Files
webmail/lib/store/engine/responder.ts
T
Linus Rath 5c5d982f9d feat: save drafts and keep attachments while offline
A draft saved offline now stays on the device under a stand-in id and
goes to the server by itself when it's back. Until now every save was
refused and the composer showed "not saved".

- `lib/store/drafts`: one pending create per draft, however often it's
  saved. A draft from the server edited offline is replaced by create,
  then destroy, so a failure leaves a copy. Each create has a fixed
  Message-ID, looked up in Drafts after a lost response. A refused
  draft stays on the device with its error and can be opened. Changed
  on another device: both are kept and the user is told.
- `JMAPClient`: stand-in ids in Email/get and Email/set resolve like
  calendar's, also when answering from the device. A file attached
  offline stays readable, and is uploaded before a save that names it.
- Held files and kept mail files are stored in 1 MB chunks. Orphans are
  dropped at the end of a flush, counting what drafts need.
- `lib/store/mail/attachments.ts`: attachments, inline images and raw
  messages per scope, newest first, after everything else in a pass,
  with eviction that spares pinned mail and never sees held files.
- `mail.rawBlob` and `mail.rawStatus` for an offline export.

Changed test assertions: `outbox.heldFile` and `mail.getBlob` return
`blob` instead of `data`, so a file isn't held whole in memory. The
attachment tests ignore raw downloads, and mail-sync runs with
attachments off, because raw messages add a commit per message.
2026-11-03 08:49:19 +01:00

90 lines
3.4 KiB
TypeScript

/**
* Answers a JMAP request from the local store: the store acting as a server
* toward the UI. Every call gets its own answer, in order, as a server would:
* one the store can't answer gets a per-call error and the rest still work.
*/
import { MethodError, type MethodContext, type MethodHandler, type MethodResult, type StoreArea } from '../area';
import type { JmapResponse, MethodCall, MethodResponse } from './transport';
class BadReference extends Error {}
/** RFC 8620 §3.7 result references, with the `*` array step. */
export 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 BadReference();
return node.flatMap((item) => {
const out = walk(item, i + 1);
return Array.isArray(out) ? out : [out];
});
}
if (node === null || typeof node !== 'object') throw new BadReference();
return walk((node as Record<string, unknown>)[step], i + 1);
};
return walk(value, 0);
}
function resolveArgs(args: Record<string, unknown>, done: readonly MethodResponse[]): 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 BadReference();
out[key.slice(1)] = resolvePointer(source[1], ref.path);
}
return out;
}
export function buildMethodTable(areas: readonly StoreArea[]): Map<string, MethodHandler> {
const table = new Map<string, MethodHandler>();
for (const area of areas) {
for (const [name, handler] of Object.entries(area.methods)) {
if (table.has(name)) throw new Error(`Two store areas answer ${name}`);
table.set(name, handler);
}
}
return table;
}
/** Answers are copied by structured clone on the way to the page, so callers can't mutate stored objects. */
export async function answerRequest(
ctx: Omit<MethodContext, 'createdIds' | 'calls'>,
methods: ReadonlyMap<string, MethodHandler>,
calls: readonly MethodCall[],
): Promise<JmapResponse | null> {
const done: MethodResponse[] = [];
const full: MethodContext = { ...ctx, createdIds: new Map(), calls };
let answered = 0;
for (const call of calls) {
const [name, rawArgs, callId] = call;
const handler = methods.get(name);
if (!handler) {
done.push(['error', { type: 'serverUnavailable', description: `${name} needs the server` }, callId]);
continue;
}
try {
const args = resolveArgs(rawArgs, done);
const result: MethodResult = await handler(full, args, call);
done.push([name, result, callId]);
answered += 1;
} catch (error) {
if (error instanceof BadReference) {
done.push(['error', { type: 'invalidResultReference' }, callId]);
} else if (error instanceof MethodError) {
done.push(['error', { type: error.type, description: error.description }, callId]);
} else {
throw error;
}
}
}
return answered === 0 ? null : { methodResponses: done };
}