fix: send the headers plugins add to an outgoing message instead of dropping them

This commit is contained in:
Linus Rath
2026-10-31 17:40:20 +01:00
parent f8805b8d52
commit 73a7bb7bcd
8 changed files with 218 additions and 4 deletions
@@ -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<string, unknown>),
headers: { 'X-Classification': 'Internal' },
}));
const { onSend } = renderComposer();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].headers).toEqual({ 'X-Classification': 'Internal' });
});
});
+3
View File
@@ -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<string, string>;
/** The id plugins know this composer by, for onAfterEmailSend. */
composerId?: string;
}) => void | false | Promise<void | false>;
@@ -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,
});
+2 -1
View File
@@ -2154,6 +2154,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
requestDsn?: boolean;
requireTls?: boolean;
priority?: MessagePriority;
headers?: Record<string, string>;
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);
+1 -1
View File
@@ -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<string, string>; draftAccountId?: string },
): Promise<SendEmailResult>;
/** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */
supportsSubmissionExtension?(extension: string, accountId?: string): boolean;
+4
View File
@@ -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<string, string>;
/** 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).
@@ -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': '<x@example.com>',
'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, unknown>, 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<string, unknown>)[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<string, Record<string, unknown>>)[0];
expect(created['header:X-Classification:asText']).toBe('Internal');
expect(created['header:From:asText']).toBeUndefined();
expect(created.from).toEqual([{ email: 'user@example.com' }]);
});
});
+67
View File
@@ -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<string, string> {
const out: Record<string, string> = {};
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return out;
const seen = new Set<string>();
for (const [name, raw] of Object.entries(headers as Record<string, unknown>)) {
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;
}
+1 -1
View File
@@ -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<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
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<SendEmailResult>;
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<string, string>; localAccountId?: string; draftAccountId?: string }) => Promise<SendEmailResult>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
/** `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<boolean>;