From 649d3adc0f181802501243c07ee214a60e96fff3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 14:57:54 +0100 Subject: [PATCH] feat: let the composer set message priority and ask for delivery notifications --- src/api/__tests__/email-send-options.test.ts | 120 +++++++++++++ .../live-stalwart-send-options.test.ts | 108 ++++++++++++ src/api/email.ts | 32 +++- .../compose/ComposerOptionsSheet.tsx | 165 ++++++++++++++++++ .../__tests__/compose-options-wiring.test.ts | 28 +++ src/lib/__tests__/submission-options.test.ts | 63 +++++++ src/lib/draft-context.ts | 3 + src/lib/message-priority.ts | 60 +++++++ src/lib/submission-options.ts | 58 ++++++ src/navigation/types.ts | 3 + src/screens/ComposeScreen.tsx | 57 +++++- 11 files changed, 686 insertions(+), 11 deletions(-) create mode 100644 src/api/__tests__/email-send-options.test.ts create mode 100644 src/api/__tests__/live-stalwart-send-options.test.ts create mode 100644 src/components/compose/ComposerOptionsSheet.tsx create mode 100644 src/lib/__tests__/compose-options-wiring.test.ts create mode 100644 src/lib/__tests__/submission-options.test.ts create mode 100644 src/lib/message-priority.ts create mode 100644 src/lib/submission-options.ts 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..9b0d43f0 --- /dev/null +++ b/src/api/__tests__/email-send-options.test.ts @@ -0,0 +1,120 @@ +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 { 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(); + }); +}); 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..79c7da2c --- /dev/null +++ b/src/api/__tests__/live-stalwart-send-options.test.ts @@ -0,0 +1,108 @@ +/** + * 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, 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 = Date.now().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); + } + const res = await singleton.request([['Email/query', { accountId: singleton.accountId, filter: { subject: tag } }, '0']]); + const leftovers = [...new Set([...(res.methodResponses[0][1].ids as string[] ?? []), ...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' }) }), + ]); + await cancelScheduledSend(result.emailSubmissionId!); + }, 60_000); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 57e31f88..8f5e4821 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 }; } 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/lib/__tests__/compose-options-wiring.test.ts b/src/lib/__tests__/compose-options-wiring.test.ts new file mode 100644 index 00000000..d9d85fc5 --- /dev/null +++ b/src/lib/__tests__/compose-options-wiring.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +// The composer's options sheet (priority, DSN, REQUIRETLS) as wired in +// ComposeScreen, checked on its source (the suite has no RN renderer). +const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); + +describe('ComposeScreen options wiring', () => { + it('opens the options sheet from the toolbar', () => { + expect(source).toMatch(/onPress=\{\(\) => setOptionsSheetOpen\(true\)\}/); + expect(source).toMatch(/ { + 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__/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/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/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/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..83a76e2f 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, + Users, Tag, Type, Highlighter, Trash2, Search, Settings, } from '../components/icons'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import * as ImagePicker from 'expo-image-picker'; @@ -77,6 +77,9 @@ 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 { assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin, } from '../lib/compose-login'; @@ -722,6 +725,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({ @@ -1187,6 +1211,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 +1220,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 +1492,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 +1514,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 +1711,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; }; @@ -2562,6 +2591,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; @@ -2997,6 +3030,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 +3515,15 @@ export default function ComposeScreen({ route, navigation }: Props) { + setOptionsSheetOpen(false)} + /> +