diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 9164a715f..764ae5547 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -27,6 +27,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { AppUpdatePrompt } from "@/components/app-update-prompt";
import { OfflineHost } from "@/components/offline/offline-host";
+import { NotificationActionsHost } from "@/components/providers/notification-actions-host";
import { DevicePolicyHost } from "@/components/device/device-policy-host";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
@@ -103,6 +104,7 @@ export default async function LocaleLayout({
{/* Offline mode isn't in Lite (no service worker there). */}
{!IS_LITE && }
+ {!IS_LITE && }
{/* Lite is a static bundle: no server keeps an archive there. */}
diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts
new file mode 100644
index 000000000..7ad419076
--- /dev/null
+++ b/app/api/push/action/route.ts
@@ -0,0 +1,315 @@
+import { cookies } from 'next/headers';
+import { NextRequest, NextResponse } from 'next/server';
+import { logger } from '@/lib/logger';
+import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
+import { configManager } from '@/lib/admin/config-manager';
+import { resolvePolicy } from '@/lib/admin/policy-scopes';
+import { strictDeviceSubject } from '@/lib/admin/server-device-policy';
+import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body';
+import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
+import { DisallowedUrlError } from '@/lib/security/url-guard';
+import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { rebaseApiUrl } from '@/lib/stalwart/jmap-api';
+import type { SettingsPolicy } from '@/lib/admin/types';
+import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+
+const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
+const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
+const MAX_BODY_BYTES = 4096;
+/** RFC 8620 ids, which is all an email or account id can be. */
+const JMAP_ID = /^[A-Za-z0-9_-]{1,255}$/;
+const MAILBOX_PROPERTIES = ['id', 'name', 'parentId', 'role', 'myRights'];
+
+type ArchiveMode = 'single' | 'year' | 'month';
+
+interface ActionRequest {
+ action: 'mark-read' | 'archive';
+ accountId: string;
+ emailId: string;
+ login: string;
+ archiveMode: ArchiveMode;
+ timeZone?: string;
+}
+
+interface JmapMailbox {
+ id: string;
+ name: string;
+ parentId?: string | null;
+ role?: string | null;
+ myRights?: { mayAddItems?: boolean };
+}
+
+type MethodResponse = [string, Record, string];
+
+const fail = (status: number, error: string) =>
+ NextResponse.json({ error }, { status, headers: { 'Cache-Control': 'no-store' } });
+
+function validTimeZone(value: unknown): string | undefined {
+ if (typeof value !== 'string' || value.length > 64) return undefined;
+ try {
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
+ return value;
+ } catch {
+ return undefined;
+ }
+}
+
+async function readActionRequest(request: Request): Promise {
+ const bytes = await readBodyCapped(request, MAX_BODY_BYTES);
+ if (!bytes) return null;
+ let body: Record;
+ try {
+ body = JSON.parse(new TextDecoder().decode(bytes)) as Record;
+ } catch {
+ return null;
+ }
+ if (!body || typeof body !== 'object') return null;
+ const { action, accountId, emailId, login, archiveMode } = body;
+ if (action !== 'mark-read' && action !== 'archive') return null;
+ if (typeof accountId !== 'string' || !JMAP_ID.test(accountId)) return null;
+ if (typeof emailId !== 'string' || !JMAP_ID.test(emailId)) return null;
+ if (typeof login !== 'string' || !login) return null;
+ return {
+ action,
+ accountId,
+ emailId,
+ login,
+ archiveMode: archiveMode === 'year' || archiveMode === 'month' ? archiveMode : 'single',
+ timeZone: validTimeZone(body.timeZone),
+ };
+}
+
+function emailSetOutcome(set: Record | undefined, emailId: string): 'updated' | 'notFound' | 'refused' {
+ const { updated, notUpdated } = (set ?? {}) as {
+ updated?: Record | null;
+ notUpdated?: Record | null;
+ };
+ if (updated && Object.prototype.hasOwnProperty.call(updated, emailId)) return 'updated';
+ return notUpdated?.[emailId]?.type === 'notFound' ? 'notFound' : 'refused';
+}
+
+/** The signed-in logins this browser's cookies name as `login`: the notification's own, or none. */
+async function loginsNamed(login: string): Promise {
+ const cookieStore = await cookies();
+ const found: StalwartAuthContext[] = [];
+ for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) {
+ const ctx = readStalwartAuthContextFromStore(cookieStore, slot);
+ if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx);
+ }
+ return found;
+}
+
+/**
+ * The policy for this login's scopes. The worker's copies of the settings
+ * decide what a toast offers; this decides what works. Null when the scopes
+ * can't be worked out, and then nothing is done from here.
+ */
+async function policyFor(ctx: StalwartAuthContext, trusted: boolean): Promise {
+ await configManager.ensureLoaded();
+ const subject = await strictDeviceSubject({ ...ctx, trusted });
+ return subject ? resolvePolicy(configManager.getPolicy(), subject).policy : null;
+}
+
+/** The year and month a message falls in where the user is. */
+function dateParts(receivedAt: string, timeZone: string | undefined): { year: string; month: string } {
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timeZone ?? 'UTC', year: 'numeric', month: '2-digit' })
+ .formatToParts(new Date(receivedAt));
+ return {
+ year: parts.find((part) => part.type === 'year')?.value ?? '',
+ month: parts.find((part) => part.type === 'month')?.value ?? '',
+ };
+}
+
+/**
+ * Where Archive files a message, as the app's own Archive does
+ * (JMAPClient.archiveEmails, ensureArchiveMailbox): the Archive folder, or a
+ * year or year/month folder in it, creating what isn't there. Creation ids
+ * resolve in the request that carries the Mailbox/set.
+ */
+function archiveDestination(
+ mailboxes: JmapMailbox[],
+ mode: ArchiveMode,
+ receivedAt: string,
+ timeZone: string | undefined,
+): { destination: string; create: Record> } {
+ const create: Record> = {};
+ const archive = mailboxes.find((m) => m.role === 'archive')
+ ?? mailboxes.find((m) => m.name.toLowerCase() === 'archive');
+ let destination = archive?.id ?? '#archive';
+ if (!archive) create.archive = { name: 'Archive', parentId: null, role: 'archive' };
+
+ const descend = (name: string, cid: string) => {
+ const existing = destination.startsWith('#')
+ ? undefined
+ : mailboxes.find((m) => m.parentId === destination && m.name === name);
+ if (existing) {
+ destination = existing.id;
+ return;
+ }
+ create[cid] = { name, parentId: destination };
+ destination = `#${cid}`;
+ };
+ if (mode !== 'single') {
+ const { year, month } = dateParts(receivedAt, timeZone);
+ descend(year, 'year');
+ if (mode === 'month') descend(month, 'month');
+ }
+ return { destination, create };
+}
+
+/**
+ * POST /api/push/action
+ *
+ * A notification's Mark read or Archive when no tab is open to do it. The
+ * service worker has no login of its own, only the context cookie, whose
+ * token a page refreshes and which ends with the browser session. Every
+ * refusal here means the same to the worker: open the message in the app.
+ *
+ * Like /api/push/preview it never hands the worker a credential. The cookie
+ * is sent because the request is same-origin; the proxy and this handler both
+ * refuse any other. Plugins run in pages, so the worker doesn't come here while
+ * an enabled plugin may stop or watch these actions.
+ */
+export async function POST(request: NextRequest) {
+ const rejected = rejectCrossOriginRequest(request);
+ if (rejected) return rejected;
+ try {
+ const body = await readActionRequest(request);
+ if (!body) return fail(400, 'Invalid request');
+
+ const logins = await loginsNamed(body.login);
+ if (logins.length === 0) return fail(401, 'Not authenticated');
+ if (logins.length > 1) return fail(409, 'Login is in more than one cookie slot');
+ const ctx = logins[0];
+
+ const serverUrl = ctx.serverUrl.replace(/\/+$/, '');
+ const trusted = await isTrustedJmapServerUrl(serverUrl);
+ const forwarded = forwardedClientHeaders(loginClientAddress(request));
+ const sessionRes = await fetchJmapServer(`${serverUrl}/.well-known/jmap`, {
+ headers: { Authorization: ctx.authHeader, ...forwarded },
+ }, trusted);
+ // An expired token (only a page refreshes it), or a password changed since.
+ if (sessionRes.status === 401 || sessionRes.status === 403) return fail(401, 'Not authenticated');
+ if (!sessionRes.ok) return fail(502, 'JMAP session failed');
+ const session = (await readJsonCapped(sessionRes, UPSTREAM_JSON_MAX_BYTES)) as {
+ apiUrl?: string;
+ primaryAccounts?: Record;
+ accounts?: Record }>;
+ };
+ const apiUrl = rebaseApiUrl(session, serverUrl);
+ if (!apiUrl) return fail(502, 'JMAP session failed');
+ // The worker named this account; it has to be one this login can reach.
+ const { accountId, emailId } = body;
+ const account = session.accounts && Object.prototype.hasOwnProperty.call(session.accounts, accountId)
+ ? session.accounts[accountId]
+ : undefined;
+ if (!account && session.primaryAccounts?.[MAIL_CAPABILITY] !== accountId) {
+ return fail(403, 'Account is not in this login');
+ }
+
+ // A session limit is the page's to apply (activity, sign-in time): acting
+ // from here would keep a login going past it. The app opens and applies it.
+ const policy = await policyFor(ctx, trusted);
+ const limits = policy?.organisation?.session;
+ if (!policy || (limits?.maxHours ?? 0) > 0 || (limits?.idleMinutes ?? 0) > 0) return fail(401, 'Not authenticated');
+ if (body.action === 'archive' && policy.features?.archiveActionEnabled === false) return fail(403, 'Archive is turned off');
+
+ const jmap = async (methodCalls: unknown[]): Promise => {
+ const res = await fetchJmapServer(apiUrl, {
+ method: 'POST',
+ headers: { Authorization: ctx.authHeader, 'Content-Type': 'application/json', ...forwarded },
+ body: JSON.stringify({ using: USING, methodCalls }),
+ }, trusted);
+ if (!res.ok) return null;
+ const data = (await readJsonCapped(res, UPSTREAM_JSON_MAX_BYTES)) as { methodResponses?: MethodResponse[] };
+ return Array.isArray(data.methodResponses) ? data.methodResponses : null;
+ };
+ const result = (responses: MethodResponse[], name: string, id: string) =>
+ responses.find(([method, , callId]) => method === name && callId === id)?.[1];
+
+ const readFolders = async (): Promise => {
+ const read = await jmap([['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes']]);
+ const list = read && result(read, 'Mailbox/get', 'mailboxes')?.list;
+ return Array.isArray(list) ? (list as JmapMailbox[]) : null;
+ };
+
+ const read = await jmap([
+ ['Mailbox/get', { accountId, ids: null, properties: MAILBOX_PROPERTIES }, 'mailboxes'],
+ ['Email/get', { accountId, ids: [emailId], properties: ['id', 'receivedAt', 'mailboxIds'] }, 'email'],
+ ]);
+ if (!read) return fail(502, 'JMAP request failed');
+ let mailboxes = result(read, 'Mailbox/get', 'mailboxes')?.list as JmapMailbox[] | undefined;
+ const emails = result(read, 'Email/get', 'email')?.list as { receivedAt?: string; mailboxIds?: Record }[] | undefined;
+ if (!Array.isArray(mailboxes) || !Array.isArray(emails)) return fail(502, 'JMAP request failed');
+ const email = emails[0];
+ if (!email?.receivedAt) return fail(404, 'Message not found');
+ // Since the notification it may have gone to Trash or Junk: not for a toast
+ // to bring back or to mark. The app opens it.
+ const bins = new Set(mailboxes.filter((m) => m.role === 'trash' || m.role === 'junk').map((m) => m.id));
+ if (Object.keys(email.mailboxIds ?? {}).some((id) => bins.has(id))) return fail(409, 'Message is in Trash or Junk');
+
+ let answer: ReturnType = 'refused';
+ const createdFolders: string[] = [];
+ // Twice at most: another client may make the same folder between our read
+ // and our write, and then the folder is there to file into.
+ for (let attempt = 0; attempt < 2; attempt++) {
+ let update: Record = { 'keywords/$seen': true };
+ let create: Record> = {};
+ if (body.action === 'archive') {
+ const planned = archiveDestination(mailboxes, body.archiveMode, email.receivedAt, body.timeZone);
+ // Only an explicit refusal stops it, as in the app.
+ if (planned.create.archive && account?.accountCapabilities?.[MAIL_CAPABILITY]?.mayCreateTopLevelMailbox === false) {
+ return fail(403, 'Archive folder is missing');
+ }
+ if (mailboxes.find((m) => m.id === planned.destination)?.myRights?.mayAddItems === false) {
+ return fail(403, 'Archive folder is read-only');
+ }
+ create = planned.create;
+ update = { mailboxIds: { [planned.destination]: true } };
+ }
+ const written = await jmap([
+ ...(Object.keys(create).length > 0 ? [['Mailbox/set', { accountId, create }, 'folders']] : []),
+ ['Email/set', { accountId, update: { [emailId]: update } }, 'write'],
+ ]);
+ if (!written) return fail(502, 'JMAP request failed');
+ const folders = result(written, 'Mailbox/set', 'folders') as {
+ created?: Record | null;
+ notCreated?: Record | null;
+ } | undefined;
+ // Deepest first, for removing them again.
+ for (const cid of ['month', 'year', 'archive']) {
+ const id = folders?.created?.[cid]?.id;
+ if (id) createdFolders.push(id);
+ }
+ answer = emailSetOutcome(result(written, 'Email/set', 'write'), emailId);
+ if (answer !== 'refused' || Object.keys(folders?.notCreated ?? {}).length === 0 || attempt > 0) break;
+ const fresh = await readFolders();
+ if (!fresh) break;
+ mailboxes = fresh;
+ }
+
+ if (answer === 'updated') return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
+ // Nothing went in: no empty folder left behind. The server refuses to
+ // destroy one someone else has filed into since.
+ if (createdFolders.length > 0) await jmap([['Mailbox/set', { accountId, destroy: createdFolders }, 'cleanup']]).catch(() => null);
+ if (answer === 'notFound') return fail(404, 'Message not found');
+ return fail(502, 'JMAP server refused the change');
+ } catch (error) {
+ if (error instanceof DisallowedUrlError) {
+ logger.warn('push action refused non-public server address', { error: error.message });
+ return fail(502, 'JMAP server address is not allowed');
+ }
+ if (error instanceof UpstreamBodyTooLargeError) {
+ logger.warn('push action: JMAP response too large', { maxBytes: error.maxBytes });
+ return fail(502, 'JMAP server response is too large');
+ }
+ const err = error as Error & { cause?: { code?: string; message?: string } };
+ logger.error('push action failed', { error: err?.message ?? 'Unknown error', causeCode: err?.cause?.code });
+ return fail(500, 'Internal error');
+ }
+}
diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts
index 8e48073ba..c862eeb99 100644
--- a/app/api/push/preview/route.ts
+++ b/app/api/push/preview/route.ts
@@ -14,6 +14,7 @@ import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-ad
import { DisallowedUrlError } from '@/lib/security/url-guard';
import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body';
import { serverDevicePolicy } from '@/lib/admin/server-device-policy';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -359,6 +360,9 @@ export async function GET(request: NextRequest) {
unreadTotal,
...(redacted ? { redacted: true } : {}),
...(target.slot !== undefined ? { slot: target.slot } : {}),
+ // For the notification's buttons, which must not land on whoever signs
+ // into this slot later (app/api/push/action).
+ ...(target.slot !== undefined && email ? { login: await loginFingerprint(target.serverUrl, target.username) } : {}),
}, {
headers: {
// SW already gates on its own logic - don't let push events get
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 1bccba921..6e766ab98 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -155,6 +155,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// The server's reason for a refused change, where the user can act on it.
const describeError = useCalendarErrorMessage();
const tSidebar = useTranslations("sidebar");
+ const tOffline = useTranslations("calendar_offline");
useDocumentTitle(tSidebar("calendar"));
const skeletonLabels = useSkeletonLabels();
const tWebcalAction = useTranslations("calendar.webcal_action");
@@ -1446,9 +1447,13 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
debug.error("Failed to refresh calendar after scope operation");
}
} catch (error) {
+ if (error instanceof Error && error.name === "SeriesSplitNeedsServerError") {
+ toast.error(tOffline("split_needs_server"));
+ return;
+ }
toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error")));
}
- }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]);
+ }, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t, tOffline]);
// A drag or resize. A series asks which occurrences it covers, as the
// editor does; one event moves at once and can be moved back.
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/components/mail/__tests__/blob-routing.test.tsx b/components/mail/__tests__/blob-routing.test.tsx
index 59b96c183..29c180bbb 100644
--- a/components/mail/__tests__/blob-routing.test.tsx
+++ b/components/mail/__tests__/blob-routing.test.tsx
@@ -800,6 +800,25 @@ function expectProBlob(route: typeof proRoutes[number], action: ProBlobAction, i
}
describe('Pro blob routing', () => {
+ it("opens a compose tab with the typed text for a notification's Reply, once, when the message is in", async () => {
+ const { requestTabReply } = await import('@/lib/pwa-notify/tab-reply');
+ const { useProTabStore } = await import('@/stores/pro-tab-store');
+ const { a } = await conversationClients();
+ vi.spyOn(a, 'getEmail').mockResolvedValue(message());
+ useEmailStore.setState({ emails: [], selectedEmail: null, isUnifiedView: false, selectedMailbox: 'inbox' });
+ useAuthStore.setState({ client: a, activeAccountId: 'login-primary' });
+ const composeTabs = () => useProTabStore.getState().tabs.filter((tab) => tab.kind === 'compose');
+ const before = composeTabs().length;
+
+ requestTabReply('email', 'Typed on the toast');
+ const view = render();
+ await waitFor(() => expect(composeTabs()).toHaveLength(before + 1));
+ expect(composeTabs().at(-1)?.composeData).toMatchObject({ mode: 'reply', sourceEmailId: 'email', initialDraftText: 'Typed on the toast' });
+
+ view.rerender();
+ expect(composeTabs()).toHaveLength(before + 1);
+ });
+
it.each(proRoutes.flatMap(route => proBlobActions.map(action => ({ ...route, action }))))(
'$label: $action uses the message owner and authenticated client', async route => {
const { origin, fetchMock } = await openProMessage(route, route.action);
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index c69d7c2c7..136093b22 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -142,6 +142,9 @@ import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
+import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions";
+import { requestTabReply } from "@/lib/pwa-notify/tab-reply";
+import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state";
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose";
import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email";
@@ -171,7 +174,7 @@ import {
resolveFolderRef,
type MailDeepLink,
} from "@/lib/deep-links";
-import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
+import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
@@ -257,6 +260,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState("");
+ // A notification's Reply, waiting for its message to be the open one.
+ const [pendingNotificationReply, setPendingNotificationReply] = useState<{ emailId: string; text: string } | null>(null);
const [pendingDraft, setPendingDraft] = useState(null);
const [composerSessionId, setComposerSessionId] = useState(0);
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -1768,7 +1773,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
};
- const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => {
+ const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean; replyText?: string }) => {
// The folder list before an account switch, if one happened: until the
// new mailbox's folders arrive the store still holds these (or nothing).
let switchedFrom: Mailbox[] | null = null;
@@ -1842,6 +1847,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A shared account's message is fetched from that account through
// the active login (#1351).
const loginId = link.ownerAccountId ? useAuthStore.getState().activeAccountId ?? undefined : undefined;
+ // A notification's Reply: the tab opens its composer once it has the message.
+ if (link.reply) requestTabReply(link.id, opts?.replyText ?? await consumePendingReplyText(link.id));
useProTabStore.getState().openEmailTab({
accountId: link.ownerAccountId ?? '',
...(loginId ? { clientAccountId: loginId } : {}),
@@ -1867,6 +1874,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
if (isMobile) setActiveView('viewer');
if (isTablet) setTabletListVisible(false);
+ if (link.reply) {
+ // A notification's Reply. The branch above may have skipped
+ // selecting, and the reply has to wait for the message to be open.
+ selectEmail(email);
+ setPendingNotificationReply({ emailId: email.id, text: opts?.replyText ?? await consumePendingReplyText(email.id) });
+ }
} finally {
setLoadingEmail(false);
}
@@ -1921,8 +1934,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// briefly and must not steal the link parked for the Pro one.
useEffect(() => {
if (!isEmbedded) return;
- return subscribePendingDeepLink('mail', (segments) => {
- const link = parseMailPath(segments, new URLSearchParams(window.location.search));
+ return subscribePendingDeepLink('mail', (segments, search) => {
+ const link = parseMailPath(segments, new URLSearchParams(search ?? window.location.search));
if (link) void applyMailDeepLinkRef.current(link);
});
}, [isEmbedded]);
@@ -1973,10 +1986,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
if (!initialMailLoadDone) return;
+ // Inside the Pro shell the route is /pro, so the link arrives through the
+ // handoff the redirect parked it in rather than as route params. Its query
+ // comes with it: the address bar may have none left by the first render.
+ const parked = linkSegments ? null : consumePendingDeepLinkEntry('mail');
+ if (parked?.search) initialSearchRef.current = parked.search;
const params = new URLSearchParams(initialSearchRef.current ?? window.location.search);
- // Inside the Pro shell the route is /pro, so the segments arrive through
- // the handoff the redirect parked them in rather than as route params.
- const segments = linkSegments ?? consumePendingDeepLink('mail') ?? [];
+ const segments = linkSegments ?? parked?.segments ?? [];
const link = parseMailPath(segments, params);
const openLatestUnread = params.get('openLatestUnread') === '1';
@@ -2764,6 +2780,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return () => window.removeEventListener(PLUGIN_REPLY_EVENT, onPluginReply);
}, []);
+ // A notification's Reply, from a `?reply=1` link or from the service worker
+ // when this tab is open. Both come through applyMailDeepLink and end here,
+ // once the message is the open one.
+ useEffect(() => {
+ if (!pendingNotificationReply || selectedEmail?.id !== pendingNotificationReply.emailId) return;
+ setPendingNotificationReply(null);
+ void pluginReplyRef.current.handleReply(pendingNotificationReply.text);
+ }, [pendingNotificationReply, selectedEmail?.id]);
+ const composerOpenRef = useRef(showComposer);
+ composerOpenRef.current = showComposer;
+ useEffect(() => {
+ return setNotificationReplyOpener(({ emailId, accountId, slot, text }) => {
+ const open = () => void applyMailDeepLinkRef.current(
+ { kind: 'message', id: emailId, slot, ownerAccountId: accountId, reply: true },
+ { replyText: text ?? '' },
+ );
+ // A live composer's text goes through its own "Save or discard?" first.
+ const requestClose = composerRequestCloseRef.current;
+ if (composerOpenRef.current && requestClose) requestClose(open);
+ else open();
+ });
+ }, []);
+
// A plugin opening a message (api.ui.openEmail), as a click on a search
// result entry naming one does: handleEmailSelect, set once it is defined.
// Not inside the Pro shell, which keeps this tab mounted while hidden and
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 6040ba126..01b0693c9 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -28,6 +28,7 @@ import { labelSourceHeaders, reopenedLabelChoice } from '@/lib/outgoing/classifi
import { useArchiveActionEnabled } from '@/lib/archive-gate';
import { confirmMailRemoval } from '@/lib/mail-removal-confirm';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
+import { takeTabReply, useNotificationTabReply } from '@/lib/pwa-notify/tab-reply';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
@@ -262,6 +263,14 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
}, { source: email, body: draftText ?? '' });
}, [email, openComposeTab, t, writeSource]);
+ // A notification's Reply, once this tab has the message (mail-app.tsx).
+ const pendingTabReply = useNotificationTabReply((s) => s.pending);
+ useEffect(() => {
+ if (!email || pendingTabReply?.emailId !== email.id) return;
+ const text = takeTabReply(email.id);
+ if (text !== null) handleReply(text);
+ }, [email, pendingTabReply, handleReply]);
+
const handleReplyAll = useCallback(() => {
if (!email) return;
openComposeTab({
diff --git a/components/providers/__tests__/notification-actions-host.test.tsx b/components/providers/__tests__/notification-actions-host.test.tsx
new file mode 100644
index 000000000..f786d3e39
--- /dev/null
+++ b/components/providers/__tests__/notification-actions-host.test.tsx
@@ -0,0 +1,170 @@
+import { act, render } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { NotificationActionsHost } from '../notification-actions-host';
+
+// The tab's end of the service worker: it keeps the worker's copy of the
+// labels and archive settings, runs notification buttons and flushes the
+// outbox when the worker says so.
+
+const writeNotificationActionPrefs = vi.fn(async (..._args: unknown[]) => {});
+const runNotificationAction = vi.fn(async (_request: unknown, answer: (status: string) => void) => answer('done'));
+const scheduleFlushAll = vi.fn();
+let state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true, plugins: [] as Array>, accounts: [] as Array<{ serverUrl: string; username: string }> };
+
+vi.mock('@/lib/pwa-notify/worker-state', () => ({
+ writeNotificationActionPrefs: (...args: unknown[]) => writeNotificationActionPrefs(...args),
+}));
+vi.mock('@/lib/pwa-notify/page-actions', async () => {
+ const actual = await vi.importActual('@/lib/pwa-notify/page-actions');
+ return { isNotificationActionRequest: actual.isNotificationActionRequest, runNotificationAction: (...args: Parameters) => runNotificationAction(...args) };
+});
+vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: (...args: unknown[]) => scheduleFlushAll(...args) }));
+vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => state.archiveEnabled }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: (select: (s: { isAuthenticated: boolean }) => unknown) => select({ isAuthenticated: state.isAuthenticated }),
+}));
+vi.mock('@/stores/settings-store', () => ({
+ useSettingsStore: (select: (s: { archiveMode: string }) => unknown) => select({ archiveMode: state.archiveMode }),
+}));
+vi.mock('@/stores/plugin-store', () => ({
+ usePluginStore: (select: (s: { plugins: unknown[] }) => unknown) => select({ plugins: state.plugins }),
+}));
+vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({}) } }));
+vi.mock('@/stores/account-store', () => {
+ const useAccountStore = (select: (s: { accounts: unknown[] }) => unknown) => select({ accounts: state.accounts });
+ useAccountStore.getState = () => ({ accounts: state.accounts });
+ return { useAccountStore };
+});
+
+const worker = Object.assign(new EventTarget(), { controller: null as null | { postMessage: ReturnType } });
+const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => {
+ const event = new Event('message');
+ Object.assign(event, { data, ports });
+ worker.dispatchEvent(event);
+});
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true, plugins: [], accounts: [] };
+ worker.controller = null;
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: worker });
+});
+
+afterEach(() => {
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined });
+});
+
+describe('the mirror for the worker', () => {
+ it('writes the labels and archive settings once signed in', () => {
+ render();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledWith({
+ archiveEnabled: true,
+ archiveMode: 'month',
+ pluginsMayVeto: false,
+ labels: { archive: 'action_archive', markRead: 'action_mark_read', reply: 'action_reply', replyPlaceholder: 'reply_placeholder' },
+ });
+ });
+
+ it('writes nothing while signed out', () => {
+ state.isAuthenticated = false;
+ render();
+ expect(writeNotificationActionPrefs).not.toHaveBeenCalled();
+ });
+
+ it('writes again on signing in, since signing out deleted the store', () => {
+ state.isAuthenticated = false;
+ const { rerender } = render();
+ state.isAuthenticated = true;
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1);
+ });
+
+ it('writes again when the archive mode or the gate changes, and not otherwise', () => {
+ const { rerender } = render();
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1);
+ state.archiveMode = 'year';
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(2);
+ state.archiveEnabled = false;
+ rerender();
+ expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(3);
+ expect(writeNotificationActionPrefs.mock.calls[2][0]).toMatchObject({ archiveEnabled: false, archiveMode: 'year' });
+ });
+});
+
+describe('what the worker hears about plugins and logins', () => {
+ it('says when an enabled plugin may stop these actions, and when that ends', () => {
+ state.plugins = [{ enabled: true, permissions: ['email:write'] }];
+ const { rerender } = render();
+ expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: true });
+ state.plugins = [{ enabled: false, permissions: ['email:write'] }];
+ rerender();
+ expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: false });
+ });
+
+ it('tells the worker which logins this tab holds, as their fingerprints', async () => {
+ const postMessage = vi.fn();
+ worker.controller = { postMessage };
+ state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }];
+ await act(async () => { render(); });
+ await vi.waitFor(() => expect(postMessage).toHaveBeenCalled());
+ expect(postMessage).toHaveBeenCalledWith({
+ type: 'client-logins',
+ logins: [await loginFingerprint('https://one.example', 'alice@example.org')],
+ });
+ });
+
+ it('tells it again when the logins change', async () => {
+ const postMessage = vi.fn();
+ worker.controller = { postMessage };
+ state.accounts = [{ serverUrl: 'https://one.example', username: 'alice@example.org' }];
+ let view!: ReturnType;
+ await act(async () => { view = render(); });
+ await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
+ state.accounts = [];
+ await act(async () => { view.rerender(); });
+ await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(2));
+ expect(postMessage).toHaveBeenLastCalledWith({ type: 'client-logins', logins: [] });
+ });
+});
+
+describe('messages from the worker', () => {
+ const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp', at: Date.now() };
+
+ it('runs a notification action and answers on its port', async () => {
+ render();
+ const port = { postMessage: vi.fn() };
+ await fromWorker(request, [port]);
+ expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action: 'mark-read', emailId: 'e1' });
+ expect(port.postMessage).toHaveBeenCalledWith({ status: 'done' });
+ });
+
+ it('ignores anything else', async () => {
+ render();
+ await fromWorker({ kind: 'notificationclick', tag: 't' });
+ await fromWorker({ ...request, action: 'delete' });
+ await fromWorker(null);
+ expect(runNotificationAction).not.toHaveBeenCalled();
+ expect(scheduleFlushAll).not.toHaveBeenCalled();
+ });
+
+ it('flushes the outbox on the word of a sync event', async () => {
+ render();
+ await fromWorker({ kind: 'outbox-flush' });
+ expect(scheduleFlushAll).toHaveBeenCalledWith(0);
+ });
+
+ it('stops listening when it goes', async () => {
+ const { unmount } = render();
+ unmount();
+ await fromWorker({ kind: 'outbox-flush' });
+ expect(scheduleFlushAll).not.toHaveBeenCalled();
+ });
+
+ it('does nothing in a browser without service workers', () => {
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined });
+ expect(() => render()).not.toThrow();
+ });
+});
diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx
new file mode 100644
index 000000000..4077951d3
--- /dev/null
+++ b/components/providers/notification-actions-host.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useEffect, useMemo } from 'react';
+import { useTranslations } from 'next-intl';
+import { useArchiveActionEnabled } from '@/lib/archive-gate';
+import { scheduleFlushAll } from '@/lib/offline/offline-mode';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions';
+import { pluginsMayVeto } from '@/lib/pwa-notify/plugin-veto';
+import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useSettingsStore } from '@/stores/settings-store';
+
+/**
+ * This tab's end of what the service worker can't do alone: it keeps the
+ * worker's copy of the labels and archive settings current, says which logins
+ * it holds, runs a notification's buttons and flushes the outbox on the
+ * worker's word. Not mounted in Lite (no service worker there).
+ */
+export function NotificationActionsHost() {
+ const t = useTranslations('pwaNotify');
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const archiveMode = useSettingsStore((s) => s.archiveMode);
+ const archiveEnabled = useArchiveActionEnabled();
+ const mayVeto = usePluginStore((s) => pluginsMayVeto(s.plugins));
+ const accounts = useAccountStore((s) => s.accounts);
+ const archive = t('action_archive');
+ const markRead = t('action_mark_read');
+ const reply = t('action_reply');
+ const replyPlaceholder = t('reply_placeholder');
+
+ // Signing out deletes the worker's store, so signing in writes it again.
+ useEffect(() => {
+ if (!isAuthenticated) return;
+ void writeNotificationActionPrefs({
+ archiveEnabled,
+ archiveMode,
+ pluginsMayVeto: mayVeto,
+ labels: { archive, markRead, reply, replyPlaceholder },
+ });
+ }, [isAuthenticated, archiveEnabled, archiveMode, mayVeto, archive, markRead, reply, replyPlaceholder]);
+
+ // Which logins this tab holds: if it stays silent on a click for one of them,
+ // the worker opens the message rather than go round its plugins.
+ const loginKey = useMemo(() => accounts.map((a) => `${a.serverUrl}\n${a.username}`).join('\n\n'), [accounts]);
+ useEffect(() => {
+ const worker = navigator.serviceWorker;
+ if (!worker) return;
+ let stale = false;
+ void (async () => {
+ try {
+ const held = await Promise.all(useAccountStore.getState().accounts.map((a) => loginFingerprint(a.serverUrl, a.username)));
+ // Without a worker `ready` never settles, and nothing needs telling.
+ const target = worker.controller ?? (await worker.ready).active;
+ if (!stale) target?.postMessage({ type: 'client-logins', logins: held });
+ } catch {
+ // The worker counts this tab as not holding them.
+ }
+ })();
+ return () => { stale = true; };
+ }, [loginKey, isAuthenticated]);
+
+ useEffect(() => {
+ const worker = navigator.serviceWorker;
+ if (!worker) return;
+ const onMessage = (event: MessageEvent) => {
+ const message = event.data as { kind?: string } | null;
+ if (message?.kind === 'outbox-flush') {
+ scheduleFlushAll(0);
+ return;
+ }
+ if (message?.kind !== 'notification-action' || !isNotificationActionRequest(message)) return;
+ const port = event.ports[0];
+ void runNotificationAction(message, (status) => port?.postMessage({ status }));
+ };
+ worker.addEventListener('message', onMessage);
+ return () => worker.removeEventListener('message', onMessage);
+ }, []);
+
+ return null;
+}
diff --git a/integration/tests/23-do-pwa-notify.spec.ts b/integration/tests/23-do-pwa-notify.spec.ts
new file mode 100644
index 000000000..5867c9c29
--- /dev/null
+++ b/integration/tests/23-do-pwa-notify.spec.ts
@@ -0,0 +1,566 @@
+import { test, expect, type BrowserContext, type Page, type Worker } from '@playwright/test';
+import { JMAP_URL } from './helpers/config';
+import { ENT, adminContext, deliver, jmapAs, requireEntEnv, userPage, withPolicy } from './helpers/ent';
+
+/**
+ * A notification's Archive, Mark read and Reply, and Background Sync, in
+ * Chromium with the real service worker of a production build, the real
+ * /api/push/preview and /api/push/action, and a VM 106 test Stalwart.
+ *
+ * Headless Chromium has no toasts to click, and a page can't make a browser
+ * send a trusted notificationclick. So the spec dispatches the event inside
+ * the real worker, on its real listener: an Event carrying `action`, the
+ * `notification` the browser really shows, and a waitUntil of its own
+ * (ExtendableEvent.waitUntil refuses a synthetic event). Everything after
+ * that is real: matchAll, postMessage to the page, the cookies on the route.
+ * What can't run this way is openWindow, focus and navigate, which want the
+ * browser's user activation, so those are recorded on the worker's side and
+ * the spec then opens the recorded URL itself.
+ *
+ * The account is ent-dave, whom the other specs leave alone: the one-message
+ * notification needs an Inbox with exactly one unread message.
+ */
+
+test.describe.configure({ mode: 'serial' });
+test.setTimeout(180_000);
+// The headless shell denies notifications whatever is granted; full Chromium
+// in headless mode really shows them, buttons and all, to getNotifications.
+test.use({ channel: 'chromium' });
+
+const RUN = Date.now().toString(36);
+let serial = 0;
+const USER = ENT.dave;
+
+interface ShownNotification {
+ title: string;
+ data: Record;
+ actions: Array<{ action: string; title: string; type?: string }>;
+}
+
+async function workerOf(context: BrowserContext, page: Page): Promise {
+ // A production build registers the worker; the dev server removes it.
+ await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
+ await expect.poll(() => context.serviceWorkers().length, { timeout: 30_000 }).toBeGreaterThan(0);
+ const sw = context.serviceWorkers().at(-1)!;
+ // What needs the user activation a dispatched click doesn't have.
+ await sw.evaluate(() => {
+ const self_ = globalThis as unknown as { __opened: string[]; __posts: string[]; clients: Record; fetch: typeof fetch };
+ if (self_.__opened) return;
+ self_.__opened = [];
+ self_.__posts = [];
+ self_.clients.openWindow = async (url: string) => { self_.__opened.push(url); return null; };
+ const realFetch = self_.fetch.bind(globalThis);
+ self_.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (init?.method === 'POST') self_.__posts.push(String(input));
+ return realFetch(input, init);
+ }) as typeof fetch;
+ });
+ return sw;
+}
+
+/** The real listener, with what the browser would pass. Resolves when its waitUntil settled. */
+async function click(sw: Worker, action: string, data: Record, reply?: string): Promise {
+ await sw.evaluate(async ({ action: a, data: d, reply: r }) => {
+ const waits: Promise[] = [];
+ const event = new Event('notificationclick') as Event & Record;
+ Object.assign(event, {
+ action: a,
+ reply: r ?? null,
+ notification: { data: d, tag: 'spec', close() {} },
+ waitUntil: (p: Promise) => waits.push(p),
+ });
+ self.dispatchEvent(event);
+ await Promise.all(waits);
+ }, { action, data, reply });
+}
+
+async function pushed(sw: Worker, accountId: string, emailId: string): Promise {
+ return sw.evaluate(async ({ accountId: account, emailId: id }) => {
+ const waits: Promise[] = [];
+ const event = new Event('push') as Event & Record;
+ Object.assign(event, {
+ data: { json: () => ({ kind: 'jmap-email-push', accountId: account, emailIds: [id] }) },
+ waitUntil: (p: Promise) => waits.push(p),
+ });
+ self.dispatchEvent(event);
+ await Promise.all(waits);
+ const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration;
+ const [shown] = await registration.getNotifications({ tag: `bulwark-mail:${account}` });
+ return { title: shown.title, data: shown.data, actions: [...(shown as unknown as { actions: ShownNotification['actions'] }).actions] };
+ }, { accountId, emailId });
+}
+
+async function opened(sw: Worker): Promise {
+ return sw.evaluate(() => (globalThis as unknown as { __opened: string[] }).__opened.splice(0));
+}
+
+async function routePosts(sw: Worker): Promise {
+ return sw.evaluate(() => (globalThis as unknown as { __posts: string[] }).__posts.splice(0));
+}
+
+/** Everything unread in the Inbox read, then one new message: what makes a notification name it. */
+async function oneUnread(): Promise<{ id: string; subject: string; accountId: string }> {
+ const client = await jmapAs(USER);
+ const inbox = await client.mailboxByRole('inbox');
+ const query = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { inMailbox: inbox!.id, notKeyword: '$seen' } }, 'q'],
+ ]);
+ const unread: string[] = query.methodResponses[0][1].ids;
+ if (unread.length > 0) {
+ await client.request([['Email/set', {
+ accountId: client.accountId,
+ update: Object.fromEntries(unread.map((id) => [id, { 'keywords/$seen': true }])),
+ }, 's']]);
+ }
+ const subject = `Notify ${RUN} ${++serial}`;
+ await deliver({ from: ENT.bob, to: [USER], subject, text: 'Please answer.' });
+ const email = await client.waitForEmail(subject, { mailboxId: inbox!.id, timeoutMs: 30_000 });
+ return { id: email.id, subject, accountId: client.accountId };
+}
+
+async function serverCopy(emailId: string): Promise<{ keywords: Record; mailboxIds: Record; receivedAt: string }> {
+ const client = await jmapAs(USER);
+ const res = await client.request([['Email/get', { accountId: client.accountId, ids: [emailId], properties: ['keywords', 'mailboxIds', 'receivedAt'] }, 'g']]);
+ return res.methodResponses[0][1].list[0];
+}
+
+async function archiveFolderId(): Promise {
+ const client = await jmapAs(USER);
+ return (await client.mailboxByRole('archive'))?.id;
+}
+
+test.beforeAll(() => requireEntEnv());
+
+test.describe('with the app open', () => {
+ let page: Page;
+ let context: BrowserContext;
+ let sw: Worker;
+
+ test.beforeAll(async ({ browser }) => {
+ page = await userPage(browser, USER);
+ context = page.context();
+ await context.grantPermissions(['notifications']);
+ sw = await workerOf(context, page);
+ // The mirror reaches the worker's store after the app has started.
+ await expect.poll(() => sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ const hit = await cache.match(`${self.location.origin}/__push-state/actions`);
+ return hit ? (await hit.json()).labels?.archive : null;
+ }), { timeout: 30_000 }).toBe('Archive');
+ });
+
+ test.afterAll(async () => {
+ await context.close();
+ });
+
+ test('a push for one message shows the buttons the browser has room for', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ expect(shown.title).toContain(ENT.bob.split('@')[0]);
+ expect(shown.data).toMatchObject({ kind: 'email', emailId: mail.id, accountId: mail.accountId });
+ expect(typeof shown.data.login).toBe('string');
+ const max = await sw.evaluate(() => (Notification as unknown as { maxActions: number }).maxActions);
+ const names = shown.actions.map((a) => a.action);
+ expect(names.length).toBe(Math.min(3, max));
+ expect(names).toContain('reply');
+ expect(names).toContain('archive');
+ expect(shown.actions.find((a) => a.action === 'archive')?.title).toBe('Archive');
+ // Chromium has `reply` on the event: the text field.
+ expect(shown.actions.find((a) => a.action === 'reply')?.type).toBe('text');
+ });
+
+ test('a group of messages gets no buttons', async () => {
+ const client = await jmapAs(USER);
+ const first = await oneUnread();
+ const subject = `Notify ${RUN} second`;
+ await deliver({ from: ENT.bob, to: [USER], subject, text: 'Another.' });
+ const second = await client.waitForEmail(subject, { timeoutMs: 30_000 });
+ const shown = await pushed(sw, first.accountId, second.id);
+ expect(shown.data.kind).toBe('mail-list');
+ expect(shown.actions).toEqual([]);
+ });
+
+ test('Mark read goes to the open tab, which marks it in the list too, not to the route', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ await click(sw, 'mark-read', shown.data);
+ await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 15_000 }).toBe(true);
+ expect(await routePosts(sw)).toEqual([]);
+ expect(await opened(sw)).toEqual([]);
+ });
+
+ test('Archive goes to the open tab and files the message by the store', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ await click(sw, 'archive', shown.data);
+ const client = await jmapAs(USER);
+ const inbox = await client.mailboxByRole('inbox');
+ await expect.poll(async () => !(await serverCopy(mail.id)).mailboxIds[inbox!.id], { timeout: 20_000 }).toBe(true);
+ const archive = await archiveFolderId();
+ expect(archive).toBeTruthy();
+ expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]);
+ expect(await routePosts(sw)).toEqual([]);
+ });
+
+ test('a body click still opens the message and runs nothing', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ // focus() needs the activation a dispatched click lacks, so this ends in openWindow.
+ await click(sw, '', shown.data);
+ const urls = await opened(sw);
+ expect(urls).toHaveLength(1);
+ expect(urls[0]).toContain(`/mail/message/${mail.id}`);
+ expect(urls[0]).not.toContain('reply=1');
+ expect(await routePosts(sw)).toEqual([]);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ });
+
+ test('Reply opens the composer in the open tab, with the text typed on the notification', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await routePosts(sw);
+ await click(sw, 'reply', shown.data, `Typed on the toast ${RUN}`);
+ await expect.poll(async () => page.evaluate((subjectText) => {
+ const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[];
+ return inputs.some((input) => input.value === `Re: ${subjectText}`);
+ }, mail.subject), { timeout: 20_000 }).toBe(true);
+ await expect(page.getByText(`Typed on the toast ${RUN}`).first()).toBeVisible({ timeout: 10_000 });
+ expect(await routePosts(sw)).toEqual([]);
+ expect(await opened(sw)).toEqual([]);
+ });
+});
+
+test.describe('Background Sync', () => {
+ test('a queued change registers the tag, and a sync event makes a tab that missed `online` flush it', async ({ browser }) => {
+ const page = await userPage(browser, USER);
+ const context = page.context();
+ await context.grantPermissions(['notifications', 'background-sync']);
+ const sw = await workerOf(context, page);
+ await page.addInitScript(() => {
+ const w = window as unknown as { __syncTags: string[] };
+ w.__syncTags = [];
+ const register = SyncManager.prototype.register;
+ SyncManager.prototype.register = function patched(tag: string) {
+ w.__syncTags.push(tag);
+ return register.call(this, tag);
+ };
+ });
+ await page.goto('/settings/backup/offline');
+ const toggle = page.getByTestId('offline-switch');
+ await expect(toggle).toBeVisible({ timeout: 30_000 });
+ if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click();
+ await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
+ await page.goto('/mail');
+ await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
+
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+
+ // The mail server is gone, the browser still online: no `online` event will come.
+ const block = `${new URL(JMAP_URL).origin}/**`;
+ await context.route(block, (route) => route.abort('internetdisconnected'));
+ await page.keyboard.press('Shift+G');
+ await expect(page.getByTestId('offline-banner')).toContainText("The mail server can't be reached", { timeout: 60_000 });
+
+ // The tab queues the change, as it does for its own Mark read button.
+ await click(sw, 'mark-read', shown.data);
+ await expect.poll(() => page.evaluate(() => (window as unknown as { __syncTags: string[] }).__syncTags)).toContain('bulwark-outbox');
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+
+ await context.unroute(block);
+ await sw.evaluate(async () => {
+ const waits: Promise[] = [];
+ const event = new Event('sync') as Event & Record;
+ Object.assign(event, { tag: 'bulwark-outbox', waitUntil: (p: Promise) => waits.push(p) });
+ self.dispatchEvent(event);
+ await Promise.all(waits);
+ });
+ await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 30_000 }).toBe(true);
+
+ // Offline mode stays local to this context, which ends here.
+ await context.close();
+ });
+});
+
+test.describe('with no tab open', () => {
+ let page: Page;
+ let context: BrowserContext;
+ let sw: Worker;
+
+ /** A blank tab keeps the context alive: it's outside the worker's scope. */
+ async function closeTheApp(): Promise {
+ const blank = await context.newPage();
+ await page.close();
+ await expect.poll(() => sw.evaluate(async () => (await (self as unknown as { clients: Clients }).clients.matchAll({ type: 'window', includeUncontrolled: true })).length)).toBe(0);
+ return blank;
+ }
+
+ test.beforeEach(async ({ browser }) => {
+ page = await userPage(browser, USER);
+ context = page.context();
+ await context.grantPermissions(['notifications']);
+ sw = await workerOf(context, page);
+ await expect.poll(() => sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ return !!(await cache.match(`${self.location.origin}/__push-state/actions`));
+ }), { timeout: 30_000 }).toBe(true);
+ });
+
+ test.afterEach(async () => {
+ await context.close();
+ });
+
+ test('Mark read goes through the route on the context cookie', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ await routePosts(sw);
+ await click(sw, 'mark-read', shown.data);
+ expect((await routePosts(sw)).at(-1)).toContain('/api/push/action');
+ expect((await serverCopy(mail.id)).keywords.$seen).toBe(true);
+ expect(await opened(sw)).toEqual([]);
+ });
+
+ test('Archive goes through the route, to the folder the app uses', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ await click(sw, 'archive', shown.data);
+ const archive = await archiveFolderId();
+ expect(archive).toBeTruthy();
+ expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]);
+ expect(await opened(sw)).toEqual([]);
+ });
+
+ test('Archive follows the mirrored mode into year and month folders', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ const key = `${self.location.origin}/__push-state/actions`;
+ const prefs = await (await cache.match(key))!.json();
+ await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' })));
+ });
+ await closeTheApp();
+ await click(sw, 'archive', shown.data);
+ const client = await jmapAs(USER);
+ const copy = await serverCopy(mail.id);
+ const [folderId] = Object.keys(copy.mailboxIds);
+ const folders = await client.mailboxes();
+ const month = folders.find((f) => f.id === folderId)!;
+ const year = folders.find((f) => f.id === month.parentId)!;
+ const when = new Date(copy.receivedAt);
+ expect(month.name).toBe(String(when.getMonth() + 1).padStart(2, '0'));
+ expect(year.name).toBe(String(when.getFullYear()));
+ expect(year.parentId).toBe(await archiveFolderId());
+ });
+
+ test('Archive makes the Archive, year and month folders on an account without them', async () => {
+ const client = await jmapAs(USER);
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ const key = `${self.location.origin}/__push-state/actions`;
+ const prefs = await (await cache.match(key))!.json();
+ await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' })));
+ });
+ await closeTheApp();
+ // Out of the way, so the account has no Archive for the moment.
+ const old = await client.mailboxByRole('archive');
+ const oldName = `Old archive ${RUN}`;
+ if (old) {
+ const renamed = await client.request([['Mailbox/set', { accountId: client.accountId, update: { [old.id]: { name: oldName, role: null } } }, 'r']]);
+ expect(renamed.methodResponses[0][1].updated).toHaveProperty(old.id);
+ }
+
+ await click(sw, 'archive', shown.data);
+
+ expect(await opened(sw)).toEqual([]);
+ const created = await client.mailboxByRole('archive');
+ expect(created?.name).toBe('Archive');
+ expect(created?.id).not.toBe(old?.id);
+ const copy = await serverCopy(mail.id);
+ const folders = await client.mailboxes();
+ const month = folders.find((f) => f.id === Object.keys(copy.mailboxIds)[0])!;
+ const year = folders.find((f) => f.id === month.parentId)!;
+ expect(year.parentId).toBe(created!.id);
+
+ if (old) {
+ // Only this spec's messages live in ent-dave's Archive.
+ await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [old.id], onDestroyRemoveEmails: true }, 'd']]);
+ }
+ });
+
+ test('with an enabled plugin that may stop it, the route is skipped and the message opens', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await sw.evaluate(async () => {
+ const cache = await caches.open('bulwark-push-state-v1');
+ const key = `${self.location.origin}/__push-state/actions`;
+ const prefs = await (await cache.match(key))!.json();
+ await cache.put(key, new Response(JSON.stringify({ ...prefs, pluginsMayVeto: true })));
+ });
+ await closeTheApp();
+ await routePosts(sw);
+ await click(sw, 'mark-read', shown.data);
+ expect(await routePosts(sw)).toEqual([]);
+ expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ });
+
+ test('a message since moved to Trash stays there, and opens in the app', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ const client = await jmapAs(USER);
+ const trash = await client.mailboxByRole('trash');
+ await client.moveEmail(mail.id, trash!.id);
+ await click(sw, 'archive', shown.data);
+ expect((await opened(sw))[0]).toContain(`/mail/message/${mail.id}`);
+ expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([trash!.id]);
+ });
+
+ test('with the context cookie gone the message opens instead, and stays as it was', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ const jar = await context.cookies();
+ await context.clearCookies({ name: /^jmap_stalwart_ctx/ });
+ expect(jar.some((c) => c.name.startsWith('jmap_stalwart_ctx'))).toBe(true);
+ await routePosts(sw);
+ await click(sw, 'mark-read', shown.data);
+ expect((await routePosts(sw)).at(-1)).toContain('/api/push/action');
+ const urls = await opened(sw);
+ expect(urls).toHaveLength(1);
+ expect(urls[0]).toContain(`/mail/message/${mail.id}`);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ });
+
+ test('a notification from a login no longer signed in acts on nothing', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await closeTheApp();
+ await click(sw, 'mark-read', { ...shown.data, login: 'someone-else' });
+ expect(await opened(sw)).toHaveLength(1);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ });
+
+ test('Reply opens the link, the composer opens once with the typed text, and a reload leaves it shut', async () => {
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ const blank = await closeTheApp();
+ await click(sw, 'reply', shown.data, `Typed while closed ${RUN}`);
+ const [url] = await opened(sw);
+ expect(url).toContain(`/mail/message/${mail.id}`);
+ expect(url).toContain('reply=1');
+ expect(url).not.toContain('Typed');
+
+ page = await context.newPage();
+ await page.goto(url);
+ await blank.close();
+ const replyInput = () => page.evaluate((subjectText) => {
+ const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[];
+ return inputs.some((input) => input.value === `Re: ${subjectText}`);
+ }, mail.subject);
+ await expect.poll(replyInput, { timeout: 45_000 }).toBe(true);
+ await expect(page.getByText(`Typed while closed ${RUN}`).first()).toBeVisible({ timeout: 10_000 });
+ expect(page.url()).not.toContain('reply=1');
+
+ // Leave the draft and come back: the link's job is done.
+ await page.reload();
+ await expect(page.getByText('Please answer.').first()).toBeVisible({ timeout: 45_000 });
+ expect(await replyInput()).toBe(false);
+ });
+});
+
+test.describe('Reply in the Pro shell', () => {
+ test('opens the composer in the message tab, with the typed text, from the live tab and from the link', async ({ browser }) => {
+ const page = await userPage(browser, USER);
+ const context = page.context();
+ await context.grantPermissions(['notifications']);
+ await page.evaluate(() => {
+ const raw = localStorage.getItem('settings-storage');
+ const stored = raw ? JSON.parse(raw) : { state: {} };
+ stored.state = { ...stored.state, proInterface: true };
+ localStorage.setItem('settings-storage', JSON.stringify(stored));
+ });
+ await page.goto('/pro');
+ await expect(page.locator('[data-pro-takeover]')).toHaveCount(0, { timeout: 60_000 });
+ const sw = await workerOf(context, page);
+ const replyIsOpen = (subject: string) => page.evaluate((text) => {
+ const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[];
+ return inputs.some((input) => input.value === `Re: ${text}`);
+ }, subject);
+
+ const live = await oneUnread();
+ const liveShown = await pushed(sw, live.accountId, live.id);
+ // Until the shell's mail surface is up the tab declines, and the worker
+ // falls back to the link: ask again. Taken once, no window opens.
+ await expect.poll(async () => {
+ await click(sw, 'reply', liveShown.data, `Pro typed ${RUN}`);
+ return (await opened(sw)).length;
+ }, { timeout: 45_000, intervals: [3_000] }).toBe(0);
+ await expect.poll(() => replyIsOpen(live.subject), { timeout: 30_000 }).toBe(true);
+ await expect(page.getByText(`Pro typed ${RUN}`).first()).toBeVisible({ timeout: 10_000 });
+
+ const linked = await oneUnread();
+ await page.goto(`/mail/message/${linked.id}?owner=${linked.accountId}&reply=1`);
+ await expect.poll(() => replyIsOpen(linked.subject), { timeout: 60_000 }).toBe(true);
+ await context.close();
+ });
+});
+
+test.describe('session limits', () => {
+ test('under a maximum session age the route answers 401 and writes nothing', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await userPage(browser, USER);
+ await page.context().grantPermissions(['notifications']);
+ const sw = await workerOf(page.context(), page);
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ await withPolicy(admin, { organisation: { session: { idleMinutes: 0, maxHours: 8, warnSeconds: 60 } } }, async () => {
+ const res = await page.request.post('/api/push/action', {
+ data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login },
+ });
+ expect(res.status()).toBe(401);
+ });
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ await page.context().close();
+ await admin.close();
+ });
+});
+
+test.describe('the route, as a page or another site would call it', () => {
+ test('refuses a cross-site POST at the gate, with the cookies on it', async ({ browser }) => {
+ const page = await userPage(browser, USER);
+ const mail = await oneUnread();
+ const res = await page.request.post('/api/push/action', {
+ headers: { 'Sec-Fetch-Site': 'cross-site' },
+ data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: 'x' },
+ });
+ expect(res.status()).toBe(403);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ await page.context().close();
+ });
+
+ test('refuses another account of the same login, and answers 401 without cookies', async ({ browser }) => {
+ const page = await userPage(browser, USER);
+ await page.context().grantPermissions(['notifications']);
+ const sw = await workerOf(page.context(), page);
+ const mail = await oneUnread();
+ const shown = await pushed(sw, mail.accountId, mail.id);
+ const post = (over: Record) => page.request.post('/api/push/action', {
+ data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login, ...over },
+ });
+ expect((await post({ accountId: 'zzzzzz' })).status()).toBe(403);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ await page.context().clearCookies({ name: /^jmap_stalwart_ctx/ });
+ expect((await post({})).status()).toBe(401);
+ expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy();
+ await page.context().close();
+ });
+});
diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts
index c9696f162..0dc59db84 100644
--- a/lib/__tests__/deep-links.test.ts
+++ b/lib/__tests__/deep-links.test.ts
@@ -185,6 +185,16 @@ describe('mail paths', () => {
expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined });
});
+ it("reads a notification's reply flag on a message link, and only there", () => {
+ expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=1&owner=a&reply=1'))).toMatchObject({
+ kind: 'message', id: 'm1', slot: 1, ownerAccountId: 'a', reply: true,
+ });
+ expect(parseMailPath(['message', 'm1'], new URLSearchParams('reply=0'))).not.toHaveProperty('reply');
+ expect(parseMailPath(['message', 'm1'])).not.toHaveProperty('reply');
+ expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply');
+ expect(parseMailPath(['thread', 't1'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply');
+ });
+
it('reads the fullscreen marker on message links', () => {
expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({
kind: 'message',
diff --git a/lib/__tests__/plugin-post-denylist.test.ts b/lib/__tests__/plugin-post-denylist.test.ts
index 5d0fad219..1c266ee1b 100644
--- a/lib/__tests__/plugin-post-denylist.test.ts
+++ b/lib/__tests__/plugin-post-denylist.test.ts
@@ -29,6 +29,7 @@ describe('routes no plugin may POST to', () => {
'/api/%70lugin-proxy',
'/api//plugin-secrets',
'/api/account/stalwart/jmap',
+ '/api/push/action',
])('%s is denied', (path) => {
expect(isPluginPostDenied(path)).toBe(true);
});
diff --git a/lib/__tests__/push-action-route.test.ts b/lib/__tests__/push-action-route.test.ts
new file mode 100644
index 000000000..c120db3d0
--- /dev/null
+++ b/lib/__tests__/push-action-route.test.ts
@@ -0,0 +1,428 @@
+// @vitest-environment node
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
+
+// POST /api/push/action: a notification's Mark read and Archive with no tab
+// open. Every refusal must leave the mail alone: the worker opens the message
+// in the app instead.
+
+vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
+vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
+vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 3 }));
+
+type Login = { serverUrl: string; username: string; authHeader: string };
+let LOGINS: Array = [];
+vi.mock('@/lib/stalwart/auth-context', () => ({
+ readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null,
+}));
+
+const ALICE: Login = { serverUrl: 'https://one.example/', username: 'alice@example.org', authHeader: 'Bearer alice' };
+const BOB: Login = { serverUrl: 'https://one.example', username: 'bob@example.org', authHeader: 'Bearer bob' };
+
+let policy: Record = {};
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: { ensureLoaded: async () => {}, getPolicy: () => policy },
+}));
+let subject: { email: string; groups: string[] } | null = { email: 'alice@example.org', groups: [] };
+vi.mock('@/lib/admin/server-device-policy', () => ({ strictDeviceSubject: async () => subject }));
+
+type Calls = Array<[string, Record, string]>;
+const writes: Array<{ auth: string; url: string; calls: Calls }> = [];
+let session: Record;
+let sessionStatus = 200;
+let mailboxes: Array>;
+let email: Record | null;
+let emailSetResult: Record | null = null;
+// The answer to each Mailbox/set in turn; `undefined`: everything was created.
+let mailboxSetResults: Array> = [];
+let mailboxReads = 0;
+let mailboxesOnSecondRead: Array> | null = null;
+let emailSetResults: Array> = [];
+let sessionsAsked = 0;
+
+const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } });
+vi.mock('@/lib/stalwart/server-fetch', () => ({
+ isTrustedJmapServerUrl: async () => true,
+ fetchJmapServer: async (url: string, init?: { headers?: Record; body?: string }) => {
+ if (url.endsWith('/.well-known/jmap')) {
+ sessionsAsked++;
+ return sessionStatus === 200 ? json(session) : new Response('', { status: sessionStatus });
+ }
+ const calls = (JSON.parse(init?.body ?? '{}') as { methodCalls: Calls }).methodCalls;
+ if (calls.some(([name]) => name.endsWith('/set'))) {
+ writes.push({ auth: init?.headers?.Authorization ?? '', url, calls });
+ }
+ return json({
+ methodResponses: calls.map(([name, args, id]) => {
+ if (name === 'Mailbox/get') {
+ mailboxReads++;
+ return [name, { list: mailboxReads >= 2 && mailboxesOnSecondRead ? mailboxesOnSecondRead : mailboxes }, id];
+ }
+ if (name === 'Email/get') return [name, { list: email ? [email] : [] }, id];
+ if (name === 'Mailbox/set') {
+ if (args.destroy) return [name, { destroyed: args.destroy }, id];
+ return [name, mailboxSetResults.shift() ?? {
+ created: Object.fromEntries(Object.keys(args.create as object).map((cid) => [cid, { id: `new-${cid}` }])),
+ }, id];
+ }
+ const emailId = Object.keys(args.update as Record)[0];
+ return [name, emailSetResults.shift() ?? emailSetResult ?? { updated: { [emailId]: null } }, id];
+ }),
+ });
+ },
+}));
+
+const INBOX = { id: 'inbox', name: 'Inbox', role: 'inbox' };
+const ARCHIVE = { id: 'arch', name: 'Archive', role: 'archive' };
+
+async function call(body: Record, headers: Record = { 'sec-fetch-site': 'same-origin' }) {
+ const { POST } = await import('@/app/api/push/action/route');
+ const res = await POST(new NextRequest('https://mail.example.com/api/push/action', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...headers },
+ body: JSON.stringify(body),
+ }));
+ return { status: res.status, body: await res.json() };
+}
+
+const fingerprint = (login: Login) => loginFingerprint(login.serverUrl, login.username);
+const destroyed = () => writes.flatMap((w) => w.calls).find(([name, args]) => name === 'Mailbox/set' && args.destroy)?.[1].destroy;
+const markRead = async (over: Record = {}) => ({
+ action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over,
+});
+const archive = async (over: Record = {}) => ({ ...(await markRead()), action: 'archive', ...over });
+const emailSet = () => {
+ const call = writes.at(-1)!.calls.find(([name]) => name === 'Email/set');
+ return call?.[1] as { accountId: string; update: Record> };
+};
+const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record> } | undefined;
+
+beforeEach(() => {
+ LOGINS = [ALICE];
+ policy = {};
+ subject = { email: 'alice@example.org', groups: [] };
+ writes.length = 0;
+ sessionsAsked = 0;
+ sessionStatus = 200;
+ session = {
+ apiUrl: 'https://one.example/jmap/',
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' },
+ accounts: { a: { name: 'alice@example.org', accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
+ };
+ mailboxes = [INBOX, ARCHIVE];
+ email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z', mailboxIds: { inbox: true } };
+ emailSetResult = null;
+ mailboxSetResults = [];
+ mailboxReads = 0;
+ mailboxesOnSecondRead = null;
+ emailSetResults = [];
+});
+
+describe('who may ask', () => {
+ it('refuses a request from another site before reading anything', async () => {
+ const res = await call(await markRead(), { 'sec-fetch-site': 'same-site' });
+ expect(res.status).toBe(403);
+ expect(sessionsAsked).toBe(0);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('accepts what a service worker sends', async () => {
+ expect((await call(await markRead())).status).toBe(200);
+ expect(writes).toHaveLength(1);
+ });
+
+ it.each([
+ ['an unknown action', { action: 'delete' }],
+ ['an id that is no JMAP id', { emailId: '../../x' }],
+ ['no account', { accountId: undefined }],
+ ['no login', { login: undefined }],
+ ])('refuses %s', async (_name, over) => {
+ const res = await call(await markRead(over));
+ expect(res.status).toBe(400);
+ expect(sessionsAsked).toBe(0);
+ });
+});
+
+describe('which login', () => {
+ it('answers 401 with no cookie, without asking the mail server', async () => {
+ LOGINS = [];
+ expect((await call(await markRead())).status).toBe(401);
+ expect(sessionsAsked).toBe(0);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('answers 401 when the slot went to someone else since the notification', async () => {
+ // Account ids repeat, so bob's session would accept account `a` as well.
+ LOGINS = [BOB];
+ expect((await call(await markRead())).status).toBe(401);
+ expect(sessionsAsked).toBe(0);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('finds the login in whichever slot it holds now and uses its credential', async () => {
+ LOGINS = [BOB, null, ALICE];
+ expect((await call(await markRead())).status).toBe(200);
+ expect(writes[0].auth).toBe('Bearer alice');
+ });
+
+ it('answers 409 when the login is in two slots', async () => {
+ LOGINS = [ALICE, { ...ALICE, authHeader: 'Bearer stale' }];
+ expect((await call(await markRead())).status).toBe(409);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('matches a server address written differently on either side', async () => {
+ LOGINS = [{ ...ALICE, serverUrl: 'https://one.example' }];
+ const login = await loginFingerprint('https://ONE.example:443/', ALICE.username);
+ expect((await call(await markRead({ login }))).status).toBe(200);
+ expect(writes).toHaveLength(1);
+ });
+
+ it('answers 401 for an expired token: only a page refreshes it', async () => {
+ sessionStatus = 401;
+ expect((await call(await markRead())).status).toBe(401);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('answers 502 when the mail server is down, which says nothing about the token', async () => {
+ sessionStatus = 503;
+ expect((await call(await markRead())).status).toBe(502);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('refuses an account this login has no part in', async () => {
+ const res = await call(await markRead({ accountId: 'stranger' }));
+ expect(res.status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it("doesn't take a name from Object.prototype for an account", async () => {
+ const res = await call(await markRead({ accountId: 'constructor' }));
+ expect(res.status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+});
+
+describe('session limits', () => {
+ // The page applies them, from activity and sign-in time: an action from here
+ // would keep the login going past them.
+ it.each([
+ ['a maximum session age', { maxHours: 8 }],
+ ['an idle limit', { idleMinutes: 30 }],
+ ])('answers 401 under %s, so the app opens and applies it', async (_name, session) => {
+ policy = { organisation: { session } };
+ expect((await call(await markRead())).status).toBe(401);
+ expect((await call(await archive())).status).toBe(401);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('follows the scope this login is in', async () => {
+ policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
+ expect((await call(await markRead())).status).toBe(401);
+ expect(writes).toHaveLength(0);
+ });
+
+ it("isn't troubled by a scope this login is not in", async () => {
+ policy = { scopes: [{ id: 'other', label: 'Other', match: { domains: ['other.test'] }, policy: { organisation: { session: { maxHours: 8 } } } }] };
+ expect((await call(await markRead())).status).toBe(200);
+ });
+
+ it("answers 401 when the login's scopes can't be worked out", async () => {
+ subject = null;
+ expect((await call(await markRead())).status).toBe(401);
+ expect(writes).toHaveLength(0);
+ });
+});
+
+describe('a message since moved to Trash or Junk', () => {
+ it.each(['trash', 'junk'])('stays as it is in %s, and the app opens it', async (role) => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'bin', name: 'Bin', role }];
+ email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z', mailboxIds: { bin: true } };
+ expect((await call(await markRead())).status).toBe(409);
+ expect((await call(await archive())).status).toBe(409);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('answers 404 for Mark read on a message that is gone, writing nothing', async () => {
+ email = null;
+ expect((await call(await markRead())).status).toBe(404);
+ expect(writes).toHaveLength(0);
+ });
+});
+
+describe('Mark read', () => {
+ it('sets $seen on that message, at the rebased API address', async () => {
+ expect(await call(await markRead())).toEqual({ status: 200, body: { ok: true } });
+ expect(writes[0].url).toBe('https://one.example/jmap/');
+ expect(emailSet()).toEqual({ accountId: 'a', update: { e1: { 'keywords/$seen': true } } });
+ expect(mailboxSet()).toBeUndefined();
+ });
+
+ it('acts in a shared account the login reaches', async () => {
+ session.accounts = { ...(session.accounts as object), team: { name: 'team@example.org' } };
+ expect((await call(await markRead({ accountId: 'team' }))).status).toBe(200);
+ expect(emailSet().accountId).toBe('team');
+ });
+
+ it('answers 404 for a message that is gone', async () => {
+ emailSetResult = { notUpdated: { e1: { type: 'notFound' } } };
+ expect((await call(await markRead())).status).toBe(404);
+ });
+
+ it('answers 502 when the server refuses', async () => {
+ emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } };
+ expect((await call(await markRead())).status).toBe(502);
+ });
+});
+
+describe('Archive', () => {
+ it('moves the message into the Archive folder', async () => {
+ expect((await call(await archive())).status).toBe(200);
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } });
+ expect(mailboxSet()).toBeUndefined();
+ });
+
+ it('finds an Archive by name when no folder has the role, as the app does', async () => {
+ mailboxes = [INBOX, { id: 'named', name: 'archive' }];
+ await call(await archive());
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { named: true } });
+ });
+
+ it('creates the Archive folder when the account has none, in the same request', async () => {
+ mailboxes = [INBOX];
+ expect((await call(await archive())).status).toBe(200);
+ expect(mailboxSet()?.create).toEqual({ archive: { name: 'Archive', parentId: null, role: 'archive' } });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#archive': true } });
+ });
+
+ it("doesn't try when the account may not create one", async () => {
+ mailboxes = [INBOX];
+ session.accounts = { a: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { mayCreateTopLevelMailbox: false } } } };
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it("doesn't try an Archive that takes no mail", async () => {
+ mailboxes = [INBOX, { ...ARCHIVE, myRights: { mayAddItems: false } }];
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('files by year into an existing year folder', async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }];
+ await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }));
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } });
+ expect(mailboxSet()).toBeUndefined();
+ });
+
+ it('ignores a folder named like the year elsewhere', async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'other', name: '2026', parentId: 'inbox' }];
+ await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }));
+ expect(mailboxSet()?.create).toEqual({ year: { name: '2026', parentId: 'arch' } });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#year': true } });
+ });
+
+ it("files by month in the user's time zone, not the server's", async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }, { id: 'm03', name: '03', parentId: 'y26' }];
+ // 23:30 UTC on 31 March is 1 April in Berlin.
+ await call(await archive({ archiveMode: 'month', timeZone: 'Europe/Berlin' }));
+ expect(mailboxSet()?.create).toEqual({ month: { name: '04', parentId: 'y26' } });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } });
+
+ writes.length = 0;
+ await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }));
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { m03: true } });
+ });
+
+ it('creates the whole path in month mode on an account without an Archive', async () => {
+ mailboxes = [INBOX];
+ await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }));
+ expect(mailboxSet()?.create).toEqual({
+ archive: { name: 'Archive', parentId: null, role: 'archive' },
+ year: { name: '2026', parentId: '#archive' },
+ month: { name: '03', parentId: '#year' },
+ });
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } });
+ });
+
+ it('survives a time zone that is no time zone', async () => {
+ mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }];
+ expect((await call(await archive({ archiveMode: 'year', timeZone: 'Mars/Olympus' }))).status).toBe(200);
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } });
+ });
+
+ it('answers 404 for a message that is gone', async () => {
+ email = null;
+ expect((await call(await archive())).status).toBe(404);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('files into a folder another client made first, on one more try', async () => {
+ mailboxes = [INBOX, ARCHIVE];
+ mailboxesOnSecondRead = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }];
+ mailboxSetResults = [{ notCreated: { year: { type: 'alreadyExists' } } }];
+ emailSetResults = [{ notUpdated: { e1: { type: 'invalidProperties' } } }];
+ expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(200);
+ expect(mailboxReads).toBe(2);
+ expect(writes).toHaveLength(2);
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } });
+ expect(destroyed()).toBeUndefined();
+ });
+
+ it('tries only once more, and stops when the second try fails too', async () => {
+ mailboxes = [INBOX, ARCHIVE];
+ mailboxSetResults = [
+ { notCreated: { year: { type: 'alreadyExists' } } },
+ { notCreated: { year: { type: 'alreadyExists' } } },
+ ];
+ emailSetResult = { notUpdated: { e1: { type: 'invalidProperties' } } };
+ expect((await call(await archive({ archiveMode: 'year', timeZone: 'UTC' }))).status).toBe(502);
+ expect(writes).toHaveLength(2);
+ });
+
+ it('leaves no empty folder when the message then is refused, deepest first', async () => {
+ mailboxes = [INBOX];
+ emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } };
+ expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(502);
+ expect(destroyed()).toEqual(['new-month', 'new-year', 'new-archive']);
+ });
+
+ it('keeps the folders it made when the message went in', async () => {
+ mailboxes = [INBOX];
+ expect((await call(await archive({ archiveMode: 'month', timeZone: 'UTC' }))).status).toBe(200);
+ expect(destroyed()).toBeUndefined();
+ });
+
+ it("files into an Archive shared with others, as the app's own button does", async () => {
+ mailboxes = [INBOX, { ...ARCHIVE, shareWith: { u2: { mayReadItems: true } } }];
+ expect((await call(await archive())).status).toBe(200);
+ expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } });
+ });
+
+ describe('the admin gate', () => {
+ it('refuses when the admin turned Archive off, whatever the worker believes', async () => {
+ policy = { features: { archiveActionEnabled: false } };
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('follows the scope this login is in', async () => {
+ policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { features: { archiveActionEnabled: false } } }] };
+ expect((await call(await archive())).status).toBe(403);
+ expect(writes).toHaveLength(0);
+ });
+
+
+ it("refuses when the login's scopes can't be worked out", async () => {
+ subject = null;
+ expect((await call(await archive())).status).toBe(401);
+ expect(writes).toHaveLength(0);
+ });
+
+ it('leaves Mark read alone', async () => {
+ policy = { features: { archiveActionEnabled: false } };
+ expect((await call(await markRead())).status).toBe(200);
+ });
+ });
+});
diff --git a/lib/__tests__/push-preview-route.test.ts b/lib/__tests__/push-preview-route.test.ts
index 3880595cb..a7abae5ca 100644
--- a/lib/__tests__/push-preview-route.test.ts
+++ b/lib/__tests__/push-preview-route.test.ts
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { endlessResponse } from './helpers/endless-body';
+import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint';
vi.mock('next/server', () => ({
NextResponse: {
@@ -112,6 +113,11 @@ describe('push preview route account resolution', () => {
expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g');
});
+ it('names the login for a message, as the action route knows it', async () => {
+ const { body } = await callRoute('a');
+ expect(body.login).toBe(await loginFingerprint('https://mail.example.com', undefined));
+ });
+
it('names the login (cookie slot) the account belongs to', async () => {
// The worker only knows the JMAP account id, which can repeat across
// servers; the click must open the message in this login.
@@ -186,7 +192,7 @@ describe('push preview JMAP failures', () => {
['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'],
] }));
expect(await callRoute('a', 'd')).toEqual({
- status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 },
+ status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0, login: expect.any(String) },
});
});
});
@@ -202,7 +208,7 @@ it('previews a delivered message outside an empty Inbox', async () => {
['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'],
] }));
expect(await callRoute('a', 'filed')).toEqual({
- status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0 },
+ status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0, login: expect.any(String) },
});
});
diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts
index d54631a62..d945c3322 100644
--- a/lib/__tests__/sw-harness.ts
+++ b/lib/__tests__/sw-harness.ts
@@ -29,17 +29,40 @@ export class FakeCaches {
}
}
-export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise }) {
+export interface ShownNotification {
+ title: string;
+ options: Record;
+}
+
+export function loadWorker(opts: {
+ buildId: string;
+ caches: FakeCaches;
+ clients: Array<{ id: string }>;
+ fetch?: (input: unknown, init?: RequestInit) => Promise;
+ /** More globals of a real worker: Notification, NotificationEvent, MessageChannel. */
+ globals?: Record;
+ openWindow?: (url: string) => Promise;
+ /** What showNotification does, after it was recorded in `shown`. */
+ showNotification?: (title: string, options: Record) => Promise;
+ notifications?: Array>;
+}) {
const listeners = new Map void>();
+ const shown: ShownNotification[] = [];
let skipped = 0;
const self = {
location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' },
addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn),
skipWaiting: () => { skipped++; },
- clients: { claim: async () => {}, matchAll: async () => opts.clients },
- registration: { showNotification: async () => {}, getNotifications: async () => [] },
+ clients: { claim: async () => {}, matchAll: async () => opts.clients, openWindow: opts.openWindow },
+ registration: {
+ showNotification: async (title: string, options: Record) => {
+ shown.push({ title, options });
+ await opts.showNotification?.(title, options);
+ },
+ getNotifications: async () => opts.notifications ?? [],
+ },
};
- const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout });
+ const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, ...opts.globals });
vm.runInContext(SW_SOURCE, context);
async function dispatch(type: string, event: Record = {}) {
const waits: Promise[] = [];
@@ -52,5 +75,5 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients:
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise) => { answer = p; } });
return answer ? await answer : null;
}
- return { dispatch, listeners, request, skipped: () => skipped };
+ return { dispatch, listeners, request, shown, skipped: () => skipped };
}
diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts
new file mode 100644
index 000000000..96e94949f
--- /dev/null
+++ b/lib/__tests__/sw-notification-actions.test.ts
@@ -0,0 +1,446 @@
+import { describe, expect, it, vi } from 'vitest';
+import { FakeCaches, loadWorker } from './sw-harness';
+
+// A notification's Archive, Mark read and Reply buttons (public/sw.js). The
+// worker has no login: an open tab goes first, then the cookie route, then the
+// message opens in the app.
+
+const ORIGIN = 'https://mail.example.com';
+const PUSH_CACHE = 'bulwark-push-state-v1';
+const PREFS = {
+ archiveEnabled: true,
+ archiveMode: 'month',
+ pluginsMayVeto: false,
+ labels: { archive: 'Archivieren', markRead: 'Als gelesen markieren', reply: 'Antworten', replyPlaceholder: 'Antwort schreiben' },
+};
+const ONE_MESSAGE = { kind: 'email', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp' };
+const MESSAGE_URL = '/mail/message/e1?slot=2&owner=a';
+
+async function cachesWith(prefs?: unknown) {
+ const caches = new FakeCaches();
+ if (prefs) {
+ const cache = await caches.open(PUSH_CACHE);
+ await cache.put(`${ORIGIN}/__push-state/actions`, new Response(JSON.stringify(prefs)));
+ }
+ return caches;
+}
+
+/** A window client that answers a posted action with the given statuses. */
+function tab(id: string, statuses: string[], extra: Record = {}) {
+ const messages: Array> = [];
+ const client = {
+ id,
+ url: `${ORIGIN}/mail/folder/inbox`,
+ messages,
+ focus: vi.fn(async function focus(this: unknown) { return client; }),
+ navigate: vi.fn(async () => client),
+ postMessage(message: Record, ports?: MessagePort[]) {
+ messages.push(message);
+ if (message.kind !== 'notification-action') return;
+ const port = ports?.[0];
+ for (const status of statuses) port?.postMessage({ status });
+ },
+ ...extra,
+ };
+ return client;
+}
+
+function worker(opts: {
+ caches: FakeCaches;
+ clients?: Array<{ id: string }>;
+ fetch?: (input: unknown, init?: RequestInit) => Promise;
+ max?: number | null;
+ inline?: boolean;
+ showNotification?: (title: string, options: Record) => Promise;
+ notifications?: Array>;
+}) {
+ const openWindow = vi.fn(async () => null);
+ const fetch = vi.fn(opts.fetch ?? (async () => new Response('{}', { status: 200 })));
+ const max = opts.max === undefined ? 3 : opts.max;
+ const loaded = loadWorker({
+ buildId: 'b1',
+ caches: opts.caches,
+ clients: opts.clients ?? [],
+ fetch,
+ openWindow,
+ showNotification: opts.showNotification,
+ notifications: opts.notifications,
+ globals: {
+ MessageChannel,
+ ...(max === null ? {} : { Notification: { maxActions: max } }),
+ ...(opts.inline ? { NotificationEvent: class { get reply() { return null; } } } : {}),
+ },
+ });
+ const click = (action: string, extra: Record = {}, data: Record = ONE_MESSAGE) =>
+ loaded.dispatch('notificationclick', { action, ...extra, notification: { data, tag: 'bulwark-mail:a', close() {} } });
+ const posts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'POST');
+ return { ...loaded, click, openWindow, fetch, posts };
+}
+
+const pushFor = (preview: Record) => async (w: ReturnType) => {
+ await w.dispatch('push', { data: { json: () => ({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }) } });
+ return preview;
+};
+
+const previewOf = (unreadTotal: number) => JSON.stringify({
+ unreadTotal,
+ slot: 2,
+ login: 'fp',
+ email: { id: 'e1', threadId: 't1', subject: 'Hello', from: [{ name: 'Bob' }] },
+});
+
+describe('the buttons a notification gets', () => {
+ const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => {
+ const w = worker({
+ caches: await cachesWith(opts.prefs),
+ max: opts.max,
+ inline: opts.inline,
+ fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }),
+ });
+ await pushFor({})(w);
+ expect(w.shown).toHaveLength(1);
+ return w.shown[0].options.actions as Array> | undefined;
+ };
+
+ it('offers the three in the labels the page left, on one message', async () => {
+ expect(await buttonsFor({ prefs: PREFS })).toEqual([
+ { action: 'archive', title: 'Archivieren' },
+ { action: 'mark-read', title: 'Als gelesen markieren' },
+ { action: 'reply', title: 'Antworten' },
+ ]);
+ });
+
+ it('keeps Archive and Reply where the browser has two buttons', async () => {
+ const buttons = await buttonsFor({ prefs: PREFS, max: 2 });
+ expect(buttons?.map((b) => b.action)).toEqual(['archive', 'reply']);
+ });
+
+ it('leaves Archive out when the admin turned it off', async () => {
+ const buttons = await buttonsFor({ prefs: { ...PREFS, archiveEnabled: false }, max: 2 });
+ expect(buttons?.map((b) => b.action)).toEqual(['mark-read', 'reply']);
+ });
+
+ it('offers no Archive, in English, before any page has spoken', async () => {
+ expect(await buttonsFor({})).toEqual([
+ { action: 'mark-read', title: 'Mark as read' },
+ { action: 'reply', title: 'Reply' },
+ ]);
+ });
+
+ it('adds nothing in a browser without notification buttons', async () => {
+ expect(await buttonsFor({ prefs: PREFS, max: null })).toBeUndefined();
+ expect(await buttonsFor({ prefs: PREFS, max: 0 })).toBeUndefined();
+ });
+
+ it('gives the group notification none: it names no message', async () => {
+ expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined();
+ });
+
+ it('gives a message without its login none', async () => {
+ const w = worker({
+ caches: await cachesWith(PREFS),
+ fetch: async () => new Response(JSON.stringify({ unreadTotal: 1, slot: 2, email: { id: 'e1', threadId: 't1' } }), { status: 200 }),
+ });
+ await pushFor({})(w);
+ expect(w.shown[0].options.actions).toBeUndefined();
+ });
+
+ it('makes Reply a text field only where the browser can hand the text back', async () => {
+ const buttons = await buttonsFor({ prefs: PREFS, inline: true });
+ expect(buttons?.find((b) => b.action === 'reply')).toEqual({
+ action: 'reply', title: 'Antworten', type: 'text', placeholder: 'Antwort schreiben',
+ });
+ });
+
+ it('still shows the notification when the browser refuses the buttons', async () => {
+ const w = worker({
+ caches: await cachesWith(PREFS),
+ fetch: async () => new Response(previewOf(1), { status: 200 }),
+ showNotification: async (_title, options) => {
+ if (options.actions) throw new TypeError('bad action');
+ },
+ });
+ await pushFor({})(w);
+ expect(w.shown).toHaveLength(2);
+ expect(w.shown[1].options.actions).toBeUndefined();
+ expect(w.shown[1].options.data).toMatchObject({ emailId: 'e1', login: 'fp' });
+ });
+
+ it('keeps the buttons when a redelivered push shows the same notification again', async () => {
+ const existing = { title: 'Bob', body: 'Hello', data: ONE_MESSAGE };
+ const caches = await cachesWith(PREFS);
+ const cache = await caches.open(PUSH_CACHE);
+ await cache.put(`${ORIGIN}/__push-state/notified/a`, new Response(JSON.stringify({ ids: ['e1'] })));
+ const w = worker({ caches, notifications: [existing] });
+ await pushFor({})(w);
+ expect(w.shown).toHaveLength(1);
+ expect((w.shown[0].options.actions as unknown[]).length).toBe(3);
+ });
+});
+
+describe('a click', () => {
+ it('on the body works as before: it opens the message and runs nothing', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('');
+ expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL);
+ expect(client.messages.map((m) => m.kind)).toEqual(['notificationclick']);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('on an unknown button counts as a click on the body', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('explode');
+ expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('on a button of a group notification opens the inbox and runs nothing', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('archive', {}, { kind: 'mail-list', accountId: 'a', slot: 2 });
+ expect(client.navigate).toHaveBeenCalledWith('/mail/folder/inbox?slot=2&owner=a');
+ expect(w.posts()).toHaveLength(0);
+ });
+});
+
+describe('Mark read and Archive', () => {
+ it('go to an open tab first, which leaves nothing else to do', async () => {
+ const client = tab('t', ['accepted', 'done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('mark-read');
+ expect(client.messages.find((m) => m.kind === 'notification-action')).toEqual({
+ kind: 'notification-action', action: 'mark-read', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp', at: expect.any(Number),
+ });
+ expect(w.posts()).toHaveLength(0);
+ expect(client.navigate).not.toHaveBeenCalled();
+ expect(client.focus).not.toHaveBeenCalled();
+ expect(w.openWindow).not.toHaveBeenCalled();
+ });
+
+ it('go to the tab in front first', async () => {
+ const hidden = tab('hidden', ['done'], { visibilityState: 'hidden' });
+ const front = tab('front', ['done'], { focused: true, visibilityState: 'visible' });
+ const w = worker({ caches: await cachesWith(PREFS), clients: [hidden, front] });
+ await w.click('mark-read');
+ expect(front.messages.some((m) => m.kind === 'notification-action')).toBe(true);
+ expect(hidden.messages.some((m) => m.kind === 'notification-action')).toBe(false);
+ });
+
+ it('try the next tab when one declines', async () => {
+ const other = tab('other', ['declined']);
+ const right = tab('right', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [other, right] });
+ await w.click('mark-read');
+ expect(right.messages.some((m) => m.kind === 'notification-action')).toBe(true);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('use the route with no tab open, and nothing opens when it works', async () => {
+ const w = worker({ caches: await cachesWith(PREFS) });
+ await w.click('mark-read');
+ expect(w.posts()).toHaveLength(1);
+ const [url, init] = w.posts()[0];
+ expect(url).toBe('/api/push/action');
+ expect(init?.credentials).toBe('include');
+ expect(JSON.parse(String(init?.body))).toEqual({ action: 'mark-read', accountId: 'a', emailId: 'e1', login: 'fp' });
+ expect(w.openWindow).not.toHaveBeenCalled();
+ });
+
+ it('send the mirrored archive mode and the time zone with Archive', async () => {
+ const w = worker({ caches: await cachesWith(PREFS) });
+ await w.click('archive');
+ const body = JSON.parse(String(w.posts()[0][1]?.body));
+ expect(body).toMatchObject({ action: 'archive', archiveMode: 'month', accountId: 'a', emailId: 'e1', login: 'fp' });
+ expect(typeof body.timeZone).toBe('string');
+ expect(body.timeZone).not.toBe('');
+ });
+
+ it('send when they asked, so a tab that thaws later can tell', async () => {
+ const client = tab('t', ['done']);
+ const before = Date.now();
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('mark-read');
+ const at = client.messages.find((m) => m.kind === 'notification-action')?.at as number;
+ expect(at).toBeGreaterThanOrEqual(before);
+ expect(at).toBeLessThanOrEqual(Date.now());
+ });
+
+ it('leave the route alone while an enabled plugin may stop them, and open the message', async () => {
+ const w = worker({ caches: await cachesWith({ ...PREFS, pluginsMayVeto: true }) });
+ await w.click('mark-read');
+ expect(w.posts()).toHaveLength(0);
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('leave the route alone before any page has said what plugins there are', async () => {
+ const w = worker({ caches: await cachesWith() });
+ await w.click('mark-read');
+ expect(w.posts()).toHaveLength(0);
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('open the message when a tab holding this login stays silent, without the route', async () => {
+ const silent = tab('held', []);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [silent] });
+ await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
+ await w.click('archive');
+ expect(w.posts()).toHaveLength(0);
+ expect(silent.focus).toHaveBeenCalled();
+ expect(silent.navigate).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('give a busy tab holding this login longer than the others', async () => {
+ const busy = tab('held', []);
+ const post = busy.postMessage;
+ busy.postMessage = (message: Record, ports?: MessagePort[]) => {
+ post(message, ports);
+ if (message.kind === 'notification-action') setTimeout(() => ports?.[0]?.postMessage({ status: 'done' }), 1_500);
+ };
+ const w = worker({ caches: await cachesWith(PREFS), clients: [busy] });
+ await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
+ await w.click('mark-read');
+ expect(busy.navigate).not.toHaveBeenCalled();
+ expect(w.posts()).toHaveLength(0);
+ expect(w.openWindow).not.toHaveBeenCalled();
+ });
+
+ it('take another tab that answers over a silent one holding this login', async () => {
+ const silent = tab('held', [], { visibilityState: 'visible', focused: true });
+ const alive = tab('alive', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [silent, alive] });
+ await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
+ await w.click('mark-read');
+ expect(alive.messages.some((m) => m.kind === 'notification-action')).toBe(true);
+ expect(silent.navigate).not.toHaveBeenCalled();
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('forget tabs that are gone when another reports its logins', async () => {
+ const caches = await cachesWith(PREFS);
+ const clients = [tab('one', []), tab('two', [])];
+ const w = worker({ caches, clients });
+ await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'one' } });
+ clients.shift();
+ await w.dispatch('message', { data: { type: 'client-logins', logins: ['other'] }, source: { id: 'two' } });
+ const cache = await caches.open(PUSH_CACHE);
+ expect(await (await cache.match(`${ORIGIN}/__push-state/client-logins`))!.json()).toEqual({ two: ['other'] });
+ });
+
+ it('use the route when a tab never answers, so a frozen tab costs a moment only', async () => {
+ const frozen = tab('frozen', []);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [frozen] });
+ await w.click('mark-read');
+ expect(w.posts()).toHaveLength(1);
+ expect(frozen.navigate).not.toHaveBeenCalled();
+ });
+
+ it.each([401, 403, 404, 409, 502])('open the message when the route answers %i', async (status) => {
+ const w = worker({ caches: await cachesWith(PREFS), fetch: async () => new Response('{}', { status }) });
+ await w.click('archive');
+ expect(w.posts()).toHaveLength(1);
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('open the message when the route is out of reach', async () => {
+ const w = worker({ caches: await cachesWith(PREFS), fetch: async () => { throw new TypeError('offline'); } });
+ await w.click('mark-read');
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('open the message when a tab with a live login tried and failed, without the route', async () => {
+ const client = tab('t', ['accepted', 'failed']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('archive');
+ expect(w.posts()).toHaveLength(0);
+ expect(client.focus).toHaveBeenCalled();
+ expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+
+ it('never post an Archive the mirror says is off', async () => {
+ const w = worker({ caches: await cachesWith({ ...PREFS, archiveEnabled: false }) });
+ await w.click('archive');
+ expect(w.posts()).toHaveLength(0);
+ expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL);
+ });
+});
+
+describe('Reply', () => {
+ it('opens the composer in an open tab and brings it to the front', async () => {
+ const client = tab('t', ['done']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('reply', { reply: ' Thanks! ' });
+ expect(client.messages.find((m) => m.kind === 'notification-action')).toMatchObject({ action: 'reply', emailId: 'e1', text: 'Thanks!' });
+ expect(client.focus).toHaveBeenCalled();
+ expect(client.navigate).not.toHaveBeenCalled();
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('navigates a tab that has no mail page to the reply link', async () => {
+ const client = tab('t', ['declined']);
+ const w = worker({ caches: await cachesWith(PREFS), clients: [client] });
+ await w.click('reply');
+ expect(client.focus).toHaveBeenCalled();
+ expect(client.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
+ });
+
+ it('opens a window on the reply link with no tab open, never through the route', async () => {
+ const w = worker({ caches: await cachesWith(PREFS) });
+ await w.click('reply');
+ expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
+ expect(w.posts()).toHaveLength(0);
+ });
+
+ it('keeps typed text in the store for the page, out of the URL', async () => {
+ const caches = await cachesWith(PREFS);
+ const w = worker({ caches });
+ await w.click('reply', { reply: 'Private words' });
+ expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
+ const cache = await caches.open(PUSH_CACHE);
+ const kept = await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json();
+ expect(kept).toMatchObject({ emailId: 'e1', text: 'Private words' });
+ });
+
+ it('keeps typed text and the reply link when a tab holding this login stays silent', async () => {
+ const caches = await cachesWith(PREFS);
+ const silent = tab('held', []);
+ const w = worker({ caches, clients: [silent] });
+ await w.dispatch('message', { data: { type: 'client-logins', logins: ['fp'] }, source: { id: 'held' } });
+ await w.click('reply', { reply: 'Private words' });
+ expect(silent.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`);
+ const cache = await caches.open(PUSH_CACHE);
+ expect(await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json()).toMatchObject({ text: 'Private words' });
+ });
+
+ it('leaves nothing in the store without typed text', async () => {
+ const caches = await cachesWith(PREFS);
+ const w = worker({ caches });
+ await w.click('reply', { reply: ' ' });
+ const cache = await caches.open(PUSH_CACHE);
+ expect(await cache.match(`${ORIGIN}/__push-state/pending-reply`)).toBeUndefined();
+ });
+});
+
+describe('Background Sync', () => {
+ it('tells every open tab to flush the outbox', async () => {
+ const one = tab('one', []);
+ const two = tab('two', []);
+ const w = worker({ caches: await cachesWith(), clients: [one, two] });
+ await w.dispatch('sync', { tag: 'bulwark-outbox' });
+ expect(one.messages).toEqual([{ kind: 'outbox-flush' }]);
+ expect(two.messages).toEqual([{ kind: 'outbox-flush' }]);
+ });
+
+ it('ignores other tags', async () => {
+ const one = tab('one', []);
+ const w = worker({ caches: await cachesWith(), clients: [one] });
+ await w.dispatch('sync', { tag: 'something-else' });
+ expect(one.messages).toEqual([]);
+ });
+
+ it('settles with no tab open', async () => {
+ const w = worker({ caches: await cachesWith(), clients: [] });
+ await expect(w.dispatch('sync', { tag: 'bulwark-outbox' })).resolves.toBeUndefined();
+ });
+});
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/lib/deep-links.ts b/lib/deep-links.ts
index 483ad33a9..9e890e58e 100644
--- a/lib/deep-links.ts
+++ b/lib/deep-links.ts
@@ -137,7 +137,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j
*/
export type MailDeepLink =
| { kind: 'folder'; ref: string; accountId?: string; slot?: number; ownerAccountId?: string }
- | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string }
+ | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string; reply?: boolean }
| { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string };
export interface MailLinkState {
@@ -286,12 +286,15 @@ export function parseMailPath(
// opens message links as fullscreen email tabs and ignores the flag.
const fullscreen = search?.get('view') === 'fullscreen' || undefined;
const ownerAccountId = search?.get('owner') || undefined;
+ // `?reply=1`, a notification's Reply: opens the reply composer once. Only
+ // opens it: nothing a link says can send or change mail.
+ const reply = search?.get('reply') === '1' || undefined;
const [kind, value] = segments;
if (kind && value) {
const id = decodeSegment(value);
if (id) {
- if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId };
+ if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId, ...(reply ? { reply } : {}) };
if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId };
if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot, ownerAccountId };
}
diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts
index 111bf736c..fe54a5f7e 100644
--- a/lib/global-search/__tests__/providers.test.ts
+++ b/lib/global-search/__tests__/providers.test.ts
@@ -163,7 +163,7 @@ describe('mail provider', () => {
const result = await mailProvider.remote(parseSearchQuery('zebra'), account('login-a'), { limit: 25, signal });
expect(buildUnifiedAccountClients).toHaveBeenCalledWith({ includeGroup: true, scopeToClientAccountId: 'login-a' });
// The stub login has no trash/junk role folders, so no exclusion is added.
- expect(advancedSearchEmails).toHaveBeenCalledWith({ text: 'zebra' }, undefined, 25, 0);
+ expect(advancedSearchEmails).toHaveBeenCalledWith({ text: 'zebra' }, undefined, 25, 0, { palette: true });
expect(result.hasMore).toBe(true);
expect(result.hits[0]).toMatchObject({
id: 'm9', localAccountId: 'login-a', jmapAccountId: 'jmap-a', source: 'remote', subtitle: 'Inbox',
diff --git a/lib/global-search/providers/mail.ts b/lib/global-search/providers/mail.ts
index 14850795c..d52db2eab 100644
--- a/lib/global-search/providers/mail.ts
+++ b/lib/global-search/providers/mail.ts
@@ -119,6 +119,7 @@ export const mailProvider: SearchProvider = {
entry.isShared ? entry.jmapAccountId : undefined,
limit,
position,
+ { palette: true },
);
if (result.hasMore) hasMore = true;
for (const raw of result.emails) {
diff --git a/lib/jmap/__tests__/search-merge.test.ts b/lib/jmap/__tests__/search-merge.test.ts
new file mode 100644
index 000000000..4ef295281
--- /dev/null
+++ b/lib/jmap/__tests__/search-merge.test.ts
@@ -0,0 +1,903 @@
+// @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, mailboxesFitFilter } 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[], fixedTotal?: number) {
+ const asked: number[] = [];
+ const answer = async (position: number, limit: number): Promise => {
+ asked.push(position);
+ const total = fixedTotal ?? rows.length;
+ 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('walks a search again when it has no cursor, so no server row is skipped', async () => {
+ const server = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l5', 50), mail('l25', 25)]);
+ const merge = new LocalSearchMerge();
+ const ask = (key: string, position: number) => merge.run({
+ key, limit: 2, position,
+ server: (at) => server.answer(at, 2),
+ local: (at) => local.answer(at, 2),
+ });
+ // The caller holds l5, s4, s3. As a merged position 3 would skip s2 on the server.
+ const result = await ask('evicted', 3);
+ expect(ids(result)).toEqual(['l25', 's2']);
+ });
+
+ it('sends a position with no cursor straight to the server when the store is off', async () => {
+ const server = side([mail('s2', 20), mail('s1', 10)]);
+ const result = await pager(server, null, 2)(1);
+ expect(ids(result)).toEqual(['s1']);
+ expect(server.asked).toEqual([0, 1]);
+ });
+
+ it('ends the list, and never guesses, past how far it will walk again', async () => {
+ const rows = Array.from({ length: 100 }, (_, i) => mail(`s${i}`, 1000 - i));
+ const server = side(rows);
+ const local = side([mail('l', 2000)]);
+ const result = await pager(server, local, 2)(90);
+ expect(result.emails).toEqual([]);
+ expect(result.hasMore).toBe(false);
+ expect(server.asked).not.toContain(90);
+ });
+
+ describe('a refresh of a longer list', () => {
+ function paged() {
+ const serverRows = [mail('s6', 60), mail('s5', 50), mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)];
+ const server = side(serverRows);
+ const local = side([mail('l7', 70)]);
+ return { server, local, page: pager(server, local, 2) };
+ }
+
+ it('leaves the next load-more where the list had got to', async () => {
+ const { page } = paged();
+ await page(0);
+ await page(2);
+ const refreshed = await page(0);
+ expect(ids(refreshed)).toEqual(['l7', 's6']);
+ const more = await page(4);
+ expect(ids(more)).toEqual(['s3', 's2']);
+ });
+
+ it('keeps total the same when nothing changed', async () => {
+ const { page } = paged();
+ const first = await page(0);
+ expect(first.total).toBe(7);
+ await page(2);
+ expect((await page(0)).total).toBe(7);
+ });
+
+ it('does not forget a local row that was on a later page', async () => {
+ const server = side([mail('s6', 60), mail('s5', 50), mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l45', 45)]);
+ const page = pager(server, local, 2);
+ expect(ids(await page(0))).toEqual(['s6', 's5']);
+ expect(ids(await page(2))).toEqual(['l45', 's4']);
+ // This first page has no local row, so it knows nothing of l45 by itself.
+ const refreshed = await page(0);
+ expect(ids(refreshed)).toEqual(['s6', 's5']);
+ expect(refreshed.total).toBe(7);
+ // The list holds four rows, three of them the server's: s3 is next.
+ const more = await page(4);
+ expect(ids(more)).toEqual(['s3', 's2']);
+ expect(more.total).toBe(7);
+ });
+
+ it('still lets a new search of the same words page from its own start', async () => {
+ const { page } = paged();
+ await page(0);
+ await page(2);
+ await page(0);
+ // The list was replaced by that page, so the next one starts right after it.
+ expect(ids(await page(2))).toEqual(['s5', 's4']);
+ });
+
+ it('moves on past a new row the refresh brought in', async () => {
+ const serverRows = [mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)];
+ const server = side(serverRows);
+ const local = side([mail('l5', 50)]);
+ const page = pager(server, local, 2);
+ await page(0);
+ await page(2);
+ serverRows.unshift(mail('new', 60));
+ const refreshed = await page(0);
+ expect(refreshed.total).toBe(6);
+ // The caller's list grew by one: l5 s4 s3 s2 plus the new row.
+ expect(ids(await page(5))).toEqual(['s1']);
+ });
+ });
+
+ describe('how long a cursor lasts', () => {
+ function search(merge: LocalSearchMerge, key: string, position: number, rows: ReturnType, local: ReturnType) {
+ return merge.run({ key, limit: 1, position, server: (at) => rows.answer(at, 1), local: (at) => local.answer(at, 1) });
+ }
+ const other = () => [side([mail('o', 1)]), side([])] as const;
+
+ it('keeps the search in use when many others come and go', async () => {
+ const merge = new LocalSearchMerge();
+ const server = side([mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l3', 30)]);
+ await search(merge, 'mine', 0, server, local);
+ for (let i = 0; i < 60; i += 1) await search(merge, `other-${i}`, 0, ...other());
+ await search(merge, 'mine', 1, server, local);
+ for (let i = 60; i < 120; i += 1) await search(merge, `other-${i}`, 0, ...other());
+ server.asked.length = 0;
+ expect(ids(await search(merge, 'mine', 2, server, local))).toEqual(['s1']);
+ // The cursor was there: no walking from the start again.
+ expect(server.asked).toEqual([1]);
+ });
+
+ it('still returns the right rows once a cursor is gone', async () => {
+ const merge = new LocalSearchMerge();
+ const server = side([mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l3', 30)]);
+ await search(merge, 'mine', 0, server, local);
+ for (let i = 0; i < 70; i += 1) await search(merge, `other-${i}`, 0, ...other());
+ expect(ids(await search(merge, 'mine', 1, server, local))).toEqual(['s2']);
+ });
+ });
+
+ it('counts a row both sides have once in total', async () => {
+ const server = side([mail('s1', 10)]);
+ const local = side([mail('s1', 10)]);
+ const result = await pager(server, local, 5)(0);
+ expect(result.total).toBe(1);
+ });
+
+ 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('rows only the store has', () => {
+ function run(
+ serverRows: Email[],
+ localRows: Email[],
+ verify: (rows: Email[]) => Promise,
+ limit = 5,
+ ) {
+ const server = side(serverRows);
+ const local = side(localRows);
+ return new LocalSearchMerge().run({
+ key: 'k', limit, position: 0,
+ server: (at) => server.answer(at, limit),
+ local: (at) => local.answer(at, limit),
+ verify,
+ });
+ }
+
+ it('drops what the server no longer has, and fills the page with the rest', async () => {
+ const verify = vi.fn(async (rows: Email[]) => rows.filter((row) => row.id !== 'deleted'));
+ const result = await run([mail('s1', 10)], [mail('deleted', 30), mail('kept', 20)], verify);
+ expect(ids(result)).toEqual(['kept', 's1']);
+ expect(result.total).toBe(2);
+ expect(verify.mock.calls[0][0].map((row) => row.id)).toEqual(['deleted', 'kept']);
+ });
+
+ it('shows the current copy of what the server still has', async () => {
+ const result = await run([], [mail('kept', 20, { subject: 'old', keywords: {} })], async () => [mail('kept', 20, { subject: 'current', keywords: { $seen: true } })]);
+ expect(result.emails[0].subject).toBe('current');
+ expect(result.emails[0].keywords).toEqual({ $seen: true });
+ });
+
+ it('marks them, so nothing takes them for new mail', async () => {
+ const result = await run([mail('s', 30)], [mail('s', 30), mail('kept', 20)], async (rows) => rows);
+ expect(result.emails.map((email) => email.foundLocally ?? false)).toEqual([false, true]);
+ });
+
+ it('does not ask when the store has nothing of its own', async () => {
+ const verify = vi.fn(async (rows: Email[]) => rows);
+ await run([mail('both', 10)], [mail('both', 10)], verify);
+ expect(verify).not.toHaveBeenCalled();
+ });
+
+ it('does not ask about a row too old for this page', async () => {
+ const verify = vi.fn(async (rows: Email[]) => rows);
+ await run([mail('s3', 30), mail('s2', 20), mail('s1', 10)], [mail('old', 5)], verify, 2);
+ expect(verify).not.toHaveBeenCalled();
+ });
+
+ it('keeps them when the check fails', async () => {
+ const result = await run([mail('s1', 10)], [mail('kept', 20)], async () => { throw new Error('offline again'); });
+ expect(ids(result)).toEqual(['kept', 's1']);
+ });
+
+ it('keeps them, unchecked, when the search itself failed', async () => {
+ const verify = vi.fn(async () => []);
+ const result = await new LocalSearchMerge().run({
+ key: 'k', limit: 5, position: 0,
+ server: async () => { throw new Error('server broke'); },
+ local: async () => ({ emails: [mail('kept', 20)], total: 1, hasMore: false }),
+ verify,
+ });
+ expect(ids(result)).toEqual(['kept']);
+ expect(verify).not.toHaveBeenCalled();
+ });
+});
+
+describe('mailboxesFitFilter', () => {
+ const inbox = { inbox: true };
+ it.each([
+ [{ inMailbox: 'inbox' }, inbox, true],
+ [{ inMailbox: 'inbox' }, { trash: true }, false],
+ [{ inMailbox: 'inbox' }, { inbox: false }, false],
+ [{ inMailbox: 'inbox' }, undefined, false],
+ [{ inMailboxOtherThan: ['trash', 'junk'] }, inbox, true],
+ [{ inMailboxOtherThan: ['trash', 'junk'] }, { trash: true }, false],
+ [{ inMailboxOtherThan: ['trash', 'junk'] }, { trash: true, inbox: true }, true],
+ [{ text: 'x' }, { trash: true }, true],
+ [{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'x' }] }, { trash: true }, false],
+ [{ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'x' }] }, inbox, true],
+ [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { inMailbox: 'b' }] }, { b: true }, true],
+ [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { inMailbox: 'b' }] }, inbox, false],
+ [{ operator: 'OR', conditions: [{ inMailbox: 'a' }, { text: 'x' }] }, inbox, true],
+ [{ operator: 'NOT', conditions: [{ inMailbox: 'trash' }] }, inbox, true],
+ [{ operator: 'NOT', conditions: [{ inMailbox: 'trash' }] }, { trash: true }, false],
+ [{ operator: 'NOT', conditions: [{ text: 'x' }] }, inbox, true],
+ [{ inMailbox: 'trash', text: 'x' }, inbox, false],
+ ])('%j with %j is %s', (filter, boxes, expected) => {
+ expect(mailboxesFitFilter(filter, boxes as Record | undefined)).toBe(expected);
+ });
+});
+
+describe('errors', () => {
+ it('leaves no unhandled rejection when the store fails while the server is still working', async () => {
+ const unhandled: unknown[] = [];
+ const note = (reason: unknown) => { unhandled.push(reason); };
+ process.on('unhandledRejection', note);
+ try {
+ const result = await new LocalSearchMerge().run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ return { emails: [mail('s', 1)], total: 1, hasMore: false };
+ },
+ local: async () => { throw new Error('store broke'); },
+ });
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(ids(result)).toEqual(['s']);
+ expect(unhandled).toEqual([]);
+ } finally {
+ process.off('unhandledRejection', note);
+ }
+ });
+
+ it('keeps the list open when the server fails on a later page', async () => {
+ let fail = false;
+ const rows = side([mail('s4', 40), mail('s3', 30), mail('s2', 20), mail('s1', 10)]);
+ const local = side([mail('l5', 50), mail('l25', 25)]);
+ const merge = new LocalSearchMerge();
+ const run = (position: number) => merge.run({
+ key: 'k', limit: 2, position,
+ server: async (at) => {
+ if (fail) throw new Error('server broke');
+ return rows.answer(at, 2);
+ },
+ local: (at) => local.answer(at, 2),
+ });
+ expect((await run(0)).hasMore).toBe(true);
+ fail = true;
+ const during = await run(2);
+ expect(ids(during)).toEqual(['l25']);
+ // Local rows ran out, but the server said it had more: the list isn't over.
+ expect(during.hasMore).toBe(true);
+ fail = false;
+ expect(ids(await run(3))).toEqual(['s3', 's2']);
+ });
+
+ 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('counts the grace period from when the server answered', async () => {
+ const merge = new LocalSearchMerge();
+ let local: (value: SearchEmailsResult) => void = () => {};
+ const answer = merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => {
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
+ return { emails: [mail('s', 1)], total: 1, hasMore: false };
+ },
+ local: () => new Promise((resolve) => { local = resolve; }),
+ });
+ await vi.advanceTimersByTimeAsync(5_000);
+ // The store answers soon after a slow server, and is still in time.
+ await vi.advanceTimersByTimeAsync(500);
+ local({ emails: [mail('l', 2)], total: 1, hasMore: false });
+ expect(ids(await answer)).toEqual(['l', 's']);
+ });
+
+ it('waits only as long as it was told to', async () => {
+ const merge = new LocalSearchMerge({ graceMs: 200 });
+ let done = false;
+ const answer = merge.run({
+ key: 'k', limit: 10, position: 0,
+ server: async () => ({ emails: [mail('s', 1)], total: 1, hasMore: false }),
+ local: () => new Promise(() => {}),
+ }).then((result) => {
+ done = true;
+ return result;
+ });
+ await vi.advanceTimersByTimeAsync(199);
+ expect(done).toBe(false);
+ await vi.advanceTimersByTimeAsync(1);
+ expect(ids(await answer)).toEqual(['s']);
+ });
+
+ 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]>>;
+ /** What Email/get by id finds on the server, past the search's own rows. */
+ let serverKnows: Array>;
+
+ 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 [firstName, firstArgs, firstId] = methodCalls[0];
+ if (firstName === 'Email/get' && Array.isArray(firstArgs.ids)) {
+ const wanted = firstArgs.ids as string[];
+ const list = [...serverRows, ...serverKnows].filter((row) => wanted.includes(row.id as string));
+ return new Response(JSON.stringify({
+ methodResponses: [[firstName, { list, notFound: wanted.filter((id) => !list.some((row) => row.id === id)) }, firstId]],
+ }), { status: 200 });
+ }
+ 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 = [];
+ serverKnows = [
+ { id: 'only-local', subject: 'Zahlungserinnerungsrechnung', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { inbox: true } },
+ ];
+ 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('drops a row deleted on the server since the store last synced', async () => {
+ const engine = await storeEngine();
+ setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls)));
+ serverKnows = [];
+ const client = await connected();
+ const result = await client.searchEmails('rechnung');
+ expect(result.emails.map((email) => email.id)).toEqual(['newer', 'both']);
+ expect(result.total).toBe(2);
+ });
+
+ it('drops a row moved out of the folder searched', async () => {
+ const engine = await storeEngine();
+ setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls)));
+ serverKnows = [{ id: 'only-local', subject: 'x', receivedAt: '2026-10-03T00:00:00Z', mailboxIds: { trash: true } }];
+ const client = await connected();
+ const inFolder = await client.searchEmails('rechnung', 'inbox');
+ expect(inFolder.emails.map((email) => email.id)).toEqual(['newer', 'both']);
+ const everywhere = await client.searchEmails('rechnung');
+ expect(everywhere.emails.map((email) => email.id)).toEqual(['newer', 'both', 'only-local']);
+ const notInTrash = await client.advancedSearchEmails({ operator: 'AND', conditions: [{ text: 'rechnung' }, { inMailboxOtherThan: ['trash'] }] });
+ expect(notInTrash.emails.map((email) => email.id)).toEqual(['newer', 'both']);
+ });
+
+ it('searches the store without snippets, and asks only for those of its own rows', async () => {
+ const engine = await storeEngine();
+ const asked: string[][] = [];
+ const snippetIds: unknown[] = [];
+ setOfflineHooks(hooksWith((_client, calls) => {
+ asked.push(calls.map(([name]) => name));
+ for (const [name, args] of calls) if (name === 'SearchSnippet/get') snippetIds.push(args.emailIds);
+ return engine.answer(calls);
+ }));
+ const client = await connected();
+ await client.searchEmails('rechnung');
+ expect(asked).toEqual([['Email/query', 'Email/get'], ['SearchSnippet/get']]);
+ expect(snippetIds).toEqual([['only-local']]);
+ });
+
+ it('adds the snippets of its own rows to the deferred ones', async () => {
+ const engine = await storeEngine();
+ setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls)));
+ const client = await connected();
+ const result = await client.searchEmails('rechnung', undefined, undefined, 50, 0, { deferSnippets: true });
+ expect(result.emails.every((email) => !email.searchSnippet)).toBe(true);
+ const snippets = await result.snippets;
+ expect(snippets?.find((s) => s.emailId === 'only-local')?.subject).toBe('Zahlungserinnerungsrechnung');
+ });
+
+ it('keeps the palette from pushing out the paging of the mail list', async () => {
+ const engine = await storeEngine();
+ setOfflineHooks(hooksWith((_client, calls) => engine.answer(calls)));
+ const client = await connected();
+ await client.searchEmails('rechnung', undefined, undefined, 2);
+ for (let i = 0; i < 100; i += 1) await client.searchEmails(`wort${i}`, undefined, undefined, 2, 0, { palette: true });
+ requests = [];
+ await client.searchEmails('rechnung', undefined, undefined, 2, 2);
+ // With its cursor, the next page starts where the server left off, and nothing is walked again.
+ const queries = requests.flatMap((calls) => calls.filter(([name]) => name === 'Email/query'));
+ expect(queries.map(([, args]) => args.position)).toEqual([2]);
+ });
+
+ it('waits less for the store in the palette', async () => {
+ setOfflineHooks(hooksWith(() => new Promise(() => {})));
+ const client = await connected();
+ const started = Date.now();
+ const result = await client.searchEmails('rechnung', undefined, undefined, 50, 0, { palette: true });
+ expect(result.emails.length).toBe(2);
+ expect(Date.now() - started).toBeLessThan(900);
+ });
+
+ 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-interface.ts b/lib/jmap/client-interface.ts
index d3b99d523..0c6e9c5fb 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -94,6 +94,11 @@ export interface SearchRequestOptions {
* where the list must not wait for the highlighting.
*/
deferSnippets?: boolean;
+ /**
+ * For the search palette. It searches on every few keystrokes, so it keeps its
+ * paging apart from the mail list's, and waits less for the local store.
+ */
+ palette?: boolean;
}
export interface SearchEmailsResult {
@@ -635,6 +640,8 @@ export interface IJMAPClient {
options?: CalendarEventUpdateOptions,
): Promise;
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise;
+ /** Whether the last request was answered from this device instead of the server. */
+ answeredFromDevice?(): boolean;
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: Record }>;
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise;
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index 70bdfedb3..1282c7c15 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -4,11 +4,13 @@ import { IncompleteDeleteError } from "./delete-error";
import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard";
import { ArchiveEmailsError } from "./archive-error";
import { generateUUID } from '@/lib/utils';
+import { standInsIn, withRealIds, withStandInsBack } from './stand-in-ids';
import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
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, mailboxesFitFilter } from "./search-merge";
import { batched, batchedBySize, itemsPerRequest } from "./request-limits";
import { keywordPointer, pointerToken } from "./patch-pointer";
import { FirstTouchGate } from "./first-touch-gate";
@@ -1640,6 +1642,8 @@ export class JMAPClient implements IJMAPClient {
* can be reached again (a reload signs in as usual).
*/
private offlineOnly = false;
+ private readonly searchMerge = new LocalSearchMerge();
+ private readonly paletteSearchMerge = new LocalSearchMerge({ graceMs: 200 });
constructor(serverUrl: string, username: string, password: string) {
this.serverUrl = serverUrl.replace(/\/$/, '');
@@ -2199,9 +2203,30 @@ export class JMAPClient implements IJMAPClient {
const offline = getOfflineHooks();
if (this.offlineOnly || offline?.skipNetwork(this)) {
const local = offline && !opts?.networkOnly ? await offline.answer(this, methodCalls) : null;
- if (local) return local as JMAPResponse;
+ if (local) {
+ this.lastAnswerFromDevice = true;
+ return local as JMAPResponse;
+ }
throw new OfflineUnavailableError();
}
+ // A stand-in id from an event created offline, still held by an open dialog
+ // or a list: the server knows it by its real id, or not yet at all.
+ const standIns = offline?.resolveStandIns ? standInsIn(methodCalls) : [];
+ let realIds: Record | null = null;
+ if (standIns.length > 0) {
+ const resolved = await offline!.resolveStandIns!(this, standIns);
+ if (standIns.some((id) => !resolved[id])) {
+ const local = opts?.networkOnly ? null : await offline!.answer(this, methodCalls);
+ if (local) {
+ this.lastAnswerFromDevice = true;
+ return local as JMAPResponse;
+ }
+ throw new OfflineUnavailableError();
+ }
+ realIds = resolved as Record;
+ methodCalls = withRealIds(methodCalls, realIds);
+ }
+ const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response);
let answer: JMAPResponse;
try {
answer = await this.networkRequest(methodCalls, using, opts);
@@ -2210,15 +2235,25 @@ export class JMAPClient implements IJMAPClient {
// headers looks the same to the browser and must stay an error.
if (offline && isNetworkFailure(error) && await offline.serverGone(this, error)) {
const local = opts?.networkOnly ? null : await offline.answer(this, methodCalls);
- if (local) return local as JMAPResponse;
+ if (local) {
+ this.lastAnswerFromDevice = true;
+ return asAsked(local as JMAPResponse);
+ }
}
throw error;
}
+ this.lastAnswerFromDevice = false;
if (offline) {
offline.noteNetwork(this, true);
if (!opts?.skipOfflineRecord) offline.record(this, methodCalls, answer);
}
- return answer;
+ return asAsked(answer);
+ }
+
+ private lastAnswerFromDevice = false;
+
+ answeredFromDevice(): boolean {
+ return this.lastAnswerFromDevice;
}
/** A request only the server may answer: for the outbox, which must see outages as outages. */
@@ -4405,61 +4440,144 @@ 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);
+ }
+ const targetAccountId = accountId || this.accountId;
+ const merge = options.palette ? this.paletteSearchMerge : this.searchMerge;
+ return merge.run({
+ key: JSON.stringify([accountId ?? null, filter]),
+ limit,
+ position,
+ server,
+ local: async (at) => {
+ // No snippets here: excerpting every hit is the slow part, and most of
+ // them are the server's to show. See localSnippets.
+ const answer = await searchLocal.call(hooks, this, this.searchCalls(targetAccountId, filter, limit, at, true));
+ return answer ? this.searchResultFrom(answer as JMAPResponse, accountId, limit, at) : null;
+ },
+ verify: (rows) => this.currentSearchRows(rows, filter, accountId),
+ localSnippets: async (ids) => {
+ if (!filterHasSnippetTerms(filter)) return null;
+ const answer = await searchLocal.call(hooks, this, [
+ ["SearchSnippet/get", { accountId: targetAccountId, filter: snippetFilterFor(filter), emailIds: ids }, "snippets"],
+ ]);
+ return answer ? this.searchSnippetsFrom(answer as JMAPResponse) ?? null : null;
+ },
+ deferSnippets: options.deferSnippets,
+ });
+ }
+
+ /**
+ * The server's copies of rows only the local store found, without those it
+ * no longer has or has outside the filter's folders. The filter is checked
+ * before the ids get their shared-account namespace, since it has bare ones.
+ */
+ private async currentSearchRows(rows: Email[], filter: Record, accountId: string | undefined): Promise {
+ const response = await this.request([
+ ["Email/get", {
+ accountId: accountId || this.accountId,
+ ids: rows.map((row) => row.id),
+ properties: [...EMAIL_LIST_PROPERTIES],
+ }, "0"],
+ ]);
+ const [name, result] = response.methodResponses?.[0] ?? [];
+ if (name !== "Email/get" || !result) throw new Error(methodErrorMessage(response, 'Failed to get emails'));
+ const list = applyListAuthenticationResults((result.list || []) as Email[])
+ .filter((email) => mailboxesFitFilter(filter, email.mailboxIds));
+ if (accountId && accountId !== this.accountId) namespaceMailboxIds(list, accountId);
+ return list;
+ }
+
+ 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);
@@ -4531,52 +4649,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(
@@ -9437,6 +9510,8 @@ export class JMAPClient implements IJMAPClient {
// Strip client-only shared fields before sending to JMAP
const { originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
cleanRecurrenceRules(cleanEvent as unknown as Record);
+ // Chosen here, not by the server: if the answer is lost, this finds the event again.
+ cleanEvent.uid ||= generateUUID();
debug.group('CalendarEvent/create', 'calendar');
debug.log('calendar', 'CalendarEvent/create outgoing payload', {
@@ -9543,6 +9618,7 @@ export class JMAPClient implements IJMAPClient {
for (const { event, index } of batch) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent;
cleanRecurrenceRules(clean as unknown as Record);
+ clean.uid ||= generateUUID();
createMap[`new-${index}`] = clean;
}
@@ -9967,7 +10043,7 @@ export class JMAPClient implements IJMAPClient {
accountId = resolved.accountId;
taskData.calendarIds = resolved.calendarIds;
}
- const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task' };
+ const cleanTask = { ...completeBareTaskDates(taskData), '@type': 'Task', uid: taskData.uid || generateUUID() };
debug.group('CalendarTask/create', 'tasks');
debug.log('tasks', 'CalendarTask/create accountId', accountId);
@@ -11161,7 +11237,7 @@ export class JMAPClient implements IJMAPClient {
if (eventType === 'state' && dataLines.length > 0) {
try {
const change = JSON.parse(dataLines.join('\n')) as StateChange;
- getOfflineHooks()?.stateChange(this, change);
+ getOfflineHooks()?.stateChange?.(this, change);
this.stateChangeCallback?.(change);
} catch {
// Malformed SSE data - ignore
@@ -11337,7 +11413,7 @@ export class JMAPClient implements IJMAPClient {
}
if (Object.keys(changedByAccount).length > 0) {
- getOfflineHooks()?.stateChange(this, { changed: changedByAccount });
+ getOfflineHooks()?.stateChange?.(this, { changed: changedByAccount });
this.stateChangeCallback?.({ '@type': 'StateChange', changed: changedByAccount });
}
} catch {
diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts
index 60998d86e..b908eafb7 100644
--- a/lib/jmap/offline-hooks.ts
+++ b/lib/jmap/offline-hooks.ts
@@ -40,7 +40,7 @@ export interface OfflineHooks {
*/
serverGone(client: OfflineClientRef, error?: unknown): Promise;
/** The server pushed new states, for the local store to catch up. */
- stateChange(client: OfflineClientRef, change: { changed?: Record> }): void;
+ stateChange?(client: OfflineClientRef, change: { changed?: Record> }): void;
/** A blob kept on the device, or null. */
blob(client: OfflineClientRef, blobId: string, accountId: string): Promise;
/** A blob the server sent, for the cache. */
@@ -53,6 +53,14 @@ export interface OfflineHooks {
* Left out when there is no store, and searches then go to the server alone.
*/
searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise;
+ /**
+ * Real ids for stand-in ids the page still holds (lib/jmap/stand-in-ids.ts), or
+ * null for one that hasn't reached the server yet. Without it the client sends
+ * ids as they are.
+ */
+ resolveStandIns?(client: OfflineClientRef, ids: string[]): Promise>;
+ /** The user opened this file: the local store keeps recently opened files. */
+ fileOpened?(client: OfflineClientRef, accountId: string, id: string): void;
}
const KEY = '__bulwarkOfflineHooks';
diff --git a/lib/jmap/search-merge.ts b/lib/jmap/search-merge.ts
new file mode 100644
index 000000000..a7200b0f9
--- /dev/null
+++ b/lib/jmap/search-merge.ts
@@ -0,0 +1,382 @@
+/**
+ * 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.
+ *
+ * A row only the store has may be stale: deleted or moved since the store last
+ * synced. So the server is asked for those rows before they're shown. What it
+ * no longer has, or has outside the filter's folders, is dropped, and what it
+ * still has is shown as its current copy. It takes a request of its own, since
+ * these ids are only known once both answers are in. If that request fails, or
+ * the search itself did, the rows stay: an error must not lose results.
+ *
+ * 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:
+ * - Position 0 can be a new search or a refresh of a longer list, and only the
+ * next page tells which. So it runs on a cursor of its own next to the one
+ * already there. The next page takes whichever ended closest to its position.
+ * - With no cursor (it was evicted), the search is walked again from the start
+ * up to the position. A merged position never goes to the server as it is.
+ * - When the store has nothing to say for this search, no merged row exists and
+ * positions are the server's own, so requests pass straight through.
+ *
+ * `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. When the server fails
+ * on a later page, what it last said about having more still counts.
+ *
+ * `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 holds up the server's answer for at most
+ * the grace period, counted from when the server answered. Snippets for local
+ * rows are asked for separately, so excerpting bodies in the worker doesn't
+ * hold up the list either.
+ */
+
+import { debug } from '@/lib/debug';
+import { attachSearchSnippets, type SearchSnippetResult } from '@/lib/search-snippet';
+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 = 64;
+/** How far back an evicted search is walked again. Past this, the list ends. */
+const MAX_WALK_PAGES = 20;
+
+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);
+}
+
+/** True, false, or null when the filter's answer depends on more than the folders. */
+function mailboxAnswer(filter: Record, boxes: readonly string[]): boolean | null {
+ if (typeof filter.operator === 'string') {
+ const answers = (Array.isArray(filter.conditions) ? filter.conditions as Array> : [])
+ .map((condition) => mailboxAnswer(condition, boxes));
+ const unknown = answers.includes(null);
+ if (filter.operator === 'AND') return answers.includes(false) ? false : unknown ? null : true;
+ const any = answers.includes(true) ? true : unknown ? null : false;
+ if (filter.operator === 'OR') return any;
+ if (filter.operator === 'NOT') return any === null ? null : !any;
+ return null;
+ }
+ const answers: boolean[] = [];
+ if (typeof filter.inMailbox === 'string') answers.push(boxes.includes(filter.inMailbox));
+ if (Array.isArray(filter.inMailboxOtherThan)) {
+ const excluded = filter.inMailboxOtherThan as string[];
+ answers.push(boxes.some((box) => !excluded.includes(box)));
+ }
+ if (answers.includes(false)) return false;
+ return answers.length > 0 && Object.keys(filter).length === answers.length ? true : null;
+}
+
+/** Whether a message's folders still fit the filter. Conditions on anything else don't count against it. */
+export function mailboxesFitFilter(filter: Record, mailboxIds: Record | undefined): boolean {
+ const boxes = Object.entries(mailboxIds ?? {}).filter(([, on]) => on).map(([id]) => id);
+ return mailboxAnswer(filter, boxes) !== false;
+}
+
+interface MergeCursor {
+ /** Merged rows returned so far, which is where the next page should ask from. */
+ next: number;
+ serverPosition: number;
+ localPosition: number;
+ serverTotal: number;
+ serverHasMore: boolean;
+ /** Local-only rows returned so far. */
+ extras: number;
+ /** Local rows the server said are gone. */
+ rejected: number;
+ /** Every row returned so far, so none appears twice. */
+ shown: Set;
+ /** The store has nothing for this search: positions are the server's. */
+ passthrough: boolean;
+}
+
+function newCursor(): MergeCursor {
+ return {
+ next: 0, serverPosition: 0, localPosition: 0, serverTotal: 0, serverHasMore: false,
+ extras: 0, rejected: 0, shown: new Set(), passthrough: false,
+ };
+}
+
+interface CursorEntry {
+ cursor: MergeCursor;
+ /** A position 0 that came after later pages: a new search or a refresh. */
+ restarted: MergeCursor | null;
+}
+
+function receivedTime(email: Email): number {
+ return Date.parse(email.receivedAt) || 0;
+}
+
+interface PageMerge {
+ emails: Email[];
+ serverRead: number;
+ localRead: number;
+ extraIds: string[];
+}
+
+/** 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[] = [];
+ const extraIds: string[] = [];
+ let s = 0;
+ let l = 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, foundLocally: true });
+ shown.add(fromLocal.id);
+ extraIds.push(fromLocal.id);
+ l += 1;
+ }
+ }
+ return { emails, serverRead: s, localRead: l, extraIds };
+}
+
+/** The promise's value, or null if it takes longer than `ms`. The promise must not reject. */
+function within(promise: Promise, ms: number): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(null), ms);
+ void promise.then((value) => {
+ clearTimeout(timer);
+ resolve(value);
+ });
+ });
+}
+
+/** Runs a store call with its failure handled right away, so it can't go unhandled while the server is awaited. */
+function safely(what: string, call: () => Promise): Promise {
+ return Promise.resolve().then(call).catch((error: unknown) => {
+ debug.warn('jmap', `${what} failed:`, error);
+ return 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;
+ /** The server's current copies of these rows, leaving out what's gone or no longer fits the search. */
+ verify?(rows: Email[]): Promise;
+ /** Highlights for rows only the store has. */
+ localSnippets?(ids: string[]): Promise;
+ /** Hand local snippets back as `snippets` instead of waiting for them. */
+ deferSnippets?: boolean;
+}
+
+export interface LocalSearchMergeOptions {
+ /** How long a slow store may hold up the server's answer. */
+ graceMs?: number;
+}
+
+export class LocalSearchMerge {
+ private readonly cursors = new Map();
+ private readonly graceMs: number;
+
+ constructor(options: LocalSearchMergeOptions = {}) {
+ this.graceMs = options.graceMs ?? LOCAL_GRACE_MS;
+ }
+
+ /** Least recently used goes first, so a search being paged through stays. */
+ private remember(key: string, entry: CursorEntry): void {
+ this.cursors.delete(key);
+ this.cursors.set(key, entry);
+ if (this.cursors.size > MAX_CURSORS) this.cursors.delete(this.cursors.keys().next().value as string);
+ }
+
+ async run(request: MergedSearchRequest): Promise {
+ const { key, limit, position } = request;
+ const entry = this.cursors.get(key);
+ if (position === 0) {
+ const fresh = newCursor();
+ const alongside = entry && entry.cursor.next > 0 ? entry.cursor : null;
+ this.remember(key, alongside ? { cursor: alongside, restarted: fresh } : { cursor: fresh, restarted: null });
+ return this.page(fresh, request, 0, limit, { alongside });
+ }
+ if (!entry) return this.walkTo(request);
+ this.remember(key, entry);
+ const cursor = this.choose(entry, position);
+ if (cursor.passthrough) return request.server(position);
+ return this.page(cursor, request, position, limit, {});
+ }
+
+ /** Which cursor the page after a position 0 continues from. Only that one is kept from then on. */
+ private choose(entry: CursorEntry, position: number): MergeCursor {
+ const { cursor, restarted } = entry;
+ if (!restarted) return cursor;
+ entry.restarted = null;
+ const restartedIsCloser = Math.abs(position - restarted.next) <= Math.abs(position - cursor.next);
+ if (cursor.passthrough || restartedIsCloser) {
+ entry.cursor = restarted;
+ return restarted;
+ }
+ for (const id of restarted.shown) cursor.shown.add(id);
+ return cursor;
+ }
+
+ /** No cursor: read the search again from the start, so the rows the caller holds are accounted for. */
+ private async walkTo(request: MergedSearchRequest): Promise {
+ const { key, limit, position } = request;
+ const cursor = newCursor();
+ let last: SearchEmailsResult | null = null;
+ for (let page = 0; cursor.next < position && !cursor.passthrough; page += 1) {
+ if (page === MAX_WALK_PAGES) return { emails: [], hasMore: false, total: last?.total ?? position };
+ last = await this.page(cursor, request, cursor.next, Math.min(limit, position - cursor.next), { walking: true });
+ if (last.emails.length === 0 || !last.hasMore) break;
+ }
+ this.remember(key, { cursor, restarted: null });
+ if (cursor.passthrough) return request.server(position);
+ // The search ended before the position: the caller holds rows that are gone.
+ cursor.next = position;
+ return this.page(cursor, request, position, limit, {});
+ }
+
+ private async page(
+ cursor: MergeCursor,
+ request: MergedSearchRequest,
+ position: number,
+ limit: number,
+ { alongside = null, walking = false }: { alongside?: MergeCursor | null; walking?: boolean },
+ ): Promise {
+ // 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 = safely('Local search', () => 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 ? this.graceMs : 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.
+ cursor.passthrough = true;
+ cursor.next = position + serverPage.emails.length;
+ return serverPage;
+ }
+
+ const server = serverPage?.emails ?? [];
+ const serverHasMore = serverPage?.hasMore ?? false;
+ const local = serverPage
+ ? await this.verified(request, cursor, server, serverHasMore, localPage?.emails ?? [])
+ : 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.extraIds.length;
+ if (serverPage) {
+ cursor.serverTotal = serverPage.total;
+ cursor.serverHasMore = merged.serverRead < server.length || serverHasMore;
+ }
+
+ const localLeft = localPage ? merged.localRead < local.length || localPage.hasMore : false;
+ // A refresh of a longer list counts the local rows already in that list.
+ const extras = Math.max(cursor.extras, alongside?.extras ?? 0);
+ const rejected = Math.max(cursor.rejected, alongside?.rejected ?? 0);
+ const result: SearchEmailsResult = {
+ ...serverPage,
+ emails: merged.emails,
+ hasMore: cursor.serverHasMore || localLeft,
+ total: Math.max(cursor.serverTotal + extras, (localPage?.total ?? 0) - rejected, cursor.next),
+ };
+ if (!walking && merged.extraIds.length > 0 && request.localSnippets) await this.addLocalSnippets(request, merged, result);
+ return result;
+ }
+
+ /** The local rows, with what the server no longer has marked as read past, and the rest as its current copies. */
+ private async verified(
+ request: MergedSearchRequest,
+ cursor: MergeCursor,
+ server: readonly Email[],
+ serverHasMore: boolean,
+ local: Email[],
+ ): Promise {
+ if (!request.verify) return local;
+ const serverIds = new Set(server.map((email) => email.id));
+ const oldestPlaceable = serverHasMore && server.length > 0 ? receivedTime(server[server.length - 1]) : -Infinity;
+ const candidates = local.filter((row) => !serverIds.has(row.id) && !cursor.shown.has(row.id) && receivedTime(row) >= oldestPlaceable);
+ if (candidates.length === 0) return local;
+ let current: Email[];
+ try {
+ current = await request.verify(candidates);
+ } catch (error) {
+ debug.warn('jmap', 'Checking local search results failed:', error);
+ return local;
+ }
+ const byId = new Map(current.map((row) => [row.id, row]));
+ for (const row of candidates) {
+ if (byId.has(row.id)) continue;
+ cursor.shown.add(row.id);
+ cursor.rejected += 1;
+ }
+ return local.map((row) => byId.get(row.id) ?? row);
+ }
+
+ private async addLocalSnippets(request: MergedSearchRequest, merged: PageMerge, result: SearchEmailsResult): Promise {
+ const asked = safely('Local snippets', () => (request.localSnippets as NonNullable)(merged.extraIds));
+ if (!request.deferSnippets) {
+ attachSearchSnippets(result.emails, (await within(asked, this.graceMs)) ?? undefined);
+ return;
+ }
+ const fromServer = result.snippets;
+ result.snippets = Promise.all([fromServer, asked]).then(([a, b]) => (a || b ? [...(a ?? []), ...(b ?? [])] : undefined));
+ }
+}
diff --git a/lib/jmap/stand-in-ids.ts b/lib/jmap/stand-in-ids.ts
new file mode 100644
index 000000000..00da263e6
--- /dev/null
+++ b/lib/jmap/stand-in-ids.ts
@@ -0,0 +1,76 @@
+/**
+ * Stand-in ids: what the local store hands out for an event created offline.
+ *
+ * A page can hold one long after the event reached the server: an open dialog,
+ * a list not refetched yet, a plugin. So the client swaps them for the real ids
+ * on the way to the network, and swaps them back in the answer, because callers
+ * look up the outcome under the id they asked about.
+ */
+
+import type { OfflineMethodCall, OfflineResponse } from './offline-hooks';
+
+const STAND_IN_PREFIX = 'local-';
+
+export function isStandInId(id: string): boolean {
+ return id.startsWith(STAND_IN_PREFIX);
+}
+
+export function newStandInId(): string {
+ return `${STAND_IN_PREFIX}${crypto.randomUUID()}`;
+}
+
+/** The stand-in ids a request names. */
+export function standInsIn(calls: readonly OfflineMethodCall[]): string[] {
+ const found = new Set();
+ for (const [name, args] of calls) {
+ if (name === 'CalendarEvent/set') {
+ for (const id of Object.keys((args.update ?? {}) as object)) if (isStandInId(id)) found.add(id);
+ for (const id of (args.destroy ?? []) as string[]) if (isStandInId(id)) found.add(id);
+ } else if (name === 'CalendarEvent/get') {
+ for (const id of (args.ids ?? []) as string[]) if (isStandInId(id)) found.add(id);
+ }
+ }
+ return [...found];
+}
+
+function renamedKeys(map: Record, rename: (id: string) => string): Record {
+ return Object.fromEntries(Object.entries(map).map(([id, value]) => [rename(id), value]));
+}
+
+/** The request with every stand-in swapped for its real id. */
+export function withRealIds(calls: readonly T[], real: Readonly>): T[] {
+ const rename = (id: string) => real[id] ?? id;
+ return calls.map((call) => {
+ const [name, args, callId] = call;
+ if (name === 'CalendarEvent/set') {
+ const next = { ...args };
+ if (args.update) next.update = renamedKeys(args.update as Record, rename);
+ if (args.destroy) next.destroy = (args.destroy as string[]).map(rename);
+ return [name, next, callId] as unknown as T;
+ }
+ if (name === 'CalendarEvent/get' && args.ids) {
+ return [name, { ...args, ids: (args.ids as string[]).map(rename) }, callId] as unknown as T;
+ }
+ return call;
+ });
+}
+
+/** The answer as if the caller's ids had gone out. The objects in a get's list keep their real id. */
+export function withStandInsBack(response: T, real: Readonly>): T {
+ const back = new Map(Object.entries(real).map(([standIn, id]) => [id, standIn]));
+ const rename = (id: string) => back.get(id) ?? id;
+ return {
+ ...response,
+ methodResponses: response.methodResponses.map(([name, result, callId]) => {
+ if (name !== 'CalendarEvent/set' && name !== 'CalendarEvent/get') return [name, result, callId];
+ const next = { ...result };
+ for (const key of ['updated', 'notUpdated', 'notDestroyed']) {
+ if (next[key]) next[key] = renamedKeys(next[key], rename);
+ }
+ for (const key of ['destroyed', 'notFound']) {
+ if (next[key]) next[key] = next[key].map(rename);
+ }
+ return [name, next, callId];
+ }),
+ };
+}
diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts
index 647a57309..6a19f659f 100644
--- a/lib/jmap/types.ts
+++ b/lib/jmap/types.ts
@@ -51,6 +51,8 @@ export interface Email {
// subject / body excerpt with the matched terms in , set on search
// hits only. See lib/search-snippet.ts.
searchSnippet?: { subject: string | null; preview: string | null };
+ /** A search row only the local store found. It's no sign of new mail, whatever its date (lib/jmap/search-merge.ts). */
+ foundLocally?: boolean;
textBody?: EmailBodyPart[];
htmlBody?: EmailBodyPart[];
bodyValues?: Record;
diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts
new file mode 100644
index 000000000..da3099e06
--- /dev/null
+++ b/lib/offline/__tests__/queue-sync.test.ts
@@ -0,0 +1,53 @@
+import 'fake-indexeddb/auto';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { setStoreBackend } from '@/lib/store/client/api';
+import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
+import { getOfflineHooks } from '@/lib/jmap/offline-hooks';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useAccountStore } from '@/stores/account-store';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { queueSend, readQueue } from '../queue';
+import { registerOfflineHooks } from '../offline-mode';
+import { writeEnabled } from '../state';
+
+// Whatever goes into the outbox asks for a Background Sync, once it's stored.
+
+const seen = vi.hoisted(() => ({ calls: 0, onCall: (() => {}) as () => void }));
+vi.mock('@/lib/pwa-notify/outbox-sync', () => ({ requestOutboxSync: () => { seen.calls += 1; seen.onCall(); } }));
+
+let store: InProcessStore;
+
+beforeEach(() => {
+ store = new InProcessStore();
+ setStoreBackend(store);
+ seen.calls = 0;
+ seen.onCall = () => {};
+ usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
+ useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
+});
+
+afterEach(() => {
+ setStoreBackend(null);
+ writeEnabled([]);
+});
+
+describe('the outbox', () => {
+ it('asks for a sync after storing a message, and still returns its id', async () => {
+ let storedWhenAsked = -1;
+ seen.onCall = () => { void readQueue('acc').then(({ outbox }) => { storedWhenAsked = outbox.length; }); };
+ const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [] });
+ expect(id).toBe(1);
+ expect(seen.calls).toBe(1);
+ await vi.waitFor(() => expect(storedWhenAsked).toBe(1));
+ });
+
+ it('asks for a sync after keeping a change the client made, not after a read', async () => {
+ writeEnabled(['acc']);
+ registerOfflineHooks();
+ const client = { getServerUrl: () => 'https://mail.example.org', getUsername: () => 'alice@example.org', getAccountId: () => 'acc1' };
+ await getOfflineHooks()?.answer(client, [['Email/set', { update: { m: { 'keywords/$seen': true } } }, '0']]);
+ expect(seen.calls).toBe(1);
+ await getOfflineHooks()?.answer(client, [['Email/get', { ids: [] }, '0']]);
+ expect(seen.calls).toBe(1);
+ });
+});
diff --git a/lib/pwa-notify/__tests__/login-fingerprint.test.ts b/lib/pwa-notify/__tests__/login-fingerprint.test.ts
new file mode 100644
index 000000000..82cab2ad3
--- /dev/null
+++ b/lib/pwa-notify/__tests__/login-fingerprint.test.ts
@@ -0,0 +1,42 @@
+// @vitest-environment node
+import { describe, expect, it } from 'vitest';
+import { loginFingerprint } from '../login-fingerprint';
+
+// The registry keeps the server address as typed, the context cookie as the
+// login route normalised it. Both must name the same login.
+describe('loginFingerprint', () => {
+ const base = 'https://mail.example.org';
+ const USER = 'alice@example.org';
+
+ it.each([
+ 'https://Mail.Example.org',
+ 'https://MAIL.EXAMPLE.ORG/',
+ 'https://mail.example.org:443',
+ 'https://mail.example.org:443/',
+ 'https://mail.example.org//',
+ ])('reads %s as the same server', async (raw) => {
+ expect(await loginFingerprint(raw, USER)).toBe(await loginFingerprint(base, USER));
+ });
+
+ it('drops the default port of http too', async () => {
+ expect(await loginFingerprint('http://mail.example.org:80', USER)).toBe(await loginFingerprint('http://mail.example.org', USER));
+ });
+
+ it('ignores the case of the username and stray spaces', async () => {
+ expect(await loginFingerprint(base, ' Alice@Example.org ')).toBe(await loginFingerprint(base, USER));
+ });
+
+ it.each([
+ ['another host', 'https://other.example.org', USER],
+ ['another port', 'https://mail.example.org:8443', USER],
+ ['another scheme', 'http://mail.example.org', USER],
+ ['another path', 'https://mail.example.org/jmap', USER],
+ ['another user', base, 'bob@example.org'],
+ ])('tells %s apart', async (_name, server, user) => {
+ expect(await loginFingerprint(server, user)).not.toBe(await loginFingerprint(base, USER));
+ });
+
+ it('keeps going on an address that is no URL', async () => {
+ expect(await loginFingerprint('not a url/', USER)).toBe(await loginFingerprint('not a url', USER));
+ });
+});
diff --git a/lib/pwa-notify/__tests__/outbox-sync.test.ts b/lib/pwa-notify/__tests__/outbox-sync.test.ts
new file mode 100644
index 000000000..0e2ae151d
--- /dev/null
+++ b/lib/pwa-notify/__tests__/outbox-sync.test.ts
@@ -0,0 +1,63 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { requestOutboxSync } from '../outbox-sync';
+
+// Background Sync where the browser has it. Elsewhere, and in Electron where
+// register rejects, nothing changes and nothing throws.
+
+const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+function browserWith(opts: { syncManager?: boolean; registration?: unknown; worker?: boolean }) {
+ if (opts.syncManager) vi.stubGlobal('SyncManager', class {});
+ Object.defineProperty(navigator, 'serviceWorker', {
+ configurable: true,
+ value: opts.worker === false ? undefined : { getRegistration: async () => opts.registration },
+ });
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined });
+});
+
+describe('requestOutboxSync', () => {
+ it('registers the tag the worker listens for', async () => {
+ const register = vi.fn(async () => {});
+ browserWith({ syncManager: true, registration: { sync: { register } } });
+ requestOutboxSync();
+ await settle();
+ expect(register).toHaveBeenCalledWith('bulwark-outbox');
+ });
+
+ it('does nothing in a browser without Background Sync', async () => {
+ const register = vi.fn(async () => {});
+ browserWith({ syncManager: false, registration: { sync: { register } } });
+ requestOutboxSync();
+ await settle();
+ expect(register).not.toHaveBeenCalled();
+ });
+
+ it('does nothing with no worker registered', async () => {
+ browserWith({ syncManager: true, registration: undefined });
+ expect(() => requestOutboxSync()).not.toThrow();
+ await settle();
+ });
+
+ it('does nothing without service workers', async () => {
+ browserWith({ syncManager: true, worker: false });
+ expect(() => requestOutboxSync()).not.toThrow();
+ await settle();
+ });
+
+ it('swallows the rejection Electron answers with', async () => {
+ const rejected = vi.fn();
+ process.on('unhandledRejection', rejected);
+ const register = vi.fn(async () => { throw new DOMException('Background Sync is disabled', 'UnknownError'); });
+ browserWith({ syncManager: true, registration: { sync: { register } } });
+ requestOutboxSync();
+ await settle();
+ await settle();
+ process.off('unhandledRejection', rejected);
+ expect(register).toHaveBeenCalled();
+ expect(rejected).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts
new file mode 100644
index 000000000..39287cd2f
--- /dev/null
+++ b/lib/pwa-notify/__tests__/page-actions.test.ts
@@ -0,0 +1,280 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { loginFingerprint } from '../login-fingerprint';
+import { isNotificationActionRequest, runNotificationAction, setNotificationReplyOpener, type NotificationActionRequest, type NotificationActionStatus } from '../page-actions';
+
+// A notification's button in an open tab: only for the login it was shown for,
+// through the store actions, with the tab's answer telling the worker what's next.
+
+const accounts = [
+ { id: 'alice@one', cookieSlot: 0, serverUrl: 'https://one.example', username: 'alice@example.org' },
+ { id: 'bob@two', cookieSlot: 1, serverUrl: 'https://two.example', username: 'bob@example.org' },
+];
+const makeClient = (accountId: string) => ({
+ getAccountId: () => accountId,
+ getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })),
+ getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]),
+});
+let clients: Record>;
+let activeAccountId = 'alice@one';
+const gateOff = new Set();
+let hooksRegistered = false;
+const batchMarkAsRead = vi.fn(async (..._args: unknown[]) => true);
+const batchArchive = vi.fn(async (..._args: unknown[]) => true);
+
+vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } }));
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: {
+ getState: () => ({
+ activeAccountId,
+ client: clients[activeAccountId] ?? null,
+ getClientForAccount: (id: string) => clients[id],
+ }),
+ },
+}));
+vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } }));
+// Each login's own subject, and a policy that reads it: the gate of the login
+// the notification is for, not of the one that's active here.
+vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) }));
+vi.mock('@/stores/policy-store', () => ({
+ usePolicyStore: {
+ getState: () => ({
+ resolveFor: (subject: { email: string }) => ({ features: { archiveActionEnabled: !gateOff.has(subject.email) } }),
+ }),
+ },
+}));
+vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered }));
+
+async function run(over: Partial = {}) {
+ const request: NotificationActionRequest = {
+ action: 'mark-read',
+ emailId: 'e1',
+ accountId: 'a',
+ slot: 0,
+ login: await loginFingerprint('https://one.example', 'alice@example.org'),
+ at: Date.now(),
+ ...over,
+ };
+ const answers: NotificationActionStatus[] = [];
+ await runNotificationAction(request, (status) => answers.push(status));
+ return answers;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ clients = { 'alice@one': makeClient('a'), 'bob@two': makeClient('b') };
+ activeAccountId = 'alice@one';
+ gateOff.clear();
+ hooksRegistered = false;
+ batchMarkAsRead.mockResolvedValue(true);
+ batchArchive.mockResolvedValue(true);
+});
+
+describe('which notifications a tab takes', () => {
+ it('declines when the slot holds someone else now', async () => {
+ const answers = await run({ login: await loginFingerprint('https://other.example', 'alice@example.org') });
+ expect(answers).toEqual(['declined']);
+ expect(batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('takes a notification whose server address was written differently', async () => {
+ const answers = await run({ login: await loginFingerprint('https://ONE.example:443/', 'alice@example.org') });
+ expect(answers).toEqual(['accepted', 'done']);
+ });
+
+ it('declines a slot it has no login for', async () => {
+ expect(await run({ slot: 7 })).toEqual(['declined']);
+ });
+
+ it('declines without a login to compare', async () => {
+ expect(await run({ login: undefined })).toEqual(['declined']);
+ expect(batchMarkAsRead).not.toHaveBeenCalled();
+ });
+
+ it('declines when that login is not connected in this tab', async () => {
+ delete clients['alice@one'];
+ activeAccountId = 'bob@two';
+ expect(await run()).toEqual(['declined']);
+ });
+
+ it('declines a click from long ago: a tab that thawed has lost it', async () => {
+ expect(await run({ at: Date.now() - 6_000 })).toEqual(['declined']);
+ expect(batchMarkAsRead).not.toHaveBeenCalled();
+ const opener = vi.fn();
+ const remove = setNotificationReplyOpener(opener);
+ expect(await run({ action: 'reply', at: Date.now() - 6_000 })).toEqual(['declined']);
+ expect(opener).not.toHaveBeenCalled();
+ remove();
+ });
+
+ it('takes one from a moment ago', async () => {
+ expect(await run({ at: Date.now() - 2_000 })).toEqual(['accepted', 'done']);
+ });
+
+ it('checks a message from the worker for the shape it needs', () => {
+ const at = 1;
+ expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a', at })).toBe(true);
+ expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a' })).toBe(false);
+ expect(isNotificationActionRequest({ action: 'delete', emailId: 'e', accountId: 'a', at })).toBe(false);
+ expect(isNotificationActionRequest({ action: 'archive', emailId: '', accountId: 'a', at })).toBe(false);
+ expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', at })).toBe(false);
+ expect(isNotificationActionRequest(null)).toBe(false);
+ });
+});
+
+describe('with plugins listening on a veto hook', () => {
+ beforeEach(() => { hooksRegistered = true; });
+
+ it("doesn't pass on a shared account's Archive to the route, which has no plugins", async () => {
+ expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['failed']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it("doesn't pass on a login that isn't connected here", async () => {
+ delete clients['alice@one'];
+ activeAccountId = 'bob@two';
+ expect(await run()).toEqual(['failed']);
+ });
+
+ it('still declines a notification of a login this tab does not hold', async () => {
+ expect(await run({ slot: 7 })).toEqual(['declined']);
+ });
+
+ it('runs what it can itself, through the store, where the hooks are', async () => {
+ expect(await run()).toEqual(['accepted', 'done']);
+ expect(batchMarkAsRead).toHaveBeenCalled();
+ });
+});
+
+describe('Mark read', () => {
+ it('goes through the store, with the login of the notification', async () => {
+ expect(await run()).toEqual(['accepted', 'done']);
+ const [active, read, target] = batchMarkAsRead.mock.calls[0];
+ expect(active).toBe(clients['alice@one']);
+ expect(read).toBe(true);
+ expect(target).toEqual({ emailIds: ['e1'], client: clients['alice@one'], accountId: undefined });
+ });
+
+ it('reaches a login that is not the active one through its own client', async () => {
+ const answers = await run({ slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') });
+ expect(answers).toEqual(['accepted', 'done']);
+ const [active, , target] = batchMarkAsRead.mock.calls[0];
+ expect(active).toBe(clients['alice@one']);
+ expect((target as { client: unknown }).client).toBe(clients['bob@two']);
+ });
+
+ it('names a shared account', async () => {
+ await run({ accountId: 'team' });
+ expect((batchMarkAsRead.mock.calls[0][2] as { accountId?: string }).accountId).toBe('team');
+ expect(clients['alice@one'].getEmail).toHaveBeenCalledWith('e1', 'team');
+ });
+
+ it('reports a failure, so the worker opens the message', async () => {
+ batchMarkAsRead.mockRejectedValue(new Error('forbidden'));
+ expect(await run()).toEqual(['accepted', 'failed']);
+ });
+
+ it('reports a plugin stopping it', async () => {
+ batchMarkAsRead.mockResolvedValue(false);
+ expect(await run()).toEqual(['accepted', 'failed']);
+ });
+
+ it('still queues with the mail server out of reach, like the app\'s own button', async () => {
+ clients['alice@one'].getEmail.mockRejectedValue(new Error('offline'));
+ clients['alice@one'].getMailboxes.mockRejectedValue(new Error('offline'));
+ expect(await run()).toEqual(['accepted', 'done']);
+ expect(batchMarkAsRead).toHaveBeenCalled();
+ });
+
+ it('leaves a message since moved to Trash alone, by the server id of the folder', async () => {
+ expect(await run({ emailId: 'binned' })).toEqual(['accepted', 'failed']);
+ expect(batchMarkAsRead).not.toHaveBeenCalled();
+ });
+});
+
+describe('Archive', () => {
+ it('goes through the store with the message, which it files by date', async () => {
+ expect(await run({ action: 'archive' })).toEqual(['accepted', 'done']);
+ const [active, target] = batchArchive.mock.calls[0];
+ expect(active).toBe(clients['alice@one']);
+ expect(target).toEqual({
+ emailIds: ['e1'],
+ client: clients['alice@one'],
+ emails: [{ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } }],
+ userArchive: true,
+ });
+ });
+
+ it('fails when the admin turned it off for this login, and the message opens', async () => {
+ gateOff.add('alice@one');
+ expect(await run({ action: 'archive' })).toEqual(['failed']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it("goes by the notification's login, not the active one", async () => {
+ const bob = { slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') };
+ gateOff.add('bob@two');
+ expect(await run({ action: 'archive', ...bob })).toEqual(['failed']);
+ expect(batchArchive).not.toHaveBeenCalled();
+
+ gateOff.clear();
+ gateOff.add('alice@one');
+ expect(await run({ action: 'archive', ...bob })).toEqual(['accepted', 'done']);
+ });
+
+ it("leaves a shared account's Archive to the route: the store reads own folders only", async () => {
+ expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['declined']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it('reports a message that is gone', async () => {
+ expect(await run({ action: 'archive', emailId: 'gone' })).toEqual(['accepted', 'failed']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it('reports a message it can\'t read with the mail server out of reach', async () => {
+ clients['alice@one'].getEmail.mockRejectedValue(new Error('offline'));
+ expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it('leaves a message since moved to Trash alone', async () => {
+ expect(await run({ action: 'archive', emailId: 'binned' })).toEqual(['accepted', 'failed']);
+ expect(batchArchive).not.toHaveBeenCalled();
+ });
+
+ it('reports a plugin or the store stopping it', async () => {
+ batchArchive.mockResolvedValue(false);
+ expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']);
+ });
+});
+
+describe('Reply', () => {
+ it('declines on a page without the mail app', async () => {
+ expect(await run({ action: 'reply' })).toEqual(['declined']);
+ });
+
+ it('declines on such a page with plugins too: a link opens the composer, nothing is skipped', async () => {
+ hooksRegistered = true;
+ expect(await run({ action: 'reply' })).toEqual(['declined']);
+ });
+
+ it('hands the request to the mail page, typed text included', async () => {
+ const opener = vi.fn();
+ const remove = setNotificationReplyOpener(opener);
+ expect(await run({ action: 'reply', text: 'Thanks' })).toEqual(['done']);
+ expect(opener).toHaveBeenCalledWith({ emailId: 'e1', accountId: 'a', slot: 0, text: 'Thanks' });
+ remove();
+ expect(await run({ action: 'reply' })).toEqual(['declined']);
+ expect(opener).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps a newer opener when an older one is removed', async () => {
+ const remove = setNotificationReplyOpener(vi.fn());
+ const current = vi.fn();
+ const removeCurrent = setNotificationReplyOpener(current);
+ remove();
+ await run({ action: 'reply' });
+ expect(current).toHaveBeenCalled();
+ removeCurrent();
+ });
+});
diff --git a/lib/pwa-notify/__tests__/plugin-veto.test.ts b/lib/pwa-notify/__tests__/plugin-veto.test.ts
new file mode 100644
index 000000000..1dfcf9292
--- /dev/null
+++ b/lib/pwa-notify/__tests__/plugin-veto.test.ts
@@ -0,0 +1,36 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { hookSpec } from '@/lib/plugin-hook-catalog';
+import { emailHooks } from '@/lib/plugin-hooks';
+import { pluginsMayVeto, vetoHooksRegistered } from '../plugin-veto';
+
+describe('pluginsMayVeto', () => {
+ it('reads the permission off the catalogue, which is email:write for all three today', () => {
+ for (const name of ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange']) {
+ expect(hookSpec(name)?.permission).toBe('email:write');
+ }
+ });
+
+ it('is true for an enabled plugin with that permission', () => {
+ expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read', 'email:write'] }])).toBe(true);
+ });
+
+ it('is false for a disabled one, for other permissions and for none', () => {
+ expect(pluginsMayVeto([{ enabled: false, permissions: ['email:write'] }])).toBe(false);
+ expect(pluginsMayVeto([{ enabled: true, permissions: ['email:read'] }])).toBe(false);
+ expect(pluginsMayVeto([{ enabled: true }])).toBe(false);
+ expect(pluginsMayVeto([])).toBe(false);
+ });
+});
+
+describe('vetoHooksRegistered', () => {
+ const stops: Array<() => void> = [];
+ afterEach(() => { stops.splice(0).forEach((stop) => stop()); });
+
+ it.each(['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const)('sees a listener on %s', (name) => {
+ expect(vetoHooksRegistered()).toBe(false);
+ const bus = emailHooks[name] as unknown as { register(id: string, fn: () => boolean): { dispose(): void } };
+ const handle = bus.register('test-plugin', () => true);
+ stops.push(() => handle.dispose());
+ expect(vetoHooksRegistered()).toBe(true);
+ });
+});
diff --git a/lib/pwa-notify/__tests__/tab-reply.test.ts b/lib/pwa-notify/__tests__/tab-reply.test.ts
new file mode 100644
index 000000000..99f35a448
--- /dev/null
+++ b/lib/pwa-notify/__tests__/tab-reply.test.ts
@@ -0,0 +1,33 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { requestTabReply, takeTabReply, useNotificationTabReply } from '../tab-reply';
+
+afterEach(() => {
+ vi.useRealTimers();
+ useNotificationTabReply.setState({ pending: null });
+});
+
+describe('a notification reply waiting for its Pro tab', () => {
+ it('reaches that message once, with the text', () => {
+ requestTabReply('e1', 'Thanks');
+ expect(takeTabReply('e1')).toBe('Thanks');
+ expect(takeTabReply('e1')).toBeNull();
+ });
+
+ it('keeps an empty reply apart from none', () => {
+ requestTabReply('e1', '');
+ expect(takeTabReply('e1')).toBe('');
+ });
+
+ it('stays for its own message when another tab loads', () => {
+ requestTabReply('e1', 'Thanks');
+ expect(takeTabReply('e2')).toBeNull();
+ expect(takeTabReply('e1')).toBe('Thanks');
+ });
+
+ it("doesn't open a composer minutes later", () => {
+ vi.useFakeTimers();
+ requestTabReply('e1', 'Thanks');
+ vi.advanceTimersByTime(3 * 60 * 1000);
+ expect(takeTabReply('e1')).toBeNull();
+ });
+});
diff --git a/lib/pwa-notify/__tests__/worker-state.test.ts b/lib/pwa-notify/__tests__/worker-state.test.ts
new file mode 100644
index 000000000..69e384f09
--- /dev/null
+++ b/lib/pwa-notify/__tests__/worker-state.test.ts
@@ -0,0 +1,70 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { FakeCaches } from '@/lib/__tests__/sw-harness';
+import { PUSH_STATE_CACHE } from '@/lib/push-preferences';
+import { consumePendingReplyText, notificationActionPrefsKey, pendingReplyKey, writeNotificationActionPrefs } from '../worker-state';
+
+let caches: FakeCaches;
+
+const keep = async (value: unknown) => {
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ await cache.put(pendingReplyKey(window.location.origin), new Response(JSON.stringify(value)));
+};
+
+beforeEach(() => {
+ caches = new FakeCaches();
+ vi.stubGlobal('caches', caches);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('the worker store', () => {
+ it('holds the prefs where sw.js reads them', async () => {
+ const prefs = { archiveEnabled: true, archiveMode: 'year' as const, pluginsMayVeto: true, labels: { archive: 'A', markRead: 'M', reply: 'R', replyPlaceholder: 'P' } };
+ await writeNotificationActionPrefs(prefs);
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ const stored = await cache.match(notificationActionPrefsKey(window.location.origin));
+ expect(await stored!.json()).toEqual(prefs);
+ });
+
+ it('uses the same keys and cache as sw.js, which can import nothing', () => {
+ const worker = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8');
+ expect(worker).toContain(`const PUSH_STATE_CACHE = "${PUSH_STATE_CACHE}"`);
+ expect(worker).toContain('/__push-state/actions`');
+ expect(worker).toContain('/__push-state/pending-reply`');
+ expect(notificationActionPrefsKey('https://x')).toBe('https://x/__push-state/actions');
+ expect(pendingReplyKey('https://x')).toBe('https://x/__push-state/pending-reply');
+ });
+
+ it('survives a browser without the Cache API', async () => {
+ vi.stubGlobal('caches', undefined);
+ await expect(writeNotificationActionPrefs({ archiveEnabled: false, archiveMode: 'single', pluginsMayVeto: false, labels: { archive: '', markRead: '', reply: '', replyPlaceholder: '' } })).resolves.toBeUndefined();
+ await expect(consumePendingReplyText('e1')).resolves.toBe('');
+ });
+});
+
+describe('a reply typed on a notification', () => {
+ it('reaches the page once', async () => {
+ await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() });
+ expect(await consumePendingReplyText('e1')).toBe('Thanks');
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+
+ it("doesn't go into a reply to another message, and is gone after", async () => {
+ await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() });
+ expect(await consumePendingReplyText('e2')).toBe('');
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+
+ it("doesn't go into a reply much later", async () => {
+ await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() - 6 * 60 * 1000 });
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+
+ it('is empty when there is none', async () => {
+ expect(await consumePendingReplyText('e1')).toBe('');
+ });
+});
diff --git a/lib/pwa-notify/login-fingerprint.ts b/lib/pwa-notify/login-fingerprint.ts
new file mode 100644
index 000000000..13eeaa0f4
--- /dev/null
+++ b/lib/pwa-notify/login-fingerprint.ts
@@ -0,0 +1,25 @@
+/**
+ * Names a login (server and user) in a notification's data, without naming
+ * either. A cookie slot goes to whoever signs in next, and JMAP account ids
+ * repeat across servers, so neither says that an action on a notification
+ * from yesterday still means the same mailbox.
+ *
+ * Runs in the page and on the server: both ends compare the result.
+ */
+function canonicalServerUrl(serverUrl: string): string {
+ // The registry keeps what the user typed, the cookie what the login route
+ // normalised: `https://Mail.Example.org:443/` and `https://mail.example.org`
+ // are the same server.
+ try {
+ const url = new URL(serverUrl);
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, '');
+ } catch {
+ return serverUrl.replace(/\/+$/, '');
+ }
+}
+
+export async function loginFingerprint(serverUrl: string, username: string | undefined): Promise {
+ const text = `${canonicalServerUrl(serverUrl)}\n${(username ?? '').trim().toLowerCase()}`;
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
+ return Array.from(new Uint8Array(digest).slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join('');
+}
diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts
new file mode 100644
index 000000000..96b1db101
--- /dev/null
+++ b/lib/pwa-notify/page-actions.ts
@@ -0,0 +1,159 @@
+import { subjectForAccount } from '@/lib/org/account-subjects';
+import { useAccountStore } from '@/stores/account-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { loginFingerprint } from './login-fingerprint';
+import { vetoHooksRegistered } from './plugin-veto';
+
+/** What the service worker posts when a notification's button is clicked (public/sw.js). */
+export interface NotificationActionRequest {
+ action: 'archive' | 'mark-read' | 'reply';
+ emailId: string;
+ accountId: string;
+ /** When the worker asked. */
+ at: number;
+ threadId?: string;
+ slot?: number;
+ login?: string;
+ /** A reply typed on the notification, where the browser has a text field there. */
+ text?: string;
+}
+
+/**
+ * `declined`: not for this tab, the worker tries the next tab or the route.
+ * `accepted`: this tab is on it. `done` and `failed`: how it ended; on
+ * `failed` the message opens, and the route isn't tried.
+ */
+export type NotificationActionStatus = 'declined' | 'accepted' | 'done' | 'failed';
+
+export interface NotificationReplyRequest {
+ emailId: string;
+ accountId: string;
+ slot: number;
+ text?: string;
+}
+
+/**
+ * A tab that was frozen and thaws after this has lost the click: the worker has
+ * moved on, maybe to the route or to opening the message. Past every wait in
+ * sw.js for a tab to take an action.
+ */
+const REQUEST_MAX_AGE_MS = 5_000;
+
+type ReplyOpener = (request: NotificationReplyRequest) => void;
+
+let replyOpener: ReplyOpener | null = null;
+
+/** The mail page opens replies. On other pages the worker navigates to it instead. */
+export function setNotificationReplyOpener(opener: ReplyOpener): () => void {
+ replyOpener = opener;
+ return () => {
+ if (replyOpener === opener) replyOpener = null;
+ };
+}
+
+export function isNotificationActionRequest(value: unknown): value is NotificationActionRequest {
+ const request = value as Partial | null;
+ return !!request
+ && (request.action === 'archive' || request.action === 'mark-read' || request.action === 'reply')
+ && typeof request.emailId === 'string' && !!request.emailId
+ && typeof request.accountId === 'string' && !!request.accountId
+ && typeof request.at === 'number';
+}
+
+/** The account of this tab's registry the notification is for, if it still means that login. */
+async function loginOf(request: NotificationActionRequest) {
+ const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === request.slot);
+ if (!entry || !request.login) return null;
+ return (await loginFingerprint(entry.serverUrl, entry.username)) === request.login ? entry : null;
+}
+
+/** The Archive gate as it applies to this login, whichever login is active here. */
+function archiveEnabledFor(accountEntryId: string): boolean {
+ return usePolicyStore.getState().resolveFor(subjectForAccount(accountEntryId)).features?.archiveActionEnabled !== false;
+}
+
+/**
+ * Runs a notification's button in this tab, through the same store actions as
+ * the app's own buttons: plugin hooks, archive mode and the Archive gate all
+ * apply, and the list on screen follows. What this tab can't do while plugins
+ * listen ends in `failed`, not `declined`: the route has no plugins.
+ */
+export async function runNotificationAction(
+ request: NotificationActionRequest,
+ answer: (status: NotificationActionStatus) => void,
+): Promise {
+ try {
+ if (Date.now() - request.at > REQUEST_MAX_AGE_MS) {
+ answer('declined');
+ return;
+ }
+ const entry = await loginOf(request);
+ if (!entry) {
+ answer('declined');
+ return;
+ }
+ const passOn = () => answer(vetoHooksRegistered() ? 'failed' : 'declined');
+ const auth = useAuthStore.getState();
+ const client = auth.getClientForAccount(entry.id) ?? (entry.id === auth.activeAccountId ? auth.client : null);
+ const activeClient = auth.client;
+ if (request.action === 'reply') {
+ if (!client || !replyOpener || request.slot === undefined) {
+ answer('declined');
+ return;
+ }
+ replyOpener({ emailId: request.emailId, accountId: request.accountId, slot: request.slot, text: request.text });
+ answer('done');
+ return;
+ }
+ if (!client || !activeClient) {
+ passOn();
+ return;
+ }
+
+ const own = request.accountId === client.getAccountId();
+ if (request.action === 'archive') {
+ // The route would refuse it too.
+ if (!archiveEnabledFor(entry.id)) {
+ answer('failed');
+ return;
+ }
+ // The store's targeted archive reads a login's own folders only. A shared
+ // account's Archive is the route's to find.
+ if (!own) {
+ passOn();
+ return;
+ }
+ }
+
+ answer('accepted');
+ const accountId = own ? undefined : request.accountId;
+ // Unknown with the mail server out of reach. Mark read then queues like the
+ // app's own button; Archive files by date and can't go without the message.
+ const [email, mailboxes] = await Promise.all([
+ client.getEmail(request.emailId, accountId).catch(() => null),
+ client.getMailboxes(accountId).catch(() => []),
+ ]);
+ if (request.action === 'archive' && !email) {
+ answer('failed');
+ return;
+ }
+ // Since the notification it may have gone to Trash or Junk: not for a toast
+ // to bring back or to mark. The app opens it.
+ const inBin = Object.keys(email?.mailboxIds ?? {}).some((id) => {
+ const mailbox = mailboxes.find((m) => m.id === id || m.originalId === id);
+ return mailbox?.role === 'trash' || mailbox?.role === 'junk';
+ });
+ if (inBin) {
+ answer('failed');
+ return;
+ }
+ const done = request.action === 'mark-read'
+ ? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId })
+ : await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true });
+ answer(done ? 'done' : 'failed');
+ } catch {
+ answer('failed');
+ }
+}
diff --git a/lib/pwa-notify/plugin-veto.ts b/lib/pwa-notify/plugin-veto.ts
new file mode 100644
index 000000000..48f1f2b52
--- /dev/null
+++ b/lib/pwa-notify/plugin-veto.ts
@@ -0,0 +1,16 @@
+import { hookSpec } from '@/lib/plugin-hook-catalog';
+import { emailHooks } from '@/lib/plugin-hooks';
+
+/** The hooks that can stop what a notification's buttons do. */
+const VETO_HOOKS = ['onBeforeEmailMove', 'onBeforeEmailArchive', 'onBeforeEmailReadStateChange'] as const;
+
+/** Whether one of these plugins could register one of those hooks, today or after a page load. */
+export function pluginsMayVeto(plugins: ReadonlyArray<{ enabled?: boolean; permissions?: readonly unknown[] }>): boolean {
+ const needed = new Set(VETO_HOOKS.map((name) => hookSpec(name)?.permission));
+ return plugins.some((plugin) => plugin.enabled && plugin.permissions?.some((permission) => needed.has(permission as never)));
+}
+
+/** Whether a plugin in this tab listens on one of those hooks now. */
+export function vetoHooksRegistered(): boolean {
+ return VETO_HOOKS.some((name) => emailHooks[name].size > 0);
+}
diff --git a/lib/pwa-notify/tab-reply.ts b/lib/pwa-notify/tab-reply.ts
new file mode 100644
index 000000000..ad8ff34eb
--- /dev/null
+++ b/lib/pwa-notify/tab-reply.ts
@@ -0,0 +1,27 @@
+import { create } from 'zustand';
+
+/** A tab that hasn't got the message by then has been left behind. */
+const MAX_AGE_MS = 2 * 60 * 1000;
+
+interface TabReplyState {
+ pending: { emailId: string; text: string; at: number } | null;
+}
+
+/**
+ * A notification's Reply in the Pro shell, where a message lives in a tab of
+ * its own that fetches it. Waits here, not in the tab store: that one is kept
+ * in localStorage, and a reply being typed is not for keeping.
+ */
+export const useNotificationTabReply = create(() => ({ pending: null }));
+
+export function requestTabReply(emailId: string, text: string): void {
+ useNotificationTabReply.setState({ pending: { emailId, text, at: Date.now() } });
+}
+
+/** The text for a reply to this message, once; null when none waits. */
+export function takeTabReply(emailId: string): string | null {
+ const { pending } = useNotificationTabReply.getState();
+ if (!pending || pending.emailId !== emailId) return null;
+ useNotificationTabReply.setState({ pending: null });
+ return Date.now() - pending.at < MAX_AGE_MS ? pending.text : null;
+}
diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts
new file mode 100644
index 000000000..161f3885c
--- /dev/null
+++ b/lib/pwa-notify/worker-state.ts
@@ -0,0 +1,60 @@
+import { PUSH_STATE_CACHE } from '@/lib/push-preferences';
+
+/**
+ * What the service worker needs for a notification's buttons. It has no
+ * settings store and no translations, so the page mirrors these into the
+ * worker's Cache store, as it does the sound setting (lib/push-preferences.ts).
+ * Signing out deletes the store with the rest of the push state.
+ */
+export interface NotificationActionPrefs {
+ archiveEnabled: boolean;
+ archiveMode: 'single' | 'year' | 'month';
+ /** An enabled plugin may stop or watch these actions: the worker leaves the route alone. */
+ pluginsMayVeto: boolean;
+ labels: { archive: string; markRead: string; reply: string; replyPlaceholder: string };
+}
+
+const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, '');
+
+/** A reply typed on a notification waits this long for the page it opened. */
+const PENDING_REPLY_MAX_AGE_MS = 5 * 60 * 1000;
+
+/** The cache key sw.js reads the prefs from. */
+export function notificationActionPrefsKey(origin: string): string {
+ return `${origin}${BASE_PATH}/__push-state/actions`;
+}
+
+/** The cache key sw.js keeps a reply typed on a notification under. */
+export function pendingReplyKey(origin: string): string {
+ return `${origin}${BASE_PATH}/__push-state/pending-reply`;
+}
+
+export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise {
+ try {
+ if (typeof caches === 'undefined') return;
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ await cache.put(
+ notificationActionPrefsKey(window.location.origin),
+ new Response(JSON.stringify(prefs), { headers: { 'Content-Type': 'application/json' } }),
+ );
+ } catch {
+ // Without the store the worker offers no Archive, in English.
+ }
+}
+
+/** The text typed on this message's notification, once. */
+export async function consumePendingReplyText(emailId: string): Promise {
+ try {
+ if (typeof caches === 'undefined') return '';
+ const cache = await caches.open(PUSH_STATE_CACHE);
+ const key = pendingReplyKey(window.location.origin);
+ const res = await cache.match(key);
+ if (!res) return '';
+ const pending = (await res.json()) as { emailId?: unknown; text?: unknown; at?: unknown };
+ await cache.delete(key);
+ const fresh = typeof pending.at === 'number' && Date.now() - pending.at < PENDING_REPLY_MAX_AGE_MS;
+ return fresh && pending.emailId === emailId && typeof pending.text === 'string' ? pending.text : '';
+ } catch {
+ return '';
+ }
+}
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/recurrence-scope-edit.ts b/lib/recurrence-scope-edit.ts
index 4de698447..7c45c9db5 100644
--- a/lib/recurrence-scope-edit.ts
+++ b/lib/recurrence-scope-edit.ts
@@ -645,6 +645,19 @@ function raisesSequences(series: Partial, master: CalendarEvent,
*/
export type OccurrenceOutcome = boolean | 'stopped';
+/**
+ * "This and all following" splits a series: ends the old one, creates the rest as
+ * a new one. Queued on a device these are two writes the server takes or refuses
+ * apart, with no one there to put the series back when only one goes through, so
+ * it waits for the server.
+ */
+export class SeriesSplitNeedsServerError extends Error {
+ constructor() {
+ super('Splitting a series needs the server');
+ this.name = 'SeriesSplitNeedsServerError';
+ }
+}
+
/** The store actions resolve to false when a plugin stopped the write. */
function stopped(result: boolean | void): boolean {
return result === false;
@@ -1035,6 +1048,11 @@ export async function saveOccurrenceEdit(
// From the first occurrence on is the whole series; ended before its
// first occurrence, the series would be left with none.
if (isFirstOccurrence(event, master)) return saveOccurrenceEdit('all', edit, actions);
+ if (client.answeredFromDevice) {
+ // Whether the server is there right now: the last answer may be minutes old.
+ await client.getCalendarEvent(master.originalId ?? master.id, master.accountId);
+ if (client.answeredFromDevice()) throw new SeriesSplitNeedsServerError();
+ }
const truncated = await truncateSeries(master, event, actions, { sendScheduling, userEmails: edit.userEmails });
if (!truncated) return 'stopped';
const { restore, announced, removed } = truncated;
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..8cd3d10a1
--- /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([]);
+ }, 30_000);
+ }
+});
diff --git a/lib/store/__tests__/calendar-sync.test.ts b/lib/store/__tests__/calendar-sync.test.ts
new file mode 100644
index 000000000..2b3e90c65
--- /dev/null
+++ b/lib/store/__tests__/calendar-sync.test.ts
@@ -0,0 +1,948 @@
+// @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), session = CALENDAR_SESSION) {
+ const db = openMemoryStore();
+ const events: StoreEvent[] = [];
+ const engine = new AccountEngine({
+ account: 'a@fake.test',
+ db,
+ 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('1.0', undefined, {
+ ...CALENDAR_SESSION,
+ accounts: { ...CALENDAR_SESSION.accounts, other: { name: 'other', isPersonal: false, accountCapabilities: { [CAL]: {} } } },
+ });
+ s.server.addEvent({ id: 'same', title: 'Mine' });
+ await s.engine.requestSync();
+ await call(s, 'CalendarEvent/set', { accountId: 'other', update: { same: { title: 'Theirs' } } });
+ 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('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');
+ // The next sync reads it back, so there's nothing left to send.
+ expect(await flush(s)).toMatchObject({ 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({ pending: 0 });
+ expect(s.server.events.size).toBe(1);
+ expect(setCalls(s.server).filter(([, args]) => args.create)).toHaveLength(1);
+ expect(uid).toBe([...s.server.events.values()][0].uid);
+ 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: `${JMAP_ACCOUNT}:series@example.org: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__/calendar-writes-review.test.ts b/lib/store/__tests__/calendar-writes-review.test.ts
new file mode 100644
index 000000000..9ab9cfc37
--- /dev/null
+++ b/lib/store/__tests__/calendar-writes-review.test.ts
@@ -0,0 +1,520 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it } from 'vitest';
+import type { CalendarEvent } from '@/lib/jmap/types';
+import { DEFAULT_SCOPE } from '../area';
+import { calendarArea } from '../calendar';
+import { listUpcomingAlerts } from '../calendar/reminders';
+import type { CalendarConflict } from '../calendar/writes';
+import { AccountEngine } from '../engine/account-engine';
+import { TransportError, type JmapResponse, type MethodCall, type StoreSession } from '../engine/transport';
+import { wire, type Wire } from './helpers/client-wire';
+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' };
+const K1 = '2026-11-17T10:00:00';
+const K2 = '2026-11-24T10:00:00';
+
+function setup(version: StalwartVersion = '1.0', session: StoreSession = CALENDAR_SESSION) {
+ const server = new FakeCalendarJmap(version);
+ const db = openMemoryStore();
+ const engine = new AccountEngine({
+ account: 'a@fake.test', db, session, transport: server, areas: [calendarArea], scope: DEFAULT_SCOPE, now: () => NOW, sleep: async () => {},
+ });
+ return { server, db, engine };
+}
+type Setup = ReturnType;
+
+async function write(s: Setup, args: Record, jmap = JMAP_ACCOUNT) {
+ const response = await s.engine.answer([['CalendarEvent/set', { accountId: jmap, ...args }, 'x']]);
+ return response!.methodResponses[0][1];
+}
+
+async function read(s: Setup, id: string, properties?: string[]) {
+ const response = await s.engine.answer([['CalendarEvent/get', { accountId: JMAP_ACCOUNT, ids: [id], ...(properties ? { properties } : {}) }, 'x']]);
+ return response!.methodResponses[0][1].list[0] as Record | undefined;
+}
+
+async function query(s: Setup, args: Record = {}) {
+ const response = await s.engine.answer([['CalendarEvent/query', { accountId: JMAP_ACCOUNT, ...args }, 'x']]);
+ return response!.methodResponses[0][1].ids as string[];
+}
+
+const flush = (s: Pick) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>;
+const conflicts = (s: Pick) => s.engine.areaCall('calendar.conflicts', undefined) as Promise;
+const sets = (server: FakeCalendarJmap) => server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object)));
+const pending = (s: Pick) => s.db.all<{ kind: string; patch: string; record_id: string; send_scheduling: number | null }>('SELECT kind, patch, record_id, send_scheduling FROM calendar_pending ORDER BY seq');
+const NEW = { title: 'Fresh', calendarIds: { cal1: true }, start: '2026-11-20T09:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin' };
+
+async function synced(version: StalwartVersion = '1.0', add?: (server: FakeCalendarJmap) => void) {
+ const s = setup(version);
+ s.server.addEvent({ id: 'e1', title: 'Meeting', description: 'Old' });
+ add?.(s.server);
+ await s.engine.requestSync();
+ s.server.calls.length = 0;
+ return s;
+}
+
+function failSets(s: Setup, decide: (args: Record) => JmapResponse | Error | null) {
+ const real = s.server.request.bind(s.server);
+ s.server.request = async (calls: MethodCall[]) => {
+ for (const [name, args] of calls) {
+ if (name !== 'CalendarEvent/set' || (args.update && 'h333333' in (args.update as object))) continue;
+ const outcome = decide(args);
+ if (outcome instanceof Error) throw outcome;
+ if (outcome) return outcome;
+ }
+ return real(calls);
+ };
+}
+
+describe('an edit after a create whose answer got lost', () => {
+ it('reaches the server as an update, without a second create', async () => {
+ const s = await synced();
+ const id = (await write(s, { create: { c: NEW } })).created.c.id as string;
+ s.server.loseNextSetResponse = true;
+ await flush(s);
+ await write(s, { update: { [id]: { title: 'Edited after the attempt' } } });
+ expect(await flush(s)).toMatchObject({ pending: 0 });
+ expect(s.server.events.size).toBe(2);
+ expect(sets(s.server).filter(([, args]) => args.create)).toHaveLength(1);
+ const onServer = [...s.server.events.values()].find((event) => event.uid !== 'e1@example.org');
+ expect(onServer?.title).toBe('Edited after the attempt');
+ expect(await read(s, onServer!.id)).toMatchObject({ title: 'Edited after the attempt' });
+ });
+
+ it('also removes what the edit removed', async () => {
+ const s = await synced();
+ const id = (await write(s, { create: { c: { ...NEW, description: 'To go' } } })).created.c.id as string;
+ s.server.loseNextSetResponse = true;
+ await flush(s);
+ await write(s, { update: { [id]: { description: null } } });
+ await flush(s);
+ const onServer = [...s.server.events.values()].find((event) => event.title === 'Fresh');
+ expect(onServer?.description ?? null).toBeNull();
+ });
+
+ it('applies an import whose uid the server already has as an update', async () => {
+ const s = await synced();
+ await write(s, { create: { c: { ...NEW, uid: 'e1@example.org', title: 'Imported', description: 'Old' } } });
+ await flush(s);
+ expect(sets(s.server).filter(([, args]) => args.create)).toEqual([]);
+ expect(s.server.events.size).toBe(1);
+ expect(s.server.events.get('e1')).toMatchObject({ title: 'Imported', description: 'Old' });
+ expect(await query(s)).toEqual(['e1']);
+ });
+
+ it('sends no update when the server already holds the same event', async () => {
+ const s = await synced();
+ const held = s.server.events.get('e1')!;
+ await write(s, { create: { c: { uid: 'e1@example.org', title: held.title, calendarIds: held.calendarIds, start: held.start, duration: held.duration, timeZone: held.timeZone, '@type': 'Event' } } });
+ await flush(s);
+ expect(sets(s.server)).toEqual([]);
+ expect(pending(s)).toEqual([]);
+ });
+});
+
+describe('writes with different scheduling flags', () => {
+ it('stay separate and go out in order', async () => {
+ const s = await synced();
+ await write(s, { update: { e1: { title: 'Quiet edit' } }, sendSchedulingMessages: false });
+ await write(s, { update: { e1: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true });
+ expect(pending(s).map((row) => row.send_scheduling)).toEqual([0, 1]);
+ expect(await read(s, 'e1')).toMatchObject({ title: 'Quiet edit', participants: { me: { participationStatus: 'accepted' } } });
+
+ await flush(s);
+ const sent = sets(s.server);
+ expect(sent.map(([, args]) => args.sendSchedulingMessages)).toEqual([false, true]);
+ expect((sent[0][1].update as Record).e1).toEqual({ title: 'Quiet edit' });
+ expect((sent[1][1].update as Record).e1).toEqual({ 'participants/me/participationStatus': 'accepted' });
+ });
+
+ it('still merge when the flags are the same', async () => {
+ const s = await synced();
+ await write(s, { update: { e1: { title: 'One' } }, sendSchedulingMessages: true });
+ await write(s, { update: { e1: { description: 'Two' } }, sendSchedulingMessages: true });
+ expect(pending(s)).toHaveLength(1);
+ });
+
+ it('create and a later edit that notifies go out as two writes, the second under the real id', async () => {
+ const s = await synced();
+ const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: false })).created.c.id as string;
+ await write(s, { update: { [id]: { title: 'Told everyone' } }, sendSchedulingMessages: true });
+ expect(pending(s).map((row) => row.kind)).toEqual(['create', 'update']);
+ await flush(s);
+ const sent = sets(s.server);
+ expect(sent).toHaveLength(2);
+ expect(sent[0][1].sendSchedulingMessages).toBe(false);
+ const real = Object.keys(sent[1][1].update as object)[0];
+ expect(s.server.events.get(real)).toMatchObject({ title: 'Told everyone' });
+ expect(sent[1][1].sendSchedulingMessages).toBe(true);
+ expect(await flush(s)).toMatchObject({ pending: 0 });
+ });
+
+ it('a create deleted before it left takes its later rows with it', async () => {
+ const s = await synced();
+ const id = (await write(s, { create: { c: NEW }, sendSchedulingMessages: true })).created.c.id as string;
+ await write(s, { update: { [id]: { title: 'Quiet' } }, sendSchedulingMessages: false });
+ await write(s, { destroy: [id] });
+ expect(pending(s)).toEqual([]);
+ await flush(s);
+ expect(sets(s.server)).toEqual([]);
+ });
+
+ it('a conflict on the first takes the ones built on it along', async () => {
+ const s = await synced();
+ await write(s, { update: { e1: { title: 'Mine' } }, sendSchedulingMessages: false });
+ await write(s, { update: { e1: { description: 'Mine too' } }, sendSchedulingMessages: true });
+ s.server.patchEvent('e1', { title: 'Theirs' });
+ await flush(s);
+ expect(sets(s.server)).toEqual([]);
+ expect((await conflicts(s)).map((conflict) => conflict.local)).toEqual([{ title: 'Mine' }, { description: 'Mine too' }]);
+ expect(await read(s, 'e1')).toMatchObject({ title: 'Theirs', description: 'Old' });
+ });
+});
+
+describe('a whole recurrenceOverrides map from stale state', () => {
+ const series = (server: FakeCalendarJmap) => server.addEvent({ id: 's', title: 'Weekly', start: '2026-11-03T10:00:00', recurrenceRule: WEEKLY });
+
+ it('does not erase an override someone else added', async () => {
+ const s = await synced('1.0', series);
+ await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } });
+ s.server.patchEvent('s', { recurrenceOverrides: { [K2]: { title: 'Theirs' } } });
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' }, [K2]: { title: 'Theirs' } });
+ });
+
+ it('goes out as a whole map only when the server has none', async () => {
+ const s = await synced('1.0', series);
+ await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } });
+ await flush(s);
+ expect((sets(s.server)[0][1].update as Record).s).toEqual({ recurrenceOverrides: { [K1]: { title: 'Mine' } } });
+ expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K1]: { title: 'Mine' } });
+ });
+
+ it('removes only the entries the sender knew and left out', async () => {
+ const s = await synced('1.0', (server) => {
+ series(server);
+ server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Dropped' } } });
+ });
+ await write(s, { update: { s: { recurrenceOverrides: { [K2]: { title: 'Kept' } } } } });
+ s.server.patchEvent('s', { [`recurrenceOverrides/2026-12-01T10:00:00`]: { title: 'Theirs' } });
+ await flush(s);
+ expect(s.server.events.get('s')?.recurrenceOverrides).toEqual({ [K2]: { title: 'Kept' }, '2026-12-01T10:00:00': { title: 'Theirs' } });
+ });
+
+ it('collides on the same entry', async () => {
+ const s = await synced('1.0', series);
+ await write(s, { update: { s: { recurrenceOverrides: { [K1]: { title: 'Mine' } } } } });
+ s.server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'Theirs' } } });
+ await flush(s);
+ expect((await conflicts(s))[0]).toMatchObject({ reason: 'changedOnServer' });
+ });
+});
+
+describe('one bad write', () => {
+ it('a 4xx refuses that write and the next one still goes', async () => {
+ const s = await synced('1.0', (server) => server.addEvent({ id: 'e2', title: 'Other' }));
+ await write(s, { update: { e1: { title: 'Bad' } } });
+ await write(s, { update: { e2: { title: 'Good' } } });
+ failSets(s, (args) => (args.update && 'e1' in (args.update as object) ? new TransportError('http', 'Bad request', 400) : null));
+ expect(await flush(s)).toMatchObject({ sent: 1, conflicts: 1, pending: 0 });
+ expect((await conflicts(s))[0]).toMatchObject({ recordId: 'e1', reason: 'refused' });
+ expect(s.server.events.get('e2')?.title).toBe('Good');
+ expect(await read(s, 'e1')).toMatchObject({ title: 'Meeting' });
+ });
+
+ it('401, 429 and a 5xx stay pending', async () => {
+ for (const error of [new TransportError('auth', 'x', 401), new TransportError('rate', 'x', 429), new TransportError('http', 'x', 500)]) {
+ const s = await synced();
+ await write(s, { update: { e1: { title: 'Later' } } });
+ failSets(s, () => error);
+ expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 1 });
+ expect(await conflicts(s)).toEqual([]);
+ }
+ });
+
+ it('an answer without a verdict, or a passing refusal, retries instead of deleting or finishing', async () => {
+ const s = await synced();
+ const id = (await write(s, { create: { c: NEW } })).created.c.id as string;
+ await write(s, { update: { e1: { title: 'Later' } } });
+ let round = 0;
+ failSets(s, (args) => {
+ if (round > 3) return null;
+ if (args.create) {
+ const notCreated = round === 1 ? { c: { type: 'serverFail' } } : round === 2 ? { c: { type: 'rateLimit' } } : undefined;
+ return { methodResponses: [['CalendarEvent/set', notCreated ? { notCreated } : {}, 's']] };
+ }
+ return { methodResponses: [['CalendarEvent/set', {}, 's']] };
+ });
+ for (round = 1; round <= 3; round += 1) {
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(pending(s)).toHaveLength(2);
+ expect(await read(s, id)).toMatchObject({ title: 'Fresh' });
+ }
+ expect(await flush(s)).toMatchObject({ pending: 0 });
+ expect(s.server.events.get('e1')?.title).toBe('Later');
+ expect([...s.server.events.values()].filter((event) => event.title === 'Fresh')).toHaveLength(1);
+ });
+});
+
+describe('a lost answer on an edit', () => {
+ const series = (server: FakeCalendarJmap) => server.addEvent({
+ id: 's',
+ title: 'Weekly',
+ description: 'Series',
+ start: '2026-11-03T10:00:00',
+ recurrenceRule: WEEKLY,
+ participants: {
+ me: { '@type': 'Participant', calendarAddress: 'mailto:a@example.org', participationStatus: 'needs-action', roles: { attendee: true } },
+ boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', participationStatus: 'accepted', roles: { owner: true } },
+ },
+ });
+
+ for (const version of ['1.0', '0.16'] as const) {
+ it(`an occurrence answer that read back changed on ${version} is not a conflict, and isn't sent twice`, async () => {
+ const s = await synced(version, series);
+ const master = s.server.events.get('s') as unknown as { participants: Record> } & Record;
+ const entry = {
+ title: 'Weekly',
+ description: 'Series',
+ start: K1,
+ duration: 'PT1H',
+ sequence: 0,
+ participants: { ...master.participants, me: { ...master.participants.me, participationStatus: 'accepted' } },
+ };
+ await write(s, { update: { s: { recurrenceOverrides: { [K1]: entry } } }, sendSchedulingMessages: true });
+ s.server.loseNextSetResponse = true;
+ expect(await flush(s)).toMatchObject({ pending: 1 });
+ expect(await flush(s)).toMatchObject({ conflicts: 0, pending: 0 });
+ expect(await conflicts(s)).toEqual([]);
+ expect(sets(s.server)).toHaveLength(1);
+ });
+
+ it(`a change to another override on ${version} is not a conflict on this one`, async () => {
+ const s = await synced(version, (server) => {
+ series(server);
+ server.patchEvent('s', { recurrenceOverrides: { [K1]: { title: 'One' }, [K2]: { title: 'Two' } } });
+ });
+ await write(s, { update: { s: { [`recurrenceOverrides/${K1}`]: { title: 'Mine' } } } });
+ s.server.patchEvent('s', { [`recurrenceOverrides/${K2}`]: { title: 'Theirs' } });
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect((s.server.events.get('s')?.recurrenceOverrides as Record)[K1].title).toBe('Mine');
+ });
+ }
+
+ it('an edit made after the lost one reads the lost value as ours, and goes out', async () => {
+ const s = await synced();
+ await write(s, { update: { e1: { title: 'First' } } });
+ s.server.loseNextSetResponse = true;
+ await flush(s);
+ await write(s, { update: { e1: { title: 'Second' } } });
+ await flush(s);
+ expect(await conflicts(s)).toEqual([]);
+ expect(s.server.events.get('e1')?.title).toBe('Second');
+ });
+
+ it('drops an RSVP whose value the server already holds, without sending', async () => {
+ const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'accepted' } } }));
+ await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true });
+ await flush(s);
+ expect(sets(s.server)).toEqual([]);
+ expect(pending(s)).toEqual([]);
+ expect(await conflicts(s)).toEqual([]);
+ });
+
+ it('drops it during sync too when someone answered the same from elsewhere', async () => {
+ const s = await synced('1.0', (server) => server.addEvent({ id: 'r', participants: { me: { participationStatus: 'needs-action' } } }));
+ await write(s, { update: { r: { 'participants/me/participationStatus': 'accepted' } }, sendSchedulingMessages: true });
+ s.server.patchEvent('r', { 'participants/me/participationStatus': 'accepted' });
+ await flush(s);
+ expect(sets(s.server)).toEqual([]);
+ });
+});
+
+describe('sync finding a created event', () => {
+ it('adopts the stand-in by uid during the delta, without a flush', async () => {
+ const s = await synced();
+ const id = (await write(s, { create: { c: NEW } })).created.c.id as string;
+ s.server.loseNextSetResponse = true;
+ await flush(s);
+ expect(await query(s)).toHaveLength(2);
+ // Only the delta gets through: no lookup by uid, no writes.
+ const real = s.server.request.bind(s.server);
+ s.server.request = async (calls: MethodCall[]) => {
+ if (calls.some(([name, args]) => (name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object)))
+ || (name === 'CalendarEvent/query' && (args.filter as { uid?: string } | undefined)?.uid))) {
+ throw new TransportError('network', 'Failed to fetch');
+ }
+ return real(calls);
+ };
+ await s.engine.requestSync();
+ const ids = await query(s);
+ expect(ids).toHaveLength(2);
+ expect(ids.filter((value) => value.startsWith('local-'))).toEqual([]);
+ expect(pending(s)).toEqual([]);
+ expect((await read(s, id))?.id).toBe(ids.find((value) => value !== 'e1'));
+ });
+});
+
+describe('a full list', () => {
+ it('keeps a live event the paging skipped, and drops only what is really gone', async () => {
+ const s = setup();
+ for (let i = 0; i < 620; i += 1) s.server.addEvent({ id: `e${String(i).padStart(3, '0')}` });
+ await s.engine.requestSync();
+ // The stored state can't be used, so this pass lists everything again.
+ s.db.run("UPDATE sync_state SET state = 'not usable!' WHERE type = 'CalendarEvent'");
+ let pages = 0;
+ const real = s.server.request.bind(s.server);
+ s.server.request = async (calls: MethodCall[]) => {
+ const answer = await real(calls);
+ if (calls.some(([name]) => name === 'CalendarEvent/query') && (pages += 1) === 1) {
+ // A delete before the page boundary moves one event across it.
+ s.server.removeEvent('e000');
+ }
+ return answer;
+ };
+ await s.engine.requestSync();
+ const ids = new Set(s.db.all<{ id: string }>('SELECT id FROM calendar_event').map((row) => row.id));
+ expect(ids.has('e000')).toBe(false);
+ expect(ids.has('e500')).toBe(true);
+ expect(ids.size).toBe(619);
+ });
+});
+
+describe('flush around other accounts', () => {
+ const session: StoreSession = {
+ ...CALENDAR_SESSION,
+ accounts: {
+ ...CALENDAR_SESSION.accounts,
+ shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } },
+ },
+ };
+
+ it('does not touch the network for a never-synced account once stopped', async () => {
+ const s = setup();
+ await write(s, { create: { c: NEW } });
+ s.engine.setOnline(false);
+ const before = s.server.requests;
+ await flush(s);
+ expect(s.server.requests).toBe(before);
+ expect(pending(s)).toHaveLength(1);
+ });
+
+ it('goes on with the next account after one answers accountNotFound', async () => {
+ const s = setup('1.0', session);
+ s.server.addEvent({ id: 'e1', title: 'Meeting' });
+ await s.engine.requestSync();
+ // The primary account comes first and fails, the shared one must still go.
+ await write(s, { update: { e1: { title: 'Primary' } } });
+ await write(s, { update: { e1: { title: 'Shared' } } }, 'shared');
+ const real = s.server.request.bind(s.server);
+ s.server.request = async (calls: MethodCall[]) => {
+ if (calls[0][1].accountId === JMAP_ACCOUNT && calls[0][0] === 'CalendarEvent/changes') {
+ return { methodResponses: [['error', { type: 'accountNotFound' }, calls[0][2]]] };
+ }
+ return real(calls);
+ };
+ await flush(s);
+ expect(s.server.events.get('e1')?.title).toBe('Shared');
+ expect(pending(s).map((row) => JSON.parse(row.patch))).toEqual([{ title: 'Primary' }]);
+ });
+
+ it('forgets an account the session no longer lists, and its reminders', async () => {
+ const s = setup('1.0', session);
+ s.server.addEvent({
+ id: 'e1',
+ start: '2026-11-10T10:00:00',
+ alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } },
+ });
+ await s.engine.requestSync();
+ const options = { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' };
+ expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap).sort()).toEqual([JMAP_ACCOUNT, 'shared'].sort());
+ s.engine.setSession(CALENDAR_SESSION);
+ await s.engine.requestSync();
+ expect(s.db.all("SELECT 1 FROM calendar_event WHERE jmap = 'shared'")).toEqual([]);
+ expect(s.db.all("SELECT 1 FROM object WHERE jmap = 'shared'")).toEqual([]);
+ expect(listUpcomingAlerts(s.db, options).map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]);
+ });
+});
+
+describe('reminder keys', () => {
+ it('stay the same when a stand-in becomes real', async () => {
+ const s = await synced();
+ await write(s, { create: { c: { ...NEW, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } } } });
+ const options = { from: Date.UTC(2026, 10, 20), to: Date.UTC(2026, 10, 21), timeZone: 'Europe/Berlin' };
+ const [before] = listUpcomingAlerts(s.db, options);
+ await flush(s);
+ const [after] = listUpcomingAlerts(s.db, options);
+ expect(before.baseEventId).toMatch(/^local-/);
+ expect(after.baseEventId).not.toMatch(/^local-/);
+ expect(after.key).toBe(before.key);
+ });
+
+ it('differ between two accounts holding the same id', async () => {
+ const s = setup('1.0', {
+ ...CALENDAR_SESSION,
+ accounts: { ...CALENDAR_SESSION.accounts, shared: { name: 'shared', isPersonal: false, accountCapabilities: { [CAL]: {} } } },
+ });
+ s.server.addEvent({ id: 'e1', start: '2026-11-10T10:00:00', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } });
+ await s.engine.requestSync();
+ expect(listUpcomingAlerts(s.db, { from: Date.UTC(2026, 10, 10), to: Date.UTC(2026, 10, 11), timeZone: 'Europe/Berlin' })).toHaveLength(2);
+ });
+});
+
+describe('creates through the real client', () => {
+ let w: Wire;
+ afterEach(() => w?.restore());
+
+ async function online() {
+ w = wire(new FakeCalendarJmap('1.0'), [calendarArea], NOW);
+ await w.engine.requestSync();
+ return w.newClient();
+ }
+ const created = () => w.sent.flatMap(([name, args]) => (name === 'CalendarEvent/set' ? Object.values((args.create ?? {}) as Record) : []));
+
+ it('createCalendarEvent has a uid on its first send, and a lost answer does not duplicate', async () => {
+ const client = await online();
+ w.server.loseNextSetResponse = true;
+ const event = await client.createCalendarEvent({ ...NEW } as Partial);
+ expect(created()[0].uid).toBeTruthy();
+ expect(event.id).toMatch(/^local-/);
+ expect(w.server.events.size).toBe(1);
+ await flush(w);
+ expect(w.server.events.size).toBe(1);
+ expect(await conflicts(w)).toEqual([]);
+ expect(pending(w)).toEqual([]);
+ });
+
+ it('batchCreateCalendarEvents does the same, which covers import', async () => {
+ const client = await online();
+ w.server.loseNextSetResponse = true;
+ const result = await client.batchCreateCalendarEvents([{ ...NEW } as Partial, { ...NEW, title: 'Two' } as Partial]);
+ expect(result.created).toHaveLength(2);
+ expect(created().every((event) => event.uid)).toBe(true);
+ expect(w.server.events.size).toBe(2);
+ await flush(w);
+ expect(w.server.events.size).toBe(2);
+ expect(await conflicts(w)).toEqual([]);
+ });
+
+ it('createCalendarTask does the same', async () => {
+ const client = await online();
+ w.server.loseNextSetResponse = true;
+ await client.createCalendarTask({ title: 'Task', calendarIds: { cal1: true }, due: '2026-12-01T10:00:00' });
+ expect(created()[0].uid).toBeTruthy();
+ await flush(w);
+ expect(w.server.events.size).toBe(1);
+ });
+
+ it('keeps a uid the caller chose', async () => {
+ const client = await online();
+ await client.createCalendarEvent({ ...NEW, uid: 'chosen@example.org' } as Partial);
+ expect(created()[0].uid).toBe('chosen@example.org');
+ });
+});
diff --git a/lib/store/__tests__/helpers/client-wire.ts b/lib/store/__tests__/helpers/client-wire.ts
new file mode 100644
index 000000000..f46fb343f
--- /dev/null
+++ b/lib/store/__tests__/helpers/client-wire.ts
@@ -0,0 +1,101 @@
+/**
+ * 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 { setOfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
+import { DEFAULT_SCOPE, type StoreArea } from '../../area';
+import { AccountEngine } from '../../engine/account-engine';
+import { TransportError, 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();
+ setOfflineHooks(null);
+ },
+ };
+ 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);
+ if (!state.online) throw new TypeError('Failed to fetch');
+ try {
+ return json(await server.request(calls));
+ } catch (error) {
+ if (error instanceof TransportError && error.kind === 'network') throw new TypeError('Failed to fetch');
+ throw error;
+ }
+ });
+ setOfflineHooks({
+ skipNetwork: () => !state.online,
+ answer: async (_client, calls: OfflineMethodCall[]) => {
+ state.sent.push(...calls);
+ return engine.answer(calls) as Promise;
+ },
+ record: () => {},
+ noteNetwork: () => {},
+ serverGone: async () => true,
+ blob: async () => null,
+ recordBlob: () => {},
+ holdUpload: async () => null,
+ resolveStandIns: (_client, ids) => engine.areaCall('calendar.resolveStandIns', { ids }) as Promise>,
+ });
+ 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..795f42b50
--- /dev/null
+++ b/lib/store/__tests__/helpers/fake-calendar.ts
@@ -0,0 +1,408 @@
+/**
+ * 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.
+ * - Overrides read back differently from what was written. 1.0 keeps only
+ * what differs from the series, a participant as a pointer key. 0.16 keeps
+ * the entry, adds `updated` to every entry on any change of the series, and
+ * drops an attendee-only `roles` from participants.
+ */
+
+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';
+
+/** One override as the server hands it back (measured on both, 2026-11-03). */
+function readBack(version: StalwartVersion, master: FakeEvent, key: string, entry: Record, tick: number): Record {
+ if (entry.excluded) return entry;
+ if (version === '1.0') {
+ const out: Record = {};
+ for (const [name, value] of Object.entries(entry)) {
+ if (name === 'updated') continue;
+ if (name === 'start' && value === key) continue;
+ if (name === 'participants') {
+ const series = (master.participants ?? {}) as Record>;
+ for (const [id, person] of Object.entries(value as Record>)) {
+ for (const [field, fieldValue] of Object.entries(person)) {
+ if (JSON.stringify(series[id]?.[field]) !== JSON.stringify(fieldValue)) out[`participants/${id}/${field}`] = fieldValue;
+ }
+ }
+ continue;
+ }
+ if (name !== 'sequence' && JSON.stringify(master[name]) === JSON.stringify(value)) continue;
+ out[name] = value;
+ }
+ return out;
+ }
+ const out: Record = { ...entry, updated: `2026-11-03T00:00:${String(tick % 60).padStart(2, '0')}Z` };
+ if (out.participants) {
+ out.participants = Object.fromEntries(Object.entries(out.participants as Record>).map(([id, person]) => {
+ const { roles, ...rest } = person;
+ const attendeeOnly = roles && Object.keys(roles).length === 1 && (roles as Record).attendee;
+ return [id, attendeeOnly ? rest : person];
+ }));
+ }
+ return out;
+}
+
+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 {
+ const next = applyPatchObject(this.events.get(id) as FakeEvent, patch);
+ if (next.recurrenceOverrides) {
+ const tick = this.eventLog.log.length;
+ next.recurrenceOverrides = Object.fromEntries(Object.entries(next.recurrenceOverrides as Record>)
+ .map(([key, entry]) => [key, readBack(this.version, next, key, entry, tick)]));
+ }
+ this.events.set(id, next);
+ 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