Merge branch 'bulwarkmail:main' into user-api-plugin
This commit is contained in:
@@ -232,6 +232,53 @@ describe('dev-jmap mock server', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api - request limits', () => {
|
||||
it('should refuse a request with more method calls than it advertises', async () => {
|
||||
const methodCalls = Array.from({ length: 17 }, (_, i) => [
|
||||
'Email/query',
|
||||
{ accountId: 'dev-account-001', limit: 0, calculateTotal: true },
|
||||
`c${i}`,
|
||||
]);
|
||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||
body: JSON.stringify({ methodCalls }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||
const data = await res.json();
|
||||
expect(res.status).toBe(400);
|
||||
expect(data.type).toBe('urn:ietf:params:jmap:error:limit');
|
||||
expect(data.limit).toBe('maxCallsInRequest');
|
||||
});
|
||||
|
||||
it('should reject an over-sized /set with requestTooLarge', async () => {
|
||||
const destroy = Array.from({ length: 501 }, (_, i) => `email-${i}`);
|
||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||
body: JSON.stringify({ methodCalls: [['Email/set', { accountId: 'dev-account-001', destroy }, '0']] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||
const data = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(data.methodResponses[0][0]).toBe('error');
|
||||
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
|
||||
});
|
||||
|
||||
it('should reject an over-sized /get with requestTooLarge', async () => {
|
||||
const ids = Array.from({ length: 501 }, (_, i) => `email-${i}`);
|
||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||
body: JSON.stringify({ methodCalls: [['Email/get', { accountId: 'dev-account-001', ids }, '0']] }),
|
||||
});
|
||||
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||
const data = await res.json();
|
||||
expect(data.methodResponses[0][0]).toBe('error');
|
||||
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /upload', () => {
|
||||
it('should return a fake blob response', async () => {
|
||||
const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { buildForwardAsAttachmentPayload } from '@/lib/forward-as-attachment';
|
||||
import type { Email } from '@/lib/jmap/types';
|
||||
|
||||
// Pin TZ so the local-time date rendering in the filename test is deterministic,
|
||||
// restoring it after so this doesn't leak into other test files in the same worker.
|
||||
let originalTZ: string | undefined;
|
||||
beforeAll(() => {
|
||||
originalTZ = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
});
|
||||
afterAll(() => {
|
||||
// process.env coerces to strings, so `= undefined` would leave the literal
|
||||
// string "undefined" behind when TZ was originally unset - delete instead.
|
||||
if (originalTZ === undefined) delete process.env.TZ;
|
||||
else process.env.TZ = originalTZ;
|
||||
});
|
||||
|
||||
function makeEmail(overrides: Partial<Email> = {}): Email {
|
||||
return {
|
||||
id: 'e1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 12345,
|
||||
receivedAt: '2026-07-26T22:25:22Z',
|
||||
subject: 'Your waste service day is changing',
|
||||
hasAttachment: false,
|
||||
blobId: 'blob123',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildForwardAsAttachmentPayload', () => {
|
||||
it('returns null when the email has no blobId', () => {
|
||||
const email = makeEmail({ blobId: undefined });
|
||||
expect(buildForwardAsAttachmentPayload(email, 'Fwd:')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefixes the subject using the given forward prefix', () => {
|
||||
const email = makeEmail({ subject: 'Missed spam example' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.subject).toBe('Fwd: Missed spam example');
|
||||
});
|
||||
|
||||
it('builds a message/rfc822 attachment referencing the email\'s own blobId, not a new upload', () => {
|
||||
const email = makeEmail({ blobId: 'the-real-blob-id', size: 26489 });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.attachment).toEqual({
|
||||
blobId: 'the-real-blob-id',
|
||||
name: expect.stringMatching(/\.eml$/),
|
||||
type: 'message/rfc822',
|
||||
size: 26489,
|
||||
});
|
||||
});
|
||||
|
||||
it('is idempotent - repeated forwarding does not stack prefixes', () => {
|
||||
const email = makeEmail({ subject: 'Fwd: already forwarded once' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.subject).toBe('Fwd: already forwarded once');
|
||||
});
|
||||
|
||||
it('leaves the subject blank (not just the bare prefix) for a subject-less message, matching normal Forward', () => {
|
||||
const email = makeEmail({ subject: undefined });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.subject).toBe('');
|
||||
});
|
||||
|
||||
it('applies user space/case transforms but ignores a custom filename template, unlike "Export as .eml"', () => {
|
||||
const email = makeEmail({ subject: 'Missed spam example' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:', {
|
||||
template: 'custom-{subject}',
|
||||
lowercase: true,
|
||||
spaceReplacement: 'dash',
|
||||
});
|
||||
expect(payload?.attachment.name).toBe('2026-07-26-22.25.22-missed-spam-example.eml');
|
||||
});
|
||||
|
||||
it('uses a dash between date and subject by default', () => {
|
||||
const email = makeEmail({ subject: 'Missed spam example' });
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.attachment.name).toBe('2026-07-26 22.25.22-Missed spam example.eml');
|
||||
});
|
||||
|
||||
it('never includes from/to in the filename, even with the default template, to avoid leaking names to the recipient', () => {
|
||||
const email = makeEmail({
|
||||
subject: 'Missed spam example',
|
||||
from: [{ name: 'Alice Sender', email: 'alice@example.com' }],
|
||||
to: [{ name: "'Bobby'", email: 'bob@example.com' }],
|
||||
});
|
||||
const payload = buildForwardAsAttachmentPayload(email, 'Fwd:');
|
||||
expect(payload?.attachment.name).not.toContain('Alice');
|
||||
expect(payload?.attachment.name).not.toContain('Bobby');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
|
||||
function makeSession() {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
||||
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for a Stalwart mailbox: Email/query returns one page of remaining
|
||||
* ids, Email/set destroys them. `includeTotal` mirrors the server's freedom to
|
||||
* omit `total` when the query did not ask for `calculateTotal` (RFC 8620 5.5).
|
||||
*/
|
||||
function makeMailboxServer(opts: {
|
||||
count: number;
|
||||
includeTotal?: boolean;
|
||||
destroyFails?: boolean;
|
||||
}) {
|
||||
let remaining = Array.from({ length: opts.count }, (_, i) => `email-${i}`);
|
||||
const requests: number[] = [];
|
||||
|
||||
const handler = async (_url: string, init: RequestInit): Promise<Response> => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
const [, queryArgs] = body.methodCalls[0];
|
||||
const limit: number = queryArgs.limit;
|
||||
const page = remaining.slice(0, limit);
|
||||
requests.push(page.length);
|
||||
|
||||
const destroyed = opts.destroyFails ? [] : page;
|
||||
remaining = remaining.slice(destroyed.length);
|
||||
|
||||
return jsonResponse({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: page, ...(opts.includeTotal ? { total: page.length } : {}) }, '0'],
|
||||
['Email/set', { destroyed, notDestroyed: {} }, '1'],
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return { handler, requests, remainingCount: () => remaining.length };
|
||||
}
|
||||
|
||||
describe('JMAPClient.emptyMailbox', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
async function connectedClient(): Promise<JMAPClient> {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
|
||||
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
return client;
|
||||
}
|
||||
|
||||
it('destroys every email in a mailbox larger than one batch', async () => {
|
||||
const client = await connectedClient();
|
||||
const server = makeMailboxServer({ count: 1200 });
|
||||
fetchSpy.mockImplementation(server.handler as never);
|
||||
|
||||
const destroyed = await client.emptyMailbox('mailbox-1');
|
||||
|
||||
expect(destroyed).toBe(1200);
|
||||
expect(server.remainingCount()).toBe(0);
|
||||
expect(server.requests).toEqual([500, 500, 200]);
|
||||
});
|
||||
|
||||
// Regression for #711: the loop used to stop after one batch when the server
|
||||
// omitted `total`, leaving folders with thousands of emails nearly full.
|
||||
it('keeps paging when the server omits Email/query total', async () => {
|
||||
const client = await connectedClient();
|
||||
const server = makeMailboxServer({ count: 2300, includeTotal: false });
|
||||
fetchSpy.mockImplementation(server.handler as never);
|
||||
|
||||
const destroyed = await client.emptyMailbox('mailbox-1');
|
||||
|
||||
expect(destroyed).toBe(2300);
|
||||
expect(server.remainingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('issues a final confirming query when the count is an exact multiple of the batch size', async () => {
|
||||
const client = await connectedClient();
|
||||
const server = makeMailboxServer({ count: 1000 });
|
||||
fetchSpy.mockImplementation(server.handler as never);
|
||||
|
||||
const destroyed = await client.emptyMailbox('mailbox-1');
|
||||
|
||||
expect(destroyed).toBe(1000);
|
||||
expect(server.requests).toEqual([500, 500, 0]);
|
||||
});
|
||||
|
||||
it('stops instead of looping forever when the server refuses to destroy', async () => {
|
||||
const client = await connectedClient();
|
||||
const server = makeMailboxServer({ count: 1200, destroyFails: true });
|
||||
fetchSpy.mockImplementation(server.handler as never);
|
||||
|
||||
const destroyed = await client.emptyMailbox('mailbox-1');
|
||||
|
||||
expect(destroyed).toBe(0);
|
||||
expect(server.requests).toEqual([500]);
|
||||
});
|
||||
|
||||
it('returns zero without extra requests for an already empty mailbox', async () => {
|
||||
const client = await connectedClient();
|
||||
const server = makeMailboxServer({ count: 0 });
|
||||
fetchSpy.mockImplementation(server.handler as never);
|
||||
|
||||
const destroyed = await client.emptyMailbox('mailbox-1');
|
||||
|
||||
expect(destroyed).toBe(0);
|
||||
expect(server.requests).toEqual([0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { batched, itemsPerRequest } from '../jmap/request-limits';
|
||||
|
||||
// Stalwart allows 16 method calls and 500 objects per request by default. A
|
||||
// batch built from a list the user controls - tags, a multi-select, an import -
|
||||
// reaches those ceilings with ordinary use, and going over fails the *whole*
|
||||
// request: nine tags used to blank every tag badge in the sidebar.
|
||||
|
||||
function makeSession(core: Record<string, number> = {}) {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': core },
|
||||
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
/** RFC 8620 §3.6.1: an over-sized request is refused whole, before any method runs. */
|
||||
function limitErrorResponse(limit: string): Response {
|
||||
return new Response(
|
||||
JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', status: 400, limit }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
describe('batched', () => {
|
||||
it('returns one batch when everything fits', () => {
|
||||
expect(batched([1, 2, 3], 5)).toEqual([[1, 2, 3]]);
|
||||
});
|
||||
|
||||
it('splits into consecutive batches of at most `size`', () => {
|
||||
expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
||||
});
|
||||
|
||||
it('returns nothing for an empty list', () => {
|
||||
expect(batched([], 10)).toEqual([]);
|
||||
});
|
||||
|
||||
it('never produces an empty batch for a nonsensical size', () => {
|
||||
expect(batched([1, 2], 0)).toEqual([[1], [2]]);
|
||||
expect(batched([1, 2], -5)).toEqual([[1], [2]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('itemsPerRequest', () => {
|
||||
it('divides the call budget by the cost of one item', () => {
|
||||
expect(itemsPerRequest(16, 2)).toBe(8);
|
||||
expect(itemsPerRequest(16, 1)).toBe(16);
|
||||
expect(itemsPerRequest(50, 3)).toBe(16);
|
||||
});
|
||||
|
||||
it('always allows at least one item, however expensive', () => {
|
||||
expect(itemsPerRequest(1, 2)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JMAPClient request limits', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function connectedClient(core?: Record<string, number>): Promise<JMAPClient> {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core)));
|
||||
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Records the method calls of every request the client makes. */
|
||||
function recordRequests(reply: (methodCalls: Array<[string, Record<string, unknown>, string]>) => unknown) {
|
||||
const sent: Array<Array<[string, Record<string, unknown>, string]>> = [];
|
||||
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
sent.push(body.methodCalls);
|
||||
return jsonResponse(reply(body.methodCalls));
|
||||
}) as never);
|
||||
return sent;
|
||||
}
|
||||
|
||||
describe('getTagCounts', () => {
|
||||
// Two Email/query calls per tag: nine tags is 18 calls against a ceiling of 16.
|
||||
const tags = Array.from({ length: 9 }, (_, i) => `tag-${i}`);
|
||||
|
||||
it('splits the tags so no request exceeds maxCallsInRequest', async () => {
|
||||
const client = await connectedClient({ maxCallsInRequest: 16 });
|
||||
const sent = recordRequests((methodCalls) => ({
|
||||
methodResponses: methodCalls.map(([, , callId], i) => [
|
||||
'Email/query',
|
||||
{ total: i + 1 },
|
||||
callId,
|
||||
]),
|
||||
}));
|
||||
|
||||
const counts = await client.getTagCounts(tags);
|
||||
|
||||
expect(sent.map(calls => calls.length)).toEqual([16, 2]);
|
||||
expect(Object.keys(counts)).toEqual(tags);
|
||||
expect(counts['tag-8']).toEqual({ total: 1, unread: 2 });
|
||||
});
|
||||
|
||||
it('keeps the tags of the batches that did succeed when one is refused', async () => {
|
||||
const client = await connectedClient({ maxCallsInRequest: 16 });
|
||||
let call = 0;
|
||||
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(init.body as string);
|
||||
if (call++ === 0) return limitErrorResponse('maxCallsInRequest');
|
||||
return jsonResponse({
|
||||
methodResponses: body.methodCalls.map(([, , callId]: [string, unknown, string]) => [
|
||||
'Email/query', { total: 7 }, callId,
|
||||
]),
|
||||
});
|
||||
}) as never);
|
||||
|
||||
const counts = await client.getTagCounts(tags);
|
||||
|
||||
expect(Object.keys(counts)).toEqual(['tag-8']);
|
||||
expect(counts['tag-8']).toEqual({ total: 7, unread: 7 });
|
||||
});
|
||||
|
||||
it('honours a lower ceiling advertised by the server', async () => {
|
||||
const client = await connectedClient({ maxCallsInRequest: 4 });
|
||||
const sent = recordRequests((methodCalls) => ({
|
||||
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 0 }, callId]),
|
||||
}));
|
||||
|
||||
await client.getTagCounts(tags);
|
||||
|
||||
expect(sent.map(calls => calls.length)).toEqual([4, 4, 4, 4, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategoryUnreadCounts', () => {
|
||||
it('splits the tabs across requests and keeps every tab id', async () => {
|
||||
const client = await connectedClient({ maxCallsInRequest: 16 });
|
||||
const tabs = Array.from({ length: 20 }, (_, i) => ({ id: `tab-${i}`, filter: null }));
|
||||
const sent = recordRequests((methodCalls) => ({
|
||||
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 3 }, callId]),
|
||||
}));
|
||||
|
||||
const counts = await client.getCategoryUnreadCounts('inbox', tabs);
|
||||
|
||||
expect(sent.map(calls => calls.length)).toEqual([16, 4]);
|
||||
expect(Object.keys(counts)).toHaveLength(20);
|
||||
expect(counts['tab-19']).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email/set batches', () => {
|
||||
const ids = Array.from({ length: 1200 }, (_, i) => `email-${i}`);
|
||||
|
||||
it('splits batchDeleteEmails at maxObjectsInSet', async () => {
|
||||
const client = await connectedClient({ maxObjectsInSet: 500 });
|
||||
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { destroyed: [] }, '0']] }));
|
||||
|
||||
await client.batchDeleteEmails(ids);
|
||||
|
||||
expect(sent.map(calls => (calls[0][1].destroy as string[]).length)).toEqual([500, 500, 200]);
|
||||
});
|
||||
|
||||
it('splits batchMarkAsRead at maxObjectsInSet', async () => {
|
||||
const client = await connectedClient({ maxObjectsInSet: 500 });
|
||||
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] }));
|
||||
|
||||
await client.batchMarkAsRead(ids, true);
|
||||
|
||||
const updated = sent.flatMap(calls => Object.keys(calls[0][1].update as object));
|
||||
expect(sent).toHaveLength(3);
|
||||
expect(updated).toEqual(ids);
|
||||
});
|
||||
|
||||
it('splits batchMoveEmails at a ceiling the server lowered', async () => {
|
||||
const client = await connectedClient({ maxObjectsInSet: 100 });
|
||||
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] }));
|
||||
|
||||
await client.batchMoveEmails(ids, 'mailbox-2');
|
||||
|
||||
expect(sent).toHaveLength(12);
|
||||
expect(Object.keys(sent[0][0][1].update as object)).toHaveLength(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email/get batches', () => {
|
||||
it('splits getSomeEmails at maxObjectsInGet and returns every message', async () => {
|
||||
const client = await connectedClient({ maxObjectsInGet: 500 });
|
||||
const sent = recordRequests((methodCalls) => ({
|
||||
methodResponses: [[
|
||||
'Email/get',
|
||||
{
|
||||
list: (methodCalls[0][1].ids as string[]).map(id => ({
|
||||
id,
|
||||
receivedAt: '2026-03-14T10:00:00Z',
|
||||
})),
|
||||
},
|
||||
'0',
|
||||
]],
|
||||
}));
|
||||
|
||||
const emails = await client.getSomeEmails(Array.from({ length: 1100 }, (_, i) => `email-${i}`));
|
||||
|
||||
expect(sent.map(calls => (calls[0][1].ids as string[]).length)).toEqual([500, 500, 100]);
|
||||
expect(emails).toHaveLength(1100);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the documented defaults when the session advertises no limits', async () => {
|
||||
const client = await connectedClient();
|
||||
|
||||
expect(client.getMaxObjectsInGet()).toBe(500);
|
||||
expect(client.getMaxObjectsInSet()).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatKeyword, formatKeywordLabels, keywordRenderings } from "@/lib/keyword-format";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("formatKeyword with nesting on", () => {
|
||||
it("joins the display name of every level", () => {
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, true)).toBe("Work/Clients/Acme");
|
||||
});
|
||||
|
||||
it("returns the plain display name for a tag with one level", () => {
|
||||
expect(formatKeyword("work", KEYWORDS, true)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the raw level for one this client does not know", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, true)).toBe("Work/archive/2026");
|
||||
expect(formatKeyword("unknown", [], true)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatKeyword with nesting off", () => {
|
||||
it("names a tag by its own label, leaving a slash in the id uninterpreted", () => {
|
||||
// The setting says a slash means nothing, so an id that happens to contain
|
||||
// one - from before it was turned off, or from another client - is a single
|
||||
// opaque token rather than a hierarchy.
|
||||
expect(formatKeyword("work/clients/acme", KEYWORDS, false)).toBe("Acme");
|
||||
expect(formatKeyword("work", KEYWORDS, false)).toBe("Work");
|
||||
});
|
||||
|
||||
it("falls back to the whole id when the tag has no definition", () => {
|
||||
expect(formatKeyword("work/archive/2026", KEYWORDS, false)).toBe("work/archive/2026");
|
||||
});
|
||||
|
||||
it("offers no shortening, leaving the markup to clip", () => {
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme", KEYWORDS, false)))
|
||||
.toEqual(["Acme"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordRenderings", () => {
|
||||
it("shortens by one intermediate level at a time, outermost first", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "EU", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/EU/Sales",
|
||||
"Work/../Acme/EU/Sales",
|
||||
"Work/.../EU/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("collapses to a single ... as soon as the run covers more than one level", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme", "Sales"])).toEqual([
|
||||
"Work/Clients/Acme/Sales",
|
||||
"Work/../Acme/Sales",
|
||||
"Work/.../Sales",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses .. for a lone intermediate level, never ...", () => {
|
||||
expect(keywordRenderings(["Work", "Clients", "Acme"])).toEqual([
|
||||
"Work/Clients/Acme",
|
||||
"Work/../Acme",
|
||||
]);
|
||||
});
|
||||
|
||||
it("has nothing to shorten without an intermediate level", () => {
|
||||
expect(keywordRenderings(["Work", "Acme"])).toEqual(["Work/Acme"]);
|
||||
expect(keywordRenderings(["Work"])).toEqual(["Work"]);
|
||||
});
|
||||
|
||||
it("drops a rendering that would not come out shorter", () => {
|
||||
// "../" costs as much as the level it replaces, so shortening buys nothing.
|
||||
expect(keywordRenderings(["a", "it", "b"])).toEqual(["a/it/b"]);
|
||||
expect(keywordRenderings(["a", "x", "b"])).toEqual(["a/x/b"]);
|
||||
});
|
||||
});
|
||||
|
||||
// How the components use the two together: resolve a tag to its display names,
|
||||
// then hand the ladder to `useShortenedText` to pick a rung.
|
||||
describe("keywordRenderings over formatKeywordLabels", () => {
|
||||
it("shortens a display name by the same ladder as an id", () => {
|
||||
const deep: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/clients/acme/eu", "Europe"),
|
||||
];
|
||||
|
||||
expect(keywordRenderings(formatKeywordLabels("work/clients/acme/eu", deep, true))).toEqual([
|
||||
"Work/Clients/Acme/Europe",
|
||||
"Work/../Acme/Europe",
|
||||
"Work/.../Europe",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a slash inside one display name as part of that name, not a level", () => {
|
||||
const slashed: KeywordDefinition[] = [kw("work", "Work"), kw("work/acme-r-d", "Acme/R&D")];
|
||||
|
||||
// Two levels, so there is no intermediate level to shorten.
|
||||
expect(keywordRenderings(formatKeywordLabels("work/acme-r-d", slashed, true))).toEqual([
|
||||
"Work/Acme/R&D",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
MAX_KEYWORD_ID_LENGTH,
|
||||
buildKeywordTree,
|
||||
composeKeywordId,
|
||||
countKeywordNodes,
|
||||
filterKeywordTree,
|
||||
getParentKeywordId,
|
||||
hasChildKeywords,
|
||||
isKeywordDescendant,
|
||||
keywordLevels,
|
||||
normalizeKeywordLevel,
|
||||
} from "@/lib/keyword-nesting";
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
|
||||
const kw = (id: string, label: string): KeywordDefinition => ({ id, label, color: "blue" });
|
||||
|
||||
// Work
|
||||
// Clients
|
||||
// Acme
|
||||
// Personal
|
||||
const KEYWORDS: KeywordDefinition[] = [
|
||||
kw("work", "Work"),
|
||||
kw("work/clients", "Clients"),
|
||||
kw("work/clients/acme", "Acme"),
|
||||
kw("work/personal", "Personal"),
|
||||
];
|
||||
|
||||
describe("normalizeKeywordLevel", () => {
|
||||
it("lowercases and folds unsupported characters into single dashes", () => {
|
||||
expect(normalizeKeywordLevel("My Custom Tag!")).toBe("my-custom-tag");
|
||||
expect(normalizeKeywordLevel(" Spaced Out ")).toBe("spaced-out");
|
||||
expect(normalizeKeywordLevel("--Trimmed--")).toBe("trimmed");
|
||||
});
|
||||
|
||||
it("treats a slash as part of the name, not as a level", () => {
|
||||
expect(normalizeKeywordLevel("Acme/R&D")).toBe("acme-r-d");
|
||||
});
|
||||
|
||||
it("returns an empty string when nothing usable is left", () => {
|
||||
expect(normalizeKeywordLevel(" ")).toBe("");
|
||||
expect(normalizeKeywordLevel("!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("composeKeywordId", () => {
|
||||
it("returns a bare slug at the top level", () => {
|
||||
expect(composeKeywordId(null, "Work")).toBe("work");
|
||||
expect(composeKeywordId("", "Work")).toBe("work");
|
||||
});
|
||||
|
||||
it("appends the slug below the parent", () => {
|
||||
expect(composeKeywordId("work/clients", "Acme")).toBe("work/clients/acme");
|
||||
});
|
||||
|
||||
it("never produces a trailing separator for an unusable name", () => {
|
||||
expect(composeKeywordId("work", "!!!")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("keywordLevels", () => {
|
||||
it("splits an id into its levels", () => {
|
||||
expect(keywordLevels("work/clients/acme")).toEqual(["work", "clients", "acme"]);
|
||||
expect(keywordLevels("work")).toEqual(["work"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getParentKeywordId", () => {
|
||||
it("drops the last level", () => {
|
||||
expect(getParentKeywordId("work/clients/acme")).toBe("work/clients");
|
||||
});
|
||||
|
||||
it("returns null for a top-level tag", () => {
|
||||
expect(getParentKeywordId("work")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isKeywordDescendant", () => {
|
||||
it("matches anything below the ancestor", () => {
|
||||
expect(isKeywordDescendant("work/clients/acme", "work")).toBe(true);
|
||||
expect(isKeywordDescendant("work/clients", "work")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the ancestor itself or a shared name prefix", () => {
|
||||
expect(isKeywordDescendant("work", "work")).toBe(false);
|
||||
expect(isKeywordDescendant("workshop/tools", "work")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasChildKeywords", () => {
|
||||
it("reports whether any defined tag sits below the given one", () => {
|
||||
expect(hasChildKeywords("work", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients", KEYWORDS)).toBe(true);
|
||||
expect(hasChildKeywords("work/clients/acme", KEYWORDS)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_KEYWORD_ID_LENGTH", () => {
|
||||
it("leaves room for the `$label:` prefix within the 255-character keyword limit", () => {
|
||||
expect(MAX_KEYWORD_ID_LENGTH).toBe(248);
|
||||
expect("$label:".length + MAX_KEYWORD_ID_LENGTH).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildKeywordTree", () => {
|
||||
it("nests each tag under its parent and records the depth", () => {
|
||||
const [work] = buildKeywordTree(KEYWORDS);
|
||||
|
||||
expect(work.id).toBe("work");
|
||||
expect(work.depth).toBe(0);
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients", "work/personal"]);
|
||||
|
||||
const clients = work.children[0];
|
||||
expect(clients.depth).toBe(1);
|
||||
expect(clients.children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||
expect(clients.children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps the manual order within a level", () => {
|
||||
const reordered = [KEYWORDS[0], KEYWORDS[3], KEYWORDS[1], KEYWORDS[2]];
|
||||
const [work] = buildKeywordTree(reordered);
|
||||
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/personal", "work/clients"]);
|
||||
});
|
||||
|
||||
it("keeps a tag whose parent is not defined at the root", () => {
|
||||
const orphan = buildKeywordTree([kw("work/clients/acme", "Acme")]);
|
||||
|
||||
expect(orphan).toHaveLength(1);
|
||||
expect(orphan[0].id).toBe("work/clients/acme");
|
||||
expect(orphan[0].depth).toBe(0);
|
||||
});
|
||||
|
||||
it("returns every tag as a root when no id describes a hierarchy", () => {
|
||||
const flat = buildKeywordTree([kw("red", "Red"), kw("blue", "Blue")]);
|
||||
|
||||
expect(flat.map((n) => n.id)).toEqual(["red", "blue"]);
|
||||
expect(flat.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterKeywordTree", () => {
|
||||
const tree = buildKeywordTree(KEYWORDS);
|
||||
|
||||
it("drops the nodes the predicate rejects", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id !== "work/personal");
|
||||
const [work] = kept;
|
||||
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||
});
|
||||
|
||||
it("keeps a rejected node when a descendant survives, so nothing is stranded", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||
const [work] = kept;
|
||||
|
||||
expect(work.id).toBe("work");
|
||||
expect(work.children.map((c) => c.id)).toEqual(["work/clients"]);
|
||||
expect(work.children[0].children.map((c) => c.id)).toEqual(["work/clients/acme"]);
|
||||
});
|
||||
|
||||
it("keeps the depth of a surviving node so its indentation does not shift", () => {
|
||||
const kept = filterKeywordTree(tree, (node) => node.id === "work/clients/acme");
|
||||
|
||||
expect(kept[0].children[0].children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("returns nothing when the predicate rejects everything", () => {
|
||||
expect(filterKeywordTree(tree, () => false)).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves the original tree untouched", () => {
|
||||
filterKeywordTree(tree, (node) => node.id === "work");
|
||||
|
||||
expect(countKeywordNodes(tree)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countKeywordNodes", () => {
|
||||
it("counts every level, not just the roots", () => {
|
||||
expect(countKeywordNodes(buildKeywordTree(KEYWORDS))).toBe(4);
|
||||
expect(countKeywordNodes([])).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients';
|
||||
|
||||
const OWN = ['me@example.com', 'info@example.com'];
|
||||
|
||||
const emails = (list: { email?: string }[]) => list.map((r) => r.email);
|
||||
|
||||
describe('buildReplyRecipients', () => {
|
||||
describe('received message', () => {
|
||||
const received = {
|
||||
from: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
to: [{ email: 'me@example.com' }, { email: 'carol@other.com' }],
|
||||
cc: [{ email: 'dave@other.com' }],
|
||||
};
|
||||
|
||||
it('replies to the sender', () => {
|
||||
const { to, cc } = buildReplyRecipients(received, 'reply', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
expect(cc).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers the Reply-To header over From', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...received, replyToAddresses: [{ email: 'list@other.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['list@other.com']);
|
||||
});
|
||||
|
||||
it('reply-all keeps the other recipients and drops our own address', () => {
|
||||
const { to, cc } = buildReplyRecipients(received, 'replyAll', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']);
|
||||
expect(emails(cc)).toEqual(['dave@other.com']);
|
||||
});
|
||||
|
||||
it('reply-all drops our own address even with +tag sub-addressing', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...received, to: [{ email: 'me+newsletter@example.com' }, { email: 'carol@other.com' }] },
|
||||
'replyAll',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['bob@other.com', 'carol@other.com']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('self-sent message (#703)', () => {
|
||||
const sent = {
|
||||
from: [{ email: 'me@example.com', name: 'Me' }],
|
||||
to: [{ email: 'bob@other.com', name: 'Bob' }],
|
||||
cc: [{ email: 'carol@other.com' }],
|
||||
};
|
||||
|
||||
it('replies to the original recipient, not to ourselves', () => {
|
||||
const { to, cc } = buildReplyRecipients(sent, 'reply', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
expect(cc).toEqual([]);
|
||||
});
|
||||
|
||||
it('reply-all restores the original To and Cc', () => {
|
||||
const { to, cc } = buildReplyRecipients(sent, 'replyAll', OWN);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
expect(emails(cc)).toEqual(['carol@other.com']);
|
||||
});
|
||||
|
||||
it('recognises the sending identity through +tag sub-addressing', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, from: [{ email: 'me+project@example.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
});
|
||||
|
||||
it('ignores our own Reply-To header so the reply leaves our mailbox', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, replyToAddresses: [{ email: 'info@example.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['bob@other.com']);
|
||||
});
|
||||
|
||||
it('keeps a self-addressed recipient we chose ourselves', () => {
|
||||
const { to } = buildReplyRecipients(
|
||||
{ ...sent, to: [{ email: 'info@example.com' }] },
|
||||
'reply',
|
||||
OWN,
|
||||
);
|
||||
expect(emails(to)).toEqual(['info@example.com']);
|
||||
});
|
||||
|
||||
it('falls back to the sender when there is no visible recipient (Bcc-only)', () => {
|
||||
const { to } = buildReplyRecipients({ ...sent, to: [], cc: [] }, 'reply', OWN);
|
||||
expect(emails(to)).toEqual(['me@example.com']);
|
||||
});
|
||||
|
||||
it('keeps the display names of the original recipients', () => {
|
||||
const { to } = buildReplyRecipients(sent, 'reply', OWN);
|
||||
expect(to[0]).toEqual({ email: 'bob@other.com', name: 'Bob' });
|
||||
});
|
||||
});
|
||||
|
||||
it('returns nothing without a source message', () => {
|
||||
expect(buildReplyRecipients(undefined, 'replyAll', OWN)).toEqual({ to: [], cc: [] });
|
||||
});
|
||||
|
||||
it('treats a message as foreign when no identity matches', () => {
|
||||
expect(isSelfSent({ from: [{ email: 'bob@other.com' }] }, OWN)).toBe(false);
|
||||
expect(isSelfSent({ from: [{ email: 'ME@Example.com ' }] }, OWN)).toBe(true);
|
||||
expect(isSelfSent({ from: [] }, OWN)).toBe(false);
|
||||
expect(isSelfSent(undefined, OWN)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
sortThreadGroups,
|
||||
getThreadParticipants,
|
||||
mergeThreadEmails,
|
||||
getEmailColorTag,
|
||||
getThreadColorTag,
|
||||
getEmailTagId,
|
||||
getEmailTagIds,
|
||||
getThreadTagId,
|
||||
getThreadTagIds,
|
||||
} from '../thread-utils';
|
||||
import type { Email, ThreadGroup } from '../jmap/types';
|
||||
|
||||
@@ -245,47 +247,71 @@ describe('mergeThreadEmails', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailColorTag', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailColorTag({ '$label:red': true, $seen: true })).toBe('red');
|
||||
describe('getEmailTagIds', () => {
|
||||
it('gathers every tag set on the message', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
|
||||
.toEqual(['red', 'work']);
|
||||
});
|
||||
|
||||
it('returns label from legacy $color: keyword', () => {
|
||||
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
|
||||
it('reads the legacy prefix alongside the current one', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailColorTag(undefined)).toBeNull();
|
||||
it('reports a tag written under both prefixes once', () => {
|
||||
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailColorTag({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailColorTag({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailColorTag({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailColorTag({})).toBeNull();
|
||||
it('is empty for an untagged message or none at all', () => {
|
||||
expect(getEmailTagIds({ $seen: true })).toEqual([]);
|
||||
expect(getEmailTagIds(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadColorTag', () => {
|
||||
describe('getEmailTagId', () => {
|
||||
it('returns label from $label: keyword', () => {
|
||||
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns label from legacy $color: keyword', () => {
|
||||
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
|
||||
});
|
||||
|
||||
it('returns null when no color keyword', () => {
|
||||
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined keywords', () => {
|
||||
expect(getEmailTagId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores keywords set to false', () => {
|
||||
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers $label: over $color: when both exist', () => {
|
||||
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
||||
});
|
||||
|
||||
it('handles custom keyword ids', () => {
|
||||
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
||||
});
|
||||
|
||||
it('returns null for empty keywords object', () => {
|
||||
expect(getEmailTagId({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadTagId', () => {
|
||||
it('returns first color found across thread emails', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('blue');
|
||||
expect(getThreadTagId(emails)).toBe('blue');
|
||||
});
|
||||
|
||||
it('returns null when no emails have color tags', () => {
|
||||
@@ -293,7 +319,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBeNull();
|
||||
expect(getThreadTagId(emails)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns first tag from earliest tagged email', () => {
|
||||
@@ -301,7 +327,7 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('red');
|
||||
expect(getThreadTagId(emails)).toBe('red');
|
||||
});
|
||||
|
||||
it('returns legacy tag from thread emails', () => {
|
||||
@@ -309,10 +335,41 @@ describe('getThreadColorTag', () => {
|
||||
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
|
||||
];
|
||||
expect(getThreadColorTag(emails)).toBe('green');
|
||||
expect(getThreadTagId(emails)).toBe('green');
|
||||
});
|
||||
|
||||
it('returns null for empty email array', () => {
|
||||
expect(getThreadColorTag([])).toBeNull();
|
||||
expect(getThreadTagId([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThreadTagIds', () => {
|
||||
it('gathers the tags of every message in the thread', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
|
||||
});
|
||||
|
||||
it('reports a tag shared by several messages once', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails)).toEqual(['red']);
|
||||
});
|
||||
|
||||
it('reads the legacy prefix alongside the current one', () => {
|
||||
const emails = [
|
||||
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
|
||||
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
||||
];
|
||||
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
|
||||
});
|
||||
|
||||
it('is empty for an untagged or empty thread', () => {
|
||||
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
|
||||
expect(getThreadTagIds([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -445,6 +445,48 @@ describe("generateVCard", () => {
|
||||
const vcf = generateVCard([contact]);
|
||||
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
|
||||
});
|
||||
|
||||
it("uses the organization name as FN for organization cards (issue #701)", () => {
|
||||
const contact: ContactCard = {
|
||||
id: "c4",
|
||||
addressBookIds: {},
|
||||
kind: "org",
|
||||
name: { full: "Acme Corp" },
|
||||
organizations: { o0: { name: "Acme Corp" } },
|
||||
};
|
||||
|
||||
const vcf = generateVCard([contact]);
|
||||
expect(vcf).toContain("KIND:org");
|
||||
expect(vcf).toContain("FN:Acme Corp");
|
||||
expect(vcf).toContain("ORG:Acme Corp");
|
||||
});
|
||||
|
||||
it("falls back to ORG for FN when the card has no name at all", () => {
|
||||
const contact: ContactCard = {
|
||||
id: "c5",
|
||||
addressBookIds: {},
|
||||
kind: "org",
|
||||
organizations: { o0: { name: "Acme Corp" } },
|
||||
};
|
||||
|
||||
expect(generateVCard([contact])).toContain("FN:Acme Corp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization-only cards (issue #701)", () => {
|
||||
it("keeps a vCard that has only an organization name", () => {
|
||||
const parsed = parseVCard([
|
||||
"BEGIN:VCARD",
|
||||
"VERSION:4.0",
|
||||
"KIND:org",
|
||||
"ORG:Acme Corp",
|
||||
"END:VCARD",
|
||||
].join("\r\n"));
|
||||
|
||||
expect(parsed).toHaveLength(1);
|
||||
expect(parsed[0].kind).toBe("org");
|
||||
expect(parsed[0].organizations?.o0.name).toBe("Acme Corp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip: parse → generate → parse", () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
getCapabilities(): Record<string, unknown> {
|
||||
return {
|
||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
|
||||
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
|
||||
'urn:ietf:params:jmap:mail': {},
|
||||
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
|
||||
'urn:ietf:params:jmap:vacationresponse': {},
|
||||
@@ -71,6 +71,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
getMaxSizeUpload(): number { return 50_000_000; }
|
||||
getMaxCallsInRequest(): number { return 16; }
|
||||
getMaxObjectsInGet(): number { return 500; }
|
||||
getMaxObjectsInSet(): number { return 500; }
|
||||
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
||||
hasDelayedSend(): boolean { return true; }
|
||||
getEventSourceUrl(): string | null { return null; }
|
||||
@@ -1046,7 +1047,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
const node: FileNode = {
|
||||
id: generateDemoId('file'),
|
||||
parentId, name, type: 'd', blobId: null, size: 0,
|
||||
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||
created: new Date().toISOString(), modified: new Date().toISOString(),
|
||||
};
|
||||
this.data.fileNodes.push(node);
|
||||
return node;
|
||||
@@ -1056,7 +1057,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
const node: FileNode = {
|
||||
id: generateDemoId('file'),
|
||||
parentId, name, type, blobId, size,
|
||||
created: new Date().toISOString(), updated: new Date().toISOString(),
|
||||
created: new Date().toISOString(), modified: new Date().toISOString(),
|
||||
};
|
||||
this.data.fileNodes.push(node);
|
||||
return node;
|
||||
@@ -1064,7 +1065,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||||
const node = this.data.fileNodes.find(n => n.id === id);
|
||||
if (node) Object.assign(node, updates, { updated: new Date().toISOString() });
|
||||
if (node) Object.assign(node, updates, { modified: new Date().toISOString() });
|
||||
}
|
||||
|
||||
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||
@@ -1072,7 +1073,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
for (const [id, patch] of Object.entries(updates)) {
|
||||
const node = this.data.fileNodes.find(n => n.id === id);
|
||||
if (node) {
|
||||
Object.assign(node, patch, { updated: new Date().toISOString() });
|
||||
Object.assign(node, patch, { modified: new Date().toISOString() });
|
||||
updated.push(id);
|
||||
}
|
||||
}
|
||||
@@ -1094,6 +1095,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
|
||||
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
|
||||
async copyEmailAcrossAccounts(): Promise<string> { return generateDemoId('email'); }
|
||||
async submitEmail(): Promise<void> { /* no-op */ }
|
||||
async submitRawEmail(blob: Blob,
|
||||
identityId: string,
|
||||
|
||||
@@ -12,7 +12,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: null,
|
||||
size: 0,
|
||||
created: demoDate(-30),
|
||||
updated: demoDate(-2),
|
||||
modified: demoDate(-2),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-photos',
|
||||
@@ -22,7 +22,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: null,
|
||||
size: 0,
|
||||
created: demoDate(-30),
|
||||
updated: demoDate(-5),
|
||||
modified: demoDate(-5),
|
||||
},
|
||||
|
||||
// Documents contents
|
||||
@@ -34,7 +34,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: 'demo-blob-file-1',
|
||||
size: 2150,
|
||||
created: demoDate(-7),
|
||||
updated: demoDate(-2),
|
||||
modified: demoDate(-2),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-quarterly-report',
|
||||
@@ -44,7 +44,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: 'demo-blob-file-2',
|
||||
size: 148480,
|
||||
created: demoDate(-14),
|
||||
updated: demoDate(-14),
|
||||
modified: demoDate(-14),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-todo',
|
||||
@@ -54,7 +54,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: 'demo-blob-file-3',
|
||||
size: 410,
|
||||
created: demoDate(-3),
|
||||
updated: demoDate(-1),
|
||||
modified: demoDate(-1),
|
||||
},
|
||||
|
||||
// Photos contents
|
||||
@@ -66,7 +66,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: 'demo-blob-file-4',
|
||||
size: 1258291,
|
||||
created: demoDate(-10),
|
||||
updated: demoDate(-10),
|
||||
modified: demoDate(-10),
|
||||
},
|
||||
{
|
||||
id: 'demo-file-team-photo',
|
||||
@@ -76,7 +76,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: 'demo-blob-file-5',
|
||||
size: 911360,
|
||||
created: demoDate(-21),
|
||||
updated: demoDate(-21),
|
||||
modified: demoDate(-21),
|
||||
},
|
||||
|
||||
// Root-level file
|
||||
@@ -88,7 +88,7 @@ export function createDemoFileNodes(): FileNode[] {
|
||||
blobId: 'demo-blob-file-6',
|
||||
size: 68608,
|
||||
created: demoDate(-5),
|
||||
updated: demoDate(-1),
|
||||
modified: demoDate(-1),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Email } from "@/lib/jmap/types";
|
||||
import { buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { emailExportFilename, type EmailFilenameOptions } from "@/lib/download-filename";
|
||||
|
||||
export interface ForwardAsAttachmentEntry {
|
||||
blobId: string;
|
||||
name: string;
|
||||
type: "message/rfc822";
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ForwardAsAttachmentPayload {
|
||||
subject: string;
|
||||
attachment: ForwardAsAttachmentEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the subject and synthetic attachment entry for forwarding a
|
||||
* message as a message/rfc822 attachment instead of inline-quoted text
|
||||
* (e.g. reporting spam to an upstream gateway that expects the raw
|
||||
* original as an attachment, or preserving exact formatting/headers).
|
||||
*
|
||||
* Referenced by blobId, not re-uploaded - JMAP blobs are account-scoped,
|
||||
* not per-email, so the same blobId a message already has can be attached
|
||||
* to a brand new outgoing email directly.
|
||||
*
|
||||
* `filenameOptions`, when passed, carries the user's configured space/case/
|
||||
* diacritics transforms (see useSettingsStore's filenameSpaceReplacement
|
||||
* and friends) for consistency with "Export as .eml" / drag-out. Its
|
||||
* `template`, if any, is ignored: this attachment goes out to a possibly
|
||||
* external recipient (spam gateway, another person), so the filename is
|
||||
* always just "{date}-{subject}.eml" - never the user's own from/to naming
|
||||
* template, which could otherwise leak sender/recipient names into an
|
||||
* attachment filename visible to that recipient.
|
||||
*
|
||||
* Returns null when the email has no blobId (nothing to reference).
|
||||
*/
|
||||
export function buildForwardAsAttachmentPayload(
|
||||
email: Email,
|
||||
forwardPrefix: string,
|
||||
filenameOptions?: EmailFilenameOptions,
|
||||
): ForwardAsAttachmentPayload | null {
|
||||
if (!email.blobId) return null;
|
||||
|
||||
return {
|
||||
// Match the normal Forward flow's getInitialSubject(), which leaves the
|
||||
// subject blank rather than prefix-only when the original has none -
|
||||
// buildForwardSubject("", prefix) would otherwise return just the bare
|
||||
// prefix (e.g. "Fwd:") for a subject-less message.
|
||||
subject: email.subject ? buildForwardSubject(email.subject, forwardPrefix) : "",
|
||||
attachment: {
|
||||
blobId: email.blobId,
|
||||
name: emailExportFilename(email, { ...filenameOptions, template: "{date}-{subject}" }),
|
||||
type: "message/rfc822",
|
||||
size: email.size,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export interface IJMAPClient {
|
||||
getMaxSizeUpload(): number;
|
||||
getMaxCallsInRequest(): number;
|
||||
getMaxObjectsInGet(): number;
|
||||
getMaxObjectsInSet(): number;
|
||||
getMaxDelayedSend(accountId?: string): number;
|
||||
hasDelayedSend(accountId?: string): boolean;
|
||||
getEventSourceUrl(): string | null;
|
||||
@@ -345,4 +346,12 @@ export interface IJMAPClient {
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||
/**
|
||||
* Server-side move of one email across accounts reachable through THIS client
|
||||
* (JMAP `Email/copy` + destroy-original). Used for delegated/shared folders,
|
||||
* where the two accounts share a client but a client can't stage a blob in a
|
||||
* delegated account (so the blob copy+import path doesn't work). Returns the
|
||||
* new email id in the destination account.
|
||||
*/
|
||||
copyEmailAcrossAccounts(emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string): Promise<string>;
|
||||
}
|
||||
|
||||
+376
-255
@@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
import { batched, itemsPerRequest } from "./request-limits";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||||
|
||||
@@ -606,31 +607,32 @@ export class JMAPClient implements IJMAPClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["Email/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: emailsId,
|
||||
properties: [...EMAIL_LIST_PROPERTIES],
|
||||
}, "0"],
|
||||
]);
|
||||
const emails: Email[] = [];
|
||||
|
||||
const getResponse = response.methodResponses?.[0]?.[1];
|
||||
for (const batchIds of batched(emailsId, this.getMaxObjectsInGet())) {
|
||||
const response = await this.request([
|
||||
["Email/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: batchIds,
|
||||
properties: [...EMAIL_LIST_PROPERTIES],
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
|
||||
const emails = (getResponse.list || []) as Email[];
|
||||
|
||||
emails.sort((a: Email, b: Email) =>
|
||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
|
||||
if (accountId && accountId !== this.accountId) {
|
||||
namespaceMailboxIds(emails, accountId);
|
||||
const getResponse = response.methodResponses?.[0]?.[1];
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
|
||||
emails.push(...((getResponse.list || []) as Email[]));
|
||||
}
|
||||
|
||||
return emails;
|
||||
}
|
||||
|
||||
return [];
|
||||
emails.sort((a: Email, b: Email) =>
|
||||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||||
);
|
||||
|
||||
if (accountId && accountId !== this.accountId) {
|
||||
namespaceMailboxIds(emails, accountId);
|
||||
}
|
||||
|
||||
return emails;
|
||||
} catch (error) {
|
||||
console.error('Failed to get specific emails:', error);
|
||||
return [];
|
||||
@@ -1266,56 +1268,62 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||||
if (tagIds.length === 0) return {};
|
||||
try {
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
for (let i = 0; i < tagIds.length; i++) {
|
||||
const keyword = `$label:${tagIds[i]}`;
|
||||
// Total count for this tag
|
||||
methodCalls.push(["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: { hasKeyword: keyword },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `total_${i}`]);
|
||||
// Unread count for this tag
|
||||
methodCalls.push(["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: {
|
||||
operator: "AND",
|
||||
conditions: [
|
||||
{ hasKeyword: keyword },
|
||||
{ notKeyword: "$seen" },
|
||||
],
|
||||
},
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `unread_${i}`]);
|
||||
const result: Record<string, { total: number; unread: number }> = {};
|
||||
|
||||
const CALLS_PER_TAG = 2;
|
||||
const perRequest = itemsPerRequest(this.getMaxCallsInRequest(), CALLS_PER_TAG);
|
||||
|
||||
for (const batch of batched(tagIds, perRequest)) {
|
||||
try {
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
const keyword = `$label:${batch[i]}`;
|
||||
// Total count for this tag
|
||||
methodCalls.push(["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: { hasKeyword: keyword },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `total_${i}`]);
|
||||
// Unread count for this tag
|
||||
methodCalls.push(["Email/query", {
|
||||
accountId: this.accountId,
|
||||
filter: {
|
||||
operator: "AND",
|
||||
conditions: [
|
||||
{ hasKeyword: keyword },
|
||||
{ notKeyword: "$seen" },
|
||||
],
|
||||
},
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `unread_${i}`]);
|
||||
}
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
const totalResp = response.methodResponses?.[i * 2]?.[1];
|
||||
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
|
||||
result[batch[i]] = {
|
||||
total: totalResp?.total ?? 0,
|
||||
unread: unreadResp?.total ?? 0,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get tag counts:', error);
|
||||
}
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
const result: Record<string, { total: number; unread: number }> = {};
|
||||
|
||||
for (let i = 0; i < tagIds.length; i++) {
|
||||
const totalResp = response.methodResponses?.[i * 2]?.[1];
|
||||
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
|
||||
result[tagIds[i]] = {
|
||||
total: totalResp?.total ?? 0,
|
||||
unread: unreadResp?.total ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get tag counts:', error);
|
||||
return {};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab unread counts for message-list category tabs. One Email/query
|
||||
* (limit 0, calculateTotal) per tab, batched in a single request. Each
|
||||
* entry's `filter` is the tab's resolved FilterCondition/FilterOperator
|
||||
* (null = no extra condition, i.e. all unread in the mailbox).
|
||||
* (limit 0, calculateTotal) per tab, batched into as few requests as the
|
||||
* server's method-call ceiling allows. Each entry's `filter` is the tab's
|
||||
* resolved FilterCondition/FilterOperator (null = no extra condition, i.e.
|
||||
* all unread in the mailbox).
|
||||
*/
|
||||
async getCategoryUnreadCounts(
|
||||
mailboxId: string,
|
||||
@@ -1324,31 +1332,34 @@ export class JMAPClient implements IJMAPClient {
|
||||
): Promise<Record<string, number>> {
|
||||
if (tabs.length === 0) return {};
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
try {
|
||||
const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => {
|
||||
const conditions: Record<string, unknown>[] = [
|
||||
{ inMailbox: mailboxId },
|
||||
{ notKeyword: "$seen" },
|
||||
];
|
||||
if (tab.filter) conditions.push(tab.filter);
|
||||
return ["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
filter: { operator: "AND", conditions },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `tab_${i}`];
|
||||
});
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
const result: Record<string, number> = {};
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
result[tabs[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
|
||||
for (const batch of batched(tabs, this.getMaxCallsInRequest())) {
|
||||
try {
|
||||
const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => {
|
||||
const conditions: Record<string, unknown>[] = [
|
||||
{ inMailbox: mailboxId },
|
||||
{ notKeyword: "$seen" },
|
||||
];
|
||||
if (tab.filter) conditions.push(tab.filter);
|
||||
return ["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
filter: { operator: "AND", conditions },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `tab_${i}`];
|
||||
});
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
for (let i = 0; i < batch.length; i++) {
|
||||
result[batch[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get category tab counts:', error);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get category tab counts:', error);
|
||||
return {};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
|
||||
@@ -1464,10 +1475,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
|
||||
if (emailIds.length === 0) return;
|
||||
|
||||
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]);
|
||||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||
const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read }]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
|
||||
@@ -1529,10 +1542,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
*/
|
||||
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
|
||||
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
|
||||
const update = Object.fromEntries(emailIds.map(id => [id, { ...patch }]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||||
]);
|
||||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||
const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||
@@ -1562,9 +1577,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (allIds.length === 0) return 0;
|
||||
|
||||
// Batch update: remove old keyword, add new keyword using per-property patches
|
||||
const updateBatchSize = 50;
|
||||
for (let i = 0; i < allIds.length; i += updateBatchSize) {
|
||||
const batch = allIds.slice(i, i + updateBatchSize);
|
||||
for (const batch of batched(allIds, this.getMaxObjectsInSet())) {
|
||||
const update: Record<string, Record<string, boolean | null>> = {};
|
||||
for (const id of batch) {
|
||||
update[id] = {
|
||||
@@ -1608,12 +1621,14 @@ export class JMAPClient implements IJMAPClient {
|
||||
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
|
||||
if (emailIds.length === 0) return;
|
||||
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: accountId || this.accountId,
|
||||
destroy: emailIds,
|
||||
}, "0"],
|
||||
]);
|
||||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: accountId || this.accountId,
|
||||
destroy: batch,
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||||
@@ -1624,10 +1639,12 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (markAsRead) patch["keywords/$seen"] = true;
|
||||
return patch;
|
||||
};
|
||||
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]);
|
||||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||||
const updates = Object.fromEntries(batch.map(id => [id, buildPatch()]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async batchArchiveEmails(
|
||||
@@ -1705,35 +1722,59 @@ export class JMAPClient implements IJMAPClient {
|
||||
updates[emailId] = { mailboxIds: { [destId]: true } };
|
||||
}
|
||||
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
// Creation ids are scoped to the request that introduced them (RFC 8620
|
||||
// §3.3), so "#<cid>" only resolves in the request carrying the Mailbox/set:
|
||||
// the folders are created alongside the first batch of messages, and the
|
||||
// ids they were assigned are substituted into every later batch.
|
||||
const updateBatches = batched(Object.entries(updates), this.getMaxObjectsInSet());
|
||||
const hasCreates = Object.keys(createEntries).length > 0;
|
||||
if (hasCreates) {
|
||||
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
|
||||
}
|
||||
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
|
||||
let createdIdFor: Record<string, string> = {};
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
for (let i = 0; i < updateBatches.length; i++) {
|
||||
const batch: Array<[string, { mailboxIds: Record<string, true> }]> = i === 0
|
||||
? updateBatches[i]
|
||||
: updateBatches[i].map(([emailId, patch]) => {
|
||||
const [destId] = Object.keys(patch.mailboxIds);
|
||||
const resolved = createdIdFor[destId];
|
||||
return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record<string, true> } : patch];
|
||||
});
|
||||
|
||||
if (hasCreates) {
|
||||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||||
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
|
||||
const failures = notCreated ? Object.entries(notCreated) : [];
|
||||
if (failures.length > 0) {
|
||||
const [cid, err] = failures[0];
|
||||
const parts = [err.type || 'unknown'];
|
||||
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err.description) parts.push(err.description);
|
||||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
||||
const methodCalls: JMAPMethodCall[] = [];
|
||||
const withCreates = hasCreates && i === 0;
|
||||
if (withCreates) {
|
||||
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
|
||||
}
|
||||
}
|
||||
methodCalls.push(['Email/set', { accountId: targetAccountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]);
|
||||
|
||||
const emailIdx = hasCreates ? 1 : 0;
|
||||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||||
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
|
||||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||||
if (emailFailures.length > 0) {
|
||||
const [id, err] = emailFailures[0];
|
||||
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
|
||||
const response = await this.request(methodCalls);
|
||||
|
||||
if (withCreates) {
|
||||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||||
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
|
||||
const failures = notCreated ? Object.entries(notCreated) : [];
|
||||
if (failures.length > 0) {
|
||||
const [cid, err] = failures[0];
|
||||
const parts = [err.type || 'unknown'];
|
||||
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
|
||||
if (err.description) parts.push(err.description);
|
||||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
||||
}
|
||||
const created = (mailboxResult?.created || {}) as Record<string, { id?: string }>;
|
||||
createdIdFor = Object.fromEntries(
|
||||
Object.entries(created)
|
||||
.filter(([, mailbox]) => !!mailbox?.id)
|
||||
.map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]),
|
||||
);
|
||||
}
|
||||
|
||||
const emailIdx = withCreates ? 1 : 0;
|
||||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||||
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
|
||||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||||
if (emailFailures.length > 0) {
|
||||
const [id, err] = emailFailures[0];
|
||||
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1758,15 +1799,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const batchSize = Math.min(500, this.getMaxObjectsInSet());
|
||||
let totalDestroyed = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
// Destroy in batches until the mailbox is empty. Never gate the loop on
|
||||
// Email/query's `total`: it is only guaranteed when `calculateTotal` is
|
||||
// requested, and Stalwart omits it otherwise, which used to stop the loop
|
||||
// after the first batch and leave folders with >500 emails mostly intact.
|
||||
while (true) {
|
||||
const response = await this.request([
|
||||
["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
filter: { inMailbox: mailboxId },
|
||||
limit: 500,
|
||||
limit: batchSize,
|
||||
}, "0"],
|
||||
["Email/set", {
|
||||
accountId: targetAccountId,
|
||||
@@ -1776,10 +1821,16 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const queryResult = response.methodResponses?.[0]?.[1];
|
||||
const setResult = response.methodResponses?.[1]?.[1];
|
||||
const found: string[] = queryResult?.ids || [];
|
||||
const destroyed = setResult?.destroyed?.length || 0;
|
||||
totalDestroyed += destroyed;
|
||||
|
||||
hasMore = destroyed > 0 && (queryResult?.total || 0) > destroyed;
|
||||
// Nothing left, or the server refused everything in this batch (missing
|
||||
// permission, immutable mail) — stop instead of looping forever on the
|
||||
// same ids.
|
||||
if (found.length === 0 || destroyed === 0) break;
|
||||
// A short page means we just handled the tail of the mailbox.
|
||||
if (found.length < batchSize) break;
|
||||
}
|
||||
|
||||
return totalDestroyed;
|
||||
@@ -1787,6 +1838,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const pageSize = Math.min(500, this.getMaxObjectsInSet());
|
||||
let totalMarked = 0;
|
||||
let hasMore = true;
|
||||
|
||||
@@ -1801,7 +1853,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
{ notKeyword: "$seen" },
|
||||
],
|
||||
},
|
||||
limit: 500,
|
||||
limit: pageSize,
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
@@ -1817,7 +1869,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
|
||||
totalMarked += ids.length;
|
||||
hasMore = ids.length === 500;
|
||||
hasMore = ids.length === pageSize;
|
||||
}
|
||||
|
||||
return totalMarked;
|
||||
@@ -1826,6 +1878,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const excludeSet = new Set(excludeMailboxIds);
|
||||
const pageSize = Math.min(500, this.getMaxObjectsInGet(), this.getMaxObjectsInSet());
|
||||
let totalMarked = 0;
|
||||
let hasMore = true;
|
||||
let position = 0;
|
||||
@@ -1835,7 +1888,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
filter: { notKeyword: "$seen" },
|
||||
limit: 500,
|
||||
limit: pageSize,
|
||||
position,
|
||||
}, "0"],
|
||||
["Email/get", {
|
||||
@@ -1871,7 +1924,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
totalMarked += targetIds.length;
|
||||
}
|
||||
|
||||
hasMore = ids.length === 500;
|
||||
hasMore = ids.length === pageSize;
|
||||
position += ids.length;
|
||||
}
|
||||
|
||||
@@ -2183,14 +2236,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (threadIds.length === 0) return [];
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const response = await this.request([
|
||||
["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"],
|
||||
]);
|
||||
const threads: Thread[] = [];
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
||||
return (response.methodResponses[0][1].list || []) as Thread[];
|
||||
for (const batchIds of batched(threadIds, this.getMaxObjectsInGet())) {
|
||||
const response = await this.request([
|
||||
["Thread/get", { accountId: targetAccountId, ids: batchIds }, "0"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
||||
threads.push(...((response.methodResponses[0][1].list || []) as Thread[]));
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
return threads;
|
||||
} catch (error) {
|
||||
console.error('Failed to get threads:', error);
|
||||
return [];
|
||||
@@ -2205,26 +2263,32 @@ export class JMAPClient implements IJMAPClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await this.request([
|
||||
["Email/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: thread.emailIds,
|
||||
properties: [
|
||||
...EMAIL_LIST_PROPERTIES,
|
||||
"textBody", "htmlBody", "bodyValues",
|
||||
"attachments", "blobId", "sentAt", "bcc", "replyTo",
|
||||
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
|
||||
],
|
||||
fetchTextBodyValues: true,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 256000,
|
||||
}, "0"],
|
||||
]);
|
||||
const emails: Email[] = [];
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/get") {
|
||||
const emails = response.methodResponses[0][1].list || [];
|
||||
for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) {
|
||||
const response = await this.request([
|
||||
["Email/get", {
|
||||
accountId: targetAccountId,
|
||||
ids: batchIds,
|
||||
properties: [
|
||||
...EMAIL_LIST_PROPERTIES,
|
||||
"textBody", "htmlBody", "bodyValues",
|
||||
"attachments", "blobId", "sentAt", "bcc", "replyTo",
|
||||
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
|
||||
],
|
||||
fetchTextBodyValues: true,
|
||||
fetchHTMLBodyValues: true,
|
||||
fetchAllBodyValues: true,
|
||||
maxBodyValueBytes: 256000,
|
||||
}, "0"],
|
||||
]);
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "Email/get") {
|
||||
emails.push(...(response.methodResponses[0][1].list || []));
|
||||
}
|
||||
}
|
||||
|
||||
if (emails.length > 0) {
|
||||
if (accountId && accountId !== this.accountId) {
|
||||
namespaceMailboxIds(emails, accountId);
|
||||
}
|
||||
@@ -3596,6 +3660,11 @@ export class JMAPClient implements IJMAPClient {
|
||||
return coreCapability?.maxObjectsInGet || 500;
|
||||
}
|
||||
|
||||
getMaxObjectsInSet(): number {
|
||||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInSet?: number } | undefined;
|
||||
return coreCapability?.maxObjectsInSet || 500;
|
||||
}
|
||||
|
||||
getMaxDelayedSend(accountId?: string): number {
|
||||
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
|
||||
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
|
||||
@@ -5050,38 +5119,41 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
|
||||
// Build the create map: { "new-0": event0, "new-1": event1, ... }
|
||||
const createMap: Record<string, Partial<CalendarEvent>> = {};
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
|
||||
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
|
||||
createMap[`new-${i}`] = clean;
|
||||
}
|
||||
|
||||
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
|
||||
|
||||
// Never emit iMIP scheduling messages when importing. Imported events often
|
||||
// carry an organizer/participants where the current user is the organizer;
|
||||
// without this, Stalwart tries to send invitation emails to every attendee
|
||||
// synchronously during CalendarEvent/set, which is both wrong (importing a
|
||||
// calendar should not spam invites) and can block the request indefinitely,
|
||||
// leaving the import spinner spinning forever (#411).
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
const createdIds: string[] = [];
|
||||
const failed: string[] = [];
|
||||
const indexed = events.map((event, index) => ({ event, index }));
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const key = `new-${i}`;
|
||||
if (result.created?.[key]?.id) {
|
||||
createdIds.push(result.created[key].id);
|
||||
} else if (result.notCreated?.[key]) {
|
||||
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
|
||||
failed.push(key);
|
||||
for (const batch of batched(indexed, this.getMaxObjectsInSet())) {
|
||||
// Build the create map: { "new-0": event0, "new-1": event1, ... }
|
||||
const createMap: Record<string, Partial<CalendarEvent>> = {};
|
||||
for (const { event, index } of batch) {
|
||||
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent;
|
||||
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
|
||||
createMap[`new-${index}`] = clean;
|
||||
}
|
||||
|
||||
// Never emit iMIP scheduling messages when importing. Imported events often
|
||||
// carry an organizer/participants where the current user is the organizer;
|
||||
// without this, Stalwart tries to send invitation emails to every attendee
|
||||
// synchronously during CalendarEvent/set, which is both wrong (importing a
|
||||
// calendar should not spam invites) and can block the request indefinitely,
|
||||
// leaving the import spinner spinning forever (#411).
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
for (const { index } of batch) {
|
||||
const key = `new-${index}`;
|
||||
if (result.created?.[key]?.id) {
|
||||
createdIds.push(result.created[key].id);
|
||||
} else if (result.notCreated?.[key]) {
|
||||
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
|
||||
failed.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5090,21 +5162,24 @@ export class JMAPClient implements IJMAPClient {
|
||||
return { created: [], failed };
|
||||
}
|
||||
|
||||
// Fetch all created events in a single CalendarEvent/get
|
||||
// Fetch the created events back for their server-assigned properties
|
||||
const refetchTimeZone = getUserTimeZone();
|
||||
const getResponse = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
ids: createdIds,
|
||||
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
const createdEvents: CalendarEvent[] = [];
|
||||
|
||||
let createdEvents: CalendarEvent[] = [];
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const list = getResponse.methodResponses[0][1].list || [];
|
||||
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
|
||||
for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) {
|
||||
const getResponse = await this.request([
|
||||
["CalendarEvent/get", {
|
||||
accountId,
|
||||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||||
ids: batchIds,
|
||||
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
||||
}, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||||
const list = getResponse.methodResponses[0][1].list || [];
|
||||
createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)));
|
||||
}
|
||||
}
|
||||
|
||||
debug.log('calendar', 'CalendarEvent/batchCreate result', {
|
||||
@@ -5255,17 +5330,19 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
||||
|
||||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: string[] = [];
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
if (result.destroyed) destroyed.push(...result.destroyed);
|
||||
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
|
||||
for (const batch of batched(eventIds, this.getMaxObjectsInSet())) {
|
||||
const response = await this.request([
|
||||
["CalendarEvent/set", { accountId, destroy: batch }, "0"]
|
||||
], this.calendarUsing());
|
||||
|
||||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||||
const result = response.methodResponses[0][1];
|
||||
if (result.destroyed) destroyed.push(...result.destroyed);
|
||||
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
|
||||
}
|
||||
}
|
||||
|
||||
return { destroyed, notDestroyed };
|
||||
@@ -5524,7 +5601,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
private static FILE_NODE_PROPERTIES = [
|
||||
"id", "parentId", "name", "type", "blobId", "size", "created", "updated",
|
||||
"id", "parentId", "name", "type", "blobId", "size", "created", "modified",
|
||||
// Stalwart omits shareWith/myRights from FileNode/get unless requested
|
||||
// explicitly, so the share dialog and indicators can't see existing
|
||||
// shares without naming them here (same as CALENDAR_PROPERTIES).
|
||||
@@ -5778,62 +5855,69 @@ export class JMAPClient implements IJMAPClient {
|
||||
* throws for per-node failures (only for a whole-method error).
|
||||
*/
|
||||
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||||
const ids = Object.keys(updates);
|
||||
if (ids.length === 0) return { updated: [], notUpdated: {} };
|
||||
const entries = Object.entries(updates);
|
||||
if (entries.length === 0) return { updated: [], notUpdated: {} };
|
||||
const accountId = this.getFilesAccountId();
|
||||
|
||||
const response = await this.request(
|
||||
[["FileNode/set", { accountId, update: updates }, "fns0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||||
}
|
||||
|
||||
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||||
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||||
const updated: string[] = [];
|
||||
const notUpdated: Record<string, string> = {};
|
||||
for (const id of Object.keys(notUpdatedMap)) {
|
||||
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
||||
|
||||
for (const batch of batched(entries, this.getMaxObjectsInSet())) {
|
||||
const response = await this.request(
|
||||
[["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||||
}
|
||||
|
||||
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||||
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||||
for (const id of Object.keys(notUpdatedMap)) {
|
||||
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
||||
}
|
||||
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||||
updated.push(...(Object.keys(updatedMap).length > 0
|
||||
? Object.keys(updatedMap)
|
||||
: batch.map(([id]) => id).filter(id => !(id in notUpdated))));
|
||||
}
|
||||
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||||
const updated = Object.keys(updatedMap).length > 0
|
||||
? Object.keys(updatedMap)
|
||||
: ids.filter(id => !(id in notUpdated));
|
||||
|
||||
return { updated, notUpdated };
|
||||
}
|
||||
|
||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||
const accountId = this.getFilesAccountId();
|
||||
const destroyed: string[] = [];
|
||||
|
||||
const response = await this.request(
|
||||
[["FileNode/set", {
|
||||
accountId,
|
||||
destroy: ids,
|
||||
onDestroyRemoveChildren: true,
|
||||
}, "fns0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
|
||||
const response = await this.request(
|
||||
[["FileNode/set", {
|
||||
accountId,
|
||||
destroy: batch,
|
||||
onDestroyRemoveChildren: true,
|
||||
}, "fns0"]],
|
||||
this.fileUsing(),
|
||||
);
|
||||
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
||||
const result = response.methodResponses?.[0];
|
||||
if (!result || result[0] === "error") {
|
||||
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
||||
}
|
||||
|
||||
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
|
||||
const notDestroyedIds = Object.keys(notDestroyedMap);
|
||||
|
||||
if (notDestroyedIds.length > 0) {
|
||||
const firstError = notDestroyedMap[notDestroyedIds[0]];
|
||||
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
||||
}
|
||||
|
||||
destroyed.push(...(result[1].destroyed || []));
|
||||
}
|
||||
|
||||
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
|
||||
const notDestroyedIds = Object.keys(notDestroyedMap);
|
||||
|
||||
if (notDestroyedIds.length > 0) {
|
||||
const firstError = notDestroyedMap[notDestroyedIds[0]];
|
||||
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
||||
}
|
||||
|
||||
return {
|
||||
destroyed: result[1].destroyed || [],
|
||||
notDestroyed: [],
|
||||
};
|
||||
return { destroyed, notDestroyed: [] };
|
||||
}
|
||||
|
||||
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||||
@@ -6394,6 +6478,43 @@ export class JMAPClient implements IJMAPClient {
|
||||
* a shared mailbox owned by another user). When omitted, falls back to the
|
||||
* client's own primary account.
|
||||
*/
|
||||
async copyEmailAcrossAccounts(
|
||||
emailId: string,
|
||||
fromAccountId: string,
|
||||
toAccountId: string,
|
||||
destMailboxId: string,
|
||||
): Promise<string> {
|
||||
// Email/copy drops keywords unless the create sets them, so carry the
|
||||
// source's over — otherwise the moved message shows up as unread.
|
||||
const srcResp = await this.request([
|
||||
["Email/get", { accountId: fromAccountId, ids: [emailId], properties: ["keywords"] }, "0"],
|
||||
]);
|
||||
const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {};
|
||||
|
||||
// onSuccessDestroyOriginal is the spec-correct way to remove the source, but
|
||||
// Stalwart currently destroys the copy's create-id instead of the source id,
|
||||
// so the original is left behind — a duplicate on every cross-account move.
|
||||
// Reported upstream (support.stalw.art #1150); this self-heals once fixed.
|
||||
const response = await this.request([
|
||||
["Email/copy", {
|
||||
fromAccountId,
|
||||
accountId: toAccountId,
|
||||
create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } },
|
||||
onSuccessDestroyOriginal: true,
|
||||
}, "0"],
|
||||
]);
|
||||
const res = response.methodResponses?.[0]?.[1];
|
||||
const err = res?.notCreated?.c;
|
||||
if (err) {
|
||||
throw new Error(err.description || err.type || "Failed to copy email across accounts");
|
||||
}
|
||||
const id = res?.created?.c?.id;
|
||||
if (!id) {
|
||||
throw new Error("Email/copy succeeded but no ID returned");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async importRawEmail(
|
||||
blob: Blob,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A JMAP session advertises hard ceilings on what one request may carry: how
|
||||
* many method calls it holds (`maxCallsInRequest`) and how many objects a
|
||||
* single /get or /set may touch (`maxObjectsInGet`, `maxObjectsInSet`). Going
|
||||
* over any of them fails the *whole* request, not the surplus, so a batch built
|
||||
* from a list the user controls - tags, category tabs, a multi-select, an
|
||||
* import - is split against the advertised limit before it is sent.
|
||||
*
|
||||
* Stalwart defaults to 16 method calls and 500 objects, so the ceilings are low
|
||||
* enough to reach with ordinary use: nine tags is already 18 calls.
|
||||
*/
|
||||
|
||||
/** Split `items` into consecutive batches of at most `size` entries. */
|
||||
export function batched<T>(items: T[], size: number): T[][] {
|
||||
const step = Math.max(1, Math.floor(size));
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += step) {
|
||||
result.push(items.slice(i, i + step));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** How many items fit in one request when each item costs `callsPerItem` method calls. */
|
||||
export function itemsPerRequest(maxCalls: number, callsPerItem: number): number {
|
||||
return Math.max(1, Math.floor(maxCalls / callsPerItem));
|
||||
}
|
||||
+5
-1
@@ -835,7 +835,11 @@ export interface FileNode {
|
||||
blobId: string | null;
|
||||
size: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
// Last content/metadata change, server-maintained. The property is named
|
||||
// `modified` in draft-ietf-jmap-filenode and in Stalwart - there is no
|
||||
// `updated` on a FileNode. Asking for the wrong name silently yields
|
||||
// undefined, which made the UI show the creation date forever (#700).
|
||||
modified: string;
|
||||
// JMAP Sharing (RFC 9670). Populated only when the server advertises the
|
||||
// filenode capability and the properties are explicitly requested. A node is
|
||||
// shared-out when `shareWith` has entries; `myRights` describes what the
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Naming a tag on screen.
|
||||
*
|
||||
* A nested tag is written out level by level - `Work/Clients/Acme` - and a flat
|
||||
* one is simply its own name, so nothing here asks the caller which kind it
|
||||
* has. `keywordRenderings` additionally offers progressively shorter forms for
|
||||
* a name with nowhere to fit, which `useShortenedText` measures against the
|
||||
* room actually available.
|
||||
*/
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
import { KEYWORD_SEPARATOR, keywordLevels } from "./keyword-nesting";
|
||||
|
||||
/** Stands in for one level left out of a name. */
|
||||
export const KEYWORD_SHORTENED_LEVEL = "..";
|
||||
|
||||
/** Stands in for a run of more than one level left out of a name. */
|
||||
export const KEYWORD_SHORTENED_RUN = "...";
|
||||
|
||||
/**
|
||||
* The display name of a tag, one entry per level, outermost first. A tag with
|
||||
* one level yields a single entry, so callers need not care either way.
|
||||
*
|
||||
* `nested` is the user's setting. With nesting off a slash carries no meaning,
|
||||
* so the id is one opaque token and the tag is named by its own label - nobody
|
||||
* who left the setting alone should find their tags rewritten because an id
|
||||
* happens to contain a slash, which can outlast turning nesting off, or arrive
|
||||
* through settings sync or another client.
|
||||
*
|
||||
* With nesting on, each level resolves to that tag's display name, falling back
|
||||
* to the raw level of the id when it has no definition - the settings list only
|
||||
* describes the tags this client knows about. Levels stay separate entries
|
||||
* because a display name may itself contain a slash, which is part of that one
|
||||
* name rather than a level of its own.
|
||||
*/
|
||||
export function formatKeywordLabels(
|
||||
id: string,
|
||||
keywords: KeywordDefinition[],
|
||||
nested: boolean,
|
||||
): string[] {
|
||||
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
|
||||
if (!nested) return [label(id) ?? id];
|
||||
|
||||
const levels = keywordLevels(id);
|
||||
return levels.map((level, index) =>
|
||||
label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The display name of a tag: `Work/Clients/Acme` for a nested one, its own name
|
||||
* otherwise. The general way to name a tag on screen.
|
||||
*/
|
||||
export function formatKeyword(
|
||||
id: string,
|
||||
keywords: KeywordDefinition[],
|
||||
nested: boolean,
|
||||
): string {
|
||||
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every way a name can be written, longest first: in full, then with an ever
|
||||
* longer run of intermediate levels replaced by `..`, collapsing to a single
|
||||
* `...` as soon as that run covers more than one level.
|
||||
*
|
||||
* The outermost and innermost levels always survive - between them they say
|
||||
* which branch a tag belongs to and which tag it is, which is exactly what a
|
||||
* trailing ellipsis destroys. A rendering that would not actually come out
|
||||
* shorter than the one before it (levels named `it`, say) is dropped, so
|
||||
* walking the list never makes the text grow.
|
||||
*/
|
||||
export function keywordRenderings(levels: string[]): string[] {
|
||||
const renderings = [levels.join(KEYWORD_SEPARATOR)];
|
||||
for (let shortened = 1; shortened <= levels.length - 2; shortened++) {
|
||||
const marker = shortened === 1 ? KEYWORD_SHORTENED_LEVEL : KEYWORD_SHORTENED_RUN;
|
||||
const rendering = [levels[0], marker, ...levels.slice(shortened + 1)]
|
||||
.join(KEYWORD_SEPARATOR);
|
||||
if (rendering.length < renderings[renderings.length - 1].length) {
|
||||
renderings.push(rendering);
|
||||
}
|
||||
}
|
||||
return renderings;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tag nesting.
|
||||
*
|
||||
* A tag is stored on the server as the JMAP keyword `$label:<id>`, where `id`
|
||||
* is a slug derived from the display name. Nesting reuses that single id: the
|
||||
* levels are joined with a forward slash, so `$label:work/clients` is the child
|
||||
* of `$label:work`. Keeping the hierarchy inside the id means the server stays
|
||||
* the source of truth for tag membership and existing lookups by keyword keep
|
||||
* working.
|
||||
*
|
||||
* RFC 8621 section 4.1.1 allows a keyword of 1-255 characters from the ASCII
|
||||
* range %x21-%x7e minus `( ) { ] % * " \`, so the separator is legal but the
|
||||
* length of a deep id is not free - `MAX_KEYWORD_ID_LENGTH` is the budget a
|
||||
* composed id has to stay within.
|
||||
*
|
||||
* Turning any of this into text for the screen lives in `keyword-format`.
|
||||
*/
|
||||
import type { KeywordDefinition } from "@/stores/settings-store";
|
||||
import { KEYWORD_PREFIX } from "./thread-utils";
|
||||
|
||||
/** Separates parent from child inside a tag id. */
|
||||
export const KEYWORD_SEPARATOR = "/";
|
||||
|
||||
/** Longest keyword a JMAP server has to accept (RFC 8621, section 4.1.1). */
|
||||
export const MAX_KEYWORD_LENGTH = 255;
|
||||
|
||||
/** What is left for the id once the `$label:` prefix is spent. */
|
||||
export const MAX_KEYWORD_ID_LENGTH = MAX_KEYWORD_LENGTH - KEYWORD_PREFIX.length;
|
||||
|
||||
/** A tag definition placed in the hierarchy its id describes. */
|
||||
export interface KeywordNode extends KeywordDefinition {
|
||||
children: KeywordNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a display name to one level of an id: lowercase, and everything
|
||||
* outside `[a-z0-9_-]` folded to a single dash. The separator is not exempt -
|
||||
* a slash typed into the name is a literal part of that name, not a level.
|
||||
* The only slug function for tag ids; keep it the only one.
|
||||
*/
|
||||
export function normalizeKeywordLevel(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/** Builds the id a tag named `name` gets under `parentId` (null = top level). */
|
||||
export function composeKeywordId(parentId: string | null, name: string): string {
|
||||
const level = normalizeKeywordLevel(name);
|
||||
if (!parentId || !level) return level;
|
||||
return `${parentId}${KEYWORD_SEPARATOR}${level}`;
|
||||
}
|
||||
|
||||
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
|
||||
export function keywordLevels(id: string): string[] {
|
||||
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
|
||||
}
|
||||
|
||||
/** The id of the tag one level up, or null for a top-level tag. */
|
||||
export function getParentKeywordId(id: string): string | null {
|
||||
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
|
||||
return index === -1 ? null : id.slice(0, index);
|
||||
}
|
||||
|
||||
/** True when `candidateId` sits anywhere below `ancestorId`. */
|
||||
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
|
||||
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
|
||||
}
|
||||
|
||||
/** True when any defined tag sits below `id`. */
|
||||
export function hasChildKeywords(id: string, keywords: KeywordDefinition[]): boolean {
|
||||
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Arranges tag definitions into the tree their ids describe, preserving the
|
||||
* user's manual order within each level.
|
||||
*
|
||||
* A tag whose direct parent is not defined stays at the root rather than being
|
||||
* hidden or grafted onto a grandparent; callers name such a root in full so the
|
||||
* missing level is still visible.
|
||||
*/
|
||||
export function buildKeywordTree(keywords: KeywordDefinition[]): KeywordNode[] {
|
||||
const nodes = new Map<string, KeywordNode>();
|
||||
for (const keyword of keywords) {
|
||||
nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
|
||||
}
|
||||
|
||||
const roots: KeywordNode[] = [];
|
||||
for (const keyword of keywords) {
|
||||
const node = nodes.get(keyword.id)!;
|
||||
const parentId = getParentKeywordId(keyword.id);
|
||||
const parent = parentId ? nodes.get(parentId) : undefined;
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const setDepth = (node: KeywordNode, depth: number) => {
|
||||
node.depth = depth;
|
||||
node.children.forEach((child) => setDepth(child, depth + 1));
|
||||
};
|
||||
roots.forEach((root) => setDepth(root, 0));
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prunes a tag tree down to the nodes worth showing.
|
||||
*
|
||||
* A node survives when the predicate accepts it or when any of its descendants
|
||||
* survives, so hiding a parent never strands the children below it. Depths are
|
||||
* left untouched: a kept node keeps the indentation of its original level even
|
||||
* when the level above it is only there to carry it.
|
||||
*/
|
||||
export function filterKeywordTree(
|
||||
nodes: KeywordNode[],
|
||||
isVisible: (node: KeywordNode) => boolean,
|
||||
): KeywordNode[] {
|
||||
const kept: KeywordNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const children = filterKeywordTree(node.children, isVisible);
|
||||
if (children.length > 0 || isVisible(node)) {
|
||||
kept.push({ ...node, children });
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Total number of nodes in a tag tree, at every level. */
|
||||
export function countKeywordNodes(nodes: KeywordNode[]): number {
|
||||
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
|
||||
}
|
||||
@@ -470,21 +470,20 @@ async function doContactCreate(contact: ContactCard): Promise<ContactCard> {
|
||||
|
||||
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
|
||||
|
||||
// This salt acts as a constant context identifier for key derivation.
|
||||
// While hardcoded, security is maintained because the WebAuthn PRF extension
|
||||
// mixes this salt with the device's unique, hardware-bound private key.
|
||||
// Changing this string will result in a completely different derived secret.
|
||||
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1");
|
||||
|
||||
/**
|
||||
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
|
||||
* This secret is typically used as a local master encryption key.
|
||||
*/
|
||||
async function doGetOrCreatePRF(
|
||||
masterCredentialIdBytes: number[] | undefined,
|
||||
pluginId: string,
|
||||
name?: string,
|
||||
displayName?: string
|
||||
displayName?: string,
|
||||
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
|
||||
|
||||
// To avoid a privileged plugin to access secret created from another privileged plugin,
|
||||
// we add the pluginID from manifest in salt.
|
||||
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1" + pluginId)
|
||||
|
||||
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
|
||||
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
|
||||
@@ -496,18 +495,18 @@ async function doGetOrCreatePRF(
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
allowCredentials: [{ type: "public-key", id: credentialId }],
|
||||
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
|
||||
extensions: { prf: { eval: { first: PRF_SALT } } } as any
|
||||
extensions: { prf: { eval: { first: PRF_SALT } } }
|
||||
}
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
// Extract the derived symmetric key from the authenticator's output
|
||||
const outputs = assertion.getClientExtensionResults();
|
||||
const prfSecret = (outputs as any).prf?.results?.first;
|
||||
const prfSecret = (outputs).prf?.results?.first;
|
||||
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
|
||||
|
||||
return {
|
||||
credentialId: masterCredentialIdBytes,
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -532,14 +531,14 @@ async function doGetOrCreatePRF(
|
||||
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
|
||||
userVerification: "required"
|
||||
},
|
||||
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
|
||||
extensions: { prf: {} } // Request PRF extension support from the authenticator
|
||||
}
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
const outputs = credential.getClientExtensionResults();
|
||||
|
||||
// Ensure the authenticator successfully enabled and supports the PRF extension
|
||||
const isPrfEnabled = (outputs as any).prf?.enabled;
|
||||
const isPrfEnabled = (outputs).prf?.enabled;
|
||||
if (!isPrfEnabled) {
|
||||
return 'The authenticator does not support or has rejected the PRF extension.';
|
||||
}
|
||||
@@ -556,20 +555,20 @@ async function doGetOrCreatePRF(
|
||||
userVerification: "required",
|
||||
extensions: {
|
||||
prf: { eval: { first: PRF_SALT } }
|
||||
} as any
|
||||
}
|
||||
}
|
||||
}) as PublicKeyCredential;
|
||||
|
||||
const assertionOutputs = assertion.getClientExtensionResults();
|
||||
|
||||
const prfSecret = (assertionOutputs as any).prf?.results?.first;
|
||||
const prfSecret = (assertionOutputs).prf?.results?.first;
|
||||
if (!prfSecret) {
|
||||
return 'Cannot get PRF secret from existing credential.';
|
||||
}
|
||||
|
||||
return {
|
||||
credentialId: Array.from(new Uint8Array(credential.rawId)),
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret))
|
||||
prfSecret: Array.from(new Uint8Array(prfSecret as ArrayBuffer))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -778,7 +777,7 @@ export async function dispatchApiCall(
|
||||
);
|
||||
case 'upfiles.get' : return getFile(args[0] as string);
|
||||
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
|
||||
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
|
||||
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string, args[2] as string | undefined, args[3] as string | undefined);
|
||||
|
||||
case 'contact.get': return doContactGet(args[0] as string);
|
||||
case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial<ContactCard>);
|
||||
|
||||
@@ -167,7 +167,7 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
settings: { ...manifest.settings },
|
||||
},
|
||||
webauthn: {
|
||||
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
|
||||
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, manifest.id, name, displayName], 0)
|
||||
},
|
||||
storage: {
|
||||
get: (key: string) => callApi('storage.get', [key]),
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
export interface ReplyAddress {
|
||||
email?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ReplySource {
|
||||
from?: ReplyAddress[];
|
||||
/** Addresses from the original message's Reply-To header. */
|
||||
replyToAddresses?: ReplyAddress[];
|
||||
to?: ReplyAddress[];
|
||||
cc?: ReplyAddress[];
|
||||
}
|
||||
|
||||
export interface ReplyRecipientsResult {
|
||||
to: ReplyAddress[];
|
||||
cc: ReplyAddress[];
|
||||
}
|
||||
|
||||
function normalize(email: string): string {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeBase(email: string): string {
|
||||
const normalized = normalize(email);
|
||||
const at = normalized.indexOf('@');
|
||||
if (at <= 0) return normalized;
|
||||
|
||||
const local = normalized.slice(0, at);
|
||||
const domain = normalized.slice(at + 1);
|
||||
const plus = local.indexOf('+');
|
||||
|
||||
return `${plus >= 0 ? local.slice(0, plus) : local}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `email` belong to the user? Matches exactly first, then with `+tag`
|
||||
* sub-addressing stripped (info+news@ is still info@).
|
||||
*/
|
||||
function isOwnAddress(email: string | undefined, ownEmails: string[]): boolean {
|
||||
if (!email?.trim()) return false;
|
||||
const exact = normalize(email);
|
||||
if (ownEmails.some((own) => normalize(own) === exact)) return true;
|
||||
const base = normalizeBase(email);
|
||||
return ownEmails.some((own) => normalizeBase(own) === base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a message the user themself sent? True when the From address is one
|
||||
* of their own identities - the case that shows up when browsing a thread and
|
||||
* replying to your own last message.
|
||||
*/
|
||||
export function isSelfSent(source: ReplySource | undefined, ownEmails: string[]): boolean {
|
||||
return isOwnAddress(source?.from?.[0]?.email, ownEmails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out the To/Cc a reply should open with.
|
||||
*
|
||||
* Normal case: reply goes to the Reply-To header if the original carried one,
|
||||
* else to From (RFC 5322). Reply-all adds the other original recipients,
|
||||
* minus the user's own addresses.
|
||||
*
|
||||
* Self-sent case (#703): replying to your own message inside a thread must
|
||||
* continue the conversation, not mail yourself. Gmail and Thunderbird address
|
||||
* the reply to the message's original recipients instead, so that's what we do
|
||||
* - the original To for reply, plus the original Cc for reply-all. Those
|
||||
* addresses were the user's own choice, so they're kept verbatim (no self-
|
||||
* filtering) and the Reply-To header is ignored, since answering your own
|
||||
* Reply-To would land the mail back in your inbox again.
|
||||
*
|
||||
* A self-sent message with no visible recipients (Bcc-only) has nothing to
|
||||
* continue to, so it falls back to the normal behaviour.
|
||||
*/
|
||||
export function buildReplyRecipients(
|
||||
source: ReplySource | undefined,
|
||||
mode: 'reply' | 'replyAll',
|
||||
ownEmails: string[],
|
||||
): ReplyRecipientsResult {
|
||||
if (!source) return { to: [], cc: [] };
|
||||
|
||||
const withEmail = (list: ReplyAddress[] | undefined) => (list ?? []).filter((r) => Boolean(r.email));
|
||||
|
||||
if (isSelfSent(source, ownEmails)) {
|
||||
const originalTo = withEmail(source.to);
|
||||
if (originalTo.length > 0) {
|
||||
return {
|
||||
to: originalTo,
|
||||
cc: mode === 'replyAll' ? withEmail(source.cc) : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const replyTarget = withEmail(source.replyToAddresses).length
|
||||
? withEmail(source.replyToAddresses)
|
||||
: (source.from?.[0]?.email ? [source.from[0]] : []);
|
||||
|
||||
if (mode === 'reply') {
|
||||
return { to: replyTarget, cc: [] };
|
||||
}
|
||||
|
||||
const others = (list: ReplyAddress[] | undefined) =>
|
||||
withEmail(list).filter((r) => !isOwnAddress(r.email, ownEmails));
|
||||
|
||||
return {
|
||||
to: [...replyTarget, ...others(source.to)],
|
||||
cc: others(source.cc),
|
||||
};
|
||||
}
|
||||
+30
-12
@@ -168,41 +168,59 @@ export const KEYWORD_PREFIX = "$label:";
|
||||
export const KEYWORD_PREFIX_LEGACY = "$color:";
|
||||
|
||||
/**
|
||||
* Gets all active label/color tag IDs from email keywords.
|
||||
* Gets every tag id set on a message.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
* A tag written under both spellings is one tag, so it is returned once.
|
||||
*/
|
||||
export function getEmailColorTags(keywords: Record<string, boolean> | undefined): string[] {
|
||||
export function getEmailTagIds(keywords: Record<string, boolean> | undefined): string[] {
|
||||
if (!keywords) return [];
|
||||
const tags: string[] = [];
|
||||
const tags = new Set<string>();
|
||||
for (const key of Object.keys(keywords)) {
|
||||
if ((key.startsWith(KEYWORD_PREFIX) || key.startsWith(KEYWORD_PREFIX_LEGACY)) && keywords[key] === true) {
|
||||
tags.push(
|
||||
tags.add(
|
||||
key.startsWith(KEYWORD_PREFIX)
|
||||
? key.slice(KEYWORD_PREFIX.length)
|
||||
: key.slice(KEYWORD_PREFIX_LEGACY.length)
|
||||
);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets label/color tag from email keywords (if any).
|
||||
* Gets the first tag id set on a message, if any.
|
||||
* Reads both the current $label: prefix and the legacy $color: prefix.
|
||||
* @deprecated Use getEmailColorTags for multi-tag support.
|
||||
* @deprecated Use getEmailTagIds for multi-tag support.
|
||||
*/
|
||||
export function getEmailColorTag(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailColorTags(keywords);
|
||||
export function getEmailTagId(keywords: Record<string, boolean> | undefined): string | null {
|
||||
const tags = getEmailTagIds(keywords);
|
||||
return tags.length > 0 ? tags[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a thread has any color tag (returns first found).
|
||||
* The first tag id found anywhere in a thread, if any.
|
||||
*/
|
||||
export function getThreadColorTag(emails: Email[]): string | null {
|
||||
export function getThreadTagId(emails: Email[]): string | null {
|
||||
for (const email of emails) {
|
||||
const color = getEmailColorTag(email.keywords);
|
||||
const color = getEmailTagId(email.keywords);
|
||||
if (color) return color;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tag anywhere in a thread, deduplicated.
|
||||
*
|
||||
* A collapsed thread row stands in for all its messages, so it has to account
|
||||
* for all their tags - showing only the first message's would hide the rest
|
||||
* with nothing to indicate they exist.
|
||||
*/
|
||||
export function getThreadTagIds(emails: Email[]): string[] {
|
||||
const tags = new Set<string>();
|
||||
for (const email of emails) {
|
||||
for (const tag of getEmailTagIds(email.keywords)) {
|
||||
tags.add(tag);
|
||||
}
|
||||
}
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
+8
-2
@@ -844,7 +844,9 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
|
||||
const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
|
||||
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
|
||||
if (!hasName && !hasEmail && card.kind !== "group") return null;
|
||||
// An organization name identifies the card just as well as a personal name.
|
||||
const hasOrg = !!Object.values(card.organizations || {})[0]?.name;
|
||||
if (!hasName && !hasEmail && !hasOrg && card.kind !== "group") return null;
|
||||
|
||||
return card;
|
||||
}
|
||||
@@ -882,7 +884,11 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
const suffix = findKind("generation", "suffix");
|
||||
const additional = findKind("given2", "additional", "middle");
|
||||
|
||||
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
|
||||
// FN is mandatory in vCard, so fall back to the organization name for org cards.
|
||||
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ")
|
||||
|| contact.name?.full
|
||||
|| Object.values(contact.organizations || {})[0]?.name
|
||||
|| "";
|
||||
if (fn) {
|
||||
lines.push(`FN:${encodeValue(fn)}`);
|
||||
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
|
||||
|
||||
Reference in New Issue
Block a user