feat: let the composer set message priority and ask for delivery notifications

This commit is contained in:
Linus Rath
2026-10-31 14:57:54 +01:00
parent 36cc49b706
commit 649d3adc0f
11 changed files with 686 additions and 11 deletions
@@ -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<typeof vi.fn>;
const OUTGOING = {
from: [{ email: 'me@example.com' }],
to: [{ email: 'you@example.com' }],
cc: [{ email: 'cc@example.com' }],
subject: 'Hello',
textBody: 'Hi',
};
const SENT = {
methodResponses: [
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
],
};
function sentCalls() {
const calls = mockRequest.mock.calls[0][0] as Array<[string, Record<string, any>, string]>;
const create = calls.find(([name]) => name === 'Email/set')![1].create.draft;
const submission = calls.find(([name]) => name === 'EmailSubmission/set')![1].create['sub-1'];
return { create, submission };
}
beforeEach(() => {
vi.clearAllMocks();
mockRequest.mockResolvedValue(SENT);
});
describe('message priority', () => {
it('writes X-Priority and Importance for a high-priority message', async () => {
await sendEmail({ ...OUTGOING, priority: 'high' }, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
const { create } = sentCalls();
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
expect(create['header:Importance:asText']).toBe('high');
});
it('writes the low values for a low-priority message', async () => {
await sendEmail({ ...OUTGOING, priority: 'low' }, 'id-1', 'sent');
const { create } = sentCalls();
expect(create['header:X-Priority:asText']).toBe('5 (Lowest)');
expect(create['header:Importance:asText']).toBe('low');
});
it('writes no priority header for a normal message', async () => {
await sendEmail({ ...OUTGOING, priority: 'normal' }, 'id-1', 'sent');
const { create } = sentCalls();
expect(Object.keys(create).some((k) => /Priority|Importance/.test(k))).toBe(false);
});
it('saves the priority with a draft, and a re-opened draft reads it back', async () => {
mockRequest.mockResolvedValueOnce({
methodResponses: [
['Email/set', { created: { draft: { id: 'd-1' } } }, '0'],
['Email/get', { list: [] }, '1'],
],
});
await saveDraftVersion({ ...OUTGOING, priority: 'high' }, 'drafts');
const create = (mockRequest.mock.calls[0][0] as any[])[0][1].create.draft;
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
const reopened = draftContextFromEmail({
id: 'd-1',
headers: [{ name: 'X-Priority', value: ' 1 (Highest)' }, { name: 'Importance', value: ' high' }],
} as unknown as Email);
expect(reopened.priority).toBe('high');
});
});
describe('delivery status notifications and REQUIRETLS', () => {
it('asks every recipient for a DSN with headers only', async () => {
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts', requestDsn: true });
const { submission } = sentCalls();
expect(submission.envelope).toEqual({
mailFrom: { email: 'me@example.com', parameters: { RET: 'HDRS' } },
rcptTo: [
{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
{ email: 'cc@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
],
});
});
it('puts REQUIRETLS next to the HOLDFOR of a held send', async () => {
await sendEmail(OUTGOING, 'id-1', 'sent', 30, { draftsMailboxId: 'drafts', requireTls: true });
const { submission } = sentCalls();
expect(submission.envelope.mailFrom.parameters).toEqual({ HOLDFOR: '30', REQUIRETLS: null });
expect(submission.envelope.rcptTo).toEqual([{ email: 'you@example.com' }, { email: 'cc@example.com' }]);
});
it('leaves the envelope to the server without options, hold or From override', async () => {
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
expect(sentCalls().submission.envelope).toBeUndefined();
});
});
@@ -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);
});
+27 -5
View File
@@ -21,6 +21,8 @@ import { withInlineBodyParts } from '../lib/email-inline-parts';
import { stalwartStateWentBack } from './stalwart-state'; import { stalwartStateWentBack } from './stalwart-state';
import { isTransientNetworkError } from '../lib/network-error'; import { isTransientNetworkError } from '../lib/network-error';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers'; import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
import { messagePriorityHeaders, type MessagePriority } from '../lib/message-priority';
import { submissionEnvelopeParameters } from '../lib/submission-options';
const EMAIL_SUMMARY_PROPERTIES = [ const EMAIL_SUMMARY_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'id', 'threadId', 'mailboxIds', 'keywords', 'size',
@@ -1581,6 +1583,8 @@ export interface OutgoingEmail {
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent. // Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
messageId?: string; messageId?: string;
requestReadReceipt?: boolean; requestReadReceipt?: boolean;
/** Importance / X-Priority headers (webmail #970); normal writes none. */
priority?: MessagePriority;
/** /**
* SMTP MAIL FROM when it must differ from the header From - a catch-all * SMTP MAIL FROM when it must differ from the header From - a catch-all
* alias on an owned domain is sent through the identity's envelope while * alias on an owned domain is sent through the identity's envelope while
@@ -1691,6 +1695,9 @@ function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
emailCreate['header:Disposition-Notification-To:asText'] = from[0].email; emailCreate['header:Disposition-Notification-To:asText'] = from[0].email;
} }
// Drafts carry it too, so a re-opened draft reads its priority back.
Object.assign(emailCreate, messagePriorityHeaders(email.priority));
return emailCreate; return emailCreate;
} }
@@ -1740,6 +1747,13 @@ export interface SendEmailOptions {
draftAccountId?: string; draftAccountId?: string;
/** Submitting account (shared/group account); defaults to the primary. */ /** Submitting account (shared/group account); defaults to the primary. */
accountId?: string; accountId?: string;
/**
* Ask for delivery status notifications (RFC 3461 RET/NOTIFY); only when
* the account lists the DSN submission extension.
*/
requestDsn?: boolean;
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
requireTls?: boolean;
} }
export async function sendEmail( export async function sendEmail(
@@ -1766,18 +1780,26 @@ export async function sendEmail(
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId }; const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
// For a deferred send the envelope must be set explicitly so the HOLDFOR // For a deferred send the envelope must be set explicitly so the HOLDFOR
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the // mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
// server derive mailFrom from the Identity, dropping our parameter). An // server derive mailFrom from the Identity, dropping our parameter). The
// explicit envelope sender (catch-all From override) needs it as well. // DSN and REQUIRETLS parameters ride the same way (webmail
// submissionEnvelopeParameters), and an explicit envelope sender
// (catch-all From override) needs it as well.
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0; const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
if (holdFor > 0 || email.envelopeMailFrom) { const parameters = submissionEnvelopeParameters(
{ requestDsn: opts?.requestDsn, requireTls: opts?.requireTls },
holdFor,
);
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) {
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])] const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
.map((r) => r.email.trim()) .map((r) => r.email.trim())
.filter(Boolean) .filter(Boolean)
.map((address) => ({ email: address })); .map((address) => (hasRcptToParams ? { email: address, parameters: parameters.rcptTo } : { email: address }));
const mailFrom: Record<string, unknown> = { const mailFrom: Record<string, unknown> = {
email: email.envelopeMailFrom || email.from[0]?.email, email: email.envelopeMailFrom || email.from[0]?.email,
}; };
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) }; if (hasMailFromParams) mailFrom.parameters = parameters.mailFrom;
submissionCreate.envelope = { mailFrom, rcptTo }; submissionCreate.envelope = { mailFrom, rcptTo };
} }
@@ -0,0 +1,165 @@
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { Check } from '../icons';
import ToggleSwitch from '../ToggleSwitch';
import { useColors } from '../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
export interface ComposerOptions {
requestReadReceipt: boolean;
requestDsn: boolean;
requireTls: boolean;
priority: MessagePriority;
}
/**
* How many of the options differ from a plain message, for the badge on the
* toolbar button (webmail composer options menu `activeCount`).
*/
export function activeComposerOptionCount(options: ComposerOptions): number {
return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal']
.filter(Boolean).length;
}
/**
* The composer's per-message options (webmail email-composer.tsx options
* menu): read receipt, delivery status notification and REQUIRETLS - the
* last two only when the account's submission extensions list them - and
* the message priority.
*/
export function ComposerOptionsSheet({
visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose,
}: {
visible: boolean;
options: ComposerOptions;
dsnAvailable: boolean;
requireTlsAvailable: boolean;
onChange: (next: Partial<ComposerOptions>) => void;
onClose: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const bottomInset = useSheetBottomInset(visible);
const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls') => (
<Pressable
style={styles.row}
onPress={() => onChange({ [key]: !value })}
accessibilityRole="switch"
accessibilityLabel={label}
accessibilityHint={hint}
accessibilityState={{ checked: value }}
>
<View style={styles.rowText}>
<Text style={styles.rowLabel}>{label}</Text>
<Text style={styles.rowHint}>{hint}</Text>
</View>
<ToggleSwitch value={value} onValueChange={(v) => onChange({ [key]: v })} accessibilityLabel={label} />
</Pressable>
);
return (
<Modal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable accessible={false} style={[styles.backdrop, { paddingBottom: spacing.lg + bottomInset }]} onPress={onClose}>
<Pressable accessible={false} style={styles.card} onPress={() => {}}>
<Text style={styles.title} accessibilityRole="header">{t('email_composer.options', 'Options')}</Text>
{toggleRow(
t('email_composer.option_read_receipt', 'Request read receipt'),
t('email_composer.option_read_receipt_hint', 'Ask the recipient to confirm they opened it'),
options.requestReadReceipt,
'requestReadReceipt',
)}
{dsnAvailable && toggleRow(
t('email_composer.option_dsn', 'Delivery notification'),
t('email_composer.option_dsn_hint', 'Get a report when the server delivers it'),
options.requestDsn,
'requestDsn',
)}
{requireTlsAvailable && toggleRow(
t('email_composer.option_require_tls', 'Require encrypted delivery'),
t('email_composer.option_require_tls_hint', 'Only deliver over TLS, otherwise bounce'),
options.requireTls,
'requireTls',
)}
<View style={styles.separator} />
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
{MESSAGE_PRIORITIES.map((level) => {
const selected = options.priority === level;
const label = level === 'high'
? t('email_composer.option_priority_high', 'High')
: level === 'low'
? t('email_composer.option_priority_low', 'Low')
: t('email_composer.option_priority_normal', 'Normal');
return (
<Pressable
key={level}
style={styles.row}
onPress={() => onChange({ priority: level })}
accessibilityRole="radio"
accessibilityLabel={label}
accessibilityState={{ checked: selected, selected }}
testID={`composer-priority-${level}`}
>
<Text style={[styles.rowLabel, styles.rowText]}>{label}</Text>
{selected && <Check size={18} color={c.primary} />}
</Pressable>
);
})}
</View>
<Pressable style={styles.done} onPress={onClose} accessibilityRole="button">
<Text style={styles.doneText}>{t('common.done', 'Done')}</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
card: {
width: '100%',
maxWidth: 420,
backgroundColor: c.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
padding: spacing.lg,
gap: spacing.xs,
},
title: { ...typography.h3, color: c.text, marginBottom: spacing.xs },
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
minHeight: 48,
paddingVertical: spacing.xs,
},
rowText: { flex: 1 },
rowLabel: { ...typography.body, color: c.text },
rowHint: { ...typography.caption, color: c.textMuted },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
groupLabel: { ...typography.captionMedium, color: c.textSecondary },
done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs },
doneText: { ...typography.bodyMedium, color: c.primary },
});
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// The composer's options sheet (priority, DSN, REQUIRETLS) as wired in
// ComposeScreen, checked on its source (the suite has no RN renderer).
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
describe('ComposeScreen options wiring', () => {
it('opens the options sheet from the toolbar', () => {
expect(source).toMatch(/onPress=\{\(\) => setOptionsSheetOpen\(true\)\}/);
expect(source).toMatch(/<ComposerOptionsSheet[\s\S]{0,200}onChange=\{onComposerOptionsChange\}/);
});
it('sends the priority with the message and saves it with the draft', () => {
const build = source.slice(source.indexOf('const buildOutgoing'), source.indexOf('// Save one draft version'));
expect(build).toMatch(/priority: priority !== 'normal' \? priority : undefined,/);
// A priority change alone makes the message dirty.
expect(source).toMatch(/pri: s\.priority \?\? 'normal',/);
expect(source).toMatch(/const initialPriority: MessagePriority = draft\?\.priority \?\? 'normal';/);
});
it('asks for DSN and REQUIRETLS only where the submitting account offers them', () => {
const send = source.slice(source.indexOf('const performSendInner'));
expect(send).toMatch(/requestDsn: requestDsn && supportsSubmissionExtension\('DSN', target\.accountId\),/);
expect(send).toMatch(/requireTls: requireTls && supportsSubmissionExtension\('REQUIRETLS', target\.accountId\),/);
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from 'vitest';
const caps = vi.hoisted(() => ({ value: undefined as unknown }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: { getAccountCapability: vi.fn(() => caps.value) },
}));
import { hasSubmissionExtension, submissionEnvelopeParameters, supportsSubmissionExtension } from '../submission-options';
import { messagePriorityFromHeaders, messagePriorityHeaders } from '../message-priority';
describe('submissionEnvelopeParameters', () => {
it('asks for DSN with RET=HDRS and NOTIFY on every recipient', () => {
expect(submissionEnvelopeParameters({ requestDsn: true })).toEqual({
mailFrom: { RET: 'HDRS' },
rcptTo: { NOTIFY: 'SUCCESS,FAILURE,DELAY' },
});
});
it('writes REQUIRETLS without a value and keeps the hold', () => {
expect(submissionEnvelopeParameters({ requireTls: true }, 90)).toEqual({
mailFrom: { HOLDFOR: '90', REQUIRETLS: null },
rcptTo: {},
});
});
it('adds nothing without options', () => {
expect(submissionEnvelopeParameters(undefined)).toEqual({ mailFrom: {}, rcptTo: {} });
});
});
describe('submission extensions', () => {
it('reads the RFC 8621 map, any case', () => {
expect(hasSubmissionExtension({ dsn: [], REQUIRETLS: [] }, 'DSN')).toBe(true);
expect(hasSubmissionExtension({ FUTURERELEASE: [] }, 'DSN')).toBe(false);
expect(hasSubmissionExtension(['DSN'], 'dsn')).toBe(true);
expect(hasSubmissionExtension(null, 'DSN')).toBe(false);
});
it("offers DSN only when the account's submission capability lists it (Stalwart 0.16 and 1.0 do)", () => {
caps.value = { submissionExtensions: { FUTURERELEASE: [], DSN: [], 'MT-PRIORITY': ['MIXER'], REQUIRETLS: [] } };
expect(supportsSubmissionExtension('DSN')).toBe(true);
expect(supportsSubmissionExtension('REQUIRETLS')).toBe(true);
caps.value = { submissionExtensions: { FUTURERELEASE: [] } };
expect(supportsSubmissionExtension('DSN')).toBe(false);
caps.value = undefined;
expect(supportsSubmissionExtension('DSN')).toBe(false);
});
});
describe('message priority headers', () => {
it('maps high and low to X-Priority and Importance, normal to nothing', () => {
expect(messagePriorityHeaders('high')).toEqual({ 'header:X-Priority:asText': '1 (Highest)', 'header:Importance:asText': 'high' });
expect(messagePriorityHeaders('low')).toEqual({ 'header:X-Priority:asText': '5 (Lowest)', 'header:Importance:asText': 'low' });
expect(messagePriorityHeaders('normal')).toEqual({});
});
it('reads a priority back, X-Priority first', () => {
expect(messagePriorityFromHeaders([{ name: 'x-priority', value: ' 2' }])).toBe('high');
expect(messagePriorityFromHeaders([{ name: 'X-Priority', value: '3 (Normal)' }, { name: 'Importance', value: 'high' }])).toBe('normal');
expect(messagePriorityFromHeaders([{ name: 'Importance', value: ' Low' }])).toBe('low');
expect(messagePriorityFromHeaders(undefined)).toBe('normal');
});
});
+3
View File
@@ -3,6 +3,7 @@
import type { Email } from '../api/types'; import type { Email } from '../api/types';
import type { ComposeDraftContext } from '../navigation/types'; import type { ComposeDraftContext } from '../navigation/types';
import { messagePriorityFromHeaders } from './message-priority';
/** /**
* Pick the draft's bodies by MIME type. A plain-text-only message lists its * Pick the draft's bodies by MIME type. A plain-text-only message lists its
@@ -37,6 +38,8 @@ export function draftContextFromEmail(email: Email, jmapAccountId?: string): Com
messageId: email.messageId ?? undefined, messageId: email.messageId ?? undefined,
inReplyTo: email.inReplyTo ?? undefined, inReplyTo: email.inReplyTo ?? undefined,
references: email.references ?? undefined, references: email.references ?? undefined,
// Read back from the headers it was saved with (webmail #970).
priority: messagePriorityFromHeaders(email.headers),
}; };
} }
+60
View File
@@ -0,0 +1,60 @@
/**
* Message priority set in the composer (webmail #970, lib/message-priority.ts).
*
* Clients disagree on which header carries it: Outlook reads Importance
* (RFC 4021), Thunderbird and most others read the de-facto X-Priority
* (1 highest ... 5 lowest). Both are written so the hint survives either
* way. Priority (RFC 4021, MIXER) is left out: it is meant to steer
* transmission, not display. Normal writes nothing, so ordinary mail is
* unchanged.
*/
export type MessagePriority = 'high' | 'normal' | 'low';
export const MESSAGE_PRIORITIES: readonly MessagePriority[] = ['high', 'normal', 'low'];
/** JMAP Email/set create properties (RFC 8621 §4.1.3) for a priority. */
export function messagePriorityHeaders(priority: MessagePriority | undefined): Record<string, string> {
if (priority === 'high') {
return {
'header:X-Priority:asText': '1 (Highest)',
'header:Importance:asText': 'high',
};
}
if (priority === 'low') {
return {
'header:X-Priority:asText': '5 (Lowest)',
'header:Importance:asText': 'low',
};
}
return {};
}
function headerValue(headers: Array<{ name: string; value: string }>, name: string): string | undefined {
const lower = name.toLowerCase();
for (const header of headers) {
if (header?.name?.toLowerCase() !== lower) continue;
const value = typeof header.value === 'string' ? header.value.trim() : '';
if (value) return value;
}
return undefined;
}
/**
* The priority a message's raw headers (JMAP `headers`) carry, for a
* re-opened draft. X-Priority wins over Importance; 1-2 is high, 4-5 low,
* anything else normal.
*/
export function messagePriorityFromHeaders(
headers: Array<{ name: string; value: string }> | undefined | null,
): MessagePriority {
if (!Array.isArray(headers)) return 'normal';
const xPriority = headerValue(headers, 'X-Priority');
const level = xPriority ? Number.parseInt(xPriority, 10) : NaN;
if (level === 1 || level === 2) return 'high';
if (level === 4 || level === 5) return 'low';
if (!Number.isNaN(level)) return 'normal';
const importance = headerValue(headers, 'Importance')?.toLowerCase();
if (importance === 'high') return 'high';
if (importance === 'low') return 'low';
return 'normal';
}
+58
View File
@@ -0,0 +1,58 @@
// Per-message SMTP submission options (RFC 8621 §7.1 envelope parameters),
// ported from the webmail's lib/jmap/client.ts (submissionEnvelopeParameters,
// supportsSubmissionExtension).
//
// `requestDsn` asks the next hops for delivery status notifications (RFC 3461
// NOTIFY / RET); `requireTls` refuses relaying over an unencrypted hop (RFC
// 8689 REQUIRETLS). Both need the matching entry in the submission account's
// `submissionExtensions`, so the composer only offers what the server lists.
import { jmapClient } from '../api/jmap-client';
import { CAPABILITIES } from '../api/types';
export interface SubmissionSendOptions {
requestDsn?: boolean;
requireTls?: boolean;
}
/** The MAIL FROM / RCPT TO parameters the options and a hold translate to. */
export function submissionEnvelopeParameters(
options: SubmissionSendOptions | undefined,
holdForSeconds?: number,
): { mailFrom: Record<string, string | null>; rcptTo: Record<string, string | null> } {
const mailFrom: Record<string, string | null> = {};
const rcptTo: Record<string, string | null> = {};
if (holdForSeconds && holdForSeconds > 0) mailFrom.HOLDFOR = String(Math.ceil(holdForSeconds));
if (options?.requireTls) mailFrom.REQUIRETLS = null;
if (options?.requestDsn) {
// Return the headers only (not the full message) with the notification.
mailFrom.RET = 'HDRS';
rcptTo.NOTIFY = 'SUCCESS,FAILURE,DELAY';
}
return { mailFrom, rcptTo };
}
/**
* Whether a `submissionExtensions` value lists `extension`. RFC 8621 makes it
* a map of extension name to its arguments; an array of names is accepted
* too, as the webmail does.
*/
export function hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
const target = extension.toUpperCase();
if (Array.isArray(submissionExtensions)) {
return submissionExtensions.some((item) => typeof item === 'string' && item.toUpperCase() === target);
}
if (submissionExtensions && typeof submissionExtensions === 'object') {
return Object.entries(submissionExtensions as Record<string, unknown>)
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
}
return false;
}
/** Whether the account (default: the primary) can submit with `extension`, e.g. "DSN". */
export function supportsSubmissionExtension(extension: string, accountId?: string): boolean {
const cap = jmapClient.getAccountCapability(CAPABILITIES.SUBMISSION, accountId) as
| { submissionExtensions?: unknown }
| undefined;
return hasSubmissionExtension(cap?.submissionExtensions, extension);
}
+3
View File
@@ -1,4 +1,5 @@
import type { Attachment, EmailAddress } from '../api/types'; import type { Attachment, EmailAddress } from '../api/types';
import type { MessagePriority } from '../lib/message-priority';
/** The message a reply / forward / draft edit starts from. */ /** The message a reply / forward / draft edit starts from. */
export interface ComposeReplyContext { export interface ComposeReplyContext {
@@ -47,6 +48,8 @@ export interface ComposeDraftContext {
messageId?: string[] | null; messageId?: string[] | null;
inReplyTo?: string[] | null; inReplyTo?: string[] | null;
references?: string[] | null; references?: string[] | null;
/** Read back from the draft's X-Priority / Importance headers (webmail #970). */
priority?: MessagePriority;
} }
export type RootStackParamList = { export type RootStackParamList = {
+51 -6
View File
@@ -11,7 +11,7 @@ import {
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote, List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting, Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck, Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
Users, Tag, Type, Highlighter, Trash2, Search, Users, Tag, Type, Highlighter, Trash2, Search, Settings,
} from '../components/icons'; } from '../components/icons';
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
@@ -77,6 +77,9 @@ import { inlineImageType } from '../lib/compose-inline-type';
import { attachmentFromFileNode } from '../lib/compose-files'; import { attachmentFromFileNode } from '../lib/compose-files';
import { supportsFiles } from '../api/files'; import { supportsFiles } from '../api/files';
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet'; import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
import type { MessagePriority } from '../lib/message-priority';
import { supportsSubmissionExtension } from '../lib/submission-options';
import { import {
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin, assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
} from '../lib/compose-login'; } from '../lib/compose-login';
@@ -722,6 +725,27 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [activeField, setActiveField] = React.useState<Field | null>(null); const [activeField, setActiveField] = React.useState<Field | null>(null);
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments); const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments);
const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault); const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault);
// Per-message submission options and priority (webmail composer options
// menu): DSN and REQUIRETLS ride on the envelope, priority on the headers.
const [requestDsn, setRequestDsn] = React.useState(false);
const [requireTls, setRequireTls] = React.useState(false);
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
const composerOptions = React.useMemo<ComposerOptions>(
() => ({ requestReadReceipt, requestDsn, requireTls, priority }),
[requestReadReceipt, requestDsn, requireTls, priority],
);
// Read when the sheet opens: the session may have arrived since the
// composer did.
const dsnAvailable = optionsSheetOpen && supportsSubmissionExtension('DSN');
const requireTlsAvailable = optionsSheetOpen && supportsSubmissionExtension('REQUIRETLS');
const onComposerOptionsChange = React.useCallback((next: Partial<ComposerOptions>) => {
if (next.requestReadReceipt !== undefined) setRequestReadReceipt(next.requestReadReceipt);
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
if (next.requireTls !== undefined) setRequireTls(next.requireTls);
if (next.priority !== undefined) setPriority(next.priority);
}, []);
const [subAddressTag, setSubAddressTag] = React.useState(''); const [subAddressTag, setSubAddressTag] = React.useState('');
const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null); const [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null);
const [selState, setSelState] = React.useState<RichTextSelectionState>({ const [selState, setSelState] = React.useState<RichTextSelectionState>({
@@ -1187,6 +1211,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
const snapshotOf = (s: { const snapshotOf = (s: {
to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string; to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string;
attachments: AttachmentEntry[]; attachments: AttachmentEntry[];
/** A priority change alone is an edit worth saving (webmail #970). */
priority?: MessagePriority;
}) => JSON.stringify({ }) => JSON.stringify({
to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]), to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]), cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
@@ -1194,18 +1220,20 @@ export default function ComposeScreen({ route, navigation }: Props) {
subject: s.subject, subject: s.subject,
body: s.body, body: s.body,
att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId), att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId),
pri: s.priority ?? 'normal',
}); });
// Memoized: the snapshot copies the whole body, and the screen re-renders // Memoized: the snapshot copies the whole body, and the screen re-renders
// for plenty that doesn't touch it (PF8). // for plenty that doesn't touch it (PF8).
const currentSnapshot = React.useMemo(() => snapshotOf({ const currentSnapshot = React.useMemo(() => snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject, to: finalTo, cc: finalCc, bcc: finalBcc, subject,
body: plainTextMode ? plainBody : bodyHtml, attachments, body: plainTextMode ? plainBody : bodyHtml, attachments, priority,
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments]); }), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments, priority]);
const baselineRef = React.useRef(snapshotOf({ const baselineRef = React.useRef(snapshotOf({
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject, to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments, body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments,
priority: initialPriority,
})); }));
// Snapshot at the last successful draft save (null = never saved). // Snapshot at the last successful draft save (null = never saved).
const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null); const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null);
@@ -1464,9 +1492,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
references: threading?.references, references: threading?.references,
messageId: messageIdRef.current, messageId: messageIdRef.current,
requestReadReceipt, requestReadReceipt,
priority: priority !== 'normal' ? priority : undefined,
envelopeMailFrom, envelopeMailFrom,
}; };
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, t]); }, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]);
// Save one draft version (create, then destroy the previous one - #849). // Save one draft version (create, then destroy the previous one - #849).
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => { const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
@@ -1485,7 +1514,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
} }
const snapshot = snapshotOf({ const snapshot = snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject, to: finalTo, cc: finalCc, bcc: finalBcc, subject,
body: plainTextMode ? plainBody : html, attachments, body: plainTextMode ? plainBody : html, attachments, priority,
}); });
if (snapshot === lastSavedRef.current) return draftIdRef.current; if (snapshot === lastSavedRef.current) return draftIdRef.current;
if (snapshot === baselineRef.current && !draftIdRef.current) return null; if (snapshot === baselineRef.current && !draftIdRef.current) return null;
@@ -1682,7 +1711,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
} }
const snapshot = snapshotOf({ const snapshot = snapshotOf({
to: finalTo, cc: finalCc, bcc: finalBcc, subject, to: finalTo, cc: finalCc, bcc: finalBcc, subject,
body: plainTextMode ? latestRef.current.plainBody : html, attachments, body: plainTextMode ? latestRef.current.plainBody : html, attachments, priority,
}); });
return snapshot === lastSavedRef.current; return snapshot === lastSavedRef.current;
}; };
@@ -2562,6 +2591,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
draftId: draftIdRef.current ?? undefined, draftId: draftIdRef.current ?? undefined,
draftAccountId: draftIdRef.current ? draftAccount() : undefined, draftAccountId: draftIdRef.current ? draftAccount() : undefined,
accountId: target.accountId, accountId: target.accountId,
// Only what the submitting account offers (webmail: the menu
// hides the toggles the account's submission extensions lack).
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
}, },
); );
draftIdRef.current = null; draftIdRef.current = null;
@@ -2997,6 +3030,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)} <ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
label={t('email_composer.read_receipt_off', 'Request a read receipt')} label={t('email_composer.read_receipt_off', 'Request a read receipt')}
icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} /> icon={<MailCheck size={18} color={requestReadReceipt ? c.primary : c.textSecondary} />} />
<ToolbarButton active={activeComposerOptionCount(composerOptions) > 0} onPress={() => setOptionsSheetOpen(true)}
label={t('email_composer.options', 'Options')}
icon={<Settings size={18} color={activeComposerOptionCount(composerOptions) > 0 ? c.primary : c.textSecondary} />} />
<ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }} <ToolbarButton active={plainTextMode} onPress={() => { void togglePlainTextMode(); }}
label={plainTextMode label={plainTextMode
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)') ? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
@@ -3479,6 +3515,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View> </View>
</Modal> </Modal>
<ComposerOptionsSheet
visible={optionsSheetOpen}
options={composerOptions}
dsnAvailable={dsnAvailable}
requireTlsAvailable={requireTlsAvailable}
onChange={onComposerOptionsChange}
onClose={() => setOptionsSheetOpen(false)}
/>
<FilesPickerSheet <FilesPickerSheet
visible={filesPickerOpen} visible={filesPickerOpen}
mode="files" mode="files"