diff --git a/components/email/__tests__/composer-plugin-send.test.tsx b/components/email/__tests__/composer-plugin-send.test.tsx index 308d766b7..b95a9f450 100644 --- a/components/email/__tests__/composer-plugin-send.test.tsx +++ b/components/email/__tests__/composer-plugin-send.test.tsx @@ -6,6 +6,7 @@ import { EmailComposer } from '../email-composer'; const hoisted = vi.hoisted(() => ({ takeOver: vi.fn(), toastError: vi.fn(), + transform: vi.fn(async (email: unknown) => email), })); // ─── Heavy component mocks (mirrors composer-from-override-envelope.test.tsx) ─ @@ -158,7 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, onComposeSend: { takeOver: hoisted.takeOver }, - onTransformOutgoingEmail: { transform: async (email: unknown) => email }, + onTransformOutgoingEmail: { transform: hoisted.transform }, }, contactHooks: { search: { call: async () => [] }, @@ -254,4 +255,16 @@ describe('composer send taken over by a plugin (#1207)', () => { await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); }); + + it('sends the headers a plugin added to the outgoing message', async () => { + hoisted.takeOver.mockResolvedValue('default'); + hoisted.transform.mockImplementationOnce(async (email: unknown) => ({ + ...(email as Record), + headers: { 'X-Classification': 'Internal' }, + })); + const { onSend } = renderComposer(); + + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0].headers).toEqual({ 'X-Classification': 'Internal' }); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 85fb6d1c0..9ff4bf140 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -297,6 +297,8 @@ interface EmailComposerProps { requireTls?: boolean; /** Importance / X-Priority; absent for normal (#970). */ priority?: MessagePriority; + /** Extra headers plugins added in onTransformOutgoingEmail. */ + headers?: Record; /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; }) => void | false | Promise; @@ -3697,6 +3699,7 @@ export function EmailComposer({ requestDsn: requestDsn || undefined, requireTls: requireTls || undefined, priority: priority !== 'normal' ? priority : undefined, + headers: outgoing.headers, delayedUntil: effectiveDelayedUntil, composerId: pluginComposerIdRef.current ?? undefined, }); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index ea048013a..9b37c8029 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -2154,6 +2154,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; + headers?: Record; composerId?: string; }) => { // Nothing is sent: the composer keeps what only goes once the mail is out. @@ -2178,7 +2179,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = composerSourceEmail?.id; - const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, localAccountId: data.localAccountId, draftAccountId: data.draftAccountId }); + const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, headers: data.headers, localAccountId: data.localAccountId, draftAccountId: data.draftAccountId }); submitted = true; announceSent(result, data); setShowComposer(false); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index f806987eb..8e3ae5177 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -449,7 +449,7 @@ export interface IJMAPClient { // supportsSubmissionExtension). // `draftAccountId`: the account `draftId` lives in, so the sent draft is // destroyed there (default: the primary account). - options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; draftAccountId?: string }, + options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; draftAccountId?: string }, ): Promise; /** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */ supportsSubmissionExtension?(extension: string, accountId?: string): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cf4bd6a55..ae878398b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -44,6 +44,7 @@ import { parseAuthenticationResults } from "@/lib/email-headers"; import { IS_LITE_STALWART } from "@/lib/lite"; import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority"; +import { customHeaderProperties } from "@/lib/outgoing/custom-headers"; import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body"; // Cap for the follow-up Email/get issued when a displayed body part comes @@ -1420,6 +1421,8 @@ export interface SubmissionSendOptions { requireTls?: boolean; /** Importance / X-Priority headers (#970); see lib/message-priority.ts. */ priority?: MessagePriority; + /** Extra headers plugins added; see lib/outgoing/custom-headers.ts. */ + headers?: Record; /** The account the replaced draft (`draftId`) lives in; default the primary. */ draftAccountId?: string; } @@ -4943,6 +4946,7 @@ export class JMAPClient implements IJMAPClient { } Object.assign(emailCreate, messagePriorityHeaders(options?.priority)); + Object.assign(emailCreate, customHeaderProperties(options?.headers)); // Images the HTML shows by cid: go into a multipart/related beside it, // or recipients don't see them (#1451). diff --git a/lib/outgoing/__tests__/custom-headers.test.ts b/lib/outgoing/__tests__/custom-headers.test.ts new file mode 100644 index 000000000..785857783 --- /dev/null +++ b/lib/outgoing/__tests__/custom-headers.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { customHeaderProperties } from '../custom-headers'; + +// Headers a plugin adds in onTransformOutgoingEmail (a classification label, +// for one) go out with the message. Ones that would change the sender, clash +// with what the client writes itself or forge trace headers don't. + +describe('customHeaderProperties', () => { + it('turns plugin headers into JMAP header properties', () => { + expect(customHeaderProperties({ 'X-Classification': 'Confidential', Sensitivity: 'Company-Confidential' })).toEqual({ + 'header:X-Classification:asText': 'Confidential', + 'header:Sensitivity:asText': 'Company-Confidential', + }); + }); + + it('drops headers that say who sent the mail, how it travelled, or that the client writes', () => { + expect(customHeaderProperties({ + From: 'boss@example.com', + sender: 'boss@example.com', + 'Reply-To': 'x@evil.example', + Bcc: 'spy@evil.example', + 'Message-ID': '', + 'Content-Type': 'text/plain', + Received: 'from somewhere', + 'Authentication-Results': 'mx; dkim=pass', + 'DKIM-Signature': 'v=1', + 'ARC-Seal': 'i=1', + 'Resent-From': 'a@example.com', + 'X-Priority': '1', + Importance: 'high', + 'Disposition-Notification-To': 'me@example.com', + })).toEqual({}); + }); + + it('drops values with line breaks or control characters', () => { + expect(customHeaderProperties({ + 'X-Label': 'ok\r\nBcc: spy@evil.example', + 'X-Other': 'a\nb', + 'X-Nul': 'a\u0000b', + })).toEqual({}); + }); + + it('drops invalid names, empty or non-text values and repeats in another case', () => { + expect(customHeaderProperties({ + 'X Bad': 'space', + 'X:Bad': 'colon', + 'X-Ümlaut': 'non-ascii', + 'X-Empty': ' ', + 'X-Number': 5 as unknown as string, + 'X-Label': 'first', + 'x-label': 'second', + })).toEqual({ 'header:X-Label:asText': 'first' }); + }); + + it('ignores anything that is not a plain header map', () => { + expect(customHeaderProperties(undefined)).toEqual({}); + expect(customHeaderProperties(['X-A'])).toEqual({}); + expect(customHeaderProperties('X-A: b')).toEqual({}); + }); + + it('caps the number of headers', () => { + const many = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`X-H${i}`, 'v'])); + expect(Object.keys(customHeaderProperties(many))).toHaveLength(20); + }); +}); + +describe('sendEmail with plugin headers', () => { + interface CapturedRequest { methodCalls: Array<[string, Record, string]> } + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + function mockFlow() { + const captured: CapturedRequest[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const body = JSON.parse((init as { body: string }).body) as CapturedRequest; + captured.push(body); + const methodResponses: unknown[] = []; + for (const [method, args, callId] of body.methodCalls) { + if (method === 'Mailbox/get') { + methodResponses.push(['Mailbox/get', { + list: [ + { id: 'mb-drafts', name: 'Drafts', role: 'drafts' }, + { id: 'mb-sent', name: 'Sent', role: 'sent' }, + ], + }, callId]); + } else if (method === 'Identity/get') { + methodResponses.push(['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, callId]); + } else if (method === 'Email/set') { + const key = Object.keys((args.create ?? {}) as Record)[0]; + methodResponses.push(['Email/set', key ? { created: { [key]: { id: 'email-new-1' } } } : {}, callId]); + } else if (method === 'EmailSubmission/set') { + methodResponses.push(['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, callId]); + } + } + const payload = { methodResponses }; + return { + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(payload)), + json: () => Promise.resolve(payload), + } as Response; + }); + return captured; + } + + it('puts the plugin headers on the created message and leaves the sender alone', async () => { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { apiUrl: 'https://jmap.example.com/api', accountId: 'account-1', username: 'user@example.com' }); + const captured = mockFlow(); + await client.sendEmail( + ['to@example.com'], 'Subject', 'body', + undefined, undefined, 'identity-1', 'user@example.com', + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + { headers: { 'X-Classification': 'Internal', From: 'boss@example.com' } }, + ); + const call = captured.flatMap(r => r.methodCalls).find(c => c[0] === 'Email/set' && c[1].create); + const created = Object.values(call![1].create as Record>)[0]; + expect(created['header:X-Classification:asText']).toBe('Internal'); + expect(created['header:From:asText']).toBeUndefined(); + expect(created.from).toEqual([{ email: 'user@example.com' }]); + }); +}); diff --git a/lib/outgoing/custom-headers.ts b/lib/outgoing/custom-headers.ts new file mode 100644 index 000000000..0058d8aee --- /dev/null +++ b/lib/outgoing/custom-headers.ts @@ -0,0 +1,67 @@ +import { debug } from '@/lib/debug'; + +/** + * Extra headers plugins add to an outgoing message (OutgoingEmail.headers), + * such as a classification label, as JMAP Email/set create properties + * (RFC 8621 §4.1.3, `header:Name:asText`, which the server encodes and folds). + * + * Only headers of their own get through. Names the client writes from other + * properties are dropped, because JMAP refuses a create that sets the same + * header twice, and so are names that say who sent the mail or how it + * travelled: a plugin must not change the sender or forge trace and + * authentication headers. + */ + +const RESERVED = new Set([ + 'from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'subject', 'date', + 'message-id', 'in-reply-to', 'references', 'mime-version', + 'return-path', 'received', 'received-spf', 'authentication-results', + 'dkim-signature', 'domainkey-signature', + 'disposition-notification-to', 'importance', 'x-priority', 'priority', +]); +const RESERVED_PREFIXES = ['content-', 'resent-', 'arc-']; + +/** RFC 5322 §3.6.8 field-name: printable US-ASCII except the colon. */ +const FIELD_NAME = /^[\x21-\x39\x3b-\x7e]{1,76}$/; +/** Line breaks or other control characters but tab (header injection). */ +function hasControlCharacter(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if ((code < 0x20 && code !== 0x09) || code === 0x7f) return true; + } + return false; +} +const MAX_HEADERS = 20; +// Room for an Autocrypt header with a whole key in it; the server folds it. +const MAX_VALUE_LENGTH = 8192; + +function isReserved(lowerName: string): boolean { + return RESERVED.has(lowerName) || RESERVED_PREFIXES.some((prefix) => lowerName.startsWith(prefix)); +} + +export function customHeaderProperties(headers: unknown): Record { + const out: Record = {}; + if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return out; + const seen = new Set(); + for (const [name, raw] of Object.entries(headers as Record)) { + const lower = name.toLowerCase(); + if (!FIELD_NAME.test(name) || isReserved(lower) || seen.has(lower)) { + debug.warn(`Outgoing header "${name}" from a plugin was not sent`); + continue; + } + if (typeof raw !== 'string') continue; + const value = raw.trim(); + if (!value) continue; + if (hasControlCharacter(value) || value.length > MAX_VALUE_LENGTH) { + debug.warn(`Outgoing header "${name}" from a plugin was not sent: invalid value`); + continue; + } + if (seen.size >= MAX_HEADERS) { + debug.warn(`Outgoing header "${name}" from a plugin was not sent: more than ${MAX_HEADERS} headers`); + continue; + } + seen.add(lower); + out[`header:${name}:asText`] = value; + } + return out; +} diff --git a/stores/email-store.ts b/stores/email-store.ts index e0f0ee36c..8f4583738 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -437,7 +437,7 @@ interface EmailStore { /** `ownerAccountId`: the JMAP account a link names for the message (#1351). */ fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; - sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; localAccountId?: string; draftAccountId?: string }) => Promise; + sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; localAccountId?: string; draftAccountId?: string }) => Promise; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise; /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */ deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise;