Files
webmail/lib/offline/__tests__/client-send.test.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

96 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
// The outbox (#523) may only try a send again when nothing reached the
// server: the client says so with SendNotStartedError for failures before
// the submission request, and never for one during or after it.
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: { j1: { name: 'alice', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: [] } } } } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'j1', 'urn:ietf:params:jmap:submission': 'j1' },
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600 } },
username: 'alice@example.org',
};
type Mode = { mailboxFails?: boolean; submissionFails?: boolean; readBackFails?: boolean };
let mode: Mode = {};
const submissions: unknown[] = [];
function answer(body: unknown) {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
beforeEach(() => {
mode = {};
submissions.length = 0;
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (String(url).endsWith('/.well-known/jmap')) return answer(SESSION);
const request = JSON.parse(String(init?.body ?? '{}')) as { methodCalls: Array<[string, Record<string, unknown>, string]> };
const names = request.methodCalls.map(([name]) => name);
if (names.includes('EmailSubmission/set')) {
submissions.push(request);
if (mode.submissionFails) throw new TypeError('Failed to fetch');
const create = request.methodCalls.find(([name]) => name === 'Email/set')?.[1].create as Record<string, unknown>;
const key = Object.keys(create)[0];
return answer({ methodResponses: [
['Email/set', { accountId: 'j1', created: { [key]: { id: 'e1' } } }, '0'],
['EmailSubmission/set', { accountId: 'j1', created: { 1: { id: 'sub1' } } }, '1'],
] });
}
if (names[0] === 'Mailbox/get') {
if (mode.mailboxFails) throw new TypeError('Failed to fetch');
return answer({ methodResponses: [['Mailbox/get', { accountId: 'j1', list: [{ id: 's', role: 'sent' }, { id: 'd', role: 'drafts' }] }, '0']] });
}
if (names[0] === 'Identity/get') {
return answer({ methodResponses: [['Identity/get', { accountId: 'j1', list: [{ id: 'i1', email: 'alice@example.org', name: 'Alice' }] }, '0']] });
}
if (names[0] === 'EmailSubmission/get') {
if (mode.readBackFails) throw new TypeError('Failed to fetch');
return answer({ methodResponses: [['EmailSubmission/get', { accountId: 'j1', list: [{ id: 'sub1', sendAt: '2030-01-01T00:00:00Z' }] }, '0']] });
}
return answer({ methodResponses: names.map((name, i) => [name, { accountId: 'j1' }, String(i)]) });
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
async function client(): Promise<JMAPClient> {
const c = new JMAPClient(SERVER, 'alice@example.org', 'pw');
await c.connect();
return c;
}
const send = (c: JMAPClient, delayedUntil?: string) => c.sendEmail(['bob@example.org'], 'Hi', 'Body', undefined, undefined, 'i1', 'alice@example.org', undefined, 'Alice', undefined, undefined, undefined, undefined, delayedUntil);
describe('telling a send that never started from one that may have gone', () => {
it('a dead network before the submission: SendNotStartedError, nothing submitted', async () => {
const c = await client();
mode.mailboxFails = true;
await expect(send(c)).rejects.toMatchObject({ name: 'SendNotStartedError' });
expect(submissions).toHaveLength(0);
});
it('a dead network during the submission: not "not started"', async () => {
const c = await client();
mode.submissionFails = true;
const error = await send(c).catch((e: unknown) => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error).name).not.toBe('SendNotStartedError');
expect(submissions).toHaveLength(1);
});
it('a held send whose hold can’t be read back afterwards still counts as sent', async () => {
const c = await client();
mode.readBackFails = true;
const result = await send(c, new Date(Date.now() + 120_000).toISOString());
expect(result.scheduled).toBe(true);
expect(result.emailSubmissionId).toBe('sub1');
});
});