diff --git a/locales/rn/en.json b/locales/rn/en.json index 391f155c..f6ef4eb6 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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.", "section_personal": "My templates" }, + "outbox": { + "discard": "Discard", + "discard_body": "It will not be sent, and its text is lost.", + "discard_title": "Discard this message?", + "failed_count": "{count, plural, one {# message} other {# messages}} not sent", + "move_failed_body": "The message stays in the Outbox. Try again when the connection is back.", + "move_failed_title": "Could not move to Drafts", + "move_to_drafts": "Move to Drafts", + "open_hint": "Opens the Outbox", + "queued_body": "It goes out as soon as you are back online.", + "queued_title": "Saved to the Outbox", + "retry": "Try again", + "send_failed": "A message in the Outbox could not be sent", + "sent": "Message from the Outbox sent", + "status_failed": "Not sent", + "status_failed_reason": "Not sent: {reason}", + "status_offline": "Waiting for a connection", + "status_retrying": "Waiting to send", + "status_sending": "Sending…", + "title": "Outbox", + "waiting": "{count, plural, one {# message} other {# messages}} in the Outbox" + }, "push": { "ios": { "more_messages": "and {count} more", diff --git a/src/api/__tests__/email-send-options.test.ts b/src/api/__tests__/email-send-options.test.ts new file mode 100644 index 00000000..a9b80469 --- /dev/null +++ b/src/api/__tests__/email-send-options.test.ts @@ -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; + +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]>; + 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) => ({ 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' } }], + }); + }); +}); diff --git a/src/api/__tests__/live-stalwart-send-options.test.ts b/src/api/__tests__/live-stalwart-send-options.test.ts new file mode 100644 index 00000000..5d347b58 --- /dev/null +++ b/src/api/__tests__/live-stalwart-send-options.test.ts @@ -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); +}); diff --git a/src/api/__tests__/live-stalwart-send-queue.test.ts b/src/api/__tests__/live-stalwart-send-queue.test.ts new file mode 100644 index 00000000..0fd7e94b --- /dev/null +++ b/src/api/__tests__/live-stalwart-send-queue.test.ts @@ -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, 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); +}); diff --git a/src/api/__tests__/queued-send.test.ts b/src/api/__tests__/queued-send.test.ts new file mode 100644 index 00000000..fa93f669 --- /dev/null +++ b/src/api/__tests__/queued-send.test.ts @@ -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; + +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; 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: [''] }, + ])); + 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('', { 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' }] }, + ], + }); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 57e31f88..23f2853c 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -21,6 +21,8 @@ import { withInlineBodyParts } from '../lib/email-inline-parts'; import { stalwartStateWentBack } from './stalwart-state'; import { isTransientNetworkError } from '../lib/network-error'; 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 = [ '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. messageId?: string; 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 * alias on an owned domain is sent through the identity's envelope while @@ -1691,6 +1695,9 @@ function buildEmailCreate(email: OutgoingEmail): Record { 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; } @@ -1740,6 +1747,13 @@ export interface SendEmailOptions { draftAccountId?: string; /** Submitting account (shared/group account); defaults to the primary. */ 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( @@ -1766,18 +1780,26 @@ export async function sendEmail( const submissionCreate: Record = { emailId: '#draft', identityId }; // 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 - // server derive mailFrom from the Identity, dropping our parameter). An - // explicit envelope sender (catch-all From override) needs it as well. + // server derive mailFrom from the Identity, dropping our parameter). The + // 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; - 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 ?? [])] .map((r) => r.email.trim()) .filter(Boolean) - .map((address) => ({ email: address })); + .map((address) => (hasRcptToParams ? { email: address, parameters: parameters.rcptTo } : { email: address })); const mailFrom: Record = { 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 }; } @@ -2267,6 +2289,22 @@ export class RescheduleLostError extends Error { * and send time, and the recipients the server refused for the replacement * 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 | null | undefined, + names: string[], +): Record | 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( scheduled: { emailSubmissionId: string; @@ -2299,8 +2337,9 @@ export async function rescheduleScheduledSend( ], [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION], ); + type EnvelopeAddress = { email?: string; parameters?: Record | null }; 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; identityId?: string; }> | undefined)?.[0]; @@ -2313,7 +2352,15 @@ export async function rescheduleScheduledSend( const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined; const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])]; 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; // Without an answer the replacement is made without filing, and a message // that can't be put back stays where it is. @@ -2344,9 +2391,12 @@ export async function rescheduleScheduledSend( emailId: scheduled.emailId, identityId, envelope: { - mailFrom: seconds === undefined + mailFrom: seconds === undefined && !keptMailFromParams ? { email: mailFrom } - : { email: mailFrom, parameters: { HOLDFOR: String(seconds) } }, + : { + email: mailFrom, + parameters: { ...keptMailFromParams, ...(seconds === undefined ? {} : { HOLDFOR: String(seconds) }) }, + }, rcptTo, }, }, diff --git a/src/api/queued-send.ts b/src/api/queued-send.ts new file mode 100644 index 00000000..d9503310 --- /dev/null +++ b/src/api/queued-send.ts @@ -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 { + 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; 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 { + 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 }; +} diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx index 92352ee7..e517d23b 100644 --- a/src/components/OfflineBanner.tsx +++ b/src/components/OfflineBanner.tsx @@ -1,12 +1,14 @@ import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { CloudOff } from './icons'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { CloudOff, Send } from './icons'; import { useNetworkStore } from '../stores/network-store'; import { useOutboxStore } from '../stores/outbox-store'; import { useLocaleStore } from '../stores/locale-store'; import { spacing, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { announceConnectivity } from '../lib/announce'; +import { OutboxSheet } from './compose/OutboxSheet'; +import { outboxHint } from '../lib/send-queue'; interface OfflineBannerProps { /** Optional extra notice (e.g. "Showing cached mail"). */ @@ -20,26 +22,58 @@ export function OfflineBanner({ hint }: OfflineBannerProps) { const t = useLocaleStore((s) => s.t); // Pending offline mutations that will replay once we're back online. const queued = useOutboxStore((s) => s.entries.length); + // Messages sent while offline (the Outbox). Counted outside the selector: + // a selector must not build a new value on every call. + const sends = useOutboxStore((s) => s.sends); + const waitingSends = sends.filter((s) => s.status !== 'failed').length; + const failedSends = sends.length - waitingSends; + const [outboxOpen, setOutboxOpen] = React.useState(false); const offlineLabel = t('offline.banner', 'You are offline'); // VoiceOver hears the drop too (Android reads the live region below). React.useEffect(() => { announceConnectivity(online, offlineLabel); }, [online, offlineLabel]); - if (online) return null; - const queuedHint = queued > 0 + const sendsHint = outboxHint(waitingSends, failedSends, t); + if (online && !sendsHint) return null; + const queuedHint = !online && queued > 0 ? t( 'offline.queued_changes', '{count, plural, one {# change} other {# changes}} will sync when you reconnect', { count: queued }, ) : null; - const suffix = [hint, queuedHint].filter(Boolean).join(' · '); - const label = offlineLabel; + const label = online ? sendsHint! : offlineLabel; + const suffix = [online ? null : hint, queuedHint, online ? null : sendsHint].filter(Boolean).join(' · '); + const text = suffix ? `${label} — ${suffix}` : label; + const sheet = setOutboxOpen(false)} />; + if (sends.length > 0) { + // With messages in the Outbox the bar opens it. + return ( + <> + setOutboxOpen(true)} + accessibilityRole="button" + accessibilityLabel={text} + accessibilityHint={t('outbox.open_hint', 'Opens the Outbox')} + accessibilityLiveRegion="polite" + > + {online + ? 0 ? c.error : c.textSecondary} /> + : } + 0 && styles.textFailed]} numberOfLines={1}> + {text} + + + {sheet} + + ); + } return ( - {suffix ? `${label} — ${suffix}` : label} + {text} ); @@ -60,5 +94,11 @@ function makeStyles(c: ThemePalette) { color: c.warningForeground, flex: 1, }, + barOutbox: { + backgroundColor: c.surfaceActive, + minHeight: 32, + }, + textOutbox: { color: c.text }, + textFailed: { color: c.error }, }); } diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 972f9fb0..437988d6 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -60,6 +60,11 @@ export interface RichTextEditorHandle { setImageAlt(cid: string, alt: string): void; /** Insert an HTML fragment at the caret (template bodies, tables). */ 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; /** * Read the live editor DOM content. Resolves with the current innerHTML or @@ -94,12 +99,16 @@ interface Props { collapseQuote?: boolean; /** The quote toggle's accessible names. */ 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(function RichTextEditor( { initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift, - collapseQuote = false, quoteToggleLabels, + collapseQuote = false, quoteToggleLabels, mentions = false, onMentionQuery, }, ref, ) { @@ -113,6 +122,8 @@ const RichTextEditor = React.forwardRef(function Ri const onBlurRef = React.useRef(onBlur); const onViewportShiftRef = React.useRef(onViewportShift); onViewportShiftRef.current = onViewportShift; + const onMentionQueryRef = React.useRef(onMentionQuery); + onMentionQueryRef.current = onMentionQuery; onChangeRef.current = onChange; onSelectionRef.current = onSelectionChange; onFocusRef.current = onFocus; @@ -123,7 +134,7 @@ const RichTextEditor = React.forwardRef(function Ri const html = React.useMemo( () => buildEditorHtml({ 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 // edits on a theme change. Bake in the initial colors only. @@ -202,6 +213,9 @@ const RichTextEditor = React.forwardRef(function Ri insertHtml: (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) => { call(`window.__rne && window.__rne.setHtml(${JSON.stringify(next)})`); }, @@ -276,6 +290,9 @@ const RichTextEditor = React.forwardRef(function Ri case 'blur': onBlurRef.current?.(); break; + case 'mention': + onMentionQueryRef.current?.(typeof data.payload === 'string' ? data.payload : null); + break; case 'viewportShift': { const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload); if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy); diff --git a/src/components/compose/ComposerOptionsSheet.tsx b/src/components/compose/ComposerOptionsSheet.tsx new file mode 100644 index 00000000..268db679 --- /dev/null +++ b/src/components/compose/ComposerOptionsSheet.tsx @@ -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) => 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') => ( + onChange({ [key]: !value })} + accessibilityRole="switch" + accessibilityLabel={label} + accessibilityHint={hint} + accessibilityState={{ checked: value }} + > + + {label} + {hint} + + onChange({ [key]: v })} accessibilityLabel={label} /> + + ); + + return ( + + + {}}> + {t('email_composer.options', 'Options')} + {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', + )} + + {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 ( + onChange({ priority: level })} + accessibilityRole="radio" + accessibilityLabel={label} + accessibilityState={{ checked: selected, selected }} + testID={`composer-priority-${level}`} + > + {label} + {selected && } + + ); + })} + + + {t('common.done', 'Done')} + + + + + ); +} + +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 }, + }); +} diff --git a/src/components/compose/MentionSuggestionList.tsx b/src/components/compose/MentionSuggestionList.tsx new file mode 100644 index 00000000..6e57af41 --- /dev/null +++ b/src/components/compose/MentionSuggestionList.tsx @@ -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 ( + + + {items.map((item) => ( + onPick(item)} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + accessibilityRole="button" + accessibilityLabel={`@${item.label}, ${item.name && item.name !== item.label ? `${item.name}, ` : ''}${item.email}`} + > + + {getEmailInitials((item.name ?? '').trim(), item.email)} + + {/* What goes in comes first: for "Nagy János" that may be the + surname, which the full name alone would not show. */} + @{item.label} + + {item.name && item.name !== item.label ? `${item.name} · ${item.email}` : item.email} + + {/* Picking a contact also adds them as a recipient; say so first. */} + {item.addsTo && ( + + + {item.addsTo === 'cc' ? t('email_composer.cc', 'Cc') : t('email_composer.to', 'To')} + + )} + + ))} + + + ); +} + +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', + }, + }); +} diff --git a/src/components/compose/OutboxSheet.tsx b/src/components/compose/OutboxSheet.tsx new file mode 100644 index 00000000..3d5de3fe --- /dev/null +++ b/src/components/compose/OutboxSheet.tsx @@ -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(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 ( + + + {}}> + {t('outbox.title', 'Outbox')} + + {sends.map((send) => { + const busy = busyId === send.id || send.status === 'sending'; + const cancellable = canCancel(send) && busyId !== send.id; + return ( + + + {send.payload.outgoing.subject || t('email_viewer.no_subject', '(No subject)')} + + {recipientsOf(send)} + + {busy && } + + {queuedSendStatus(send, online, t)} + + + + {send.status === 'failed' && ( + { void useOutboxStore.getState().retrySend(send.id); }} + accessibilityRole="button" + > + {t('outbox.retry', 'Try again')} + + )} + {online && ( + { void moveToDrafts(send); }} + accessibilityRole="button" + accessibilityState={{ disabled: !cancellable }} + > + + {t('outbox.move_to_drafts', 'Move to Drafts')} + + + )} + discard(send)} + accessibilityRole="button" + accessibilityState={{ disabled: !cancellable }} + > + + {t('outbox.discard', 'Discard')} + + + + + ); + })} + + + {t('common.close', 'Close')} + + + + + ); +} + +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 }, + }); +} diff --git a/src/components/compose/RecipientPicker.tsx b/src/components/compose/RecipientPicker.tsx new file mode 100644 index 00000000..6282bbca --- /dev/null +++ b/src/components/compose/RecipientPicker.tsx @@ -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({ + 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(null); + const showList = suggestions.length > 0 || (!!onSearchServer && !!input.trim()); + + return ( + inputRef.current?.focus()} + > + + + + + + + {label} + + {t('common.done', 'Done')} + + + + {recipients.map((r, i) => ( + onRemove(i)} + /> + ))} + { + if (input.trim()) onSubmitInput(); + else onDone(); + }} + blurOnSubmit={false} + keyboardType="email-address" + autoCapitalize="none" + autoCorrect={false} + autoComplete="off" + importantForAutofill="no" + textContentType="none" + /> + + {showList ? ( + + ) : ( + + )} + + + + ); +} + +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 }, + }); +} diff --git a/src/components/compose/RecipientSuggestionList.tsx b/src/components/compose/RecipientSuggestionList.tsx new file mode 100644 index 00000000..972fc93b --- /dev/null +++ b/src/components/compose/RecipientSuggestionList.tsx @@ -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 ( + + {recipient.group && } + + {label} + + + + + + ); +} + +/** + * 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 ( + + + {suggestions.map((s, i) => ( + onPick(s)} + style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]} + > + + {s.group + ? + : {getEmailInitials(s.name.trim(), s.email)}} + + + + {s.name || s.email} + + {s.group ? ( + + {t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })} + + ) : !!s.name && ( + {s.email} + )} + + + ))} + {onSearchServer && ( + { if (!searchingServer) onSearchServer(); }} + disabled={searchingServer} + accessibilityRole="button" + accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }} + style={({ pressed }) => [ + styles.suggestionRow, + suggestions.length > 0 && styles.suggestionSearchRowDivided, + pressed && styles.suggestionRowPressed, + ]} + > + + {searchingServer + ? + : } + + + {searchingServer + ? t('email_composer.autocomplete_searching', 'Searching...') + : t('email_composer.autocomplete_search_server', 'Search the server')} + + + )} + + + ); +} + +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 }, + }); +} diff --git a/src/lib/__tests__/compose-html.test.ts b/src/lib/__tests__/compose-html.test.ts index 1915fdb5..f0a81c19 100644 --- a/src/lib/__tests__/compose-html.test.ts +++ b/src/lib/__tests__/compose-html.test.ts @@ -185,6 +185,11 @@ describe('htmlToPlainText links', () => { it('drops the mailto: prefix when the text is the address', () => { expect(htmlToPlainText('a@b.c')).toBe('a@b.c'); }); + + it('writes an @mention as the name alone, as the webmail does', () => { + expect(htmlToPlainText('

Hi @Max how

', { mailMarkup: true })) + .toBe('Hi @Max how'); + }); }); describe('extractUserAuthoredText', () => { diff --git a/src/lib/__tests__/compose-mentions-wiring.test.ts b/src/lib/__tests__/compose-mentions-wiring.test.ts new file mode 100644 index 00000000..f4957688 --- /dev/null +++ b/src/lib/__tests__/compose-mentions-wiring.test.ts @@ -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(/', 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(/ { + it('opens the options sheet from the toolbar', () => { + expect(source).toMatch(/onPress=\{\(\) => setOptionsSheetOpen\(true\)\}/); + expect(source).toMatch(/ { + 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\),/); + }); +}); diff --git a/src/lib/__tests__/compose-send-queue-wiring.test.ts b/src/lib/__tests__/compose-send-queue-wiring.test.ts new file mode 100644 index 00000000..bf2f126e --- /dev/null +++ b/src/lib/__tests__/compose-send-queue-wiring.test.ts @@ -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(/([\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 } + +function boot(mentions: boolean) { + const listeners: Record void>> = {}; + const docListeners: Record 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 = { + 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: '


', 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 ', '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 '); + }); + + 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([]); + }); +}); diff --git a/src/lib/__tests__/recipient-mentions.test.ts b/src/lib/__tests__/recipient-mentions.test.ts new file mode 100644 index 00000000..64b429ce --- /dev/null +++ b/src/lib/__tests__/recipient-mentions.test.ts @@ -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) => 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); + }); +}); diff --git a/src/lib/__tests__/recipient-picker.test.ts b/src/lib/__tests__/recipient-picker.test.ts new file mode 100644 index 00000000..4d9a8aeb --- /dev/null +++ b/src/lib/__tests__/recipient-picker.test.ts @@ -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(' { + 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\(\);/); + }); +}); diff --git a/src/lib/__tests__/send-queue.test.ts b/src/lib/__tests__/send-queue.test.ts new file mode 100644 index 00000000..aed61cba --- /dev/null +++ b/src/lib/__tests__/send-queue.test.ts @@ -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) => + (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'); + }); +}); diff --git a/src/lib/__tests__/submission-options.test.ts b/src/lib/__tests__/submission-options.test.ts new file mode 100644 index 00000000..f18e8a22 --- /dev/null +++ b/src/lib/__tests__/submission-options.test.ts @@ -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'); + }); +}); diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index a0135d73..f838f677 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -154,6 +154,9 @@ function blocksToPlainText(html: string, mailMarkup: boolean): string { if (!href) return inner; const normalizedHref = href.replace(/^mailto:/i, ''); 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. return `${text} \u0001${href}\u0002`; }); diff --git a/src/lib/draft-context.ts b/src/lib/draft-context.ts index 370436a4..c2fb9831 100644 --- a/src/lib/draft-context.ts +++ b/src/lib/draft-context.ts @@ -3,6 +3,7 @@ import type { Email } from '../api/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 @@ -37,6 +38,8 @@ export function draftContextFromEmail(email: Email, jmapAccountId?: string): Com messageId: email.messageId ?? undefined, inReplyTo: email.inReplyTo ?? undefined, references: email.references ?? undefined, + // Read back from the headers it was saved with (webmail #970). + priority: messagePriorityFromHeaders(email.headers), }; } diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 40ba1e33..b5a539ee 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -100,6 +100,31 @@ export const FOCUS_END_SOURCE = `function focusEnd(editor) { 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 * document (`source={{ html }}` loads as about:blank on both platforms, as @@ -150,8 +175,14 @@ export function buildEditorHtml(opts: { collapseQuote?: boolean; /** The toggle's accessible names. */ 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 { - 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' }; // Inject initial content as a JSON-encoded string so any HTML/quotes inside // are safely embedded (no template literal collision with the script body, @@ -202,6 +233,7 @@ export function buildEditorHtml(opts: { color: ${c.textSecondary}; } #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 pre, #editor code { background: ${c.surfaceActive}; @@ -417,12 +449,46 @@ export function buildEditorHtml(opts: { 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 () { scheduleChange(); reportHeight(); + reportMention(); }); editor.addEventListener('blur', function () { flushChange(); + reportMention(null); post('blur', null); }); editor.addEventListener('focus', function () { post('focus', null); }); @@ -430,6 +496,7 @@ export function buildEditorHtml(opts: { if (document.activeElement === editor) { revealQuoteAtCaret(); reportSelection(); + reportMention(); } }); @@ -541,6 +608,37 @@ export function buildEditorHtml(opts: { }, focus: function () { editor.focus(); }, 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. setImageAlt: function (cid, alt) { var images = editor.getElementsByTagName('img'); diff --git a/src/lib/message-priority.ts b/src/lib/message-priority.ts new file mode 100644 index 00000000..dee582c3 --- /dev/null +++ b/src/lib/message-priority.ts @@ -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 { + 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'; +} diff --git a/src/lib/recipient-mentions.ts b/src/lib/recipient-mentions.ts new file mode 100644 index 00000000..051b0d6c --- /dev/null +++ b/src/lib/recipient-mentions.ts @@ -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 = { ä: '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 { + const seen = new Set(); + const clashes = new Set(); + 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( + 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)]; +} diff --git a/src/lib/recipient-picker.ts b/src/lib/recipient-picker.ts new file mode 100644 index 00000000..2f5aebe6 --- /dev/null +++ b/src/lib/recipient-picker.ts @@ -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; +} diff --git a/src/lib/send-queue.ts b/src/lib/send-queue.ts new file mode 100644 index 00000000..a4a4b0ca --- /dev/null +++ b/src/lib/send-queue.ts @@ -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; +} + +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; + +/** 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'; +} diff --git a/src/lib/submission-options.ts b/src/lib/submission-options.ts new file mode 100644 index 00000000..182c52bd --- /dev/null +++ b/src/lib/submission-options.ts @@ -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; rcptTo: Record } { + const mailFrom: Record = {}; + const rcptTo: Record = {}; + 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) + .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); +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts index ac96d074..982e58de 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -1,4 +1,5 @@ import type { Attachment, EmailAddress } from '../api/types'; +import type { MessagePriority } from '../lib/message-priority'; /** The message a reply / forward / draft edit starts from. */ export interface ComposeReplyContext { @@ -47,6 +48,8 @@ export interface ComposeDraftContext { messageId?: string[] | null; inReplyTo?: string[] | null; references?: string[] | null; + /** Read back from the draft's X-Priority / Importance headers (webmail #970). */ + priority?: MessagePriority; } export type RootStackParamList = { diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index b0997960..868396da 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -11,7 +11,7 @@ import { List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, - Users, Tag, Type, Highlighter, Trash2, Search, + Tag, Type, Highlighter, Trash2, Settings, } from '../components/icons'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; @@ -28,7 +28,6 @@ import RichTextEditor, { } from '../components/RichTextEditor'; import { useEmailStore } from '../stores/email-store'; import { ownMailboxes } from '../lib/mailbox-tree'; -import { getEmailInitials } from '../lib/avatar-utils'; import { useContactsStore, type RecipientSuggestion } from '../stores/contacts-store'; import { useKeyboardObstruction } from '../lib/use-keyboard-obstruction'; import { useLayout } from '../lib/use-layout'; @@ -77,6 +76,20 @@ import { inlineImageType } from '../lib/compose-inline-type'; import { attachmentFromFileNode } from '../lib/compose-files'; import { supportsFiles } from '../api/files'; 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 { assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin, } from '../lib/compose-login'; @@ -211,118 +224,6 @@ function quoteLines(text: string): string { 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 ( - - {recipient.group && } - - {label} - - - - - - ); -} - -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 ( - - - {suggestions.map((s, i) => ( - onPick(s)} - style={({ pressed }) => [styles.suggestionRow, pressed && styles.suggestionRowPressed]} - > - - {s.group - ? - : {initialsOf(s.name, s.email)}} - - - - {s.name || s.email} - - {s.group ? ( - - {t('contacts.groups.member_count', '{count, plural, =0 {No members} one {1 member} other {# members}}', { count: s.group.memberCount })} - - ) : !!s.name && ( - {s.email} - )} - - - ))} - {onSearchServer && ( - { if (!searchingServer) onSearchServer(); }} - disabled={searchingServer} - accessibilityRole="button" - accessibilityState={{ busy: !!searchingServer, disabled: !!searchingServer }} - style={({ pressed }) => [ - styles.suggestionRow, - suggestions.length > 0 && styles.suggestionSearchRowDivided, - pressed && styles.suggestionRowPressed, - ]} - > - - {searchingServer - ? - : } - - - {searchingServer - ? t('email_composer.autocomplete_searching', 'Searching...') - : t('email_composer.autocomplete_search_server', 'Search the server')} - - - )} - - - ); -} - function AttachmentChip({ attachment, onRemove, onPress, }: { @@ -722,6 +623,27 @@ export default function ComposeScreen({ route, navigation }: Props) { const [activeField, setActiveField] = React.useState(null); const [attachments, setAttachments] = React.useState(initialAttachments); 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(initialPriority); + const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false); + const composerOptions = React.useMemo( + () => ({ 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) => { + 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 [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null); const [selState, setSelState] = React.useState({ @@ -738,7 +660,7 @@ export default function ComposeScreen({ route, navigation }: Props) { }), [t]); // The label column grows with the system font size: at 1.3 "Betreff" // 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 subjectRef = React.useRef(null); const plainBodyRef = React.useRef(null); @@ -795,7 +717,16 @@ export default function ComposeScreen({ route, navigation }: Props) { const inputFor = (field: Field | null) => 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(null); + const pickerClosedAtRef = React.useRef(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>>({}); + const [activeFieldBottom, setActiveFieldBottom] = React.useState(null); + const suggestField = pickerField ?? activeField; + const suggestionQuery = inputFor(suggestField); const alreadySelected = React.useMemo( () => new Set( [...toRecipients, ...ccRecipients, ...bccRecipients] @@ -841,7 +772,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const pickSuggestion = (s: RecipientSuggestion) => { isPickingSuggestion.current = true; - const field = activeField ?? 'to'; + const field = suggestField ?? 'to'; let recipient: Recipient; if (s.group) { 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; }, [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(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(() => { + 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 // signature, also when it has none (webmail identityPickedByUser). const [identityPickedByUser, setIdentityPickedByUser] = React.useState(false); @@ -1187,6 +1159,8 @@ export default function ComposeScreen({ route, navigation }: Props) { const snapshotOf = (s: { to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string; attachments: AttachmentEntry[]; + /** A priority change alone is an edit worth saving (webmail #970). */ + priority?: MessagePriority; }) => JSON.stringify({ 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)]), @@ -1194,18 +1168,20 @@ export default function ComposeScreen({ route, navigation }: Props) { subject: s.subject, body: s.body, 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 // for plenty that doesn't touch it (PF8). const currentSnapshot = React.useMemo(() => snapshotOf({ 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 - }), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments]); + }), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments, priority]); const baselineRef = React.useRef(snapshotOf({ to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject, body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments, + priority: initialPriority, })); // Snapshot at the last successful draft save (null = never saved). const lastSavedRef = React.useRef(draft ? baselineRef.current : null); @@ -1464,9 +1440,10 @@ export default function ComposeScreen({ route, navigation }: Props) { references: threading?.references, messageId: messageIdRef.current, requestReadReceipt, + priority: priority !== 'normal' ? priority : undefined, 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). const saveDraftOnce = async (opts: { live: boolean }): Promise => { @@ -1485,7 +1462,7 @@ export default function ComposeScreen({ route, navigation }: Props) { } const snapshot = snapshotOf({ 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 === baselineRef.current && !draftIdRef.current) return null; @@ -1682,7 +1659,7 @@ export default function ComposeScreen({ route, navigation }: Props) { } const snapshot = snapshotOf({ 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; }; @@ -2542,9 +2519,48 @@ export default function ComposeScreen({ route, navigation }: Props) { // notification tap to switch logins. if (refuseSendAfterSwitch()) return; + // Offline, or the connection fails on the way: the message waits in the + // Outbox and goes out once the app is back online (parity 04/09/10 + // "offline send"). The Outbox takes over the autosaved draft, which it + // removes once the message is sent. + let queuedOutgoing: OutgoingEmail | null = null; + const queueForLater = (outgoing: OutgoingEmail, maybeSent: boolean): boolean => { + if (!primaryIdentity || !sentMailbox || loginMoved()) return false; + const outbox = useOutboxStore.getState(); + // Only into the queue of the login the message was written in. + if (outbox.activeAccountId !== ownerLoginRef.current) return false; + const queuedId = outbox.enqueueSend({ + outgoing, + identity: primaryIdentity, + fromEmail: outgoing.from[0]?.email ?? primaryIdentity.email, + sentMailboxId: sentMailbox.id, + draftsMailboxId: draftsMailbox?.id, + draftId: draftIdRef.current ?? undefined, + draftAccountId: draftIdRef.current ? draftAccount() : undefined, + requestDsn, + requireTls, + scheduledAt: scheduledAt?.toISOString(), + original: replyTo?.originalEmailId + ? { emailId: replyTo.originalEmailId, accountId: replyTo.jmapAccountId, keyword: mode === 'forward' ? '$forwarded' : '$answered' } + : undefined, + }, { maybeSent }); + if (!queuedId) return false; + draftIdRef.current = null; + lastSavedRef.current = null; + toast.info( + t('outbox.queued_title', 'Saved to the Outbox'), + { message: t('outbox.queued_body', 'It goes out as soon as you are back online.') }, + ); + allowLeaveRef.current = true; + navigation.goBack(); + return true; + }; + setSending(true); try { const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false }); + queuedOutgoing = outgoing; + if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return; // The account the submission is held in, for Undo (read before the // request: a switch may land while it is out). // A group or shared identity sends from its own account, and its Sent @@ -2562,6 +2578,10 @@ export default function ComposeScreen({ route, navigation }: Props) { draftId: draftIdRef.current ?? undefined, draftAccountId: draftIdRef.current ? draftAccount() : undefined, 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; @@ -2631,6 +2651,12 @@ export default function ComposeScreen({ route, navigation }: Props) { allowLeaveRef.current = true; navigation.goBack(); } catch (e) { + // The connection failed: the request may have got through, so the + // Outbox looks for the message before it sends it again. + if (queuedOutgoing && shouldQueueFailedSend(classifySendFailure(e, isTransientNetworkError)) + && queueForLater(queuedOutgoing, true)) { + return; + } if (e instanceof RequestTimeoutError) { // The request may have reached the server; a blind retry would send // the message twice (#702). @@ -2690,13 +2716,68 @@ export default function ComposeScreen({ route, navigation }: Props) { : 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 list = field === 'to' ? toRecipients : field === 'cc' ? ccRecipients : bccRecipients; 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; return ( - + { fieldRowRefs.current[field] = view; }} + > {label} {list.map((r, i) => ( @@ -2718,18 +2799,19 @@ export default function ComposeScreen({ route, navigation }: Props) { placeholderTextColor={c.textMuted} value={input} onChangeText={(text) => { - // A separator ends the entry: commit immediately so a pasted - // list turns into chips as it lands. - 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(' ')); - } + changeFieldInput(field, text); + measureFieldRow(field); + }} + onFocus={() => { + setActiveField(field); + // A phone in landscape: no room for suggestions above the + // keyboard, so the full-screen picker takes the typing (U-069). + if (needsRecipientPicker({ shortHeight, windowHeight, keyboardObstruction: kbObstruction, fieldBottom: null })) { + openRecipientPicker(field); + } else { + measureFieldRow(field); } }} - onFocus={() => setActiveField(field)} onBlur={() => { setTimeout(() => { if (!isPickingSuggestion.current) { @@ -2757,8 +2839,8 @@ export default function ComposeScreen({ route, navigation }: Props) { textContentType="none" /> - {activeField === field && (suggestions.length > 0 || (canSearchServer && !!input.trim())) && ( - 0 || (canSearchServer && !!input.trim())) && ( + { @@ -2967,6 +3049,8 @@ export default function ComposeScreen({ route, navigation }: Props) { onChange={setBodyHtml} onSelectionChange={setSelState} onViewportShift={onEditorViewportShift} + mentions + onMentionQuery={setMentionQuery} /> )} {/* The empty space under the body looks like part of it (on a @@ -2981,6 +3065,10 @@ export default function ComposeScreen({ route, navigation }: Props) { /> + {!plainTextMode && ( + + )} + {/* 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. */} {!typingInShortWindow && ( @@ -2997,6 +3085,9 @@ export default function ComposeScreen({ route, navigation }: Props) { setRequestReadReceipt((v) => !v)} label={t('email_composer.read_receipt_off', 'Request a read receipt')} icon={} /> + 0} onPress={() => setOptionsSheetOpen(true)} + label={t('email_composer.options', 'Options')} + icon={ 0 ? c.primary : c.textSecondary} />} /> { void togglePlainTextMode(); }} label={plainTextMode ? t('email_composer.format_rich_text', 'Switch to rich text (HTML)') @@ -3479,6 +3570,38 @@ export default function ComposeScreen({ route, navigation }: Props) { + {pickerField && ( + changeFieldInput(pickerField, text)} + onSubmitInput={() => addTyped(pickerField)} + suggestions={suggestions} + onPick={pickSuggestion} + onSearchServer={canSearchServer ? () => { void searchServer(); } : undefined} + searchingServer={searchingServer} + onRemove={(index) => removeChip(pickerField, index)} + onDone={closeRecipientPicker} + /> + )} + + setOptionsSheetOpen(false)} + /> + ({ 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 => ({ 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(); + }); +}); diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 7f78a33b..3dd97d8f 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -19,6 +19,11 @@ // replayed by flush() once connectivity returns, and not before the auth store // has resumed the account (see `resumed`). Storage is keyed per account, // mirroring offline-cache-store, so queues never leak between logins. +// +// Messages sent while offline wait here too (`sends`, see lib/send-queue.ts): +// the same flush sends them after the mutation ops, once each, looking for +// a copy of the message first whenever an earlier attempt may have got +// through. import { create } from 'zustand'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -32,9 +37,14 @@ import { } from '../api/email'; import type { KeywordPatch } from '../lib/keyword-patch'; import type { ArchiveMode } from './settings-store'; +import { + afterFailure, canCancel, classifySendFailure, newQueuedSend, restoreAfterRestart, startAttempt, + type QueuedSend, type QueuedSendPayload, +} from '../lib/send-queue'; const KEY_PREFIX = 'webmail:outbox:v1:'; const FAILED_SUFFIX = ':failed'; +const SENDS_SUFFIX = ':sends'; // Give up on an op that the server keeps rejecting (a non-transient failure) // after this many attempts so one poison entry can't wedge the whole queue. const MAX_ATTEMPTS = 5; @@ -86,6 +96,12 @@ interface OutboxState { * op lands here so the optimistic edit is reverted. */ failed: OutboxEntry[]; + /** + * Messages sent while offline, oldest first: waiting, on their way, or + * failed (kept until the user retries, moves them to Drafts or discards + * them). + */ + sends: QueuedSend[]; hydrated: boolean; flushing: boolean; /** Set after an authentication failure; cleared by setAccount()/resume(). */ @@ -120,6 +136,20 @@ interface OutboxState { flush: () => Promise; /** Move every failed op back into the queue and flush. */ retryFailed: () => Promise; + /** + * 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; + /** + * 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; discardFailed: () => void; clear: () => Promise; /** @@ -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 { + try { + const raw = await AsyncStorage.getItem(storageKey(accountId) + SENDS_SUFFIX); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? restoreAfterRestart(parsed as QueuedSend[]) : []; + } catch (err) { + console.warn('[outbox] hydrate sends failed', err); + return []; + } +} + function persistFailed(accountId: string, failed: OutboxEntry[]): void { void AsyncStorage.setItem(storageKey(accountId) + FAILED_SUFFIX, JSON.stringify(failed)).catch((err) => { console.warn('[outbox] persist failed-list failed', err); @@ -264,7 +311,9 @@ function loadBucket(accountId: string): Promise { } const epoch = ++loadEpoch; const promise = (async () => { - const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); + const [entries, failed, sends] = await Promise.all([ + load(accountId), load(accountId, FAILED_SUFFIX), loadSends(accountId), + ]); const store = useOutboxStore.getState(); if (epoch !== loadEpoch || store.activeAccountId !== accountId) return; // Ops the user made while the bucket was loading go after the stored @@ -272,8 +321,11 @@ function loadBucket(accountId: string): Promise { // replaced it with only the new ops. const early = store.entries; const merged = early.length > 0 ? [...entries, ...early] : entries; - useOutboxStore.setState({ entries: merged, failed, hydrated: true }); + const earlySends = store.sends; + const mergedSends = earlySends.length > 0 ? [...sends, ...earlySends] : sends; + useOutboxStore.setState({ entries: merged, failed, sends: mergedSends, hydrated: true }); if (early.length > 0) persist(accountId, merged); + if (earlySends.length > 0) persistSends(accountId, mergedSends); })().finally(() => { if (bucketLoad?.promise === promise) bucketLoad = null; }); @@ -285,6 +337,7 @@ export const useOutboxStore = create((set, get) => ({ activeAccountId: null, entries: [], failed: [], + sends: [], hydrated: false, flushing: false, paused: false, @@ -301,7 +354,7 @@ export const useOutboxStore = create((set, get) => ({ retryAttempt = 0; // Each attach reads the bucket afresh; a read from before is dropped. loadEpoch += 1; - set({ activeAccountId: accountId, entries: [], failed: [], hydrated: false, paused: false, resumed: false }); + set({ activeAccountId: accountId, entries: [], failed: [], sends: [], hydrated: false, paused: false, resumed: false }); if (accountId) { await loadBucket(accountId); } else { @@ -386,7 +439,7 @@ export const useOutboxStore = create((set, get) => ({ if (get().flushing || get().paused || !get().resumed || !get().hydrated) return; const accountId = get().activeAccountId; if (!accountId) return; - if (get().entries.length === 0) return; + if (get().entries.length === 0 && !hasWaitingSends()) return; // Need a live client that's actually serving this account, and a network. if (!clientServes(accountId)) return; if (!useNetworkStore.getState().online) return; @@ -446,10 +499,16 @@ export const useOutboxStore = create((set, get) => ({ } } } + // Queued messages go after the mutation ops, and only while the + // connection holds. + if (!brokeTransient && !get().paused && hasWaitingSends()) { + const result = await flushSends(accountId); + if (result === 'transient') brokeTransient = true; + } } finally { set({ flushing: false }); } - if (brokeTransient && get().entries.length > 0) scheduleRetry(); + if (brokeTransient && (get().entries.length > 0 || hasWaitingSends())) scheduleRetry(); else if (changedInFlight) void get().flush(); if (dropped) void refreshAfterDrop(); }, @@ -469,6 +528,56 @@ export const useOutboxStore = create((set, get) => ({ await get().flush(); }, + enqueueSend: (payload, opts) => { + const accountId = get().activeAccountId; + if (!accountId) { + console.warn('[outbox] enqueueSend with no active account'); + return null; + } + const send = newQueuedSend(generateUUID(), payload, Date.now(), !!opts?.maybeSent); + const sends = [...get().sends, send]; + set({ sends }); + persistSends(accountId, sends); + void get().flush(); + return send.id; + }, + + retrySend: async (id) => { + const accountId = get().activeAccountId; + const send = get().sends.find((s) => s.id === id); + if (!accountId || !send || send.status !== 'failed') return; + // The attempts start over; an unconfirmed one keeps its mark, so the + // message is looked for first. + updateSend(accountId, { ...send, status: 'queued', attempts: 0, lastError: undefined }); + set({ paused: false }); + await get().flush(); + }, + + cancelSend: async (id, opts) => { + const accountId = get().activeAccountId; + const send = get().sends.find((s) => s.id === id); + if (!accountId || !send || !canCancel(send)) return false; + // Out of the queue first, so a flush can't send it while it is saved. + removeSend(accountId, id); + if (!opts?.keepAsDraft) return true; + const p = send.payload; + try { + if (!p.draftsMailboxId) throw new Error('No Drafts folder'); + const { saveDraftVersion } = await import('../api/email'); + await saveDraftVersion(p.outgoing, p.draftsMailboxId, p.draftId, undefined, p.draftAccountId); + return true; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (get().activeAccountId === accountId) { + const sends = [...get().sends, { ...send, status: 'failed' as const, lastError: message }] + .sort((a, b) => a.createdAt - b.createdAt); + set({ sends }); + persistSends(accountId, sends); + } + return false; + } + }, + discardFailed: () => { const accountId = get().activeAccountId; set({ failed: [] }); @@ -478,29 +587,33 @@ export const useOutboxStore = create((set, get) => ({ clear: async () => { const accountId = get().activeAccountId; clearRetry(); - set({ entries: [], failed: [] }); + set({ entries: [], failed: [], sends: [] }); if (accountId) { await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); + await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined); } }, clearAccount: async (accountId) => { const state = get(); const loaded = state.activeAccountId === accountId && state.hydrated; - let dropped = loaded ? state.entries.length + state.failed.length : 0; + let dropped = loaded ? state.entries.length + state.failed.length + state.sends.length : 0; if (state.activeAccountId === accountId) { clearRetry(); // A read of the bucket still under way must not bring the ops back. loadEpoch += 1; - set({ entries: [], failed: [], hydrated: true }); + set({ entries: [], failed: [], sends: [], hydrated: true }); } if (!loaded) { - const [entries, failed] = await Promise.all([load(accountId), load(accountId, FAILED_SUFFIX)]); - dropped = entries.length + failed.length; + const [entries, failed, sends] = await Promise.all([ + load(accountId), load(accountId, FAILED_SUFFIX), loadSends(accountId), + ]); + dropped = entries.length + failed.length + sends.length; } await AsyncStorage.removeItem(storageKey(accountId)).catch(() => undefined); await AsyncStorage.removeItem(storageKey(accountId) + FAILED_SUFFIX).catch(() => undefined); + await AsyncStorage.removeItem(storageKey(accountId) + SENDS_SUFFIX).catch(() => undefined); return dropped; }, })); @@ -523,6 +636,111 @@ function removeEntry(accountId: string, id: string, sent?: OutboxOp): boolean { return true; } +// ── Queued messages ───────────────────────────────────────────────────────── + +function hasWaitingSends(): boolean { + return useOutboxStore.getState().sends.some((s) => s.status === 'queued'); +} + +function updateSend(accountId: string, next: QueuedSend): void { + const store = useOutboxStore.getState(); + if (store.activeAccountId !== accountId) return; + if (!store.sends.some((s) => s.id === next.id)) return; + const sends = store.sends.map((s) => (s.id === next.id ? next : s)); + useOutboxStore.setState({ sends }); + persistSends(accountId, sends); +} + +function removeSend(accountId: string, id: string): void { + const store = useOutboxStore.getState(); + if (store.activeAccountId !== accountId) return; + const sends = store.sends.filter((s) => s.id !== id); + useOutboxStore.setState({ sends }); + persistSends(accountId, sends); +} + +/** + * Send the waiting messages, oldest first. 'transient' when the connection + * broke (or an answer never came), so the flush retries with back-off. + */ +async function flushSends(accountId: string): Promise<'done' | 'transient'> { + const waiting = useOutboxStore.getState().sends + .filter((s) => s.status === 'queued') + .sort((a, b) => a.createdAt - b.createdAt); + if (waiting.length === 0) return 'done'; + const [{ runQueuedSend }, { useEmailStore }] = await Promise.all([ + import('../api/queued-send'), + import('./email-store'), + ]); + for (const snapshot of waiting) { + const store = useOutboxStore.getState(); + if (store.activeAccountId !== accountId || !store.resumed || store.paused) break; + if (!useNetworkStore.getState().online || !clientServes(accountId)) break; + const current = store.sends.find((s) => s.id === snapshot.id); + if (!current || current.status !== 'queued') continue; // cancelled or retried meanwhile + + // Marked before the request leaves: from here on it may reach the server. + const started = startAttempt(current); + updateSend(accountId, started); + try { + const outcome = await runQueuedSend(started, useEmailStore.getState().mailboxes); + removeSend(accountId, current.id); + retryAttempt = 0; + void afterQueuedSend(started, outcome); + } catch (err) { + const kind = classifySendFailure(err, isTransientNetworkError); + const message = err instanceof Error ? err.message : String(err); + updateSend(accountId, afterFailure(started, kind, message)); + if (kind === 'auth') { + useOutboxStore.setState({ paused: true }); + break; + } + if (kind === 'offline' || kind === 'unconfirmed') return 'transient'; + // Refused: this one waits for the user; the others go on. + void notifyQueuedSendFailed(started, message); + } + } + return 'done'; +} + +type QueuedSendOutcomeLike = { kind: 'sent'; result: { refusedRecipients?: Array<{ email: string }> } } | { kind: 'alreadySent' }; + +// Flag the original and say the message went. Lazy imports: these pull in +// the UI stores, which the queue itself doesn't need. +async function afterQueuedSend(send: QueuedSend, outcome: QueuedSendOutcomeLike): Promise { + 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[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 { + try { + const [{ toast }, { useLocaleStore }] = await Promise.all([import('./toast-store'), import('./locale-store')]); + const t = useLocaleStore.getState().t; + toast.error(t('outbox.send_failed', 'A message in the Outbox could not be sent'), { + message: [send.payload.outgoing.subject, message].filter(Boolean).join(': '), + }); + } catch (err) { + console.warn('[outbox] could not report a failed queued send', err); + } +} + function moveToFailed(accountId: string, id: string, err: unknown): void { const store = useOutboxStore.getState(); if (store.activeAccountId !== accountId) return;