Merge branch 'cu-compx' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 15:42:13 +01:00
35 changed files with 3640 additions and 214 deletions
+22
View File
@@ -922,6 +922,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.", "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" "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": { "push": {
"ios": { "ios": {
"more_messages": "and {count} more", "more_messages": "and {count} more",
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// The composer's message priority (webmail #970) and the DSN / REQUIRETLS
// submission options (webmail submissionEnvelopeParameters): what the
// Email/set create and the EmailSubmission envelope carry.
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
learnHoldLimit: vi.fn(),
getSubmissionAccountIds: vi.fn(() => ['acc-1']),
hasDelayedSend: vi.fn(() => true),
hasAccountCapability: vi.fn(() => false),
getMaxCallsInRequest: vi.fn(() => 16),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
getAccountCapability: vi.fn(),
},
}));
import { jmapClient } from '../jmap-client';
import { rescheduleScheduledSend, saveDraftVersion, sendEmail } from '../email';
import { draftContextFromEmail } from '../../lib/draft-context';
import type { Email } from '../types';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
const OUTGOING = {
from: [{ email: 'me@example.com' }],
to: [{ email: 'you@example.com' }],
cc: [{ email: 'cc@example.com' }],
subject: 'Hello',
textBody: 'Hi',
};
const SENT = {
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
],
};
function sentCalls() {
const calls = mockRequest.mock.calls[0][0] as Array<[string, Record<string, any>, string]>;
const create = calls.find(([name]) => name === 'Email/set')![1].create.draft;
const submission = calls.find(([name]) => name === 'EmailSubmission/set')![1].create['sub-1'];
return { create, submission };
}
beforeEach(() => {
vi.clearAllMocks();
mockRequest.mockResolvedValue(SENT);
});
describe('message priority', () => {
it('writes X-Priority and Importance for a high-priority message', async () => {
await sendEmail({ ...OUTGOING, priority: 'high' }, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
const { create } = sentCalls();
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
expect(create['header:Importance:asText']).toBe('high');
});
it('writes the low values for a low-priority message', async () => {
await sendEmail({ ...OUTGOING, priority: 'low' }, 'id-1', 'sent');
const { create } = sentCalls();
expect(create['header:X-Priority:asText']).toBe('5 (Lowest)');
expect(create['header:Importance:asText']).toBe('low');
});
it('writes no priority header for a normal message', async () => {
await sendEmail({ ...OUTGOING, priority: 'normal' }, 'id-1', 'sent');
const { create } = sentCalls();
expect(Object.keys(create).some((k) => /Priority|Importance/.test(k))).toBe(false);
});
it('saves the priority with a draft, and a re-opened draft reads it back', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'd-1' } } }, '0'],
['Email/get', { list: [] }, '1'],
],
});
await saveDraftVersion({ ...OUTGOING, priority: 'high' }, 'drafts');
const create = (mockRequest.mock.calls[0][0] as any[])[0][1].create.draft;
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
const reopened = draftContextFromEmail({
id: 'd-1',
headers: [{ name: 'X-Priority', value: ' 1 (Highest)' }, { name: 'Importance', value: ' high' }],
} as unknown as Email);
expect(reopened.priority).toBe('high');
});
});
describe('delivery status notifications and REQUIRETLS', () => {
it('asks every recipient for a DSN with headers only', async () => {
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts', requestDsn: true });
const { submission } = sentCalls();
expect(submission.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { RET: 'HDRS' } },
rcptTo: [
{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
{ email: 'cc@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
],
});
});
it('puts REQUIRETLS next to the HOLDFOR of a held send', async () => {
await sendEmail(OUTGOING, 'id-1', 'sent', 30, { draftsMailboxId: 'drafts', requireTls: true });
const { submission } = sentCalls();
expect(submission.envelope.mailFrom.parameters).toEqual({ HOLDFOR: '30', REQUIRETLS: null });
expect(submission.envelope.rcptTo).toEqual([{ email: 'you@example.com' }, { email: 'cc@example.com' }]);
});
it('leaves the envelope to the server without options, hold or From override', async () => {
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
expect(sentCalls().submission.envelope).toBeUndefined();
});
});
describe('moving a held send', () => {
it('keeps its delivery notification and REQUIRETLS, and only those', async () => {
const set = (body: Record<string, unknown>) => ({ methodResponses: [['EmailSubmission/set', body, '0']] });
mockRequest.mockReset();
mockRequest
.mockResolvedValueOnce({
methodResponses: [
['EmailSubmission/get', {
list: [{
id: 'sub-1',
envelope: {
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '3600', RET: 'HDRS', REQUIRETLS: null, SIZE: '1234' } },
rcptTo: [{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY', ORCPT: 'x' } }],
},
}],
}, '0'],
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'you@example.com' }] }] }, '1'],
],
})
.mockResolvedValueOnce(set({ updated: { 'sub-1': null } }))
.mockResolvedValueOnce(set({ destroyed: ['sub-1'] }))
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-2', sendAt: '2026-11-01T08:00:00Z' } } }));
await rescheduleScheduledSend(
{ emailSubmissionId: 'sub-1', emailId: 'e-1', identityId: 'id-1', from: [{ email: 'me@example.com' }], to: [{ email: 'you@example.com' }] } as never,
7200,
);
const create = mockRequest.mock.calls
.map((c) => c[0][0])
.find(([method, args]: [string, { create?: object }]) => method === 'EmailSubmission/set' && args.create)[1];
expect(create.create.replacement.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { RET: 'HDRS', REQUIRETLS: null, HOLDFOR: '7200' } },
rcptTo: [{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } }],
});
});
});
@@ -0,0 +1,129 @@
/**
* Live checks of the composer's send options against a real Stalwart: the
* priority headers (webmail #970), the read-receipt request and the DSN /
* REQUIRETLS envelope parameters (webmail submissionEnvelopeParameters).
*
* 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-options.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 { cancelScheduledSend, destroyEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
import { getIdentities } from '../identity';
import { supportsSubmissionExtension } from '../../lib/submission-options';
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_STARTED = Date.now();
const RUN = RUN_STARTED.toString(36);
let singleton: JMAPClient;
live('live Stalwart: composer send options', () => {
let drafts = '';
let sent = '';
const tag = `bulwark-sendopts-${RUN}`;
const createdEmails: string[] = [];
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());
const boxes = await getMailboxes();
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
}, 60_000);
afterAll(async () => {
const held = await listScheduledEmails().catch(() => []);
for (const s of held.filter((h) => createdEmails.includes(h.emailId))) {
await cancelScheduledSend(s.emailSubmissionId).catch(() => undefined);
}
// Not a subject search: Stalwart answers that from a full-text index
// that lags. Every message since the run began is read and compared.
const res = await singleton.request([
['Email/query', { accountId: singleton.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'],
['Email/get', {
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'],
}, '1'],
]);
const matching = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }> ?? [])
.filter((e) => e.subject?.includes(tag)).map((e) => e.id);
const leftovers = [...new Set([...(matching), ...createdEmails])];
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
}, 60_000);
it('lists DSN and REQUIRETLS in the submission extensions', () => {
expect(supportsSubmissionExtension('DSN')).toBe(true);
expect(supportsSubmissionExtension('REQUIRETLS')).toBe(true);
});
it('files the priority and read-receipt headers with the sent message', async () => {
const [identity] = await getIdentities();
const result = await sendEmail(
{
from: [{ email: identity.email }], to: [{ email: identity.email }],
subject: `priority ${tag}`, textBody: 'live', priority: 'high', requestReadReceipt: true,
},
identity.id, sent, undefined, { draftsMailboxId: drafts, requestDsn: true },
);
createdEmails.push(result.emailId!);
expect(result.emailSubmissionId).toBeTruthy();
const res = await singleton.request([['Email/get', {
accountId: singleton.accountId,
ids: [result.emailId],
properties: ['mailboxIds', 'header:X-Priority:asText', 'header:Importance:asText', 'header:Disposition-Notification-To:asText'],
}, '0']]);
const [email] = res.methodResponses[0][1].list;
expect(email.mailboxIds[sent]).toBe(true);
expect(email['header:X-Priority:asText']?.trim()).toBe('1 (Highest)');
expect(email['header:Importance:asText']?.trim()).toBe('high');
expect(email['header:Disposition-Notification-To:asText']?.trim()).toBe(identity.email);
}, 60_000);
it('submits with RET/NOTIFY and REQUIRETLS on the envelope', async () => {
const [identity] = await getIdentities();
// Held, so the submission record (and its envelope) can be read back.
const result = await sendEmail(
{ from: [{ email: identity.email }], to: [{ email: identity.email }], subject: `dsn ${tag}`, textBody: 'live' },
identity.id, sent, 3600, { draftsMailboxId: drafts, requestDsn: true, requireTls: true },
);
createdEmails.push(result.emailId!);
const res = await singleton.request([['EmailSubmission/get', {
accountId: singleton.accountId, ids: [result.emailSubmissionId], properties: ['envelope', 'undoStatus'],
}, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
const [submission] = res.methodResponses[0][1].list;
expect(submission.undoStatus).toBe('pending');
expect(submission.envelope.mailFrom.email).toBe(identity.email);
expect(submission.envelope.mailFrom.parameters).toMatchObject({ RET: 'HDRS', HOLDFOR: '3600' });
expect('REQUIRETLS' in submission.envelope.mailFrom.parameters).toBe(true);
expect(submission.envelope.rcptTo).toEqual([
expect.objectContaining({ email: identity.email, parameters: expect.objectContaining({ NOTIFY: 'SUCCESS,FAILURE,DELAY' }) }),
]);
// Moved to another time, it keeps them.
const held = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!;
const moved = await rescheduleScheduledSend(held, 7200);
const after = await singleton.request([['EmailSubmission/get', {
accountId: singleton.accountId, ids: [moved.emailSubmissionId], properties: ['envelope'],
}, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
const [replacement] = after.methodResponses[0][1].list;
expect(replacement.envelope.mailFrom.parameters).toMatchObject({ RET: 'HDRS', HOLDFOR: '7200' });
expect('REQUIRETLS' in replacement.envelope.mailFrom.parameters).toBe(true);
expect(replacement.envelope.rcptTo[0].parameters).toMatchObject({ NOTIFY: 'SUCCESS,FAILURE,DELAY' });
await cancelScheduledSend(moved.emailSubmissionId!);
}, 60_000);
});
@@ -0,0 +1,116 @@
/**
* 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_STARTED = Date.now();
const RUN = RUN_STARTED.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 () => {
// Not a subject search: Stalwart answers that from a full-text index
// that lags. Every message since the run began is read and compared.
const res = await singleton.request([
['Email/query', { accountId: singleton.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'],
['Email/get', {
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'],
}, '1'],
]);
const matching = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }> ?? [])
.filter((e) => e.subject?.includes(tag)).map((e) => e.id);
const leftovers = matching;
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' }] },
],
});
});
});
+59 -9
View File
@@ -21,6 +21,8 @@ import { withInlineBodyParts } from '../lib/email-inline-parts';
import { stalwartStateWentBack } from './stalwart-state'; import { stalwartStateWentBack } from './stalwart-state';
import { isTransientNetworkError } from '../lib/network-error'; import { isTransientNetworkError } from '../lib/network-error';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers'; import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
import { messagePriorityHeaders, type MessagePriority } from '../lib/message-priority';
import { submissionEnvelopeParameters } from '../lib/submission-options';
const EMAIL_SUMMARY_PROPERTIES = [ const EMAIL_SUMMARY_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'id', 'threadId', 'mailboxIds', 'keywords', 'size',
@@ -1581,6 +1583,8 @@ export interface OutgoingEmail {
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent. // Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
messageId?: string; messageId?: string;
requestReadReceipt?: boolean; requestReadReceipt?: boolean;
/** Importance / X-Priority headers (webmail #970); normal writes none. */
priority?: MessagePriority;
/** /**
* SMTP MAIL FROM when it must differ from the header From - a catch-all * SMTP MAIL FROM when it must differ from the header From - a catch-all
* alias on an owned domain is sent through the identity's envelope while * alias on an owned domain is sent through the identity's envelope while
@@ -1691,6 +1695,9 @@ function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
emailCreate['header:Disposition-Notification-To:asText'] = from[0].email; emailCreate['header:Disposition-Notification-To:asText'] = from[0].email;
} }
// Drafts carry it too, so a re-opened draft reads its priority back.
Object.assign(emailCreate, messagePriorityHeaders(email.priority));
return emailCreate; return emailCreate;
} }
@@ -1740,6 +1747,13 @@ export interface SendEmailOptions {
draftAccountId?: string; draftAccountId?: string;
/** Submitting account (shared/group account); defaults to the primary. */ /** Submitting account (shared/group account); defaults to the primary. */
accountId?: string; accountId?: string;
/**
* Ask for delivery status notifications (RFC 3461 RET/NOTIFY); only when
* the account lists the DSN submission extension.
*/
requestDsn?: boolean;
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
requireTls?: boolean;
} }
export async function sendEmail( export async function sendEmail(
@@ -1766,18 +1780,26 @@ export async function sendEmail(
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId }; const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR // For a deferred send the envelope must be set explicitly so the HOLDFOR
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the // mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
// server derive mailFrom from the Identity, dropping our parameter). An // server derive mailFrom from the Identity, dropping our parameter). The
// explicit envelope sender (catch-all From override) needs it as well. // DSN and REQUIRETLS parameters ride the same way (webmail
// submissionEnvelopeParameters), and an explicit envelope sender
// (catch-all From override) needs it as well.
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0; const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
if (holdFor > 0 || email.envelopeMailFrom) { const parameters = submissionEnvelopeParameters(
{ requestDsn: opts?.requestDsn, requireTls: opts?.requireTls },
holdFor,
);
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) {
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])] const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim()) .map((r) => r.email.trim())
.filter(Boolean) .filter(Boolean)
.map((address) => ({ email: address })); .map((address) => (hasRcptToParams ? { email: address, parameters: parameters.rcptTo } : { email: address }));
const mailFrom: Record<string, unknown> = { const mailFrom: Record<string, unknown> = {
email: email.envelopeMailFrom || email.from[0]?.email, email: email.envelopeMailFrom || email.from[0]?.email,
}; };
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) }; if (hasMailFromParams) mailFrom.parameters = parameters.mailFrom;
submissionCreate.envelope = { mailFrom, rcptTo }; submissionCreate.envelope = { mailFrom, rcptTo };
} }
@@ -2267,6 +2289,22 @@ export class RescheduleLostError extends Error {
* and send time, and the recipients the server refused for the replacement * and send time, and the recipients the server refused for the replacement
* when it took the others. * when it took the others.
*/ */
/**
* The SMTP parameters among `names` that a held submission's envelope
* carries, or undefined with none. Only the ones the composer sets are kept,
* never parameters the server may have added.
*/
function keptSubmissionParameters(
parameters: Record<string, string | null> | null | undefined,
names: string[],
): Record<string, string | null> | undefined {
if (!parameters) return undefined;
const kept = Object.fromEntries(
Object.entries(parameters).filter(([key]) => names.includes(key.toUpperCase())),
);
return Object.keys(kept).length > 0 ? kept : undefined;
}
export async function rescheduleScheduledSend( export async function rescheduleScheduledSend(
scheduled: { scheduled: {
emailSubmissionId: string; emailSubmissionId: string;
@@ -2299,8 +2337,9 @@ export async function rescheduleScheduledSend(
], ],
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION], [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
); );
type EnvelopeAddress = { email?: string; parameters?: Record<string, string | null> | null };
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{ const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null; envelope?: { mailFrom?: EnvelopeAddress; rcptTo?: EnvelopeAddress[] } | null;
sendAt?: string; sendAt?: string;
identityId?: string; identityId?: string;
}> | undefined)?.[0]; }> | undefined)?.[0];
@@ -2313,7 +2352,15 @@ export async function rescheduleScheduledSend(
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined; const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])]; const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients); const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email); // The original's delivery notification (RET / NOTIFY) and REQUIRETLS
// ride along; dropping them turned a moved send into a plain one.
const keptMailFromParams = keptSubmissionParameters(envelope?.mailFrom?.parameters, ['RET', 'REQUIRETLS']);
const keptRcptParams = keptSubmissionParameters(
envelope?.rcptTo?.find((r) => r.parameters)?.parameters, ['NOTIFY'],
);
const rcptTo = source
.map((r) => ({ email: (r.email ?? '').trim(), ...(keptRcptParams ? { parameters: keptRcptParams } : {}) }))
.filter((r) => r.email);
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email; const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
// Without an answer the replacement is made without filing, and a message // Without an answer the replacement is made without filing, and a message
// that can't be put back stays where it is. // that can't be put back stays where it is.
@@ -2344,9 +2391,12 @@ export async function rescheduleScheduledSend(
emailId: scheduled.emailId, emailId: scheduled.emailId,
identityId, identityId,
envelope: { envelope: {
mailFrom: seconds === undefined mailFrom: seconds === undefined && !keptMailFromParams
? { email: mailFrom } ? { email: mailFrom }
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } }, : {
email: mailFrom,
parameters: { ...keptMailFromParams, ...(seconds === undefined ? {} : { HOLDFOR: String(seconds) }) },
},
rcptTo, rcptTo,
}, },
}, },
+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 React from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet, Pressable } from 'react-native';
import { CloudOff } from './icons'; import { CloudOff, Send } from './icons';
import { useNetworkStore } from '../stores/network-store'; import { useNetworkStore } from '../stores/network-store';
import { useOutboxStore } from '../stores/outbox-store'; import { useOutboxStore } from '../stores/outbox-store';
import { useLocaleStore } from '../stores/locale-store'; import { useLocaleStore } from '../stores/locale-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens'; import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors'; import { useColors } from '../theme/colors';
import { announceConnectivity } from '../lib/announce'; import { announceConnectivity } from '../lib/announce';
import { OutboxSheet } from './compose/OutboxSheet';
import { outboxHint } from '../lib/send-queue';
interface OfflineBannerProps { interface OfflineBannerProps {
/** Optional extra notice (e.g. "Showing cached mail"). */ /** Optional extra notice (e.g. "Showing cached mail"). */
@@ -20,26 +22,58 @@ export function OfflineBanner({ hint }: OfflineBannerProps) {
const t = useLocaleStore((s) => s.t); const t = useLocaleStore((s) => s.t);
// Pending offline mutations that will replay once we're back online. // Pending offline mutations that will replay once we're back online.
const queued = useOutboxStore((s) => s.entries.length); 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'); const offlineLabel = t('offline.banner', 'You are offline');
// VoiceOver hears the drop too (Android reads the live region below). // VoiceOver hears the drop too (Android reads the live region below).
React.useEffect(() => { React.useEffect(() => {
announceConnectivity(online, offlineLabel); announceConnectivity(online, offlineLabel);
}, [online, offlineLabel]); }, [online, offlineLabel]);
if (online) return null; const sendsHint = outboxHint(waitingSends, failedSends, t);
const queuedHint = queued > 0 if (online && !sendsHint) return null;
const queuedHint = !online && queued > 0
? t( ? t(
'offline.queued_changes', 'offline.queued_changes',
'{count, plural, one {# change} other {# changes}} will sync when you reconnect', '{count, plural, one {# change} other {# changes}} will sync when you reconnect',
{ count: queued }, { count: queued },
) )
: null; : null;
const suffix = [hint, queuedHint].filter(Boolean).join(' · '); const label = online ? sendsHint! : offlineLabel;
const label = 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 ( return (
<View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite"> <View style={styles.bar} accessibilityRole="alert" accessibilityLiveRegion="polite">
<CloudOff size={14} color={c.warningForeground} /> <CloudOff size={14} color={c.warningForeground} />
<Text style={styles.text} numberOfLines={1}> <Text style={styles.text} numberOfLines={1}>
{suffix ? `${label} — ${suffix}` : label} {text}
</Text> </Text>
</View> </View>
); );
@@ -60,5 +94,11 @@ function makeStyles(c: ThemePalette) {
color: c.warningForeground, color: c.warningForeground,
flex: 1, flex: 1,
}, },
barOutbox: {
backgroundColor: c.surfaceActive,
minHeight: 32,
},
textOutbox: { color: c.text },
textFailed: { color: c.error },
}); });
} }
+19 -2
View File
@@ -60,6 +60,11 @@ export interface RichTextEditorHandle {
setImageAlt(cid: string, alt: string): void; setImageAlt(cid: string, alt: string): void;
/** Insert an HTML fragment at the caret (template bodies, tables). */ /** Insert an HTML fragment at the caret (template bodies, tables). */
insertHtml(html: string): void; insertHtml(html: string): void;
/**
* Replace the "@..." being typed with a mention link to `href` reading
* "@label" (needs `mentions`).
*/
insertMention(label: string, href: string): void;
setHtml(html: string): void; setHtml(html: string): void;
/** /**
* Read the live editor DOM content. Resolves with the current innerHTML or * Read the live editor DOM content. Resolves with the current innerHTML or
@@ -94,12 +99,16 @@ interface Props {
collapseQuote?: boolean; collapseQuote?: boolean;
/** The quote toggle's accessible names. */ /** The quote toggle's accessible names. */
quoteToggleLabels?: { show: string; hide: string }; quoteToggleLabels?: { show: string; hide: string };
/** Report "@" mentions being typed (read once, on mount). */
mentions?: boolean;
/** What follows the "@" being typed, or null once there is none. */
onMentionQuery?: (query: string | null) => void;
} }
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor( const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
{ {
initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift, initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift,
collapseQuote = false, quoteToggleLabels, collapseQuote = false, quoteToggleLabels, mentions = false, onMentionQuery,
}, },
ref, ref,
) { ) {
@@ -113,6 +122,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const onBlurRef = React.useRef(onBlur); const onBlurRef = React.useRef(onBlur);
const onViewportShiftRef = React.useRef(onViewportShift); const onViewportShiftRef = React.useRef(onViewportShift);
onViewportShiftRef.current = onViewportShift; onViewportShiftRef.current = onViewportShift;
const onMentionQueryRef = React.useRef(onMentionQuery);
onMentionQueryRef.current = onMentionQuery;
onChangeRef.current = onChange; onChangeRef.current = onChange;
onSelectionRef.current = onSelectionChange; onSelectionRef.current = onSelectionChange;
onFocusRef.current = onFocus; onFocusRef.current = onFocus;
@@ -123,7 +134,7 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
const html = React.useMemo( const html = React.useMemo(
() => buildEditorHtml({ () => buildEditorHtml({
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android', initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
collapseQuote, quoteToggleLabels, collapseQuote, quoteToggleLabels, mentions,
}), }),
// c reference changes per theme switch but we don't want to lose user // c reference changes per theme switch but we don't want to lose user
// edits on a theme change. Bake in the initial colors only. // edits on a theme change. Bake in the initial colors only.
@@ -202,6 +213,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
insertHtml: (html) => { insertHtml: (html) => {
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`); call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`);
}, },
insertMention: (label, href) => {
call(`window.__rne && window.__rne.insertMention(${JSON.stringify(label)}, ${JSON.stringify(href)})`);
},
setHtml: (next) => { setHtml: (next) => {
call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`); call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`);
}, },
@@ -276,6 +290,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
case 'blur': case 'blur':
onBlurRef.current?.(); onBlurRef.current?.();
break; break;
case 'mention':
onMentionQueryRef.current?.(typeof data.payload === 'string' ? data.payload : null);
break;
case 'viewportShift': { case 'viewportShift': {
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload); const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy); if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
@@ -0,0 +1,165 @@
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { Check } from '../icons';
import ToggleSwitch from '../ToggleSwitch';
import { useColors } from '../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
export interface ComposerOptions {
requestReadReceipt: boolean;
requestDsn: boolean;
requireTls: boolean;
priority: MessagePriority;
}
/**
* How many of the options differ from a plain message, for the badge on the
* toolbar button (webmail composer options menu `activeCount`).
*/
export function activeComposerOptionCount(options: ComposerOptions): number {
return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal']
.filter(Boolean).length;
}
/**
* The composer's per-message options (webmail email-composer.tsx options
* menu): read receipt, delivery status notification and REQUIRETLS - the
* last two only when the account's submission extensions list them - and
* the message priority.
*/
export function ComposerOptionsSheet({
visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose,
}: {
visible: boolean;
options: ComposerOptions;
dsnAvailable: boolean;
requireTlsAvailable: boolean;
onChange: (next: Partial<ComposerOptions>) => void;
onClose: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const bottomInset = useSheetBottomInset(visible);
const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls') => (
<Pressable
style={styles.row}
onPress={() => onChange({ [key]: !value })}
accessibilityRole="switch"
accessibilityLabel={label}
accessibilityHint={hint}
accessibilityState={{ checked: value }}
>
<View style={styles.rowText}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowHint}>{hint}</Text>
</View>
<ToggleSwitch value={value} onValueChange={(v) => onChange({ [key]: v })} accessibilityLabel={label} />
</Pressable>
);
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('email_composer.options', 'Options')}</Text>
{toggleRow(
t('email_composer.option_read_receipt', 'Request read receipt'),
t('email_composer.option_read_receipt_hint', 'Ask the recipient to confirm they opened it'),
options.requestReadReceipt,
'requestReadReceipt',
)}
{dsnAvailable && toggleRow(
t('email_composer.option_dsn', 'Delivery notification'),
t('email_composer.option_dsn_hint', 'Get a report when the server delivers it'),
options.requestDsn,
'requestDsn',
)}
{requireTlsAvailable && toggleRow(
t('email_composer.option_require_tls', 'Require encrypted delivery'),
t('email_composer.option_require_tls_hint', 'Only deliver over TLS, otherwise bounce'),
options.requireTls,
'requireTls',
)}
<View style={styles.separator} />
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
{MESSAGE_PRIORITIES.map((level) => {
const selected = options.priority === level;
const label = level === 'high'
? t('email_composer.option_priority_high', 'High')
: level === 'low'
? t('email_composer.option_priority_low', 'Low')
: t('email_composer.option_priority_normal', 'Normal');
return (
<Pressable
key={level}
style={styles.row}
onPress={() => onChange({ priority: level })}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={{ checked: selected, selected }}
testID={`composer-priority-${level}`}
>
<Text style={[styles.rowLabel, styles.rowText]}>{label}</Text>
{selected && <Check size={18} color={c.primary} />}
</Pressable>
);
})}
</View>
<Pressable style={styles.done} onPress={onClose} accessibilityRole="button">
<Text style={styles.doneText}>{t('common.done', 'Done')}</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: 420,
backgroundColor: c.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
padding: spacing.lg,
gap: spacing.xs,
},
title: { ...typography.h3, color: c.text, marginBottom: spacing.xs },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
minHeight: 48,
paddingVertical: spacing.xs,
},
rowText: { flex: 1 },
rowLabel: { ...typography.body, color: c.text },
rowHint: { ...typography.caption, color: c.textMuted },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
groupLabel: { ...typography.captionMedium, color: c.textSecondary },
done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs },
doneText: { ...typography.bodyMedium, color: c.primary },
});
}
@@ -0,0 +1,104 @@
import React from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useColors } from '../../theme/colors';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { getEmailInitials } from '../../lib/avatar-utils';
import type { MentionCandidate } from '../../lib/recipient-mentions';
/** Rows of the list above the keyboard; a short window gets fewer. */
export function mentionListMaxHeight(shortWindow: boolean): number {
return (shortWindow ? 2 : 4) * ROW_HEIGHT;
}
const ROW_HEIGHT = 48;
/**
* Who an "@" in the body can name (webmail RecipientMentionList): the
* recipients first, then contacts, who picking adds to To. Sits between the
* body and the format bar, above the keyboard.
*/
export function MentionSuggestionList({
items, onPick, shortWindow,
}: {
items: MentionCandidate[];
onPick: (item: MentionCandidate) => void;
shortWindow: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
if (items.length === 0) return null;
return (
<View
style={[styles.box, { maxHeight: mentionListMaxHeight(shortWindow) }]}
accessibilityLabel={t('email_composer.mention_recipients', 'Recipients')}
>
<ScrollView keyboardShouldPersistTaps="always" nestedScrollEnabled>
{items.map((item) => (
<Pressable
key={item.email}
onPress={() => onPick(item)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
accessibilityRole="button"
accessibilityLabel={`@${item.label}, ${item.name && item.name !== item.label ? `${item.name}, ` : ''}${item.email}`}
>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{getEmailInitials((item.name ?? '').trim(), item.email)}</Text>
</View>
{/* What goes in comes first: for "Nagy János" that may be the
surname, which the full name alone would not show. */}
<Text style={styles.label} numberOfLines={1}>@{item.label}</Text>
<Text style={styles.detail} numberOfLines={1}>
{item.name && item.name !== item.label ? `${item.name} · ${item.email}` : item.email}
</Text>
{/* Picking a contact also adds them as a recipient; say so first. */}
{item.addsTo && (
<Text style={styles.addsTo}>
+ {item.addsTo === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.to', 'To')}
</Text>
)}
</Pressable>
))}
</ScrollView>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
box: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: c.borderLight,
backgroundColor: c.card,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: ROW_HEIGHT,
paddingHorizontal: spacing.lg,
},
rowPressed: { backgroundColor: c.surfaceHover },
avatar: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: { ...typography.captionMedium, color: c.primary },
label: { ...typography.bodyMedium, color: c.text, flexShrink: 0, maxWidth: '45%' },
detail: { ...typography.caption, color: c.textSecondary, flex: 1 },
addsTo: {
...typography.caption,
color: c.textSecondary,
backgroundColor: c.surfaceActive,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
overflow: 'hidden',
},
});
}
+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 },
});
}
+159
View File
@@ -0,0 +1,159 @@
import React from 'react';
import { KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ArrowLeft } from '../icons';
import { SafeAreaModal } from '../SafeAreaModal';
import { useColors } from '../../theme/colors';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { useModalKeyboardPadding } from '../../lib/sheet-bottom-inset';
import type { RecipientSuggestion } from '../../stores/contacts-store';
import { RecipientChip, RecipientSuggestionList, type ChipRecipient } from './RecipientSuggestionList';
/**
* Full-screen recipient picker (U-069) for where the inline suggestions have
* no room: a phone in landscape with the keyboard up. The field sits at the
* top and the same suggestions fill the space down to the keyboard; Done or
* Back goes back to the composer with what was typed turned into chips.
*/
export function RecipientPicker<R extends ChipRecipient>({
visible, label, placeholder, recipients, isValid, input, onChangeInput, onSubmitInput,
suggestions, onPick, onSearchServer, searchingServer, onRemove, onDone,
}: {
visible: boolean;
/** "To", "Cc" or "Bcc". */
label: string;
placeholder: string;
recipients: R[];
isValid: (r: R) => boolean;
input: string;
onChangeInput: (text: string) => void;
/** Return in the field: turn the typed text into a chip. */
onSubmitInput: () => void;
suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void;
onSearchServer?: () => void;
searchingServer?: boolean;
onRemove: (index: number) => void;
onDone: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const keyboardPadding = useModalKeyboardPadding(visible);
const inputRef = React.useRef<TextInput>(null);
const showList = suggestions.length > 0 || (!!onSearchServer && !!input.trim());
return (
<SafeAreaModal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="fade"
onRequestClose={onDone}
onShow={() => inputRef.current?.focus()}
>
<SafeAreaView style={styles.screen} edges={['top', 'bottom', 'left', 'right']}>
<KeyboardAvoidingView
style={[styles.screen, { paddingBottom: keyboardPadding }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.header}>
<Pressable
onPress={onDone}
hitSlop={10}
style={styles.headerBtn}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<Text style={styles.title} numberOfLines={1} accessibilityRole="header">{label}</Text>
<Pressable onPress={onDone} hitSlop={10} style={styles.headerBtn} accessibilityRole="button">
<Text style={styles.done}>{t('common.done', 'Done')}</Text>
</Pressable>
</View>
<View style={styles.field}>
{recipients.map((r, i) => (
<RecipientChip
key={`${r.email}-${i}`}
recipient={r}
invalid={!isValid(r)}
onRemove={() => onRemove(i)}
/>
))}
<TextInput
ref={inputRef}
disableFullscreenUI
accessibilityLabel={label}
style={styles.input}
placeholder={placeholder}
placeholderTextColor={c.textMuted}
value={input}
onChangeText={onChangeInput}
returnKeyType="done"
onSubmitEditing={() => {
if (input.trim()) onSubmitInput();
else onDone();
}}
blurOnSubmit={false}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
importantForAutofill="no"
textContentType="none"
/>
</View>
{showList ? (
<RecipientSuggestionList
fill
suggestions={suggestions}
onPick={onPick}
onSearchServer={onSearchServer}
searchingServer={searchingServer}
/>
) : (
<View style={styles.empty} />
)}
</KeyboardAvoidingView>
</SafeAreaView>
</SafeAreaModal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
screen: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
headerBtn: { minHeight: 40, minWidth: 40, alignItems: 'center', justifyContent: 'center' },
title: { ...typography.h3, color: c.text, flex: 1 },
done: { ...typography.bodyMedium, color: c.primary },
field: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
input: {
flexGrow: 1,
minWidth: 140,
...typography.body,
color: c.text,
paddingVertical: 8,
},
empty: { flex: 1 },
});
}
@@ -0,0 +1,191 @@
import React from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { Search, Users, X } from '../icons';
import { useColors } from '../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { getEmailInitials } from '../../lib/avatar-utils';
import { SUGGESTION_ROW_HEIGHT } from '../../lib/recipient-picker';
import type { RecipientSuggestion } from '../../stores/contacts-store';
/** A recipient chip's data. Group chips carry their resolved members and no email. */
export interface ChipRecipient {
name?: string;
email: string;
group?: { members: Array<{ name?: string; email: string }> };
}
export function RecipientChip({
recipient, invalid, onRemove, onLongPress,
}: {
recipient: ChipRecipient;
invalid?: boolean;
onRemove: () => void;
onLongPress?: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const label = recipient.group
? t('email_composer.group_chip', '{name} ({count})', {
name: recipient.name || t('contacts.group', 'Group'),
count: recipient.group.members.length,
})
: recipient.name || recipient.email;
return (
<Pressable onLongPress={onLongPress} delayLongPress={350} style={[styles.chip, invalid && styles.chipInvalid]}>
{recipient.group && <Users size={12} color={c.textSecondary} />}
<Text style={[styles.chipText, invalid && styles.chipTextInvalid]} numberOfLines={1}>
{label}
</Text>
<Pressable
onPress={onRemove}
// A 12 px icon: the touch area reaches about 40 dp around it.
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
>
<X size={12} color={invalid ? c.error : c.textMuted} />
</Pressable>
</Pressable>
);
}
/**
* The contact suggestions for the address being typed. Inline it drops down
* under the field; `fill` makes it the body of the full-screen recipient
* picker, where it takes the room above the keyboard.
*/
export function RecipientSuggestionList({
suggestions, onPick, onPressIn, onSearchServer, searchingServer, fill = false,
}: {
suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void;
onPressIn?: () => void;
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
onSearchServer?: () => void;
searchingServer?: boolean;
fill?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<View style={fill ? styles.fillBox : styles.suggestionBox}>
<ScrollView
style={fill ? styles.fillScroll : styles.suggestionScroll}
keyboardShouldPersistTaps="always"
nestedScrollEnabled
>
{suggestions.map((s, i) => (
<Pressable
key={`${s.group?.id ?? s.email}-${i}`}
onPressIn={onPressIn}
onPress={() => onPick(s)}
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
>
<View style={styles.suggestionAvatar}>
{s.group
? <Users size={14} color={c.primary} />
: <Text style={styles.suggestionAvatarText}>{getEmailInitials(s.name.trim(), s.email)}</Text>}
</View>
<View style={styles.suggestionText}>
<Text style={styles.suggestionName} numberOfLines={1}>
{s.name || s.email}
</Text>
{s.group ? (
<Text style={styles.suggestionEmail} numberOfLines={1}>
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })}
</Text>
) : !!s.name && (
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
)}
</View>
</Pressable>
))}
{onSearchServer && (
<Pressable
onPressIn={onPressIn}
onPress={() => { if (!searchingServer) onSearchServer(); }}
disabled={searchingServer}
accessibilityRole="button"
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
style={({ pressed }) => [
styles.suggestionRow,
suggestions.length > 0 && styles.suggestionSearchRowDivided,
pressed && styles.suggestionRowPressed,
]}
>
<View style={styles.suggestionSearchIcon}>
{searchingServer
? <ActivityIndicator size="small" color={c.textSecondary} />
: <Search size={16} color={c.textSecondary} />}
</View>
<Text style={styles.suggestionSearchText} numberOfLines={1}>
{searchingServer
? t('email_composer.autocomplete_searching', 'Searching...')
: t('email_composer.autocomplete_search_server', 'Search the server')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.surfaceActive,
maxWidth: 220,
},
chipInvalid: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.error },
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
chipTextInvalid: { color: c.error },
suggestionBox: {
position: 'absolute',
top: '100%',
left: spacing.lg + 56 + spacing.md,
right: spacing.lg,
borderWidth: 1,
borderColor: c.borderLight,
borderRadius: radius.sm,
backgroundColor: c.card,
zIndex: 20,
elevation: 5,
overflow: 'hidden',
},
fillBox: { flex: 1, backgroundColor: c.background },
suggestionRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
minHeight: SUGGESTION_ROW_HEIGHT,
gap: spacing.sm,
},
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionScroll: { maxHeight: 320 },
fillScroll: { flex: 1 },
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
suggestionAvatar: {
width: 32, height: 32,
borderRadius: 16,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
suggestionText: { flex: 1 },
suggestionName: { ...typography.bodyMedium, color: c.text },
suggestionEmail: { ...typography.caption, color: c.textSecondary },
});
}
+5
View File
@@ -185,6 +185,11 @@ describe('htmlToPlainText links', () => {
it('drops the mailto: prefix when the text is the address', () => { it('drops the mailto: prefix when the text is the address', () => {
expect(htmlToPlainText('<a href="mailto:a@b.c">a@b.c</a>')).toBe('a@b.c'); expect(htmlToPlainText('<a href="mailto:a@b.c">a@b.c</a>')).toBe('a@b.c');
}); });
it('writes an @mention as the name alone, as the webmail does', () => {
expect(htmlToPlainText('<p>Hi <a href="mailto:max@x.de" class="mention">@Max</a>&nbsp;how</p>', { mailMarkup: true }))
.toBe('Hi @Max how');
});
}); });
describe('extractUserAuthoredText', () => { describe('extractUserAuthoredText', () => {
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// "@" mentions as wired in ComposeScreen (webmail email-composer.tsx
// mentionCandidates / findMentionContacts / addMentionedRecipient), checked
// on its source (the suite has no RN renderer).
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
describe('ComposeScreen mention wiring', () => {
it('turns mentions on in the message editor and follows its query', () => {
const at = source.search(/<RichTextEditor\s/);
const editor = source.slice(at, source.indexOf('/>', at));
expect(editor).toMatch(/\bmentions\b/);
expect(editor).toMatch(/onMentionQuery=\{setMentionQuery\}/);
});
it('offers To and Cc, never Bcc, and leaves out contacts already on the message', () => {
expect(source).toMatch(/buildMentionCandidates\(toRecipients, ccRecipients\)/);
const items = source.slice(source.indexOf('const mentionItems'), source.indexOf('const pickMention'));
expect(items).toMatch(/\.\.\.bccRecipients/);
expect(items).toMatch(/primaryIdentity\?\.bcc/);
expect(items).toMatch(/!onMessage\.has\(s\.email\.trim\(\)\.toLowerCase\(\)\)/);
expect(items).toMatch(/mentionSuggestions\(mentionCandidates, contacts, mentionQuery, 'to'\)/);
});
it('inserts the picked mention as a mailto link and adds a contact to To', () => {
const pick = source.slice(source.indexOf('const pickMention'), source.indexOf('const pickMention') + 800);
expect(pick).toMatch(/editorRef\.current\?\.insertMention\(candidate\.label, mentionHref\(candidate\.email\)\)/);
expect(pick).toMatch(/setToRecipients\(\(prev\) => addMentionedRecipient\(prev, candidate,/);
expect(source).toMatch(/<MentionSuggestionList items=\{mentionItems\} onPick=\{pickMention\}/);
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// The composer's options sheet (priority, DSN, REQUIRETLS) as wired in
// ComposeScreen, checked on its source (the suite has no RN renderer).
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
describe('ComposeScreen options wiring', () => {
it('opens the options sheet from the toolbar', () => {
expect(source).toMatch(/onPress=\{\(\) => setOptionsSheetOpen\(true\)\}/);
expect(source).toMatch(/<ComposerOptionsSheet[\s\S]{0,200}onChange=\{onComposerOptionsChange\}/);
});
it('sends the priority with the message and saves it with the draft', () => {
const build = source.slice(source.indexOf('const buildOutgoing'), source.indexOf('// Save one draft version'));
expect(build).toMatch(/priority: priority !== 'normal' \? priority : undefined,/);
// A priority change alone makes the message dirty.
expect(source).toMatch(/pri: s\.priority \?\? 'normal',/);
expect(source).toMatch(/const initialPriority: MessagePriority = draft\?\.priority \?\? 'normal';/);
});
it('asks for DSN and REQUIRETLS only where the submitting account offers them', () => {
const send = source.slice(source.indexOf('const performSendInner'));
expect(send).toMatch(/requestDsn: requestDsn && supportsSubmissionExtension\('DSN', target\.accountId\),/);
expect(send).toMatch(/requireTls: requireTls && supportsSubmissionExtension\('REQUIRETLS', target\.accountId\),/);
});
});
@@ -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\}/);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import { buildEditorHtml, MENTION_QUERY_SOURCE } from '../editor-html';
import { LIGHT_COLORS } from '../../theme/tokens';
// "@" mentions in the composer's editor page (webmail #1134): the page
// reports the "@..." being typed and swaps it for a mention link on request.
function extractScript(html: string): string {
const m = html.match(/<script(?: nonce="[0-9a-f]+")?>([\s\S]*?)<\/script>/);
return m![1];
}
const mentionQueryIn = new Function(`${MENTION_QUERY_SOURCE}; return mentionQueryIn;`)() as
(text: string) => { at: number; query: string } | null;
describe('mentionQueryIn', () => {
it('starts at an @ at the start of a word', () => {
expect(mentionQueryIn('@')).toEqual({ at: 0, query: '' });
expect(mentionQueryIn('Hi @ma')).toEqual({ at: 3, query: 'ma' });
expect(mentionQueryIn(`Hi${String.fromCharCode(160)}@jü`)).toEqual({ at: 3, query: 'jü' });
});
it('never starts inside an address, and ends at a space', () => {
expect(mentionQueryIn('info@example')).toBeNull();
expect(mentionQueryIn('Hi @max and')).toBeNull();
expect(mentionQueryIn('no at sign')).toBeNull();
});
});
interface FakeNode { nodeType: number; tagName?: string; data?: string; parentNode?: unknown; textContent?: string; attrs?: Record<string, string> }
function boot(mentions: boolean) {
const listeners: Record<string, Array<() => void>> = {};
const docListeners: Record<string, Array<() => void>> = {};
const posted: Array<{ type: string; payload: unknown }> = [];
const ops: string[] = [];
const editor = {
nodeType: 1,
tagName: 'DIV',
innerHTML: '',
get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); },
scrollHeight: 100,
querySelector: () => null,
setAttribute: () => undefined,
focus: () => undefined,
addEventListener: (name: string, fn: () => void) => { (listeners[name] ??= []).push(fn); },
};
const text: FakeNode = { nodeType: 3, data: 'Hi @ma', parentNode: editor };
let selection = { rangeCount: 1, isCollapsed: true, anchorNode: text as unknown, anchorOffset: 6 };
const range = {
setStart: (n: FakeNode, o: number) => ops.push(`start ${n === text ? 'text' : '?'} ${o}`),
setEnd: (n: FakeNode, o: number) => ops.push(`end ${n === text ? 'text' : '?'} ${o}`),
deleteContents: () => ops.push('delete'),
insertNode: (n: FakeNode) => ops.push(`insert ${n.tagName ?? JSON.stringify(n.data)}`),
setStartAfter: (n: FakeNode) => ops.push(`caret after ${n.tagName ?? JSON.stringify(n.data)}`),
collapse: () => undefined,
};
const created: FakeNode[] = [];
const document = {
activeElement: editor as unknown,
getElementById: () => editor,
addEventListener: (name: string, fn: () => void) => { (docListeners[name] ??= []).push(fn); },
queryCommandState: () => false,
createRange: () => range,
createElement: (tag: string) => {
const el: FakeNode = { nodeType: 1, tagName: tag.toUpperCase(), attrs: {} };
(el as unknown as { setAttribute: (k: string, v: string) => void }).setAttribute = (k, v) => { el.attrs![k] = v; };
created.push(el);
return el;
},
createTextNode: (data: string) => ({ nodeType: 3, data }),
};
const window: Record<string, unknown> = {
ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) },
addEventListener: () => undefined,
getSelection: () => ({ ...selection, removeAllRanges: () => ops.push('clear'), addRange: () => ops.push('add range') }),
};
const script = extractScript(buildEditorHtml({ initialHtml: '<p><br></p>', placeholder: '', c: LIGHT_COLORS, mentions }));
new Function('window', 'document', script)(window, document);
const mentionsPosted = () => posted.filter((m) => m.type === 'mention').map((m) => m.payload);
const input = () => listeners.input.forEach((fn) => fn());
const blur = () => listeners.blur.forEach((fn) => fn());
const setCaret = (data: string, offset = data.length) => {
text.data = data;
selection = { ...selection, anchorOffset: offset };
};
const bridge = window.__rne as { insertMention: (label: string, href: string) => void };
return { mentionsPosted, input, blur, setCaret, bridge, ops, created, text };
}
describe('editor page mentions', () => {
it('reports what follows the @ while it is typed, and null once it ends', () => {
const page = boot(true);
page.input();
page.setCaret('Hi @max');
page.input();
page.setCaret('Hi @max ');
page.input();
expect(page.mentionsPosted()).toEqual(['ma', 'max', null]);
});
it('closes the list when the editor loses focus', () => {
const page = boot(true);
page.input();
page.blur();
expect(page.mentionsPosted()).toEqual(['ma', null]);
});
it('replaces the typed @query with a mailto link and a space, caret after it', () => {
const page = boot(true);
page.bridge.insertMention('Max <b>', 'mailto:max@dornig.de');
expect(page.ops).toEqual([
'start text 3', 'end text 6', 'delete',
`insert ${JSON.stringify(String.fromCharCode(160))}`, 'insert A',
`caret after ${JSON.stringify(String.fromCharCode(160))}`, 'clear', 'add range',
]);
const [link] = page.created;
expect(link.attrs).toEqual({ href: 'mailto:max@dornig.de', class: 'mention' });
// The label goes in as text, never as markup.
expect(link.textContent).toBe('@Max <b>');
});
it('does nothing in editors that are not a message', () => {
const page = boot(false);
page.input();
page.bridge.insertMention('Max', 'mailto:max@dornig.de');
expect(page.mentionsPosted()).toEqual([]);
expect(page.ops).toEqual([]);
});
});
@@ -0,0 +1,197 @@
import { describe, expect, it } from 'vitest';
// Ported from the webmail's lib/__tests__/recipient-mentions.test.ts, plus
// the app's own helpers at the end.
import {
addMentionedRecipient, buildMentionCandidates, filterMentionCandidates, mentionHref, mentionLabel, mentionSuggestions,
} from '../recipient-mentions';
describe('mentionLabel', () => {
it('takes the first name from a display name', () => {
expect(mentionLabel('Max Mustermann', 'mm@dornig.de')).toBe('Max');
expect(mentionLabel('Mustermann, Max', 'mm@dornig.de')).toBe('Max');
expect(mentionLabel('Dr. Max Mustermann', 'mm@dornig.de')).toBe('Max');
expect(mentionLabel('Prof. Dr.-Ing. Max Mustermann', 'mm@dornig.de')).toBe('Max');
expect(mentionLabel('Jürgen Müller', 'jm@dornig.de')).toBe('Jürgen');
expect(mentionLabel("'Max Mustermann'", 'mm@dornig.de')).toBe('Max');
});
it('takes the first name from a firstname.lastname address when no name is known', () => {
expect(mentionLabel(undefined, 'max.mustermann@dornig.de')).toBe('Max');
expect(mentionLabel('', 'anna-lena.schmidt@dornig.de')).toBe('Anna-Lena');
expect(mentionLabel(undefined, 'max.mustermann+rechnungen@dornig.de')).toBe('Max');
expect(mentionLabel(undefined, 'juergen.mueller@dornig.de')).toBe('Juergen');
expect(mentionLabel(undefined, 'info@dornig.de')).toBe('Info');
});
it('lets a firstname.lastname address tell which word of the name is the first name', () => {
expect(mentionLabel('Mustermann Max', 'max.mustermann@dornig.de')).toBe('Max');
expect(mentionLabel('Müller Jürgen', 'juergen.mueller@dornig.de')).toBe('Jürgen');
expect(mentionLabel('Dornig GmbH - Max Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
// A one-part mailbox name confirms nothing: it may be the last name.
expect(mentionLabel('Max Mustermann', 'mustermann@dornig.de')).toBe('Max');
});
it('treats a display name that is an address or mailbox name like the address', () => {
expect(mentionLabel('max.mustermann@dornig.de', 'max.mustermann@dornig.de')).toBe('Max');
expect(mentionLabel('max.mustermann', 'mm@dornig.de')).toBe('Max');
});
it('keeps the whole mailbox name when the first name is a bare initial', () => {
expect(mentionLabel(undefined, 'm.mustermann@dornig.de')).toBe('m.mustermann');
expect(mentionLabel(undefined, 'm.mustermann+news@dornig.de')).toBe('m.mustermann');
expect(mentionLabel('M. Mustermann', 'm.mustermann@dornig.de')).toBe('m.mustermann');
// The address may know more than the display name.
expect(mentionLabel('M. Mustermann', 'max.mustermann@dornig.de')).toBe('Max');
});
it('stays fast on a crafted display name from an incoming message', () => {
// Uncapped, trimming this token takes seconds (quadratic backtracking).
const crafted = `a${'!'.repeat(100_000)}b`;
const started = performance.now();
expect(mentionLabel(crafted, 'max.mustermann@dornig.de')).toBe('Max');
expect(performance.now() - started).toBeLessThan(1000);
});
});
describe('buildMentionCandidates', () => {
it('offers To and Cc recipients in order, each address once', () => {
const candidates = buildMentionCandidates(
[{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'anna.schmidt@dornig.de' }],
[{ email: 'MAX.MUSTERMANN@dornig.de' }, { name: 'Eva Weber', email: 'eva.weber@dornig.de' }],
);
expect(candidates).toEqual([
{ label: 'Max', name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
{ label: 'Anna', name: undefined, email: 'anna.schmidt@dornig.de' },
{ label: 'Eva', name: 'Eva Weber', email: 'eva.weber@dornig.de' },
]);
});
it('expands contact groups into their members and skips entries without an address', () => {
const candidates = buildMentionCandidates(
[{
name: 'Team',
email: '',
group: { members: [{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }, { email: 'eva.weber@dornig.de' }] },
}],
[{ email: 'not-an-address' }],
);
expect(candidates.map((c) => c.label)).toEqual(['Max', 'Eva']);
});
it('tells apart recipients who would share a first name', () => {
const labels = (to: Array<{ name?: string; email: string }>) => buildMentionCandidates(to, []).map((c) => c.label);
expect(labels([
{ name: 'Max Mustermann', email: 'a@dornig.de' },
{ name: 'Mueller, Max', email: 'b@dornig.de' },
{ name: 'Eva Weber', email: 'c@dornig.de' },
])).toEqual(['Max Mustermann', 'Max Mueller', 'Eva']);
expect(labels([
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
{ email: 'max.mueller@dornig.de' },
])).toEqual(['Max Mustermann', 'max.mueller']);
expect(labels([
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
{ name: 'Max Mustermann', email: 'max.mustermann2@dornig.de' },
])).toEqual(['max.mustermann', 'max.mustermann2']);
});
});
describe('filterMentionCandidates', () => {
const candidates = buildMentionCandidates(
[
{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' },
{ name: 'Jürgen Müller', email: 'juergen.mueller@dornig.de' },
{ email: 'anna-lena.schmidt@dornig.de' },
],
[],
);
const labels = (query: string) => filterMentionCandidates(candidates, query).map((c) => c.label);
it('offers everyone for a bare @', () => {
expect(labels('')).toEqual(['Max', 'Jürgen', 'Anna-Lena']);
});
it('matches the start of the label, a name word or a mailbox part, ignoring case', () => {
expect(labels('ma')).toEqual(['Max']);
expect(labels('MUSTER')).toEqual(['Max']);
expect(labels('lena')).toEqual(['Anna-Lena']);
expect(labels('schm')).toEqual(['Anna-Lena']);
expect(labels('xyz')).toEqual([]);
});
it('finds umlaut names typed either way', () => {
expect(labels('jü')).toEqual(['Jürgen']);
expect(labels('jue')).toEqual(['Jürgen']);
expect(labels('mül')).toEqual(['Jürgen']);
});
});
describe('mentionSuggestions', () => {
const recipients = buildMentionCandidates(
[{ name: 'Max Mustermann', email: 'max.mustermann@dornig.de' }],
[{ name: 'Eva Weber', email: 'eva.weber@dornig.de' }],
);
const shown = (list: ReturnType<typeof mentionSuggestions>) => list.map((c) => `${c.label} ${c.email} ${c.addsTo ?? '-'}`);
it('offers matching recipients first, then contacts marked with the field they go to', () => {
const contacts = [{ name: 'Marta Klein', email: 'marta@dornig.de' }];
expect(shown(mentionSuggestions(recipients, contacts, 'ma', 'to'))).toEqual([
'Max max.mustermann@dornig.de -',
'Marta marta@dornig.de to',
]);
expect(mentionSuggestions(recipients, contacts, 'ma', 'cc')[1].addsTo).toBe('cc');
});
it('leaves out contacts who are already recipients', () => {
const contacts = [{ name: 'Eva W.', email: 'Eva.Weber@dornig.de' }];
expect(shown(mentionSuggestions(recipients, contacts, 'ev', 'to'))).toEqual(['Eva eva.weber@dornig.de -']);
});
it('drops contacts that only matched in the middle of a word', () => {
// The address autocomplete matches anywhere; an @ matches word starts.
const contacts = [{ name: 'Thomas Bauer', email: 'thomas@dornig.de' }];
expect(mentionSuggestions(recipients, contacts, 'ma', 'to').map((c) => c.email)).toEqual(['max.mustermann@dornig.de']);
});
it('skips groups and entries without a usable address', () => {
const contacts = [{ name: 'Marketing', email: '' }, { name: 'Mara', email: 'mara' }];
expect(mentionSuggestions([], contacts, 'ma', 'to')).toEqual([]);
});
it('renames a contact who shares a first name with a recipient, not the recipient', () => {
const contacts = [{ name: 'Max Müller', email: 'max.mueller@dornig.de' }];
expect(shown(mentionSuggestions(recipients, contacts, 'max', 'to'))).toEqual([
'Max max.mustermann@dornig.de -',
'Max Müller max.mueller@dornig.de to',
]);
// The recipient list itself keeps its labels.
expect(recipients[0].label).toBe('Max');
});
it('caps the contacts it offers', () => {
const contacts = Array.from({ length: 20 }, (_, i) => ({ name: `Mia ${i}`, email: `mia${i}@dornig.de` }));
expect(mentionSuggestions([], contacts, 'mia', 'to')).toHaveLength(8);
});
});
describe('mentionHref', () => {
it('addresses only the one mailbox, whatever the address carries', () => {
expect(mentionHref('max@dornig.de')).toBe('mailto:max@dornig.de');
expect(mentionHref('a?cc=evil@x.de#f@dornig.de')).toBe('mailto:a%3Fcc=evil@x.de%23f@dornig.de');
});
});
describe('addMentionedRecipient', () => {
const make = (c: { name?: string; email: string }): { name?: string; email: string } => ({ name: c.name, email: c.email });
it('adds a contact who is not on the message yet', () => {
const next = addMentionedRecipient<{ name?: string; email: string }>([{ email: 'eva@dornig.de' }], { label: 'Max', name: 'Max M', email: 'max@dornig.de', addsTo: 'to' }, make);
expect(next).toEqual([{ email: 'eva@dornig.de' }, { name: 'Max M', email: 'max@dornig.de' }]);
});
it('leaves the field alone when the address is there already, also inside a group', () => {
const list = [{ name: 'Team', email: '', group: { members: [{ email: 'MAX@dornig.de' }] } }];
expect(addMentionedRecipient(list, { label: 'Max', email: 'max@dornig.de', addsTo: 'to' }, make)).toBe(list);
});
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
isPickerReopenTooSoon, MIN_SUGGESTION_ROOM, needsRecipientPicker, PICKER_REOPEN_GRACE_MS, suggestionRoom,
} from '../recipient-picker';
// U-069: in phone landscape the keyboard leaves about 50 dp under the To
// field, so the inline suggestions hid behind it. The composer hands the
// field to a full-screen picker there, and wherever the measured room under
// the field is too small for the list.
describe('needsRecipientPicker', () => {
it('always uses the picker in a phone-landscape window', () => {
// use-layout's shortHeight: a window under 480 dp tall.
expect(needsRecipientPicker({ shortHeight: true, windowHeight: 360, keyboardObstruction: 0, fieldBottom: null })).toBe(true);
});
it('keeps the inline list in portrait, where it has room above the keyboard', () => {
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 844, keyboardObstruction: 300, fieldBottom: 160 })).toBe(false);
});
it('uses the picker when less than two rows fit between field and keyboard', () => {
// A short split-screen window: 520 tall, keyboard 300, field ends at 140.
expect(suggestionRoom(520, 300, 140)).toBe(80);
expect(80).toBeLessThan(MIN_SUGGESTION_ROOM);
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 300, fieldBottom: 140 })).toBe(true);
});
it('waits for the keyboard and a measurement before deciding on the room', () => {
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 0, fieldBottom: 140 })).toBe(false);
expect(needsRecipientPicker({ shortHeight: false, windowHeight: 520, keyboardObstruction: 300, fieldBottom: null })).toBe(false);
});
it('does not reopen the picker from the focus the field gets back as it closes', () => {
expect(isPickerReopenTooSoon(1000, 1000 + PICKER_REOPEN_GRACE_MS - 1)).toBe(true);
expect(isPickerReopenTooSoon(1000, 1000 + PICKER_REOPEN_GRACE_MS)).toBe(false);
expect(isPickerReopenTooSoon(null, 5)).toBe(false);
});
});
describe('ComposeScreen recipient picker wiring', () => {
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
const field = source.slice(source.indexOf('const renderRecipientField'), source.indexOf('const composer = ('));
it('opens the picker when an address field takes the focus in a short window', () => {
expect(field).toMatch(/needsRecipientPicker\(\{ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null \}\)[\s\S]{0,40}openRecipientPicker\(field\)/);
});
it('hides the inline list while the picker has the field, and feeds the picker the same suggestions', () => {
expect(field).toMatch(/activeField === field && !pickerField &&/);
expect(source).toMatch(/const suggestField = pickerField \?\? activeField;\s+const suggestionQuery = inputFor\(suggestField\);/);
const picker = source.slice(source.indexOf('<RecipientPicker'), source.indexOf('<ComposerOptionsSheet'));
expect(picker).toMatch(/suggestions=\{suggestions\}/);
expect(picker).toMatch(/onPick=\{pickSuggestion\}/);
expect(picker).toMatch(/onDone=\{closeRecipientPicker\}/);
});
it('turns what was typed into chips when the picker closes', () => {
const close = source.slice(source.indexOf('const closeRecipientPicker'), source.indexOf('const closeRecipientPicker') + 400);
expect(close).toMatch(/if \(inputFor\(field\)\.trim\(\)\) addTyped\(field\);/);
expect(close).toMatch(/pickerClosedAtRef\.current = Date\.now\(\);/);
});
});
+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');
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
const caps = vi.hoisted(() => ({ value: undefined as unknown }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { getAccountCapability: vi.fn(() => caps.value) },
}));
import { hasSubmissionExtension, submissionEnvelopeParameters, supportsSubmissionExtension } from '../submission-options';
import { messagePriorityFromHeaders, messagePriorityHeaders } from '../message-priority';
describe('submissionEnvelopeParameters', () => {
it('asks for DSN with RET=HDRS and NOTIFY on every recipient', () => {
expect(submissionEnvelopeParameters({ requestDsn: true })).toEqual({
mailFrom: { RET: 'HDRS' },
rcptTo: { NOTIFY: 'SUCCESS,FAILURE,DELAY' },
});
});
it('writes REQUIRETLS without a value and keeps the hold', () => {
expect(submissionEnvelopeParameters({ requireTls: true }, 90)).toEqual({
mailFrom: { HOLDFOR: '90', REQUIRETLS: null },
rcptTo: {},
});
});
it('adds nothing without options', () => {
expect(submissionEnvelopeParameters(undefined)).toEqual({ mailFrom: {}, rcptTo: {} });
});
});
describe('submission extensions', () => {
it('reads the RFC 8621 map, any case', () => {
expect(hasSubmissionExtension({ dsn: [], REQUIRETLS: [] }, 'DSN')).toBe(true);
expect(hasSubmissionExtension({ FUTURERELEASE: [] }, 'DSN')).toBe(false);
expect(hasSubmissionExtension(['DSN'], 'dsn')).toBe(true);
expect(hasSubmissionExtension(null, 'DSN')).toBe(false);
});
it("offers DSN only when the account's submission capability lists it (Stalwart 0.16 and 1.0 do)", () => {
caps.value = { submissionExtensions: { FUTURERELEASE: [], DSN: [], 'MT-PRIORITY': ['MIXER'], REQUIRETLS: [] } };
expect(supportsSubmissionExtension('DSN')).toBe(true);
expect(supportsSubmissionExtension('REQUIRETLS')).toBe(true);
caps.value = { submissionExtensions: { FUTURERELEASE: [] } };
expect(supportsSubmissionExtension('DSN')).toBe(false);
caps.value = undefined;
expect(supportsSubmissionExtension('DSN')).toBe(false);
});
});
describe('message priority headers', () => {
it('maps high and low to X-Priority and Importance, normal to nothing', () => {
expect(messagePriorityHeaders('high')).toEqual({ 'header:X-Priority:asText': '1 (Highest)', 'header:Importance:asText': 'high' });
expect(messagePriorityHeaders('low')).toEqual({ 'header:X-Priority:asText': '5 (Lowest)', 'header:Importance:asText': 'low' });
expect(messagePriorityHeaders('normal')).toEqual({});
});
it('reads a priority back, X-Priority first', () => {
expect(messagePriorityFromHeaders([{ name: 'x-priority', value: ' 2' }])).toBe('high');
expect(messagePriorityFromHeaders([{ name: 'X-Priority', value: '3 (Normal)' }, { name: 'Importance', value: 'high' }])).toBe('normal');
expect(messagePriorityFromHeaders([{ name: 'Importance', value: ' Low' }])).toBe('low');
expect(messagePriorityFromHeaders(undefined)).toBe('normal');
});
});
+3
View File
@@ -154,6 +154,9 @@ function blocksToPlainText(html: string, mailMarkup: boolean): string {
if (!href) return inner; if (!href) return inner;
const normalizedHref = href.replace(/^mailto:/i, ''); const normalizedHref = href.replace(/^mailto:/i, '');
if (!text || text === href || text === normalizedHref) return text || href; if (!text || text === href || text === normalizedHref) return text || href;
// A mention: a mailto: link reading "@Name" is "@Name" in the text part,
// as the webmail's mention chip writes it (renderText, #1134).
if (/^mailto:/i.test(href) && text.startsWith('@') && text.length > 1) return text;
// Angle brackets are restored after the tag strip below. // Angle brackets are restored after the tag strip below.
return `${text} \u0001${href}\u0002`; return `${text} \u0001${href}\u0002`;
}); });
+3
View File
@@ -3,6 +3,7 @@
import type { Email } from '../api/types'; import type { Email } from '../api/types';
import type { ComposeDraftContext } from '../navigation/types'; import type { ComposeDraftContext } from '../navigation/types';
import { messagePriorityFromHeaders } from './message-priority';
/** /**
* Pick the draft's bodies by MIME type. A plain-text-only message lists its * Pick the draft's bodies by MIME type. A plain-text-only message lists its
@@ -37,6 +38,8 @@ export function draftContextFromEmail(email: Email, jmapAccountId?: string): Com
messageId: email.messageId ?? undefined, messageId: email.messageId ?? undefined,
inReplyTo: email.inReplyTo ?? undefined, inReplyTo: email.inReplyTo ?? undefined,
references: email.references ?? undefined, references: email.references ?? undefined,
// Read back from the headers it was saved with (webmail #970).
priority: messagePriorityFromHeaders(email.headers),
}; };
} }
+99 -1
View File
@@ -100,6 +100,31 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) {
sel.addRange(range); sel.addRange(range);
}`; }`;
/**
* Page-script function for "@" mentions (webmail #1134): given the text of
* the caret's text node up to the caret, where the "@" being typed starts
* and what follows it, or null. Like the webmail's suggestion it only
* starts at the beginning of a word, so info@example.com never triggers,
* and the query ends at the first space. A string, embedded in the page
* script and run by the unit test. No regex: see the template-cooking
* warning at the top.
*/
export const MENTION_QUERY_SOURCE = `function mentionQueryIn(text) {
var at = text.lastIndexOf('@');
if (at < 0) return null;
if (at > 0) {
var before = text.charCodeAt(at - 1);
if (before !== 32 && before !== 160) return null;
}
var query = text.slice(at + 1);
if (query.length > 64) return null;
for (var i = 0; i < query.length; i++) {
var code = query.charCodeAt(i);
if (code === 32 || code === 160 || code === 10 || code === 9 || code === 64) return null;
}
return { at: at, query: query };
}`;
/** /**
* Whether the editor WebView may load `url`: only its initial about:blank * Whether the editor WebView may load `url`: only its initial about:blank
* document (`source={{ html }}` loads as about:blank on both platforms, as * document (`source={{ html }}` loads as about:blank on both platforms, as
@@ -150,8 +175,14 @@ export function buildEditorHtml(opts: {
collapseQuote?: boolean; collapseQuote?: boolean;
/** The toggle's accessible names. */ /** The toggle's accessible names. */
quoteToggleLabels?: { show: string; hide: string }; quoteToggleLabels?: { show: string; hide: string };
/**
* Report an "@" being typed at the start of a word as `mention` messages
* (the query, or null once it ends) and accept `insertMention` (webmail
* recipient mentions, #1134). Off for editors that aren't a message.
*/
mentions?: boolean;
}): string { }): string {
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false, collapseQuote = false } = opts; const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false, collapseQuote = false, mentions = false } = opts;
const quoteLabels = opts.quoteToggleLabels ?? { show: 'Show quoted text', hide: 'Hide quoted text' }; const quoteLabels = opts.quoteToggleLabels ?? { show: 'Show quoted text', hide: 'Hide quoted text' };
// Inject initial content as a JSON-encoded string so any HTML/quotes inside // Inject initial content as a JSON-encoded string so any HTML/quotes inside
// are safely embedded (no template literal collision with the script body, // are safely embedded (no template literal collision with the script body,
@@ -202,6 +233,7 @@ export function buildEditorHtml(opts: {
color: ${c.textSecondary}; color: ${c.textSecondary};
} }
#editor a { color: ${c.primary}; text-decoration: underline; } #editor a { color: ${c.primary}; text-decoration: underline; }
#editor a.mention { text-decoration: none; font-weight: 600; }
#editor img { max-width: 100%; height: auto; border-radius: 4px; } #editor img { max-width: 100%; height: auto; border-radius: 4px; }
#editor pre, #editor code { #editor pre, #editor code {
background: ${c.surfaceActive}; background: ${c.surfaceActive};
@@ -417,12 +449,46 @@ export function buildEditorHtml(opts: {
setQuoteCollapsed(true); setQuoteCollapsed(true);
} }
// ── "@" mentions ───────────────────────────────────────────────────
var MENTIONS = ${mentions ? 'true' : 'false'};
var lastMention = null;
${MENTION_QUERY_SOURCE}
// The "@..." being typed at the caret: its text node and range, or null.
// Never inside a link (a mention already made) or code.
function currentMention() {
var sel = window.getSelection();
if (!sel || !sel.rangeCount || !sel.isCollapsed) return null;
var node = sel.anchorNode;
if (!node || node.nodeType !== 3) return null;
var parent = node.parentNode;
while (parent && parent !== editor) {
if (parent.nodeType === 1 && (parent.tagName === 'A' || parent.tagName === 'CODE' || parent.tagName === 'PRE')) return null;
parent = parent.parentNode;
}
if (parent !== editor) return null;
var hit = mentionQueryIn(node.data.slice(0, sel.anchorOffset));
if (!hit) return null;
return { node: node, start: hit.at, end: sel.anchorOffset, query: hit.query };
}
function reportMention(force) {
if (!MENTIONS) return;
var m = force === null ? null : currentMention();
var query = m ? m.query : null;
if (query === lastMention) return;
lastMention = query;
post('mention', query);
}
editor.addEventListener('input', function () { editor.addEventListener('input', function () {
scheduleChange(); scheduleChange();
reportHeight(); reportHeight();
reportMention();
}); });
editor.addEventListener('blur', function () { editor.addEventListener('blur', function () {
flushChange(); flushChange();
reportMention(null);
post('blur', null); post('blur', null);
}); });
editor.addEventListener('focus', function () { post('focus', null); }); editor.addEventListener('focus', function () { post('focus', null); });
@@ -430,6 +496,7 @@ export function buildEditorHtml(opts: {
if (document.activeElement === editor) { if (document.activeElement === editor) {
revealQuoteAtCaret(); revealQuoteAtCaret();
reportSelection(); reportSelection();
reportMention();
} }
}); });
@@ -541,6 +608,37 @@ export function buildEditorHtml(opts: {
}, },
focus: function () { editor.focus(); }, focus: function () { editor.focus(); },
focusEnd: function () { focusEnd(editor); }, focusEnd: function () { focusEnd(editor); },
// Replace the "@..." being typed with a mention link and a space, the
// caret after it (webmail insertMention). The label and href go in as
// text and attribute, never as markup: a label comes from a display
// name, which a reply-all takes from the incoming message.
insertMention: function (label, href) {
if (!MENTIONS) return;
var m = currentMention();
if (!m) return;
var range = document.createRange();
range.setStart(m.node, m.start);
range.setEnd(m.node, m.end);
range.deleteContents();
var link = document.createElement('a');
link.setAttribute('href', href);
link.setAttribute('class', 'mention');
link.textContent = '@' + label;
var space = document.createTextNode(String.fromCharCode(160));
range.insertNode(space);
range.insertNode(link);
var sel = window.getSelection();
var caret = document.createRange();
caret.setStartAfter(space);
caret.collapse(true);
if (sel) {
sel.removeAllRanges();
sel.addRange(caret);
}
reportChange();
reportHeight();
reportMention();
},
// The description of the inline image with this cid; "" is decorative. // The description of the inline image with this cid; "" is decorative.
setImageAlt: function (cid, alt) { setImageAlt: function (cid, alt) {
var images = editor.getElementsByTagName('img'); var images = editor.getElementsByTagName('img');
+60
View File
@@ -0,0 +1,60 @@
/**
* Message priority set in the composer (webmail #970, lib/message-priority.ts).
*
* Clients disagree on which header carries it: Outlook reads Importance
* (RFC 4021), Thunderbird and most others read the de-facto X-Priority
* (1 highest ... 5 lowest). Both are written so the hint survives either
* way. Priority (RFC 4021, MIXER) is left out: it is meant to steer
* transmission, not display. Normal writes nothing, so ordinary mail is
* unchanged.
*/
export type MessagePriority = 'high' | 'normal' | 'low';
export const MESSAGE_PRIORITIES: readonly MessagePriority[] = ['high', 'normal', 'low'];
/** JMAP Email/set create properties (RFC 8621 §4.1.3) for a priority. */
export function messagePriorityHeaders(priority: MessagePriority | undefined): Record<string, string> {
if (priority === 'high') {
return {
'header:X-Priority:asText': '1 (Highest)',
'header:Importance:asText': 'high',
};
}
if (priority === 'low') {
return {
'header:X-Priority:asText': '5 (Lowest)',
'header:Importance:asText': 'low',
};
}
return {};
}
function headerValue(headers: Array<{ name: string; value: string }>, name: string): string | undefined {
const lower = name.toLowerCase();
for (const header of headers) {
if (header?.name?.toLowerCase() !== lower) continue;
const value = typeof header.value === 'string' ? header.value.trim() : '';
if (value) return value;
}
return undefined;
}
/**
* The priority a message's raw headers (JMAP `headers`) carry, for a
* re-opened draft. X-Priority wins over Importance; 1-2 is high, 4-5 low,
* anything else normal.
*/
export function messagePriorityFromHeaders(
headers: Array<{ name: string; value: string }> | undefined | null,
): MessagePriority {
if (!Array.isArray(headers)) return 'normal';
const xPriority = headerValue(headers, 'X-Priority');
const level = xPriority ? Number.parseInt(xPriority, 10) : NaN;
if (level === 1 || level === 2) return 'high';
if (level === 4 || level === 5) return 'low';
if (!Number.isNaN(level)) return 'normal';
const importance = headerValue(headers, 'Importance')?.toLowerCase();
if (importance === 'high') return 'high';
if (importance === 'low') return 'low';
return 'normal';
}
+235
View File
@@ -0,0 +1,235 @@
// "@" mentions in the message body (webmail #1134), ported from the webmail's
// lib/recipient-mentions.ts. Typing "@" at the start of a word offers the
// message's To and Cc recipients, then contacts who aren't on the message
// yet; picking one inserts "@FirstName" as a mailto: link and adds a
// contact to To (webmail default `mentionAddsRecipientTo: 'to'`).
import { expandRecipients, type Recipient } from './recipients';
/** A recipient an @ in the message body can address. */
export interface MentionCandidate {
/** What goes after the @ in the body. */
label: string;
name?: string;
email: string;
/** Not a recipient yet: picking them adds them to this field. */
addsTo?: 'to' | 'cc';
}
/** Someone from the contacts, the directory or recent recipients. */
export interface MentionContact {
name?: string;
email: string;
}
/** How many contacts an @ offers after the recipients. */
const CONTACT_LIMIT = 8;
function letterCount(value: string): number {
return value.match(/\p{L}/gu)?.length ?? 0;
}
/** The mailbox part of an address, without a +subaddress tag. */
function localPartOf(email: string): string {
const local = email.slice(0, email.lastIndexOf('@'));
return local.split('+')[0] || local;
}
/** "Mustermann, Max" -> "Max Mustermann"; any other name as is. */
function naturalOrder(name: string): string {
const parts = name.split(',');
if (parts.length !== 2 || !parts[1].trim()) return name;
return `${parts[1].trim()} ${parts[0].trim()}`;
}
/**
* "max.mustermann" -> "Max", "anna-lena.schmidt" -> "Anna-Lena". Null when
* the first part is a bare initial ("m.mustermann").
*/
function givenNameFromLocalPart(localPart: string): string | null {
const first = localPart.split(/[._]/)[0];
if (letterCount(first) <= 1) return null;
return first.replace(/(^|-)(\p{Ll})/gu, (_, sep: string, letter: string) => sep + letter.toUpperCase());
}
/** The words of a display name, without the punctuation around them. */
function nameWords(name: string): string[] {
return naturalOrder(name)
.split(/\s+/)
.map((raw) => raw.replace(/^[^\p{L}\p{N}]+/u, '').replace(/[^\p{L}\p{N}.]+$/u, ''))
.filter(Boolean);
}
/**
* The given name in a display name: "Max Mustermann", "Mustermann, Max" and
* "Dr. Max Mustermann" all give "Max". Null when the name opens with a bare
* initial ("M. Mustermann").
*/
function givenNameFromDisplayName(name: string): string | null {
// Some systems put the mailbox name in the display name.
if (!/\s/.test(name) && /\p{L}[._]\p{L}/u.test(name)) return givenNameFromLocalPart(name);
for (const word of nameWords(name)) {
if (letterCount(word) <= 1) return null;
// Titles and abbreviations: "Dr.", "Prof.", "Dipl.-Ing."
if (word.endsWith('.')) continue;
return word;
}
return null;
}
const UMLAUT_SPELLINGS: Record<string, string> = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
/** Whether two names match as an address spells them: "Jürgen" and "juergen". */
function spelledAlike(a: string, b: string): boolean {
const key = (s: string) => fold(s.normalize('NFC').toLowerCase().replace(/[äöüß]/g, (c) => UMLAUT_SPELLINGS[c]));
return key(a) === key(b);
}
/**
* A display name worth deriving a label from: not empty, not an address.
* Capped because a reply-all takes it from the incoming message.
*/
function usableName(name: string | undefined): string | undefined {
const trimmed = name?.trim().slice(0, 200).replace(/^(['"])(.*)\1$/, '$2').trim();
return trimmed && !trimmed.includes('@') ? trimmed : undefined;
}
/**
* First name to put after the @. A firstname.lastname@ address tells which
* word of the display name is the first name; otherwise it is the display
* name's first word, or the address's first part when no name is known.
*/
export function mentionLabel(name: string | undefined, email: string): string {
const localPart = localPartOf(email);
const fromAddress = givenNameFromLocalPart(localPart);
const usable = usableName(name);
if (usable) {
const confirmed = fromAddress && /[._]/.test(localPart)
? nameWords(usable).find((word) => spelledAlike(word, fromAddress))
: undefined;
const given = confirmed || givenNameFromDisplayName(usable);
if (given) return given;
}
return fromAddress ?? localPart;
}
function clashingLabels(candidates: MentionCandidate[]): Set<string> {
const seen = new Set<string>();
const clashes = new Set<string>();
for (const { label } of candidates) {
const key = label.toLowerCase();
if (seen.has(key)) clashes.add(key);
seen.add(key);
}
return clashes;
}
/**
* Two candidates who would both be "@Max" are told apart by full name, or
* by mailbox name where no name is known - and by mailbox name when even
* the full names match.
*/
function distinguishLabels(candidates: MentionCandidate[]): MentionCandidate[] {
const clashes = clashingLabels(candidates);
for (const c of candidates) {
if (clashes.has(c.label.toLowerCase())) c.label = c.name ? naturalOrder(c.name) : localPartOf(c.email);
}
const stillClashing = clashingLabels(candidates);
for (const c of candidates) {
if (stillClashing.has(c.label.toLowerCase())) c.label = localPartOf(c.email);
}
return candidates;
}
/**
* The recipients an @ in the message body can address: To and Cc, contact
* groups expanded into their members, each address once. Bcc is left out on
* purpose - naming a blind-copied recipient in the body would disclose them
* to everyone else on the message.
*/
export function buildMentionCandidates(to: Recipient[], cc: Recipient[]): MentionCandidate[] {
return distinguishLabels(expandRecipients([...to, ...cc])
.filter((r) => r.email.lastIndexOf('@') > 0)
.map((r) => {
const name = usableName(r.name);
return { label: mentionLabel(name, r.email), name, email: r.email };
}));
}
/**
* What an @ followed by `query` offers: the matching recipients, then
* matching contacts who aren't on the message yet, marked with the field
* picking them adds them to. `contacts` must already leave out Bcc
* recipients, who an @ never names.
*/
export function mentionSuggestions(
recipients: MentionCandidate[],
contacts: MentionContact[],
query: string,
addsTo: 'to' | 'cc',
): MentionCandidate[] {
const matched = filterMentionCandidates(recipients, query);
const seen = new Set(recipients.map((r) => r.email.toLowerCase()));
const extra: MentionCandidate[] = [];
for (const contact of contacts) {
if (extra.length >= CONTACT_LIMIT) break;
const key = contact.email.toLowerCase();
if (contact.email.lastIndexOf('@') <= 0 || seen.has(key)) continue;
seen.add(key);
const name = usableName(contact.name);
const candidate = { label: mentionLabel(name, contact.email), name, email: contact.email, addsTo };
if (filterMentionCandidates([candidate], query).length > 0) extra.push(candidate);
}
if (extra.length === 0) return matched;
// A contact who would also be "@Max" next to a recipient Max goes by full
// name, or mailbox name. The recipient keeps "@Max".
const taken = new Set(recipients.map((r) => r.label.toLowerCase()));
distinguishLabels(extra);
for (const c of extra) {
if (!taken.has(c.label.toLowerCase())) continue;
c.label = c.name && !taken.has(naturalOrder(c.name).toLowerCase()) ? naturalOrder(c.name) : localPartOf(c.email);
}
return [...matched, ...extra];
}
function fold(value: string): string {
return value.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase();
}
/**
* Candidates whose label, a word of their name, their address or a part of
* their mailbox name starts with what was typed after the @ - case and
* accents ignored, so "jue" finds juergen.mueller@ and "jü" finds Jürgen.
*/
export function filterMentionCandidates(candidates: MentionCandidate[], query: string): MentionCandidate[] {
const typed = fold(query);
if (!typed) return candidates;
return candidates.filter((c) =>
[c.label, c.email, ...(c.name?.split(/\s+/) ?? []), ...localPartOf(c.email).split(/[._-]/)]
.some((term) => fold(term).startsWith(typed)),
);
}
/**
* A mailto: href for the address. Characters that would start a query or
* fragment (a crafted reply-all address) are escaped, so the link can only
* ever address this one mailbox.
*/
export function mentionHref(email: string): string {
return `mailto:${email.replace(/[%?#&\s"'<>]/g, (c) => encodeURIComponent(c))}`;
}
/**
* Add a mentioned contact to To (or Cc) unless the address is on the message
* already, groups included (webmail addMentionedRecipient). Returns the same
* list when nothing changes; `make` builds the field's entry.
*/
export function addMentionedRecipient<T extends Recipient>(
list: T[],
candidate: MentionCandidate,
make: (candidate: MentionCandidate) => T,
): T[] {
const key = candidate.email.trim().toLowerCase();
if (expandRecipients(list).some((r) => r.email.trim().toLowerCase() === key)) return list;
return [...list, make(candidate)];
}
+44
View File
@@ -0,0 +1,44 @@
// When the composer's recipient suggestions can't drop down under the field
// (U-069): a phone in landscape has about 50 dp left under To with the
// keyboard up, so the list hid behind the keyboard. There the composer opens
// a full-screen recipient picker instead, whose suggestions take the room
// between its own field and the keyboard.
/** One suggestion row: a 32 dp avatar and 8 dp padding above and below. */
export const SUGGESTION_ROW_HEIGHT = 48;
/** The inline list needs room for at least this much to be of use. */
export const MIN_SUGGESTION_ROOM = 2 * SUGGESTION_ROW_HEIGHT;
/** Room between the bottom of the field and the top of the keyboard (dp). */
export function suggestionRoom(windowHeight: number, keyboardObstruction: number, fieldBottom: number): number {
return Math.max(0, windowHeight - keyboardObstruction - fieldBottom);
}
/**
* Whether the address field should hand over to the full-screen picker.
* A short window (a phone in landscape) always does: the keyboard leaves no
* room. Elsewhere it does once the keyboard is up and the measured room
* under the field is less than two rows (a small split-screen window).
*/
export function needsRecipientPicker(opts: {
shortHeight: boolean;
windowHeight: number;
keyboardObstruction: number;
/** The field row's bottom edge in window coordinates, when measured. */
fieldBottom: number | null;
}): boolean {
if (opts.shortHeight) return true;
if (opts.keyboardObstruction <= 0 || opts.fieldBottom == null) return false;
return suggestionRoom(opts.windowHeight, opts.keyboardObstruction, opts.fieldBottom) < MIN_SUGGESTION_ROOM;
}
/**
* A focus that lands right after the picker closed is the field getting its
* focus back, not the user asking for the picker again.
*/
export const PICKER_REOPEN_GRACE_MS = 600;
export function isPickerReopenTooSoon(closedAt: number | null, now: number): boolean {
return closedAt != null && now - closedAt < PICKER_REOPEN_GRACE_MS;
}
+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';
}
+58
View File
@@ -0,0 +1,58 @@
// Per-message SMTP submission options (RFC 8621 §7.1 envelope parameters),
// ported from the webmail's lib/jmap/client.ts (submissionEnvelopeParameters,
// supportsSubmissionExtension).
//
// `requestDsn` asks the next hops for delivery status notifications (RFC 3461
// NOTIFY / RET); `requireTls` refuses relaying over an unencrypted hop (RFC
// 8689 REQUIRETLS). Both need the matching entry in the submission account's
// `submissionExtensions`, so the composer only offers what the server lists.
import { jmapClient } from '../api/jmap-client';
import { CAPABILITIES } from '../api/types';
export interface SubmissionSendOptions {
requestDsn?: boolean;
requireTls?: boolean;
}
/** The MAIL FROM / RCPT TO parameters the options and a hold translate to. */
export function submissionEnvelopeParameters(
options: SubmissionSendOptions | undefined,
holdForSeconds?: number,
): { mailFrom: Record<string, string | null>; rcptTo: Record<string, string | null> } {
const mailFrom: Record<string, string | null> = {};
const rcptTo: Record<string, string | null> = {};
if (holdForSeconds && holdForSeconds > 0) mailFrom.HOLDFOR = String(Math.ceil(holdForSeconds));
if (options?.requireTls) mailFrom.REQUIRETLS = null;
if (options?.requestDsn) {
// Return the headers only (not the full message) with the notification.
mailFrom.RET = 'HDRS';
rcptTo.NOTIFY = 'SUCCESS,FAILURE,DELAY';
}
return { mailFrom, rcptTo };
}
/**
* Whether a `submissionExtensions` value lists `extension`. RFC 8621 makes it
* a map of extension name to its arguments; an array of names is accepted
* too, as the webmail does.
*/
export function hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
const target = extension.toUpperCase();
if (Array.isArray(submissionExtensions)) {
return submissionExtensions.some((item) => typeof item === 'string' && item.toUpperCase() === target);
}
if (submissionExtensions && typeof submissionExtensions === 'object') {
return Object.entries(submissionExtensions as Record<string, unknown>)
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
}
return false;
}
/** Whether the account (default: the primary) can submit with `extension`, e.g. "DSN". */
export function supportsSubmissionExtension(extension: string, accountId?: string): boolean {
const cap = jmapClient.getAccountCapability(CAPABILITIES.SUBMISSION, accountId) as
| { submissionExtensions?: unknown }
| undefined;
return hasSubmissionExtension(cap?.submissionExtensions, extension);
}
+3
View File
@@ -1,4 +1,5 @@
import type { Attachment, EmailAddress } from '../api/types'; import type { Attachment, EmailAddress } from '../api/types';
import type { MessagePriority } from '../lib/message-priority';
/** The message a reply / forward / draft edit starts from. */ /** The message a reply / forward / draft edit starts from. */
export interface ComposeReplyContext { export interface ComposeReplyContext {
@@ -47,6 +48,8 @@ export interface ComposeDraftContext {
messageId?: string[] | null; messageId?: string[] | null;
inReplyTo?: string[] | null; inReplyTo?: string[] | null;
references?: string[] | null; references?: string[] | null;
/** Read back from the draft's X-Priority / Importance headers (webmail #970). */
priority?: MessagePriority;
} }
export type RootStackParamList = { export type RootStackParamList = {
+259 -185
View File
@@ -11,7 +11,7 @@ import {
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type, Highlighter, Trash2, Search, Tag, Type, Highlighter, Trash2, Settings,
} from '../components/icons'; } from '../components/icons';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
@@ -28,7 +28,6 @@ import RichTextEditor, {
} from '../components/RichTextEditor'; } from '../components/RichTextEditor';
import { useEmailStore } from '../stores/email-store'; import { useEmailStore } from '../stores/email-store';
import { ownMailboxes } from '../lib/mailbox-tree'; import { ownMailboxes } from '../lib/mailbox-tree';
import { getEmailInitials } from '../lib/avatar-utils';
import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store'; import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store';
import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction';
import { useLayout } from '../lib/use-layout'; import { useLayout } from '../lib/use-layout';
@@ -77,6 +76,20 @@ import { inlineImageType } from '../lib/compose-inline-type';
import { attachmentFromFileNode } from '../lib/compose-files'; import { attachmentFromFileNode } from '../lib/compose-files';
import { supportsFiles } from '../api/files'; import { supportsFiles } from '../api/files';
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet'; import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
import type { MessagePriority } from '../lib/message-priority';
import { supportsSubmissionExtension } from '../lib/submission-options';
import {
addMentionedRecipient, buildMentionCandidates, mentionHref, mentionSuggestions, type MentionCandidate,
} from '../lib/recipient-mentions';
import { MentionSuggestionList } from '../components/compose/MentionSuggestionList';
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 { import {
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin, assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../lib/compose-login'; } from '../lib/compose-login';
@@ -211,118 +224,6 @@ function quoteLines(text: string): string {
return text.split('\n').map((l) => `> ${l}`).join('\n'); return text.split('\n').map((l) => `> ${l}`).join('\n');
} }
function RecipientChip({
recipient, invalid, onRemove, onLongPress,
}: {
recipient: Recipient;
invalid?: boolean;
onRemove: () => void;
onLongPress?: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const label = recipient.group
? t('email_composer.group_chip', '{name} ({count})', {
name: recipient.name || t('contacts.group', 'Group'),
count: recipient.group.members.length,
})
: recipient.name || recipient.email;
return (
<Pressable onLongPress={onLongPress} delayLongPress={350} style={[styles.chip, invalid && styles.chipInvalid]}>
{recipient.group && <Users size={12} color={c.textSecondary} />}
<Text style={[styles.chipText, invalid && styles.chipTextInvalid]} numberOfLines={1}>
{label}
</Text>
<Pressable
onPress={onRemove}
// A 12 px icon: the touch area reaches about 40 dp around it.
hitSlop={14}
accessibilityRole="button"
accessibilityLabel={t('email_composer.recipient_remove', 'Remove')}
>
<X size={12} color={invalid ? c.error : c.textMuted} />
</Pressable>
</Pressable>
);
}
function initialsOf(name: string, email: string): string {
return getEmailInitials(name.trim(), email);
}
function SuggestionList({
suggestions, onPick, onPressIn, onSearchServer, searchingServer,
}: {
suggestions: RecipientSuggestion[];
onPick: (s: RecipientSuggestion) => void;
onPressIn?: () => void;
/** Search the Sent folder on the server for more (webmail: "Search the server"). */
onSearchServer?: () => void;
searchingServer?: boolean;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
return (
<View style={styles.suggestionBox}>
<ScrollView style={styles.suggestionScroll} keyboardShouldPersistTaps="always" nestedScrollEnabled>
{suggestions.map((s, i) => (
<Pressable
key={`${s.group?.id ?? s.email}-${i}`}
onPressIn={onPressIn}
onPress={() => onPick(s)}
style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]}
>
<View style={styles.suggestionAvatar}>
{s.group
? <Users size={14} color={c.primary} />
: <Text style={styles.suggestionAvatarText}>{initialsOf(s.name, s.email)}</Text>}
</View>
<View style={styles.suggestionText}>
<Text style={styles.suggestionName} numberOfLines={1}>
{s.name || s.email}
</Text>
{s.group ? (
<Text style={styles.suggestionEmail} numberOfLines={1}>
{t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })}
</Text>
) : !!s.name && (
<Text style={styles.suggestionEmail} numberOfLines={1}>{s.email}</Text>
)}
</View>
</Pressable>
))}
{onSearchServer && (
<Pressable
onPressIn={onPressIn}
onPress={() => { if (!searchingServer) onSearchServer(); }}
disabled={searchingServer}
accessibilityRole="button"
accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }}
style={({ pressed }) => [
styles.suggestionRow,
suggestions.length > 0 && styles.suggestionSearchRowDivided,
pressed && styles.suggestionRowPressed,
]}
>
<View style={styles.suggestionSearchIcon}>
{searchingServer
? <ActivityIndicator size="small" color={c.textSecondary} />
: <Search size={16} color={c.textSecondary} />}
</View>
<Text style={styles.suggestionSearchText} numberOfLines={1}>
{searchingServer
? t('email_composer.autocomplete_searching', 'Searching...')
: t('email_composer.autocomplete_search_server', 'Search the server')}
</Text>
</Pressable>
)}
</ScrollView>
</View>
);
}
function AttachmentChip({ function AttachmentChip({
attachment, onRemove, onPress, attachment, onRemove, onPress,
}: { }: {
@@ -722,6 +623,27 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [activeField, setActiveField] = React.useState<Field | null>(null); const [activeField, setActiveField] = React.useState<Field | null>(null);
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments); const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments);
const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault); const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault);
// Per-message submission options and priority (webmail composer options
// menu): DSN and REQUIRETLS ride on the envelope, priority on the headers.
const [requestDsn, setRequestDsn] = React.useState(false);
const [requireTls, setRequireTls] = React.useState(false);
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
const composerOptions = React.useMemo<ComposerOptions>(
() => ({ requestReadReceipt, requestDsn, requireTls, priority }),
[requestReadReceipt, requestDsn, requireTls, priority],
);
// Read when the sheet opens: the session may have arrived since the
// composer did.
const dsnAvailable = optionsSheetOpen && supportsSubmissionExtension('DSN');
const requireTlsAvailable = optionsSheetOpen && supportsSubmissionExtension('REQUIRETLS');
const onComposerOptionsChange = React.useCallback((next: Partial<ComposerOptions>) => {
if (next.requestReadReceipt !== undefined) setRequestReadReceipt(next.requestReadReceipt);
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
if (next.requireTls !== undefined) setRequireTls(next.requireTls);
if (next.priority !== undefined) setPriority(next.priority);
}, []);
const [subAddressTag, setSubAddressTag] = React.useState(''); const [subAddressTag, setSubAddressTag] = React.useState('');
const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null); const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null);
const [selState, setSelState] = React.useState<RichTextSelectionState>({ const [selState, setSelState] = React.useState<RichTextSelectionState>({
@@ -738,7 +660,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}), [t]); }), [t]);
// The label column grows with the system font size: at 1.3 "Betreff" // The label column grows with the system font size: at 1.3 "Betreff"
// wrapped into "Betref" / "f" in a fixed 56 dp column. // wrapped into "Betref" / "f" in a fixed 56 dp column.
const { fontScale } = useWindowDimensions(); const { fontScale, height: windowHeight } = useWindowDimensions();
const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]); const fieldLabelWidth = React.useMemo(() => ({ width: Math.round(56 * Math.max(1, fontScale)) }), [fontScale]);
const subjectRef = React.useRef<TextInput>(null); const subjectRef = React.useRef<TextInput>(null);
const plainBodyRef = React.useRef<TextInput>(null); const plainBodyRef = React.useRef<TextInput>(null);
@@ -795,7 +717,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
const inputFor = (field: Field | null) => const inputFor = (field: Field | null) =>
field === 'to' ? toInput : field === 'cc' ? ccInput : field === 'bcc' ? bccInput : ''; field === 'to' ? toInput : field === 'cc' ? ccInput : field === 'bcc' ? bccInput : '';
const suggestionQuery = inputFor(activeField); // The full-screen recipient picker (U-069) for a field whose inline
// suggestions would have no room; it owns the suggestions while open.
const [pickerField, setPickerField] = React.useState<Field | null>(null);
const pickerClosedAtRef = React.useRef<number | null>(null);
// The bottom edge of each address row in window coordinates, measured
// while it has the focus, to tell whether the list fits under it.
const fieldRowRefs = React.useRef<Partial<Record<Field, View | null>>>({});
const [activeFieldBottom, setActiveFieldBottom] = React.useState<number | null>(null);
const suggestField = pickerField ?? activeField;
const suggestionQuery = inputFor(suggestField);
const alreadySelected = React.useMemo( const alreadySelected = React.useMemo(
() => new Set( () => new Set(
[...toRecipients, ...ccRecipients, ...bccRecipients] [...toRecipients, ...ccRecipients, ...bccRecipients]
@@ -841,7 +772,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const pickSuggestion = (s: RecipientSuggestion) => { const pickSuggestion = (s: RecipientSuggestion) => {
isPickingSuggestion.current = true; isPickingSuggestion.current = true;
const field = activeField ?? 'to'; const field = suggestField ?? 'to';
let recipient: Recipient; let recipient: Recipient;
if (s.group) { if (s.group) {
const members = getGroupRecipients(s.group.id) const members = getGroupRecipients(s.group.id)
@@ -1062,6 +993,47 @@ export default function ComposeScreen({ route, navigation }: Props) {
return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity; return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity;
}, [identities, selectedIdentityId]); }, [identities, selectedIdentityId]);
// ── "@" mentions in the body (webmail #1134) ──────────────────────────
// What follows the "@" being typed in the editor, null when none is.
const [mentionQuery, setMentionQuery] = React.useState<string | null>(null);
// Who an "@" offers: To and Cc. Never Bcc - naming a blind-copied
// recipient in the text would disclose them to everyone else.
const mentionCandidates = React.useMemo(
() => buildMentionCandidates(toRecipients, ccRecipients),
[toRecipients, ccRecipients],
);
// Contacts come after the recipients; anyone already on the message is
// left out, Bcc and the identity's own Bcc included.
const mentionItems = React.useMemo<MentionCandidate[]>(() => {
if (mentionQuery === null) return [];
const onMessage = new Set(
[...toRecipients, ...ccRecipients, ...bccRecipients]
.flatMap((r) => (r.group ? r.group.members.map((m) => m.email) : [r.email]))
.concat((primaryIdentity?.bcc ?? []).map((r) => r.email))
.map((e) => e.trim().toLowerCase()),
);
const contacts = mentionQuery
? getAutocomplete(mentionQuery, 16)
.filter((s) => !s.group && !!s.email && !onMessage.has(s.email.trim().toLowerCase()))
.map((s) => ({ name: s.name || undefined, email: s.email }))
: [];
return mentionSuggestions(mentionCandidates, contacts, mentionQuery, 'to');
// contactsVersion/recentVersion re-run the lookup when the store loads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mentionQuery, mentionCandidates, toRecipients, ccRecipients, bccRecipients, primaryIdentity, getAutocomplete, contactsVersion, recentVersion]);
const pickMention = (candidate: MentionCandidate) => {
editorRef.current?.insertMention(candidate.label, mentionHref(candidate.email));
// A contact who isn't on the message yet joins To (webmail default).
if (candidate.addsTo === 'cc') {
setCcRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
setCcVisible(true);
} else if (candidate.addsTo) {
setToRecipients((prev) => addMentionedRecipient(prev, candidate, (c) => ({ name: c.name ?? '', email: c.email })));
}
setMentionQuery(null);
};
// Set once the user picks From by hand: their pick then brings its own // Set once the user picks From by hand: their pick then brings its own
// signature, also when it has none (webmail identityPickedByUser). // signature, also when it has none (webmail identityPickedByUser).
const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false); const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false);
@@ -1187,6 +1159,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
const snapshotOf = (s: { const snapshotOf = (s: {
to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string; to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string;
attachments: AttachmentEntry[]; attachments: AttachmentEntry[];
/** A priority change alone is an edit worth saving (webmail #970). */
priority?: MessagePriority;
}) => JSON.stringify({ }) => JSON.stringify({
to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]), to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]), cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
@@ -1194,18 +1168,20 @@ export default function ComposeScreen({ route, navigation }: Props) {
subject: s.subject, subject: s.subject,
body: s.body, body: s.body,
att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId), att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId),
pri: s.priority ?? 'normal',
}); });
// Memoized: the snapshot copies the whole body, and the screen re-renders // Memoized: the snapshot copies the whole body, and the screen re-renders
// for plenty that doesn't touch it (PF8). // for plenty that doesn't touch it (PF8).
const currentSnapshot = React.useMemo(() => snapshotOf({ const currentSnapshot = React.useMemo(() => snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject, to: finalTo, cc: finalCc, bcc: finalBcc, subject,
body: plainTextMode ? plainBody : bodyHtml, attachments, body: plainTextMode ? plainBody : bodyHtml, attachments, priority,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments]); }), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments, priority]);
const baselineRef = React.useRef(snapshotOf({ const baselineRef = React.useRef(snapshotOf({
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject, to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments, body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments,
priority: initialPriority,
})); }));
// Snapshot at the last successful draft save (null = never saved). // Snapshot at the last successful draft save (null = never saved).
const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null); const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null);
@@ -1464,9 +1440,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
references: threading?.references, references: threading?.references,
messageId: messageIdRef.current, messageId: messageIdRef.current,
requestReadReceipt, requestReadReceipt,
priority: priority !== 'normal' ? priority : undefined,
envelopeMailFrom, envelopeMailFrom,
}; };
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, t]); }, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]);
// Save one draft version (create, then destroy the previous one - #849). // Save one draft version (create, then destroy the previous one - #849).
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => { const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
@@ -1485,7 +1462,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
} }
const snapshot = snapshotOf({ const snapshot = snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject, to: finalTo, cc: finalCc, bcc: finalBcc, subject,
body: plainTextMode ? plainBody : html, attachments, body: plainTextMode ? plainBody : html, attachments, priority,
}); });
if (snapshot === lastSavedRef.current) return draftIdRef.current; if (snapshot === lastSavedRef.current) return draftIdRef.current;
if (snapshot === baselineRef.current && !draftIdRef.current) return null; if (snapshot === baselineRef.current && !draftIdRef.current) return null;
@@ -1682,7 +1659,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
} }
const snapshot = snapshotOf({ const snapshot = snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject, to: finalTo, cc: finalCc, bcc: finalBcc, subject,
body: plainTextMode ? latestRef.current.plainBody : html, attachments, body: plainTextMode ? latestRef.current.plainBody : html, attachments, priority,
}); });
return snapshot === lastSavedRef.current; return snapshot === lastSavedRef.current;
}; };
@@ -2542,9 +2519,48 @@ export default function ComposeScreen({ route, navigation }: Props) {
// notification tap to switch logins. // notification tap to switch logins.
if (refuseSendAfterSwitch()) return; 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); setSending(true);
try { try {
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false }); 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 // The account the submission is held in, for Undo (read before the
// request: a switch may land while it is out). // request: a switch may land while it is out).
// A group or shared identity sends from its own account, and its Sent // A group or shared identity sends from its own account, and its Sent
@@ -2562,6 +2578,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
draftId: draftIdRef.current ?? undefined, draftId: draftIdRef.current ?? undefined,
draftAccountId: draftIdRef.current ? draftAccount() : undefined, draftAccountId: draftIdRef.current ? draftAccount() : undefined,
accountId: target.accountId, accountId: target.accountId,
// Only what the submitting account offers (webmail: the menu
// hides the toggles the account's submission extensions lack).
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
}, },
); );
draftIdRef.current = null; draftIdRef.current = null;
@@ -2631,6 +2651,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
allowLeaveRef.current = true; allowLeaveRef.current = true;
navigation.goBack(); navigation.goBack();
} catch (e) { } 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) { if (e instanceof RequestTimeoutError) {
// The request may have reached the server; a blind retry would send // The request may have reached the server; a blind retry would send
// the message twice (#702). // the message twice (#702).
@@ -2690,13 +2716,68 @@ export default function ComposeScreen({ route, navigation }: Props) {
: draftStatus === 'failed' ? t('email_composer.save_failed', 'Failed to save') : draftStatus === 'failed' ? t('email_composer.save_failed', 'Failed to save')
: ''; : '';
// Typing in an address field, inline or in the picker. A separator ends
// the entry: commit immediately so a pasted list turns into chips as it
// lands.
const changeFieldInput = (field: Field, text: string) => {
const setInput = inputSetterFor(field);
setInput(text);
if (/[,;\n]$/.test(text) && text.trim().length > 1) {
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected));
if (valid.length) {
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]);
setInput(invalid.join(' '));
}
}
};
const measureFieldRow = (field: Field) => {
fieldRowRefs.current[field]?.measureInWindow((_x, y, _w, h) => {
if (Number.isFinite(y) && Number.isFinite(h)) setActiveFieldBottom(y + h);
});
};
const openRecipientPicker = (field: Field) => {
if (pickerField) return;
if (isPickerReopenTooSoon(pickerClosedAtRef.current, Date.now())) return;
// The picker takes over the field; the inline blur must not commit
// half-typed text behind it.
isPickingSuggestion.current = true;
setPickerField(field);
};
const closeRecipientPicker = () => {
const field = pickerField;
if (!field) return;
if (inputFor(field).trim()) addTyped(field);
pickerClosedAtRef.current = Date.now();
isPickingSuggestion.current = false;
setPickerField(null);
setActiveField(null);
};
// Typing inline where the list has no room under the field (a small
// split-screen window) hands over to the picker too.
React.useEffect(() => {
if (!activeField || pickerField) return;
if (!inputFor(activeField).trim() || suggestions.length === 0) return;
if (needsRecipientPicker({
shortHeight: false, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: activeFieldBottom,
})) {
openRecipientPicker(activeField);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeField, pickerField, suggestions.length, windowHeight, kbObstruction, activeFieldBottom]);
const renderRecipientField = (field: Field, label: string, placeholder: string, zIndex: number) => { const renderRecipientField = (field: Field, label: string, placeholder: string, zIndex: number) => {
const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients; const list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients;
const input = inputFor(field); const input = inputFor(field);
const setInput = inputSetterFor(field);
const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field; const invalidInput = typedRecipients[field].invalid.length > 0 && !typedRecipients[field].valid.length && input.trim().length > 0 && activeField !== field;
return ( return (
<View style={[styles.fieldRow, { zIndex }]}> <View
style={[styles.fieldRow, { zIndex }]}
ref={(view) => { fieldRowRefs.current[field] = view; }}
>
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text> <Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{label}</Text>
<View style={styles.recipientField}> <View style={styles.recipientField}>
{list.map((r, i) => ( {list.map((r, i) => (
@@ -2718,18 +2799,19 @@ export default function ComposeScreen({ route, navigation }: Props) {
placeholderTextColor={c.textMuted} placeholderTextColor={c.textMuted}
value={input} value={input}
onChangeText={(text) => { onChangeText={(text) => {
// A separator ends the entry: commit immediately so a pasted changeFieldInput(field, text);
// list turns into chips as it lands. measureFieldRow(field);
setInput(text); }}
if (/[,;\n]$/.test(text) && text.trim().length > 1) { onFocus={() => {
const { valid, invalid } = splitPastedRecipients(text, Array.from(alreadySelected)); setActiveField(field);
if (valid.length) { // A phone in landscape: no room for suggestions above the
setterFor(field)((prev) => [...prev, ...valid.map(fromParsed)]); // keyboard, so the full-screen picker takes the typing (U-069).
setInput(invalid.join(' ')); if (needsRecipientPicker({ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null })) {
} openRecipientPicker(field);
} else {
measureFieldRow(field);
} }
}} }}
onFocus={() => setActiveField(field)}
onBlur={() => { onBlur={() => {
setTimeout(() => { setTimeout(() => {
if (!isPickingSuggestion.current) { if (!isPickingSuggestion.current) {
@@ -2757,8 +2839,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
textContentType="none" textContentType="none"
/> />
</View> </View>
{activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && ( {activeField === field && !pickerField && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && (
<SuggestionList <RecipientSuggestionList
suggestions={suggestions} suggestions={suggestions}
onPick={pickSuggestion} onPick={pickSuggestion}
onPressIn={() => { onPressIn={() => {
@@ -2967,6 +3049,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChange={setBodyHtml} onChange={setBodyHtml}
onSelectionChange={setSelState} onSelectionChange={setSelState}
onViewportShift={onEditorViewportShift} onViewportShift={onEditorViewportShift}
mentions
onMentionQuery={setMentionQuery}
/> />
)} )}
{/* The empty space under the body looks like part of it (on a {/* The empty space under the body looks like part of it (on a
@@ -2981,6 +3065,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
/> />
</ScrollView> </ScrollView>
{!plainTextMode && (
<MentionSuggestionList items={mentionItems} onPick={pickMention} shortWindow={typingInShortWindow} />
)}
{/* A phone in landscape with the keyboard up has room for a few lines {/* A phone in landscape with the keyboard up has room for a few lines
of text only; the format bar comes back with the keyboard down. */} of text only; the format bar comes back with the keyboard down. */}
{!typingInShortWindow && ( {!typingInShortWindow && (
@@ -2997,6 +3085,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)} <ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
label={t('email_composer.read_receipt_off', 'Request a read receipt')} label={t('email_composer.read_receipt_off', 'Request a read receipt')}
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} /> icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
<ToolbarButton active={activeComposerOptionCount(composerOptions) > 0} onPress={() => setOptionsSheetOpen(true)}
label={t('email_composer.options', 'Options')}
icon={<Settings size={18} color={activeComposerOptionCount(composerOptions) > 0 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }} <ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
label={plainTextMode label={plainTextMode
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)') ? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
@@ -3479,6 +3570,38 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View> </View>
</Modal> </Modal>
{pickerField && (
<RecipientPicker
visible
label={pickerField === 'to' ? t('email_composer.to', 'To') : pickerField === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.bcc', 'Bcc')}
placeholder={pickerField === 'to'
? t('email_composer.to_placeholder', 'Recipient email addresses')
: pickerField === 'cc'
? t('email_composer.cc_placeholder', 'Cc recipients')
: t('email_composer.bcc_placeholder', 'Bcc recipients')}
recipients={pickerField === 'to' ? toRecipients : pickerField === 'cc' ? ccRecipients : bccRecipients}
isValid={chipIsValid}
input={inputFor(pickerField)}
onChangeInput={(text) => changeFieldInput(pickerField, text)}
onSubmitInput={() => addTyped(pickerField)}
suggestions={suggestions}
onPick={pickSuggestion}
onSearchServer={canSearchServer ? () => { void searchServer(); } : undefined}
searchingServer={searchingServer}
onRemove={(index) => removeChip(pickerField, index)}
onDone={closeRecipientPicker}
/>
)}
<ComposerOptionsSheet
visible={optionsSheetOpen}
options={composerOptions}
dsnAvailable={dsnAvailable}
requireTlsAvailable={requireTlsAvailable}
onChange={onComposerOptionsChange}
onClose={() => setOptionsSheetOpen(false)}
/>
<FilesPickerSheet <FilesPickerSheet
visible={filesPickerOpen} visible={filesPickerOpen}
mode="files" mode="files"
@@ -3597,19 +3720,6 @@ function makeStyles(c: ThemePalette) {
gap: spacing.xs, gap: spacing.xs,
paddingVertical: 4, paddingVertical: 4,
}, },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderRadius: radius.full,
backgroundColor: c.surfaceActive,
maxWidth: 220,
},
chipInvalid: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.error },
chipText: { ...typography.caption, color: c.text, flexShrink: 1 },
chipTextInvalid: { color: c.error },
recipientInput: { recipientInput: {
flexGrow: 1, flexGrow: 1,
minWidth: 100, minWidth: 100,
@@ -3618,42 +3728,6 @@ function makeStyles(c: ThemePalette) {
paddingVertical: 6, paddingVertical: 6,
}, },
recipientInputInvalid: { color: c.error }, recipientInputInvalid: { color: c.error },
suggestionBox: {
position: 'absolute',
top: '100%',
left: spacing.lg + 56 + spacing.md,
right: spacing.lg,
borderWidth: 1,
borderColor: c.borderLight,
borderRadius: radius.sm,
backgroundColor: c.card,
zIndex: 20,
elevation: 5,
overflow: 'hidden',
},
suggestionRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
suggestionRowPressed: { backgroundColor: c.surfaceHover },
suggestionScroll: { maxHeight: 320 },
suggestionSearchRowDivided: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: c.borderLight },
suggestionSearchIcon: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
suggestionSearchText: { ...typography.body, color: c.textSecondary, flex: 1 },
suggestionAvatar: {
width: 32, height: 32,
borderRadius: 16,
backgroundColor: c.primaryBg,
alignItems: 'center',
justifyContent: 'center',
},
suggestionAvatarText: { ...typography.captionMedium, color: c.primary },
suggestionText: { flex: 1 },
suggestionName: { ...typography.bodyMedium, color: c.text },
suggestionEmail: { ...typography.caption, color: c.textSecondary },
ccToggle: { ccToggle: {
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: 6, paddingVertical: 6,
@@ -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 // replayed by flush() once connectivity returns, and not before the auth store
// has resumed the account (see `resumed`). Storage is keyed per account, // has resumed the account (see `resumed`). Storage is keyed per account,
// mirroring offline-cache-store, so queues never leak between logins. // 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 { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -32,9 +37,14 @@ import {
} from '../api/email'; } from '../api/email';
import type { KeywordPatch } from '../lib/keyword-patch'; import type { KeywordPatch } from '../lib/keyword-patch';
import type { ArchiveMode } from './settings-store'; 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 KEY_PREFIX = 'webmail:outbox:v1:';
const FAILED_SUFFIX = ':failed'; const FAILED_SUFFIX = ':failed';
const SENDS_SUFFIX = ':sends';
// Give up on an op that the server keeps rejecting (a non-transient failure) // 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. // after this many attempts so one poison entry can't wedge the whole queue.
const MAX_ATTEMPTS = 5; const MAX_ATTEMPTS = 5;
@@ -86,6 +96,12 @@ interface OutboxState {
* op lands here so the optimistic edit is reverted. * op lands here so the optimistic edit is reverted.
*/ */
failed: OutboxEntry[]; 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; hydrated: boolean;
flushing: boolean; flushing: boolean;
/** Set after an authentication failure; cleared by setAccount()/resume(). */ /** Set after an authentication failure; cleared by setAccount()/resume(). */
@@ -120,6 +136,20 @@ interface OutboxState {
flush: () => Promise<void>; flush: () => Promise<void>;
/** Move every failed op back into the queue and flush. */ /** Move every failed op back into the queue and flush. */
retryFailed: () => Promise<void>; 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; discardFailed: () => void;
clear: () => Promise<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 { function persistFailed(accountId: string, failed: OutboxEntry[]): void {
void AsyncStorage.setItem(storageKey(accountId) + FAILED_SUFFIX, JSON.stringify(failed)).catch((err) => { void AsyncStorage.setItem(storageKey(accountId) + FAILED_SUFFIX, JSON.stringify(failed)).catch((err) => {
console.warn('[outbox] persist failed-list failed', err); console.warn('[outbox] persist failed-list failed', err);
@@ -264,7 +311,9 @@ function loadBucket(accountId: string): Promise<void> {
} }
const epoch = ++loadEpoch; const epoch = ++loadEpoch;
const promise = (async () => { 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(); const store = useOutboxStore.getState();
if (epoch !== loadEpoch || store.activeAccountId !== accountId) return; if (epoch !== loadEpoch || store.activeAccountId !== accountId) return;
// Ops the user made while the bucket was loading go after the stored // 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. // replaced it with only the new ops.
const early = store.entries; const early = store.entries;
const merged = early.length > 0 ? [...entries, ...early] : 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 (early.length > 0) persist(accountId, merged);
if (earlySends.length > 0) persistSends(accountId, mergedSends);
})().finally(() => { })().finally(() => {
if (bucketLoad?.promise === promise) bucketLoad = null; if (bucketLoad?.promise === promise) bucketLoad = null;
}); });
@@ -285,6 +337,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
activeAccountId: null, activeAccountId: null,
entries: [], entries: [],
failed: [], failed: [],
sends: [],
hydrated: false, hydrated: false,
flushing: false, flushing: false,
paused: false, paused: false,
@@ -301,7 +354,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
retryAttempt = 0; retryAttempt = 0;
// Each attach reads the bucket afresh; a read from before is dropped. // Each attach reads the bucket afresh; a read from before is dropped.
loadEpoch += 1; 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) { if (accountId) {
await loadBucket(accountId); await loadBucket(accountId);
} else { } else {
@@ -386,7 +439,7 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
if (get().flushing || get().paused || !get().resumed || !get().hydrated) return; if (get().flushing || get().paused || !get().resumed || !get().hydrated) return;
const accountId = get().activeAccountId; const accountId = get().activeAccountId;
if (!accountId) return; 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. // Need a live client that's actually serving this account, and a network.
if (!clientServes(accountId)) return; if (!clientServes(accountId)) return;
if (!useNetworkStore.getState().online) 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 { } finally {
set({ flushing: false }); set({ flushing: false });
} }
if (brokeTransient && get().entries.length > 0) scheduleRetry(); if (brokeTransient && (get().entries.length > 0 || hasWaitingSends())) scheduleRetry();
else if (changedInFlight) void get().flush(); else if (changedInFlight) void get().flush();
if (dropped) void refreshAfterDrop(); if (dropped) void refreshAfterDrop();
}, },
@@ -469,6 +528,56 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
await get().flush(); 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: () => { discardFailed: () => {
const accountId = get().activeAccountId; const accountId = get().activeAccountId;
set({ failed: [] }); set({ failed: [] });
@@ -478,29 +587,33 @@ export const useOutboxStore = create<OutboxState>((set, get) => ({
clear: async () => { clear: async () => {
const accountId = get().activeAccountId; const accountId = get().activeAccountId;
clearRetry(); clearRetry();
set({ entries: [], failed: [] }); set({ entries: [], failed: [], sends: [] });
if (accountId) { if (accountId) {
await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined);
} }
}, },
clearAccount: async (accountId) => { clearAccount: async (accountId) => {
const state = get(); const state = get();
const loaded = state.activeAccountId === accountId && state.hydrated; 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) { if (state.activeAccountId === accountId) {
clearRetry(); clearRetry();
// A read of the bucket still under way must not bring the ops back. // A read of the bucket still under way must not bring the ops back.
loadEpoch += 1; loadEpoch += 1;
set({ entries: [], failed: [], hydrated: true }); set({ entries: [], failed: [], sends: [], hydrated: true });
} }
if (!loaded) { if (!loaded) {
const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); const [entries, failed, sends] = await Promise.all([
dropped = entries.length + failed.length; 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)).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined);
await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined);
return dropped; return dropped;
}, },
})); }));
@@ -523,6 +636,111 @@ function removeEntry(accountId: string, id: string, sent?: OutboxOp): boolean {
return true; 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 { function moveToFailed(accountId: string, id: string, err: unknown): void {
const store = useOutboxStore.getState(); const store = useOutboxStore.getState();
if (store.activeAccountId !== accountId) return; if (store.activeAccountId !== accountId) return;