feat: keep mail sent offline in an Outbox and send it once back online

This commit is contained in:
Linus Rath
2026-10-31 15:34:02 +01:00
parent e1ad5c0945
commit 0ec809eb49
12 changed files with 1412 additions and 17 deletions
+22
View File
@@ -913,6 +913,28 @@
"personal_in_account": "Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.",
"section_personal": "My templates"
},
"outbox": {
"discard": "Discard",
"discard_body": "It will not be sent, and its text is lost.",
"discard_title": "Discard this message?",
"failed_count": "{count, plural, one {# message} other {# messages}} not sent",
"move_failed_body": "The message stays in the Outbox. Try again when the connection is back.",
"move_failed_title": "Could not move to Drafts",
"move_to_drafts": "Move to Drafts",
"open_hint": "Opens the Outbox",
"queued_body": "It goes out as soon as you are back online.",
"queued_title": "Saved to the Outbox",
"retry": "Try again",
"send_failed": "A message in the Outbox could not be sent",
"sent": "Message from the Outbox sent",
"status_failed": "Not sent",
"status_failed_reason": "Not sent: {reason}",
"status_offline": "Waiting for a connection",
"status_retrying": "Waiting to send",
"status_sending": "Sending…",
"title": "Outbox",
"waiting": "{count, plural, one {# message} other {# messages}} in the Outbox"
},
"push": {
"ios": {
"more_messages": "and {count} more",
@@ -0,0 +1,106 @@
/**
* Live checks of the offline send queue's rule against sending twice: a
* message is found by its Message-ID, a copy that went out stops a resend,
* and the autosaved draft alone does not.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
* `BULWARK_LIVE_PASS` are set.
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
* npx vitest run src/api/__tests__/live-stalwart-send-queue.test.ts
*
* Mail goes to the user's own address; everything it creates is removed.
* Verified against Stalwart 0.16.27 and 1.0.1 (2026-10-31).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { JMAPClient } from '../jmap-client';
import { destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
import { getIdentities } from '../identity';
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
import { generateMessageId } from '../../lib/email-threading';
import { newQueuedSend } from '../../lib/send-queue';
import type { Identity, Mailbox } from '../types';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const live = SERVER && PASS ? describe : describe.skip;
const RUN = Date.now().toString(36);
let singleton: JMAPClient;
live('live Stalwart: offline send queue', () => {
let drafts = '';
let sent = '';
let boxes: Mailbox[] = [];
let identity: Identity;
const tag = `bulwark-sendqueue-${RUN}`;
beforeAll(async () => {
const mod = await import('../jmap-client');
singleton = mod.jmapClient as unknown as JMAPClient;
const client = new JMAPClient();
await client.connect(SERVER!, USER, PASS);
singleton.restoreSnapshot(client.snapshot());
boxes = await getMailboxes();
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
[identity] = await getIdentities();
}, 60_000);
afterAll(async () => {
const res = await singleton.request([['Email/query', { accountId: singleton.accountId, filter: { subject: tag } }, '0']]);
const leftovers = res.methodResponses[0][1].ids as string[] ?? [];
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
}, 60_000);
const message = (subject: string) => ({
from: [{ email: identity.email }],
to: [{ email: identity.email }],
subject: `${subject} ${tag}`,
textBody: 'live',
messageId: generateMessageId(identity.email),
});
const started = Date.now();
const lookup = () => ({ mailboxIds: [sent, drafts], since: started });
const payloadFor = (outgoing: ReturnType<typeof message>, draftId?: string) => ({
outgoing, identity, fromEmail: identity.email, sentMailboxId: sent, draftsMailboxId: drafts, draftId,
});
it('finds a message by its Message-ID, and only that one', async () => {
const outgoing = message('lookup');
const result = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
expect(copies.map((c) => c.id)).toEqual([result.emailId]);
expect(copies[0].keywords?.$draft).toBeFalsy();
expect(await findCopiesByMessageId(generateMessageId(identity.email), lookup())).toEqual([]);
}, 60_000);
it('does not send again a message an unanswered attempt already sent', async () => {
const outgoing = message('sent-once');
const first = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
// The answer to that send was lost: the queued entry is still marked.
const outcome = await runQueuedSend(newQueuedSend('q1', payloadFor(outgoing), Date.now(), true), boxes);
expect(outcome.kind).toBe('alreadySent');
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
expect(copies.map((c) => c.id)).toEqual([first.emailId]);
}, 60_000);
it('sends a marked message whose only copy is its draft, and removes the draft', async () => {
const outgoing = message('draft-only');
const draft = await saveDraftVersion(outgoing, drafts);
const outcome = await runQueuedSend(newQueuedSend('q2', payloadFor(outgoing, draft.id), Date.now(), true), boxes);
expect(outcome.kind).toBe('sent');
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
expect(copies).toHaveLength(1);
expect(copies[0].id).not.toBe(draft.id);
expect(copies[0].keywords?.$draft).toBeFalsy();
const res = await singleton.request([['Email/get', { accountId: singleton.accountId, ids: [copies[0].id], properties: ['mailboxIds'] }, '0']]);
expect(res.methodResponses[0][1].list[0].mailboxIds[sent]).toBe(true);
}, 60_000);
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Sending from the offline queue: a message an unanswered attempt may have
// sent is looked up by its Message-ID first and not sent again when a copy
// went out.
vi.mock('../jmap-client', () => ({
jmapClient: { accountId: 'acc-1', request: vi.fn(), getAccountCapability: vi.fn(() => undefined) },
}));
const sendEmail = vi.fn(async (..._a: any[]) => ({ scheduled: false, emailId: 'e-new', emailSubmissionId: 's-1' }));
const destroyEmails = vi.fn(async (..._a: any[]) => undefined);
vi.mock('../email', () => ({
sendEmail: (...a: any[]) => sendEmail(...a),
destroyEmails: (...a: any[]) => destroyEmails(...a),
}));
vi.mock('../../lib/compose-send-account', () => ({
sendTargetFor: vi.fn(async (identity: { id: string }) => ({ identityId: identity.id, sentMailboxId: 'sent', draftsMailboxId: 'drafts' })),
}));
import { jmapClient } from '../jmap-client';
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
import { newQueuedSend, type QueuedSendPayload } from '../../lib/send-queue';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const PAYLOAD = {
outgoing: { from: [{ email: 'me@x.de' }], to: [{ email: 'you@x.de' }], subject: 'Hi', textBody: 'x', messageId: 'm1@x.de' },
identity: { id: 'id-1', email: 'me@x.de', name: 'Me' },
fromEmail: 'me@x.de',
sentMailboxId: 'sent',
draftsMailboxId: 'drafts',
draftId: 'draft-1',
requestDsn: true,
} as unknown as QueuedSendPayload;
const found = (list: Array<{ id: string; keywords?: Record<string, boolean>; messageId?: string[] }>) => ({
methodResponses: [
['Email/query', { ids: list.map((e) => e.id) }, '0'],
['Email/get', { list }, '1'],
],
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('runQueuedSend', () => {
it('sends a message no attempt reached the server with, without looking first', async () => {
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, false), []);
expect(outcome.kind).toBe('sent');
expect(mockRequest).not.toHaveBeenCalled();
expect(sendEmail).toHaveBeenCalledWith(
PAYLOAD.outgoing, 'id-1', 'sent', undefined,
// DSN only where the account lists it; this one lists nothing.
expect.objectContaining({ draftsMailboxId: 'drafts', draftId: 'draft-1', requestDsn: false }),
);
});
it('does not send again a message whose earlier attempt went out, and removes its old draft', async () => {
mockRequest.mockResolvedValueOnce(found([
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
{ id: 'sent-1', keywords: { $seen: true }, messageId: ['<m1@x.de>'] },
]));
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), []);
expect(outcome.kind).toBe('alreadySent');
expect(sendEmail).not.toHaveBeenCalled();
expect(destroyEmails).toHaveBeenCalledWith(['draft-1'], 'acc-1');
});
it('removes a copy a refused attempt left behind, then sends', async () => {
mockRequest.mockResolvedValueOnce(found([
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
{ id: 'left-1', keywords: { $draft: true, $seen: true }, messageId: ['m1@x.de'] },
]));
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), []);
expect(outcome.kind).toBe('sent');
expect(destroyEmails).toHaveBeenCalledWith(['left-1'], 'acc-1');
expect(sendEmail).toHaveBeenCalledTimes(1);
});
it('holds a scheduled message for the time that is left', async () => {
const now = Date.parse('2026-10-31T12:00:00Z');
await runQueuedSend(newQueuedSend('q', { ...PAYLOAD, scheduledAt: '2026-10-31T13:00:00Z' }, now, false), [], now);
expect(sendEmail.mock.calls[0][3]).toBe(3600);
});
});
describe('findCopiesByMessageId', () => {
it('reads the recent messages of Sent and Drafts and keeps exact matches only', async () => {
mockRequest.mockResolvedValueOnce(found([
{ id: 'a', keywords: {}, messageId: ['m1@x.de'] },
{ id: 'b', keywords: {}, messageId: ['other-m1@x.de'] },
{ id: 'c', keywords: {}, messageId: [] },
]));
const copies = await findCopiesByMessageId('<m1@x.de>', { mailboxIds: ['sent', 'drafts'], since: Date.parse('2026-10-31T12:00:00Z') });
expect(copies.map((c) => c.id)).toEqual(['a']);
const [query] = mockRequest.mock.calls[0][0];
// Not the header filter: Stalwart answers that from a lagging index.
expect(query[1].filter).toEqual({
operator: 'AND',
conditions: [
{ after: '2026-10-31T11:50:00.000Z' },
{ operator: 'OR', conditions: [{ inMailbox: 'sent' }, { inMailbox: 'drafts' }] },
],
});
});
});
+120
View File
@@ -0,0 +1,120 @@
// Sending a message from the offline send queue (see lib/send-queue.ts for
// the state machine and the rule against sending twice).
import { jmapClient } from './jmap-client';
import { destroyEmails, sendEmail, type SendEmailResult } from './email';
import { requireMethodResult } from './jmap-result';
import type { Mailbox } from './types';
import { sendTargetFor } from '../lib/compose-send-account';
import { supportsSubmissionExtension } from '../lib/submission-options';
import { stripMessageIdBrackets } from '../lib/email-threading';
import { holdForScheduled, verdictFromCopies, type FoundCopy, type QueuedSend } from '../lib/send-queue';
/** How far before the queueing a copy can be dated (the server's clock may differ). */
const CLOCK_SKEW_MS = 10 * 60_000;
/** Messages filed in Sent and Drafts since the queueing that are compared. */
const LOOKUP_LIMIT = 200;
/**
* The messages in the given folders of `accountId` whose Message-ID is
* `messageId`, filed since `since` (ms). Not a header search: Stalwart
* answers `{ header: ['Message-ID', …] }` from its full-text index, which
* lags behind (0.16.27 and 1.0.1 found nothing seconds after the send), so
* the folders' recent messages are read and compared instead.
*/
export async function findCopiesByMessageId(
messageId: string,
opts: { accountId?: string; mailboxIds: string[]; since: number },
): Promise<FoundCopy[]> {
const id = stripMessageIdBrackets(messageId);
const mailboxIds = opts.mailboxIds.filter(Boolean);
if (!id || mailboxIds.length === 0) return [];
const account = opts.accountId ?? jmapClient.accountId;
const inFolders = mailboxIds.length === 1
? { inMailbox: mailboxIds[0] }
: { operator: 'OR', conditions: mailboxIds.map((m) => ({ inMailbox: m })) };
const filter = {
operator: 'AND',
conditions: [{ after: new Date(opts.since - CLOCK_SKEW_MS).toISOString() }, inFolders],
};
const res = await jmapClient.request([
['Email/query', {
accountId: account, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: LOOKUP_LIMIT,
}, '0'],
['Email/get', {
accountId: account,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['id', 'keywords', 'messageId'],
}, '1'],
]);
requireMethodResult(res, '0', 'Email/query');
const list = (requireMethodResult(res, '1', 'Email/get').list ?? []) as Array<{
id: string; keywords?: Record<string, boolean>; messageId?: string[] | null;
}>;
return list
.filter((e) => (e.messageId ?? []).some((m) => stripMessageIdBrackets(m) === id))
.map((e) => ({ id: e.id, keywords: e.keywords ?? {} }));
}
export type QueuedSendOutcome =
| { kind: 'sent'; result: SendEmailResult; accountId?: string }
/** An earlier attempt went out; nothing was sent now. */
| { kind: 'alreadySent'; accountId?: string };
/**
* Send a queued message. The account, identity and folders are worked out
* as the composer does (sendTargetFor, #1090). A message an earlier attempt
* may have sent is looked up first, and is not sent again when a copy of it
* went out; copies a refused attempt left in Drafts are removed before the
* new attempt.
*/
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise<QueuedSendOutcome> {
const p = send.payload;
const target = await sendTargetFor(p.identity, p.fromEmail, mailboxes);
const accountId = target.accountId ?? jmapClient.accountId;
const draftAccountId = p.draftAccountId ?? jmapClient.accountId;
const sentMailboxId = target.sentMailboxId ?? p.sentMailboxId;
const draftsMailboxId = target.accountId ? target.draftsMailboxId : p.draftsMailboxId;
if (send.maybeSent && p.outgoing.messageId) {
const copies = await findCopiesByMessageId(p.outgoing.messageId, {
accountId: target.accountId,
mailboxIds: [sentMailboxId, draftsMailboxId ?? ''],
since: send.createdAt,
});
// The autosaved draft lives in this account only when the draft did.
const draftHere = p.draftId && draftAccountId === accountId ? p.draftId : undefined;
const verdict = verdictFromCopies(copies, draftHere);
if (verdict.kind === 'sent') {
// The send went out but its answer was lost, so the old draft it would
// have removed is still there.
if (p.draftId && p.draftId !== verdict.copyId) {
await destroyEmails([p.draftId], draftAccountId).catch((err) => {
console.warn('[send-queue] could not remove the draft of a sent message', err);
});
}
return { kind: 'alreadySent', accountId: target.accountId };
}
if (verdict.staleIds.length > 0) {
await destroyEmails(verdict.staleIds, accountId);
}
}
const result = await sendEmail(
p.outgoing,
target.identityId,
sentMailboxId,
holdForScheduled(p.scheduledAt, now),
{
draftsMailboxId,
draftId: p.draftId,
draftAccountId: p.draftId ? p.draftAccountId : undefined,
accountId: target.accountId,
// Only what the submitting account offers, as the composer does.
requestDsn: !!p.requestDsn && supportsSubmissionExtension('DSN', target.accountId),
requireTls: !!p.requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
},
);
return { kind: 'sent', result, accountId: target.accountId };
}
+47 -7
View File
@@ -1,12 +1,14 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { CloudOff } from './icons';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { CloudOff, Send } from './icons';
import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { announceConnectivity } from '../lib/announce';
import { OutboxSheet } from './compose/OutboxSheet';
import { outboxHint } from '../lib/send-queue';
interface OfflineBannerProps {
/** Optional extra notice (e.g. "Showing cached mail"). */
@@ -20,26 +22,58 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const t = useLocaleStore((s) => s.t);
// Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length);
// Messages sent while offline (the Outbox). Counted outside the selector:
// a selector must not build a new value on every call.
const sends = useOutboxStore((s) => s.sends);
const waitingSends = sends.filter((s) => s.status !== 'failed').length;
const failedSends = sends.length - waitingSends;
const [outboxOpen, setOutboxOpen] = React.useState(false);
const offlineLabel = t('offline.banner', 'You are offline');
// VoiceOver hears the drop too (Android reads the live region below).
React.useEffect(() => {
announceConnectivity(online, offlineLabel);
}, [online, offlineLabel]);
if (online) return null;
const queuedHint = queued > 0
const sendsHint = outboxHint(waitingSends, failedSends, t);
if (online && !sendsHint) return null;
const queuedHint = !online && queued > 0
? t(
'offline.queued_changes',
'{count, plural, one {# change} other {# changes}} will sync when you reconnect',
{ count: queued },
)
: null;
const suffix = [hint, queuedHint].filter(Boolean).join(' · ');
const label = offlineLabel;
const label = online ? sendsHint! : offlineLabel;
const suffix = [online ? null : hint, queuedHint, online ? null : sendsHint].filter(Boolean).join(' · ');
const text = suffix ? `${label} — ${suffix}` : label;
const sheet = <OutboxSheet visible={outboxOpen} onClose={() => setOutboxOpen(false)} />;
if (sends.length > 0) {
// With messages in the Outbox the bar opens it.
return (
<>
<Pressable
style={[styles.bar, online && styles.barOutbox]}
onPress={() => setOutboxOpen(true)}
accessibilityRole="button"
accessibilityLabel={text}
accessibilityHint={t('outbox.open_hint', 'Opens the Outbox')}
accessibilityLiveRegion="polite"
>
{online
? <Send size={14} color={failedSends > 0 ? c.error : c.textSecondary} />
: <CloudOff size={14} color={c.warningForeground} />}
<Text style={[styles.text, online && styles.textOutbox, online && failedSends > 0 && styles.textFailed]} numberOfLines={1}>
{text}
</Text>
</Pressable>
{sheet}
</>
);
}
return (
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
<CloudOff size={14} color={c.warningForeground} />
<Text style={styles.text} numberOfLines={1}>
{suffix ? `${label} — ${suffix}` : label}
{text}
</Text>
</View>
);
@@ -60,5 +94,11 @@ function makeStyles(c: ThemePalette) {
color: c.warningForeground,
flex: 1,
},
barOutbox: {
backgroundColor: c.surfaceActive,
minHeight: 32,
},
textOutbox: { color: c.text },
textFailed: { color: c.error },
});
}
+176
View File
@@ -0,0 +1,176 @@
import React from 'react';
import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useColors } from '../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { useNetworkStore } from '../../stores/network-store';
import { useOutboxStore } from '../../stores/outbox-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
import { canCancel, queuedSendStatus, type QueuedSend } from '../../lib/send-queue';
function recipientsOf(send: QueuedSend): string {
const { to, cc, bcc } = send.payload.outgoing;
return [...to, ...(cc ?? []), ...(bcc ?? [])].map((r) => r.name || r.email).join(', ');
}
/**
* The Outbox: messages sent while offline that haven't gone out yet. Each
* can be moved to Drafts (online) or discarded, and a failed one tried
* again; none of that while it is on its way.
*/
export function OutboxSheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const online = useNetworkStore((s) => s.online);
const sends = useOutboxStore((s) => s.sends);
const bottomInset = useSheetBottomInset(visible);
const [busyId, setBusyId] = React.useState<string | null>(null);
React.useEffect(() => {
if (visible && sends.length === 0) onClose();
}, [visible, sends.length, onClose]);
const moveToDrafts = async (send: QueuedSend) => {
setBusyId(send.id);
const ok = await useOutboxStore.getState().cancelSend(send.id, { keepAsDraft: true });
setBusyId(null);
if (!ok) {
Alert.alert(
t('outbox.move_failed_title', 'Could not move to Drafts'),
t('outbox.move_failed_body', 'The message stays in the Outbox. Try again when the connection is back.'),
);
}
};
const discard = (send: QueuedSend) => {
Alert.alert(
t('outbox.discard_title', 'Discard this message?'),
t('outbox.discard_body', 'It will not be sent, and its text is lost.'),
[
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
{
text: t('outbox.discard', 'Discard'),
style: 'destructive',
onPress: () => { void useOutboxStore.getState().cancelSend(send.id); },
},
],
);
};
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<Pressable accessible={false} style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]} onPress={onClose}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<Text style={styles.title} accessibilityRole="header">{t('outbox.title', 'Outbox')}</Text>
<ScrollView style={styles.list}>
{sends.map((send) => {
const busy = busyId === send.id || send.status === 'sending';
const cancellable = canCancel(send) && busyId !== send.id;
return (
<View key={send.id} style={styles.row}>
<Text style={styles.subject} numberOfLines={1}>
{send.payload.outgoing.subject || t('email_viewer.no_subject', '(No subject)')}
</Text>
<Text style={styles.recipients} numberOfLines={1}>{recipientsOf(send)}</Text>
<View style={styles.statusRow}>
{busy && <ActivityIndicator size="small" color={c.textSecondary} />}
<Text
style={[styles.status, send.status === 'failed' && styles.statusFailed]}
numberOfLines={2}
>
{queuedSendStatus(send, online, t)}
</Text>
</View>
<View style={styles.actions}>
{send.status === 'failed' && (
<Pressable
style={styles.action}
onPress={() => { void useOutboxStore.getState().retrySend(send.id); }}
accessibilityRole="button"
>
<Text style={styles.actionPrimary}>{t('outbox.retry', 'Try again')}</Text>
</Pressable>
)}
{online && (
<Pressable
style={styles.action}
disabled={!cancellable}
onPress={() => { void moveToDrafts(send); }}
accessibilityRole="button"
accessibilityState={{ disabled: !cancellable }}
>
<Text style={[styles.actionText, !cancellable && styles.actionDisabled]}>
{t('outbox.move_to_drafts', 'Move to Drafts')}
</Text>
</Pressable>
)}
<Pressable
style={styles.action}
disabled={!cancellable}
onPress={() => discard(send)}
accessibilityRole="button"
accessibilityState={{ disabled: !cancellable }}
>
<Text style={[styles.actionDestructive, !cancellable && styles.actionDisabled]}>
{t('outbox.discard', 'Discard')}
</Text>
</Pressable>
</View>
</View>
);
})}
</ScrollView>
<Pressable style={styles.close} onPress={onClose} accessibilityRole="button">
<Text style={styles.closeText}>{t('common.close', 'Close')}</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
card: {
width: '100%',
maxWidth: 480,
maxHeight: '90%',
backgroundColor: c.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
padding: spacing.lg,
gap: spacing.sm,
},
title: { ...typography.h3, color: c.text },
list: { flexGrow: 0 },
row: {
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: c.borderLight,
gap: 2,
},
subject: { ...typography.bodyMedium, color: c.text },
recipients: { ...typography.caption, color: c.textSecondary },
statusRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
status: { ...typography.caption, color: c.textMuted, flex: 1 },
statusFailed: { color: c.error },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.md, marginTop: spacing.xs },
action: { minHeight: 40, justifyContent: 'center' },
actionText: { ...typography.bodyMedium, color: c.textSecondary },
actionPrimary: { ...typography.bodyMedium, color: c.primary },
actionDestructive: { ...typography.bodyMedium, color: c.error },
actionDisabled: { opacity: 0.4 },
close: { alignItems: 'center', paddingVertical: spacing.sm },
closeText: { ...typography.bodyMedium, color: c.textSecondary },
});
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Offline send as wired in ComposeScreen and the offline banner, checked on
// their source (the suite has no RN renderer).
const compose = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
const banner = fs.readFileSync(path.resolve(__dirname, '../../components/OfflineBanner.tsx'), 'utf8');
describe('ComposeScreen offline send', () => {
const send = compose.slice(compose.indexOf('const performSendInner'), compose.indexOf('const titleKey'));
it('queues the message instead of trying while offline', () => {
const offline = send.indexOf('if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return;');
expect(offline).toBeGreaterThan(-1);
// Before anything goes to the server.
expect(offline).toBeLessThan(send.indexOf('await sendTargetFor('));
});
it('queues a send whose connection failed, marked as possibly sent, ahead of the other failure messages', () => {
const queued = send.indexOf('shouldQueueFailedSend(classifySendFailure(e, isTransientNetworkError))');
expect(queued).toBeGreaterThan(-1);
expect(send).toMatch(/&& queueForLater\(queuedOutgoing, true\)\) \{\s+return;/);
expect(queued).toBeLessThan(send.indexOf('if (e instanceof RequestTimeoutError)'));
});
it("queues into the login the message was written in, and hands the Outbox the autosaved draft", () => {
const queue = send.slice(send.indexOf('const queueForLater'), send.indexOf('setSending(true);'));
expect(queue).toMatch(/if \(outbox\.activeAccountId !== ownerLoginRef\.current\) return false;/);
expect(queue).toMatch(/draftId: draftIdRef\.current \?\? undefined,/);
expect(queue).toMatch(/if \(!queuedId\) return false;\s+draftIdRef\.current = null;/);
expect(queue).toMatch(/navigation\.goBack\(\);/);
});
});
describe('OfflineBanner Outbox', () => {
it('shows the Outbox while messages wait, online too, and opens it', () => {
expect(banner).toMatch(/const sends = useOutboxStore\(\(s\) => s\.sends\);/);
expect(banner).toMatch(/if \(online && !sendsHint\) return null;/);
expect(banner).toMatch(/onPress=\{\(\) => setOutboxOpen\(true\)\}/);
expect(banner).toMatch(/<OutboxSheet visible=\{outboxOpen\}/);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
afterFailure, canCancel, classifySendFailure, holdForScheduled, MAX_SEND_ATTEMPTS, newQueuedSend, outboxHint,
queuedSendStatus, restoreAfterRestart, shouldQueueFailedSend, startAttempt, verdictFromCopies,
type QueuedSendPayload,
} from '../send-queue';
// The offline send queue's state machine and its rule against sending a
// message twice (parity 04/09/10 "offline send").
const PAYLOAD = {
outgoing: { from: [{ email: 'me@x.de' }], to: [{ email: 'you@x.de' }], subject: 'Hi', textBody: 'x', messageId: 'm1@x.de' },
identity: { id: 'id-1', email: 'me@x.de', name: 'Me' },
fromEmail: 'me@x.de',
sentMailboxId: 'sent',
} as unknown as QueuedSendPayload;
const named = (name: string) => Object.assign(new Error(name), { name });
const transient = (err: unknown) => err instanceof Error && err.name === 'NetworkError';
describe('classifySendFailure', () => {
it('tells the four outcomes apart', () => {
expect(classifySendFailure(named('AuthenticationError'), transient)).toBe('auth');
expect(classifySendFailure(named('RequestTimeoutError'), transient)).toBe('unconfirmed');
expect(classifySendFailure(named('SendUnconfirmedError'), transient)).toBe('unconfirmed');
expect(classifySendFailure(named('NetworkError'), transient)).toBe('offline');
expect(classifySendFailure(new Error('Invalid envelope'), transient)).toBe('refused');
});
it('queues only a send whose connection failed; a deadline keeps its "check Sent" warning', () => {
expect(shouldQueueFailedSend('offline')).toBe(true);
expect(shouldQueueFailedSend('unconfirmed')).toBe(false);
expect(shouldQueueFailedSend('refused')).toBe(false);
});
});
describe('queued send transitions', () => {
const fresh = newQueuedSend('q1', PAYLOAD, 1000, false);
it('marks an attempt as possibly sent before its request leaves', () => {
expect(startAttempt(fresh)).toMatchObject({ status: 'sending', maybeSent: true });
});
it('keeps an offline failure queued and marked, without counting it', () => {
const next = afterFailure(startAttempt(fresh), 'offline', 'Network request failed');
expect(next).toMatchObject({ status: 'queued', maybeSent: true, attempts: 0 });
});
it('clears the mark when the server turned the request away', () => {
expect(afterFailure(startAttempt(fresh), 'auth', 'Session expired')).toMatchObject({ status: 'queued', maybeSent: false });
});
it('fails a refused send at once, with the reason; nothing went out', () => {
expect(afterFailure(startAttempt(fresh), 'refused', 'Invalid envelope'))
.toMatchObject({ status: 'failed', maybeSent: false, attempts: 1, lastError: 'Invalid envelope' });
});
it('gives up after repeated unconfirmed attempts, still marked', () => {
let send = fresh;
for (let i = 0; i < MAX_SEND_ATTEMPTS - 1; i++) {
send = afterFailure(startAttempt(send), 'unconfirmed', 'no answer');
expect(send.status).toBe('queued');
}
send = afterFailure(startAttempt(send), 'unconfirmed', 'no answer');
expect(send).toMatchObject({ status: 'failed', maybeSent: true, attempts: MAX_SEND_ATTEMPTS });
});
it('puts a send the app was killed in back into the queue, marked', () => {
const [restored, other] = restoreAfterRestart([startAttempt(fresh), { ...fresh, id: 'q2' }]);
expect(restored).toMatchObject({ status: 'queued', maybeSent: true });
expect(other).toMatchObject({ status: 'queued', maybeSent: false });
});
it('cannot be cancelled while it is on its way', () => {
expect(canCancel(startAttempt(fresh))).toBe(false);
expect(canCancel(fresh)).toBe(true);
expect(canCancel(afterFailure(fresh, 'refused', 'x'))).toBe(true);
});
});
describe('verdictFromCopies', () => {
it('counts a copy without $draft as sent', () => {
expect(verdictFromCopies([{ id: 'd1', keywords: { $draft: true } }, { id: 's1', keywords: { $seen: true } }], 'd1'))
.toEqual({ kind: 'sent', copyId: 's1' });
});
it('sends again when only the autosaved draft is there', () => {
expect(verdictFromCopies([{ id: 'd1', keywords: { $draft: true } }], 'd1')).toEqual({ kind: 'resend', staleIds: [] });
});
it('removes copies a refused attempt left in Drafts, but never the autosaved draft', () => {
expect(verdictFromCopies([{ id: 'd1', keywords: { $draft: true } }, { id: 'x1', keywords: { $draft: true } }], 'd1'))
.toEqual({ kind: 'resend', staleIds: ['x1'] });
});
it('sends when nothing is found', () => {
expect(verdictFromCopies([], undefined)).toEqual({ kind: 'resend', staleIds: [] });
});
});
describe('holdForScheduled', () => {
it('holds a scheduled send for the time left, and sends at once once it has passed', () => {
expect(holdForScheduled('2026-10-31T12:00:30Z', Date.parse('2026-10-31T12:00:00Z'))).toBe(30);
expect(holdForScheduled('2026-10-31T11:00:00Z', Date.parse('2026-10-31T12:00:00Z'))).toBeUndefined();
expect(holdForScheduled(undefined, 0)).toBeUndefined();
});
});
describe('Outbox texts', () => {
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
(fallback ?? '').replace(/\{count, plural, one \{# (\w+)\} other \{# (\w+)\}\}/, (_m, one, other) =>
`${params?.count} ${params?.count === 1 ? one : other}`).replace(/\{(\w+)\}/g, (_m, k) => String(params?.[k] ?? ''));
it('counts waiting and failed messages', () => {
expect(outboxHint(2, 0, t)).toBe('2 messages in the Outbox');
expect(outboxHint(1, 1, t)).toBe('1 message in the Outbox · 1 message not sent');
expect(outboxHint(0, 0, t)).toBeNull();
});
it('says why a message did not go out', () => {
const failed = afterFailure(newQueuedSend('q', PAYLOAD, 0, false), 'refused', 'Invalid envelope');
expect(queuedSendStatus(failed, true, t)).toBe('Not sent: Invalid envelope');
expect(queuedSendStatus(newQueuedSend('q', PAYLOAD, 0, false), false, t)).toBe('Waiting for a connection');
});
});
+193
View File
@@ -0,0 +1,193 @@
// The offline send queue's state machine (parity 04/09/10 "offline send").
//
// The webmail has no offline outbox: an offline send says so and keeps the
// composer open (email-composer.tsx, `send_offline`). On a phone the natural
// behaviour is to queue the message and send it once the connection is back,
// so the app keeps queued messages next to its mutation outbox
// (stores/outbox-store.ts), persisted per account, and replays them from the
// same flush.
//
// The rule that keeps a message from going out twice: a send attempt is
// marked `maybeSent` before its request leaves, and only an answer that
// says how it ended clears the mark. A queued message still marked (the
// connection dropped mid-request, the app was killed, the server did not
// confirm) is looked up by its Message-ID before it is sent again: a copy
// that is no longer a draft means it went out.
import type { OutgoingEmail } from '../api/email';
import type { Identity } from '../api/types';
/** Everything needed to send the message later, without the composer. */
export interface QueuedSendPayload {
/** The message as the composer built it; `messageId` is always set. */
outgoing: OutgoingEmail;
/** The composer's identity; the send target is worked out again at send time. */
identity: Identity;
/** The header From, which picks a group or shared account (#1090). */
fromEmail: string;
/** The login's own Sent and Drafts when queued, for when folders aren't loaded. */
sentMailboxId: string;
draftsMailboxId?: string;
/** The autosaved draft version this message replaces, removed once it is sent. */
draftId?: string;
draftAccountId?: string;
requestDsn?: boolean;
requireTls?: boolean;
/** An explicit "send later": the hold is worked out from it at send time. */
scheduledAt?: string;
/** The message replied to or forwarded, flagged once this one is sent. */
original?: { emailId: string; accountId?: string; keyword: '$answered' | '$forwarded' };
}
export type QueuedSendStatus = 'queued' | 'sending' | 'failed';
export interface QueuedSend {
id: string;
createdAt: number;
payload: QueuedSendPayload;
status: QueuedSendStatus;
/** Attempts that ended without the message going out for sure. */
attempts: number;
/**
* A request may have reached the server without an answer saying how it
* ended: look for the message before sending it again.
*/
maybeSent: boolean;
lastError?: string;
}
/** Give up (and let the user decide) after this many unconfirmed attempts. */
export const MAX_SEND_ATTEMPTS = 3;
export function newQueuedSend(id: string, payload: QueuedSendPayload, now: number, maybeSent: boolean): QueuedSend {
return { id, createdAt: now, payload, status: 'queued', attempts: 0, maybeSent };
}
/**
* How a send attempt failed, by error name so this stays free of the API
* modules:
* - `auth`: the credentials are gone; nothing reached the mail queue.
* - `unconfirmed`: an answer that never came (deadline) or a send the
* server did not confirm; the message may be out.
* - `offline`: the connection failed; the request may have got through.
* - `refused`: the server answered and refused; nothing went out (the send
* removes the copy it made).
*/
export type SendFailureKind = 'auth' | 'unconfirmed' | 'offline' | 'refused';
export function classifySendFailure(err: unknown, isTransient: (err: unknown) => boolean): SendFailureKind {
const name = err instanceof Error ? err.name : '';
if (name === 'AuthenticationError' || name === 'TotpRequiredError') return 'auth';
if (name === 'RequestTimeoutError' || name === 'SendUnconfirmedError') return 'unconfirmed';
if (isTransient(err)) return 'offline';
return 'refused';
}
/**
* Whether the composer queues a send that failed this way instead of
* reporting it: only when the connection failed. A deadline or an
* unconfirmed send keeps its "check Sent first" warning (#702, #1019).
*/
export function shouldQueueFailedSend(kind: SendFailureKind): boolean {
return kind === 'offline';
}
/** The attempt is about to leave: from here on it may reach the server. */
export function startAttempt(send: QueuedSend): QueuedSend {
return { ...send, status: 'sending', maybeSent: true, lastError: undefined };
}
/** The state after an attempt failed this way. */
export function afterFailure(send: QueuedSend, kind: SendFailureKind, message: string): QueuedSend {
switch (kind) {
case 'auth':
// Turned away at the door: nothing was taken. Wait for a sign-in.
return { ...send, status: 'queued', maybeSent: false, lastError: message };
case 'offline':
// May have got through; the next attempt looks first. Not counted:
// being offline is what the queue is for.
return { ...send, status: 'queued', maybeSent: true, lastError: message };
case 'unconfirmed': {
const attempts = send.attempts + 1;
return {
...send, status: attempts >= MAX_SEND_ATTEMPTS ? 'failed' : 'queued', maybeSent: true, attempts, lastError: message,
};
}
case 'refused':
return { ...send, status: 'failed', maybeSent: false, attempts: send.attempts + 1, lastError: message };
}
}
/** A message found by its Message-ID. */
export interface FoundCopy {
id: string;
keywords?: Record<string, boolean>;
}
export type CopyVerdict =
| { kind: 'sent'; copyId: string }
| { kind: 'resend'; staleIds: string[] };
/**
* What the copies with the message's Message-ID say. A send creates the
* message as a draft and the submission's onSuccessUpdateEmail files it in
* Sent without `$draft` in the same request, so a copy without `$draft` went
* out (or is held for a scheduled time). Copies still marked `$draft` are
* the autosaved draft (`draftId`, kept until the send succeeds) and copies
* a refused submission left behind with its answer lost: those go before
* the message is sent again.
*/
export function verdictFromCopies(copies: FoundCopy[], draftId?: string): CopyVerdict {
const sentCopy = copies.find((c) => c.id !== draftId && !c.keywords?.$draft);
if (sentCopy) return { kind: 'sent', copyId: sentCopy.id };
return { kind: 'resend', staleIds: copies.filter((c) => c.id !== draftId).map((c) => c.id) };
}
/** Seconds to hold a scheduled send for, now; none once its time has passed. */
export function holdForScheduled(scheduledAt: string | undefined, now: number): number | undefined {
if (!scheduledAt) return undefined;
const at = Date.parse(scheduledAt);
if (!Number.isFinite(at)) return undefined;
const seconds = Math.ceil((at - now) / 1000);
return seconds > 0 ? seconds : undefined;
}
/** Sends left in `sending` by an app that was killed go back to the queue, still marked. */
export function restoreAfterRestart(sends: QueuedSend[]): QueuedSend[] {
return sends.map((s) => (s.status === 'sending' ? { ...s, status: 'queued', maybeSent: true } : s));
}
type Translate = (key: string, fallback?: string, params?: Record<string, string | number>) => string;
/** The status line under a message in the Outbox. */
export function queuedSendStatus(send: QueuedSend, online: boolean, t: Translate): string {
if (send.status === 'sending') return t('outbox.status_sending', 'Sending…');
if (send.status === 'failed') {
return send.lastError
? t('outbox.status_failed_reason', 'Not sent: {reason}', { reason: send.lastError })
: t('outbox.status_failed', 'Not sent');
}
return online
? t('outbox.status_retrying', 'Waiting to send')
: t('outbox.status_offline', 'Waiting for a connection');
}
/**
* The Outbox part of the offline banner: how many messages wait to go out
* and how many could not be sent, or null without any.
*/
export function outboxHint(waiting: number, failed: number, t: Translate): string | null {
const parts: string[] = [];
if (waiting > 0) {
parts.push(t('outbox.waiting', '{count, plural, one {# message} other {# messages}} in the Outbox', { count: waiting }));
}
if (failed > 0) {
parts.push(t('outbox.failed_count', '{count, plural, one {# message} other {# messages}} not sent', { count: failed }));
}
return parts.length ? parts.join(' · ') : null;
}
/** Whether the user can take the message back: not while it is on its way. */
export function canCancel(send: QueuedSend): boolean {
return send.status !== 'sending';
}
+49
View File
@@ -86,6 +86,10 @@ import { MentionSuggestionList } from '../components/compose/MentionSuggestionLi
import { RecipientChip, RecipientSuggestionList } from '../components/compose/RecipientSuggestionList';
import { RecipientPicker } from '../components/compose/RecipientPicker';
import { isPickerReopenTooSoon, needsRecipientPicker } from '../lib/recipient-picker';
import { useOutboxStore } from '../stores/outbox-store';
import { useNetworkStore } from '../stores/network-store';
import { isTransientNetworkError } from '../lib/network-error';
import { classifySendFailure, shouldQueueFailedSend } from '../lib/send-queue';
import {
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../lib/compose-login';
@@ -2515,9 +2519,48 @@ export default function ComposeScreen({ route, navigation }: Props) {
// notification tap to switch logins.
if (refuseSendAfterSwitch()) return;
// Offline, or the connection fails on the way: the message waits in the
// Outbox and goes out once the app is back online (parity 04/09/10
// "offline send"). The Outbox takes over the autosaved draft, which it
// removes once the message is sent.
let queuedOutgoing: OutgoingEmail | null = null;
const queueForLater = (outgoing: OutgoingEmail, maybeSent: boolean): boolean => {
if (!primaryIdentity || !sentMailbox || loginMoved()) return false;
const outbox = useOutboxStore.getState();
// Only into the queue of the login the message was written in.
if (outbox.activeAccountId !== ownerLoginRef.current) return false;
const queuedId = outbox.enqueueSend({
outgoing,
identity: primaryIdentity,
fromEmail: outgoing.from[0]?.email ?? primaryIdentity.email,
sentMailboxId: sentMailbox.id,
draftsMailboxId: draftsMailbox?.id,
draftId: draftIdRef.current ?? undefined,
draftAccountId: draftIdRef.current ? draftAccount() : undefined,
requestDsn,
requireTls,
scheduledAt: scheduledAt?.toISOString(),
original: replyTo?.originalEmailId
? { emailId: replyTo.originalEmailId, accountId: replyTo.jmapAccountId, keyword: mode === 'forward' ? '$forwarded' : '$answered' }
: undefined,
}, { maybeSent });
if (!queuedId) return false;
draftIdRef.current = null;
lastSavedRef.current = null;
toast.info(
t('outbox.queued_title', 'Saved to the Outbox'),
{ message: t('outbox.queued_body', 'It goes out as soon as you are back online.') },
);
allowLeaveRef.current = true;
navigation.goBack();
return true;
};
setSending(true);
try {
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
queuedOutgoing = outgoing;
if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return;
// The account the submission is held in, for Undo (read before the
// request: a switch may land while it is out).
// A group or shared identity sends from its own account, and its Sent
@@ -2608,6 +2651,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
allowLeaveRef.current = true;
navigation.goBack();
} catch (e) {
// The connection failed: the request may have got through, so the
// Outbox looks for the message before it sends it again.
if (queuedOutgoing && shouldQueueFailedSend(classifySendFailure(e, isTransientNetworkError))
&& queueForLater(queuedOutgoing, true)) {
return;
}
if (e instanceof RequestTimeoutError) {
// The request may have reached the server; a blind retry would send
// the message twice (#702).
@@ -0,0 +1,196 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// The offline send queue in the outbox store: messages wait while offline,
// survive a restart, go out once, and an attempt that may have got through
// is sent again only through runQueuedSend's lookup (maybeSent).
const client = vi.hoisted(() => ({ served: 'user1@mail.example.org' as string | null }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
get isConnected() { return client.served !== null; },
get username() { return client.served ? client.served.split('@')[0] : null; },
get serverUrl() { return client.served ? `https://${client.served.split('@')[1]}` : null; },
},
}));
const patchKeywordsForEmails = vi.fn(async (..._a: any[]) => undefined);
const saveDraftVersion = vi.fn(async (..._a: any[]) => ({ id: 'draft-2', attachments: [] }));
vi.mock('../../api/email', () => ({
patchKeywordsForEmails: (...a: any[]) => patchKeywordsForEmails(...a),
setEmailMailboxes: vi.fn(),
destroyEmails: vi.fn(),
archiveEmails: vi.fn(),
unprefixMailboxId: (id: string) => id,
saveDraftVersion: (...a: any[]) => saveDraftVersion(...a),
}));
const runQueuedSend = vi.fn(async (..._a: any[]): Promise<any> => ({ kind: 'sent', result: { scheduled: false } }));
vi.mock('../../api/queued-send', () => ({
runQueuedSend: (...a: any[]) => runQueuedSend(...a),
}));
vi.mock('../email-store', () => ({
useEmailStore: { getState: () => ({ mailboxes: [{ id: 'sent', role: 'sent' }] }) },
}));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
vi.mock('../toast-store', () => ({ toast }));
vi.mock('../locale-store', () => ({
useLocaleStore: { getState: () => ({ t: (_k: string, fallback?: string) => fallback ?? _k }) },
}));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useOutboxStore } from '../outbox-store';
import { useNetworkStore } from '../network-store';
import type { QueuedSendPayload } from '../../lib/send-queue';
const ACCOUNT = 'user1@mail.example.org';
const SENDS_KEY = `webmail:outbox:v1:${ACCOUNT}:sends`;
const payload = (subject = 'Hello'): QueuedSendPayload => ({
outgoing: { from: [{ email: 'me@x.de' }], to: [{ email: 'you@x.de' }], subject, textBody: 'Hi', messageId: `${subject}@x.de` },
identity: { id: 'id-1', email: 'me@x.de', name: 'Me' } as QueuedSendPayload['identity'],
fromEmail: 'me@x.de',
sentMailboxId: 'sent',
draftsMailboxId: 'drafts',
draftId: 'draft-1',
original: { emailId: 'orig-1', keyword: '$answered' },
});
const named = (name: string, message = name) => Object.assign(new Error(message), { name });
const flushed = () => new Promise((r) => setTimeout(r, 0));
beforeEach(async () => {
vi.clearAllMocks();
client.served = ACCOUNT;
useNetworkStore.setState({ online: false, connected: false });
await useOutboxStore.getState().setAccount(null);
await useOutboxStore.getState().setAccount(ACCOUNT);
await useOutboxStore.getState().clear();
await useOutboxStore.getState().resume(ACCOUNT);
});
describe('offline send queue', () => {
it('keeps a message while offline and sends it once when the connection is back', async () => {
useOutboxStore.getState().enqueueSend(payload());
await useOutboxStore.getState().flush();
expect(runQueuedSend).not.toHaveBeenCalled();
expect(useOutboxStore.getState().sends).toHaveLength(1);
useNetworkStore.setState({ online: true, connected: true });
await useOutboxStore.getState().flush();
expect(runQueuedSend).toHaveBeenCalledTimes(1);
// Marked before the request left.
expect(runQueuedSend.mock.calls[0][0]).toMatchObject({ status: 'sending', maybeSent: true });
expect(useOutboxStore.getState().sends).toEqual([]);
expect(JSON.parse((await AsyncStorage.getItem(SENDS_KEY))!)).toEqual([]);
await useOutboxStore.getState().flush();
expect(runQueuedSend).toHaveBeenCalledTimes(1);
await flushed();
// The original gets its reply arrow.
expect(patchKeywordsForEmails).toHaveBeenCalledWith(['orig-1'], { $answered: true }, undefined);
});
it('survives a restart of the app', async () => {
useOutboxStore.getState().enqueueSend(payload('kept'));
await flushed();
await useOutboxStore.getState().setAccount(null);
expect(useOutboxStore.getState().sends).toEqual([]);
await useOutboxStore.getState().setAccount(ACCOUNT);
expect(useOutboxStore.getState().sends.map((s) => s.payload.outgoing.subject)).toEqual(['kept']);
});
it('marks a message whose attempt the app was killed in, so the next attempt looks for it first', async () => {
const id = useOutboxStore.getState().enqueueSend(payload())!;
await flushed();
// What the store persisted right before the request left.
const stored = JSON.parse((await AsyncStorage.getItem(SENDS_KEY))!);
stored[0] = { ...stored[0], status: 'sending', maybeSent: true };
await AsyncStorage.setItem(SENDS_KEY, JSON.stringify(stored));
await useOutboxStore.getState().setAccount(null);
await useOutboxStore.getState().setAccount(ACCOUNT);
expect(useOutboxStore.getState().sends[0]).toMatchObject({ id, status: 'queued', maybeSent: true });
});
it('keeps a message queued and marked when the connection breaks mid-send', async () => {
useNetworkStore.setState({ online: true, connected: true });
runQueuedSend.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
useOutboxStore.getState().enqueueSend(payload());
await flushed();
await flushed();
expect(useOutboxStore.getState().sends[0]).toMatchObject({ status: 'queued', maybeSent: true, attempts: 0 });
// The retry hands the mark on: runQueuedSend looks for the message first.
useOutboxStore.setState({ flushing: false });
await useOutboxStore.getState().flush();
expect(runQueuedSend).toHaveBeenCalledTimes(2);
expect(useOutboxStore.getState().sends).toEqual([]);
});
it('never sends one message twice from overlapping flushes', async () => {
useOutboxStore.getState().enqueueSend(payload());
let release!: () => void;
runQueuedSend.mockImplementationOnce(() => new Promise((r) => { release = () => r({ kind: 'sent', result: {} }); }));
useNetworkStore.setState({ online: true, connected: true });
const first = useOutboxStore.getState().flush();
await flushed();
const second = useOutboxStore.getState().flush();
release();
await Promise.all([first, second]);
expect(runQueuedSend).toHaveBeenCalledTimes(1);
});
it('parks a refused message with its reason and leaves it until the user tries again', async () => {
useNetworkStore.setState({ online: true, connected: true });
runQueuedSend.mockRejectedValueOnce(new Error('Invalid envelope'));
const id = useOutboxStore.getState().enqueueSend(payload())!;
await flushed();
await flushed();
expect(useOutboxStore.getState().sends[0]).toMatchObject({ status: 'failed', lastError: 'Invalid envelope', maybeSent: false });
await useOutboxStore.getState().flush();
expect(runQueuedSend).toHaveBeenCalledTimes(1);
await useOutboxStore.getState().retrySend(id);
expect(runQueuedSend).toHaveBeenCalledTimes(2);
expect(useOutboxStore.getState().sends).toEqual([]);
});
it('stops after an expired session without failing the message', async () => {
useNetworkStore.setState({ online: true, connected: true });
runQueuedSend.mockRejectedValueOnce(named('AuthenticationError', 'Session expired'));
useOutboxStore.getState().enqueueSend(payload());
await flushed();
await flushed();
expect(useOutboxStore.getState().sends[0]).toMatchObject({ status: 'queued', maybeSent: false });
expect(useOutboxStore.getState().paused).toBe(true);
});
it('lets the user discard a waiting message, but not one on its way', async () => {
const id = useOutboxStore.getState().enqueueSend(payload())!;
const sending = { ...useOutboxStore.getState().sends[0], status: 'sending' as const };
useOutboxStore.setState({ sends: [sending] });
expect(await useOutboxStore.getState().cancelSend(id)).toBe(false);
useOutboxStore.setState({ sends: [{ ...sending, status: 'queued' }] });
expect(await useOutboxStore.getState().cancelSend(id)).toBe(true);
expect(useOutboxStore.getState().sends).toEqual([]);
});
it('moves a message to Drafts in place of its autosaved version, and keeps it when that fails', async () => {
const id = useOutboxStore.getState().enqueueSend(payload('to-drafts'))!;
expect(await useOutboxStore.getState().cancelSend(id, { keepAsDraft: true })).toBe(true);
expect(saveDraftVersion).toHaveBeenCalledWith(
expect.objectContaining({ subject: 'to-drafts' }), 'drafts', 'draft-1', undefined, undefined,
);
expect(useOutboxStore.getState().sends).toEqual([]);
saveDraftVersion.mockRejectedValueOnce(named('NetworkError', 'Network request failed'));
const again = useOutboxStore.getState().enqueueSend(payload('stays'))!;
expect(await useOutboxStore.getState().cancelSend(again, { keepAsDraft: true })).toBe(false);
expect(useOutboxStore.getState().sends[0]).toMatchObject({ id: again, status: 'failed' });
});
it('drops the queued messages with the rest when the server behind the account changed', async () => {
useOutboxStore.getState().enqueueSend(payload());
expect(await useOutboxStore.getState().clearAccount(ACCOUNT)).toBe(1);
expect(await AsyncStorage.getItem(SENDS_KEY)).toBeNull();
});
});
+228 -10
View File
@@ -19,6 +19,11 @@
// replayed by flush() once connectivity returns, and not before the auth store
// has resumed the account (see `resumed`). Storage is keyed per account,
// mirroring offline-cache-store, so queues never leak between logins.
//
// Messages sent while offline wait here too (`sends`, see lib/send-queue.ts):
// the same flush sends them after the mutation ops, once each, looking for
// a copy of the message first whenever an earlier attempt may have got
// through.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -32,9 +37,14 @@ import {
} from '../api/email';
import type { KeywordPatch } from '../lib/keyword-patch';
import type { ArchiveMode } from './settings-store';
import {
afterFailure, canCancel, classifySendFailure, newQueuedSend, restoreAfterRestart, startAttempt,
type QueuedSend, type QueuedSendPayload,
} from '../lib/send-queue';
const KEY_PREFIX = 'webmail:outbox:v1:';
const FAILED_SUFFIX = ':failed';
const SENDS_SUFFIX = ':sends';
// Give up on an op that the server keeps rejecting (a non-transient failure)
// after this many attempts so one poison entry can't wedge the whole queue.
const MAX_ATTEMPTS = 5;
@@ -86,6 +96,12 @@ interface OutboxState {
* op lands here so the optimistic edit is reverted.
*/
failed: OutboxEntry[];
/**
* Messages sent while offline, oldest first: waiting, on their way, or
* failed (kept until the user retries, moves them to Drafts or discards
* them).
*/
sends: QueuedSend[];
hydrated: boolean;
flushing: boolean;
/** Set after an authentication failure; cleared by setAccount()/resume(). */
@@ -120,6 +136,20 @@ interface OutboxState {
flush: () => Promise<void>;
/** Move every failed op back into the queue and flush. */
retryFailed: () => Promise<void>;
/**
* Queue a message to send once online. `maybeSent`: an attempt already
* went out without an answer. Returns the entry id, or null with no
* account attached.
*/
enqueueSend: (payload: QueuedSendPayload, opts?: { maybeSent?: boolean }) => string | null;
/** Try a failed message again. */
retrySend: (id: string) => Promise<void>;
/**
* Take a message out of the queue, not while it is on its way. With
* `keepAsDraft` it is saved to Drafts first (needs the server); it stays
* queued, as failed, when that fails. Resolves to whether it went.
*/
cancelSend: (id: string, opts?: { keepAsDraft?: boolean }) => Promise<boolean>;
discardFailed: () => void;
clear: () => Promise<void>;
/**
@@ -136,6 +166,23 @@ function persist(accountId: string, entries: OutboxEntry[]): void {
});
}
function persistSends(accountId: string, sends: QueuedSend[]): void {
void AsyncStorage.setItem(storageKey(accountId) + SENDS_SUFFIX, JSON.stringify(sends)).catch((err) => {
console.warn('[outbox] persist sends failed', err);
});
}
async function loadSends(accountId: string): Promise<QueuedSend[]> {
try {
const raw = await AsyncStorage.getItem(storageKey(accountId) + SENDS_SUFFIX);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? restoreAfterRestart(parsed as QueuedSend[]) : [];
} catch (err) {
console.warn('[outbox] hydrate sends failed', err);
return [];
}
}
function persistFailed(accountId: string, failed: OutboxEntry[]): void {
void AsyncStorage.setItem(storageKey(accountId) + FAILED_SUFFIX, JSON.stringify(failed)).catch((err) => {
console.warn('[outbox] persist failed-list failed', err);
@@ -264,7 +311,9 @@ function loadBucket(accountId: string): Promise<void> {
}
const epoch = ++loadEpoch;
const promise = (async () => {
const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]);
const [entries, failed, sends] = await Promise.all([
load(accountId), load(accountId, FAILED_SUFFIX), loadSends(accountId),
]);
const store = useOutboxStore.getState();
if (epoch !== loadEpoch || store.activeAccountId !== accountId) return;
// Ops the user made while the bucket was loading go after the stored
@@ -272,8 +321,11 @@ function loadBucket(accountId: string): Promise<void> {
// replaced it with only the new ops.
const early = store.entries;
const merged = early.length > 0 ? [...entries, ...early] : entries;
useOutboxStore.setState({ entries: merged, failed, hydrated: true });
const earlySends = store.sends;
const mergedSends = earlySends.length > 0 ? [...sends, ...earlySends] : sends;
useOutboxStore.setState({ entries: merged, failed, sends: mergedSends, hydrated: true });
if (early.length > 0) persist(accountId, merged);
if (earlySends.length > 0) persistSends(accountId, mergedSends);
})().finally(() => {
if (bucketLoad?.promise === promise) bucketLoad = null;
});
@@ -285,6 +337,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
activeAccountId: null,
entries: [],
failed: [],
sends: [],
hydrated: false,
flushing: false,
paused: false,
@@ -301,7 +354,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
retryAttempt = 0;
// Each attach reads the bucket afresh; a read from before is dropped.
loadEpoch += 1;
set({ activeAccountId: accountId, entries: [], failed: [], hydrated: false, paused: false, resumed: false });
set({ activeAccountId: accountId, entries: [], failed: [], sends: [], hydrated: false, paused: false, resumed: false });
if (accountId) {
await loadBucket(accountId);
} else {
@@ -386,7 +439,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
if (get().flushing || get().paused || !get().resumed || !get().hydrated) return;
const accountId = get().activeAccountId;
if (!accountId) return;
if (get().entries.length === 0) return;
if (get().entries.length === 0 && !hasWaitingSends()) return;
// Need a live client that's actually serving this account, and a network.
if (!clientServes(accountId)) return;
if (!useNetworkStore.getState().online) return;
@@ -446,10 +499,16 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
}
}
}
// Queued messages go after the mutation ops, and only while the
// connection holds.
if (!brokeTransient && !get().paused && hasWaitingSends()) {
const result = await flushSends(accountId);
if (result === 'transient') brokeTransient = true;
}
} finally {
set({ flushing: false });
}
if (brokeTransient && get().entries.length > 0) scheduleRetry();
if (brokeTransient && (get().entries.length > 0 || hasWaitingSends())) scheduleRetry();
else if (changedInFlight) void get().flush();
if (dropped) void refreshAfterDrop();
},
@@ -469,6 +528,56 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
await get().flush();
},
enqueueSend: (payload, opts) => {
const accountId = get().activeAccountId;
if (!accountId) {
console.warn('[outbox] enqueueSend with no active account');
return null;
}
const send = newQueuedSend(generateUUID(), payload, Date.now(), !!opts?.maybeSent);
const sends = [...get().sends, send];
set({ sends });
persistSends(accountId, sends);
void get().flush();
return send.id;
},
retrySend: async (id) => {
const accountId = get().activeAccountId;
const send = get().sends.find((s) => s.id === id);
if (!accountId || !send || send.status !== 'failed') return;
// The attempts start over; an unconfirmed one keeps its mark, so the
// message is looked for first.
updateSend(accountId, { ...send, status: 'queued', attempts: 0, lastError: undefined });
set({ paused: false });
await get().flush();
},
cancelSend: async (id, opts) => {
const accountId = get().activeAccountId;
const send = get().sends.find((s) => s.id === id);
if (!accountId || !send || !canCancel(send)) return false;
// Out of the queue first, so a flush can't send it while it is saved.
removeSend(accountId, id);
if (!opts?.keepAsDraft) return true;
const p = send.payload;
try {
if (!p.draftsMailboxId) throw new Error('No Drafts folder');
const { saveDraftVersion } = await import('../api/email');
await saveDraftVersion(p.outgoing, p.draftsMailboxId, p.draftId, undefined, p.draftAccountId);
return true;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (get().activeAccountId === accountId) {
const sends = [...get().sends, { ...send, status: 'failed' as const, lastError: message }]
.sort((a, b) => a.createdAt - b.createdAt);
set({ sends });
persistSends(accountId, sends);
}
return false;
}
},
discardFailed: () => {
const accountId = get().activeAccountId;
set({ failed: [] });
@@ -478,29 +587,33 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
clear: async () => {
const accountId = get().activeAccountId;
clearRetry();
set({ entries: [], failed: [] });
set({ entries: [], failed: [], sends: [] });
if (accountId) {
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined);
}
},
clearAccount: async (accountId) => {
const state = get();
const loaded = state.activeAccountId === accountId && state.hydrated;
let dropped = loaded ? state.entries.length + state.failed.length : 0;
let dropped = loaded ? state.entries.length + state.failed.length + state.sends.length : 0;
if (state.activeAccountId === accountId) {
clearRetry();
// A read of the bucket still under way must not bring the ops back.
loadEpoch += 1;
set({ entries: [], failed: [], hydrated: true });
set({ entries: [], failed: [], sends: [], hydrated: true });
}
if (!loaded) {
const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]);
dropped = entries.length + failed.length;
const [entries, failed, sends] = await Promise.all([
load(accountId), load(accountId, FAILED_SUFFIX), loadSends(accountId),
]);
dropped = entries.length + failed.length + sends.length;
}
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined);
return dropped;
},
}));
@@ -523,6 +636,111 @@ function removeEntry(accountId: string, id: string, sent?: OutboxOp): boolean {
return true;
}
// ── Queued messages ─────────────────────────────────────────────────────────
function hasWaitingSends(): boolean {
return useOutboxStore.getState().sends.some((s) => s.status === 'queued');
}
function updateSend(accountId: string, next: QueuedSend): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;
if (!store.sends.some((s) => s.id === next.id)) return;
const sends = store.sends.map((s) => (s.id === next.id ? next : s));
useOutboxStore.setState({ sends });
persistSends(accountId, sends);
}
function removeSend(accountId: string, id: string): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;
const sends = store.sends.filter((s) => s.id !== id);
useOutboxStore.setState({ sends });
persistSends(accountId, sends);
}
/**
* Send the waiting messages, oldest first. 'transient' when the connection
* broke (or an answer never came), so the flush retries with back-off.
*/
async function flushSends(accountId: string): Promise<'done' | 'transient'> {
const waiting = useOutboxStore.getState().sends
.filter((s) => s.status === 'queued')
.sort((a, b) => a.createdAt - b.createdAt);
if (waiting.length === 0) return 'done';
const [{ runQueuedSend }, { useEmailStore }] = await Promise.all([
import('../api/queued-send'),
import('./email-store'),
]);
for (const snapshot of waiting) {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId || !store.resumed || store.paused) break;
if (!useNetworkStore.getState().online || !clientServes(accountId)) break;
const current = store.sends.find((s) => s.id === snapshot.id);
if (!current || current.status !== 'queued') continue; // cancelled or retried meanwhile
// Marked before the request leaves: from here on it may reach the server.
const started = startAttempt(current);
updateSend(accountId, started);
try {
const outcome = await runQueuedSend(started, useEmailStore.getState().mailboxes);
removeSend(accountId, current.id);
retryAttempt = 0;
void afterQueuedSend(started, outcome);
} catch (err) {
const kind = classifySendFailure(err, isTransientNetworkError);
const message = err instanceof Error ? err.message : String(err);
updateSend(accountId, afterFailure(started, kind, message));
if (kind === 'auth') {
useOutboxStore.setState({ paused: true });
break;
}
if (kind === 'offline' || kind === 'unconfirmed') return 'transient';
// Refused: this one waits for the user; the others go on.
void notifyQueuedSendFailed(started, message);
}
}
return 'done';
}
type QueuedSendOutcomeLike = { kind: 'sent'; result: { refusedRecipients?: Array<{ email: string }> } } | { kind: 'alreadySent' };
// Flag the original and say the message went. Lazy imports: these pull in
// the UI stores, which the queue itself doesn't need.
async function afterQueuedSend(send: QueuedSend, outcome: QueuedSendOutcomeLike): Promise<void> {
const original = send.payload.original;
if (original) {
patchKeywordsForEmails([original.emailId], { [original.keyword]: true }, original.accountId).catch(() => undefined);
}
try {
const [{ toast }, { useLocaleStore }, { partlyRefusedWarning }] = await Promise.all([
import('./toast-store'), import('./locale-store'), import('../lib/send-failure-message'),
]);
const t = useLocaleStore.getState().t;
const refused = outcome.kind === 'sent' ? outcome.result.refusedRecipients ?? [] : [];
if (refused.length > 0) {
const { title, message } = partlyRefusedWarning(refused as Parameters<typeof partlyRefusedWarning>[0], t);
toast.warning(title, { message, duration: 10_000 });
} else {
toast.success(t('outbox.sent', 'Message from the Outbox sent'), { message: send.payload.outgoing.subject || undefined });
}
} catch (err) {
console.warn('[outbox] could not report a queued send', err);
}
}
async function notifyQueuedSendFailed(send: QueuedSend, message: string): Promise<void> {
try {
const [{ toast }, { useLocaleStore }] = await Promise.all([import('./toast-store'), import('./locale-store')]);
const t = useLocaleStore.getState().t;
toast.error(t('outbox.send_failed', 'A message in the Outbox could not be sent'), {
message: [send.payload.outgoing.subject, message].filter(Boolean).join(': '),
});
} catch (err) {
console.warn('[outbox] could not report a failed queued send', err);
}
}
function moveToFailed(accountId: string, id: string, err: unknown): void {
const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return;