Files
webmail/lib/offline/responder.ts
T
Linus Rath 4b60fcc1e6 fix: make the offline outbox never send twice, never lose mail silently and never skip a plugin #523
The review of offline mode found ways to send a message twice, to lose
unsent mail without a word, and to send mail without its signing,
encryption or check plugin. This fixes them and the smaller findings:

- each outbox message is taken for sending in one database transaction
  under a Web Lock per account; only a failure before the submission
  request (SendNotStartedError from the client) puts it back, any other
  one marks it "may have gone out"; read-backs after the submission no
  longer fail a send; a send of a closed tab turns uncertain after a minute
- every sign-out the user didn't confirm names the deleted messages on
  the sign-in page, or in a toast when another account stays signed in
- nothing is queued while an enabled plugin isn't running (after a start
  without the network none can); the composer says why
- the outbox waits for a fresh policy and holds messages the rules have
  since made stricter (a required label, a required send check)
- another tab switching offline mode off sticks: only switching it on
  may create the database
- only attachments of kept messages are kept, filed with their message,
  deleted with it and under the cap between syncs; file names and types
  are sealed; folders that went away leave the scope, and a folder that
  couldn't be asked keeps its mail without holding the others back
- a group scope that forbids offline mode waits until the account's
  groups are known; one server down no longer holds another's mail
- an offline start only when the server is really unreachable, and the
  real sign-in follows by itself once it answers
- the service worker files pages and chunks under the build that served
  them, and the shell list of another build isn't kept under this one
- a raw send counts as the composer's only from the plugin running its
  takeover, for that identity
- errors and refusals are in all languages; the banner tells "offline"
  from "server can't be reached"; outbox buttons name their message;
  changes and messages go in the order they were made
2026-11-01 14:19:54 +01:00

377 lines
16 KiB
TypeScript

/**
* Answers JMAP requests from the mail kept on this device while the server
* can't be reached (#523), so the app works as usual: the folder list, the
* message list of a cached folder, opening a cached message, threads.
*
* Changes are queued (queue.ts) and applied to the cache at once: keywords
* (read, flagged, any tag) and folders (move, delete to Trash). Deleting for
* good, emptying a folder and everything else that would change the server
* is refused, as are calls the cache can't answer (search, changes since a
* state): the whole request then fails as it would without offline mode.
*
* Answers carry no state strings: a made-up one would end up in an
* Email/changes once the server is back, which refuses it.
*/
import { BLOBS, emailIndex, getBody, getMisc, patchEmails, putMisc, type CachedEmail } from './cache';
import type { OfflineMethodCall, OfflineResponse } from '@/lib/jmap/offline-hooks';
export type MethodResult = Record<string, unknown>;
export interface ResponderContext {
account: string;
/** The client's own JMAP account (calls without accountId use it). */
defaultJmap: string;
/** Queue changes to messages; resolves once they are kept. */
queuePatch: (jmap: string, update: Record<string, Record<string, unknown>>) => Promise<void>;
/** A change offline mode refuses (delete for good); the caller tells the user. */
onRefused: (what: 'destroy' | 'other') => void;
/**
* The folders whose mail is kept here, per JMAP account. A list of any
* other folder isn't answered (it would look empty while its count says
* otherwise). Without it, every folder is answered.
*/
keptFolders?: Record<string, readonly string[]>;
}
/** The folders a filter lists, as far as it names them. */
function filterFolders(filter: Filter | undefined | null, out: string[] = []): string[] {
if (!filter) return out;
if (typeof filter.inMailbox === 'string') out.push(filter.inMailbox);
if (Array.isArray(filter.conditions)) for (const c of filter.conditions as Filter[]) filterFolders(c, out);
return out;
}
class Unanswerable extends Error {}
/** RFC 8620 §3.7 result references, with the `*` array step. */
function resolvePointer(value: unknown, path: string): unknown {
const steps = path.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
const walk = (node: unknown, i: number): unknown => {
if (i === steps.length) return node;
const step = steps[i];
if (step === '*') {
if (!Array.isArray(node)) throw new Unanswerable();
return node.flatMap((item) => {
const out = walk(item, i + 1);
return Array.isArray(out) ? out : [out];
});
}
if (node === null || typeof node !== 'object') throw new Unanswerable();
return walk((node as Record<string, unknown>)[step], i + 1);
};
return walk(value, 0);
}
function resolveArgs(args: Record<string, unknown>, done: Array<[string, MethodResult, string]>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (!key.startsWith('#')) {
out[key] = value;
continue;
}
const ref = value as { resultOf?: string; name?: string; path?: string };
const source = done.find(([name, , id]) => id === ref.resultOf && name === ref.name);
if (!source || typeof ref.path !== 'string') throw new Unanswerable();
out[key.slice(1)] = resolvePointer(source[1], ref.path);
}
return out;
}
// ---- Email/query filters and sort ----
type Filter = Record<string, unknown>;
function matches(email: CachedEmail, filter: Filter | undefined | null): boolean {
if (!filter || Object.keys(filter).length === 0) return true;
if (typeof filter.operator === 'string') {
const conditions = Array.isArray(filter.conditions) ? (filter.conditions as Filter[]) : [];
if (filter.operator === 'AND') return conditions.every((c) => matches(email, c));
if (filter.operator === 'OR') return conditions.some((c) => matches(email, c));
if (filter.operator === 'NOT') return !conditions.some((c) => matches(email, c));
throw new Unanswerable();
}
for (const [key, value] of Object.entries(filter)) {
switch (key) {
case 'inMailbox':
if (!email.mailboxIds?.[value as string]) return false;
break;
case 'inMailboxOtherThan':
if (!Object.keys(email.mailboxIds ?? {}).some((id) => !(value as string[]).includes(id))) return false;
break;
case 'hasKeyword':
if (!email.keywords?.[value as string]) return false;
break;
case 'notKeyword':
if (email.keywords?.[value as string]) return false;
break;
case 'after':
if (Date.parse(String(email.receivedAt)) < Date.parse(String(value))) return false;
break;
case 'before':
if (Date.parse(String(email.receivedAt)) >= Date.parse(String(value))) return false;
break;
case 'hasAttachment':
if (Boolean(email.hasAttachment) !== Boolean(value)) return false;
break;
default:
// Text search and the rest need the server.
throw new Unanswerable();
}
}
return true;
}
interface Comparator { property?: string; isAscending?: boolean; keyword?: string }
function firstAddress(value: unknown): string {
const first = Array.isArray(value) ? (value[0] as { name?: string; email?: string } | undefined) : undefined;
return (first?.name || first?.email || '').toLowerCase();
}
function compareBy(sort: Comparator[]): (a: CachedEmail, b: CachedEmail) => number {
const levels = sort.length > 0 ? sort : [{ property: 'receivedAt', isAscending: false }];
return (a, b) => {
for (const level of levels) {
let diff = 0;
switch (level.property) {
case 'receivedAt':
case 'sentAt':
diff = (Date.parse(String(a[level.property] ?? a.receivedAt)) || 0) - (Date.parse(String(b[level.property] ?? b.receivedAt)) || 0);
break;
case 'size':
diff = Number(a.size ?? 0) - Number(b.size ?? 0);
break;
case 'subject':
diff = String(a.subject ?? '').localeCompare(String(b.subject ?? ''));
break;
case 'from':
diff = firstAddress(a.from).localeCompare(firstAddress(b.from));
break;
case 'to':
diff = firstAddress(a.to).localeCompare(firstAddress(b.to));
break;
case 'hasKeyword':
diff = Number(Boolean(a.keywords?.[level.keyword ?? ''])) - Number(Boolean(b.keywords?.[level.keyword ?? '']));
break;
default:
diff = 0;
}
if (diff !== 0) return level.isAscending === false ? -diff : diff;
}
return 0;
};
}
async function emailsOf(ctx: ResponderContext, jmap: string): Promise<CachedEmail[]> {
const index = await emailIndex(ctx.account);
const prefix = `${ctx.account}\u0001${jmap}\u0001`;
const out: CachedEmail[] = [];
for (const [key, email] of index) if (key.startsWith(prefix)) out.push(email);
return out;
}
async function emailQuery(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
const jmap = (args.accountId as string) || ctx.defaultJmap;
if (args.anchor !== undefined) throw new Unanswerable();
if (ctx.keptFolders) {
const kept = ctx.keptFolders[jmap] ?? [];
if (filterFolders(args.filter as Filter).some((id) => !kept.includes(id))) throw new Unanswerable();
}
let list = (await emailsOf(ctx, jmap)).filter((email) => matches(email, args.filter as Filter));
list.sort(compareBy((args.sort as Comparator[]) ?? []));
if (args.collapseThreads) {
const seen = new Set<string>();
list = list.filter((email) => {
const thread = email.threadId ?? email.id;
if (seen.has(thread)) return false;
seen.add(thread);
return true;
});
}
const position = Math.max(0, Number(args.position ?? 0));
const limit = typeof args.limit === 'number' ? args.limit : list.length;
return {
accountId: jmap,
canCalculateChanges: false,
position,
ids: list.slice(position, position + limit).map((email) => email.id),
total: list.length,
};
}
function pick(email: Record<string, unknown>, properties: unknown): Record<string, unknown> {
if (!Array.isArray(properties)) {
const all = { ...email };
delete all[BLOBS];
return all;
}
const out: Record<string, unknown> = { id: email.id };
for (const prop of properties as string[]) if (prop in email) out[prop] = email[prop];
return out;
}
const NEEDS_BODY = new Set(['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments']);
async function emailGet(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
const jmap = (args.accountId as string) || ctx.defaultJmap;
const index = await emailIndex(ctx.account);
const ids = Array.isArray(args.ids) ? (args.ids as string[]) : null;
if (!ids) throw new Unanswerable();
const properties = args.properties as string[] | undefined;
const wantsBody = args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues
|| !properties || properties.some((p) => NEEDS_BODY.has(p));
const list: Record<string, unknown>[] = [];
const notFound: string[] = [];
for (const id of ids) {
const meta = index.get(`${ctx.account}\u0001${jmap}\u0001${id}`);
if (!meta) { notFound.push(id); continue; }
if (!wantsBody) { list.push(pick(meta, properties)); continue; }
const body = await getBody(ctx.account, jmap, id);
// Asked for the text and it isn't here: as if the message weren't
// there, rather than an empty message.
if (!body && (args.fetchTextBodyValues || args.fetchHTMLBodyValues || args.fetchAllBodyValues || properties?.includes('bodyValues'))) {
notFound.push(id);
continue;
}
list.push(pick({ ...(body ?? {}), ...meta }, properties));
}
return { accountId: jmap, list, notFound };
}
async function threadGet(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
const jmap = (args.accountId as string) || ctx.defaultJmap;
const ids = Array.isArray(args.ids) ? (args.ids as string[]) : [];
const emails = await emailsOf(ctx, jmap);
const list = [...new Set(ids)].map((id) => ({
id,
emailIds: emails
.filter((email) => email.threadId === id)
.sort((a, b) => (Date.parse(String(a.receivedAt)) || 0) - (Date.parse(String(b.receivedAt)) || 0))
.map((email) => email.id),
}));
return { accountId: jmap, list: list.filter((t) => t.emailIds.length > 0), notFound: list.filter((t) => t.emailIds.length === 0).map((t) => t.id) };
}
async function listGet(ctx: ResponderContext, args: Record<string, unknown>, name: 'mailboxes' | 'identities'): Promise<MethodResult> {
const jmap = (args.accountId as string) || ctx.defaultJmap;
const all = await getMisc<Record<string, Record<string, unknown>[]>>(ctx.account, name);
const list = all?.[jmap];
if (!list) throw new Unanswerable();
const ids = Array.isArray(args.ids) ? new Set(args.ids as string[]) : null;
const chosen = ids ? list.filter((item) => ids.has(item.id as string)) : list;
return {
accountId: jmap,
list: chosen.map((item) => pick(item, args.properties)),
notFound: ids ? [...ids].filter((id) => !list.some((item) => item.id === id)) : [],
};
}
/** Whether a PatchObject only changes what offline mode may queue. */
function queueablePatch(patch: Record<string, unknown>): boolean {
return Object.keys(patch).length > 0 && Object.keys(patch).every((path) =>
path === 'keywords' || path === 'mailboxIds' || path.startsWith('keywords/') || path.startsWith('mailboxIds/'));
}
async function emailSet(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
const jmap = (args.accountId as string) || ctx.defaultJmap;
// A new message (a draft saved while offline) needs the server; the
// composer says the draft wasn't saved, so no notice here.
if (args.create && Object.keys(args.create as object).length > 0) throw new Unanswerable();
if (Array.isArray(args.destroy) && args.destroy.length > 0) { ctx.onRefused('destroy'); throw new Unanswerable(); }
const update = (args.update ?? {}) as Record<string, Record<string, unknown>>;
if (!Object.values(update).every(queueablePatch)) { ctx.onRefused('other'); throw new Unanswerable(); }
// A message moved out of every folder would be gone: that is a delete.
const index = await emailIndex(ctx.account);
for (const [id, patch] of Object.entries(update)) {
if ('mailboxIds' in patch && Object.keys((patch.mailboxIds as object) ?? {}).length === 0) { ctx.onRefused('destroy'); throw new Unanswerable(); }
const current = index.get(`${ctx.account}\u0001${jmap}\u0001${id}`);
if (current && Object.keys(patch).some((p) => p.startsWith('mailboxIds/'))) {
const after = new Set(Object.keys(current.mailboxIds ?? {}));
for (const [path, value] of Object.entries(patch)) {
if (!path.startsWith('mailboxIds/')) continue;
const box = path.slice('mailboxIds/'.length);
if (value) after.add(box); else after.delete(box);
}
if (after.size === 0) { ctx.onRefused('destroy'); throw new Unanswerable(); }
}
}
await ctx.queuePatch(jmap, update);
const before = new Map(Object.keys(update).map((id) => [id, index.get(`${ctx.account}\u0001${jmap}\u0001${id}`)]));
await patchEmails(ctx.account, jmap, update);
await adjustFolderCounts(ctx, jmap, before, index);
return {
accountId: jmap,
updated: Object.fromEntries(Object.keys(update).map((id) => [id, null])),
};
}
/** The folder counts kept with the folder list follow a change made offline (read state, moves). */
async function adjustFolderCounts(
ctx: ResponderContext,
jmap: string,
before: Map<string, CachedEmail | undefined>,
index: Map<string, CachedEmail>,
): Promise<void> {
const delta = new Map<string, { total: number; unread: number }>();
const bump = (box: string, total: number, unread: number) => {
const d = delta.get(box) ?? { total: 0, unread: 0 };
d.total += total;
d.unread += unread;
delta.set(box, d);
};
for (const [id, old] of before) {
const now = index.get(`${ctx.account}\u0001${jmap}\u0001${id}`);
if (!old || !now) continue;
const wasUnread = !old.keywords?.$seen ? 1 : 0;
const isUnread = !now.keywords?.$seen ? 1 : 0;
const oldBoxes = new Set(Object.keys(old.mailboxIds ?? {}));
const newBoxes = new Set(Object.keys(now.mailboxIds ?? {}));
for (const box of oldBoxes) if (!newBoxes.has(box)) bump(box, -1, -wasUnread);
for (const box of newBoxes) {
if (!oldBoxes.has(box)) bump(box, 1, isUnread);
else if (wasUnread !== isUnread) bump(box, 0, isUnread - wasUnread);
}
}
if (delta.size === 0) return;
const all = await getMisc<Record<string, Array<Record<string, unknown>>>>(ctx.account, 'mailboxes');
const list = all?.[jmap];
if (!all || !list) return;
all[jmap] = list.map((box) => {
const d = delta.get(box.id as string);
if (!d) return box;
const count = (key: string, by: number) => (typeof box[key] === 'number' ? Math.max(0, (box[key] as number) + by) : box[key]);
// Messages only: how the conversations change can't be told from here.
return { ...box, totalEmails: count('totalEmails', d.total), unreadEmails: count('unreadEmails', d.unread) };
});
await putMisc(ctx.account, 'mailboxes', all);
}
/**
* The answer to a whole request from this device, or null when any call in
* it needs the server.
*/
export async function answerOffline(ctx: ResponderContext, methodCalls: OfflineMethodCall[]): Promise<OfflineResponse | null> {
const done: Array<[string, MethodResult, string]> = [];
try {
for (const [name, rawArgs, callId] of methodCalls) {
const args = resolveArgs(rawArgs, done);
let result: MethodResult;
switch (name) {
case 'Email/query': result = await emailQuery(ctx, args); break;
case 'Email/get': result = await emailGet(ctx, args); break;
case 'Thread/get': result = await threadGet(ctx, args); break;
case 'Mailbox/get': result = await listGet(ctx, args, 'mailboxes'); break;
case 'Identity/get': result = await listGet(ctx, args, 'identities'); break;
case 'Email/set': result = await emailSet(ctx, args); break;
default: throw new Unanswerable();
}
done.push([name, result, callId]);
}
} catch (error) {
if (error instanceof Unanswerable) return null;
throw error;
}
return { methodResponses: done };
}