mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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 { isTransientNetworkError } from '../lib/network-error';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||
import { messagePriorityHeaders, type MessagePriority } from '../lib/message-priority';
|
||||
import { submissionEnvelopeParameters } from '../lib/submission-options';
|
||||
|
||||
const EMAIL_SUMMARY_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
@@ -1581,6 +1583,8 @@ export interface OutgoingEmail {
|
||||
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
|
||||
messageId?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
/** Importance / X-Priority headers (webmail #970); normal writes none. */
|
||||
priority?: MessagePriority;
|
||||
/**
|
||||
* SMTP MAIL FROM when it must differ from the header From - a catch-all
|
||||
* alias on an owned domain is sent through the identity's envelope while
|
||||
@@ -1691,6 +1695,9 @@ function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
|
||||
emailCreate['header:Disposition-Notification-To:asText'] = from[0].email;
|
||||
}
|
||||
|
||||
// Drafts carry it too, so a re-opened draft reads its priority back.
|
||||
Object.assign(emailCreate, messagePriorityHeaders(email.priority));
|
||||
|
||||
return emailCreate;
|
||||
}
|
||||
|
||||
@@ -1740,6 +1747,13 @@ export interface SendEmailOptions {
|
||||
draftAccountId?: string;
|
||||
/** Submitting account (shared/group account); defaults to the primary. */
|
||||
accountId?: string;
|
||||
/**
|
||||
* Ask for delivery status notifications (RFC 3461 RET/NOTIFY); only when
|
||||
* the account lists the DSN submission extension.
|
||||
*/
|
||||
requestDsn?: boolean;
|
||||
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
|
||||
requireTls?: boolean;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
@@ -1766,18 +1780,26 @@ export async function sendEmail(
|
||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
||||
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
||||
// server derive mailFrom from the Identity, dropping our parameter). An
|
||||
// explicit envelope sender (catch-all From override) needs it as well.
|
||||
// server derive mailFrom from the Identity, dropping our parameter). The
|
||||
// DSN and REQUIRETLS parameters ride the same way (webmail
|
||||
// submissionEnvelopeParameters), and an explicit envelope sender
|
||||
// (catch-all From override) needs it as well.
|
||||
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
|
||||
if (holdFor > 0 || email.envelopeMailFrom) {
|
||||
const parameters = submissionEnvelopeParameters(
|
||||
{ requestDsn: opts?.requestDsn, requireTls: opts?.requireTls },
|
||||
holdFor,
|
||||
);
|
||||
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
|
||||
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
|
||||
if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) {
|
||||
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||
.map((r) => r.email.trim())
|
||||
.filter(Boolean)
|
||||
.map((address) => ({ email: address }));
|
||||
.map((address) => (hasRcptToParams ? { email: address, parameters: parameters.rcptTo } : { email: address }));
|
||||
const mailFrom: Record<string, unknown> = {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { ComposeDraftContext } from '../navigation/types';
|
||||
import { messagePriorityFromHeaders } from './message-priority';
|
||||
|
||||
/**
|
||||
* Pick the draft's bodies by MIME type. A plain-text-only message lists its
|
||||
@@ -37,6 +38,8 @@ export function draftContextFromEmail(email: Email, jmapAccountId?: string): Com
|
||||
messageId: email.messageId ?? undefined,
|
||||
inReplyTo: email.inReplyTo ?? undefined,
|
||||
references: email.references ?? undefined,
|
||||
// Read back from the headers it was saved with (webmail #970).
|
||||
priority: messagePriorityFromHeaders(email.headers),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { MessagePriority } from '../lib/message-priority';
|
||||
|
||||
/** The message a reply / forward / draft edit starts from. */
|
||||
export interface ComposeReplyContext {
|
||||
@@ -47,6 +48,8 @@ export interface ComposeDraftContext {
|
||||
messageId?: string[] | null;
|
||||
inReplyTo?: string[] | null;
|
||||
references?: string[] | null;
|
||||
/** Read back from the draft's X-Priority / Importance headers (webmail #970). */
|
||||
priority?: MessagePriority;
|
||||
}
|
||||
|
||||
export type RootStackParamList = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
List, ListOrdered, Link2, Link2Off, Image as ImageIcon, Quote,
|
||||
Heading1, Heading2, AlignLeft, AlignCenter, AlignRight, RemoveFormatting,
|
||||
Undo2, Redo2, FileText, Clock, Check, Palette, Table, LayoutTemplate, MailCheck,
|
||||
Users, Tag, Type, Highlighter, Trash2, Search,
|
||||
Users, Tag, Type, Highlighter, Trash2, Search, Settings,
|
||||
} from '../components/icons';
|
||||
import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
@@ -77,6 +77,9 @@ import { inlineImageType } from '../lib/compose-inline-type';
|
||||
import { attachmentFromFileNode } from '../lib/compose-files';
|
||||
import { supportsFiles } from '../api/files';
|
||||
import { FilesPickerSheet } from '../components/compose/FilesPickerSheet';
|
||||
import { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
|
||||
import type { MessagePriority } from '../lib/message-priority';
|
||||
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||
import {
|
||||
assertComposerLogin, ComposerLoginMovedError, loginMovedFrom, uploadBytesForLogin, uploadFileForLogin,
|
||||
} from '../lib/compose-login';
|
||||
@@ -722,6 +725,27 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const [activeField, setActiveField] = React.useState<Field | null>(null);
|
||||
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>(initialAttachments);
|
||||
const [requestReadReceipt, setRequestReadReceipt] = React.useState(requestReadReceiptDefault);
|
||||
// Per-message submission options and priority (webmail composer options
|
||||
// menu): DSN and REQUIRETLS ride on the envelope, priority on the headers.
|
||||
const [requestDsn, setRequestDsn] = React.useState(false);
|
||||
const [requireTls, setRequireTls] = React.useState(false);
|
||||
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
|
||||
const [priority, setPriority] = React.useState<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 [fromOverride, setFromOverride] = React.useState<{ name: string; email: string } | null>(null);
|
||||
const [selState, setSelState] = React.useState<RichTextSelectionState>({
|
||||
@@ -1187,6 +1211,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const snapshotOf = (s: {
|
||||
to: Recipient[]; cc: Recipient[]; bcc: Recipient[]; subject: string; body: string;
|
||||
attachments: AttachmentEntry[];
|
||||
/** A priority change alone is an edit worth saving (webmail #970). */
|
||||
priority?: MessagePriority;
|
||||
}) => JSON.stringify({
|
||||
to: s.to.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
||||
cc: s.cc.map((r) => [r.name, r.email, r.group?.members.map((m) => m.email)]),
|
||||
@@ -1194,18 +1220,20 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
subject: s.subject,
|
||||
body: s.body,
|
||||
att: s.attachments.filter((a) => !a.inline).map((a) => a.blobId ?? a.localId),
|
||||
pri: s.priority ?? 'normal',
|
||||
});
|
||||
|
||||
// Memoized: the snapshot copies the whole body, and the screen re-renders
|
||||
// for plenty that doesn't touch it (PF8).
|
||||
const currentSnapshot = React.useMemo(() => snapshotOf({
|
||||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||||
body: plainTextMode ? plainBody : bodyHtml, attachments,
|
||||
body: plainTextMode ? plainBody : bodyHtml, attachments, priority,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments]);
|
||||
}), [finalTo, finalCc, finalBcc, subject, plainTextMode, plainBody, bodyHtml, attachments, priority]);
|
||||
const baselineRef = React.useRef(snapshotOf({
|
||||
to: initialTo, cc: initialCc, bcc: initialBcc, subject: initialSubject,
|
||||
body: plainTextMode ? initialPlainBody : initialBodyHtml, attachments: initialAttachments,
|
||||
priority: initialPriority,
|
||||
}));
|
||||
// Snapshot at the last successful draft save (null = never saved).
|
||||
const lastSavedRef = React.useRef<string | null>(draft ? baselineRef.current : null);
|
||||
@@ -1464,9 +1492,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
references: threading?.references,
|
||||
messageId: messageIdRef.current,
|
||||
requestReadReceipt,
|
||||
priority: priority !== 'normal' ? priority : undefined,
|
||||
envelopeMailFrom,
|
||||
};
|
||||
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, t]);
|
||||
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]);
|
||||
|
||||
// Save one draft version (create, then destroy the previous one - #849).
|
||||
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
||||
@@ -1485,7 +1514,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
}
|
||||
const snapshot = snapshotOf({
|
||||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||||
body: plainTextMode ? plainBody : html, attachments,
|
||||
body: plainTextMode ? plainBody : html, attachments, priority,
|
||||
});
|
||||
if (snapshot === lastSavedRef.current) return draftIdRef.current;
|
||||
if (snapshot === baselineRef.current && !draftIdRef.current) return null;
|
||||
@@ -1682,7 +1711,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
}
|
||||
const snapshot = snapshotOf({
|
||||
to: finalTo, cc: finalCc, bcc: finalBcc, subject,
|
||||
body: plainTextMode ? latestRef.current.plainBody : html, attachments,
|
||||
body: plainTextMode ? latestRef.current.plainBody : html, attachments, priority,
|
||||
});
|
||||
return snapshot === lastSavedRef.current;
|
||||
};
|
||||
@@ -2562,6 +2591,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
draftId: draftIdRef.current ?? undefined,
|
||||
draftAccountId: draftIdRef.current ? draftAccount() : undefined,
|
||||
accountId: target.accountId,
|
||||
// Only what the submitting account offers (webmail: the menu
|
||||
// hides the toggles the account's submission extensions lack).
|
||||
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
||||
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
||||
},
|
||||
);
|
||||
draftIdRef.current = null;
|
||||
@@ -2997,6 +3030,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
<ToolbarButton active={requestReadReceipt} onPress={() => setRequestReadReceipt((v) => !v)}
|
||||
label={t('email_composer.read_receipt_off', 'Request a read receipt')}
|
||||
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(); }}
|
||||
label={plainTextMode
|
||||
? t('email_composer.format_rich_text', 'Switch to rich text (HTML)')
|
||||
@@ -3479,6 +3515,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<ComposerOptionsSheet
|
||||
visible={optionsSheetOpen}
|
||||
options={composerOptions}
|
||||
dsnAvailable={dsnAvailable}
|
||||
requireTlsAvailable={requireTlsAvailable}
|
||||
onChange={onComposerOptionsChange}
|
||||
onClose={() => setOptionsSheetOpen(false)}
|
||||
/>
|
||||
|
||||
<FilesPickerSheet
|
||||
visible={filesPickerOpen}
|
||||
mode="files"
|
||||
|
||||
Reference in New Issue
Block a user