| null, emailIds: string[]) => {
+ const answer = await engine.answer([['SearchSnippet/get', { filter, emailIds }, 's']]);
+ return answer?.methodResponses[0][1] as {
+ list: Array<{ emailId: string; subject: string | null; preview: string | null }>;
+ notFound: string[];
+ };
+ };
+ return { engine, query, snippets };
+}
+
+const alice = [{ name: 'Alice Adler', email: 'alice@example.org' }];
+const bob = [{ name: 'Bob Berg', email: 'bob@example.org' }];
+const carol = [{ name: 'Carol Conrad', email: 'carol@example.org' }];
+const dave = [{ name: 'Dave Dorn', email: 'dave@example.org' }];
+
+describe('matching', () => {
+ it('finds a word inside a German compound, in the subject and in the body', async () => {
+ const { query } = await storeWith([
+ { id: 'subject', subject: 'Zahlungserinnerungsrechnung Oktober' },
+ { id: 'body', subject: 'Hallo', ...withBody('Anbei die Stromrechnung.') },
+ { id: 'other', subject: 'Einladung', ...withBody('Bis morgen') },
+ ]);
+ expect(await query({ text: 'rechnung' })).toEqual(['subject', 'body']);
+ expect(await query({ subject: 'rechnung' })).toEqual(['subject']);
+ expect(await query({ body: 'rechnung' })).toEqual(['body']);
+ });
+
+ it('reads word* as the start of a word, not anywhere in it', async () => {
+ const { query } = await storeWith([
+ { id: 'start', subject: 'Rechnungsbetrag offen' },
+ { id: 'inside', subject: 'Stromrechnung offen' },
+ ]);
+ expect(await query({ text: 'rechn*' })).toEqual(['start']);
+ expect(await query({ text: 'rechn' })).toEqual(['start', 'inside']);
+ });
+
+ it('reads a quoted phrase as exact words in order', async () => {
+ const { query } = await storeWith([
+ { id: 'phrase', subject: 'The annual report is here' },
+ { id: 'apart', subject: 'The report for annual leave' },
+ { id: 'compound', subject: 'Stromrechnung' },
+ { id: 'word', subject: 'Eine Rechnung' },
+ ]);
+ expect(await query({ text: '"annual report"' })).toEqual(['phrase']);
+ expect(await query({ text: '"rechnung"' })).toEqual(['word']);
+ });
+
+ it('matches one and two characters as whole words only, unless a star asks for more', async () => {
+ const { query } = await storeWith([
+ { id: 'zu', subject: 'Bitte zu Hause' },
+ { id: 'zum', subject: 'Bitte zum Arzt' },
+ { id: 'a', subject: 'Plan a today' },
+ { id: 'anna', subject: 'Anna kommt' },
+ ]);
+ expect(await query({ subject: 'zu' })).toEqual(['zu']);
+ expect(await query({ subject: 'zu*' })).toEqual(['zu', 'zum']);
+ expect(await query({ subject: 'a' })).toEqual(['a']);
+ // One letter is too expensive as a prefix, so it stays the word.
+ expect(await query({ subject: 'a*' })).toEqual(['a']);
+ });
+
+ it('keeps To, Cc and Bcc apart, and searches all of them for text', async () => {
+ const { query } = await storeWith([
+ { id: 'one', from: alice, to: bob, cc: carol, bcc: dave },
+ ]);
+ expect(await query({ from: 'alice' })).toEqual(['one']);
+ expect(await query({ to: 'bob' })).toEqual(['one']);
+ expect(await query({ to: 'carol' })).toEqual([]);
+ expect(await query({ to: 'dave' })).toEqual([]);
+ expect(await query({ cc: 'carol' })).toEqual(['one']);
+ expect(await query({ cc: 'bob' })).toEqual([]);
+ expect(await query({ bcc: 'dave' })).toEqual(['one']);
+ expect(await query({ bcc: 'carol' })).toEqual([]);
+ for (const name of ['alice', 'bob', 'carol', 'dave']) expect(await query({ text: name })).toEqual(['one']);
+ });
+
+ it('answers the recipient search the client sends', async () => {
+ const { query } = await storeWith([
+ { id: 'to', to: bob },
+ { id: 'cc', to: alice, cc: bob },
+ { id: 'none', to: alice },
+ ]);
+ expect(await query({ operator: 'OR', conditions: [{ to: 'bob@example.org' }, { cc: 'bob@example.org' }] })).toEqual(['to', 'cc']);
+ });
+
+ it('answers every search shape buildJMAPFilter produces', async () => {
+ const { query } = await storeWith([
+ {
+ id: 'hit',
+ subject: 'Budget 2024 annual report',
+ from: alice,
+ to: bob,
+ ...withBody('Die Stromrechnung liegt bei'),
+ },
+ { id: 'miss', subject: 'Budget 2024', from: alice, to: bob, ...withBody('Nichts') },
+ ]);
+ const filter = buildJMAPFilter('"annual report" budg* 2024', {
+ ...DEFAULT_SEARCH_FILTERS,
+ from: 'alice',
+ to: 'bob',
+ subject: 'annual',
+ body: 'rechnung',
+ }, 'inbox');
+ expect(await query(filter)).toEqual(['hit']);
+ });
+
+ it('ignores diacritics both ways', async () => {
+ const { query } = await storeWith([
+ { id: 'umlaut', subject: 'Frau Müller' },
+ { id: 'plain', subject: 'Herr Muller' },
+ { id: 'accent', subject: 'Café am Markt' },
+ ]);
+ expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'plain']);
+ expect(await query({ text: 'muller' })).toEqual(['umlaut', 'plain']);
+ expect(await query({ text: 'cafe' })).toEqual(['accent']);
+ expect(await query({ text: 'Café' })).toEqual(['accent']);
+ expect(await query({ text: 'mül*' })).toEqual(['umlaut', 'plain']);
+ });
+
+ it('finds the spelled-out German spelling both ways, without false friends', async () => {
+ const { query } = await storeWith([
+ { id: 'umlaut', subject: 'Frau Müller' },
+ { id: 'written', subject: 'Herr Mueller' },
+ { id: 'sharp', subject: 'Hauptstraße 1' },
+ { id: 'double', subject: 'Hauptstrasse 2' },
+ { id: 'care', subject: 'Die Betreuung beginnt' },
+ { id: 'loyal', subject: 'Treue' },
+ ]);
+ expect(await query({ text: 'Mueller' })).toEqual(['umlaut', 'written']);
+ expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'written']);
+ expect(await query({ text: 'Hauptstraße' })).toEqual(['sharp', 'double']);
+ expect(await query({ text: 'Hauptstrasse' })).toEqual(['sharp', 'double']);
+ // "treu" is inside "Betreuung": the plain spelling is a whole word only.
+ expect(await query({ text: 'Treue' })).toEqual(['loyal']);
+ });
+
+ it('finds an umlaut an HTML mail wrote as an entity', async () => {
+ const { query } = await storeWith([{
+ id: 'html',
+ subject: 'Hallo',
+ textBody: [],
+ htmlBody: [{ partId: '1', type: 'text/html' }],
+ ...withBody('VorlageGrüße von Frau Müller <Chefin>
'),
+ }]);
+ expect(await query({ body: 'Müller' })).toEqual(['html']);
+ expect(await query({ body: 'grüße' })).toEqual(['html']);
+ expect(await query({ body: 'Vorlage' })).toEqual([]);
+ // Decoded once: the encoded text stays text.
+ expect(await query({ body: '"<Chefin>"' })).toEqual(['html']);
+ });
+
+ it('answers a very common word', async () => {
+ const emails: Email[] = [];
+ for (let i = 0; i < 300; i += 1) emails.push({ subject: 'die Rechnung für die Firma' });
+ const { query } = await storeWith(emails);
+ expect((await query({ text: 'die' })).length).toBe(300);
+ });
+});
+
+describe('hostile input', () => {
+ const HOSTILE = [
+ '"', '""', '"""', '" "', '"unterminated', 'odd " quote', '*', '**', '*foo', 'foo*bar', 'a*b*c*',
+ '-', '--', '-foo', 'foo-', 'foo - bar', ':', 'subject:', 'subject:foo', 'nope:foo', 'from_addr : x',
+ '(', ')', '()', '(foo', 'foo)', '((((', '))))', '(foo OR bar)',
+ 'AND', 'OR', 'NOT', 'NEAR', 'NEAR(a b)', 'NEAR(a b, 2)', 'a AND', 'OR b', 'NOT c', 'a OR OR b',
+ '^', '^foo', '+', 'foo + bar', '{', '{subject body}: foo', '}', '[', ']', '\\', '\\"', "'", "'foo'", '%', '_', ';',
+ "'; DROP TABLE email; --", 'foo\0bar', '\0', '\x01\x02', '\n', '\t', '\ud83d', '\udc00', '🎉', '🎉🎉🎉', '',
+ 'x'.repeat(100_000), '"'.repeat(5001), '('.repeat(5001), 'a '.repeat(5000), '"a" '.repeat(2000), 'a* '.repeat(2000),
+ ];
+
+ it('never throws, for any field and any input', async () => {
+ const { query } = await storeWith([{ subject: 'foo bar' }]);
+ for (const field of ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body']) {
+ for (const input of HOSTILE) {
+ await expect(query({ [field]: input }), `${field}: ${input.slice(0, 30)}`).resolves.toBeInstanceOf(Array);
+ }
+ }
+ });
+
+ it('reads the punctuation as text around the words', async () => {
+ const { query } = await storeWith([
+ { id: 'hit', subject: 'foo bar' },
+ { id: 'miss', subject: 'other words' },
+ ]);
+ for (const input of ['foo - bar', '(foo)', '(foo bar)', 'foo: bar:', '"foo" -', '-foo', 'foo*', 'foo-bar']) {
+ expect(await query({ text: input }), input).toEqual(['hit']);
+ }
+ });
+
+ it('finds nothing, rather than everything, when no term is left', async () => {
+ const { query } = await storeWith([{ subject: 'foo bar' }]);
+ for (const input of ['', ' ', '*', '-', '"', '""', '()', '🎉']) {
+ expect(await query({ text: input }), input).toEqual([]);
+ }
+ });
+
+ it('caps what one value and one filter can ask, widening rather than failing', async () => {
+ const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]);
+ // The terms past the limit are ignored.
+ expect(await query({ text: `${'foo '.repeat(16)} nothere` })).toEqual(['hit']);
+ const many = Array.from({ length: 1000 }, (_, i) => ({ text: i < 32 ? 'foo' : 'nothere' }));
+ expect(await query({ operator: 'AND', conditions: many })).toEqual(['hit']);
+ });
+
+ it('refuses too many terms where dropping one would change the meaning', async () => {
+ const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]);
+ const many = Array.from({ length: 33 }, () => ({ text: 'foo' }));
+ await expect(query({ operator: 'OR', conditions: many })).rejects.toThrow('unsupportedFilter');
+ await expect(query({ operator: 'NOT', conditions: many })).rejects.toThrow('unsupportedFilter');
+ await expect(query({ operator: 'OR', conditions: many.slice(0, 32) })).resolves.toEqual(['hit']);
+ });
+});
+
+describe('SearchSnippet/get', () => {
+ it('marks the terms in the subject and the body, as the server would', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'Die Rechnung', ...withBody('Hier ist die Rechnung von heute.') }]);
+ const { list, notFound } = await snippets({ text: 'rechnung' }, ['one', 'gone']);
+ expect(list).toEqual([{
+ emailId: 'one',
+ subject: 'Die Rechnung',
+ preview: 'Hier ist die Rechnung von heute.',
+ }]);
+ expect(notFound).toEqual(['gone']);
+ });
+
+ it('escapes everything except its own marks', async () => {
+ const hostile = 'Tom & fake it\'s Rechnung & more';
+ const { snippets } = await storeWith([{ id: 'one', subject: hostile, ...withBody(hostile) }]);
+ const { list } = await snippets({ text: 'rechnung' }, ['one']);
+ const escaped = 'Tom & <script>alert("x")</script> <mark>fake</mark> it's Rechnung & more';
+ expect(list[0].subject).toBe(escaped);
+ // The preview starts near the match, and is escaped the same way.
+ expect(list[0].preview).toBe(escaped.slice(escaped.indexOf('<mark>')));
+ // What the list shows: the markup as plain text, one highlighted word.
+ expect(parseSearchSnippet(list[0].subject as string)).toEqual([
+ { text: 'Tom & fake it\'s ', marked: false },
+ { text: 'Rechnung', marked: true },
+ { text: ' & more', marked: false },
+ ]);
+ });
+
+ it('escapes a term that holds markup', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'a c' }]);
+ const { list } = await snippets({ subject: '""' }, ['one']);
+ expect(list[0].subject).toBe('a <b> c');
+ });
+
+ it('marks the original text when diacritics or spelling differ', async () => {
+ const { snippets } = await storeWith([
+ { id: 'umlaut', subject: 'Frau Müller, MÜLLER' },
+ { id: 'written', subject: 'Herr Mueller' },
+ ]);
+ expect((await snippets({ text: 'muller' }, ['umlaut'])).list[0].subject).toBe('Frau Müller, MÜLLER');
+ expect((await snippets({ text: 'Mueller' }, ['umlaut'])).list[0].subject).toBe('Frau Müller, MÜLLER');
+ expect((await snippets({ text: 'Müller' }, ['written'])).list[0].subject).toBe('Herr Mueller');
+ });
+
+ it('marks only where the index would have matched', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'zu zum Stromrechnung Rechnung' }]);
+ expect((await snippets({ subject: 'zu' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung');
+ expect((await snippets({ subject: 'rechnung' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung');
+ expect((await snippets({ subject: 'rech*' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung Rechnung');
+ });
+
+ it('marks the words of the loosened filter the client sends', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'annual and report, budget' }]);
+ const filter = buildJMAPFilter('"annual report" budg*', DEFAULT_SEARCH_FILTERS, 'inbox');
+ const { list } = await snippets(snippetFilterFor(filter), ['one']);
+ expect(list[0].subject).toBe('annual and report, budget');
+ });
+
+ it('gives null for the part with no match, and for filters with nothing to mark', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'Betreff', from: alice, ...withBody('Inhalt hier') }]);
+ expect((await snippets({ text: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: 'Inhalt hier' });
+ expect((await snippets({ text: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: 'Betreff', preview: null });
+ // subject and body only mark their own part.
+ expect((await snippets({ subject: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
+ expect((await snippets({ body: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
+ expect((await snippets({ from: 'alice' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
+ expect((await snippets({ inMailbox: 'inbox' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
+ expect((await snippets(null, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
+ });
+
+ it('does not mark terms the filter excludes', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'foo bar' }]);
+ const { list } = await snippets({ operator: 'AND', conditions: [{ text: 'foo' }, { operator: 'NOT', conditions: [{ text: 'bar' }] }] }, ['one']);
+ expect(list[0].subject).toBe('foo bar');
+ });
+
+ it('keeps a preview within 255 bytes, cuts no entity or mark, and starts near the match', async () => {
+ const before = 'Wörter füllen den Anfang dieser Nachricht ganz ohne Treffer. '.repeat(20);
+ const after = '& < > " \' ü '.repeat(200);
+ const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`${before}\n\nDie Rechnung ${after} Rechnung`) }]);
+ const preview = (await snippets({ body: 'rechnung' }, ['one'])).list[0].preview as string;
+ expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255);
+ expect(preview.length).toBeGreaterThan(200);
+ expect(preview).toContain('Die Rechnung & <');
+ expect(preview.indexOf('')).toBeLessThan(60);
+ // Whatever is left over parses back with no half entity.
+ expect(preview.replace(/<\/?mark>/g, '')).not.toMatch(/&(?!amp;|lt;|gt;|quot;|#39;)/);
+ expect(preview.split('').length).toBe(preview.split('').length);
+ });
+
+ it('never leaves an empty or open mark when the budget runs out on one', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`foo ${'abcd '.repeat(48)}foo foo foo foo foo foo foo foo foo`) }]);
+ const preview = (await snippets({ body: 'foo' }, ['one'])).list[0].preview as string;
+ expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255);
+ expect(preview).not.toContain('');
+ expect(preview.split('').length).toBe(preview.split('').length);
+ });
+
+ it('builds the preview from an HTML body as plain text', async () => {
+ const { snippets } = await storeWith([{
+ id: 'one',
+ subject: 'x',
+ textBody: [],
+ htmlBody: [{ partId: '1', type: 'text/html' }],
+ ...withBody('Ihre Rechnung von Müller
'),
+ }]);
+ expect((await snippets({ body: 'rechnung' }, ['one'])).list[0].preview).toBe('Ihre Rechnung von Müller');
+ });
+
+ it('works chained onto the query, as the client asks for it', async () => {
+ const { engine } = await storeWith([{ id: 'one', subject: 'Rechnung' }, { id: 'two', subject: 'Hallo' }]);
+ const answer = await engine.answer([
+ ['Email/query', { filter: { text: 'rechnung' } }, '0'],
+ ['SearchSnippet/get', { filter: { text: 'rechnung' }, '#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' } }, 'snippets'],
+ ]);
+ expect(answer?.methodResponses[1][0]).toBe('SearchSnippet/get');
+ expect(answer?.methodResponses[1][1].list).toEqual([{ emailId: 'one', subject: 'Rechnung', preview: null }]);
+ });
+
+ it('answers per-call errors for bad arguments', async () => {
+ const { engine } = await storeWith([]);
+ const answer = await engine.answer([
+ ['SearchSnippet/get', { filter: { text: 'x' } }, 'a'],
+ ['SearchSnippet/get', { filter: { text: 'x' }, emailIds: Array.from({ length: 501 }, (_, i) => `e${i}`) }, 'b'],
+ ['Mailbox/query', {}, 'c'],
+ ]);
+ expect(answer?.methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'invalidArguments' }), 'a']);
+ expect(answer?.methodResponses[1]).toEqual(['error', expect.objectContaining({ type: 'requestTooLarge' }), 'b']);
+ });
+
+ it('survives hostile search terms', async () => {
+ const { snippets } = await storeWith([{ id: 'one', subject: 'foo (bar) [baz]', ...withBody('foo (bar) [baz] \\ ^ $ . * + ? | /') }]);
+ for (const text of ['(', ')', '[', ']', '\\', '^', '$', '.', '*', '+', '?', '|', '/', '(bar)', '[baz', 'a|b', '.*', '(?', '\ud83d', 'foo\0bar']) {
+ await expect(snippets({ text }, ['one']), text).resolves.toHaveProperty('list');
+ }
+ expect((await snippets({ text: '.*' }, ['one'])).list[0].preview).toBeNull();
+ expect((await snippets({ text: '(bar)' }, ['one'])).list[0].subject).toBe('foo (bar) [baz]');
+ });
+});
diff --git a/lib/store/mail/index.ts b/lib/store/mail/index.ts
index e76d61d28..0c7749864 100644
--- a/lib/store/mail/index.ts
+++ b/lib/store/mail/index.ts
@@ -1,11 +1,12 @@
import type { StoreArea } from '../area';
import { MAIL_METHODS } from './methods';
import { MAIL_MIGRATIONS } from './schema';
+import { SEARCH_MIGRATIONS } from './search-schema';
import { mailSyncer } from './sync';
export const mailArea: StoreArea = {
name: 'mail',
- migrations: MAIL_MIGRATIONS,
+ migrations: [...MAIL_MIGRATIONS, ...SEARCH_MIGRATIONS],
methods: MAIL_METHODS,
syncers: [mailSyncer],
};
diff --git a/lib/store/mail/methods.ts b/lib/store/mail/methods.ts
index e91bdd45c..34a191df5 100644
--- a/lib/store/mail/methods.ts
+++ b/lib/store/mail/methods.ts
@@ -3,14 +3,12 @@
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
import { listObjects } from '../engine/core-schema';
import type { SqlValue } from '../storage';
-import { textCondition, type TextField } from './search';
+import { limitTextConditions, searchSnippetGet, textCondition, TEXT_FIELDS, type TextField } from './search';
type Filter = Record;
interface Sql { sql: string; params: SqlValue[] }
-const TEXT_FIELDS: readonly TextField[] = ['text', 'subject', 'from', 'to', 'body'];
-
function jmapOf(ctx: MethodContext, args: Record): string {
return (args.accountId as string | undefined) ?? ctx.defaultJmap;
}
@@ -78,7 +76,7 @@ function filterSql(filter: Filter, jmap: string): Sql {
params.push(...text.params);
break;
}
- // cc, bcc, header and the thread keyword filters: the server's job.
+ // header and the thread keyword filters: the server's job.
throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
}
}
@@ -132,7 +130,7 @@ function drivingMailbox(filter: Filter | undefined): string | null {
const emailQuery: MethodHandler = (ctx, args) => {
const jmap = jmapOf(ctx, args);
- const filter = (args.filter ?? undefined) as Filter | undefined;
+ const filter = limitTextConditions((args.filter ?? undefined) as Filter | undefined);
if (args.anchor !== undefined) throw new MethodError('unsupportedFilter', 'anchor needs the server');
const where = filter ? filterSql(filter, jmap) : { sql: '1', params: [] };
const order = orderSql(ctx, (args.sort as Comparator[]) ?? []);
@@ -270,4 +268,5 @@ export const MAIL_METHODS: Readonly> = {
'Thread/get': threadGet,
'Mailbox/get': mailboxGet,
'Mailbox/query': mailboxQuery,
+ 'SearchSnippet/get': searchSnippetGet,
};
diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts
index b0e292718..f8496fb9c 100644
--- a/lib/store/mail/records.ts
+++ b/lib/store/mail/records.ts
@@ -101,6 +101,20 @@ export function deleteEmailRow(db: StorageAdapter, rowid: number): void {
db.run('DELETE FROM email WHERE rowid = ?', [rowid]);
}
+/** German HTML mail often writes its umlauts as entities: unread, "Müller" would never be found. */
+const NAMED_ENTITIES: Record = {
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', shy: '',
+ auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß',
+ eacute: 'é', egrave: 'è', agrave: 'à', ccedil: 'ç', ndash: '-', mdash: '-', hellip: '...', euro: '€',
+ bdquo: '"', ldquo: '"', rdquo: '"', lsquo: "'", rsquo: "'",
+};
+
+function decodeEntity(whole: string, name: string): string {
+ if (name[0] !== '#') return NAMED_ENTITIES[name] ?? whole;
+ const code = /^#x/i.test(name) ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
+ return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
+}
+
/** Plain text of a message for the search index: the text part, or the HTML part without its tags. */
export function bodyText(email: Record): string {
const values = (email.bodyValues ?? {}) as Record;
@@ -113,25 +127,25 @@ export function bodyText(email: Record): string {
return parts('htmlBody')
.map((part) => values[part.partId ?? '']?.value ?? '')
.join('\n')
- .replace(/<(style|script)[\s\S]*?<\/\1>/gi, ' ')
+ .replace(//g, ' ')
+ .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
- .replace(/ /g, ' ')
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
+ .replace(/&(#\d+|#x[\da-f]+|[a-z]+\d*);/gi, decodeEntity)
.replace(/\s+/g, ' ');
}
/** A long message past this is rare and only bloats the index. */
-const INDEX_BODY_CHARS = 200_000;
+export const INDEX_BODY_CHARS = 200_000;
export function indexEmail(db: StorageAdapter, rowid: number, email: Record, body: string): void {
const subject = typeof email.subject === 'string' ? email.subject : '';
const from = addressText(email.from);
- const to = [addressText(email.to), addressText(email.cc), addressText(email.bcc)].filter(Boolean).join(' ');
+ const to = addressText(email.to);
+ const cc = addressText(email.cc);
+ const bcc = addressText(email.bcc);
const text = body.slice(0, INDEX_BODY_CHARS);
for (const table of ['email_fts_word', 'email_fts_tri']) {
- db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, body) VALUES (?, ?, ?, ?, ?)`, [rowid, subject, from, to, text]);
+ db.run(`INSERT INTO ${table} (rowid, subject, from_addr, to_addr, cc_addr, bcc_addr, body) VALUES (?, ?, ?, ?, ?, ?, ?)`, [rowid, subject, from, to, cc, bcc, text]);
}
}
diff --git a/lib/store/mail/search-schema.ts b/lib/store/mail/search-schema.ts
new file mode 100644
index 000000000..d30613b72
--- /dev/null
+++ b/lib/store/mail/search-schema.ts
@@ -0,0 +1,38 @@
+import type { Migration } from '../storage';
+
+const FTS_COLUMNS = 'subject, from_addr, to_addr, cc_addr, bcc_addr, body';
+
+/**
+ * Gives To, Cc and Bcc their own index columns. `to` used to find Cc and Bcc
+ * too, and `cc` (the client's recipient search asks for it) had no answer.
+ *
+ * FTS5 can't add a column and contentless tables have nothing to copy, so the
+ * indexes are made again and the mail syncs again to fill them. No released
+ * version has data here: this only touches a development database.
+ */
+export const SEARCH_MIGRATIONS: readonly Migration[] = [
+ {
+ id: 300,
+ name: 'search-address-columns',
+ sql: `
+ DROP TABLE email_fts_word;
+ DROP TABLE email_fts_tri;
+ CREATE VIRTUAL TABLE email_fts_word USING fts5(
+ ${FTS_COLUMNS},
+ content = '', contentless_delete = 1,
+ tokenize = 'unicode61 remove_diacritics 2'
+ );
+ CREATE VIRTUAL TABLE email_fts_tri USING fts5(
+ ${FTS_COLUMNS},
+ content = '', contentless_delete = 1,
+ tokenize = 'trigram remove_diacritics 1'
+ );
+ DELETE FROM email_keyword;
+ DELETE FROM email_mailbox;
+ DELETE FROM email_body;
+ DELETE FROM thread;
+ DELETE FROM email;
+ DELETE FROM sync_state WHERE type IN ('Email', 'Thread');
+ `,
+ },
+];
diff --git a/lib/store/mail/search.ts b/lib/store/mail/search.ts
index 57b94f849..dba103b4d 100644
--- a/lib/store/mail/search.ts
+++ b/lib/store/mail/search.ts
@@ -1,59 +1,398 @@
-/** Full-text matching over the two FTS indexes. */
+/**
+ * Full-text matching and SearchSnippet/get over the two FTS indexes.
+ *
+ * What a search value means here (the shapes buildJMAPFilter sends):
+ * - `rechnung`: a word of three or more characters is found anywhere, also
+ * inside a longer word, through the trigram index ("Rechnung" in
+ * "Zahlungserinnerungsrechnung").
+ * - `zu`: one or two characters can't use trigrams, so only the whole word
+ * matches, through the word index. "zu" doesn't find "zum".
+ * - `rech*`: the start of a word, through the word index. It needs two
+ * characters: `a*` is the word "a", since reading every word that starts
+ * with one letter is the most expensive thing FTS5 can be asked.
+ * - `"annual report"`: these exact words in this order, through the word index.
+ * - Several words have to all match, each in any field `text` covers. That's a
+ * little wider than Stalwart, which wants them in one field.
+ * - Case and diacritics don't count: Müller and muller find each other.
+ *
+ * Input never reaches FTS5 as syntax: every term goes in as a quoted string, so
+ * `"`, `*`, `-`, `:` and parentheses are text. A term without a letter or digit
+ * has nothing to search for and is left out; a value left with no terms matches
+ * nothing, because leaving it out would widen the search to every message.
+ *
+ * Limits: MAX_TERMS per value, MAX_TERM_CHARS per term, MAX_TEXT_CONDITIONS per
+ * filter. Past them the extra terms are ignored, which only widens the result,
+ * so a pasted page of text still gets an answer and can't keep the worker busy.
+ * A very common word costs one read of its list in each index, at most the size
+ * of the account.
+ */
+import { MethodError, type MethodHandler } from '../area';
import type { SqlValue } from '../storage';
+import { bodyText, INDEX_BODY_CHARS } from './records';
-export type TextField = 'text' | 'subject' | 'from' | 'to' | 'body';
+export const TEXT_FIELDS = ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body'] as const;
+export type TextField = (typeof TEXT_FIELDS)[number];
const COLUMNS: Record = {
text: null,
subject: 'subject',
from: 'from_addr',
to: 'to_addr',
+ cc: 'cc_addr',
+ bcc: 'bcc_addr',
body: 'body',
};
+const MAX_INPUT_CHARS = 8192;
+const MAX_TERMS = 16;
+const MAX_TERM_CHARS = 200;
+const MAX_TEXT_CONDITIONS = 32;
+const MIN_TRIGRAM_CHARS = 3;
+const MIN_PREFIX_CHARS = 2;
+const MIN_PLAIN_SPELLING_CHARS = 5;
+const MAX_SNIPPET_IDS = 500;
+/** RFC 8621 §5: a preview is at most 255 octets. */
+const PREVIEW_BYTES = 255;
+const PREVIEW_CONTEXT_CHARS = 40;
+
+const WORD_CHAR = '[\\p{L}\\p{N}\\p{M}]';
+const NOT_WORD_CHAR = '[^\\p{L}\\p{N}\\p{M}]';
+const EDGE_PUNCTUATION = new RegExp(`^${NOT_WORD_CHAR}+|${NOT_WORD_CHAR}+$`, 'gu');
+
+interface Term {
+ needle: string;
+ kind: 'word' | 'prefix' | 'phrase';
+}
+
+/** One lookup in one index. A term matches when any of its lookups does. */
+interface Probe {
+ index: 'word' | 'tri';
+ text: string;
+ prefix: boolean;
+}
+
+function chars(text: string): number {
+ return [...text].length;
+}
+
+function parseTerms(input: string): Term[] {
+ const clean = input.slice(0, MAX_INPUT_CHARS).normalize('NFC').replace(/\p{Cc}/gu, ' ');
+ const terms: Term[] = [];
+ for (const match of clean.matchAll(/"([^"]*)"|(\S+)/g)) {
+ const quoted = match[1] !== undefined;
+ const raw = match[1] ?? match[2];
+ const needle = [...raw.replace(EDGE_PUNCTUATION, '')].slice(0, MAX_TERM_CHARS).join('');
+ if (!needle) continue;
+ terms.push({ needle, kind: quoted ? 'phrase' : raw.endsWith('*') ? 'prefix' : 'word' });
+ if (terms.length === MAX_TERMS) break;
+ }
+ return terms;
+}
+
+/**
+ * The other German spellings of a term, worked out when searching so the
+ * indexes stay as they are. The indexes store ü as u, so:
+ * - Müller also looks for "mueller", the way the term itself is looked for;
+ * - Mueller also looks for "muller", as a whole word only: Treue must not find
+ * "Betreuung" through "treu". Short words stay out for the same reason (Poet,
+ * blue, neue).
+ * ß and ss work the same way.
+ */
+function spellings(needle: string): { written: string | null; plain: string | null } {
+ const lower = needle.toLowerCase();
+ const written = lower.replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c] as string);
+ const plain = chars(lower) >= MIN_PLAIN_SPELLING_CHARS
+ ? lower.replace(/ae|oe|ue/g, (pair) => pair[0]).replace(/ss/g, 'ß')
+ : lower;
+ return { written: written === lower ? null : written, plain: plain === lower ? null : plain };
+}
+
+function probesFor(term: Term): Probe[] {
+ const probes: Probe[] = [];
+ const addWord = (text: string) => {
+ probes.push({ index: 'word', text, prefix: term.kind === 'prefix' && chars(text) >= MIN_PREFIX_CHARS });
+ };
+ const addAll = (text: string) => {
+ addWord(text);
+ if (term.kind === 'word' && chars(text) >= MIN_TRIGRAM_CHARS) probes.push({ index: 'tri', text, prefix: false });
+ };
+ addAll(term.needle);
+ const { written, plain } = spellings(term.needle);
+ if (written) addAll(written);
+ if (plain) addWord(plain);
+ return probes;
+}
+
function quote(term: string): string {
return `"${term.replace(/"/g, '""')}"`;
}
-/** The terms of a search box value: quoted phrases stay whole, `word*` keeps its star. */
-export function splitTerms(input: string): string[] {
- const out: string[] = [];
- for (const match of input.matchAll(/"([^"]+)"|(\S+)/g)) out.push(match[1] ?? match[2]);
- return out;
-}
-
-/**
- * One term as SQL over rowids. The word index finds words and word starts,
- * the trigram index finds a term inside a longer word, which is how German
- * compounds are found ("Rechnung" in "Zahlungserinnerungsrechnung").
- */
-function termRowids(field: TextField, term: string): { sql: string; params: SqlValue[] } {
- const column = COLUMNS[field];
- const star = term.endsWith('*');
- const clean = star ? term.slice(0, -1) : term;
- const scope = column ? `${column} : ` : '';
- const word = `${scope}${quote(clean)}${' *'.slice(1)}`;
- const parts = ['SELECT rowid FROM email_fts_word WHERE email_fts_word MATCH ?'];
- const params: SqlValue[] = [word];
- // Trigrams need three characters. Shorter terms use the word index alone.
- if ([...clean].length >= 3) {
- parts.push('SELECT rowid FROM email_fts_tri WHERE email_fts_tri MATCH ?');
- params.push(`${scope}${quote(clean)}`);
- }
- return { sql: parts.join(' UNION '), params };
-}
-
/** SQL for a text condition, as a condition on `e.rowid`. Every term has to match. */
export function textCondition(field: TextField, input: string): { sql: string; params: SqlValue[] } {
- const terms = splitTerms(input);
- if (terms.length === 0) return { sql: '1', params: [] };
+ const terms = parseTerms(input);
+ if (terms.length === 0) return { sql: '0', params: [] };
+ const column = COLUMNS[field];
+ const scope = column ? `${column} : ` : '';
const conditions: string[] = [];
const params: SqlValue[] = [];
for (const term of terms) {
- const part = termRowids(field, term);
- conditions.push(`e.rowid IN (${part.sql})`);
- params.push(...part.params);
+ const selects: string[] = [];
+ for (const probe of probesFor(term)) {
+ const table = probe.index === 'word' ? 'email_fts_word' : 'email_fts_tri';
+ selects.push(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`);
+ params.push(`${scope}${quote(probe.text)}${probe.prefix ? '*' : ''}`);
+ }
+ conditions.push(`e.rowid IN (${selects.join(' UNION ')})`);
}
return { sql: conditions.join(' AND '), params };
}
+
+// ---- Limiting a whole filter ----
+
+type Filter = Record;
+
+function isOperator(filter: Filter): boolean {
+ return typeof filter.operator === 'string';
+}
+
+function conditionsOf(filter: Filter): Filter[] {
+ return Array.isArray(filter.conditions) ? filter.conditions as Filter[] : [];
+}
+
+function textKeysOf(filter: Filter): TextField[] {
+ return TEXT_FIELDS.filter((field) => field in filter);
+}
+
+function countTextConditions(filter: Filter): number {
+ if (isOperator(filter)) return conditionsOf(filter).reduce((sum, condition) => sum + countTextConditions(condition), 0);
+ return textKeysOf(filter).length;
+}
+
+/**
+ * Keeps a filter within MAX_TEXT_CONDITIONS. Under AND a text condition past
+ * the limit is dropped, which only widens the result. Under OR or NOT that
+ * would narrow it or turn it around, so such a branch has to fit or the call
+ * gets an error.
+ */
+export function limitTextConditions(filter: Filter | undefined): Filter | undefined {
+ if (!filter) return filter;
+ let left = MAX_TEXT_CONDITIONS;
+ const walk = (node: Filter): Filter | null => {
+ if (isOperator(node)) {
+ if (node.operator === 'AND') {
+ const kept = conditionsOf(node).map(walk).filter((condition): condition is Filter => condition !== null);
+ return kept.length > 0 || conditionsOf(node).length === 0 ? { ...node, conditions: kept } : null;
+ }
+ const count = countTextConditions(node);
+ if (count > left) throw new MethodError('unsupportedFilter', 'This search has too many terms');
+ left -= count;
+ return node;
+ }
+ const keys = textKeysOf(node);
+ if (keys.length <= left) {
+ left -= keys.length;
+ return node;
+ }
+ const kept = { ...node };
+ for (const key of keys.slice(left)) delete kept[key];
+ left = 0;
+ return Object.keys(kept).length > 0 ? kept : null;
+ };
+ return walk(filter) ?? {};
+}
+
+// ---- SearchSnippet/get ----
+
+const FOLD_CACHE = new Map();
+
+function foldChar(ch: string): string {
+ const code = ch.charCodeAt(0);
+ if (code < 128) return code >= 65 && code <= 90 ? String.fromCharCode(code + 32) : ch;
+ let folded = FOLD_CACHE.get(ch);
+ if (folded === undefined) {
+ folded = ch.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
+ if (FOLD_CACHE.size < 4096) FOLD_CACHE.set(ch, folded);
+ }
+ return folded;
+}
+
+function fold(text: string): string {
+ let out = '';
+ for (const ch of text) out += foldChar(ch);
+ return out;
+}
+
+/** Text without case and diacritics, and where each of its positions came from. */
+function foldWithMap(text: string): { folded: string; map: number[] } {
+ const map: number[] = [];
+ let folded = '';
+ let at = 0;
+ for (const ch of text) {
+ const piece = foldChar(ch);
+ for (let i = 0; i < piece.length; i += 1) map.push(at);
+ folded += piece;
+ at += ch.length;
+ }
+ map.push(text.length);
+ return { folded, map };
+}
+
+function escapeRegExp(text: string): string {
+ return text.replace(/[\\^$.*+?()[\]{}|/]/g, '\\$&');
+}
+
+/** What a probe finds, as a pattern over folded text, so a mark lands where the index matched. */
+function probePattern(probe: Probe): string | null {
+ const text = fold(probe.text);
+ if (probe.index === 'tri') return escapeRegExp(text);
+ const words = text.split(new RegExp(`${NOT_WORD_CHAR}+`, 'u')).filter(Boolean).map(escapeRegExp);
+ if (words.length === 0) return null;
+ return `(?();
+ for (const term of terms) {
+ for (const probe of probesFor(term)) {
+ const pattern = probePattern(probe);
+ if (pattern) patterns.add(pattern);
+ }
+ }
+ if (patterns.size === 0) return null;
+ // Longest first, so a match isn't cut short by a shorter spelling of it.
+ return new RegExp([...patterns].sort((a, b) => b.length - a.length).join('|'), 'gu');
+}
+
+/** The terms a snippet marks. Anything under NOT was searched to be absent, so it isn't marked. */
+function snippetTerms(filter: Filter | null | undefined): { subject: Term[]; body: Term[] } {
+ const subject: Term[] = [];
+ const body: Term[] = [];
+ let left = MAX_TEXT_CONDITIONS;
+ const walk = (node: Filter) => {
+ if (isOperator(node)) {
+ if (node.operator !== 'NOT') conditionsOf(node).forEach(walk);
+ return;
+ }
+ for (const key of ['text', 'subject', 'body'] as const) {
+ const value = node[key];
+ if (typeof value !== 'string' || left === 0) continue;
+ left -= 1;
+ const terms = parseTerms(value);
+ if (key !== 'body') subject.push(...terms);
+ if (key !== 'subject') body.push(...terms);
+ }
+ };
+ if (filter) walk(filter);
+ return { subject, body };
+}
+
+const ENTITIES: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+
+function escapeHtml(text: string): string {
+ return text.replace(/[&<>"']/g, (c) => ENTITIES[c]);
+}
+
+function utf8Bytes(text: string): number {
+ let bytes = 0;
+ for (const ch of text) {
+ const code = ch.codePointAt(0) as number;
+ bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
+ }
+ return bytes;
+}
+
+/** The whole text, escaped, with its matches marked; null when nothing matches. */
+function markAll(text: string, pattern: RegExp): string | null {
+ const { folded, map } = foldWithMap(text);
+ let out = '';
+ let at = 0;
+ for (const match of folded.matchAll(pattern)) {
+ const start = map[match.index];
+ const end = map[match.index + match[0].length];
+ out += `${escapeHtml(text.slice(at, start))}${escapeHtml(text.slice(start, end))}`;
+ at = end;
+ }
+ return out === '' ? null : out + escapeHtml(text.slice(at));
+}
+
+/** A section around the first match, escaped and marked, never past PREVIEW_BYTES; null when nothing matches. */
+function excerpt(raw: string, pattern: RegExp): string | null {
+ const text = raw.replace(/\s+/g, ' ').trim();
+ const { folded, map } = foldWithMap(text);
+ let out = '';
+ let used = 0;
+ // Stops before the character that wouldn't fit, keeping room for what must still follow.
+ const put = (segment: string, reserve: number): boolean => {
+ for (const ch of segment) {
+ const piece = escapeHtml(ch);
+ const size = utf8Bytes(piece);
+ if (used + size + reserve > PREVIEW_BYTES) return false;
+ out += piece;
+ used += size;
+ }
+ return true;
+ };
+ let at = -1;
+ for (const match of folded.matchAll(pattern)) {
+ const start = map[match.index];
+ const end = map[match.index + match[0].length];
+ if (at < 0) {
+ const earliest = Math.max(0, start - PREVIEW_CONTEXT_CHARS);
+ const space = earliest === 0 ? -1 : text.indexOf(' ', earliest);
+ at = earliest === 0 ? 0 : space >= 0 && space < start ? space + 1 : start;
+ }
+ if (!put(text.slice(at, start), 0)) return out;
+ const before = out;
+ const usedBefore = used;
+ out += '';
+ used += ''.length;
+ const opened = out.length;
+ const whole = put(text.slice(start, end), ''.length);
+ if (out.length === opened) {
+ // Not even one character fit: no empty mark.
+ out = before;
+ used = usedBefore;
+ return out;
+ }
+ out += '';
+ used += ''.length;
+ if (!whole) return out;
+ at = end;
+ }
+ if (at < 0) return null;
+ put(text.slice(at), 0);
+ return out;
+}
+
+/** The indexes are contentless, so snippets come from the stored message itself. */
+export const searchSnippetGet: MethodHandler = (ctx, args) => {
+ const jmap = (args.accountId as string | undefined) ?? ctx.defaultJmap;
+ const ids = args.emailIds;
+ if (!Array.isArray(ids)) throw new MethodError('invalidArguments', 'emailIds is required');
+ if (ids.length > MAX_SNIPPET_IDS) throw new MethodError('requestTooLarge', `At most ${MAX_SNIPPET_IDS} messages at a time`);
+ const terms = snippetTerms(args.filter as Filter | null | undefined);
+ const subjectPattern = highlighter(terms.subject);
+ const bodyPattern = highlighter(terms.body);
+ const list: Array<{ emailId: string; subject: string | null; preview: string | null }> = [];
+ const notFound: string[] = [];
+ for (const id of ids as string[]) {
+ const row = ctx.db.get<{ rowid: number; json: string }>('SELECT rowid, json FROM email WHERE jmap = ? AND id = ?', [jmap, id]);
+ if (!row) {
+ notFound.push(id);
+ continue;
+ }
+ const email = JSON.parse(row.json) as { subject?: unknown; preview?: unknown };
+ const subject = subjectPattern && typeof email.subject === 'string' ? markAll(email.subject, subjectPattern) : null;
+ let preview: string | null = null;
+ if (bodyPattern) {
+ const body = ctx.db.get<{ json: string }>('SELECT json FROM email_body WHERE email_rowid = ?', [row.rowid]);
+ // Before its body has arrived, a message only has its preview to show.
+ const text = body ? bodyText(JSON.parse(body.json) as Record) : '';
+ const source = text.trim() ? text.slice(0, INDEX_BODY_CHARS) : typeof email.preview === 'string' ? email.preview : '';
+ preview = excerpt(source, bodyPattern);
+ }
+ list.push({ emailId: id, subject, preview });
+ }
+ return { accountId: jmap, list, notFound };
+};
From a4451dc900f16600fed5aa50791502a3c7f4bf73 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 05:31:35 +0100
Subject: [PATCH 3/9] feat: add results from the local store to online searches
---
lib/jmap/__tests__/search-merge.test.ts | 523 ++++++++++++++++++++++++
lib/jmap/client.ts | 205 +++++-----
lib/jmap/offline-hooks.ts | 6 +
lib/jmap/search-merge.ts | 203 +++++++++
4 files changed, 838 insertions(+), 99 deletions(-)
create mode 100644 lib/jmap/__tests__/search-merge.test.ts
create mode 100644 lib/jmap/search-merge.ts
diff --git a/lib/jmap/__tests__/search-merge.test.ts b/lib/jmap/__tests__/search-merge.test.ts
new file mode 100644
index 000000000..4bfa9135b
--- /dev/null
+++ b/lib/jmap/__tests__/search-merge.test.ts
@@ -0,0 +1,523 @@
+// @vitest-environment node
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { DEFAULT_SCOPE } from '@/lib/store/area';
+import { AccountEngine } from '@/lib/store/engine/account-engine';
+import { mailArea } from '@/lib/store/mail';
+import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap';
+import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter';
+import { JMAPClient } from '../client';
+import type { SearchEmailsResult } from '../client-interface';
+import { setOfflineHooks, type OfflineHooks } from '../offline-hooks';
+import { filterHasTextTerms, LocalSearchMerge } from '../search-merge';
+import type { Email } from '../types';
+
+function mail(id: string, minute: number, extra: Partial = {}): Email {
+ return { id, receivedAt: new Date(Date.UTC(2026, 9, 1, 0, minute)).toISOString(), ...extra } as Email;
+}
+
+/** A side of the search: the whole result list, answered a page at a time, newest first. */
+function side(rows: Email[], total: number = rows.length) {
+ const asked: number[] = [];
+ const answer = async (position: number, limit: number): Promise => {
+ asked.push(position);
+ const emails = rows.slice(position, position + limit);
+ return { emails, total, hasMore: position + emails.length < total };
+ };
+ return { asked, answer };
+}
+
+function pager(server: ReturnType, local: ReturnType | null, limit: number) {
+ const merge = new LocalSearchMerge();
+ return (position: number) => merge.run({
+ key: 'search',
+ limit,
+ position,
+ server: (at) => server.answer(at, limit),
+ local: async (at) => (local ? local.answer(at, limit) : null),
+ });
+}
+
+const ids = (result: SearchEmailsResult) => result.emails.map((email) => email.id);
+
+describe('merging one page', () => {
+ it('sorts newest first and has no duplicates', async () => {
+ const server = side([mail('s4', 40), mail('both3', 30), mail('s1', 10)]);
+ const local = side([mail('l5', 50), mail('both3', 30), mail('l2', 20)]);
+ const result = await pager(server, local, 50)(0);
+ expect(ids(result)).toEqual(['l5', 's4', 'both3', 'l2', 's1']);
+ expect(result.hasMore).toBe(false);
+ expect(result.total).toBe(5);
+ });
+
+ it('keeps the server copy when both have a message', async () => {
+ const server = side([mail('both', 10, { subject: 'server copy' })]);
+ const local = side([mail('both', 10, { subject: 'local copy' })]);
+ const result = await pager(server, local, 50)(0);
+ expect(result.emails.map((email) => email.subject)).toEqual(['server copy']);
+ expect(result.total).toBe(1);
+ });
+
+ it('keeps the server copy when the local row sorts earlier than it', async () => {
+ // The local copy is out of date and looks newer: still one row, the server's.
+ const server = side([mail('s2', 20), mail('both', 10, { subject: 'server copy' })]);
+ const local = side([mail('both', 30, { subject: 'local copy' })]);
+ const result = await pager(server, local, 50)(0);
+ expect(ids(result)).toEqual(['s2', 'both']);
+ expect(result.emails[1].subject).toBe('server copy');
+ });
+
+ it('passes on what the server added to its result, such as its deferred snippets', async () => {
+ const snippets = Promise.resolve([]);
+ const merge = new LocalSearchMerge();
+ const result = await merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false, snippets }),
+ local: async () => ({ emails: [mail('l', 2)], total: 1, hasMore: false }),
+ });
+ expect(result.snippets).toBe(snippets);
+ });
+});
+
+describe('paging', () => {
+ it('walks both lists with no row lost or repeated, counting by merged rows', async () => {
+ const serverRows = Array.from({ length: 25 }, (_, i) => mail(`s${i}`, 1000 - i * 10));
+ const localOnly = [mail('l-a', 995), mail('l-b', 905), mail('l-c', 805), mail('l-d', 500)];
+ const server = side(serverRows);
+ const local = side([...localOnly, serverRows[1], serverRows[12]].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt)));
+ const page = pager(server, local, 10);
+
+ const seen: string[] = [];
+ let result = await page(0);
+ seen.push(...ids(result));
+ expect(result.emails.length).toBe(10);
+ expect(result.hasMore).toBe(true);
+ let guard = 0;
+ while (result.hasMore && guard < 10) {
+ // As the store does: the next position is how many rows it holds.
+ result = await page(seen.length);
+ seen.push(...ids(result));
+ guard += 1;
+ }
+ expect(seen.length).toBe(29);
+ expect(new Set(seen).size).toBe(29);
+ expect(seen).toEqual(expect.arrayContaining([...serverRows.map((e) => e.id), ...localOnly.map((e) => e.id)]));
+ const times = [...serverRows, ...localOnly].sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt)).map((e) => e.id);
+ expect(seen).toEqual(times);
+ expect(result.total).toBe(29);
+ });
+
+ it('asks the server for rows a local row pushed off the page', async () => {
+ const server = side([mail('s3', 30), mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l4', 40)]);
+ const page = pager(server, local, 2);
+ expect(ids(await page(0))).toEqual(['l4', 's3']);
+ expect(server.asked).toEqual([0]);
+ // Merged position 2, but the server has only given one row so far.
+ expect(ids(await page(2))).toEqual(['s2', 's1']);
+ expect(server.asked).toEqual([0, 1]);
+ });
+
+ it('holds back a local row older than what the server has sent, in case the server has it later', async () => {
+ const serverRows = [mail('s4', 40), mail('s3', 30), mail('both', 20), mail('s1', 10)];
+ const server = side(serverRows);
+ const local = side([mail('both', 20)]);
+ const page = pager(server, local, 2);
+ const first = await page(0);
+ expect(ids(first)).toEqual(['s4', 's3']);
+ const second = await page(2);
+ expect(ids(second)).toEqual(['both', 's1']);
+ expect(second.hasMore).toBe(false);
+ expect(second.total).toBe(4);
+ });
+
+ it('keeps paging through local rows when the server has none', async () => {
+ const server = side([]);
+ const local = side([mail('l3', 30), mail('l2', 20), mail('l1', 10)]);
+ const page = pager(server, local, 2);
+ const first = await page(0);
+ expect(ids(first)).toEqual(['l3', 'l2']);
+ expect(first.hasMore).toBe(true);
+ expect(first.total).toBe(3);
+ const second = await page(2);
+ expect(ids(second)).toEqual(['l1']);
+ expect(second.hasMore).toBe(false);
+ expect(second.total).toBe(3);
+ });
+
+ it('keeps total at or above what has been returned and what either side knows of', async () => {
+ const server = side([mail('s2', 20), mail('s1', 10)], 40);
+ const local = side([mail('l3', 30)], 50);
+ const result = await pager(server, local, 2)(0);
+ expect(result.total).toBe(50);
+ const noLocalLead = await pager(side([mail('s2', 20)], 40), side([mail('l3', 30)], 1), 2)(0);
+ expect(noLocalLead.total).toBe(41);
+ });
+
+ it('never repeats a local row the server later returns', async () => {
+ // Same time on both sides, split by the page edge: it shows once.
+ const server = side([mail('s', 20), mail('tie', 20)]);
+ const local = side([mail('tie', 20), mail('l', 5)]);
+ const page = pager(server, local, 1);
+ const seen = [...ids(await page(0))];
+ seen.push(...ids(await page(seen.length)));
+ seen.push(...ids(await page(seen.length)));
+ expect(seen).toEqual(['s', 'tie', 'l']);
+ });
+
+ it('drops a row the server returns later if the store already showed it, and holds older local rows until the server catches up', async () => {
+ // The local copy of x looks newer than the server's, so it goes out first.
+ const server = side([mail('a', 30), mail('b', 25), mail('x', 20), mail('c', 10)]);
+ const local = side([mail('x', 40), mail('l', 5)]);
+ const page = pager(server, local, 2);
+ const first = await page(0);
+ expect(ids(first)).toEqual(['x', 'a']);
+ // The server's page is b and x. x has been shown, and the server has more,
+ // so l can't be placed yet: it may be on the server's next page.
+ const second = await page(2);
+ expect(ids(second)).toEqual(['b']);
+ expect(second.hasMore).toBe(true);
+ const third = await page(3);
+ expect(ids(third)).toEqual(['c', 'l']);
+ expect(third.hasMore).toBe(false);
+ });
+
+ it('follows a caller whose position has moved since the last page', async () => {
+ const server = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l5', 50)]);
+ const page = pager(server, local, 2);
+ await page(0);
+ // The caller added a row of its own to the list, as plugins can: one past.
+ await page(3);
+ expect(server.asked).toEqual([0, 2]);
+ });
+
+ it('starts over at position 0', async () => {
+ const server = side([mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l3', 30)]);
+ const page = pager(server, local, 2);
+ await page(0);
+ expect(ids(await page(0))).toEqual(['l3', 's2']);
+ });
+
+ it('sends a position it has no cursor for to the server alone', async () => {
+ const server = side([mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l3', 30)]);
+ const result = await pager(server, local, 2)(1);
+ expect(ids(result)).toEqual(['s1']);
+ expect(local.asked).toEqual([]);
+ });
+
+ it('keeps searches apart by key', async () => {
+ const merge = new LocalSearchMerge();
+ const server = (rows: Email[]) => async (at: number) => ({ emails: rows.slice(at, at + 1), total: rows.length, hasMore: at + 1 < rows.length });
+ const a = { key: 'a', limit: 1, server: server([mail('a2', 20), mail('a1', 10)]), local: async () => ({ emails: [mail('la', 30)], total: 1, hasMore: false }) };
+ const b = { key: 'b', limit: 1, server: server([mail('b1', 10)]), local: async () => null };
+ expect(ids(await merge.run({ ...a, position: 0 }))).toEqual(['la']);
+ expect(ids(await merge.run({ ...b, position: 0 }))).toEqual(['b1']);
+ expect(ids(await merge.run({ ...a, position: 1 }))).toEqual(['a2']);
+ });
+});
+
+describe('errors', () => {
+ it('keeps the server result when the store fails', async () => {
+ const merge = new LocalSearchMerge();
+ const result = await merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }),
+ local: async () => { throw new Error('store broke'); },
+ });
+ expect(ids(result)).toEqual(['s']);
+ });
+
+ it('survives a store that fails before it can return a promise', async () => {
+ const merge = new LocalSearchMerge();
+ const result = await merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }),
+ local: () => { throw new Error('store broke'); },
+ });
+ expect(ids(result)).toEqual(['s']);
+ });
+
+ it('keeps the local result when the server fails', async () => {
+ const merge = new LocalSearchMerge();
+ const result = await merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => { throw new Error('server broke'); },
+ local: async () => ({ emails: [mail('l', 1)], total: 1, hasMore: false }),
+ });
+ expect(ids(result)).toEqual(['l']);
+ expect(result.total).toBe(1);
+ expect(result.hasMore).toBe(false);
+ });
+
+ it('asks the server again on the next page after it failed', async () => {
+ let fail = true;
+ const server = side([mail('s1', 10)]);
+ const merge = new LocalSearchMerge();
+ const run = (position: number) => merge.run({
+ key: 'k', limit: 1, position,
+ server: async (at) => {
+ if (fail) throw new Error('server broke');
+ return server.answer(at, 1);
+ },
+ local: async (at) => side([mail('l2', 20), mail('l1', 5)]).answer(at, 1),
+ });
+ expect(ids(await run(0))).toEqual(['l2']);
+ fail = false;
+ expect(ids(await run(1))).toEqual(['s1']);
+ });
+
+ it('keeps the server error when the store has nothing to show', async () => {
+ const failing = async () => { throw new Error('server broke'); };
+ await expect(new LocalSearchMerge().run({
+ key: 'k', limit: 10, position: 0, server: failing, local: async () => ({ emails: [], total: 0, hasMore: false }),
+ })).rejects.toThrow('server broke');
+ await expect(new LocalSearchMerge().run({
+ key: 'k', limit: 10, position: 0, server: failing, local: async () => null,
+ })).rejects.toThrow('server broke');
+ await expect(new LocalSearchMerge().run({
+ key: 'k', limit: 10, position: 0, server: failing, local: async () => { throw new Error('store broke too'); },
+ })).rejects.toThrow('server broke');
+ });
+
+ it('keeps the error of a later page, so the user can try again', async () => {
+ let fail = false;
+ const merge = new LocalSearchMerge();
+ const run = (position: number) => merge.run({
+ key: 'k', limit: 1, position,
+ server: async (at) => {
+ if (fail) throw new Error('server broke');
+ return side([mail('s2', 20), mail('s1', 10)]).answer(at, 1);
+ },
+ local: async (at) => side([mail('l3', 30)]).answer(at, 1),
+ });
+ expect(ids(await run(0))).toEqual(['l3']);
+ fail = true;
+ await expect(run(1)).rejects.toThrow('server broke');
+ fail = false;
+ expect(ids(await run(1))).toEqual(['s2']);
+ });
+
+ describe('with a store that never answers', () => {
+ beforeEach(() => { vi.useFakeTimers(); });
+ afterEach(() => { vi.useRealTimers(); });
+
+ it('does not hold the server result back for long', async () => {
+ const merge = new LocalSearchMerge();
+ const answer = merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }),
+ local: () => new Promise(() => {}),
+ });
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(ids(await answer)).toEqual(['s']);
+ });
+
+ it('gives the server error once it has waited for the store', async () => {
+ const merge = new LocalSearchMerge();
+ const answer = merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => { throw new Error('server broke'); },
+ local: () => new Promise(() => {}),
+ });
+ const failed = expect(answer).rejects.toThrow('server broke');
+ await vi.advanceTimersByTimeAsync(5_000);
+ await failed;
+ });
+ });
+});
+
+describe('filterHasTextTerms', () => {
+ it('is true for searches for text, in any shape', () => {
+ for (const filter of [
+ { text: 'x' }, { from: 'x' }, { to: 'x' }, { cc: 'x' }, { bcc: 'x' }, { subject: 'x' }, { body: 'x' },
+ { operator: 'AND', conditions: [{ inMailbox: 'i' }, { text: 'x' }] },
+ { operator: 'OR', conditions: [{ to: 'x' }, { cc: 'x' }] },
+ { operator: 'AND', conditions: [{ operator: 'OR', conditions: [{ hasKeyword: 'a' }, { body: 'x' }] }] },
+ ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(true);
+ });
+
+ it('is false when nothing is searched for', () => {
+ for (const filter of [
+ {}, { inMailbox: 'i' }, { hasKeyword: '$seen' }, { text: '' }, { text: ' ' },
+ { operator: 'AND', conditions: [{ inMailbox: 'i' }, { hasAttachment: true }] },
+ { operator: 'NOT', conditions: [{ text: 'x' }] },
+ { operator: 'AND', conditions: [] },
+ ]) expect(filterHasTextTerms(filter), JSON.stringify(filter)).toBe(false);
+ });
+});
+
+describe('through the client, with a real store', () => {
+ const SERVER = 'https://mail.example.org';
+ const SESSION = {
+ apiUrl: `${SERVER}/jmap/`,
+ downloadUrl: `${SERVER}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`,
+ uploadUrl: `${SERVER}/jmap/upload/{accountId}/`,
+ accounts: { [JMAP_ACCOUNT]: { name: 'alice', isPersonal: true, accountCapabilities: {} } },
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT },
+ capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } },
+ username: 'alice@example.org',
+ };
+
+ let serverRows: Array>;
+ let serverFails: boolean;
+ let requests: Array, string]>>;
+
+ function fakeServerFetch() {
+ return vi.fn(async (url: string, init?: RequestInit) => {
+ if (String(url).endsWith('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 });
+ const { methodCalls } = JSON.parse(String(init?.body)) as { methodCalls: Array<[string, Record, string]> };
+ requests.push(methodCalls);
+ if (serverFails) return new Response('broken', { status: 500 });
+ const position = (methodCalls[0][1].position as number) ?? 0;
+ const limit = methodCalls[0][1].limit as number;
+ const page = serverRows.slice(position, position + limit);
+ return new Response(JSON.stringify({
+ methodResponses: methodCalls.map(([name, , id]) => {
+ if (name === 'Email/query') return [name, { ids: page.map((row) => row.id), total: serverRows.length, position }, id];
+ if (name === 'Email/get') return [name, { list: page, notFound: [] }, id];
+ return [name, { list: [], notFound: [] }, id];
+ }),
+ }), { status: 200 });
+ });
+ }
+
+ async function storeEngine() {
+ const fake = new FakeJmap();
+ fake.addEmail({ id: 'both', subject: 'Stromrechnung', receivedAt: '2026-10-05T00:00:00Z' });
+ fake.addEmail({ id: 'only-local', subject: 'Zahlungserinnerungsrechnung', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { inbox: true } });
+ fake.addEmail({ id: 'not-a-hit', subject: 'Hallo', receivedAt: '2026-10-02T00:00:00Z' });
+ const engine = new AccountEngine({
+ account: 'a@fake.test',
+ db: openMemoryStore(),
+ session: FAKE_SESSION,
+ transport: fake,
+ areas: [mailArea],
+ scope: { ...DEFAULT_SCOPE, days: null },
+ now: () => Date.UTC(2026, 10, 3),
+ sleep: async () => {},
+ });
+ await engine.requestSync();
+ return engine;
+ }
+
+ function hooksWith(searchLocal: OfflineHooks['searchLocal'], skipNetwork = false): OfflineHooks {
+ return {
+ skipNetwork: () => skipNetwork,
+ answer: async () => null,
+ record: () => {},
+ noteNetwork: () => {},
+ serverGone: async () => false,
+ blob: async () => null,
+ recordBlob: () => {},
+ holdUpload: async () => null,
+ searchLocal,
+ };
+ }
+
+ async function connected(): Promise {
+ const client = new JMAPClient(SERVER, 'alice@example.org', 'pw');
+ await client.connect();
+ return client;
+ }
+
+ beforeEach(() => {
+ serverFails = false;
+ requests = [];
+ serverRows = [
+ { id: 'newer', subject: 'Rechnung', receivedAt: '2026-10-06T00:00:00Z', mailboxIds: { inbox: true } },
+ { id: 'both', subject: 'Stromrechnung, server copy', receivedAt: '2026-10-05T00:00:00Z', mailboxIds: { inbox: true } },
+ ];
+ vi.stubGlobal('fetch', fakeServerFetch());
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ setOfflineHooks(null);
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it('merges what the store finds into searchEmails, with its highlights', async () => {
+ const engine = await storeEngine();
+ setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls)));
+ const client = await connected();
+ const result = await client.searchEmails('rechnung');
+ expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']);
+ expect(result.emails[1].subject).toBe('Stromrechnung, server copy');
+ expect(result.emails[2].searchSnippet?.subject).toBe('Zahlungserinnerungsrechnung');
+ expect(result.total).toBe(3);
+ expect(result.hasMore).toBe(false);
+ });
+
+ it('merges into advancedSearchEmails too, and leaves the server request as it was', async () => {
+ const engine = await storeEngine();
+ setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls)));
+ const client = await connected();
+ const filter = { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'rechnung' }] };
+ const result = await client.advancedSearchEmails(filter);
+ expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']);
+ const query = requests[0][0];
+ expect(query[0]).toBe('Email/query');
+ expect(query[1]).toMatchObject({ filter, limit: 50, position: 0, calculateTotal: true, sort: [{ property: 'receivedAt', isAscending: false }] });
+ });
+
+ it('shows what the store found when the server fails', async () => {
+ const engine = await storeEngine();
+ setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls)));
+ const client = await connected();
+ serverFails = true;
+ const result = await client.searchEmails('rechnung');
+ expect(result.emails.map((email) => email.id).sort()).toEqual(['both', 'only-local']);
+ });
+
+ it('shows what the server found when the store fails', async () => {
+ setOfflineHooks(hooksWith(async () => { throw new Error('store broke'); }));
+ const client = await connected();
+ const result = await client.searchEmails('rechnung');
+ expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']);
+ });
+
+ it('changes nothing when the store is off for the account', async () => {
+ const searchLocal = vi.fn(async () => null);
+ setOfflineHooks(hooksWith(searchLocal));
+ const client = await connected();
+ const result = await client.searchEmails('rechnung');
+ expect(searchLocal).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ emails: expect.any(Array), total: 2, hasMore: false });
+ expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']);
+ expect(requests.length).toBe(1);
+ });
+
+ it('changes nothing without a store', async () => {
+ setOfflineHooks(hooksWith(undefined));
+ const client = await connected();
+ const result = await client.searchEmails('rechnung', undefined, undefined, 50, 7);
+ expect(requests[0][0][1].position).toBe(7);
+ expect(result.total).toBe(2);
+ });
+
+ it('leaves filters without text to the server', async () => {
+ const searchLocal = vi.fn(async () => null);
+ setOfflineHooks(hooksWith(searchLocal));
+ const client = await connected();
+ await client.advancedSearchEmails({ inMailbox: 'inbox' });
+ await client.advancedSearchEmails({});
+ expect(searchLocal).not.toHaveBeenCalled();
+ });
+
+ it('does not ask twice when the device is answering anyway', async () => {
+ const searchLocal = vi.fn(async () => null);
+ const hooks = hooksWith(searchLocal, true);
+ hooks.answer = async (_client, calls) => ({
+ methodResponses: calls.map(([name, , id]) => (name === 'Email/query'
+ ? [name, { ids: [], total: 0, position: 0 }, id]
+ : [name, { list: [], notFound: [] }, id])),
+ });
+ setOfflineHooks(hooks);
+ const client = await connected();
+ await client.searchEmails('rechnung');
+ expect(searchLocal).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 6d35dcdfa..6563320f0 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -9,6 +9,7 @@ import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions, UploadedEmailImport, UploadedEmailImportResult } from "./client-interface";
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
import { textConditions, textFilter } from "./search-utils";
+import { filterHasTextTerms, LocalSearchMerge } from "./search-merge";
import { batched, batchedBySize, itemsPerRequest } from "./request-limits";
import { keywordPointer, pointerToken } from "./patch-pointer";
import { FirstTouchGate } from "./first-touch-gate";
@@ -1638,6 +1639,7 @@ export class JMAPClient implements IJMAPClient {
* can be reached again (a reload signs in as usual).
*/
private offlineOnly = false;
+ private readonly searchMerge = new LocalSearchMerge();
constructor(serverUrl: string, username: string, password: string) {
this.serverUrl = serverUrl.replace(/\/$/, '');
@@ -4392,61 +4394,111 @@ export class JMAPClient implements IJMAPClient {
}
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise {
+ // The JMAP "text" filter searches from, to, cc, bcc, subject and body.
+ // Quoted phrases and `word*` prefixes go out as conditions of their
+ // own (see splitSearchText). inMailbox joins them in the same AND: a
+ // FilterOperator cannot also carry property keys.
+ const filter: Record = mailboxId
+ ? { operator: "AND", conditions: [{ inMailbox: mailboxId }, ...textConditions(query)] }
+ : textFilter(query);
+ return this.searchWithLocal(filter, accountId, limit, position, options);
+ }
+
+ /**
+ * A search on the server, merged with what the local store finds (see
+ * search-merge.ts). Without a store for this client, or when the request
+ * would be answered on this device anyway, it's the server's answer alone.
+ */
+ private searchWithLocal(
+ filter: Record,
+ accountId: string | undefined,
+ limit: number,
+ position: number,
+ options: SearchRequestOptions,
+ ): Promise {
+ const server = (at: number) => this.serverSearch(filter, accountId, limit, at, options);
+ const hooks = getOfflineHooks();
+ const searchLocal = hooks?.searchLocal;
+ if (!searchLocal || this.offlineOnly || hooks.skipNetwork(this) || !filterHasTextTerms(filter)) {
+ return server(position);
+ }
+ return this.searchMerge.run({
+ key: JSON.stringify([accountId ?? null, filter]),
+ limit,
+ position,
+ server,
+ local: async (at) => {
+ const targetAccountId = accountId || this.accountId;
+ const answer = await searchLocal.call(hooks, this, this.searchCalls(targetAccountId, filter, limit, at, false));
+ return answer ? this.searchResultFrom(answer as JMAPResponse, accountId, limit, at) : null;
+ },
+ });
+ }
+
+ private searchCalls(
+ targetAccountId: string,
+ filter: Record,
+ limit: number,
+ position: number,
+ deferSnippets: boolean | undefined,
+ ): JMAPMethodCall[] {
+ // Searching all folders with no criteria yields an empty FilterCondition,
+ // which servers reject; omit the key entirely to mean "no filter".
+ const hasFilter = Object.keys(filter).length > 0;
+ return [
+ ["Email/query", {
+ accountId: targetAccountId,
+ ...(hasFilter ? { filter } : {}),
+ sort: [{ property: "receivedAt", isAscending: false }],
+ limit,
+ position,
+ calculateTotal: true,
+ }, "0"],
+ ["Email/get", {
+ accountId: targetAccountId,
+ "#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
+ properties: [...EMAIL_LIST_PROPERTIES],
+ }, "1"],
+ ...(deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]),
+ ];
+ }
+
+ private searchResultFrom(response: JMAPResponse, accountId: string | undefined, limit: number, position: number): SearchEmailsResult {
+ assertQuerySucceeded(response, 'Search');
+
+ const queryResponse = response.methodResponses?.[0]?.[1];
+ const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]);
+ emails.sort((a: Email, b: Email) =>
+ new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
+ );
+ const total = queryResponse?.total || 0;
+ const hasMore = computeHasMore(position, emails.length, total, limit);
+ attachSearchSnippets(emails, this.searchSnippetsFrom(response));
+
+ // Mirror getEmails: emails fetched from a delegated/shared account carry
+ // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line
+ // up with the namespaced ids the store holds for shared mailboxes. The
+ // cross-account views (All mail / Unread / Starred) browse via
+ // advancedSearchEmails, so they need this too. (#281 V3)
+ if (accountId && accountId !== this.accountId) {
+ namespaceMailboxIds(emails, accountId);
+ }
+ return { emails, hasMore, total };
+ }
+
+ private async serverSearch(
+ filter: Record,
+ accountId: string | undefined,
+ limit: number,
+ position: number,
+ options: SearchRequestOptions,
+ ): Promise {
try {
const targetAccountId = accountId || this.accountId;
-
- // The JMAP "text" filter searches from, to, cc, bcc, subject and body.
- // Quoted phrases and `word*` prefixes go out as conditions of their
- // own (see splitSearchText). inMailbox joins them in the same AND: a
- // FilterOperator cannot also carry property keys.
- let filter: Record;
- if (mailboxId) {
- filter = {
- operator: "AND",
- conditions: [
- { inMailbox: mailboxId },
- ...textConditions(query),
- ],
- };
- } else {
- filter = textFilter(query);
- }
-
- const response = await this.request([
- ["Email/query", {
- accountId: targetAccountId,
- filter,
- sort: [{ property: "receivedAt", isAscending: false }],
- limit,
- position,
- calculateTotal: true,
- }, "0"],
- ["Email/get", {
- accountId: targetAccountId,
- "#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
- properties: [...EMAIL_LIST_PROPERTIES],
- }, "1"],
- ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, filter)]),
- ]);
- assertQuerySucceeded(response, 'Search');
-
- const queryResponse = response.methodResponses?.[0]?.[1];
- const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]);
- emails.sort((a: Email, b: Email) =>
- new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
- );
- const total = queryResponse?.total || 0;
- const hasMore = computeHasMore(position, emails.length, total, limit);
- attachSearchSnippets(emails, this.searchSnippetsFrom(response));
-
- // Mirror getEmails: emails fetched from a delegated/shared account carry
- // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line
- // up with the namespaced ids the store holds for shared mailboxes. (#281 V3)
- if (accountId && accountId !== this.accountId) {
- namespaceMailboxIds(emails, accountId);
- }
-
- return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, filter, emails) };
+ const hasFilter = Object.keys(filter).length > 0;
+ const response = await this.request(this.searchCalls(targetAccountId, filter, limit, position, options.deferSnippets));
+ const result = this.searchResultFrom(response, accountId, limit, position);
+ return { ...result, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, result.emails) };
} catch (error) {
// Rethrow: an empty result would read as "No results found".
console.error('Search failed:', error);
@@ -4518,52 +4570,7 @@ export class JMAPClient implements IJMAPClient {
position: number = 0,
options: SearchRequestOptions = {},
): Promise {
- try {
- const targetAccountId = accountId || this.accountId;
-
- // Searching all folders with no criteria yields an empty FilterCondition,
- // which servers reject; omit the key entirely to mean "no filter".
- const hasFilter = Object.keys(filter).length > 0;
-
- const response = await this.request([
- ["Email/query", {
- accountId: targetAccountId,
- ...(hasFilter ? { filter } : {}),
- sort: [{ property: "receivedAt", isAscending: false }],
- limit,
- position,
- calculateTotal: true,
- }, "0"],
- ["Email/get", {
- accountId: targetAccountId,
- "#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
- properties: [...EMAIL_LIST_PROPERTIES],
- }, "1"],
- ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]),
- ]);
- assertQuerySucceeded(response, 'Search');
-
- const queryResponse = response.methodResponses?.[0]?.[1];
- const emails = applyListAuthenticationResults((response.methodResponses?.[1]?.[1]?.list || []) as Email[]);
- emails.sort((a: Email, b: Email) =>
- new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
- );
- const total = queryResponse?.total || 0;
- const hasMore = computeHasMore(position, emails.length, total, limit);
- attachSearchSnippets(emails, this.searchSnippetsFrom(response));
-
- // Namespace shared/delegated-account mailbox ids (see searchEmails). The
- // cross-account views (All mail / Unread / Starred) browse via this method,
- // so without it shared emails would carry bare owner ids there. (#281 V3)
- if (accountId && accountId !== this.accountId) {
- namespaceMailboxIds(emails, accountId);
- }
-
- return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, emails) };
- } catch (error) {
- console.error('Advanced search failed:', error);
- throw error;
- }
+ return this.searchWithLocal(filter, accountId, limit, position, options);
}
async getEmailFields(
diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts
index ab0477fc9..b96dc1818 100644
--- a/lib/jmap/offline-hooks.ts
+++ b/lib/jmap/offline-hooks.ts
@@ -45,6 +45,12 @@ export interface OfflineHooks {
recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void;
/** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */
holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>;
+ /**
+ * Search calls answered from the local store while the server is asked too
+ * (lib/jmap/search-merge.ts). Null when the store doesn't cover the client.
+ * Left out when there is no store, and searches then go to the server alone.
+ */
+ searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise;
}
const KEY = '__bulwarkOfflineHooks';
diff --git a/lib/jmap/search-merge.ts b/lib/jmap/search-merge.ts
new file mode 100644
index 000000000..a38cb53d1
--- /dev/null
+++ b/lib/jmap/search-merge.ts
@@ -0,0 +1,203 @@
+/**
+ * Online, a search also asks the local store and merges the two answers. The
+ * store finds what the server's index can't, such as a word inside a German
+ * compound. This module knows nothing about the store: it merges two lists.
+ *
+ * The merge walks both lists newest first, as a search sorts them. When both
+ * have a message, the server's copy is kept. A page never holds more than
+ * `limit` rows, so a local row can push a server row to the next page.
+ *
+ * Callers page by counting the rows they have, so `position` counts merged
+ * rows and means nothing to the server once a local row is among them. A cursor
+ * for each search remembers how far each side was read. A request past 0 with
+ * no cursor goes to the server alone, exactly as it would without a store.
+ *
+ * `hasMore` is true while either side has rows this search hasn't returned.
+ * A local list made only of rows the server also has can therefore cost one
+ * extra page that comes back empty, and then it's false.
+ *
+ * `total` is the server's total plus the local-only rows returned so far, and
+ * never less than the local total or the rows already returned. It can grow
+ * while paging, because how many local rows the server also has only shows
+ * when the lists are read.
+ *
+ * Local rows older than the last row the server sent wait for the next page:
+ * they may still be on the server's next page.
+ *
+ * An error on one side keeps the other side's rows. When the server fails and
+ * the store has nothing, the server's error stands, since an empty list would
+ * read as "no results". A slow store never holds up the server's answer for
+ * longer than LOCAL_GRACE_MS.
+ */
+
+import { debug } from '@/lib/debug';
+import type { SearchEmailsResult } from './client-interface';
+import type { Email } from './types';
+
+const LOCAL_GRACE_MS = 1_000;
+const LOCAL_ALONE_MS = 5_000;
+const MAX_CURSORS = 8;
+
+const TEXT_FILTER_KEYS = ['text', 'from', 'to', 'cc', 'bcc', 'subject', 'body'];
+
+/**
+ * Whether a filter searches for text. Other filters (a folder, a flag, a date)
+ * have the same answer on both sides, so only a stale local row could come of
+ * merging them. Terms under NOT are searched to be absent, so they don't count.
+ */
+export function filterHasTextTerms(filter: Record): boolean {
+ if (typeof filter.operator === 'string') {
+ if (filter.operator === 'NOT' || !Array.isArray(filter.conditions)) return false;
+ return (filter.conditions as Array>).some(filterHasTextTerms);
+ }
+ return TEXT_FILTER_KEYS.some((key) => typeof filter[key] === 'string' && (filter[key] as string).trim().length > 0);
+}
+
+interface MergeCursor {
+ /** Merged rows returned so far, which is where the next page should ask from. */
+ next: number;
+ serverPosition: number;
+ localPosition: number;
+ serverTotal: number;
+ /** Local-only rows returned so far. */
+ extras: number;
+ /** Every row returned so far, so none appears twice. */
+ shown: Set;
+}
+
+function receivedTime(email: Email): number {
+ return Date.parse(email.receivedAt) || 0;
+}
+
+interface PageMerge {
+ emails: Email[];
+ serverRead: number;
+ localRead: number;
+ extras: number;
+}
+
+/** One merged page. Rows are added to `shown`. */
+export function mergeSearchPages(
+ server: readonly Email[],
+ serverHasMore: boolean,
+ local: readonly Email[],
+ limit: number,
+ shown: Set,
+): PageMerge {
+ const serverIds = new Set(server.map((email) => email.id));
+ const emails: Email[] = [];
+ let s = 0;
+ let l = 0;
+ let extras = 0;
+ while (emails.length < limit) {
+ const fromServer = server[s];
+ const fromLocal = local[l];
+ if (fromLocal && (serverIds.has(fromLocal.id) || shown.has(fromLocal.id))) {
+ l += 1;
+ continue;
+ }
+ if (fromServer && shown.has(fromServer.id)) {
+ s += 1;
+ continue;
+ }
+ if (!fromServer && (!fromLocal || serverHasMore)) break;
+ if (fromServer && (!fromLocal || receivedTime(fromLocal) <= receivedTime(fromServer))) {
+ emails.push(fromServer);
+ shown.add(fromServer.id);
+ s += 1;
+ } else {
+ emails.push(fromLocal);
+ shown.add(fromLocal.id);
+ l += 1;
+ extras += 1;
+ }
+ }
+ return { emails, serverRead: s, localRead: l, extras };
+}
+
+/** The promise's value, or null if it fails or takes longer than `ms`. */
+function within(promise: Promise, ms: number): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(null), ms);
+ promise.then(
+ (value) => {
+ clearTimeout(timer);
+ resolve(value);
+ },
+ (error) => {
+ clearTimeout(timer);
+ debug.warn('jmap', 'Local search failed:', error);
+ resolve(null);
+ },
+ );
+ });
+}
+
+export interface MergedSearchRequest {
+ /** The same for every page of one search. */
+ key: string;
+ limit: number;
+ position: number;
+ server(position: number): Promise;
+ /** Null when the store doesn't cover this account. */
+ local(position: number): Promise;
+}
+
+export class LocalSearchMerge {
+ private readonly cursors = new Map();
+
+ private startCursor(key: string): MergeCursor {
+ const cursor: MergeCursor = { next: 0, serverPosition: 0, localPosition: 0, serverTotal: 0, extras: 0, shown: new Set() };
+ this.cursors.delete(key);
+ this.cursors.set(key, cursor);
+ if (this.cursors.size > MAX_CURSORS) this.cursors.delete(this.cursors.keys().next().value as string);
+ return cursor;
+ }
+
+ async run(request: MergedSearchRequest): Promise {
+ const { key, limit, position } = request;
+ const cursor = position === 0 ? this.startCursor(key) : this.cursors.get(key);
+ if (!cursor) return request.server(position);
+
+ // A caller that removed rows or added its own asks from a little off
+ // where the last page ended, and the server's side moves with it.
+ const serverPosition = Math.max(0, cursor.serverPosition + position - cursor.next);
+ const localAnswer = Promise.resolve().then(() => request.local(cursor.localPosition));
+
+ let serverPage: SearchEmailsResult | null = null;
+ let serverError: unknown = null;
+ try {
+ serverPage = await request.server(serverPosition);
+ } catch (error) {
+ serverError = error;
+ }
+ const localPage = await within(localAnswer, serverPage ? LOCAL_GRACE_MS : LOCAL_ALONE_MS);
+
+ if (!serverPage && !localPage?.emails.length) throw serverError;
+ if (serverPage && !localPage && cursor.extras === 0) {
+ // Nothing local in this search: the server's answer, untouched.
+ this.cursors.delete(key);
+ return serverPage;
+ }
+
+ const server = serverPage?.emails ?? [];
+ const serverHasMore = serverPage?.hasMore ?? false;
+ const local = localPage?.emails ?? [];
+ const merged = mergeSearchPages(server, serverHasMore, local, limit, cursor.shown);
+
+ cursor.serverPosition = serverPosition + merged.serverRead;
+ cursor.localPosition += merged.localRead;
+ cursor.next = position + merged.emails.length;
+ cursor.extras += merged.extras;
+ if (serverPage) cursor.serverTotal = serverPage.total;
+
+ const serverLeft = merged.serverRead < server.length || serverHasMore;
+ const localLeft = localPage ? merged.localRead < local.length || localPage.hasMore : false;
+ return {
+ ...serverPage,
+ emails: merged.emails,
+ hasMore: serverLeft || localLeft,
+ total: Math.max(cursor.serverTotal + cursor.extras, localPage?.total ?? 0, cursor.next),
+ };
+ }
+}
From fdeb7963bab486358179f1e1861cff6c6c0abe38 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 05:41:35 +0100
Subject: [PATCH 4/9] refactor: read event durations without loading the
calendar UI
---
components/calendar/event-card.tsx | 15 +--------------
lib/calendar-alerts.ts | 2 +-
lib/calendar-event-normalization.ts | 15 +++++++++++++++
lib/calendar-utils.ts | 2 +-
stores/calendar-store.ts | 2 +-
5 files changed, 19 insertions(+), 17 deletions(-)
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx
index d53f88b36..b7618622e 100644
--- a/components/calendar/event-card.tsx
+++ b/components/calendar/event-card.tsx
@@ -4,6 +4,7 @@ import { useCallback, useRef, useState, type CSSProperties, type DragEvent } fro
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
+import { parseDuration } from "@/lib/calendar-event-normalization";
import { format } from "date-fns";
import { ChevronDown, ChevronUp, Users } from "@/components/icons";
import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participants";
@@ -101,20 +102,6 @@ export function eventFillClass(tinted: boolean, inactive: boolean): string | und
return tinted && !inactive ? "start-bar" : undefined;
}
-function parseDuration(duration: string | undefined): number {
- if (!duration) return 0;
- let totalMinutes = 0;
- const weekMatch = duration.match(/(\d+)W/);
- const hourMatch = duration.match(/(\d+)H/);
- const minMatch = duration.match(/(\d+)M/);
- const dayMatch = duration.match(/(\d+)D/);
- if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60;
- if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60;
- if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60;
- if (minMatch) totalMinutes += parseInt(minMatch[1]);
- return totalMinutes;
-}
-
function createEventDragPreview(title: string, timeRange: string, color: string): HTMLElement {
const el = document.createElement("div");
el.style.cssText = `
diff --git a/lib/calendar-alerts.ts b/lib/calendar-alerts.ts
index bef0151e1..2c5e8ff96 100644
--- a/lib/calendar-alerts.ts
+++ b/lib/calendar-alerts.ts
@@ -7,7 +7,7 @@ import type {
Calendar,
CalendarTask,
} from '@/lib/jmap/types';
-import { parseDuration } from '@/components/calendar/event-card';
+import { parseDuration } from '@/lib/calendar-event-normalization';
export interface PendingAlert {
eventId: string;
diff --git a/lib/calendar-event-normalization.ts b/lib/calendar-event-normalization.ts
index 6dd31bf83..5305faa8e 100644
--- a/lib/calendar-event-normalization.ts
+++ b/lib/calendar-event-normalization.ts
@@ -39,6 +39,21 @@ export function parseDurationSeconds(duration: string | undefined): number | nul
return (((weeks * 7 + days) * 24 + hours) * 60 + minutes) * 60 + seconds;
}
+/** A duration in whole minutes, 0 when there is none. */
+export function parseDuration(duration: string | undefined): number {
+ if (!duration) return 0;
+ let totalMinutes = 0;
+ const weekMatch = duration.match(/(\d+)W/);
+ const hourMatch = duration.match(/(\d+)H/);
+ const minMatch = duration.match(/(\d+)M/);
+ const dayMatch = duration.match(/(\d+)D/);
+ if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60;
+ if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60;
+ if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60;
+ if (minMatch) totalMinutes += parseInt(minMatch[1]);
+ return totalMinutes;
+}
+
export function normalizeAllDayDurationValue(duration: string | undefined): string | undefined {
const totalSeconds = parseDurationSeconds(duration);
if (totalSeconds === null || totalSeconds < 86400 || totalSeconds % 86400 !== 0) {
diff --git a/lib/calendar-utils.ts b/lib/calendar-utils.ts
index 2299fb54c..faff279c4 100644
--- a/lib/calendar-utils.ts
+++ b/lib/calendar-utils.ts
@@ -1,5 +1,5 @@
import { addDays, differenceInCalendarDays, parseISO, startOfDay, subMilliseconds } from "date-fns";
-import { parseDuration } from "@/components/calendar/event-card";
+import { parseDuration } from "@/lib/calendar-event-normalization";
import type { CalendarEvent } from "@/lib/jmap/types";
import { fromWallClock, isValidTimeZone, parseLocalDateTime, toDisplayDate } from "@/lib/timezone";
diff --git a/stores/calendar-store.ts b/stores/calendar-store.ts
index d7f9c53f7..fceb9c4e7 100644
--- a/stores/calendar-store.ts
+++ b/stores/calendar-store.ts
@@ -5,7 +5,7 @@ import type { Calendar, CalendarEvent, CalendarParticipant, CalendarParticipantI
import { debug } from '@/lib/debug';
import { getEventStartDate, normalizeAllDayDuration } from '@/lib/calendar-utils';
import { displayNow } from '@/lib/timezone';
-import { parseDuration } from '@/components/calendar/event-card';
+import { parseDuration } from '@/lib/calendar-event-normalization';
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
import { feedChanged, feedFields } from '@/lib/calendar-feed-diff';
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
From 877a7a93b208cd33bd91cbcab5887a474d2cd266 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 05:42:01 +0100
Subject: [PATCH 5/9] feat: keep calendars on the device and queue calendar
changes while offline
Adds a `calendar` area to the local store. It syncs calendars, participant
identities, events and tasks as masters, through CalendarEvent/changes,
and answers Calendar/get, CalendarEvent/query, /get and /set from SQLite
when the server can't be reached.
- A range query gets series in both client modes, with their overrides,
and the calendar store expands them as it does for an older server.
- The synthetic-id probe gets the verdict from the last sync, so a client
started offline behaves the same once the server is back.
- Create, edit, delete and RSVP queue in `calendar_pending`, one merged
row per event, and go out through `calendar.flush` or the next sync.
A create finds itself by uid after a lost response.
- A write the server refuses, or one on a value the server changed, goes
to `calendar.conflicts`.
- `lib/store/calendar/reminders.ts` lists upcoming alerts with keys that
don't depend on synthetic ids.
---
lib/recurrence-expansion.ts | 8 +-
lib/recurrence-instances.ts | 2 +-
.../__tests__/calendar-client-modes.test.ts | 212 ++++
lib/store/__tests__/calendar-no-dom.test.ts | 32 +
lib/store/__tests__/calendar-sync.test.ts | 955 ++++++++++++++++++
lib/store/__tests__/helpers/client-wire.ts | 79 ++
lib/store/__tests__/helpers/fake-calendar.ts | 365 +++++++
lib/store/__tests__/helpers/fake-jmap.ts | 2 +-
lib/store/area.ts | 5 +-
lib/store/areas.ts | 3 +-
lib/store/calendar/filter.ts | 220 ++++
lib/store/calendar/flush.ts | 229 +++++
lib/store/calendar/index.ts | 19 +
lib/store/calendar/methods.ts | 197 ++++
lib/store/calendar/records.ts | 162 +++
lib/store/calendar/reminders.ts | 160 +++
lib/store/calendar/schema.ts | 77 ++
lib/store/calendar/sync.ts | 264 +++++
lib/store/calendar/writes.ts | 259 +++++
lib/store/engine/account-engine.ts | 2 +-
.../calendar-store-offline-occurrence.test.ts | 117 +++
21 files changed, 3359 insertions(+), 10 deletions(-)
create mode 100644 lib/store/__tests__/calendar-client-modes.test.ts
create mode 100644 lib/store/__tests__/calendar-no-dom.test.ts
create mode 100644 lib/store/__tests__/calendar-sync.test.ts
create mode 100644 lib/store/__tests__/helpers/client-wire.ts
create mode 100644 lib/store/__tests__/helpers/fake-calendar.ts
create mode 100644 lib/store/calendar/filter.ts
create mode 100644 lib/store/calendar/flush.ts
create mode 100644 lib/store/calendar/index.ts
create mode 100644 lib/store/calendar/methods.ts
create mode 100644 lib/store/calendar/records.ts
create mode 100644 lib/store/calendar/reminders.ts
create mode 100644 lib/store/calendar/schema.ts
create mode 100644 lib/store/calendar/sync.ts
create mode 100644 lib/store/calendar/writes.ts
create mode 100644 stores/__tests__/calendar-store-offline-occurrence.test.ts
diff --git a/lib/recurrence-expansion.ts b/lib/recurrence-expansion.ts
index 90897c253..f8e0f4a33 100644
--- a/lib/recurrence-expansion.ts
+++ b/lib/recurrence-expansion.ts
@@ -210,7 +210,7 @@ function validDate(value: string | null | undefined): Date | null {
}
/** The wall-clock fields of a parsed LocalDateTime, read as UTC. */
-function wallAsUtc(wall: Date): number {
+export function wallClockFieldsAsUtc(wall: Date): number {
return Date.UTC(
wall.getFullYear(), wall.getMonth(), wall.getDate(),
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
@@ -236,7 +236,7 @@ function plausibleInstants(
): { start: Date; end: Date | null } | null {
const wall = validDate(event.start);
if (!wall) return null;
- const wallStart = wallAsUtc(wall);
+ const wallStart = wallClockFieldsAsUtc(wall);
const start = instantNear(event.utcStart, wallStart);
if (!start) return null;
const end = instantNear(event.utcEnd, wallStart + (parseDurationSeconds(event.duration) ?? 0) * 1000);
@@ -288,10 +288,10 @@ function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number
* and return the corresponding UTC instant. Two fixup iterations converge
* for all real offsets, including across DST transitions.
*/
-function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
+export function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
const formatter = getTzFormatter(timeZone);
if (!formatter) return null;
- const wallMs = wallAsUtc(wall);
+ const wallMs = wallClockFieldsAsUtc(wall);
let guess = wallMs;
for (let i = 0; i < 2; i++) {
guess = wallMs - (wallClockAsUtcTimestamp(formatter, guess) - guess);
diff --git a/lib/recurrence-instances.ts b/lib/recurrence-instances.ts
index 715895d11..562e7b9e7 100644
--- a/lib/recurrence-instances.ts
+++ b/lib/recurrence-instances.ts
@@ -341,7 +341,7 @@ export function applyOverridePatch>(event: T, p
* replaces whatever the entry patches beneath it, and one beneath a value the
* entry sets goes into that value.
*/
-function mergeOverrideEntry(entry: Record, updates: Record): Record {
+export function mergeOverrideEntry(entry: Record, updates: Record): Record {
const merged = { ...entry };
for (const [key, value] of Object.entries(updates)) {
for (const existing of Object.keys(merged)) {
diff --git a/lib/store/__tests__/calendar-client-modes.test.ts b/lib/store/__tests__/calendar-client-modes.test.ts
new file mode 100644
index 000000000..35cb3ef7a
--- /dev/null
+++ b/lib/store/__tests__/calendar-client-modes.test.ts
@@ -0,0 +1,212 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it } from 'vitest';
+import type { JMAPClient } from '@/lib/jmap/client';
+import type { CalendarEvent } from '@/lib/jmap/types';
+import { expandRecurringEvents } from '@/lib/recurrence-expansion';
+import { isBrowserExpandedOccurrence, isServerRecurrenceInstance } from '@/lib/recurrence-instances';
+import { calendarArea } from '../calendar';
+import { FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar';
+import { wire, type Wire } from './helpers/client-wire';
+
+const NOW = Date.UTC(2026, 10, 3, 12);
+const RANGE = { after: '2026-11-02T00:00:00', before: '2026-11-23T00:00:00' };
+
+let current: Wire | null = null;
+
+afterEach(() => {
+ current?.restore();
+ current = null;
+});
+
+function seeded(version: StalwartVersion): Wire {
+ const server = new FakeCalendarJmap(version);
+ server.addEvent({
+ id: 'series',
+ title: 'Weekly',
+ start: '2026-01-05T10:00:00',
+ recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' },
+ recurrenceOverrides: {
+ '2026-11-09T10:00:00': { title: 'Special' },
+ '2026-11-16T10:00:00': { excluded: true },
+ },
+ });
+ server.addEvent({ id: 'single', title: 'One off', start: '2026-11-10T09:00:00' });
+ current = wire(server, [calendarArea], NOW);
+ return current;
+}
+
+/** What the calendar grid shows for a range: the client's query, then the store's expansion. */
+async function grid(client: JMAPClient, range = RANGE): Promise {
+ const raw = await client.queryAllCalendarEvents(range);
+ return expandRecurringEvents(raw, range.after, range.before);
+}
+
+function summary(events: CalendarEvent[]): Array<[string, string]> {
+ return events.map((event): [string, string] => [event.start, event.title]).sort();
+}
+
+const SHOWN = [
+ ['2026-11-02T10:00:00', 'Weekly'],
+ ['2026-11-09T10:00:00', 'Special'],
+ ['2026-11-10T09:00:00', 'One off'],
+];
+
+const expandedAsked = (w: Wire) => w.sent.some(([name, args]) => name === 'CalendarEvent/query' && args.expandRecurrences === true);
+
+describe('a client that probed online and keeps its mode', () => {
+ for (const version of ['1.0', '0.16'] as const) {
+ it(`synthetic-id mode on ${version}: the expanded query gets a correct grid`, async () => {
+ const w = seeded(version);
+ await w.engine.requestSync();
+ const client = await w.newClient();
+ await client.getCalendars();
+ expect(await client.supportsSyntheticCalendarIds()).toBe(true);
+ const online = await grid(client);
+ expect(online.some(isServerRecurrenceInstance)).toBe(true);
+ expect(summary(online)).toEqual(SHOWN);
+
+ w.online = false;
+ w.sent.length = 0;
+ const offline = await grid(client);
+ expect(expandedAsked(w)).toBe(true);
+ expect(summary(offline)).toEqual(SHOWN);
+ // Occurrences the calendar store can write back as overrides on the master.
+ const occurrences = offline.filter((event) => event.recurrenceId);
+ expect(occurrences.length).toBe(2);
+ for (const occurrence of occurrences) {
+ expect(isBrowserExpandedOccurrence(occurrence)).toBe(true);
+ expect(occurrence.originalId).toBe('series');
+ }
+ // Back online, the same client still asks the server to expand.
+ w.online = true;
+ expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true);
+ });
+ }
+
+ it('client-expansion mode on an old server: never asks to expand, same grid', async () => {
+ const w = seeded('0.16.19');
+ await w.engine.requestSync();
+ const client = await w.newClient();
+ await client.getCalendars();
+ expect(await client.supportsSyntheticCalendarIds()).toBe(false);
+ w.online = false;
+ w.sent.length = 0;
+ expect(summary(await grid(client))).toEqual(SHOWN);
+ expect(expandedAsked(w)).toBe(false);
+ // The second get with the overrides went out, as the online path sends it.
+ const gets = w.sent.filter(([name]) => name === 'CalendarEvent/get');
+ expect(gets.some(([, args]) => (args.properties as string[]).join() === 'id,recurrenceOverrides')).toBe(true);
+ });
+
+ it('gives the grid across a range longer than one expanded query may span', async () => {
+ const w = seeded('1.0');
+ await w.engine.requestSync();
+ const client = await w.newClient();
+ await client.getCalendars();
+ w.online = false;
+ w.sent.length = 0;
+ const range = { after: '2026-11-02T00:00:00', before: '2028-11-02T00:00:00' };
+ const events = await grid(client, range);
+ const queries = w.sent.filter(([name]) => name === 'CalendarEvent/query');
+ expect(queries.length).toBeGreaterThan(1);
+ expect(new Set(events.map((event) => event.id)).size).toBe(events.length);
+ expect(events.filter((event) => event.title === 'Weekly').length).toBeGreaterThan(90);
+ expect(events.filter((event) => event.title === 'Special')).toHaveLength(1);
+ });
+});
+
+describe('a client started offline', () => {
+ it('takes the mode the server had at the last sync, so it behaves the same once the server is back', async () => {
+ const w = seeded('1.0');
+ await w.engine.requestSync();
+ w.online = false;
+ const client = await w.newClient();
+ await client.getCalendars();
+ expect(await client.supportsSyntheticCalendarIds()).toBe(true);
+ expect(summary(await grid(client))).toEqual(SHOWN);
+ w.online = true;
+ expect((await grid(client)).some(isServerRecurrenceInstance)).toBe(true);
+ });
+
+ it('does not ask to expand on a server that had no synthetic ids', async () => {
+ const w = seeded('0.16.19');
+ await w.engine.requestSync();
+ w.online = false;
+ w.sent.length = 0;
+ const client = await w.newClient();
+ await client.getCalendars();
+ expect(await client.supportsSyntheticCalendarIds()).toBe(false);
+ expect(summary(await grid(client))).toEqual(SHOWN);
+ expect(expandedAsked(w)).toBe(false);
+ });
+
+ it('uses client-side expansion before any sync, and a failed read does not look like an empty calendar', async () => {
+ const w = seeded('1.0');
+ w.online = false;
+ const client = await w.newClient();
+ expect(await client.supportsSyntheticCalendarIds()).toBe(false);
+ await expect(client.queryAllCalendarEvents(RANGE)).rejects.toThrow();
+ });
+});
+
+describe('reads and writes through the real client', () => {
+ it('reads back a created event under its stand-in id, then edits and deletes it', async () => {
+ const w = seeded('1.0');
+ await w.engine.requestSync();
+ const client = await w.newClient();
+ await client.getCalendars();
+ w.online = false;
+
+ const created = await client.createCalendarEvent({
+ title: 'Offline',
+ start: '2026-11-11T08:00:00',
+ duration: 'PT1H',
+ timeZone: 'Europe/Berlin',
+ calendarIds: { cal1: true },
+ } as Partial);
+ expect(created.id).toMatch(/^local-/);
+ expect(created.title).toBe('Offline');
+ expect(created.utcStart).toBe('2026-11-11T07:00:00Z');
+ expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Offline']);
+
+ await client.updateCalendarEvent(created.id, { title: 'Renamed' });
+ expect(summary(await grid(client))).toContainEqual(['2026-11-11T08:00:00', 'Renamed']);
+
+ await client.deleteCalendarEvent(created.id);
+ expect(summary(await grid(client))).toEqual(SHOWN);
+ expect(await client.getCalendarEvent(created.id)).toBeNull();
+ expect(w.server.events.size).toBe(2);
+ });
+
+ it('fetches overrides with its one-event read', async () => {
+ const w = seeded('1.0');
+ await w.engine.requestSync();
+ const client = await w.newClient();
+ w.online = false;
+ const event = await client.getCalendarEvent('series');
+ expect(event?.recurrenceRules).toEqual([{ '@type': 'RecurrenceRule', frequency: 'weekly' }]);
+ expect(Object.keys(event?.recurrenceOverrides ?? {})).toHaveLength(2);
+ expect(event?.utcStart).toBe('2026-01-05T09:00:00Z');
+ });
+
+ it('lists tasks through the shape fetchTaskObjects reads', async () => {
+ const w = seeded('1.0');
+ w.server.addEvent({ id: 'task', '@type': 'Task', title: 'To do', start: undefined, due: '2026-11-12T10:00:00', progress: 'needs-action' });
+ await w.engine.requestSync();
+ const client = await w.newClient();
+ w.online = false;
+ const tasks = await client.getCalendarTasks();
+ expect(tasks.map((task) => [task.id, task.title, task.due])).toEqual([['task', 'To do', '2026-11-12T10:00:00']]);
+ expect(summary(await grid(client)).map(([, title]) => title)).not.toContain('To do');
+ });
+
+ it('marks tasks-only calendars and reads identities from the last sync', async () => {
+ const w = seeded('1.0');
+ await w.engine.requestSync();
+ const client = await w.newClient();
+ w.online = false;
+ const calendars = await client.getCalendars();
+ expect(calendars.map((calendar) => [calendar.id, calendar.isTasksOnly])).toEqual([['cal1', false]]);
+ expect((await client.getParticipantIdentities())[0].calendarAddress).toBe('mailto:a@example.org');
+ });
+});
diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts
new file mode 100644
index 000000000..9ee189b68
--- /dev/null
+++ b/lib/store/__tests__/calendar-no-dom.test.ts
@@ -0,0 +1,32 @@
+// @vitest-environment node
+import path from 'node:path';
+import { build } from 'esbuild';
+import { describe, expect, it } from 'vitest';
+
+const root = path.resolve(__dirname, '../../..');
+
+async function inputsOf(entry: string): Promise {
+ const result = await build({
+ entryPoints: [path.join(root, entry)],
+ bundle: true,
+ write: false,
+ format: 'esm',
+ platform: 'browser',
+ alias: { '@': root },
+ metafile: true,
+ logLevel: 'silent',
+ // Stay away from the packages' own graph: only our files matter here.
+ packages: 'external',
+ });
+ return Object.keys(result.metafile.inputs);
+}
+
+describe('what the worker pulls in', () => {
+ for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) {
+ it(`${entry} reaches no UI code`, async () => {
+ const inputs = await inputsOf(entry);
+ expect(inputs.length).toBeGreaterThan(3);
+ expect(inputs.filter((file) => /(^|\/)(components|stores|hooks|app)\//.test(file))).toEqual([]);
+ });
+ }
+});
diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts
new file mode 100644
index 000000000..45ba37796
--- /dev/null
+++ b/lib/store/__tests__/calendar-sync.test.ts
@@ -0,0 +1,955 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_SCOPE, type StoreEvent } from '../area';
+import { calendarArea } from '../calendar';
+import { listUpcomingAlerts } from '../calendar/reminders';
+import { AccountEngine } from '../engine/account-engine';
+import { TransportError, type MethodCall } from '../engine/transport';
+import type { CalendarConflict } from '../calendar/writes';
+import { CAL, CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar';
+import { JMAP_ACCOUNT } from './helpers/fake-jmap';
+import { openMemoryStore } from './helpers/node-adapter';
+
+const NOW = Date.UTC(2026, 10, 3, 12);
+const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' };
+
+function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(version)) {
+ const db = openMemoryStore();
+ const events: StoreEvent[] = [];
+ const engine = new AccountEngine({
+ account: 'a@fake.test',
+ db,
+ session: CALENDAR_SESSION,
+ transport: server,
+ areas: [calendarArea],
+ scope: DEFAULT_SCOPE,
+ emit: (event) => events.push(event),
+ now: () => NOW,
+ sleep: async () => {},
+ });
+ return { server, db, engine, events };
+}
+
+type Setup = ReturnType;
+
+async function call(s: Setup, name: string, args: Record) {
+ // A companion call: the engine answers null when nothing at all could be answered.
+ const response = await s.engine.answer([
+ [name, { accountId: JMAP_ACCOUNT, ...args }, 'x'],
+ ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, ids: [] }, 'y'],
+ ]);
+ return response!.methodResponses[0];
+}
+
+async function queryIds(s: Setup, args: Record): Promise {
+ const [name, result] = await call(s, 'CalendarEvent/query', args);
+ expect(name, JSON.stringify(result)).toBe('CalendarEvent/query');
+ return result.ids;
+}
+
+async function getOne(s: Setup, id: string, properties?: string[], timeZone?: string): Promise | undefined> {
+ const [, result] = await call(s, 'CalendarEvent/get', { ids: [id], ...(properties ? { properties } : {}), ...(timeZone ? { timeZone } : {}) });
+ return result.list[0];
+}
+
+async function setEvent(s: Setup, args: Record) {
+ const [name, result] = await call(s, 'CalendarEvent/set', args);
+ expect(name).toBe('CalendarEvent/set');
+ return result;
+}
+
+const flush = (s: Setup) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>;
+const conflicts = (s: Setup) => s.engine.areaCall('calendar.conflicts', undefined) as Promise;
+const pendingRows = (s: Setup) => s.db.all<{ kind: string; patch: string; record_id: string }>('SELECT kind, patch, record_id FROM calendar_pending');
+const setCalls = (server: FakeCalendarJmap): MethodCall[] => server.calls.filter(([name]) => name === 'CalendarEvent/set')
+ .filter(([, args]) => !(args.update && 'h333333' in (args.update as object)));
+
+describe('calendar sync', () => {
+ it('keeps a recurring event as its master across sync, with its overrides', async () => {
+ const s = setup();
+ s.server.addEvent({
+ id: 'series',
+ recurrenceRule: WEEKLY,
+ start: '2026-01-05T10:00:00',
+ recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } },
+ });
+ await s.engine.requestSync();
+ expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']);
+ const stored = await getOne(s, 'series');
+ expect(stored?.recurrenceRule).toEqual(WEEKLY);
+ expect(stored?.recurrenceOverrides).toEqual({ '2026-11-09T10:00:00': { title: 'Moved' }, '2026-11-16T10:00:00': { excluded: true } });
+ });
+
+ it('asks for overrides without the utc properties, which 1.0 refuses next to them', async () => {
+ const s = setup('1.0');
+ s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } });
+ await s.engine.requestSync();
+ const events = s.server.calls.filter(([name]) => name === 'CalendarEvent/get');
+ const withOverrides = events.filter(([, args]) => (args.properties as string[]).includes('recurrenceOverrides'));
+ expect(withOverrides.length).toBeGreaterThan(0);
+ for (const [, args] of withOverrides) expect(args.properties).not.toContain('utcStart');
+ expect(await getOne(s, 'series')).toMatchObject({ recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'x' } } });
+ });
+
+ it('never runs an expanded query or stores a synthetic id', async () => {
+ const s = setup('0.16');
+ s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY });
+ s.server.addEvent({ id: 'single' });
+ await s.engine.requestSync();
+ expect(s.server.calls.filter(([, args]) => args.expandRecurrences)).toEqual([]);
+ expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id)).toEqual(['series', 'single']);
+ });
+
+ it('pages past 500 objects', async () => {
+ const s = setup();
+ for (let i = 0; i < 1234; i += 1) s.server.addEvent();
+ await s.engine.requestSync();
+ expect(Number(s.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_event')?.n)).toBe(1234);
+ expect(s.server.calls.filter(([name]) => name === 'CalendarEvent/query').length).toBe(3);
+ });
+
+ it('takes an edit of one occurrence from /changes as an update of the master', async () => {
+ const s = setup('1.0');
+ s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY, start: '2026-11-02T10:00:00' });
+ await s.engine.requestSync();
+ // Someone edits one occurrence through its synthetic id.
+ const [, expanded] = (await s.server.request([['CalendarEvent/query', {
+ accountId: JMAP_ACCOUNT, filter: { after: '2026-11-08T00:00:00', before: '2026-11-10T00:00:00' }, expandRecurrences: true,
+ }, 'q']])).methodResponses[0];
+ expect(expanded.canCalculateChanges).toBe(false);
+ await s.server.request([['CalendarEvent/set', { accountId: JMAP_ACCOUNT, update: { [expanded.ids[0]]: { title: 'One off' } } }, 'u']]);
+ await s.engine.requestSync();
+ expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['series']);
+ expect(await getOne(s, 'series')).toMatchObject({
+ recurrenceOverrides: { '2026-11-09T10:00:00': { title: 'One off' } },
+ });
+ });
+
+ it('keeps old single events out and old series and tasks in, whatever their start', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'ancient', start: '2020-01-01T10:00:00' });
+ s.server.addEvent({ id: 'recent', start: '2025-06-01T10:00:00' });
+ s.server.addEvent({ id: 'future', start: '2040-01-01T10:00:00' });
+ s.server.addEvent({ id: 'old-series', start: '2015-01-01T10:00:00', recurrenceRule: WEEKLY });
+ s.server.addEvent({ id: 'old-task', '@type': 'Task', start: undefined, due: '2015-01-01T10:00:00' });
+ await s.engine.requestSync();
+ expect(s.db.all('SELECT id FROM calendar_event ORDER BY id').map((row) => row.id))
+ .toEqual(['future', 'old-series', 'old-task', 'recent']);
+ });
+
+ it('starts over when the log is gone, and drops what the server no longer has', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'stays' });
+ s.server.addEvent({ id: 'goes' });
+ await s.engine.requestSync();
+ s.server.events.delete('goes');
+ s.server.eventLog.push('destroyed', 'goes');
+ s.server.eventLog.truncatedBelow = s.server.eventLog.log.length;
+ await s.engine.requestSync();
+ expect(s.db.all('SELECT id FROM calendar_event').map((row) => row.id)).toEqual(['stays']);
+ });
+
+ it('keeps ids of two JMAP accounts apart', async () => {
+ const s = setup();
+ s.db.run(
+ "INSERT INTO calendar_event (jmap, id, json) VALUES ('other', 'same', ?)",
+ [JSON.stringify({ id: 'same', title: 'Theirs', start: '2026-11-10T10:00:00' })],
+ );
+ s.server.addEvent({ id: 'same', title: 'Mine' });
+ await s.engine.requestSync();
+ expect((await getOne(s, 'same'))?.title).toBe('Mine');
+ const row = s.db.get<{ json: string }>("SELECT json FROM calendar_event WHERE jmap = 'other' AND id = 'same'");
+ expect(JSON.parse(row!.json).title).toBe('Theirs');
+ });
+
+ it('stores calendars and participant identities and answers them with the real state', async () => {
+ const s = setup();
+ await s.engine.requestSync();
+ const [, calendars] = await call(s, 'Calendar/get', { properties: ['id', 'name'] });
+ expect(calendars.list).toEqual([{ id: 'cal1', name: 'Personal' }]);
+ expect(calendars.state).toBe('cs1');
+ const [, identities] = await call(s, 'ParticipantIdentity/get', {});
+ expect(identities.list[0].calendarAddress).toBe('mailto:a@example.org');
+ });
+
+ it('refuses to read before anything was kept, instead of saying there is nothing', async () => {
+ const s = setup();
+ const [name, result] = await call(s, 'CalendarEvent/query', {});
+ expect(name).toBe('error');
+ expect(result.type).toBe('serverUnavailable');
+ expect((await call(s, 'Calendar/get', {}))[0]).toBe('error');
+ });
+});
+
+describe('queries from the store', () => {
+ async function seeded() {
+ const s = setup();
+ s.server.addEvent({ id: 'late', title: 'Late meeting', start: '2026-11-10T23:30:00', duration: 'PT1H', timeZone: 'Europe/Berlin' });
+ s.server.addEvent({
+ id: 'lunch',
+ title: 'Lünch with Bob',
+ description: 'Bring the slides',
+ start: '2026-11-11T12:00:00',
+ calendarIds: { cal2: true },
+ locations: { l: { '@type': 'Location', name: 'Room Seven' } },
+ organizerCalendarAddress: 'mailto:owner@example.org',
+ participants: {
+ p1: { '@type': 'Participant', name: 'Bob', calendarAddress: 'mailto:bob@example.org', roles: { attendee: true }, participationStatus: 'accepted' },
+ p2: { '@type': 'Participant', name: 'Olga', calendarAddress: 'mailto:olga@example.org', roles: { owner: true }, participationStatus: 'declined' },
+ },
+ });
+ s.server.addEvent({ id: 'series', title: 'Weekly', start: '2026-01-05T10:00:00', recurrenceRule: WEEKLY });
+ s.server.addEvent({ id: 'ended', title: 'Ended', start: '2026-01-05T10:00:00', recurrenceRule: { ...WEEKLY, count: 2 } });
+ s.server.addEvent({ id: 'task', '@type': 'Task', title: 'Task', start: undefined, due: '2026-11-12T10:00:00' });
+ await s.engine.requestSync();
+ return s;
+ }
+
+ const ids = async (s: Setup, filter: unknown, extra: Record = {}) => (await queryIds(s, { filter, ...extra })).sort();
+
+ it('answers each filter the client can send', async () => {
+ const s = await seeded();
+ expect(await ids(s, { inCalendar: 'cal2' })).toEqual(['lunch']);
+ expect(await ids(s, { inCalendars: ['cal2', 'nope'] })).toEqual(['lunch']);
+ expect(await ids(s, { title: 'lunch' })).toEqual(['lunch']);
+ expect(await ids(s, { description: 'slides' })).toEqual(['lunch']);
+ expect(await ids(s, { location: 'seven' })).toEqual(['lunch']);
+ expect(await ids(s, { owner: 'olga' })).toEqual(['lunch']);
+ expect(await ids(s, { owner: 'bob' })).toEqual([]);
+ expect(await ids(s, { attendee: 'bob' })).toEqual(['lunch']);
+ expect(await ids(s, { attendee: 'olga' })).toEqual([]);
+ expect(await ids(s, { participationStatus: 'declined' })).toEqual(['lunch']);
+ expect(await ids(s, { attendee: 'bob', participationStatus: 'declined' })).toEqual([]);
+ expect(await ids(s, { uid: 'lunch@example.org' })).toEqual(['lunch']);
+ expect(await ids(s, { uid: 'LUNCH@example.org' })).toEqual([]);
+ expect(await ids(s, { types: ['Task'] })).toEqual(['task']);
+ expect(await ids(s, { text: 'BOB' })).toEqual(['lunch']);
+ expect(await ids(s, { text: 'lunch bob' })).toEqual(['lunch']);
+ expect(await ids(s, { text: 'lunch nothing' })).toEqual([]);
+ });
+
+ it('answers operators', async () => {
+ const s = await seeded();
+ expect(await ids(s, { operator: 'OR', conditions: [{ uid: 'late@example.org' }, { uid: 'task@example.org' }] })).toEqual(['late', 'task']);
+ expect(await ids(s, { operator: 'AND', conditions: [{ text: 'bob' }, { inCalendar: 'cal2' }] })).toEqual(['lunch']);
+ expect(await ids(s, { operator: 'NOT', conditions: [{ title: 'weekly' }, { title: 'ended' }, { title: 'late' }, { types: ['Task'] }] })).toEqual(['lunch']);
+ });
+
+ it('gives a per-call error for a filter it does not know', async () => {
+ const s = await seeded();
+ const [name, result] = await call(s, 'CalendarEvent/query', { filter: { nonsense: 1 } });
+ expect(name).toBe('error');
+ expect(result.type).toBe('unsupportedFilter');
+ });
+
+ it('reads bounds as wall clock in the request zone', async () => {
+ const s = await seeded();
+ // 23:30 in Berlin on the 10th is 17:30 in New York, and 22:30 UTC.
+ const day10 = { after: '2026-11-10T00:00:00', before: '2026-11-11T00:00:00' };
+ expect(await ids(s, day10, { timeZone: 'Europe/Berlin' })).toContain('late');
+ const night = { after: '2026-11-10T22:45:00', before: '2026-11-10T23:00:00' };
+ expect(await ids(s, night, { timeZone: 'Europe/Berlin' })).not.toContain('late');
+ expect(await ids(s, night)).toContain('late');
+ // A Z says nothing: Stalwart drops it and reads the wall clock.
+ expect(await ids(s, { after: '2026-11-10T22:45:00Z', before: '2026-11-10T23:00:00Z' }, { timeZone: 'Europe/Berlin' })).not.toContain('late');
+ expect(await ids(s, { after: '2026-11-10T17:00:00', before: '2026-11-10T18:00:00' }, { timeZone: 'America/New_York' })).toContain('late');
+ });
+
+ it('matches a series by any occurrence, once, and not when it has none in range', async () => {
+ const s = await seeded();
+ const later = { after: '2026-11-16T00:00:00', before: '2026-11-17T00:00:00' };
+ expect(await ids(s, later)).toEqual(['series']);
+ const bare = { operator: 'AND', conditions: [{ after: later.after }, { before: later.before }] };
+ expect(await ids(s, bare)).toEqual(['series']);
+ // `ended` has two occurrences in January and none after.
+ expect(await ids(s, { after: '2026-01-05T00:00:00', before: '2026-01-06T00:00:00' })).toEqual(['ended', 'series']);
+ expect(await ids(s, { after: '2026-02-02T00:00:00', before: '2026-02-03T00:00:00' })).toEqual(['series']);
+ });
+
+ it('sorts, pages and counts', async () => {
+ const s = await seeded();
+ const sort = [{ property: 'start', isAscending: false }];
+ const [, first] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, calculateTotal: true });
+ expect(first.ids).toEqual(['lunch', 'late']);
+ expect(first.total).toBe(4);
+ const [, second] = await call(s, 'CalendarEvent/query', { filter: { types: ['Event'] }, sort, limit: 2, position: 2 });
+ expect(second.ids.sort()).toEqual(['ended', 'series']);
+ expect(first.canCalculateChanges).toBe(false);
+ });
+
+ it('returns utcStart for the request zone, and reads a floating event in it', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'floating', start: '2026-11-10T10:00:00', timeZone: null, duration: 'PT1H' });
+ s.server.addEvent({ id: 'zoned', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT1H' });
+ await s.engine.requestSync();
+ expect(await getOne(s, 'floating', ['utcStart', 'utcEnd'], 'America/New_York')).toEqual({
+ id: 'floating', utcStart: '2026-11-10T15:00:00Z', utcEnd: '2026-11-10T16:00:00Z',
+ });
+ expect((await getOne(s, 'zoned', ['utcStart'], 'America/New_York'))?.utcStart).toBe('2026-11-10T09:00:00Z');
+ });
+
+ it('answers the task list like the client reads it', async () => {
+ const s = await seeded();
+ const list = await ids(s, undefined, { limit: 1000 });
+ expect(list).toContain('task');
+ const task = await getOne(s, 'task', ['id', '@type', 'due', 'progress', 'title']);
+ expect(task).toMatchObject({ '@type': 'Task', due: '2026-11-12T10:00:00', title: 'Task' });
+ });
+
+ it('answers notifications with nothing rather than failing', async () => {
+ const s = await seeded();
+ const response = await s.engine.answer([
+ ['CalendarEventNotification/query', { accountId: JMAP_ACCOUNT, sort: [] }, '0'],
+ ['CalendarEventNotification/get', { accountId: JMAP_ACCOUNT, '#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' } }, '1'],
+ ]);
+ expect(response!.methodResponses[1][1].list).toEqual([]);
+ });
+
+ it('refuses an expanded query without both bounds, as both servers do', async () => {
+ const s = await seeded();
+ const [name, result] = await call(s, 'CalendarEvent/query', { filter: { after: '2026-11-16T00:00:00' }, expandRecurrences: true });
+ expect(name).toBe('error');
+ expect(result.type).toBe('invalidArguments');
+ });
+});
+
+describe('writes while offline', () => {
+ async function synced(version: StalwartVersion = '1.0') {
+ const s = setup(version);
+ s.server.addEvent({
+ id: 'e1',
+ title: 'Meeting',
+ participants: {
+ me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } },
+ bob: { '@type': 'Participant', calendarAddress: 'mailto:bob@example.org', participationStatus: 'accepted', roles: { attendee: true } },
+ },
+ });
+ await s.engine.requestSync();
+ s.server.calls.length = 0;
+ return s;
+ }
+
+ it('merges edits of one event into one patch, and shows them at once', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'First' } } });
+ await setEvent(s, { update: { e1: { description: 'Added' } } });
+ await setEvent(s, { update: { e1: { title: 'Second' } } });
+ expect(pendingRows(s)).toHaveLength(1);
+ expect(await getOne(s, 'e1')).toMatchObject({ title: 'Second', description: 'Added' });
+
+ await flush(s);
+ const sent = setCalls(s.server);
+ expect(sent).toHaveLength(1);
+ expect((sent[0][1].update as Record).e1).toEqual({ title: 'Second', description: 'Added' });
+ expect(s.server.events.get('e1')).toMatchObject({ title: 'Second', description: 'Added' });
+ expect(pendingRows(s)).toEqual([]);
+ });
+
+ it('merges a pointer into a value set whole, and a whole value over pointers beneath it', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { locations: { a: { name: 'One' } } } } });
+ await setEvent(s, { update: { e1: { 'locations/b': { name: 'Two' } } } });
+ expect(JSON.parse(pendingRows(s)[0].patch)).toEqual({ locations: { a: { name: 'One' }, b: { name: 'Two' } } });
+
+ const other = await synced();
+ await setEvent(other, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } });
+ await setEvent(other, { update: { e1: { participants: { only: { name: 'Solo' } } } } });
+ expect(JSON.parse(pendingRows(other)[0].patch)).toEqual({ participants: { only: { name: 'Solo' } } });
+ });
+
+ it('queues an RSVP as a participant patch and sends it with scheduling on', async () => {
+ const s = await synced();
+ const answer = await setEvent(s, {
+ update: { e1: { 'participants/me/participationStatus': 'accepted' } },
+ sendSchedulingMessages: true,
+ });
+ expect(answer.updated).toEqual({ e1: null });
+ expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'needs-action' } });
+ const shown = await getOne(s, 'e1') as { participants: Record };
+ expect(shown.participants.me.participationStatus).toBe('accepted');
+ expect(shown.participants.bob.participationStatus).toBe('accepted');
+
+ await flush(s);
+ const [, args] = setCalls(s.server)[0];
+ expect(args.sendSchedulingMessages).toBe(true);
+ expect(args.update).toEqual({ e1: { 'participants/me/participationStatus': 'accepted' } });
+ expect(s.server.events.get('e1')?.participants).toMatchObject({ me: { participationStatus: 'accepted' }, bob: { participationStatus: 'accepted' } });
+ });
+
+ it('keeps scheduling on when a later edit does not ask for it', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true });
+ await setEvent(s, { update: { e1: { description: 'Note' } }, sendSchedulingMessages: false });
+ await flush(s);
+ expect(setCalls(s.server)[0][1].sendSchedulingMessages).toBe(true);
+ });
+
+ it('survives a sync while pending: the server copy moves on underneath, the edit stays on top', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Mine' } } });
+ s.server.patchEvent('e1', { description: 'Theirs' });
+ // The delta gets through, the write doesn't.
+ const realRequest = s.server.request.bind(s.server);
+ let refuseWrites = true;
+ s.server.request = async (calls: MethodCall[]) => {
+ if (refuseWrites && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.update && 'e1' in (args.update as object))) {
+ throw new TransportError('network', 'Failed to fetch');
+ }
+ return realRequest(calls);
+ };
+ await s.engine.requestSync();
+ expect(pendingRows(s)).toHaveLength(1);
+ expect(s.server.events.get('e1')).toMatchObject({ title: 'Meeting', description: 'Theirs' });
+ expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' });
+
+ refuseWrites = false;
+ await s.engine.requestSync();
+ expect(await conflicts(s)).toEqual([]);
+ expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' });
+ expect(await getOne(s, 'e1')).toMatchObject({ title: 'Mine', description: 'Theirs' });
+ });
+
+ it('sends an edit that came in during a send, and takes its own first write for its own', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'One' } } });
+ const realRequest = s.server.request.bind(s.server);
+ let once = true;
+ s.server.request = async (calls: MethodCall[]) => {
+ const response = await realRequest(calls);
+ if (once && calls.some(([name]) => name === 'CalendarEvent/set')) {
+ once = false;
+ await setEvent(s, { update: { e1: { title: 'Two', description: 'During' } } });
+ }
+ return response;
+ };
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(s.server.events.get('e1')).toMatchObject({ title: 'Two', description: 'During' });
+ expect(pendingRows(s)).toEqual([]);
+ });
+
+ it('does not take an edit whose answer got lost for a conflict when it comes back through sync', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Sent once' } } });
+ s.server.loseNextSetResponse = true;
+ expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 });
+ expect(s.server.events.get('e1')?.title).toBe('Sent once');
+ expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 });
+ expect(await conflicts(s)).toEqual([]);
+ });
+
+ it('gives out a stand-in id on create that later edits and deletes can use', async () => {
+ const s = await synced();
+ const result = await setEvent(s, { create: { 'new-event': { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' } } });
+ const id = result.created['new-event'].id as string;
+ expect(await getOne(s, id, ['title', 'utcStart', 'uid'])).toMatchObject({ title: 'Fresh', utcStart: '2026-11-20T08:00:00Z' });
+ expect(await queryIds(s, { filter: { after: '2026-11-20T00:00:00', before: '2026-11-21T00:00:00' } })).toEqual([id]);
+
+ await setEvent(s, { update: { [id]: { title: 'Fresher', 'locations/a': { name: 'Here' } } } });
+ expect(pendingRows(s)).toHaveLength(1);
+ expect(pendingRows(s)[0].kind).toBe('create');
+ expect(await getOne(s, id)).toMatchObject({ title: 'Fresher', locations: { a: { name: 'Here' } } });
+
+ await flush(s);
+ const creates = setCalls(s.server).filter(([, args]) => args.create);
+ expect(creates).toHaveLength(1);
+ expect(s.server.events.size).toBe(2);
+ const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresher');
+ expect(onServer?.locations).toEqual({ a: { name: 'Here' } });
+ expect(await getOne(s, id)).toMatchObject({ id: onServer?.id, title: 'Fresher' });
+ expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', [id])).toBeUndefined();
+ });
+
+ it('sends nothing for a create deleted again before it left', async () => {
+ const s = await synced();
+ const result = await setEvent(s, { create: { c: { title: 'Gone', start: '2026-11-20T09:00:00', calendarIds: { cal1: true } } } });
+ const id = result.created.c.id as string;
+ const gone = await setEvent(s, { destroy: [id] });
+ expect(gone.destroyed).toEqual([id]);
+ expect(await getOne(s, id)).toBeUndefined();
+ await flush(s);
+ expect(setCalls(s.server)).toEqual([]);
+ expect(s.server.events.size).toBe(1);
+ });
+
+ it('sends an edit then delete as just the delete, and hides it meanwhile', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Doomed' } } });
+ await setEvent(s, { destroy: ['e1'] });
+ expect(pendingRows(s)[0].kind).toBe('destroy');
+ expect(await getOne(s, 'e1')).toBeUndefined();
+ expect(await queryIds(s, {})).toEqual([]);
+ await flush(s);
+ expect(setCalls(s.server).map(([, args]) => args.destroy)).toEqual([['e1']]);
+ expect(s.server.events.has('e1')).toBe(false);
+ expect(s.db.get('SELECT 1 AS x FROM calendar_event WHERE id = ?', ['e1'])).toBeUndefined();
+ });
+
+ it('takes a delete of an event already gone as done', async () => {
+ const s = await synced();
+ await setEvent(s, { destroy: ['e1'] });
+ s.server.removeEvent('e1');
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(pendingRows(s)).toEqual([]);
+ });
+
+ it('keeps writes across a run without a network and sends them after', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Later' } } });
+ s.server.offline = true;
+ const offline = await flush(s);
+ expect(offline).toMatchObject({ sent: 0, pending: 1 });
+ expect(await getOne(s, 'e1')).toMatchObject({ title: 'Later' });
+ s.server.offline = false;
+ expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 });
+ expect(s.server.events.get('e1')?.title).toBe('Later');
+ });
+
+ it('flushes during a normal sync pass too', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'By sync' } } });
+ await s.engine.requestSync();
+ expect(s.server.events.get('e1')?.title).toBe('By sync');
+ expect(pendingRows(s)).toEqual([]);
+ });
+
+ it('tells the page something changed', async () => {
+ const s = await synced();
+ s.events.length = 0;
+ await setEvent(s, { update: { e1: { title: 'x' } } });
+ expect(s.events).toContainEqual({ type: 'changed', account: 'a@fake.test', collections: ['CalendarEvent'] });
+ });
+});
+
+describe('a create and a lost response', () => {
+ const NEW = { title: 'Once', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' };
+
+ async function queued() {
+ const s = setup();
+ await s.engine.requestSync();
+ s.server.calls.length = 0;
+ const result = await setEvent(s, { create: { c: NEW } });
+ return { s, id: result.created.c.id as string, uid: result.created.c.uid as string };
+ }
+
+ it('gives the event a uid of its own before it goes out', async () => {
+ const { s, uid } = await queued();
+ expect(uid).toBeTruthy();
+ await flush(s);
+ expect([...s.server.events.values()][0].uid).toBe(uid);
+ });
+
+ it('keeps a uid the client chose', async () => {
+ const s = setup();
+ await s.engine.requestSync();
+ const result = await setEvent(s, { create: { c: { ...NEW, uid: 'mine@example.org' } } });
+ expect(result.created.c.uid).toBe('mine@example.org');
+ });
+
+ it('finds the event by uid instead of creating it again', async () => {
+ const { s, id, uid } = await queued();
+ s.server.loseNextSetResponse = true;
+ expect(await flush(s)).toMatchObject({ sent: 0, pending: 1 });
+ expect(s.server.events.size).toBe(1);
+
+ expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 });
+ expect(s.server.events.size).toBe(1);
+ expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1);
+ expect(s.server.calls.some(([name, args]) => name === 'CalendarEvent/query' && (args.filter as { uid?: string })?.uid === uid)).toBe(true);
+ const real = [...s.server.events.keys()][0];
+ expect(await queryIds(s, {})).toEqual([real]);
+ expect(await getOne(s, id)).toMatchObject({ id: real });
+ expect(await conflicts(s)).toEqual([]);
+ });
+
+ it('shows one event when a sync finds the created event before the flush does', async () => {
+ const { s } = await queued();
+ s.server.loseNextSetResponse = true;
+ await flush(s);
+ await s.engine.requestSync();
+ expect((await queryIds(s, {}))).toHaveLength(1);
+ expect(s.server.events.size).toBe(1);
+ });
+
+ it('creates it after all when the request never reached the server', async () => {
+ const { s } = await queued();
+ // The attempt is marked, the network drops before it arrives.
+ const realRequest = s.server.request.bind(s.server);
+ let dropped = false;
+ s.server.request = async (calls: MethodCall[]) => {
+ if (!dropped && calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) {
+ dropped = true;
+ throw new TransportError('network', 'Failed to fetch');
+ }
+ return realRequest(calls);
+ };
+ await flush(s);
+ expect(s.server.events.size).toBe(0);
+ expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 });
+ expect(s.server.events.size).toBe(1);
+ });
+
+ it('treats the uid refusal as "it exists already", should the mark have been lost', async () => {
+ const { s, uid } = await queued();
+ s.db.run('UPDATE calendar_pending SET attempted = 0');
+ s.server.addEvent({ ...NEW, uid, id: 'already' });
+ await flush(s);
+ expect(s.server.events.size).toBe(1);
+ expect(await conflicts(s)).toEqual([]);
+ expect(await queryIds(s, {})).toEqual(['already']);
+ });
+
+ it('follows a create with its delete when it went out before the delete came', async () => {
+ const { s, id } = await queued();
+ s.server.loseNextSetResponse = true;
+ await flush(s);
+ expect(s.server.events.size).toBe(1);
+ await setEvent(s, { destroy: [id] });
+ expect(await queryIds(s, {})).toEqual([]);
+ await flush(s);
+ expect(s.server.events.size).toBe(0);
+ expect(pendingRows(s)).toEqual([]);
+ });
+
+ it('applies a write that came in during the send as a write of its own', async () => {
+ const { s, id } = await queued();
+ const realRequest = s.server.request.bind(s.server);
+ s.server.request = async (calls: MethodCall[]) => {
+ const response = await realRequest(calls);
+ if (calls.some(([name, args]) => name === 'CalendarEvent/set' && args.create)) {
+ await setEvent(s, { update: { [id]: { title: 'Changed meanwhile' } } });
+ }
+ return response;
+ };
+ await flush(s);
+ expect([...s.server.events.values()][0].title).toBe('Changed meanwhile');
+ expect(pendingRows(s)).toEqual([]);
+ });
+});
+
+describe('conflicts', () => {
+ async function synced(version: StalwartVersion = '1.0') {
+ const s = setup(version);
+ s.server.addEvent({ id: 'e1', title: 'Meeting', recurrenceRule: WEEKLY });
+ await s.engine.requestSync();
+ s.server.calls.length = 0;
+ return s;
+ }
+
+ it('lists a patch on a value the server changed too, and does not send it', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Mine' } } });
+ s.server.patchEvent('e1', { title: 'Theirs' });
+ const result = await flush(s);
+ expect(setCalls(s.server)).toEqual([]);
+ expect(result).toMatchObject({ sent: 0, pending: 0 });
+ const [conflict] = await conflicts(s);
+ expect(conflict).toMatchObject({ recordId: 'e1', kind: 'update', reason: 'changedOnServer', local: { title: 'Mine' } });
+ expect(conflict.server).toMatchObject({ title: 'Theirs' });
+ expect(s.server.events.get('e1')?.title).toBe('Theirs');
+ expect(await getOne(s, 'e1')).toMatchObject({ title: 'Theirs' });
+ });
+
+ it('lets an edit through when the server changed something else', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Mine' } } });
+ s.server.patchEvent('e1', { description: 'Theirs' });
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(s.server.events.get('e1')).toMatchObject({ title: 'Mine', description: 'Theirs' });
+ });
+
+ it('does not take the same value on both sides for a conflict', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Same' } } });
+ s.server.patchEvent('e1', { title: 'Same' });
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ });
+
+ it('lets an RSVP through when the organizer changed the title', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'e1', participants: { me: { participationStatus: 'needs-action' } } });
+ await s.engine.requestSync();
+ await setEvent(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } } });
+ s.server.patchEvent('e1', { title: 'New title' });
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(s.server.events.get('e1')).toMatchObject({ title: 'New title', participants: { me: { participationStatus: 'accepted' } } });
+ });
+
+ it('lists a patch the server refuses, with its reason, and goes on with the others', async () => {
+ const s = await synced();
+ s.server.addEvent({ id: 'e2', title: 'Other' });
+ await s.engine.requestSync();
+ await setEvent(s, { update: { e1: { title: 'Refused' } } });
+ await setEvent(s, { update: { e2: { title: 'Fine' } } });
+ s.server.refuse.e1 = { type: 'forbidden', description: 'No rights' };
+ const result = await flush(s);
+ expect(result).toMatchObject({ sent: 1, conflicts: 1, pending: 0 });
+ const [conflict] = await conflicts(s);
+ expect(conflict).toMatchObject({ recordId: 'e1', reason: 'refused', errorType: 'forbidden', description: 'No rights', local: { title: 'Refused' } });
+ expect(await getOne(s, 'e1')).toMatchObject({ title: 'Meeting' });
+ expect(s.server.events.get('e2')?.title).toBe('Fine');
+ });
+
+ it('lists an edit of an event the server deleted, and drops the event', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Too late' } } });
+ s.server.removeEvent('e1');
+ await flush(s);
+ const [conflict] = await conflicts(s);
+ expect(conflict).toMatchObject({ reason: 'destroyedOnServer', local: { title: 'Too late' }, server: null });
+ expect(await getOne(s, 'e1')).toBeUndefined();
+ });
+
+ it('lists a delete of an event the server changed, and brings the event back', async () => {
+ const s = await synced();
+ await setEvent(s, { destroy: ['e1'] });
+ s.server.patchEvent('e1', { title: 'Still wanted' });
+ await flush(s);
+ expect(s.server.events.has('e1')).toBe(true);
+ expect((await conflicts(s))[0]).toMatchObject({ kind: 'destroy', reason: 'changedOnServer' });
+ expect(await getOne(s, 'e1')).toMatchObject({ title: 'Still wanted' });
+ });
+
+ it('lists a create the server refuses and takes the stand-in away', async () => {
+ const s = await synced();
+ const result = await setEvent(s, { create: { c: { title: 'Bad', calendarIds: { nope: true }, start: '2026-11-20T09:00:00' } } });
+ s.server.refuse.c = { type: 'invalidProperties', description: 'calendarIds', properties: ['calendarIds'] };
+ await flush(s);
+ const [conflict] = await conflicts(s);
+ expect(conflict).toMatchObject({ kind: 'create', reason: 'refused', local: { title: 'Bad' } });
+ expect(await getOne(s, result.created.c.id)).toBeUndefined();
+ expect(pendingRows(s)).toEqual([]);
+ });
+
+ it('retries without series keys when 1.0 refuses them inside an override, instead of a conflict', async () => {
+ const s = setup('1.0');
+ s.server.addEvent({ id: 'e1', recurrenceRule: WEEKLY, privacy: 'private' });
+ await s.engine.requestSync();
+ s.server.calls.length = 0;
+ await setEvent(s, { update: { e1: { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'One', privacy: 'private' } } } } });
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(setCalls(s.server)).toHaveLength(2);
+ expect(s.server.events.get('e1')?.recurrenceOverrides).toEqual({ '2026-11-17T10:00:00': { title: 'One' } });
+ });
+
+ it('keeps a write for later when the server has a bad moment', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Soon' } } });
+ const realRequest = s.server.request.bind(s.server);
+ let failed = false;
+ s.server.request = async (calls: MethodCall[]) => {
+ if (!failed && calls.some(([name]) => name === 'CalendarEvent/set')) {
+ failed = true;
+ return { methodResponses: [['error', { type: 'serverFail' }, 's']] };
+ }
+ return realRequest(calls);
+ };
+ expect(await flush(s)).toMatchObject({ sent: 0, conflicts: 0, pending: 1 });
+ expect(await conflicts(s)).toEqual([]);
+ expect(await flush(s)).toMatchObject({ sent: 1, pending: 0 });
+ });
+
+ it('lists a method error that means the write will not work', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'x' } } });
+ const realRequest = s.server.request.bind(s.server);
+ s.server.request = async (calls: MethodCall[]) => {
+ if (calls.some(([name]) => name === 'CalendarEvent/set')) return { methodResponses: [['error', { type: 'accountReadOnly' }, 's']] };
+ return realRequest(calls);
+ };
+ expect(await flush(s)).toMatchObject({ conflicts: 1, pending: 0 });
+ expect((await conflicts(s))[0].errorType).toBe('accountReadOnly');
+ });
+
+ it('can dismiss a conflict', async () => {
+ const s = await synced();
+ await setEvent(s, { update: { e1: { title: 'Mine' } } });
+ s.server.patchEvent('e1', { title: 'Theirs' });
+ await flush(s);
+ const [conflict] = await conflicts(s);
+ await s.engine.areaCall('calendar.dismissConflict', { id: conflict.id });
+ expect(await conflicts(s)).toEqual([]);
+ });
+
+ it('keeps a pending edit through a resync, and lists it when the event is gone', async () => {
+ const s = await synced();
+ s.server.addEvent({ id: 'e2', title: 'Stays' });
+ await s.engine.requestSync();
+ await setEvent(s, { update: { e1: { title: 'Mine' } } });
+ await setEvent(s, { update: { e2: { title: 'Mine too' } } });
+ s.server.events.delete('e1');
+ s.server.eventLog.push('destroyed', 'e1');
+ s.server.eventLog.truncatedBelow = s.server.eventLog.log.length;
+ await s.engine.requestSync();
+ const [conflict] = await conflicts(s);
+ expect(conflict).toMatchObject({ recordId: 'e1', reason: 'destroyedOnServer' });
+ expect(s.server.events.get('e2')?.title).toBe('Mine too');
+ });
+});
+
+describe('the synthetic-id probe', () => {
+ const probe = { update: { h333333: {} } };
+
+ it('tells a client what the server said at the last sync', async () => {
+ for (const version of ['1.0', '0.16'] as const) {
+ const s = setup(version);
+ await s.engine.requestSync();
+ expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound');
+ }
+ const old = setup('0.16.19');
+ await old.engine.requestSync();
+ const refusal = (await setEvent(old, probe)).notUpdated.h333333;
+ expect(refusal.type).toBe('invalidProperties');
+ expect(refusal.description).toMatch(/synthetic ids is not yet supported/);
+ });
+
+ it('says not supported before any sync, because client-side expansion works on every server', async () => {
+ const s = setup();
+ const refusal = (await setEvent(s, probe)).notUpdated.h333333;
+ expect(refusal.type).toBe('invalidProperties');
+ });
+
+ it('queues nothing for the probe', async () => {
+ const s = setup();
+ await s.engine.requestSync();
+ await setEvent(s, probe);
+ expect(pendingRows(s)).toEqual([]);
+ });
+
+ it('runs the probe on the sync request, and follows a server that was upgraded', async () => {
+ const s = setup('0.16.19');
+ await s.engine.requestSync();
+ expect(s.server.requests).toBeGreaterThan(0);
+ expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('invalidProperties');
+ Object.defineProperty(s.server, 'version', { value: '1.0' });
+ await s.engine.requestSync();
+ expect((await setEvent(s, probe)).notUpdated.h333333.type).toBe('notFound');
+ });
+});
+
+describe('occurrence writes for ids the store does not hold', () => {
+ it('refuses them the way an old server does, so the client writes on the base event', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'series', recurrenceRule: WEEKLY });
+ await s.engine.requestSync();
+ const update = await setEvent(s, { update: { h20261110series: { title: 'x' } } });
+ expect(update.notUpdated.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i);
+ const destroy = await setEvent(s, { destroy: ['h20261110series'] });
+ expect(destroy.notDestroyed.h20261110series.description).toMatch(/synthetic ids?\b.*\bnot (?:yet )?supported/i);
+ expect(pendingRows(s)).toEqual([]);
+ });
+});
+
+describe('reminders from stored events', () => {
+ const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } };
+ const FROM = Date.UTC(2026, 10, 10);
+ const TO = Date.UTC(2026, 10, 11);
+ const OPTIONS = { from: FROM, to: TO, timeZone: 'Europe/Berlin' };
+
+ it('lists an occurrence of a series, keyed by the master, with the time from its own zone', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'series', start: '2026-01-06T10:00:00', recurrenceRule: WEEKLY, alerts: { a: ALERT } });
+ await s.engine.requestSync();
+ const alerts = listUpcomingAlerts(s.db, OPTIONS);
+ expect(alerts).toHaveLength(1);
+ const fire = Date.UTC(2026, 10, 10, 8, 45);
+ expect(alerts[0]).toMatchObject({
+ baseEventId: 'series', alertId: 'a', fireTimeMs: fire, recurrenceId: '2026-11-10T10:00:00', key: `series:a:${fire}`, kind: 'event', jmap: JMAP_ACCOUNT,
+ });
+ });
+
+ it('follows a moved occurrence and skips an excluded one', async () => {
+ const s = setup();
+ s.server.addEvent({
+ id: 'moved',
+ start: '2026-01-06T10:00:00',
+ recurrenceRule: WEEKLY,
+ alerts: { a: ALERT },
+ recurrenceOverrides: { '2026-11-10T10:00:00': { start: '2026-11-10T15:00:00' } },
+ });
+ s.server.addEvent({
+ id: 'excluded',
+ start: '2026-01-06T10:00:00',
+ recurrenceRule: WEEKLY,
+ alerts: { a: ALERT },
+ recurrenceOverrides: { '2026-11-10T10:00:00': { excluded: true } },
+ });
+ await s.engine.requestSync();
+ const alerts = listUpcomingAlerts(s.db, OPTIONS);
+ expect(alerts.map((alert) => [alert.baseEventId, alert.fireTimeMs])).toEqual([['moved', Date.UTC(2026, 10, 10, 13, 45)]]);
+ });
+
+ it('finds an alert days before its occurrence, and a fixed-time alert of a series', async () => {
+ const s = setup();
+ s.server.addEvent({
+ id: 'days',
+ start: '2026-11-13T10:00:00',
+ alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } },
+ });
+ s.server.addEvent({
+ id: 'days-series',
+ start: '2026-01-06T10:00:00',
+ recurrenceRule: { frequency: 'yearly', byMonth: ['11'], byMonthDay: [13] },
+ alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P3D' } } },
+ });
+ s.server.addEvent({
+ id: 'fixed',
+ start: '2026-01-06T10:00:00',
+ recurrenceRule: WEEKLY,
+ alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: '2026-11-10T06:00:00Z' } } },
+ });
+ await s.engine.requestSync();
+ const alerts = listUpcomingAlerts(s.db, OPTIONS);
+ expect(alerts.map((alert) => alert.baseEventId).sort()).toEqual(['days', 'days-series', 'fixed']);
+ expect(alerts.filter((alert) => alert.baseEventId === 'fixed')).toHaveLength(1);
+ });
+
+ it('uses the calendar defaults while the event does, and reads a floating event in the given zone', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'def', start: '2026-11-10T10:00:00', timeZone: null, useDefaultAlerts: true });
+ await s.engine.requestSync();
+ const [alert] = listUpcomingAlerts(s.db, OPTIONS);
+ expect(alert).toMatchObject({ baseEventId: 'def', alertId: 'd1', calendarName: 'Personal', fireTimeMs: Date.UTC(2026, 10, 10, 8, 45) });
+ expect(listUpcomingAlerts(s.db, { ...OPTIONS, timeZone: 'UTC' })[0].fireTimeMs).toBe(Date.UTC(2026, 10, 10, 9, 45));
+ });
+
+ it('leaves out what should not fire, and what is outside the window', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'email', alerts: { a: { ...ALERT, action: 'email' } } });
+ s.server.addEvent({ id: 'seen', alerts: { a: { ...ALERT, acknowledged: '2026-11-01T00:00:00Z' } } });
+ s.server.addEvent({ id: 'off', status: 'cancelled', alerts: { a: ALERT } });
+ s.server.addEvent({ id: 'later', start: '2026-12-10T10:00:00', alerts: { a: ALERT } });
+ s.server.addEvent({ id: 'task-done', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00', progress: 'completed', alerts: { a: ALERT } });
+ await s.engine.requestSync();
+ expect(listUpcomingAlerts(s.db, OPTIONS)).toEqual([]);
+ });
+
+ it('lists a task, and an edit or a delete made offline', async () => {
+ const s = setup();
+ s.server.addEvent({ id: 'task', '@type': 'Task', start: undefined, due: '2026-11-10T10:00:00Z', alerts: { a: ALERT } });
+ s.server.addEvent({ id: 'moved', start: '2026-12-10T10:00:00', alerts: { a: ALERT } });
+ s.server.addEvent({ id: 'dropped', start: '2026-11-10T11:00:00', alerts: { a: ALERT } });
+ await s.engine.requestSync();
+ await setEvent(s, { update: { moved: { start: '2026-11-10T09:00:00' } } });
+ await setEvent(s, { destroy: ['dropped'] });
+ expect(listUpcomingAlerts(s.db, OPTIONS).map((alert) => [alert.baseEventId, alert.kind])).toEqual([['moved', 'event'], ['task', 'task']]);
+ });
+});
+
+it('uses only capabilities the session lists', async () => {
+ const s = setup();
+ s.server.addEvent();
+ const usings: string[][] = [];
+ const realRequest = s.server.request.bind(s.server) as (calls: MethodCall[]) => ReturnType;
+ s.server.request = ((calls: MethodCall[], using: string[]) => {
+ usings.push(using);
+ return realRequest(calls);
+ }) as FakeCalendarJmap['request'];
+ await s.engine.requestSync();
+ for (const using of usings) expect(using).toEqual(['urn:ietf:params:jmap:core', CAL]);
+});
diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts
new file mode 100644
index 000000000..d3c9860b7
--- /dev/null
+++ b/lib/store/__tests__/helpers/client-wire.ts
@@ -0,0 +1,79 @@
+/**
+ * A real JMAPClient talking to a FakeCalendarJmap while "online" and to the
+ * store's answers while "offline", through a stubbed fetch.
+ */
+
+import { vi } from 'vitest';
+import { JMAPClient } from '@/lib/jmap/client';
+import { DEFAULT_SCOPE, type StoreArea } from '../../area';
+import { AccountEngine } from '../../engine/account-engine';
+import type { MethodCall } from '../../engine/transport';
+import { CALENDAR_SESSION, FakeCalendarJmap } from './fake-calendar';
+import { JMAP_ACCOUNT } from './fake-jmap';
+import { openMemoryStore } from './node-adapter';
+
+const CLIENT_SESSION = {
+ ...CALENDAR_SESSION,
+ primaryAccounts: { ...CALENDAR_SESSION.primaryAccounts, 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT },
+ accounts: {
+ [JMAP_ACCOUNT]: {
+ name: 'a@example.org',
+ isPersonal: true,
+ accountCapabilities: { ...CALENDAR_SESSION.accounts[JMAP_ACCOUNT].accountCapabilities, 'urn:ietf:params:jmap:mail': {} },
+ },
+ },
+ capabilities: { ...CALENDAR_SESSION.capabilities, 'urn:ietf:params:jmap:mail': {} },
+ eventSourceUrl: '',
+};
+
+function json(body: unknown): Response {
+ return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
+}
+
+export interface Wire {
+ server: FakeCalendarJmap;
+ engine: AccountEngine;
+ db: ReturnType;
+ /** Everything the client sent, wherever it went. */
+ sent: MethodCall[];
+ online: boolean;
+ newClient(): Promise;
+ restore(): void;
+}
+
+export function wire(server: FakeCalendarJmap, areas: readonly StoreArea[], now: number): Wire {
+ const db = openMemoryStore();
+ const engine = new AccountEngine({
+ account: 'a@fake.test',
+ db,
+ session: CALENDAR_SESSION,
+ transport: server,
+ areas,
+ scope: DEFAULT_SCOPE,
+ now: () => now,
+ sleep: async () => {},
+ });
+ const spy = vi.spyOn(globalThis, 'fetch');
+ const state: Wire = {
+ server,
+ engine,
+ db,
+ sent: [],
+ online: true,
+ newClient: async () => {
+ const client = new JMAPClient('https://fake.test', 'a@example.org', 'pw');
+ await client.connect();
+ return client;
+ },
+ restore: () => spy.mockRestore(),
+ };
+ spy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
+ if (!init?.body) return json(CLIENT_SESSION);
+ const calls = (JSON.parse(String(init.body)) as { methodCalls: MethodCall[] }).methodCalls;
+ state.sent.push(...calls);
+ const answer = state.online ? await server.request(calls) : await engine.answer(calls);
+ if (!answer) throw new TypeError('Failed to fetch');
+ return json(answer);
+ });
+ return state;
+}
diff --git a/lib/store/__tests__/helpers/fake-calendar.ts b/lib/store/__tests__/helpers/fake-calendar.ts
new file mode 100644
index 000000000..752017d57
--- /dev/null
+++ b/lib/store/__tests__/helpers/fake-calendar.ts
@@ -0,0 +1,365 @@
+/**
+ * FakeJmap with calendars, modelled on what was measured on both Stalwarts
+ * (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md section 7, and
+ * the probe run in desktop-offline-decisions.md):
+ *
+ * - /changes lists masters only. An edit through a synthetic id gives
+ * `updated: [master]`.
+ * - A plain query returns masters. An expanded one needs both bounds, says
+ * canCalculateChanges false on 1.0 and true on 0.16, refuses more than P52W1D
+ * on 1.0, and gives a single event a synthetic id on 0.16 only.
+ * - 1.0 refuses recurrenceOverrides next to utcStart in one get, and series keys
+ * inside an override. `recurrenceRules` is an invalid property on both.
+ * - A pointer into overrides fails while a series has none.
+ * - A second event with one uid is refused, uid filters match exactly.
+ */
+
+import { normalizeCalendarEventLike } from '@/lib/calendar-event-normalization';
+import { applyPatchObject } from '@/lib/jmap/patch-object';
+import type { CalendarEvent } from '@/lib/jmap/types';
+import { expandRecurringEvents } from '@/lib/recurrence-expansion';
+import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances';
+import { TransportError, type JmapResponse, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport';
+import { Collection, FakeJmap, JMAP_ACCOUNT } from './fake-jmap';
+
+export const CAL = 'urn:ietf:params:jmap:calendars';
+
+export const CALENDAR_SESSION: StoreSession = {
+ apiUrl: 'https://fake.test/jmap/',
+ downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}',
+ uploadUrl: 'https://fake.test/u/{accountId}',
+ primaryAccounts: { [CAL]: JMAP_ACCOUNT },
+ accounts: {
+ [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CAL]: { maxExpandedQueryDuration: 'P52W1D' } } },
+ },
+ capabilities: { 'urn:ietf:params:jmap:core': {}, [CAL]: {} },
+};
+
+export type FakeEvent = Record & { id: string };
+export type StalwartVersion = '1.0' | '0.16' | '0.16.19';
+
+function jmapError(type: string, description?: string): Error {
+ return Object.assign(new Error(type), { jmapError: type, description });
+}
+
+const SERIES_KEYS_IN_OVERRIDE = ['privacy', 'organizerCalendarAddress'];
+
+interface Instance {
+ id: string;
+ master: FakeEvent;
+ recurrenceId: string | null;
+}
+
+export class FakeCalendarJmap extends FakeJmap {
+ readonly events = new Map();
+ readonly calendars = new Map & { id: string }>();
+ readonly identities: Array> = [{ id: 'pi1', name: 'A', calendarAddress: 'mailto:a@example.org', isDefault: true }];
+ readonly eventLog = new Collection();
+ private readonly instances = new Map();
+ private nextEvent = 1;
+ /** The next create or update reaches the server, but its answer doesn't reach the client. */
+ loseNextSetResponse = false;
+ /** The next CalendarEvent/set refuses this id like a server would. */
+ refuse: Record = {};
+
+ constructor(readonly version: StalwartVersion = '1.0') {
+ super();
+ this.calendars.set('cal1', {
+ id: 'cal1', name: 'Personal', isDefault: true, myRights: { mayWriteAll: true },
+ defaultAlertsWithTime: { d1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } },
+ });
+ }
+
+ addEvent(partial: Partial = {}): FakeEvent {
+ const id = partial.id ?? `ev${this.nextEvent++}`;
+ const event: FakeEvent = {
+ '@type': 'Event',
+ uid: `${id}@example.org`,
+ calendarIds: { cal1: true },
+ title: `Event ${id}`,
+ start: '2026-11-10T10:00:00',
+ duration: 'PT1H',
+ timeZone: 'Europe/Berlin',
+ baseEventId: null,
+ isOrigin: true,
+ useDefaultAlerts: false,
+ ...partial,
+ id,
+ };
+ this.events.set(id, event);
+ this.eventLog.push('created', id);
+ return event;
+ }
+
+ /** Someone else changes an event. */
+ patchEvent(id: string, patch: Record): void {
+ this.events.set(id, applyPatchObject(this.events.get(id) as FakeEvent, patch));
+ this.eventLog.push('updated', id);
+ }
+
+ removeEvent(id: string): void {
+ this.events.delete(id);
+ this.eventLog.push('destroyed', id);
+ }
+
+ private expanded(after: string, before: string): Instance[] {
+ const out: Instance[] = [];
+ for (const master of this.events.values()) {
+ const normal = normalizeCalendarEventLike(master as unknown as CalendarEvent);
+ if (!normal.recurrenceRules?.length) {
+ const start = String(master.start);
+ if (start < after || start >= before) continue;
+ out.push({ id: this.version === '1.0' ? master.id : `e${master.id}`, master, recurrenceId: null });
+ continue;
+ }
+ for (const occurrence of expandRecurringEvents([normal], after, before)) {
+ out.push({
+ id: `h${String(occurrence.recurrenceId).replace(/\D/g, '')}${master.id}`,
+ master,
+ recurrenceId: occurrence.recurrenceId as string,
+ });
+ }
+ }
+ for (const instance of out) this.instances.set(instance.id, instance);
+ return out;
+ }
+
+ private instanceView(instance: Instance): FakeEvent {
+ const { master, recurrenceId } = instance;
+ if (!recurrenceId) return { ...master, id: instance.id, baseEventId: this.version === '1.0' ? null : master.id };
+ const override = ((master.recurrenceOverrides ?? {}) as Record>)[recurrenceId] ?? {};
+ const { recurrenceRule: _r, recurrenceOverrides: _o, ...rest } = master;
+ return {
+ ...rest,
+ start: recurrenceId,
+ ...override,
+ id: instance.id,
+ baseEventId: master.id,
+ recurrenceId,
+ recurrenceRule: null,
+ };
+ }
+
+ private query(args: Record): Record {
+ const filter = (args.filter ?? {}) as { uid?: string; after?: string; before?: string; inCalendar?: string; operator?: string };
+ const position = Number(args.position ?? 0);
+ const limit = Number(args.limit ?? 5000) || 5000;
+ const page = (ids: string[], canCalculateChanges: boolean) => ({
+ accountId: args.accountId,
+ queryState: this.eventLog.state(),
+ canCalculateChanges,
+ position,
+ ids: ids.slice(position, position + limit),
+ });
+ if (args.expandRecurrences) {
+ if (!filter.after || !filter.before) throw jmapError('invalidArguments', 'Expansion needs after and before');
+ const days = (Date.parse(`${filter.before}Z`) - Date.parse(`${filter.after}Z`)) / 86_400_000;
+ if (this.version === '1.0' && days > 365) throw jmapError('expandDurationTooLarge');
+ const list = this.expanded(filter.after, filter.before);
+ return page(list.map((instance) => instance.id), this.version !== '1.0');
+ }
+ const masters = [...this.events.values()].filter((event) => {
+ if (filter.uid !== undefined && event.uid !== filter.uid) return false;
+ if (filter.inCalendar && !(event.calendarIds as Record)[filter.inCalendar]) return false;
+ return true;
+ });
+ return page(masters.map((event) => event.id), true);
+ }
+
+ private get(args: Record): Record {
+ const properties = args.properties as string[] | undefined;
+ if (properties?.includes('recurrenceRules')) throw jmapError('invalidArguments', 'recurrenceRules');
+ if (this.version === '1.0' && properties?.includes('recurrenceOverrides')
+ && (properties.includes('utcStart') || properties.includes('utcEnd'))) {
+ throw jmapError('invalidArguments', 'recurrenceOverrides cannot be requested together with utcStart or utcEnd');
+ }
+ const ids = (args.ids as string[] | null) ?? [...this.events.keys()].slice(0, 500);
+ if (ids.length > 500) throw jmapError('requestTooLarge');
+ const list: Record[] = [];
+ const notFound: string[] = [];
+ for (const id of ids) {
+ const instance = this.instances.get(id);
+ const event = instance && this.events.has(instance.master.id)
+ ? this.instanceView({ ...instance, master: this.events.get(instance.master.id) as FakeEvent })
+ : this.events.get(id);
+ if (!event) {
+ notFound.push(id);
+ continue;
+ }
+ const full = { ...event, utcStart: `${String(event.start)}Z`, utcEnd: `${String(event.start)}Z` };
+ if (!properties) {
+ list.push({ ...event });
+ continue;
+ }
+ const out: Record = { id };
+ for (const name of properties) if (name in full) out[name] = (full as Record)[name];
+ list.push(out);
+ }
+ return { accountId: args.accountId, state: this.eventLog.state(), list, notFound };
+ }
+
+ private seriesKeyInOverride(patch: Record): string | null {
+ if (this.version !== '1.0') return null;
+ for (const [key, value] of Object.entries(patch)) {
+ if (key !== 'recurrenceOverrides' && !key.startsWith('recurrenceOverrides/')) continue;
+ const entries = key === 'recurrenceOverrides' ? Object.values(value as object) : [value];
+ for (const entry of entries) {
+ for (const name of SERIES_KEYS_IN_OVERRIDE) {
+ if (entry && typeof entry === 'object' && name in entry) return name;
+ }
+ }
+ }
+ return null;
+ }
+
+ private update(id: string, patch: Record): { type: string; description?: string; properties?: string[] } | null {
+ if (this.refuse[id]) {
+ const refusal = this.refuse[id];
+ delete this.refuse[id];
+ return refusal;
+ }
+ if (id === SYNTHETIC_ID_PROBE) {
+ return this.version === '0.16.19'
+ ? { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] }
+ : { type: 'notFound' };
+ }
+ const instance = this.instances.get(id);
+ if (instance?.recurrenceId) {
+ if (this.version === '0.16.19') {
+ return { type: 'invalidProperties', description: 'Updating synthetic ids is not yet supported.', properties: ['id'] };
+ }
+ const master = this.events.get(instance.master.id);
+ if (!master) return { type: 'notFound' };
+ const overrides = { ...(master.recurrenceOverrides as object | undefined) } as Record>;
+ overrides[instance.recurrenceId] = { ...overrides[instance.recurrenceId], ...patch };
+ this.patchEvent(master.id, { recurrenceOverrides: overrides });
+ return null;
+ }
+ const event = this.events.get(id);
+ if (!event) return { type: 'notFound' };
+ const seriesKey = this.seriesKeyInOverride(patch);
+ if (seriesKey) {
+ return { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] };
+ }
+ const pointsIntoNothing = Object.keys(patch).some((key) => key.startsWith('recurrenceOverrides/')) && !event.recurrenceOverrides;
+ if (pointsIntoNothing) return { type: this.version === '1.0' ? 'invalidPatch' : 'invalidProperties', description: 'Patch operation failed.' };
+ this.patchEvent(id, patch);
+ return null;
+ }
+
+ private set(args: Record): Record {
+ const out: Record = { accountId: args.accountId, oldState: this.eventLog.state() };
+ const created: Record = {};
+ const notCreated: Record = {};
+ for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) {
+ const seriesKey = this.seriesKeyInOverride(event);
+ if (seriesKey) {
+ notCreated[key] = { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] };
+ } else if (typeof event.uid === 'string' && [...this.events.values()].some((other) => other.uid === event.uid)) {
+ notCreated[key] = { type: 'invalidProperties', description: `An event with UID ${event.uid} already exists.`, properties: ['uid'] };
+ } else if (this.refuse[key]) {
+ notCreated[key] = this.refuse[key];
+ delete this.refuse[key];
+ } else {
+ created[key] = { id: this.addEvent(event).id };
+ }
+ }
+ const updated: Record = {};
+ const notUpdated: Record = {};
+ for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) {
+ const refusal = this.update(id, patch);
+ if (refusal) notUpdated[id] = refusal;
+ else updated[id] = null;
+ }
+ const destroyed: string[] = [];
+ const notDestroyed: Record = {};
+ for (const id of (args.destroy ?? []) as string[]) {
+ const instance = this.instances.get(id);
+ if (instance?.recurrenceId && this.events.has(instance.master.id)) {
+ const master = this.events.get(instance.master.id) as FakeEvent;
+ this.patchEvent(master.id, {
+ recurrenceOverrides: { ...(master.recurrenceOverrides as object | undefined), [instance.recurrenceId]: { excluded: true } },
+ });
+ destroyed.push(id);
+ } else if (this.refuse[id]) {
+ notDestroyed[id] = this.refuse[id];
+ delete this.refuse[id];
+ } else if (this.events.has(id)) {
+ this.removeEvent(id);
+ destroyed.push(id);
+ } else {
+ notDestroyed[id] = { type: 'notFound' };
+ }
+ }
+ if (Object.keys(created).length > 0) out.created = created;
+ if (Object.keys(notCreated).length > 0) out.notCreated = notCreated;
+ if (Object.keys(updated).length > 0) out.updated = updated;
+ if (Object.keys(notUpdated).length > 0) out.notUpdated = notUpdated;
+ if (destroyed.length > 0) out.destroyed = destroyed;
+ if (Object.keys(notDestroyed).length > 0) out.notDestroyed = notDestroyed;
+ out.newState = this.eventLog.state();
+ return out;
+ }
+
+ private answerCalendarCall(name: string, args: Record): Record {
+ switch (name) {
+ case 'Calendar/get':
+ return { accountId: args.accountId, state: 'cs1', list: [...this.calendars.values()], notFound: [] };
+ case 'ParticipantIdentity/get':
+ return { accountId: args.accountId, list: this.identities, notFound: [] };
+ case 'CalendarEvent/query':
+ return this.query(args);
+ case 'CalendarEvent/get':
+ return this.get(args);
+ case 'CalendarEvent/set':
+ return this.set(args);
+ case 'CalendarEvent/changes': {
+ const since = String(args.sinceState);
+ if (!/^s\d+$/.test(since)) throw jmapError('invalidArguments');
+ const from = Number(since.slice(1));
+ if (from < this.eventLog.truncatedBelow || from > this.eventLog.log.length) throw jmapError('cannotCalculateChanges');
+ const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000);
+ return { accountId: args.accountId, oldState: since, ...this.eventLog.changes(since, maxChanges) };
+ }
+ default:
+ throw jmapError('unknownMethod');
+ }
+ }
+
+ override async request(calls: MethodCall[]): Promise {
+ if (!calls.every(([name]) => /^(Calendar|CalendarEvent|ParticipantIdentity)\//.test(name))) return super.request(calls);
+ this.requests += 1;
+ this.beforeRequest?.(this.requests);
+ if (this.offline) throw new TransportError('network', 'Failed to fetch');
+ if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit');
+ const done: MethodResponse[] = [];
+ let lose = false;
+ for (const call of calls) {
+ this.calls.push(call);
+ const [name, raw, id] = call;
+ const args: Record = {};
+ for (const [key, value] of Object.entries(raw)) {
+ if (key.startsWith('#')) {
+ const ref = value as { resultOf: string; path: string };
+ const source = done.find(([, , callId]) => callId === ref.resultOf);
+ args[key.slice(1)] = source?.[1][ref.path.slice(1)];
+ } else {
+ args[key] = value;
+ }
+ }
+ try {
+ done.push([name, this.answerCalendarCall(name, args), id]);
+ if (name === 'CalendarEvent/set' && (raw.create || raw.update) && this.loseNextSetResponse) lose = true;
+ } catch (error) {
+ const type = (error as { jmapError?: string }).jmapError;
+ if (!type) throw error;
+ done.push(['error', { type, description: (error as { description?: string }).description }, id]);
+ }
+ }
+ if (lose) {
+ this.loseNextSetResponse = false;
+ throw new TransportError('network', 'The connection dropped before the answer came');
+ }
+ return { methodResponses: done };
+ }
+}
diff --git a/lib/store/__tests__/helpers/fake-jmap.ts b/lib/store/__tests__/helpers/fake-jmap.ts
index 9aa917be5..bac572400 100644
--- a/lib/store/__tests__/helpers/fake-jmap.ts
+++ b/lib/store/__tests__/helpers/fake-jmap.ts
@@ -25,7 +25,7 @@ type ChangeKind = 'created' | 'updated' | 'destroyed';
interface Log { kind: ChangeKind; id: string }
-class Collection {
+export class Collection {
log: Log[] = [];
/** Below this log position, states are refused as truncated. */
truncatedBelow = 0;
diff --git a/lib/store/area.ts b/lib/store/area.ts
index fceed110e..b73740b5b 100644
--- a/lib/store/area.ts
+++ b/lib/store/area.ts
@@ -96,9 +96,10 @@ export type MethodHandler = (ctx: MethodContext, args: Record,
/**
* A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached
* from any tab with `store.area(account, 'outbox.list', params)`. Names are
- * unique across areas and start with the area's name.
+ * unique across areas and start with the area's name. It gets the sync context,
+ * because some calls talk to the server (`calendar.flush`).
*/
-export type AreaCall = (ctx: StoreContext, params: never) => unknown | Promise;
+export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise;
export interface StoreArea {
name: string;
diff --git a/lib/store/areas.ts b/lib/store/areas.ts
index 6bff665ca..30a6541ff 100644
--- a/lib/store/areas.ts
+++ b/lib/store/areas.ts
@@ -1,6 +1,7 @@
/** Every part of the local store. New areas go here. */
import type { StoreArea } from './area';
+import { calendarArea } from './calendar';
import { mailArea } from './mail';
-export const STORE_AREAS: readonly StoreArea[] = [mailArea];
+export const STORE_AREAS: readonly StoreArea[] = [mailArea, calendarArea];
diff --git a/lib/store/calendar/filter.ts b/lib/store/calendar/filter.ts
new file mode 100644
index 000000000..41a29ffd3
--- /dev/null
+++ b/lib/store/calendar/filter.ts
@@ -0,0 +1,220 @@
+/**
+ * CalendarEvent/query filters, read the way Stalwart reads them: after and
+ * before are LocalDateTimes in the request's time zone, and a series matches
+ * when any occurrence is in the range. Text matches parts of words, where the
+ * server only matches whole ones: offline search finds more, not less.
+ */
+
+import { addDays, format, parseISO } from 'date-fns';
+import { isTaskLikeObject } from '@/lib/calendar-component-detection';
+import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization';
+import type { CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types';
+import { expandRecurringEvents } from '@/lib/recurrence-expansion';
+import { MethodError } from '../area';
+import { instantOf, instantsOf, isSeries, wallClock, type CalendarObject } from './records';
+
+export interface RangeBounds {
+ after?: string;
+ before?: string;
+}
+
+/** The range of a filter, in the two shapes the client sends it: plain keys, or an AND of one-key conditions. */
+export function boundsOf(filter: CalendarEventFilter): RangeBounds {
+ if (filter.operator === 'AND') {
+ const bounds: RangeBounds = {};
+ for (const condition of filter.conditions ?? []) {
+ if (condition.operator || Object.keys(condition).length !== 1) continue;
+ if (condition.after) bounds.after = condition.after;
+ if (condition.before) bounds.before = condition.before;
+ }
+ return bounds;
+ }
+ if (filter.operator) return {};
+ return { after: filter.after, before: filter.before };
+}
+
+function fold(text: string): string {
+ return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
+}
+
+function containsWords(haystack: Array, needle: string): boolean {
+ const text = fold(haystack.filter((part): part is string => typeof part === 'string').join('\n'));
+ return fold(needle).split(/\s+/).filter(Boolean).every((word) => text.includes(word));
+}
+
+type Participant = {
+ name?: string;
+ email?: string;
+ calendarAddress?: string;
+ participationStatus?: string;
+ roles?: Record;
+};
+
+function participantsOf(object: CalendarObject): Participant[] {
+ return Object.values((object.participants ?? {}) as Record);
+}
+
+function placeTexts(object: CalendarObject): unknown[] {
+ const places = Object.values((object.locations ?? {}) as Record);
+ const virtual = Object.values((object.virtualLocations ?? {}) as Record);
+ return [...places, ...virtual].flatMap((place) => [place.name, place.description]);
+}
+
+function personTexts(participant: Participant): unknown[] {
+ return [participant.name, participant.email, participant.calendarAddress];
+}
+
+function isOwner(participant: Participant): boolean {
+ return participant.roles?.owner === true;
+}
+
+function isAttendee(participant: Participant): boolean {
+ return !participant.roles || participant.roles.attendee === true;
+}
+
+function ownerTexts(object: CalendarObject): unknown[] {
+ return [object.organizerCalendarAddress, ...participantsOf(object).filter(isOwner).flatMap(personTexts)];
+}
+
+// An occurrence's wall clock is in the series' zone and the bounds in the
+// request's: up to 26 hours apart. The client narrows the rest when it expands.
+const SERIES_SLACK_DAYS = 2;
+const OPEN_RANGE_DAYS = 5 * 366;
+
+function seriesOccursIn(object: CalendarObject, after: string | undefined, before: string | undefined): boolean {
+ const seriesStart = typeof object.start === 'string' ? parseISO(object.start) : null;
+ if (!seriesStart || Number.isNaN(seriesStart.getTime())) return false;
+ const lengthMs = (parseDurationSeconds(typeof object.duration === 'string' ? object.duration : undefined) ?? 0) * 1000;
+ const from = after
+ ? new Date(addDays(parseISO(wallClock(after)), -SERIES_SLACK_DAYS).getTime() - lengthMs)
+ : seriesStart;
+ const to = before ? addDays(parseISO(wallClock(before)), SERIES_SLACK_DAYS) : addDays(from, OPEN_RANGE_DAYS);
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) return false;
+ const stamp = (date: Date) => format(date, "yyyy-MM-dd'T'HH:mm:ss");
+ const event = normalizeCalendarEventLike(object as unknown as CalendarEvent);
+ return expandRecurringEvents([event], stamp(from), stamp(to)).length > 0;
+}
+
+function inRange(object: CalendarObject, bounds: RangeBounds, zone: string): boolean {
+ if (isSeries(object)) return seriesOccursIn(object, bounds.after, bounds.before);
+ const { start, end } = instantsOf(object, zone);
+ if (start === null || end === null) return false;
+ const after = bounds.after ? instantOf(bounds.after, zone) : null;
+ const before = bounds.before ? instantOf(bounds.before, zone) : null;
+ if (before !== null && start >= before) return false;
+ if (after === null) return true;
+ return end > after || (end === start && start >= after);
+}
+
+function matchesCondition(object: CalendarObject, condition: CalendarEventFilter, zone: string): boolean {
+ const bounds: RangeBounds = {};
+ for (const [key, value] of Object.entries(condition)) {
+ if (value === undefined) continue;
+ switch (key) {
+ case 'after':
+ case 'before':
+ bounds[key as 'after' | 'before'] = value as string;
+ break;
+ case 'inCalendar':
+ if (!(object.calendarIds as Record | undefined)?.[value as string]) return false;
+ break;
+ case 'inCalendars':
+ if (!(value as string[]).some((id) => (object.calendarIds as Record | undefined)?.[id])) return false;
+ break;
+ case 'uid':
+ if (object.uid !== value) return false;
+ break;
+ case 'title':
+ if (!containsWords([object.title], value as string)) return false;
+ break;
+ case 'description':
+ if (!containsWords([object.description], value as string)) return false;
+ break;
+ case 'location':
+ if (!containsWords(placeTexts(object), value as string)) return false;
+ break;
+ case 'owner':
+ if (!containsWords(ownerTexts(object), value as string)) return false;
+ break;
+ case 'attendee': {
+ // With participationStatus in the same condition: the same person.
+ const status = condition.participationStatus;
+ const found = participantsOf(object).some((person) => isAttendee(person)
+ && containsWords(personTexts(person), value as string)
+ && (!status || person.participationStatus === status));
+ if (!found) return false;
+ break;
+ }
+ case 'participationStatus':
+ if (!participantsOf(object).some((person) => person.participationStatus === value)) return false;
+ break;
+ case 'text':
+ if (!containsWords([
+ object.title,
+ object.description,
+ ...placeTexts(object),
+ ...ownerTexts(object),
+ ...participantsOf(object).flatMap(personTexts),
+ ], value as string)) return false;
+ break;
+ case 'types': {
+ const type = isTaskLikeObject(object) ? 'task' : 'event';
+ if (!(value as string[]).some((name) => name.toLowerCase() === type)) return false;
+ break;
+ }
+ default:
+ throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
+ }
+ }
+ return bounds.after || bounds.before ? inRange(object, bounds, zone) : true;
+}
+
+export function matchesFilter(object: CalendarObject, filter: CalendarEventFilter, zone: string): boolean {
+ const conditions = filter.conditions ?? [];
+ switch (filter.operator) {
+ case 'AND': {
+ // A range split over two conditions is one range: a series must have one
+ // occurrence inside it, not one after the start and another before the end.
+ const bounds = boundsOf(filter);
+ const isBound = (c: CalendarEventFilter) => !c.operator && Object.keys(c).length === 1 && Boolean(c.after || c.before);
+ if ((bounds.after || bounds.before) && !inRange(object, bounds, zone)) return false;
+ return conditions.every((c) => isBound(c) || matchesFilter(object, c, zone));
+ }
+ case 'OR':
+ return conditions.some((c) => matchesFilter(object, c, zone));
+ case 'NOT':
+ return !conditions.some((c) => matchesFilter(object, c, zone));
+ case undefined:
+ return matchesCondition(object, filter, zone);
+ default:
+ throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`);
+ }
+}
+
+export interface EventComparator {
+ property?: string;
+ isAscending?: boolean;
+}
+
+function sortValue(object: CalendarObject, property: string | undefined, zone: string): number | string {
+ if (property === 'start') return instantsOf(object, zone).start ?? 0;
+ const value = property ? object[property] : undefined;
+ return typeof value === 'number' || typeof value === 'string' ? value : '';
+}
+
+/** By start when the request names no order. Ties go by id, so paging is stable. */
+export function sortEvents(objects: CalendarObject[], sort: readonly EventComparator[], zone: string): CalendarObject[] {
+ const levels = sort.length > 0 ? sort : [{ property: 'start', isAscending: true }];
+ const keyed = objects.map((object) => ({ object, keys: levels.map((level) => sortValue(object, level.property, zone)) }));
+ keyed.sort((a, b) => {
+ for (let i = 0; i < levels.length; i += 1) {
+ const x = a.keys[i];
+ const y = b.keys[i];
+ if (x === y) continue;
+ const order = x < y ? -1 : 1;
+ return levels[i].isAscending === false ? -order : order;
+ }
+ return a.object.id < b.object.id ? -1 : a.object.id > b.object.id ? 1 : 0;
+ });
+ return keyed.map((entry) => entry.object);
+}
diff --git a/lib/store/calendar/flush.ts b/lib/store/calendar/flush.ts
new file mode 100644
index 000000000..b4266a473
--- /dev/null
+++ b/lib/store/calendar/flush.ts
@@ -0,0 +1,229 @@
+/**
+ * Sends pending calendar writes, one event per request, so a lost response
+ * leaves one event's outcome open and not a batch's.
+ *
+ * Only a create can go wrong by being repeated. It's marked `attempted` in its
+ * own transaction before it goes out, and carries a uid the device chose. A
+ * create with that mark first looks for its uid on the server. An edit sent
+ * twice writes the same values twice, and a delete sent twice finds nothing the
+ * second time.
+ */
+
+import { jmapError } from '@/lib/jmap/jmap-error';
+import { isOverrideSeriesKeyRejection, stableJson, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances';
+import type { CalendarEvent } from '@/lib/jmap/types';
+import { applyPatchObject } from '@/lib/jmap/patch-object';
+import type { SyncContext } from '../area';
+import { JmapMethodError, responseFor } from '../engine/transport';
+import { calendarUsing, currentGen, deleteEventRow, EVENT_PROPERTIES, eventRow, valueAtPointer, writeEvent, type CalendarObject } from './records';
+import { recordConflict, type PendingRow } from './writes';
+
+export interface FlushTally {
+ sent: number;
+ conflicts: number;
+}
+
+interface SetError {
+ type?: string;
+ description?: string;
+ properties?: string[];
+}
+
+interface SetResult {
+ created?: Record | null;
+ updated?: Record | null;
+ destroyed?: string[] | null;
+ notCreated?: Record | null;
+ notUpdated?: Record | null;
+ notDestroyed?: Record | null;
+}
+
+// The server had a bad moment, nothing is wrong with this write: try again later.
+const PASSING_ERRORS = new Set(['serverFail', 'serverPartialFail', 'serverUnavailable', 'rateLimit', 'tooManyRequests']);
+
+export function isPassingError(error: unknown): boolean {
+ return error instanceof JmapMethodError && PASSING_ERRORS.has(error.type);
+}
+
+type Outcome = 'sent' | 'conflict' | 'again';
+
+async function sendSet(ctx: SyncContext, row: PendingRow, args: Record): Promise {
+ const response = await ctx.transport.request([['CalendarEvent/set', {
+ accountId: row.jmap,
+ ...(row.send_scheduling === null ? {} : { sendSchedulingMessages: row.send_scheduling === 1 }),
+ ...args,
+ }, 's']], calendarUsing(ctx.session));
+ return responseFor(response, 's')[1] as SetResult;
+}
+
+/** Stalwart 1.0 refuses what 0.16 expects inside an override. The client normally learns that from the refusal. */
+function isSeriesKeyRefusal(error: SetError | undefined): boolean {
+ return Boolean(error) && isOverrideSeriesKeyRejection(jmapError(error?.description ?? '', error));
+}
+
+async function findByUid(ctx: SyncContext, jmap: string, uid: string | null): Promise {
+ if (!uid) return null;
+ const response = await ctx.transport.request([['CalendarEvent/query', { accountId: jmap, filter: { uid }, limit: 1 }, 'u']], calendarUsing(ctx.session));
+ const ids = responseFor(response, 'u')[1].ids as string[];
+ return ids[0] ?? null;
+}
+
+async function fetchEvent(ctx: SyncContext, jmap: string, id: string): Promise {
+ const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [id], properties: EVENT_PROPERTIES }, 'g']], calendarUsing(ctx.session));
+ return (responseFor(response, 'g')[1].list as CalendarObject[])[0] ?? null;
+}
+
+/** The stand-in becomes the real event. What came in while the create was on its way goes on as a write of its own. */
+function finishCreate(ctx: SyncContext, row: PendingRow, sent: Record, realId: string, event: CalendarObject): Outcome {
+ const { db } = ctx;
+ return db.transaction(() => {
+ const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]);
+ const gen = currentGen(db, row.jmap);
+ deleteEventRow(db, row.jmap, row.record_id);
+ db.run(
+ 'INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)',
+ [row.jmap, row.record_id, realId],
+ );
+ ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
+ if (current?.kind === 'destroy') {
+ writeEvent(db, row.jmap, { shown: event, server: event, hidden: true, gen });
+ db.run('UPDATE calendar_pending SET record_id = ?, uid = NULL, attempted = 0 WHERE seq = ?', [realId, row.seq]);
+ return 'again';
+ }
+ if (current && current.rev !== row.rev) {
+ const now = JSON.parse(current.patch) as Record;
+ const patch: Record = {};
+ for (const key of new Set([...Object.keys(sent), ...Object.keys(now)])) {
+ if (stableJson(sent[key]) !== stableJson(now[key])) patch[key] = now[key] ?? null;
+ }
+ const base = Object.fromEntries(Object.keys(patch).map((key) => [key, valueAtPointer(event, key) ?? null]));
+ db.run(
+ `UPDATE calendar_pending SET kind = 'update', record_id = ?, patch = ?, base = ?, uid = NULL, attempted = 0 WHERE seq = ?`,
+ [realId, JSON.stringify(patch), JSON.stringify(base), row.seq],
+ );
+ writeEvent(db, row.jmap, { shown: applyPatchObject(event, patch), server: event, gen });
+ return 'again';
+ }
+ db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]);
+ writeEvent(db, row.jmap, { shown: event, gen });
+ return 'sent';
+ });
+}
+
+async function sendCreate(ctx: SyncContext, row: PendingRow): Promise {
+ const payload = JSON.parse(row.patch) as Record;
+ let realId = row.attempted ? await findByUid(ctx, row.jmap, row.uid) : null;
+ if (!realId) {
+ ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1 WHERE seq = ?', [row.seq]));
+ let result = await sendSet(ctx, row, { create: { c: payload } });
+ if (isSeriesKeyRefusal(result.notCreated?.c)) {
+ const without = withoutOverrideSeriesKeys(payload as Partial);
+ result = await sendSet(ctx, row, { create: { c: without } });
+ }
+ realId = result.created?.c?.id ?? null;
+ const refusal = result.notCreated?.c;
+ // Both Stalwarts refuse a second event with one uid, which also covers a
+ // create that got through when the device didn't know it had tried.
+ if (!realId && refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) {
+ realId = await findByUid(ctx, row.jmap, row.uid);
+ }
+ if (!realId) {
+ ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal ?? { type: 'serverFail' }));
+ return 'conflict';
+ }
+ }
+ const event = await fetchEvent(ctx, row.jmap, realId) ?? ({ ...payload, id: realId } as CalendarObject);
+ return finishCreate(ctx, row, payload, realId, event);
+}
+
+function finishUpdate(ctx: SyncContext, row: PendingRow, sent: Record): Outcome {
+ const { db } = ctx;
+ return db.transaction(() => {
+ const current = db.get('SELECT * FROM calendar_pending WHERE seq = ?', [row.seq]);
+ const event = eventRow(db, row.jmap, row.record_id);
+ ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
+ if (!current || !event) return 'sent';
+ if (current.rev === row.rev) {
+ db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]);
+ writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, gen: event.gen });
+ return 'sent';
+ }
+ // A newer write came in during the send. What went out is the server's now,
+ // so the next sync must not take it for someone else's change.
+ const server = applyPatchObject(JSON.parse(event.server_json ?? event.json) as CalendarObject, sent);
+ const base = JSON.parse(current.base) as Record;
+ for (const key of Object.keys(sent)) if (key in base) base[key] = sent[key];
+ db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(base), row.seq]);
+ writeEvent(db, row.jmap, { shown: JSON.parse(event.json) as CalendarObject, server, hidden: Boolean(event.hidden), gen: event.gen });
+ return 'again';
+ });
+}
+
+async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise {
+ let patch = JSON.parse(row.patch) as Record;
+ const id = row.record_id;
+ let result = await sendSet(ctx, row, { update: { [id]: patch } });
+ if (isSeriesKeyRefusal(result.notUpdated?.[id])) {
+ patch = withoutOverrideSeriesKeys(patch as Partial) as Record;
+ result = await sendSet(ctx, row, { update: { [id]: patch } });
+ }
+ const refusal = result.notUpdated?.[id];
+ if (refusal) {
+ ctx.db.transaction(() => recordConflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal));
+ return 'conflict';
+ }
+ return finishUpdate(ctx, row, patch);
+}
+
+async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise {
+ // A create turned delete while its create was out: find out what the server has.
+ const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id;
+ const finish = () => ctx.db.transaction(() => {
+ ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]);
+ deleteEventRow(ctx.db, row.jmap, row.record_id);
+ ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
+ });
+ if (!id) {
+ finish();
+ return 'sent';
+ }
+ const result = await sendSet(ctx, row, { destroy: [id] });
+ const refusal = result.notDestroyed?.[id];
+ if (refusal && refusal.type !== 'notFound') {
+ ctx.db.transaction(() => recordConflict(ctx, row, 'refused', refusal));
+ return 'conflict';
+ }
+ finish();
+ return 'sent';
+}
+
+/**
+ * Sends what's pending for one JMAP account. Network failures and a server's
+ * bad moment are thrown, with the rest still pending. Any other method error
+ * means this write won't work as it is, so it goes to the conflict list and the
+ * others still go out.
+ */
+export async function sendPending(ctx: SyncContext, jmap: string): Promise {
+ const tally: FlushTally = { sent: 0, conflicts: 0 };
+ const finished = new Set();
+ for (;;) {
+ if (ctx.stopped()) return tally;
+ const row = ctx.db.all('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq', [jmap])
+ .find((candidate) => !finished.has(Number(candidate.seq)));
+ if (!row) return tally;
+ let outcome: Outcome;
+ try {
+ if (row.kind === 'create') outcome = await sendCreate(ctx, row);
+ else if (row.kind === 'update') outcome = await sendUpdate(ctx, row);
+ else outcome = await sendDestroy(ctx, row);
+ } catch (error) {
+ if (!(error instanceof JmapMethodError) || isPassingError(error)) throw error;
+ ctx.db.transaction(() => recordConflict(ctx, row, 'refused', { type: error.type, description: error.message }));
+ outcome = 'conflict';
+ }
+ if (outcome === 'again') continue;
+ finished.add(Number(row.seq));
+ if (outcome === 'sent') tally.sent += 1;
+ else tally.conflicts += 1;
+ }
+}
diff --git a/lib/store/calendar/index.ts b/lib/store/calendar/index.ts
new file mode 100644
index 000000000..da9115b78
--- /dev/null
+++ b/lib/store/calendar/index.ts
@@ -0,0 +1,19 @@
+import type { StoreArea } from '../area';
+import { CALENDAR_METHODS } from './methods';
+import { CALENDAR_MIGRATIONS } from './schema';
+import { calendarSyncer, flushCalendar } from './sync';
+import { listConflicts } from './writes';
+
+export const calendarArea: StoreArea = {
+ name: 'calendar',
+ migrations: CALENDAR_MIGRATIONS,
+ methods: CALENDAR_METHODS,
+ syncers: [calendarSyncer],
+ calls: {
+ 'calendar.flush': (ctx) => flushCalendar(ctx),
+ 'calendar.conflicts': (ctx) => listConflicts(ctx.db),
+ 'calendar.dismissConflict': (ctx, params: { id: number }) => {
+ ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]);
+ },
+ },
+};
diff --git a/lib/store/calendar/methods.ts b/lib/store/calendar/methods.ts
new file mode 100644
index 000000000..2443584dc
--- /dev/null
+++ b/lib/store/calendar/methods.ts
@@ -0,0 +1,197 @@
+/**
+ * Calendar calls answered from the store.
+ *
+ * Series, not occurrences, in both client modes. A client in synthetic-id mode
+ * sends `expandRecurrences: true` and gets the masters, with their overrides
+ * (see projectEvent). hydrateRecurrenceInstances leaves those alone, and
+ * expandRecurringEvents in the calendar store makes the occurrences, the same as
+ * for a client in the other mode. Occurrence writes then arrive as overrides on
+ * the master, which is what a stand-in id and the pending queue can work with.
+ */
+
+import type { CalendarEventFilter } from '@/lib/jmap/types';
+import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances';
+import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
+import { listObjects } from '../engine/core-schema';
+import type { SqlValue, StorageAdapter } from '../storage';
+import { boundsOf, matchesFilter, sortEvents, type EventComparator } from './filter';
+import { CALENDARS_CAPABILITY, instantOf, projectEvent, type CalendarObject } from './records';
+import { syntheticIdVerdict } from './sync';
+import { queueCreate, queueDestroy, queueUpdate, resolveId } from './writes';
+
+function jmapOf(ctx: MethodContext, args: Record): string {
+ return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CALENDARS_CAPABILITY] ?? ctx.defaultJmap;
+}
+
+function stateOf(db: StorageAdapter, jmap: string, type: string): string | undefined {
+ return db.get<{ state: string | null }>('SELECT state FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type])?.state ?? undefined;
+}
+
+/** An account never synced has no answer yet. An empty one would read as "no events". */
+function assertSynced(db: StorageAdapter, jmap: string, type: string): void {
+ const row = db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]);
+ if (!row?.state && !row?.phase) throw new MethodError('serverUnavailable', `${type} hasn't been kept on this device yet`);
+}
+
+// An event's start_ms reads a floating event in UTC, and a request's bounds are
+// in its own zone: up to 14 hours apart. This only narrows, the filter decides.
+const ZONE_SLACK_MS = 15 * 3_600_000;
+
+function candidates(db: StorageAdapter, jmap: string, filter: CalendarEventFilter, zone: string): CalendarObject[] {
+ const where = ['jmap = ?', 'hidden = 0'];
+ const params: SqlValue[] = [jmap];
+ if (typeof filter.uid === 'string' && !filter.operator) {
+ where.push('uid = ?');
+ params.push(filter.uid);
+ }
+ const { after, before } = boundsOf(filter);
+ const afterMs = after ? instantOf(after, zone) : null;
+ const beforeMs = before ? instantOf(before, zone) : null;
+ if (afterMs !== null || beforeMs !== null) {
+ where.push(`(is_series = 1 OR start_ms IS NULL OR (start_ms < ? AND end_ms >= ?))`);
+ params.push(beforeMs === null ? Number.MAX_SAFE_INTEGER : beforeMs + ZONE_SLACK_MS);
+ params.push(afterMs === null ? Number.MIN_SAFE_INTEGER : afterMs - ZONE_SLACK_MS);
+ }
+ return db.all<{ json: string }>(`SELECT json FROM calendar_event WHERE ${where.join(' AND ')}`, params)
+ .map((row) => JSON.parse(row.json) as CalendarObject);
+}
+
+const calendarGet: MethodHandler = (ctx, args) => {
+ const jmap = jmapOf(ctx, args);
+ assertSynced(ctx.db, jmap, 'Calendar');
+ const ids = args.ids as string[] | null | undefined;
+ const properties = args.properties as string[] | undefined;
+ const all = listObjects<{ id: string } & Record>(ctx.db, jmap, 'Calendar');
+ const chosen = ids ? all.filter((calendar) => ids.includes(calendar.id)) : all;
+ const state = stateOf(ctx.db, jmap, 'Calendar');
+ return {
+ accountId: jmap,
+ ...(state ? { state } : {}),
+ list: chosen.map((calendar) => (properties
+ ? Object.fromEntries(['id', ...properties].filter((name) => name in calendar).map((name) => [name, calendar[name]]))
+ : calendar)),
+ notFound: ids ? ids.filter((id) => !all.some((calendar) => calendar.id === id)) : [],
+ };
+};
+
+const eventQuery: MethodHandler = (ctx, args) => {
+ const jmap = jmapOf(ctx, args);
+ assertSynced(ctx.db, jmap, 'CalendarEvent');
+ const filter = (args.filter ?? {}) as CalendarEventFilter;
+ const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC';
+ const { after, before } = boundsOf(filter);
+ if (args.expandRecurrences && !(after && before)) {
+ throw new MethodError('invalidArguments', 'expandRecurrences needs both after and before');
+ }
+ const matching = candidates(ctx.db, jmap, filter, zone).filter((object) => matchesFilter(object, filter, zone));
+ const sorted = sortEvents(matching, (args.sort ?? []) as EventComparator[], zone);
+ const position = Math.max(0, Number(args.position ?? 0) || 0);
+ const limit = Number(args.limit);
+ const ids = sorted.map((object) => object.id);
+ return {
+ accountId: jmap,
+ // No queryState: the client doesn't read it, and the store has no real one.
+ canCalculateChanges: false,
+ position,
+ ids: ids.slice(position, limit > 0 ? position + limit : undefined),
+ ...(args.calculateTotal ? { total: ids.length } : {}),
+ };
+};
+
+const eventGet: MethodHandler = (ctx, args) => {
+ const jmap = jmapOf(ctx, args);
+ const requested = args.ids as string[] | null | undefined;
+ // A page's first calendar list carries `ids: []` to check a time zone, also before any sync.
+ if (!(Array.isArray(requested) && requested.length === 0)) assertSynced(ctx.db, jmap, 'CalendarEvent');
+ const properties = (args.properties as string[] | undefined) ?? null;
+ const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC';
+ const list: Record[] = [];
+ const notFound: string[] = [];
+ if (requested) {
+ for (const id of requested) {
+ const row = ctx.db.get<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND id = ? AND hidden = 0', [jmap, resolveId(ctx.db, jmap, id)]);
+ if (row) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone));
+ else notFound.push(id);
+ }
+ } else {
+ const rows = ctx.db.all<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND hidden = 0', [jmap]);
+ for (const row of rows) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone));
+ }
+ const state = stateOf(ctx.db, jmap, 'CalendarEvent');
+ return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
+};
+
+// What a server without synthetic-id writes says. It's the answer here too, for
+// an id the store doesn't hold, which is an occurrence still on screen from
+// before the connection went: the client catches this (updateOccurrence,
+// destroyOccurrence in stores/calendar-store.ts) and writes the change as an
+// override on the base event, which the store does hold.
+function syntheticRefusal(verb: 'Updating' | 'Deleting'): { type: string; description: string; properties: string[] } {
+ return { type: 'invalidProperties', description: `${verb} synthetic ids is not yet supported.`, properties: ['id'] };
+}
+
+const eventSet: MethodHandler = (ctx, args) => {
+ const jmap = jmapOf(ctx, args);
+ const send = typeof args.sendSchedulingMessages === 'boolean' ? args.sendSchedulingMessages : undefined;
+ const created: Record = {};
+ const updated: Record = {};
+ const destroyed: string[] = [];
+ const notUpdated: Record = {};
+ const notDestroyed: Record = {};
+ let wrote = false;
+ ctx.db.transaction(() => {
+ for (const [key, event] of Object.entries((args.create ?? {}) as Record>)) {
+ created[key] = queueCreate(ctx, jmap, event, send);
+ wrote = true;
+ }
+ for (const [id, patch] of Object.entries((args.update ?? {}) as Record>)) {
+ if (id === SYNTHETIC_ID_PROBE) {
+ // A client that started offline asks here. Tell it what the server said
+ // at the last sync, so it behaves the same when the server is back. With
+ // no answer yet: client-side expansion, which works on every server.
+ notUpdated[id] = syntheticIdVerdict(ctx, jmap) === true ? { type: 'notFound' } : syntheticRefusal('Updating');
+ continue;
+ }
+ if (queueUpdate(ctx, jmap, resolveId(ctx.db, jmap, id), patch, send)) {
+ updated[id] = null;
+ wrote = true;
+ } else {
+ notUpdated[id] = syntheticRefusal('Updating');
+ }
+ }
+ for (const id of (args.destroy ?? []) as string[]) {
+ if (queueDestroy(ctx, jmap, resolveId(ctx.db, jmap, id), send)) {
+ destroyed.push(id);
+ wrote = true;
+ } else {
+ notDestroyed[id] = syntheticRefusal('Deleting');
+ }
+ }
+ });
+ if (wrote) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
+ const result: MethodResult = { accountId: jmap };
+ if (Object.keys(created).length > 0) result.created = created;
+ if (Object.keys(updated).length > 0) result.updated = updated;
+ if (destroyed.length > 0) result.destroyed = destroyed;
+ if (Object.keys(notUpdated).length > 0) result.notUpdated = notUpdated;
+ if (Object.keys(notDestroyed).length > 0) result.notDestroyed = notDestroyed;
+ return result;
+};
+
+const identityGet: MethodHandler = (ctx, args) => {
+ const jmap = jmapOf(ctx, args);
+ const list = listObjects(ctx.db, jmap, 'ParticipantIdentity');
+ if (list.length === 0) throw new MethodError('serverUnavailable', 'No participant identities kept yet');
+ return { accountId: jmap, list, notFound: [] };
+};
+
+export const CALENDAR_METHODS: Readonly> = {
+ 'Calendar/get': calendarGet,
+ 'CalendarEvent/query': eventQuery,
+ 'CalendarEvent/get': eventGet,
+ 'CalendarEvent/set': eventSet,
+ 'ParticipantIdentity/get': identityGet,
+ // Notifications about others' changes wait for the server.
+ 'CalendarEventNotification/query': (ctx, args) => ({ accountId: jmapOf(ctx, args), canCalculateChanges: false, position: 0, ids: [] }),
+ 'CalendarEventNotification/get': (ctx, args) => ({ accountId: jmapOf(ctx, args), list: [], notFound: [] }),
+};
diff --git a/lib/store/calendar/records.ts b/lib/store/calendar/records.ts
new file mode 100644
index 000000000..84cdcb0cc
--- /dev/null
+++ b/lib/store/calendar/records.ts
@@ -0,0 +1,162 @@
+/** How calendar objects are stored and read back. */
+
+import { parseISO } from 'date-fns';
+import { isTaskLikeObject } from '@/lib/calendar-component-detection';
+import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
+import { pointerTokenValue } from '@/lib/jmap/patch-pointer';
+import { wallClockFieldsAsUtc, zonedWallTimeToUtc } from '@/lib/recurrence-expansion';
+import type { StoreSession } from '../engine/transport';
+import type { SqlValue, StorageAdapter } from '../storage';
+
+export const CALENDARS_CAPABILITY = 'urn:ietf:params:jmap:calendars';
+const PRINCIPALS_OWNER = 'urn:ietf:params:jmap:principals:owner';
+
+export type CalendarObject = Record & { id: string };
+
+export function calendarUsing(session: StoreSession): string[] {
+ const using = ['urn:ietf:params:jmap:core', CALENDARS_CAPABILITY];
+ if (PRINCIPALS_OWNER in session.capabilities) using.push(PRINCIPALS_OWNER);
+ return using;
+}
+
+/** Stalwart's default list for Calendar/get leaves out sharing and default alerts. */
+export const CALENDAR_PROPERTIES = [
+ 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
+ 'includeInAvailability', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', 'timeZone', 'shareWith', 'myRights',
+] as const;
+
+/**
+ * Everything the client's event and task property lists ask for, in one get.
+ * No utcStart or utcEnd: Stalwart 1.0 refuses those next to recurrenceOverrides,
+ * and they depend on the zone of the request, so answers work them out.
+ */
+export const EVENT_PROPERTIES = [
+ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated',
+ 'sequence', 'start', 'due', 'duration', 'timeZone', 'showWithoutTime', 'status', 'freeBusyStatus', 'privacy',
+ 'color', 'keywords', 'categories', 'locale', 'replyTo', 'organizerCalendarAddress', 'participants',
+ 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
+ 'excludedRecurrenceRule', 'recurrenceOverrides', 'useDefaultAlerts', 'alerts', 'locations', 'virtualLocations',
+ 'links', 'relatedTo', 'isDraft', 'isOrigin', 'baseEventId', 'progress', 'priority', 'percentComplete',
+] as const;
+
+export function isSeries(object: Record): boolean {
+ const rule = object.recurrenceRule ?? object.recurrenceRules;
+ return Array.isArray(rule) ? rule.length > 0 : Boolean(rule);
+}
+
+/** The value a PatchObject key points at. */
+export function valueAtPointer(object: unknown, pointer: string): unknown {
+ let value = object;
+ for (const token of pointer.split('/')) {
+ if (value === null || typeof value !== 'object') return undefined;
+ value = (value as Record)[pointerTokenValue(token)];
+ }
+ return value;
+}
+
+/** A LocalDateTime without any `Z` or offset: Stalwart drops those instead of honouring them. */
+export function wallClock(value: string): string {
+ return value.replace(/(?:Z|[+-]\d{2}:?\d{2})$/, '');
+}
+
+/** The instant a wall clock names in `zone`. A zone the runtime doesn't know reads as UTC, as 0.16 does. */
+export function instantOf(value: string, zone: string | null | undefined): number | null {
+ const wall = parseISO(wallClock(value));
+ if (Number.isNaN(wall.getTime())) return null;
+ const zoned = zone ? zonedWallTimeToUtc(wall, zone) : null;
+ return zoned ? zoned.getTime() : wallClockFieldsAsUtc(wall);
+}
+
+/** An object's start and end as instants. A floating one is read in `zone`. A task without a start goes by its due. */
+export function instantsOf(object: Record, zone: string | null | undefined): { start: number | null; end: number | null } {
+ const at = typeof object.start === 'string' ? object.start : typeof object.due === 'string' ? object.due : null;
+ if (!at) return { start: null, end: null };
+ const start = instantOf(at, typeof object.timeZone === 'string' ? object.timeZone : zone);
+ if (start === null) return { start: null, end: null };
+ const seconds = typeof object.duration === 'string' ? parseDurationSeconds(object.duration) : null;
+ return { start, end: start + (seconds ?? 0) * 1000 };
+}
+
+function utcString(ms: number): string {
+ return new Date(ms).toISOString().replace('.000Z', 'Z');
+}
+
+/**
+ * An object as a CalendarEvent/get asked for it. utcStart and utcEnd are worked
+ * out here, so they hold for the request's zone and for events edited offline.
+ * A series keeps its overrides even when not asked for: the client's
+ * synthetic-id mode reads occurrences without them, and it gets series from the
+ * store, which it can only expand correctly with them.
+ */
+export function projectEvent(object: CalendarObject, properties: readonly string[] | null, zone: string | null | undefined): Record {
+ if (!properties) return { ...object };
+ const out: Record = { id: object.id };
+ for (const name of properties) {
+ if (name === 'utcStart' || name === 'utcEnd') continue;
+ if (name in object) out[name] = object[name];
+ }
+ if (properties.includes('utcStart') || properties.includes('utcEnd')) {
+ const { start, end } = typeof object.start === 'string' ? instantsOf(object, zone) : { start: null, end: null };
+ if (start !== null && properties.includes('utcStart')) out.utcStart = utcString(start);
+ if (end !== null && properties.includes('utcEnd')) out.utcEnd = utcString(end);
+ }
+ if (properties.includes('recurrenceRule') && object.recurrenceOverrides && isSeries(object)) {
+ out.recurrenceOverrides = object.recurrenceOverrides;
+ }
+ return out;
+}
+
+export interface EventRow {
+ jmap: string;
+ id: string;
+ json: string;
+ server_json: string | null;
+ hidden: number;
+ gen: number;
+}
+
+export function eventRow(db: StorageAdapter, jmap: string, id: string): EventRow | undefined {
+ return db.get('SELECT jmap, id, json, server_json, hidden, gen FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]);
+}
+
+export function currentGen(db: StorageAdapter, jmap: string): number {
+ return Number(db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM calendar_event WHERE jmap = ?', [jmap])?.gen ?? 0);
+}
+
+export interface EventWrite {
+ /** What answers show. */
+ shown: CalendarObject;
+ /** The server's copy while a write is pending, otherwise null. */
+ server?: CalendarObject | null;
+ hidden?: boolean;
+ gen: number;
+}
+
+export function writeEvent(db: StorageAdapter, jmap: string, write: EventWrite): void {
+ const { shown } = write;
+ const { start, end } = instantsOf(shown, 'UTC');
+ const params: SqlValue[] = [
+ jmap,
+ shown.id,
+ typeof shown.uid === 'string' ? shown.uid : null,
+ isTaskLikeObject(shown) ? 1 : 0,
+ isSeries(shown) ? 1 : 0,
+ start,
+ end,
+ JSON.stringify(shown),
+ write.server ? JSON.stringify(write.server) : null,
+ write.hidden ? 1 : 0,
+ write.gen,
+ ];
+ db.run(
+ `INSERT INTO calendar_event (jmap, id, uid, is_task, is_series, start_ms, end_ms, json, server_json, hidden, gen)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT (jmap, id) DO UPDATE SET uid = excluded.uid, is_task = excluded.is_task, is_series = excluded.is_series, start_ms = excluded.start_ms,
+ end_ms = excluded.end_ms, json = excluded.json, server_json = excluded.server_json, hidden = excluded.hidden, gen = excluded.gen`,
+ params,
+ );
+}
+
+export function deleteEventRow(db: StorageAdapter, jmap: string, id: string): void {
+ db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]);
+}
diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts
new file mode 100644
index 000000000..4ab5e3227
--- /dev/null
+++ b/lib/store/calendar/reminders.ts
@@ -0,0 +1,160 @@
+/**
+ * Which alerts fire in a time window, from what's stored. No DOM, no stores, no
+ * React: desktop reminders call this while the window is hidden.
+ *
+ * Keys use the master's id and the fire time, never an occurrence id. Synthetic
+ * ids can change, and browser occurrences don't exist on the server. A moved
+ * occurrence gets a new key, as it should: it's a new time to remind.
+ */
+
+import { format, parseISO } from 'date-fns';
+import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts';
+import { isTaskLikeObject } from '@/lib/calendar-component-detection';
+import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization';
+import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types';
+import { expandRecurringEvents } from '@/lib/recurrence-expansion';
+import { listObjects } from '../engine/core-schema';
+import type { StorageAdapter } from '../storage';
+import { instantsOf, isSeries, type CalendarObject } from './records';
+
+export interface UpcomingAlert {
+ /** Stable for this alert at this time. Use it to remember what was shown. */
+ key: string;
+ jmap: string;
+ kind: 'event' | 'task';
+ /** The stored event or task: the master of a series. */
+ baseEventId: string;
+ /** Which occurrence of the series, when it's one. */
+ recurrenceId: string | null;
+ alertId: string;
+ fireTimeMs: number;
+ title: string;
+ /** The start as a wall clock, or a task's due when it has no start. */
+ start: string | null;
+ timeZone: string | null;
+ calendarId: string | null;
+ calendarName: string | null;
+}
+
+export interface UpcomingAlertsOptions {
+ /** Alerts firing at or after this. */
+ from: number;
+ /** Alerts firing before this. */
+ to: number;
+ /** Reads floating events. The user's zone. */
+ timeZone: string;
+}
+
+type AlertMap = Record | null | undefined;
+
+// An alert fires up to its offset away from its occurrence, and the occurrence's
+// wall clock is up to 26 hours from the instant. Expand wide, filter by instant.
+function offsetReachMs(alerts: AlertMap): number {
+ let reach = 0;
+ for (const alert of Object.values(alerts ?? {})) {
+ if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue;
+ reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0));
+ }
+ return reach;
+}
+
+function alertsInOverrides(event: CalendarEvent): AlertMap[] {
+ return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts);
+}
+
+const ZONE_REACH_MS = 27 * 3_600_000;
+
+function occurrencesNear(event: CalendarEvent, calendars: Calendar[], options: UpcomingAlertsOptions): CalendarEvent[] {
+ const reach = ZONE_REACH_MS
+ + Math.max(offsetReachMs(getEffectiveAlerts(event, calendars)), ...alertsInOverrides(event).map(offsetReachMs), 0)
+ + (parseDurationSeconds(event.duration) ?? 0) * 1000;
+ const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss");
+ return expandRecurringEvents([event], stamp(options.from - reach), stamp(options.to + reach));
+}
+
+function withInstants(object: CalendarObject, zone: string): CalendarObject {
+ const { start, end } = instantsOf(object, zone);
+ return {
+ ...object,
+ ...(start !== null ? { utcStart: new Date(start).toISOString() } : {}),
+ ...(end !== null ? { utcEnd: new Date(end).toISOString() } : {}),
+ };
+}
+
+function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } {
+ const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null;
+ return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null };
+}
+
+/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */
+export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] {
+ const found = new Map();
+ const add = (alert: UpcomingAlert) => {
+ if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert);
+ };
+ const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0');
+ const calendarsOf = new Map();
+ for (const row of rows) {
+ if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects(db, row.jmap, 'Calendar'));
+ const calendars = calendarsOf.get(row.jmap)!;
+ const object = JSON.parse(row.json) as CalendarObject;
+ const calendar = firstCalendar(object.calendarIds, calendars);
+
+ if (isTaskLikeObject(object)) {
+ const task = object as unknown as CalendarTask;
+ if (task.progress === 'completed' || task.progress === 'cancelled') continue;
+ for (const [alertId, alert] of Object.entries(task.alerts ?? {})) {
+ if (alert.action !== 'display' || alert.acknowledged) continue;
+ const fireTimeMs = computeTaskFireTime(task, alert.trigger);
+ if (fireTimeMs === null) continue;
+ add({
+ key: buildAlertKey(object.id, alertId, fireTimeMs),
+ jmap: row.jmap,
+ kind: 'task',
+ baseEventId: object.id,
+ recurrenceId: null,
+ alertId,
+ fireTimeMs,
+ title: task.title ?? '',
+ start: task.start ?? task.due ?? null,
+ timeZone: task.timeZone ?? null,
+ calendarId: calendar.id,
+ calendarName: calendar.name,
+ });
+ }
+ continue;
+ }
+
+ const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent);
+ if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue;
+ const series = isSeries(object);
+ const occurrences = series ? occurrencesNear(master, calendars, options) : [master];
+ // A fixed time doesn't need an occurrence nearby.
+ const candidates = series ? [master, ...occurrences] : occurrences;
+ for (const occurrence of candidates) {
+ if (occurrence.status === 'cancelled') continue;
+ const isMasterOfSeries = series && occurrence === master;
+ for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) {
+ if (alert.action !== 'display' || alert.acknowledged) continue;
+ if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue;
+ const fireTimeMs = computeFireTime(occurrence, alert.trigger);
+ if (fireTimeMs === null) continue;
+ add({
+ key: buildAlertKey(object.id, alertId, fireTimeMs),
+ jmap: row.jmap,
+ kind: 'event',
+ baseEventId: object.id,
+ recurrenceId: occurrence.recurrenceId ?? null,
+ alertId,
+ fireTimeMs,
+ title: occurrence.title ?? '',
+ start: occurrence.start,
+ timeZone: occurrence.timeZone ?? null,
+ calendarId: calendar.id,
+ calendarName: calendar.name,
+ });
+ }
+ }
+ }
+ return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key));
+}
diff --git a/lib/store/calendar/schema.ts b/lib/store/calendar/schema.ts
new file mode 100644
index 000000000..1d56d505e
--- /dev/null
+++ b/lib/store/calendar/schema.ts
@@ -0,0 +1,77 @@
+import type { Migration } from '../storage';
+
+export const CALENDAR_MIGRATIONS: readonly Migration[] = [
+ {
+ id: 500,
+ name: 'calendar',
+ sql: `
+ CREATE TABLE calendar_event (
+ jmap TEXT NOT NULL,
+ id TEXT NOT NULL,
+ uid TEXT,
+ is_task INTEGER NOT NULL DEFAULT 0,
+ -- 1 for a series master. Occurrences are never stored: /changes only
+ -- lists masters, and the client expands them.
+ is_series INTEGER NOT NULL DEFAULT 0,
+ -- Instants to narrow a query with. A floating event is read in UTC here.
+ start_ms INTEGER,
+ end_ms INTEGER,
+ -- What answers show: the server's copy with any pending write applied.
+ json TEXT NOT NULL,
+ -- The server's copy while a write is pending on it, otherwise NULL.
+ server_json TEXT,
+ -- 1 while a delete is pending.
+ hidden INTEGER NOT NULL DEFAULT 0,
+ gen INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (jmap, id)
+ ) WITHOUT ROWID;
+ CREATE INDEX calendar_event_uid ON calendar_event (jmap, uid);
+ CREATE INDEX calendar_event_start ON calendar_event (jmap, start_ms);
+ CREATE INDEX calendar_event_end ON calendar_event (jmap, end_ms);
+
+ -- One row per event: a later write merges into it (see writes.ts).
+ CREATE TABLE calendar_pending (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ jmap TEXT NOT NULL,
+ record_id TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('create', 'update', 'destroy')),
+ -- create: the whole event. update: a PatchObject. destroy: empty.
+ patch TEXT NOT NULL,
+ -- The server's value at each patched path when it was queued.
+ base TEXT NOT NULL DEFAULT '{}',
+ -- For a create: how to find it again after a lost response.
+ uid TEXT,
+ -- sendSchedulingMessages: NULL when no write named it.
+ send_scheduling INTEGER,
+ -- 1 once a create went out, so the server may hold it already.
+ attempted INTEGER NOT NULL DEFAULT 0,
+ -- Moves on every merge, to see a write that came in during a send.
+ rev INTEGER NOT NULL DEFAULT 0,
+ queued_at INTEGER NOT NULL,
+ UNIQUE (jmap, record_id)
+ );
+
+ -- What a stand-in id became, for a page still holding the stand-in.
+ CREATE TABLE calendar_id_map (
+ jmap TEXT NOT NULL,
+ local_id TEXT NOT NULL,
+ server_id TEXT NOT NULL,
+ PRIMARY KEY (jmap, local_id)
+ ) WITHOUT ROWID;
+
+ CREATE TABLE calendar_conflict (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ jmap TEXT NOT NULL,
+ record_id TEXT NOT NULL,
+ kind TEXT NOT NULL,
+ reason TEXT NOT NULL,
+ error_type TEXT,
+ description TEXT,
+ title TEXT,
+ local TEXT NOT NULL,
+ server TEXT,
+ created_at INTEGER NOT NULL
+ );
+ `,
+ },
+];
diff --git a/lib/store/calendar/sync.ts b/lib/store/calendar/sync.ts
new file mode 100644
index 000000000..8463a0896
--- /dev/null
+++ b/lib/store/calendar/sync.ts
@@ -0,0 +1,264 @@
+/**
+ * Calendar sync for one JMAP account: calendars, the last participant
+ * identities, then every event and task.
+ *
+ * Masters only. CalendarEvent/changes lists nothing else on either Stalwart, an
+ * instance edit shows up as `updated: [master]`, and 1.0's expanded queries
+ * can't give changes and refuse long ranges. Occurrences are made when read.
+ */
+
+import { isTaskLikeObject } from '@/lib/calendar-component-detection';
+import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances';
+import type { StoreContext, SyncContext, Syncer } from '../area';
+import { runChangesLoop } from '../engine/changes';
+import { metaGet, metaSet, putObject } from '../engine/core-schema';
+import { JmapMethodError, responseFor, TransportError } from '../engine/transport';
+import type { StorageAdapter } from '../storage';
+import { isPassingError, sendPending, type FlushTally } from './flush';
+import {
+ CALENDAR_PROPERTIES,
+ CALENDARS_CAPABILITY,
+ calendarUsing,
+ currentGen,
+ EVENT_PROPERTIES,
+ eventRow,
+ instantsOf,
+ isSeries,
+ type CalendarObject,
+} from './records';
+import { applyServerDestroy, applyServerEvent, isStandIn, pendingFor } from './writes';
+
+const PAGE = 500;
+/** How far back single events are kept. A series stays whatever its start, a task whatever its date. */
+const PAST_WINDOW_MS = 2 * 366 * 86_400_000;
+
+function syntheticIdsKey(jmap: string): string {
+ return `calendar.syntheticIds.${jmap}`;
+}
+
+/**
+ * What this account's server said to the synthetic-id probe at the last sync.
+ * Null before it ever answered. A client that probed online holds its verdict
+ * for life and never asks again; only one started offline asks the store.
+ */
+export function syntheticIdVerdict(ctx: StoreContext, jmap: string): boolean | null {
+ const value = metaGet(ctx.db, syntheticIdsKey(jmap));
+ return value === null ? null : value === '1';
+}
+
+// A shared account probed on suspicion that grants no calendar access (the
+// client treats these the same way).
+function isAccessDenial(error: unknown): boolean {
+ return error instanceof JmapMethodError
+ && (error.type === 'forbidden' || error.type === 'accountNotFound' || error.type === 'accountNotSupportedByMethod');
+}
+
+/** False when the account grants no calendar access. */
+async function syncCalendars(ctx: SyncContext, jmap: string): Promise {
+ const response = await ctx.transport.request([
+ ['Calendar/get', { accountId: jmap, ids: null, properties: CALENDAR_PROPERTIES }, 'c'],
+ ['CalendarEvent/set', { accountId: jmap, update: { [SYNTHETIC_ID_PROBE]: {} } }, 'p'],
+ ['ParticipantIdentity/get', { accountId: jmap }, 'i'],
+ ], calendarUsing(ctx.session));
+ let result: { list: Array<{ id: string }>; state: unknown };
+ try {
+ result = responseFor(response, 'c')[1];
+ } catch (error) {
+ if (isAccessDenial(error)) return false;
+ throw error;
+ }
+ const probe = response.methodResponses.find(([, , id]) => id === 'p');
+ const identities = response.methodResponses.find(([, , id]) => id === 'i');
+ const before = ctx.syncState(jmap, 'Calendar').state;
+ ctx.db.transaction(() => {
+ ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Calendar'", [jmap]);
+ for (const calendar of result.list) putObject(ctx.db, jmap, 'Calendar', calendar.id, calendar);
+ ctx.setSyncState(jmap, 'Calendar', { state: String(result.state) });
+ if (probe?.[0] === 'CalendarEvent/set') {
+ const failure = probe[1]?.notUpdated?.[SYNTHETIC_ID_PROBE] as { type?: string } | undefined;
+ metaSet(ctx.db, syntheticIdsKey(jmap), failure?.type === 'notFound' ? '1' : '0');
+ }
+ // Only ever read as "the last value". 0.16 has no usable /changes for it.
+ if (identities?.[0] === 'ParticipantIdentity/get') {
+ ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'ParticipantIdentity'", [jmap]);
+ for (const identity of identities[1].list as Array<{ id: string }>) putObject(ctx.db, jmap, 'ParticipantIdentity', identity.id, identity);
+ }
+ });
+ if (before !== String(result.state)) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar'] });
+ return true;
+}
+
+function inWindow(ctx: SyncContext, event: CalendarObject): boolean {
+ if (isSeries(event) || isTaskLikeObject(event)) return true;
+ const { end } = instantsOf(event, 'UTC');
+ return end === null || end >= ctx.now() - PAST_WINDOW_MS;
+}
+
+interface ListCursor { position: number; gen: number; captured: string }
+
+async function startFullList(ctx: SyncContext, jmap: string): Promise {
+ const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], calendarUsing(ctx.session));
+ const captured = String(responseFor(response, 's')[1].state);
+ const cursor: ListCursor = { position: 0, gen: currentGen(ctx.db, jmap) + 1, captured };
+ ctx.db.transaction(() => ctx.setSyncState(jmap, 'CalendarEvent', { state: null, phase: 'list', cursor: { ...cursor } }));
+ return cursor;
+}
+
+function storeEvent(ctx: SyncContext, jmap: string, event: CalendarObject, gen: number): void {
+ // An old event changing doesn't pull it back into the window.
+ if (!inWindow(ctx, event) && !eventRow(ctx.db, jmap, event.id) && !pendingFor(ctx.db, jmap, event.id)) return;
+ applyServerEvent(ctx, jmap, event, gen);
+}
+
+/** Pages through every object: the query has no filter for tasks, and old series must stay. False when stopped. */
+async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise {
+ const cursor = { ...start };
+ for (;;) {
+ if (ctx.stopped()) return false;
+ const response = await ctx.transport.request([
+ ['CalendarEvent/query', { accountId: jmap, position: cursor.position, limit: PAGE }, 'q'],
+ ['CalendarEvent/get', {
+ accountId: jmap,
+ '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
+ properties: EVENT_PROPERTIES,
+ }, 'g'],
+ ], calendarUsing(ctx.session));
+ const ids = responseFor(response, 'q')[1].ids as string[];
+ const list = responseFor(response, 'g')[1].list as CalendarObject[];
+ const done = ids.length < PAGE;
+ cursor.position += ids.length;
+ ctx.db.transaction(() => {
+ for (const event of list) storeEvent(ctx, jmap, event, cursor.gen);
+ if (done) {
+ // Whatever this pass didn't see is gone from the server.
+ const stale = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen]);
+ for (const { id } of stale) if (!isStandIn(id)) applyServerDestroy(ctx, jmap, id);
+ ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null });
+ } else {
+ ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } });
+ }
+ });
+ ctx.emit({ type: 'progress', account: ctx.account, area: 'calendar', phase: 'list', done: cursor.position, total: null });
+ if (done) {
+ ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
+ return true;
+ }
+ }
+}
+
+async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> {
+ const outcome = await runChangesLoop({
+ transport: ctx.transport,
+ type: 'CalendarEvent',
+ jmap,
+ using: calendarUsing(ctx.session),
+ since,
+ target,
+ stopped: ctx.stopped,
+ apply: async (page) => {
+ const ids = [...page.created, ...page.updated];
+ const fetched: CalendarObject[] = [];
+ for (let i = 0; i < ids.length; i += PAGE) {
+ const response = await ctx.transport.request([['CalendarEvent/get', {
+ accountId: jmap,
+ ids: ids.slice(i, i + PAGE),
+ properties: EVENT_PROPERTIES,
+ }, 'g']], calendarUsing(ctx.session));
+ fetched.push(...responseFor(response, 'g')[1].list as CalendarObject[]);
+ }
+ ctx.db.transaction(() => {
+ const gen = currentGen(ctx.db, jmap);
+ for (const event of fetched) storeEvent(ctx, jmap, event, gen);
+ for (const id of page.destroyed) applyServerDestroy(ctx, jmap, id);
+ ctx.setSyncState(jmap, 'CalendarEvent', { state: page.newState });
+ });
+ if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
+ },
+ });
+ if (outcome.outcome === 'resync') return 'resync';
+ return outcome.outcome === 'stopped' ? 'stopped' : 'done';
+}
+
+/** False when stopped before catching up. */
+async function syncEvents(ctx: SyncContext, jmap: string): Promise {
+ const sync = ctx.syncState(jmap, 'CalendarEvent');
+ let cursor = sync.phase === 'list' ? sync.cursor as unknown as ListCursor : null;
+ if (!cursor && !sync.state) cursor = await startFullList(ctx, jmap);
+ if (!cursor && sync.state) {
+ const outcome = await syncDelta(ctx, jmap, sync.state, sync.target);
+ if (outcome === 'stopped') return false;
+ if (outcome === 'resync') cursor = await startFullList(ctx, jmap);
+ }
+ if (cursor) {
+ if (!(await runFullList(ctx, jmap, cursor))) return false;
+ // What changed while paging.
+ const after = ctx.syncState(jmap, 'CalendarEvent');
+ if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return false;
+ }
+ return true;
+}
+
+/** Single events that aged out of the window. The window moves on without any change on the server. */
+function pruneOld(ctx: SyncContext, jmap: string): void {
+ ctx.db.run(
+ `DELETE FROM calendar_event WHERE jmap = ? AND is_series = 0 AND is_task = 0 AND end_ms IS NOT NULL AND end_ms < ?
+ AND NOT EXISTS (SELECT 1 FROM calendar_pending p WHERE p.jmap = calendar_event.jmap AND p.record_id = calendar_event.id)`,
+ [jmap, ctx.now() - PAST_WINDOW_MS],
+ );
+}
+
+// A sync and a flush must not interleave. Our own write coming back through
+// /changes while it's still pending would read as someone else's change.
+const queues = new WeakMap>();
+
+function oneAtATime(db: StorageAdapter, work: () => Promise): Promise {
+ const next = (queues.get(db) ?? Promise.resolve()).catch(() => {}).then(work);
+ queues.set(db, next);
+ return next;
+}
+
+async function syncAndSend(ctx: SyncContext, jmap: string): Promise {
+ const none: FlushTally = { sent: 0, conflicts: 0 };
+ if (!(await syncEvents(ctx, jmap)) || ctx.stopped()) return none;
+ const tally = await sendPending(ctx, jmap);
+ if (tally.sent > 0 && !ctx.stopped()) await syncEvents(ctx, jmap);
+ return tally;
+}
+
+export const calendarSyncer: Syncer = {
+ name: 'calendar',
+ capability: CALENDARS_CAPABILITY,
+ async run(ctx, jmap) {
+ if (!(await syncCalendars(ctx, jmap)) || ctx.stopped()) return;
+ await oneAtATime(ctx.db, () => syncAndSend(ctx, jmap));
+ pruneOld(ctx, jmap);
+ },
+};
+
+export interface FlushResult extends FlushTally {
+ /** Writes still waiting: the network went away, or the server had a bad moment. */
+ pending: number;
+}
+
+/**
+ * `calendar.flush`: catches up with the server, so a write that collides with
+ * a change there is found, then sends every pending write. Doesn't throw for a
+ * lost connection: what wasn't sent stays pending.
+ */
+export function flushCalendar(ctx: SyncContext): Promise {
+ return oneAtATime(ctx.db, async () => {
+ const accounts = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_pending').map((row) => row.jmap);
+ const total: FlushTally = { sent: 0, conflicts: 0 };
+ try {
+ for (const jmap of accounts) {
+ const tally = await syncAndSend(ctx, jmap);
+ total.sent += tally.sent;
+ total.conflicts += tally.conflicts;
+ }
+ } catch (error) {
+ if (!(error instanceof TransportError) && !isPassingError(error)) throw error;
+ }
+ const pending = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0);
+ return { ...total, pending };
+ });
+}
diff --git a/lib/store/calendar/writes.ts b/lib/store/calendar/writes.ts
new file mode 100644
index 000000000..80d6b005b
--- /dev/null
+++ b/lib/store/calendar/writes.ts
@@ -0,0 +1,259 @@
+/**
+ * Calendar writes made while the server can't be reached.
+ *
+ * Each event has at most one pending row. A later write merges into it, so a
+ * flush sends one change per event: edit + edit is one patch, create + edit is
+ * one create, create + delete is nothing, edit + delete is a delete.
+ *
+ * Answers read calendar_event.json, which is the server's copy with the pending
+ * write applied. server_json keeps the server's copy underneath, so sync can
+ * move it on and reapply the write, or notice that the two collide.
+ */
+
+import { applyPatchObject } from '@/lib/jmap/patch-object';
+import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances';
+import type { StoreContext } from '../area';
+import type { StorageAdapter } from '../storage';
+import { currentGen, deleteEventRow, eventRow, valueAtPointer, writeEvent, type CalendarObject, type EventRow } from './records';
+
+export interface PendingRow {
+ seq: number;
+ jmap: string;
+ record_id: string;
+ kind: 'create' | 'update' | 'destroy';
+ patch: string;
+ base: string;
+ uid: string | null;
+ send_scheduling: number | null;
+ attempted: number;
+ rev: number;
+ queued_at: number;
+}
+
+export type ConflictReason = 'refused' | 'changedOnServer' | 'destroyedOnServer';
+
+export interface CalendarConflict {
+ id: number;
+ jmap: string;
+ recordId: string;
+ kind: PendingRow['kind'];
+ reason: ConflictReason;
+ errorType: string | null;
+ description: string | null;
+ title: string | null;
+ /** The write that wasn't made: the whole event for a create, otherwise the patch. */
+ local: Record;
+ /** The event as the server has it, when it still does. */
+ server: Record | null;
+ createdAt: number;
+}
+
+const STAND_IN_PREFIX = 'local-';
+
+export function isStandIn(id: string): boolean {
+ return id.startsWith(STAND_IN_PREFIX);
+}
+
+export function pendingFor(db: StorageAdapter, jmap: string, id: string): PendingRow | undefined {
+ return db.get