mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
chore: merge feat/desktop-offline into the windows branch
This commit is contained in:
@@ -12,11 +12,16 @@ scripts/
|
||||
!scripts/lite/
|
||||
!scripts/build-plugin-sandbox.mjs
|
||||
!scripts/build-pst-worker.mjs
|
||||
!scripts/build-store-worker.mjs
|
||||
!scripts/fetch-sqlite3mc.mjs
|
||||
!scripts/healthcheck.mjs
|
||||
!scripts/sourcemaps.mjs
|
||||
!scripts/standalone-guard.mjs
|
||||
# Built in the image by scripts/build-plugin-sandbox.mjs
|
||||
public/plugin-sandbox-assets/
|
||||
# Built in the image by scripts/build-store-worker.mjs
|
||||
public/store-worker.js
|
||||
public/sqlite3mc/
|
||||
TODO.md
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
@@ -4,3 +4,5 @@
|
||||
.husky/* text eol=lf
|
||||
# Demo fixtures record exact byte sizes of these files; never normalise them.
|
||||
public/demo/** -text
|
||||
# Checked against pinned hashes by scripts/fetch-sqlite3mc.mjs; never normalise them.
|
||||
vendor/sqlite3mc/** -text
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
/public/plugin-sandbox-assets/
|
||||
# scripts/build-pst-worker.mjs
|
||||
/public/pst-worker.js
|
||||
# scripts/build-store-worker.mjs
|
||||
/public/store-worker.js
|
||||
/public/sqlite3mc/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
@@ -35,6 +35,7 @@ RUN rm -rf "app/api/dev-jmap" && \
|
||||
\( -name __tests__ -o -name "*.test.ts" -o -name "*.test.tsx" \) \
|
||||
-prune -exec rm -rf {} \; && \
|
||||
node scripts/build-pst-worker.mjs && \
|
||||
node scripts/build-store-worker.mjs && \
|
||||
node scripts/build-plugin-sandbox.mjs && \
|
||||
npx next build --webpack && \
|
||||
node scripts/standalone-guard.mjs
|
||||
|
||||
@@ -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";
|
||||
@@ -114,6 +115,7 @@ export default async function LocaleLayout({
|
||||
</MainWindowOnly>
|
||||
{/* Offline mode isn't in Lite (no service worker there). */}
|
||||
{!IS_LITE && <OfflineHost />}
|
||||
{!IS_LITE && <NotificationActionsHost />}
|
||||
<DevicePolicyHost />
|
||||
<MainWindowOnly>
|
||||
<PushNotificationPrompt />
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The manifest's share_target, when no service worker took it (public/sw.js
|
||||
* normally answers first): the first visit, a browser without one, or a worker
|
||||
* from before this existed. Nothing keeps files here, so text and links go on
|
||||
* in the address's fragment, which stays out of access logs, and the page asks
|
||||
* the user and says the files are gone
|
||||
* (components/protocol/protocol-launch-handler-provider.tsx).
|
||||
*
|
||||
* Open to any origin, like a mailto: link: it writes nothing and acts as nobody.
|
||||
*/
|
||||
|
||||
const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, "");
|
||||
// A form with text and links is small. Anything bigger carries files, and
|
||||
// isn't read into memory just to throw them away.
|
||||
const MAX_READ_BYTES = 256 * 1024;
|
||||
// Encoded lengths: a proxy in front has to pass the Location header, and one
|
||||
// Japanese character takes 9 bytes in it.
|
||||
const BUDGET = { title: 400, url: 1000, text: 1800 };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function land(fragment: string): Response {
|
||||
return new Response(null, { status: 303, headers: { Location: `${BASE_PATH}/${fragment}` } });
|
||||
}
|
||||
|
||||
/** As much of `value` as takes up to `budget` bytes once encoded, whole characters only. */
|
||||
function fitEncoded(value: string, budget: number): { value: string; cut: boolean } {
|
||||
let used = 0;
|
||||
let out = "";
|
||||
for (const character of value) {
|
||||
let size: number;
|
||||
try {
|
||||
size = encodeURIComponent(character).length;
|
||||
} catch {
|
||||
continue; // A lone surrogate.
|
||||
}
|
||||
used += size;
|
||||
if (used > budget) return { value: out, cut: true };
|
||||
out += character;
|
||||
}
|
||||
return { value: out, cut: false };
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const length = Number(request.headers.get("content-length") ?? "");
|
||||
const params = new URLSearchParams({ share: "unavailable" });
|
||||
if (!Number.isFinite(length) || length <= 0 || length > MAX_READ_BYTES) {
|
||||
params.set("share_files", "1");
|
||||
return land(`#${params}`);
|
||||
}
|
||||
try {
|
||||
const form = await request.formData();
|
||||
let cut = false;
|
||||
for (const name of ["title", "text", "url"] as const) {
|
||||
const value = form.get(name);
|
||||
if (typeof value !== "string" || !value) continue;
|
||||
const fitted = fitEncoded(value, BUDGET[name]);
|
||||
params.set(`share_${name}`, fitted.value);
|
||||
cut ||= fitted.cut;
|
||||
}
|
||||
if (cut) params.set("share_cut", "1");
|
||||
if (form.getAll("files").some((file) => typeof file !== "string" && file.size > 0)) {
|
||||
params.set("share_files", "1");
|
||||
}
|
||||
} catch {
|
||||
params.set("share_files", "1");
|
||||
}
|
||||
return land(`#${params}`);
|
||||
}
|
||||
|
||||
/** Nothing to share: the app. */
|
||||
export function GET(): Response {
|
||||
return land("");
|
||||
}
|
||||
@@ -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, unknown>, 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<ActionRequest | null> {
|
||||
const bytes = await readBodyCapped(request, MAX_BODY_BYTES);
|
||||
if (!bytes) return null;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;
|
||||
} 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<string, unknown> | undefined, emailId: string): 'updated' | 'notFound' | 'refused' {
|
||||
const { updated, notUpdated } = (set ?? {}) as {
|
||||
updated?: Record<string, unknown> | null;
|
||||
notUpdated?: Record<string, { type?: string }> | 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<StalwartAuthContext[]> {
|
||||
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<SettingsPolicy | null> {
|
||||
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<string, Record<string, unknown>> } {
|
||||
const create: Record<string, Record<string, unknown>> = {};
|
||||
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<string, string>;
|
||||
accounts?: Record<string, { accountCapabilities?: Record<string, { mayCreateTopLevelMailbox?: boolean }> }>;
|
||||
};
|
||||
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<MethodResponse[] | null> => {
|
||||
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<JmapMailbox[] | null> => {
|
||||
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<string, boolean> }[] | 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<typeof emailSetOutcome> = '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<string, unknown> = { 'keywords/$seen': true };
|
||||
let create: Record<string, Record<string, unknown>> = {};
|
||||
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<string, { id?: string }> | null;
|
||||
notCreated?: Record<string, unknown> | 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');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+60
-1
@@ -1,5 +1,8 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { localeFromAcceptLanguage } from "@/i18n/locale-matcher";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import {
|
||||
matchDomainBranding,
|
||||
@@ -15,9 +18,28 @@ type WebAppProtocolHandler = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type WebAppFileHandler = {
|
||||
action: string;
|
||||
accept: Record<string, string[]>;
|
||||
};
|
||||
|
||||
type WebAppShareTarget = {
|
||||
action: string;
|
||||
method: "POST";
|
||||
enctype: "multipart/form-data";
|
||||
params: {
|
||||
title: string;
|
||||
text: string;
|
||||
url: string;
|
||||
files: Array<{ name: string; accept: string[] }>;
|
||||
};
|
||||
};
|
||||
|
||||
type ExtendedManifest = Omit<MetadataRoute.Manifest, "display_override"> & {
|
||||
display_override?: Array<"window-controls-overlay" | "standalone" | "minimal-ui" | "browser" | "fullscreen">;
|
||||
protocol_handlers?: WebAppProtocolHandler[];
|
||||
file_handlers?: WebAppFileHandler[];
|
||||
share_target?: WebAppShareTarget;
|
||||
launch_handler?: {
|
||||
client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new"
|
||||
| Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">;
|
||||
@@ -33,7 +55,8 @@ const withBase = (p: string) => `${BASE_PATH}${p}`;
|
||||
export default async function manifest(): Promise<ExtendedManifest> {
|
||||
await configManager.ensureLoaded();
|
||||
|
||||
const host = pickRequestHost(await headers());
|
||||
const requestHeaders = await headers();
|
||||
const host = pickRequestHost(requestHeaders);
|
||||
const domainOverrides = matchDomainBranding(
|
||||
host,
|
||||
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
|
||||
@@ -84,6 +107,17 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
{ src: withBase("/icon-maskable-dark-512x512.png"), sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
];
|
||||
|
||||
// Shortcut names show in the OS menu, in the language the browser asks in.
|
||||
const t = await getTranslations({
|
||||
locale: localeFromAcceptLanguage(requestHeaders.get("accept-language"), routing.locales) ?? routing.defaultLocale,
|
||||
namespace: "pwaLaunch",
|
||||
});
|
||||
const shortcutIcons = [{
|
||||
src: withBase(hasCustomIcon ? "/api/pwa-icon/192" : "/icon-192x192.png"),
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
}];
|
||||
|
||||
return {
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
@@ -122,6 +156,31 @@ export default async function manifest(): Promise<ExtendedManifest> {
|
||||
{ protocol: "mailto", url: withBase("/protocol/mailto?url=%s") },
|
||||
{ protocol: "webcal", url: withBase("/protocol/webcal?url=%s") },
|
||||
],
|
||||
shortcuts: [
|
||||
{ name: t("shortcut_new_message"), url: withBase("/?compose=1"), icons: shortcutIcons },
|
||||
{ name: t("shortcut_calendar"), url: withBase("/calendar"), icons: shortcutIcons },
|
||||
{ name: t("shortcut_contacts"), url: withBase("/contacts"), icons: shortcutIcons },
|
||||
],
|
||||
// Where a file goes is decided by its type, in lib/launch-handoff.ts. The
|
||||
// actions are the pages a new window should start on.
|
||||
file_handlers: [
|
||||
{ action: withBase("/mail"), accept: { "message/rfc822": [".eml"] } },
|
||||
{ action: withBase("/calendar"), accept: { "text/calendar": [".ics", ".ical"] } },
|
||||
{ action: withBase("/contacts"), accept: { "text/vcard": [".vcf", ".vcard"], "text/x-vcard": [".vcf", ".vcard"] } },
|
||||
],
|
||||
// Not under /api/: proxy.ts refuses a POST from outside there. The service
|
||||
// worker takes it (public/sw.js); without one, app/(main)/protocol/share.
|
||||
share_target: {
|
||||
action: withBase("/protocol/share"),
|
||||
method: "POST",
|
||||
enctype: "multipart/form-data",
|
||||
params: {
|
||||
title: "title",
|
||||
text: "text",
|
||||
url: "url",
|
||||
files: [{ name: "files", accept: ["*/*"] }],
|
||||
},
|
||||
},
|
||||
launch_handler: {
|
||||
client_mode: ["focus-existing", "navigate-new"],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { ICalImportModal } from '../ical-import-modal';
|
||||
|
||||
// A .ics opened from the OS. The server reads it, so without a connection the
|
||||
// dialog has to say so instead of calling a good file invalid.
|
||||
|
||||
vi.mock('@/stores/calendar-store', () => ({
|
||||
useCalendarStore: (selector: (state: { importEvents: () => Promise<number> }) => unknown) => selector({ importEvents: async () => 1 }),
|
||||
}));
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: (selector: (state: { timeFormat: string }) => unknown) => selector({ timeFormat: '24h' }),
|
||||
}));
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
const calendars = [{ id: 'cal-1', name: 'Home', isDefault: true }] as never[];
|
||||
const ics = () => new File(['BEGIN:VCALENDAR'], 'invite.ics', { type: 'text/calendar' });
|
||||
|
||||
function client(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
getServerUrl: () => 'https://mail.example.org',
|
||||
getCalendarsAccountId: () => 'acc',
|
||||
uploadBlob: vi.fn(async () => ({ blobId: 'blob-1' })),
|
||||
parseCalendarEvents: vi.fn(async () => [{ title: 'Standup', start: '2026-11-03T09:00:00' }]),
|
||||
...overrides,
|
||||
} as never as { uploadBlob: ReturnType<typeof vi.fn>; parseCalendarEvents: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useOfflineStore.setState({ browserOffline: false, downServers: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useOfflineStore.setState({ browserOffline: false, downServers: [] });
|
||||
});
|
||||
|
||||
describe('ICalImportModal with an initial file', () => {
|
||||
it('reads the file at once and shows the preview', async () => {
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
await screen.findByText('Standup');
|
||||
expect(c.uploadBlob).toHaveBeenCalledTimes(1);
|
||||
expect(c.parseCalendarEvents).toHaveBeenCalledWith('acc', 'blob-1');
|
||||
});
|
||||
|
||||
it('reads nothing without one', async () => {
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} />);
|
||||
await act(async () => {});
|
||||
expect(c.uploadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says the server is needed when offline, and starts by itself once back online', async () => {
|
||||
useOfflineStore.setState({ browserOffline: true });
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
expect(await screen.findByText('ics_needs_server')).toBeInTheDocument();
|
||||
expect(c.uploadBlob).not.toHaveBeenCalled();
|
||||
|
||||
useOfflineStore.setState({ browserOffline: false });
|
||||
await act(async () => { window.dispatchEvent(new Event('online')); });
|
||||
await screen.findByText('Standup');
|
||||
expect(c.uploadBlob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('says the same when the connection fails while it reads', async () => {
|
||||
const c = client({
|
||||
uploadBlob: vi.fn(async () => {
|
||||
useOfflineStore.setState({ downServers: ['https://mail.example.org'] });
|
||||
throw new TypeError('Failed to fetch');
|
||||
}),
|
||||
});
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
expect(await screen.findByText('ics_needs_server')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still calls a bad file invalid while online', async () => {
|
||||
const c = client({ parseCalendarEvents: vi.fn(async () => { throw new Error('parse'); }) });
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
expect(await screen.findByText('invalid_format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the size limit to it', async () => {
|
||||
const big = new File([new Uint8Array(10 * 1024 * 1024 + 1)], 'big.ics');
|
||||
const c = client();
|
||||
render(<ICalImportModal calendars={calendars} client={c as never} onClose={vi.fn()} initialFile={big} />);
|
||||
expect(await screen.findByText('file_too_large')).toBeInTheDocument();
|
||||
expect(c.uploadBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('picks the default calendar when the calendars load after it opened', async () => {
|
||||
const importEvents = vi.fn();
|
||||
const c = client();
|
||||
const view = render(<ICalImportModal calendars={[]} client={c as never} onClose={vi.fn()} initialFile={ics()} />);
|
||||
await screen.findByText('Standup');
|
||||
view.rerender(
|
||||
<ICalImportModal
|
||||
calendars={[{ id: 'a', name: 'Work' }, { id: 'b', name: 'Home', isDefault: true }] as never[]}
|
||||
client={c as never}
|
||||
onClose={vi.fn()}
|
||||
initialFile={ics()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('b'));
|
||||
expect(importEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,7 @@ import { useCompanyCalendars } from "./use-company-calendars";
|
||||
import { sharedCalendarColorKey, autoAssignSharedCalendarColors } from "@/lib/shared-calendar-colors";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
|
||||
import { useHandoffFiles } from "@/hooks/use-handoff-files";
|
||||
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
import { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links";
|
||||
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
@@ -155,6 +156,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");
|
||||
@@ -178,6 +180,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
|
||||
const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore();
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
|
||||
// A .ics opened from the OS (lib/launch-handoff.ts). The standard page that
|
||||
// renders while the Pro shell takes over leaves it for the Pro one. A demo
|
||||
// has no import.
|
||||
const launchedFile = useHandoffFiles("calendar", isAuthenticated && !!client && !demoMode && (isEmbedded || !proInterfaceActive));
|
||||
const { quota, isPushConnected } = useEmailStore();
|
||||
const {
|
||||
calendars, events, selectedDate, viewMode, selectedCalendarIds,
|
||||
@@ -1446,9 +1452,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.
|
||||
@@ -2574,6 +2584,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
/>
|
||||
)}
|
||||
|
||||
{launchedFile.file && client && (
|
||||
<ICalImportModal
|
||||
key={`${launchedFile.file.name}:${launchedFile.file.lastModified}`}
|
||||
calendars={displayCalendars}
|
||||
client={client}
|
||||
initialFile={launchedFile.file}
|
||||
onClose={launchedFile.done}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSubscriptionModal && client && (
|
||||
<ICalSubscriptionModal
|
||||
client={client}
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useId } from "react";
|
||||
import { useState, useCallback, useEffect, useRef, useId } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { buildRecurrenceSummary } from "./recurrence-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,12 +16,15 @@ import { toast } from "@/stores/toast-store";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { explainServerAuthError } from "@/lib/server-auth-status";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { isOfflineNow } from "@/lib/offline/state";
|
||||
|
||||
interface ICalImportModalProps {
|
||||
calendars: Calendar[];
|
||||
client: IJMAPClient;
|
||||
onClose: () => void;
|
||||
initialUrl?: string;
|
||||
/** A file opened from the OS: read at once, as if picked here. */
|
||||
initialFile?: File | null;
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -30,8 +33,9 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"];
|
||||
type ImportStep = "select" | "preview" | "importing";
|
||||
type ImportMode = "file" | "url";
|
||||
|
||||
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
|
||||
export function ICalImportModal({ calendars, client, onClose, initialUrl, initialFile }: ICalImportModalProps) {
|
||||
const t = useTranslations("calendar.import");
|
||||
const tLaunch = useTranslations("pwaLaunch");
|
||||
const tCal = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
const tForm = useTranslations("calendar.form");
|
||||
@@ -56,6 +60,15 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
const dropZoneRef = useRef<HTMLButtonElement>(null);
|
||||
const urlInputRef = useRef<HTMLInputElement>(null);
|
||||
const targetId = useId();
|
||||
// The file that couldn't be read for lack of a connection, read again when
|
||||
// it is back.
|
||||
const waitingForServer = useRef<File | null>(null);
|
||||
|
||||
// Opened before the calendars had loaded, from a launch: the default once they have.
|
||||
useEffect(() => {
|
||||
if (calendarId || calendars.length === 0) return;
|
||||
setCalendarId((calendars.find((c) => c.isDefault) ?? calendars[0]).id);
|
||||
}, [calendars, calendarId]);
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
if (file.size > MAX_FILE_SIZE) return t("file_too_large");
|
||||
@@ -71,6 +84,16 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
return;
|
||||
}
|
||||
|
||||
// The server reads the file (uploadBlob, then CalendarEvent/parse). Say so
|
||||
// rather than call a good file invalid.
|
||||
const serverUrl = client.getServerUrl();
|
||||
if (isOfflineNow(serverUrl)) {
|
||||
waitingForServer.current = file;
|
||||
setError(tLaunch("ics_needs_server"));
|
||||
return;
|
||||
}
|
||||
waitingForServer.current = null;
|
||||
|
||||
setError(null);
|
||||
setIsParsing(true);
|
||||
setStep("select");
|
||||
@@ -91,11 +114,31 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
setSelectedIndices(new Set(events.map((_, i) => i)));
|
||||
setStep("preview");
|
||||
} catch {
|
||||
setError(t("invalid_format"));
|
||||
if (isOfflineNow(serverUrl)) {
|
||||
waitingForServer.current = file;
|
||||
setError(tLaunch("ics_needs_server"));
|
||||
} else {
|
||||
setError(t("invalid_format"));
|
||||
}
|
||||
} finally {
|
||||
setIsParsing(false);
|
||||
}
|
||||
}, [client, validateFile, t]);
|
||||
}, [client, validateFile, t, tLaunch]);
|
||||
|
||||
const initialFileRead = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialFile || initialFileRead.current) return;
|
||||
initialFileRead.current = true;
|
||||
void handleFile(initialFile);
|
||||
}, [initialFile, handleFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const retry = () => {
|
||||
if (waitingForServer.current) void handleFile(waitingForServer.current);
|
||||
};
|
||||
window.addEventListener("online", retry);
|
||||
return () => window.removeEventListener("online", retry);
|
||||
}, [handleFile]);
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { ContactImportDialog } from '../contact-import-dialog';
|
||||
|
||||
// A .vcf opened from the OS: it parses in the browser, also without a connection.
|
||||
|
||||
const VCARD = ['BEGIN:VCARD', 'VERSION:3.0', 'FN:Ada Lovelace', 'EMAIL:ada@example.org', 'END:VCARD', ''].join('\r\n');
|
||||
const vcf = (body = VCARD) => {
|
||||
const file = new File([body], 'people.vcf', { type: 'text/vcard' });
|
||||
// jsdom's File has no text().
|
||||
if (typeof file.text !== 'function') Object.defineProperty(file, 'text', { value: async () => body });
|
||||
return file;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
useOfflineStore.setState({ browserOffline: false, downServers: [] });
|
||||
});
|
||||
|
||||
describe('ContactImportDialog with an initial file', () => {
|
||||
it('parses it at once and offers the cards', async () => {
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('parses it offline as well', async () => {
|
||||
useOfflineStore.setState({ browserOffline: true });
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies the size limit to it', async () => {
|
||||
const big = new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'big.vcf');
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={big} />);
|
||||
expect(await screen.findByText('import.file_too_large')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the file input without one', async () => {
|
||||
const { container } = render(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(container.ownerDocument.querySelector('input[type="file"]')).not.toBeNull();
|
||||
expect(screen.queryByText('Ada Lovelace')).toBeNull();
|
||||
});
|
||||
|
||||
it('says the server is needed when the import fails offline', async () => {
|
||||
const onImport = vi.fn(async () => {
|
||||
useOfflineStore.setState({ browserOffline: true });
|
||||
throw new TypeError('Failed to fetch');
|
||||
});
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
await screen.findByText('Ada Lovelace');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('contacts_need_server'));
|
||||
});
|
||||
|
||||
it('calls a failed import failed while online', async () => {
|
||||
const onImport = vi.fn(async () => { throw new Error('server said no'); });
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={vcf()} />);
|
||||
await screen.findByText('Ada Lovelace');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
|
||||
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('import.failed'));
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, Check } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -9,19 +9,24 @@ import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { isOfflineNow } from "@/lib/offline/state";
|
||||
|
||||
interface ContactImportDialogProps {
|
||||
existingContacts: ContactCard[];
|
||||
onImport: (contacts: ContactCard[]) => Promise<number>;
|
||||
onClose: () => void;
|
||||
/** A file opened from the OS: read at once, as if picked here. */
|
||||
initialFile?: File | null;
|
||||
}
|
||||
|
||||
export function ContactImportDialog({
|
||||
existingContacts,
|
||||
onImport,
|
||||
onClose,
|
||||
initialFile,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const tLaunch = useTranslations("pwaLaunch");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [parsed, setParsed] = useState<ContactCard[]>([]);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
@@ -30,10 +35,7 @@ export function ContactImportDialog({
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const readFile = useCallback(async (file: File) => {
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
@@ -66,6 +68,18 @@ export function ContactImportDialog({
|
||||
}
|
||||
}, [existingContacts, t]);
|
||||
|
||||
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) await readFile(file);
|
||||
}, [readFile]);
|
||||
|
||||
const initialFileRead = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialFile || initialFileRead.current) return;
|
||||
initialFileRead.current = true;
|
||||
void readFile(initialFile);
|
||||
}, [initialFile, readFile]);
|
||||
|
||||
const toggleSelect = (idx: number) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(idx)) {
|
||||
@@ -94,7 +108,7 @@ export function ContactImportDialog({
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
setError(t("import.failed"));
|
||||
setError(isOfflineNow() ? tLaunch("contacts_need_server") : t("import.failed"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/typ
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
import { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links";
|
||||
import { consumePendingDeepLinkEntry, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useHandoffFiles } from "@/hooks/use-handoff-files";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
@@ -356,6 +357,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// focused tab writes the address bar; the standard instance that renders
|
||||
// while Pro takes over a route stays silent.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
// A .vcf opened from the OS (lib/launch-handoff.ts). It waits for the
|
||||
// contacts, or everything would look new to the duplicate check. The
|
||||
// standard page that renders while the Pro shell takes over leaves it for
|
||||
// the Pro one.
|
||||
const launchedFile = useHandoffFiles(
|
||||
"contacts",
|
||||
isAuthenticated && !!client && (contactsLoaded || !supportsSync) && (isEmbedded || !proInterfaceActive),
|
||||
);
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const contactsLinkPath = appPath(buildContactsPath({
|
||||
contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
|
||||
@@ -1572,6 +1581,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
if (movingContactIds) void handleDropContacts(movingContactIds, book);
|
||||
}}
|
||||
/>
|
||||
{launchedFile.file && (
|
||||
<ContactImportDialog
|
||||
key={`${launchedFile.file.name}:${launchedFile.file.lastModified}`}
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
initialFile={launchedFile.file}
|
||||
onClose={launchedFile.done}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
|
||||
@@ -232,6 +232,124 @@ function pickFromFiles(nodes: FileNode[]) {
|
||||
fireEvent.click(screen.getByTestId('fake-file-picker'));
|
||||
}
|
||||
|
||||
describe('files handed to a new composer (a share, a launch)', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
vi.clearAllMocks();
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async () => true);
|
||||
});
|
||||
|
||||
const uploading = () => filesClient({ uploadBlob: vi.fn(() => new Promise(() => {})), getMaxSizeUpload: () => 0 });
|
||||
|
||||
const handedIn = (extra: Record<string, unknown> = {}) => ({
|
||||
to: '', cc: '', bcc: '', subject: 'Shared subject', body: 'Shared text', showCc: false, showBcc: false,
|
||||
selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null, unedited: true, ...extra,
|
||||
}) as never;
|
||||
const settled = (client: Record<string, unknown>) => ({
|
||||
...client,
|
||||
uploadBlob: vi.fn(async () => ({ blobId: 'blob-shared', size: 1, type: 'text/plain' })),
|
||||
getMaxSizeUpload: () => 0,
|
||||
});
|
||||
|
||||
it('saves no draft from handed-in text and files until the first real edit', async () => {
|
||||
const client = settled(filesClient()) as unknown as ReturnType<typeof filesClient>;
|
||||
useAuthStore.setState({ client: client as never });
|
||||
vi.useFakeTimers();
|
||||
render(<EmailComposer onClose={vi.fn()} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000); });
|
||||
expect(screen.getByText('shared.pdf')).toBeInTheDocument();
|
||||
expect(client.createDraft).not.toHaveBeenCalled();
|
||||
|
||||
// Closing the page doesn't save it either.
|
||||
await act(async () => { window.dispatchEvent(new Event('beforeunload')); await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(client.createDraft).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/subject/i), { target: { value: 'Shared subject, edited' } });
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2500); });
|
||||
expect(client.createDraft).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('closes without asking while nothing is the user\'s own', async () => {
|
||||
const onClose = vi.fn();
|
||||
useAuthStore.setState({ client: settled(filesClient()) as never });
|
||||
const ref = { current: null as ((afterClose?: () => void) => void) | null };
|
||||
render(<EmailComposer onClose={onClose} requestCloseRef={ref} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await screen.findByText('shared.pdf');
|
||||
await act(async () => { ref.current?.(); });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts a removed attachment as an edit', async () => {
|
||||
const client = settled(filesClient()) as unknown as ReturnType<typeof filesClient>;
|
||||
useAuthStore.setState({ client: client as never });
|
||||
vi.useFakeTimers();
|
||||
render(<EmailComposer onClose={vi.fn()} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
const chip = screen.getByText('shared.pdf').closest('[data-testid], div')!.parentElement!;
|
||||
const remove = chip.querySelector('button[aria-label]') ?? chip.querySelector('button');
|
||||
fireEvent.click(remove!);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(2500); });
|
||||
expect(client.createDraft).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('hands its unedited state back to a remount, without asking to save', async () => {
|
||||
useAuthStore.setState({ client: settled(filesClient()) as never });
|
||||
const onSaveState = vi.fn();
|
||||
const view = render(<EmailComposer onClose={vi.fn()} onSaveState={onSaveState} initialData={handedIn()} initialFiles={[new File(['x'], 'shared.pdf')]} />);
|
||||
await screen.findByText('shared.pdf');
|
||||
view.unmount();
|
||||
expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ unedited: true, unsaved: false, subject: 'Shared subject' }));
|
||||
});
|
||||
|
||||
it('attaches them when it opens', async () => {
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['x'], 'shared.pdf', { type: 'application/pdf' })]} />);
|
||||
expect(await screen.findByText('shared.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('waits for the client', async () => {
|
||||
const files = [new File(['x'], 'shared.pdf')];
|
||||
const view = render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
|
||||
await act(async () => {});
|
||||
expect(hoisted.beforeAttachmentUpload).not.toHaveBeenCalled();
|
||||
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
view.rerender(<EmailComposer key="with-client" onClose={vi.fn()} initialFiles={files} />);
|
||||
expect(await screen.findByText('shared.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('attaches an array once, also when the composer remounts with it', async () => {
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
const files = [new File(['x'], 'shared.pdf')];
|
||||
const first = render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
|
||||
await screen.findByText('shared.pdf');
|
||||
first.unmount();
|
||||
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={files} />);
|
||||
await act(async () => {});
|
||||
expect(hoisted.beforeAttachmentUpload).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('shared.pdf')).toBeNull();
|
||||
});
|
||||
|
||||
it('runs the same checks as a picked file: a plugin may refuse it', async () => {
|
||||
hoisted.beforeAttachmentUpload.mockImplementation(async (info) => info.name !== 'setup.exe');
|
||||
useAuthStore.setState({ client: uploading() as never });
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['x'], 'setup.exe'), new File(['x'], 'notes.pdf')]} />);
|
||||
expect(await screen.findByText('notes.pdf')).toBeInTheDocument();
|
||||
expect(screen.queryByText('setup.exe')).toBeNull();
|
||||
});
|
||||
|
||||
it('runs the same checks as a picked file: the size limit', async () => {
|
||||
useAuthStore.setState({ client: filesClient({ getMaxSizeUpload: () => 1 }) as never });
|
||||
render(<EmailComposer onClose={vi.fn()} initialFiles={[new File(['too big'], 'big.pdf')]} />);
|
||||
await act(async () => {});
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('attachment_too_large');
|
||||
expect(screen.queryByText('big.pdf')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attach from Files', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
|
||||
@@ -214,7 +214,16 @@ function createChipDragPreview(label: string): HTMLElement {
|
||||
// inserts as a single chip and expands into its members on send.
|
||||
type SuggestionItem = { name: string; email: string; group?: { id: string; memberCount: number } };
|
||||
|
||||
// Which `initialFiles` arrays are attached: a remount gets the same array.
|
||||
const attachedInitialFiles = new WeakSet<File[]>();
|
||||
|
||||
export interface ComposerDraftData {
|
||||
/**
|
||||
* Handed in from outside (a share, the desktop app) and not touched yet.
|
||||
* It is only where the composer starts: no draft on the server until the
|
||||
* user's first real edit.
|
||||
*/
|
||||
unedited?: boolean;
|
||||
to: string;
|
||||
cc: string;
|
||||
bcc: string;
|
||||
@@ -390,6 +399,12 @@ interface EmailComposerProps {
|
||||
sessionKey?: string;
|
||||
/** Id of the message being replied to or forwarded, for onComposerOpen. */
|
||||
sourceEmailId?: string;
|
||||
/**
|
||||
* Files to attach when the composer opens, from a share or a launch (see
|
||||
* lib/launch-handoff.ts). They go through the same checks as a picked file.
|
||||
* Attached once per array, also when the composer remounts with it.
|
||||
*/
|
||||
initialFiles?: File[] | null;
|
||||
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
|
||||
/**
|
||||
* Email of the mailbox/account the user is viewing when they start a new
|
||||
@@ -524,6 +539,7 @@ export function EmailComposer({
|
||||
className,
|
||||
initialDraftText: initialDraftTextProp,
|
||||
initialData: initialDataProp,
|
||||
initialFiles,
|
||||
prefill,
|
||||
sessionKey,
|
||||
sourceEmailId,
|
||||
@@ -1793,6 +1809,16 @@ export function EmailComposer({
|
||||
}
|
||||
changeVersionRef.current += 1;
|
||||
}, [toStr, ccStr, bccStr, subject, body, attachments.length]);
|
||||
// Handed-in text and files aren't the user's until they touch them. Nothing
|
||||
// counts as dirty until then, so autosave, the page-close save and the flush
|
||||
// before a sign-out all leave them alone.
|
||||
const uneditedRef = useRef(initialData?.unedited === true);
|
||||
const [firstEditSeen, setFirstEditSeen] = useState(false);
|
||||
const markEdited = useCallback(() => {
|
||||
if (!uneditedRef.current) return;
|
||||
uneditedRef.current = false;
|
||||
setFirstEditSeen(true);
|
||||
}, []);
|
||||
const isDirtyRef = useRef(false);
|
||||
isDirtyRef.current = toStr !== initialValuesRef.current.to || ccStr !== initialValuesRef.current.cc ||
|
||||
bccStr !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
|
||||
@@ -1804,6 +1830,19 @@ export function EmailComposer({
|
||||
if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
|
||||
const initialLabelChoiceRef = useRef(classification.choice);
|
||||
if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true;
|
||||
if (uneditedRef.current) isDirtyRef.current = false;
|
||||
// Changes only the user makes, also without typing: a picked recipient, a
|
||||
// removed attachment, a priority. Handed-in files move the count they're
|
||||
// compared with (addFiles). The body isn't here, a signature can change it;
|
||||
// typing in it comes through the composer root's input events.
|
||||
useEffect(() => {
|
||||
if (!uneditedRef.current) return;
|
||||
const start = initialValuesRef.current;
|
||||
if (toStr !== start.to || ccStr !== start.cc || bccStr !== start.bcc || subject !== start.subject
|
||||
|| attachments.length !== start.attachmentCount || priority !== initialPriorityRef.current) {
|
||||
markEdited();
|
||||
}
|
||||
}, [toStr, ccStr, bccStr, subject, attachments.length, priority, markEdited]);
|
||||
const firstLabelRunRef = useRef(true);
|
||||
useEffect(() => {
|
||||
if (firstLabelRunRef.current) {
|
||||
@@ -1870,14 +1909,17 @@ export function EmailComposer({
|
||||
classification: classificationRef.current.draftChoice,
|
||||
// Edits the last save didn't cover; a minimised composer with
|
||||
// none needs no save before an automatic sign-out.
|
||||
unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
|
||||
unsaved: !uneditedRef.current && (changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current),
|
||||
...(uneditedRef.current ? { unedited: true } : {}),
|
||||
});
|
||||
|
||||
// Auto-save state on unmount (when user navigates away without explicitly closing)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (explicitCloseRef.current) return;
|
||||
if (onSaveState && isDirtyRef.current) onSaveState(snapshotStateRef.current());
|
||||
// Kept in the host's memory across a remount, handed-in or not. Only
|
||||
// the server stays out of it.
|
||||
if (onSaveState && (isDirtyRef.current || uneditedRef.current)) onSaveState(snapshotStateRef.current());
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
@@ -2353,7 +2395,7 @@ export function EmailComposer({
|
||||
return () => window.removeEventListener('keydown', handleTemplateKey);
|
||||
}, [templatesEnabled]);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
const addFiles = useCallback(async (files: File[], opts?: { handedIn?: boolean }) => {
|
||||
if (!client || files.length === 0) return;
|
||||
|
||||
// Let plugins veto each upload before it's queued.
|
||||
@@ -2407,6 +2449,8 @@ export function EmailComposer({
|
||||
abortController: controller,
|
||||
};
|
||||
});
|
||||
// Files handed in are where the composer starts, not an edit.
|
||||
if (opts?.handedIn) initialValuesRef.current.attachmentCount += newAttachments.length;
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
@@ -2520,6 +2564,12 @@ export function EmailComposer({
|
||||
}
|
||||
}, [client, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !initialFiles || initialFiles.length === 0 || attachedInitialFiles.has(initialFiles)) return;
|
||||
attachedInitialFiles.add(initialFiles);
|
||||
void addFiles(initialFiles, { handedIn: true });
|
||||
}, [client, initialFiles, addFiles]);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files) return;
|
||||
await addFiles(Array.from(event.target.files));
|
||||
@@ -3026,7 +3076,7 @@ export function EmailComposer({
|
||||
// login page can say a draft wasn't saved.
|
||||
useEffect(() => registerComposeFlush(async () => {
|
||||
while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS));
|
||||
if (sentRef.current) return;
|
||||
if (sentRef.current || uneditedRef.current) return;
|
||||
const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
|
||||
if (!unsaved) return;
|
||||
if (saveTimeoutRef.current) {
|
||||
@@ -3379,7 +3429,7 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- saveDraft reads current state when called, not when effect is set up
|
||||
}, [toStr, ccStr, bccStr, subject, body, attachments, priority]);
|
||||
}, [toStr, ccStr, bccStr, subject, body, attachments, priority, firstEditSeen]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -4446,6 +4496,13 @@ export function EmailComposer({
|
||||
data-testid="email-composer"
|
||||
className={cn("flex h-full bg-background relative", className)}
|
||||
onKeyDownCapture={handleComposerKeyDown}
|
||||
// Bubble phase, in the same batch as the field's own onChange. A state
|
||||
// change in the capture phase renders first and puts the old value back.
|
||||
onInput={markEdited}
|
||||
onChange={markEdited}
|
||||
onPaste={markEdited}
|
||||
onCut={markEdited}
|
||||
onDrop={markEdited}
|
||||
>
|
||||
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only" data-testid="composer-live-region">
|
||||
{liveMessage && <span key={liveMessage.n}>{liveMessage.text}</span>}
|
||||
|
||||
@@ -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(<ProEmailTabBody tabId="reader" data={{ accountId: '', emailId: 'email', mailboxId: null, title: 'Loading' }} />);
|
||||
await waitFor(() => expect(composeTabs()).toHaveLength(before + 1));
|
||||
expect(composeTabs().at(-1)?.composeData).toMatchObject({ mode: 'reply', sourceEmailId: 'email', initialDraftText: 'Typed on the toast' });
|
||||
|
||||
view.rerender(<ProEmailTabBody tabId="reader" data={{ accountId: '', emailId: 'email', mailboxId: null, title: 'Hello' }} />);
|
||||
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);
|
||||
|
||||
+145
-22
@@ -142,6 +142,10 @@ 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 { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff";
|
||||
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";
|
||||
@@ -173,7 +177,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";
|
||||
@@ -232,6 +236,9 @@ const UNIFIED_LIST_PUSH = {
|
||||
},
|
||||
};
|
||||
|
||||
/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */
|
||||
type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean };
|
||||
|
||||
export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('mail', routeSegments);
|
||||
@@ -259,6 +266,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<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
// Fields a plugin filled in through onBeforeCompose for the composer about
|
||||
@@ -316,7 +325,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Office attachments open read-only in the configured WOPI editor (#1047).
|
||||
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
|
||||
const wopiStatus = useWopiStatus(true);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<DraftRequest | null>(null);
|
||||
// Files from a share or a launch, for the next composer. It attaches an
|
||||
// array once, so a stale one does nothing.
|
||||
const [composerInitialFiles, setComposerInitialFiles] = useState<File[] | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastUndoToastSubmissionRef = useRef<string | null>(null);
|
||||
@@ -1378,6 +1390,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
initialDraftText: composerDraftText,
|
||||
initialData: pendingDraft,
|
||||
prefill: composerPrefill,
|
||||
initialFiles: composerInitialFiles,
|
||||
sourceEmailId: selectedEmail?.id ?? null,
|
||||
sourceEmailSource: selectedEmail ? emailWriteSource(selectedEmail, client) : null,
|
||||
title,
|
||||
@@ -1388,6 +1401,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
setComposerDraftText("");
|
||||
setPendingDraft(null);
|
||||
setComposerPrefill(null);
|
||||
setComposerInitialFiles(null);
|
||||
setComposerQuoteHeader(null);
|
||||
// We only react to the rising edge of `showComposer` here; the other
|
||||
// variables read above are captured-but-stale-safe because the next
|
||||
@@ -1467,18 +1481,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
return true;
|
||||
}, [identities, t]);
|
||||
|
||||
const applyMailtoDraft = useCallback((pending: ParsedMailto, suppressOutgoingStash: boolean) => {
|
||||
const applyMailtoDraft = useCallback((pending: DraftRequest, suppressOutgoingStash: boolean) => {
|
||||
if (suppressOutgoingStash) {
|
||||
suppressComposerStateSaveSessionRef.current = composerSessionId;
|
||||
}
|
||||
setComposerSessionId((id) => id + 1);
|
||||
setPendingDraft(mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode));
|
||||
setPendingDraft({
|
||||
...mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
|
||||
...(pending.unedited ? { unedited: true } : {}),
|
||||
});
|
||||
setComposerInitialFiles(pending.initialFiles ?? null);
|
||||
setComposerMode("compose");
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView("viewer");
|
||||
}, [composerSessionId, isMobile, setActiveView]);
|
||||
|
||||
const openMailtoDraft = useCallback((pending: ParsedMailto) => withComposeGate('compose', {
|
||||
const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
}, () => {
|
||||
// A live composer owns text the user has not sent. Replacing it outright
|
||||
@@ -1497,7 +1515,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
applyMailtoDraft(pending, showComposer);
|
||||
}), [applyMailtoDraft, showComposer, withComposeGate]);
|
||||
|
||||
const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => {
|
||||
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
if (useAuthStore.getState().activeAccountId !== accountId) {
|
||||
@@ -1521,13 +1539,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
|
||||
if (!(await mailtoAllowed(pending))) return;
|
||||
// From another app of this webmail: the account in use, no question.
|
||||
if (pending.inApp) {
|
||||
openMailtoDraft(pending);
|
||||
return;
|
||||
}
|
||||
// A draft that comes from outside: which account it goes out from.
|
||||
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
@@ -1536,12 +1549,87 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
const accountId = protocolAccounts[0]?.id ?? activeAccountId;
|
||||
if (accountId) {
|
||||
void openMailtoForAccount(pending, accountId);
|
||||
await openMailtoForAccount(pending, accountId);
|
||||
return;
|
||||
}
|
||||
|
||||
openMailtoDraft(pending);
|
||||
}, [activeAccountId, getMailtoProtocolAccounts, mailtoAllowed, openMailtoDraft, openMailtoForAccount]);
|
||||
await openMailtoDraft(pending);
|
||||
}, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]);
|
||||
|
||||
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
|
||||
if (!(await mailtoAllowed(pending))) return;
|
||||
// From another app of this webmail: the account in use, no question.
|
||||
if (pending.inApp) {
|
||||
openMailtoDraft(pending);
|
||||
return;
|
||||
}
|
||||
void routeOutsideDraft(pending);
|
||||
}, [mailtoAllowed, openMailtoDraft, routeOutsideDraft]);
|
||||
|
||||
// A new draft from a shortcut, a share or the desktop app
|
||||
// (lib/launch-handoff.ts). Not a mailto:, so onMailtoIntercept stays out of
|
||||
// it; onBeforeCompose still runs. The standard page that renders while the
|
||||
// Pro shell takes over must leave it for the Pro one.
|
||||
const proTakingOver = proInterfaceActive && !isEmbedded;
|
||||
// One at a time. A composer that is open holds the user's text, so what comes
|
||||
// next stays queued until it closes, and a second one can't replace the
|
||||
// first. So does one set aside by reading a message, while the server hasn't
|
||||
// got what it holds. Under Pro a composer moves to its own tab, so nothing
|
||||
// waits long.
|
||||
const handoffOpeningRef = useRef(false);
|
||||
const handoffToldWaitingRef = useRef(new Set<string>());
|
||||
const composerInUseRef = useRef(false);
|
||||
const composerInUse = showComposer || pendingMailtoAccountChoice !== null
|
||||
|| pendingDraft?.unsaved === true || pendingDraft?.unedited === true;
|
||||
composerInUseRef.current = composerInUse;
|
||||
const [handoffSettled, setHandoffSettled] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client || proTakingOver) return;
|
||||
|
||||
const open = async (handoff: Handoff) => {
|
||||
if (!handoff.subject && !handoff.body && handoff.files.length === 0) {
|
||||
await withComposeGate('compose', {}, () => {
|
||||
startFreshComposerSession();
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setActiveView('viewer');
|
||||
});
|
||||
return;
|
||||
}
|
||||
await routeOutsideDraft({
|
||||
to: [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: handoff.subject ?? '',
|
||||
body: handoff.body ?? '',
|
||||
initialFiles: handoff.files,
|
||||
unedited: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openNext = () => {
|
||||
if (handoffOpeningRef.current) return;
|
||||
if (composerInUseRef.current) {
|
||||
const waiting = pendingHandoffs().find((handoff) => handoff.kind === 'compose');
|
||||
if (waiting && !handoffToldWaitingRef.current.has(waiting.id)) {
|
||||
handoffToldWaitingRef.current.add(waiting.id);
|
||||
toast.info(t('pwaLaunch.compose_waiting'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const handoff = takeHandoff('compose');
|
||||
if (!handoff) return;
|
||||
handoffOpeningRef.current = true;
|
||||
void open(handoff).finally(() => {
|
||||
handoffOpeningRef.current = false;
|
||||
setHandoffSettled((n) => n + 1);
|
||||
});
|
||||
};
|
||||
|
||||
openNext();
|
||||
return subscribeHandoff('compose', openNext);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, client, proTakingOver, routeOutsideDraft, withComposeGate, isMobile, composerInUse, handoffSettled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
@@ -1770,7 +1858,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;
|
||||
@@ -1844,6 +1932,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 } : {}),
|
||||
@@ -1869,6 +1959,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);
|
||||
}
|
||||
@@ -1923,8 +2019,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]);
|
||||
@@ -1975,10 +2071,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';
|
||||
|
||||
@@ -2788,6 +2887,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
|
||||
@@ -4872,6 +4994,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
initialDraftText={composerDraftText}
|
||||
initialData={pendingDraft}
|
||||
prefill={composerPrefill}
|
||||
initialFiles={composerInitialFiles}
|
||||
sessionKey={`inline-${composerSessionId}`}
|
||||
sourceEmailId={composerSourceEmail?.id}
|
||||
onSaveState={(data) => {
|
||||
|
||||
@@ -264,6 +264,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
|
||||
initialDraftText={data.initialDraftText}
|
||||
initialData={data.initialData}
|
||||
prefill={data.prefill}
|
||||
initialFiles={data.initialFiles}
|
||||
sessionKey={`pro-${tabId}-${data.sessionId}`}
|
||||
sourceEmailId={data.sourceEmailId ?? undefined}
|
||||
composeFromAccountEmail={composeFromAccountEmail}
|
||||
|
||||
@@ -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';
|
||||
@@ -283,6 +284,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({
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeCaches } from '@/lib/__tests__/sw-harness';
|
||||
|
||||
// The launchQueue consumer. It is the only one the app may have: a second
|
||||
// setConsumer replaces the first. Files used to be dropped here.
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
pathname: { current: '/' },
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })),
|
||||
peek: vi.fn(),
|
||||
adopt: vi.fn(),
|
||||
discard: vi.fn(async () => {}),
|
||||
savePendingMailto: vi.fn(),
|
||||
protocolOpenMode: 'active-session',
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }),
|
||||
usePathname: () => hoisted.pathname.current,
|
||||
}));
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: vi.fn() } }));
|
||||
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers }));
|
||||
vi.mock('@/lib/launch-handoff', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/launch-handoff')>()),
|
||||
peekStoredHandoff: hoisted.peek,
|
||||
adoptStoredHandoff: hoisted.adopt,
|
||||
discardStoredHandoff: hoisted.discard,
|
||||
}));
|
||||
vi.mock('@/stores/settings-store', () => ({
|
||||
useSettingsStore: Object.assign(() => undefined, { getState: () => ({ protocolOpenMode: hoisted.protocolOpenMode }) }),
|
||||
}));
|
||||
vi.mock('@/lib/protocol-handlers/session', () => ({
|
||||
listenForMailtoRequests: () => () => {},
|
||||
notifyPendingMailto: vi.fn(),
|
||||
notifyPendingWebcal: vi.fn(),
|
||||
requestOpenMailtoInExistingClient: vi.fn(async () => false),
|
||||
savePendingMailto: hoisted.savePendingMailto,
|
||||
savePendingWebcal: vi.fn(),
|
||||
}));
|
||||
// The real modal has its own tests; here: does the launch reach it, and is
|
||||
// its download gated by the same function.
|
||||
vi.mock('@/components/files/file-preview-modal', () => ({
|
||||
FilePreviewModal: function PreviewStub({ name, onClose, onDownload, getFileContent }: {
|
||||
name: string; onClose: () => void; onDownload: () => void;
|
||||
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
|
||||
}) {
|
||||
const [type, setType] = React.useState('');
|
||||
React.useEffect(() => { void getFileContent().then((c) => setType(c.contentType)); }, [getFileContent]);
|
||||
return (
|
||||
<div data-testid="eml-viewer" data-type={type}>
|
||||
{name}
|
||||
<button onClick={onClose}>close</button>
|
||||
<button onClick={onDownload}>save</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const { clearHandoffs, handOffFiles, pendingHandoffs, takeHandoff, HANDOFF_TTL_MS } = await import('@/lib/launch-handoff');
|
||||
const { setDownloadGuard } = await import('@/lib/download-guard');
|
||||
const { ProtocolLaunchHandlerProvider } = await import('../protocol-launch-handler-provider');
|
||||
|
||||
type Consumer = (params: { targetURL?: string; files?: Array<{ kind: string; getFile: () => Promise<File> }> }) => void;
|
||||
|
||||
let consumer: Consumer | null = null;
|
||||
const setConsumer = vi.fn((fn: Consumer) => { consumer = fn; });
|
||||
const handle = (name: string, type = '') => ({ kind: 'file', getFile: async () => new File(['x'], name, { type }) });
|
||||
const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
|
||||
function mount() {
|
||||
return render(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
consumer = null;
|
||||
setConsumer.mockClear();
|
||||
hoisted.push.mockClear();
|
||||
hoisted.toastError.mockClear();
|
||||
hoisted.toastInfo.mockClear();
|
||||
hoisted.flushComposers.mockReset();
|
||||
hoisted.flushComposers.mockResolvedValue({ total: 0, settled: 0, failed: 0, timedOut: false });
|
||||
hoisted.peek.mockReset();
|
||||
hoisted.adopt.mockReset();
|
||||
hoisted.discard.mockClear();
|
||||
hoisted.savePendingMailto.mockClear();
|
||||
hoisted.pathname.current = '/';
|
||||
hoisted.protocolOpenMode = 'active-session';
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal('caches', new FakeCaches());
|
||||
window.launchQueue = { setConsumer } as never;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearHandoffs();
|
||||
delete window.launchQueue;
|
||||
vi.unstubAllGlobals();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
describe('file launches', () => {
|
||||
it('opens a .eml in the viewer without leaving the page', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/mail`, files: [handle('note.eml')] });
|
||||
const viewer = await screen.findByTestId('eml-viewer');
|
||||
expect(viewer).toHaveTextContent('note.eml');
|
||||
await waitFor(() => expect(viewer).toHaveAttribute('data-type', 'message/rfc822'));
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows several .eml files one after the other', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('one.eml'), handle('two.eml')] });
|
||||
expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('one.eml');
|
||||
await act(async () => { screen.getByText('close').click(); });
|
||||
expect(await screen.findByTestId('eml-viewer')).toHaveTextContent('two.eml');
|
||||
await act(async () => { screen.getByText('close').click(); });
|
||||
expect(screen.queryByTestId('eml-viewer')).toBeNull();
|
||||
});
|
||||
|
||||
it("saves a .eml only if the device policy allows downloads", async () => {
|
||||
const created = vi.fn(() => 'blob:x');
|
||||
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: created, revokeObjectURL: vi.fn() }));
|
||||
setDownloadGuard(() => false);
|
||||
mount();
|
||||
consumer!({ files: [handle('note.eml')] });
|
||||
await screen.findByTestId('eml-viewer');
|
||||
await act(async () => { screen.getByText('save').click(); });
|
||||
expect(created).not.toHaveBeenCalled();
|
||||
setDownloadGuard(null);
|
||||
});
|
||||
|
||||
it('takes a .ics to the calendar with the file waiting', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/calendar`, files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
|
||||
expect(takeHandoff('calendar')?.files[0].name).toBe('invite.ics');
|
||||
});
|
||||
|
||||
it('takes a .vcf to the contacts with the file waiting', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('people.vcf')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/contacts');
|
||||
expect(takeHandoff('contacts')?.files[0].name).toBe('people.vcf');
|
||||
});
|
||||
|
||||
it('stays when already on the right page', async () => {
|
||||
hoisted.pathname.current = '/calendar';
|
||||
mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('goes to the page in the Pro shell too, which has one route', async () => {
|
||||
hoisted.pathname.current = '/pro';
|
||||
mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
|
||||
});
|
||||
|
||||
it('says so for a file it cannot handle', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('photo.png', 'image/png')] });
|
||||
await flush();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('unsupported_file');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('says so for a file it cannot read', async () => {
|
||||
mount();
|
||||
consumer!({ files: [{ kind: 'file', getFile: async () => { throw new Error('denied'); } }] });
|
||||
await flush();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('open_failed');
|
||||
});
|
||||
|
||||
it('waits on the sign-in page, and goes on after it', async () => {
|
||||
hoisted.pathname.current = '/login';
|
||||
const view = mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
|
||||
hoisted.pathname.current = '/';
|
||||
view.rerender(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
|
||||
});
|
||||
|
||||
it('goes to the page only once for a handoff, so it does not trap a later visit elsewhere', async () => {
|
||||
hoisted.pathname.current = '/settings';
|
||||
const view = mount();
|
||||
consumer!({ files: [handle('invite.ics')] });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledTimes(1);
|
||||
|
||||
hoisted.pathname.current = '/mail';
|
||||
view.rerender(<ProtocolLaunchHandlerProvider><div>app</div></ProtocolLaunchHandlerProvider>);
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('launches that are not files', () => {
|
||||
it('still handles mailto:', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/protocol/mailto?url=${encodeURIComponent('mailto:a@example.org')}` });
|
||||
await waitFor(() => expect(hoisted.savePendingMailto).toHaveBeenCalled());
|
||||
expect(hoisted.savePendingMailto.mock.calls[0][0].to).toEqual(['a@example.org']);
|
||||
});
|
||||
|
||||
it('opens a new message from the shortcut in an open window', async () => {
|
||||
hoisted.pathname.current = '/settings';
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/?compose=1` });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/');
|
||||
expect(takeHandoff('compose')).toMatchObject({ files: [] });
|
||||
});
|
||||
|
||||
it('goes to the calendar from its shortcut in an open window', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/calendar` });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/calendar');
|
||||
});
|
||||
|
||||
it('leaves an open window where it is for the plain app icon', async () => {
|
||||
hoisted.pathname.current = '/settings';
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/` });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a launch of the share target: the service worker has that', async () => {
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/protocol/share` });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
expect(hoisted.toastError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks launchQueue once per consumer: never a second one on one render', () => {
|
||||
mount();
|
||||
expect(setConsumer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaving a composer for a launched file', () => {
|
||||
it('saves what was typed first, then goes', async () => {
|
||||
const order: string[] = [];
|
||||
hoisted.flushComposers.mockImplementation(async () => { order.push('flush'); return { total: 1, settled: 1, failed: 0, timedOut: false }; });
|
||||
hoisted.push.mockImplementation(() => { order.push('push'); });
|
||||
mount();
|
||||
consumer!({ files: [handle('people.vcf')] });
|
||||
await flush();
|
||||
expect(order).toEqual(['flush', 'push']);
|
||||
hoisted.push.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the server refused the save', { total: 1, settled: 1, failed: 1, timedOut: false }],
|
||||
['the save took too long', { total: 1, settled: 0, failed: 0, timedOut: true }],
|
||||
])('stays when %s, with the file still waiting', async (_name, result) => {
|
||||
hoisted.flushComposers.mockResolvedValue(result);
|
||||
mount();
|
||||
consumer!({ files: [handle('people.vcf')] });
|
||||
await flush();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('save_first_failed');
|
||||
expect(pendingHandoffs().map((h) => h.kind)).toEqual(['contacts']);
|
||||
});
|
||||
|
||||
it('does not save for a .eml, which leaves nothing', async () => {
|
||||
mount();
|
||||
consumer!({ files: [handle('note.eml')] });
|
||||
await screen.findByTestId('eml-viewer');
|
||||
expect(hoisted.flushComposers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a handoff that goes without arriving', () => {
|
||||
it('says so', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
mount();
|
||||
handOffFiles([new File(['x'], 'a.ics')], 'calendar');
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
takeHandoff('calendar');
|
||||
vi.useRealTimers();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('handoff_expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the address a launch lands on', () => {
|
||||
it('opens a new message once and removes the parameter', async () => {
|
||||
window.history.replaceState(null, '', '/?compose=1&slot=2#x');
|
||||
mount();
|
||||
expect(window.location.search).toBe('?slot=2');
|
||||
expect(window.location.hash).toBe('#x');
|
||||
expect(takeHandoff('compose')).toMatchObject({ files: [] });
|
||||
expect(takeHandoff('compose')).toBeNull();
|
||||
});
|
||||
|
||||
it('counts the same launch once when launchQueue names it too', async () => {
|
||||
window.history.replaceState(null, '', '/?compose=1');
|
||||
mount();
|
||||
consumer!({ targetURL: `${window.location.origin}/?compose=1` });
|
||||
await flush();
|
||||
expect(takeHandoff('compose')).not.toBeNull();
|
||||
expect(takeHandoff('compose')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a share from an address anyone can make', () => {
|
||||
const dialogText = () => screen.getByTestId('share-confirm-details').textContent ?? '';
|
||||
const agree = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_accept' }));
|
||||
const refuse = () => fireEvent.click(screen.getByRole('button', { name: 'share_confirm_decline' }));
|
||||
|
||||
it('asks about text in the address, and starts nothing before the answer', async () => {
|
||||
window.history.replaceState(null, '', '/#share=unavailable&share_title=T&share_text=B&share_files=1&share_cut=1');
|
||||
mount();
|
||||
expect(window.location.hash).toBe('');
|
||||
await screen.findByText('share_confirm_message');
|
||||
expect(dialogText()).toContain('T');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
|
||||
agree();
|
||||
await flush();
|
||||
expect(takeHandoff('compose')).toMatchObject({ subject: 'T', body: 'B' });
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost');
|
||||
expect(hoisted.toastInfo).toHaveBeenCalledWith('share_cut');
|
||||
});
|
||||
|
||||
it('starts nothing when refused', async () => {
|
||||
window.history.replaceState(null, '', '/#share=unavailable&share_text=B');
|
||||
mount();
|
||||
await screen.findByText('share_confirm_message');
|
||||
refuse();
|
||||
await flush();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('only reports lost files when there was no text to ask about', async () => {
|
||||
window.history.replaceState(null, '', '/#share=unavailable&share_files=1');
|
||||
mount();
|
||||
await flush();
|
||||
expect(screen.queryByText('share_confirm_message')).toBeNull();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('share_files_lost');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores it in the query, which reaches logs and any link', async () => {
|
||||
window.history.replaceState(null, '', '/?share=unavailable&share_text=B');
|
||||
mount();
|
||||
await flush();
|
||||
expect(screen.queryByText('share_confirm_message')).toBeNull();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks about a stored share, naming the files, before anything is taken in', async () => {
|
||||
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] });
|
||||
hoisted.adopt.mockResolvedValue(true);
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
expect(window.location.hash).toBe('');
|
||||
await screen.findByText('share_confirm_message');
|
||||
expect(hoisted.peek).toHaveBeenCalledWith('abc', 'k1');
|
||||
expect(dialogText()).toContain('invoice.exe');
|
||||
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
|
||||
|
||||
agree();
|
||||
await waitFor(() => expect(hoisted.adopt).toHaveBeenCalledWith('abc', 'k1'));
|
||||
expect(hoisted.discard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks in an installed-app window as well: link capturing can bring a POST there', async () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({ matches: query.includes('standalone'), media: query, addEventListener: () => {}, removeEventListener: () => {} })) as never;
|
||||
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: [] });
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
await screen.findByText('share_confirm_message');
|
||||
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
it('drops a stored share when refused', async () => {
|
||||
hoisted.peek.mockResolvedValue({ subject: 'S', body: '', files: ['invoice.exe'] });
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
await screen.findByText('share_confirm_message');
|
||||
refuse();
|
||||
await waitFor(() => expect(hoisted.discard).toHaveBeenCalledWith('abc'));
|
||||
expect(hoisted.adopt).not.toHaveBeenCalledWith('abc', 'k1');
|
||||
});
|
||||
|
||||
it('says so when what was shared is gone', async () => {
|
||||
hoisted.peek.mockResolvedValue(null);
|
||||
window.history.replaceState(null, '', '/#share=abc&key=k1');
|
||||
mount();
|
||||
expect(window.location.hash).toBe('');
|
||||
await flush();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith('share_gone');
|
||||
expect(screen.queryByText('share_confirm_message')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a browser without launchQueue', () => {
|
||||
it('renders the app and does nothing else', async () => {
|
||||
delete window.launchQueue;
|
||||
mount();
|
||||
await flush();
|
||||
expect(screen.getByText('app')).toBeInTheDocument();
|
||||
expect(setConsumer).not.toHaveBeenCalled();
|
||||
expect(hoisted.push).not.toHaveBeenCalled();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type ComponentType, type ComponentProps } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { FilePreviewModal as FilePreviewModalType } from "@/components/files/file-preview-modal";
|
||||
import { useHandoffFiles } from "@/hooks/use-handoff-files";
|
||||
import { saveBlobAs } from "@/lib/download-guard";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
type PreviewComponent = ComponentType<ComponentProps<typeof FilePreviewModalType>>;
|
||||
|
||||
/**
|
||||
* Shows a .eml opened from the OS. It parses in the browser, so it works
|
||||
* offline and before sign-in. `policyAccount` stays out: the strictest device
|
||||
* policy of the accounts signed in here decides about saving (FilePreviewModal
|
||||
* and saveBlobAs check it).
|
||||
*
|
||||
* The preview is loaded when a file arrives, to keep it off the layout's
|
||||
* bundle, and by hand rather than with next/dynamic: without a connection and
|
||||
* without the offline shell its chunk can't load, which has to end in a
|
||||
* message, not in the error page, and work again later.
|
||||
*/
|
||||
export function LaunchFileHost() {
|
||||
const t = useTranslations("pwaLaunch");
|
||||
const { file, done } = useHandoffFiles("eml", true);
|
||||
const [Preview, setPreview] = useState<PreviewComponent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file || Preview) return;
|
||||
let stale = false;
|
||||
import("@/components/files/file-preview-modal")
|
||||
.then((module) => {
|
||||
if (!stale) setPreview(() => module.FilePreviewModal);
|
||||
})
|
||||
.catch(() => {
|
||||
if (stale) return;
|
||||
toast.error(t("open_failed"));
|
||||
done();
|
||||
});
|
||||
return () => { stale = true; };
|
||||
}, [file, Preview, done, t]);
|
||||
|
||||
const getFileContent = useCallback(async () => {
|
||||
if (!file) throw new Error("no file");
|
||||
return { blob: file, contentType: "message/rfc822" };
|
||||
}, [file]);
|
||||
|
||||
const onDownload = useCallback(() => {
|
||||
if (file) saveBlobAs(file, file.name, { kind: "attachment" });
|
||||
}, [file]);
|
||||
|
||||
if (!file || !Preview) return null;
|
||||
return (
|
||||
<Preview
|
||||
key={`${file.name}:${file.lastModified}`}
|
||||
name={file.name}
|
||||
onClose={done}
|
||||
onDownload={onDownload}
|
||||
getFileContent={getFileContent}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { flushComposers } from "@/lib/compose-flush-registry";
|
||||
import { isPopoutPath } from "@/lib/popout/window-role";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { matchSurface, type AppSurface } from "@/lib/deep-links";
|
||||
import {
|
||||
adoptStoredHandoff,
|
||||
discardStoredHandoff,
|
||||
handOffDraft,
|
||||
handOffLaunchFiles,
|
||||
handOffNewMessage,
|
||||
parseLaunchLanding,
|
||||
peekStoredHandoff,
|
||||
pendingHandoffs,
|
||||
restoreStoredHandoffs,
|
||||
shortcutFromTargetUrl,
|
||||
stripLandingParams,
|
||||
subscribeHandoff,
|
||||
subscribeHandoffLost,
|
||||
type HandoffKind,
|
||||
} from "@/lib/launch-handoff";
|
||||
import { productName } from "@/lib/product-name";
|
||||
import { parseMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { parseWebcal } from "@/lib/protocol-handlers/webcal";
|
||||
@@ -19,8 +39,13 @@ import {
|
||||
savePendingWebcal,
|
||||
} from "@/lib/protocol-handlers/session";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { LaunchFileHost } from "./launch-file-host";
|
||||
|
||||
type LaunchParams = { targetURL?: string };
|
||||
type LaunchParams = {
|
||||
targetURL?: string;
|
||||
files?: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>;
|
||||
};
|
||||
type StandaloneNavigator = Navigator & { standalone?: boolean };
|
||||
|
||||
declare global {
|
||||
@@ -52,6 +77,21 @@ function getProtocolLaunch(targetURL: string):
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The page a handoff waits for. A .eml has none: it opens over any page. */
|
||||
const HANDOFF_TARGET: Partial<Record<HandoffKind, { surface: AppSurface; path: string }>> = {
|
||||
compose: { surface: "mail", path: "/" },
|
||||
calendar: { surface: "calendar", path: "/calendar" },
|
||||
contacts: { surface: "contacts", path: "/contacts" },
|
||||
};
|
||||
|
||||
/** How long a launch waits for an open composer to save before it leaves the page. */
|
||||
const FLUSH_BEFORE_LEAVING_MS = 5000;
|
||||
/** How much of a share the question shows. */
|
||||
const OFFER_TEXT_LENGTH = 200;
|
||||
const OFFER_FILE_COUNT = 5;
|
||||
|
||||
type ShareOffer = { subject: string; body: string; files: string[] };
|
||||
|
||||
function isStandaloneDisplayMode() {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches
|
||||
|| (navigator as StandaloneNavigator).standalone === true;
|
||||
@@ -74,6 +114,97 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
|
||||
const appName = productName(useConfig().appName);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const tLaunch = useTranslations("pwaLaunch");
|
||||
const sentToTarget = useRef(new Set<string>());
|
||||
const { dialogProps, confirm } = useConfirmDialog();
|
||||
const [offer, setOffer] = useState<ShareOffer | null>(null);
|
||||
|
||||
// Anyone can POST to the share target or link to a landing address, and
|
||||
// Chromium's link capturing can bring a cross-site form POST into an
|
||||
// installed app's window, so nothing tells a real share from a planted one.
|
||||
// Nothing goes into an account until the user says yes.
|
||||
const askAboutShare = useCallback(async (shared: ShareOffer) => {
|
||||
setOffer(shared);
|
||||
const agreed = await confirm({
|
||||
title: tLaunch("share_confirm_title"),
|
||||
message: tLaunch("share_confirm_message"),
|
||||
confirmText: tLaunch("share_confirm_accept"),
|
||||
cancelText: tLaunch("share_confirm_decline"),
|
||||
});
|
||||
setOffer(null);
|
||||
return agreed;
|
||||
}, [confirm, tLaunch]);
|
||||
|
||||
// What the page's own address asks for: a shortcut's new message, or what
|
||||
// was shared. Once: it leaves the address before anything else happens.
|
||||
useEffect(() => {
|
||||
const stopLossNotes = subscribeHandoffLost((loss) => {
|
||||
toast.error(tLaunch(loss === "missing" ? "share_files_lost" : "handoff_expired"));
|
||||
});
|
||||
const { search, hash, pathname: path } = window.location;
|
||||
const landing = parseLaunchLanding(search, hash);
|
||||
if (landing) {
|
||||
const left = stripLandingParams(search, hash);
|
||||
window.history.replaceState(window.history.state, "", path + left.search + left.hash);
|
||||
}
|
||||
if (landing?.type === "new-message") {
|
||||
handOffNewMessage();
|
||||
} else if (landing?.type === "inline") {
|
||||
void (async () => {
|
||||
const { subject, body } = landing;
|
||||
if ((subject || body) && !(await askAboutShare({ subject, body, files: [] }))) return;
|
||||
if (subject || body) handOffDraft({ subject, body });
|
||||
if (landing.filesLost) toast.error(tLaunch("share_files_lost"));
|
||||
if (landing.cut) toast.info(tLaunch("share_cut"));
|
||||
})();
|
||||
} else if (landing?.type === "stored") {
|
||||
void (async () => {
|
||||
const shared = await peekStoredHandoff(landing.id, landing.key);
|
||||
if (!shared) {
|
||||
await discardStoredHandoff(landing.id);
|
||||
toast.error(tLaunch("share_gone"));
|
||||
return;
|
||||
}
|
||||
if (!(await askAboutShare(shared))) {
|
||||
await discardStoredHandoff(landing.id);
|
||||
return;
|
||||
}
|
||||
if (!(await adoptStoredHandoff(landing.id, landing.key))) toast.error(tLaunch("share_gone"));
|
||||
})();
|
||||
}
|
||||
void restoreStoredHandoffs();
|
||||
return stopLossNotes;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Leaving the mail page ends an open composer, and its autosave waits 2
|
||||
// seconds: what was just typed is saved first. If it can't be, stay. A file
|
||||
// keeps waiting and opens once the user goes to that page.
|
||||
const leaveFor = useCallback(async (path: "/" | "/calendar" | "/contacts") => {
|
||||
const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS);
|
||||
if (result.failed > 0 || result.timedOut) {
|
||||
toast.error(tLaunch("save_first_failed"));
|
||||
return;
|
||||
}
|
||||
router.push(path);
|
||||
}, [router, tLaunch]);
|
||||
|
||||
// Takes a handoff to the page that handles it, once each: on arrival, or
|
||||
// when the sign-in it had to wait for is done.
|
||||
useEffect(() => {
|
||||
const goToTarget = () => {
|
||||
if (pathname === "/login" || pathname.startsWith("/login/") || pathname.startsWith("/auth/")) return;
|
||||
for (const handoff of pendingHandoffs()) {
|
||||
const target = HANDOFF_TARGET[handoff.kind];
|
||||
if (!target || sentToTarget.current.has(handoff.id)) continue;
|
||||
sentToTarget.current.add(handoff.id);
|
||||
if (matchSurface(pathname)?.surface !== target.surface) void leaveFor(target.path as "/" | "/calendar" | "/contacts");
|
||||
return;
|
||||
}
|
||||
};
|
||||
goToTarget();
|
||||
return subscribeHandoff("any", goToTarget);
|
||||
}, [pathname, leaveFor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return;
|
||||
@@ -94,10 +225,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
|
||||
if (typeof window === "undefined" || !window.launchQueue || isPopoutPath(pathname)) return;
|
||||
|
||||
window.launchQueue.setConsumer((launchParams) => {
|
||||
if (launchParams.files && launchParams.files.length > 0) {
|
||||
void handOffLaunchFiles(launchParams.files).then(({ unsupported, failed }) => {
|
||||
for (const name of unsupported) toast.error(tLaunch("unsupported_file", { name }));
|
||||
if (failed > 0) toast.error(tLaunch("open_failed"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!launchParams.targetURL) return;
|
||||
|
||||
const launch = getProtocolLaunch(launchParams.targetURL);
|
||||
if (!launch) return;
|
||||
if (!launch) {
|
||||
const shortcut = shortcutFromTargetUrl(launchParams.targetURL, window.location.origin);
|
||||
if (shortcut === "new-message") handOffNewMessage();
|
||||
else if (shortcut && matchSurface(pathname)?.surface !== shortcut) void leaveFor(`/${shortcut}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (launch.kind === "mailto") {
|
||||
const parsed = parseMailto(launch.raw);
|
||||
@@ -131,7 +275,23 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle
|
||||
notifyPendingWebcal();
|
||||
if (pathname !== "/calendar") router.push("/calendar");
|
||||
});
|
||||
}, [pathname, router]);
|
||||
}, [pathname, router, tLaunch, leaveFor]);
|
||||
|
||||
return children;
|
||||
const offeredFiles = offer?.files ?? [];
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<LaunchFileHost />
|
||||
<ConfirmDialog
|
||||
{...dialogProps}
|
||||
details={offer && (
|
||||
<dl className="space-y-1 break-words text-sm" data-testid="share-confirm-details">
|
||||
{offer.subject && <div><dt className="inline font-medium">{tLaunch("share_confirm_subject")} </dt><dd className="inline">{offer.subject}</dd></div>}
|
||||
{offer.body && <div><dt className="inline font-medium">{tLaunch("share_confirm_text")} </dt><dd className="inline">{offer.body.slice(0, OFFER_TEXT_LENGTH)}</dd></div>}
|
||||
{offeredFiles.length > 0 && <div><dt className="inline font-medium">{tLaunch("share_confirm_files", { count: offeredFiles.length })} </dt><dd className="inline">{offeredFiles.slice(0, OFFER_FILE_COUNT).join(", ")}{offeredFiles.length > OFFER_FILE_COUNT ? ", …" : ""}</dd></div>}
|
||||
</dl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>, 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<typeof import('@/lib/pwa-notify/page-actions')>('@/lib/pwa-notify/page-actions');
|
||||
return { isNotificationActionRequest: actual.isNotificationActionRequest, runNotificationAction: (...args: Parameters<typeof runNotificationAction>) => 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<typeof vi.fn> } });
|
||||
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(<NotificationActionsHost />);
|
||||
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(<NotificationActionsHost />);
|
||||
expect(writeNotificationActionPrefs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes again on signing in, since signing out deleted the store', () => {
|
||||
state.isAuthenticated = false;
|
||||
const { rerender } = render(<NotificationActionsHost />);
|
||||
state.isAuthenticated = true;
|
||||
rerender(<NotificationActionsHost />);
|
||||
expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('writes again when the archive mode or the gate changes, and not otherwise', () => {
|
||||
const { rerender } = render(<NotificationActionsHost />);
|
||||
rerender(<NotificationActionsHost />);
|
||||
expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1);
|
||||
state.archiveMode = 'year';
|
||||
rerender(<NotificationActionsHost />);
|
||||
expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(2);
|
||||
state.archiveEnabled = false;
|
||||
rerender(<NotificationActionsHost />);
|
||||
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(<NotificationActionsHost />);
|
||||
expect(writeNotificationActionPrefs.mock.calls.at(-1)?.[0]).toMatchObject({ pluginsMayVeto: true });
|
||||
state.plugins = [{ enabled: false, permissions: ['email:write'] }];
|
||||
rerender(<NotificationActionsHost />);
|
||||
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(<NotificationActionsHost />); });
|
||||
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<typeof render>;
|
||||
await act(async () => { view = render(<NotificationActionsHost />); });
|
||||
await vi.waitFor(() => expect(postMessage).toHaveBeenCalledTimes(1));
|
||||
state.accounts = [];
|
||||
await act(async () => { view.rerender(<NotificationActionsHost />); });
|
||||
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(<NotificationActionsHost />);
|
||||
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(<NotificationActionsHost />);
|
||||
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(<NotificationActionsHost />);
|
||||
await fromWorker({ kind: 'outbox-flush' });
|
||||
expect(scheduleFlushAll).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('stops listening when it goes', async () => {
|
||||
const { unmount } = render(<NotificationActionsHost />);
|
||||
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(<NotificationActionsHost />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { subscribeHandoff, takeHandoff, type HandoffKind } from "@/lib/launch-handoff";
|
||||
|
||||
/**
|
||||
* The files handed to this part of the app (lib/launch-handoff.ts), one at a
|
||||
* time. Nothing is taken while `active` is false, for a surface that isn't
|
||||
* ready for them yet. `done` moves on to the next file.
|
||||
*/
|
||||
export function useHandoffFiles(kind: HandoffKind, active: boolean): { file: File | null; done: () => void } {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const take = () => {
|
||||
for (let handoff = takeHandoff(kind); handoff; handoff = takeHandoff(kind)) {
|
||||
const received = handoff.files;
|
||||
if (received.length > 0) setFiles((waiting) => [...waiting, ...received]);
|
||||
}
|
||||
};
|
||||
take();
|
||||
return subscribeHandoff(kind, take);
|
||||
}, [kind, active]);
|
||||
|
||||
const done = useCallback(() => setFiles((waiting) => waiting.slice(1)), []);
|
||||
return { file: files[0] ?? null, done };
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
import { WEBMAIL_URL } from './helpers/config';
|
||||
import { ENT, ENT_PASSWORD, adminContext, contextOptions, getPolicy, jmapAs, putPolicy, requireEntEnv, shot } from './helpers/ent';
|
||||
|
||||
/**
|
||||
* The installed app's launches through the real UI: shortcuts, files opened
|
||||
* from the OS, and shares. Chromium can't fire launchQueue from a test, so a
|
||||
* stand-in catches the consumer the app registers, and the spec calls that
|
||||
* consumer the way the browser would. Shares go through the real service
|
||||
* worker as a real form POST. Needs a production build, the only one that
|
||||
* registers the worker.
|
||||
*
|
||||
* BUILD=1 bash desktop-offline-rig/start-prod.sh <worktree> 3230 <jmap-url> pwa-launch-1.0
|
||||
* bash desktop-offline-rig/run-spec.sh <worktree> 3230 <jmap-url> 1.0 integration/tests/23-do-pwa-launch.spec.ts
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const RUN = Date.now().toString(36);
|
||||
const composer = (page: Page) => page.locator('[data-testid="email-composer"]').first();
|
||||
const SHARE_CACHE = 'bulwark-launch-handoff-v1';
|
||||
|
||||
type LaunchFile = { name: string; type: string; body: string };
|
||||
|
||||
const EML: LaunchFile = {
|
||||
name: 'note.eml',
|
||||
type: 'message/rfc822',
|
||||
body: [
|
||||
'From: Bob <bob@example.org>',
|
||||
'To: Alice <alice@example.org>',
|
||||
`Subject: Launched message ${RUN}`,
|
||||
'Date: Tue, 03 Nov 2026 10:00:00 +0000',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Opened from the file system.',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
};
|
||||
|
||||
const ICS: LaunchFile = {
|
||||
name: 'invite.ics',
|
||||
type: 'text/calendar',
|
||||
body: [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//Bulwark//Test//EN',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:launch-${RUN}@example.org`,
|
||||
'DTSTAMP:20261103T100000Z',
|
||||
'DTSTART:20261110T100000Z',
|
||||
'DTEND:20261110T110000Z',
|
||||
`SUMMARY:Launched event ${RUN}`,
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
};
|
||||
|
||||
const VCF: LaunchFile = {
|
||||
name: 'people.vcf',
|
||||
type: 'text/vcard',
|
||||
body: ['BEGIN:VCARD', 'VERSION:3.0', `FN:Launched Person ${RUN}`, `EMAIL:launched-${RUN}@example.org`, 'END:VCARD', ''].join('\r\n'),
|
||||
};
|
||||
|
||||
/** Stands in for the browser's launchQueue, keeping the consumer the app sets. */
|
||||
function catchLaunchConsumer() {
|
||||
Object.defineProperty(window, 'launchQueue', {
|
||||
configurable: true,
|
||||
value: {
|
||||
setConsumer(fn: unknown) {
|
||||
(window as unknown as { __launch: unknown }).__launch = fn;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function launch(page: Page, opts: { target?: string; files?: LaunchFile[] }): Promise<void> {
|
||||
await page.waitForFunction(() => typeof (window as unknown as { __launch?: unknown }).__launch === 'function');
|
||||
await page.evaluate(({ target, files }) => {
|
||||
const consumer = (window as unknown as { __launch: (params: unknown) => void }).__launch;
|
||||
consumer({
|
||||
targetURL: new URL(target, window.location.origin).href,
|
||||
...(files.length > 0 ? {
|
||||
files: files.map((f) => ({ kind: 'file', getFile: async () => new File([f.body], f.name, { type: f.type }) })),
|
||||
} : {}),
|
||||
});
|
||||
}, { target: opts.target ?? '/', files: opts.files ?? [] });
|
||||
}
|
||||
|
||||
async function signIn(page: Page, email: string): Promise<void> {
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', ENT_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 });
|
||||
}
|
||||
|
||||
/** A new window lands signed out or signed in, depending on what can be restored. */
|
||||
async function signInIfAsked(page: Page, email: string): Promise<void> {
|
||||
const login = page.locator('#username');
|
||||
await expect(login.or(accountSwitcher(page))).toBeVisible({ timeout: 60_000 });
|
||||
if (await login.isVisible()) await signIn(page, email);
|
||||
}
|
||||
|
||||
async function launchContext(browser: Browser, opts: Parameters<Browser['newContext']>[0] = {}): Promise<BrowserContext> {
|
||||
const context = await browser.newContext({ ...contextOptions(), ...opts });
|
||||
await context.addInitScript(catchLaunchConsumer);
|
||||
return context;
|
||||
}
|
||||
|
||||
async function signedIn(browser: Browser, opts: Parameters<Browser['newContext']>[0] = {}): Promise<Page> {
|
||||
const page = await (await launchContext(browser, opts)).newPage();
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await signIn(page, ENT.alice);
|
||||
return page;
|
||||
}
|
||||
|
||||
/** A real form POST to the share target, as the OS's share sheet sends it. */
|
||||
async function postShare(page: Page, fields: Record<string, string>, files: LaunchFile[]): Promise<void> {
|
||||
await page.evaluate(({ fields: values, files: list }) => {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.enctype = 'multipart/form-data';
|
||||
form.action = '/protocol/share';
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = name;
|
||||
input.value = value;
|
||||
form.append(input);
|
||||
}
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.name = 'files';
|
||||
input.multiple = true;
|
||||
const data = new DataTransfer();
|
||||
for (const f of list) data.items.add(new File([f.body], f.name, { type: f.type }));
|
||||
input.files = data.files;
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
}, { fields, files });
|
||||
}
|
||||
|
||||
async function workerReady(page: Page): Promise<void> {
|
||||
const active = await page.evaluate(() => navigator.serviceWorker.ready.then((r) => !!r.active));
|
||||
expect(active).toBe(true);
|
||||
}
|
||||
|
||||
/** The question a share has to pass, wherever it came from. */
|
||||
const startMessage = (page: Page) => page.getByRole('button', { name: 'Start message', exact: true });
|
||||
const dontStart = (page: Page) => page.getByRole('button', { name: "Don't start", exact: true });
|
||||
|
||||
const HOSTILE = 'http://hostile.example';
|
||||
|
||||
/** A page on another origin that POSTs to the share target as soon as it loads. Nobody clicks. */
|
||||
function hostilePage(opts: { subject: string; fileName?: string }): string {
|
||||
return `<!doctype html><title>hostile</title><body><script>
|
||||
const f = document.createElement('form');
|
||||
f.method = 'POST'; f.enctype = 'multipart/form-data'; f.action = ${JSON.stringify(`${WEBMAIL_URL}/protocol/share`)};
|
||||
const title = document.createElement('input'); title.type = 'hidden'; title.name = 'title'; title.value = ${JSON.stringify(opts.subject)}; f.append(title);
|
||||
const text = document.createElement('input'); text.type = 'hidden'; text.name = 'text'; text.value = 'body from another site'; f.append(text);
|
||||
const input = document.createElement('input'); input.type = 'file'; input.name = 'files'; input.multiple = true;
|
||||
${opts.fileName ? `const d = new DataTransfer(); d.items.add(new File(['attacker bytes'], ${JSON.stringify(opts.fileName)})); input.files = d.files;` : ''}
|
||||
f.append(input); document.body.append(f); f.submit();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
async function hostileTab(context: BrowserContext, opts: Parameters<typeof hostilePage>[0]): Promise<Page> {
|
||||
await context.route(`${HOSTILE}/**`, (route) => route.fulfill({ contentType: 'text/html', body: hostilePage(opts) }));
|
||||
const tab = await context.newPage();
|
||||
await tab.goto(`${HOSTILE}/`);
|
||||
return tab;
|
||||
}
|
||||
|
||||
/** Every message on the server with this subject. */
|
||||
async function draftsWithSubject(subject: string): Promise<string[]> {
|
||||
const client = await jmapAs(ENT.alice);
|
||||
const res = await client.request([['Email/query', { accountId: client.accountId, filter: { subject } }, 'q']]);
|
||||
return res.methodResponses[0][1].ids as string[];
|
||||
}
|
||||
|
||||
async function deleteDrafts(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const client = await jmapAs(ENT.alice);
|
||||
await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, 'd']]);
|
||||
}
|
||||
|
||||
const storedShareEntries = (page: Page) => page.evaluate(async (name) => {
|
||||
if (!(await caches.has(name))) return 0;
|
||||
return (await (await caches.open(name)).keys()).length;
|
||||
}, SHARE_CACHE);
|
||||
|
||||
test.beforeAll(() => requireEntEnv());
|
||||
|
||||
test.describe('manifest', () => {
|
||||
test('offers shortcuts, file handlers and the share target, in the browser language', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const res = await context.request.get('/manifest.webmanifest', { headers: { 'Accept-Language': 'de-DE,de;q=0.9' } });
|
||||
expect(res.ok()).toBe(true);
|
||||
const manifest = await res.json();
|
||||
expect(manifest.shortcuts.map((s: { name: string; url: string }) => [s.name, s.url])).toEqual([
|
||||
['Neue Nachricht', '/?compose=1'],
|
||||
['Kalender', '/calendar'],
|
||||
['Kontakte', '/contacts'],
|
||||
]);
|
||||
expect(manifest.file_handlers.map((h: { action: string }) => h.action)).toEqual(['/mail', '/calendar', '/contacts']);
|
||||
expect(manifest.share_target).toMatchObject({ action: '/protocol/share', method: 'POST', enctype: 'multipart/form-data' });
|
||||
expect(manifest.protocol_handlers).toHaveLength(2);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('every address in it answers', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const manifest = await (await context.request.get('/manifest.webmanifest')).json();
|
||||
for (const shortcut of manifest.shortcuts) {
|
||||
expect((await context.request.get(shortcut.url)).ok(), shortcut.url).toBe(true);
|
||||
expect((await context.request.get(shortcut.icons[0].src)).ok(), shortcut.icons[0].src).toBe(true);
|
||||
}
|
||||
for (const handler of manifest.file_handlers) {
|
||||
expect((await context.request.get(handler.action)).ok(), handler.action).toBe(true);
|
||||
}
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('share target', () => {
|
||||
test('files and a link go through the service worker into a new draft', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await workerReady(page);
|
||||
|
||||
const subject = `Shared ${RUN}`;
|
||||
await postShare(page, { title: subject, text: 'Have a look', url: 'https://example.org/page' }, [
|
||||
{ name: `shared-${RUN}.txt`, type: 'text/plain', body: 'shared bytes' },
|
||||
]);
|
||||
await page.waitForURL((url) => url.pathname.endsWith('/') || !url.pathname.includes('protocol'));
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await signInIfAsked(page, ENT.alice);
|
||||
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/page');
|
||||
await expect(composer(page).getByText(`shared-${RUN}.txt`)).toBeVisible();
|
||||
// It reached the app through the worker's store, not the server's fallback.
|
||||
await expect(page.getByText("The shared files couldn't be kept")).toHaveCount(0);
|
||||
await expect.poll(() => new URL(page.url()).hash).toBe('');
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(0);
|
||||
await shot(page, 'pwa-launch-share');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('survives a sign-in that reloads the page', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await workerReady(page);
|
||||
|
||||
await postShare(page, { url: 'https://example.org/waited' }, [{ name: `waited-${RUN}.txt`, type: 'text/plain', body: 'x' }]);
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(3);
|
||||
// What SSO does: away and back, in the same tab.
|
||||
await page.reload();
|
||||
await signIn(page, ENT.alice);
|
||||
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page).getByText(`waited-${RUN}.txt`)).toBeVisible();
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('without a service worker the link still arrives and the app says the files did not', async ({ browser }) => {
|
||||
const page = await signedIn(browser, { serviceWorkers: 'block' });
|
||||
await postShare(page, { url: 'https://example.org/no-worker' }, [{ name: 'lost.txt', type: 'text/plain', body: 'x' }]);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await signInIfAsked(page, ENT.alice);
|
||||
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page).locator('[contenteditable="true"]').first()).toContainText('https://example.org/no-worker');
|
||||
await expect(composer(page).getByText('lost.txt')).toHaveCount(0);
|
||||
await expect(page.getByText("The shared files couldn't be kept").first()).toBeVisible();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the server takes the POST from outside the origin, which /api/ would refuse', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const res = await context.request.post('/protocol/share', {
|
||||
headers: { Origin: 'https://other.example', 'Sec-Fetch-Site': 'cross-site' },
|
||||
multipart: { url: 'https://example.org/direct' },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.status()).toBe(303);
|
||||
const location = new URL(res.headers().location, 'http://x.invalid');
|
||||
// In the fragment: shared text stays out of access logs.
|
||||
expect(location.search).toBe('');
|
||||
const fragment = new URLSearchParams(location.hash.slice(1));
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.get('share_url')).toBe('https://example.org/direct');
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('long non-Latin text fits the address the fallback sends on', async ({ browser }) => {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const res = await context.request.post('/protocol/share', {
|
||||
multipart: { text: 'あ'.repeat(3000) },
|
||||
maxRedirects: 0,
|
||||
});
|
||||
expect(res.status()).toBe(303);
|
||||
const location = res.headers().location;
|
||||
expect(location.length).toBeLessThan(4000);
|
||||
expect((await context.request.get(location)).ok()).toBe(true);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a share nobody asked for', () => {
|
||||
test('a POST from another site leaves no draft on the server and asks first', async ({ browser }) => {
|
||||
const signedInPage = await signedIn(browser);
|
||||
await workerReady(signedInPage);
|
||||
const subject = `hostile-${RUN}-a`;
|
||||
const tab = await hostileTab(signedInPage.context(), { subject, fileName: `invoice-${RUN}.exe` });
|
||||
await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL));
|
||||
|
||||
// The question comes before anything else, also before a sign-in.
|
||||
await expect(startMessage(tab)).toBeVisible({ timeout: 30_000 }).catch(() => {});
|
||||
if (await startMessage(tab).isVisible()) {
|
||||
await expect(tab.getByTestId('share-confirm-details')).toContainText(`invoice-${RUN}.exe`);
|
||||
} else {
|
||||
await signInIfAsked(tab, ENT.alice);
|
||||
}
|
||||
await tab.waitForTimeout(6000);
|
||||
const drafts = await draftsWithSubject(subject);
|
||||
await deleteDrafts(drafts);
|
||||
expect(drafts).toEqual([]);
|
||||
await expect(composer(tab)).toHaveCount(0);
|
||||
await expect(startMessage(tab)).toBeVisible();
|
||||
await shot(tab, 'pwa-launch-share-confirm');
|
||||
|
||||
// Refused: what it stored goes too.
|
||||
await dontStart(tab).click();
|
||||
await expect.poll(() => storedShareEntries(tab)).toBe(0);
|
||||
await expect(composer(tab)).toHaveCount(0);
|
||||
await signedInPage.context().close();
|
||||
});
|
||||
|
||||
test('agreed, it is only the starting state: no draft until the first real edit', async ({ browser }) => {
|
||||
const signedInPage = await signedIn(browser);
|
||||
await workerReady(signedInPage);
|
||||
const subject = `hostile-${RUN}-b`;
|
||||
const tab = await hostileTab(signedInPage.context(), { subject, fileName: `kept-${RUN}.txt` });
|
||||
await tab.waitForURL((url) => url.href.startsWith(WEBMAIL_URL));
|
||||
await startMessage(tab).click({ timeout: 60_000 });
|
||||
await signInIfAsked(tab, ENT.alice);
|
||||
await expect(composer(tab).getByText(`kept-${RUN}.txt`)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
|
||||
await tab.waitForTimeout(6000);
|
||||
expect(await draftsWithSubject(subject)).toEqual([]);
|
||||
|
||||
// Leaving doesn't save it either.
|
||||
await tab.evaluate(() => { window.dispatchEvent(new Event('beforeunload')); });
|
||||
await tab.waitForTimeout(3000);
|
||||
expect(await draftsWithSubject(subject)).toEqual([]);
|
||||
|
||||
// The user's own edit: from here it is a draft like any other.
|
||||
await composer(tab).getByPlaceholder(/subject/i).fill(`${subject} edited`);
|
||||
await expect(composer(tab).getByPlaceholder(/subject/i)).toHaveValue(`${subject} edited`);
|
||||
await expect.poll(() => draftsWithSubject(subject), { timeout: 20_000 }).toHaveLength(1);
|
||||
await deleteDrafts(await draftsWithSubject(subject));
|
||||
await signedInPage.context().close();
|
||||
});
|
||||
|
||||
test('a hidden frame stores nothing', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await workerReady(page);
|
||||
// From the app's own origin: another site's frame doesn't reach this worker
|
||||
// at all in Chromium (partitioned storage), so only this shows the check.
|
||||
await page.evaluate(() => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.name = 'hidden-share';
|
||||
frame.style.display = 'none';
|
||||
document.body.append(frame);
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.enctype = 'multipart/form-data';
|
||||
form.action = '/protocol/share';
|
||||
form.target = 'hidden-share';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'title';
|
||||
input.value = 'frame';
|
||||
form.append(input);
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
expect(await storedShareEntries(page)).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('text in an address anyone can link to asks first too', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await page.goto(`/#share=unavailable&share_title=${encodeURIComponent(`linked-${RUN}`)}&share_text=hello`);
|
||||
await expect(startMessage(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await dontStart(page).click();
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('a handoff while a composer is open', () => {
|
||||
test('one set aside by reading a message is not replaced by the next', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await page.locator('[data-testid="email-list-item"]').first().waitFor({ timeout: 30_000 });
|
||||
const subject = `setaside${RUN}`;
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await composer(page).getByPlaceholder(/subject/i).fill(subject);
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
// Reading a message suspends the composer before its autosave ran.
|
||||
await page.locator('[data-testid="email-list-item"]').first().click();
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await page.waitForTimeout(2100);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await expect(page.getByText(subject).first()).toBeVisible();
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('two at once: the second waits until the first composer closes', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
// Let the page settle after the sign-in: the composer shouldn't race the first mail load.
|
||||
await page.waitForTimeout(3000);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
const subject = `kept-open-${RUN}`;
|
||||
await composer(page).getByPlaceholder(/subject/i).fill(subject);
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
|
||||
await page.waitForTimeout(2100);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(page.getByText('Close this message to start the next one.').first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue(subject);
|
||||
|
||||
await page.locator('[data-testid="composer-close"]').first().click();
|
||||
await expect(composer(page).getByPlaceholder(/subject/i)).toHaveValue('', { timeout: 15_000 });
|
||||
await deleteDrafts(await draftsWithSubject(subject));
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a .vcf launch saves what was just typed before leaving the composer', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
const subject = `typedfirst${RUN}`;
|
||||
await composer(page).getByPlaceholder(/subject/i).fill(subject);
|
||||
// Well inside the autosave's 2 seconds.
|
||||
await launch(page, { target: '/contacts', files: [VCF] });
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(() => draftsWithSubject(subject), { timeout: 10_000 }).toHaveLength(1);
|
||||
await deleteDrafts(await draftsWithSubject(subject));
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('file launches', () => {
|
||||
test('.eml opens in the viewer, over the page', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
const before = new URL(page.url()).pathname;
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
expect(new URL(page.url()).pathname).toBe(before);
|
||||
await expect(page.getByRole('button', { name: 'Download', exact: true }).first()).toBeVisible();
|
||||
await shot(page, 'pwa-launch-eml');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.eml opens before signing in', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.eml with downloads blocked: the viewer opens, with nothing to save', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const saved = await getPolicy(admin);
|
||||
const organisation = (saved.organisation ?? {}) as Record<string, unknown>;
|
||||
await putPolicy(admin, {
|
||||
...saved,
|
||||
organisation: {
|
||||
...organisation,
|
||||
devices: { ...((organisation.devices ?? {}) as object), noDownloads: true },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: 'Download', exact: true })).toHaveCount(0);
|
||||
await page.context().close();
|
||||
} finally {
|
||||
await putPolicy(admin, saved);
|
||||
await admin.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('.eml without a connection or the viewer chunk: a message, not the error page', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await page.context().setOffline(true);
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByText("The file couldn't be opened").first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(accountSwitcher(page)).toBeVisible();
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await launch(page, { target: '/mail', files: [EML] });
|
||||
await expect(page.getByRole('heading', { name: `Launched message ${RUN}` })).toBeVisible({ timeout: 20_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.ics goes to the calendar import, which the server parses', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await shot(page, 'pwa-launch-ics');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.ics while offline says the server is needed, and carries on once back', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
// Into the calendar first: its code isn't on the device without offline mode.
|
||||
await launch(page, { target: '/calendar' });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await page.context().setOffline(true);
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await expect(page.getByText('Importing a calendar file needs a connection to the server.')).toBeVisible({ timeout: 20_000 });
|
||||
await shot(page, 'pwa-launch-ics-offline');
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('.vcf goes to the contacts import, parsed in the browser', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/contacts', files: [VCF] });
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await shot(page, 'pwa-launch-vcf');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a file launched before signing in waits for it', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await launch(page, { target: '/contacts', files: [VCF] });
|
||||
await expect(page.locator('#username')).toBeVisible();
|
||||
await signIn(page, ENT.alice);
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await expect(page.getByText(`Launched Person ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('a file launched into a signed-in page with a reload on the way still arrives', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/');
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(3);
|
||||
await page.reload();
|
||||
await signIn(page, ENT.alice);
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect.poll(() => storedShareEntries(page)).toBe(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('shortcuts', () => {
|
||||
test('New message opens a composer from the address, once, and clears the address', async ({ browser }) => {
|
||||
const page = await (await launchContext(browser)).newPage();
|
||||
await page.goto('/?compose=1');
|
||||
await signIn(page, ENT.alice);
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
expect(new URL(page.url()).search).toBe('');
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('New message in an open window', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await launch(page, { target: '/?compose=1' });
|
||||
await expect(composer(page)).toBeVisible({ timeout: 30_000 });
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('Calendar and Contacts in an open window', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/calendar' });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await launch(page, { target: '/contacts' });
|
||||
await page.waitForURL(/\/contacts/);
|
||||
await page.context().close();
|
||||
});
|
||||
|
||||
test('the plain app icon leaves an open window where it is', async ({ browser }) => {
|
||||
const page = await signedIn(browser);
|
||||
await launch(page, { target: '/calendar' });
|
||||
await page.waitForURL(/\/calendar/);
|
||||
await launch(page, { target: '/' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(new URL(page.url()).pathname).toMatch(/\/calendar(\/|$)/);
|
||||
await expect(composer(page)).toHaveCount(0);
|
||||
await page.context().close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('in the Pro shell', () => {
|
||||
const proOn = () => {
|
||||
const key = 'settings-storage';
|
||||
const parsed = JSON.parse(localStorage.getItem(key) ?? '{"state":{},"version":0}');
|
||||
parsed.state = { ...parsed.state, proInterface: true };
|
||||
localStorage.setItem(key, JSON.stringify(parsed));
|
||||
};
|
||||
|
||||
test('a shared file reaches the composer tab, and a .ics the calendar tab', async ({ browser }) => {
|
||||
const context = await launchContext(browser);
|
||||
await context.addInitScript(proOn);
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await signIn(page, ENT.alice);
|
||||
await page.waitForURL(/\/pro/);
|
||||
await workerReady(page);
|
||||
|
||||
await postShare(page, { url: 'https://example.org/pro' }, [{ name: `pro-${RUN}.txt`, type: 'text/plain', body: 'x' }]);
|
||||
await startMessage(page).click({ timeout: 60_000 });
|
||||
await signInIfAsked(page, ENT.alice);
|
||||
await page.waitForURL(/\/pro/);
|
||||
await expect(page.getByText(`pro-${RUN}.txt`).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await launch(page, { target: '/calendar', files: [ICS] });
|
||||
await expect(page.getByText(`Launched event ${RUN}`).first()).toBeVisible({ timeout: 30_000 });
|
||||
await shot(page, 'pwa-launch-pro');
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
actions: Array<{ action: string; title: string; type?: string }>;
|
||||
}
|
||||
|
||||
async function workerOf(context: BrowserContext, page: Page): Promise<Worker> {
|
||||
// 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<string, unknown>; 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<string, unknown>, reply?: string): Promise<void> {
|
||||
await sw.evaluate(async ({ action: a, data: d, reply: r }) => {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
const event = new Event('notificationclick') as Event & Record<string, unknown>;
|
||||
Object.assign(event, {
|
||||
action: a,
|
||||
reply: r ?? null,
|
||||
notification: { data: d, tag: 'spec', close() {} },
|
||||
waitUntil: (p: Promise<unknown>) => waits.push(p),
|
||||
});
|
||||
self.dispatchEvent(event);
|
||||
await Promise.all(waits);
|
||||
}, { action, data, reply });
|
||||
}
|
||||
|
||||
async function pushed(sw: Worker, accountId: string, emailId: string): Promise<ShownNotification> {
|
||||
return sw.evaluate(async ({ accountId: account, emailId: id }) => {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
const event = new Event('push') as Event & Record<string, unknown>;
|
||||
Object.assign(event, {
|
||||
data: { json: () => ({ kind: 'jmap-email-push', accountId: account, emailIds: [id] }) },
|
||||
waitUntil: (p: Promise<unknown>) => 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<string[]> {
|
||||
return sw.evaluate(() => (globalThis as unknown as { __opened: string[] }).__opened.splice(0));
|
||||
}
|
||||
|
||||
async function routePosts(sw: Worker): Promise<string[]> {
|
||||
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<string, boolean>; mailboxIds: Record<string, boolean>; 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<string | undefined> {
|
||||
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<unknown>[] = [];
|
||||
const event = new Event('sync') as Event & Record<string, unknown>;
|
||||
Object.assign(event, { tag: 'bulwark-outbox', waitUntil: (p: Promise<unknown>) => 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<Page> {
|
||||
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<string, unknown>) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FakeCaches, loadWorker } from './sw-harness';
|
||||
import {
|
||||
HANDOFF_CACHE,
|
||||
HANDOFF_MAX_BYTES,
|
||||
HANDOFF_MAX_ENTRIES,
|
||||
HANDOFF_TTL_MS,
|
||||
adoptStoredHandoff,
|
||||
clearHandoffs,
|
||||
discardStoredHandoff,
|
||||
expireHandoffs,
|
||||
handOffDraft,
|
||||
handOffFiles,
|
||||
handOffLaunchFiles,
|
||||
handOffNewMessage,
|
||||
kindForFile,
|
||||
parseLaunchLanding,
|
||||
peekStoredHandoff,
|
||||
pendingHandoffs,
|
||||
restoreStoredHandoffs,
|
||||
shareDraft,
|
||||
shortcutFromTargetUrl,
|
||||
stripLandingParams,
|
||||
subscribeHandoff,
|
||||
subscribeHandoffLost,
|
||||
takeHandoff,
|
||||
} from '../launch-handoff';
|
||||
|
||||
// Files and links from outside the app on their way to what handles them.
|
||||
|
||||
const file = (name: string, type = '', body = 'x') => new File([body], name, { type });
|
||||
const handle = (f: File) => ({ kind: 'file', getFile: async () => f });
|
||||
// Sealing runs on Web Crypto's thread pool.
|
||||
const settle = () => new Promise((r) => setTimeout(r, 60));
|
||||
const whenStored = () => vi.waitFor(() => expect(sessionStorage.getItem('bulwark:launch-handoffs')).not.toBeNull(), { timeout: 5000 });
|
||||
|
||||
function memoryStorage() {
|
||||
const backing = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => backing.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { backing.set(key, value); },
|
||||
removeItem: (key: string) => { backing.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
let caches: FakeCaches;
|
||||
|
||||
beforeEach(() => {
|
||||
caches = new FakeCaches();
|
||||
vi.stubGlobal('caches', caches);
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearHandoffs();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('where a launched file goes', () => {
|
||||
it.each([
|
||||
['mail.eml', '', 'eml'],
|
||||
['MAIL.EML', '', 'eml'],
|
||||
['invite.ics', '', 'calendar'],
|
||||
['invite.ical', '', 'calendar'],
|
||||
['people.vcf', '', 'contacts'],
|
||||
['people.vcard', '', 'contacts'],
|
||||
['noextension', 'message/rfc822', 'eml'],
|
||||
['noextension', 'text/calendar', 'calendar'],
|
||||
['noextension', 'text/x-vcard', 'contacts'],
|
||||
['photo.png', 'image/png', null],
|
||||
])('%s (%s) goes to %s', (name, type, kind) => {
|
||||
expect(kindForFile(file(name, type))).toBe(kind);
|
||||
});
|
||||
|
||||
it('splits one launch by type and names what nothing handles', async () => {
|
||||
const eml = file('a.eml');
|
||||
const ics = file('b.ics');
|
||||
const vcf = file('c.vcf');
|
||||
const result = await handOffLaunchFiles([handle(eml), handle(ics), handle(vcf), handle(file('d.png'))]);
|
||||
|
||||
expect(result).toEqual({ unsupported: ['d.png'], failed: 0 });
|
||||
expect(takeHandoff('eml')?.files).toEqual([eml]);
|
||||
expect(takeHandoff('calendar')?.files).toEqual([ics]);
|
||||
expect(takeHandoff('contacts')?.files).toEqual([vcf]);
|
||||
});
|
||||
|
||||
it('counts a file it could not read', async () => {
|
||||
const result = await handOffLaunchFiles([{ kind: 'file', getFile: async () => { throw new Error('gone'); } }]);
|
||||
expect(result).toEqual({ unsupported: [], failed: 1 });
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips a directory handle', async () => {
|
||||
const result = await handOffLaunchFiles([{ kind: 'directory' }]);
|
||||
expect(result).toEqual({ unsupported: [], failed: 0 });
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handing off', () => {
|
||||
it('gives a handoff to one taker, once', () => {
|
||||
const ics = file('a.ics');
|
||||
handOffFiles([ics], 'calendar');
|
||||
expect(takeHandoff('contacts')).toBeNull();
|
||||
expect(takeHandoff('calendar')?.files).toEqual([ics]);
|
||||
expect(takeHandoff('calendar')).toBeNull();
|
||||
});
|
||||
|
||||
it('tells the app first, while the handoff is still pending, then the taker', () => {
|
||||
const order: string[] = [];
|
||||
const offKind = subscribeHandoff('contacts', () => {
|
||||
order.push(`taker:${takeHandoff('contacts') ? 'got it' : 'nothing'}`);
|
||||
});
|
||||
const offAny = subscribeHandoff('any', (kind) => {
|
||||
order.push(`app:${kind}:${pendingHandoffs().length}`);
|
||||
});
|
||||
handOffFiles([file('a.vcf')], 'contacts');
|
||||
expect(order).toEqual(['app:contacts:1', 'taker:got it']);
|
||||
offKind();
|
||||
offAny();
|
||||
});
|
||||
|
||||
it('stops telling a listener that unsubscribed', () => {
|
||||
const listener = vi.fn();
|
||||
subscribeHandoff('calendar', listener)();
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for no files', () => {
|
||||
handOffFiles([], 'calendar');
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops what waited too long', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
expect(takeHandoff('calendar')).toBeNull();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a new message that arrives twice in one launch once', () => {
|
||||
handOffNewMessage();
|
||||
handOffNewMessage();
|
||||
expect(takeHandoff('compose')).toMatchObject({ files: [] });
|
||||
expect(takeHandoff('compose')).toBeNull();
|
||||
});
|
||||
|
||||
it('carries the text of a draft', () => {
|
||||
handOffDraft({ subject: 'Hi', body: 'There', files: [file('a.pdf')] });
|
||||
const handoff = takeHandoff('compose');
|
||||
expect(handoff).toMatchObject({ subject: 'Hi', body: 'There' });
|
||||
expect(handoff?.files.map((f) => f.name)).toEqual(['a.pdf']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('across a page load', () => {
|
||||
// A sign-in through SSO leaves the page. A new module state stands in for
|
||||
// the new page: the queue is emptied, sessionStorage and the cache stay.
|
||||
async function newPage() {
|
||||
pendingHandoffs();
|
||||
while (takeHandoffQuietly()) { /* the old page's memory goes */ }
|
||||
}
|
||||
function takeHandoffQuietly() {
|
||||
const list = pendingHandoffs() as unknown as unknown[];
|
||||
return list.length > 0 ? list.splice(0, list.length) : null;
|
||||
}
|
||||
|
||||
it('brings back a file that waited for a sign-in, with its name and type', async () => {
|
||||
handOffFiles([file('a.ics', 'text/calendar', 'BEGIN:VCALENDAR')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
expect(takeHandoff('calendar')).toBeNull();
|
||||
|
||||
await restoreStoredHandoffs();
|
||||
const handoff = takeHandoff('calendar');
|
||||
expect(handoff?.files[0].name).toBe('a.ics');
|
||||
expect(handoff?.files[0].type).toBe('text/calendar');
|
||||
expect(await handoff?.files[0].text()).toBe('BEGIN:VCALENDAR');
|
||||
});
|
||||
|
||||
it('removes the stored copy once it is taken', async () => {
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
expect((await store.keys()).length).toBe(3);
|
||||
|
||||
takeHandoff('calendar');
|
||||
await settle();
|
||||
await settle();
|
||||
expect((await store.keys()).length).toBe(0);
|
||||
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
|
||||
});
|
||||
|
||||
it('never stores a .eml: it opens at once and needs no sign-in', async () => {
|
||||
handOffFiles([file('a.eml')], 'eml');
|
||||
await settle();
|
||||
expect(await caches.has(HANDOFF_CACHE)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves another window's handoff alone", async () => {
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
// Another window: its own sessionStorage, the same cache.
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
await restoreStoredHandoffs();
|
||||
expect(takeHandoff('calendar')).toBeNull();
|
||||
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps no readable bytes, name or text at rest', async () => {
|
||||
handOffDraft({ subject: 'Secret subject', body: 'Secret text', files: [file('secret-name.txt', 'text/plain', 'secret bytes')] });
|
||||
await whenStored();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const keys = await store.keys();
|
||||
expect(keys.length).toBeGreaterThanOrEqual(3);
|
||||
for (const key of keys) {
|
||||
const stored = await (await store.match(key))!.text();
|
||||
for (const secret of ['Secret subject', 'Secret text', 'secret-name', 'secret bytes']) expect(stored).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it('cannot read it once the tab with the key is gone', async () => {
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const metaKey = (await store.keys()).find((key) => key.url.endsWith('/meta'))!.url;
|
||||
const id = metaKey.split('/').slice(-2)[0];
|
||||
expect(await adoptStoredHandoff(id)).toBe(false);
|
||||
expect(await adoptStoredHandoff(id, 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')).toBe(false);
|
||||
});
|
||||
|
||||
it('says so when a stored handoff of this tab is gone after the reload', async () => {
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
await caches.delete(HANDOFF_CACHE);
|
||||
await restoreStoredHandoffs();
|
||||
expect(losses).toEqual(['expired']);
|
||||
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
|
||||
off();
|
||||
});
|
||||
|
||||
it('says so when a stored file is missing, and delivers the rest', async () => {
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffDraft({ subject: 'S', files: [file('one.txt'), file('two.txt')] });
|
||||
await whenStored();
|
||||
await newPage();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const first = (await store.keys()).find((key) => key.url.endsWith('/0'))!;
|
||||
await store.delete(first);
|
||||
await restoreStoredHandoffs();
|
||||
expect(losses).toEqual(['missing']);
|
||||
expect(takeHandoff('compose')?.files.map((f) => f.name)).toEqual(['two.txt']);
|
||||
off();
|
||||
});
|
||||
|
||||
it('keeps no more than the limit, oldest out first', async () => {
|
||||
const created: string[] = [];
|
||||
for (let n = 0; n < HANDOFF_MAX_ENTRIES + 2; n += 1) {
|
||||
const name = `f${n}.ics`;
|
||||
handOffFiles([file(name)], 'calendar');
|
||||
created.push(name);
|
||||
await settle();
|
||||
await settle();
|
||||
}
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const metas = (await store.keys()).filter((key) => key.url.endsWith('/meta'));
|
||||
expect(metas.length).toBeLessThanOrEqual(HANDOFF_MAX_ENTRIES);
|
||||
});
|
||||
|
||||
it('does not store what is over the byte limit, and keeps it in memory', async () => {
|
||||
const big = file('big.ics');
|
||||
Object.defineProperty(big, 'size', { value: HANDOFF_MAX_BYTES + 1 });
|
||||
handOffFiles([big], 'calendar');
|
||||
await settle();
|
||||
expect(await caches.has(HANDOFF_CACHE)).toBe(false);
|
||||
expect(takeHandoff('calendar')?.files).toEqual([big]);
|
||||
});
|
||||
|
||||
it('sweeps what nobody took, and keeps what is recent', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
handOffFiles([file('old.ics')], 'calendar');
|
||||
await whenStored();
|
||||
await newPage();
|
||||
vi.stubGlobal('sessionStorage', memoryStorage());
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
handOffFiles([file('new.vcf')], 'contacts');
|
||||
await whenStored();
|
||||
|
||||
await restoreStoredHandoffs();
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
const urls = (await store.keys()).map((key) => key.url);
|
||||
expect(urls).toHaveLength(3);
|
||||
const meta = await store.match(urls.find((url) => url.endsWith('/meta'))!);
|
||||
expect(((await meta!.json()) as { kind: string }).kind).toBe('contacts');
|
||||
});
|
||||
|
||||
it('keeps working in memory without the Cache API', async () => {
|
||||
vi.stubGlobal('caches', undefined);
|
||||
const ics = file('a.ics');
|
||||
handOffFiles([ics], 'calendar');
|
||||
await settle();
|
||||
expect(takeHandoff('calendar')?.files).toEqual([ics]);
|
||||
expect(await adoptStoredHandoff('anything')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the file in memory when the cache is full', async () => {
|
||||
const original = caches.open.bind(caches);
|
||||
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => ({
|
||||
...(await original(name)),
|
||||
put: async () => { throw new DOMException('full', 'QuotaExceededError'); },
|
||||
}));
|
||||
const ics = file('a.ics');
|
||||
handOffFiles([ics], 'calendar');
|
||||
await settle();
|
||||
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
|
||||
expect(takeHandoff('calendar')?.files).toEqual([ics]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('while the page stays open', () => {
|
||||
it('drops what waited too long on a timer, and says so', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffFiles([file('a.eml')], 'eml');
|
||||
await vi.advanceTimersByTimeAsync(HANDOFF_TTL_MS + 2000);
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
expect(losses).toEqual(['expired']);
|
||||
off();
|
||||
});
|
||||
|
||||
it('says so when a taker finds only an expired handoff', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
const losses: string[] = [];
|
||||
const off = subscribeHandoffLost((loss) => losses.push(loss));
|
||||
handOffFiles([file('a.eml')], 'eml');
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 1);
|
||||
expect(takeHandoff('eml')).toBeNull();
|
||||
expect(losses).toEqual(['expired']);
|
||||
off();
|
||||
});
|
||||
|
||||
it('keeps what is still in time', async () => {
|
||||
handOffFiles([file('a.eml')], 'eml');
|
||||
await expireHandoffs();
|
||||
expect(pendingHandoffs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves the second of two drafts queued until it is taken', () => {
|
||||
handOffDraft({ subject: 'first' });
|
||||
handOffDraft({ subject: 'second' });
|
||||
expect(takeHandoff('compose')?.subject).toBe('first');
|
||||
expect(pendingHandoffs().map((h) => h.subject)).toEqual(['second']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sign-out', () => {
|
||||
it('forgets what is waiting, in memory and stored', async () => {
|
||||
handOffFiles([file('a.ics')], 'calendar');
|
||||
await whenStored();
|
||||
clearHandoffs();
|
||||
await settle();
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
expect(await caches.has(HANDOFF_CACHE)).toBe(false);
|
||||
expect(sessionStorage.getItem('bulwark:launch-handoffs')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what the service worker stored', () => {
|
||||
it('becomes a draft with the files, under the id in the address', async () => {
|
||||
// The real worker writes it, so this fails if the two layouts drift apart.
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.set('title', 'A link');
|
||||
form.set('text', 'Look at this');
|
||||
form.set('url', 'https://example.org/page');
|
||||
form.append('files', file('photo.png', 'image/png', 'bytes'));
|
||||
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
|
||||
const location = new URL(answer!.headers.get('location')!);
|
||||
const landing = parseLaunchLanding(location.search, location.hash);
|
||||
expect(landing?.type).toBe('stored');
|
||||
const { id, key } = landing as { id: string; key: string };
|
||||
|
||||
// Without the key from the address it can't be read; asking shows what it is.
|
||||
expect(await peekStoredHandoff(id, '')).toBeNull();
|
||||
expect(await peekStoredHandoff(id, key)).toEqual({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page', files: ['photo.png'] });
|
||||
expect(pendingHandoffs()).toEqual([]);
|
||||
|
||||
expect(await adoptStoredHandoff(id, key)).toBe(true);
|
||||
const handoff = takeHandoff('compose');
|
||||
expect(handoff).toMatchObject({ subject: 'A link', body: 'Look at this\n\nhttps://example.org/page' });
|
||||
expect(handoff?.files[0].name).toBe('photo.png');
|
||||
expect(handoff?.files[0].type).toBe('image/png');
|
||||
expect(await handoff?.files[0].text()).toBe('bytes');
|
||||
await settle();
|
||||
await settle();
|
||||
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0);
|
||||
});
|
||||
|
||||
it('is gone when the id names nothing', async () => {
|
||||
expect(await adoptStoredHandoff('11111111-2222', 'AAAA')).toBe(false);
|
||||
expect(await peekStoredHandoff('11111111-2222', 'AAAA')).toBeNull();
|
||||
});
|
||||
|
||||
it('goes when the user refuses it', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.append('files', file('photo.png'));
|
||||
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
|
||||
const location = new URL(answer!.headers.get('location')!);
|
||||
const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string };
|
||||
await discardStoredHandoff(id);
|
||||
expect((await (await caches.open(HANDOFF_CACHE)).keys()).length).toBe(0);
|
||||
expect(await peekStoredHandoff(id, key)).toBeNull();
|
||||
});
|
||||
|
||||
it('cleans control characters out of names and text, as for a mailto: link', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.set('title', 'Hi\r\nBcc: evil@example.org');
|
||||
form.append('files', file('a\u202Egnp.exe'));
|
||||
const answer = await worker.request({ url: 'https://mail.example.com/protocol/share', method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form } as never);
|
||||
const location = new URL(answer!.headers.get('location')!);
|
||||
const { id, key } = parseLaunchLanding(location.search, location.hash) as { id: string; key: string };
|
||||
expect(await peekStoredHandoff(id, key)).toMatchObject({ subject: 'HiBcc: evil@example.org', files: ['agnp.exe'] });
|
||||
});
|
||||
|
||||
it('is gone when it is too old', async () => {
|
||||
const store = await caches.open(HANDOFF_CACHE);
|
||||
await store.put('/__bulwark/handoff/old/meta', new Response(JSON.stringify({ kind: 'compose', createdAt: Date.now() - HANDOFF_TTL_MS - 1, bytes: 0 })));
|
||||
expect(await adoptStoredHandoff('old', 'AAAA')).toBe(false);
|
||||
expect((await store.keys()).length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text of a share', () => {
|
||||
it('puts the link under the text', () => {
|
||||
expect(shareDraft({ title: ' T ', text: 'Hello', url: 'https://a.example' })).toEqual({ subject: 'T', body: 'Hello\n\nhttps://a.example' });
|
||||
});
|
||||
|
||||
it('does not repeat a link that is in the text already', () => {
|
||||
expect(shareDraft({ text: 'See https://a.example now', url: 'https://a.example' }).body).toBe('See https://a.example now');
|
||||
});
|
||||
|
||||
it('copes with only a link', () => {
|
||||
expect(shareDraft({ url: 'https://a.example' })).toEqual({ subject: '', body: 'https://a.example' });
|
||||
});
|
||||
|
||||
it('strips control characters from the subject, so no header can be planted', () => {
|
||||
expect(shareDraft({ title: 'Hi\r\nBcc: evil@example.org' }).subject).toBe('HiBcc: evil@example.org');
|
||||
});
|
||||
|
||||
it('keeps line breaks in the body and drops other control characters', () => {
|
||||
expect(shareDraft({ text: 'one\r\ntwo\u0000\u0007' }).body).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('applies the limits of a mailto: link', () => {
|
||||
const draft = shareDraft({ title: 'x'.repeat(2000), text: 'y'.repeat(100_000), url: 'https://a.example' });
|
||||
expect(draft.subject).toHaveLength(998);
|
||||
expect(draft.body).toHaveLength(64 * 1024);
|
||||
});
|
||||
|
||||
it('applies them to a draft handed in directly as well', () => {
|
||||
handOffDraft({ subject: 'a\r\nb', body: 'y'.repeat(100_000) });
|
||||
const handoff = takeHandoff('compose');
|
||||
expect(handoff?.subject).toBe('ab');
|
||||
expect(handoff?.body).toHaveLength(64 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the address a launch lands on', () => {
|
||||
it.each([
|
||||
['?compose=1', '', { type: 'new-message' }],
|
||||
['', '#share=abc-123&key=k1', { type: 'stored', id: 'abc-123', key: 'k1' }],
|
||||
['', '#share=unavailable&share_title=T&share_text=B&share_files=1', { type: 'inline', subject: 'T', body: 'B', filesLost: true, cut: false }],
|
||||
['', '#share=unavailable&share_url=https%3A%2F%2Fa.example&share_cut=1', { type: 'inline', subject: '', body: 'https://a.example', filesLost: false, cut: true }],
|
||||
['', '#share=..%2F..%2Fetc', null],
|
||||
// Shared text stays out of the query, which reaches logs.
|
||||
['?share=unavailable&share_text=x', '', null],
|
||||
['?compose=0', '', null],
|
||||
['?openLatestUnread=1', '#x', null],
|
||||
['', '', null],
|
||||
])('%s %s', (search, hash, expected) => {
|
||||
expect(parseLaunchLanding(search, hash)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('cleans text that came in the address', () => {
|
||||
const landing = parseLaunchLanding('', '#share=unavailable&share_title=Hi%0D%0ABcc%3A%20evil');
|
||||
expect(landing).toMatchObject({ subject: 'HiBcc: evil' });
|
||||
});
|
||||
|
||||
it('leaves only what is not about the launch', () => {
|
||||
expect(stripLandingParams('?compose=1', '')).toEqual({ search: '', hash: '' });
|
||||
expect(stripLandingParams('?slot=2', '#share=unavailable&share_text=x')).toEqual({ search: '?slot=2', hash: '' });
|
||||
expect(stripLandingParams('?compose=1&slot=2', '#section')).toEqual({ search: '?slot=2', hash: '#section' });
|
||||
expect(stripLandingParams('', '#share=a&key=b')).toEqual({ search: '', hash: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a shortcut reaching an open window', () => {
|
||||
const origin = 'https://mail.example.com';
|
||||
it.each([
|
||||
[`${origin}/?compose=1`, 'new-message'],
|
||||
[`${origin}/en/?compose=1`, 'new-message'],
|
||||
[`${origin}/calendar`, 'calendar'],
|
||||
[`${origin}/de/contacts`, 'contacts'],
|
||||
// The plain app icon and links into the app don't move an open window.
|
||||
[`${origin}/`, null],
|
||||
[`${origin}/calendar/some-event`, null],
|
||||
[`${origin}/settings`, null],
|
||||
['https://evil.example/calendar', null],
|
||||
])('%s', (target, expected) => {
|
||||
expect(shortcutFromTargetUrl(target, origin)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Shortcuts, file handlers and the share target in the manifest, with what
|
||||
// the route already did (per-domain branding, the deployment subpath).
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
requestHeaders: {} as Record<string, string>,
|
||||
config: {} as Record<string, unknown>,
|
||||
sources: {} as Record<string, { source: string }>,
|
||||
}));
|
||||
|
||||
vi.mock('next/headers', () => ({
|
||||
headers: async () => new Headers(hoisted.requestHeaders),
|
||||
}));
|
||||
|
||||
// The real strings, so a missing or misnamed key fails here.
|
||||
vi.mock('next-intl/server', () => ({
|
||||
getTranslations: async ({ locale, namespace }: { locale: string; namespace: string }) => {
|
||||
const messages = JSON.parse(readFileSync(join(process.cwd(), 'locales', locale, 'common.json'), 'utf8'));
|
||||
return (key: string) => {
|
||||
const value = messages[namespace]?.[key];
|
||||
if (typeof value !== 'string') throw new Error(`missing ${namespace}.${key} in ${locale}`);
|
||||
return value;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => hoisted.config[key] ?? fallback,
|
||||
getAllWithSources: () => hoisted.sources,
|
||||
},
|
||||
}));
|
||||
|
||||
type Manifest = {
|
||||
name: string;
|
||||
shortcuts: Array<{ name: string; url: string; icons: Array<{ src: string }> }>;
|
||||
file_handlers: Array<{ action: string; accept: Record<string, string[]> }>;
|
||||
share_target: { action: string; method: string; enctype: string; params: Record<string, unknown> };
|
||||
protocol_handlers: Array<{ protocol: string; url: string }>;
|
||||
};
|
||||
|
||||
async function load(): Promise<Manifest> {
|
||||
vi.resetModules();
|
||||
const { default: manifest } = await import('@/app/manifest');
|
||||
return (await manifest()) as unknown as Manifest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
hoisted.requestHeaders = {};
|
||||
hoisted.config = {};
|
||||
hoisted.sources = {
|
||||
pwaIconUrl: { source: 'default' },
|
||||
faviconUrl: { source: 'default' },
|
||||
pwaScreenshotMobileUrl: { source: 'default' },
|
||||
pwaScreenshotDesktopUrl: { source: 'default' },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('manifest launch fields', () => {
|
||||
it('offers the three shortcuts', async () => {
|
||||
const m = await load();
|
||||
expect(m.shortcuts.map((s) => [s.name, s.url])).toEqual([
|
||||
['New message', '/?compose=1'],
|
||||
['Calendar', '/calendar'],
|
||||
['Contacts', '/contacts'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names the shortcuts in the language the browser asks in', async () => {
|
||||
hoisted.requestHeaders = { 'accept-language': 'de-DE,de;q=0.9,en;q=0.8' };
|
||||
expect((await load()).shortcuts.map((s) => s.name)).toEqual(['Neue Nachricht', 'Kalender', 'Kontakte']);
|
||||
});
|
||||
|
||||
it('falls back to English for a language it does not have', async () => {
|
||||
hoisted.requestHeaders = { 'accept-language': 'xx' };
|
||||
expect((await load()).shortcuts[0].name).toBe('New message');
|
||||
});
|
||||
|
||||
it('handles .eml, .ics and .vcf', async () => {
|
||||
const m = await load();
|
||||
const extensions = (action: string) => Object.values(m.file_handlers.find((h) => h.action === action)?.accept ?? {}).flat();
|
||||
expect(extensions('/mail')).toEqual(['.eml']);
|
||||
expect(extensions('/calendar')).toEqual(expect.arrayContaining(['.ics']));
|
||||
expect(extensions('/contacts')).toEqual(expect.arrayContaining(['.vcf']));
|
||||
expect(m.file_handlers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('takes shares as multipart POST outside /api/, with files and links', async () => {
|
||||
const { share_target: share } = await load();
|
||||
expect(share.action).toBe('/protocol/share');
|
||||
expect(share.action.startsWith('/api/')).toBe(false);
|
||||
expect(share.method).toBe('POST');
|
||||
expect(share.enctype).toBe('multipart/form-data');
|
||||
expect(share.params).toEqual({ title: 'title', text: 'text', url: 'url', files: [{ name: 'files', accept: ['*/*'] }] });
|
||||
});
|
||||
|
||||
it('prefixes every new address with the subpath', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail');
|
||||
const m = await load();
|
||||
expect(m.shortcuts.map((s) => s.url)).toEqual(['/webmail/?compose=1', '/webmail/calendar', '/webmail/contacts']);
|
||||
expect(m.shortcuts[0].icons[0].src).toBe('/webmail/icon-192x192.png');
|
||||
expect(m.file_handlers.map((h) => h.action)).toEqual(['/webmail/mail', '/webmail/calendar', '/webmail/contacts']);
|
||||
expect(m.share_target.action).toBe('/webmail/protocol/share');
|
||||
});
|
||||
|
||||
it("keeps a domain's branding, its icon on the shortcuts too", async () => {
|
||||
hoisted.requestHeaders = { host: 'mail.acme.example' };
|
||||
hoisted.config = { domainBranding: [{ host: 'mail.acme.example', appName: 'Acme Mail', pwaIconUrl: 'https://cdn.example/i.png' }] };
|
||||
const m = await load();
|
||||
expect(m.name).toBe('Acme Mail');
|
||||
expect(m.shortcuts[0].icons[0].src).toBe('/api/pwa-icon/192');
|
||||
expect(m.file_handlers).toHaveLength(3);
|
||||
expect(m.share_target.action).toBe('/protocol/share');
|
||||
});
|
||||
|
||||
it('leaves the protocol handlers as they are', async () => {
|
||||
expect((await load()).protocol_handlers.map((h) => h.url)).toEqual(['/protocol/mailto?url=%s', '/protocol/webcal?url=%s']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lite', () => {
|
||||
it('has no share target or file handlers: no service worker, no launch consumer behind them', async () => {
|
||||
const { buildManifest } = await import('@/scripts/lite/lib.mjs');
|
||||
const lite = buildManifest({ appName: 'Acme' });
|
||||
expect(lite).not.toHaveProperty('share_target');
|
||||
expect(lite).not.toHaveProperty('file_handlers');
|
||||
expect(lite).not.toHaveProperty('shortcuts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('share route without a service worker', () => {
|
||||
const route = () => import('@/app/(main)/protocol/share/route');
|
||||
|
||||
function post(form: FormData | null, contentLength?: string) {
|
||||
const request = new Request('https://mail.example.com/protocol/share', { method: 'POST', ...(form ? { body: form } : {}) });
|
||||
if (contentLength !== undefined) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('content-length', contentLength);
|
||||
return { headers, formData: () => request.formData() } as unknown as Request;
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
it('sends text and links on to a new draft, and goes to the app with a 303', async () => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('title', 'T');
|
||||
form.set('text', 'B');
|
||||
form.set('url', 'https://example.org/');
|
||||
const res = await POST(post(form, '500'));
|
||||
expect(res.status).toBe(303);
|
||||
const landing = new URL(res.headers.get('location')!, 'https://x.example');
|
||||
expect(landing.pathname).toBe('/');
|
||||
// In the fragment: it never reaches an access log.
|
||||
expect(landing.search).toBe('');
|
||||
expect(Object.fromEntries(new URLSearchParams(landing.hash.slice(1)))).toEqual({ share: 'unavailable', share_title: 'T', share_text: 'B', share_url: 'https://example.org/' });
|
||||
});
|
||||
|
||||
it('says files were lost', async () => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('text', 'B');
|
||||
form.append('files', new File(['x'], 'a.png'));
|
||||
const res = await POST(post(form, '500'));
|
||||
expect(res.headers.get('location')).toContain('share_files=1');
|
||||
});
|
||||
|
||||
it('does not read a large upload into memory', async () => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('text', 'B');
|
||||
const spy = vi.fn(async () => form);
|
||||
const request = { headers: new Headers({ 'content-length': String(50 * 1024 * 1024) }), formData: spy } as unknown as Request;
|
||||
const res = await POST(request);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(res.headers.get('location')).toContain('share_files=1');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Latin', 'x'],
|
||||
['Japanese, 9 encoded bytes each', 'あ'],
|
||||
['emoji, whole characters only', '😀'],
|
||||
])('keeps the address short and says it cut: %s', async (_name, character) => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('text', character.repeat(3000));
|
||||
form.set('title', character.repeat(3000));
|
||||
form.set('url', character.repeat(3000));
|
||||
const res = await POST(post(form, '100000'));
|
||||
const location = res.headers.get('location')!;
|
||||
expect(location.length).toBeLessThan(4000);
|
||||
const fragment = new URLSearchParams(new URL(location, 'https://x.example').hash.slice(1));
|
||||
expect(fragment.get('share_cut')).toBe('1');
|
||||
// Decodes cleanly: no half a character at the end.
|
||||
expect(fragment.get('share_text')!.endsWith(character)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not say it cut what fits', async () => {
|
||||
const { POST } = await route();
|
||||
const form = new FormData();
|
||||
form.set('text', 'あ'.repeat(100));
|
||||
const res = await POST(post(form, '1000'));
|
||||
expect(res.headers.get('location')).not.toContain('share_cut');
|
||||
});
|
||||
|
||||
it('honours the subpath', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail');
|
||||
vi.resetModules();
|
||||
const { GET } = await route();
|
||||
expect(GET().headers.get('location')).toBe('/webmail/');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<Login | null> = [];
|
||||
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<string, unknown> = {};
|
||||
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, unknown>, string]>;
|
||||
const writes: Array<{ auth: string; url: string; calls: Calls }> = [];
|
||||
let session: Record<string, unknown>;
|
||||
let sessionStatus = 200;
|
||||
let mailboxes: Array<Record<string, unknown>>;
|
||||
let email: Record<string, unknown> | null;
|
||||
let emailSetResult: Record<string, unknown> | null = null;
|
||||
// The answer to each Mailbox/set in turn; `undefined`: everything was created.
|
||||
let mailboxSetResults: Array<Record<string, unknown>> = [];
|
||||
let mailboxReads = 0;
|
||||
let mailboxesOnSecondRead: Array<Record<string, unknown>> | null = null;
|
||||
let emailSetResults: Array<Record<string, unknown>> = [];
|
||||
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<string, string>; 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<string, unknown>)[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<string, unknown>, headers: Record<string, string> = { '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<string, unknown> = {}) => ({
|
||||
action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over,
|
||||
});
|
||||
const archive = async (over: Record<string, unknown> = {}) => ({ ...(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<string, Record<string, unknown>> };
|
||||
};
|
||||
const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record<string, Record<string, unknown>> } | 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.mock('@/lib/plugin-storage', () => ({
|
||||
|
||||
import { fileStorage } from '@/lib/plugin-storage';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup';
|
||||
import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff';
|
||||
|
||||
// A full sign-out left search history, open tabs (with subjects), Files
|
||||
// favourites and staged attachment bytes behind for the next person at the
|
||||
@@ -14,6 +15,13 @@ import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/li
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe('purgeSignedOutData', () => {
|
||||
it('forgets files and links on their way into an account', () => {
|
||||
handOffFiles([new File(['x'], 'a.ics')], 'calendar');
|
||||
expect(pendingHandoffs()).toHaveLength(1);
|
||||
purgeSignedOutData();
|
||||
expect(pendingHandoffs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("removes the accounts' leftovers and keeps device preferences", () => {
|
||||
for (const key of [
|
||||
'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage',
|
||||
|
||||
@@ -29,17 +29,41 @@ export class FakeCaches {
|
||||
}
|
||||
}
|
||||
|
||||
export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise<Response> }) {
|
||||
export interface ShownNotification {
|
||||
title: string;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function loadWorker(opts: {
|
||||
buildId: string;
|
||||
caches: FakeCaches;
|
||||
clients: Array<{ id: string }>;
|
||||
fetch?: (input: unknown, init?: RequestInit) => Promise<Response>;
|
||||
/** More globals of a real worker: Notification, NotificationEvent, MessageChannel. */
|
||||
globals?: Record<string, unknown>;
|
||||
openWindow?: (url: string) => Promise<unknown>;
|
||||
/** What showNotification does, after it was recorded in `shown`. */
|
||||
showNotification?: (title: string, options: Record<string, unknown>) => Promise<void>;
|
||||
notifications?: Array<Record<string, unknown>>;
|
||||
location?: string;
|
||||
}) {
|
||||
const listeners = new Map<string, (event: unknown) => 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' },
|
||||
location: { href: opts.location ?? `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<string, unknown>) => {
|
||||
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, crypto: globalThis.crypto, TextEncoder, btoa, ...opts.globals });
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
async function dispatch(type: string, event: Record<string, unknown> = {}) {
|
||||
const waits: Promise<unknown>[] = [];
|
||||
@@ -52,5 +76,5 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients:
|
||||
listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise<Response>) => { answer = p; } });
|
||||
return answer ? await answer : null;
|
||||
}
|
||||
return { dispatch, listeners, request, skipped: () => skipped };
|
||||
return { dispatch, listeners, request, shown, skipped: () => skipped };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
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<string, unknown>, 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<Response>;
|
||||
max?: number | null;
|
||||
inline?: boolean;
|
||||
showNotification?: (title: string, options: Record<string, unknown>) => Promise<void>;
|
||||
notifications?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
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<string, unknown> = {}, data: Record<string, unknown> = 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<string, unknown>) => async (w: ReturnType<typeof worker>) => {
|
||||
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<Record<string, unknown>> | 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<string, unknown>, 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { FakeCaches, loadWorker } from './sw-harness';
|
||||
|
||||
// The manifest's share_target POST, in the service worker, and a mailto:
|
||||
// launch while offline. Any site can POST here, signed in or not, so what the
|
||||
// worker keeps is limited and sealed. Before: a POST went to the server when
|
||||
// offline mode was off, and files sat readable in the cache with no limit.
|
||||
|
||||
const ORIGIN = 'https://mail.example.com';
|
||||
const CACHE = 'bulwark-launch-handoff-v1';
|
||||
const TTL = 15 * 60 * 1000;
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function shareRequest(form: FormData, overrides: Record<string, unknown> = {}) {
|
||||
return { url: `${ORIGIN}/protocol/share`, method: 'POST', mode: 'navigate', destination: 'document', formData: async () => form, ...overrides } as never;
|
||||
}
|
||||
|
||||
function sharedForm(): FormData {
|
||||
const form = new FormData();
|
||||
form.set('title', 'A link');
|
||||
form.set('text', 'Look');
|
||||
form.set('url', 'https://example.org/');
|
||||
form.append('files', new File(['secret bytes'], 'photo.png', { type: 'image/png' }));
|
||||
return form;
|
||||
}
|
||||
|
||||
const landingOf = (answer: Response | null) => {
|
||||
const url = new URL(answer!.headers.get('location')!);
|
||||
return { url, fragment: new URLSearchParams(url.hash.slice(1)) };
|
||||
};
|
||||
|
||||
async function shellOn(caches: FakeCaches, on: boolean) {
|
||||
const cache = await caches.open('bulwark-sw-state-v1');
|
||||
await cache.put('/__bulwark/offline-shell', new Response(JSON.stringify({ on })));
|
||||
}
|
||||
|
||||
async function plantEntry(caches: FakeCaches, id: string, createdAt: number, bytes = 0) {
|
||||
const store = await caches.open(CACHE);
|
||||
await store.put(`/__bulwark/handoff/${id}/meta`, new Response(JSON.stringify({ kind: 'compose', createdAt, bytes })));
|
||||
await store.put(`/__bulwark/handoff/${id}/sealed`, new Response('x'));
|
||||
}
|
||||
|
||||
async function entryIds(caches: FakeCaches): Promise<string[]> {
|
||||
const store = await caches.open(CACHE);
|
||||
return (await store.keys()).filter((key) => key.url.endsWith('/meta')).map((key) => key.url.split('/').slice(-2)[0]);
|
||||
}
|
||||
|
||||
function htmlPage(body: string) {
|
||||
const res = new Response(body, { status: 200, headers: { 'Content-Type': 'text/html' } });
|
||||
Object.defineProperty(res, 'type', { value: 'basic' });
|
||||
return res;
|
||||
}
|
||||
|
||||
describe('share target', () => {
|
||||
it.each([[false], [true]])('takes the POST with offline mode on=%s, keeps the files sealed and goes to the app', async (on) => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, on);
|
||||
const fetchMock = vi.fn();
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock });
|
||||
await settle();
|
||||
|
||||
const answer = await worker.request(shareRequest(sharedForm()));
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(answer?.status).toBe(303);
|
||||
const { url, fragment } = landingOf(answer);
|
||||
expect(`${url.origin}${url.pathname}`).toBe(`${ORIGIN}/`);
|
||||
// Id and key in the fragment: neither ever reaches the server.
|
||||
expect(url.search).toBe('');
|
||||
const id = fragment.get('share')!;
|
||||
expect(id).toMatch(/^[\w-]+$/);
|
||||
expect(fragment.get('key')).toMatch(/^[\w-]{43}$/);
|
||||
|
||||
const store = await caches.open(CACHE);
|
||||
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as Record<string, unknown>;
|
||||
expect(meta).toMatchObject({ kind: 'compose', bytes: 12 });
|
||||
// Only what a sweep needs is readable.
|
||||
expect(Object.keys(meta).sort()).toEqual(['bytes', 'createdAt', 'kind']);
|
||||
for (const part of ['meta', 'sealed', '0']) {
|
||||
const text = await (await store.match(`/__bulwark/handoff/${id}/${part}`))!.text();
|
||||
for (const secret of ['secret bytes', 'photo.png', 'A link', 'example.org']) expect(text).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
it('answers while the worker has only just started and has not read its state', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] });
|
||||
const answer = await worker.request(shareRequest(sharedForm()));
|
||||
expect(answer?.status).toBe(303);
|
||||
});
|
||||
|
||||
it('works under a subpath', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [], location: `${ORIGIN}/webmail/sw.js?v=a` });
|
||||
expect(await worker.request(shareRequest(sharedForm()))).toBeNull();
|
||||
const answer = await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/webmail/protocol/share` }));
|
||||
expect(answer!.headers.get('location')).toContain(`${ORIGIN}/webmail/#share=`);
|
||||
});
|
||||
|
||||
it('skips empty file fields', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.set('url', 'https://example.org/');
|
||||
form.append('files', new File([], ''));
|
||||
const answer = await worker.request(shareRequest(form));
|
||||
const id = landingOf(answer).fragment.get('share')!;
|
||||
const store = await caches.open(CACHE);
|
||||
const meta = (await (await store.match(`/__bulwark/handoff/${id}/meta`))!.json()) as { bytes: number };
|
||||
expect(meta.bytes).toBe(0);
|
||||
expect(await store.match(`/__bulwark/handoff/${id}/0`)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the text and says the files are lost when there is no room', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const original = caches.open.bind(caches);
|
||||
const store = await original(CACHE);
|
||||
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE
|
||||
? { ...store, put: async () => { throw new Error('QuotaExceededError'); } }
|
||||
: original(name)));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
|
||||
const { fragment } = landingOf(await worker.request(shareRequest(sharedForm())));
|
||||
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.get('share_files')).toBe('1');
|
||||
expect(fragment.get('share_title')).toBe('A link');
|
||||
expect(fragment.get('share_url')).toBe('https://example.org/');
|
||||
expect((await store.keys()).length).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps that address short too, and says it cut', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const original = caches.open.bind(caches);
|
||||
const store = await original(CACHE);
|
||||
vi.spyOn(caches, 'open').mockImplementation(async (name: string) => (name === CACHE
|
||||
? { ...store, put: async () => { throw new Error('QuotaExceededError'); } }
|
||||
: original(name)));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const form = new FormData();
|
||||
form.set('text', 'あ'.repeat(3000));
|
||||
|
||||
const answer = await worker.request(shareRequest(form));
|
||||
|
||||
expect(answer!.headers.get('location')!.length).toBeLessThan(4000);
|
||||
expect(landingOf(answer).fragment.get('share_cut')).toBe('1');
|
||||
});
|
||||
|
||||
it('says so when the form cannot be read', async () => {
|
||||
const worker = loadWorker({ buildId: 'a', caches: new FakeCaches(), clients: [] });
|
||||
const answer = await worker.request(shareRequest(new FormData(), { formData: async () => { throw new TypeError('bad body'); } }));
|
||||
const { fragment } = landingOf(answer);
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.get('share_files')).toBe('1');
|
||||
});
|
||||
|
||||
it('leaves other requests alone', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, false);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await settle();
|
||||
// A fetch from a page, a POST elsewhere, another origin, and a plain GET.
|
||||
expect(await worker.request(shareRequest(sharedForm(), { mode: 'cors' }))).toBeNull();
|
||||
expect(await worker.request(shareRequest(sharedForm(), { url: `${ORIGIN}/api/share` }))).toBeNull();
|
||||
expect(await worker.request(shareRequest(sharedForm(), { url: 'https://other.example/protocol/share' }))).toBeNull();
|
||||
expect(await worker.request({ url: `${ORIGIN}/protocol/share`, mode: 'navigate', destination: 'document' } as never)).toBeNull();
|
||||
expect(await caches.has(CACHE)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves a POST into a hidden frame to the network: it stores nothing', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, false);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await settle();
|
||||
expect(await worker.request(shareRequest(sharedForm(), { destination: 'iframe' }))).toBeNull();
|
||||
expect(await worker.request(shareRequest(sharedForm(), { destination: 'embed' }))).toBeNull();
|
||||
expect(await caches.has(CACHE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('limits on what the worker keeps', () => {
|
||||
it('drops the oldest entries over the entry limit, on every share', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const now = Date.now();
|
||||
for (let n = 0; n < 5; n += 1) await plantEntry(caches, `old-${n}`, now - 1000 * (5 - n));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
|
||||
const answer = await worker.request(shareRequest(sharedForm()));
|
||||
|
||||
const ids = await entryIds(caches);
|
||||
expect(ids).toHaveLength(5);
|
||||
expect(ids).not.toContain('old-0');
|
||||
expect(ids).toContain('old-1');
|
||||
expect(ids).toContain(landingOf(answer).fragment.get('share'));
|
||||
// Everything of the dropped one goes, not only its list.
|
||||
expect(await (await caches.open(CACHE)).match('/__bulwark/handoff/old-0/sealed')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops the oldest entries over the byte limit', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const now = Date.now();
|
||||
await plantEntry(caches, 'big', now - 2000, 100 * 1024 * 1024 - 5);
|
||||
await plantEntry(caches, 'small', now - 1000, 2);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
|
||||
await worker.request(shareRequest(sharedForm()));
|
||||
|
||||
const ids = await entryIds(caches);
|
||||
expect(ids).not.toContain('big');
|
||||
expect(ids).toContain('small');
|
||||
});
|
||||
|
||||
it('stores nothing from a share over the byte limit, says the files are lost, and keeps the rest', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await plantEntry(caches, 'kept', Date.now() - 1000, 10);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
const big = new File(['x'], 'huge.bin');
|
||||
Object.defineProperty(big, 'size', { value: 100 * 1024 * 1024 + 1 });
|
||||
const fields = new Map<string, unknown>([['title', 'T']]);
|
||||
const form = { get: (name: string) => fields.get(name) ?? null, getAll: () => [big] } as unknown as FormData;
|
||||
|
||||
const { fragment } = landingOf(await worker.request(shareRequest(form)));
|
||||
|
||||
expect(fragment.get('share')).toBe('unavailable');
|
||||
expect(fragment.get('share_files')).toBe('1');
|
||||
expect(await entryIds(caches)).toEqual(['kept']);
|
||||
});
|
||||
|
||||
it('sweeps what is too old on every share', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await plantEntry(caches, 'stale', Date.now() - TTL - 1);
|
||||
await plantEntry(caches, 'fresh', Date.now() - 1000);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await worker.request(shareRequest(sharedForm()));
|
||||
const ids = await entryIds(caches);
|
||||
expect(ids).not.toContain('stale');
|
||||
expect(ids).toContain('fresh');
|
||||
});
|
||||
|
||||
it('sweeps what is too old when it activates', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await plantEntry(caches, 'stale', Date.now() - TTL - 1);
|
||||
await plantEntry(caches, 'fresh', Date.now() - 1000);
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await worker.dispatch('activate');
|
||||
expect(await entryIds(caches)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('creates no cache when it activates with nothing kept', async () => {
|
||||
const caches = new FakeCaches();
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await worker.dispatch('activate');
|
||||
expect(await caches.has(CACHE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a mailto: launch without a connection', () => {
|
||||
it('gets the kept handler page instead of a network error', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const online = vi.fn(async () => htmlPage('<html>mailto page'));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: online });
|
||||
await settle();
|
||||
|
||||
await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls: ['/protocol/mailto'], buildId: 'a' } });
|
||||
await settle();
|
||||
|
||||
online.mockImplementation(async () => { throw new TypeError('offline'); });
|
||||
const answer = await worker.request({ url: `${ORIGIN}/protocol/mailto?url=mailto%3Aa%40example.org`, mode: 'navigate' });
|
||||
expect(await answer!.text()).toBe('<html>mailto page');
|
||||
});
|
||||
|
||||
it('falls back to the start page when that page was never kept', async () => {
|
||||
const caches = new FakeCaches();
|
||||
await shellOn(caches, true);
|
||||
const shell = await caches.open('bulwark-shell-a');
|
||||
await shell.put(ORIGIN, htmlPage('<html>start'));
|
||||
const worker = loadWorker({ buildId: 'a', caches, clients: [] });
|
||||
await settle();
|
||||
|
||||
const answer = await worker.request({ url: `${ORIGIN}/protocol/webcal?url=x`, mode: 'navigate' });
|
||||
expect(await answer!.text()).toBe('<html>start');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
+5
-2
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> = {}): 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<SearchEmailsResult> => {
|
||||
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<typeof side>, local: ReturnType<typeof side> | 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<typeof side>, local: ReturnType<typeof side>) {
|
||||
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<Email[]>,
|
||||
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<string, boolean> | 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<Record<string, unknown>>;
|
||||
let serverFails: boolean;
|
||||
let requests: Array<Array<[string, Record<string, unknown>, string]>>;
|
||||
/** What Email/get by id finds on the server, past the search's own rows. */
|
||||
let serverKnows: Array<Record<string, unknown>>;
|
||||
|
||||
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, unknown>, 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<JMAPClient> {
|
||||
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('Zahlungserinnerungs<mark>rechnung</mark>');
|
||||
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('Zahlungserinnerungs<mark>rechnung</mark>');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||
/** 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<string, { type?: string; description?: string }> }>;
|
||||
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string, options?: CalendarQueryOptions): Promise<CalendarEvent[]>;
|
||||
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, options?: CalendarQueryOptions): Promise<CalendarEvent[]>;
|
||||
|
||||
+179
-103
@@ -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";
|
||||
@@ -1638,6 +1640,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(/\/$/, '');
|
||||
@@ -2214,9 +2218,30 @@ export class JMAPClient implements IJMAPClient {
|
||||
const offline = getOfflineHooks();
|
||||
if (this.offlineOnly || offline?.skipNetwork(this)) {
|
||||
const local = offline ? 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<string, string> | null = null;
|
||||
if (standIns.length > 0) {
|
||||
const resolved = await offline!.resolveStandIns!(this, standIns);
|
||||
if (standIns.some((id) => !resolved[id])) {
|
||||
const local = await offline!.answer(this, methodCalls);
|
||||
if (local) {
|
||||
this.lastAnswerFromDevice = true;
|
||||
return local as JMAPResponse;
|
||||
}
|
||||
throw new OfflineUnavailableError();
|
||||
}
|
||||
realIds = resolved as Record<string, string>;
|
||||
methodCalls = withRealIds(methodCalls, realIds);
|
||||
}
|
||||
const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response);
|
||||
let answer: JMAPResponse;
|
||||
try {
|
||||
answer = await this.networkRequest(methodCalls, using, opts);
|
||||
@@ -2225,15 +2250,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)) {
|
||||
const local = 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;
|
||||
}
|
||||
|
||||
private async networkRequest(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean }): Promise<JMAPResponse> {
|
||||
@@ -4411,61 +4446,144 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise<SearchEmailsResult> {
|
||||
// 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<string, unknown> = 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<string, unknown>,
|
||||
accountId: string | undefined,
|
||||
limit: number,
|
||||
position: number,
|
||||
options: SearchRequestOptions,
|
||||
): Promise<SearchEmailsResult> {
|
||||
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<string, unknown>, accountId: string | undefined): Promise<Email[]> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
accountId: string | undefined,
|
||||
limit: number,
|
||||
position: number,
|
||||
options: SearchRequestOptions,
|
||||
): Promise<SearchEmailsResult> {
|
||||
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<string, unknown>;
|
||||
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);
|
||||
@@ -4537,52 +4655,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
position: number = 0,
|
||||
options: SearchRequestOptions = {},
|
||||
): Promise<SearchEmailsResult> {
|
||||
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<string, unknown>);
|
||||
// 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<string, unknown>);
|
||||
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);
|
||||
|
||||
@@ -45,6 +45,18 @@ export interface OfflineHooks {
|
||||
recordBlob(client: OfflineClientRef, blobId: string, accountId: string, blob: Blob): void;
|
||||
/** Keep a file to upload later; the stand-in blob id, or null when offline mode doesn't cover the client. */
|
||||
holdUpload(client: OfflineClientRef, file: File, accountId: string): Promise<{ blobId: string; size: number; type: string } | null>;
|
||||
/**
|
||||
* Search calls answered from the local store while the server is asked too
|
||||
* (lib/jmap/search-merge.ts). Null when the store doesn't cover the client.
|
||||
* Left out when there is no store, and searches then go to the server alone.
|
||||
*/
|
||||
searchLocal?(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise<OfflineResponse | null>;
|
||||
/**
|
||||
* 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<Record<string, string | null>>;
|
||||
}
|
||||
|
||||
const KEY = '__bulwarkOfflineHooks';
|
||||
|
||||
@@ -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<string, unknown>): boolean {
|
||||
if (typeof filter.operator === 'string') {
|
||||
if (filter.operator === 'NOT' || !Array.isArray(filter.conditions)) return false;
|
||||
return (filter.conditions as Array<Record<string, unknown>>).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<string, unknown>, boxes: readonly string[]): boolean | null {
|
||||
if (typeof filter.operator === 'string') {
|
||||
const answers = (Array.isArray(filter.conditions) ? filter.conditions as Array<Record<string, unknown>> : [])
|
||||
.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<string, unknown>, mailboxIds: Record<string, boolean> | 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<string>;
|
||||
/** 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<string>,
|
||||
): 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<T>(promise: Promise<T | null>, ms: number): Promise<T | null> {
|
||||
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<T>(what: string, call: () => Promise<T | null>): Promise<T | null> {
|
||||
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<SearchEmailsResult>;
|
||||
/** Null when the store doesn't cover this account. */
|
||||
local(position: number): Promise<SearchEmailsResult | null>;
|
||||
/** The server's current copies of these rows, leaving out what's gone or no longer fits the search. */
|
||||
verify?(rows: Email[]): Promise<Email[]>;
|
||||
/** Highlights for rows only the store has. */
|
||||
localSnippets?(ids: string[]): Promise<SearchSnippetResult[] | null>;
|
||||
/** 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<string, CursorEntry>();
|
||||
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<SearchEmailsResult> {
|
||||
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<SearchEmailsResult> {
|
||||
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<SearchEmailsResult> {
|
||||
// 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<Email[]> {
|
||||
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<void> {
|
||||
const asked = safely('Local snippets', () => (request.localSnippets as NonNullable<MergedSearchRequest['localSnippets']>)(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));
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<T>(map: Record<string, T>, rename: (id: string) => string): Record<string, T> {
|
||||
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<T extends OfflineMethodCall>(calls: readonly T[], real: Readonly<Record<string, string>>): 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<string, unknown>, 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<T extends OfflineResponse>(response: T, real: Readonly<Record<string, string>>): 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];
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export interface Email {
|
||||
// subject / body excerpt with the matched terms in <mark>, 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<string, EmailBodyValue>;
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
/**
|
||||
* Files and links that arrive from outside the app, on their way to the part
|
||||
* of the app that handles them: a .eml to the viewer, a .ics to the calendar
|
||||
* import, a .vcf to the contacts import, shared files and links to a new
|
||||
* draft.
|
||||
*
|
||||
* Three things feed it: the launchQueue consumer
|
||||
* (components/protocol/protocol-launch-handler-provider.tsx), the service
|
||||
* worker's share target (public/sw.js) and, later, the desktop app's bridge,
|
||||
* because launchQueue never fires in Electron. A bridge only has to call
|
||||
* handOffFiles, handOffDraft or handOffNewMessage.
|
||||
*
|
||||
* The pending mailto next to it (lib/protocol-handlers/session.ts) is JSON in
|
||||
* sessionStorage, which can't hold a File. So a handoff lives in this module's
|
||||
* memory, and everything except a .eml also goes into the Cache API: signing
|
||||
* in with SSO, or after a session ended, leaves the page, and the file has to
|
||||
* still be there afterwards.
|
||||
*
|
||||
* What is stored is sealed with a key of its own, and the key stays in this
|
||||
* tab's sessionStorage, so a second window can't take it. The worker passes
|
||||
* its key in the landing address's fragment. What is stored goes when it is
|
||||
* taken, after HANDOFF_TTL_MS, when newer handoffs need the room, at sign-out
|
||||
* and when offline mode wipes.
|
||||
*
|
||||
* Anyone can POST to the share target, or link to a landing address, so what
|
||||
* arrives that way is untrusted: the provider asks the user before it gets
|
||||
* here (peekStoredHandoff, then adoptStoredHandoff or discardStoredHandoff).
|
||||
* Chromium's link capturing can bring a cross-site form POST into an installed
|
||||
* app's window, so that goes for app windows too. Only a bridge skips it.
|
||||
*/
|
||||
|
||||
import { stripBidiControls } from '@/lib/bidi';
|
||||
import { matchSurface } from '@/lib/deep-links';
|
||||
import { cleanMailtoBody, cleanMailtoSubject } from '@/lib/protocol-handlers/mailto';
|
||||
|
||||
export type HandoffKind = 'compose' | 'eml' | 'calendar' | 'contacts';
|
||||
|
||||
export interface Handoff {
|
||||
id: string;
|
||||
kind: HandoffKind;
|
||||
files: File[];
|
||||
/** New draft only. */
|
||||
subject?: string;
|
||||
body?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** The service worker writes this cache too, in the same layout and with the same limits. */
|
||||
export const HANDOFF_CACHE = 'bulwark-launch-handoff-v1';
|
||||
export const HANDOFF_TTL_MS = 15 * 60 * 1000;
|
||||
export const HANDOFF_MAX_ENTRIES = 5;
|
||||
export const HANDOFF_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const STORE_PREFIX = '/__bulwark/handoff/';
|
||||
const TAB_KEYS_KEY = 'bulwark:launch-handoffs';
|
||||
/** A launch can arrive twice: as launchQueue's target and as the page's own address. */
|
||||
const NEW_MESSAGE_REPEAT_MS = 2000;
|
||||
|
||||
type Listener = (kind: HandoffKind) => void;
|
||||
export type HandoffLoss = 'expired' | 'missing';
|
||||
|
||||
const queue: Handoff[] = [];
|
||||
const stored = new Map<string, Promise<void>>();
|
||||
const kindListeners = new Map<HandoffKind, Set<Listener>>();
|
||||
const anyListeners = new Set<Listener>();
|
||||
const lossListeners = new Set<(loss: HandoffLoss) => void>();
|
||||
let lastNewMessageAt = 0;
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** Bumped by clearHandoffs: a store still under way when it ran takes itself back. */
|
||||
let clearCount = 0;
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function cacheApi(): CacheStorage | null {
|
||||
return typeof caches !== 'undefined' && caches && typeof caches.open === 'function' ? caches : null;
|
||||
}
|
||||
|
||||
function readTabKeys(): Record<string, string> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(sessionStorage.getItem(TAB_KEYS_KEY) ?? '{}');
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, string> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeTabKeys(keys: Record<string, string>): void {
|
||||
try {
|
||||
if (Object.keys(keys).length > 0) sessionStorage.setItem(TAB_KEYS_KEY, JSON.stringify(keys));
|
||||
else sessionStorage.removeItem(TAB_KEYS_KEY);
|
||||
} catch {
|
||||
// Hardened or private modes: the handoff stays in memory only.
|
||||
}
|
||||
}
|
||||
|
||||
function setTabKey(id: string, key: string | null): void {
|
||||
const keys = readTabKeys();
|
||||
if (key) keys[id] = key;
|
||||
else delete keys[id];
|
||||
writeTabKeys(keys);
|
||||
}
|
||||
|
||||
function toBase64Url(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(text: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** A 12-byte IV, then the AES-GCM ciphertext. */
|
||||
async function seal(key: CryptoKey, data: BufferSource): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const body = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data));
|
||||
const sealed = new Uint8Array(new ArrayBuffer(iv.length + body.length));
|
||||
sealed.set(iv);
|
||||
sealed.set(body, iv.length);
|
||||
return sealed;
|
||||
}
|
||||
|
||||
async function unseal(key: CryptoKey, sealed: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.slice(0, 12) }, key, sealed.slice(12));
|
||||
}
|
||||
|
||||
function canSeal(): boolean {
|
||||
return typeof crypto !== 'undefined' && !!crypto.subtle;
|
||||
}
|
||||
|
||||
function entryPrefix(id: string): string {
|
||||
return `${STORE_PREFIX}${id}/`;
|
||||
}
|
||||
|
||||
function pathOf(key: { url: string }): string {
|
||||
return new URL(key.url, 'https://x.invalid').pathname;
|
||||
}
|
||||
|
||||
async function deleteEntry(cache: Cache, prefix: string): Promise<void> {
|
||||
const keys = await cache.keys();
|
||||
const own = keys.filter((key) => pathOf(key).startsWith(prefix));
|
||||
// The list last: the sweep goes by it.
|
||||
await Promise.all(own.filter((key) => !pathOf(key).endsWith('/meta')).map((key) => cache.delete(key)));
|
||||
await cache.delete(`${prefix}meta`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops what is too old and, when `incoming` wants in, the oldest entries that
|
||||
* are in the way. Returns when the oldest remaining entry was stored.
|
||||
*/
|
||||
async function sweep(cache: Cache, incoming: { bytes: number } | null): Promise<number | null> {
|
||||
const entries: Array<{ prefix: string; createdAt: number; bytes: number }> = [];
|
||||
for (const key of await cache.keys()) {
|
||||
const path = pathOf(key);
|
||||
if (!path.endsWith('/meta')) continue;
|
||||
const response = await cache.match(key);
|
||||
const meta = response ? ((await response.json().catch(() => null)) as { createdAt?: unknown; bytes?: unknown } | null) : null;
|
||||
entries.push({
|
||||
prefix: path.slice(0, -'meta'.length),
|
||||
createdAt: typeof meta?.createdAt === 'number' ? meta.createdAt : 0,
|
||||
bytes: typeof meta?.bytes === 'number' ? meta.bytes : 0,
|
||||
});
|
||||
}
|
||||
entries.sort((a, b) => a.createdAt - b.createdAt);
|
||||
const now = Date.now();
|
||||
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
|
||||
while (entries.length > 0) {
|
||||
const tooOld = now - entries[0].createdAt > HANDOFF_TTL_MS;
|
||||
const inTheWay = incoming !== null
|
||||
&& (entries.length >= HANDOFF_MAX_ENTRIES || total + incoming.bytes > HANDOFF_MAX_BYTES);
|
||||
if (!tooOld && !inTheWay) break;
|
||||
const [oldest] = entries.splice(0, 1);
|
||||
total -= oldest.bytes;
|
||||
await deleteEntry(cache, oldest.prefix);
|
||||
}
|
||||
return entries[0]?.createdAt ?? null;
|
||||
}
|
||||
|
||||
async function writeStored(handoff: Handoff): Promise<void> {
|
||||
const api = cacheApi();
|
||||
if (!api || !canSeal()) return;
|
||||
const bytes = handoff.files.reduce((sum, file) => sum + file.size, 0);
|
||||
if (bytes > HANDOFF_MAX_BYTES) return;
|
||||
const prefix = entryPrefix(handoff.id);
|
||||
const startedAt = clearCount;
|
||||
try {
|
||||
const cache = await api.open(HANDOFF_CACHE);
|
||||
await sweep(cache, { bytes });
|
||||
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key));
|
||||
// The list first: the sweep goes by it, so no file can stay behind unlisted.
|
||||
await cache.put(`${prefix}meta`, new Response(JSON.stringify({ kind: handoff.kind, createdAt: handoff.createdAt, bytes })));
|
||||
const contents = JSON.stringify({
|
||||
title: handoff.subject ?? '',
|
||||
text: handoff.body ?? '',
|
||||
url: '',
|
||||
files: handoff.files.map((file) => ({ name: file.name, type: file.type })),
|
||||
});
|
||||
await cache.put(`${prefix}sealed`, new Response(await seal(key, new TextEncoder().encode(contents))));
|
||||
for (const [index, file] of handoff.files.entries()) {
|
||||
await cache.put(`${prefix}${index}`, new Response(await seal(key, await file.arrayBuffer())));
|
||||
}
|
||||
if (startedAt !== clearCount) {
|
||||
await deleteEntry(cache, prefix);
|
||||
return;
|
||||
}
|
||||
setTabKey(handoff.id, toBase64Url(rawKey));
|
||||
} catch {
|
||||
// Quota, or no Cache API here: the handoff stays in memory only.
|
||||
await deleteStored(handoff.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStored(id: string): Promise<void> {
|
||||
setTabKey(id, null);
|
||||
const api = cacheApi();
|
||||
if (!api) return;
|
||||
try {
|
||||
if (!(await api.has(HANDOFF_CACHE))) return;
|
||||
await deleteEntry(await api.open(HANDOFF_CACHE), entryPrefix(id));
|
||||
} catch {
|
||||
// The sweep gets it.
|
||||
}
|
||||
}
|
||||
|
||||
function notify(kind: HandoffKind): void {
|
||||
// The app first, so it can go to the right page while the handoff is still
|
||||
// pending, then whoever takes it.
|
||||
for (const listener of [...anyListeners]) listener(kind);
|
||||
for (const listener of [...(kindListeners.get(kind) ?? [])]) listener(kind);
|
||||
}
|
||||
|
||||
function reportLoss(loss: HandoffLoss): void {
|
||||
for (const listener of [...lossListeners]) listener(loss);
|
||||
}
|
||||
|
||||
function dropFromQueue(index: number): Handoff {
|
||||
const [handoff] = queue.splice(index, 1);
|
||||
void (stored.get(handoff.id) ?? Promise.resolve()).then(() => deleteStored(handoff.id));
|
||||
stored.delete(handoff.id);
|
||||
return handoff;
|
||||
}
|
||||
|
||||
function armExpiry(oldestStored: number | null): void {
|
||||
if (expiryTimer !== null) clearTimeout(expiryTimer);
|
||||
expiryTimer = null;
|
||||
const times = [...queue.map((handoff) => handoff.createdAt), ...(oldestStored === null ? [] : [oldestStored])];
|
||||
if (times.length === 0) return;
|
||||
const delay = Math.max(0, Math.min(...times) + HANDOFF_TTL_MS - Date.now()) + 1000;
|
||||
expiryTimer = setTimeout(() => { void expireHandoffs(); }, delay);
|
||||
}
|
||||
|
||||
/** What waited too long goes, in memory and stored. Also runs on a timer while this page is open. */
|
||||
export async function expireHandoffs(): Promise<void> {
|
||||
const now = Date.now();
|
||||
let lost = false;
|
||||
for (let index = queue.length - 1; index >= 0; index -= 1) {
|
||||
if (now - queue[index].createdAt <= HANDOFF_TTL_MS) continue;
|
||||
dropFromQueue(index);
|
||||
lost = true;
|
||||
}
|
||||
if (lost) reportLoss('expired');
|
||||
await sweepStoredHandoffs();
|
||||
}
|
||||
|
||||
async function sweepStoredHandoffs(): Promise<void> {
|
||||
let oldest: number | null = null;
|
||||
const api = cacheApi();
|
||||
try {
|
||||
if (api && (await api.has(HANDOFF_CACHE))) oldest = await sweep(await api.open(HANDOFF_CACHE), null);
|
||||
} catch {
|
||||
// Tried again at the next page load.
|
||||
}
|
||||
armExpiry(oldest);
|
||||
}
|
||||
|
||||
function enqueue(handoff: Handoff, persist: boolean): void {
|
||||
queue.push(handoff);
|
||||
armExpiry(null);
|
||||
if (persist) stored.set(handoff.id, writeStored(handoff));
|
||||
notify(handoff.kind);
|
||||
}
|
||||
|
||||
/** The kind a file from the OS goes to; null when nothing here handles it. */
|
||||
export function kindForFile(file: { name: string; type?: string }): HandoffKind | null {
|
||||
const dot = file.name.lastIndexOf('.');
|
||||
const extension = dot >= 0 ? file.name.slice(dot).toLowerCase() : '';
|
||||
if (extension === '.eml') return 'eml';
|
||||
if (extension === '.ics' || extension === '.ical') return 'calendar';
|
||||
if (extension === '.vcf' || extension === '.vcard') return 'contacts';
|
||||
const type = (file.type ?? '').toLowerCase();
|
||||
if (type === 'message/rfc822') return 'eml';
|
||||
if (type === 'text/calendar') return 'calendar';
|
||||
if (type === 'text/vcard' || type === 'text/x-vcard') return 'contacts';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Hands files to a part of the app, which opens when they arrive. */
|
||||
export function handOffFiles(files: File[], kind: HandoffKind): void {
|
||||
if (files.length === 0) return;
|
||||
// A .eml opens at once and needs no sign-in, so it never waits for one.
|
||||
enqueue({ id: newId(), kind, files, createdAt: Date.now() }, kind !== 'eml');
|
||||
}
|
||||
|
||||
/** Opens a new draft with this text and these files. Trusted: what came from outside was asked about first. */
|
||||
export function handOffDraft(draft: { subject?: string; body?: string; files?: File[] }): void {
|
||||
const subject = cleanMailtoSubject(draft.subject ?? '');
|
||||
const body = cleanMailtoBody(draft.body ?? '');
|
||||
enqueue({
|
||||
id: newId(),
|
||||
kind: 'compose',
|
||||
files: draft.files ?? [],
|
||||
...(subject ? { subject } : {}),
|
||||
...(body ? { body } : {}),
|
||||
createdAt: Date.now(),
|
||||
}, true);
|
||||
}
|
||||
|
||||
/** Opens an empty new message. */
|
||||
export function handOffNewMessage(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastNewMessageAt < NEW_MESSAGE_REPEAT_MS) return;
|
||||
lastNewMessageAt = now;
|
||||
enqueue({ id: newId(), kind: 'compose', files: [], createdAt: now }, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes files from a launch by their type. Returns the names nothing handles
|
||||
* and how many couldn't be read.
|
||||
*/
|
||||
export async function handOffLaunchFiles(
|
||||
handles: ReadonlyArray<{ kind?: string; getFile?: () => Promise<File> }>,
|
||||
): Promise<{ unsupported: string[]; failed: number }> {
|
||||
const byKind = new Map<HandoffKind, File[]>();
|
||||
const unsupported: string[] = [];
|
||||
let failed = 0;
|
||||
for (const handle of handles) {
|
||||
if (typeof handle.getFile !== 'function') continue;
|
||||
try {
|
||||
const file = await handle.getFile();
|
||||
const kind = kindForFile(file);
|
||||
if (!kind) {
|
||||
unsupported.push(file.name);
|
||||
continue;
|
||||
}
|
||||
byKind.set(kind, [...(byKind.get(kind) ?? []), file]);
|
||||
} catch {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
for (const [kind, files] of byKind) handOffFiles(files, kind);
|
||||
return { unsupported, failed };
|
||||
}
|
||||
|
||||
/** The oldest handoff of this kind; it is gone from here after this. */
|
||||
export function takeHandoff(kind: HandoffKind): Handoff | null {
|
||||
const now = Date.now();
|
||||
let lost = false;
|
||||
let found: Handoff | null = null;
|
||||
for (let index = 0; index < queue.length && !found; index += 1) {
|
||||
if (queue[index].kind !== kind) continue;
|
||||
const handoff = dropFromQueue(index);
|
||||
index -= 1;
|
||||
if (now - handoff.createdAt <= HANDOFF_TTL_MS) found = handoff;
|
||||
else lost = true;
|
||||
}
|
||||
if (lost) reportLoss('expired');
|
||||
return found;
|
||||
}
|
||||
|
||||
/** What is waiting, oldest first. Nothing is taken. */
|
||||
export function pendingHandoffs(): readonly Handoff[] {
|
||||
return queue;
|
||||
}
|
||||
|
||||
/** `kind` "any" hears every handoff, before those listening for that kind. */
|
||||
export function subscribeHandoff(kind: HandoffKind | 'any', listener: Listener): () => void {
|
||||
if (kind === 'any') {
|
||||
anyListeners.add(listener);
|
||||
return () => { anyListeners.delete(listener); };
|
||||
}
|
||||
const set = kindListeners.get(kind) ?? new Set<Listener>();
|
||||
kindListeners.set(kind, set);
|
||||
set.add(listener);
|
||||
return () => { set.delete(listener); };
|
||||
}
|
||||
|
||||
/** Hears about a handoff that went without arriving, so the user can be told. */
|
||||
export function subscribeHandoffLost(listener: (loss: HandoffLoss) => void): () => void {
|
||||
lossListeners.add(listener);
|
||||
return () => { lossListeners.delete(listener); };
|
||||
}
|
||||
|
||||
/** Subject and body of a draft from what the OS shared, under the same rules as a mailto: link. */
|
||||
export function shareDraft(shared: { title?: string; text?: string; url?: string }): { subject: string; body: string } {
|
||||
const text = cleanMailtoBody(shared.text ?? '').trim();
|
||||
const url = cleanMailtoSubject(shared.url ?? '').trim();
|
||||
// Android often puts the link in the text as well.
|
||||
const body = url && !text.includes(url) ? [text, url].filter(Boolean).join('\n\n') : text;
|
||||
return { subject: cleanMailtoSubject(shared.title ?? '').trim(), body: cleanMailtoBody(body) };
|
||||
}
|
||||
|
||||
/** A name from outside, as it may be shown and attached. */
|
||||
function cleanFileName(name: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return stripBidiControls(name.replace(/[\u0000-\u001F\u007F]/g, '')).slice(0, 255) || 'file';
|
||||
}
|
||||
|
||||
interface StoredContents {
|
||||
meta: { kind: HandoffKind; createdAt: number };
|
||||
subject: string;
|
||||
body: string;
|
||||
files: Array<{ name: string; type: string }>;
|
||||
key: CryptoKey;
|
||||
cache: Cache;
|
||||
}
|
||||
|
||||
async function openStored(id: string, encodedKey: string): Promise<StoredContents | null> {
|
||||
const api = cacheApi();
|
||||
if (!api || !canSeal() || !encodedKey) return null;
|
||||
const prefix = entryPrefix(id);
|
||||
try {
|
||||
if (!(await api.has(HANDOFF_CACHE))) return null;
|
||||
const cache = await api.open(HANDOFF_CACHE);
|
||||
const metaResponse = await cache.match(`${prefix}meta`);
|
||||
if (!metaResponse) return null;
|
||||
const meta = (await metaResponse.json()) as StoredContents['meta'];
|
||||
if (Date.now() - meta.createdAt > HANDOFF_TTL_MS) {
|
||||
await deleteStored(id);
|
||||
return null;
|
||||
}
|
||||
const sealedResponse = await cache.match(`${prefix}sealed`);
|
||||
if (!sealedResponse) return null;
|
||||
const key = await crypto.subtle.importKey('raw', fromBase64Url(encodedKey), 'AES-GCM', false, ['decrypt']);
|
||||
const contents = JSON.parse(new TextDecoder().decode(await unseal(key, await sealedResponse.arrayBuffer()))) as {
|
||||
title?: string; text?: string; url?: string; files?: Array<{ name: string; type?: string }>;
|
||||
};
|
||||
return {
|
||||
meta,
|
||||
...shareDraft(contents),
|
||||
files: (contents.files ?? []).map((file) => ({ name: cleanFileName(file.name), type: file.type ?? '' })),
|
||||
key,
|
||||
cache,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What a stored handoff holds, to ask the user about. Null when it is gone or can't be read. */
|
||||
export async function peekStoredHandoff(id: string, encodedKey: string): Promise<{ subject: string; body: string; files: string[] } | null> {
|
||||
const contents = await openStored(id, encodedKey);
|
||||
return contents && { subject: contents.subject, body: contents.body, files: contents.files.map((file) => file.name) };
|
||||
}
|
||||
|
||||
/** The user refused it. */
|
||||
export function discardStoredHandoff(id: string): Promise<void> {
|
||||
return deleteStored(id);
|
||||
}
|
||||
|
||||
/** Brings a stored handoff into this tab. False when it is gone. Without `encodedKey`: this tab's own key. */
|
||||
export async function adoptStoredHandoff(id: string, encodedKey?: string): Promise<boolean> {
|
||||
if (queue.some((handoff) => handoff.id === id)) return true;
|
||||
const key = encodedKey ?? readTabKeys()[id] ?? '';
|
||||
const contents = await openStored(id, key);
|
||||
if (!contents) return false;
|
||||
const prefix = entryPrefix(id);
|
||||
const files: File[] = [];
|
||||
let missing = false;
|
||||
for (const [index, entry] of contents.files.entries()) {
|
||||
try {
|
||||
const response = await contents.cache.match(`${prefix}${index}`);
|
||||
if (!response) throw new Error('gone');
|
||||
const bytes = await unseal(contents.key, await response.arrayBuffer());
|
||||
files.push(new File([bytes], entry.name, { type: entry.type }));
|
||||
} catch {
|
||||
missing = true;
|
||||
}
|
||||
}
|
||||
// The awaits above: another caller may have got here first.
|
||||
if (queue.some((handoff) => handoff.id === id)) return true;
|
||||
setTabKey(id, key);
|
||||
enqueue({
|
||||
id,
|
||||
kind: contents.meta.kind,
|
||||
files,
|
||||
...(contents.subject ? { subject: contents.subject } : {}),
|
||||
...(contents.body ? { body: contents.body } : {}),
|
||||
createdAt: contents.meta.createdAt,
|
||||
}, false);
|
||||
if (missing) reportLoss('missing');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** After a page load: this tab's stored handoffs come back, what is too old goes. */
|
||||
export async function restoreStoredHandoffs(): Promise<void> {
|
||||
let lost = false;
|
||||
await Promise.all(Object.keys(readTabKeys()).map(async (id) => {
|
||||
if (await adoptStoredHandoff(id)) return;
|
||||
setTabKey(id, null);
|
||||
lost = true;
|
||||
}));
|
||||
if (lost) reportLoss('expired');
|
||||
await sweepStoredHandoffs();
|
||||
}
|
||||
|
||||
/** Sign-out and offline wipes: nothing that was on its way into an account stays. */
|
||||
export function clearHandoffs(): void {
|
||||
clearCount += 1;
|
||||
queue.length = 0;
|
||||
stored.clear();
|
||||
writeTabKeys({});
|
||||
lastNewMessageAt = 0;
|
||||
if (expiryTimer !== null) clearTimeout(expiryTimer);
|
||||
expiryTimer = null;
|
||||
const api = cacheApi();
|
||||
if (api) void api.delete(HANDOFF_CACHE).catch(() => {});
|
||||
}
|
||||
|
||||
export type LaunchLanding =
|
||||
| { type: 'new-message' }
|
||||
/** The service worker stored what was shared. */
|
||||
| { type: 'stored'; id: string; key: string }
|
||||
/** It couldn't be stored, or no worker ran: the text came along in the address. */
|
||||
| { type: 'inline'; subject: string; body: string; filesLost: boolean; cut: boolean };
|
||||
|
||||
const LANDING_HASH_PARAMS = ['share', 'key', 'share_title', 'share_text', 'share_url', 'share_files', 'share_cut'];
|
||||
|
||||
/** What the page's own address asks for, once. See stripLandingParams. */
|
||||
export function parseLaunchLanding(search: string, hash: string): LaunchLanding | null {
|
||||
if (new URLSearchParams(search).get('compose') === '1') return { type: 'new-message' };
|
||||
const params = new URLSearchParams(hash.replace(/^#/, ''));
|
||||
const share = params.get('share');
|
||||
if (!share) return null;
|
||||
if (share === 'unavailable') {
|
||||
return {
|
||||
type: 'inline',
|
||||
...shareDraft({
|
||||
title: params.get('share_title') ?? '',
|
||||
text: params.get('share_text') ?? '',
|
||||
url: params.get('share_url') ?? '',
|
||||
}),
|
||||
filesLost: params.get('share_files') === '1',
|
||||
cut: params.get('share_cut') === '1',
|
||||
};
|
||||
}
|
||||
return /^[\w-]{1,64}$/.test(share) ? { type: 'stored', id: share, key: params.get('key') ?? '' } : null;
|
||||
}
|
||||
|
||||
/** The address without what parseLaunchLanding read, so a reload doesn't do it again. */
|
||||
export function stripLandingParams(search: string, hash: string): { search: string; hash: string } {
|
||||
const query = new URLSearchParams(search);
|
||||
query.delete('compose');
|
||||
const rest = query.toString();
|
||||
let leftHash = hash;
|
||||
const fragment = new URLSearchParams(hash.replace(/^#/, ''));
|
||||
if (fragment.has('share')) {
|
||||
for (const name of LANDING_HASH_PARAMS) fragment.delete(name);
|
||||
const left = fragment.toString();
|
||||
leftHash = left ? `#${left}` : '';
|
||||
}
|
||||
return { search: rest ? `?${rest}` : '', hash: leftHash };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a launch's address asks of a window that is already open. A shortcut
|
||||
* with launch_handler focus-existing only focuses that window and passes the
|
||||
* address here. Anything else, the start page included, stays as it is.
|
||||
*/
|
||||
export function shortcutFromTargetUrl(targetURL: string, origin: string): 'new-message' | 'calendar' | 'contacts' | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(targetURL, origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.origin !== origin) return null;
|
||||
const match = matchSurface(url.pathname);
|
||||
if (!match || match.segments.length > 0) return null;
|
||||
if (match.surface === 'mail') return url.searchParams.get('compose') === '1' ? 'new-message' : null;
|
||||
if (match.surface === 'calendar' || match.surface === 'contacts') return match.surface;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
import { handOffFiles, pendingHandoffs } from '@/lib/launch-handoff';
|
||||
import { disableOffline, enforcePolicy, wipeEverything } from '../offline-mode';
|
||||
import { forgetIndexes } from '../cache';
|
||||
import { forgetDeviceKey } from '../crypto';
|
||||
import { allowOfflineDbCreation, deleteOfflineDb } from '../db';
|
||||
import { useOfflineStore, writeEnabled } from '../state';
|
||||
|
||||
// Offline mode's wipes take what was on its way into an account along.
|
||||
|
||||
function waiting() {
|
||||
handOffFiles([new File(['x'], 'a.ics')], 'calendar');
|
||||
expect(pendingHandoffs()).toHaveLength(1);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
forgetIndexes();
|
||||
forgetDeviceKey();
|
||||
await deleteOfflineDb();
|
||||
allowOfflineDbCreation();
|
||||
localStorage.clear();
|
||||
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false });
|
||||
usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
|
||||
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await wipeEverything();
|
||||
});
|
||||
|
||||
describe('offline wipes and handoffs', () => {
|
||||
it('wipeEverything clears them', async () => {
|
||||
waiting();
|
||||
await wipeEverything();
|
||||
expect(pendingHandoffs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disableOffline clears them', async () => {
|
||||
writeEnabled(['acc']);
|
||||
waiting();
|
||||
await disableOffline('acc');
|
||||
expect(pendingHandoffs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('enforcePolicy clears them when the organisation forbids offline mode', async () => {
|
||||
writeEnabled(['acc']);
|
||||
waiting();
|
||||
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } });
|
||||
enforcePolicy();
|
||||
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([]));
|
||||
expect(pendingHandoffs()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('enforcePolicy leaves them while offline mode is allowed', () => {
|
||||
writeEnabled(['acc']);
|
||||
waiting();
|
||||
enforcePolicy();
|
||||
expect(pendingHandoffs()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { queuePatch, queueSend } from '../queue';
|
||||
|
||||
// Whatever goes into the queue asks for a Background Sync, once it's stored.
|
||||
|
||||
const requestOutboxSync = vi.fn();
|
||||
const stored: unknown[] = [];
|
||||
vi.mock('@/lib/pwa-notify/outbox-sync', () => ({ requestOutboxSync: () => requestOutboxSync(stored.length) }));
|
||||
vi.mock('../db', () => ({
|
||||
idbAdd: vi.fn(async (_store: string, record: unknown) => { stored.push(record); return stored.length; }),
|
||||
idbDeleteAccount: vi.fn(), idbGet: vi.fn(), idbGetAll: vi.fn(), idbGetAllByAccount: vi.fn(), idbUpdate: vi.fn(), idbWrite: vi.fn(),
|
||||
}));
|
||||
vi.mock('../crypto', () => ({
|
||||
sealJson: vi.fn(async () => ({ iv: 'i', data: 'd' })),
|
||||
openJson: vi.fn(), openBytes: vi.fn(), sealBytes: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
stored.length = 0;
|
||||
requestOutboxSync.mockClear();
|
||||
});
|
||||
|
||||
describe('the offline queue', () => {
|
||||
it('asks for a sync after storing a change', async () => {
|
||||
await queuePatch('acc', 'jmap', {} as never);
|
||||
expect(requestOutboxSync).toHaveBeenCalledTimes(1);
|
||||
expect(requestOutboxSync).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('asks for a sync after storing a message, and still returns its id', async () => {
|
||||
const id = await queueSend('acc', {} as never, {} as never);
|
||||
expect(id).toBe(1);
|
||||
expect(requestOutboxSync).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// A mailto: or webcal: launch without a connection needs its handler page in
|
||||
// the app shell. Its address is what opens the draft; the start page alone
|
||||
// doesn't read it outside an installed app.
|
||||
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: async () => new Response(JSON.stringify({ buildId: 'build-1', files: ['/_next/static/a.js'] })),
|
||||
withBasePath: (path: string) => path,
|
||||
}));
|
||||
vi.mock('@/lib/app-update', () => ({ currentBuildId: () => 'build-1', SW_SCOPE: '/' }));
|
||||
|
||||
const { tellServiceWorker } = await import('../shell');
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe('app shell pages', () => {
|
||||
it('includes the protocol handler pages', async () => {
|
||||
const postMessage = vi.fn();
|
||||
vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => ({ active: { postMessage } }) } });
|
||||
|
||||
await tellServiceWorker(true);
|
||||
|
||||
expect(postMessage.mock.calls[0][0].urls).toEqual(expect.arrayContaining(['/', '/protocol/mailto', '/protocol/webcal']));
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
type OutboxSummary,
|
||||
type PolicyFingerprint,
|
||||
} from './queue';
|
||||
import { clearHandoffs } from '@/lib/launch-handoff';
|
||||
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
|
||||
import { flushQueue, settleStaleSends } from './flush';
|
||||
import { syncAccount } from './sync';
|
||||
@@ -696,6 +697,7 @@ export async function enableOffline(account: string): Promise<void> {
|
||||
/** Switches offline mode off for an account (the user confirmed losing unsent mail) and removes everything it kept here. */
|
||||
export async function disableOffline(account: string): Promise<void> {
|
||||
confirmLoss(account);
|
||||
clearHandoffs();
|
||||
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
|
||||
await wipeAccount(account);
|
||||
}
|
||||
@@ -779,6 +781,7 @@ export async function wipeEverything(): Promise<void> {
|
||||
await deleteOfflineDb();
|
||||
await tellServiceWorker(false);
|
||||
await deleteShellCaches();
|
||||
clearHandoffs();
|
||||
confirmed.clear();
|
||||
named.clear();
|
||||
useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} });
|
||||
@@ -801,6 +804,7 @@ export function enforcePolicy(): void {
|
||||
for (const account of useOfflineStore.getState().enabled) {
|
||||
if (!policyDecided(account)) continue;
|
||||
if (!offlineAllowed(account)) {
|
||||
clearHandoffs();
|
||||
void wipeAccount(account);
|
||||
notify({ kind: 'forbidden' });
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbUpdate, idbWrite } from './db';
|
||||
import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
|
||||
|
||||
export interface PatchItem {
|
||||
kind: 'patch';
|
||||
@@ -104,11 +105,14 @@ interface SendPayload {
|
||||
export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise<void> {
|
||||
const item: PatchItem = { kind: 'patch', jmap, update };
|
||||
await idbAdd('queue', { account, kind: 'patch', createdAt: Date.now(), ...(await sealJson(item)) } satisfies QueueRecord);
|
||||
requestOutboxSync();
|
||||
}
|
||||
|
||||
export async function queueSend(account: string, summary: OutboxSummary, item: SendItem, fingerprint?: PolicyFingerprint): Promise<number> {
|
||||
const payload: SendPayload = { summary, item, fingerprint };
|
||||
return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord));
|
||||
const id = Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord));
|
||||
requestOutboxSync();
|
||||
return id;
|
||||
}
|
||||
|
||||
export interface QueuedPatch { id: number; account: string; item: PatchItem }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
import { currentBuildId, SW_SCOPE } from '@/lib/app-update';
|
||||
|
||||
/** The app's pages kept for a start without the network. */
|
||||
const SHELL_PAGES = ['/', '/mail', '/settings', '/popout/compose', '/popout/message/_'];
|
||||
const SHELL_PAGES = ['/', '/mail', '/settings', '/protocol/mailto', '/protocol/webcal', '/popout/compose', '/popout/message/_'];
|
||||
|
||||
/**
|
||||
* The files of this tab's build. After a deploy the server lists the new
|
||||
|
||||
@@ -39,6 +39,16 @@ function stripBodyControlChars(value: string): string {
|
||||
.replace(CONTROL_CHARS_EXCEPT_LINE_BREAKS, "");
|
||||
}
|
||||
|
||||
/** A subject from outside the app: no control characters, no longer than a mail header allows. */
|
||||
export function cleanMailtoSubject(value: string): string {
|
||||
return stripControlChars(value).slice(0, MAX_SUBJECT_LENGTH);
|
||||
}
|
||||
|
||||
/** A body from outside the app: line breaks kept, other control characters gone, limited in length. */
|
||||
export function cleanMailtoBody(value: string): string {
|
||||
return stripBodyControlChars(value).slice(0, MAX_BODY_LENGTH);
|
||||
}
|
||||
|
||||
function splitRecipients(value: string): string[] {
|
||||
// Quote/angle-aware split so a `"Doe, John" <john@doo.org>` display name with
|
||||
// an embedded comma stays a single recipient instead of being torn in two.
|
||||
@@ -125,8 +135,8 @@ export function parseMailto(raw: string): ParsedMailto | null {
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject: stripControlChars(getQueryValue(searchParams, "subject")).slice(0, MAX_SUBJECT_LENGTH),
|
||||
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
||||
subject: cleanMailtoSubject(getQueryValue(searchParams, "subject")),
|
||||
body: cleanMailtoBody(getQueryValue(searchParams, "body")),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, ReturnType<typeof makeClient>>;
|
||||
let activeAccountId = 'alice@one';
|
||||
const gateOff = new Set<string>();
|
||||
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<NotificationActionRequest> = {}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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('');
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Same tag as sw.js's OUTBOX_SYNC_TAG. */
|
||||
const OUTBOX_SYNC_TAG = 'bulwark-outbox';
|
||||
|
||||
/**
|
||||
* Something went into the outbox: asks the browser to wake the service worker
|
||||
* when the network is back, which tells the open tabs to flush. Only a tab
|
||||
* can flush (the worker has no login), so this helps a tab that missed the
|
||||
* `online` event.
|
||||
*
|
||||
* Chromium only. Without it, or without a worker (Lite, development), or in
|
||||
* Electron, where register rejects, the outbox flushes as it always did.
|
||||
*/
|
||||
export function requestOutboxSync(): void {
|
||||
void (async () => {
|
||||
try {
|
||||
if (typeof window === 'undefined' || !('SyncManager' in window) || !navigator.serviceWorker) return;
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
const sync = (registration as (ServiceWorkerRegistration & { sync?: { register(tag: string): Promise<void> } }) | undefined)?.sync;
|
||||
await sync?.register(OUTBOX_SYNC_TAG);
|
||||
} catch {
|
||||
// Nothing to do: the outbox doesn't depend on this.
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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<NotificationActionRequest> | 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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<TabReplyState>(() => ({ 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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string> {
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -341,7 +341,7 @@ export function applyOverridePatch<T extends Partial<CalendarEvent>>(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<string, unknown>, updates: Record<string, unknown>): Record<string, unknown> {
|
||||
export function mergeOverrideEntry(entry: Record<string, unknown>, updates: Record<string, unknown>): Record<string, unknown> {
|
||||
const merged = { ...entry };
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
for (const existing of Object.keys(merged)) {
|
||||
|
||||
@@ -645,6 +645,19 @@ function raisesSequences(series: Partial<CalendarEvent>, 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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fileStorage } from '@/lib/plugin-storage';
|
||||
import { clearHandoffs } from '@/lib/launch-handoff';
|
||||
import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints';
|
||||
import { sweepPrivateExports } from '@/lib/mail-export/sinks';
|
||||
|
||||
@@ -114,6 +115,8 @@ export function purgeSignedOutData(): void {
|
||||
/* storage unavailable */
|
||||
}
|
||||
forgetOfflineShell();
|
||||
// Files and links on their way into a draft or an import.
|
||||
clearHandoffs();
|
||||
for (const cleanup of signedOutCleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
|
||||
@@ -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<CalendarEvent[]> {
|
||||
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<CalendarEvent>);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string[]> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -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<typeof setup>;
|
||||
|
||||
async function call(s: Setup, name: string, args: Record<string, unknown>) {
|
||||
// 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<string, unknown>): Promise<string[]> {
|
||||
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<Record<string, unknown> | 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<string, unknown>) {
|
||||
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<CalendarConflict[]>;
|
||||
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<string, unknown> = {}) => (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<string, unknown>).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<string, { participationStatus: string }> };
|
||||
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<FakeCalendarJmap['request']>;
|
||||
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]);
|
||||
});
|
||||
@@ -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<typeof setup>;
|
||||
|
||||
async function write(s: Setup, args: Record<string, unknown>, 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<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
async function query(s: Setup, args: Record<string, unknown> = {}) {
|
||||
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<Setup, 'engine'>) => s.engine.areaCall('calendar.flush', undefined) as Promise<{ sent: number; conflicts: number; pending: number }>;
|
||||
const conflicts = (s: Pick<Setup, 'engine'>) => s.engine.areaCall('calendar.conflicts', undefined) as Promise<CalendarConflict[]>;
|
||||
const sets = (server: FakeCalendarJmap) => server.calls.filter(([name, args]) => name === 'CalendarEvent/set' && !(args.update && 'h333333' in (args.update as object)));
|
||||
const pending = (s: Pick<Setup, 'db'>) => 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<string, unknown>) => 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<string, unknown>).e1).toEqual({ title: 'Quiet edit' });
|
||||
expect((sent[1][1].update as Record<string, unknown>).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<string, unknown>).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<string, Record<string, unknown>> } & Record<string, unknown>;
|
||||
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<string, { title: string }>)[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<string, { uid?: string }>) : []));
|
||||
|
||||
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<CalendarEvent>);
|
||||
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<CalendarEvent>, { ...NEW, title: 'Two' } as Partial<CalendarEvent>]);
|
||||
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<CalendarEvent>);
|
||||
expect(created()[0].uid).toBe('chosen@example.org');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof openMemoryStore>;
|
||||
/** Everything the client sent, wherever it went. */
|
||||
sent: MethodCall[];
|
||||
online: boolean;
|
||||
newClient(): Promise<JMAPClient>;
|
||||
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<OfflineResponse | null>;
|
||||
},
|
||||
record: () => {},
|
||||
noteNetwork: () => {},
|
||||
serverGone: async () => true,
|
||||
blob: async () => null,
|
||||
recordBlob: () => {},
|
||||
holdUpload: async () => null,
|
||||
resolveStandIns: (_client, ids) => engine.areaCall('calendar.resolveStandIns', { ids }) as Promise<Record<string, string | null>>,
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -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<string, unknown> & { 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<string, unknown>, tick: number): Record<string, unknown> {
|
||||
if (entry.excluded) return entry;
|
||||
if (version === '1.0') {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, Record<string, unknown>>;
|
||||
for (const [id, person] of Object.entries(value as Record<string, Record<string, unknown>>)) {
|
||||
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<string, unknown> = { ...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<string, Record<string, unknown>>).map(([id, person]) => {
|
||||
const { roles, ...rest } = person;
|
||||
const attendeeOnly = roles && Object.keys(roles).length === 1 && (roles as Record<string, boolean>).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<string, FakeEvent>();
|
||||
readonly calendars = new Map<string, Record<string, unknown> & { id: string }>();
|
||||
readonly identities: Array<Record<string, unknown>> = [{ id: 'pi1', name: 'A', calendarAddress: 'mailto:a@example.org', isDefault: true }];
|
||||
readonly eventLog = new Collection();
|
||||
private readonly instances = new Map<string, Instance>();
|
||||
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<string, { type: string; description?: string; properties?: string[] }> = {};
|
||||
|
||||
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> = {}): 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<string, unknown>): 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<string, Record<string, unknown>>)
|
||||
.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<string, Record<string, unknown>>)[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<string, unknown>): Record<string, unknown> {
|
||||
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<string, boolean>)[filter.inCalendar]) return false;
|
||||
return true;
|
||||
});
|
||||
return page(masters.map((event) => event.id), true);
|
||||
}
|
||||
|
||||
private get(args: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>[] = [];
|
||||
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<string, unknown> = { id };
|
||||
for (const name of properties) if (name in full) out[name] = (full as Record<string, unknown>)[name];
|
||||
list.push(out);
|
||||
}
|
||||
return { accountId: args.accountId, state: this.eventLog.state(), list, notFound };
|
||||
}
|
||||
|
||||
private seriesKeyInOverride(patch: Record<string, unknown>): 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<string, unknown>): { 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<string, Record<string, unknown>>;
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { accountId: args.accountId, oldState: this.eventLog.state() };
|
||||
const created: Record<string, unknown> = {};
|
||||
const notCreated: Record<string, unknown> = {};
|
||||
for (const [key, event] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
const seriesKey = this.seriesKeyInOverride(event);
|
||||
if (seriesKey) {
|
||||
notCreated[key] = { type: 'invalidProperties', description: `${seriesKey} cannot be set inside recurrenceOverrides.`, properties: ['recurrenceOverrides'] };
|
||||
} else if (typeof event.uid === 'string' && [...this.events.values()].some((other) => other.uid === event.uid)) {
|
||||
notCreated[key] = { type: 'invalidProperties', description: `An event with UID ${event.uid} already exists.`, properties: ['uid'] };
|
||||
} else if (this.refuse[key]) {
|
||||
notCreated[key] = this.refuse[key];
|
||||
delete this.refuse[key];
|
||||
} else {
|
||||
created[key] = { id: this.addEvent(event).id };
|
||||
}
|
||||
}
|
||||
const updated: Record<string, unknown> = {};
|
||||
const notUpdated: Record<string, unknown> = {};
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
const refusal = this.update(id, patch);
|
||||
if (refusal) notUpdated[id] = refusal;
|
||||
else updated[id] = null;
|
||||
}
|
||||
const destroyed: string[] = [];
|
||||
const notDestroyed: Record<string, unknown> = {};
|
||||
for (const id of (args.destroy ?? []) as string[]) {
|
||||
const instance = this.instances.get(id);
|
||||
if (instance?.recurrenceId && this.events.has(instance.master.id)) {
|
||||
const master = this.events.get(instance.master.id) as FakeEvent;
|
||||
this.patchEvent(master.id, {
|
||||
recurrenceOverrides: { ...(master.recurrenceOverrides as object | undefined), [instance.recurrenceId]: { excluded: true } },
|
||||
});
|
||||
destroyed.push(id);
|
||||
} else if (this.refuse[id]) {
|
||||
notDestroyed[id] = this.refuse[id];
|
||||
delete this.refuse[id];
|
||||
} else if (this.events.has(id)) {
|
||||
this.removeEvent(id);
|
||||
destroyed.push(id);
|
||||
} else {
|
||||
notDestroyed[id] = { type: 'notFound' };
|
||||
}
|
||||
}
|
||||
if (Object.keys(created).length > 0) out.created = created;
|
||||
if (Object.keys(notCreated).length > 0) out.notCreated = notCreated;
|
||||
if (Object.keys(updated).length > 0) out.updated = updated;
|
||||
if (Object.keys(notUpdated).length > 0) out.notUpdated = notUpdated;
|
||||
if (destroyed.length > 0) out.destroyed = destroyed;
|
||||
if (Object.keys(notDestroyed).length > 0) out.notDestroyed = notDestroyed;
|
||||
out.newState = this.eventLog.state();
|
||||
return out;
|
||||
}
|
||||
|
||||
private answerCalendarCall(name: string, args: Record<string, unknown>): Record<string, unknown> {
|
||||
switch (name) {
|
||||
case 'Calendar/get':
|
||||
return { accountId: args.accountId, state: 'cs1', list: [...this.calendars.values()], notFound: [] };
|
||||
case 'ParticipantIdentity/get':
|
||||
return { accountId: args.accountId, list: this.identities, notFound: [] };
|
||||
case 'CalendarEvent/query':
|
||||
return this.query(args);
|
||||
case 'CalendarEvent/get':
|
||||
return this.get(args);
|
||||
case 'CalendarEvent/set':
|
||||
return this.set(args);
|
||||
case 'CalendarEvent/changes': {
|
||||
const since = String(args.sinceState);
|
||||
if (!/^s\d+$/.test(since)) throw jmapError('invalidArguments');
|
||||
const from = Number(since.slice(1));
|
||||
if (from < this.eventLog.truncatedBelow || from > this.eventLog.log.length) throw jmapError('cannotCalculateChanges');
|
||||
const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000);
|
||||
return { accountId: args.accountId, oldState: since, ...this.eventLog.changes(since, maxChanges) };
|
||||
}
|
||||
default:
|
||||
throw jmapError('unknownMethod');
|
||||
}
|
||||
}
|
||||
|
||||
override async request(calls: MethodCall[]): Promise<JmapResponse> {
|
||||
if (!calls.every(([name]) => /^(Calendar|CalendarEvent|ParticipantIdentity)\//.test(name))) return super.request(calls);
|
||||
this.requests += 1;
|
||||
this.beforeRequest?.(this.requests);
|
||||
if (this.offline) throw new TransportError('network', 'Failed to fetch');
|
||||
if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit');
|
||||
const done: MethodResponse[] = [];
|
||||
let lose = false;
|
||||
for (const call of calls) {
|
||||
this.calls.push(call);
|
||||
const [name, raw, id] = call;
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key.startsWith('#')) {
|
||||
const ref = value as { resultOf: string; path: string };
|
||||
const source = done.find(([, , callId]) => callId === ref.resultOf);
|
||||
args[key.slice(1)] = source?.[1][ref.path.slice(1)];
|
||||
} else {
|
||||
args[key] = value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
done.push([name, this.answerCalendarCall(name, args), id]);
|
||||
if (name === 'CalendarEvent/set' && (raw.create || raw.update) && this.loseNextSetResponse) lose = true;
|
||||
} catch (error) {
|
||||
const type = (error as { jmapError?: string }).jmapError;
|
||||
if (!type) throw error;
|
||||
done.push(['error', { type, description: (error as { description?: string }).description }, id]);
|
||||
}
|
||||
}
|
||||
if (lose) {
|
||||
this.loseNextSetResponse = false;
|
||||
throw new TransportError('network', 'The connection dropped before the answer came');
|
||||
}
|
||||
return { methodResponses: done };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* A small JMAP server for engine tests, modelled on how Stalwart really
|
||||
* behaves (local-data/desktop-offline-facts-parts/stalwart-changes-prf.md):
|
||||
* a 5000 cap on /changes pages, fixed log ranges, the three kinds of state
|
||||
* refusal, 500 ids per Email/get, 16 calls per request, `ids: null` giving the
|
||||
* first 500.
|
||||
*/
|
||||
|
||||
import { TransportError, type JmapResponse, type JmapTransport, type MethodCall, type MethodResponse, type StoreSession } from '../../engine/transport';
|
||||
|
||||
export type FakeEmail = Record<string, unknown> & { id: string };
|
||||
|
||||
export const JMAP_ACCOUNT = 'acc1';
|
||||
|
||||
export const FAKE_SESSION: StoreSession = {
|
||||
apiUrl: 'https://fake.test/jmap/',
|
||||
downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}',
|
||||
uploadUrl: 'https://fake.test/u/{accountId}',
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT },
|
||||
accounts: { [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
|
||||
capabilities: { 'urn:ietf:params:jmap:mail': {} },
|
||||
};
|
||||
|
||||
type ChangeKind = 'created' | 'updated' | 'destroyed';
|
||||
|
||||
interface Log { kind: ChangeKind; id: string }
|
||||
|
||||
export class Collection {
|
||||
log: Log[] = [];
|
||||
/** Below this log position, states are refused as truncated. */
|
||||
truncatedBelow = 0;
|
||||
|
||||
state(): string {
|
||||
return `s${this.log.length}`;
|
||||
}
|
||||
|
||||
push(kind: ChangeKind, id: string): void {
|
||||
this.log.push({ kind, id });
|
||||
}
|
||||
|
||||
changes(since: string, maxChanges: number): { created: string[]; updated: string[]; destroyed: string[]; newState: string; hasMoreChanges: boolean } {
|
||||
const from = Number(since.slice(1));
|
||||
const slice = this.log.slice(from, from + maxChanges);
|
||||
const created = new Set<string>();
|
||||
const updated = new Set<string>();
|
||||
const destroyed = new Set<string>();
|
||||
for (const { kind, id } of slice) {
|
||||
if (kind === 'created') created.add(id);
|
||||
else if (kind === 'updated') {
|
||||
if (!created.has(id)) updated.add(id);
|
||||
} else if (created.delete(id)) {
|
||||
// Created and destroyed in the window: nowhere.
|
||||
} else {
|
||||
updated.delete(id);
|
||||
destroyed.add(id);
|
||||
}
|
||||
}
|
||||
const end = from + slice.length;
|
||||
return {
|
||||
created: [...created],
|
||||
updated: [...updated],
|
||||
destroyed: [...destroyed],
|
||||
newState: `s${end}`,
|
||||
hasMoreChanges: end < this.log.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface FakeOptions {
|
||||
/** Ten mailboxes by default, a single Inbox here unless given. */
|
||||
mailboxes?: Array<Record<string, unknown> & { id: string }>;
|
||||
}
|
||||
|
||||
export class FakeJmap implements JmapTransport {
|
||||
readonly emails = new Map<string, FakeEmail>();
|
||||
readonly mailboxes = new Map<string, Record<string, unknown> & { id: string }>();
|
||||
readonly threads = new Map<string, string[]>();
|
||||
readonly coll = { Email: new Collection(), Mailbox: new Collection(), Thread: new Collection() };
|
||||
readonly calls: MethodCall[] = [];
|
||||
requests = 0;
|
||||
/** Set to make every request fail like a network drop. */
|
||||
offline = false;
|
||||
/** Fails the next N requests as busy. */
|
||||
busyNext = 0;
|
||||
/** Called before each request; can change data while a sync is running. */
|
||||
beforeRequest: ((n: number) => void) | null = null;
|
||||
nextId = 1;
|
||||
|
||||
constructor(options: FakeOptions = {}) {
|
||||
for (const box of options.mailboxes ?? [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 }]) {
|
||||
this.mailboxes.set(box.id, box);
|
||||
this.coll.Mailbox.push('created', box.id);
|
||||
}
|
||||
}
|
||||
|
||||
addEmail(partial: Partial<FakeEmail> = {}): FakeEmail {
|
||||
const n = this.nextId++;
|
||||
const id = partial.id ?? `e${n}`;
|
||||
const threadId = (partial.threadId as string) ?? `t${n}`;
|
||||
const email: FakeEmail = {
|
||||
id,
|
||||
threadId,
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
size: 100,
|
||||
receivedAt: new Date(Date.UTC(2026, 9, 1) + n * 1000).toISOString(),
|
||||
sentAt: new Date(Date.UTC(2026, 9, 1) + n * 1000).toISOString(),
|
||||
from: [{ name: `Sender ${n}`, email: `s${n}@example.org` }],
|
||||
to: [{ name: 'A', email: 'a@example.org' }],
|
||||
cc: [],
|
||||
subject: `Subject ${n}`,
|
||||
preview: `Preview ${n}`,
|
||||
hasAttachment: false,
|
||||
blobId: `b${n}`,
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
htmlBody: [{ partId: '1', type: 'text/plain' }],
|
||||
bodyValues: { 1: { value: `Body of message ${n}`, isTruncated: false, isEncodingProblem: false } },
|
||||
attachments: [],
|
||||
messageId: [`m${n}@example.org`],
|
||||
headers: [],
|
||||
bodyStructure: { partId: '1', type: 'text/plain' },
|
||||
...partial,
|
||||
};
|
||||
this.emails.set(id, email);
|
||||
this.coll.Email.push('created', id);
|
||||
const members = this.threads.get(threadId);
|
||||
if (members) {
|
||||
members.push(id);
|
||||
this.coll.Thread.push('updated', threadId);
|
||||
} else {
|
||||
this.threads.set(threadId, [id]);
|
||||
this.coll.Thread.push('created', threadId);
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
updateEmail(id: string, patch: Partial<FakeEmail>): void {
|
||||
Object.assign(this.emails.get(id) as FakeEmail, patch);
|
||||
this.coll.Email.push('updated', id);
|
||||
}
|
||||
|
||||
destroyEmail(id: string): void {
|
||||
const email = this.emails.get(id);
|
||||
if (!email) return;
|
||||
this.emails.delete(id);
|
||||
this.coll.Email.push('destroyed', id);
|
||||
const threadId = email.threadId as string;
|
||||
const members = (this.threads.get(threadId) ?? []).filter((m) => m !== id);
|
||||
if (members.length > 0) {
|
||||
this.threads.set(threadId, members);
|
||||
this.coll.Thread.push('updated', threadId);
|
||||
} else {
|
||||
this.threads.delete(threadId);
|
||||
this.coll.Thread.push('destroyed', threadId);
|
||||
}
|
||||
}
|
||||
|
||||
/** The next /changes from before this point gets cannotCalculateChanges. */
|
||||
truncateLog(type: 'Email' | 'Mailbox' | 'Thread'): void {
|
||||
this.coll[type].truncatedBelow = this.coll[type].log.length;
|
||||
}
|
||||
|
||||
private queryIds(args: Record<string, unknown>): string[] {
|
||||
const filter = args.filter as { after?: string; inMailbox?: string } | undefined;
|
||||
let list = [...this.emails.values()];
|
||||
if (filter?.after) list = list.filter((e) => Date.parse(String(e.receivedAt)) >= Date.parse(filter.after as string));
|
||||
if (filter?.inMailbox) list = list.filter((e) => (e.mailboxIds as Record<string, boolean>)[filter.inMailbox as string]);
|
||||
list.sort((a, b) => Date.parse(String(b.receivedAt)) - Date.parse(String(a.receivedAt)));
|
||||
return list.map((e) => e.id);
|
||||
}
|
||||
|
||||
private handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
|
||||
switch (name) {
|
||||
case 'Mailbox/get': {
|
||||
const ids = args.ids as string[] | null;
|
||||
const all = [...this.mailboxes.values()];
|
||||
const list = ids ? all.filter((b) => ids.includes(b.id)) : all;
|
||||
return { accountId: args.accountId, state: this.coll.Mailbox.state(), list, notFound: [] };
|
||||
}
|
||||
case 'Email/query': {
|
||||
const ids = this.queryIds(args);
|
||||
const position = Number(args.position ?? 0);
|
||||
const limit = Number(args.limit ?? 5000) || 5000;
|
||||
return {
|
||||
accountId: args.accountId,
|
||||
queryState: this.coll.Email.state(),
|
||||
canCalculateChanges: true,
|
||||
position,
|
||||
ids: ids.slice(position, position + Math.min(limit, 5000)),
|
||||
...(args.calculateTotal ? { total: ids.length } : {}),
|
||||
};
|
||||
}
|
||||
case 'Email/get': {
|
||||
const ids = args.ids as string[] | null;
|
||||
if (ids && ids.length > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
|
||||
const chosen = ids ?? [...this.emails.keys()].slice(0, 500);
|
||||
const properties = args.properties as string[] | undefined;
|
||||
const list: Record<string, unknown>[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const id of chosen) {
|
||||
const email = this.emails.get(id);
|
||||
if (!email) {
|
||||
notFound.push(id);
|
||||
continue;
|
||||
}
|
||||
if (!properties) {
|
||||
list.push({ ...email });
|
||||
continue;
|
||||
}
|
||||
const out: Record<string, unknown> = { id };
|
||||
for (const p of properties) if (p in email) out[p] = email[p];
|
||||
list.push(out);
|
||||
}
|
||||
return { accountId: args.accountId, state: this.coll.Email.state(), list, notFound };
|
||||
}
|
||||
case 'Thread/get': {
|
||||
const ids = (args.ids ?? []) as string[];
|
||||
return {
|
||||
accountId: args.accountId,
|
||||
state: this.coll.Thread.state(),
|
||||
list: ids.filter((id) => this.threads.has(id)).map((id) => ({ id, emailIds: this.threads.get(id) })),
|
||||
notFound: ids.filter((id) => !this.threads.has(id)),
|
||||
};
|
||||
}
|
||||
case 'Email/changes':
|
||||
case 'Mailbox/changes':
|
||||
case 'Thread/changes': {
|
||||
const type = name.split('/')[0] as 'Email' | 'Mailbox' | 'Thread';
|
||||
const coll = this.coll[type];
|
||||
const since = String(args.sinceState);
|
||||
if (!/^s\d+$/.test(since)) throw Object.assign(new Error('bad state'), { jmapError: 'invalidArguments' });
|
||||
const from = Number(since.slice(1));
|
||||
if (from < coll.truncatedBelow || from > coll.log.length) {
|
||||
throw Object.assign(new Error('log truncated'), { jmapError: 'cannotCalculateChanges' });
|
||||
}
|
||||
const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000);
|
||||
return { accountId: args.accountId, oldState: since, ...coll.changes(since, maxChanges) };
|
||||
}
|
||||
default:
|
||||
throw Object.assign(new Error(name), { jmapError: 'unknownMethod' });
|
||||
}
|
||||
}
|
||||
|
||||
async request(calls: MethodCall[]): Promise<JmapResponse> {
|
||||
this.requests += 1;
|
||||
this.beforeRequest?.(this.requests);
|
||||
if (this.offline) throw new TransportError('network', 'Failed to fetch');
|
||||
if (this.busyNext > 0) {
|
||||
this.busyNext -= 1;
|
||||
throw new TransportError('rate', 'Server busy', 429, undefined, 1);
|
||||
}
|
||||
if (calls.length > 16) throw new TransportError('http', 'too many calls', 400, 'urn:ietf:params:jmap:error:limit');
|
||||
const done: MethodResponse[] = [];
|
||||
for (const call of calls) {
|
||||
this.calls.push(call);
|
||||
const [name, raw, id] = call;
|
||||
const args: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key.startsWith('#')) {
|
||||
const ref = value as { resultOf: string; path: string };
|
||||
const source = done.find(([, , callId]) => callId === ref.resultOf);
|
||||
args[key.slice(1)] = source?.[1][ref.path.slice(1)];
|
||||
} else {
|
||||
args[key] = value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
done.push([name, this.handle(name, args), id]);
|
||||
} catch (error) {
|
||||
const type = (error as { jmapError?: string }).jmapError;
|
||||
if (!type) throw error;
|
||||
done.push(['error', { type }, id]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: done };
|
||||
}
|
||||
|
||||
async download(): Promise<Uint8Array> {
|
||||
return new Uint8Array();
|
||||
}
|
||||
|
||||
async upload(): Promise<{ blobId: string; type: string; size: number }> {
|
||||
return { blobId: 'uploaded', type: 'application/octet-stream', size: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** The storage adapter for tests: real SQLite with FTS5 through node:sqlite. */
|
||||
|
||||
import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
|
||||
import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../../storage';
|
||||
|
||||
export function openMemoryStore(): StorageAdapter {
|
||||
return adapterFor(new DatabaseSync(':memory:'));
|
||||
}
|
||||
|
||||
export function openFileStore(file: string): StorageAdapter {
|
||||
return adapterFor(new DatabaseSync(file));
|
||||
}
|
||||
|
||||
function adapterFor(db: DatabaseSync): StorageAdapter {
|
||||
const tx = { depth: 0 };
|
||||
const prepared = new Map<string, ReturnType<DatabaseSync['prepare']>>();
|
||||
const statement = (sql: string) => {
|
||||
let stmt = prepared.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = db.prepare(sql);
|
||||
prepared.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
};
|
||||
return {
|
||||
exec: (sql) => db.exec(sql),
|
||||
run: (sql, params = []) => {
|
||||
const result = statement(sql).run(...(params as SQLInputValue[]));
|
||||
return { changes: Number(result.changes), lastInsertRowid: Number(result.lastInsertRowid) };
|
||||
},
|
||||
all: <T extends object = SqlRow>(sql: string, params: readonly SqlValue[] = []) => statement(sql).all(...(params as SQLInputValue[])) as T[],
|
||||
get: <T extends object = SqlRow>(sql: string, params: readonly SqlValue[] = []) => statement(sql).get(...(params as SQLInputValue[])) as T | undefined,
|
||||
transaction: (fn) => runTransaction(tx, (sql) => db.exec(sql), fn),
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_SCOPE, type StoreContext, type StoreEvent } from '../area';
|
||||
import { AccountEngine } from '../engine/account-engine';
|
||||
import { runChangesLoop } from '../engine/changes';
|
||||
import { answerRequest, buildMethodTable } from '../engine/responder';
|
||||
import type { SqlValue } from '../storage';
|
||||
import { TransportError, type MethodCall } from '../engine/transport';
|
||||
import { mailArea } from '../mail';
|
||||
import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './helpers/fake-jmap';
|
||||
import { openFileStore, openMemoryStore } from './helpers/node-adapter';
|
||||
|
||||
const EVERYTHING = { ...DEFAULT_SCOPE, days: null };
|
||||
const INBOX = { inMailbox: 'inbox' };
|
||||
|
||||
function engineFor(server: FakeJmap, db = openMemoryStore(), emit?: (e: StoreEvent) => void) {
|
||||
return new AccountEngine({
|
||||
account: 'a@fake.test',
|
||||
db,
|
||||
session: FAKE_SESSION,
|
||||
transport: server,
|
||||
areas: [mailArea],
|
||||
scope: EVERYTHING,
|
||||
emit,
|
||||
now: () => Date.UTC(2026, 10, 3),
|
||||
sleep: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function count(db: ReturnType<typeof openMemoryStore>, table: string): number {
|
||||
return Number(db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`)?.n);
|
||||
}
|
||||
|
||||
function callsNamed(server: FakeJmap, name: string): MethodCall[] {
|
||||
return server.calls.filter(([n]) => n === name);
|
||||
}
|
||||
|
||||
describe('full sync', () => {
|
||||
it('stores every message, thread and body across pages', async () => {
|
||||
const server = new FakeJmap();
|
||||
for (let i = 0; i < 1234; i += 1) server.addEmail();
|
||||
const db = openMemoryStore();
|
||||
await engineFor(server, db).requestSync();
|
||||
expect(count(db, 'email')).toBe(1234);
|
||||
expect(count(db, 'email_body')).toBe(1234);
|
||||
expect(count(db, 'thread')).toBe(1234);
|
||||
expect(count(db, 'email_mailbox')).toBe(1234);
|
||||
});
|
||||
|
||||
it('captures the state before paging, so a message landing mid-sync is not lost', async () => {
|
||||
const server = new FakeJmap();
|
||||
for (let i = 0; i < 600; i += 1) server.addEmail();
|
||||
let landed = false;
|
||||
server.beforeRequest = (n) => {
|
||||
if (n === 4 && !landed) {
|
||||
landed = true;
|
||||
server.addEmail({ id: 'late' });
|
||||
}
|
||||
};
|
||||
const db = openMemoryStore();
|
||||
await engineFor(server, db).requestSync();
|
||||
expect(landed).toBe(true);
|
||||
expect(db.get('SELECT id FROM email WHERE id = ?', ['late'])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps only the chosen window', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'old', receivedAt: '2020-01-01T00:00:00Z' });
|
||||
server.addEmail({ id: 'new', receivedAt: '2026-10-30T00:00:00Z' });
|
||||
const db = openMemoryStore();
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test', db, session: FAKE_SESSION, transport: server, areas: [mailArea],
|
||||
scope: { ...DEFAULT_SCOPE, days: 30 }, now: () => Date.UTC(2026, 10, 3), sleep: async () => {},
|
||||
});
|
||||
await engine.requestSync();
|
||||
expect(db.all('SELECT id FROM email').map((r) => r.id)).toEqual(['new']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persisted state', () => {
|
||||
it('survives a reload and continues from the saved state with a delta', async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'store-'));
|
||||
const file = path.join(dir, 's.db');
|
||||
try {
|
||||
const server = new FakeJmap();
|
||||
for (let i = 0; i < 50; i += 1) server.addEmail();
|
||||
const first = openFileStore(file);
|
||||
await engineFor(server, first).requestSync();
|
||||
first.close();
|
||||
|
||||
server.addEmail({ id: 'after-reload' });
|
||||
server.calls.length = 0;
|
||||
const second = openFileStore(file);
|
||||
await engineFor(server, second).requestSync();
|
||||
expect(second.get('SELECT id FROM email WHERE id = ?', ['after-reload'])).toBeTruthy();
|
||||
expect(count(second, 'email')).toBe(51);
|
||||
// A delta, not another full listing.
|
||||
expect(callsNamed(server, 'Email/query')).toHaveLength(0);
|
||||
expect(callsNamed(server, 'Email/changes').length).toBeGreaterThan(0);
|
||||
second.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resumes an interrupted full sync from its cursor', async () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'store-'));
|
||||
const file = path.join(dir, 's.db');
|
||||
try {
|
||||
const server = new FakeJmap();
|
||||
for (let i = 0; i < 4500; i += 1) server.addEmail();
|
||||
const first = openFileStore(file);
|
||||
// Goes down after the first page of the list.
|
||||
server.beforeRequest = (n) => {
|
||||
if (n === 4) server.offline = true;
|
||||
};
|
||||
await engineFor(server, first).requestSync();
|
||||
const listed = count(first, 'email');
|
||||
expect(listed).toBeGreaterThan(0);
|
||||
expect(listed).toBeLessThan(4500);
|
||||
first.close();
|
||||
|
||||
server.offline = false;
|
||||
server.beforeRequest = null;
|
||||
server.calls.length = 0;
|
||||
const second = openFileStore(file);
|
||||
await engineFor(server, second).requestSync();
|
||||
expect(count(second, 'email')).toBe(4500);
|
||||
const firstQuery = callsNamed(server, 'Email/query')[0];
|
||||
expect(firstQuery[1].position).toBe(listed);
|
||||
second.close();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deltas', () => {
|
||||
it('applies created, updated and destroyed, and leaves bodies alone on an update', async () => {
|
||||
const server = new FakeJmap();
|
||||
const a = server.addEmail();
|
||||
const b = server.addEmail();
|
||||
const db = openMemoryStore();
|
||||
const engine = engineFor(server, db);
|
||||
await engine.requestSync();
|
||||
|
||||
server.updateEmail(a.id, { keywords: { $seen: true } });
|
||||
server.destroyEmail(b.id);
|
||||
server.addEmail({ id: 'c' });
|
||||
await engine.requestSync();
|
||||
|
||||
expect(db.all('SELECT id FROM email ORDER BY id').map((r) => r.id)).toEqual(['c', a.id]);
|
||||
expect(db.get('SELECT 1 AS x FROM email_keyword WHERE keyword = ?', ['$seen'])).toBeTruthy();
|
||||
const kept = db.get<{ body_state: number }>('SELECT body_state FROM email WHERE id = ?', [a.id]);
|
||||
expect(kept?.body_state).toBe(1);
|
||||
// The destroyed message left nothing behind in the search index.
|
||||
expect(db.all("SELECT rowid FROM email_fts_word WHERE email_fts_word MATCH ?", [`"Subject" `]).length).toBe(2);
|
||||
});
|
||||
|
||||
it('pages through more changes than one call gives', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail();
|
||||
const db = openMemoryStore();
|
||||
const engine = engineFor(server, db);
|
||||
await engine.requestSync();
|
||||
server.calls.length = 0;
|
||||
for (let i = 0; i < 1500; i += 1) server.addEmail();
|
||||
await engine.requestSync();
|
||||
expect(count(db, 'email')).toBe(1501);
|
||||
expect(callsNamed(server, 'Email/changes').length).toBeGreaterThanOrEqual(3);
|
||||
const page = callsNamed(server, 'Email/changes')[0][1];
|
||||
expect(page.maxChanges).toBe(500);
|
||||
});
|
||||
|
||||
it('sends sinceState last, because a malformed one otherwise fails the whole request on Stalwart', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail();
|
||||
const engine = engineFor(server);
|
||||
await engine.requestSync();
|
||||
server.addEmail();
|
||||
server.calls.length = 0;
|
||||
await engine.requestSync();
|
||||
const keys = Object.keys(callsNamed(server, 'Email/changes')[0][1]);
|
||||
expect(keys[keys.length - 1]).toBe('sinceState');
|
||||
});
|
||||
|
||||
it('keeps going while behind the pushed state, since hasMoreChanges false does not mean caught up', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail();
|
||||
const applied: string[] = [];
|
||||
let wrote = false;
|
||||
const outcome = await runChangesLoop({
|
||||
transport: server,
|
||||
type: 'Email',
|
||||
jmap: JMAP_ACCOUNT,
|
||||
using: [],
|
||||
since: 's0',
|
||||
target: 's2',
|
||||
apply: async (page) => {
|
||||
applied.push(page.newState);
|
||||
if (!wrote) {
|
||||
wrote = true;
|
||||
server.addEmail();
|
||||
}
|
||||
},
|
||||
});
|
||||
expect(outcome).toMatchObject({ outcome: 'caughtUp', state: 's2' });
|
||||
expect(applied).toEqual(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('stops when the state stands still even though the push named a newer one', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail();
|
||||
const outcome = await runChangesLoop({
|
||||
transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's1', target: 's9',
|
||||
apply: async () => {},
|
||||
});
|
||||
expect(outcome.outcome).toBe('caughtUp');
|
||||
expect(server.requests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resync', () => {
|
||||
it('starts Email over on cannotCalculateChanges, keeps bodies, and drops what is gone', async () => {
|
||||
const server = new FakeJmap();
|
||||
const keep = server.addEmail({ id: 'keep' });
|
||||
server.addEmail({ id: 'gone' });
|
||||
const db = openMemoryStore();
|
||||
const engine = engineFor(server, db);
|
||||
await engine.requestSync();
|
||||
|
||||
server.destroyEmail('gone');
|
||||
server.updateEmail(keep.id, { keywords: { $flagged: true } });
|
||||
server.truncateLog('Email');
|
||||
server.calls.length = 0;
|
||||
await engine.requestSync();
|
||||
|
||||
expect(callsNamed(server, 'Email/query').length).toBeGreaterThan(0);
|
||||
expect(db.all('SELECT id FROM email').map((r) => r.id)).toEqual(['keep']);
|
||||
expect(db.get('SELECT 1 AS x FROM email_keyword WHERE keyword = ?', ['$flagged'])).toBeTruthy();
|
||||
// The body wasn't fetched a second time.
|
||||
const bodyFetches = callsNamed(server, 'Email/get').filter(([, a]) => a.fetchTextBodyValues);
|
||||
expect(bodyFetches).toHaveLength(0);
|
||||
expect(count(db, 'email_body')).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalidArguments', { jmapError: 'invalidArguments' }],
|
||||
['cannotCalculateChanges', { jmapError: 'cannotCalculateChanges' }],
|
||||
])('treats %s as a reason to start over', async (_name, thrown) => {
|
||||
const server = new FakeJmap();
|
||||
const original = server.request.bind(server);
|
||||
let refused = false;
|
||||
server.request = async (calls) => {
|
||||
if (!refused && calls[0][0] === 'Email/changes') {
|
||||
refused = true;
|
||||
return { methodResponses: [['error', { type: thrown.jmapError }, 'c']] };
|
||||
}
|
||||
return original(calls);
|
||||
};
|
||||
const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's0', apply: async () => {} });
|
||||
expect(outcome.outcome).toBe('resync');
|
||||
});
|
||||
|
||||
it('treats an HTTP 400 notRequest as a reason to start over, not as an outage', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.request = async () => {
|
||||
throw new TransportError('http', 'bad', 400, 'urn:ietf:params:jmap:error:notRequest');
|
||||
};
|
||||
const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 's0', apply: async () => {} });
|
||||
expect(outcome.outcome).toBe('resync');
|
||||
});
|
||||
|
||||
it('never sends a stored state a server cannot have sent', async () => {
|
||||
const server = new FakeJmap();
|
||||
const outcome = await runChangesLoop({ transport: server, type: 'Email', jmap: JMAP_ACCOUNT, using: [], since: 'bad state\n', apply: async () => {} });
|
||||
expect(outcome.outcome).toBe('resync');
|
||||
expect(server.requests).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the server is busy or gone', () => {
|
||||
it('backs off and carries on after a 429', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail();
|
||||
server.busyNext = 2;
|
||||
const db = openMemoryStore();
|
||||
await engineFor(server, db).requestSync();
|
||||
expect(count(db, 'email')).toBe(1);
|
||||
});
|
||||
|
||||
it('ends the run quietly when the network is gone, and picks up when back', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail();
|
||||
server.offline = true;
|
||||
const events: StoreEvent[] = [];
|
||||
const db = openMemoryStore();
|
||||
const engine = engineFor(server, db, (e) => events.push(e));
|
||||
await engine.requestSync();
|
||||
expect(count(db, 'email')).toBe(0);
|
||||
expect(events.some((e) => e.type === 'error')).toBe(false);
|
||||
server.offline = false;
|
||||
await engine.requestSync();
|
||||
expect(count(db, 'email')).toBe(1);
|
||||
});
|
||||
|
||||
it('tells the page about new mail only for messages it had not seen', async () => {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail();
|
||||
const events: StoreEvent[] = [];
|
||||
const engine = engineFor(server, openMemoryStore(), (e) => events.push(e));
|
||||
await engine.requestSync();
|
||||
expect(events.filter((e) => e.type === 'created')).toHaveLength(0);
|
||||
server.addEmail({ id: 'fresh' });
|
||||
await engine.requestSync();
|
||||
expect(events.filter((e) => e.type === 'created')).toEqual([
|
||||
{ type: 'created', account: 'a@fake.test', jmap: JMAP_ACCOUNT, collection: 'Email', ids: ['fresh'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query plans', () => {
|
||||
it('reads a newest-first folder page off the index, without sorting the folder', async () => {
|
||||
const server = new FakeJmap();
|
||||
for (let i = 0; i < 30; i += 1) server.addEmail();
|
||||
const db = openMemoryStore();
|
||||
const engine = engineFor(server, db);
|
||||
await engine.requestSync();
|
||||
const seen: string[] = [];
|
||||
const spied = {
|
||||
...db,
|
||||
all: <T extends object>(sql: string, params?: readonly SqlValue[]) => {
|
||||
seen.push(sql);
|
||||
return db.all<T>(sql, params);
|
||||
},
|
||||
};
|
||||
const answer = await answerRequest(
|
||||
{ account: 'a', db: spied as typeof db, session: FAKE_SESSION, scope: EVERYTHING, now: Date.now, emit: () => {}, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false },
|
||||
buildMethodTable([mailArea]),
|
||||
[['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], limit: 5 }, 'q']],
|
||||
);
|
||||
expect(answer?.methodResponses[0][1].ids).toHaveLength(5);
|
||||
const [sql] = seen;
|
||||
const plan = db.all<{ detail: string }>(`EXPLAIN QUERY PLAN ${sql}`, [JMAP_ACCOUNT, 'inbox', 5, 0]).map((row) => row.detail).join('; ');
|
||||
expect(plan).not.toContain('TEMP B-TREE');
|
||||
expect(plan).toContain('email_mailbox_list');
|
||||
// The folder condition isn't looked up again for every row.
|
||||
expect(sql).not.toContain('m.mailbox_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a failure inside a transaction', () => {
|
||||
it('reports the real error even when SQLite has already rolled back', () => {
|
||||
const db = openMemoryStore();
|
||||
db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)');
|
||||
expect(() => db.transaction(() => {
|
||||
db.exec('ROLLBACK');
|
||||
throw new Error('disk is full');
|
||||
})).toThrow('disk is full');
|
||||
// And the connection still works.
|
||||
db.transaction(() => db.run('INSERT INTO t (id) VALUES (1)'));
|
||||
expect(db.all('SELECT id FROM t')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calls into an area', () => {
|
||||
const noteArea = {
|
||||
name: 'note',
|
||||
migrations: [{ id: 9999, name: 'note', sql: 'CREATE TABLE note (id INTEGER PRIMARY KEY, text TEXT)' }],
|
||||
methods: {},
|
||||
syncers: [],
|
||||
calls: {
|
||||
'note.add': (ctx: StoreContext, params: { text: string }) => ctx.db.run('INSERT INTO note (text) VALUES (?)', [params.text]).lastInsertRowid,
|
||||
},
|
||||
};
|
||||
|
||||
it('reaches the area and runs its migration', async () => {
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(), areas: [mailArea, noteArea], scope: EVERYTHING,
|
||||
});
|
||||
expect(await engine.areaCall('note.add', { text: 'hello' })).toBe(1);
|
||||
await expect(engine.areaCall('note.nothing', {})).rejects.toThrow('Unknown store call');
|
||||
});
|
||||
|
||||
it('refuses two areas defining the same name', () => {
|
||||
expect(() => new AccountEngine({
|
||||
account: 'a@fake.test', db: openMemoryStore(), session: FAKE_SESSION, transport: new FakeJmap(),
|
||||
areas: [noteArea, { ...noteArea, name: 'other', migrations: [] }], scope: EVERYTHING,
|
||||
})).toThrow('Two store areas define note.add');
|
||||
});
|
||||
});
|
||||
|
||||
describe('answers from the store', () => {
|
||||
async function seeded() {
|
||||
const server = new FakeJmap();
|
||||
server.addEmail({ id: 'one', subject: 'Zahlungserinnerung Rechnungsbetrag', keywords: { $seen: true } });
|
||||
server.addEmail({ id: 'two', subject: 'Hello', mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'See you Tuesday' } } });
|
||||
const db = openMemoryStore();
|
||||
const engine = engineFor(server, db);
|
||||
await engine.requestSync();
|
||||
return { engine, db, server };
|
||||
}
|
||||
|
||||
it('gives every call its own answer, in order, with a per-call error for what it cannot answer', async () => {
|
||||
const { engine } = await seeded();
|
||||
const answer = await engine.answer([
|
||||
['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], calculateTotal: true }, 'a'],
|
||||
['SieveScript/get', {}, 'b'],
|
||||
['Email/get', { '#ids': { resultOf: 'a', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, 'c'],
|
||||
]);
|
||||
expect(answer?.methodResponses.map(([name, , id]) => [name, id])).toEqual([
|
||||
['Email/query', 'a'], ['error', 'b'], ['Email/get', 'c'],
|
||||
]);
|
||||
expect(answer?.methodResponses[0][1]).toMatchObject({ ids: ['two', 'one'], total: 2, position: 0 });
|
||||
expect(answer?.methodResponses[1][1].type).toBe('serverUnavailable');
|
||||
expect(answer?.methodResponses[2][1].list).toEqual([
|
||||
{ id: 'two', subject: 'Hello' },
|
||||
{ id: 'one', subject: 'Zahlungserinnerung Rechnungsbetrag' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers null when nothing in the request could be answered', async () => {
|
||||
const { engine } = await seeded();
|
||||
expect(await engine.answer([['SieveScript/get', {}, 'a']])).toBeNull();
|
||||
});
|
||||
|
||||
it('finds a German compound from inside the word, and a word start', async () => {
|
||||
const { engine } = await seeded();
|
||||
const search = async (text: string) => (await engine.answer([['Email/query', { filter: { text } }, 'q']]))?.methodResponses[0][1].ids;
|
||||
expect(await search('rechnung')).toEqual(['one']);
|
||||
expect(await search('erinnerung')).toEqual(['one']);
|
||||
expect(await search('zahl*')).toEqual(['one']);
|
||||
expect(await search('tuesday')).toEqual(['two']);
|
||||
expect(await search('nichtda')).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads keyword sort the way the client sends it for that server', async () => {
|
||||
const { engine } = await seeded();
|
||||
const sorted = async () => (await engine.answer([['Email/query', {
|
||||
filter: INBOX,
|
||||
sort: [{ property: 'hasKeyword', keyword: '$seen', isAscending: false }],
|
||||
}, 'q']]))?.methodResponses[0][1].ids;
|
||||
expect(await sorted()).toEqual(['one', 'two']);
|
||||
engine.setInvertedKeywordSort(true);
|
||||
expect(await sorted()).toEqual(['two', 'one']);
|
||||
});
|
||||
|
||||
it('does not make up a message with empty text when the body is not kept', async () => {
|
||||
const { engine, db } = await seeded();
|
||||
db.run('DELETE FROM email_body');
|
||||
const answer = await engine.answer([['Email/get', { ids: ['one'], fetchTextBodyValues: true }, 'g']]);
|
||||
expect(answer?.methodResponses[0][1]).toMatchObject({ list: [], notFound: ['one'] });
|
||||
});
|
||||
|
||||
it('answers with the real server state, never a made-up one', async () => {
|
||||
const { engine, server } = await seeded();
|
||||
const answer = await engine.answer([['Email/get', { ids: ['one'], properties: ['id'] }, 'g']]);
|
||||
expect(answer?.methodResponses[0][1].state).toBe(server.coll.Email.state());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,557 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildJMAPFilter, DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { parseSearchSnippet, snippetFilterFor } from '@/lib/search-snippet';
|
||||
import { DEFAULT_SCOPE } from '../area';
|
||||
import { AccountEngine } from '../engine/account-engine';
|
||||
import { mailArea } from '../mail';
|
||||
import { bodyText, deleteEmail } from '../mail/records';
|
||||
import { FAKE_SESSION, FakeJmap } from './helpers/fake-jmap';
|
||||
import { openMemoryStore } from './helpers/node-adapter';
|
||||
|
||||
type Email = Parameters<FakeJmap['addEmail']>[0];
|
||||
|
||||
function withBody(value: string): Email {
|
||||
return { bodyValues: { 1: { value, isTruncated: false, isEncodingProblem: false } } };
|
||||
}
|
||||
|
||||
async function storeWith(emails: Email[], db = openMemoryStore()) {
|
||||
const server = new FakeJmap();
|
||||
for (const email of emails) server.addEmail(email);
|
||||
const engine = new AccountEngine({
|
||||
account: 'a@fake.test',
|
||||
db,
|
||||
session: FAKE_SESSION,
|
||||
transport: server,
|
||||
areas: [mailArea],
|
||||
scope: { ...DEFAULT_SCOPE, days: null },
|
||||
now: () => Date.UTC(2026, 10, 3),
|
||||
sleep: async () => {},
|
||||
});
|
||||
await engine.requestSync();
|
||||
const query = async (filter: Record<string, unknown>) => {
|
||||
// A second call, because a request with no call answered comes back as null.
|
||||
const answer = await engine.answer([
|
||||
['Email/query', { filter, sort: [{ property: 'receivedAt', isAscending: true }] }, 'q'],
|
||||
['Mailbox/get', { ids: [] }, 'm'],
|
||||
]);
|
||||
const [name, result] = answer?.methodResponses[0] ?? ['error', { type: 'no answer' }];
|
||||
if (name === 'error') throw new Error(`per-call error: ${result.type}`);
|
||||
return result.ids as string[];
|
||||
};
|
||||
const snippets = async (filter: Record<string, unknown> | null, emailIds: string[]) => {
|
||||
const answer = await engine.answer([['SearchSnippet/get', { filter, emailIds }, 's']]);
|
||||
return answer?.methodResponses[0][1] as {
|
||||
list: Array<{ emailId: string; subject: string | null; preview: string | null }>;
|
||||
notFound: string[];
|
||||
};
|
||||
};
|
||||
return { engine, query, snippets, db };
|
||||
}
|
||||
|
||||
/** A store that records the statements it runs. */
|
||||
function recordingStore() {
|
||||
const db = openMemoryStore();
|
||||
const statements: string[] = [];
|
||||
const run = db.run.bind(db);
|
||||
db.run = (sql, params) => {
|
||||
statements.push(sql);
|
||||
return run(sql, params);
|
||||
};
|
||||
return { db, statements };
|
||||
}
|
||||
|
||||
const alice = [{ name: 'Alice Adler', email: 'alice@example.org' }];
|
||||
const bob = [{ name: 'Bob Berg', email: 'bob@example.org' }];
|
||||
const carol = [{ name: 'Carol Conrad', email: 'carol@example.org' }];
|
||||
const dave = [{ name: 'Dave Dorn', email: 'dave@example.org' }];
|
||||
|
||||
describe('matching', () => {
|
||||
it('finds a word inside a German compound, in the subject and in the body', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'subject', subject: 'Zahlungserinnerungsrechnung Oktober' },
|
||||
{ id: 'body', subject: 'Hallo', ...withBody('Anbei die Stromrechnung.') },
|
||||
{ id: 'other', subject: 'Einladung', ...withBody('Bis morgen') },
|
||||
]);
|
||||
expect(await query({ text: 'rechnung' })).toEqual(['subject', 'body']);
|
||||
expect(await query({ subject: 'rechnung' })).toEqual(['subject']);
|
||||
expect(await query({ body: 'rechnung' })).toEqual(['body']);
|
||||
});
|
||||
|
||||
it('reads word* as the start of a word, not anywhere in it', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'start', subject: 'Rechnungsbetrag offen' },
|
||||
{ id: 'inside', subject: 'Stromrechnung offen' },
|
||||
]);
|
||||
expect(await query({ text: 'rechn*' })).toEqual(['start']);
|
||||
expect(await query({ text: 'rechn' })).toEqual(['start', 'inside']);
|
||||
});
|
||||
|
||||
it('reads a quoted phrase as exact words in order', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'phrase', subject: 'The annual report is here' },
|
||||
{ id: 'apart', subject: 'The report for annual leave' },
|
||||
{ id: 'compound', subject: 'Stromrechnung' },
|
||||
{ id: 'word', subject: 'Eine Rechnung' },
|
||||
]);
|
||||
expect(await query({ text: '"annual report"' })).toEqual(['phrase']);
|
||||
expect(await query({ text: '"rechnung"' })).toEqual(['word']);
|
||||
});
|
||||
|
||||
it('matches one and two characters as whole words only, unless a star asks for more', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'zu', subject: 'Bitte zu Hause' },
|
||||
{ id: 'zum', subject: 'Bitte zum Arzt' },
|
||||
{ id: 'a', subject: 'Plan a today' },
|
||||
{ id: 'anna', subject: 'Anna kommt' },
|
||||
]);
|
||||
expect(await query({ subject: 'zu' })).toEqual(['zu']);
|
||||
expect(await query({ subject: 'zu*' })).toEqual(['zu', 'zum']);
|
||||
expect(await query({ subject: 'a' })).toEqual(['a']);
|
||||
// One letter is too expensive as a prefix, so it stays the word.
|
||||
expect(await query({ subject: 'a*' })).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('keeps To, Cc and Bcc apart, and searches all of them for text', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'one', from: alice, to: bob, cc: carol, bcc: dave },
|
||||
]);
|
||||
expect(await query({ from: 'alice' })).toEqual(['one']);
|
||||
expect(await query({ to: 'bob' })).toEqual(['one']);
|
||||
expect(await query({ to: 'carol' })).toEqual([]);
|
||||
expect(await query({ to: 'dave' })).toEqual([]);
|
||||
expect(await query({ cc: 'carol' })).toEqual(['one']);
|
||||
expect(await query({ cc: 'bob' })).toEqual([]);
|
||||
expect(await query({ bcc: 'dave' })).toEqual(['one']);
|
||||
expect(await query({ bcc: 'carol' })).toEqual([]);
|
||||
for (const name of ['alice', 'bob', 'carol', 'dave']) expect(await query({ text: name })).toEqual(['one']);
|
||||
});
|
||||
|
||||
it('answers the recipient search the client sends', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'to', to: bob },
|
||||
{ id: 'cc', to: alice, cc: bob },
|
||||
{ id: 'none', to: alice },
|
||||
]);
|
||||
expect(await query({ operator: 'OR', conditions: [{ to: 'bob@example.org' }, { cc: 'bob@example.org' }] })).toEqual(['to', 'cc']);
|
||||
});
|
||||
|
||||
it('answers every search shape buildJMAPFilter produces', async () => {
|
||||
const { query } = await storeWith([
|
||||
{
|
||||
id: 'hit',
|
||||
subject: 'Budget 2024 annual report',
|
||||
from: alice,
|
||||
to: bob,
|
||||
...withBody('Die Stromrechnung liegt bei'),
|
||||
},
|
||||
{ id: 'miss', subject: 'Budget 2024', from: alice, to: bob, ...withBody('Nichts') },
|
||||
]);
|
||||
const filter = buildJMAPFilter('"annual report" budg* 2024', {
|
||||
...DEFAULT_SEARCH_FILTERS,
|
||||
from: 'alice',
|
||||
to: 'bob',
|
||||
subject: 'annual',
|
||||
body: 'rechnung',
|
||||
}, 'inbox');
|
||||
expect(await query(filter)).toEqual(['hit']);
|
||||
});
|
||||
|
||||
it('ignores diacritics both ways', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'umlaut', subject: 'Frau Müller' },
|
||||
{ id: 'plain', subject: 'Herr Muller' },
|
||||
{ id: 'accent', subject: 'Café am Markt' },
|
||||
]);
|
||||
expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'plain']);
|
||||
expect(await query({ text: 'muller' })).toEqual(['umlaut', 'plain']);
|
||||
expect(await query({ text: 'cafe' })).toEqual(['accent']);
|
||||
expect(await query({ text: 'Café' })).toEqual(['accent']);
|
||||
expect(await query({ text: 'mül*' })).toEqual(['umlaut', 'plain']);
|
||||
});
|
||||
|
||||
it('finds the spelled-out German spelling both ways, without false friends', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'umlaut', subject: 'Frau Müller' },
|
||||
{ id: 'written', subject: 'Herr Mueller' },
|
||||
{ id: 'sharp', subject: 'Hauptstraße 1' },
|
||||
{ id: 'double', subject: 'Hauptstrasse 2' },
|
||||
{ id: 'care', subject: 'Die Betreuung beginnt' },
|
||||
{ id: 'loyal', subject: 'Treue' },
|
||||
]);
|
||||
expect(await query({ text: 'Mueller' })).toEqual(['umlaut', 'written']);
|
||||
expect(await query({ text: 'Müller' })).toEqual(['umlaut', 'written']);
|
||||
expect(await query({ text: 'Hauptstraße' })).toEqual(['sharp', 'double']);
|
||||
expect(await query({ text: 'Hauptstrasse' })).toEqual(['sharp', 'double']);
|
||||
// "treu" is inside "Betreuung": the plain spelling is a whole word only.
|
||||
expect(await query({ text: 'Treue' })).toEqual(['loyal']);
|
||||
});
|
||||
|
||||
it('finds an umlaut an HTML mail wrote as an entity', async () => {
|
||||
const { query } = await storeWith([{
|
||||
id: 'html',
|
||||
subject: 'Hallo',
|
||||
textBody: [],
|
||||
htmlBody: [{ partId: '1', type: 'text/html' }],
|
||||
...withBody('<head><title>Vorlage</title></head><p>Grüße von Frau Müller &lt;Chefin&gt;</p>'),
|
||||
}]);
|
||||
expect(await query({ body: 'Müller' })).toEqual(['html']);
|
||||
expect(await query({ body: 'grüße' })).toEqual(['html']);
|
||||
expect(await query({ body: 'Vorlage' })).toEqual([]);
|
||||
// Decoded once: the encoded text stays text.
|
||||
expect(await query({ body: '"<Chefin>"' })).toEqual(['html']);
|
||||
});
|
||||
|
||||
it('answers a very common word', async () => {
|
||||
const emails: Email[] = [];
|
||||
for (let i = 0; i < 300; i += 1) emails.push({ subject: 'die Rechnung für die Firma' });
|
||||
const { query } = await storeWith(emails);
|
||||
expect((await query({ text: 'die' })).length).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the split indexes', () => {
|
||||
it('writes each index once for a message, with no delete when its body arrives', async () => {
|
||||
const { db, statements } = recordingStore();
|
||||
await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }], db);
|
||||
const fts = statements.filter((sql) => sql.includes('email_fts'));
|
||||
expect(fts.filter((sql) => sql.startsWith('DELETE'))).toEqual([]);
|
||||
for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) {
|
||||
expect(fts.filter((sql) => sql.startsWith(`INSERT INTO ${table} `)).length, table).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('touches the head row again only for a message with Bcc', async () => {
|
||||
const { db, statements } = recordingStore();
|
||||
const { query } = await storeWith([{ id: 'one', subject: 'Hallo', bcc: dave }], db);
|
||||
const fts = statements.filter((sql) => sql.includes('email_fts'));
|
||||
expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_word')).length).toBe(1);
|
||||
expect(fts.filter((sql) => sql.startsWith('DELETE FROM email_fts_body'))).toEqual([]);
|
||||
// Nothing of the head was lost in writing it again.
|
||||
expect(await query({ subject: 'hallo' })).toEqual(['one']);
|
||||
expect(await query({ bcc: 'dave' })).toEqual(['one']);
|
||||
});
|
||||
|
||||
it('finds head fields only in the head and body text only in the body', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'in-subject', subject: 'Stromrechnung', ...withBody('nichts') },
|
||||
{ id: 'in-body', subject: 'Hallo', ...withBody('Die Stromrechnung') },
|
||||
{ id: 'from-word', subject: 'Hallo', from: [{ name: 'Stromrechnung', email: 'x@example.org' }], ...withBody('nichts') },
|
||||
]);
|
||||
expect(await query({ text: 'rechnung' })).toEqual(['in-subject', 'in-body', 'from-word']);
|
||||
expect(await query({ text: 'stromrechnung' })).toEqual(['in-subject', 'in-body', 'from-word']);
|
||||
expect(await query({ subject: 'rechnung' })).toEqual(['in-subject']);
|
||||
expect(await query({ body: 'rechnung' })).toEqual(['in-body']);
|
||||
expect(await query({ from: 'rechnung' })).toEqual(['from-word']);
|
||||
});
|
||||
|
||||
it('keeps each word of a search free to match in the head or the body', async () => {
|
||||
const { query } = await storeWith([{ id: 'one', subject: 'Rechnung', ...withBody('Oktober') }, { id: 'two', subject: 'Rechnung' }]);
|
||||
expect(await query({ text: 'rechnung oktober' })).toEqual(['one']);
|
||||
});
|
||||
|
||||
it('caps the trigrams of a body at 32 KB and leaves its words to 200 KB', async () => {
|
||||
const filler = 'fuellwort '.repeat(4000);
|
||||
const { query } = await storeWith([
|
||||
{ id: 'early', subject: 'x', ...withBody('Stromrechnung am Anfang') },
|
||||
{ id: 'late', subject: 'x', ...withBody(`${filler} Gasrechnung am Ende`) },
|
||||
{ id: 'past', subject: 'x', ...withBody(`${filler.repeat(6)} Wasserrechnung`) },
|
||||
]);
|
||||
expect(await query({ body: 'rechnung' })).toEqual(['early']);
|
||||
// Past the trigram cap the whole word and its start are still found.
|
||||
expect(await query({ body: 'gasrechnung' })).toEqual(['late']);
|
||||
expect(await query({ body: 'gasrech*' })).toEqual(['late']);
|
||||
// Past the word cap too: not indexed at all.
|
||||
expect(await query({ body: 'wasserrechnung' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes a message from all four indexes', async () => {
|
||||
const { db, query } = await storeWith([{ id: 'one', subject: 'Hallo', ...withBody('Inhalt') }]);
|
||||
const jmap = String(db.get<{ jmap: string }>('SELECT jmap FROM email')?.jmap);
|
||||
deleteEmail(db, jmap, 'one');
|
||||
for (const table of ['email_fts_word', 'email_fts_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"hallo"'])).toEqual([]);
|
||||
for (const table of ['email_fts_body_word', 'email_fts_body_tri']) expect(db.all(`SELECT rowid FROM ${table} WHERE ${table} MATCH ?`, ['"inhalt"'])).toEqual([]);
|
||||
expect(await query({ text: 'inhalt' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('sets the options for bulk writes', async () => {
|
||||
const { db } = await storeWith([]);
|
||||
const option = (table: string, key: string) => Number(db.get<{ v: number }>(`SELECT v FROM ${table}_config WHERE k = ?`, [key])?.v);
|
||||
expect(option('email_fts_body_word', 'hashsize')).toBe(8 * 1024 * 1024);
|
||||
expect(option('email_fts_body_tri', 'hashsize')).toBe(16 * 1024 * 1024);
|
||||
for (const table of ['email_fts_word', 'email_fts_tri', 'email_fts_body_word', 'email_fts_body_tri']) {
|
||||
expect(option(table, 'automerge'), table).toBe(8);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyText', () => {
|
||||
const html = (value: string) => ({
|
||||
textBody: [],
|
||||
htmlBody: [{ partId: '1', type: 'text/html' }],
|
||||
bodyValues: { 1: { value } },
|
||||
});
|
||||
const MEGABYTE = 1_000_000;
|
||||
const HOSTILE: Record<string, string> = {
|
||||
'opening brackets': '<'.repeat(MEGABYTE),
|
||||
'comment openers': '<!--'.repeat(MEGABYTE / 4),
|
||||
'style openers': '<style'.repeat(MEGABYTE / 6),
|
||||
'style tags': '<style>'.repeat(MEGABYTE / 7),
|
||||
'head and script openers': '<head><script>'.repeat(MEGABYTE / 14),
|
||||
'one long tag': `<${'a'.repeat(MEGABYTE)}`,
|
||||
'ampersands': '&'.repeat(MEGABYTE),
|
||||
'an endless entity': `&${'a'.repeat(MEGABYTE)}`,
|
||||
'many entities': '&a'.repeat(MEGABYTE / 2),
|
||||
'spaces': ' '.repeat(MEGABYTE),
|
||||
'closed blocks': '<style>x</style>'.repeat(MEGABYTE / 16),
|
||||
};
|
||||
|
||||
for (const [name, value] of Object.entries(HOSTILE)) {
|
||||
it(`gets through a 1 MB body of ${name} fast`, () => {
|
||||
const started = Date.now();
|
||||
bodyText(html(value));
|
||||
// Generous, to not be flaky. The quadratic version took 40 s on a sixth of this.
|
||||
expect(Date.now() - started).toBeLessThan(5_000);
|
||||
});
|
||||
}
|
||||
|
||||
it('caps what it reads', () => {
|
||||
expect(bodyText(html(`<p>${'x'.repeat(2 * MEGABYTE)}</p>`)).length).toBeLessThanOrEqual(MEGABYTE);
|
||||
expect(bodyText({
|
||||
textBody: [{ partId: '1', type: 'text/plain' }],
|
||||
bodyValues: { 1: { value: 'y'.repeat(2 * MEGABYTE) } },
|
||||
}).length).toBe(MEGABYTE);
|
||||
});
|
||||
|
||||
it('still takes out tags, comments and blocks, whatever their case', () => {
|
||||
expect(bodyText(html(
|
||||
'<!-- a --><HEAD><title>b</title></HEAD><STYLE>c</style><script type="x">d</SCRIPT><header>kept</header><p>text</p><!-- e -->',
|
||||
)).trim()).toBe('kept text');
|
||||
});
|
||||
|
||||
it('leaves the text of a block that never closes', () => {
|
||||
expect(bodyText(html('before <style>.a{} after')).trim()).toBe('before .a{} after');
|
||||
expect(bodyText(html('before <!-- after')).trim()).toBe('before <!-- after');
|
||||
// One kind left open doesn't stop the others being taken out.
|
||||
expect(bodyText(html('<style>a <script>b</script> c')).trim()).toBe('a c');
|
||||
});
|
||||
|
||||
it('does not take a property of Object for an entity', () => {
|
||||
for (const name of ['constructor', 'toString', 'valueOf', 'hasOwnProperty']) {
|
||||
expect(bodyText(html(`a &${name}; b`))).toBe(`a &${name}; b`);
|
||||
}
|
||||
expect(bodyText(html('Grüße ü ü'))).toBe('Grüße ü ü');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostile input', () => {
|
||||
const HOSTILE = [
|
||||
'"', '""', '"""', '" "', '"unterminated', 'odd " quote', '*', '**', '*foo', 'foo*bar', 'a*b*c*',
|
||||
'-', '--', '-foo', 'foo-', 'foo - bar', ':', 'subject:', 'subject:foo', 'nope:foo', 'from_addr : x',
|
||||
'(', ')', '()', '(foo', 'foo)', '((((', '))))', '(foo OR bar)',
|
||||
'AND', 'OR', 'NOT', 'NEAR', 'NEAR(a b)', 'NEAR(a b, 2)', 'a AND', 'OR b', 'NOT c', 'a OR OR b',
|
||||
'^', '^foo', '+', 'foo + bar', '{', '{subject body}: foo', '}', '[', ']', '\\', '\\"', "'", "'foo'", '%', '_', ';',
|
||||
"'; DROP TABLE email; --", 'foo\0bar', '\0', '\x01\x02', '\n', '\t', '\ud83d', '\udc00', '🎉', '🎉🎉🎉', '<mark>',
|
||||
'x'.repeat(100_000), '"'.repeat(5001), '('.repeat(5001), 'a '.repeat(5000), '"a" '.repeat(2000), 'a* '.repeat(2000),
|
||||
];
|
||||
|
||||
it('never throws, for any field and any input', async () => {
|
||||
const { query } = await storeWith([{ subject: 'foo bar' }]);
|
||||
for (const field of ['text', 'subject', 'from', 'to', 'cc', 'bcc', 'body']) {
|
||||
for (const input of HOSTILE) {
|
||||
await expect(query({ [field]: input }), `${field}: ${input.slice(0, 30)}`).resolves.toBeInstanceOf(Array);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('reads the punctuation as text around the words', async () => {
|
||||
const { query } = await storeWith([
|
||||
{ id: 'hit', subject: 'foo bar' },
|
||||
{ id: 'miss', subject: 'other words' },
|
||||
]);
|
||||
for (const input of ['foo - bar', '(foo)', '(foo bar)', 'foo: bar:', '"foo" -', '-foo', 'foo*', 'foo-bar']) {
|
||||
expect(await query({ text: input }), input).toEqual(['hit']);
|
||||
}
|
||||
});
|
||||
|
||||
it('finds nothing, rather than everything, when no term is left', async () => {
|
||||
const { query } = await storeWith([{ subject: 'foo bar' }]);
|
||||
for (const input of ['', ' ', '*', '-', '"', '""', '()', '🎉']) {
|
||||
expect(await query({ text: input }), input).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('caps what one value and one filter can ask, widening rather than failing', async () => {
|
||||
const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]);
|
||||
// The terms past the limit are ignored.
|
||||
expect(await query({ text: `${'foo '.repeat(16)} nothere` })).toEqual(['hit']);
|
||||
const many = Array.from({ length: 1000 }, (_, i) => ({ text: i < 32 ? 'foo' : 'nothere' }));
|
||||
expect(await query({ operator: 'AND', conditions: many })).toEqual(['hit']);
|
||||
});
|
||||
|
||||
it('refuses too many terms where dropping one would change the meaning', async () => {
|
||||
const { query } = await storeWith([{ id: 'hit', subject: 'foo' }]);
|
||||
const many = Array.from({ length: 33 }, () => ({ text: 'foo' }));
|
||||
await expect(query({ operator: 'OR', conditions: many })).rejects.toThrow('unsupportedFilter');
|
||||
await expect(query({ operator: 'NOT', conditions: many })).rejects.toThrow('unsupportedFilter');
|
||||
await expect(query({ operator: 'OR', conditions: many.slice(0, 32) })).resolves.toEqual(['hit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchSnippet/get', () => {
|
||||
it('marks the terms in the subject and the body, as the server would', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'Die Rechnung', ...withBody('Hier ist die Rechnung von heute.') }]);
|
||||
const { list, notFound } = await snippets({ text: 'rechnung' }, ['one', 'gone']);
|
||||
expect(list).toEqual([{
|
||||
emailId: 'one',
|
||||
subject: 'Die <mark>Rechnung</mark>',
|
||||
preview: 'Hier ist die <mark>Rechnung</mark> von heute.',
|
||||
}]);
|
||||
expect(notFound).toEqual(['gone']);
|
||||
});
|
||||
|
||||
it('escapes everything except its own marks', async () => {
|
||||
const hostile = 'Tom & <script>alert("x")</script> <mark>fake</mark> it\'s Rechnung & more';
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: hostile, ...withBody(hostile) }]);
|
||||
const { list } = await snippets({ text: 'rechnung' }, ['one']);
|
||||
const escaped = 'Tom & <script>alert("x")</script> <mark>fake</mark> it's <mark>Rechnung</mark> &amp; more';
|
||||
expect(list[0].subject).toBe(escaped);
|
||||
// The preview starts near the match, and is escaped the same way.
|
||||
expect(list[0].preview).toBe(escaped.slice(escaped.indexOf('<mark>')));
|
||||
// What the list shows: the markup as plain text, one highlighted word.
|
||||
expect(parseSearchSnippet(list[0].subject as string)).toEqual([
|
||||
{ text: 'Tom & <script>alert("x")</script> <mark>fake</mark> it\'s ', marked: false },
|
||||
{ text: 'Rechnung', marked: true },
|
||||
{ text: ' & more', marked: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('escapes a term that holds markup', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'a <b> c' }]);
|
||||
const { list } = await snippets({ subject: '"<b>"' }, ['one']);
|
||||
expect(list[0].subject).toBe('a <<mark>b</mark>> c');
|
||||
});
|
||||
|
||||
it('marks the original text when diacritics or spelling differ', async () => {
|
||||
const { snippets } = await storeWith([
|
||||
{ id: 'umlaut', subject: 'Frau Müller, MÜLLER' },
|
||||
{ id: 'written', subject: 'Herr Mueller' },
|
||||
]);
|
||||
expect((await snippets({ text: 'muller' }, ['umlaut'])).list[0].subject).toBe('Frau <mark>Müller</mark>, <mark>MÜLLER</mark>');
|
||||
expect((await snippets({ text: 'Mueller' }, ['umlaut'])).list[0].subject).toBe('Frau <mark>Müller</mark>, <mark>MÜLLER</mark>');
|
||||
expect((await snippets({ text: 'Müller' }, ['written'])).list[0].subject).toBe('Herr <mark>Mueller</mark>');
|
||||
});
|
||||
|
||||
it('marks only where the index would have matched', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'zu zum Stromrechnung Rechnung' }]);
|
||||
expect((await snippets({ subject: 'zu' }, ['one'])).list[0].subject).toBe('<mark>zu</mark> zum Stromrechnung Rechnung');
|
||||
expect((await snippets({ subject: 'rechnung' }, ['one'])).list[0].subject).toBe('zu zum Strom<mark>rechnung</mark> <mark>Rechnung</mark>');
|
||||
expect((await snippets({ subject: 'rech*' }, ['one'])).list[0].subject).toBe('zu zum Stromrechnung <mark>Rech</mark>nung');
|
||||
});
|
||||
|
||||
it('marks the words of the loosened filter the client sends', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'annual and report, budget' }]);
|
||||
const filter = buildJMAPFilter('"annual report" budg*', DEFAULT_SEARCH_FILTERS, 'inbox');
|
||||
const { list } = await snippets(snippetFilterFor(filter), ['one']);
|
||||
expect(list[0].subject).toBe('<mark>annual</mark> and <mark>report</mark>, <mark>budg</mark>et');
|
||||
});
|
||||
|
||||
it('gives null for the part with no match, and for filters with nothing to mark', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'Betreff', from: alice, ...withBody('Inhalt hier') }]);
|
||||
expect((await snippets({ text: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: '<mark>Inhalt</mark> hier' });
|
||||
expect((await snippets({ text: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: '<mark>Betreff</mark>', preview: null });
|
||||
// subject and body only mark their own part.
|
||||
expect((await snippets({ subject: 'inhalt' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
|
||||
expect((await snippets({ body: 'betreff' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
|
||||
expect((await snippets({ from: 'alice' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
|
||||
expect((await snippets({ inMailbox: 'inbox' }, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
|
||||
expect((await snippets(null, ['one'])).list[0]).toEqual({ emailId: 'one', subject: null, preview: null });
|
||||
});
|
||||
|
||||
it('does not mark terms the filter excludes', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'foo bar' }]);
|
||||
const { list } = await snippets({ operator: 'AND', conditions: [{ text: 'foo' }, { operator: 'NOT', conditions: [{ text: 'bar' }] }] }, ['one']);
|
||||
expect(list[0].subject).toBe('<mark>foo</mark> bar');
|
||||
});
|
||||
|
||||
it('keeps a preview within 255 bytes, cuts no entity or mark, and starts near the match', async () => {
|
||||
const before = 'Wörter füllen den Anfang dieser Nachricht ganz ohne Treffer. '.repeat(20);
|
||||
const after = '& < > " \' ü '.repeat(200);
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`${before}\n\nDie Rechnung ${after} Rechnung`) }]);
|
||||
const preview = (await snippets({ body: 'rechnung' }, ['one'])).list[0].preview as string;
|
||||
expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255);
|
||||
expect(preview.length).toBeGreaterThan(200);
|
||||
expect(preview).toContain('Die <mark>Rechnung</mark> & <');
|
||||
expect(preview.indexOf('<mark>')).toBeLessThan(60);
|
||||
// Whatever is left over parses back with no half entity.
|
||||
expect(preview.replace(/<\/?mark>/g, '')).not.toMatch(/&(?!amp;|lt;|gt;|quot;|#39;)/);
|
||||
expect(preview.split('<mark>').length).toBe(preview.split('</mark>').length);
|
||||
});
|
||||
|
||||
it('never leaves an empty or open mark when the budget runs out on one', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'x', ...withBody(`foo ${'abcd '.repeat(48)}foo foo foo foo foo foo foo foo foo`) }]);
|
||||
const preview = (await snippets({ body: 'foo' }, ['one'])).list[0].preview as string;
|
||||
expect(new TextEncoder().encode(preview).length).toBeLessThanOrEqual(255);
|
||||
expect(preview).not.toContain('<mark></mark>');
|
||||
expect(preview.split('<mark>').length).toBe(preview.split('</mark>').length);
|
||||
});
|
||||
|
||||
it('builds the preview from an HTML body as plain text', async () => {
|
||||
const { snippets } = await storeWith([{
|
||||
id: 'one',
|
||||
subject: 'x',
|
||||
textBody: [],
|
||||
htmlBody: [{ partId: '1', type: 'text/html' }],
|
||||
...withBody('<style>.rechnung{}</style><p class="rechnung">Ihre <b>Rechnung</b> von Müller</p>'),
|
||||
}]);
|
||||
expect((await snippets({ body: 'rechnung' }, ['one'])).list[0].preview).toBe('Ihre <mark>Rechnung</mark> von Müller');
|
||||
});
|
||||
|
||||
it('works chained onto the query, as the client asks for it', async () => {
|
||||
const { engine } = await storeWith([{ id: 'one', subject: 'Rechnung' }, { id: 'two', subject: 'Hallo' }]);
|
||||
const answer = await engine.answer([
|
||||
['Email/query', { filter: { text: 'rechnung' } }, '0'],
|
||||
['SearchSnippet/get', { filter: { text: 'rechnung' }, '#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' } }, 'snippets'],
|
||||
]);
|
||||
expect(answer?.methodResponses[1][0]).toBe('SearchSnippet/get');
|
||||
expect(answer?.methodResponses[1][1].list).toEqual([{ emailId: 'one', subject: '<mark>Rechnung</mark>', preview: null }]);
|
||||
});
|
||||
|
||||
it('answers per-call errors for bad arguments', async () => {
|
||||
const { engine } = await storeWith([]);
|
||||
const answer = await engine.answer([
|
||||
['SearchSnippet/get', { filter: { text: 'x' } }, 'a'],
|
||||
['SearchSnippet/get', { filter: { text: 'x' }, emailIds: Array.from({ length: 501 }, (_, i) => `e${i}`) }, 'b'],
|
||||
['Mailbox/query', {}, 'c'],
|
||||
]);
|
||||
expect(answer?.methodResponses[0]).toEqual(['error', expect.objectContaining({ type: 'invalidArguments' }), 'a']);
|
||||
expect(answer?.methodResponses[1]).toEqual(['error', expect.objectContaining({ type: 'requestTooLarge' }), 'b']);
|
||||
});
|
||||
|
||||
it('caps the patterns one highlight can use, keeping it working', async () => {
|
||||
const words = Array.from({ length: 512 }, (_, i) => `wort${String(i).padStart(3, '0')}`);
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: words.join(' ').slice(0, 1000) }]);
|
||||
const conditions = Array.from({ length: 32 }, (_, i) => ({ subject: words.slice(i * 16, i * 16 + 16).join(' ') }));
|
||||
const { list } = await snippets({ operator: 'AND', conditions }, ['one']);
|
||||
const marks = (list[0].subject as string).split('<mark>').length - 1;
|
||||
expect(marks).toBeGreaterThan(0);
|
||||
// The subject holds about 125 of the words, so more than this would be uncapped.
|
||||
expect(marks).toBeLessThanOrEqual(64);
|
||||
});
|
||||
|
||||
it('caps how much of a subject it reads', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: `foo ${'x'.repeat(100_000)}` }]);
|
||||
const subject = (await snippets({ subject: 'foo' }, ['one'])).list[0].subject as string;
|
||||
expect(subject.startsWith('<mark>foo</mark>')).toBe(true);
|
||||
expect(subject.length).toBeLessThan(2_000);
|
||||
});
|
||||
|
||||
it('survives hostile search terms', async () => {
|
||||
const { snippets } = await storeWith([{ id: 'one', subject: 'foo (bar) [baz]', ...withBody('foo (bar) [baz] \\ ^ $ . * + ? | /') }]);
|
||||
for (const text of ['(', ')', '[', ']', '\\', '^', '$', '.', '*', '+', '?', '|', '/', '(bar)', '[baz', 'a|b', '.*', '(?<x>', '\ud83d', 'foo\0bar']) {
|
||||
await expect(snippets({ text }, ['one']), text).resolves.toHaveProperty('list');
|
||||
}
|
||||
expect((await snippets({ text: '.*' }, ['one'])).list[0].preview).toBeNull();
|
||||
expect((await snippets({ text: '(bar)' }, ['one'])).list[0].subject).toBe('foo (<mark>bar</mark>) [baz]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* SQLite3MultipleCiphers on OPFS, for the owner tab's worker.
|
||||
*
|
||||
* Rules that come from bugs reproduced in 2.6.1, see
|
||||
* local-data/desktop-offline-facts-parts/sqlite-wasm.md section 5:
|
||||
* - PRAGMA key is the very first thing on a connection. Reading first deletes
|
||||
* a hot journal (#292).
|
||||
* - The page size must be 8192. On 4096, rollback doesn't work (#293).
|
||||
* - No PRAGMA rekey (#291, #294). The key is random and only ever re-wrapped.
|
||||
* - Always the multipleciphers VFS. On plain opfs-sahpool, PRAGMA key throws
|
||||
* and the connection still writes in the clear if that's ignored.
|
||||
*/
|
||||
|
||||
import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../storage';
|
||||
|
||||
const VFS = 'multipleciphers-opfs-sahpool';
|
||||
const INIT_TIMEOUT_MS = 30_000;
|
||||
const REQUIRED_PAGE_SIZE = 8192;
|
||||
const POOL_DIRECTORY = '.bulwark-store';
|
||||
const INITIAL_CAPACITY = 16;
|
||||
/** A database and its journal, plus room for one more. */
|
||||
const SLOTS_PER_DATABASE = 3;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
interface Pool {
|
||||
getFileNames(): string[];
|
||||
getCapacity(): number;
|
||||
getFileCount(): number;
|
||||
addCapacity(n: number): Promise<number>;
|
||||
unlink(name: string): boolean;
|
||||
wipeFiles(): Promise<void>;
|
||||
pauseVfs(): void;
|
||||
unpauseVfs(): Promise<void>;
|
||||
isPaused(): boolean;
|
||||
}
|
||||
|
||||
export class StoreKeyError extends Error {
|
||||
constructor() {
|
||||
super('The local store could not be opened with this key');
|
||||
this.name = 'StoreKeyError';
|
||||
}
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`${what} timed out`)), ms);
|
||||
promise.then((value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class WasmAdapter implements StorageAdapter {
|
||||
private readonly tx = { depth: 0 };
|
||||
private readonly statements = new Map<string, any>();
|
||||
|
||||
constructor(private readonly sqlite3: any, private readonly db: any) {}
|
||||
|
||||
exec(sql: string): void {
|
||||
this.db.exec(sql);
|
||||
}
|
||||
|
||||
private statement(sql: string): any {
|
||||
let stmt = this.statements.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = this.db.prepare(sql);
|
||||
this.statements.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
}
|
||||
|
||||
private bindAndRun<T>(sql: string, params: readonly SqlValue[] | undefined, read: (stmt: any) => T): T {
|
||||
const stmt = this.statement(sql);
|
||||
try {
|
||||
if (params && params.length > 0) stmt.bind(params as SqlValue[]);
|
||||
return read(stmt);
|
||||
} finally {
|
||||
stmt.reset(true);
|
||||
}
|
||||
}
|
||||
|
||||
run(sql: string, params?: readonly SqlValue[]): { changes: number; lastInsertRowid: number } {
|
||||
this.bindAndRun(sql, params, (stmt) => {
|
||||
while (stmt.step()) { /* no rows wanted */ }
|
||||
});
|
||||
return {
|
||||
changes: Number(this.db.changes()),
|
||||
lastInsertRowid: Number(this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer)),
|
||||
};
|
||||
}
|
||||
|
||||
all<T extends object = SqlRow>(sql: string, params?: readonly SqlValue[]): T[] {
|
||||
return this.bindAndRun(sql, params, (stmt) => {
|
||||
const rows: T[] = [];
|
||||
while (stmt.step()) rows.push(stmt.get({}) as T);
|
||||
return rows;
|
||||
});
|
||||
}
|
||||
|
||||
get<T extends object = SqlRow>(sql: string, params?: readonly SqlValue[]): T | undefined {
|
||||
return this.bindAndRun(sql, params, (stmt) => (stmt.step() ? stmt.get({}) as T : undefined));
|
||||
}
|
||||
|
||||
transaction<T>(fn: () => T): T {
|
||||
return runTransaction(this.tx, (sql) => this.db.exec(sql), fn);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
for (const stmt of this.statements.values()) stmt.finalize();
|
||||
this.statements.clear();
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The module and the pool, once. Only the owner gets here. */
|
||||
export class WasmStorage {
|
||||
private constructor(private readonly sqlite3: any, private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Call only while holding the owner lock. The holder before may still be
|
||||
* letting go, so a refusal is retried for a while.
|
||||
*/
|
||||
static async install(moduleUrl: string): Promise<WasmStorage> {
|
||||
const { default: init } = await import(/* webpackIgnore: true */ moduleUrl) as { default: (o: unknown) => Promise<any> };
|
||||
// A failed wasm load never settles the promise from the module's init.
|
||||
const sqlite3 = await withTimeout(init({ print() {}, printErr() {} }), INIT_TIMEOUT_MS, 'Loading the store engine');
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
const pool = await sqlite3.installOpfsSAHPoolVfs({
|
||||
name: 'opfs-sahpool',
|
||||
directory: POOL_DIRECTORY,
|
||||
initialCapacity: INITIAL_CAPACITY,
|
||||
preserveOnInitFailure: true,
|
||||
forceReinitIfPreviouslyFailed: true,
|
||||
}) as Pool;
|
||||
return new WasmStorage(sqlite3, pool);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100 + attempt * 50));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/** Opens one account's database. `keyHex` is 32 random bytes in hex. */
|
||||
async open(file: string, keyHex: string): Promise<StorageAdapter> {
|
||||
if (!/^\/a[0-9a-f]+\.db$/.test(file)) throw new Error('Store file names carry no account data');
|
||||
if (!/^[0-9a-f]{64}$/.test(keyHex)) throw new Error('A store key is 32 bytes of hex');
|
||||
const needed = this.pool.getFileCount() + SLOTS_PER_DATABASE;
|
||||
if (this.pool.getCapacity() < needed) await this.pool.addCapacity(needed - this.pool.getCapacity());
|
||||
const db = new this.sqlite3.oo1.DB({ filename: file, vfs: VFS, flags: 'c' });
|
||||
try {
|
||||
db.exec(`PRAGMA key = "raw:${keyHex}"`);
|
||||
try {
|
||||
db.selectValue('SELECT count(*) FROM sqlite_master');
|
||||
} catch {
|
||||
throw new StoreKeyError();
|
||||
}
|
||||
const pageSize = Number(db.selectValue('PRAGMA page_size'));
|
||||
if (pageSize !== REQUIRED_PAGE_SIZE) throw new Error(`Refusing an unsafe page size: ${pageSize}`);
|
||||
db.exec('PRAGMA temp_store = MEMORY');
|
||||
// Every page read is a decryption, so a miss costs more than usual.
|
||||
db.exec('PRAGMA cache_size = -65536');
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
db.exec('PRAGMA synchronous = NORMAL');
|
||||
} catch (error) {
|
||||
db.close();
|
||||
throw error;
|
||||
}
|
||||
return new WasmAdapter(this.sqlite3, db);
|
||||
}
|
||||
|
||||
remove(file: string): boolean {
|
||||
let removed = false;
|
||||
for (const name of this.pool.getFileNames()) {
|
||||
if (name === file || name === `${file}-journal`) removed = this.pool.unlink(name) || removed;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** Lets go of the OPFS handles. Every database must be closed first. */
|
||||
pause(): void {
|
||||
this.pool.pauseVfs();
|
||||
}
|
||||
|
||||
async unpause(): Promise<void> {
|
||||
await this.pool.unpauseVfs();
|
||||
}
|
||||
|
||||
files(): string[] {
|
||||
return this.pool.getFileNames();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* What one part of the local store (mail, calendar, contacts, files, ...)
|
||||
* plugs into the engine: its tables, the JMAP methods it answers while the
|
||||
* server can't be reached, and how it syncs.
|
||||
*
|
||||
* Migration id ranges: core 1-99, mail 100-199, outbox 200-299, search
|
||||
* 300-399, drafts 400-499, calendar 500-599, contacts 600-699, files 700-799,
|
||||
* settings and reminders 800-899, wipe and lock 900-999.
|
||||
*/
|
||||
|
||||
import type { JmapTransport, MethodCall, StoreSession } from './engine/transport';
|
||||
import type { Migration, StorageAdapter } from './storage';
|
||||
|
||||
export interface StoreScope {
|
||||
/** How far back mail is kept, in days. Null keeps everything. */
|
||||
days: number | null;
|
||||
/** Whether attachments are kept. */
|
||||
attachments: boolean;
|
||||
/** The largest attachment kept, and the total for all of them. */
|
||||
maxAttachmentBytes: number;
|
||||
maxTotalBytes: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCOPE: StoreScope = {
|
||||
days: 90,
|
||||
attachments: true,
|
||||
maxAttachmentBytes: 25 * 1024 * 1024,
|
||||
maxTotalBytes: 2 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export interface SyncStateRow {
|
||||
state: string | null;
|
||||
/** The newest state the server told us about, to know when a run is caught up. */
|
||||
target: string | null;
|
||||
/** The area's own phase name; null when in normal delta mode. */
|
||||
phase: string | null;
|
||||
/** The area's own resume point. */
|
||||
cursor: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type StoreEvent =
|
||||
| { type: 'progress'; account: string; area: string; phase: string; done: number; total: number | null }
|
||||
| { type: 'synced'; account: string; at: number }
|
||||
| { type: 'changed'; account: string; collections: string[] }
|
||||
| { type: 'created'; account: string; jmap: string; collection: string; ids: string[] }
|
||||
| { type: 'error'; account: string; area: string; message: string };
|
||||
|
||||
export interface StoreContext {
|
||||
account: string;
|
||||
db: StorageAdapter;
|
||||
session: StoreSession;
|
||||
scope: StoreScope;
|
||||
now(): number;
|
||||
emit(event: StoreEvent): void;
|
||||
}
|
||||
|
||||
export interface SyncContext extends StoreContext {
|
||||
transport: JmapTransport;
|
||||
/** True once the engine is closing or lost the network: stop at the next batch. */
|
||||
stopped(): boolean;
|
||||
syncState(jmap: string, type: string): SyncStateRow;
|
||||
/** Call inside the transaction that writes the data, so the two can't disagree. */
|
||||
setSyncState(jmap: string, type: string, row: Partial<SyncStateRow>): void;
|
||||
}
|
||||
|
||||
export interface Syncer {
|
||||
/** A short name for progress and errors. */
|
||||
name: string;
|
||||
/** The JMAP capability an account must advertise for this to run for it. */
|
||||
capability: string;
|
||||
/** Runs one pass for one JMAP account. Must be safe to stop between batches and start again. */
|
||||
run(ctx: SyncContext, jmap: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type MethodResult = Record<string, unknown>;
|
||||
|
||||
/** Thrown by a method handler: the client gets a per-call JMAP error. */
|
||||
export class MethodError extends Error {
|
||||
constructor(readonly type: string, readonly description?: string) {
|
||||
super(description ?? type);
|
||||
this.name = 'MethodError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface MethodContext extends StoreContext {
|
||||
/** The account's own default JMAP account for calls that don't say. */
|
||||
defaultJmap: string;
|
||||
/** Whether keyword sort comparators arrive inverted from this client. */
|
||||
invertedKeywordSort: boolean;
|
||||
/** Earlier responses in this request, for creation-id references. */
|
||||
createdIds: Map<string, string>;
|
||||
}
|
||||
|
||||
export type MethodHandler = (ctx: MethodContext, args: Record<string, unknown>, call: MethodCall) => MethodResult | Promise<MethodResult>;
|
||||
|
||||
/**
|
||||
* A call from the page that isn't JMAP: outbox rows, usage, clearing. Reached
|
||||
* from any tab with `store.area(account, 'outbox.list', params)`. Names are
|
||||
* unique across areas and start with the area's name. It gets the sync context,
|
||||
* because some calls talk to the server (`calendar.flush`).
|
||||
*/
|
||||
export type AreaCall = (ctx: SyncContext, params: never) => unknown | Promise<unknown>;
|
||||
|
||||
export interface StoreArea {
|
||||
name: string;
|
||||
migrations: readonly Migration[];
|
||||
methods: Readonly<Record<string, MethodHandler>>;
|
||||
syncers: readonly Syncer[];
|
||||
calls?: Readonly<Record<string, AreaCall>>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Every part of the local store. New areas go here. */
|
||||
|
||||
import type { StoreArea } from './area';
|
||||
import { calendarArea } from './calendar';
|
||||
import { mailArea } from './mail';
|
||||
|
||||
export const STORE_AREAS: readonly StoreArea[] = [mailArea, calendarArea];
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* CalendarEvent/query filters, read the way Stalwart reads them: after and
|
||||
* before are LocalDateTimes in the request's time zone, and a series matches
|
||||
* when any occurrence is in the range. Text matches parts of words, where the
|
||||
* server only matches whole ones: offline search finds more, not less.
|
||||
*/
|
||||
|
||||
import { addDays, format, parseISO } from 'date-fns';
|
||||
import { isTaskLikeObject } from '@/lib/calendar-component-detection';
|
||||
import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
||||
import type { CalendarEvent, CalendarEventFilter } from '@/lib/jmap/types';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { MethodError } from '../area';
|
||||
import { instantOf, instantsOf, isSeries, wallClock, type CalendarObject } from './records';
|
||||
|
||||
export interface RangeBounds {
|
||||
after?: string;
|
||||
before?: string;
|
||||
}
|
||||
|
||||
/** The range of a filter, in the two shapes the client sends it: plain keys, or an AND of one-key conditions. */
|
||||
export function boundsOf(filter: CalendarEventFilter): RangeBounds {
|
||||
if (filter.operator === 'AND') {
|
||||
const bounds: RangeBounds = {};
|
||||
for (const condition of filter.conditions ?? []) {
|
||||
if (condition.operator || Object.keys(condition).length !== 1) continue;
|
||||
if (condition.after) bounds.after = condition.after;
|
||||
if (condition.before) bounds.before = condition.before;
|
||||
}
|
||||
return bounds;
|
||||
}
|
||||
if (filter.operator) return {};
|
||||
return { after: filter.after, before: filter.before };
|
||||
}
|
||||
|
||||
function fold(text: string): string {
|
||||
return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase();
|
||||
}
|
||||
|
||||
function containsWords(haystack: Array<unknown>, needle: string): boolean {
|
||||
const text = fold(haystack.filter((part): part is string => typeof part === 'string').join('\n'));
|
||||
return fold(needle).split(/\s+/).filter(Boolean).every((word) => text.includes(word));
|
||||
}
|
||||
|
||||
type Participant = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
calendarAddress?: string;
|
||||
participationStatus?: string;
|
||||
roles?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
function participantsOf(object: CalendarObject): Participant[] {
|
||||
return Object.values((object.participants ?? {}) as Record<string, Participant>);
|
||||
}
|
||||
|
||||
function placeTexts(object: CalendarObject): unknown[] {
|
||||
const places = Object.values((object.locations ?? {}) as Record<string, { name?: unknown; description?: unknown }>);
|
||||
const virtual = Object.values((object.virtualLocations ?? {}) as Record<string, { name?: unknown; description?: unknown }>);
|
||||
return [...places, ...virtual].flatMap((place) => [place.name, place.description]);
|
||||
}
|
||||
|
||||
function personTexts(participant: Participant): unknown[] {
|
||||
return [participant.name, participant.email, participant.calendarAddress];
|
||||
}
|
||||
|
||||
function isOwner(participant: Participant): boolean {
|
||||
return participant.roles?.owner === true;
|
||||
}
|
||||
|
||||
function isAttendee(participant: Participant): boolean {
|
||||
return !participant.roles || participant.roles.attendee === true;
|
||||
}
|
||||
|
||||
function ownerTexts(object: CalendarObject): unknown[] {
|
||||
return [object.organizerCalendarAddress, ...participantsOf(object).filter(isOwner).flatMap(personTexts)];
|
||||
}
|
||||
|
||||
// An occurrence's wall clock is in the series' zone and the bounds in the
|
||||
// request's: up to 26 hours apart. The client narrows the rest when it expands.
|
||||
const SERIES_SLACK_DAYS = 2;
|
||||
const OPEN_RANGE_DAYS = 5 * 366;
|
||||
|
||||
function seriesOccursIn(object: CalendarObject, after: string | undefined, before: string | undefined): boolean {
|
||||
const seriesStart = typeof object.start === 'string' ? parseISO(object.start) : null;
|
||||
if (!seriesStart || Number.isNaN(seriesStart.getTime())) return false;
|
||||
const lengthMs = (parseDurationSeconds(typeof object.duration === 'string' ? object.duration : undefined) ?? 0) * 1000;
|
||||
const from = after
|
||||
? new Date(addDays(parseISO(wallClock(after)), -SERIES_SLACK_DAYS).getTime() - lengthMs)
|
||||
: seriesStart;
|
||||
const to = before ? addDays(parseISO(wallClock(before)), SERIES_SLACK_DAYS) : addDays(from, OPEN_RANGE_DAYS);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from >= to) return false;
|
||||
const stamp = (date: Date) => format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
const event = normalizeCalendarEventLike(object as unknown as CalendarEvent);
|
||||
return expandRecurringEvents([event], stamp(from), stamp(to)).length > 0;
|
||||
}
|
||||
|
||||
function inRange(object: CalendarObject, bounds: RangeBounds, zone: string): boolean {
|
||||
if (isSeries(object)) return seriesOccursIn(object, bounds.after, bounds.before);
|
||||
const { start, end } = instantsOf(object, zone);
|
||||
if (start === null || end === null) return false;
|
||||
const after = bounds.after ? instantOf(bounds.after, zone) : null;
|
||||
const before = bounds.before ? instantOf(bounds.before, zone) : null;
|
||||
if (before !== null && start >= before) return false;
|
||||
if (after === null) return true;
|
||||
return end > after || (end === start && start >= after);
|
||||
}
|
||||
|
||||
function matchesCondition(object: CalendarObject, condition: CalendarEventFilter, zone: string): boolean {
|
||||
const bounds: RangeBounds = {};
|
||||
for (const [key, value] of Object.entries(condition)) {
|
||||
if (value === undefined) continue;
|
||||
switch (key) {
|
||||
case 'after':
|
||||
case 'before':
|
||||
bounds[key as 'after' | 'before'] = value as string;
|
||||
break;
|
||||
case 'inCalendar':
|
||||
if (!(object.calendarIds as Record<string, boolean> | undefined)?.[value as string]) return false;
|
||||
break;
|
||||
case 'inCalendars':
|
||||
if (!(value as string[]).some((id) => (object.calendarIds as Record<string, boolean> | undefined)?.[id])) return false;
|
||||
break;
|
||||
case 'uid':
|
||||
if (object.uid !== value) return false;
|
||||
break;
|
||||
case 'title':
|
||||
if (!containsWords([object.title], value as string)) return false;
|
||||
break;
|
||||
case 'description':
|
||||
if (!containsWords([object.description], value as string)) return false;
|
||||
break;
|
||||
case 'location':
|
||||
if (!containsWords(placeTexts(object), value as string)) return false;
|
||||
break;
|
||||
case 'owner':
|
||||
if (!containsWords(ownerTexts(object), value as string)) return false;
|
||||
break;
|
||||
case 'attendee': {
|
||||
// With participationStatus in the same condition: the same person.
|
||||
const status = condition.participationStatus;
|
||||
const found = participantsOf(object).some((person) => isAttendee(person)
|
||||
&& containsWords(personTexts(person), value as string)
|
||||
&& (!status || person.participationStatus === status));
|
||||
if (!found) return false;
|
||||
break;
|
||||
}
|
||||
case 'participationStatus':
|
||||
if (!participantsOf(object).some((person) => person.participationStatus === value)) return false;
|
||||
break;
|
||||
case 'text':
|
||||
if (!containsWords([
|
||||
object.title,
|
||||
object.description,
|
||||
...placeTexts(object),
|
||||
...ownerTexts(object),
|
||||
...participantsOf(object).flatMap(personTexts),
|
||||
], value as string)) return false;
|
||||
break;
|
||||
case 'types': {
|
||||
const type = isTaskLikeObject(object) ? 'task' : 'event';
|
||||
if (!(value as string[]).some((name) => name.toLowerCase() === type)) return false;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new MethodError('unsupportedFilter', `${key} isn't available offline`);
|
||||
}
|
||||
}
|
||||
return bounds.after || bounds.before ? inRange(object, bounds, zone) : true;
|
||||
}
|
||||
|
||||
export function matchesFilter(object: CalendarObject, filter: CalendarEventFilter, zone: string): boolean {
|
||||
const conditions = filter.conditions ?? [];
|
||||
switch (filter.operator) {
|
||||
case 'AND': {
|
||||
// A range split over two conditions is one range: a series must have one
|
||||
// occurrence inside it, not one after the start and another before the end.
|
||||
const bounds = boundsOf(filter);
|
||||
const isBound = (c: CalendarEventFilter) => !c.operator && Object.keys(c).length === 1 && Boolean(c.after || c.before);
|
||||
if ((bounds.after || bounds.before) && !inRange(object, bounds, zone)) return false;
|
||||
return conditions.every((c) => isBound(c) || matchesFilter(object, c, zone));
|
||||
}
|
||||
case 'OR':
|
||||
return conditions.some((c) => matchesFilter(object, c, zone));
|
||||
case 'NOT':
|
||||
return !conditions.some((c) => matchesFilter(object, c, zone));
|
||||
case undefined:
|
||||
return matchesCondition(object, filter, zone);
|
||||
default:
|
||||
throw new MethodError('unsupportedFilter', `Unknown operator ${String(filter.operator)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface EventComparator {
|
||||
property?: string;
|
||||
isAscending?: boolean;
|
||||
}
|
||||
|
||||
function sortValue(object: CalendarObject, property: string | undefined, zone: string): number | string {
|
||||
if (property === 'start') return instantsOf(object, zone).start ?? 0;
|
||||
const value = property ? object[property] : undefined;
|
||||
return typeof value === 'number' || typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
/** By start when the request names no order. Ties go by id, so paging is stable. */
|
||||
export function sortEvents(objects: CalendarObject[], sort: readonly EventComparator[], zone: string): CalendarObject[] {
|
||||
const levels = sort.length > 0 ? sort : [{ property: 'start', isAscending: true }];
|
||||
const keyed = objects.map((object) => ({ object, keys: levels.map((level) => sortValue(object, level.property, zone)) }));
|
||||
keyed.sort((a, b) => {
|
||||
for (let i = 0; i < levels.length; i += 1) {
|
||||
const x = a.keys[i];
|
||||
const y = b.keys[i];
|
||||
if (x === y) continue;
|
||||
const order = x < y ? -1 : 1;
|
||||
return levels[i].isAscending === false ? -order : order;
|
||||
}
|
||||
return a.object.id < b.object.id ? -1 : a.object.id > b.object.id ? 1 : 0;
|
||||
});
|
||||
return keyed.map((entry) => entry.object);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Sends pending calendar writes, one write per request, so a lost response leaves
|
||||
* one write's outcome open and not a batch's. An event's writes go in order.
|
||||
*
|
||||
* A row is marked before it goes out. A create so marked first looks for its uid
|
||||
* on the server, because repeating it could duplicate the event. A marked edit
|
||||
* whose values read back from the server is done, and one that's repeated writes
|
||||
* the same values. A delete sent twice finds nothing the second time.
|
||||
*
|
||||
* A write that can't work goes to the conflict list and the others still go out.
|
||||
* Anything that may pass stays pending: an answer with no verdict for it, a set
|
||||
* error such as serverFail or rateLimit, and for the whole run a network failure,
|
||||
* 401, 429 or 5xx.
|
||||
*/
|
||||
|
||||
import { jmapError } from '@/lib/jmap/jmap-error';
|
||||
import type { CalendarEvent } from '@/lib/jmap/types';
|
||||
import { isOverrideSeriesKeyRejection, withoutOverrideSeriesKeys } from '@/lib/recurrence-instances';
|
||||
import type { SyncContext } from '../area';
|
||||
import { JmapMethodError, responseFor, TransportError } from '../engine/transport';
|
||||
import { holdsPatch, patchForServer } from './patches';
|
||||
import { calendarUsing, EVENT_PROPERTIES, eventRow, type CalendarObject } from './records';
|
||||
import { adoptStandIn, finishWrite, markSent, pendingBySeq, recordConflict, type PendingRow } from './writes';
|
||||
|
||||
export interface FlushTally {
|
||||
sent: number;
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
interface SetError {
|
||||
type?: string;
|
||||
description?: string;
|
||||
properties?: string[];
|
||||
}
|
||||
|
||||
interface SetResult {
|
||||
created?: Record<string, { id: string }> | null;
|
||||
updated?: Record<string, unknown> | null;
|
||||
destroyed?: string[] | null;
|
||||
notCreated?: Record<string, SetError> | null;
|
||||
notUpdated?: Record<string, SetError> | null;
|
||||
notDestroyed?: Record<string, SetError> | null;
|
||||
}
|
||||
|
||||
// The server had a bad moment, nothing is wrong with this write: try again later.
|
||||
const PASSING_ERRORS = new Set(['serverFail', 'serverPartialFail', 'serverUnavailable', 'rateLimit', 'tooManyRequests']);
|
||||
|
||||
export function isPassingError(error: unknown): boolean {
|
||||
return error instanceof JmapMethodError && PASSING_ERRORS.has(error.type);
|
||||
}
|
||||
|
||||
function passes(refusal: SetError | undefined): boolean {
|
||||
return !refusal || PASSING_ERRORS.has(refusal.type ?? '');
|
||||
}
|
||||
|
||||
/** A 4xx other than 401 and 429, which the transport reports as their own kinds: this request won't work. */
|
||||
function isRefusedRequest(error: unknown): boolean {
|
||||
return error instanceof TransportError && error.kind === 'http' && (error.status ?? 0) >= 400 && (error.status ?? 0) < 500;
|
||||
}
|
||||
|
||||
type Outcome = 'sent' | 'conflict' | 'again' | 'retry';
|
||||
|
||||
async function sendSet(ctx: SyncContext, row: PendingRow, args: Record<string, unknown>): Promise<SetResult> {
|
||||
const response = await ctx.transport.request([['CalendarEvent/set', {
|
||||
accountId: row.jmap,
|
||||
...(row.send_scheduling === null ? {} : { sendSchedulingMessages: row.send_scheduling === 1 }),
|
||||
...args,
|
||||
}, 's']], calendarUsing(ctx.session));
|
||||
return responseFor(response, 's')[1] as SetResult;
|
||||
}
|
||||
|
||||
/** Stalwart 1.0 refuses what 0.16 expects inside an override. The client normally learns that from the refusal. */
|
||||
function isSeriesKeyRefusal(error: SetError | undefined): boolean {
|
||||
return Boolean(error) && isOverrideSeriesKeyRejection(jmapError(error?.description ?? '', error));
|
||||
}
|
||||
|
||||
async function findByUid(ctx: SyncContext, jmap: string, uid: string | null): Promise<string | null> {
|
||||
if (!uid) return null;
|
||||
const response = await ctx.transport.request([['CalendarEvent/query', { accountId: jmap, filter: { uid }, limit: 1 }, 'u']], calendarUsing(ctx.session));
|
||||
const ids = responseFor(response, 'u')[1].ids as string[];
|
||||
return ids[0] ?? null;
|
||||
}
|
||||
|
||||
async function fetchEvent(ctx: SyncContext, jmap: string, id: string): Promise<CalendarObject | null> {
|
||||
const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [id], properties: EVENT_PROPERTIES }, 'g']], calendarUsing(ctx.session));
|
||||
return (responseFor(response, 'g')[1].list as CalendarObject[])[0] ?? null;
|
||||
}
|
||||
|
||||
function conflict(ctx: SyncContext, row: PendingRow, reason: 'refused' | 'destroyedOnServer', error: SetError): Outcome {
|
||||
ctx.db.transaction(() => recordConflict(ctx, row, reason, error));
|
||||
return 'conflict';
|
||||
}
|
||||
|
||||
async function sendCreate(ctx: SyncContext, row: PendingRow): Promise<Outcome> {
|
||||
const payload = JSON.parse(row.patch) as Record<string, unknown>;
|
||||
let realId = row.attempted ? await findByUid(ctx, row.jmap, row.uid) : null;
|
||||
if (!realId) {
|
||||
markSent(ctx, row, payload);
|
||||
let result = await sendSet(ctx, row, { create: { c: payload } });
|
||||
if (isSeriesKeyRefusal(result.notCreated?.c)) {
|
||||
result = await sendSet(ctx, row, { create: { c: withoutOverrideSeriesKeys(payload as Partial<CalendarEvent>) } });
|
||||
}
|
||||
realId = result.created?.c?.id ?? null;
|
||||
const refusal = result.notCreated?.c;
|
||||
if (!realId) {
|
||||
// Both Stalwarts refuse a second event with one uid. Nothing of ours went
|
||||
// in then, so what the server holds is compared as a whole.
|
||||
if (refusal?.type === 'invalidProperties' && refusal.properties?.includes('uid')) {
|
||||
ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET sent = NULL WHERE seq = ?', [row.seq]));
|
||||
realId = await findByUid(ctx, row.jmap, row.uid);
|
||||
}
|
||||
if (!realId) return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError);
|
||||
}
|
||||
}
|
||||
const event = await fetchEvent(ctx, row.jmap, realId) ?? ({ ...payload, id: realId } as CalendarObject);
|
||||
const left = ctx.db.transaction(() => adoptStandIn(ctx, row, event));
|
||||
return left ? 'again' : 'sent';
|
||||
}
|
||||
|
||||
async function sendUpdate(ctx: SyncContext, row: PendingRow): Promise<Outcome> {
|
||||
const id = row.record_id;
|
||||
const stored = eventRow(ctx.db, row.jmap, id);
|
||||
const server = JSON.parse(stored?.server_json ?? stored?.json ?? '{}') as Record<string, unknown>;
|
||||
const queued = JSON.parse(row.patch) as Record<string, unknown>;
|
||||
let patch = patchForServer(queued, server);
|
||||
// Nothing to change, and an answer sent twice would mail the organizer twice.
|
||||
if (holdsPatch(server, queued) || Object.keys(patch).length === 0) {
|
||||
return ctx.db.transaction(() => finishWrite(ctx, row, {})) ? 'again' : 'sent';
|
||||
}
|
||||
markSent(ctx, row, patch);
|
||||
let result = await sendSet(ctx, row, { update: { [id]: patch } });
|
||||
if (isSeriesKeyRefusal(result.notUpdated?.[id])) {
|
||||
patch = withoutOverrideSeriesKeys(patch as Partial<CalendarEvent>) as Record<string, unknown>;
|
||||
result = await sendSet(ctx, row, { update: { [id]: patch } });
|
||||
}
|
||||
const refusal = result.notUpdated?.[id];
|
||||
if (refusal && !passes(refusal)) return conflict(ctx, row, refusal.type === 'notFound' ? 'destroyedOnServer' : 'refused', refusal);
|
||||
if (refusal || !result.updated || !(id in result.updated)) return 'retry';
|
||||
return ctx.db.transaction(() => finishWrite(ctx, row, patch)) ? 'again' : 'sent';
|
||||
}
|
||||
|
||||
async function sendDestroy(ctx: SyncContext, row: PendingRow): Promise<Outcome> {
|
||||
// A create turned delete: only the server knows whether the create got there.
|
||||
const id = row.uid ? await findByUid(ctx, row.jmap, row.uid) : row.record_id;
|
||||
const finish = (): Outcome => ctx.db.transaction(() => {
|
||||
ctx.db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]);
|
||||
ctx.db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [row.jmap, row.record_id]);
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
|
||||
return 'sent' as const;
|
||||
});
|
||||
if (!id) return finish();
|
||||
const result = await sendSet(ctx, row, { destroy: [id] });
|
||||
const refusal = result.notDestroyed?.[id];
|
||||
if (refusal?.type === 'notFound' || result.destroyed?.includes(id)) return finish();
|
||||
return passes(refusal) ? 'retry' : conflict(ctx, row, 'refused', refusal as SetError);
|
||||
}
|
||||
|
||||
function conflictCount(ctx: SyncContext): number {
|
||||
return Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_conflict')?.n ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends what's pending for one JMAP account. Network failures and a server's bad
|
||||
* moment are thrown, with the rest still pending.
|
||||
*/
|
||||
export async function sendPending(ctx: SyncContext, jmap: string): Promise<FlushTally> {
|
||||
const tally: FlushTally = { sent: 0, conflicts: 0 };
|
||||
const finished = new Set<number>();
|
||||
// An event whose write has to wait: its later writes wait with it.
|
||||
const waiting = new Set<string>();
|
||||
for (;;) {
|
||||
if (ctx.stopped()) return tally;
|
||||
const row = ctx.db.all<PendingRow>('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq', [jmap])
|
||||
.find((candidate) => !finished.has(Number(candidate.seq)) && !waiting.has(candidate.record_id));
|
||||
if (!row) return tally;
|
||||
const conflictsBefore = conflictCount(ctx);
|
||||
let outcome: Outcome;
|
||||
try {
|
||||
if (row.kind === 'create') outcome = await sendCreate(ctx, row);
|
||||
else if (row.kind === 'update') outcome = await sendUpdate(ctx, row);
|
||||
else outcome = await sendDestroy(ctx, row);
|
||||
} catch (error) {
|
||||
const current = pendingBySeq(ctx.db, row.seq) ?? row;
|
||||
if (isRefusedRequest(error)) {
|
||||
outcome = conflict(ctx, current, 'refused', { type: 'http', description: (error as Error).message });
|
||||
} else if (error instanceof JmapMethodError && !isPassingError(error)) {
|
||||
outcome = conflict(ctx, current, 'refused', { type: error.type, description: error.message });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (outcome === 'again') continue;
|
||||
if (outcome === 'retry') {
|
||||
waiting.add(row.record_id);
|
||||
continue;
|
||||
}
|
||||
finished.add(Number(row.seq));
|
||||
if (outcome === 'sent') tally.sent += 1;
|
||||
else tally.conflicts += conflictCount(ctx) - conflictsBefore;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StoreArea } from '../area';
|
||||
import { CALENDAR_METHODS } from './methods';
|
||||
import { CALENDAR_MIGRATIONS } from './schema';
|
||||
import { calendarSyncer, flushCalendar } from './sync';
|
||||
import { listConflicts, resolveStandIns } from './writes';
|
||||
|
||||
export const calendarArea: StoreArea = {
|
||||
name: 'calendar',
|
||||
migrations: CALENDAR_MIGRATIONS,
|
||||
methods: CALENDAR_METHODS,
|
||||
syncers: [calendarSyncer],
|
||||
calls: {
|
||||
'calendar.flush': (ctx) => flushCalendar(ctx),
|
||||
'calendar.conflicts': (ctx) => listConflicts(ctx.db),
|
||||
'calendar.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids),
|
||||
'calendar.dismissConflict': (ctx, params: { id: number }) => {
|
||||
ctx.db.run('DELETE FROM calendar_conflict WHERE id = ?', [params.id]);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Calendar calls answered from the store.
|
||||
*
|
||||
* Series, not occurrences, in both client modes. A client in synthetic-id mode
|
||||
* sends `expandRecurrences: true` and gets the masters, with their overrides
|
||||
* (see projectEvent). hydrateRecurrenceInstances leaves those alone, and
|
||||
* expandRecurringEvents in the calendar store makes the occurrences, the same as
|
||||
* for a client in the other mode. Occurrence writes then arrive as overrides on
|
||||
* the master, which is what a stand-in id and the pending queue can work with.
|
||||
*/
|
||||
|
||||
import type { CalendarEventFilter } from '@/lib/jmap/types';
|
||||
import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances';
|
||||
import { MethodError, type MethodContext, type MethodHandler, type MethodResult } from '../area';
|
||||
import { listObjects } from '../engine/core-schema';
|
||||
import type { SqlValue, StorageAdapter } from '../storage';
|
||||
import { boundsOf, matchesFilter, sortEvents, type EventComparator } from './filter';
|
||||
import { CALENDARS_CAPABILITY, instantOf, projectEvent, type CalendarObject } from './records';
|
||||
import { syntheticIdVerdict } from './sync';
|
||||
import { queueCreate, queueDestroy, queueUpdate, resolveId } from './writes';
|
||||
|
||||
function jmapOf(ctx: MethodContext, args: Record<string, unknown>): string {
|
||||
return (args.accountId as string | undefined) ?? ctx.session.primaryAccounts[CALENDARS_CAPABILITY] ?? ctx.defaultJmap;
|
||||
}
|
||||
|
||||
function stateOf(db: StorageAdapter, jmap: string, type: string): string | undefined {
|
||||
return db.get<{ state: string | null }>('SELECT state FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type])?.state ?? undefined;
|
||||
}
|
||||
|
||||
/** An account never synced has no answer yet. An empty one would read as "no events". */
|
||||
function assertSynced(db: StorageAdapter, jmap: string, type: string): void {
|
||||
const row = db.get<{ state: string | null; phase: string | null }>('SELECT state, phase FROM sync_state WHERE jmap = ? AND type = ?', [jmap, type]);
|
||||
if (!row?.state && !row?.phase) throw new MethodError('serverUnavailable', `${type} hasn't been kept on this device yet`);
|
||||
}
|
||||
|
||||
// An event's start_ms reads a floating event in UTC, and a request's bounds are
|
||||
// in its own zone: up to 14 hours apart. This only narrows, the filter decides.
|
||||
const ZONE_SLACK_MS = 15 * 3_600_000;
|
||||
|
||||
function candidates(db: StorageAdapter, jmap: string, filter: CalendarEventFilter, zone: string): CalendarObject[] {
|
||||
const where = ['jmap = ?', 'hidden = 0'];
|
||||
const params: SqlValue[] = [jmap];
|
||||
if (typeof filter.uid === 'string' && !filter.operator) {
|
||||
where.push('uid = ?');
|
||||
params.push(filter.uid);
|
||||
}
|
||||
const { after, before } = boundsOf(filter);
|
||||
const afterMs = after ? instantOf(after, zone) : null;
|
||||
const beforeMs = before ? instantOf(before, zone) : null;
|
||||
if (afterMs !== null || beforeMs !== null) {
|
||||
where.push(`(is_series = 1 OR start_ms IS NULL OR (start_ms < ? AND end_ms >= ?))`);
|
||||
params.push(beforeMs === null ? Number.MAX_SAFE_INTEGER : beforeMs + ZONE_SLACK_MS);
|
||||
params.push(afterMs === null ? Number.MIN_SAFE_INTEGER : afterMs - ZONE_SLACK_MS);
|
||||
}
|
||||
return db.all<{ json: string }>(`SELECT json FROM calendar_event WHERE ${where.join(' AND ')}`, params)
|
||||
.map((row) => JSON.parse(row.json) as CalendarObject);
|
||||
}
|
||||
|
||||
const calendarGet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
assertSynced(ctx.db, jmap, 'Calendar');
|
||||
const ids = args.ids as string[] | null | undefined;
|
||||
const properties = args.properties as string[] | undefined;
|
||||
const all = listObjects<{ id: string } & Record<string, unknown>>(ctx.db, jmap, 'Calendar');
|
||||
const chosen = ids ? all.filter((calendar) => ids.includes(calendar.id)) : all;
|
||||
const state = stateOf(ctx.db, jmap, 'Calendar');
|
||||
return {
|
||||
accountId: jmap,
|
||||
...(state ? { state } : {}),
|
||||
list: chosen.map((calendar) => (properties
|
||||
? Object.fromEntries(['id', ...properties].filter((name) => name in calendar).map((name) => [name, calendar[name]]))
|
||||
: calendar)),
|
||||
notFound: ids ? ids.filter((id) => !all.some((calendar) => calendar.id === id)) : [],
|
||||
};
|
||||
};
|
||||
|
||||
const eventQuery: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
assertSynced(ctx.db, jmap, 'CalendarEvent');
|
||||
const filter = (args.filter ?? {}) as CalendarEventFilter;
|
||||
const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC';
|
||||
const { after, before } = boundsOf(filter);
|
||||
if (args.expandRecurrences && !(after && before)) {
|
||||
throw new MethodError('invalidArguments', 'expandRecurrences needs both after and before');
|
||||
}
|
||||
const matching = candidates(ctx.db, jmap, filter, zone).filter((object) => matchesFilter(object, filter, zone));
|
||||
const sorted = sortEvents(matching, (args.sort ?? []) as EventComparator[], zone);
|
||||
const position = Math.max(0, Number(args.position ?? 0) || 0);
|
||||
const limit = Number(args.limit);
|
||||
const ids = sorted.map((object) => object.id);
|
||||
return {
|
||||
accountId: jmap,
|
||||
// No queryState: the client doesn't read it, and the store has no real one.
|
||||
canCalculateChanges: false,
|
||||
position,
|
||||
ids: ids.slice(position, limit > 0 ? position + limit : undefined),
|
||||
...(args.calculateTotal ? { total: ids.length } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const eventGet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
const requested = args.ids as string[] | null | undefined;
|
||||
// A page's first calendar list carries `ids: []` to check a time zone, also before any sync.
|
||||
if (!(Array.isArray(requested) && requested.length === 0)) assertSynced(ctx.db, jmap, 'CalendarEvent');
|
||||
const properties = (args.properties as string[] | undefined) ?? null;
|
||||
const zone = typeof args.timeZone === 'string' && args.timeZone ? args.timeZone : 'UTC';
|
||||
const list: Record<string, unknown>[] = [];
|
||||
const notFound: string[] = [];
|
||||
if (requested) {
|
||||
for (const id of requested) {
|
||||
const row = ctx.db.get<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND id = ? AND hidden = 0', [jmap, resolveId(ctx.db, jmap, id)]);
|
||||
if (row) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone));
|
||||
else notFound.push(id);
|
||||
}
|
||||
} else {
|
||||
const rows = ctx.db.all<{ json: string }>('SELECT json FROM calendar_event WHERE jmap = ? AND hidden = 0', [jmap]);
|
||||
for (const row of rows) list.push(projectEvent(JSON.parse(row.json) as CalendarObject, properties, zone));
|
||||
}
|
||||
const state = stateOf(ctx.db, jmap, 'CalendarEvent');
|
||||
return { accountId: jmap, ...(state ? { state } : {}), list, notFound };
|
||||
};
|
||||
|
||||
// What a server without synthetic-id writes says. It's the answer here too, for
|
||||
// an id the store doesn't hold, which is an occurrence still on screen from
|
||||
// before the connection went: the client catches this (updateOccurrence,
|
||||
// destroyOccurrence in stores/calendar-store.ts) and writes the change as an
|
||||
// override on the base event, which the store does hold.
|
||||
function syntheticRefusal(verb: 'Updating' | 'Deleting'): { type: string; description: string; properties: string[] } {
|
||||
return { type: 'invalidProperties', description: `${verb} synthetic ids is not yet supported.`, properties: ['id'] };
|
||||
}
|
||||
|
||||
const eventSet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
const send = typeof args.sendSchedulingMessages === 'boolean' ? args.sendSchedulingMessages : undefined;
|
||||
const created: Record<string, { id: string; uid: string }> = {};
|
||||
const updated: Record<string, null> = {};
|
||||
const destroyed: string[] = [];
|
||||
const notUpdated: Record<string, unknown> = {};
|
||||
const notDestroyed: Record<string, unknown> = {};
|
||||
let wrote = false;
|
||||
ctx.db.transaction(() => {
|
||||
for (const [key, event] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
created[key] = queueCreate(ctx, jmap, event, send);
|
||||
wrote = true;
|
||||
}
|
||||
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
|
||||
if (id === SYNTHETIC_ID_PROBE) {
|
||||
// A client that started offline asks here. Tell it what the server said
|
||||
// at the last sync, so it behaves the same when the server is back. With
|
||||
// no answer yet: client-side expansion, which works on every server.
|
||||
notUpdated[id] = syntheticIdVerdict(ctx, jmap) === true ? { type: 'notFound' } : syntheticRefusal('Updating');
|
||||
continue;
|
||||
}
|
||||
if (queueUpdate(ctx, jmap, resolveId(ctx.db, jmap, id), patch, send)) {
|
||||
updated[id] = null;
|
||||
wrote = true;
|
||||
} else {
|
||||
notUpdated[id] = syntheticRefusal('Updating');
|
||||
}
|
||||
}
|
||||
for (const id of (args.destroy ?? []) as string[]) {
|
||||
if (queueDestroy(ctx, jmap, resolveId(ctx.db, jmap, id), send)) {
|
||||
destroyed.push(id);
|
||||
wrote = true;
|
||||
} else {
|
||||
notDestroyed[id] = syntheticRefusal('Deleting');
|
||||
}
|
||||
}
|
||||
});
|
||||
if (wrote) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
|
||||
const result: MethodResult = { accountId: jmap };
|
||||
if (Object.keys(created).length > 0) result.created = created;
|
||||
if (Object.keys(updated).length > 0) result.updated = updated;
|
||||
if (destroyed.length > 0) result.destroyed = destroyed;
|
||||
if (Object.keys(notUpdated).length > 0) result.notUpdated = notUpdated;
|
||||
if (Object.keys(notDestroyed).length > 0) result.notDestroyed = notDestroyed;
|
||||
return result;
|
||||
};
|
||||
|
||||
const identityGet: MethodHandler = (ctx, args) => {
|
||||
const jmap = jmapOf(ctx, args);
|
||||
const list = listObjects(ctx.db, jmap, 'ParticipantIdentity');
|
||||
if (list.length === 0) throw new MethodError('serverUnavailable', 'No participant identities kept yet');
|
||||
return { accountId: jmap, list, notFound: [] };
|
||||
};
|
||||
|
||||
export const CALENDAR_METHODS: Readonly<Record<string, MethodHandler>> = {
|
||||
'Calendar/get': calendarGet,
|
||||
'CalendarEvent/query': eventQuery,
|
||||
'CalendarEvent/get': eventGet,
|
||||
'CalendarEvent/set': eventSet,
|
||||
'ParticipantIdentity/get': identityGet,
|
||||
// Notifications about others' changes wait for the server.
|
||||
'CalendarEventNotification/query': (ctx, args) => ({ accountId: jmapOf(ctx, args), canCalculateChanges: false, position: 0, ids: [] }),
|
||||
'CalendarEventNotification/get': (ctx, args) => ({ accountId: jmapOf(ctx, args), list: [], notFound: [] }),
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Reading a PatchObject against the server's copy of an event.
|
||||
*
|
||||
* What goes in doesn't read back the same (measured on both Stalwarts,
|
||||
* 2026-11-03). 1.0 keeps only what an override changes against its series, a
|
||||
* participant as a pointer key such as `participants/me/participationStatus`.
|
||||
* 0.16 keeps the whole entry, moves `updated` in every entry on any change of the
|
||||
* series, and drops an attendee-only `roles`. Both drop `@type` from a recurrence
|
||||
* rule and add `iCalendar` to a location. So a value counts as held when the
|
||||
* server's has what was sent, and an override is read as its occurrence.
|
||||
*/
|
||||
|
||||
import { applyPatchObject } from '@/lib/jmap/patch-object';
|
||||
import { pointerToken, pointerTokenValue } from '@/lib/jmap/patch-pointer';
|
||||
import { applyOverridePatch, stableJson } from '@/lib/recurrence-instances';
|
||||
import { valueAtPointer } from './records';
|
||||
|
||||
type Patch = Record<string, unknown>;
|
||||
|
||||
const OVERRIDE_POINTER = /^recurrenceOverrides\/([^/]+)(?:\/(.+))?$/;
|
||||
const IGNORED_MEMBERS = new Set(['@type']);
|
||||
|
||||
function isEmptyDefault(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === false || value === 0 || value === '') return true;
|
||||
return typeof value === 'object' && Object.keys(value as object).length === 0;
|
||||
}
|
||||
|
||||
/** Whether `actual` has what `sent` says. Extra members in `actual` don't matter. */
|
||||
function contains(actual: unknown, sent: unknown, member?: string): boolean {
|
||||
if (actual === null || actual === undefined) {
|
||||
if (isEmptyDefault(sent)) return true;
|
||||
return member === 'roles' && stableJson(sent) === stableJson({ attendee: true });
|
||||
}
|
||||
if (Array.isArray(sent)) {
|
||||
return Array.isArray(actual) && actual.length === sent.length && sent.every((item, i) => contains(actual[i], item));
|
||||
}
|
||||
if (sent !== null && typeof sent === 'object') {
|
||||
if (typeof actual !== 'object' || Array.isArray(actual)) return false;
|
||||
return Object.entries(sent).every(([name, value]) => IGNORED_MEMBERS.has(name)
|
||||
|| contains((actual as Record<string, unknown>)[name], value, name));
|
||||
}
|
||||
return actual === sent;
|
||||
}
|
||||
|
||||
function overrideEntry(server: Patch, key: string): Patch | undefined {
|
||||
return (server.recurrenceOverrides as Record<string, Patch> | null | undefined)?.[key];
|
||||
}
|
||||
|
||||
/** Whether the server's copy holds `value` at PatchObject key `key`. */
|
||||
export function holdsValue(server: Patch, key: string, value: unknown): boolean {
|
||||
if (key === 'recurrenceOverrides') {
|
||||
const entries = Object.entries((value ?? {}) as Record<string, unknown>);
|
||||
if (entries.length === 0) return Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0;
|
||||
return entries.every(([id, entry]) => holdsValue(server, `recurrenceOverrides/${pointerToken(id)}`, entry));
|
||||
}
|
||||
const match = OVERRIDE_POINTER.exec(key);
|
||||
if (!match) return contains(valueAtPointer(server, key), value);
|
||||
const id = pointerTokenValue(match[1]);
|
||||
const entry = overrideEntry(server, id);
|
||||
const inEntry = match[2];
|
||||
if (value === null && !inEntry) return !entry;
|
||||
if (!entry) return false;
|
||||
if (!inEntry && (entry.excluded || (value as Patch).excluded)) return Boolean(entry.excluded) === Boolean((value as Patch).excluded);
|
||||
// `start` as the rule puts it: a server leaves it out of an unmoved override.
|
||||
const occurrence = applyOverridePatch({ ...server, start: id } as Patch, entry);
|
||||
return contains(inEntry ? valueAtPointer(occurrence, inEntry) : occurrence, value);
|
||||
}
|
||||
|
||||
/** Whether a row's write is already on the server, whoever put it there. */
|
||||
export function holdsPatch(server: Patch, patch: Patch): boolean {
|
||||
return Object.entries(patch).every(([key, value]) => holdsValue(server, key, value));
|
||||
}
|
||||
|
||||
// `updated` inside an override moves on 0.16 when any part of the series changes.
|
||||
function comparable(key: string, value: unknown): string {
|
||||
if (OVERRIDE_POINTER.test(key) && value && typeof value === 'object') {
|
||||
const { updated: _updated, ...rest } = value as Patch;
|
||||
return stableJson(rest);
|
||||
}
|
||||
return stableJson(value);
|
||||
}
|
||||
|
||||
export interface PatchState {
|
||||
patch: Patch;
|
||||
/** The server's value at each key when this was queued. */
|
||||
base: Patch;
|
||||
/** Values of ours that may have reached the server: a send whose answer was lost, an earlier row of the same event. */
|
||||
sent: Patch | null;
|
||||
}
|
||||
|
||||
/** Whether the server moved a value this patch writes, to something that isn't ours. */
|
||||
export function collides({ patch, base, sent }: PatchState, server: Patch): boolean {
|
||||
return Object.keys(patch).some((key) => {
|
||||
if (holdsValue(server, key, patch[key])) return false;
|
||||
if (sent && key in sent && holdsValue(server, key, sent[key])) return false;
|
||||
return comparable(key, valueAtPointer(server, key)) !== comparable(key, base[key]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole overrides map becomes one key per entry that differs from what the
|
||||
* writer could see. Written whole, it would erase any override someone else
|
||||
* added while the write waited; this way that one collides only on the same entry.
|
||||
*/
|
||||
export function asOverridePointers(patch: Patch, shown: Patch): Patch {
|
||||
if (!('recurrenceOverrides' in patch)) return patch;
|
||||
const { recurrenceOverrides: map, ...rest } = patch;
|
||||
const entries = (map ?? {}) as Record<string, unknown>;
|
||||
const known = (shown.recurrenceOverrides ?? {}) as Record<string, unknown>;
|
||||
const out: Patch = { ...rest };
|
||||
for (const [id, entry] of Object.entries(entries)) {
|
||||
if (stableJson(entry) !== stableJson(known[id])) out[`recurrenceOverrides/${pointerToken(id)}`] = entry;
|
||||
}
|
||||
for (const id of Object.keys(known)) {
|
||||
if (!(id in entries)) out[`recurrenceOverrides/${pointerToken(id)}`] = null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The patch as it can go out now. A pointer into overrides fails while a series
|
||||
* has none, on both Stalwarts, so they go as a whole map then, which erases
|
||||
* nothing. Removing an entry the server doesn't have is nothing to send.
|
||||
*/
|
||||
export function patchForServer(patch: Patch, server: Patch): Patch {
|
||||
const pointers = Object.keys(patch).filter((key) => OVERRIDE_POINTER.test(key));
|
||||
if (pointers.length === 0) return patch;
|
||||
const out: Patch = {};
|
||||
for (const [key, value] of Object.entries(patch)) if (!OVERRIDE_POINTER.test(key)) out[key] = value;
|
||||
if (Object.keys((server.recurrenceOverrides ?? {}) as object).length === 0) {
|
||||
const built = applyPatchObject({ recurrenceOverrides: {} }, Object.fromEntries(pointers.map((key) => [key, patch[key]])));
|
||||
if (Object.keys(built.recurrenceOverrides).length > 0) out.recurrenceOverrides = built.recurrenceOverrides;
|
||||
return out;
|
||||
}
|
||||
for (const key of pointers) {
|
||||
const entry = overrideEntry(server, pointerTokenValue((OVERRIDE_POINTER.exec(key) as RegExpExecArray)[1]));
|
||||
if (patch[key] === null && !entry) continue;
|
||||
out[key] = patch[key];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/** How calendar objects are stored and read back. */
|
||||
|
||||
import { parseISO } from 'date-fns';
|
||||
import { isTaskLikeObject } from '@/lib/calendar-component-detection';
|
||||
import { parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
||||
import { pointerTokenValue } from '@/lib/jmap/patch-pointer';
|
||||
import { wallClockFieldsAsUtc, zonedWallTimeToUtc } from '@/lib/recurrence-expansion';
|
||||
import type { StoreSession } from '../engine/transport';
|
||||
import type { SqlValue, StorageAdapter } from '../storage';
|
||||
|
||||
export const CALENDARS_CAPABILITY = 'urn:ietf:params:jmap:calendars';
|
||||
const PRINCIPALS_OWNER = 'urn:ietf:params:jmap:principals:owner';
|
||||
|
||||
export type CalendarObject = Record<string, unknown> & { id: string };
|
||||
|
||||
export function calendarUsing(session: StoreSession): string[] {
|
||||
const using = ['urn:ietf:params:jmap:core', CALENDARS_CAPABILITY];
|
||||
if (PRINCIPALS_OWNER in session.capabilities) using.push(PRINCIPALS_OWNER);
|
||||
return using;
|
||||
}
|
||||
|
||||
/** Stalwart's default list for Calendar/get leaves out sharing and default alerts. */
|
||||
export const CALENDAR_PROPERTIES = [
|
||||
'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault',
|
||||
'includeInAvailability', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', 'timeZone', 'shareWith', 'myRights',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Everything the client's event and task property lists ask for, in one get.
|
||||
* No utcStart or utcEnd: Stalwart 1.0 refuses those next to recurrenceOverrides,
|
||||
* and they depend on the zone of the request, so answers work them out.
|
||||
*/
|
||||
export const EVENT_PROPERTIES = [
|
||||
'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated',
|
||||
'sequence', 'start', 'due', 'duration', 'timeZone', 'showWithoutTime', 'status', 'freeBusyStatus', 'privacy',
|
||||
'color', 'keywords', 'categories', 'locale', 'replyTo', 'organizerCalendarAddress', 'participants',
|
||||
'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule',
|
||||
'excludedRecurrenceRule', 'recurrenceOverrides', 'useDefaultAlerts', 'alerts', 'locations', 'virtualLocations',
|
||||
'links', 'relatedTo', 'isDraft', 'isOrigin', 'baseEventId', 'progress', 'priority', 'percentComplete',
|
||||
] as const;
|
||||
|
||||
export function isSeries(object: Record<string, unknown>): boolean {
|
||||
const rule = object.recurrenceRule ?? object.recurrenceRules;
|
||||
return Array.isArray(rule) ? rule.length > 0 : Boolean(rule);
|
||||
}
|
||||
|
||||
/** The value a PatchObject key points at. */
|
||||
export function valueAtPointer(object: unknown, pointer: string): unknown {
|
||||
let value = object;
|
||||
for (const token of pointer.split('/')) {
|
||||
if (value === null || typeof value !== 'object') return undefined;
|
||||
value = (value as Record<string, unknown>)[pointerTokenValue(token)];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A LocalDateTime without any `Z` or offset: Stalwart drops those instead of honouring them. */
|
||||
export function wallClock(value: string): string {
|
||||
return value.replace(/(?:Z|[+-]\d{2}:?\d{2})$/, '');
|
||||
}
|
||||
|
||||
/** The instant a wall clock names in `zone`. A zone the runtime doesn't know reads as UTC, as 0.16 does. */
|
||||
export function instantOf(value: string, zone: string | null | undefined): number | null {
|
||||
const wall = parseISO(wallClock(value));
|
||||
if (Number.isNaN(wall.getTime())) return null;
|
||||
const zoned = zone ? zonedWallTimeToUtc(wall, zone) : null;
|
||||
return zoned ? zoned.getTime() : wallClockFieldsAsUtc(wall);
|
||||
}
|
||||
|
||||
/** An object's start and end as instants. A floating one is read in `zone`. A task without a start goes by its due. */
|
||||
export function instantsOf(object: Record<string, unknown>, zone: string | null | undefined): { start: number | null; end: number | null } {
|
||||
const at = typeof object.start === 'string' ? object.start : typeof object.due === 'string' ? object.due : null;
|
||||
if (!at) return { start: null, end: null };
|
||||
const start = instantOf(at, typeof object.timeZone === 'string' ? object.timeZone : zone);
|
||||
if (start === null) return { start: null, end: null };
|
||||
const seconds = typeof object.duration === 'string' ? parseDurationSeconds(object.duration) : null;
|
||||
return { start, end: start + (seconds ?? 0) * 1000 };
|
||||
}
|
||||
|
||||
function utcString(ms: number): string {
|
||||
return new Date(ms).toISOString().replace('.000Z', 'Z');
|
||||
}
|
||||
|
||||
/**
|
||||
* An object as a CalendarEvent/get asked for it. utcStart and utcEnd are worked
|
||||
* out here, so they hold for the request's zone and for events edited offline.
|
||||
* A series keeps its overrides even when not asked for: the client's
|
||||
* synthetic-id mode reads occurrences without them, and it gets series from the
|
||||
* store, which it can only expand correctly with them.
|
||||
*/
|
||||
export function projectEvent(object: CalendarObject, properties: readonly string[] | null, zone: string | null | undefined): Record<string, unknown> {
|
||||
if (!properties) return { ...object };
|
||||
const out: Record<string, unknown> = { id: object.id };
|
||||
for (const name of properties) {
|
||||
if (name === 'utcStart' || name === 'utcEnd') continue;
|
||||
if (name in object) out[name] = object[name];
|
||||
}
|
||||
if (properties.includes('utcStart') || properties.includes('utcEnd')) {
|
||||
const { start, end } = typeof object.start === 'string' ? instantsOf(object, zone) : { start: null, end: null };
|
||||
if (start !== null && properties.includes('utcStart')) out.utcStart = utcString(start);
|
||||
if (end !== null && properties.includes('utcEnd')) out.utcEnd = utcString(end);
|
||||
}
|
||||
if (properties.includes('recurrenceRule') && object.recurrenceOverrides && isSeries(object)) {
|
||||
out.recurrenceOverrides = object.recurrenceOverrides;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface EventRow {
|
||||
jmap: string;
|
||||
id: string;
|
||||
json: string;
|
||||
server_json: string | null;
|
||||
hidden: number;
|
||||
gen: number;
|
||||
}
|
||||
|
||||
export function eventRow(db: StorageAdapter, jmap: string, id: string): EventRow | undefined {
|
||||
return db.get<EventRow>('SELECT jmap, id, json, server_json, hidden, gen FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
}
|
||||
|
||||
export function currentGen(db: StorageAdapter, jmap: string): number {
|
||||
return Number(db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM calendar_event WHERE jmap = ?', [jmap])?.gen ?? 0);
|
||||
}
|
||||
|
||||
export interface EventWrite {
|
||||
/** What answers show. */
|
||||
shown: CalendarObject;
|
||||
/** The server's copy while a write is pending, otherwise null. */
|
||||
server?: CalendarObject | null;
|
||||
hidden?: boolean;
|
||||
gen: number;
|
||||
}
|
||||
|
||||
export function writeEvent(db: StorageAdapter, jmap: string, write: EventWrite): void {
|
||||
const { shown } = write;
|
||||
const { start, end } = instantsOf(shown, 'UTC');
|
||||
const params: SqlValue[] = [
|
||||
jmap,
|
||||
shown.id,
|
||||
typeof shown.uid === 'string' ? shown.uid : null,
|
||||
isTaskLikeObject(shown) ? 1 : 0,
|
||||
isSeries(shown) ? 1 : 0,
|
||||
start,
|
||||
end,
|
||||
JSON.stringify(shown),
|
||||
write.server ? JSON.stringify(write.server) : null,
|
||||
write.hidden ? 1 : 0,
|
||||
write.gen,
|
||||
];
|
||||
db.run(
|
||||
`INSERT INTO calendar_event (jmap, id, uid, is_task, is_series, start_ms, end_ms, json, server_json, hidden, gen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (jmap, id) DO UPDATE SET uid = excluded.uid, is_task = excluded.is_task, is_series = excluded.is_series, start_ms = excluded.start_ms,
|
||||
end_ms = excluded.end_ms, json = excluded.json, server_json = excluded.server_json, hidden = excluded.hidden, gen = excluded.gen`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteEventRow(db: StorageAdapter, jmap: string, id: string): void {
|
||||
db.run('DELETE FROM calendar_event WHERE jmap = ? AND id = ?', [jmap, id]);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Which alerts fire in a time window, from what's stored. No DOM, no stores, no
|
||||
* React: desktop reminders call this while the window is hidden.
|
||||
*
|
||||
* Keys use the series' uid and the fire time, never an id. Synthetic ids can
|
||||
* change, browser occurrences don't exist on the server, and a stand-in id becomes
|
||||
* a real one. A moved occurrence gets a new key, as it should: it's a new time to
|
||||
* remind.
|
||||
*/
|
||||
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts';
|
||||
import { isTaskLikeObject } from '@/lib/calendar-component-detection';
|
||||
import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization';
|
||||
import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { listObjects } from '../engine/core-schema';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
import { instantsOf, isSeries, type CalendarObject } from './records';
|
||||
|
||||
export interface UpcomingAlert {
|
||||
/** Stable for this alert at this time. Use it to remember what was shown. */
|
||||
key: string;
|
||||
jmap: string;
|
||||
kind: 'event' | 'task';
|
||||
/** The stored event or task: the master of a series. */
|
||||
baseEventId: string;
|
||||
/** Which occurrence of the series, when it's one. */
|
||||
recurrenceId: string | null;
|
||||
alertId: string;
|
||||
fireTimeMs: number;
|
||||
title: string;
|
||||
/** The start as a wall clock, or a task's due when it has no start. */
|
||||
start: string | null;
|
||||
timeZone: string | null;
|
||||
calendarId: string | null;
|
||||
calendarName: string | null;
|
||||
}
|
||||
|
||||
export interface UpcomingAlertsOptions {
|
||||
/** Alerts firing at or after this. */
|
||||
from: number;
|
||||
/** Alerts firing before this. */
|
||||
to: number;
|
||||
/** Reads floating events. The user's zone. */
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
type AlertMap = Record<string, CalendarEventAlert> | null | undefined;
|
||||
|
||||
// An alert fires up to its offset away from its occurrence, and the occurrence's
|
||||
// wall clock is up to 26 hours from the instant. Expand wide, filter by instant.
|
||||
function offsetReachMs(alerts: AlertMap): number {
|
||||
let reach = 0;
|
||||
for (const alert of Object.values(alerts ?? {})) {
|
||||
if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue;
|
||||
reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0));
|
||||
}
|
||||
return reach;
|
||||
}
|
||||
|
||||
function alertsInOverrides(event: CalendarEvent): AlertMap[] {
|
||||
return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts);
|
||||
}
|
||||
|
||||
const ZONE_REACH_MS = 27 * 3_600_000;
|
||||
|
||||
function occurrencesNear(event: CalendarEvent, calendars: Calendar[], options: UpcomingAlertsOptions): CalendarEvent[] {
|
||||
const reach = ZONE_REACH_MS
|
||||
+ Math.max(offsetReachMs(getEffectiveAlerts(event, calendars)), ...alertsInOverrides(event).map(offsetReachMs), 0)
|
||||
+ (parseDurationSeconds(event.duration) ?? 0) * 1000;
|
||||
const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss");
|
||||
return expandRecurringEvents([event], stamp(options.from - reach), stamp(options.to + reach));
|
||||
}
|
||||
|
||||
function withInstants(object: CalendarObject, zone: string): CalendarObject {
|
||||
const { start, end } = instantsOf(object, zone);
|
||||
return {
|
||||
...object,
|
||||
...(start !== null ? { utcStart: new Date(start).toISOString() } : {}),
|
||||
...(end !== null ? { utcEnd: new Date(end).toISOString() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } {
|
||||
const id = Object.keys((calendarIds ?? {}) as Record<string, boolean>)[0] ?? null;
|
||||
return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null };
|
||||
}
|
||||
|
||||
/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */
|
||||
export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] {
|
||||
const found = new Map<string, UpcomingAlert>();
|
||||
const add = (alert: UpcomingAlert) => {
|
||||
if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert);
|
||||
};
|
||||
const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0');
|
||||
const calendarsOf = new Map<string, Calendar[]>();
|
||||
for (const row of rows) {
|
||||
if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects<Calendar>(db, row.jmap, 'Calendar'));
|
||||
const calendars = calendarsOf.get(row.jmap)!;
|
||||
const object = JSON.parse(row.json) as CalendarObject;
|
||||
const calendar = firstCalendar(object.calendarIds, calendars);
|
||||
// With the account: Stalwart's ids repeat across accounts, and one meeting can be in two.
|
||||
const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`;
|
||||
|
||||
if (isTaskLikeObject(object)) {
|
||||
const task = object as unknown as CalendarTask;
|
||||
if (task.progress === 'completed' || task.progress === 'cancelled') continue;
|
||||
for (const [alertId, alert] of Object.entries(task.alerts ?? {})) {
|
||||
if (alert.action !== 'display' || alert.acknowledged) continue;
|
||||
const fireTimeMs = computeTaskFireTime(task, alert.trigger);
|
||||
if (fireTimeMs === null) continue;
|
||||
add({
|
||||
key: buildAlertKey(stableId, alertId, fireTimeMs),
|
||||
jmap: row.jmap,
|
||||
kind: 'task',
|
||||
baseEventId: object.id,
|
||||
recurrenceId: null,
|
||||
alertId,
|
||||
fireTimeMs,
|
||||
title: task.title ?? '',
|
||||
start: task.start ?? task.due ?? null,
|
||||
timeZone: task.timeZone ?? null,
|
||||
calendarId: calendar.id,
|
||||
calendarName: calendar.name,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent);
|
||||
if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue;
|
||||
const series = isSeries(object);
|
||||
const occurrences = series ? occurrencesNear(master, calendars, options) : [master];
|
||||
// A fixed time doesn't need an occurrence nearby.
|
||||
const candidates = series ? [master, ...occurrences] : occurrences;
|
||||
for (const occurrence of candidates) {
|
||||
if (occurrence.status === 'cancelled') continue;
|
||||
const isMasterOfSeries = series && occurrence === master;
|
||||
for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) {
|
||||
if (alert.action !== 'display' || alert.acknowledged) continue;
|
||||
if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue;
|
||||
const fireTimeMs = computeFireTime(occurrence, alert.trigger);
|
||||
if (fireTimeMs === null) continue;
|
||||
add({
|
||||
key: buildAlertKey(stableId, alertId, fireTimeMs),
|
||||
jmap: row.jmap,
|
||||
kind: 'event',
|
||||
baseEventId: object.id,
|
||||
recurrenceId: occurrence.recurrenceId ?? null,
|
||||
alertId,
|
||||
fireTimeMs,
|
||||
title: occurrence.title ?? '',
|
||||
start: occurrence.start,
|
||||
timeZone: occurrence.timeZone ?? null,
|
||||
calendarId: calendar.id,
|
||||
calendarName: calendar.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Migration } from '../storage';
|
||||
|
||||
export const CALENDAR_MIGRATIONS: readonly Migration[] = [
|
||||
{
|
||||
id: 500,
|
||||
name: 'calendar',
|
||||
sql: `
|
||||
CREATE TABLE calendar_event (
|
||||
jmap TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
uid TEXT,
|
||||
is_task INTEGER NOT NULL DEFAULT 0,
|
||||
-- 1 for a series master. Occurrences are never stored: /changes only
|
||||
-- lists masters, and the client expands them.
|
||||
is_series INTEGER NOT NULL DEFAULT 0,
|
||||
-- Instants to narrow a query with. A floating event is read in UTC here.
|
||||
start_ms INTEGER,
|
||||
end_ms INTEGER,
|
||||
-- What answers show: the server's copy with any pending write applied.
|
||||
json TEXT NOT NULL,
|
||||
-- The server's copy while a write is pending on it, otherwise NULL.
|
||||
server_json TEXT,
|
||||
-- 1 while a delete is pending.
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
gen INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (jmap, id)
|
||||
) WITHOUT ROWID;
|
||||
CREATE INDEX calendar_event_uid ON calendar_event (jmap, uid);
|
||||
CREATE INDEX calendar_event_start ON calendar_event (jmap, start_ms);
|
||||
CREATE INDEX calendar_event_end ON calendar_event (jmap, end_ms);
|
||||
|
||||
-- An event's writes in order. A later one merges when the flags match (see writes.ts).
|
||||
CREATE TABLE calendar_pending (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jmap TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('create', 'update', 'destroy')),
|
||||
-- create: the whole event. update: a PatchObject. destroy: empty.
|
||||
patch TEXT NOT NULL,
|
||||
-- The server's value at each patched path when it was queued.
|
||||
base TEXT NOT NULL DEFAULT '{}',
|
||||
-- For a create: how to find it again after a lost response.
|
||||
uid TEXT,
|
||||
-- sendSchedulingMessages: NULL when no write named it.
|
||||
send_scheduling INTEGER,
|
||||
-- 1 once it went out, or when the server may hold it for another reason.
|
||||
attempted INTEGER NOT NULL DEFAULT 0,
|
||||
-- What went out last, or for a later row what its earlier rows wrote:
|
||||
-- values of ours the server may hold.
|
||||
sent TEXT,
|
||||
-- Moves on every merge, to see a write that came in during a send.
|
||||
rev INTEGER NOT NULL DEFAULT 0,
|
||||
queued_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX calendar_pending_record ON calendar_pending (jmap, record_id, seq);
|
||||
CREATE INDEX calendar_pending_uid ON calendar_pending (jmap, uid);
|
||||
|
||||
-- What a stand-in id became, for a page still holding the stand-in.
|
||||
CREATE TABLE calendar_id_map (
|
||||
jmap TEXT NOT NULL,
|
||||
local_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
PRIMARY KEY (jmap, local_id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE TABLE calendar_conflict (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jmap TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
error_type TEXT,
|
||||
description TEXT,
|
||||
title TEXT,
|
||||
local TEXT NOT NULL,
|
||||
server TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Calendar sync for one JMAP account: calendars, the last participant
|
||||
* identities, then every event and task.
|
||||
*
|
||||
* Masters only. CalendarEvent/changes lists nothing else on either Stalwart, an
|
||||
* instance edit shows up as `updated: [master]`, and 1.0's expanded queries
|
||||
* can't give changes and refuse long ranges. Occurrences are made when read.
|
||||
*/
|
||||
|
||||
import { isTaskLikeObject } from '@/lib/calendar-component-detection';
|
||||
import { SYNTHETIC_ID_PROBE } from '@/lib/recurrence-instances';
|
||||
import type { StoreContext, SyncContext, Syncer } from '../area';
|
||||
import { runChangesLoop } from '../engine/changes';
|
||||
import { metaGet, metaSet, putObject } from '../engine/core-schema';
|
||||
import { JmapMethodError, responseFor, TransportError } from '../engine/transport';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
import { isPassingError, sendPending, type FlushTally } from './flush';
|
||||
import {
|
||||
CALENDAR_PROPERTIES,
|
||||
CALENDARS_CAPABILITY,
|
||||
calendarUsing,
|
||||
currentGen,
|
||||
EVENT_PROPERTIES,
|
||||
eventRow,
|
||||
instantsOf,
|
||||
isSeries,
|
||||
type CalendarObject,
|
||||
} from './records';
|
||||
import { applyServerDestroy, applyServerEvent, isStandIn, pendingChain, recordConflict, type PendingRow } from './writes';
|
||||
|
||||
const PAGE = 500;
|
||||
/** How far back single events are kept. A series stays whatever its start, a task whatever its date. */
|
||||
const PAST_WINDOW_MS = 2 * 366 * 86_400_000;
|
||||
|
||||
function syntheticIdsKey(jmap: string): string {
|
||||
return `calendar.syntheticIds.${jmap}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this account's server said to the synthetic-id probe at the last sync.
|
||||
* Null before it ever answered. A client that probed online holds its verdict
|
||||
* for life and never asks again; only one started offline asks the store.
|
||||
*/
|
||||
export function syntheticIdVerdict(ctx: StoreContext, jmap: string): boolean | null {
|
||||
const value = metaGet(ctx.db, syntheticIdsKey(jmap));
|
||||
return value === null ? null : value === '1';
|
||||
}
|
||||
|
||||
// A shared account probed on suspicion that grants no calendar access (the
|
||||
// client treats these the same way).
|
||||
function isAccessDenial(error: unknown): boolean {
|
||||
return error instanceof JmapMethodError
|
||||
&& (error.type === 'forbidden' || error.type === 'accountNotFound' || error.type === 'accountNotSupportedByMethod');
|
||||
}
|
||||
|
||||
/** False when the account grants no calendar access. */
|
||||
async function syncCalendars(ctx: SyncContext, jmap: string): Promise<boolean> {
|
||||
const response = await ctx.transport.request([
|
||||
['Calendar/get', { accountId: jmap, ids: null, properties: CALENDAR_PROPERTIES }, 'c'],
|
||||
['CalendarEvent/set', { accountId: jmap, update: { [SYNTHETIC_ID_PROBE]: {} } }, 'p'],
|
||||
['ParticipantIdentity/get', { accountId: jmap }, 'i'],
|
||||
], calendarUsing(ctx.session));
|
||||
let result: { list: Array<{ id: string }>; state: unknown };
|
||||
try {
|
||||
result = responseFor(response, 'c')[1];
|
||||
} catch (error) {
|
||||
if (isAccessDenial(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
const probe = response.methodResponses.find(([, , id]) => id === 'p');
|
||||
const identities = response.methodResponses.find(([, , id]) => id === 'i');
|
||||
const before = ctx.syncState(jmap, 'Calendar').state;
|
||||
ctx.db.transaction(() => {
|
||||
ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'Calendar'", [jmap]);
|
||||
for (const calendar of result.list) putObject(ctx.db, jmap, 'Calendar', calendar.id, calendar);
|
||||
ctx.setSyncState(jmap, 'Calendar', { state: String(result.state) });
|
||||
if (probe?.[0] === 'CalendarEvent/set') {
|
||||
const failure = probe[1]?.notUpdated?.[SYNTHETIC_ID_PROBE] as { type?: string } | undefined;
|
||||
metaSet(ctx.db, syntheticIdsKey(jmap), failure?.type === 'notFound' ? '1' : '0');
|
||||
}
|
||||
// Only ever read as "the last value". 0.16 has no usable /changes for it.
|
||||
if (identities?.[0] === 'ParticipantIdentity/get') {
|
||||
ctx.db.run("DELETE FROM object WHERE jmap = ? AND type = 'ParticipantIdentity'", [jmap]);
|
||||
for (const identity of identities[1].list as Array<{ id: string }>) putObject(ctx.db, jmap, 'ParticipantIdentity', identity.id, identity);
|
||||
}
|
||||
});
|
||||
if (before !== String(result.state)) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar'] });
|
||||
return true;
|
||||
}
|
||||
|
||||
function inWindow(ctx: SyncContext, event: CalendarObject): boolean {
|
||||
if (isSeries(event) || isTaskLikeObject(event)) return true;
|
||||
const { end } = instantsOf(event, 'UTC');
|
||||
return end === null || end >= ctx.now() - PAST_WINDOW_MS;
|
||||
}
|
||||
|
||||
interface ListCursor { position: number; gen: number; captured: string }
|
||||
|
||||
async function startFullList(ctx: SyncContext, jmap: string): Promise<ListCursor> {
|
||||
const response = await ctx.transport.request([['CalendarEvent/get', { accountId: jmap, ids: [], properties: ['id'] }, 's']], calendarUsing(ctx.session));
|
||||
const captured = String(responseFor(response, 's')[1].state);
|
||||
const cursor: ListCursor = { position: 0, gen: currentGen(ctx.db, jmap) + 1, captured };
|
||||
ctx.db.transaction(() => ctx.setSyncState(jmap, 'CalendarEvent', { state: null, phase: 'list', cursor: { ...cursor } }));
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function storeEvent(ctx: SyncContext, jmap: string, event: CalendarObject, gen: number): void {
|
||||
// An old event changing doesn't pull it back into the window.
|
||||
if (!inWindow(ctx, event) && !eventRow(ctx.db, jmap, event.id) && pendingChain(ctx.db, jmap, event.id).length === 0
|
||||
&& !ctx.db.get('SELECT 1 AS x FROM calendar_pending WHERE jmap = ? AND uid = ?', [jmap, typeof event.uid === 'string' ? event.uid : ''])) return;
|
||||
applyServerEvent(ctx, jmap, event, gen);
|
||||
}
|
||||
|
||||
/** Pages through every object: the query has no filter for tasks, and old series must stay. False when stopped. */
|
||||
async function runFullList(ctx: SyncContext, jmap: string, start: ListCursor): Promise<boolean> {
|
||||
const cursor = { ...start };
|
||||
for (;;) {
|
||||
if (ctx.stopped()) return false;
|
||||
const response = await ctx.transport.request([
|
||||
['CalendarEvent/query', { accountId: jmap, position: cursor.position, limit: PAGE }, 'q'],
|
||||
['CalendarEvent/get', {
|
||||
accountId: jmap,
|
||||
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
|
||||
properties: EVENT_PROPERTIES,
|
||||
}, 'g'],
|
||||
], calendarUsing(ctx.session));
|
||||
const ids = responseFor(response, 'q')[1].ids as string[];
|
||||
const list = responseFor(response, 'g')[1].list as CalendarObject[];
|
||||
const done = ids.length < PAGE;
|
||||
cursor.position += ids.length;
|
||||
ctx.db.transaction(() => {
|
||||
for (const event of list) storeEvent(ctx, jmap, event, cursor.gen);
|
||||
if (!done) ctx.setSyncState(jmap, 'CalendarEvent', { cursor: { ...cursor } });
|
||||
});
|
||||
if (done && !(await finishFullList(ctx, jmap, cursor))) return false;
|
||||
ctx.emit({ type: 'progress', account: ctx.account, area: 'calendar', phase: 'list', done: cursor.position, total: null });
|
||||
if (done) {
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A delete during paging moves later objects across a page boundary, so what this
|
||||
* pass didn't see may still be there. Asks for it by id, and only what the server
|
||||
* says it doesn't have goes. False when stopped.
|
||||
*/
|
||||
async function finishFullList(ctx: SyncContext, jmap: string, cursor: ListCursor): Promise<boolean> {
|
||||
const unseen = ctx.db.all<{ id: string }>('SELECT id FROM calendar_event WHERE jmap = ? AND gen < ?', [jmap, cursor.gen])
|
||||
.map((row) => row.id)
|
||||
.filter((id) => !isStandIn(id));
|
||||
const found: CalendarObject[] = [];
|
||||
const gone: string[] = [];
|
||||
for (let i = 0; i < unseen.length; i += PAGE) {
|
||||
if (ctx.stopped()) return false;
|
||||
const response = await ctx.transport.request([['CalendarEvent/get', {
|
||||
accountId: jmap,
|
||||
ids: unseen.slice(i, i + PAGE),
|
||||
properties: EVENT_PROPERTIES,
|
||||
}, 'g']], calendarUsing(ctx.session));
|
||||
const [, result] = responseFor(response, 'g');
|
||||
found.push(...result.list as CalendarObject[]);
|
||||
gone.push(...result.notFound as string[]);
|
||||
}
|
||||
ctx.db.transaction(() => {
|
||||
for (const event of found) storeEvent(ctx, jmap, event, cursor.gen);
|
||||
for (const id of gone) applyServerDestroy(ctx, jmap, id);
|
||||
ctx.setSyncState(jmap, 'CalendarEvent', { state: cursor.captured, phase: null, cursor: null });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function syncDelta(ctx: SyncContext, jmap: string, since: string, target: string | null): Promise<'done' | 'resync' | 'stopped'> {
|
||||
const outcome = await runChangesLoop({
|
||||
transport: ctx.transport,
|
||||
type: 'CalendarEvent',
|
||||
jmap,
|
||||
using: calendarUsing(ctx.session),
|
||||
since,
|
||||
target,
|
||||
stopped: ctx.stopped,
|
||||
apply: async (page) => {
|
||||
const ids = [...page.created, ...page.updated];
|
||||
const fetched: CalendarObject[] = [];
|
||||
for (let i = 0; i < ids.length; i += PAGE) {
|
||||
const response = await ctx.transport.request([['CalendarEvent/get', {
|
||||
accountId: jmap,
|
||||
ids: ids.slice(i, i + PAGE),
|
||||
properties: EVENT_PROPERTIES,
|
||||
}, 'g']], calendarUsing(ctx.session));
|
||||
fetched.push(...responseFor(response, 'g')[1].list as CalendarObject[]);
|
||||
}
|
||||
ctx.db.transaction(() => {
|
||||
const gen = currentGen(ctx.db, jmap);
|
||||
for (const event of fetched) storeEvent(ctx, jmap, event, gen);
|
||||
for (const id of page.destroyed) applyServerDestroy(ctx, jmap, id);
|
||||
ctx.setSyncState(jmap, 'CalendarEvent', { state: page.newState });
|
||||
});
|
||||
if (ids.length + page.destroyed.length > 0) ctx.emit({ type: 'changed', account: ctx.account, collections: ['CalendarEvent'] });
|
||||
},
|
||||
});
|
||||
if (outcome.outcome === 'resync') return 'resync';
|
||||
return outcome.outcome === 'stopped' ? 'stopped' : 'done';
|
||||
}
|
||||
|
||||
/** False when stopped before catching up. */
|
||||
async function syncEvents(ctx: SyncContext, jmap: string): Promise<boolean> {
|
||||
if (ctx.stopped()) return false;
|
||||
const sync = ctx.syncState(jmap, 'CalendarEvent');
|
||||
let cursor = sync.phase === 'list' ? sync.cursor as unknown as ListCursor : null;
|
||||
if (!cursor && !sync.state) cursor = await startFullList(ctx, jmap);
|
||||
if (!cursor && sync.state) {
|
||||
const outcome = await syncDelta(ctx, jmap, sync.state, sync.target);
|
||||
if (outcome === 'stopped') return false;
|
||||
if (outcome === 'resync') cursor = await startFullList(ctx, jmap);
|
||||
}
|
||||
if (cursor) {
|
||||
if (!(await runFullList(ctx, jmap, cursor))) return false;
|
||||
// What changed while paging.
|
||||
const after = ctx.syncState(jmap, 'CalendarEvent');
|
||||
if (after.state && (await syncDelta(ctx, jmap, after.state, after.target)) === 'stopped') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Single events that aged out of the window. The window moves on without any change on the server. */
|
||||
function pruneOld(ctx: SyncContext, jmap: string): void {
|
||||
ctx.db.run(
|
||||
`DELETE FROM calendar_event WHERE jmap = ? AND is_series = 0 AND is_task = 0 AND end_ms IS NOT NULL AND end_ms < ?
|
||||
AND NOT EXISTS (SELECT 1 FROM calendar_pending p WHERE p.jmap = calendar_event.jmap AND p.record_id = calendar_event.id)`,
|
||||
[jmap, ctx.now() - PAST_WINDOW_MS],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A share taken away: the session stops listing the account, and nothing of it
|
||||
* stays on the device, reminders included. Its waiting writes can't go anywhere.
|
||||
*/
|
||||
function forgetUnlistedAccounts(ctx: SyncContext): void {
|
||||
const listed = new Set(Object.keys(ctx.session.accounts));
|
||||
if (listed.size === 0) return;
|
||||
const kept = ctx.db.all<{ jmap: string }>(
|
||||
`SELECT jmap FROM calendar_event UNION SELECT jmap FROM calendar_pending
|
||||
UNION SELECT jmap FROM object WHERE type IN ('Calendar', 'ParticipantIdentity')`,
|
||||
).map((row) => row.jmap);
|
||||
for (const jmap of kept) {
|
||||
if (listed.has(jmap)) continue;
|
||||
ctx.db.transaction(() => {
|
||||
for (;;) {
|
||||
const row = ctx.db.get<PendingRow>('SELECT * FROM calendar_pending WHERE jmap = ? ORDER BY seq LIMIT 1', [jmap]);
|
||||
if (!row) break;
|
||||
recordConflict(ctx, row, 'destroyedOnServer', { type: 'accountNotFound' });
|
||||
}
|
||||
ctx.db.run('DELETE FROM calendar_event WHERE jmap = ?', [jmap]);
|
||||
ctx.db.run('DELETE FROM calendar_id_map WHERE jmap = ?', [jmap]);
|
||||
ctx.db.run("DELETE FROM object WHERE jmap = ? AND type IN ('Calendar', 'ParticipantIdentity')", [jmap]);
|
||||
ctx.db.run("DELETE FROM sync_state WHERE jmap = ? AND type IN ('Calendar', 'CalendarEvent')", [jmap]);
|
||||
metaSet(ctx.db, syntheticIdsKey(jmap), null);
|
||||
});
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections: ['Calendar', 'CalendarEvent'] });
|
||||
}
|
||||
}
|
||||
|
||||
// A sync and a flush must not interleave. Our own write coming back through
|
||||
// /changes while it's still pending would read as someone else's change.
|
||||
const queues = new WeakMap<StorageAdapter, Promise<unknown>>();
|
||||
|
||||
function oneAtATime<T>(db: StorageAdapter, work: () => Promise<T>): Promise<T> {
|
||||
const next = (queues.get(db) ?? Promise.resolve()).catch(() => {}).then(work);
|
||||
queues.set(db, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async function syncAndSend(ctx: SyncContext, jmap: string): Promise<FlushTally> {
|
||||
const none: FlushTally = { sent: 0, conflicts: 0 };
|
||||
if (!(await syncEvents(ctx, jmap)) || ctx.stopped()) return none;
|
||||
const tally = await sendPending(ctx, jmap);
|
||||
if (tally.sent > 0 && !ctx.stopped()) await syncEvents(ctx, jmap);
|
||||
return tally;
|
||||
}
|
||||
|
||||
export const calendarSyncer: Syncer = {
|
||||
name: 'calendar',
|
||||
capability: CALENDARS_CAPABILITY,
|
||||
async run(ctx, jmap) {
|
||||
forgetUnlistedAccounts(ctx);
|
||||
if (!(await syncCalendars(ctx, jmap)) || ctx.stopped()) return;
|
||||
await oneAtATime(ctx.db, () => syncAndSend(ctx, jmap));
|
||||
pruneOld(ctx, jmap);
|
||||
},
|
||||
};
|
||||
|
||||
export interface FlushResult extends FlushTally {
|
||||
/** Writes still waiting: the network went away, or the server had a bad moment. */
|
||||
pending: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `calendar.flush`: catches up with the server, so a write that collides with
|
||||
* a change there is found, then sends every pending write. Doesn't throw for a
|
||||
* lost connection: what wasn't sent stays pending.
|
||||
*/
|
||||
export function flushCalendar(ctx: SyncContext): Promise<FlushResult> {
|
||||
return oneAtATime(ctx.db, async () => {
|
||||
forgetUnlistedAccounts(ctx);
|
||||
const accounts = ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM calendar_pending').map((row) => row.jmap);
|
||||
const total: FlushTally = { sent: 0, conflicts: 0 };
|
||||
for (const jmap of accounts) {
|
||||
try {
|
||||
const tally = await syncAndSend(ctx, jmap);
|
||||
total.sent += tally.sent;
|
||||
total.conflicts += tally.conflicts;
|
||||
} catch (error) {
|
||||
// No network, no credentials: no point in trying the other accounts.
|
||||
if (error instanceof TransportError && (error.kind === 'network' || error.kind === 'auth')) break;
|
||||
// Anything else is about this account, whose writes stay pending.
|
||||
if (!(error instanceof TransportError) && !(error instanceof JmapMethodError) && !isPassingError(error)) throw error;
|
||||
}
|
||||
}
|
||||
const pending = Number(ctx.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM calendar_pending')?.n ?? 0);
|
||||
return { ...total, pending };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Calendar writes made while the server can't be reached.
|
||||
*
|
||||
* An event has a chain of pending rows, sent in order. A later write merges into
|
||||
* the last row when both have the same scheduling flag: edit + edit is one patch,
|
||||
* create + edit is one create. Different flags stay apart, because they mean
|
||||
* different things to attendees. The change to one occurrence that must reach
|
||||
* those who answered is a quiet write and then one with messages, and a change
|
||||
* made with "don't notify" must not go out with a later answer that does.
|
||||
*
|
||||
* Answers read calendar_event.json, which is the server's copy with the chain
|
||||
* applied. server_json keeps the server's copy underneath, so sync can move it on
|
||||
* and reapply the chain, or notice that the two collide.
|
||||
*/
|
||||
|
||||
import { applyPatchObject } from '@/lib/jmap/patch-object';
|
||||
import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids';
|
||||
import { mergeOverrideEntry, stableJson } from '@/lib/recurrence-instances';
|
||||
import type { StoreContext } from '../area';
|
||||
import type { StorageAdapter } from '../storage';
|
||||
import { asOverridePointers, collides, holdsValue } from './patches';
|
||||
import { currentGen, deleteEventRow, eventRow, valueAtPointer, writeEvent, type CalendarObject, type EventRow } from './records';
|
||||
|
||||
export interface PendingRow {
|
||||
seq: number;
|
||||
jmap: string;
|
||||
record_id: string;
|
||||
kind: 'create' | 'update' | 'destroy';
|
||||
patch: string;
|
||||
base: string;
|
||||
uid: string | null;
|
||||
send_scheduling: number | null;
|
||||
attempted: number;
|
||||
sent: string | null;
|
||||
rev: number;
|
||||
queued_at: number;
|
||||
}
|
||||
|
||||
export type ConflictReason = 'refused' | 'changedOnServer' | 'destroyedOnServer';
|
||||
|
||||
export interface CalendarConflict {
|
||||
id: number;
|
||||
jmap: string;
|
||||
recordId: string;
|
||||
kind: PendingRow['kind'];
|
||||
reason: ConflictReason;
|
||||
errorType: string | null;
|
||||
description: string | null;
|
||||
title: string | null;
|
||||
/** The write that wasn't made: the whole event for a create, otherwise the patch. */
|
||||
local: Record<string, unknown>;
|
||||
/** The event as the server has it, when it still does. */
|
||||
server: Record<string, unknown> | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export const isStandIn = isStandInId;
|
||||
|
||||
/** An event's pending writes, in the order they go out. */
|
||||
export function pendingChain(db: StorageAdapter, jmap: string, id: string): PendingRow[] {
|
||||
return db.all<PendingRow>('SELECT * FROM calendar_pending WHERE jmap = ? AND record_id = ? ORDER BY seq', [jmap, id]);
|
||||
}
|
||||
|
||||
export function pendingBySeq(db: StorageAdapter, seq: number): PendingRow | undefined {
|
||||
return db.get<PendingRow>('SELECT * FROM calendar_pending WHERE seq = ?', [seq]);
|
||||
}
|
||||
|
||||
/** What a stand-in id became, once its create reached the server. */
|
||||
export function resolveId(db: StorageAdapter, jmap: string, id: string): string {
|
||||
if (!isStandIn(id)) return id;
|
||||
return db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE jmap = ? AND local_id = ?', [jmap, id])?.server_id ?? id;
|
||||
}
|
||||
|
||||
/** For the page's client: the real id, or null while it's still only on this device. */
|
||||
export function resolveStandIns(db: StorageAdapter, ids: readonly string[]): Record<string, string | null> {
|
||||
const out: Record<string, string | null> = {};
|
||||
for (const id of ids) {
|
||||
out[id] = db.get<{ server_id: string }>('SELECT server_id FROM calendar_id_map WHERE local_id = ?', [id])?.server_id ?? null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function flagOf(sendScheduling: boolean | undefined): number | null {
|
||||
return sendScheduling === undefined ? null : Number(sendScheduling);
|
||||
}
|
||||
|
||||
function parse(json: string): Record<string, unknown> {
|
||||
return JSON.parse(json) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function serverCopy(row: EventRow): CalendarObject {
|
||||
return JSON.parse(row.server_json ?? row.json) as CalendarObject;
|
||||
}
|
||||
|
||||
function changed(ctx: StoreContext, ...collections: string[]): void {
|
||||
ctx.emit({ type: 'changed', account: ctx.account, collections });
|
||||
}
|
||||
|
||||
/** Writes what answers show: `server` with the event's chain on top. */
|
||||
function rewriteShown(db: StorageAdapter, jmap: string, id: string, server: CalendarObject | null, gen: number): void {
|
||||
const chain = pendingChain(db, jmap, id);
|
||||
if (chain.length === 0) {
|
||||
if (server) writeEvent(db, jmap, { shown: server, gen });
|
||||
else deleteEventRow(db, jmap, id);
|
||||
return;
|
||||
}
|
||||
const row = eventRow(db, jmap, id);
|
||||
let shown: CalendarObject | null = server ?? (row ? JSON.parse(row.json) as CalendarObject : null);
|
||||
let hidden = false;
|
||||
for (const pending of chain) {
|
||||
if (pending.kind === 'create') shown = { ...parse(pending.patch), id, isOrigin: true };
|
||||
else if (pending.kind === 'update') shown = applyPatchObject(shown as CalendarObject, parse(pending.patch));
|
||||
else hidden = true;
|
||||
}
|
||||
if (shown) writeEvent(db, jmap, { shown, server, hidden, gen });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a local event under a stand-in id. The uid is what finds it again. One
|
||||
* the device chose can't be on the server yet. One from the caller might: the
|
||||
* client's request may have got through before its answer was lost, or an import
|
||||
* names an event that exists. That counts as attempted, so a flush looks first.
|
||||
*/
|
||||
export function queueCreate(ctx: StoreContext, jmap: string, event: Record<string, unknown>, sendScheduling: boolean | undefined): { id: string; uid: string } {
|
||||
const id = newStandInId();
|
||||
const given = typeof event.uid === 'string' && event.uid ? event.uid : null;
|
||||
const uid = given ?? crypto.randomUUID();
|
||||
const payload = { '@type': 'Event', ...event, uid };
|
||||
ctx.db.run(
|
||||
`INSERT INTO calendar_pending (jmap, record_id, kind, patch, uid, send_scheduling, attempted, queued_at) VALUES (?, ?, 'create', ?, ?, ?, ?, ?)`,
|
||||
[jmap, id, JSON.stringify(payload), uid, flagOf(sendScheduling), given ? 1 : 0, ctx.now()],
|
||||
);
|
||||
rewriteShown(ctx.db, jmap, id, null, currentGen(ctx.db, jmap));
|
||||
return { id, uid };
|
||||
}
|
||||
|
||||
function baseFor(server: Record<string, unknown>, patch: Record<string, unknown>, earlier: Record<string, unknown>): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(patch)) base[key] = key in earlier ? earlier[key] : valueAtPointer(server, key) ?? null;
|
||||
return base;
|
||||
}
|
||||
|
||||
/** False when the store has no such event. */
|
||||
export function queueUpdate(ctx: StoreContext, jmap: string, id: string, rawPatch: Record<string, unknown>, sendScheduling: boolean | undefined): boolean {
|
||||
const { db } = ctx;
|
||||
const row = eventRow(db, jmap, id);
|
||||
if (!row || row.hidden) return false;
|
||||
const chain = pendingChain(db, jmap, id);
|
||||
const last = chain.at(-1);
|
||||
const flag = flagOf(sendScheduling);
|
||||
const local = chain[0]?.kind === 'create';
|
||||
const server = local ? null : serverCopy(row);
|
||||
if (last?.kind === 'create' && last.send_scheduling === flag) {
|
||||
db.run(
|
||||
'UPDATE calendar_pending SET patch = ?, rev = rev + 1 WHERE seq = ?',
|
||||
[JSON.stringify(applyPatchObject(parse(last.patch), rawPatch)), last.seq],
|
||||
);
|
||||
rewriteShown(db, jmap, id, null, row.gen);
|
||||
return true;
|
||||
}
|
||||
const patch = asOverridePointers(rawPatch, JSON.parse(row.json) as Record<string, unknown>);
|
||||
if (Object.keys(patch).length === 0) return true;
|
||||
if (last?.kind === 'update' && last.send_scheduling === flag) {
|
||||
const merged = mergeOverrideEntry(parse(last.patch), patch);
|
||||
const base = baseFor(server ?? {}, merged, parse(last.base));
|
||||
db.run('UPDATE calendar_pending SET patch = ?, base = ?, rev = rev + 1 WHERE seq = ?', [JSON.stringify(merged), JSON.stringify(base), last.seq]);
|
||||
} else {
|
||||
db.run(
|
||||
`INSERT INTO calendar_pending (jmap, record_id, kind, patch, base, send_scheduling, queued_at) VALUES (?, ?, 'update', ?, ?, ?, ?)`,
|
||||
[jmap, id, JSON.stringify(patch), JSON.stringify(baseFor(server ?? {}, patch, {})), flag, ctx.now()],
|
||||
);
|
||||
}
|
||||
rewriteShown(db, jmap, id, server, row.gen);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** False when the store has no such event. */
|
||||
export function queueDestroy(ctx: StoreContext, jmap: string, id: string, sendScheduling: boolean | undefined): boolean {
|
||||
const { db } = ctx;
|
||||
const row = eventRow(db, jmap, id);
|
||||
if (!row || row.hidden) return false;
|
||||
const chain = pendingChain(db, jmap, id);
|
||||
const flag = flagOf(sendScheduling);
|
||||
const head = chain[0];
|
||||
if (head?.kind === 'create') {
|
||||
db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ? AND seq <> ?', [jmap, id, head.seq]);
|
||||
if (!head.attempted) {
|
||||
// It never left the device.
|
||||
db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]);
|
||||
deleteEventRow(db, jmap, id);
|
||||
return true;
|
||||
}
|
||||
// It may be on the server. Its uid stays, so the delete can follow it.
|
||||
db.run(
|
||||
`UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', send_scheduling = ?, rev = rev + 1 WHERE seq = ?`,
|
||||
[flag, head.seq],
|
||||
);
|
||||
rewriteShown(db, jmap, id, null, row.gen);
|
||||
return true;
|
||||
}
|
||||
const last = chain.at(-1);
|
||||
if (last?.kind === 'update' && last.send_scheduling === flag) {
|
||||
db.run(`UPDATE calendar_pending SET kind = 'destroy', patch = '{}', base = '{}', sent = NULL, rev = rev + 1 WHERE seq = ?`, [last.seq]);
|
||||
} else {
|
||||
db.run(
|
||||
`INSERT INTO calendar_pending (jmap, record_id, kind, patch, send_scheduling, queued_at) VALUES (?, ?, 'destroy', '{}', ?, ?)`,
|
||||
[jmap, id, flag, ctx.now()],
|
||||
);
|
||||
}
|
||||
rewriteShown(db, jmap, id, serverCopy(row), row.gen);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a write on the conflict list with every later write of that event, which
|
||||
* were built on it. The event goes back to the server's copy plus what's left. A
|
||||
* refused create leaves nothing behind.
|
||||
*/
|
||||
export function recordConflict(
|
||||
ctx: StoreContext,
|
||||
pending: PendingRow,
|
||||
reason: ConflictReason,
|
||||
error?: { type?: string; description?: string },
|
||||
): void {
|
||||
const { db } = ctx;
|
||||
const { jmap, record_id: id } = pending;
|
||||
const row = eventRow(db, jmap, id);
|
||||
const gone = reason === 'destroyedOnServer';
|
||||
const local = pending.kind === 'create' || (pending.kind === 'destroy' && pending.uid !== null);
|
||||
const server = row && !local && !gone ? serverCopy(row) : null;
|
||||
const title = row ? (parse(row.json).title as string | undefined) ?? null : null;
|
||||
const chain = pendingChain(db, jmap, id).filter((other) => gone || other.seq >= pending.seq);
|
||||
for (const other of chain) {
|
||||
db.run(
|
||||
`INSERT INTO calendar_conflict (jmap, record_id, kind, reason, error_type, description, title, local, server, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[jmap, id, other.kind, reason, error?.type ?? null, error?.description ?? null, title, other.patch, server ? JSON.stringify(server) : null, ctx.now()],
|
||||
);
|
||||
db.run('DELETE FROM calendar_pending WHERE seq = ?', [other.seq]);
|
||||
}
|
||||
if (row) {
|
||||
if (local || gone) deleteEventRow(db, jmap, id);
|
||||
else rewriteShown(db, jmap, id, serverCopy(row), row.gen);
|
||||
}
|
||||
changed(ctx, 'CalendarEvent', 'CalendarConflict');
|
||||
}
|
||||
|
||||
/** Before a row goes out: what it says may be on the server from now on. */
|
||||
export function markSent(ctx: StoreContext, row: PendingRow, sent: Record<string, unknown>): void {
|
||||
ctx.db.transaction(() => ctx.db.run('UPDATE calendar_pending SET attempted = 1, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's event for a create found again, or just made: the stand-in becomes
|
||||
* it, and later rows go on under the real id. What the server doesn't hold of the
|
||||
* create goes on as an update: what came in after the last send, or, when nothing
|
||||
* of ours was sent, whatever differs. True when writes are left.
|
||||
*/
|
||||
export function adoptStandIn(ctx: StoreContext, row: PendingRow, event: CalendarObject): boolean {
|
||||
const { db } = ctx;
|
||||
const { jmap, record_id: standIn } = row;
|
||||
const head = pendingBySeq(db, row.seq);
|
||||
const gen = currentGen(db, jmap);
|
||||
deleteEventRow(db, jmap, standIn);
|
||||
db.run('INSERT OR REPLACE INTO calendar_id_map (jmap, local_id, server_id) VALUES (?, ?, ?)', [jmap, standIn, event.id]);
|
||||
db.run('UPDATE calendar_pending SET record_id = ? WHERE jmap = ? AND record_id = ?', [event.id, jmap, standIn]);
|
||||
if (head?.kind === 'destroy') {
|
||||
db.run('UPDATE calendar_pending SET uid = NULL, attempted = 0, sent = NULL WHERE seq = ?', [head.seq]);
|
||||
} else if (head) {
|
||||
const payload = parse(head.patch);
|
||||
const sent = head.sent ? parse(head.sent) : null;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (sent) {
|
||||
for (const key of new Set([...Object.keys(sent), ...Object.keys(payload)])) {
|
||||
if (stableJson(sent[key]) !== stableJson(payload[key])) patch[key] = payload[key] ?? null;
|
||||
}
|
||||
} else {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key !== '@type' && key !== 'uid' && !holdsValue(event, key, value)) patch[key] = value;
|
||||
}
|
||||
}
|
||||
const pointers = asOverridePointers(patch, event);
|
||||
if (Object.keys(pointers).length === 0) {
|
||||
db.run('DELETE FROM calendar_pending WHERE seq = ?', [head.seq]);
|
||||
} else {
|
||||
db.run(
|
||||
`UPDATE calendar_pending SET kind = 'update', patch = ?, base = ?, uid = NULL, attempted = 0, sent = NULL WHERE seq = ?`,
|
||||
[JSON.stringify(pointers), JSON.stringify(baseFor(event, pointers, {})), head.seq],
|
||||
);
|
||||
}
|
||||
}
|
||||
const rest = pendingChain(db, jmap, event.id);
|
||||
for (const later of rest) {
|
||||
if (later.kind === 'update' && later.seq !== head?.seq) {
|
||||
db.run('UPDATE calendar_pending SET base = ? WHERE seq = ?', [JSON.stringify(baseFor(event, parse(later.patch), {})), later.seq]);
|
||||
}
|
||||
}
|
||||
rewriteShown(db, jmap, event.id, event, gen);
|
||||
changed(ctx, 'CalendarEvent');
|
||||
return rest.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A write went through: `sent` is the server's now. True when this row has to go
|
||||
* again, because a newer write merged into it during the send. Later rows read
|
||||
* their overlap with `sent` as ours, not as someone's change.
|
||||
*/
|
||||
export function finishWrite(ctx: StoreContext, row: PendingRow, sent: Record<string, unknown>): boolean {
|
||||
const { db } = ctx;
|
||||
const current = pendingBySeq(db, row.seq);
|
||||
const event = eventRow(db, row.jmap, row.record_id);
|
||||
if (!current || !event) return false;
|
||||
const server = applyPatchObject(serverCopy(event), sent);
|
||||
const stays = current.rev !== row.rev;
|
||||
if (stays) {
|
||||
db.run('UPDATE calendar_pending SET attempted = 0, sent = ? WHERE seq = ?', [JSON.stringify(sent), row.seq]);
|
||||
} else {
|
||||
db.run('DELETE FROM calendar_pending WHERE seq = ?', [row.seq]);
|
||||
}
|
||||
for (const later of pendingChain(db, row.jmap, row.record_id)) {
|
||||
if (later.kind !== 'update') continue;
|
||||
const earlierSent = later.sent ? parse(later.sent) : {};
|
||||
db.run(
|
||||
'UPDATE calendar_pending SET base = ?, sent = ? WHERE seq = ?',
|
||||
[JSON.stringify(baseFor(server, parse(later.patch), {})), JSON.stringify({ ...earlierSent, ...sent }), later.seq],
|
||||
);
|
||||
}
|
||||
rewriteShown(db, row.jmap, row.record_id, server, event.gen);
|
||||
changed(ctx, 'CalendarEvent');
|
||||
return stays;
|
||||
}
|
||||
|
||||
/** The server's copy of an event arriving during sync. Any pending writes go back on top, or to the conflict list. */
|
||||
export function applyServerEvent(ctx: StoreContext, jmap: string, event: CalendarObject, gen: number): void {
|
||||
const { db } = ctx;
|
||||
if (typeof event.uid === 'string') {
|
||||
// A create of ours that no answer told us about.
|
||||
const waiting = db.get<PendingRow>('SELECT * FROM calendar_pending WHERE jmap = ? AND uid = ? ORDER BY seq LIMIT 1', [jmap, event.uid]);
|
||||
if (waiting && waiting.record_id !== event.id) {
|
||||
adoptStandIn(ctx, waiting, event);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const before = eventRow(db, jmap, event.id);
|
||||
const previous = before ? stableJson(serverCopy(before)) : null;
|
||||
const chain = pendingChain(db, jmap, event.id);
|
||||
if (chain.length === 0) {
|
||||
writeEvent(db, jmap, { shown: event, gen });
|
||||
return;
|
||||
}
|
||||
writeEvent(db, jmap, { shown: event, server: event, gen });
|
||||
for (const pending of chain) {
|
||||
if (pending.kind === 'destroy') {
|
||||
// Deleting what someone just changed is the one write that can't be taken
|
||||
// back, so the change wins and the user decides.
|
||||
if (previous !== null && previous !== stableJson(event)) recordConflict(ctx, pending, 'changedOnServer');
|
||||
break;
|
||||
}
|
||||
if (collides({ patch: parse(pending.patch), base: parse(pending.base), sent: pending.sent ? parse(pending.sent) : null }, event)) {
|
||||
recordConflict(ctx, pending, 'changedOnServer');
|
||||
break;
|
||||
}
|
||||
}
|
||||
rewriteShown(db, jmap, event.id, event, gen);
|
||||
}
|
||||
|
||||
/** The server no longer has this event. A pending delete is done; a pending edit has nothing left to change. */
|
||||
export function applyServerDestroy(ctx: StoreContext, jmap: string, id: string): void {
|
||||
const { db } = ctx;
|
||||
const chain = pendingChain(db, jmap, id);
|
||||
const edit = chain.find((row) => row.kind === 'update');
|
||||
if (edit) {
|
||||
recordConflict(ctx, edit, 'destroyedOnServer');
|
||||
return;
|
||||
}
|
||||
db.run('DELETE FROM calendar_pending WHERE jmap = ? AND record_id = ?', [jmap, id]);
|
||||
deleteEventRow(db, jmap, id);
|
||||
}
|
||||
|
||||
export function listConflicts(db: StorageAdapter): CalendarConflict[] {
|
||||
const rows = db.all<{
|
||||
id: number; jmap: string; record_id: string; kind: PendingRow['kind']; reason: ConflictReason; error_type: string | null;
|
||||
description: string | null; title: string | null; local: string; server: string | null; created_at: number;
|
||||
}>('SELECT * FROM calendar_conflict ORDER BY id');
|
||||
return rows.map((row) => ({
|
||||
id: Number(row.id),
|
||||
jmap: row.jmap,
|
||||
recordId: row.record_id,
|
||||
kind: row.kind,
|
||||
reason: row.reason,
|
||||
errorType: row.error_type,
|
||||
description: row.description,
|
||||
title: row.title,
|
||||
local: JSON.parse(row.local) as Record<string, unknown>,
|
||||
server: row.server ? JSON.parse(row.server) as Record<string, unknown> : null,
|
||||
createdAt: Number(row.created_at),
|
||||
}));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user