feat: move offline mode onto the encrypted local store

The IndexedDB cache, its crypto, the responder and the page-side sync
go. lib/offline keeps its structure, policy, wipes, sign-out gates and
notices; answers, the outbox and sync now live in lib/store, in a worker
behind lib/store/client/api.ts.

What users get besides that:
- every folder is kept, not just the Inbox and opened ones
- delete for good and empty trash work offline
- a change the server refuses, or that meets a message changed on
  another device, is shown instead of dropped
- a send whose response got lost is looked up by a Message-ID fixed when
  it was queued, then finished, submitted or sent again
- with the store on, a start paints from the local copy, then connects

An account the app drops by itself no longer wipes its data: only a
sign-out or an explicit removal does. The outbox of the old database
moves over once, in lib/offline/migrate-legacy.ts, and the old database
goes only after the store committed. `isNetworkFailure` now counts
timeouts and proxy 5xx. Sends and outbox requests are never answered
from the device.

Tests moved with the code: cache.test.ts and responder.test.ts are
replaced by lib/store/__tests__/, and review-fixes.test.ts, outbox.test.ts
and client-fallback.test.ts now run the real engine on node:sqlite.
Changed assertions: an uncertain send is looked up rather than left alone
forever, destroy and empty trash are queued rather than refused, and #9
(opened folders) became "a folder the server deleted goes", since all
folders are kept.
This commit is contained in:
Linus Rath
2026-11-03 05:54:39 +01:00
parent 7aa12d19b5
commit 687e7cc012
74 changed files with 2789 additions and 2306 deletions
@@ -14,6 +14,7 @@ import { useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
function setNoOffline(noOffline: boolean) {
usePolicyStore.setState({
@@ -24,6 +25,8 @@ function setNoOffline(noOffline: boolean) {
}
beforeEach(() => {
// jsdom has no OPFS: the store's page side says it's there, as a browser would.
setStoreBackend({ supported: () => true } as unknown as StoreBackend);
offline.enableOffline.mockClear();
offline.disableOffline.mockClear();
useAuthStore.setState({ activeAccountId: 'acc' });
+14 -1
View File
@@ -24,9 +24,11 @@ export function OfflineBanner() {
const browserOffline = useOfflineStore((s) => s.browserOffline || s.startedOffline);
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
const waiting = useOfflineStore((s) => s.outbox.length);
const conflicts = useOfflineStore((s) => s.conflicts.length);
const tConflicts = useTranslations('offlineConflicts');
const [open, setOpen] = useState(false);
const offline = browserOffline || serverDown;
if (IS_LITE || !active || (!offline && waiting === 0 && !open)) return null;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && !open)) return null;
return (
<>
<div
@@ -50,6 +52,17 @@ export function OfflineBanner() {
{t('banner.outbox', { count: waiting })}
</button>
)}
{conflicts > 0 && (
<button
type="button"
data-testid="offline-banner-conflicts"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="min-h-11 rounded-md px-1 font-medium underline underline-offset-2 hover:bg-foreground/5 sm:min-h-0"
>
{tConflicts('banner', { count: conflicts })}
</button>
)}
</div>
<Dialog isOpen={open} onClose={() => setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog">
<OutboxList heading={false} />
+6
View File
@@ -20,6 +20,9 @@ export function OfflineHost() {
const t = useTranslations('offline');
const tRef = useRef(t);
tRef.current = t;
const tConflicts = useTranslations('offlineConflicts');
const tConflictsRef = useRef(tConflicts);
tConflictsRef.current = tConflicts;
const { dialogProps, confirm } = useConfirmDialog();
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
@@ -45,6 +48,9 @@ export function OfflineHost() {
case 'lost':
toast.warning(tr('toast.lost', { subjects: notice.subjects.map((s) => s || tr('outbox.no_subject')).join(', ') }), { duration: 0 });
break;
case 'conflict':
toast.warning(tConflictsRef.current('toast', { count: notice.count }));
break;
case 'refused-delete':
toast.warning(tr('toast.refused_delete'));
break;
+38 -3
View File
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush';
import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
import { dismissConflict, type ConflictEntry } from '@/lib/offline/queue';
import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state';
import { useAccountStore } from '@/stores/account-store';
@@ -15,13 +16,16 @@ import { useAccountStore } from '@/stores/account-store';
* be sent again or deleted; one whose connection broke on the way is never
* sent again on its own (it may have gone out); one held because the
* organisation's rules changed can only be deleted (and written again).
* Changes that didn't go through ("changed on another device") are listed too.
*/
export function OutboxList({ heading = true }: { heading?: boolean }) {
const t = useTranslations('offline.outbox');
const tErrors = useTranslations('offline.errors');
const tConflicts = useTranslations('offlineConflicts');
const format = useFormatter();
const rows = useOfflineStore((s) => s.outbox);
const changes = useOfflineStore((s) => s.pendingChanges);
const conflicts = useOfflineStore((s) => s.conflicts);
const accounts = useAccountStore((s) => s.accounts);
const [deleting, setDeleting] = useState<OutboxRow | null>(null);
const several = new Set(rows.map((r) => r.account)).size > 1 || accounts.length > 1;
@@ -37,11 +41,22 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
};
const retry = async (row: OutboxRow) => {
await retryOutboxEntry(row.id);
await retryOutboxEntry(row.account, row.id);
await refreshQueueState();
if (!isOfflineNow()) scheduleFlushAll(0);
};
const conflictText = (conflict: ConflictEntry) => {
if (conflict.summary.reason === 'refused') return tConflicts('refused', { reason: conflict.summary.detail ?? '' });
if (conflict.summary.reason === 'gone') return tConflicts('gone');
return tConflicts(conflict.summary.op === 'destroy' ? 'changed_delete' : 'changed');
};
const dismiss = async (conflict: ConflictEntry) => {
await dismissConflict(conflict.account, conflict.id);
await refreshQueueState();
};
return (
<div className={heading ? 'pt-2' : undefined} data-testid="offline-outbox">
{heading && <h4 className="text-sm font-medium text-foreground">{t('title')}</h4>}
@@ -51,7 +66,7 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
) : (
<ul className="mt-3 divide-y divide-border rounded-md border border-border" aria-label={t('title')}>
{rows.map((row) => (
<li key={row.id} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-outbox-row" data-status={row.status}>
<li key={`${row.account}:${row.id}`} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-outbox-row" data-status={row.status}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{row.subject || t('no_subject')}</p>
<p className="truncate text-xs text-muted-foreground">
@@ -77,13 +92,33 @@ export function OutboxList({ heading = true }: { heading?: boolean }) {
{changes > 0 && (
<p className="mt-2 text-xs text-muted-foreground" data-testid="offline-pending-changes">{t('changes', { count: changes })}</p>
)}
{conflicts.length > 0 && (
<div className="mt-3" data-testid="offline-conflicts">
<p className="text-xs text-muted-foreground">{tConflicts('description')}</p>
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={tConflicts('title')}>
{conflicts.map((conflict) => (
<li key={`${conflict.account}:${conflict.id}`} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-conflict-row" data-reason={conflict.summary.reason}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{conflict.summary.subject || t('no_subject')}</p>
<p className="text-xs text-destructive [overflow-wrap:anywhere]">
{conflictText(conflict)} · {format.dateTime(conflict.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" aria-label={tConflicts('dismiss_label', { subject: conflict.summary.subject || t('no_subject') })} onClick={() => void dismiss(conflict)}>{tConflicts('dismiss')}</Button>
</div>
</li>
))}
</ul>
</div>
)}
<ConfirmDialog
isOpen={!!deleting}
onClose={() => setDeleting(null)}
onConfirm={() => {
const row = deleting;
setDeleting(null);
if (row) void deleteOutboxEntry(row.id).then(refreshQueueState);
if (row) void deleteOutboxEntry(row.account, row.id).then(refreshQueueState);
}}
title={t('delete_confirm_title')}
message={t('delete_confirm_message')}
+19 -14
View File
@@ -148,24 +148,29 @@ async function send(page: Page): Promise<void> {
await page.keyboard.press('Control+Enter');
}
/** Whether the offline database exists, without opening it (opening would create it). */
/** Whether anything of the local store is left, without opening it (opening would create it). */
async function offlineDatabaseExists(page: Page): Promise<boolean> {
return page.evaluate(async () => (await indexedDB.databases()).some((d) => d.name === 'bulwark-offline'));
return page.evaluate(async () => {
if ((await indexedDB.databases()).some((d) => d.name === 'bulwark-store' || d.name === 'bulwark-offline')) return true;
const root = await navigator.storage.getDirectory();
return root.getDirectoryHandle('.bulwark-store').then(() => true, () => false);
});
}
async function offlineDatabase(page: Page): Promise<{ exists: boolean; emails: number }> {
/** The accounts the local store holds a key for. Their mail is in encrypted files nothing here can read. */
async function offlineDatabase(page: Page): Promise<{ exists: boolean; accounts: number }> {
return page.evaluate(async () => {
const dbs = await indexedDB.databases();
if (!dbs.some((d) => d.name === 'bulwark-offline')) return { exists: false, emails: 0 };
return new Promise<{ exists: boolean; emails: number }>((resolve) => {
const req = indexedDB.open('bulwark-offline');
if (!dbs.some((d) => d.name === 'bulwark-store')) return { exists: false, accounts: 0 };
return new Promise<{ exists: boolean; accounts: number }>((resolve) => {
const req = indexedDB.open('bulwark-store');
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains('emails')) { db.close(); resolve({ exists: true, emails: 0 }); return; }
const count = db.transaction('emails').objectStore('emails').count();
count.onsuccess = () => { db.close(); resolve({ exists: true, emails: count.result }); };
if (!db.objectStoreNames.contains('accounts')) { db.close(); resolve({ exists: true, accounts: 0 }); return; }
const count = db.transaction('accounts').objectStore('accounts').count();
count.onsuccess = () => { db.close(); resolve({ exists: true, accounts: count.result }); };
};
req.onerror = () => resolve({ exists: false, emails: 0 });
req.onerror = () => resolve({ exists: false, accounts: 0 });
});
});
}
@@ -342,7 +347,7 @@ test('signing out asks first when unsent mail would be lost, then wipes the devi
await compose(page, ENT.bob, subject, 'Never sent');
await send(page);
await expect(page.getByTestId('offline-banner-outbox')).toHaveText('1 message in the outbox', { timeout: 30_000 });
expect((await offlineDatabase(page)).emails).toBeGreaterThan(0);
expect((await offlineDatabase(page)).accounts).toBeGreaterThan(0);
// Sign out from the account menu: asked first, and Cancel keeps everything.
await accountSwitcher(page).click();
@@ -417,7 +422,7 @@ test('forbidding offline mode wipes the device at the next policy fetch and hide
await enableOffline(carol);
await toMail(alice);
const loadedAt = Date.now();
expect((await offlineDatabase(alice)).emails).toBeGreaterThan(0);
expect((await offlineDatabase(alice)).accounts).toBeGreaterThan(0);
// ent-team (alice, not carol) may no longer keep mail on devices.
await setPolicy(browser, {
@@ -427,7 +432,7 @@ test('forbidding offline mode wipes the device at the next policy fetch and hide
await alice.waitForTimeout(Math.max(0, 62_000 - (Date.now() - loadedAt)));
await alice.evaluate(() => window.dispatchEvent(new Event('focus')));
await expect(alice.getByText('Your organisation turned off offline mode', { exact: false }).first()).toBeVisible({ timeout: 30_000 });
await expect.poll(async () => (await offlineDatabase(alice)).emails, { timeout: 20_000 }).toBe(0);
await expect.poll(async () => (await offlineDatabase(alice)).accounts, { timeout: 20_000 }).toBe(0);
await alice.goto('/settings/backup/offline');
await expect(alice.getByTestId('offline-forbidden')).toBeVisible({ timeout: 30_000 });
await expect(alice.getByTestId('offline-switch')).toHaveCount(0);
@@ -436,7 +441,7 @@ test('forbidding offline mode wipes the device at the next policy fetch and hide
// carol is outside the scope: her switch and her mail stay.
await carol.reload();
await expect(carol.getByTestId('offline-switch')).toHaveAttribute('aria-checked', 'true', { timeout: 30_000 });
expect((await offlineDatabase(carol)).emails).toBeGreaterThan(0);
expect((await offlineDatabase(carol)).accounts).toBeGreaterThan(0);
await alice.context().close();
await carol.context().close();
await setPolicy(browser, {});
+2 -2
View File
@@ -494,8 +494,8 @@ export interface IJMAPClient {
humanText?: string;
}): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
submitRawEmail(blob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[], existingCopyId?: string): Promise<SendEmailResult>;
submitRawEmail(blob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[], existingCopyId?: string): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; totalByAccount?: Record<string, number>; nextPosition: number }>;
/** Cancels a pending submission, then destroys its record. */
cancelEmailSubmission(submissionId: string, accountId?: string): Promise<void>;
+77 -50
View File
@@ -1403,12 +1403,14 @@ function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
const GATEWAY_STATUSES = new Set([502, 503, 504]);
// Generate a Message-ID for outgoing mail (bare msg-id, no angle brackets, per
// RFC 8621 §4.1.2.3). Without one the server synthesizes it from its OS
// hostname, which leaks internal names (e.g. @ip-10-0-12-97.ec2.internal) into
// headers — an anti-spam signal and an information disclosure. Use the sender's
// domain instead, matching what receivers expect a Message-ID to look like.
function generateMessageId(fromEmail: string): string {
export function generateMessageId(fromEmail: string): string {
const at = fromEmail.lastIndexOf('@');
const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost';
// FTM fix (2026-08-22): crypto.randomUUID() only exists in SECURE contexts (https/localhost).
@@ -2181,20 +2183,22 @@ export class JMAPClient implements IJMAPClient {
* `transfer`: a mail import or export's own request. A 429 throws
* RateLimitError for the transfer to wait on, without pausing the rest of
* the client, and a pause the client is in doesn't hold the transfer up.
* `networkOnly`: only the server's own word counts. A send or a change the
* outbox hands over must never be answered by the device: the device would
* queue it again, or answer for the server.
*/
private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean; skipOfflineRecord?: boolean }): Promise<JMAPResponse> {
private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean; skipOfflineRecord?: boolean; networkOnly?: boolean }): Promise<JMAPResponse> {
if (!this.apiUrl) {
throw new Error('Not connected. Call connect() first.');
}
// Error reports keep the method names of the last calls, nothing else.
recordJmapCall(methodCalls.map(([name]) => name));
// Offline mode (#523, lib/offline/): answered from this device when the
// browser is offline or the network fails, and every answer from the
// server goes to the device's cache. Without offline mode no hooks are
// set and this is the plain network request.
// browser is offline or the network fails. Without offline mode no hooks
// are set and this is the plain network request.
const offline = getOfflineHooks();
if (this.offlineOnly || offline?.skipNetwork(this)) {
const local = offline ? await offline.answer(this, methodCalls) : null;
const local = offline && !opts?.networkOnly ? await offline.answer(this, methodCalls) : null;
if (local) return local as JMAPResponse;
throw new OfflineUnavailableError();
}
@@ -2204,8 +2208,8 @@ export class JMAPClient implements IJMAPClient {
} catch (error) {
// Only when the server is really gone: a refusal without CORS
// 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 (offline && isNetworkFailure(error) && await offline.serverGone(this, error)) {
const local = opts?.networkOnly ? null : await offline.answer(this, methodCalls);
if (local) return local as JMAPResponse;
}
throw error;
@@ -2217,6 +2221,22 @@ export class JMAPClient implements IJMAPClient {
return answer;
}
/** A request only the server may answer: for the outbox, which must see outages as outages. */
requestNetworkOnly(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
return this.request(methodCalls, using, { networkOnly: true, skipOfflineRecord: true });
}
/** The account that holds the submissions of mail in `accountId`. */
submissionAccountFor(accountId?: string): string {
return this.getSubmissionAccountId(accountId);
}
/** Whether this server reads keyword sort the other way. Null until the client has probed it. */
keywordSortInverted(accountId?: string): boolean | null {
const polarity = this.keywordSortPolarity.get(accountId ?? this.accountId)?.value;
return polarity ? polarity === 'inverted' : null;
}
private async networkRequest(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean }): Promise<JMAPResponse> {
const requestBody = {
@@ -2254,7 +2274,10 @@ export class JMAPClient implements IJMAPClient {
console.error('Request failed:', response.status, responseText);
// The status only: the answer may quote what was sent.
if (!UNREPORTED_STATUSES.has(response.status)) reportJmapFailure({ status: response.status });
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
const failure = new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
// A proxy in front of a server that's down: to the app, the same as no network.
if (GATEWAY_STATUSES.has(response.status)) failure.name = 'GatewayError';
throw failure;
}
let parsed: JMAPResponse;
@@ -3280,16 +3303,6 @@ export class JMAPClient implements IJMAPClient {
return out;
}
/** Changes made offline, sent as they were made: the ids the server refused. */
async applyEmailPatches(accountId: string | undefined, update: Record<string, Record<string, unknown>>): Promise<string[]> {
const response = await this.request([
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
]);
const [name, result] = response.methodResponses?.[0] ?? [];
if (name !== "Email/set") throw jmapError(methodErrorMessage(response, 'Failed to update emails'), result);
return Object.keys((result?.notUpdated ?? {}) as Record<string, unknown>);
}
// The 256000-byte body cap keeps list/thread fetches fast for the common
// case, but truncation lands mid-tag on large HTML bodies (e.g. a data:image
// src cut in half), which drops the whole element and can render blank. Only
@@ -5122,8 +5135,10 @@ export class JMAPClient implements IJMAPClient {
delayedUntil?: string,
envelopeMailFrom?: string,
// `draftAccountId`: the account `draftId` lives in when it is not this
// one (see createDraft).
options?: SubmissionSendOptions & { draftAccountId?: string }
// one (see createDraft). `messageId` and `existingCopyId` come from the
// offline outbox: a Message-ID fixed when the message was queued, and the
// copy already on the server that only needs submitting (lib/store/outbox/dedupe.ts).
options?: SubmissionSendOptions & { draftAccountId?: string; messageId?: string; existingCopyId?: string }
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`;
@@ -5231,7 +5246,7 @@ export class JMAPClient implements IJMAPClient {
cc: cc?.length ? cc.map(parseRecipientString) : undefined,
bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined,
subject,
messageId: [generateMessageId(senderEmail)],
messageId: [options?.messageId ?? generateMessageId(senderEmail)],
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
references: normalizedReferences?.length ? normalizedReferences : undefined,
keywords: { "$seen": true, "$draft": true },
@@ -5326,13 +5341,16 @@ export class JMAPClient implements IJMAPClient {
// same request meant a failed create/submission - e.g. the outgoing email
// referencing part blobs of that very draft, which die with it - still
// deleted the user's only copy of the message.
methodCalls.push(["Email/set", {
accountId: targetAccountId,
create: { [emailId]: emailCreate },
}, "0"]);
const existingCopyId = options?.existingCopyId;
if (!existingCopyId) {
methodCalls.push(["Email/set", {
accountId: targetAccountId,
create: { [emailId]: emailCreate },
}, "0"]);
}
methodCalls.push(["EmailSubmission/set", {
accountId: submissionAccountId,
create: buildSubmissionCreate(`#${emailId}`, firstMailFrom),
create: buildSubmissionCreate(existingCopyId ?? `#${emailId}`, firstMailFrom),
...onSuccess,
}, "1"]);
// Which recipients the server took. A refused one is lost without a
@@ -5350,7 +5368,7 @@ export class JMAPClient implements IJMAPClient {
// the identity's address - the composer warns about this before sending
// (#1009).
// Stalwart checks the quota first, so both can happen, one after the other.
const draftCopyId = response.methodResponses
const draftCopyId = existingCopyId ?? response.methodResponses
?.find(([name]) => name === 'Email/set')?.[1]?.created?.[emailId]?.id as string | undefined;
let mailFrom = firstMailFrom;
let madeRoom = false;
@@ -5373,7 +5391,7 @@ export class JMAPClient implements IJMAPClient {
accountId: submissionAccountId,
create: buildSubmissionCreate(draftCopyId, mailFrom),
...onSuccess,
}, "1"], deliveryStatusCall(submissionAccountId, "1")]);
}, "1"], deliveryStatusCall(submissionAccountId, "1")], undefined, { networkOnly: true });
response = {
...retry,
methodResponses: [
@@ -5388,7 +5406,7 @@ export class JMAPClient implements IJMAPClient {
const statusReadBack = takeDeliveryStatus(response);
response = statusReadBack.response;
let createdEmailId: string | undefined;
let createdEmailId: string | undefined = existingCopyId;
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
let filingError: string | undefined;
@@ -6721,17 +6739,18 @@ export class JMAPClient implements IJMAPClient {
try {
return await step();
} catch (error) {
if (isNetworkFailure(error) || (error instanceof Error && (error.name === 'RequestTimeoutError' || error.name === 'OfflineUnavailableError'))) {
throw new SendNotStartedError(error);
}
if (isNetworkFailure(error)) throw new SendNotStartedError(error);
throw error;
}
}
/** The request that sends: only a refusal without touching the network means it didn't start. */
/**
* The request that sends: only a refusal without touching the network means
* it didn't start. The device never answers it: the send may be on its way.
*/
private async submissionRequest(methodCalls: JMAPMethodCall[]): Promise<JMAPResponse> {
try {
return await this.request(methodCalls);
return await this.request(methodCalls, undefined, { networkOnly: true });
} catch (error) {
if (error instanceof Error && error.name === 'OfflineUnavailableError') throw new SendNotStartedError(error);
throw error;
@@ -11142,6 +11161,7 @@ export class JMAPClient implements IJMAPClient {
if (eventType === 'state' && dataLines.length > 0) {
try {
const change = JSON.parse(dataLines.join('\n')) as StateChange;
getOfflineHooks()?.stateChange(this, change);
this.stateChangeCallback?.(change);
} catch {
// Malformed SSE data - ignore
@@ -11316,8 +11336,9 @@ export class JMAPClient implements IJMAPClient {
this.pollingStates[key] = state;
}
if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) {
this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount });
if (Object.keys(changedByAccount).length > 0) {
getOfflineHooks()?.stateChange(this, { changed: changedByAccount });
this.stateChangeCallback?.({ '@type': 'StateChange', changed: changedByAccount });
}
} catch {
// Silently fail - polling will retry
@@ -11742,11 +11763,13 @@ export class JMAPClient implements IJMAPClient {
draftMailboxId?: string,
delayedUntil?: string,
envelopeRecipients?: string[],
/** The offline outbox found this message's copy on the server: submit that one. */
existingCopyId?: string,
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
// Upload the raw message
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.beforeSubmission(() => this.uploadBlob(file));
const blobId = existingCopyId ? undefined : (await this.beforeSubmission(() => this.uploadBlob(file))).blobId;
// Import into Drafts first, then move to Sent after submission succeeds.
// This avoids encrypt-on-append affecting the SMTP send. See #188.
@@ -11759,7 +11782,7 @@ export class JMAPClient implements IJMAPClient {
const submissionAccountId = this.getSubmissionAccountId();
const { emailId, submissionId: emailSubmissionId, sendAt, rejected } = await this.importAndSubmitRaw(
['Email/import', {
existingCopyId ? null : ['Email/import', {
accountId: this.accountId,
emails: {
'raw-import': {
@@ -11769,7 +11792,7 @@ export class JMAPClient implements IJMAPClient {
},
},
}, '0'],
'raw-import',
existingCopyId ?? 'raw-import',
submissionAccountId,
(submitEmailId) => ({
accountId: submissionAccountId,
@@ -11812,6 +11835,8 @@ export class JMAPClient implements IJMAPClient {
identityId: string,
delayedUntil?: string,
envelopeRecipients?: string[],
/** The offline outbox found this message's copy on the server: submit that one. */
existingCopyId?: string,
): Promise<SendEmailResult> {
const mailboxes = await this.beforeSubmission(() => this.getMailboxes());
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
@@ -11822,7 +11847,7 @@ export class JMAPClient implements IJMAPClient {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.beforeSubmission(() => this.uploadBlob(file));
const blobId = existingCopyId ? undefined : (await this.beforeSubmission(() => this.uploadBlob(file))).blobId;
const identities = await this.beforeSubmission(() => this.getIdentities());
const identity = identities.find(item => item.id === identityId);
@@ -11832,7 +11857,7 @@ export class JMAPClient implements IJMAPClient {
//Temporarily import the raw email into Drafts to satisfy JMAP's requirement that an EmailSubmission references an existing Email.
// The Email will be destroyed after submission.
const { submissionId: emailSubmissionId, sendAt, rejected } = await this.importAndSubmitRaw(
['Email/import', {
existingCopyId ? null : ['Email/import', {
accountId: this.accountId,
emails: {
'temp-submit': {
@@ -11842,7 +11867,7 @@ export class JMAPClient implements IJMAPClient {
},
},
}, '0'],
'temp-submit',
existingCopyId ?? 'temp-submit',
submissionAccountId,
(submitEmailId) => ({
accountId: submissionAccountId,
@@ -11879,29 +11904,31 @@ export class JMAPClient implements IJMAPClient {
* (or in Sent, where it passes for a sent message). So does a submission
* whose recipients were all refused (#1123): it throws
* RecipientsRejectedError; the ones refused while others were taken come
* back as `rejected`.
* back as `rejected`. Without `importCall`, `importCreationId` is the id of a
* copy already on the server.
*/
private async importAndSubmitRaw(
importCall: JMAPMethodCall,
importCall: JMAPMethodCall | null,
importCreationId: string,
submissionAccountId: string,
submissionSet: (emailId: string) => Record<string, unknown>,
failureMessage: string,
): Promise<{ emailId?: string; submissionId?: string; sendAt?: string; rejected: RejectedRecipient[] }> {
const firstSet = submissionSet(`#${importCreationId}`);
const firstSet = submissionSet(importCall ? `#${importCreationId}` : importCreationId);
let response = await this.submissionRequest([
importCall,
...(importCall ? [importCall] : []),
['EmailSubmission/set', firstSet, '1'],
deliveryStatusCall(submissionAccountId, 'raw-submit'),
]);
const importedId = response.methodResponses
?.find(([name]) => name === 'Email/import')?.[1]?.created?.[importCreationId]?.id as string | undefined;
const importedId = importCall
? response.methodResponses?.find(([name]) => name === 'Email/import')?.[1]?.created?.[importCreationId]?.id as string | undefined
: importCreationId;
if (importedId && submissionCreateError(response, 'raw-submit')?.type === 'overQuota') {
await this.makeRoomForSubmission(submissionAccountId);
const retry = await this.request([
['EmailSubmission/set', submissionSet(importedId), '1'],
deliveryStatusCall(submissionAccountId, 'raw-submit'),
]);
], undefined, { networkOnly: true });
response = {
...retry,
methodResponses: [
+18 -6
View File
@@ -1,7 +1,7 @@
/**
* Where the JMAP client hands over to offline mode (#523, lib/offline/).
* The client knows nothing about the cache: it asks these hooks whether to
* skip the network, gives them what the server answered, and asks them for
* The client knows nothing about the local store: it asks these hooks whether
* to skip the network, gives them what the server answered, and asks them for
* an answer when the network is gone. Nothing is registered while offline
* mode is off for every account, so the client then behaves as before.
*
@@ -36,15 +36,23 @@ export interface OfflineHooks {
/**
* After a request failed on the network: whether the server can't be
* reached at all (then the device answers), or it only refused that
* request (then it stays an error).
* request (then it stays an error). `error` is what the request failed with.
*/
serverGone(client: OfflineClientRef): Promise<boolean>;
serverGone(client: OfflineClientRef, error?: unknown): Promise<boolean>;
/** The server pushed new states, for the local store to catch up. */
stateChange(client: OfflineClientRef, change: { changed?: Record<string, Record<string, string>> }): void;
/** A blob kept on the device, or null. */
blob(client: OfflineClientRef, blobId: string, accountId: string): Promise<Blob | null>;
/** A blob the server sent, for the cache. */
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>;
}
const KEY = '__bulwarkOfflineHooks';
@@ -59,10 +67,14 @@ export function getOfflineHooks(): OfflineHooks | null {
return (globalThis as HookGlobal)[KEY] ?? null;
}
/** The network refused the request (not an HTTP error): offline, DNS, a dropped connection. */
/**
* The server can't be talked to: offline, DNS, a dropped connection, a request
* that hung until its deadline, a proxy answering for a server that's down.
* Not an answer from the server itself.
*/
export function isNetworkFailure(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (error.name === 'OfflineUnavailableError') return true;
if (error.name === 'OfflineUnavailableError' || error.name === 'RequestTimeoutError' || error.name === 'GatewayError') return true;
return error.name === 'TypeError' && /fetch|network|load failed|internet/i.test(error.message);
}
-121
View File
@@ -1,121 +0,0 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it } from 'vitest';
import {
cacheUsage,
emailIndex,
emailKey,
enforceCap,
forgetIndexes,
getBlob,
getBody,
getMisc,
patchEmails,
putBlob,
putEmails,
putMisc,
wipeAccountCache,
} from '../cache';
import { forgetDeviceKey, openJson, sealJson } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb, idbGet, idbGetAll } from '../db';
const day = 24 * 60 * 60 * 1000;
const email = (id: string, ageDays: number, extra: Record<string, unknown> = {}) => ({
id,
threadId: `t-${id}`,
mailboxIds: { inbox: true },
keywords: {},
receivedAt: new Date(Date.now() - ageDays * day).toISOString(),
subject: `Subject ${id}`,
...extra,
});
beforeEach(async () => {
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
allowOfflineDbCreation();
});
describe('offline encryption', () => {
it('keeps nothing readable and a key that cannot be exported', async () => {
const sealed = await sealJson({ subject: 'Quarterly numbers' });
expect(new TextDecoder().decode(sealed.data)).not.toContain('Quarterly');
expect(await openJson(sealed)).toEqual({ subject: 'Quarterly numbers' });
const stored = await idbGet<{ key: CryptoKey }>('keys', 'aes');
expect(stored?.key.extractable).toBe(false);
await expect(crypto.subtle.exportKey('raw', stored!.key)).rejects.toThrow();
});
it('stores messages encrypted, list part and body apart', async () => {
await putEmails('acc', 'j1', [email('m1', 1, { bodyValues: { 1: { value: 'Secret text' } }, textBody: [{ partId: '1' }] })]);
const records = await idbGetAll<{ data: ArrayBuffer }>('emails');
expect(records).toHaveLength(1);
expect(new TextDecoder().decode(records[0].data)).not.toContain('Subject m1');
const bodies = await idbGetAll<{ data: ArrayBuffer }>('bodies');
expect(new TextDecoder().decode(bodies[0].data)).not.toContain('Secret text');
forgetIndexes();
const index = await emailIndex('acc');
const meta = index.get(emailKey('acc', 'j1', 'm1'))!;
expect(meta.subject).toBe('Subject m1');
expect(meta).not.toHaveProperty('bodyValues');
expect((await getBody('acc', 'j1', 'm1'))?.bodyValues).toEqual({ 1: { value: 'Secret text' } });
});
it('merges a later list fetch into the kept message', async () => {
await putEmails('acc', 'j1', [email('m1', 1, { bodyValues: { 1: { value: 'x' } } })]);
await putEmails('acc', 'j1', [{ id: 'm1', keywords: { $seen: true } }]);
const meta = (await emailIndex('acc')).get(emailKey('acc', 'j1', 'm1'))!;
expect(meta.keywords).toEqual({ $seen: true });
expect(meta.subject).toBe('Subject m1');
expect(await getBody('acc', 'j1', 'm1')).not.toBeNull();
});
it('applies keyword and folder patches', async () => {
await putEmails('acc', 'j1', [email('m1', 1)]);
await patchEmails('acc', 'j1', { m1: { 'keywords/$flagged': true, 'mailboxIds/inbox': null, 'mailboxIds/archive': true } });
const meta = (await emailIndex('acc')).get(emailKey('acc', 'j1', 'm1'))!;
expect(meta.keywords).toEqual({ $flagged: true });
expect(meta.mailboxIds).toEqual({ archive: true });
});
});
describe('the cache cap', () => {
it('drops the oldest messages first, with their bodies and attachments', async () => {
await putEmails('acc', 'j1', [
email('old', 20, { bodyValues: { 1: { value: 'a'.repeat(4000) } } }),
email('mid', 10, { bodyValues: { 1: { value: 'b'.repeat(4000) } } }),
email('new', 1, { bodyValues: { 1: { value: 'c'.repeat(4000) } } }),
]);
await putBlob('acc', 'j1', 'blob-old', new Blob(['x'.repeat(4000)]), Date.now() - 20 * day, emailKey('acc', 'j1', 'old'));
const { total } = await cacheUsage();
// Room for about two messages.
const freed = await enforceCap(total - 6000);
expect(freed).toBeGreaterThan(0);
const index = await emailIndex('acc');
expect(index.has(emailKey('acc', 'j1', 'old'))).toBe(false);
expect(index.has(emailKey('acc', 'j1', 'new'))).toBe(true);
expect(await getBody('acc', 'j1', 'old')).toBeNull();
expect(await getBlob('acc', 'j1', 'blob-old')).toBeNull();
expect(await getBody('acc', 'j1', 'new')).not.toBeNull();
expect((await cacheUsage()).total).toBeLessThanOrEqual(total - 6000);
});
it('leaves the cache alone under the cap', async () => {
await putEmails('acc', 'j1', [email('m1', 1)]);
expect(await enforceCap()).toBe(0);
});
});
describe('wiping one account', () => {
it('removes that account only', async () => {
await putEmails('a', 'j1', [email('m1', 1)]);
await putEmails('b', 'j1', [email('m2', 1)]);
await putMisc('a', 'session', { apiUrl: 'x' });
await wipeAccountCache('a');
expect((await emailIndex('a')).size).toBe(0);
expect(await getMisc('a', 'session')).toBeNull();
forgetIndexes();
expect((await emailIndex('b')).size).toBe(1);
});
});
+176 -144
View File
@@ -5,36 +5,44 @@ import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { setStoreBackend } from '@/lib/store/client/api';
import { FakeJmapMail } from '@/lib/store/__tests__/helpers/fake-jmap-mail';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { startOfflineMode, wipeEverything } from '../offline-mode';
import { forgetIndexes, putEmails, putMisc, getMisc, emailIndex } from '../cache';
import { forgetDeviceKey } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb } from '../db';
import { forgetLegacyMove } from '../migrate-legacy';
import { useOfflineStore, writeEnabled } from '../state';
import { syncAccount } from '../sync';
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: {} } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'j1' },
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } },
accounts: { acc1: { name: 'alice', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:submission': {} } } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acc1' },
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500, maxObjectsInSet: 500 }, 'urn:ietf:params:jmap:mail': {} },
username: 'alice@example.org',
};
let probeOk = true;
let apiFails = false;
let apiFails: 'no' | 'network' | 'gateway' = 'no';
let server: FakeJmapMail;
let store: InProcessStore;
beforeEach(async () => {
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
allowOfflineDbCreation();
localStorage.clear();
probeOk = true;
apiFails = false;
useOfflineStore.setState({ downServers: [], browserOffline: false });
apiFails = 'no';
server = new FakeJmapMail({ mailboxes: [
{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 2, unreadEmails: 2 },
{ id: 'trash', name: 'Trash', role: 'trash', totalEmails: 0, unreadEmails: 0 },
] });
server.identities.set('i1', { id: 'i1', email: 'alice@example.org', name: 'Alice' });
server.addEmail({ id: 'm1', subject: 'Kept one', threadId: 't1', keywords: {} });
server.addEmail({ id: 'm2', subject: 'Kept two', threadId: 't2', keywords: {} });
store = new InProcessStore(server, SESSION);
setStoreBackend(store);
forgetLegacyMove();
localStorage.clear();
useOfflineStore.setState({ enabled: [], downServers: [], browserOffline: false, outbox: [], pendingChanges: 0, conflicts: [] });
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: SERVER, username: 'alice@example.org' } as never] });
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
@@ -43,175 +51,199 @@ beforeEach(async () => {
return new Response(null, { status: 200 });
}
if (String(url).endsWith('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 });
if (apiFails) throw new TypeError('Failed to fetch');
return new Response(JSON.stringify({ methodResponses: [['Email/get', { accountId: 'j1', list: [], notFound: ['m1'] }, '0']] }), { status: 200 });
if (apiFails === 'network') throw new TypeError('Failed to fetch');
if (apiFails === 'gateway') return new Response('<html>Bad Gateway</html>', { status: 502 });
const body = JSON.parse(String(init?.body)) as { methodCalls: never[] };
return new Response(JSON.stringify(await server.request(body.methodCalls)), { status: 200 });
}));
});
afterEach(async () => {
vi.unstubAllGlobals();
await wipeEverything();
setStoreBackend(null);
});
async function connectedClient(): Promise<JMAPClient> {
async function ready() {
const client = new JMAPClient(SERVER, 'alice@example.org', 'pw');
await client.connect();
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
return client;
writeEnabled(['acc']);
const stop = startOfflineMode();
await store.sync('acc');
return { client, stop };
}
async function offline(run: () => Promise<void>): Promise<void> {
vi.stubGlobal('navigator', { onLine: false });
try {
await run();
} finally {
vi.stubGlobal('navigator', window.navigator);
}
}
describe('falling back to the device', () => {
it('answers from the device when the server is gone', async () => {
const client = await connectedClient();
writeEnabled(['acc']);
const stop = startOfflineMode();
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString(), subject: 'Kept', bodyValues: { 1: { value: 'x' } } }]);
apiFails = true;
it('answers from the store when the server is gone', async () => {
const { client, stop } = await ready();
apiFails = 'network';
probeOk = false;
const email = await client.getEmail('m1');
expect(email?.subject).toBe('Kept');
expect(email?.subject).toBe('Kept one');
expect(useOfflineStore.getState().downServers).toEqual(['https://mail.example.org']);
stop();
});
it('keeps a refused request an error while the server answers', async () => {
const client = await connectedClient();
writeEnabled(['acc']);
const stop = startOfflineMode();
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString(), subject: 'Kept', bodyValues: { 1: { value: 'x' } } }]);
apiFails = true;
const { client, stop } = await ready();
apiFails = 'network';
probeOk = true;
// getEmail swallows errors into null: the device's copy isn't passed off as the server's answer.
expect(await client.getEmail('m1')).toBeNull();
expect(useOfflineStore.getState().downServers).toEqual([]);
stop();
});
it('counts a proxy\'s 5xx as the server being gone, though a probe would pass', async () => {
const { client, stop } = await ready();
apiFails = 'gateway';
probeOk = true;
const email = await client.getEmail('m1');
expect(email?.subject).toBe('Kept one');
expect(useOfflineStore.getState().downServers).toEqual(['https://mail.example.org']);
// A later good answer ends it.
apiFails = 'no';
await client.getEmail('m1');
expect(useOfflineStore.getState().downServers).toEqual([]);
stop();
});
});
describe('mailbox paging with the device cache', () => {
const mailboxes = Array.from({ length: 5 }, (_, i) => ({ id: `box-${i}`, name: `Folder ${i}`, parentId: null }));
function mailboxServer(failLaterPage = false) {
const positions: number[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (String(url).endsWith('/.well-known/jmap')) {
return new Response(JSON.stringify({
...SESSION,
capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2 } },
}), { status: 200 });
}
const { methodCalls } = JSON.parse(String(init?.body));
const query = methodCalls.find(([name]: [string]) => name === 'Mailbox/query');
const position = query?.[1].position ?? 0;
if (query) positions.push(position);
const page = mailboxes.slice(position, position + 2);
const methodResponses = methodCalls.map(([name, , id]: [string, unknown, string]) => {
if (name === 'Identity/get') return [name, { accountId: 'j1', list: [{ id: 'recording-barrier' }] }, id];
if (query && failLaterPage && position > 0) return ['error', { type: 'serverFail' }, id];
if (name === 'Mailbox/query') return [name, {
accountId: 'j1', queryState: 'query-state', position,
ids: page.map((box) => box.id), total: mailboxes.length,
}, id];
return [name, { accountId: 'j1', state: 'mailbox-state', list: page, notFound: [] }, id];
});
return new Response(JSON.stringify({ methodResponses }), { status: 200 });
}));
return positions;
}
// Identity recording shares the per-account queue with mailbox recording.
// Waiting for this marker also detects erroneous writes queued by failed pages.
async function recorded(client: JMAPClient) {
await client.getIdentities();
await vi.waitFor(async () => {
expect(await getMisc('acc', 'identities')).toEqual({ j1: [{ id: 'recording-barrier' }] });
describe('per-call errors, end to end through the client', () => {
it('keeps the results when one call in the request can\'t be answered', async () => {
const { client, stop } = await ready();
await offline(async () => {
// A search with text: Email/query, Email/get, and SearchSnippet/get, which the store refuses.
const result = await client.searchEmails('kept');
expect(result.emails.map((email) => email.id).sort()).toEqual(['m1', 'm2']);
expect(result.emails[0].subject).toBeTruthy();
});
}
stop();
});
it('returns a cached list larger than maxObjectsInGet without inventing a collection state', async () => {
mailboxServer();
const client = await connectedClient();
writeEnabled(['acc']);
const stop = startOfflineMode();
try {
await putMisc('acc', 'mailboxes', { j1: mailboxes });
vi.stubGlobal('navigator', { onLine: false });
it('does a full fetch when /changes is refused: the client reads null', async () => {
const { client, stop } = await ready();
await offline(async () => {
expect(await client.getEmailChanges('s1')).toBeNull();
expect(await client.getMailboxChanges('s1')).toBeNull();
});
stop();
});
it('a draft can\'t be saved yet, and says so the way the composer expects', async () => {
const { client, stop } = await ready();
await offline(async () => {
await expect(client.createDraft(['b@example.org'], 'S', 'x', undefined, undefined, undefined, 'alice@example.org')).rejects.toThrow();
});
stop();
});
it('queues a delete for good and an emptied folder, and the client sees them done', async () => {
const { client, stop } = await ready();
await offline(async () => {
await client.deleteEmail('m1');
expect(await client.getEmail('m1')).toBeNull();
await client.moveToTrash('m2', 'trash');
expect(await client.emptyMailbox('trash')).toBe(1);
const boxes = await client.getMailboxes();
expect(boxes.find((box) => box.id === 'inbox')).toMatchObject({ totalEmails: 0, unreadEmails: 0 });
expect(boxes.find((box) => box.id === 'trash')).toMatchObject({ totalEmails: 0 });
});
stop();
// Nothing reached the server yet.
expect(server.emails.size).toBe(2);
});
it('marks read offline and the change reaches the message and the counts', async () => {
const { client, stop } = await ready();
await offline(async () => {
await client.markAsRead('m1', true);
expect((await client.getEmail('m1'))?.keywords).toMatchObject({ $seen: true });
const boxes = await client.getMailboxes();
expect(boxes.find((box) => box.id === 'inbox')?.unreadEmails).toBe(1);
});
stop();
});
});
describe('what the store answers and is told', () => {
it('answers identities', async () => {
const { client, stop } = await ready();
await offline(async () => {
expect((await client.getIdentities()).map((identity) => identity.id)).toEqual(['i1']);
});
stop();
});
it('answers the folder list with the server\'s real state', async () => {
const { client, stop } = await ready();
await offline(async () => {
const result = await client.getAllMailboxesWithState();
expect(result.mailboxes.map((box) => box.id)).toEqual(mailboxes.map((box) => box.id));
expect(result.states).toEqual({});
// Only session discovery touched the network; both query/get pages are local.
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
stop();
}
expect(result.mailboxes.map((box) => box.id).sort()).toEqual(['inbox', 'trash']);
expect(Object.values(result.states)).toEqual([server.coll.Mailbox.state()]);
});
stop();
});
it('records the complete live multi-page list rather than its final page', async () => {
const positions = mailboxServer();
const client = await connectedClient();
writeEnabled(['acc']);
const stop = startOfflineMode();
try {
const result = await client.getMailboxes();
expect(result.map((box) => box.id)).toEqual(mailboxes.map((box) => box.id));
expect(positions).toEqual([0, 2, 4]);
await recorded(client);
expect(await getMisc('acc', 'mailboxes')).toEqual({ j1: mailboxes });
} finally {
stop();
}
it('never lets the device answer for the server when the outbox asks', async () => {
const { client, stop } = await ready();
apiFails = 'network';
probeOk = false;
await expect(client.requestNetworkOnly([['Email/set', { accountId: 'acc1', update: { m1: { 'keywords/$seen': true } } }, '0']])).rejects.toThrow('Failed to fetch');
expect(await store.area('acc', 'outbox.read')).toMatchObject({ patches: 0 });
stop();
});
it('retains the previous complete cache when a later live page fails', async () => {
const positions = mailboxServer(true);
const client = await connectedClient();
writeEnabled(['acc']);
const stop = startOfflineMode();
const previous = [{ id: 'old', name: 'Previously cached', parentId: null }];
try {
await putMisc('acc', 'mailboxes', { j1: previous });
await expect(client.getMailboxes()).rejects.toThrow('Failed to load the complete mailbox list');
expect(positions).toEqual([0, 2]);
await recorded(client);
expect(await getMisc('acc', 'mailboxes')).toEqual({ j1: previous });
} finally {
stop();
}
it('keeps a message opened from outside the window', async () => {
server.addEmail({ id: 'old', subject: 'Old', receivedAt: '2019-01-01T00:00:00Z', bodyValues: { 1: { value: 'Old text' } } });
const { client, stop } = await ready();
store.dbs.get('acc')!.run("DELETE FROM email WHERE id = 'old'");
await client.getEmail('old');
await vi.waitFor(async () => {
const answer = await store.answer('acc', [['Email/get', { ids: ['old'], properties: ['subject', 'bodyValues'], fetchAllBodyValues: true }, '0']]);
expect(answer?.methodResponses[0][1].list[0]?.subject).toBe('Old');
});
stop();
});
});
describe('the sync', () => {
function fakeClient(opts: { failQuery?: boolean }) {
return {
getSessionSnapshot: () => SESSION,
getAccountId: () => 'j1',
getAllMailboxes: vi.fn(async () => { await putMisc('acc', 'mailboxes', { j1: [{ id: 'inbox', role: 'inbox' }] }); return []; }),
getMailboxes: vi.fn(async () => []),
getIdentities: vi.fn(async () => []),
getRecentEmailIds: vi.fn(async (mailboxId: string) => { if (opts.failQuery && mailboxId === 'archive') throw new Error('refused'); return mailboxId === 'inbox' ? ['new'] : []; }),
getSomeEmails: vi.fn(async () => []),
getFullEmails: vi.fn(async (ids: string[]) => {
await putEmails('acc', 'j1', ids.map((id) => ({ id, mailboxIds: { inbox: true }, receivedAt: new Date().toISOString(), bodyValues: {} })));
return [];
}),
fetchBlob: vi.fn(),
};
}
const deps = { stillActive: () => true, recorded: async () => {}, scopeMailboxes: async () => [{ jmap: 'j1', mailboxId: 'inbox', isInbox: true }] };
it('tells the store what the server pushed', async () => {
const { client, stop } = await ready();
const note = vi.spyOn(store, 'noteStateChange');
const { getOfflineHooks } = await import('@/lib/jmap/offline-hooks');
getOfflineHooks()?.stateChange(client, { changed: { acc1: { Email: 's99' } } });
expect(note).toHaveBeenCalledWith('acc', { acc1: { Email: 's99' } });
stop();
});
it('fetches what is missing and drops what left the Inbox', async () => {
await putEmails('acc', 'j1', [{ id: 'gone', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
const client = fakeClient({});
await syncAccount('acc', client as never, deps);
expect(client.getFullEmails).toHaveBeenCalledWith(['new'], 'j1');
const index = await emailIndex('acc');
expect([...index.values()].map((e) => e.id)).toEqual(['new']);
it('holds a file while offline and hands it back for the message', async () => {
const { client, stop } = await ready();
await offline(async () => {
const held = await client.uploadBlob(new File(['hello'], 'note.txt', { type: 'text/plain' }));
expect(held.blobId).toMatch(/^bulwark-local-/);
const blob = await client.fetchBlob(held.blobId);
expect(await blob.text()).toBe('hello');
});
stop();
});
it('keeps everything when a folder couldn\'t be asked', async () => {
await putEmails('acc', 'j1', [{ id: 'kept', mailboxIds: { archive: true }, receivedAt: new Date().toISOString() }]);
const both = { ...deps, scopeMailboxes: async () => [{ jmap: 'j1', mailboxId: 'inbox', isInbox: true }, { jmap: 'j1', mailboxId: 'archive', isInbox: false }] };
await syncAccount('acc', fakeClient({ failQuery: true }) as never, both);
expect([...(await emailIndex('acc')).values()].map((e) => e.id).sort()).toEqual(['kept', 'new']);
it('answers a search for the merge with the server', async () => {
const { client, stop } = await ready();
const { getOfflineHooks } = await import('@/lib/jmap/offline-hooks');
const answer = await getOfflineHooks()?.searchLocal?.(client, [['Email/query', { filter: { text: 'kept' } }, '0']]);
expect(answer?.methodResponses[0][1].ids).toHaveLength(2);
useAccountStore.setState({ accounts: [] });
useAuthStore.setState({ client: null, activeAccountId: null });
expect(await getOfflineHooks()?.searchLocal?.(client, [['Email/query', {}, '0']])).toBeNull();
stop();
});
});
@@ -0,0 +1,176 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { setStoreBackend } from '@/lib/store/client/api';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { forgetLegacyMove, LEGACY_DB_NAME, moveLegacyOutbox } from '../migrate-legacy';
import { readQueue } from '../queue';
import { useOfflineStore } from '../state';
/** The old database, exactly as lib/offline/db.ts laid it out. */
const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'queue', 'uploads'];
function openOld(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(LEGACY_DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
for (const name of STORES) {
if (name === 'queue') db.createObjectStore(name, { keyPath: 'id', autoIncrement: true }).createIndex('account', 'account');
else if (name === 'keys') db.createObjectStore(name, { keyPath: 'id' });
else db.createObjectStore(name, { keyPath: 'key' }).createIndex('account', 'account');
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function put(db: IDBDatabase, store: string, value: unknown): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(store, 'readwrite');
tx.objectStore(store).put(value);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
let key: CryptoKey;
async function seal(value: unknown): Promise<{ iv: Uint8Array; data: ArrayBuffer }> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const bytes = ArrayBuffer.isView(value) ? value as Uint8Array : new TextEncoder().encode(JSON.stringify(value));
return { iv, data: await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv as BufferSource }, key, bytes as BufferSource) };
}
const RAW = 'Subject: Signed\r\nMessage-ID: <raw-1@example.org>\r\n\r\nbody';
async function seedOld(): Promise<void> {
key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const db = await openOld();
await put(db, 'keys', { id: 'aes', key });
await put(db, 'misc', { key: 'acc\u0001misc\u0001session', account: 'acc', ...(await seal({ apiUrl: 'https://old/jmap', accounts: {}, primaryAccounts: {}, capabilities: {}, downloadUrl: '', uploadUrl: '' })) });
await put(db, 'queue', { id: 1, account: 'acc', kind: 'patch', createdAt: 1, ...(await seal({ kind: 'patch', jmap: 'acc1', update: { m1: { 'keywords/$seen': true } } })) });
await put(db, 'queue', { id: 2, account: 'acc', kind: 'patch', createdAt: 2, ...(await seal({ kind: 'patch', jmap: 'acc1', update: { m1: { 'keywords/$flagged': true } } })) });
await put(db, 'queue', {
id: 3, account: 'acc', kind: 'send', createdAt: 3, status: 'waiting',
...(await seal({ summary: { subject: 'Waiting', to: ['b@example.org'] }, item: { kind: 'email', args: [['b@example.org'], 'Waiting', 'x', null, null, 'i', 'a@example.org'] }, fingerprint: { labelRequired: false, hasLabel: false, guards: [] } })),
});
await put(db, 'queue', { id: 4, account: 'acc', kind: 'send', createdAt: 4, status: 'sending', sendingSince: 4, ...(await seal({ summary: { subject: 'On its way', to: [] }, item: { kind: 'email', args: [] } })) });
await put(db, 'queue', { id: 5, account: 'acc', kind: 'send', createdAt: 5, status: 'failed', ...(await seal({ summary: { subject: 'Refused', to: [] }, item: { kind: 'email', args: [] }, error: { text: 'No' } })) });
await put(db, 'queue', { id: 6, account: 'acc', kind: 'send', createdAt: 6, status: 'waiting', ...(await seal({ summary: { subject: 'Signed', to: [] }, item: { kind: 'raw', blobId: 'bulwark-local-raw', identityId: 'i' } })) });
await put(db, 'uploads', { key: 'acc\u0001bulwark-local-raw', account: 'acc', createdAt: 6, meta: await seal({ name: 'message.eml', type: 'message/rfc822' }), ...(await seal(new TextEncoder().encode(RAW))) });
// Someone else's, with offline mode off here.
await put(db, 'queue', { id: 7, account: 'stranger', kind: 'send', createdAt: 7, status: 'waiting', ...(await seal({ summary: { subject: 'Not moved', to: [] }, item: { kind: 'email', args: [] } })) });
db.close();
}
const oldExists = async () => (await indexedDB.databases()).some((d) => d.name === LEGACY_DB_NAME);
let store: InProcessStore;
beforeEach(async () => {
store = new InProcessStore();
setStoreBackend(store);
forgetLegacyMove();
useOfflineStore.setState({ enabled: ['acc'] });
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(LEGACY_DB_NAME);
request.onsuccess = () => resolve();
});
});
afterEach(() => {
setStoreBackend(null);
});
describe('moving the old outbox into the store', () => {
it('moves everything that can\'t be fetched again, then deletes the old database', async () => {
await seedOld();
await moveLegacyOutbox();
const { outbox, patches } = await readQueue('acc');
expect(outbox.map((entry) => [entry.summary.subject, entry.status])).toEqual([
['Waiting', 'waiting'],
['On its way', 'uncertain'],
['Refused', 'failed'],
['Signed', 'waiting'],
]);
expect(outbox[0].createdAt).toBe(3);
expect(outbox[0].fingerprint).toEqual({ labelRequired: false, hasLabel: false, guards: [] });
expect(outbox[2].error).toEqual({ text: 'No' });
// The patches of one message merge.
expect(patches).toBe(1);
const rows = await store.area<Array<{ patch: unknown }>>('acc', 'outbox.pendingPatches', {});
expect(rows[0].patch).toEqual({ 'keywords/$seen': true, 'keywords/$flagged': true });
// A waiting message gets a fixed id; one that may have left under another id doesn't pretend.
expect(outbox[0].messageId).toMatch(/@example\.org$/);
expect(outbox[1].messageId).toBeUndefined();
expect(outbox[3].messageId).toBe('raw-1@example.org');
const file = await store.area<{ name: string; data: Uint8Array }>('acc', 'outbox.heldFile', { blobId: 'bulwark-local-raw' });
expect(file.name).toBe('message.eml');
expect(new TextDecoder().decode(file.data)).toBe(RAW);
expect(await oldExists()).toBe(false);
expect(store.has('stranger')).toBe(false);
});
it('keeps the old database when the store can\'t take it, and moves it next time without doubling', async () => {
await seedOld();
store.failArea = (name) => name === 'outbox.importLegacy';
await moveLegacyOutbox();
expect(await oldExists()).toBe(true);
expect((await readQueue('acc')).outbox).toEqual([]);
store.failArea = null;
forgetLegacyMove();
await moveLegacyOutbox();
expect((await readQueue('acc')).outbox).toHaveLength(4);
expect(await oldExists()).toBe(false);
});
it('adds nothing when a later account failed and the first runs again', async () => {
await seedOld();
const db = await openOld();
await put(db, 'queue', { id: 8, account: 'second', kind: 'send', createdAt: 8, status: 'waiting', ...(await seal({ summary: { subject: 'Second', to: [] }, item: { kind: 'email', args: [] } })) });
db.close();
useOfflineStore.setState({ enabled: ['acc', 'second'] });
const realArea = store.area.bind(store);
let failSecond = true;
store.area = (async (account: string, name: string, params?: unknown) => {
if (failSecond && account === 'second' && name === 'outbox.importLegacy') throw new Error('full');
return realArea(account, name, params);
}) as typeof store.area;
await moveLegacyOutbox();
expect(await oldExists()).toBe(true);
expect((await readQueue('acc')).outbox).toHaveLength(4);
failSecond = false;
forgetLegacyMove();
await moveLegacyOutbox();
expect((await readQueue('acc')).outbox).toHaveLength(4);
expect((await readQueue('second')).outbox).toHaveLength(1);
expect(await oldExists()).toBe(false);
});
it('hands the kept session to the store, for a first start without a network', async () => {
await seedOld();
await moveLegacyOutbox();
expect(await store.area('acc', 'mail.session')).toMatchObject({ apiUrl: 'https://old/jmap' });
});
it('never creates the old database when it isn\'t there', async () => {
await moveLegacyOutbox();
expect(await oldExists()).toBe(false);
expect(store.has('acc')).toBe(false);
});
it('skips records sealed with a key that\'s gone, and finishes', async () => {
await seedOld();
const db = await openOld();
const other = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
await put(db, 'queue', { id: 9, account: 'acc', kind: 'send', createdAt: 9, status: 'waiting', iv, data: await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv as BufferSource }, other, new Uint8Array([1])) });
db.close();
await moveLegacyOutbox();
expect((await readQueue('acc')).outbox).toHaveLength(4);
expect(await oldExists()).toBe(false);
});
});
+262 -46
View File
@@ -6,25 +6,33 @@ import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { passSignOutGates, addSignOutGate, purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { setStoreBackend } from '@/lib/store/client/api';
import { FakeJmapMail } from '@/lib/store/__tests__/helpers/fake-jmap-mail';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { enforcePolicy, startOfflineMode, wipeEverything } from '../offline-mode';
import { flushQueue } from '../flush';
import { holdFile, queuePatch, readQueue } from '../queue';
import { emailIndex, forgetIndexes, putEmails } from '../cache';
import { forgetDeviceKey } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb } from '../db';
import { holdFile, readQueue } from '../queue';
import { forgetLegacyMove } from '../migrate-legacy';
import { useOfflineStore, writeEnabled, OFFLINE_ACCOUNTS_KEY } from '../state';
import type { JMAPClient } from '@/lib/jmap/client';
let server: FakeJmapMail;
let store: InProcessStore;
function fakeClient(overrides: Record<string, unknown> = {}) {
return {
getServerUrl: () => 'https://mail.example.org',
getUsername: () => 'alice@example.org',
getAccountId: () => 'j1',
getAccountId: () => 'acc1',
isOfflineOnly: () => false,
getSessionSnapshot: () => ({ apiUrl: 'https://mail.example.org/jmap' }),
getSessionSnapshot: () => null,
getAuthHeader: () => 'Basic x',
keywordSortInverted: () => null,
sendEmail: vi.fn(async () => ({ scheduled: false, emailId: 'sent-1', emailSubmissionId: 'sub-1' })),
uploadBlob: vi.fn(async (file: File) => ({ blobId: `server-${file.name}`, size: file.size, type: file.type })),
applyEmailPatches: vi.fn(async () => []),
requestNetworkOnly: vi.fn((calls: never) => server.request(calls)),
resolveSendAccountId: vi.fn(async () => 'acc1'),
submissionAccountFor: () => 'acc1',
pruneEmailSubmissions: vi.fn(async () => {}),
getMailboxes: vi.fn(async () => [{ id: 'sent', role: 'sent' }, { id: 'drafts', role: 'drafts' }]),
sendRawEmail: vi.fn(async () => ({ scheduled: false })),
@@ -41,20 +49,23 @@ function setOnline(online: boolean) {
let stop: () => void = () => {};
beforeEach(async () => {
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
allowOfflineDbCreation();
server = new FakeJmapMail({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }] });
server.addEmail({ id: 'm1', keywords: {}, subject: 'One' });
store = new InProcessStore(server);
setStoreBackend(store);
forgetLegacyMove();
localStorage.clear();
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false });
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], browserOffline: false, downServers: [], startedOffline: false });
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
});
afterEach(async () => {
stop();
stop = () => {};
setOnline(true);
await wipeEverything();
setStoreBackend(null);
});
const sendArgs = (client: unknown, options: Record<string, unknown>) => useEmailStore.getState().sendEmail(
@@ -62,8 +73,10 @@ const sendArgs = (client: unknown, options: Record<string, unknown>) => useEmail
'draft-1', 'Alice', '<p>Hi Bob</p>', [{ blobId: 'blob-1', name: 'a.txt', type: 'text/plain', size: 3 }], undefined, undefined, undefined, undefined, options as never,
);
const patchOffline = (update: Record<string, unknown>) => store.answer('acc', [['Email/set', { update }, '0']]);
describe('the outbox', () => {
it('keeps a composer send while offline, with its label, and sends nothing', async () => {
it('keeps a composer send while offline, with its label and a fixed Message-ID, and sends nothing', async () => {
const client = fakeClient();
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
@@ -77,11 +90,34 @@ describe('the outbox', () => {
expect(outbox).toHaveLength(1);
expect(outbox[0].summary).toEqual({ subject: 'Quarterly numbers', to: ['bob@example.org'] });
expect(outbox[0].item).toMatchObject({ kind: 'email' });
expect(outbox[0].messageId).toMatch(/@example\.org$/);
const options = (outbox[0].item as { args: unknown[] }).args[15] as Record<string, unknown>;
expect(options.labelHeaders).toEqual({ 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' });
expect(options).not.toHaveProperty('outbox');
});
it('sends with the Message-ID fixed at queueing, and the same one after a retry', async () => {
const client = fakeClient({
sendEmail: vi.fn()
.mockRejectedValueOnce(Object.assign(new Error('x'), { name: 'SendNotStartedError' }))
.mockResolvedValue({ scheduled: false }),
});
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
stop = startOfflineMode();
setOnline(false);
await sendArgs(client, { outbox: true });
const fixed = (await readQueue('acc')).outbox[0].messageId;
setOnline(true);
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
const calls = (client.sendEmail as ReturnType<typeof vi.fn>).mock.calls as unknown[][];
expect(calls).toHaveLength(2);
expect((calls[0][15] as { messageId: string }).messageId).toBe(fixed);
expect((calls[1][15] as { messageId: string }).messageId).toBe(fixed);
expect((await readQueue('acc')).outbox).toEqual([]);
});
it('leaves sends from elsewhere (plugin forward, phishing report) to the network', async () => {
const client = fakeClient({ sendEmail: vi.fn(async () => { throw new TypeError('Failed to fetch'); }) });
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
@@ -105,14 +141,18 @@ describe('the outbox', () => {
it('sends what waited in order once the server answers: changes first, then mail', async () => {
const order: string[] = [];
const client = fakeClient({
applyEmailPatches: vi.fn(async () => { order.push('patch'); return []; }),
requestNetworkOnly: vi.fn(async (calls: Array<[string]>) => {
if (calls[0][0] === 'Email/set') order.push('patch');
return server.request(calls as never);
}),
sendEmail: vi.fn(async () => { order.push('send'); return { scheduled: false }; }),
});
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
stop = startOfflineMode();
await store.sync('acc');
setOnline(false);
await queuePatch('acc', 'j1', { m1: { 'keywords/$seen': true } });
await patchOffline({ m1: { 'keywords/$seen': true } });
const held = await holdFile('acc', new File(['hello'], 'note.txt', { type: 'text/plain' }), 'note.txt');
await useEmailStore.getState().sendEmail(client as never, ['bob@example.org'], 'With file', 'x', undefined, undefined, 'id-1', 'alice@example.org', undefined, 'Alice', undefined,
[{ blobId: held.blobId, name: 'note.txt', type: 'text/plain', size: 5 }], undefined, undefined, new Date(Date.now() + 5000).toISOString(), undefined, { outbox: true } as never);
@@ -121,7 +161,7 @@ describe('the outbox', () => {
const notify = vi.fn();
await flushQueue('acc', client as unknown as JMAPClient, { notify, allowed: () => true });
expect(order).toEqual(['patch', 'send']);
expect(client.applyEmailPatches).toHaveBeenCalledWith('j1', { m1: { 'keywords/$seen': true } });
expect(server.emails.get('m1')?.keywords).toEqual({ $seen: true });
const args = (client.sendEmail as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[];
expect(args[1]).toBe('With file');
// The file attached offline was uploaded first; the passed undo hold was dropped.
@@ -130,7 +170,28 @@ describe('the outbox', () => {
expect(notify).toHaveBeenCalledWith({ kind: 'sent', subject: 'With file' });
const left = await readQueue('acc');
expect(left.outbox).toHaveLength(0);
expect(left.patches).toHaveLength(0);
expect(left.patches).toBe(0);
});
it('a change made after a send goes after it', async () => {
const order: string[] = [];
const client = fakeClient({
requestNetworkOnly: vi.fn(async (calls: Array<[string]>) => {
if (calls[0][0] === 'Email/set') order.push('patch');
return server.request(calls as never);
}),
sendEmail: vi.fn(async () => { order.push('send'); return { scheduled: false }; }),
});
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
stop = startOfflineMode();
await store.sync('acc');
setOnline(false);
await sendArgs(client, { outbox: true });
await patchOffline({ m1: { 'keywords/$answered': true } });
setOnline(true);
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
expect(order).toEqual(['send', 'patch']);
});
it('keeps a send the server refused, marked failed, and one cut off on the way, marked uncertain', async () => {
@@ -146,10 +207,13 @@ describe('the outbox', () => {
expect(outbox[0].status).toBe('failed');
expect(outbox[0].error).toEqual({ text: 'Recipient address rejected' });
const cut = fakeClient({ sendEmail: vi.fn(async () => { throw new TypeError('Failed to fetch'); }) });
await wipeEverything();
allowOfflineDbCreation();
store.stop();
writeEnabled(['acc']);
// The lookup that follows can't reach the server either, so it stays uncertain.
const cut = fakeClient({
sendEmail: vi.fn(async () => { throw new TypeError('Failed to fetch'); }),
requestNetworkOnly: vi.fn(async () => { throw new TypeError('Failed to fetch'); }),
});
useAuthStore.setState({ client: cut as never, activeAccountId: 'acc' });
setOnline(false);
await sendArgs(cut, { outbox: true });
@@ -164,9 +228,133 @@ describe('the outbox', () => {
it('sends nothing once the organisation forbids offline mode', async () => {
const client = fakeClient();
await queuePatch('acc', 'j1', { m1: { 'keywords/$seen': true } });
await store.sync('acc');
await patchOffline({ m1: { 'keywords/$seen': true } });
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => false });
expect(client.applyEmailPatches).not.toHaveBeenCalled();
expect(client.requestNetworkOnly).not.toHaveBeenCalled();
expect(server.emails.get('m1')?.keywords).toEqual({});
});
});
describe('a send whose response got lost', () => {
async function uncertainSend(client: ReturnType<typeof fakeClient>) {
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
stop = startOfflineMode();
setOnline(false);
await sendArgs(client, { outbox: true });
setOnline(true);
const entry = (await readQueue('acc')).outbox[0];
await store.area('acc', 'outbox.claim', { id: entry.id });
await store.area('acc', 'outbox.update', { id: entry.id, status: 'uncertain' });
return entry;
}
const flush = (client: ReturnType<typeof fakeClient>, notify = vi.fn()) => flushQueue('acc', client as unknown as JMAPClient, { notify, allowed: () => true });
const copy = (messageId: string | undefined, over: Record<string, unknown>) => server.addEmail({
id: 'copy', messageId: [messageId as string], receivedAt: new Date().toISOString(), ...over,
});
it('never arrived: sends it again, with the same Message-ID', async () => {
const client = fakeClient();
const entry = await uncertainSend(client);
const notify = vi.fn();
await flush(client, notify);
expect(client.sendEmail).toHaveBeenCalledTimes(1);
const args = (client.sendEmail as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[];
expect((args[15] as { messageId?: string; existingCopyId?: string })).toMatchObject({ messageId: entry.messageId });
expect(args[15]).not.toHaveProperty('existingCopyId');
expect(notify).toHaveBeenCalledWith({ kind: 'sent', subject: 'Quarterly numbers' });
});
it('draft arrived, submission didn\'t: submits that copy and uploads nothing again', async () => {
const client = fakeClient();
const entry = await uncertainSend(client);
copy(entry.messageId, { mailboxIds: { drafts: true }, keywords: { $draft: true } });
await flush(client);
expect(client.sendEmail).toHaveBeenCalledTimes(1);
const args = (client.sendEmail as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[];
expect(args[15]).toMatchObject({ messageId: entry.messageId, existingCopyId: 'copy' });
expect(client.uploadBlob).not.toHaveBeenCalled();
});
it('both arrived: done, nothing sent, and the old draft goes', async () => {
const client = fakeClient();
const entry = await uncertainSend(client);
copy(entry.messageId, { mailboxIds: { sent: true }, keywords: {} });
server.addEmail({ id: 'draft-1', mailboxIds: { drafts: true }, keywords: { $draft: true } });
const notify = vi.fn();
await flush(client, notify);
expect(client.sendEmail).not.toHaveBeenCalled();
expect((await readQueue('acc')).outbox).toEqual([]);
expect(server.emails.has('draft-1')).toBe(false);
expect(notify).toHaveBeenCalledWith({ kind: 'sent', subject: 'Quarterly numbers' });
});
it('scheduled: done, the submission stays', async () => {
const client = fakeClient();
const entry = await uncertainSend(client);
copy(entry.messageId, { mailboxIds: { drafts: true }, keywords: { $draft: true } });
server.addSubmission('copy', 'pending');
await flush(client);
expect(client.sendEmail).not.toHaveBeenCalled();
expect((await readQueue('acc')).outbox).toEqual([]);
});
it('canceled: done without a "sent" notice, and it doesn\'t go', async () => {
const client = fakeClient();
const entry = await uncertainSend(client);
copy(entry.messageId, { mailboxIds: { drafts: true }, keywords: { $draft: true } });
server.addSubmission('copy', 'canceled');
const notify = vi.fn();
await flush(client, notify);
expect(client.sendEmail).not.toHaveBeenCalled();
expect((await readQueue('acc')).outbox).toEqual([]);
expect(notify).not.toHaveBeenCalledWith(expect.objectContaining({ kind: 'sent' }));
});
it('a lookup that can\'t decide leaves it uncertain for the user, who can still send it', async () => {
const client = fakeClient();
const entry = await uncertainSend(client);
server.failNext.set('Email/query', 'forbidden');
await flush(client);
expect(client.sendEmail).not.toHaveBeenCalled();
expect((await readQueue('acc')).outbox[0].status).toBe('uncertain');
const { retryOutboxEntry } = await import('../flush');
await retryOutboxEntry('acc', entry.id);
expect((await readQueue('acc')).outbox[0].status).toBe('waiting');
await flush(client);
expect(client.sendEmail).toHaveBeenCalledTimes(1);
});
it('a send that fails on the server clears the copy, so a retry starts clean', async () => {
const client = fakeClient({ sendEmail: vi.fn(async () => { throw new Error('Refused'); }) });
const entry = await uncertainSend(client);
copy(entry.messageId, { mailboxIds: { drafts: true }, keywords: { $draft: true } });
await flush(client);
const [after] = (await readQueue('acc')).outbox;
expect(after.status).toBe('failed');
expect(after.copyId).toBeUndefined();
});
});
describe('conflicts reach the page', () => {
it('lists a refused change and tells the user', async () => {
const client = fakeClient();
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
stop = startOfflineMode();
await store.sync('acc');
await patchOffline({ m1: { 'keywords/$seen': true } });
server.refuse.set('m1', { type: 'forbidden' });
const notify = vi.fn();
await flushQueue('acc', client as unknown as JMAPClient, { notify, allowed: () => true });
expect(notify).toHaveBeenCalledWith({ kind: 'conflict', count: 1 });
const { conflicts, patches } = await readQueue('acc');
expect(patches).toBe(0);
expect(conflicts).toEqual([expect.objectContaining({ account: 'acc', recordId: 'm1', summary: expect.objectContaining({ subject: 'One', reason: 'refused' }) })]);
});
});
@@ -176,39 +364,66 @@ describe('wiping offline data', () => {
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
stop = startOfflineMode();
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } });
enforcePolicy();
await vi.waitFor(async () => expect(useOfflineStore.getState().enabled).toEqual([]));
// Its only account: the whole database goes.
await vi.waitFor(async () => expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-offline'));
// Its only account: the whole store goes.
await vi.waitFor(() => expect(store.stopped).toBeGreaterThan(0));
expect(localStorage.getItem(OFFLINE_ACCOUNTS_KEY)).toBeNull();
});
it('wipes an account that signs out', async () => {
it('wipes an account that signs out or is removed on purpose', async () => {
useAccountStore.setState({ accounts: [{ id: 'acc' } as never, { id: 'other' } as never] });
writeEnabled(['acc', 'other']);
stop = startOfflineMode();
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
await putEmails('other', 'j1', [{ id: 'm2', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
useAccountStore.setState({ accounts: [{ id: 'other' } as never] });
await store.sync('acc');
await store.sync('other');
useAccountStore.getState().removeAccount('acc');
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['other']));
await vi.waitFor(async () => {
forgetIndexes();
expect((await emailIndex('acc')).size).toBe(0);
});
expect((await emailIndex('other')).size).toBe(1);
await vi.waitFor(() => expect(store.removed).toEqual(['acc']));
expect(store.has('acc')).toBe(false);
expect(store.has('other')).toBe(true);
});
it('deletes the database and the switch at a full sign-out', async () => {
it('keeps the data of an account the app dropped by itself', async () => {
useAccountStore.setState({ accounts: [{ id: 'acc' } as never, { id: 'other' } as never] });
writeEnabled(['acc', 'other']);
stop = startOfflineMode();
await store.sync('acc');
useAccountStore.getState().removeAccount('acc', 'evicted');
await new Promise((r) => setTimeout(r, 20));
expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual(['other']);
expect(useOfflineStore.getState().enabled).toEqual(['acc', 'other']);
expect(store.removed).toEqual([]);
});
it('deletes the old database, the key database and the switch at a full sign-out', async () => {
writeEnabled(['acc']);
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: new Date().toISOString() }]);
forgetIndexes();
purgeSignedOutData();
expect(localStorage.getItem(OFFLINE_ACCOUNTS_KEY)).toBeNull();
await vi.waitFor(async () => {
const dbs = await indexedDB.databases();
expect(dbs.map((d) => d.name)).not.toContain('bulwark-offline');
await new Promise<void>((resolve) => {
const request = indexedDB.open('bulwark-offline');
request.onsuccess = () => { request.result.close(); resolve(); };
});
await new Promise<void>((resolve) => {
const request = indexedDB.open('bulwark-store');
request.onsuccess = () => { request.result.close(); resolve(); };
});
const removeEntry = vi.fn(async () => {});
const getDirectory = vi.fn(async () => ({ removeEntry }));
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory } });
try {
purgeSignedOutData();
expect(localStorage.getItem(OFFLINE_ACCOUNTS_KEY)).toBeNull();
await vi.waitFor(async () => {
const dbs = (await indexedDB.databases()).map((d) => d.name);
expect(dbs).not.toContain('bulwark-offline');
expect(dbs).not.toContain('bulwark-store');
});
// With no worker running: straight from the page.
await vi.waitFor(() => expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true }));
expect(store.stopped).toBeGreaterThan(0);
} finally {
delete (window.navigator as { storage?: unknown }).storage;
}
});
});
@@ -222,13 +437,14 @@ describe('signing out with unsent mail', () => {
});
describe('after a sign-out', () => {
it('starting offline mode on the sign-in page doesn\'t create the database again', async () => {
const stop = startOfflineMode();
it('starting offline mode on the sign-in page doesn\'t create the store again', async () => {
const stopHere = startOfflineMode();
await new Promise((r) => setTimeout(r, 50));
const { refreshQueueState, refreshUsage } = await import('../offline-mode');
await refreshQueueState();
await refreshUsage();
stop();
expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-offline');
stopHere();
expect(store.engines.size).toBe(0);
expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-store');
});
});
-122
View File
@@ -1,122 +0,0 @@
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { answerOffline, type ResponderContext } from '../responder';
import { emailIndex, emailKey, forgetIndexes, putEmails, putMisc } from '../cache';
import { forgetDeviceKey } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb } from '../db';
const day = 24 * 60 * 60 * 1000;
const at = (ageDays: number) => new Date(Date.now() - ageDays * day).toISOString();
let queued: Array<{ jmap: string; update: Record<string, Record<string, unknown>> }>;
let refused: string[];
const ctx: ResponderContext = {
account: 'acc',
defaultJmap: 'j1',
queuePatch: async (jmap, update) => { queued.push({ jmap, update }); },
onRefused: (what) => { refused.push(what); },
};
beforeEach(async () => {
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
allowOfflineDbCreation();
queued = [];
refused = [];
await putEmails('acc', 'j1', [
{ id: 'a', threadId: 't1', mailboxIds: { inbox: true }, keywords: {}, receivedAt: at(3), subject: 'Older', bodyValues: { 1: { value: 'Body A' } }, textBody: [{ partId: '1' }] },
{ id: 'b', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $flagged: true }, receivedAt: at(1), subject: 'Newer' },
{ id: 'c', threadId: 't2', mailboxIds: { archive: true }, keywords: {}, receivedAt: at(2), subject: 'Archived' },
]);
await putMisc('acc', 'mailboxes', { j1: [
{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 2, unreadEmails: 1 },
{ id: 'archive', name: 'Archive', role: 'archive', totalEmails: 1, unreadEmails: 1 },
] });
});
describe('answering a folder list offline', () => {
it('answers the list request the client makes, back-references included', async () => {
const response = await answerOffline(ctx, [
['Email/query', { accountId: 'j1', filter: { inMailbox: 'inbox' }, sort: [{ property: 'receivedAt', isAscending: false }], limit: 50, position: 0, calculateTotal: true }, '0'],
['Email/get', { accountId: 'j1', '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'threadId', 'subject', 'keywords'] }, '1'],
['Thread/get', { accountId: 'j1', '#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' } }, '2'],
]);
expect(response).not.toBeNull();
const [query, get, thread] = response!.methodResponses;
expect(query[1].ids).toEqual(['b', 'a']);
expect(query[1].total).toBe(2);
expect(get[1].list).toEqual([
{ id: 'b', threadId: 't1', subject: 'Newer', keywords: { $flagged: true } },
{ id: 'a', threadId: 't1', subject: 'Older', keywords: {} },
]);
expect(thread[1].list).toEqual([{ id: 't1', emailIds: ['a', 'b'] }]);
});
it('pins flagged mail first when the list sorts that way', async () => {
const response = await answerOffline(ctx, [
['Email/query', { filter: { inMailbox: 'inbox' }, sort: [{ property: 'receivedAt', isAscending: true }] }, '0'],
]);
expect(response!.methodResponses[0][1].ids).toEqual(['a', 'b']);
const pinned = await answerOffline(ctx, [
['Email/query', { filter: { inMailbox: 'inbox' }, sort: [{ property: 'hasKeyword', keyword: '$flagged', isAscending: false }, { property: 'receivedAt', isAscending: true }] }, '0'],
]);
expect(pinned!.methodResponses[0][1].ids).toEqual(['b', 'a']);
});
it('opens a kept message with its body and refuses one without', async () => {
const full = { properties: ['id', 'subject', 'bodyValues', 'textBody'], fetchTextBodyValues: true };
const response = await answerOffline(ctx, [['Email/get', { ids: ['a', 'b'], ...full }, '0']]);
const result = response!.methodResponses[0][1];
expect(result.list).toEqual([{ id: 'a', subject: 'Older', bodyValues: { 1: { value: 'Body A' } }, textBody: [{ partId: '1' }] }]);
expect(result.notFound).toEqual(['b']);
});
it('answers the folder list', async () => {
const response = await answerOffline(ctx, [['Mailbox/get', { accountId: 'j1' }, '0']]);
expect(response!.methodResponses[0][1].list.map((m: { id: string }) => m.id)).toEqual(['inbox', 'archive']);
});
it('leaves search to the server', async () => {
expect(await answerOffline(ctx, [['Email/query', { filter: { text: 'invoice' } }, '0']])).toBeNull();
expect(await answerOffline(ctx, [['SearchSnippet/get', {}, '0']])).toBeNull();
});
});
describe('changes made offline', () => {
it('queues read, flag and move, and shows them at once', async () => {
const response = await answerOffline(ctx, [
['Email/set', { accountId: 'j1', update: { a: { 'keywords/$seen': true, 'keywords/$flagged': true }, c: { 'mailboxIds/archive': null, 'mailboxIds/inbox': true } } }, '0'],
]);
expect(response!.methodResponses[0][1].updated).toEqual({ a: null, c: null });
expect(queued).toEqual([{ jmap: 'j1', update: { a: { 'keywords/$seen': true, 'keywords/$flagged': true }, c: { 'mailboxIds/archive': null, 'mailboxIds/inbox': true } } }]);
const index = await emailIndex('acc');
expect(index.get(emailKey('acc', 'j1', 'a'))?.keywords).toEqual({ $seen: true, $flagged: true });
const inbox = await answerOffline(ctx, [['Email/query', { filter: { inMailbox: 'inbox' } }, '0']]);
expect(inbox!.methodResponses[0][1].ids).toEqual(['b', 'c', 'a']);
});
it('keeps the folder counts in step', async () => {
await answerOffline(ctx, [['Email/set', { update: { a: { 'keywords/$seen': true }, c: { 'mailboxIds/archive': null, 'mailboxIds/inbox': true } } }, '0']]);
const boxes = (await answerOffline(ctx, [['Mailbox/get', {}, '0']]))!.methodResponses[0][1].list;
expect(boxes.find((b: { id: string }) => b.id === 'inbox')).toMatchObject({ totalEmails: 3, unreadEmails: 1 });
expect(boxes.find((b: { id: string }) => b.id === 'archive')).toMatchObject({ totalEmails: 0, unreadEmails: 0 });
});
it('refuses deleting for good and emptying a folder', async () => {
expect(await answerOffline(ctx, [['Email/set', { destroy: ['a'] }, '0']])).toBeNull();
expect(await answerOffline(ctx, [['Email/set', { update: { a: { mailboxIds: {} } } }, '0']])).toBeNull();
expect(await answerOffline(ctx, [['Email/set', { update: { c: { 'mailboxIds/archive': null } } }, '0']])).toBeNull();
expect(refused).toEqual(['destroy', 'destroy', 'destroy']);
expect(queued).toEqual([]);
expect((await emailIndex('acc')).size).toBe(3);
});
it('refuses other changes and new messages without queueing', async () => {
const onRefused = vi.fn();
expect(await answerOffline({ ...ctx, onRefused }, [['Email/set', { update: { a: { subject: 'x' } } }, '0']])).toBeNull();
expect(await answerOffline({ ...ctx, onRefused }, [['Email/set', { create: { d: { subject: 'draft' } } }, '0']])).toBeNull();
expect(onRefused).toHaveBeenCalledTimes(1);
expect(queued).toEqual([]);
});
});
+58 -64
View File
@@ -9,32 +9,28 @@ import { runBeforeSignOut } from '@/lib/sign-out-cleanup';
import { setSendGuardSource } from '@/lib/plugin-send-guards';
import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
import { beginComposeTakeover, completesComposeTakeover, endComposeTakeover, resetComposeTakeovers } from '@/lib/outgoing/compose-takeover';
import { setStoreBackend } from '@/lib/store/client/api';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { FakeJmapMail } from '@/lib/store/__tests__/helpers/fake-jmap-mail';
import { JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap';
import type { JMAPClient } from '@/lib/jmap/client';
import type { InstalledPlugin } from '@/lib/plugin-types';
import {
confirmLoss,
enforcePolicy,
inScope,
offlineSessionFor,
pluginsNotRunning,
recordAnswer,
scopeOf,
startOfflineMode,
stricterNow,
wipeEverything,
} from '../offline-mode';
import { flushQueue, settleStaleSends, SENDING_STALE_MS } from '../flush';
import { claimForSending, holdFile, queueSend, readQueue } from '../queue';
import { emailIndex, emailKey, forgetIndexes, getBlob, messageOfBlob, putBlob, putEmails, putMisc, removeEmails } from '../cache';
import { forgetDeviceKey } from '../crypto';
import { allowOfflineDbCreation, deleteOfflineDb, idbGetAll, openOfflineDb } from '../db';
import { forgetLegacyMove } from '../migrate-legacy';
import { consumeLost } from '../lost';
import { shellFiles } from '../shell';
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, setServerDown, useOfflineStore, writeEnabled } from '../state';
const day = 24 * 60 * 60 * 1000;
const recent = () => new Date(Date.now() - day).toISOString();
function fakeClient(server = 'https://mail.example.org', overrides: Record<string, unknown> = {}) {
return {
getServerUrl: () => server,
@@ -42,9 +38,10 @@ function fakeClient(server = 'https://mail.example.org', overrides: Record<strin
getAccountId: () => 'j1',
isOfflineOnly: () => false,
getSessionSnapshot: () => ({ apiUrl: `${server}/jmap` }),
getAuthHeader: () => 'Basic x',
keywordSortInverted: () => null,
sendEmail: vi.fn(async () => ({ scheduled: false, emailId: 'sent-1', emailSubmissionId: 'sub-1' })),
uploadBlob: vi.fn(async (file: File) => ({ blobId: `server-${file.name}`, size: file.size, type: file.type })),
applyEmailPatches: vi.fn(async () => []),
pruneEmailSubmissions: vi.fn(async () => {}),
...overrides,
};
@@ -59,16 +56,16 @@ function setOnline(online: boolean) {
}
let stop: () => void = () => {};
let store: InProcessStore;
beforeEach(async () => {
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
allowOfflineDbCreation();
store = new InProcessStore();
setStoreBackend(store);
forgetLegacyMove();
localStorage.clear();
sessionStorage.clear();
setOnline(true);
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false });
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], browserOffline: false, downServers: [], startedOffline: false });
setPolicyOrg({});
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'acc', client: null });
@@ -82,19 +79,20 @@ afterEach(async () => {
setOnline(true);
setSendGuardSource(null);
await wipeEverything();
setStoreBackend(null);
});
describe('1: two tabs never send the same message', () => {
it('only one claim wins', async () => {
const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [] });
const results = await Promise.all([claimForSending(id), claimForSending(id)]);
const results = await Promise.all([claimForSending('acc', id), claimForSending('acc', id)]);
expect(results.filter(Boolean)).toHaveLength(1);
expect((await readQueue('acc')).outbox[0].status).toBe('sending');
});
it('a message another tab took isn’t sent here', async () => {
const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [['b@example.org'], 'S', 'x'] });
await claimForSending(id);
await claimForSending('acc', id);
const client = fakeClient();
await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true });
expect(client.sendEmail).not.toHaveBeenCalled();
@@ -112,7 +110,7 @@ describe('1: two tabs never send the same message', () => {
});
});
describe('2: a send that may have reached the server is never queued or sent again', () => {
describe('2: a send that may have reached the server is never queued or sent again blindly', () => {
it('puts a send back to waiting only when it never started', async () => {
const notStarted = Object.assign(new Error('x'), { name: 'SendNotStartedError' });
const client = fakeClient(undefined, { sendEmail: vi.fn(async () => { throw notStarted; }) });
@@ -158,7 +156,7 @@ describe('3: every sign-out the user didn’t agree to names the lost messages',
setOfflineNotifier((n) => notices.push(n));
stop = startOfflineMode();
await queueSend('acc', { subject: 'Lost one', to: [] }, { kind: 'email', args: [] });
useAccountStore.setState({ accounts: [{ id: 'other' } as never] });
useAccountStore.getState().removeAccount('acc');
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'lost', subjects: ['Lost one'] }));
setOfflineNotifier(() => {});
});
@@ -186,16 +184,17 @@ describe('4: no queueing while a plugin isn’t running', () => {
});
describe('5: another tab switching it off sticks', () => {
it('follows the switch from another tab and doesn’t create the database again', async () => {
it('follows the switch from another tab and doesn’t open the store for it again', async () => {
writeEnabled(['acc']);
stop = startOfflineMode();
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: recent() }]);
// The other tab: switched off, database deleted.
await deleteOfflineDb();
await vi.waitFor(() => expect(store.has('acc')).toBe(true));
await new Promise((r) => setTimeout(r, 10));
store.stop();
window.dispatchEvent(new StorageEvent('storage', { key: OFFLINE_ACCOUNTS_KEY, newValue: null }));
expect(useOfflineStore.getState().enabled).toEqual([]);
await expect(openOfflineDb()).rejects.toMatchObject({ name: 'OfflineDbMissingError' });
expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-offline');
await new Promise((r) => setTimeout(r, 20));
expect(store.has('acc')).toBe(false);
expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-store');
});
});
@@ -216,7 +215,7 @@ describe('6: queued mail and a stricter policy', () => {
describe('7: a send of a closed tab isn’t stuck', () => {
it('becomes uncertain after a minute', async () => {
const id = await queueSend('acc', { subject: 'S', to: [] }, { kind: 'email', args: [] });
await claimForSending(id);
await claimForSending('acc', id);
await settleStaleSends('acc');
expect((await readQueue('acc')).outbox[0].status).toBe('sending');
await settleStaleSends('acc', Date.now() + SENDING_STALE_MS + 1000);
@@ -225,21 +224,31 @@ describe('7: a send of a closed tab isn’t stuck', () => {
});
describe('8: only parts of kept messages are kept, and go with them', () => {
it('knows which message a blob belongs to and deletes it with the message', async () => {
await putEmails('acc', 'j1', [{ id: 'm1', mailboxIds: { inbox: true }, receivedAt: recent(), bodyValues: {}, attachments: [{ blobId: 'b1', name: 'a.pdf' }] }]);
expect(await messageOfBlob('acc', 'j1', 'b1')).toMatchObject({ key: emailKey('acc', 'j1', 'm1') });
expect(await messageOfBlob('acc', 'j1', 'export-of-a-whole-message')).toBeNull();
await putBlob('acc', 'j1', 'b1', new Blob(['x']), Date.now(), emailKey('acc', 'j1', 'm1'));
await removeEmails('acc', [emailKey('acc', 'j1', 'm1')]);
expect(await getBlob('acc', 'j1', 'b1')).toBeNull();
it('files a blob under its message and deletes it with the message', async () => {
const server = new FakeJmapMail();
server.addEmail({ id: 'm1', attachments: [{ blobId: 'b1', name: 'a.pdf', size: 99_999_999 }] });
store = new InProcessStore(server);
setStoreBackend(store);
await store.sync('acc');
expect(await store.area('acc', 'mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'export-of-a-whole-message', type: null, data: new Uint8Array([1]) })).toBe(false);
expect(await store.area('acc', 'mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b1', type: null, data: new Uint8Array([1]) })).toBe(true);
server.destroyEmail('m1');
await store.sync('acc');
expect(await store.area('acc', 'mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b1' })).toBeNull();
});
});
describe('9: folders that went away', () => {
it('drops opened folders the folder list no longer has', async () => {
await putMisc('acc', 'mailboxes', { j1: [{ id: 'inbox', role: 'inbox' }, { id: 'kept' }] });
await putMisc('acc', 'opened', { j1: ['kept', 'deleted'] });
expect((await scopeOf('acc')).opened).toEqual({ j1: ['kept'] });
it('drops a folder the server no longer has', async () => {
const server = new FakeJmapMail({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'kept' }, { id: 'deleted' }] });
store = new InProcessStore(server);
setStoreBackend(store);
await store.sync('acc');
server.mailboxes.delete('deleted');
server.coll.Mailbox.push('destroyed', 'deleted');
await store.sync('acc');
const answer = await store.answer('acc', [['Mailbox/get', {}, '0']]);
expect(answer?.methodResponses[0][1].list.map((box: { id: string }) => box.id).sort()).toEqual(['inbox', 'kept']);
});
});
@@ -259,7 +268,6 @@ describe('10: no decision on an account whose groups aren’t known yet', () =>
describe('11: starting from the device', () => {
it('uses the policy kept from the last fetch, never the defaults', async () => {
writeEnabled(['acc']);
await putMisc('acc', 'session', { apiUrl: 'x' });
localStorage.setItem('bulwark-policy-last', JSON.stringify({ ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } }));
usePolicyStore.setState({ loaded: false, fetching: false, source: 'none' });
vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('Failed to fetch'); }));
@@ -270,6 +278,11 @@ describe('11: starting from the device', () => {
vi.unstubAllGlobals();
}
});
it('hands out the session kept with the account', async () => {
writeEnabled(['acc']);
expect(await offlineSessionFor('acc')).toMatchObject({ primaryAccounts: { 'urn:ietf:params:jmap:mail': JMAP_ACCOUNT } });
});
});
describe('12: one server down doesn’t hold another’s mail', () => {
@@ -281,10 +294,13 @@ describe('12: one server down doesn’t hold another’s mail', () => {
});
describe('13: file names stay sealed', () => {
it('keeps no name or type of a held file readable', async () => {
await holdFile('acc', new File(['x'], 'merger-plan.pdf', { type: 'application/pdf' }), 'merger-plan.pdf');
const records = await idbGetAll<Record<string, unknown>>('uploads');
expect(JSON.stringify(records.map((r) => ({ ...r, data: undefined, iv: undefined, meta: undefined })))).not.toMatch(/merger|pdf/);
it('keeps the name and type of a held file only in the encrypted store', async () => {
const held = await holdFile('acc', new File(['x'], 'merger-plan.pdf', { type: 'application/pdf' }), 'merger-plan.pdf');
expect(held.blobId).not.toMatch(/merger|pdf/);
const db = store.dbs.get('acc')!;
expect(db.get<{ name: string }>('SELECT name FROM upload')?.name).toBe('merger-plan.pdf');
const everythingElse = JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage }, dbs: await indexedDB.databases() });
expect(everythingElse).not.toMatch(/merger/);
});
});
@@ -318,25 +334,3 @@ describe('15: only the plugin running the composer’s send counts', () => {
expect(completesComposeTakeover('smime', 'i1')).toBe(false);
});
});
describe('recording: what the cache takes', () => {
it('keeps recent mail of the Inbox and opened folders, refreshes kept messages, skips the rest', async () => {
const scope = { inbox: { j1: 'inbox' }, opened: { j1: ['projects'] } };
expect(inScope(scope, 'j1', { id: 'a', mailboxIds: { inbox: true }, receivedAt: recent() })).toBe(true);
expect(inScope(scope, 'j1', { id: 'b', mailboxIds: { projects: true }, receivedAt: recent() })).toBe(true);
expect(inScope(scope, 'j1', { id: 'c', mailboxIds: { archive: true }, receivedAt: recent() })).toBe(false);
expect(inScope(scope, 'j1', { id: 'd', mailboxIds: { inbox: true }, receivedAt: new Date(Date.now() - 40 * day).toISOString() })).toBe(false);
await putMisc('acc', 'mailboxes', { j1: [{ id: 'inbox', role: 'inbox', name: 'Inbox' }] });
await recordAnswer('acc', 'j1', [['Email/get', { ids: ['a', 'c'] }, '0'], ['Mailbox/get', { properties: ['id', 'role'] }, '1']], {
methodResponses: [
['Email/get', { accountId: 'j1', list: [{ id: 'a', mailboxIds: { inbox: true }, receivedAt: recent() }, { id: 'c', mailboxIds: { archive: true }, receivedAt: recent() }] }, '0'],
['Mailbox/get', { accountId: 'j1', list: [{ id: 'inbox', role: 'inbox' }] }, '1'],
],
});
const index = await emailIndex('acc');
expect([...index.values()].map((e) => e.id)).toEqual(['a']);
// A partial folder list (id and role only) doesn't replace the full one.
expect((await scopeOf('acc')).inbox).toEqual({ j1: 'inbox' });
});
});
-310
View File
@@ -1,310 +0,0 @@
/**
* The mail kept on this device for offline mode (#523), per Bulwark account
* (`account`, the local account id) and JMAP account (`jmap`).
*
* Messages are kept in two parts: what the list shows (emails) and the text
* and structure of the message (bodies), so a folder list never decrypts
* the bodies. Attachments and inline images are blobs. Every value is
* encrypted (crypto.ts); only the ledger (size, date) and the keys (ids) are
* not.
*
* The cache holds at most CACHE_CAP_BYTES on the device, across accounts;
* the oldest messages go first, with their bodies and attachments. The
* outbox (queue.ts) doesn't count and never goes.
*/
import { idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbWrite, type StoreName } from './db';
import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto';
/** Most bytes the cache keeps on the device (messages, bodies, attachments). */
export const CACHE_CAP_BYTES = 250 * 1024 * 1024;
/** How far back the Inbox (and the folders the user opened) are kept. */
export const CACHE_DAYS = 30;
export const CACHE_WINDOW_MS = CACHE_DAYS * 24 * 60 * 60 * 1000;
/** Where the metadata keeps the blob ids of a message's parts (never handed out). */
export const BLOBS = '_bulwarkBlobs';
/** The parts of a message that only the reader needs. */
const BODY_KEYS = ['bodyValues', 'textBody', 'htmlBody', 'bodyStructure', 'headers', 'attachments'] as const;
export type CachedEmail = Record<string, unknown> & {
id: string;
threadId?: string;
mailboxIds?: Record<string, boolean>;
keywords?: Record<string, boolean>;
receivedAt?: string;
};
interface SealedRecord extends Sealed {
key: string;
account: string;
}
interface LedgerEntry {
key: string;
account: string;
store: StoreName;
ref: string;
/** The message's date (ms), for oldest-first eviction. */
at: number;
bytes: number;
/** The message a body or blob belongs to. */
emailKey?: string;
}
const join = (...parts: string[]) => parts.join('\u0001');
export const emailKey = (account: string, jmap: string, id: string) => join(account, jmap, id);
const blobKey = (account: string, jmap: string, blobId: string) => join(account, jmap, 'blob', blobId);
const miscKey = (account: string, name: string) => join(account, 'misc', name);
const ledgerKey = (store: StoreName, key: string) => `${store}:${key}`;
const dateOf = (email: { receivedAt?: unknown }) => {
const at = typeof email.receivedAt === 'string' ? Date.parse(email.receivedAt) : NaN;
return Number.isFinite(at) ? at : 0;
};
const byteSize = (sealed: Sealed) => sealed.data.byteLength + 64;
// Decrypted message metadata per account, loaded on first use.
const indexes = new Map<string, Promise<Map<string, CachedEmail>>>();
/** The decrypted metadata of every cached message of an account, by emailKey. */
export function emailIndex(account: string): Promise<Map<string, CachedEmail>> {
let index = indexes.get(account);
if (!index) {
index = (async () => {
const map = new Map<string, CachedEmail>();
const records = await idbGetAllByAccount<SealedRecord>('emails', account);
for (const record of records) {
try {
map.set(record.key, await openJson<CachedEmail>(record));
} catch {
// Sealed with a key that is gone: dropped at the next write.
}
}
return map;
})();
index.catch(() => indexes.delete(account));
indexes.set(account, index);
}
return index;
}
/** Forget what is held in memory (after a wipe). */
export function forgetIndexes(account?: string): void {
if (account) indexes.delete(account);
else indexes.clear();
}
function splitBody(email: CachedEmail): { meta: CachedEmail; body: Record<string, unknown> | null } {
const meta: CachedEmail = { ...email };
let body: Record<string, unknown> | null = null;
for (const key of BODY_KEYS) {
if (key in meta) {
body ??= {};
body[key] = meta[key];
delete meta[key];
}
}
// Only a fetch with the text counts as the message's body.
return { meta, body: body && 'bodyValues' in body ? body : null };
}
/** Keep messages (as the server sent them, any properties) and their bodies when they came with one. */
export async function putEmails(account: string, jmap: string, emails: CachedEmail[]): Promise<void> {
if (emails.length === 0) return;
const index = await emailIndex(account);
const ops: Array<{ store: StoreName; put?: unknown; del?: IDBValidKey }> = [];
for (const email of emails) {
if (!email || typeof email.id !== 'string') continue;
const key = emailKey(account, jmap, email.id);
const { meta, body } = splitBody(email);
const merged = { ...(index.get(key) ?? {}), ...meta } as CachedEmail;
// The attachments and inline images of the message: only these blobs are kept (blobsOf).
if (body && Array.isArray(body.attachments)) {
merged[BLOBS] = (body.attachments as Array<{ blobId?: unknown }>).map((a) => a.blobId).filter((id): id is string => typeof id === 'string');
}
index.set(key, merged);
const at = dateOf(merged);
const sealed = await sealJson(merged);
ops.push({ store: 'emails', put: { key, account, ...sealed } });
ops.push({ store: 'ledger', put: { key: ledgerKey('emails', key), account, store: 'emails', ref: key, at, bytes: byteSize(sealed), emailKey: key } satisfies LedgerEntry });
if (body) {
const sealedBody = await sealJson(body);
ops.push({ store: 'bodies', put: { key, account, ...sealedBody } });
ops.push({ store: 'ledger', put: { key: ledgerKey('bodies', key), account, store: 'bodies', ref: key, at, bytes: byteSize(sealedBody), emailKey: key } satisfies LedgerEntry });
}
}
await idbWrite(ops);
}
/** Apply a change made to messages (keywords, folders), here or on the server. */
export async function patchEmails(account: string, jmap: string, patches: Record<string, Record<string, unknown>>): Promise<void> {
const index = await emailIndex(account);
const changed: CachedEmail[] = [];
for (const [id, patch] of Object.entries(patches)) {
const current = index.get(emailKey(account, jmap, id));
if (!current) continue;
changed.push(applyPatch(current, patch));
}
if (changed.length) await putEmails(account, jmap, changed);
}
/** A JMAP PatchObject (RFC 8620 §5.3) applied to a message, for the properties offline mode changes. */
export function applyPatch(email: CachedEmail, patch: Record<string, unknown>): CachedEmail {
const next: CachedEmail = { ...email, keywords: { ...(email.keywords ?? {}) }, mailboxIds: { ...(email.mailboxIds ?? {}) } };
for (const [path, value] of Object.entries(patch)) {
const [prop, ...rest] = path.split('/');
if (rest.length === 0) {
(next as Record<string, unknown>)[prop] = value;
continue;
}
if (prop !== 'keywords' && prop !== 'mailboxIds') continue;
const name = rest.join('/').replace(/~1/g, '/').replace(/~0/g, '~');
const map = next[prop] as Record<string, boolean>;
if (value === null || value === false) delete map[name];
else map[name] = true;
}
return next;
}
export async function removeEmails(account: string, keys: string[]): Promise<void> {
if (keys.length === 0) return;
const index = await emailIndex(account);
const ops: Array<{ store: StoreName; del: IDBValidKey }> = [];
const gone = new Set(keys);
for (const key of keys) {
index.delete(key);
ops.push({ store: 'emails', del: key }, { store: 'bodies', del: key });
ops.push({ store: 'ledger', del: ledgerKey('emails', key) }, { store: 'ledger', del: ledgerKey('bodies', key) });
}
// Their attachments go with them.
for (const entry of await idbGetAllByAccount<LedgerEntry>('ledger', account)) {
if (entry.store === 'blobs' && entry.emailKey && gone.has(entry.emailKey)) {
ops.push({ store: 'blobs', del: entry.ref }, { store: 'ledger', del: entry.key });
}
}
await idbWrite(ops);
}
/** The kept message a blob (attachment, inline image) belongs to, with its date. */
export async function messageOfBlob(account: string, jmap: string, blobId: string): Promise<{ key: string; at: number } | null> {
const index = await emailIndex(account);
const prefix = `${account}\u0001${jmap}\u0001`;
for (const [key, email] of index) {
if (!key.startsWith(prefix)) continue;
const blobs = email[BLOBS];
if (Array.isArray(blobs) && blobs.includes(blobId)) return { key, at: dateOf(email) };
}
return null;
}
/** A message's body parts, or null when only its list entry is kept. */
export async function getBody(account: string, jmap: string, id: string): Promise<Record<string, unknown> | null> {
const record = await idbGet<SealedRecord>('bodies', emailKey(account, jmap, id));
if (!record) return null;
try {
return await openJson<Record<string, unknown>>(record);
} catch {
return null;
}
}
export async function hasBody(account: string, jmap: string, id: string): Promise<boolean> {
return (await idbGet<SealedRecord>('bodies', emailKey(account, jmap, id))) !== undefined;
}
export async function putBlob(account: string, jmap: string, blobId: string, blob: Blob, at: number, ofEmail?: string): Promise<void> {
const key = blobKey(account, jmap, blobId);
const sealed = await sealBytes(await blob.arrayBuffer());
// The media type too says something about the message: sealed as well.
const meta = await sealJson({ type: blob.type });
await idbWrite([
{ store: 'blobs', put: { key, account, meta, ...sealed } },
{ store: 'ledger', put: { key: ledgerKey('blobs', key), account, store: 'blobs', ref: key, at, bytes: byteSize(sealed), emailKey: ofEmail } satisfies LedgerEntry },
]);
}
export async function getBlob(account: string, jmap: string, blobId: string): Promise<Blob | null> {
const record = await idbGet<SealedRecord & { meta?: Sealed }>('blobs', blobKey(account, jmap, blobId));
if (!record) return null;
try {
const meta = record.meta ? await openJson<{ type?: string }>(record.meta) : {};
return new Blob([await openBytes(record)], { type: meta.type || 'application/octet-stream' });
} catch {
return null;
}
}
export async function hasBlob(account: string, jmap: string, blobId: string): Promise<boolean> {
return (await idbGet<SealedRecord>('blobs', blobKey(account, jmap, blobId))) !== undefined;
}
/** Small named values of an account (session, folders, identities, opened folders). */
export async function putMisc(account: string, name: string, value: unknown): Promise<void> {
const key = miscKey(account, name);
await idbWrite([{ store: 'misc', put: { key, account, ...(await sealJson(value)) } }]);
}
export async function getMisc<T>(account: string, name: string): Promise<T | null> {
const record = await idbGet<SealedRecord>('misc', miscKey(account, name));
if (!record) return null;
try {
return await openJson<T>(record);
} catch {
return null;
}
}
/** Bytes the cache uses on this device, and per account. */
export async function cacheUsage(): Promise<{ total: number; byAccount: Record<string, number> }> {
const entries = await idbGetAll<LedgerEntry>('ledger');
const byAccount: Record<string, number> = {};
let total = 0;
for (const entry of entries) {
total += entry.bytes;
byAccount[entry.account] = (byAccount[entry.account] ?? 0) + entry.bytes;
}
return { total, byAccount };
}
/**
* Keeps the cache under `cap`: the oldest messages go first, each with its
* body and attachments. Returns the bytes freed.
*/
export async function enforceCap(cap: number = CACHE_CAP_BYTES): Promise<number> {
const entries = await idbGetAll<LedgerEntry>('ledger');
let total = entries.reduce((sum, entry) => sum + entry.bytes, 0);
if (total <= cap) return 0;
// Group by message: a message and everything that belongs to it go together.
const groups = new Map<string, { at: number; bytes: number; account: string; entries: LedgerEntry[] }>();
for (const entry of entries) {
const id = entry.emailKey ?? entry.key;
const group = groups.get(id) ?? { at: entry.at, bytes: 0, account: entry.account, entries: [] };
group.at = Math.max(group.at, entry.at);
group.bytes += entry.bytes;
group.entries.push(entry);
groups.set(id, group);
}
const oldestFirst = [...groups.values()].sort((a, b) => a.at - b.at);
let freed = 0;
const ops: Array<{ store: StoreName; del: IDBValidKey }> = [];
for (const group of oldestFirst) {
if (total <= cap) break;
for (const entry of group.entries) {
ops.push({ store: entry.store, del: entry.ref }, { store: 'ledger', del: entry.key });
if (entry.store === 'emails') (await indexes.get(entry.account))?.delete(entry.ref);
}
total -= group.bytes;
freed += group.bytes;
}
await idbWrite(ops);
return freed;
}
/** Everything cached for one account (not its outbox). */
export async function wipeAccountCache(account: string): Promise<void> {
forgetIndexes(account);
await idbDeleteAccount(account, ['emails', 'bodies', 'blobs', 'misc', 'ledger']);
}
+25
View File
@@ -0,0 +1,25 @@
/**
* The server refused this account's refresh token for good. Nothing wipes on
* this yet: a refused account is dropped, and its local data stays for the next
* sign-in. HOOK: the wipe work (lib/store/wipe) registers here to act on a
* permanent rejection. Free of the stores, so the auth store can call it.
*/
type Listener = (accountId: string) => void;
const listeners = new Set<Listener>();
export function onCredentialsRejected(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function noteCredentialsRejected(accountId: string): void {
for (const listener of [...listeners]) {
try {
listener(accountId);
} catch {
// A sign-out must go on whatever a listener does.
}
}
}
-59
View File
@@ -1,59 +0,0 @@
/**
* Encryption at rest for offline mode (#523): AES-GCM with a 256-bit key
* the browser generates as non-extractable and keeps in the offline
* database. Script on this origin can't read the key's bytes, only use it.
*
* What it protects against: someone who copies the browser profile's files
* (a backup, a stolen disk image) without being able to run this site in
* that profile. What it doesn't: someone using the unlocked browser profile,
* or a script running on this origin, can decrypt everything. The docs say
* so and recommend disk encryption.
*/
import { idbGet, idbWrite } from './db';
export interface Sealed {
iv: Uint8Array;
data: ArrayBuffer;
}
let keyPromise: Promise<CryptoKey> | null = null;
async function loadOrCreateKey(): Promise<CryptoKey> {
const stored = await idbGet<{ id: string; key: CryptoKey }>('keys', 'aes');
if (stored?.key) return stored.key;
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
await idbWrite([{ store: 'keys', put: { id: 'aes', key } }]);
return key;
}
function deviceKey(): Promise<CryptoKey> {
if (!keyPromise) {
keyPromise = loadOrCreateKey();
keyPromise.catch(() => { keyPromise = null; });
}
return keyPromise;
}
/** Forget the key in memory (the database with it was deleted). */
export function forgetDeviceKey(): void {
keyPromise = null;
}
export async function sealBytes(bytes: ArrayBuffer | Uint8Array): Promise<Sealed> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await deviceKey(), bytes as BufferSource);
return { iv, data };
}
export async function openBytes(sealed: Sealed): Promise<ArrayBuffer> {
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.iv as BufferSource }, await deviceKey(), sealed.data);
}
export async function sealJson(value: unknown): Promise<Sealed> {
return sealBytes(new TextEncoder().encode(JSON.stringify(value)));
}
export async function openJson<T>(sealed: Sealed): Promise<T> {
return JSON.parse(new TextDecoder().decode(await openBytes(sealed))) as T;
}
-219
View File
@@ -1,219 +0,0 @@
/**
* The IndexedDB database of offline mode (#523): one per browser profile,
* deleted as a whole at a full sign-out (lib/sign-out-cleanup.ts) and when
* the last account switches offline mode off.
*
* Stores (every value but the key and the ledger is encrypted, see crypto.ts):
* - keys: the AES-GCM key, non-extractable.
* - emails: message metadata (what the list shows), per account and JMAP account.
* - bodies: the text of a message (bodyValues and the parts that say how to show it).
* - blobs: attachments and inline images.
* - misc: the session, folders, identities, the folders the user opened.
* - ledger: size and date of each cached record, unencrypted, so the cache
* can be kept under its cap without decrypting everything.
* - queue: changes and messages waiting for the network (never evicted).
* - uploads: files attached while offline, until their message is sent.
*/
export const OFFLINE_DB_NAME = 'bulwark-offline';
const VERSION = 1;
export const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'queue', 'uploads'] as const;
export type StoreName = (typeof STORES)[number];
let dbPromise: Promise<IDBDatabase> | null = null;
/**
* Only switching offline mode on may create the database. Everything else
* opens it only when it exists: another tab may have deleted it (offline
* mode switched off, a sign-out), and opening it would create it anew.
*/
let mayCreate = false;
export function allowOfflineDbCreation(): void {
mayCreate = true;
}
async function offlineDbExists(): Promise<boolean> {
try {
if (typeof indexedDB.databases !== 'function') return true;
return (await indexedDB.databases()).some((d) => d.name === OFFLINE_DB_NAME);
} catch {
return true;
}
}
/** Thrown when the database was deleted (or never made) and may not be created here. */
export class OfflineDbMissingError extends Error {
constructor() {
super('The offline database is not set up');
this.name = 'OfflineDbMissingError';
}
}
export function hasIndexedDb(): boolean {
try {
return typeof indexedDB !== 'undefined' && indexedDB !== null;
} catch {
return false;
}
}
export function openOfflineDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
const opening = (async () => {
if (!mayCreate && !(await offlineDbExists())) throw new OfflineDbMissingError();
return openNow();
})();
dbPromise = opening;
opening.catch(() => {
if (dbPromise === opening) dbPromise = null;
});
return opening;
}
function openNow(): Promise<IDBDatabase> {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(OFFLINE_DB_NAME, VERSION);
request.onupgradeneeded = () => {
const db = request.result;
for (const name of STORES) {
if (db.objectStoreNames.contains(name)) continue;
if (name === 'queue') {
db.createObjectStore(name, { keyPath: 'id', autoIncrement: true }).createIndex('account', 'account');
} else if (name === 'keys') {
db.createObjectStore(name, { keyPath: 'id' });
} else {
db.createObjectStore(name, { keyPath: 'key' }).createIndex('account', 'account');
}
}
};
request.onsuccess = () => {
const db = request.result;
// A sign-out in this or another tab deletes the database: let it.
db.onversionchange = () => {
db.close();
dbPromise = null;
mayCreate = false;
};
db.onclose = () => {
dbPromise = null;
};
resolve(db);
};
request.onerror = () => reject(request.error);
request.onblocked = () => reject(new Error('offline database blocked'));
});
}
function done(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('transaction aborted'));
});
}
function result<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function idbGet<T>(store: StoreName, key: IDBValidKey): Promise<T | undefined> {
const db = await openOfflineDb();
return result(db.transaction(store, 'readonly').objectStore(store).get(key)) as Promise<T | undefined>;
}
export async function idbGetAllByAccount<T>(store: StoreName, account: string): Promise<T[]> {
const db = await openOfflineDb();
return result(db.transaction(store, 'readonly').objectStore(store).index('account').getAll(account)) as Promise<T[]>;
}
export async function idbGetAll<T>(store: StoreName): Promise<T[]> {
const db = await openOfflineDb();
return result(db.transaction(store, 'readonly').objectStore(store).getAll()) as Promise<T[]>;
}
/** Writes and deletes in several stores at once. */
export async function idbWrite(ops: Array<{ store: StoreName; put?: unknown; del?: IDBValidKey }>): Promise<void> {
if (ops.length === 0) return;
const db = await openOfflineDb();
const names = [...new Set(ops.map((op) => op.store))];
const tx = db.transaction(names, 'readwrite');
for (const op of ops) {
const store = tx.objectStore(op.store);
if (op.put !== undefined) store.put(op.put);
else if (op.del !== undefined) store.delete(op.del);
}
await done(tx);
}
/**
* Reads a record and writes what `change` makes of it in one transaction;
* `change` returns undefined to leave it. True when it wrote.
*/
export async function idbUpdate<T>(store: StoreName, key: IDBValidKey, change: (value: T | undefined) => T | undefined): Promise<boolean> {
const db = await openOfflineDb();
const tx = db.transaction(store, 'readwrite');
const objects = tx.objectStore(store);
let wrote = false;
const read = objects.get(key);
read.onsuccess = () => {
const next = change(read.result as T | undefined);
if (next !== undefined) {
objects.put(next);
wrote = true;
}
};
await done(tx);
return wrote;
}
export async function idbAdd(store: StoreName, value: unknown): Promise<IDBValidKey> {
const db = await openOfflineDb();
const tx = db.transaction(store, 'readwrite');
const key = result(tx.objectStore(store).add(value));
await done(tx);
return key;
}
/** Deletes every record of one account in the given stores. */
export async function idbDeleteAccount(account: string, stores: readonly StoreName[]): Promise<void> {
const db = await openOfflineDb();
const tx = db.transaction([...stores], 'readwrite');
for (const name of stores) {
const index = tx.objectStore(name).index('account');
const request = index.openKeyCursor(IDBKeyRange.only(account));
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return;
tx.objectStore(name).delete(cursor.primaryKey);
cursor.continue();
};
}
await done(tx);
}
/** Deletes the whole database (sign-out, offline mode off everywhere). */
export async function deleteOfflineDb(): Promise<void> {
if (!hasIndexedDb()) return;
mayCreate = false;
const open = dbPromise;
dbPromise = null;
try {
(await open)?.close();
} catch {
/* it never opened */
}
await new Promise<void>((resolve) => {
try {
const request = indexedDB.deleteDatabase(OFFLINE_DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
} catch {
resolve();
}
});
}
+122 -46
View File
@@ -1,22 +1,24 @@
/**
* Sends what waited on this device (#523), once the server answers again:
* first the changes (read, flagged, tags, moves) in the order they were
* made, then the outbox, oldest first.
* changes (read, flagged, tags, moves, deletes) and messages, in the order
* they were made.
*
* - One tab at a time per account (Web Locks), and each message is taken
* for sending in one database transaction (claimForSending), so two tabs
* never send it twice.
* - Changes and messages go in the order they were made.
* - A change the server refuses (the message is gone, moved elsewhere) is
* dropped: the server's state wins.
* for sending in one transaction (claimForSending), so two tabs never send
* it twice.
* - A change the server refuses, or that meets a record changed since, goes
* to the user as a conflict (lib/store/outbox/flush-patches.ts).
* - A message is sent as it was finished at Send (queue.ts); files attached
* offline are uploaded first. A hold for undo-send that has passed is
* dropped, so the message goes at once.
* - Only a failure before the submission request (SendNotStartedError, a
* file upload on a dead network) puts a message back to waiting. A send
* whose connection broke after that is marked uncertain and never sent
* again by itself: it may have gone out. A refusal by the server is marked
* failed, with the reason.
* whose connection broke after that is marked uncertain. A refusal by the
* server is marked failed, with the reason.
* - An uncertain message is looked up when the server is back, by the
* Message-ID fixed when it was queued (lib/store/outbox/dedupe.ts): it's
* done, its copy gets submitted, or it goes again. Only when the lookup
* can't tell does it stay uncertain, for the user to decide.
* - A message taken for sending by a tab that was closed on the way is
* uncertain after a minute.
* - A message finished under rules the organisation has since made stricter
@@ -26,17 +28,21 @@
import type { JMAPClient } from '@/lib/jmap/client';
import { isNetworkFailure } from '@/lib/jmap/offline-hooks';
import type { SendEmailResult } from '@/lib/jmap/types';
import { getStore } from '@/lib/store/client/api';
import type { MethodCall } from '@/lib/store/engine/transport';
import { decideRetry, type LookupIo, type RetryDecision } from '@/lib/store/outbox/dedupe';
import { flushPatches, type PatchIo, type PatchOutcome, type PendingPatch } from '@/lib/store/outbox/flush-patches';
import {
claimForSending,
heldFile,
isLocalBlobId,
readQueue,
removeOutboxEntry,
removeQueued,
updateOutboxEntry,
type OutboxEntry,
type OutboxError,
} from './queue';
import { useOfflineStore } from './state';
import type { OfflineNotice } from './offline-mode';
export interface FlushDeps {
@@ -95,7 +101,7 @@ async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry
const file = await heldFile(account, item.blobId);
if (!file) throw new OutboxPrepError('file_missing');
const delayed = item.delayedUntil && Date.parse(item.delayedUntil) - Date.now() > MIN_HOLD_MS ? item.delayedUntil : undefined;
if (item.submitOnly) return client.submitRawEmail(file, item.identityId, delayed, item.envelopeRecipients);
if (item.submitOnly) return client.submitRawEmail(file, item.identityId, delayed, item.envelopeRecipients, entry.copyId);
let mailboxes;
try {
mailboxes = await client.getMailboxes();
@@ -106,11 +112,12 @@ async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry
const sent = mailboxes.find((m) => m.role === 'sent');
if (!sent) throw new OutboxPrepError('no_sent_mailbox');
const drafts = mailboxes.find((m) => m.role === 'drafts');
return client.sendRawEmail(file, item.identityId, sent.id, drafts?.id, delayed, item.envelopeRecipients);
return client.sendRawEmail(file, item.identityId, sent.id, drafts?.id, delayed, item.envelopeRecipients, entry.copyId);
}
const args = [...item.args];
const attachments = args[10];
if (Array.isArray(attachments)) {
// A copy on the server has its files already.
if (Array.isArray(attachments) && !entry.copyId) {
const resolved = [];
for (const attachment of attachments as Array<{ blobId: string }>) {
resolved.push(isLocalBlobId(attachment.blobId) ? { ...attachment, blobId: await uploadHeld(account, client, attachment.blobId) } : attachment);
@@ -119,6 +126,13 @@ async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry
}
const delayedUntil = args[13] as string | undefined;
if (delayedUntil && Date.parse(delayedUntil) - Date.now() <= MIN_HOLD_MS) args[13] = undefined;
if (entry.messageId || entry.copyId) {
args[15] = {
...((args[15] as Record<string, unknown> | undefined) ?? {}),
...(entry.messageId ? { messageId: entry.messageId } : {}),
...(entry.copyId ? { existingCopyId: entry.copyId } : {}),
};
}
const send = client.sendEmail.bind(client) as (...a: unknown[]) => Promise<SendEmailResult>;
return send(...args);
}
@@ -136,12 +150,88 @@ export async function flushQueue(account: string, client: JMAPClient, deps: Flus
/** Marks sends of closed tabs uncertain (they may have gone out). */
export async function settleStaleSends(account?: string, now = Date.now()): Promise<void> {
const accounts = account ? [account] : useOfflineStore.getState().enabled;
await Promise.all(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS })));
}
/** The server refused the request itself: more tries won't change that. Outages and auth wait instead. */
function isRequestRefusal(error: unknown): boolean {
return error instanceof Error && /^Request failed: (400|403|404|413|422)\b/.test(error.message);
}
/** The changes queued before `upToSeq` (all, with null). False when the run stopped on an outage. */
async function sendPatches(account: string, client: JMAPClient, deps: FlushDeps, upToSeq: number | null): Promise<boolean> {
const pending = await getStore().area<PendingPatch[]>(account, 'outbox.pendingPatches', { upToSeq });
if (pending.length === 0) return true;
const io: PatchIo = {
request: (calls: MethodCall[], using: string[]) => client.requestNetworkOnly(calls, using),
isRefusal: isRequestRefusal,
};
return flushPatches(io, pending, async (outcomes: PatchOutcome[]) => {
const conflicts = await getStore().area<number>(account, 'outbox.patchResults', { outcomes });
if (conflicts > 0) deps.notify({ kind: 'conflict', count: conflicts });
});
}
async function lookUp(client: JMAPClient, entry: OutboxEntry): Promise<RetryDecision> {
const io: LookupIo = { request: (calls, using) => client.requestNetworkOnly(calls, using) };
const fromEmail = entry.item.kind === 'email' ? entry.item.args[6] as string | undefined : undefined;
const jmap = await client.resolveSendAccountId(fromEmail);
const options = entry.item.kind === 'email' ? entry.item.args[15] as { noSentCopy?: boolean } | undefined : undefined;
return decideRetry(io, {
jmap,
submissionJmap: client.submissionAccountFor(jmap),
messageId: entry.messageId,
attemptAt: entry.firstAttemptAt ?? entry.createdAt,
now: Date.now(),
copyKept: entry.item.kind === 'raw' ? !entry.item.submitOnly : !options?.noSentCopy,
});
}
/** The draft the send was meant to replace: the lost response left it behind. Best effort. */
async function dropOldDraft(client: JMAPClient, entry: OutboxEntry): Promise<void> {
if (entry.item.kind !== 'email' || typeof entry.item.args[7] !== 'string') return;
const options = entry.item.args[15] as { draftAccountId?: string } | undefined;
try {
await client.requestNetworkOnly([['Email/set', { accountId: options?.draftAccountId || client.getAccountId(), destroy: [entry.item.args[7]] }, '0']]);
} catch {
// An orphan in Drafts, nothing worse.
}
}
/** Uncertain messages, looked up now that the server answers. False when the run stopped on an outage. */
async function settleUncertain(account: string, client: JMAPClient, deps: FlushDeps): Promise<boolean> {
const { outbox } = await readQueue(account);
for (const entry of outbox) {
if (entry.status === 'sending' && (entry.sendingSince ?? 0) < now - SENDING_STALE_MS) {
await updateOutboxEntry(entry, { status: 'uncertain' });
if (entry.status !== 'uncertain') continue;
if (stillOffline() || !deps.allowed()) return false;
let decision: RetryDecision;
try {
decision = await lookUp(client, entry);
} catch (error) {
// The lookup failed: it stays uncertain, for this time.
if (isNetworkFailure(error)) return false;
continue;
}
switch (decision.action) {
case 'done':
await removeOutboxEntry(entry);
await dropOldDraft(client, entry);
void client.pruneEmailSubmissions();
// A canceled one wasn't sent: someone decided that, and it's in Drafts.
if (decision.why !== 'canceled') deps.notify({ kind: 'sent', subject: entry.summary.subject });
break;
case 'submit':
await updateOutboxEntry(entry, { status: 'waiting', copyId: decision.emailId });
break;
case 'send':
await updateOutboxEntry(entry, { status: 'waiting', copyId: null });
break;
case 'undecided':
break;
}
}
return true;
}
async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps): Promise<void> {
@@ -149,36 +239,21 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps)
flushing.add(account);
try {
await settleStaleSends(account);
const { patches, outbox } = await readQueue(account);
if (!(await settleUncertain(account, client, deps))) return;
// In the order they were made: a reply's $answered, queued after the
// reply, goes after it.
const items = [
...patches.map((patch) => ({ id: patch.id, patch })),
...outbox.map((entry) => ({ id: entry.id, entry })),
].sort((x, y) => x.id - y.id);
for (const item of items) {
if ('patch' in item && item.patch) {
if (stillOffline()) return;
try {
await client.applyEmailPatches(item.patch.item.jmap, item.patch.item.update);
} catch (error) {
// Still no server: try again later. Anything else: the server's state wins.
if (isNetworkFailure(error)) return;
}
await removeQueued([item.patch.id]);
continue;
}
const entry = (item as { entry: OutboxEntry }).entry;
const { outbox } = await readQueue(account);
for (const entry of outbox) {
if (entry.status !== 'waiting') continue;
if (stillOffline() || !deps.allowed()) return;
if (!(await sendPatches(account, client, deps, entry.seq))) return;
if (deps.stricterNow?.(entry)) {
await updateOutboxEntry(entry, { status: 'held' });
deps.notify({ kind: 'held', subject: entry.summary.subject });
continue;
}
// Another tab took it, or it was deleted meanwhile.
if (!(await claimForSending(entry.id))) continue;
if (!(await claimForSending(account, entry.id))) continue;
let result: SendEmailResult;
try {
result = await sendEntry(account, client, entry);
@@ -190,17 +265,18 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps)
}
if (error instanceof Error && error.name === 'OutboxPrepError') {
const code = (error as OutboxPrepError).code;
await updateOutboxEntry(entry, { status: 'failed', error: { code } });
await updateOutboxEntry(entry, { status: 'failed', error: { code }, copyId: null });
deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { code } });
continue;
}
if (isNetworkFailure(error) || (error instanceof Error && (error.name === 'RequestTimeoutError' || error.name === 'SendUnconfirmedError'))) {
// It may have reached the server: never sent again by itself.
if (isNetworkFailure(error) || (error instanceof Error && error.name === 'SendUnconfirmedError')) {
// It may have reached the server: looked up before it goes again.
await updateOutboxEntry(entry, { status: 'uncertain' });
return;
}
const text = error instanceof Error ? error.message : String(error);
await updateOutboxEntry(entry, { status: 'failed', error: { text } });
// The client took back the copy of a refused send.
await updateOutboxEntry(entry, { status: 'failed', error: { text }, copyId: null });
deps.notify({ kind: 'send-failed', subject: entry.summary.subject, error: { text } });
continue;
}
@@ -209,21 +285,21 @@ async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps)
if (result.scheduled) deps.onScheduled?.(entry, result);
deps.notify({ kind: 'sent', subject: entry.summary.subject });
}
if (stillOffline() || !deps.allowed()) return;
await sendPatches(account, client, deps, null);
} finally {
flushing.delete(account);
}
}
/** "Send again" for a failed or uncertain message (a held one waits for its rules). */
export async function retryOutboxEntry(id: number): Promise<void> {
const { outbox } = await readQueue();
export async function retryOutboxEntry(account: string, id: number): Promise<void> {
const { outbox } = await readQueue(account);
const entry = outbox.find((e) => e.id === id);
if (entry && (entry.status === 'failed' || entry.status === 'uncertain')) await updateOutboxEntry(entry, { status: 'waiting' });
}
/** "Delete" in the outbox. */
export async function deleteOutboxEntry(id: number): Promise<void> {
const { outbox } = await readQueue();
const entry = outbox.find((e) => e.id === id);
if (entry) await removeOutboxEntry(entry);
export async function deleteOutboxEntry(account: string, id: number): Promise<void> {
await removeOutboxEntry({ account, id });
}
+196
View File
@@ -0,0 +1,196 @@
/**
* One time, on the first start after the update: what can't be fetched again
* moves from the old IndexedDB `bulwark-offline` into the local store. That's
* the outbox, the changes that wait, the files they hold and the session kept
* for a start without the server. The mail itself syncs again.
*
* The old database holds its own key, so it goes only after the store has
* committed every account. A move that fails leaves it in place and the next
* start tries again; the store takes each account's part only once.
*/
import { generateMessageId } from '@/lib/jmap/client';
import { getStore } from '@/lib/store/client/api';
import type { StoreSession } from '@/lib/store/engine/transport';
import type { LegacyImport } from '@/lib/store/outbox/calls';
import type { OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem } from '@/lib/store/outbox/items';
import { useOfflineStore } from './state';
export const LEGACY_DB_NAME = 'bulwark-offline';
interface Sealed {
iv: Uint8Array;
data: ArrayBuffer;
}
interface QueueRecord extends Sealed {
id: number;
account: string;
kind: 'patch' | 'send';
createdAt: number;
status?: string;
}
interface UploadRecord extends Sealed {
key: string;
account: string;
meta: Sealed;
}
interface SendPayload {
summary: OutboxSummary;
item: SendItem;
fingerprint?: PolicyFingerprint;
error?: OutboxError;
}
function result<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/** Opens the old database only if it exists: opening it must never create it. */
function openLegacy(): Promise<IDBDatabase | null> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(LEGACY_DB_NAME);
let creating = false;
request.onupgradeneeded = () => {
creating = true;
request.transaction?.abort();
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
if (creating) resolve(null);
else reject(request.error);
};
request.onblocked = () => resolve(null);
});
}
function deleteLegacy(): Promise<void> {
return new Promise((resolve) => {
const request = indexedDB.deleteDatabase(LEGACY_DB_NAME);
request.onsuccess = () => resolve();
request.onerror = () => resolve();
// An old tab still has it open: it goes when that tab does.
request.onblocked = () => resolve();
});
}
function storeNamesHold(db: IDBDatabase, ...names: string[]): boolean {
return names.every((name) => db.objectStoreNames.contains(name));
}
async function open(key: CryptoKey, sealed: Sealed): Promise<ArrayBuffer> {
return crypto.subtle.decrypt({ name: 'AES-GCM', iv: sealed.iv as BufferSource }, key, sealed.data);
}
async function openJson<T>(key: CryptoKey, sealed: Sealed): Promise<T> {
return JSON.parse(new TextDecoder().decode(await open(key, sealed))) as T;
}
/** The Message-ID of a finished raw message, from its head. */
function rawMessageId(bytes: Uint8Array): string | undefined {
const head = new TextDecoder().decode(bytes.subarray(0, 64 * 1024));
const end = head.search(/\r?\n\r?\n/);
const block = (end >= 0 ? head.slice(0, end) : head).replace(/\r?\n[ \t]+/g, ' ');
return block.match(/^message-id:\s*<([^>\s]+)>/im)?.[1];
}
interface AccountPart extends LegacyImport {
session: StoreSession | null;
}
async function readAccount(db: IDBDatabase, key: CryptoKey | null, account: string): Promise<AccountPart> {
const part: AccountPart = { entries: [], patches: [], files: [], session: null };
if (!key) return part;
const stores = db.transaction(['queue', 'uploads', 'misc'], 'readonly');
const [queue, uploads, session] = await Promise.all([
result(stores.objectStore('queue').index('account').getAll(account)) as Promise<QueueRecord[]>,
result(stores.objectStore('uploads').index('account').getAll(account)) as Promise<UploadRecord[]>,
result(stores.objectStore('misc').get(`${account}\u0001misc\u0001session`)) as Promise<Sealed | undefined>,
]);
if (session) part.session = await openJson<StoreSession>(key, session).catch(() => null);
const rawIds = new Map<string, string | undefined>();
for (const record of uploads) {
// Sealed with a key that is gone, as the old code skipped it.
try {
const meta = await openJson<{ name?: string; type?: string }>(key, record.meta);
const data = new Uint8Array(await open(key, record));
const blobId = record.key.split('\u0001')[1];
part.files.push({ blobId, name: meta.name || 'attachment', type: meta.type || 'application/octet-stream', data });
rawIds.set(blobId, rawMessageId(data));
} catch {
continue;
}
}
// In the order they were made.
for (const record of [...queue].sort((a, b) => a.id - b.id)) {
try {
if (record.kind === 'patch') {
const item = await openJson<{ jmap: string; update: Record<string, Record<string, unknown>> }>(key, record);
part.patches.push({ jmap: item.jmap, update: item.update });
continue;
}
const payload = await openJson<SendPayload>(key, record);
// Sent by a tab that's gone: it may have left, so it's looked up or asked about.
const status = (record.status === 'sending' ? 'uncertain' : record.status ?? 'waiting') as OutboxStatus;
const item = payload.item;
let messageId: string | undefined;
if (item.kind === 'raw') messageId = rawIds.get(item.blobId);
// Not sent yet: it can still get a fixed id. One that left under the old
// code had another id, which nothing here knows.
else if (status === 'waiting' && typeof item.args[6] === 'string') messageId = generateMessageId(item.args[6]);
part.entries.push({
summary: payload.summary,
item,
fingerprint: payload.fingerprint,
error: payload.error,
status,
createdAt: record.createdAt,
messageId,
});
} catch {
continue;
}
}
return part;
}
let moving: Promise<void> | null = null;
async function moveNow(): Promise<void> {
if (typeof indexedDB === 'undefined') return;
const db = await openLegacy();
if (!db) return;
try {
if (!storeNamesHold(db, 'keys', 'queue', 'uploads', 'misc')) return;
const keyRecord = await result(db.transaction('keys', 'readonly').objectStore('keys').get('aes')) as { key: CryptoKey } | undefined;
const key = keyRecord?.key ?? null;
for (const account of useOfflineStore.getState().enabled) {
const part = await readAccount(db, key, account);
const { session, ...content } = part;
if (session) await getStore().ensureOpen(account, session);
if (content.entries.length + content.patches.length + content.files.length === 0) continue;
await getStore().area(account, 'outbox.importLegacy', content);
}
} finally {
db.close();
}
await deleteLegacy();
}
/** Runs the move once per page. A failure is kept for nobody to see: the old database stays and the next start tries again. */
export function moveLegacyOutbox(): Promise<void> {
moving ??= moveNow().catch(() => {});
return moving;
}
/** Tests only: forget that this page already ran it. */
export function forgetLegacyMove(): void {
moving = null;
}
+218 -292
View File
@@ -4,19 +4,20 @@
* - Opt-in per account and per browser (state.ts), off by default, hidden
* and wiped when the organisation forbids it (policy.json
* `organisation.devices.noOffline`, per account) and in Lite.
* - While on, the JMAP client hands every answer of the mail server to the
* cache (cache.ts) and asks this device when the network is gone
* (responder.ts); a background sync keeps the last 30 days of the Inbox
* and of the folders the user opened, with bodies and attachments, under
* a cap (sync.ts).
* - Changes and sends made offline wait in the queue (queue.ts) and go out
* in order once the server answers again and the organisation's policy
* was fetched afresh (flush.ts).
* - While on, the local store (lib/store, a database in a worker) keeps the
* account's mail in sync by itself, and the JMAP client asks it when the
* network is gone. This file is the orchestration around it: who may use
* it, when it syncs and flushes, the wipes.
* - Changes and sends made offline wait in the store's outbox and go out in
* order once the server answers again and the organisation's policy was
* fetched afresh (flush.ts).
* - Everything is wiped at a full sign-out and the automatic sign-outs
* (lib/sign-out-cleanup.ts deletes the database), for one account when it
* signs out or switches offline mode off, and when the organisation
* forbids it (checked on every policy fetch). Unsent messages a sign-out
* deletes without the user's word are named (lost.ts).
* (lib/sign-out-cleanup.ts deletes the store and its keys), for one account
* when it signs out, is removed or switches offline mode off, and when the
* organisation forbids it (checked on every policy fetch). An account the
* app drops by itself (its session cookie ran out, say) keeps its data for
* the next sign-in. Unsent messages a sign-out deletes without the user's
* word are named (lost.ts).
*/
import { IS_LITE } from '@/lib/lite';
@@ -24,43 +25,25 @@ import { devicesForAccount } from '@/lib/device-policy';
import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects';
import { labelsPolicyFor } from '@/lib/outgoing/classification';
import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards';
import { useAccountStore } from '@/stores/account-store';
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api';
import { purgeStoreFiles } from '@/lib/store/client/purge';
import type { StoreSession } from '@/lib/store/engine/transport';
import { onAccountRemoved, useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store';
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
import { generateMessageId } from '@/lib/jmap/client';
import { setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
import type { SendEmailResult } from '@/lib/jmap/types';
import { setOutboxHooks, type OutboxHooks } from './outbox-hooks';
import { answerOffline } from './responder';
import {
CACHE_WINDOW_MS,
cacheUsage,
emailIndex,
enforceCap,
forgetIndexes,
getBlob,
getMisc,
messageOfBlob,
patchEmails,
putBlob,
putEmails,
putMisc,
removeEmails,
wipeAccountCache,
emailKey,
type CachedEmail,
} from './cache';
import { allowOfflineDbCreation, deleteOfflineDb, hasIndexedDb } from './db';
import { forgetDeviceKey } from './crypto';
import {
heldFile,
holdFile,
isLocalBlobId,
queuePatch,
queueSend,
readQueue,
wipeAccountQueue,
type OutboxEntry,
type OutboxError,
type OutboxSummary,
@@ -68,7 +51,7 @@ import {
} from './queue';
import { isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
import { flushQueue, settleStaleSends } from './flush';
import { syncAccount } from './sync';
import { LEGACY_DB_NAME, moveLegacyOutbox } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
import { rememberLost } from './lost';
@@ -78,6 +61,7 @@ export type OfflineNotice =
| { kind: 'send-failed'; subject: string; error: OutboxError }
| { kind: 'held'; subject: string }
| { kind: 'lost'; subjects: string[] }
| { kind: 'conflict'; count: number }
| { kind: 'refused-delete' }
| { kind: 'refused-other' }
| { kind: 'forbidden' }
@@ -96,11 +80,19 @@ export function offlineNotify(notice: OfflineNotice): void {
// ---- Who may use it ----
/** Offline mode is possible here at all: not in Lite, with IndexedDB and WebCrypto. */
function hasIndexedDb(): boolean {
try {
return typeof indexedDB !== 'undefined' && indexedDB !== null;
} catch {
return false;
}
}
/** Offline mode is possible here at all: not in Lite, with what the store needs. */
export function offlineSupported(): boolean {
if (IS_LITE || !hasIndexedDb()) return false;
try {
return typeof crypto !== 'undefined' && !!crypto.subtle;
return typeof crypto !== 'undefined' && !!crypto.subtle && getStore().supported();
} catch {
return false;
}
@@ -137,179 +129,60 @@ function onlineClientFor(account: string) {
return client;
}
function serverOf(account: string): string | undefined {
return useAccountStore.getState().accounts.find((a) => a.id === account)?.serverUrl;
}
// ---- The store's way to this tab ----
// ---- What the server sends, into the cache ----
export interface Scope {
inbox: Record<string, string>;
opened: Record<string, string[]>;
}
const MAX_OPENED_FOLDERS = 10;
/** The Inbox and the opened folders that still exist, per JMAP account. */
export async function scopeOf(account: string): Promise<Scope> {
const mailboxes = (await getMisc<Record<string, Array<{ id: string; role?: string | null }>>>(account, 'mailboxes')) ?? {};
const inbox: Record<string, string> = {};
for (const [jmap, list] of Object.entries(mailboxes)) {
const box = list.find((m) => m.role === 'inbox');
if (box) inbox[jmap] = box.id;
}
const stored = (await getMisc<Record<string, string[]>>(account, 'opened')) ?? {};
// A folder deleted or moved away is no longer opened.
const opened: Record<string, string[]> = {};
let pruned = false;
for (const [jmap, ids] of Object.entries(stored)) {
const list = mailboxes[jmap];
const kept = list ? ids.filter((id) => list.some((m) => m.id === id)) : ids;
if (kept.length !== ids.length) pruned = true;
opened[jmap] = kept;
}
if (pruned) await putMisc(account, 'opened', opened);
return { inbox, opened };
}
export async function scopeMailboxes(account: string): Promise<Array<{ jmap: string; mailboxId: string; isInbox: boolean }>> {
const scope = await scopeOf(account);
const out: Array<{ jmap: string; mailboxId: string; isInbox: boolean }> = [];
for (const [jmap, id] of Object.entries(scope.inbox)) out.push({ jmap, mailboxId: id, isInbox: true });
for (const [jmap, ids] of Object.entries(scope.opened)) {
for (const id of ids) if (scope.inbox[jmap] !== id) out.push({ jmap, mailboxId: id, isInbox: false });
}
return out;
}
function keptFolders(scope: Scope): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const [jmap, id] of Object.entries(scope.inbox)) out[jmap] = [id];
for (const [jmap, ids] of Object.entries(scope.opened)) out[jmap] = [...(out[jmap] ?? []), ...ids];
return out;
}
/** A message the cache keeps: received in the last 30 days, in the Inbox or an opened folder. */
export function inScope(scope: Scope, jmap: string, email: CachedEmail, now = Date.now()): boolean {
const at = Date.parse(String(email.receivedAt ?? ''));
if (Number.isFinite(at) && at < now - CACHE_WINDOW_MS) return false;
const boxes = Object.keys(email.mailboxIds ?? {});
const wanted = new Set([...(scope.opened[jmap] ?? []), ...(scope.inbox[jmap] ? [scope.inbox[jmap]] : [])]);
return boxes.some((id) => wanted.has(id));
}
/** The folder a list query opened, if it is one. */
function openedFolder(args: Record<string, unknown>): string | null {
if (typeof args.limit !== 'number') return null;
const filter = args.filter as Record<string, unknown> | undefined;
if (!filter) return null;
if (typeof filter.inMailbox === 'string' && Object.keys(filter).every((k) => k === 'inMailbox' || k === 'hasKeyword')) return filter.inMailbox;
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
for (const c of filter.conditions as Array<Record<string, unknown>>) if (typeof c.inMailbox === 'string' && Object.keys(c).length === 1) return c.inMailbox;
}
return null;
}
async function noteOpenedFolder(account: string, jmap: string, mailboxId: string): Promise<void> {
const opened = (await getMisc<Record<string, string[]>>(account, 'opened')) ?? {};
const list = opened[jmap] ?? [];
if (list[0] === mailboxId) return;
opened[jmap] = [mailboxId, ...list.filter((id) => id !== mailboxId)].slice(0, MAX_OPENED_FOLDERS);
await putMisc(account, 'opened', opened);
scheduleSync(account, 5_000);
}
async function putList(account: string, name: 'mailboxes' | 'identities', jmap: string, list: unknown[]): Promise<void> {
const all = (await getMisc<Record<string, unknown[]>>(account, name)) ?? {};
all[jmap] = list;
await putMisc(account, name, all);
}
/** The methods whose answers the cache takes. */
const RECORDED = new Set(['Mailbox/get', 'Identity/get', 'Email/get', 'Email/query', 'Email/set']);
/** What the server answered, into the cache. */
export async function recordAnswer(account: string, defaultJmap: string, calls: OfflineMethodCall[], response: OfflineResponse): Promise<void> {
const scope = await scopeOf(account);
for (const [name, result, callId] of response.methodResponses) {
if (!result || typeof result !== 'object') continue;
const call = calls.find(([n, , id]) => id === callId && n === name);
const args = call?.[1] ?? {};
const jmap = (result.accountId as string) || (args.accountId as string) || defaultJmap;
const properties = args.properties as string[] | undefined;
switch (name) {
case 'Mailbox/get':
if (!args.ids && (!properties || properties.includes('name')) && Array.isArray(result.list)) {
await putList(account, 'mailboxes', jmap, result.list);
}
break;
case 'Identity/get':
if (!args.ids && Array.isArray(result.list)) await putList(account, 'identities', jmap, result.list);
break;
case 'Email/get': {
const list = (Array.isArray(result.list) ? result.list : []) as CachedEmail[];
// Fresh state for every message already kept; new ones only within the scope.
const index = await emailIndex(account);
const keep = list.filter((email) => index.has(emailKey(account, jmap, email.id)) || inScope(scope, jmap, email));
await putEmails(account, jmap, keep);
if (keep.length) scheduleCapCheck();
break;
}
case 'Email/query': {
const folder = openedFolder(args);
if (folder) await noteOpenedFolder(account, jmap, folder);
break;
}
case 'Email/set': {
const update = (args.update ?? {}) as Record<string, Record<string, unknown>>;
const updated = Object.keys((result.updated ?? {}) as object);
if (updated.length) await patchEmails(account, jmap, Object.fromEntries(updated.filter((id) => update[id]).map((id) => [id, update[id]])));
const destroyed = (Array.isArray(result.destroyed) ? result.destroyed : []) as string[];
if (destroyed.length) await removeEmails(account, destroyed.map((id) => emailKey(account, jmap, id)));
break;
}
default:
break;
const storeHooks: StorePageHooks = {
inputs(account) {
const client = onlineClientFor(account);
return {
session: (client?.getSessionSnapshot() as StoreSession | undefined) ?? null,
auth: client?.getAuthHeader() ?? null,
invertedKeywordSort: client?.keywordSortInverted() ?? null,
};
},
async refreshAuth(account) {
const client = onlineClientFor(account);
if (!client) return null;
// Through the client: a 401 there runs its token renewal or re-auth.
await client.requestNetworkOnly([['Core/echo', {}, '0']]);
return client.getAuthHeader();
},
onEvent(event) {
if (event.type === 'synced') {
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
void refreshUsage();
}
}
},
};
// ---- What the client asks and tells ----
/** Parts cut short for a list view aren't what the user opened. */
function hasWholeText(email: { bodyValues?: Record<string, { isTruncated?: boolean }> }): boolean {
const values = Object.values(email.bodyValues ?? {});
return values.length > 0 && !values.some((value) => value?.isTruncated);
}
// One write after the other per account, so a list and its refresh land in order.
const recording = new Map<string, Promise<void>>();
function enqueueRecord(account: string, task: () => Promise<void>): void {
const previous = recording.get(account) ?? Promise.resolve();
const next = previous.then(task).catch(() => {});
recording.set(account, next);
}
let capTimer: ReturnType<typeof setTimeout> | null = null;
/** The cap holds between syncs too: checked shortly after writes. */
function scheduleCapCheck(delayMs = 5_000): void {
if (capTimer) return;
capTimer = setTimeout(() => {
capTimer = null;
if (useOfflineStore.getState().enabled.length === 0) return;
void enforceCap().then(() => refreshUsage()).catch(() => {});
}, delayMs);
}
/** Mutating calls the device can't take: a send, an import, calendar and contact changes. */
/** Mutating calls the device can't take: an import, calendar and contact changes. */
const NEEDS_SERVER = /^(EmailSubmission|Email\/import|CalendarEvent\/set|ContactCard\/set|AddressBook\/set|Calendar\/set|Mailbox\/set|FileNode\/set|SieveScript\/set|VacationResponse\/set|Identity\/set)/;
let lastRefusalNotice = 0;
function noticeRefused(methodCalls: OfflineMethodCall[]): void {
if (!methodCalls.some(([name]) => NEEDS_SERVER.test(name))) return;
function noticeRefused(answered: OfflineResponse | null, methodCalls: OfflineMethodCall[]): void {
const refusedCall = methodCalls.some(([name, , id]) => NEEDS_SERVER.test(name)
&& (!answered || answered.methodResponses.some(([responseName, , responseId]) => responseId === id && responseName === 'error')));
const refusedChange = answered?.methodResponses.some(([name, result]) => name === 'Email/set' && result?.notUpdated) ?? false;
if (!refusedCall && !refusedChange) return;
const now = Date.now();
if (now - lastRefusalNotice < 5_000) return;
lastRefusalNotice = now;
notify({ kind: 'refused-other' });
const emptied = answered?.methodResponses.some(([name, result]) => name === 'Email/set'
&& Object.values((result?.notUpdated ?? {}) as Record<string, { type?: string }>).some((error) => error.type === 'invalidProperties'));
notify({ kind: emptied ? 'refused-delete' : 'refused-other' });
}
// ---- Hooks for the JMAP client ----
const probes = new Map<string, Promise<boolean>>();
/** A black-holed connection never answers the probe either. */
const PROBE_TIMEOUT_MS = 5_000;
/**
* Whether a mail server is gone. Browsers report a refusal without CORS
@@ -324,7 +197,7 @@ export function probeServer(serverUrl: string): Promise<boolean> {
if (running) return running;
const probe = (async () => {
try {
await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' });
await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
if (setServerDown(serverUrl, false)) scheduleFlushAll(500);
return false;
} catch {
@@ -344,32 +217,43 @@ const hooks: OfflineHooks = {
async answer(client, methodCalls) {
const account = accountForClient(client);
if (!offlineActive(account) || !account) return null;
const scope = await scopeOf(account).catch(() => null);
const answered = await answerOffline({
account,
defaultJmap: client.getAccountId(),
keptFolders: scope ? keptFolders(scope) : undefined,
queuePatch: async (jmap, update) => {
await queuePatch(account, jmap, update);
void refreshQueueState();
},
onRefused: (what) => notify({ kind: what === 'destroy' ? 'refused-delete' : 'refused-other' }),
}, methodCalls);
if (!answered) noticeRefused(methodCalls);
let answered: OfflineResponse | null = null;
try {
answered = await getStore().answer(account, methodCalls);
} catch {
// The store couldn't start or lost its owner: as if nothing were kept.
}
noticeRefused(answered, methodCalls);
if (methodCalls.some(([name]) => name === 'Email/set') && answered) {
requestOutboxSync();
void refreshQueueState();
}
return answered;
},
async searchLocal(client, methodCalls) {
const account = accountForClient(client);
if (!offlineActive(account) || !account) return null;
try {
return await getStore().answer(account, methodCalls);
} catch {
return null;
}
},
record(client, methodCalls, response) {
// The store syncs by itself. What it may not have is a message the user
// opened from outside the chosen window, so only those come through here.
const account = accountForClient(client);
if (!offlineActive(account) || !account) return;
// Only the answers the cache takes; copied now, because callers change
// the answer's objects after this returns.
const wanted = response.methodResponses.filter(([name]) => RECORDED.has(name));
if (wanted.length === 0) return;
const ids = new Set(wanted.map(([, , id]) => id));
const copy = JSON.parse(JSON.stringify({ methodResponses: wanted })) as OfflineResponse;
const calls = JSON.parse(JSON.stringify(methodCalls.filter(([, , id]) => ids.has(id)))) as OfflineMethodCall[];
const jmap = client.getAccountId();
enqueueRecord(account, () => recordAnswer(account, jmap, calls, copy));
for (const [name, result, callId] of response.methodResponses) {
if (name !== 'Email/get' || !Array.isArray(result?.list)) continue;
const opened = (result.list as Array<{ bodyValues?: Record<string, { isTruncated?: boolean }> }>).filter(hasWholeText);
if (opened.length === 0) continue;
const args = methodCalls.find(([n, , id]) => id === callId && n === name)?.[1];
const jmap = (result.accountId as string) || (args?.accountId as string) || client.getAccountId();
// Copied now: callers change the answer's objects after this returns.
const emails = JSON.parse(JSON.stringify(opened)) as unknown[];
void getStore().area(account, 'mail.recordOpened', { jmap, emails }).catch(() => {});
}
},
noteNetwork(client, reachable) {
if (reachable) {
@@ -378,33 +262,41 @@ const hooks: OfflineHooks = {
void probeServer(client.getServerUrl());
}
},
async serverGone(client) {
async serverGone(client, error) {
if (!offlineActive(accountForClient(client))) return false;
if (typeof navigator !== 'undefined' && navigator.onLine === false) return true;
// The proxy answered, so a probe would pass; the server behind it is what's gone.
if (error instanceof Error && error.name === 'GatewayError') {
setServerDown(client.getServerUrl(), true);
return true;
}
return probeServer(client.getServerUrl());
},
stateChange(client, change) {
const account = accountForClient(client);
if (!offlineActive(account) || !account || !change.changed) return;
void getStore().noteStateChange(account, change.changed).catch(() => {});
},
async blob(client, blobId, jmap) {
const account = accountForClient(client);
if (!account || !offlineActive(account)) return null;
if (isLocalBlobId(blobId)) return heldFile(account, blobId);
return getBlob(account, jmap, blobId);
const kept = await getStore().area<{ type: string | null; data: Uint8Array } | null>(account, 'mail.getBlob', { jmap, blobId }).catch(() => null);
return kept ? new Blob([kept.data as BlobPart], { type: kept.type || 'application/octet-stream' }) : null;
},
recordBlob(client, blobId, jmap, blob) {
const account = accountForClient(client);
if (!account || !offlineActive(account) || blob.size > MAX_RECORDED_BLOB_BYTES) return;
// Only a part of a kept message (an attachment, an inline image), filed
// with it: never a whole message an export reads, never a stray file.
enqueueRecord(account, async () => {
const owner = await messageOfBlob(account, jmap, blobId);
if (!owner) return;
await putBlob(account, jmap, blobId, blob, owner.at, owner.key);
scheduleCapCheck();
});
// Filed under its kept message by the store: never a whole message an
// export reads, never a stray file.
void blob.arrayBuffer()
.then((buffer) => getStore().area(account, 'mail.recordBlob', { jmap, blobId, type: blob.type || null, data: new Uint8Array(buffer) }))
.catch(() => {});
},
async holdUpload(client, file) {
async holdUpload(client, file, accountId) {
const account = accountForClient(client);
if (!account || !offlineActive(account)) return null;
return holdFile(account, file, file.name);
return holdFile(account, file, file.name, accountId);
},
};
@@ -481,7 +373,9 @@ const outbox: OutboxHooks = {
const stored = [...args.slice(0, 15), options];
const summary = summaryOf(stored);
const labelled = !!options.labelHeaders && Object.keys(options.labelHeaders as object).length > 0;
const id = await queueSend(account, summary, { kind: 'email', args: stored }, policyFingerprint(account, labelled));
// Fixed now and kept across retries: how a lost response is told from a send that never arrived.
const messageId = generateMessageId((args[6] as string | undefined) || client.getUsername());
const id = await queueSend(account, summary, { kind: 'email', args: stored }, policyFingerprint(account, labelled), messageId);
await refreshQueueState();
notify({ kind: 'queued', subject: summary.subject });
return queuedResult(id);
@@ -491,8 +385,8 @@ const outbox: OutboxHooks = {
if (!account) throw new Error('No account for this send');
if (pluginsNotRunning()) throw new OfflinePluginsDownError();
const held = await holdFile(account, raw, 'message.eml');
const { summary, labelled } = await rawSummary(raw);
const id = await queueSend(account, summary, { kind: 'raw', blobId: held.blobId, ...options }, policyFingerprint(account, labelled));
const { summary, labelled, messageId } = await rawSummary(raw);
const id = await queueSend(account, summary, { kind: 'raw', blobId: held.blobId, ...options }, policyFingerprint(account, labelled), messageId);
await refreshQueueState();
notify({ kind: 'queued', subject: summary.subject });
return queuedResult(id);
@@ -514,14 +408,18 @@ const outbox: OutboxHooks = {
},
};
/** Subject, recipients and label of a raw message, for the outbox list. */
async function rawSummary(raw: Blob): Promise<{ summary: OutboxSummary; labelled: boolean }> {
/** Subject, recipients, label and Message-ID of a raw message, for the outbox. */
async function rawSummary(raw: Blob): Promise<{ summary: OutboxSummary; labelled: boolean; messageId?: string }> {
const head = await raw.slice(0, 64 * 1024).text();
const end = head.search(/\r?\n\r?\n/);
const block = (end >= 0 ? head.slice(0, end) : head).replace(/\r?\n[ \t]+/g, ' ');
const header = (name: string) => block.split(/\r?\n/).find((line) => line.toLowerCase().startsWith(`${name}:`))?.slice(name.length + 1).trim() ?? '';
const to = ['to', 'cc'].flatMap((name) => header(name).split(',')).map((s) => s.trim()).filter(Boolean);
return { summary: { subject: decodeMimeWords(header('subject')), to }, labelled: header('sio-label') !== '' };
return {
summary: { subject: decodeMimeWords(header('subject')), to },
labelled: header('sio-label') !== '',
messageId: header('message-id').match(/<([^>\s]+)>/)?.[1],
};
}
function decodeMimeWords(value: string): string {
@@ -541,15 +439,15 @@ function decodeMimeWords(value: string): string {
export async function refreshQueueState(): Promise<void> {
if (!offlineSupported()) return;
// Nothing is kept for an account that hasn't it on; and opening the
// database would create it (on the sign-in page, after a sign-out).
// store would create it (on the sign-in page, after a sign-out).
if (useOfflineStore.getState().enabled.length === 0) {
useOfflineStore.setState({ outbox: [], pendingChanges: 0 });
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [] });
return;
}
try {
// Sends of a tab closed on the way: uncertain, so they can be retried or deleted.
// Sends of a tab closed on the way: uncertain, so they can be looked up.
await settleStaleSends();
const { patches, outbox: entries } = await readQueue();
const { patches, outbox: entries, conflicts } = await readQueue();
const rows: OutboxRow[] = entries.map((entry) => ({
id: entry.id,
account: entry.account,
@@ -559,16 +457,17 @@ export async function refreshQueueState(): Promise<void> {
status: entry.status,
error: entry.error,
}));
useOfflineStore.setState({ outbox: rows, pendingChanges: patches.length });
useOfflineStore.setState({ outbox: rows, pendingChanges: patches, conflicts });
} catch {
// The database is being deleted.
// The store is being deleted, or can't start.
}
}
export async function refreshUsage(): Promise<void> {
if (!offlineSupported() || useOfflineStore.getState().enabled.length === 0) return;
try {
useOfflineStore.setState({ usageBytes: (await cacheUsage()).total });
const usage = await Promise.all(useOfflineStore.getState().enabled.map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage')));
useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0) });
} catch {
/* deleted meanwhile */
}
@@ -597,12 +496,9 @@ export async function runSync(account: string): Promise<void> {
syncsRunning.add(account);
useOfflineStore.setState((s) => ({ syncing: [...new Set([...s.syncing, account])] }));
try {
await syncAccount(account, client, {
stillActive: () => offlineActive(account),
recorded: async () => { await recording.get(account); },
scopeMailboxes,
});
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [account]: Date.now() } }));
await getStore().ensureOpen(account);
getStore().refresh(account);
await getStore().sync(account);
} catch {
// Tried again at the next change or in 15 minutes.
} finally {
@@ -668,7 +564,7 @@ export async function flushAll(): Promise<void> {
allowed: () => offlineAllowed(account),
stricterNow,
onScheduled: offerUndo,
});
}).catch(() => {});
}
await refreshQueueState();
for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 3_000);
@@ -678,7 +574,6 @@ export async function flushAll(): Promise<void> {
export async function enableOffline(account: string): Promise<void> {
if (!offlineAllowed(account)) return;
allowOfflineDbCreation();
const enabled = useOfflineStore.getState().enabled;
if (!enabled.includes(account)) writeEnabled([...enabled, account]);
// Ask the browser not to clear it when space runs low (it may say no).
@@ -687,8 +582,7 @@ export async function enableOffline(account: string): Promise<void> {
} catch {
/* no Storage API */
}
const client = onlineClientFor(account);
if (client) await putMisc(account, 'session', client.getSessionSnapshot());
await getStore().ensureOpen(account).catch(() => {});
await tellServiceWorker(true);
scheduleSync(account, 0);
}
@@ -714,12 +608,14 @@ export function confirmLoss(scope: string): void {
async function unsentSubjects(scope: string): Promise<string[]> {
let rows = useOfflineStore.getState().outbox;
// Reading an account's store would create it if it never had one.
if (scope !== 'all' && !useOfflineStore.getState().enabled.includes(scope)) return rows.filter((row) => row.account === scope).map((row) => row.subject);
try {
rows = (await readQueue(scope === 'all' ? undefined : scope)).outbox.map((entry) => ({
id: entry.id, account: entry.account, createdAt: entry.createdAt, subject: entry.summary.subject, to: entry.summary.to, status: entry.status,
}));
} catch {
// No database: what the store last saw.
// No store: what the state last saw.
}
return rows.filter((row) => scope === 'all' || row.account === scope).map((row) => row.subject);
}
@@ -745,7 +641,6 @@ export async function wipeAccount(account: string): Promise<void> {
const timer = syncTimers.get(account);
if (timer) clearTimeout(timer);
syncTimers.delete(account);
await recording.get(account)?.catch(() => {});
if (useOfflineStore.getState().enabled.includes(account) || useOfflineStore.getState().outbox.some((row) => row.account === account)) {
await nameLost(account, useAuthStore.getState().isAuthenticated);
}
@@ -753,17 +648,16 @@ export async function wipeAccount(account: string): Promise<void> {
if (useOfflineStore.getState().enabled.includes(account)) {
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
}
// Nobody keeps mail here any more: the whole database goes (and isn't
// opened again, which would create it anew).
// Nobody keeps mail here any more: everything goes (and isn't opened
// again, which would create it anew).
if (useOfflineStore.getState().enabled.length === 0) {
await wipeEverything();
return;
}
try {
await wipeAccountCache(account);
await wipeAccountQueue(account);
await getStore().remove(account);
} catch {
// No database: nothing to remove.
// No store: nothing to remove. The key is gone in any case.
}
await refreshQueueState();
await refreshUsage();
@@ -774,14 +668,18 @@ export async function wipeEverything(): Promise<void> {
for (const timer of syncTimers.values()) clearTimeout(timer);
syncTimers.clear();
writeEnabled([]);
forgetIndexes();
forgetDeviceKey();
await deleteOfflineDb();
getStore().stop();
await purgeStoreFiles();
try {
indexedDB.deleteDatabase(LEGACY_DB_NAME);
} catch {
/* no IndexedDB */
}
await tellServiceWorker(false);
await deleteShellCaches();
confirmed.clear();
named.clear();
useOfflineStore.setState({ outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {} });
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
}
/** Whether the policy of an account is known well enough to decide on it. */
@@ -847,18 +745,34 @@ async function leaveOfflineStart(): Promise<void> {
let started = false;
const cleanups: Array<() => void> = [];
/**
* The JMAP client's hooks. Separate from startOfflineMode because the auth
* store needs them before it restores accounts: a start from the local copy
* asks the store long before the host component mounts.
*/
export function registerOfflineHooks(): void {
if (!offlineSupported()) return;
configureStore(storeHooks);
setOfflineHooks(hooks);
setOutboxHooks(outbox);
void moveLegacyOutbox();
}
/** Runs offline mode in this tab. Safe to call more than once. */
export function startOfflineMode(): () => void {
if (started || !offlineSupported()) return () => {};
started = true;
setOfflineHooks(hooks);
setOutboxHooks(outbox);
registerOfflineHooks();
const onOnline = () => {
useOfflineStore.setState({ browserOffline: false });
void getStore().setOnline(true).catch(() => {});
scheduleFlushAll(500);
};
const onOffline = () => useOfflineStore.setState({ browserOffline: true });
const onOffline = () => {
useOfflineStore.setState({ browserOffline: true });
void getStore().setOnline(false).catch(() => {});
};
window.addEventListener('online', onOnline);
window.addEventListener('offline', onOffline);
cleanups.push(() => {
@@ -866,8 +780,17 @@ export function startOfflineMode(): () => void {
window.removeEventListener('offline', onOffline);
});
// Back in front: catch up now rather than at the next interval.
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
for (const account of useOfflineStore.getState().enabled) scheduleSync(account, 0);
};
document.addEventListener('visibilitychange', onVisible);
cleanups.push(() => document.removeEventListener('visibilitychange', onVisible));
// Another tab switched it off for an account (or everyone signed out
// there): stop here too, and don't write anything for that account.
// there): stop here too, and don't write anything for that account. One
// switched on there has to be opened here if this tab owns the store.
const onStorage = (event: StorageEvent) => {
if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return;
const next = readEnabledFrom(event.key === null ? null : event.newValue);
@@ -877,13 +800,9 @@ export function startOfflineMode(): () => void {
const timer = syncTimers.get(id);
if (timer) clearTimeout(timer);
syncTimers.delete(id);
forgetIndexes(id);
}
if (next.length === 0) {
forgetIndexes();
forgetDeviceKey();
}
useOfflineStore.setState({ enabled: next });
for (const id of next) if (!before.includes(id)) void getStore().ensureOpen(id).catch(() => {});
void refreshQueueState();
};
window.addEventListener('storage', onStorage);
@@ -897,12 +816,11 @@ export function startOfflineMode(): () => void {
cleanups.push(useAccountSubjects.subscribe(() => enforcePolicy()));
enforcePolicy();
// An account that signs out (or is removed) takes its offline data with it.
let known = new Set(useAccountStore.getState().accounts.map((a) => a.id));
cleanups.push(useAccountStore.subscribe((state) => {
const now = new Set(state.accounts.map((a) => a.id));
for (const id of known) if (!now.has(id)) void wipeAccount(id);
known = now;
// An account signed out or removed takes its data with it. One the app
// dropped by itself doesn't: its session ran out, or the server can't
// confirm it right now, and that says nothing against the mail on this device.
cleanups.push(onAccountRemoved((id, reason) => {
if (reason === 'explicit') void wipeAccount(id);
}));
// Session, then a sync, for every account signed in with offline mode on.
@@ -910,7 +828,7 @@ export function startOfflineMode(): () => void {
for (const account of useOfflineStore.getState().enabled) {
const client = onlineClientFor(account);
if (!client || !offlineActive(account)) continue;
void putMisc(account, 'session', client.getSessionSnapshot()).catch(() => {});
void getStore().ensureOpen(account).then(() => getStore().refresh(account)).catch(() => {});
scheduleSync(account, 3_000);
}
};
@@ -930,7 +848,7 @@ export function startOfflineMode(): () => void {
if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true);
void (async () => {
if (useOfflineStore.getState().enabled.length > 0) await settleStaleSends().catch(() => {});
await moveLegacyOutbox();
await refreshQueueState();
if (!isOfflineNow()) scheduleFlushAll(2_000);
})();
@@ -940,23 +858,23 @@ export function startOfflineMode(): () => void {
for (const cleanup of cleanups.splice(0)) cleanup();
setOfflineHooks(null);
setOutboxHooks(null);
configureStore(null);
started = false;
};
}
/** How often the cache catches up while the tab is open. */
/** How often the store catches up while the tab is open, on top of pushes. */
export const SYNC_INTERVAL_MS = 15 * 60 * 1000;
// A full sign-out deletes the database (lib/sign-out-cleanup.ts); forget
// what is held in memory as well.
// A full sign-out deletes the store (lib/sign-out-cleanup.ts); forget what
// is held in memory as well.
onFullSignOut(() => {
for (const timer of syncTimers.values()) clearTimeout(timer);
syncTimers.clear();
forgetIndexes();
forgetDeviceKey();
getStore().stop();
confirmed.clear();
named.clear();
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false });
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false });
});
// Every sign-out the user didn't agree to lose mail at (the session limits,
@@ -972,7 +890,10 @@ export async function serverUnreachable(serverUrl: string): Promise<boolean> {
return probeServer(serverUrl);
}
/** The session kept for an account, for starting without the server. */
/** How long a start waits for the store before it signs in the usual way. */
const SESSION_READ_TIMEOUT_MS = 5_000;
/** The session kept for an account, for starting from the local copy. */
export async function offlineSessionFor(account: string): Promise<unknown | null> {
// The organisation's last word: the policy kept from the last fetch (the
// fetch itself fails without a network), never the defaults.
@@ -980,8 +901,13 @@ export async function offlineSessionFor(account: string): Promise<unknown | null
if (!policy.loaded && !policy.fetching) await policy.fetchPolicy();
else await whenPolicySettled(3000);
if (!offlineActive(account)) return null;
registerOfflineHooks();
try {
return await getMisc<unknown>(account, 'session');
await moveLegacyOutbox();
return await Promise.race([
getStore().area<unknown>(account, 'mail.session'),
new Promise<null>((resolve) => setTimeout(() => resolve(null), SESSION_READ_TIMEOUT_MS)),
]);
} catch {
return null;
}
+61 -220
View File
@@ -1,250 +1,91 @@
/**
* What waits on this device for the network (#523): changes made offline
* (read, flagged, tags, moves) and the outbox. Both are kept encrypted in
* the offline database and sent in the order they were made.
*
* An outbox entry is a finished message: everything a send does up to the
* transport ran when the user clicked Send (the organisation's checks,
* the classification label, signatures and disclaimers, plugin send checks,
* S/MIME or PGP with the user present). What is kept is the arguments of the
* final send, with the files that couldn't be uploaded yet, or the finished
* raw message of a plugin's send. Sending it later can't skip any of that.
*
* The state of an outbox entry (waiting, sending, ...) is kept unencrypted
* next to it, so that taking an entry for sending is one database
* transaction: two tabs can't both take it (claimForSending).
* What waits on this device for the network (#523): the page's view of the
* outbox in the local store (lib/store/outbox). Changes made offline wait there
* merged per record; messages wait as finished sends. Everything is kept
* encrypted in the store, and taking an entry for sending is one SQLite
* transaction, so two tabs can't both take it (claimForSending).
*/
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';
import { getStore } from '@/lib/store/client/api';
import type { ConflictSummary, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem, StoredConflict, StoredOutboxEntry } from '@/lib/store/outbox/items';
import { useOfflineStore } from './state';
export interface PatchItem {
kind: 'patch';
jmap: string;
update: Record<string, Record<string, unknown>>;
export { LOCAL_BLOB_PREFIX, heldBlobIds, isLocalBlobId } from '@/lib/store/outbox/items';
export type { EmailSendItem, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, RawSendItem, SendItem } from '@/lib/store/outbox/items';
export interface OutboxEntry extends StoredOutboxEntry {
account: string;
}
/** The arguments of useEmailStore.sendEmail after the client (positional, as the client takes them). */
export interface EmailSendItem {
kind: 'email';
args: unknown[];
}
export interface RawSendItem {
kind: 'raw';
/** The finished message, kept as an upload until it is sent. */
blobId: string;
identityId: string;
delayedUntil?: string;
envelopeRecipients?: string[];
/** submitRaw: no copy in Sent. */
submitOnly?: boolean;
}
export type SendItem = EmailSendItem | RawSendItem;
export interface OutboxSummary {
subject: string;
to: string[];
}
/**
* - waiting: goes out by itself when the server answers;
* - sending: on its way (a tab took it);
* - failed: the server refused it; the error says why;
* - uncertain: the connection broke after it may have reached the server;
* never sent again by itself;
* - held: the organisation's rules got stricter while it waited.
*/
export type OutboxStatus = 'waiting' | 'sending' | 'failed' | 'uncertain' | 'held';
/** Why an entry failed: one of ours (a code the UI translates) or the server's own words. */
export interface OutboxError {
code?: 'file_missing' | 'no_sent_mailbox' | 'no_drafts_mailbox';
text?: string;
}
/** What the organisation required when the message was finished (see offline-mode.ts policyFingerprint). */
export interface PolicyFingerprint {
labelRequired: boolean;
hasLabel: boolean;
guards: string[];
}
export interface OutboxEntry {
export interface ConflictEntry {
id: number;
account: string;
recordId: string;
createdAt: number;
summary: OutboxSummary;
status: OutboxStatus;
/** When a tab took it for sending (ms). */
sendingSince?: number;
error?: OutboxError;
item: SendItem;
fingerprint?: PolicyFingerprint;
summary: ConflictSummary;
}
interface QueueRecord extends Sealed {
id?: number;
account: string;
kind: 'patch' | 'send';
createdAt: number;
status?: OutboxStatus;
sendingSince?: number;
export async function queueSend(account: string, summary: OutboxSummary, item: SendItem, fingerprint?: PolicyFingerprint, messageId?: string): Promise<number> {
const id = await getStore().area<number>(account, 'outbox.queueSend', { summary, item, fingerprint, messageId });
requestOutboxSync();
return id;
}
interface SendPayload {
summary: OutboxSummary;
error?: OutboxError;
item: SendItem;
fingerprint?: PolicyFingerprint;
interface StoredQueue {
patches: number;
entries: StoredOutboxEntry[];
conflicts: StoredConflict[];
}
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);
}
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));
}
export interface QueuedPatch { id: number; account: string; item: PatchItem }
/** Everything waiting for one account (or all), oldest first. */
export async function readQueue(account?: string): Promise<{ patches: QueuedPatch[]; outbox: OutboxEntry[] }> {
const records = account ? await idbGetAllByAccount<QueueRecord>('queue', account) : await idbGetAll<QueueRecord>('queue');
records.sort((a, b) => (a.id ?? 0) - (b.id ?? 0));
const patches: QueuedPatch[] = [];
const outbox: OutboxEntry[] = [];
for (const record of records) {
try {
if (record.kind === 'patch') {
patches.push({ id: record.id!, account: record.account, item: await openJson<PatchItem>(record) });
} else {
const payload = await openJson<SendPayload>(record);
outbox.push({
id: record.id!,
account: record.account,
createdAt: record.createdAt,
status: record.status ?? 'waiting',
sendingSince: record.sendingSince,
...payload,
});
}
} catch {
// Sealed with a key that is gone; removed with the account.
}
}
return { patches, outbox };
}
/**
* Takes a waiting entry for sending, in one transaction: false when it
* isn't waiting any more (another tab took it, the user deleted it).
*/
export async function claimForSending(id: number): Promise<boolean> {
return idbUpdate<QueueRecord>('queue', id, (record) => {
if (!record || record.kind !== 'send' || (record.status ?? 'waiting') !== 'waiting') return undefined;
return { ...record, status: 'sending', sendingSince: Date.now() };
});
}
/** Sets an entry's state; with an error or a new after-send list, its payload too. */
export async function updateOutboxEntry(
entry: OutboxEntry,
patch: { status: OutboxStatus; error?: OutboxError },
): Promise<void> {
const payload: SendPayload = {
summary: entry.summary,
item: entry.item,
fingerprint: entry.fingerprint,
error: patch.error,
/** Everything waiting for one account (or every account with it on), oldest first. */
export async function readQueue(account?: string): Promise<{ patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
const accounts = account ? [account] : useOfflineStore.getState().enabled;
const read = await Promise.all(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') })));
return {
patches: read.reduce((sum, { queue }) => sum + queue.patches, 0),
outbox: read.flatMap(({ id, queue }) => queue.entries.map((entry) => ({ ...entry, account: id }))),
conflicts: read.flatMap(({ id, queue }) => queue.conflicts.map((conflict) => ({
id: conflict.id,
account: id,
recordId: conflict.recordId,
createdAt: conflict.createdAt,
summary: conflict.summary,
}))),
};
const sealed = await sealJson(payload);
await idbUpdate<QueueRecord>('queue', entry.id, (record) => {
// Deleted meanwhile: stays deleted.
if (!record) return undefined;
return { ...record, status: patch.status, sendingSince: patch.status === 'sending' ? Date.now() : undefined, ...sealed };
});
}
export async function removeQueued(ids: number[]): Promise<void> {
await idbWrite(ids.map((id) => ({ store: 'queue' as const, del: id })));
/** Takes a waiting entry for sending: false when it isn't waiting any more (another tab took it, the user deleted it). */
export function claimForSending(account: string, id: number): Promise<boolean> {
return getStore().area<boolean>(account, 'outbox.claim', { id });
}
export function updateOutboxEntry(
entry: Pick<OutboxEntry, 'account' | 'id'>,
patch: { status: OutboxStatus; error?: OutboxError; copyId?: string | null },
): Promise<void> {
return getStore().area(entry.account, 'outbox.update', { id: entry.id, ...patch });
}
/** Removes an outbox entry and the files it held. */
export async function removeOutboxEntry(entry: OutboxEntry): Promise<void> {
const blobIds = heldBlobIds(entry.item);
await idbWrite([
{ store: 'queue', del: entry.id },
...blobIds.map((blobId) => ({ store: 'uploads' as const, del: uploadKey(entry.account, blobId) })),
]);
export function removeOutboxEntry(entry: Pick<OutboxEntry, 'account' | 'id'>): Promise<void> {
return getStore().area(entry.account, 'outbox.remove', { id: entry.id });
}
export function dismissConflict(account: string, id: number): Promise<void> {
return getStore().area(account, 'outbox.dismissConflict', { id });
}
// ---- Files attached while offline ----
/** Stand-in blob ids of files kept on this device start with this. */
export const LOCAL_BLOB_PREFIX = 'bulwark-local-';
const uploadKey = (account: string, blobId: string) => `${account}\u0001${blobId}`;
export function isLocalBlobId(blobId: unknown): blobId is string {
return typeof blobId === 'string' && blobId.startsWith(LOCAL_BLOB_PREFIX);
}
interface UploadRecord extends Sealed {
key: string;
account: string;
createdAt: number;
/** The file's name and type, sealed: names say a lot about a message. */
meta: Sealed;
}
/** Keeps a file under a stand-in blob id until its message is sent. */
export async function holdFile(account: string, file: Blob, name: string): Promise<{ blobId: string; size: number; type: string }> {
const blobId = `${LOCAL_BLOB_PREFIX}${generateUUID()}`;
const sealed = await sealBytes(await file.arrayBuffer());
const meta = await sealJson({ name, type: file.type });
await idbWrite([{ store: 'uploads', put: { key: uploadKey(account, blobId), account, createdAt: Date.now(), meta, ...sealed } satisfies UploadRecord }]);
return { blobId, size: file.size, type: file.type || 'application/octet-stream' };
export async function holdFile(account: string, file: Blob, name: string, jmap?: string): Promise<{ blobId: string; size: number; type: string }> {
const data = new Uint8Array(await file.arrayBuffer());
return getStore().area(account, 'outbox.holdFile', { jmap, name, type: file.type, data });
}
export async function heldFile(account: string, blobId: string): Promise<File | null> {
const record = await idbGet<UploadRecord>('uploads', uploadKey(account, blobId));
if (!record) return null;
try {
const meta = await openJson<{ name?: string; type?: string }>(record.meta);
return new File([await openBytes(record)], meta.name || 'attachment', { type: meta.type || 'application/octet-stream' });
} catch {
return null;
}
}
/** The stand-in blob ids an outbox entry needs uploaded first. */
export function heldBlobIds(item: SendItem): string[] {
if (item.kind === 'raw') return isLocalBlobId(item.blobId) ? [item.blobId] : [];
const attachments = item.args[10];
if (!Array.isArray(attachments)) return [];
return attachments.map((a) => (a as { blobId?: unknown }).blobId).filter(isLocalBlobId);
}
/** Files held for no outbox entry (attached offline to a message that was then discarded). */
export async function dropOrphanFiles(account: string, olderThanMs = 24 * 60 * 60 * 1000): Promise<void> {
const { outbox } = await readQueue(account);
const needed = new Set(outbox.flatMap((entry) => heldBlobIds(entry.item)));
const held = await idbGetAllByAccount<{ key: string; createdAt?: number }>('uploads', account);
const cutoff = Date.now() - olderThanMs;
const stale = held.filter((record) => {
const blobId = record.key.split('\u0001')[1];
return !needed.has(blobId) && (record.createdAt ?? 0) < cutoff;
});
await idbWrite(stale.map((record) => ({ store: 'uploads' as const, del: record.key })));
}
/** The queue and held files of one account. */
export async function wipeAccountQueue(account: string): Promise<void> {
await idbDeleteAccount(account, ['queue', 'uploads']);
const held = await getStore().area<{ name: string; type: string; data: Uint8Array } | null>(account, 'outbox.heldFile', { blobId });
if (!held) return null;
return new File([held.data as BlobPart], held.name, { type: held.type });
}
-394
View File
@@ -1,394 +0,0 @@
/**
* 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 collection 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)) : [],
};
}
/** Unfiltered mailbox pages used to load a complete folder list offline. */
async function mailboxQuery(ctx: ResponderContext, args: Record<string, unknown>): Promise<MethodResult> {
if (args.filter || args.sort || args.anchor || args.sortAsTree) throw new Unanswerable();
const { list } = await listGet(ctx, { accountId: args.accountId }, 'mailboxes');
const mailboxes = list as Record<string, unknown>[];
const position = typeof args.position === 'number' ? args.position : 0;
const limit = typeof args.limit === 'number' ? args.limit : mailboxes.length;
return {
accountId: (args.accountId as string) || ctx.defaultJmap,
// A query token only, never a Mailbox collection state for /changes.
queryState: JSON.stringify(mailboxes),
position,
ids: mailboxes.slice(position, position + limit).map((mailbox) => mailbox.id),
...(args.calculateTotal ? { total: mailboxes.length } : {}),
};
}
/** 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/query': result = await mailboxQuery(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 };
}
+5 -2
View File
@@ -1,13 +1,13 @@
/**
* What offline mode (#523) shows: which accounts keep mail on this device,
* whether the browser is offline, what waits in the outbox, how much the
* cache uses. The switch itself ("Keep mail on this device") is per account
* local store uses. The switch itself ("Keep mail on this device") is per account
* and per browser, in localStorage, never synced: it is off until the user
* turns it on here.
*/
import { create } from 'zustand';
import type { OutboxEntry, OutboxError } from './queue';
import type { ConflictEntry, OutboxEntry, OutboxError } from './queue';
/** localStorage: the local account ids that keep mail on this device. */
export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts';
@@ -33,6 +33,8 @@ interface OfflineState {
startedOffline: boolean;
outbox: OutboxRow[];
pendingChanges: number;
/** Changes the server refused, or that met a message changed on another device. */
conflicts: ConflictEntry[];
usageBytes: number;
/** Accounts whose mail is being brought onto the device right now. */
syncing: string[];
@@ -56,6 +58,7 @@ export const useOfflineStore = create<OfflineState>()(() => ({
startedOffline: false,
outbox: [],
pendingChanges: 0,
conflicts: [],
usageBytes: 0,
syncing: [],
lastSyncedAt: {},
-137
View File
@@ -1,137 +0,0 @@
/**
* Brings an account's recent mail onto the device (#523): the last 30 days
* of the Inbox and of the folders the user opened (at most INBOX_LIMIT and
* FOLDER_LIMIT messages), with bodies, and the attachments and inline
* images of those messages up to ATTACHMENT_MAX_BYTES each. Messages that
* left that window or the folders are removed; then the cache is brought
* under its cap, oldest first.
*
* Everything goes through the JMAP client, whose answers the cache records
* (offline-mode.ts), so the sync only asks.
*/
import type { JMAPClient } from '@/lib/jmap/client';
import { CACHE_WINDOW_MS, emailIndex, emailKey, enforceCap, getBody, hasBlob, hasBody, putBlob, putMisc, removeEmails, type CachedEmail } from './cache';
export const INBOX_LIMIT = 500;
export const FOLDER_LIMIT = 200;
export const ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024;
/** Attachment bytes one sync run fetches at most. */
const ATTACHMENT_BUDGET_BYTES = 100 * 1024 * 1024;
interface SyncDeps {
stillActive: () => boolean;
/** Resolves once the cache has written every answer the client recorded so far. */
recorded: () => Promise<void>;
scopeMailboxes: (account: string) => Promise<Array<{ jmap: string; mailboxId: string; isInbox: boolean }>>;
}
interface AttachmentPart {
blobId?: string;
name?: string | null;
type?: string;
size?: number;
}
/** Once more after a second: Stalwart refuses requests beyond its limit of parallel ones. */
async function twice<T>(run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch {
await new Promise((r) => setTimeout(r, 1000));
return run();
}
}
export async function syncAccount(account: string, client: JMAPClient, deps: SyncDeps): Promise<void> {
await putMisc(account, 'session', client.getSessionSnapshot());
// Folders and identities are recorded from these answers.
await twice(() => client.getAllMailboxes()).catch(() => client.getMailboxes());
await twice(() => client.getIdentities()).catch(() => []);
// The folder list says which folder is the Inbox.
await deps.recorded();
if (!deps.stillActive()) return;
const since = new Date(Date.now() - CACHE_WINDOW_MS).toISOString();
const wanted = new Map<string, Set<string>>();
const scopes = await deps.scopeMailboxes(account);
// Per folder: which ones were asked, and which couldn't be.
const scopeFolders = new Map<string, Set<string>>();
const failedFolders = new Map<string, Set<string>>();
const add = (map: Map<string, Set<string>>, jmap: string, id: string) => {
const set = map.get(jmap) ?? new Set<string>();
set.add(id);
map.set(jmap, set);
};
for (const { jmap, mailboxId, isInbox } of scopes) {
add(scopeFolders, jmap, mailboxId);
let ids: string[];
try {
ids = await twice(() => client.getRecentEmailIds(mailboxId, jmap, since, isInbox ? INBOX_LIMIT : FOLDER_LIMIT));
} catch {
add(failedFolders, jmap, mailboxId);
continue;
}
const set = wanted.get(jmap) ?? new Set<string>();
ids.forEach((id) => set.add(id));
wanted.set(jmap, set);
}
if (!deps.stillActive()) return;
for (const [jmap, idSet] of wanted) {
const ids = [...idSet];
// Fresh keywords and folders for all of them (cheap: list properties).
if (ids.length) await twice(() => client.getSomeEmails(ids, jmap === client.getAccountId() ? undefined : jmap)).catch(() => []);
// Bodies for the ones that don't have one yet, a batch at a time.
const missing: string[] = [];
for (const id of ids) if (!(await hasBody(account, jmap, id))) missing.push(id);
for (let i = 0; i < missing.length; i += 20) {
// Fetched again at the next sync.
await twice(() => client.getFullEmails(missing.slice(i, i + 20), jmap)).catch(() => {});
if (!deps.stillActive()) return;
}
}
await deps.recorded();
// What left the window or the folders goes; a folder that couldn't be
// asked keeps what it had, the others are brought up to date.
const index = await emailIndex(account);
const stale: string[] = [];
if (scopes.length > 0) {
for (const [key, email] of index) {
const jmap = key.split('\u0001')[1];
if (wanted.get(jmap)?.has(email.id)) continue;
const folders = Object.keys(email.mailboxIds ?? {}).filter((id) => scopeFolders.get(jmap)?.has(id));
if (folders.some((id) => failedFolders.get(jmap)?.has(id))) continue;
stale.push(key);
}
}
await removeEmails(account, stale);
// Attachments and inline images, newest messages first, within the budget.
let budget = ATTACHMENT_BUDGET_BYTES;
const newestFirst = [...index.entries()].sort(([, a], [, b]) => (Date.parse(String(b.receivedAt)) || 0) - (Date.parse(String(a.receivedAt)) || 0));
for (const [key, email] of newestFirst) {
if (budget <= 0 || !deps.stillActive()) break;
const jmap = key.split('\u0001')[1];
for (const part of await attachmentsOf(account, email, jmap)) {
if (!part.blobId || !part.size || part.size > ATTACHMENT_MAX_BYTES || part.size > budget) continue;
if (await hasBlob(account, jmap, part.blobId)) continue;
try {
const blob = await client.fetchBlob(part.blobId, part.name ?? undefined, part.type, jmap);
await putBlob(account, jmap, part.blobId, blob, Date.parse(String(email.receivedAt)) || Date.now(), emailKey(account, jmap, email.id));
budget -= blob.size;
} catch {
// Fetched again at the next sync.
}
}
}
await enforceCap();
}
async function attachmentsOf(account: string, email: CachedEmail, jmap: string): Promise<AttachmentPart[]> {
const body = await getBody(account, jmap, email.id);
const parts = body?.attachments;
return Array.isArray(parts) ? (parts as AttachmentPart[]) : [];
}
+24
View File
@@ -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.
}
})();
}
+5 -2
View File
@@ -1,6 +1,7 @@
import { fileStorage } from '@/lib/plugin-storage';
import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints';
import { sweepPrivateExports } from '@/lib/mail-export/sinks';
import { purgeStoreFiles } from '@/lib/store/client/purge';
/**
* What a full sign-out removes from this browser beyond the stores that
@@ -106,8 +107,10 @@ export function purgeSignedOutData(): void {
void clearAllCheckpoints().catch(() => {});
// Mail exports written to the browser's private files and not deleted yet.
void sweepPrivateExports({ force: true }).catch(() => {});
// Offline mode (#523): the mail kept on this device, the outbox, the key
// they were encrypted with, and the app shell the service worker kept.
// Offline mode (#523): the local store with the mail and the outbox, the keys
// it was encrypted with, and the app shell the service worker kept. Also the
// database of older versions, in case it wasn't moved yet.
void purgeStoreFiles();
try {
window.indexedDB?.deleteDatabase('bulwark-offline');
} catch {
@@ -0,0 +1,104 @@
/**
* The local store for tests of lib/offline: the real engine on node:sqlite in
* the same process, behind the seam the page uses. Fetches nothing unless a
* test hands it a transport.
*/
import { DEFAULT_SCOPE } from '../../area';
import { STORE_AREAS } from '../../areas';
import type { StoreBackend } from '../../client/api';
import { AccountEngine } from '../../engine/account-engine';
import type { JmapTransport, MethodCall, StoreSession } from '../../engine/transport';
import type { StorageAdapter } from '../../storage';
import { FAKE_SESSION } from './fake-jmap';
import { openMemoryStore } from './node-adapter';
const NO_SERVER: JmapTransport = {
request: async () => { throw new Error('This test has no server for the store'); },
download: async () => new Uint8Array(),
upload: async () => ({ blobId: 'x', type: '', size: 0 }),
};
export class InProcessStore implements StoreBackend {
readonly engines = new Map<string, AccountEngine>();
readonly dbs = new Map<string, StorageAdapter>();
/** What a test wants a failing store to do. */
failArea: ((name: string) => boolean) | null = null;
removed: string[] = [];
stopped = 0;
isSupported = true;
constructor(private readonly transport: JmapTransport = NO_SERVER, private readonly session: StoreSession = FAKE_SESSION) {}
supported(): boolean {
return this.isSupported;
}
engine(account: string, session?: StoreSession | null): AccountEngine {
let engine = this.engines.get(account);
if (!engine) {
const db = openMemoryStore();
this.dbs.set(account, db);
engine = new AccountEngine({
account,
db,
session: session ?? this.session,
transport: this.transport,
areas: STORE_AREAS,
scope: { ...DEFAULT_SCOPE, days: null },
sleep: async () => {},
});
this.engines.set(account, engine);
}
return engine;
}
has(account: string): boolean {
return this.engines.has(account);
}
async ensureOpen(account: string, session?: StoreSession | null): Promise<void> {
this.engine(account, session);
}
refresh(): void {}
async answer(account: string, calls: MethodCall[]) {
const answer = await this.engine(account).answer(calls);
// Across the worker boundary answers are copies.
return answer ? structuredClone(answer) : null;
}
async area<T = unknown>(account: string, name: string, params?: unknown): Promise<T> {
if (this.failArea?.(name)) throw new Error(`${name} failed`);
const result = await this.engine(account).areaCall(name, structuredClone(params));
return (result === undefined ? undefined : structuredClone(result)) as T;
}
async sync(account: string): Promise<void> {
await this.engine(account).requestSync();
}
async noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void> {
this.engine(account).noteStateChange(changed);
}
async setOnline(online: boolean): Promise<void> {
for (const engine of this.engines.values()) engine.setOnline(online);
}
async remove(account: string): Promise<void> {
this.removed.push(account);
await this.engines.get(account)?.close();
this.dbs.get(account)?.close();
this.engines.delete(account);
this.dbs.delete(account);
}
stop(): void {
this.stopped += 1;
for (const engine of this.engines.values()) void engine.close();
this.engines.clear();
this.dbs.clear();
}
}
+6 -3
View File
@@ -1,14 +1,17 @@
/** The storage adapter for tests: real SQLite with FTS5 through node:sqlite. */
import { DatabaseSync, type SQLInputValue } from 'node:sqlite';
import type { DatabaseSync, SQLInputValue } from 'node:sqlite';
import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../../storage';
// Not an import: Vite refuses node:sqlite in the jsdom tests of lib/offline.
const sqlite = process.getBuiltinModule('node:sqlite');
export function openMemoryStore(): StorageAdapter {
return adapterFor(new DatabaseSync(':memory:'));
return adapterFor(new sqlite.DatabaseSync(':memory:'));
}
export function openFileStore(file: string): StorageAdapter {
return adapterFor(new DatabaseSync(file));
return adapterFor(new sqlite.DatabaseSync(file));
}
function adapterFor(db: DatabaseSync): StorageAdapter {
+1 -1
View File
@@ -63,7 +63,7 @@ async function boxes() {
}
const io = (server: FakeJmapMail = t.server, isRefusal: (error: unknown) => boolean = () => false) => ({
request: (calls: MethodCall[], using: string[]) => server.request(calls, using),
request: (calls: MethodCall[]) => server.request(calls),
isRefusal,
});
+218
View File
@@ -0,0 +1,218 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { configureStore, getStore, setStoreBackend } from '../client/api';
import { getAccountRecord } from '../client/keys';
import { purgeStoreFiles } from '../client/purge';
import { StoreClient } from '../client/store-client';
import { FAKE_SESSION } from './helpers/fake-jmap';
/** Everything on one page: a BroadcastChannel that also reaches other objects here, as in a browser. */
class FakeChannel {
static open = new Set<FakeChannel>();
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(readonly name: string) {
FakeChannel.open.add(this);
}
postMessage(data: unknown): void {
for (const channel of FakeChannel.open) {
if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent);
}
}
close(): void {
FakeChannel.open.delete(this);
}
}
class FakeWorker {
static all: FakeWorker[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
posted: Array<Record<string, unknown>> = [];
terminated = false;
constructor(readonly url: string, readonly options: unknown) {
FakeWorker.all.push(this);
}
postMessage(message: Record<string, unknown>): void {
this.posted.push(message);
if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id } } as MessageEvent));
}
terminate(): void {
this.terminated = true;
}
becomeOwner(): void {
this.onmessage?.({ data: { kind: 'owner' } } as MessageEvent);
}
}
beforeEach(() => {
FakeChannel.open.clear();
FakeWorker.all = [];
vi.stubGlobal('BroadcastChannel', FakeChannel);
vi.stubGlobal('Worker', FakeWorker);
});
afterEach(() => {
vi.unstubAllGlobals();
setStoreBackend(null);
configureStore(null);
});
describe('a tab that starts after the owner', () => {
it('asks who owns the store, so its calls don\'t wait for an announcement that never comes', async () => {
const owner = new FakeChannel('bulwark-store-rpc');
const asked: unknown[] = [];
owner.onmessage = (event) => {
asked.push(event.data);
if ((event.data as { kind: string }).kind === 'who-owns') owner.postMessage({ kind: 'owner-changed', owner: 'other-tab' });
if ((event.data as { kind: string }).kind === 'rpc') {
const { id, from } = event.data as { id: string; from: string };
owner.postMessage({ kind: 'reply', id, to: from, result: { answered: true } });
}
};
const client = new StoreClient();
client.start();
expect(asked).toEqual([{ kind: 'who-owns' }]);
await expect(client.area('a', 'x.y')).resolves.toEqual({ answered: true });
client.halt();
});
it('passes the error\'s name over the channel, so a caller can wait for the account to open', async () => {
const owner = new FakeChannel('bulwark-store-rpc');
owner.onmessage = (event) => {
if ((event.data as { kind: string }).kind === 'who-owns') owner.postMessage({ kind: 'owner-changed', owner: 'other-tab' });
if ((event.data as { kind: string }).kind === 'rpc') {
const { id, from } = event.data as { id: string; from: string };
owner.postMessage({ kind: 'reply', id, to: from, error: 'Not open', errorName: 'StoreAccountClosedError' });
}
};
const client = new StoreClient();
client.start();
await expect(client.area('a', 'x.y')).rejects.toMatchObject({ name: 'StoreAccountClosedError' });
client.halt();
});
});
describe('a sign-out in any tab', () => {
it('stops this tab\'s worker and rejects what was waiting', async () => {
const halted = vi.fn();
const client = new StoreClient({ onHalted: halted });
client.start();
const owner = new FakeChannel('bulwark-store-rpc');
owner.postMessage({ kind: 'owner-changed', owner: 'x' });
const pending = client.area('a', 'x.y');
await Promise.resolve();
owner.postMessage({ kind: 'shutdown' });
await expect(pending).rejects.toMatchObject({ name: 'StoreOwnerGoneError' });
expect(FakeWorker.all[0].terminated).toBe(true);
expect(halted).toHaveBeenCalled();
});
});
describe('purgeStoreFiles', () => {
function storage(removeEntry: (name: string, options: unknown) => Promise<void>) {
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: { getDirectory: async () => ({ removeEntry }) } });
}
afterEach(() => {
delete (window.navigator as { storage?: unknown }).storage;
delete (window.navigator as { locks?: unknown }).locks;
});
it('forgets the keys first, tells every tab, and deletes the pool holding the owner lock', async () => {
const seen: unknown[] = [];
const tab = new FakeChannel('bulwark-store-rpc');
tab.onmessage = (event) => seen.push(event.data);
const order: string[] = [];
Object.defineProperty(window.navigator, 'locks', {
configurable: true,
value: { request: async (name: string, run: () => Promise<void>) => { order.push(`lock ${name}`); await run(); order.push('unlock'); } },
});
storage(async (name, options) => {
order.push(`remove ${name} ${JSON.stringify(options)}`);
});
await purgeStoreFiles();
expect(seen).toEqual([{ kind: 'shutdown' }]);
expect(order).toEqual(['lock bulwark-store-owner', 'remove .bulwark-store {"recursive":true}', 'unlock']);
expect((await indexedDB.databases()).map((d) => d.name)).not.toContain('bulwark-store');
});
it('waits out a worker that still holds the files, and shrugs at a pool that isn\'t there', async () => {
const remove = vi.fn()
.mockRejectedValueOnce(Object.assign(new Error('busy'), { name: 'NoModificationAllowedError' }))
.mockResolvedValue(undefined);
storage(remove);
await purgeStoreFiles();
expect(remove).toHaveBeenCalledTimes(2);
storage(async () => { throw Object.assign(new Error('none'), { name: 'NotFoundError' }); });
await expect(purgeStoreFiles()).resolves.toBeUndefined();
});
});
describe('the page\'s store', () => {
function allowEngine() {
configureStore({
inputs: () => ({ session: FAKE_SESSION, auth: 'Basic x', invertedKeywordSort: true }),
refreshAuth: async () => 'Bearer fresh',
onEvent: () => {},
});
}
it('opens an account only as the owner, with the key to its own worker', async () => {
allowEngine();
const store = getStore();
await store.ensureOpen('a@x');
const worker = FakeWorker.all[0];
expect(worker.posted.some((m) => m.kind === 'open')).toBe(false);
worker.becomeOwner();
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
const open = worker.posted.find((m) => m.kind === 'open') as { params: { account: string; keyHex: string; file: string; auth: string; invertedKeywordSort: boolean } };
expect(open.params).toMatchObject({ account: 'a@x', auth: 'Basic x', invertedKeywordSort: true });
expect(open.params.keyHex).toMatch(/^[0-9a-f]{64}$/);
expect(open.params.file).toMatch(/^\/a[0-9a-f]+\.db$/);
store.stop();
expect(worker.terminated).toBe(true);
});
it('runs the client\'s refresh when the worker needs auth, and hands the result over', async () => {
allowEngine();
const store = getStore();
await store.ensureOpen('a@x');
const worker = FakeWorker.all[0];
worker.onmessage?.({ data: { kind: 'need-auth', account: 'a@x' } } as MessageEvent);
await vi.waitFor(() => expect(worker.posted).toContainEqual({ kind: 'set-auth', account: 'a@x', auth: 'Bearer fresh' }));
store.stop();
});
it('forgets the key when an account is removed, also in a tab that isn\'t the owner', async () => {
allowEngine();
const store = getStore();
await store.ensureOpen('a@x');
const worker = FakeWorker.all[0];
worker.becomeOwner();
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
expect(await getAccountRecord('a@x')).not.toBeNull();
const owner = new FakeChannel('bulwark-store-rpc');
const removed: unknown[] = [];
owner.onmessage = (event) => {
const message = event.data as { kind: string; id: string; from: string; method: string; params: unknown };
if (message.kind !== 'rpc') return;
removed.push([message.method, message.params]);
owner.postMessage({ kind: 'reply', id: message.id, to: message.from, result: true });
};
const record = await getAccountRecord('a@x');
await store.remove('a@x');
expect(await getAccountRecord('a@x')).toBeNull();
expect(removed).toEqual([['removeAccount', { account: 'a@x', file: record?.file }]]);
store.stop();
});
it('stops waiting when the engine couldn\'t start in this browser', async () => {
allowEngine();
const store = getStore();
await store.ensureOpen('a@x');
FakeWorker.all[0].onmessage?.({ data: { kind: 'event', event: { type: 'error', account: '', area: 'store', message: 'no wasm' } } } as MessageEvent);
await expect(store.answer('a@x', [])).rejects.toThrow('could not start');
store.stop();
});
});
+1 -1
View File
@@ -12,11 +12,11 @@
*/
import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../storage';
import { POOL_DIRECTORY } from '../worker/protocol';
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;
+203
View File
@@ -0,0 +1,203 @@
/**
* The page's way to the local store: one StoreClient per tab, accounts opened
* while this tab is the owner, sessions and auth handed over. Knows nothing of
* the app's stores: lib/offline/offline-mode.ts wires those in with
* configureStore, so the worker bundle stays free of them.
*/
import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area';
import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport';
import { forgetAccountKey, getAccountRecord, openAccountKey, type WrapLayer } from './keys';
import { StoreClient } from './store-client';
export interface StoreAccountInputs {
session: StoreSession | null;
auth: string | null;
scope?: StoreScope;
invertedKeywordSort: boolean | null;
}
export interface StorePageHooks {
/** What this tab knows about an account right now. */
inputs(account: string): StoreAccountInputs;
/** Runs the client's refresh path; the fresh Authorization header, or null. */
refreshAuth(account: string): Promise<string | null>;
onEvent(event: StoreEvent): void;
/** Wrap layers on top of the WebCrypto one (desktop keychain, app lock). */
keyLayers?(): readonly WrapLayer[];
}
/** What lib/offline uses. A seam, so its tests run the real engine on node:sqlite. */
export interface StoreBackend {
supported(): boolean;
/** Asks for the account to be open. `session` is for a first open with no client yet. */
ensureOpen(account: string, session?: StoreSession | null): Promise<void>;
/** Hands the owner what this tab now knows: session, auth, sort polarity. */
refresh(account: string): void;
answer(account: string, calls: MethodCall[]): Promise<JmapResponse | null>;
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T>;
sync(account: string): Promise<void>;
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void>;
setOnline(online: boolean): Promise<void>;
/** Closes the account, forgets its key and deletes its file. */
remove(account: string): Promise<void>;
/** Stops this tab's worker at once (a sign-out; the files go separately). */
stop(): void;
}
/** How long a call waits for the owner to have opened an account. */
const OPEN_RETRIES = 25;
const OPEN_RETRY_MS = 200;
class PageStore implements StoreBackend {
private client: StoreClient | null = null;
private readonly wanted = new Map<string, StoreSession | null>();
private readonly opening = new Map<string, Promise<void>>();
/** The engine can't start in this browser: don't keep every caller waiting for an owner. */
private broken = false;
constructor(private readonly hooks: () => StorePageHooks | null) {}
supported(): boolean {
return StoreClient.supported();
}
private started(): StoreClient {
if (this.client) return this.client;
this.broken = false;
const client = new StoreClient({
basePath: (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''),
buildId: process.env.NEXT_PUBLIC_BUILD_ID,
onEvent: (event) => {
if (event.type === 'error' && event.area === 'store' && !event.account) this.broken = true;
this.hooks()?.onEvent(event);
},
onOwner: () => {
this.opening.clear();
for (const account of this.wanted.keys()) void this.openHere(account).catch(() => {});
},
onNeedAuth: (account) => {
void (this.hooks()?.refreshAuth(account) ?? Promise.resolve(null))
.catch(() => null)
.then((auth) => this.client?.setAuth(account, auth));
},
onHalted: () => this.reset(),
});
client.start();
this.client = client;
return client;
}
private reset(): void {
this.client = null;
this.wanted.clear();
this.opening.clear();
this.broken = false;
}
/** Only the owner may open: the key goes to this tab's own worker, never over the channel. */
private openHere(account: string): Promise<void> {
const running = this.opening.get(account);
if (running) return running;
const run = (async () => {
const client = this.started();
const { keyHex, record } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []);
const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null };
await client.openAccount({
account,
file: record.file,
keyHex,
session: inputs.session ?? this.wanted.get(account) ?? null,
scope: inputs.scope ?? DEFAULT_SCOPE,
invertedKeywordSort: inputs.invertedKeywordSort,
auth: inputs.auth,
});
})();
this.opening.set(account, run);
run.catch(() => this.opening.delete(account));
return run;
}
async ensureOpen(account: string, session?: StoreSession | null): Promise<void> {
this.wanted.set(account, session ?? this.wanted.get(account) ?? null);
const client = this.started();
if (client.ownsStore) await this.openHere(account);
// Otherwise the owner opens it: calls below wait for that.
}
refresh(account: string): void {
const client = this.client;
if (!client?.ownsStore) return;
const inputs = this.hooks()?.inputs(account);
if (!inputs) return;
if (inputs.session) client.setSession(account, inputs.session);
client.setAuth(account, inputs.auth);
if (inputs.invertedKeywordSort !== null) client.setInvertedKeywordSort(account, inputs.invertedKeywordSort);
}
/** A call for an account: the owner may still be opening it, in this tab or another. */
private async whenOpen<T>(account: string, call: (client: StoreClient) => Promise<T>): Promise<T> {
if (this.broken) throw new Error('The local store could not start');
await this.ensureOpen(account);
for (let attempt = 0; ; attempt += 1) {
try {
return await call(this.started());
} catch (error) {
if ((error as Error)?.name !== 'StoreAccountClosedError' || attempt >= OPEN_RETRIES) throw error;
await new Promise((resolve) => setTimeout(resolve, OPEN_RETRY_MS));
}
}
}
answer(account: string, calls: MethodCall[]): Promise<JmapResponse | null> {
return this.whenOpen(account, (client) => client.answer(account, calls));
}
area<T = unknown>(account: string, name: string, params?: unknown): Promise<T> {
return this.whenOpen(account, (client) => client.area<T>(account, name, params));
}
sync(account: string): Promise<void> {
return this.whenOpen(account, (client) => client.sync(account));
}
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void> {
return this.whenOpen(account, (client) => client.noteStateChange(account, changed));
}
async setOnline(online: boolean): Promise<void> {
if (!this.client || this.broken) return;
await this.client.setOnline(online);
}
async remove(account: string): Promise<void> {
const record = await getAccountRecord(account);
await forgetAccountKey(account);
this.wanted.delete(account);
this.opening.delete(account);
if (record && !this.broken) await this.started().removeAccount(account, record.file);
}
stop(): void {
this.client?.halt();
this.reset();
}
}
let hooks: StorePageHooks | null = null;
let backend: StoreBackend | null = null;
/** Registers what the store needs from the app. */
export function configureStore(next: StorePageHooks | null): void {
hooks = next;
}
export function getStore(): StoreBackend {
backend ??= new PageStore(() => hooks);
return backend;
}
/** Tests run the real engine in-process. */
export function setStoreBackend(next: StoreBackend | null): void {
backend = next;
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Deletes the local store from the page: the keys and the OPFS pool, with no
* worker running and no wasm loaded, also on the sign-in page. Imports nothing
* heavy, because lib/sign-out-cleanup.ts is in every page's bundle.
*/
import { OWNER_LOCK, POOL_DIRECTORY, RPC_CHANNEL, type ChannelMessage } from '../worker/protocol';
import { deleteKeyDatabase } from './keys';
/** A worker just stopped may hold the files for a moment longer. */
const REMOVE_TRIES = 10;
const REMOVE_WAIT_MS = 200;
async function removePool(): Promise<void> {
const root = await navigator.storage.getDirectory();
for (let attempt = 0; ; attempt += 1) {
try {
await root.removeEntry(POOL_DIRECTORY, { recursive: true });
return;
} catch (error) {
if ((error as Error)?.name === 'NotFoundError') return;
if (attempt >= REMOVE_TRIES) throw error;
await new Promise((resolve) => setTimeout(resolve, REMOVE_WAIT_MS));
}
}
}
/** Deletes the pool folder, holding the owner lock so no worker can take the files meanwhile. */
export async function deleteStorePool(): Promise<void> {
if (typeof navigator === 'undefined' || typeof navigator.storage?.getDirectory !== 'function') return;
if (navigator.locks) await navigator.locks.request(OWNER_LOCK, removePool);
else await removePool();
}
/**
* Forgets every key first, which makes the files unreadable even if deleting
* them fails, then has every tab stop its worker and deletes the files.
* Started synchronously: the sign-out may navigate away right after.
*/
export function purgeStoreFiles(): Promise<void> {
deleteKeyDatabase();
try {
const channel = new BroadcastChannel(RPC_CHANNEL);
channel.postMessage({ kind: 'shutdown' } satisfies ChannelMessage);
channel.close();
} catch {
// No BroadcastChannel: no store either.
}
return deleteStorePool().catch(() => {
// The key is gone, so what's left can't be read.
});
}
+47 -11
View File
@@ -21,6 +21,8 @@ import {
const CALL_TIMEOUT_MS = 60_000;
const OWNER_WAIT_MS = 15_000;
/** A first sync of a large account runs a long time, and its promise is what ends "syncing". */
const SYNC_TIMEOUT_MS = 30 * 60_000;
export class StoreOwnerGoneError extends Error {
constructor() {
@@ -44,6 +46,8 @@ export interface StoreClientOptions {
onOwner?(): void;
/** The worker asks for a fresh Authorization header. */
onNeedAuth?(account: string): void;
/** Another tab signed everyone out: this tab's worker is gone. */
onHalted?(): void;
}
export class StoreClient {
@@ -54,6 +58,7 @@ export class StoreClient {
private isOwner = false;
private readonly pending = new Map<string, Pending>();
private readonly ownerWaiters: Array<() => void> = [];
private lifecycleBound = false;
constructor(private readonly options: StoreClientOptions = {}) {}
@@ -80,10 +85,14 @@ export class StoreClient {
clientId: this.clientId,
moduleUrl: new URL(`${base}/${SQLITE3MC_PUBLIC_DIR}/sqlite3.mjs`, location.origin).href,
});
// An owner from before this tab won't announce itself again.
this.channel.postMessage({ kind: 'who-owns' } satisfies ChannelMessage);
this.bindLifecycle();
}
private bindLifecycle(): void {
if (this.lifecycleBound) return;
this.lifecycleBound = true;
// A clean handover when the tab goes. If this doesn't get to run, the lock
// and the handles die with the worker and the next owner retries.
addEventListener('pagehide', (event) => {
@@ -139,6 +148,10 @@ export class StoreClient {
this.owner = message.owner;
if (message.owner) this.flushOwnerWaiters();
break;
case 'shutdown':
this.halt();
this.options.onHalted?.();
break;
default:
break;
}
@@ -149,8 +162,13 @@ export class StoreClient {
if (!pending) return;
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.error) pending.reject(new Error(message.error));
else pending.resolve(message.result);
if (message.error) {
const error = new Error(message.error);
if (message.errorName) error.name = message.errorName;
pending.reject(error);
} else {
pending.resolve(message.result);
}
}
private flushOwnerWaiters(): void {
@@ -178,10 +196,10 @@ export class StoreClient {
});
}
private async rpc<K extends keyof StoreRpc>(method: K, params: Parameters<StoreRpc[K]>[0]): Promise<ReturnType<StoreRpc[K]>> {
private async rpc<K extends keyof StoreRpc>(method: K, params: Parameters<StoreRpc[K]>[0], timeoutMs = CALL_TIMEOUT_MS): Promise<ReturnType<StoreRpc[K]>> {
await this.waitForOwner();
const id = crypto.randomUUID();
const result = this.track<ReturnType<StoreRpc[K]>>(id, CALL_TIMEOUT_MS);
const result = this.track<ReturnType<StoreRpc[K]>>(id, timeoutMs);
this.channel?.postMessage({ kind: 'rpc', id, from: this.clientId, method, params } as ChannelMessage);
return result;
}
@@ -206,10 +224,6 @@ export class StoreClient {
return this.direct({ kind: 'close-account', id: crypto.randomUUID(), account });
}
removeAccount(account: string, file: string): Promise<boolean> {
return this.direct({ kind: 'remove-account', id: crypto.randomUUID(), account, file });
}
setAuth(account: string, auth: string | null): void {
this.post({ kind: 'set-auth', account, auth });
}
@@ -222,19 +236,37 @@ export class StoreClient {
this.post({ kind: 'set-scope', account, scope });
}
setInvertedKeywordSort(account: string, inverted: boolean): void {
this.post({ kind: 'set-inverted', account, inverted });
}
/** Hands the store over: the worker closes everything, then lets go of the lock. */
async release(): Promise<void> {
if (!this.worker) return;
const worker = this.worker;
if (this.isOwner) {
await this.direct({ kind: 'release', id: crypto.randomUUID() });
this.isOwner = false;
this.owner = null;
}
worker.terminate();
this.halt();
}
/**
* Stops at once, with no handover. For a sign-out: the data goes anyway, and
* the worker's end frees the lock and the OPFS handles.
*/
halt(): void {
this.worker?.terminate();
this.worker = null;
this.channel?.close();
this.channel = null;
this.isOwner = false;
this.owner = null;
for (const [id, pending] of this.pending) {
this.pending.delete(id);
clearTimeout(pending.timer);
pending.reject(new StoreOwnerGoneError());
}
}
// ---- Any tab ----
@@ -244,7 +276,11 @@ export class StoreClient {
}
sync(account: string): Promise<void> {
return this.rpc('sync', { account });
return this.rpc('sync', { account }, SYNC_TIMEOUT_MS);
}
removeAccount(account: string, file: string): Promise<boolean> {
return this.rpc('removeAccount', { account, file });
}
noteStateChange(account: string, changed: Record<string, Record<string, string>>): Promise<void> {
+2 -6
View File
@@ -31,7 +31,6 @@ interface OutboxRow {
summary: string;
message_id: string | null;
copy_id: string | null;
skip_lookup: number;
error: string | null;
}
@@ -50,7 +49,6 @@ function toEntry(row: OutboxRow): StoredOutboxEntry {
...(row.error ? { error: JSON.parse(row.error) as OutboxError } : {}),
...(row.message_id ? { messageId: row.message_id } : {}),
...(row.copy_id ? { copyId: row.copy_id } : {}),
...(row.skip_lookup ? { skipLookup: true } : {}),
};
}
@@ -109,18 +107,16 @@ function claim(ctx: StoreContext, params: { id: number }): boolean {
}
/** Deleted meanwhile stays deleted: there's no row to update. */
function update(ctx: StoreContext, params: { id: number; status: OutboxStatus; error?: OutboxError; copyId?: string | null; skipLookup?: boolean }): void {
function update(ctx: StoreContext, params: { id: number; status: OutboxStatus; error?: OutboxError; copyId?: string | null }): void {
ctx.db.run(
`UPDATE outbox SET status = ?, error = ?, sending_since = NULL,
copy_id = CASE WHEN ? THEN ? ELSE copy_id END,
skip_lookup = COALESCE(?, skip_lookup)
copy_id = CASE WHEN ? THEN ? ELSE copy_id END
WHERE id = ?`,
[
params.status,
params.error ? JSON.stringify(params.error) : null,
params.copyId === undefined ? 0 : 1,
params.copyId ?? null,
params.skipLookup === undefined ? null : (params.skipLookup ? 1 : 0),
params.id,
],
);
-1
View File
@@ -68,7 +68,6 @@ export interface StoredOutboxEntry {
fingerprint?: PolicyFingerprint;
messageId?: string;
copyId?: string;
skipLookup?: boolean;
}
/** Stand-in blob ids of files kept on this device start with this. */
-2
View File
@@ -21,8 +21,6 @@ export const OUTBOX_MIGRATIONS: readonly Migration[] = [
message_id TEXT,
-- The copy already on the server that only needs submitting.
copy_id TEXT,
-- The user said send again: no lookup first.
skip_lookup INTEGER NOT NULL DEFAULT 0,
error TEXT
);
-- One row per record, later changes merged in. base is the record's state
+19 -4
View File
@@ -30,6 +30,13 @@ export interface StoreHostOptions {
needAuth(account: string): void;
}
/** The owner hasn't opened this account yet. The caller may try again shortly. */
function accountClosed(): Error {
const error = new Error('The local store is not open for this account');
error.name = 'StoreAccountClosedError';
return error;
}
export class StoreHost {
private readonly accounts = new Map<string, OpenAccount>();
private online = true;
@@ -43,7 +50,7 @@ export class StoreHost {
if (params.session) this.setSession(params.account, params.session);
this.setAuth(params.account, params.auth);
existing.engine.setScope(params.scope);
existing.engine.setInvertedKeywordSort(params.invertedKeywordSort);
if (params.invertedKeywordSort !== null) existing.engine.setInvertedKeywordSort(params.invertedKeywordSort);
void existing.engine.requestSync();
return;
}
@@ -69,7 +76,7 @@ export class StoreHost {
transport,
areas: this.options.areas,
scope: params.scope,
invertedKeywordSort: params.invertedKeywordSort,
invertedKeywordSort: params.invertedKeywordSort ?? undefined,
emit: (event) => {
if (event.type === 'synced') this.synced.set(event.account, event.at);
this.options.emit(event);
@@ -101,6 +108,10 @@ export class StoreHost {
void open.engine.requestSync();
}
setInvertedKeywordSort(account: string, inverted: boolean): void {
this.accounts.get(account)?.engine.setInvertedKeywordSort(inverted);
}
async closeAccount(account: string): Promise<void> {
const open = this.accounts.get(account);
if (!open) return;
@@ -126,7 +137,7 @@ export class StoreHost {
case 'answer': {
const { account, calls } = params as Parameters<StoreRpc['answer']>[0];
const open = this.accounts.get(account);
if (!open) throw new Error('The local store is not open for this account');
if (!open) throw accountClosed();
return open.engine.answer(calls);
}
case 'sync': {
@@ -152,9 +163,13 @@ export class StoreHost {
case 'area': {
const { account, name, params: callParams } = params as Parameters<StoreRpc['area']>[0];
const open = this.accounts.get(account);
if (!open) throw new Error('The local store is not open for this account');
if (!open) throw accountClosed();
return open.engine.areaCall(name, callParams);
}
case 'removeAccount': {
const { account, file } = params as Parameters<StoreRpc['removeAccount']>[0];
return this.removeAccount(account, file);
}
default:
throw new Error(`Unknown store call ${String(method)}`);
}
+13 -3
View File
@@ -5,6 +5,8 @@ import type { JmapResponse, MethodCall, StoreSession } from '../engine/transport
export const RPC_CHANNEL = 'bulwark-store-rpc';
export const OWNER_LOCK = 'bulwark-store-owner';
/** The OPFS folder of the database pool. The page can delete it without loading wasm. */
export const POOL_DIRECTORY = '.bulwark-store';
export interface OpenAccountParams {
account: string;
@@ -14,7 +16,8 @@ export interface OpenAccountParams {
keyHex: string;
session: StoreSession | null;
scope: StoreScope;
invertedKeywordSort: boolean;
/** Null when this tab's client hasn't probed it: the stored answer stays. */
invertedKeywordSort: boolean | null;
/** The page's current Authorization header. */
auth: string | null;
}
@@ -28,6 +31,8 @@ export interface StoreRpc {
status(params: { account: string }): StoreStatus | null;
/** A non-JMAP call into an area of the store. */
area(params: { account: string; name: string; params: unknown }): unknown;
/** Closes an account and deletes its file. The name holds no account data. */
removeAccount(params: { account: string; file: string }): boolean;
}
export interface StoreStatus {
@@ -52,21 +57,26 @@ export type RpcReply = {
to: string;
result?: unknown;
error?: string;
errorName?: string;
};
export type ChannelMessage =
| RpcRequest
| RpcReply
| { kind: 'event'; event: StoreEvent }
| { kind: 'owner-changed'; owner: string | null };
| { kind: 'owner-changed'; owner: string | null }
// A tab started after the owner announced itself asks.
| { kind: 'who-owns' }
// Everyone signed out: every tab stops its worker, so the pool can go.
| { kind: 'shutdown' };
/** From this tab's page to its own worker, never on the channel. */
export type PageToWorker =
| { kind: 'init'; clientId: string; moduleUrl: string }
| { kind: 'open'; id: string; params: OpenAccountParams }
| { kind: 'close-account'; id: string; account: string }
| { kind: 'remove-account'; id: string; account: string; file: string }
| { kind: 'set-auth'; account: string; auth: string | null }
| { kind: 'set-inverted'; account: string; inverted: boolean }
| { kind: 'set-session'; account: string; session: StoreSession }
| { kind: 'set-scope'; account: string; scope: StoreScope }
| { kind: 'release'; id: string };
+8 -7
View File
@@ -56,6 +56,10 @@ async function becomeOwner(): Promise<void> {
channel = new BroadcastChannel(RPC_CHANNEL);
channel.onmessage = async (event: MessageEvent<ChannelMessage>) => {
const message = event.data;
if (message.kind === 'who-owns') {
channel?.postMessage({ kind: 'owner-changed', owner: clientId } satisfies ChannelMessage);
return;
}
if (message.kind !== 'rpc') return;
try {
const result = await requireHost().call(message.method, message.params);
@@ -66,6 +70,7 @@ async function becomeOwner(): Promise<void> {
id: message.id,
to: message.from,
error: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : undefined,
} satisfies ChannelMessage);
}
};
@@ -119,13 +124,6 @@ scope.addEventListener('message', async (event) => {
replyError(message.id, error);
}
break;
case 'remove-account':
try {
reply(message.id, await requireHost().removeAccount(message.account, message.file));
} catch (error) {
replyError(message.id, error);
}
break;
case 'set-auth':
host?.setAuth(message.account, message.auth);
break;
@@ -135,6 +133,9 @@ scope.addEventListener('message', async (event) => {
case 'set-scope':
host?.setScope(message.account, message.scope);
break;
case 'set-inverted':
host?.setInvertedKeywordSort(message.account, message.inverted);
break;
case 'release':
try {
await release();
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "تعذّر العثور على مجلد المسودات."
}
},
"offlineConflicts": {
"title": "تغييرات لم تُطبَّق",
"description": "أجريت هذه التغييرات دون اتصال. لم تُطبَّق وتبقى الرسالة كما هي على الخادم.",
"changed": "تم التغيير على جهاز آخر، لذا لم يُطبَّق تغييرك",
"changed_delete": "تم التغيير على جهاز آخر، لذا لم تُحذف",
"gone": "لم تعد على الخادم، لذا لم يُطبَّق تغييرك",
"refused": "رفض الخادم هذا التغيير: {reason}",
"dismiss": "إغلاق",
"dismiss_label": "إغلاق الملاحظة حول {subject}",
"banner": "تغييرات لم تُطبَّق: {count}",
"toast": "تغييرات أُجريت دون اتصال ولم تُطبَّق: {count}. يوضح صندوق الصادر أيها."
},
"deep_link": {
"message_not_found": "لم تعد هذه الرسالة متاحة.",
"thread_not_found": "لم تعد هذه المحادثة متاحة.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "No s'ha trobat la carpeta Esborranys."
}
},
"offlineConflicts": {
"title": "Canvis no aplicats",
"description": "Heu fet aquests canvis sense connexió. No s'han aplicat i el missatge queda tal com és al servidor.",
"changed": "Canviat en un altre dispositiu, el vostre canvi no s'ha aplicat",
"changed_delete": "Canviat en un altre dispositiu, no s'ha suprimit",
"gone": "Ja no és al servidor, el vostre canvi no s'ha aplicat",
"refused": "El servidor ha rebutjat aquest canvi: {reason}",
"dismiss": "Descarta",
"dismiss_label": "Descarta la nota sobre {subject}",
"banner": "Canvis no aplicats: {count}",
"toast": "Canvis fets sense connexió que no s'han aplicat: {count}. La safata de sortida indica quins."
},
"deep_link": {
"message_not_found": "Aquest missatge ja no està disponible.",
"thread_not_found": "Aquesta conversa ja no està disponible.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Složka Koncepty nebyla nalezena."
}
},
"offlineConflicts": {
"title": "Nepoužité změny",
"description": "Tyto změny jste provedli offline. Nebyly použity a zpráva zůstává tak, jak je na serveru.",
"changed": "Změněno na jiném zařízení, vaše změna nebyla použita",
"changed_delete": "Změněno na jiném zařízení, zpráva nebyla smazána",
"gone": "Už není na serveru, vaše změna nebyla použita",
"refused": "Server tuto změnu odmítl: {reason}",
"dismiss": "Zavřít",
"dismiss_label": "Zavřít poznámku o {subject}",
"banner": "Nepoužité změny: {count}",
"toast": "Nepoužité změny provedené offline: {count}. Které to jsou, ukazuje pošta k odeslání."
},
"deep_link": {
"message_not_found": "Tato zpráva už není dostupná.",
"thread_not_found": "Tato konverzace už není dostupná.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Mappen Kladder blev ikke fundet."
}
},
"offlineConflicts": {
"title": "Ændringer, der ikke blev gennemført",
"description": "Du lavede disse ændringer offline. De blev ikke gennemført, og beskeden forbliver, som den er på serveren.",
"changed": "Ændret på en anden enhed, din ændring blev ikke gennemført",
"changed_delete": "Ændret på en anden enhed, den blev ikke slettet",
"gone": "Findes ikke længere på serveren, din ændring blev ikke gennemført",
"refused": "Serveren afviste denne ændring: {reason}",
"dismiss": "Afvis",
"dismiss_label": "Afvis noten om {subject}",
"banner": "Ændringer, der ikke blev gennemført: {count}",
"toast": "Ændringer lavet offline, der ikke blev gennemført: {count}. Udbakken viser hvilke."
},
"deep_link": {
"message_not_found": "Denne besked er ikke længere tilgængelig.",
"thread_not_found": "Denne samtale er ikke længere tilgængelig.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Ihr Ordner „Entwürfe“ wurde nicht gefunden."
}
},
"offlineConflicts": {
"title": "Nicht übernommene Änderungen",
"description": "Sie haben diese Änderungen offline gemacht. Sie wurden nicht übernommen, die Nachricht bleibt wie auf dem Server.",
"changed": "Auf einem anderen Gerät geändert, Ihre Änderung wurde nicht übernommen",
"changed_delete": "Auf einem anderen Gerät geändert, die Nachricht wurde nicht gelöscht",
"gone": "Nicht mehr auf dem Server, Ihre Änderung wurde nicht übernommen",
"refused": "Der Server hat diese Änderung abgelehnt: {reason}",
"dismiss": "Ausblenden",
"dismiss_label": "Hinweis zu {subject} ausblenden",
"banner": "Nicht übernommene Änderungen: {count}",
"toast": "Offline gemachte Änderungen wurden nicht übernommen: {count}. Welche, steht im Postausgang."
},
"deep_link": {
"message_not_found": "Diese Nachricht ist nicht mehr verfügbar.",
"thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Your Drafts folder couldn't be found."
}
},
"offlineConflicts": {
"title": "Changes that didn't go through",
"description": "You made these changes offline. They weren't applied, and the message stays as it is on the server.",
"changed": "Changed on another device, so your change wasn't applied",
"changed_delete": "Changed on another device, so it wasn't deleted",
"gone": "No longer on the server, so your change wasn't applied",
"refused": "The server refused this change: {reason}",
"dismiss": "Dismiss",
"dismiss_label": "Dismiss the note about {subject}",
"banner": "Changes that didn't go through: {count}",
"toast": "Changes you made offline weren't applied: {count}. The outbox shows which."
},
"deep_link": {
"message_not_found": "This message is no longer available.",
"thread_not_found": "This conversation is no longer available.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "No se encontró tu carpeta Borradores."
}
},
"offlineConflicts": {
"title": "Cambios no aplicados",
"description": "Hiciste estos cambios sin conexión. No se aplicaron y el mensaje queda como está en el servidor.",
"changed": "Cambiado en otro dispositivo, tu cambio no se aplicó",
"changed_delete": "Cambiado en otro dispositivo, no se eliminó",
"gone": "Ya no está en el servidor, tu cambio no se aplicó",
"refused": "El servidor rechazó este cambio: {reason}",
"dismiss": "Descartar",
"dismiss_label": "Descartar la nota sobre {subject}",
"banner": "Cambios no aplicados: {count}",
"toast": "Cambios hechos sin conexión que no se aplicaron: {count}. La bandeja de salida indica cuáles."
},
"deep_link": {
"message_not_found": "Este mensaje ya no está disponible.",
"thread_not_found": "Esta conversación ya no está disponible.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Kausta Mustandid ei leitud."
}
},
"offlineConflicts": {
"title": "Rakendamata muudatused",
"description": "Tegite need muudatused võrguühenduseta. Neid ei rakendatud ja kiri jääb selliseks, nagu see on serveris.",
"changed": "Muudetud teises seadmes, teie muudatust ei rakendatud",
"changed_delete": "Muudetud teises seadmes, seda ei kustutatud",
"gone": "Pole enam serveris, teie muudatust ei rakendatud",
"refused": "Server keeldus sellest muudatusest: {reason}",
"dismiss": "Sulge",
"dismiss_label": "Sulge märkus teemal {subject}",
"banner": "Rakendamata muudatused: {count}",
"toast": "Võrguühenduseta tehtud rakendamata muudatused: {count}. Väljundkaust näitab, millised."
},
"deep_link": {
"message_not_found": "See sõnum ei ole enam saadaval.",
"thread_not_found": "See vestlus ei ole enam saadaval.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "پوشهٔ پیش‌نویس‌ها پیدا نشد."
}
},
"offlineConflicts": {
"title": "تغییراتی که اعمال نشدند",
"description": "این تغییرات را در حالت آفلاین انجام دادید. اعمال نشدند و پیام همان‌طور که روی سرور است می‌ماند.",
"changed": "در دستگاه دیگری تغییر کرده، پس تغییر شما اعمال نشد",
"changed_delete": "در دستگاه دیگری تغییر کرده، پس حذف نشد",
"gone": "دیگر روی سرور نیست، پس تغییر شما اعمال نشد",
"refused": "سرور این تغییر را رد کرد: {reason}",
"dismiss": "بستن",
"dismiss_label": "بستن یادداشت درباره {subject}",
"banner": "تغییراتی که اعمال نشدند: {count}",
"toast": "تغییرات آفلاین که اعمال نشدند: {count}. صندوق خروجی نشان می‌دهد کدام‌ها."
},
"deep_link": {
"message_not_found": "این پیام دیگر در دسترس نیست.",
"thread_not_found": "این گفتگو دیگر در دسترس نیست.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Votre dossier Brouillons est introuvable."
}
},
"offlineConflicts": {
"title": "Modifications non appliquées",
"description": "Vous avez fait ces modifications hors ligne. Elles n'ont pas été appliquées et le message reste tel qu'il est sur le serveur.",
"changed": "Modifié sur un autre appareil, votre modification n'a pas été appliquée",
"changed_delete": "Modifié sur un autre appareil, le message n'a pas été supprimé",
"gone": "N'est plus sur le serveur, votre modification n'a pas été appliquée",
"refused": "Le serveur a refusé cette modification : {reason}",
"dismiss": "Ignorer",
"dismiss_label": "Ignorer la note sur {subject}",
"banner": "Modifications non appliquées : {count}",
"toast": "Des modifications faites hors ligne n'ont pas été appliquées : {count}. La boîte d'envoi indique lesquelles."
},
"deep_link": {
"message_not_found": "Ce message n'est plus disponible.",
"thread_not_found": "Cette conversation n'est plus disponible.",
+12
View File
@@ -5719,6 +5719,18 @@
"no_drafts_mailbox": "התיקייה 'טיוטות' לא נמצאה."
}
},
"offlineConflicts": {
"title": "שינויים שלא הוחלו",
"description": "ביצעת את השינויים האלה במצב לא מקוון. הם לא הוחלו וההודעה נשארת כפי שהיא בשרת.",
"changed": "שונה במכשיר אחר, ולכן השינוי שלך לא הוחל",
"changed_delete": "שונה במכשיר אחר, ולכן לא נמחק",
"gone": "כבר לא בשרת, ולכן השינוי שלך לא הוחל",
"refused": "השרת דחה את השינוי הזה: {reason}",
"dismiss": "סגירה",
"dismiss_label": "סגירת ההערה על {subject}",
"banner": "שינויים שלא הוחלו: {count}",
"toast": "שינויים במצב לא מקוון שלא הוחלו: {count}. דואר יוצא מראה אילו."
},
"deep_link": {
"message_not_found": "הודעה זו אינה זמינה עוד.",
"thread_not_found": "שיחה זו אינה זמינה עוד.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "A Piszkozatok mappa nem található."
}
},
"offlineConflicts": {
"title": "Nem alkalmazott módosítások",
"description": "Ezeket a módosításokat offline végezte. Nem lettek alkalmazva, és az üzenet úgy marad, ahogy a szerveren van.",
"changed": "Másik eszközön módosult, az Ön módosítása nem lett alkalmazva",
"changed_delete": "Másik eszközön módosult, nem lett törölve",
"gone": "Már nincs a szerveren, az Ön módosítása nem lett alkalmazva",
"refused": "A szerver elutasította ezt a módosítást: {reason}",
"dismiss": "Bezárás",
"dismiss_label": "A(z) {subject} megjegyzésének bezárása",
"banner": "Nem alkalmazott módosítások: {count}",
"toast": "Offline végzett, nem alkalmazott módosítások: {count}. A postázandók mappa mutatja, melyek."
},
"deep_link": {
"message_not_found": "Ez az üzenet már nem érhető el.",
"thread_not_found": "Ez a beszélgetés már nem érhető el.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Impossibile trovare la cartella Bozze."
}
},
"offlineConflicts": {
"title": "Modifiche non applicate",
"description": "Hai fatto queste modifiche offline. Non sono state applicate e il messaggio resta com'è sul server.",
"changed": "Modificato su un altro dispositivo, la tua modifica non è stata applicata",
"changed_delete": "Modificato su un altro dispositivo, non è stato eliminato",
"gone": "Non è più sul server, la tua modifica non è stata applicata",
"refused": "Il server ha rifiutato questa modifica: {reason}",
"dismiss": "Ignora",
"dismiss_label": "Ignora la nota su {subject}",
"banner": "Modifiche non applicate: {count}",
"toast": "Modifiche fatte offline non applicate: {count}. La posta in uscita indica quali."
},
"deep_link": {
"message_not_found": "Questo messaggio non è più disponibile.",
"thread_not_found": "Questa conversazione non è più disponibile.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "下書きフォルダーが見つかりません。"
}
},
"offlineConflicts": {
"title": "適用されなかった変更",
"description": "これらの変更はオフライン中に行われました。適用されず、メッセージはサーバー上の状態のままです。",
"changed": "別のデバイスで変更されたため、変更は適用されませんでした",
"changed_delete": "別のデバイスで変更されたため、削除されませんでした",
"gone": "サーバーにもうないため、変更は適用されませんでした",
"refused": "サーバーがこの変更を拒否しました: {reason}",
"dismiss": "閉じる",
"dismiss_label": "{subject} のメモを閉じる",
"banner": "適用されなかった変更: {count}",
"toast": "オフライン中の変更で適用されなかったもの: {count} 件。送信トレイで確認できます。"
},
"deep_link": {
"message_not_found": "このメッセージは利用できなくなりました。",
"thread_not_found": "この会話は利用できなくなりました。",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "임시보관함 폴더를 찾을 수 없습니다."
}
},
"offlineConflicts": {
"title": "적용되지 않은 변경 사항",
"description": "오프라인 상태에서 변경한 내용입니다. 적용되지 않았으며 메시지는 서버에 있는 그대로 유지됩니다.",
"changed": "다른 기기에서 변경되어 변경 사항이 적용되지 않았습니다",
"changed_delete": "다른 기기에서 변경되어 삭제되지 않았습니다",
"gone": "서버에 더 이상 없어 변경 사항이 적용되지 않았습니다",
"refused": "서버가 이 변경을 거부했습니다: {reason}",
"dismiss": "닫기",
"dismiss_label": "{subject} 관련 메모 닫기",
"banner": "적용되지 않은 변경 사항: {count}",
"toast": "오프라인에서 변경했지만 적용되지 않은 항목: {count}개. 보낼 편지함에서 확인할 수 있습니다."
},
"deep_link": {
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
"thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Mape Melnraksti netika atrasta."
}
},
"offlineConflicts": {
"title": "Nepiemērotās izmaiņas",
"description": "Šīs izmaiņas veicāt bezsaistē. Tās netika piemērotas, un ziņojums paliek tāds, kāds tas ir serverī.",
"changed": "Mainīts citā ierīcē, jūsu izmaiņas netika piemērotas",
"changed_delete": "Mainīts citā ierīcē, tas netika izdzēsts",
"gone": "Vairs nav serverī, jūsu izmaiņas netika piemērotas",
"refused": "Serveris noraidīja šīs izmaiņas: {reason}",
"dismiss": "Aizvērt",
"dismiss_label": "Aizvērt piezīmi par {subject}",
"banner": "Nepiemērotās izmaiņas: {count}",
"toast": "Bezsaistē veiktās nepiemērotās izmaiņas: {count}. Izsūtne parāda, kuras."
},
"deep_link": {
"message_not_found": "Šī ziņa vairs nav pieejama.",
"thread_not_found": "Šī sarakste vairs nav pieejama.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Ноорог хавтас олдсонгүй."
}
},
"offlineConflicts": {
"title": "Хэрэгжээгүй өөрчлөлтүүд",
"description": "Та эдгээр өөрчлөлтийг офлайн хийсэн. Тэдгээр нь хэрэгжээгүй бөгөөд захидал серверт байгаа хэвээрээ үлдэнэ.",
"changed": "Өөр төхөөрөмж дээр өөрчлөгдсөн тул таны өөрчлөлт хэрэгжээгүй",
"changed_delete": "Өөр төхөөрөмж дээр өөрчлөгдсөн тул устгагдаагүй",
"gone": "Серверт байхгүй болсон тул таны өөрчлөлт хэрэгжээгүй",
"refused": "Сервер энэ өөрчлөлтөөс татгалзлаа: {reason}",
"dismiss": "Хаах",
"dismiss_label": "{subject}-ийн тэмдэглэлийг хаах",
"banner": "Хэрэгжээгүй өөрчлөлтүүд: {count}",
"toast": "Офлайн хийсэн, хэрэгжээгүй өөрчлөлтүүд: {count}. Аль нь болохыг Илгээх хайрцгаас харна уу."
},
"deep_link": {
"message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.",
"thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Fant ikke mappen Utkast."
}
},
"offlineConflicts": {
"title": "Endringer som ikke ble utført",
"description": "Du gjorde disse endringene uten nett. De ble ikke utført, og meldingen forblir som den er på serveren.",
"changed": "Endret på en annen enhet, endringen din ble ikke utført",
"changed_delete": "Endret på en annen enhet, den ble ikke slettet",
"gone": "Finnes ikke lenger på serveren, endringen din ble ikke utført",
"refused": "Serveren avviste denne endringen: {reason}",
"dismiss": "Lukk",
"dismiss_label": "Lukk merknaden om {subject}",
"banner": "Endringer som ikke ble utført: {count}",
"toast": "Endringer gjort uten nett som ikke ble utført: {count}. Utboksen viser hvilke."
},
"deep_link": {
"message_not_found": "Denne meldingen er ikke lenger tilgjengelig.",
"thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Je map Concepten is niet gevonden."
}
},
"offlineConflicts": {
"title": "Niet doorgevoerde wijzigingen",
"description": "Je hebt deze wijzigingen offline gemaakt. Ze zijn niet doorgevoerd en het bericht blijft zoals het op de server staat.",
"changed": "Gewijzigd op een ander apparaat, je wijziging is niet doorgevoerd",
"changed_delete": "Gewijzigd op een ander apparaat, niet verwijderd",
"gone": "Staat niet meer op de server, je wijziging is niet doorgevoerd",
"refused": "De server heeft deze wijziging geweigerd: {reason}",
"dismiss": "Sluiten",
"dismiss_label": "Melding over {subject} sluiten",
"banner": "Niet doorgevoerde wijzigingen: {count}",
"toast": "Offline gemaakte wijzigingen niet doorgevoerd: {count}. Het Postvak UIT laat zien welke."
},
"deep_link": {
"message_not_found": "Dit bericht is niet meer beschikbaar.",
"thread_not_found": "Dit gesprek is niet meer beschikbaar.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Nie znaleziono folderu Wersje robocze."
}
},
"offlineConflicts": {
"title": "Niezastosowane zmiany",
"description": "Te zmiany wprowadzono w trybie offline. Nie zostały zastosowane, a wiadomość pozostaje taka jak na serwerze.",
"changed": "Zmieniono na innym urządzeniu, Twoja zmiana nie została zastosowana",
"changed_delete": "Zmieniono na innym urządzeniu, wiadomość nie została usunięta",
"gone": "Nie ma już na serwerze, Twoja zmiana nie została zastosowana",
"refused": "Serwer odrzucił tę zmianę: {reason}",
"dismiss": "Odrzuć",
"dismiss_label": "Odrzuć uwagę o {subject}",
"banner": "Niezastosowane zmiany: {count}",
"toast": "Niezastosowane zmiany wprowadzone offline: {count}. Skrzynka nadawcza pokazuje które."
},
"deep_link": {
"message_not_found": "Ta wiadomość nie jest już dostępna.",
"thread_not_found": "Ta rozmowa nie jest już dostępna.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Não foi possível encontrar a pasta Rascunhos."
}
},
"offlineConflicts": {
"title": "Alterações não aplicadas",
"description": "Fez estas alterações offline. Não foram aplicadas e a mensagem fica como está no servidor.",
"changed": "Alterada noutro dispositivo, a sua alteração não foi aplicada",
"changed_delete": "Alterada noutro dispositivo, não foi eliminada",
"gone": "Já não está no servidor, a sua alteração não foi aplicada",
"refused": "O servidor recusou esta alteração: {reason}",
"dismiss": "Dispensar",
"dismiss_label": "Dispensar a nota sobre {subject}",
"banner": "Alterações não aplicadas: {count}",
"toast": "Alterações feitas offline não aplicadas: {count}. A caixa de saída mostra quais."
},
"deep_link": {
"message_not_found": "Esta mensagem não está mais disponível.",
"thread_not_found": "Esta conversa não está mais disponível.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Dosarul Ciorne nu a fost găsit."
}
},
"offlineConflicts": {
"title": "Modificări neaplicate",
"description": "Ați făcut aceste modificări offline. Nu au fost aplicate, iar mesajul rămâne așa cum este pe server.",
"changed": "Modificat pe alt dispozitiv, modificarea dvs. nu a fost aplicată",
"changed_delete": "Modificat pe alt dispozitiv, nu a fost șters",
"gone": "Nu mai este pe server, modificarea dvs. nu a fost aplicată",
"refused": "Serverul a refuzat această modificare: {reason}",
"dismiss": "Închide",
"dismiss_label": "Închide nota despre {subject}",
"banner": "Modificări neaplicate: {count}",
"toast": "Modificări făcute offline care nu au fost aplicate: {count}. Căsuța de trimitere arată care."
},
"deep_link": {
"message_not_found": "Acest mesaj nu mai este disponibil.",
"thread_not_found": "Această conversație nu mai este disponibilă.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Папка «Черновики» не найдена."
}
},
"offlineConflicts": {
"title": "Неприменённые изменения",
"description": "Вы внесли эти изменения офлайн. Они не применены, письмо остаётся таким, как на сервере.",
"changed": "Изменено на другом устройстве, ваше изменение не применено",
"changed_delete": "Изменено на другом устройстве, письмо не удалено",
"gone": "Больше нет на сервере, ваше изменение не применено",
"refused": "Сервер отклонил это изменение: {reason}",
"dismiss": "Закрыть",
"dismiss_label": "Закрыть заметку о {subject}",
"banner": "Неприменённые изменения: {count}",
"toast": "Изменения, внесённые офлайн и не применённые: {count}. Какие именно, видно в исходящих."
},
"deep_link": {
"message_not_found": "Это письмо больше недоступно.",
"thread_not_found": "Эта переписка больше недоступна.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Priečinok Koncepty sa nenašiel."
}
},
"offlineConflicts": {
"title": "Nepoužité zmeny",
"description": "Tieto zmeny ste urobili offline. Neboli použité a správa zostáva tak, ako je na serveri.",
"changed": "Zmenené na inom zariadení, vaša zmena nebola použitá",
"changed_delete": "Zmenené na inom zariadení, správa nebola odstránená",
"gone": "Už nie je na serveri, vaša zmena nebola použitá",
"refused": "Server túto zmenu odmietol: {reason}",
"dismiss": "Zavrieť",
"dismiss_label": "Zavrieť poznámku o {subject}",
"banner": "Nepoužité zmeny: {count}",
"toast": "Nepoužité zmeny urobené offline: {count}. Ktoré to sú, ukazuje pošta na odoslanie."
},
"deep_link": {
"message_not_found": "Táto správa už nie je dostupná.",
"thread_not_found": "Táto konverzácia už nie je dostupná.",
+12
View File
@@ -2088,6 +2088,18 @@
"no_drafts_mailbox": "ไม่พบโฟลเดอร์แบบร่าง"
}
},
"offlineConflicts": {
"title": "การเปลี่ยนแปลงที่ไม่ได้นำไปใช้",
"description": "คุณทำการเปลี่ยนแปลงเหล่านี้ขณะออฟไลน์ การเปลี่ยนแปลงไม่ได้ถูกนำไปใช้ และข้อความยังคงเหมือนบนเซิร์ฟเวอร์",
"changed": "มีการเปลี่ยนแปลงบนอุปกรณ์อื่น การเปลี่ยนแปลงของคุณจึงไม่ถูกนำไปใช้",
"changed_delete": "มีการเปลี่ยนแปลงบนอุปกรณ์อื่น จึงไม่ถูกลบ",
"gone": "ไม่มีบนเซิร์ฟเวอร์แล้ว การเปลี่ยนแปลงของคุณจึงไม่ถูกนำไปใช้",
"refused": "เซิร์ฟเวอร์ปฏิเสธการเปลี่ยนแปลงนี้: {reason}",
"dismiss": "ปิด",
"dismiss_label": "ปิดหมายเหตุเกี่ยวกับ {subject}",
"banner": "การเปลี่ยนแปลงที่ไม่ได้นำไปใช้: {count}",
"toast": "การเปลี่ยนแปลงขณะออฟไลน์ที่ไม่ได้นำไปใช้: {count} ดูรายการได้ในกล่องขาออก"
},
"plugin_pages": {
"loading": "กำลังโหลดหน้าเว็บ...",
"not_available_title": "หน้านี้ไม่พร้อมใช้งาน",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Taslaklar klasörü bulunamadı."
}
},
"offlineConflicts": {
"title": "Uygulanmayan değişiklikler",
"description": "Bu değişiklikleri çevrimdışıyken yaptınız. Uygulanmadı ve ileti sunucudaki haliyle kalıyor.",
"changed": "Başka bir cihazda değiştirildi, değişikliğiniz uygulanmadı",
"changed_delete": "Başka bir cihazda değiştirildi, silinmedi",
"gone": "Artık sunucuda yok, değişikliğiniz uygulanmadı",
"refused": "Sunucu bu değişikliği reddetti: {reason}",
"dismiss": "Kapat",
"dismiss_label": "{subject} ile ilgili notu kapat",
"banner": "Uygulanmayan değişiklikler: {count}",
"toast": "Çevrimdışı yapılan ve uygulanmayan değişiklikler: {count}. Giden kutusu hangileri olduğunu gösterir."
},
"deep_link": {
"message_not_found": "Bu ileti artık kullanılamıyor.",
"thread_not_found": "Bu sohbet artık kullanılamıyor.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "Теку «Чернетки» не знайдено."
}
},
"offlineConflicts": {
"title": "Незастосовані зміни",
"description": "Ви внесли ці зміни офлайн. Їх не застосовано, і лист залишається таким, як на сервері.",
"changed": "Змінено на іншому пристрої, вашу зміну не застосовано",
"changed_delete": "Змінено на іншому пристрої, лист не видалено",
"gone": "Більше немає на сервері, вашу зміну не застосовано",
"refused": "Сервер відхилив цю зміну: {reason}",
"dismiss": "Закрити",
"dismiss_label": "Закрити примітку про {subject}",
"banner": "Незастосовані зміни: {count}",
"toast": "Зміни, внесені офлайн і не застосовані: {count}. Які саме, видно у вихідних."
},
"deep_link": {
"message_not_found": "Цей лист більше недоступний.",
"thread_not_found": "Ця розмова більше недоступна.",
+12
View File
@@ -5730,6 +5730,18 @@
"no_drafts_mailbox": "Không tìm thấy thư mục Thư nháp."
}
},
"offlineConflicts": {
"title": "Các thay đổi chưa được áp dụng",
"description": "Bạn đã thực hiện các thay đổi này khi ngoại tuyến. Chúng chưa được áp dụng và thư vẫn giữ nguyên như trên máy chủ.",
"changed": "Đã thay đổi trên thiết bị khác nên thay đổi của bạn chưa được áp dụng",
"changed_delete": "Đã thay đổi trên thiết bị khác nên chưa bị xóa",
"gone": "Không còn trên máy chủ nên thay đổi của bạn chưa được áp dụng",
"refused": "Máy chủ đã từ chối thay đổi này: {reason}",
"dismiss": "Đóng",
"dismiss_label": "Đóng ghi chú về {subject}",
"banner": "Các thay đổi chưa được áp dụng: {count}",
"toast": "Thay đổi khi ngoại tuyến chưa được áp dụng: {count}. Hộp thư đi cho biết là những thay đổi nào."
},
"deep_link": {
"message_not_found": "Thư này không còn nữa.",
"thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "找不到「草稿」資料夾。"
}
},
"offlineConflicts": {
"title": "未套用的變更",
"description": "這些變更是您離線時做的。它們沒有被套用,郵件維持伺服器上的狀態。",
"changed": "已在其他裝置上變更,您的變更未被套用",
"changed_delete": "已在其他裝置上變更,未被刪除",
"gone": "伺服器上已沒有此郵件,您的變更未被套用",
"refused": "伺服器拒絕了此變更:{reason}",
"dismiss": "關閉",
"dismiss_label": "關閉關於 {subject} 的提示",
"banner": "未套用的變更:{count}",
"toast": "離線時的變更未被套用:{count} 項。可在寄件匣中查看。"
},
"deep_link": {
"message_not_found": "已無法使用此郵件。",
"thread_not_found": "已無法使用此對話串。",
+12
View File
@@ -5718,6 +5718,18 @@
"no_drafts_mailbox": "找不到“草稿”文件夹。"
}
},
"offlineConflicts": {
"title": "未应用的更改",
"description": "这些更改是您离线时做的。它们没有被应用,邮件保持服务器上的状态。",
"changed": "已在其他设备上更改,您的更改未被应用",
"changed_delete": "已在其他设备上更改,未被删除",
"gone": "服务器上已没有此邮件,您的更改未被应用",
"refused": "服务器拒绝了此更改:{reason}",
"dismiss": "关闭",
"dismiss_label": "关闭关于 {subject} 的提示",
"banner": "未应用的更改:{count}",
"toast": "离线时的更改未被应用:{count} 项。可在发件箱中查看。"
},
"deep_link": {
"message_not_found": "此邮件已不可用。",
"thread_not_found": "此会话已不可用。",
@@ -0,0 +1,151 @@
import { beforeAll, beforeEach, expect, it, vi } from 'vitest';
import { createConfig } from '@/lib/__tests__/fixtures/config';
// With the local store on, a start paints from the local copy at once, then
// connects and swaps. The hooks are there before the first request.
const offline = vi.hoisted(() => ({
order: [] as string[],
session: { apiUrl: 'x' } as unknown,
gone: false,
}));
vi.mock('@/lib/offline/offline-mode', () => ({
registerOfflineHooks: () => { offline.order.push('hooks'); },
offlineSessionFor: async () => offline.session,
serverUnreachable: async () => offline.gone,
markStartedOffline: () => { offline.order.push('startedOffline'); },
}));
let connectGate: Promise<void> = Promise.resolve();
vi.mock('@/lib/jmap/client', () => {
class JMAPClient {
offlineOnly = false;
constructor(public serverUrl: string, public username: string, public password: string) {}
static withBearer() { throw new Error('not used'); }
static fromOfflineSession(serverUrl: string, username: string) {
offline.order.push('local client');
const client = new JMAPClient(serverUrl, username, '');
client.offlineOnly = true;
return client;
}
isOfflineOnly() { return this.offlineOnly; }
async connect() { offline.order.push('connect'); await connectGate; }
getSessionUsername() { return this.username; }
getUsername() { return this.username; }
async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; }
getAuthHeader() { return 'Basic eA=='; }
onConnectionChange() {}
onRateLimit() {}
getRateLimitRemainingMs() { return 0; }
supportsContacts() { return false; }
supportsPrincipals() { return false; }
supportsVacationResponse() { return false; }
supportsCalendars() { return false; }
supportsSieve() { return false; }
disconnect() {}
getAccountId() { return 'acct'; }
}
class RateLimitError extends Error {}
class RequestTimeoutError extends Error {}
return { JMAPClient, RateLimitError, RequestTimeoutError };
});
vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null }));
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useOfflineStore } from '@/lib/offline/state';
// The auth store keeps its clients for good, so each test gets an account of its own.
let run = 0;
let ACCOUNT = makeAccount();
function makeAccount() {
run += 1;
const username = `bob${run}@x.test`;
return {
id: `${username}@mail.x.test`, label: 'Bob', serverUrl: 'https://mail.x.test', username, authMode: 'basic' as const,
rememberMe: true, cookieSlot: 0, displayName: 'Bob', email: username, isConnected: false, hasError: false,
lastLoginAt: 1, avatarColor: '#000000', isDefault: true,
};
}
let sessionAnswer: () => Response;
beforeAll(() => {
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
const url = String(input);
if (url.includes('/api/auth/session')) return sessionAnswer();
if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
return new Response('{}', { status: 200 });
}));
});
const goodSession = () => new Response(JSON.stringify({ serverUrl: ACCOUNT.serverUrl, username: ACCOUNT.username, password: 'x' }), { status: 200 });
beforeEach(async () => {
ACCOUNT = makeAccount();
offline.order.length = 0;
offline.session = { apiUrl: 'x' };
offline.gone = false;
connectGate = Promise.resolve();
sessionAnswer = goodSession;
useAccountStore.setState({ accounts: [ACCOUNT] as never, activeAccountId: ACCOUNT.id, defaultAccountId: ACCOUNT.id });
useOfflineStore.setState({ enabled: [ACCOUNT.id], startedOffline: false });
useAuthStore.setState({ client: null, isAuthenticated: false, activeAccountId: ACCOUNT.id });
});
type ClientLike = { isOfflineOnly(): boolean };
const current = () => useAuthStore.getState().client as unknown as ClientLike | null;
it('registers the hooks first, shows the local copy before connecting, then swaps', async () => {
let open: () => void = () => {};
connectGate = new Promise((resolve) => { open = resolve; });
await useAuthStore.getState().checkAuth();
// The real connect hasn't finished, and the app is up on the local copy.
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().isLoading).toBe(false);
expect(current()?.isOfflineOnly()).toBe(true);
expect(offline.order.indexOf('hooks')).toBeLessThan(offline.order.indexOf('local client'));
// No "you're offline" while the server is simply being asked.
expect(offline.order).not.toContain('startedOffline');
open();
await vi.waitFor(() => expect(current()?.isOfflineOnly()).toBe(false));
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(useAuthStore.getState().getClientForAccount(ACCOUNT.id)).toBe(useAuthStore.getState().client);
});
it('stays on the local copy when the server can\'t be reached', async () => {
sessionAnswer = () => new Response('{}', { status: 503 });
offline.gone = true;
await useAuthStore.getState().checkAuth();
await vi.waitFor(() => expect(offline.order).toContain('startedOffline'));
expect(useAuthStore.getState().isAuthenticated).toBe(true);
expect(current()?.isOfflineOnly()).toBe(true);
expect(useAccountStore.getState().getAccountById(ACCOUNT.id)).toBeTruthy();
});
it('goes the usual way when the store has no copy', async () => {
offline.session = null;
await useAuthStore.getState().checkAuth();
expect(offline.order).not.toContain('local client');
expect(current()?.isOfflineOnly()).toBe(false);
});
it('doesn\'t touch the store for an account without it', async () => {
useOfflineStore.setState({ enabled: [] });
await useAuthStore.getState().checkAuth();
expect(offline.order).toEqual(['connect']);
expect(current()?.isOfflineOnly()).toBe(false);
});
it('leaves the login path to a refused account, and keeps its data', async () => {
sessionAnswer = () => new Response('{}', { status: 401 });
await useAuthStore.getState().checkAuth();
await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ACCOUNT.id)).toBeUndefined());
await vi.waitFor(() => expect(useAuthStore.getState().isAuthenticated).toBe(false));
expect(useAuthStore.getState().client).toBeNull();
expect(useOfflineStore.getState().enabled).toEqual([ACCOUNT.id]);
});
+17 -2
View File
@@ -3,6 +3,16 @@ import { persist } from 'zustand/middleware';
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
import { authHooks } from '@/lib/plugin-hooks';
export type AccountRemovalReason = 'explicit' | 'evicted';
const removalListeners = new Set<(accountId: string, reason: AccountRemovalReason) => void>();
/** Told before an account leaves the registry, and why. */
export function onAccountRemoved(listener: (accountId: string, reason: AccountRemovalReason) => void): () => void {
removalListeners.add(listener);
return () => removalListeners.delete(listener);
}
export interface AccountEntry {
/** Unique key: `${username}@${serverHostname}` */
id: string;
@@ -74,7 +84,11 @@ interface AccountState {
defaultAccountId: string | null;
addAccount: (entry: Omit<AccountEntry, 'id' | 'cookieSlot' | 'avatarColor'>) => string;
removeAccount: (accountId: string) => void;
/**
* `reason`: 'explicit' when someone signed the account out or removed it, its
* offline data goes with it; 'evicted' when the app dropped it by itself.
*/
removeAccount: (accountId: string, reason?: AccountRemovalReason) => void;
setActiveAccount: (accountId: string) => void;
setDefaultAccount: (accountId: string) => void;
getDefaultAccount: () => AccountEntry | null;
@@ -147,8 +161,9 @@ export const useAccountStore = create<AccountState>()(
return id;
},
removeAccount: (accountId) => {
removeAccount: (accountId, reason = 'explicit') => {
if (!get().accounts.some((a) => a.id === accountId)) return;
for (const listener of [...removalListeners]) listener(accountId, reason);
set((s) => {
const remaining = s.accounts.filter((a) => a.id !== accountId);
const wasDefault = s.defaultAccountId === accountId;
+82 -11
View File
@@ -9,7 +9,7 @@ import { useVacationStore } from './vacation-store';
import { subscriptionOwner, useCalendarStore } from './calendar-store';
import { useFilterStore } from './filter-store';
import { useSettingsStore } from './settings-store';
import { useAccountStore, type AccountEntry } from './account-store';
import { useAccountStore, type AccountEntry, type AccountRemovalReason } from './account-store';
import { fetchPrincipalDisplayName } from '@/lib/stalwart/principal';
import { fetchConfig, getCachedConfig } from '@/hooks/use-config';
import { debug } from '@/lib/debug';
@@ -30,6 +30,8 @@ import { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected';
import { useOfflineStore } from '@/lib/offline/state';
import { toAsciiEmail } from '@/lib/idn';
import { flushComposers } from '@/lib/compose-flush-registry';
import { sortIdentities } from '@/lib/identity-order';
@@ -116,7 +118,7 @@ interface AuthState {
* (the organisation's session limits, which sign out without asking).
*/
logoutAll: (options?: LogoutOptions) => Promise<void>;
removeAccount: (accountId: string) => void;
removeAccount: (accountId: string, reason?: AccountRemovalReason) => void;
switchAccount: (accountId: string) => Promise<void>;
checkAuth: () => Promise<void>;
clearError: () => void;
@@ -1337,6 +1339,25 @@ async function startOfflineClient(accountId: string, serverUrl: string, username
}
}
/**
* The account's local copy, to show right away while the real connect runs. A
* client that answers from the device only; null without the local store for
* that account, and the start goes the usual way.
*/
async function startLocalCopyClient(accountId: string, serverUrl: string, username: string): Promise<JMAPClient | null> {
if (IS_LITE || skipLocalCopy || !useOfflineStore.getState().enabled.includes(accountId)) return null;
try {
const offline = await import('@/lib/offline/offline-mode');
const session = await offline.offlineSessionFor(accountId);
return session ? JMAPClient.fromOfflineSession(serverUrl, username, session) : null;
} catch {
return null;
}
}
// While device-only clients get replaced, restoring goes straight to the server.
let skipLocalCopy = false;
function performFullLogout(set: (state: Partial<AuthState>) => void): void {
useSettingsStore.getState().disableSync();
// With settings sync on, the local settings and templates are a copy of the
@@ -2196,8 +2217,9 @@ export const useAuthStore = create<AuthState>()(
if (res.status === 401) {
resetRefreshBackoff(accountId ?? undefined);
// Only this login's refresh token was refused.
if (accountId) noteCredentialsRejected(accountId);
if (accountId && !isActive) {
get().removeAccount(accountId);
get().removeAccount(accountId, 'evicted');
return null;
}
await saveDraftsBeforeExpiry(slot);
@@ -2215,7 +2237,7 @@ export const useAuthStore = create<AuthState>()(
notifyParent('sso:session-expired');
markSessionExpired();
// removeAccount() on the active account would sign out as if the user asked to.
if (accountId && accountId !== get().activeAccountId) get().removeAccount(accountId);
if (accountId && accountId !== get().activeAccountId) get().removeAccount(accountId, 'evicted');
else {
await saveDraftsBeforeExpiry(slot);
get().logout({ endProviderSession: false });
@@ -2431,7 +2453,7 @@ export const useAuthStore = create<AuthState>()(
// drop it from the registry, and clear its per-slot cookies. If asked to
// remove the active account, defer to logout() which handles switching
// away or redirecting.
removeAccount: (accountId: string) => {
removeAccount: (accountId: string, reason = 'explicit') => {
if (accountId === get().activeAccountId) { void get().logout(); return; }
const accountStore = useAccountStore.getState();
const account = accountStore.getAccountById(accountId);
@@ -2451,7 +2473,7 @@ export const useAuthStore = create<AuthState>()(
clients.delete(accountId);
evictAccount(accountId);
forgetAccountRows(accountId, get().client);
accountStore.removeAccount(accountId);
accountStore.removeAccount(accountId, reason);
forgetPrincipals(accountId);
clearPluginStorageForAccount(accountId);
},
@@ -2611,7 +2633,7 @@ export const useAuthStore = create<AuthState>()(
// Cannot restore - remove the stale account and redirect to login
evictAccount(accountId);
accountStore.removeAccount(accountId);
accountStore.removeAccount(accountId, 'evicted');
void clearSlotCredentials(targetAccount.cookieSlot, targetAccount.authMode === 'oauth');
// Restore the previous account if still available
@@ -2789,6 +2811,12 @@ export const useAuthStore = create<AuthState>()(
// with a stale client reference while we're restoring accounts.
set({ isLoading: true, client: null });
// The offline hooks have to be there before the first request, or a
// start without the server has nothing to paint from.
if (!IS_LITE && useOfflineStore.getState().enabled.length > 0) {
await import('@/lib/offline/offline-mode').then((offline) => offline.registerOfflineHooks()).catch(() => {});
}
// Determine which account to activate first
const defaultAccount = accountStore.getDefaultAccount();
const activeId = get().activeAccountId;
@@ -2817,7 +2845,7 @@ export const useAuthStore = create<AuthState>()(
if (!account.rememberMe && !IS_LITE) {
forgottenAccounts += 1;
evictAccount(account.id);
accountStore.removeAccount(account.id);
accountStore.removeAccount(account.id, 'evicted');
if (account.authMode === 'oauth') void clearSlotCredentials(account.cookieSlot, true);
return;
}
@@ -2901,7 +2929,7 @@ export const useAuthStore = create<AuthState>()(
// again rather than seeing a stale error entry forever. Its
// refresh token goes with it: nothing would use or revoke it.
evictAccount(account.id);
accountStore.removeAccount(account.id);
accountStore.removeAccount(account.id, 'evicted');
void clearSlotCredentials(account.cookieSlot, account.authMode === 'oauth');
}
};
@@ -2912,7 +2940,44 @@ export const useAuthStore = create<AuthState>()(
// bump re-runs per-client effects (push binding) over the full set.
const targetEntry = accounts.find((account) => account.id === targetId);
const otherAccounts = accounts.filter((account) => account.id !== targetId);
if (targetEntry) await restoreAccount(targetEntry);
// With the local store on, the local copy shows at once and the real
// connect takes over when it's done (swapInRealClient below).
const localCopy = targetEntry && !targetEntry.awaitingSignIn && targetEntry.rememberMe
? await startLocalCopyClient(targetEntry.id, targetEntry.serverUrl, targetEntry.username)
: null;
if (localCopy && targetEntry) {
clients.set(targetEntry.id, localCopy);
accountStore.updateAccount(targetEntry.id, { isConnected: true, hasError: false });
} else if (targetEntry) {
await restoreAccount(targetEntry);
}
/**
* The real connect behind a local copy on screen. No reload and no
* loading state: the client in the store changes and the pages
* refetch from the server. A refused account goes through the usual
* restore path, an unreachable server keeps the local copy.
*/
const swapInRealClient = async (entry: (typeof accounts)[number], local: JMAPClient) => {
clients.delete(entry.id);
await restoreAccount(entry);
const real = clients.get(entry.id);
if (!real) {
if (useAccountStore.getState().getAccountById(entry.id)) {
clients.set(entry.id, local);
return;
}
if (get().activeAccountId !== entry.id) return;
// Refused, and evicted by the restore. Whoever is left, or the login page.
set({ client: null, isAuthenticated: false, activeAccountId: null });
if (useAccountStore.getState().accounts.length > 0) await get().checkAuth();
else markSessionExpired();
return;
}
if (get().activeAccountId !== entry.id) return;
initializeFeatureStores(real);
set({ client: real, ...getClientRateLimitState(real), connectionLost: false, error: null });
};
// A few at a time rather than one after the other: with many logins
// the serial walk left the last of them - and with it a complete
@@ -2974,6 +3039,7 @@ export const useAuthStore = create<AuthState>()(
error: null,
activeAccountId: targetId,
});
if (localCopy && targetEntry) void swapInRealClient(targetEntry, localCopy);
fetchConfig().then(config => {
if (!config.settingsSyncEnabled) return;
@@ -3292,7 +3358,12 @@ export const useAuthStore = create<AuthState>()(
const offline = [...clients.entries()].filter(([, c]) => c.isOfflineOnly());
if (offline.length === 0) return true;
for (const [id] of offline) clients.delete(id);
await get().checkAuth();
skipLocalCopy = true;
try {
await get().checkAuth();
} finally {
skipLocalCopy = false;
}
return offline.every(([id]) => {
const client = clients.get(id);
return !!client && !client.isOfflineOnly();