mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: send the headers plugins add to an outgoing message instead of dropping them
This commit is contained in:
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user