mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
feat: sign and encrypt mail with pgp or s/mime from the composer options #58
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Sending an uploaded raw message (#58): a signed or encrypted PGP / S/MIME
|
||||||
|
// message is added with Email/import and submitted like any other, and the
|
||||||
|
// envelope names every recipient, Bcc included, since the raw headers don't.
|
||||||
|
|
||||||
|
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 { sendEmail } from '../email';
|
||||||
|
|
||||||
|
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const OUTGOING = {
|
||||||
|
from: [{ email: 'me@example.com' }],
|
||||||
|
to: [{ email: 'you@example.com' }],
|
||||||
|
bcc: [{ email: 'hidden@example.com' }],
|
||||||
|
subject: 'Secret',
|
||||||
|
textBody: 'not sent as is',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/import', { created: { draft: { id: 'e-raw' } } }, '0'],
|
||||||
|
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sendEmail with a raw blob', () => {
|
||||||
|
it('imports the blob into Drafts, submits it with every recipient in the envelope and files it in Sent', async () => {
|
||||||
|
const result = await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts', rawBlobId: 'blob-raw' });
|
||||||
|
const calls = mockRequest.mock.calls[0][0] as Array<[string, Record<string, any>, string]>;
|
||||||
|
expect(calls.find(([name]) => name === 'Email/set')).toBeUndefined();
|
||||||
|
const [, importArgs] = calls.find(([name]) => name === 'Email/import')!;
|
||||||
|
expect(importArgs.emails.draft).toEqual({ blobId: 'blob-raw', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true } });
|
||||||
|
const submission = calls.find(([name]) => name === 'EmailSubmission/set')![1];
|
||||||
|
expect(submission.create['sub-1'].emailId).toBe('#draft');
|
||||||
|
expect(submission.create['sub-1'].envelope).toEqual({
|
||||||
|
mailFrom: { email: 'me@example.com' },
|
||||||
|
rcptTo: [{ email: 'you@example.com' }, { email: 'hidden@example.com' }],
|
||||||
|
});
|
||||||
|
expect(submission.onSuccessUpdateEmail['#sub-1'].mailboxIds).toEqual({ sent: true });
|
||||||
|
expect(result.emailId).toBe('e-raw');
|
||||||
|
expect(result.emailSubmissionId).toBe('s-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an import the server refused', async () => {
|
||||||
|
mockRequest.mockResolvedValueOnce({
|
||||||
|
methodResponses: [
|
||||||
|
['Email/import', { notCreated: { draft: { type: 'invalidEmail', description: 'bad message' } } }, '0'],
|
||||||
|
['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'invalidProperties' } } }, '1'],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await expect(sendEmail(OUTGOING, 'id-1', 'sent', undefined, { rawBlobId: 'blob-raw' })).rejects.toThrow(/bad message/);
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-5
@@ -1754,6 +1754,13 @@ export interface SendEmailOptions {
|
|||||||
requestDsn?: boolean;
|
requestDsn?: boolean;
|
||||||
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
|
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
|
||||||
requireTls?: boolean;
|
requireTls?: boolean;
|
||||||
|
/**
|
||||||
|
* A complete message already uploaded as a blob (a signed or encrypted
|
||||||
|
* PGP / S/MIME message, #58): it is added with Email/import instead of
|
||||||
|
* built with Email/set, and the envelope always names every recipient,
|
||||||
|
* since Bcc is not in its headers. The webmail's sendRawEmail.
|
||||||
|
*/
|
||||||
|
rawBlobId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
@@ -1767,7 +1774,8 @@ export async function sendEmail(
|
|||||||
opts?: SendEmailOptions,
|
opts?: SendEmailOptions,
|
||||||
): Promise<SendEmailResult> {
|
): Promise<SendEmailResult> {
|
||||||
const accountId = opts?.accountId ?? jmapClient.accountId;
|
const accountId = opts?.accountId ?? jmapClient.accountId;
|
||||||
const emailCreate = buildEmailCreate(email);
|
const raw = !!opts?.rawBlobId;
|
||||||
|
const emailCreate: Record<string, unknown> = raw ? { blobId: opts!.rawBlobId } : buildEmailCreate(email);
|
||||||
const viaDrafts = !!opts?.draftsMailboxId;
|
const viaDrafts = !!opts?.draftsMailboxId;
|
||||||
if (viaDrafts) {
|
if (viaDrafts) {
|
||||||
emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true };
|
emailCreate.mailboxIds = { [opts!.draftsMailboxId!]: true };
|
||||||
@@ -1776,6 +1784,9 @@ export async function sendEmail(
|
|||||||
emailCreate.mailboxIds = { [sentMailboxId]: true };
|
emailCreate.mailboxIds = { [sentMailboxId]: true };
|
||||||
emailCreate.keywords = { $seen: true };
|
emailCreate.keywords = { $seen: true };
|
||||||
}
|
}
|
||||||
|
const createCall: [string, Record<string, unknown>, string] = raw
|
||||||
|
? ['Email/import', { accountId, emails: { draft: emailCreate } }, '0']
|
||||||
|
: ['Email/set', { accountId, create: { draft: emailCreate } }, '0'];
|
||||||
|
|
||||||
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
|
||||||
@@ -1791,7 +1802,7 @@ export async function sendEmail(
|
|||||||
);
|
);
|
||||||
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
|
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
|
||||||
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
|
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
|
||||||
if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) {
|
if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom || raw) {
|
||||||
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)
|
||||||
@@ -1818,7 +1829,7 @@ export async function sendEmail(
|
|||||||
|
|
||||||
let res = await jmapClient.request(
|
let res = await jmapClient.request(
|
||||||
[
|
[
|
||||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
createCall,
|
||||||
['EmailSubmission/set', submissionArgs, '1'],
|
['EmailSubmission/set', submissionArgs, '1'],
|
||||||
// Which recipients the server took. A refused one is lost without a
|
// Which recipients the server took. A refused one is lost without a
|
||||||
// bounce while the create succeeds and files the message in Sent.
|
// bounce while the create succeeds and files the message in Sent.
|
||||||
@@ -1830,7 +1841,7 @@ export async function sendEmail(
|
|||||||
// Stalwart 1.0 refuses a submission once the account holds `maxSubmissions`
|
// Stalwart 1.0 refuses a submission once the account holds `maxSubmissions`
|
||||||
// records (overQuota). The message exists by then, so make room and submit
|
// records (overQuota). The message exists by then, so make room and submit
|
||||||
// that copy once more instead of failing the send.
|
// that copy once more instead of failing the send.
|
||||||
const copyCreated = res.methodResponses.find(([name, , callId]) => name === 'Email/set' && callId === '0');
|
const copyCreated = res.methodResponses.find(([name, , callId]) => (name === 'Email/set' || name === 'Email/import') && callId === '0');
|
||||||
const copyId = (copyCreated?.[1] as { created?: Record<string, { id?: string }> } | undefined)?.created?.draft?.id;
|
const copyId = (copyCreated?.[1] as { created?: Record<string, { id?: string }> } | undefined)?.created?.draft?.id;
|
||||||
if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') {
|
if (copyCreated && copyId && submissionCreateError(res, 'sub-1')?.type === 'overQuota') {
|
||||||
await makeRoomForSubmission(accountId);
|
await makeRoomForSubmission(accountId);
|
||||||
@@ -1871,7 +1882,7 @@ export async function sendEmail(
|
|||||||
failure = new Error((result as { description?: string }).description ?? 'Send failed');
|
failure = new Error((result as { description?: string }).description ?? 'Send failed');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (methodName === 'Email/set') {
|
if (methodName === 'Email/set' || methodName === 'Email/import') {
|
||||||
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }> }).notCreated?.draft;
|
const notCreated = (result as { notCreated?: Record<string, { description?: string; type?: string; properties?: string[] }> }).notCreated?.draft;
|
||||||
if (notCreated) {
|
if (notCreated) {
|
||||||
const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : '';
|
const props = notCreated.properties?.length ? ` (properties: ${notCreated.properties.join(', ')})` : '';
|
||||||
|
|||||||
@@ -8,12 +8,23 @@ import { useLocaleStore } from '../../stores/locale-store';
|
|||||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||||
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
|
import { MESSAGE_PRIORITIES, type MessagePriority } from '../../lib/message-priority';
|
||||||
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
||||||
|
import type { SecureMode } from '../../lib/crypto/secure-send';
|
||||||
|
|
||||||
export interface ComposerOptions {
|
export interface ComposerOptions {
|
||||||
requestReadReceipt: boolean;
|
requestReadReceipt: boolean;
|
||||||
requestDsn: boolean;
|
requestDsn: boolean;
|
||||||
requireTls: boolean;
|
requireTls: boolean;
|
||||||
priority: MessagePriority;
|
priority: MessagePriority;
|
||||||
|
/** Sign / encrypt with OpenPGP or S/MIME (#58; the plugins' Options entries). */
|
||||||
|
secureMode: SecureMode;
|
||||||
|
sign: boolean;
|
||||||
|
encrypt: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which kinds of signing/encryption the From address has a key for. */
|
||||||
|
export interface SecureAvailability {
|
||||||
|
pgp: boolean;
|
||||||
|
smime: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,23 +32,28 @@ export interface ComposerOptions {
|
|||||||
* toolbar button (webmail composer options menu `activeCount`).
|
* toolbar button (webmail composer options menu `activeCount`).
|
||||||
*/
|
*/
|
||||||
export function activeComposerOptionCount(options: ComposerOptions): number {
|
export function activeComposerOptionCount(options: ComposerOptions): number {
|
||||||
return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal']
|
return [options.requestReadReceipt, options.requestDsn, options.requireTls, options.priority !== 'normal', options.sign, options.encrypt]
|
||||||
.filter(Boolean).length;
|
.filter(Boolean).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The composer's per-message options (webmail email-composer.tsx options
|
* The composer's per-message options (webmail email-composer.tsx options
|
||||||
* menu): read receipt, delivery status notification and REQUIRETLS - the
|
* menu): read receipt, delivery status notification and REQUIRETLS - the
|
||||||
* last two only when the account's submission extensions list them - and
|
* last two only when the account's submission extensions list them - the
|
||||||
* the message priority.
|
* message priority, and Sign / Encrypt with OpenPGP or S/MIME when the From
|
||||||
|
* address has a key (the PGP and S/MIME plugins' Options entries).
|
||||||
*/
|
*/
|
||||||
export function ComposerOptionsSheet({
|
export function ComposerOptionsSheet({
|
||||||
visible, options, dsnAvailable, requireTlsAvailable, onChange, onClose,
|
visible, options, dsnAvailable, requireTlsAvailable, secureAvailable, secureHint, onChange, onClose,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
options: ComposerOptions;
|
options: ComposerOptions;
|
||||||
dsnAvailable: boolean;
|
dsnAvailable: boolean;
|
||||||
requireTlsAvailable: boolean;
|
requireTlsAvailable: boolean;
|
||||||
|
/** Own keys for the From address; the section is left out without any. */
|
||||||
|
secureAvailable?: SecureAvailability;
|
||||||
|
/** Why the chosen sign/encrypt can't go out yet (a recipient without a key). */
|
||||||
|
secureHint?: string | null;
|
||||||
onChange: (next: Partial<ComposerOptions>) => void;
|
onChange: (next: Partial<ComposerOptions>) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -46,7 +62,7 @@ export function ComposerOptionsSheet({
|
|||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const bottomInset = useSheetBottomInset(visible);
|
const bottomInset = useSheetBottomInset(visible);
|
||||||
|
|
||||||
const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls') => (
|
const toggleRow = (label: string, hint: string, value: boolean, key: 'requestReadReceipt' | 'requestDsn' | 'requireTls' | 'sign' | 'encrypt') => (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.row}
|
style={styles.row}
|
||||||
onPress={() => onChange({ [key]: !value })}
|
onPress={() => onChange({ [key]: !value })}
|
||||||
@@ -92,6 +108,47 @@ export function ComposerOptionsSheet({
|
|||||||
options.requireTls,
|
options.requireTls,
|
||||||
'requireTls',
|
'requireTls',
|
||||||
)}
|
)}
|
||||||
|
{secureAvailable && (secureAvailable.pgp || secureAvailable.smime) && (
|
||||||
|
<>
|
||||||
|
<View style={styles.separator} />
|
||||||
|
<Text style={styles.groupLabel}>{t('email_composer.option_security', 'Signature and encryption')}</Text>
|
||||||
|
{secureAvailable.pgp && secureAvailable.smime && (
|
||||||
|
<View style={styles.segment} accessibilityRole="radiogroup">
|
||||||
|
{(['pgp', 'smime'] as const).map((mode) => {
|
||||||
|
const selected = options.secureMode === mode;
|
||||||
|
const label = mode === 'pgp' ? 'OpenPGP' : 'S/MIME';
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={mode}
|
||||||
|
style={[styles.segmentItem, selected && styles.segmentItemSelected]}
|
||||||
|
onPress={() => onChange({ secureMode: mode })}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityLabel={label}
|
||||||
|
accessibilityState={{ checked: selected, selected }}
|
||||||
|
>
|
||||||
|
<Text style={[styles.rowLabel, selected && styles.segmentTextSelected]}>{label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{toggleRow(
|
||||||
|
t('email_composer.option_sign', 'Sign'),
|
||||||
|
options.secureMode === 'pgp'
|
||||||
|
? t('email_composer.option_sign_pgp_hint', 'Add an OpenPGP signature with your key')
|
||||||
|
: t('email_composer.option_sign_smime_hint', 'Add an S/MIME signature with your certificate'),
|
||||||
|
options.sign,
|
||||||
|
'sign',
|
||||||
|
)}
|
||||||
|
{toggleRow(
|
||||||
|
t('email_composer.option_encrypt', 'Encrypt'),
|
||||||
|
t('email_composer.option_encrypt_hint', 'Only the recipients (and you) can read it; every recipient needs a key'),
|
||||||
|
options.encrypt,
|
||||||
|
'encrypt',
|
||||||
|
)}
|
||||||
|
{!!secureHint && (options.sign || options.encrypt) && <Text style={styles.warning}>{secureHint}</Text>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<View style={styles.separator} />
|
<View style={styles.separator} />
|
||||||
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
|
<Text style={styles.groupLabel}>{t('email_composer.option_priority', 'Priority')}</Text>
|
||||||
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
|
<View accessibilityRole="radiogroup" accessibilityLabel={t('email_composer.option_priority', 'Priority')}>
|
||||||
@@ -159,6 +216,13 @@ function makeStyles(c: ThemePalette) {
|
|||||||
rowHint: { ...typography.caption, color: c.textMuted },
|
rowHint: { ...typography.caption, color: c.textMuted },
|
||||||
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
|
separator: { height: StyleSheet.hairlineWidth, backgroundColor: c.borderLight, marginVertical: spacing.xs },
|
||||||
groupLabel: { ...typography.captionMedium, color: c.textSecondary },
|
groupLabel: { ...typography.captionMedium, color: c.textSecondary },
|
||||||
|
segment: { flexDirection: 'row', gap: spacing.xs, paddingVertical: spacing.xs },
|
||||||
|
segmentItem: {
|
||||||
|
flex: 1, alignItems: 'center', paddingVertical: spacing.sm, borderRadius: radius.sm, borderWidth: 1, borderColor: c.border,
|
||||||
|
},
|
||||||
|
segmentItemSelected: { borderColor: c.primary, backgroundColor: c.primaryBg },
|
||||||
|
segmentTextSelected: { color: c.primary },
|
||||||
|
warning: { ...typography.caption, color: c.warning },
|
||||||
done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs },
|
done: { alignItems: 'center', paddingVertical: spacing.sm, marginTop: spacing.xs },
|
||||||
doneText: { ...typography.bodyMedium, color: c.primary },
|
doneText: { ...typography.bodyMedium, color: c.primary },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Sign / encrypt in the composer (#58) as wired in ComposeScreen, checked on
|
||||||
|
// its source (the suite has no RN renderer): the options sheet gets the
|
||||||
|
// availability, a secure send goes out as the uploaded raw message, and it
|
||||||
|
// never lands in the Outbox, which would send it as plain mail later.
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
|
||||||
|
const reader = fs.readFileSync(path.resolve(__dirname, '../../components/email/MessageContent.tsx'), 'utf8');
|
||||||
|
|
||||||
|
describe('ComposeScreen sign / encrypt wiring', () => {
|
||||||
|
it('passes the keys the From address has to the options sheet', () => {
|
||||||
|
expect(source).toMatch(/<ComposerOptionsSheet[\s\S]{0,400}secureAvailable=\{secureAvailable\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends the signed / encrypted blob through sendEmail', () => {
|
||||||
|
expect(source).toMatch(/const blobId = await prepareSecureBlob\(secureChoice, outgoing, target\.accountId\);/);
|
||||||
|
expect(source).toMatch(/requireTls: requireTls && supportsSubmissionExtension\('REQUIRETLS', target\.accountId\),\s*rawBlobId,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a secure send out of the Outbox', () => {
|
||||||
|
const block = source.slice(source.indexOf('if (secureChoice) {'), source.indexOf('const target = await sendTargetFor(primaryIdentity'));
|
||||||
|
expect(block).toMatch(/if \(!useNetworkStore\.getState\(\)\.online\) \{[\s\S]*?return;/);
|
||||||
|
expect(block).toMatch(/\} else \{\s*queuedOutgoing = outgoing;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reader wiring', () => {
|
||||||
|
it('shows the decrypted body and the banner', () => {
|
||||||
|
expect(reader).toMatch(/const secure = useSecureMessage\(email, jmapAccountId\);/);
|
||||||
|
expect(reader).toMatch(/bodyOverride=\{bodyOverride\}/);
|
||||||
|
expect(reader).toMatch(/<SecureMessageBanner status=/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Signing / encrypting from the composer (#58): recipient keys come from the
|
||||||
|
// stored keys or a contact card, a missing key stops the send, and the
|
||||||
|
// uploaded message carries the attachments and the composer's options.
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { prepareSecureSend, resolveRecipientKeys, SecureSendError } from '../compose-secure';
|
||||||
|
import { importPgpKey, lockAll } from '../keyring';
|
||||||
|
import { openSecureMessage } from '../secure-message';
|
||||||
|
import { useCryptoKeysStore } from '../../../stores/crypto-keys-store';
|
||||||
|
import type { ContactCard } from '../../../api/types';
|
||||||
|
import type { OutgoingEmail } from '../../../api/email';
|
||||||
|
import { makePgpKey, useNodeCrypto } from './helpers';
|
||||||
|
|
||||||
|
const secure = vi.hoisted(() => new Map<string, string>());
|
||||||
|
vi.mock('expo-secure-store', () => ({
|
||||||
|
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6,
|
||||||
|
setItemAsync: vi.fn(async (k: string, v: string) => { secure.set(k, v); }),
|
||||||
|
getItemAsync: vi.fn(async (k: string) => secure.get(k) ?? null),
|
||||||
|
deleteItemAsync: vi.fn(async (k: string) => { secure.delete(k); }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let me: Awaited<ReturnType<typeof makePgpKey>>;
|
||||||
|
let friend: Awaited<ReturnType<typeof makePgpKey>>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
useNodeCrypto();
|
||||||
|
me = await makePgpKey('me@example.org');
|
||||||
|
friend = await makePgpKey('friend@example.org');
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
secure.clear();
|
||||||
|
lockAll();
|
||||||
|
useCryptoKeysStore.setState({ keys: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const outgoing = (bcc: string[] = []): OutgoingEmail => ({
|
||||||
|
from: [{ name: 'Me', email: 'me@example.org' }],
|
||||||
|
to: [{ email: 'friend@example.org' }],
|
||||||
|
bcc: bcc.map((email) => ({ email })),
|
||||||
|
subject: 'Encrypted with attachment',
|
||||||
|
textBody: 'see attached',
|
||||||
|
htmlBody: '<p>see attached</p>',
|
||||||
|
attachments: [{ blobId: 'b1', name: 'report.pdf', type: 'application/pdf', size: 3, disposition: 'attachment' }],
|
||||||
|
priority: 'high',
|
||||||
|
requestReadReceipt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactWithKey = (armored: string): ContactCard => ({
|
||||||
|
id: 'c1',
|
||||||
|
emails: { e1: { address: 'Friend@Example.org' } },
|
||||||
|
cryptoKeys: { k1: { uri: `data:application/pgp-keys;base64,${Buffer.from(armored).toString('base64')}` } },
|
||||||
|
} as unknown as ContactCard);
|
||||||
|
|
||||||
|
describe('recipient keys', () => {
|
||||||
|
it('finds a key in a contact card when none is stored, and names recipients without one', async () => {
|
||||||
|
expect(resolveRecipientKeys('pgp', ['friend@example.org']).missing).toEqual(['friend@example.org']);
|
||||||
|
const r = resolveRecipientKeys('pgp', ['friend@example.org', 'stranger@example.org'], [contactWithKey(friend.publicKey)]);
|
||||||
|
expect(r.missing).toEqual(['stranger@example.org']);
|
||||||
|
expect(r.found).toEqual([friend.publicKey]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareSecureSend', () => {
|
||||||
|
it('uploads a signed and encrypted PGP/MIME message the recipient can read, attachment and headers included', async () => {
|
||||||
|
await importPgpKey(me.privateKey, me.passphrase);
|
||||||
|
await importPgpKey(friend.publicKey);
|
||||||
|
let uploaded: Uint8Array | null = null;
|
||||||
|
const blobId = await prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: true }, {
|
||||||
|
fetchBlob: async () => new Uint8Array([1, 2, 3]),
|
||||||
|
upload: async (bytes) => {
|
||||||
|
uploaded = bytes;
|
||||||
|
return 'raw-blob';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(blobId).toBe('raw-blob');
|
||||||
|
const text = new TextDecoder().decode(uploaded!);
|
||||||
|
expect(text).toMatch(/^X-Priority: 1 \(Highest\)$/m);
|
||||||
|
expect(text).toMatch(/^Disposition-Notification-To: me@example.org$/m);
|
||||||
|
expect(text).toMatch(/^From: "Me" <me@example.org>$/m);
|
||||||
|
|
||||||
|
const res = await openSecureMessage(uploaded!, 'me@example.org', {
|
||||||
|
pgpDecryptionKeys: () => [friend.unlocked],
|
||||||
|
pgpOwnKeys: () => [{ id: 'friend', publicKeyArmored: friend.publicKey, unlocked: true }],
|
||||||
|
pgpPublicKeys: () => [me.publicKey],
|
||||||
|
smimeKeys: () => [],
|
||||||
|
});
|
||||||
|
expect(res?.status).toMatchObject({ decrypted: true, signature: 'valid', signerEmail: 'me@example.org' });
|
||||||
|
expect(res?.attachments.map((a) => [a.name, Array.from(a.bytes)])).toEqual([['report.pdf', [1, 2, 3]]]);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('never writes Bcc into the message', async () => {
|
||||||
|
await importPgpKey(me.privateKey, me.passphrase);
|
||||||
|
await importPgpKey(friend.publicKey);
|
||||||
|
let uploaded: Uint8Array | null = null;
|
||||||
|
// The Bcc recipient needs a key too: give them the friend's for the test.
|
||||||
|
const contacts = [{ id: 'c2', emails: { e: { address: 'bcc@example.org' } }, cryptoKeys: contactWithKey(friend.publicKey).cryptoKeys } as unknown as ContactCard];
|
||||||
|
await prepareSecureSend(outgoing(['bcc@example.org']), { mode: 'pgp', sign: false, encrypt: true }, {
|
||||||
|
contacts,
|
||||||
|
fetchBlob: async () => new Uint8Array([1]),
|
||||||
|
upload: async (bytes) => { uploaded = bytes; return 'x'; },
|
||||||
|
});
|
||||||
|
expect(new TextDecoder().decode(uploaded!)).not.toMatch(/bcc@example.org/i);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
it('stops when a recipient has no key, or when the signing key is locked', async () => {
|
||||||
|
await importPgpKey(me.privateKey, me.passphrase);
|
||||||
|
const deps = { fetchBlob: async () => new Uint8Array(), upload: async () => 'x' };
|
||||||
|
await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: false, encrypt: true }, deps))
|
||||||
|
.rejects.toMatchObject({ code: 'missing-recipients', detail: ['friend@example.org'] });
|
||||||
|
lockAll();
|
||||||
|
await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: false }, deps)).rejects.toBeInstanceOf(SecureSendError);
|
||||||
|
useCryptoKeysStore.setState({ keys: [] });
|
||||||
|
await expect(prepareSecureSend(outgoing(), { mode: 'pgp', sign: true, encrypt: false }, deps)).rejects.toMatchObject({ code: 'no-own-key' });
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Signing / encrypting from the composer (#58): which keys a send needs,
|
||||||
|
// whether every recipient has one, and turning the composer's outgoing
|
||||||
|
// message into the raw signed/encrypted blob that sendEmail imports. The
|
||||||
|
// plugins' onComposeSend (repos/plugins/bulwark-gpg/src/index.js,
|
||||||
|
// repos/plugins/smime/src/index.js) do the same with host.jmap.sendRaw.
|
||||||
|
|
||||||
|
import type { OutgoingEmail } from '../../api/email';
|
||||||
|
import type { ContactCard } from '../../api/types';
|
||||||
|
import { useCryptoKeysStore, keysForEmail, isRecordCurrent, type CryptoKeyRecord, type PgpKeyRecord, type SmimeCertRecord } from '../../stores/crypto-keys-store';
|
||||||
|
import { messagePriorityHeaders } from '../message-priority';
|
||||||
|
import { base64Decode, formatHeader, encodeHeaderWords, type MimeAttachment, type MimeMessageInput } from './mime';
|
||||||
|
import { buildSecureMessage, type SecureMode } from './secure-send';
|
||||||
|
import { ownKeyFor, unlockedPgpKey, unlockedSmimeKey } from './keyring';
|
||||||
|
|
||||||
|
export interface SecureChoice {
|
||||||
|
mode: SecureMode;
|
||||||
|
sign: boolean;
|
||||||
|
encrypt: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NO_SECURE_CHOICE: SecureChoice = Object.freeze({ mode: 'pgp', sign: false, encrypt: false }) as SecureChoice;
|
||||||
|
|
||||||
|
export function isSecureChoice(choice: SecureChoice | null | undefined): choice is SecureChoice {
|
||||||
|
return !!choice && (choice.sign || choice.encrypt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A recipient key found in a contact card (JSContact cryptoKeys, vCard KEY). */
|
||||||
|
function keysFromContacts(kind: SecureMode, email: string, contacts: ContactCard[]): Array<string | Uint8Array> {
|
||||||
|
const e = email.toLowerCase();
|
||||||
|
const out: Array<string | Uint8Array> = [];
|
||||||
|
for (const card of contacts) {
|
||||||
|
const emails = Object.values(card.emails ?? {}).map((m) => (m.address ?? '').toLowerCase());
|
||||||
|
if (!emails.includes(e)) continue;
|
||||||
|
for (const key of Object.values(card.cryptoKeys ?? {})) {
|
||||||
|
const decoded = decodeDataUri(key.uri);
|
||||||
|
if (!decoded) continue;
|
||||||
|
const type = (key.mediaType || decoded.type).toLowerCase();
|
||||||
|
const text = new TextDecoder('utf-8', { fatal: false }).decode(decoded.bytes);
|
||||||
|
if (kind === 'pgp' && (type.includes('pgp') || text.includes('BEGIN PGP PUBLIC KEY BLOCK'))) {
|
||||||
|
if (text.includes('BEGIN PGP PUBLIC KEY BLOCK')) out.push(text);
|
||||||
|
} else if (kind === 'smime' && (type.includes('pkix') || type.includes('x509') || type.includes('x-x509') || text.includes('BEGIN CERTIFICATE'))) {
|
||||||
|
out.push(text.includes('BEGIN CERTIFICATE') ? base64Decode(text.replace(/-----[^-]+-----/g, '')) : decoded.bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeDataUri(uri: string | undefined): { type: string; bytes: Uint8Array } | null {
|
||||||
|
const m = /^data:([^;,]*)((?:;[^,]*)*?),(.*)$/s.exec(uri ?? '');
|
||||||
|
if (!m) return null;
|
||||||
|
const isBase64 = /;base64/i.test(m[2]);
|
||||||
|
try {
|
||||||
|
const bytes = isBase64 ? base64Decode(decodeURIComponent(m[3])) : new TextEncoder().encode(decodeURIComponent(m[3]));
|
||||||
|
return { type: m[1] || 'application/octet-stream', bytes };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecipientKeys {
|
||||||
|
/** Armored PGP keys or DER certificates, one or more per found recipient. */
|
||||||
|
found: Array<string | Uint8Array>;
|
||||||
|
missing: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public keys for every recipient: the stored keys first, then contact cards. */
|
||||||
|
export function resolveRecipientKeys(kind: SecureMode, emails: string[], contacts: ContactCard[] = []): RecipientKeys {
|
||||||
|
const keys = useCryptoKeysStore.getState().keys;
|
||||||
|
const found: Array<string | Uint8Array> = [];
|
||||||
|
const missing: string[] = [];
|
||||||
|
for (const email of [...new Set(emails.map((e) => e.trim().toLowerCase()).filter(Boolean))]) {
|
||||||
|
const stored = keysForEmail(keys, email).filter((k): k is CryptoKeyRecord => k.kind === kind && isRecordCurrent(k));
|
||||||
|
const usable = kind === 'smime' ? stored.filter((k) => (k as SmimeCertRecord).canEncrypt) : stored;
|
||||||
|
if (usable.length) {
|
||||||
|
for (const k of usable) found.push(k.kind === 'pgp' ? (k as PgpKeyRecord).publicKeyArmored : base64Decode((k as SmimeCertRecord).certificate));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fromContacts = keysFromContacts(kind, email, contacts);
|
||||||
|
if (fromContacts.length) found.push(...fromContacts);
|
||||||
|
else missing.push(email);
|
||||||
|
}
|
||||||
|
return { found, missing };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecureReadiness {
|
||||||
|
/** Your own key for the From address (needed to sign, and to keep Sent readable). */
|
||||||
|
ownKey?: CryptoKeyRecord;
|
||||||
|
/** Recipients without a key (encrypt only). */
|
||||||
|
missingRecipients: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function secureReadiness(choice: SecureChoice, fromEmail: string, recipients: string[], contacts: ContactCard[] = []): SecureReadiness {
|
||||||
|
const ownKey = ownKeyFor(choice.mode, fromEmail);
|
||||||
|
const missingRecipients = choice.encrypt ? resolveRecipientKeys(choice.mode, recipients, contacts).missing : [];
|
||||||
|
return { ownKey, missingRecipients };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header lines the composer's options add (priority, read receipt, Reply-To). */
|
||||||
|
function extraHeaders(outgoing: OutgoingEmail): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (outgoing.replyTo?.length) {
|
||||||
|
lines.push(formatHeader('Reply-To', outgoing.replyTo.map((a) => (a.name ? `${encodeHeaderWords(a.name)} <${a.email}>` : a.email)).join(', ')));
|
||||||
|
}
|
||||||
|
for (const [prop, value] of Object.entries(messagePriorityHeaders(outgoing.priority))) {
|
||||||
|
const name = /^header:([^:]+):/.exec(prop)?.[1];
|
||||||
|
if (name) lines.push(formatHeader(name, value));
|
||||||
|
}
|
||||||
|
if (outgoing.requestReadReceipt && outgoing.from[0]?.email) lines.push(formatHeader('Disposition-Notification-To', outgoing.from[0].email));
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SecureSendError extends Error {
|
||||||
|
code: 'no-own-key' | 'locked' | 'missing-recipients';
|
||||||
|
detail?: string[];
|
||||||
|
constructor(code: SecureSendError['code'], message: string, detail?: string[]) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SecureSendError';
|
||||||
|
this.code = code;
|
||||||
|
this.detail = detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the signed/encrypted message for `outgoing` and upload it; returns
|
||||||
|
* the blob id for sendEmail's `rawBlobId`. The own key must be unlocked
|
||||||
|
* first when signing (the composer asks for the passphrase).
|
||||||
|
*/
|
||||||
|
export async function prepareSecureSend(
|
||||||
|
outgoing: OutgoingEmail,
|
||||||
|
choice: SecureChoice,
|
||||||
|
deps: {
|
||||||
|
contacts?: ContactCard[];
|
||||||
|
fetchBlob: (blobId: string, name: string, type: string) => Promise<Uint8Array>;
|
||||||
|
upload: (bytes: Uint8Array) => Promise<string>;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const fromEmail = outgoing.from[0]?.email ?? '';
|
||||||
|
const own = ownKeyFor(choice.mode, fromEmail);
|
||||||
|
if (!own) throw new SecureSendError('no-own-key', `No ${choice.mode === 'pgp' ? 'PGP key' : 'S/MIME certificate'} of yours for ${fromEmail}`);
|
||||||
|
const recipients = [...outgoing.to, ...(outgoing.cc ?? []), ...(outgoing.bcc ?? [])].map((r) => r.email);
|
||||||
|
|
||||||
|
const attachments: MimeAttachment[] = [];
|
||||||
|
for (const a of outgoing.attachments ?? []) {
|
||||||
|
attachments.push({
|
||||||
|
filename: a.name || 'attachment',
|
||||||
|
contentType: a.type || 'application/octet-stream',
|
||||||
|
content: await deps.fetchBlob(a.blobId, a.name || 'attachment', a.type || 'application/octet-stream'),
|
||||||
|
cid: a.disposition === 'inline' ? a.cid : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const input: MimeMessageInput = {
|
||||||
|
from: outgoing.from[0],
|
||||||
|
to: outgoing.to,
|
||||||
|
cc: outgoing.cc,
|
||||||
|
subject: outgoing.subject,
|
||||||
|
textBody: outgoing.textBody,
|
||||||
|
htmlBody: outgoing.htmlBody,
|
||||||
|
attachments,
|
||||||
|
inReplyTo: outgoing.inReplyTo,
|
||||||
|
references: outgoing.references,
|
||||||
|
messageId: outgoing.messageId,
|
||||||
|
extraHeaders: extraHeaders(outgoing),
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw: Uint8Array;
|
||||||
|
if (choice.mode === 'pgp') {
|
||||||
|
const record = own as PgpKeyRecord;
|
||||||
|
const signingKey = choice.sign ? unlockedPgpKey(record.id) : null;
|
||||||
|
if (choice.sign && !signingKey) throw new SecureSendError('locked', 'Unlock your PGP key to sign');
|
||||||
|
let encryptionKeys: string[] | undefined;
|
||||||
|
if (choice.encrypt) {
|
||||||
|
const r = resolveRecipientKeys('pgp', recipients, deps.contacts);
|
||||||
|
if (r.missing.length) throw new SecureSendError('missing-recipients', `No public key for ${r.missing.join(', ')}`, r.missing);
|
||||||
|
// Encrypted to yourself too, so Sent stays readable.
|
||||||
|
encryptionKeys = [...(r.found as string[]), record.publicKeyArmored];
|
||||||
|
}
|
||||||
|
raw = await buildSecureMessage(input, choice, { pgp: { signingKey, encryptionKeys } });
|
||||||
|
} else {
|
||||||
|
const record = own as SmimeCertRecord;
|
||||||
|
const unlocked = choice.sign ? unlockedSmimeKey(record.id) : undefined;
|
||||||
|
if (choice.sign && !unlocked) throw new SecureSendError('locked', 'Unlock your S/MIME key to sign');
|
||||||
|
let recipientCerts: Uint8Array[] | undefined;
|
||||||
|
if (choice.encrypt) {
|
||||||
|
const r = resolveRecipientKeys('smime', recipients, deps.contacts);
|
||||||
|
if (r.missing.length) throw new SecureSendError('missing-recipients', `No certificate for ${r.missing.join(', ')}`, r.missing);
|
||||||
|
recipientCerts = r.found as Uint8Array[];
|
||||||
|
}
|
||||||
|
raw = await buildSecureMessage(input, choice, {
|
||||||
|
smime: {
|
||||||
|
signer: unlocked ? { pkcs8: unlocked.pkcs8, certificate: base64Decode(record.certificate), chain: record.chain.map(base64Decode), algorithm: record.algorithm } : undefined,
|
||||||
|
recipientCerts,
|
||||||
|
senderCert: base64Decode(record.certificate),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return deps.upload(raw);
|
||||||
|
}
|
||||||
@@ -77,6 +77,13 @@ 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 { ComposerOptionsSheet, activeComposerOptionCount, type ComposerOptions } from '../components/compose/ComposerOptionsSheet';
|
||||||
|
import { UnlockKeyDialog } from '../components/crypto/UnlockKeyDialog';
|
||||||
|
import { ownKeyFor, isUnlocked, needsPassphrase, unlock } from '../lib/crypto/keyring';
|
||||||
|
import { prepareSecureSend, resolveRecipientKeys, SecureSendError, type SecureChoice } from '../lib/crypto/compose-secure';
|
||||||
|
import type { SecureMode } from '../lib/crypto/secure-send';
|
||||||
|
import { useCryptoKeysStore, type CryptoKeyRecord } from '../stores/crypto-keys-store';
|
||||||
|
import { usePolicyFeature } from '../stores/policy-store';
|
||||||
|
import { fetchBlobBytes } from '../lib/email-export';
|
||||||
import type { MessagePriority } from '../lib/message-priority';
|
import type { MessagePriority } from '../lib/message-priority';
|
||||||
import { supportsSubmissionExtension } from '../lib/submission-options';
|
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||||
import {
|
import {
|
||||||
@@ -630,9 +637,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
|
const initialPriority: MessagePriority = draft?.priority ?? 'normal';
|
||||||
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
|
const [priority, setPriority] = React.useState<MessagePriority>(initialPriority);
|
||||||
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
|
const [optionsSheetOpen, setOptionsSheetOpen] = React.useState(false);
|
||||||
|
// Sign / encrypt with OpenPGP or S/MIME (#58), per message.
|
||||||
|
const [secureMode, setSecureMode] = React.useState<SecureMode>('pgp');
|
||||||
|
const [secureSign, setSecureSign] = React.useState(false);
|
||||||
|
const [secureEncrypt, setSecureEncrypt] = React.useState(false);
|
||||||
|
const [unlockRecord, setUnlockRecord] = React.useState<CryptoKeyRecord | null>(null);
|
||||||
|
const unlockResolveRef = React.useRef<((ok: boolean) => void) | null>(null);
|
||||||
const composerOptions = React.useMemo<ComposerOptions>(
|
const composerOptions = React.useMemo<ComposerOptions>(
|
||||||
() => ({ requestReadReceipt, requestDsn, requireTls, priority }),
|
() => ({ requestReadReceipt, requestDsn, requireTls, priority, secureMode, sign: secureSign, encrypt: secureEncrypt }),
|
||||||
[requestReadReceipt, requestDsn, requireTls, priority],
|
[requestReadReceipt, requestDsn, requireTls, priority, secureMode, secureSign, secureEncrypt],
|
||||||
);
|
);
|
||||||
// Read when the sheet opens: the session may have arrived since the
|
// Read when the sheet opens: the session may have arrived since the
|
||||||
// composer did.
|
// composer did.
|
||||||
@@ -643,6 +656,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
|
if (next.requestDsn !== undefined) setRequestDsn(next.requestDsn);
|
||||||
if (next.requireTls !== undefined) setRequireTls(next.requireTls);
|
if (next.requireTls !== undefined) setRequireTls(next.requireTls);
|
||||||
if (next.priority !== undefined) setPriority(next.priority);
|
if (next.priority !== undefined) setPriority(next.priority);
|
||||||
|
if (next.secureMode !== undefined) setSecureMode(next.secureMode);
|
||||||
|
if (next.sign !== undefined) setSecureSign(next.sign);
|
||||||
|
if (next.encrypt !== undefined) setSecureEncrypt(next.encrypt);
|
||||||
}, []);
|
}, []);
|
||||||
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);
|
||||||
@@ -1445,6 +1461,78 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
};
|
};
|
||||||
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]);
|
}, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, priority, t]);
|
||||||
|
|
||||||
|
// Which of OpenPGP / S/MIME the From address has a key for, and who of the
|
||||||
|
// recipients has none (read while the options sheet is open).
|
||||||
|
const cryptoKeys = useCryptoKeysStore((s) => s.keys);
|
||||||
|
const smimeAllowed = usePolicyFeature('smimeEnabled');
|
||||||
|
const secureFromEmail = primaryIdentity ? senderAddress(primaryIdentity).from.email : '';
|
||||||
|
const secureAvailable = React.useMemo(
|
||||||
|
() => ({ pgp: !!ownKeyFor('pgp', secureFromEmail), smime: smimeAllowed && !!ownKeyFor('smime', secureFromEmail) }),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[cryptoKeys, secureFromEmail, smimeAllowed],
|
||||||
|
);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!secureAvailable[secureMode] && secureAvailable[secureMode === 'pgp' ? 'smime' : 'pgp']) {
|
||||||
|
setSecureMode(secureMode === 'pgp' ? 'smime' : 'pgp');
|
||||||
|
}
|
||||||
|
}, [secureAvailable, secureMode]);
|
||||||
|
const secureHint = React.useMemo(() => {
|
||||||
|
if (!optionsSheetOpen || !secureEncrypt) return null;
|
||||||
|
const emails = [...expandRecipients(finalTo), ...expandRecipients(finalCc), ...expandRecipients(finalBcc)].map((r) => toAddress(r).email);
|
||||||
|
const { missing } = resolveRecipientKeys(secureMode, emails, useContactsStore.getState().contacts);
|
||||||
|
return missing.length
|
||||||
|
? t('email_composer.secure_missing_keys', 'No key for {recipients}. Import one under Settings → Encryption.', { recipients: missing.join(', ') })
|
||||||
|
: null;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [optionsSheetOpen, secureEncrypt, secureMode, finalTo, finalCc, finalBcc, cryptoKeys, t]);
|
||||||
|
|
||||||
|
// Unlock the own key for a signed send; resolves false when cancelled.
|
||||||
|
const askUnlock = (record: CryptoKeyRecord): Promise<boolean> => new Promise((resolve) => {
|
||||||
|
unlockResolveRef.current = resolve;
|
||||||
|
setUnlockRecord(record);
|
||||||
|
});
|
||||||
|
const finishUnlock = (ok: boolean) => {
|
||||||
|
setUnlockRecord(null);
|
||||||
|
unlockResolveRef.current?.(ok);
|
||||||
|
unlockResolveRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build, sign/encrypt and upload the raw message for a secure send
|
||||||
|
* (#58); the blob id for sendEmail, or null when the user was told why
|
||||||
|
* it can't go out.
|
||||||
|
*/
|
||||||
|
const prepareSecureBlob = async (choice: SecureChoice, outgoing: OutgoingEmail, accountId: string | undefined): Promise<string | null> => {
|
||||||
|
const title = t('email_composer.secure_failed_title', 'Cannot sign or encrypt');
|
||||||
|
const own = ownKeyFor(choice.mode, outgoing.from[0]?.email ?? '');
|
||||||
|
if (!own) {
|
||||||
|
Alert.alert(title, t('email_composer.secure_no_key', 'There is no key for {email} on this device. Import one under Settings → Encryption.', { email: outgoing.from[0]?.email ?? '' }));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (choice.sign && !isUnlocked(own)) {
|
||||||
|
if (await needsPassphrase(own)) {
|
||||||
|
if (!(await askUnlock(own))) return null;
|
||||||
|
} else {
|
||||||
|
await unlock(own, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await prepareSecureSend(outgoing, choice, {
|
||||||
|
contacts: useContactsStore.getState().contacts,
|
||||||
|
fetchBlob: (blobId, name, type) => fetchBlobBytes(blobId, name, type),
|
||||||
|
upload: async (bytes) => (await uploadBytesForLogin(ownerLoginRef.current, bytes, 'message/rfc822', accountId)).blobId,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SecureSendError) {
|
||||||
|
Alert.alert(title, e.code === 'missing-recipients'
|
||||||
|
? t('email_composer.secure_missing_keys', 'No key for {recipients}. Import one under Settings → Encryption.', { recipients: (e.detail ?? []).join(', ') })
|
||||||
|
: e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 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> => {
|
||||||
const identity = primaryIdentity;
|
const identity = primaryIdentity;
|
||||||
@@ -2559,8 +2647,23 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
|
const outgoing = buildOutgoing(primaryIdentity, liveBodyHtml, { forDraft: false });
|
||||||
queuedOutgoing = outgoing;
|
const secureChoice: SecureChoice | null = secureSign || secureEncrypt
|
||||||
if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return;
|
? { mode: secureMode, sign: secureSign, encrypt: secureEncrypt }
|
||||||
|
: null;
|
||||||
|
if (secureChoice) {
|
||||||
|
// The Outbox would send it as plain mail later: a signed or
|
||||||
|
// encrypted message only goes out now.
|
||||||
|
if (!useNetworkStore.getState().online) {
|
||||||
|
Alert.alert(
|
||||||
|
t('email_composer.secure_failed_title', 'Cannot sign or encrypt'),
|
||||||
|
t('email_composer.secure_offline', 'Signed or encrypted messages can\'t wait in the Outbox. Send it when you are online.'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queuedOutgoing = outgoing;
|
||||||
|
if (!useNetworkStore.getState().online && queueForLater(outgoing, false)) return;
|
||||||
|
}
|
||||||
// The account the submission is held in, for Undo (read before the
|
// The account the submission is held in, for Undo (read before the
|
||||||
// request: a switch may land while it is out).
|
// request: a switch may land while it is out).
|
||||||
// A group or shared identity sends from its own account, and its Sent
|
// A group or shared identity sends from its own account, and its Sent
|
||||||
@@ -2568,6 +2671,13 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes);
|
const target = await sendTargetFor(primaryIdentity, outgoing.from[0]?.email ?? primaryIdentity.email, mailboxes);
|
||||||
if (refuseSendAfterSwitch()) return;
|
if (refuseSendAfterSwitch()) return;
|
||||||
const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined;
|
const submissionAccountId = target.accountId ?? jmapClient.connectedAccountId ?? undefined;
|
||||||
|
let rawBlobId: string | undefined;
|
||||||
|
if (secureChoice) {
|
||||||
|
const blobId = await prepareSecureBlob(secureChoice, outgoing, target.accountId);
|
||||||
|
if (!blobId) return;
|
||||||
|
if (refuseSendAfterSwitch()) return;
|
||||||
|
rawBlobId = blobId;
|
||||||
|
}
|
||||||
const result = await sendEmail(
|
const result = await sendEmail(
|
||||||
outgoing,
|
outgoing,
|
||||||
target.identityId,
|
target.identityId,
|
||||||
@@ -2582,6 +2692,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// hides the toggles the account's submission extensions lack).
|
// hides the toggles the account's submission extensions lack).
|
||||||
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
requestDsn: requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
||||||
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
requireTls: requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
||||||
|
rawBlobId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
draftIdRef.current = null;
|
draftIdRef.current = null;
|
||||||
@@ -3600,6 +3711,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
requireTlsAvailable={requireTlsAvailable}
|
requireTlsAvailable={requireTlsAvailable}
|
||||||
onChange={onComposerOptionsChange}
|
onChange={onComposerOptionsChange}
|
||||||
onClose={() => setOptionsSheetOpen(false)}
|
onClose={() => setOptionsSheetOpen(false)}
|
||||||
|
secureAvailable={secureAvailable}
|
||||||
|
secureHint={secureHint}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UnlockKeyDialog
|
||||||
|
record={unlockRecord}
|
||||||
|
reason={t('email_composer.secure_unlock_reason', 'Your key signs this message.')}
|
||||||
|
onUnlocked={() => finishUnlock(true)}
|
||||||
|
onCancel={() => finishUnlock(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FilesPickerSheet
|
<FilesPickerSheet
|
||||||
|
|||||||
Reference in New Issue
Block a user