Merge branch 'bulwarkmail:main' into user-api-plugin

This commit is contained in:
Paulhenry Saux
2026-08-01 20:24:08 +02:00
committed by GitHub
106 changed files with 10802 additions and 2479 deletions
+47
View File
@@ -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');
});
});
+133
View File
@@ -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]);
});
});
+231
View File
@@ -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);
});
});
+115
View File
@@ -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",
]);
});
});
+183
View File
@@ -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);
});
});
+114
View File
@@ -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);
});
});
+87 -30
View File
@@ -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([]);
});
});
+42
View File
@@ -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", () => {
+7 -5
View File
@@ -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,
+8 -8
View File
@@ -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),
},
];
}
+58
View File
@@ -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,
},
};
}
+9
View File
@@ -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
View File
@@ -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>,
+26
View File
@@ -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
View File
@@ -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
+83
View File
@@ -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;
}
+139
View File
@@ -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);
}
+15 -16
View File
@@ -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>);
+1 -1
View File
@@ -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]),
+108
View File
@@ -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
View File
@@ -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
View File
@@ -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)}`);