mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
feat: let the composer set message priority and ask for delivery notifications
This commit is contained in:
@@ -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
@@ -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,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user