From 85deb006a8821cc3dbe355edf4217364e9a798ba Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:52:44 +0100 Subject: [PATCH] fix: keep plugin raw sends from dropping or forging the classification label Raw sends (api.jmap.sendRaw and submitRaw) now always lose label headers a plugin wrote while the organisation uses labels. A crypto takeover is matched to its raw send by a labelToken the composer puts in the onComposeSend request (plugins that don't pass it back are matched by identity, and refused when two takeovers are open), not by comparing subjects. A raw send outside a takeover gets the default label, or is refused when a label is required. The extra header is folded on the raw path. The takeover code moves to lib/outgoing/classification-raw.ts; the old test that let a raw send outside a takeover through unlabelled now expects the forged header to be removed. --- components/email/email-composer.tsx | 9 +- lib/__tests__/plugin-host-labels.test.ts | 24 +++- lib/classification/labels.ts | 50 +++++-- .../__tests__/classification-raw.test.ts | 121 ++++++++++++++++ lib/outgoing/__tests__/classification.test.ts | 50 +------ lib/outgoing/classification-raw.ts | 134 ++++++++++++++++++ lib/outgoing/classification.ts | 107 +------------- lib/plugin-sandbox/host-api.ts | 16 ++- 8 files changed, 331 insertions(+), 180 deletions(-) create mode 100644 lib/outgoing/__tests__/classification-raw.test.ts create mode 100644 lib/outgoing/classification-raw.ts diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0173ba66c..9864a3cce 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -111,7 +111,8 @@ import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; -import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; +import { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; +import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw"; import { whenPolicySettled } from "@/stores/policy-store"; import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker"; import { useComposerClassification } from "./use-composer-classification"; @@ -3647,9 +3648,12 @@ export function EmailComposer({ const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId }); const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal); // A crypto plugin builds the MIME itself: it gets the labelled - // subject, and its raw send gets the label's headers (keyed to the token). + // subject, and its raw send gets the label's headers, matched by the + // token it can pass back (lib/outgoing/classification-raw.ts). const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject }); + const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentity?.id ?? ''); const composeSendRequest = { + labelToken: labelToken ?? undefined, to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), @@ -3669,7 +3673,6 @@ export function EmailComposer({ ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), ], }; - const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentityParts.rawId ?? currentIdentity?.id ?? '', takeoverLabelled.subject); let pluginOutcome: Awaited>; try { pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest); diff --git a/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts index 414128714..18bc81c2d 100644 --- a/lib/__tests__/plugin-host-labels.test.ts +++ b/lib/__tests__/plugin-host-labels.test.ts @@ -13,7 +13,7 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { LABEL_PRESETS } from '@/lib/classification/presets'; import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog'; import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; -import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification'; +import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification-raw'; // Classification labels on the plugin paths: api.email.forward carries the // original's label (else the default) and refuses when a required one is @@ -123,18 +123,28 @@ describe('labels on the plugin paths', () => { expect(toast.error).toHaveBeenCalled(); }); - it('puts the label into a raw send during a labelled takeover only', async () => { + it('puts the takeover’s label into its raw sends and takes forged ones out of every raw send', async () => { useLabels({}); - const raw = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\n\r\nENCRYPTED\r\n'); - await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {}); + const forged = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\nSIO-Label: marking="PUBLIC"\r\n\r\nENCRYPTED\r\n'); + // Outside a takeover, no default label: nothing stamped, the forged header gone. + await call(SMIME, 'jmap.sendRaw', forged, 'id-a', {}); expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label'); - const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a', '[Confidential] Hi'); - await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {}); - await call(SMIME, 'jmap.submitRaw', raw, 'id-a', {}); + const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a')!; + await call(SMIME, 'jmap.sendRaw', forged, 'id-a', { labelToken: token }); + await call(SMIME, 'jmap.submitRaw', forged, 'id-a', { labelToken: token }); endLabelTakeover(token); const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text(); expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/); expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"'); }); + + it('refuses a raw send outside the composer when a label is required', async () => { + useLabels({ required: true }); + const bytes = new TextEncoder().encode('Subject: Hi\r\n\r\nX\r\n'); + await expect(call(SMIME, 'jmap.sendRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/); + await expect(call(SMIME, 'jmap.submitRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/); + expect(sendRawEmail).not.toHaveBeenCalled(); + expect(client.submitRawEmail).not.toHaveBeenCalled(); + }); }); diff --git a/lib/classification/labels.ts b/lib/classification/labels.ts index 86d13007a..c814bcae2 100644 --- a/lib/classification/labels.ts +++ b/lib/classification/labels.ts @@ -64,7 +64,7 @@ function percentEncode(value: string): string { /** The marking parameter(s): quoted when ASCII, RFC 2231 (with continuations when long) otherwise. */ function markingParameters(marking: string): string[] { const clean = marking.replace(/["\\\r\n]/g, ''); - + if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`]; const encoded = percentEncode(clean); const MAX = 60; @@ -122,18 +122,50 @@ export function labelHeaderProperties(label: ClassificationLabel, extraHeader: s return out; } -function encodedWord(value: string): string { - - if (/^[\x20-\x7e]*$/.test(value)) return value; - let binary = ''; - for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte); - return `=?UTF-8?B?${btoa(binary)}?=`; +/** + * A header value for raw MIME, folded under 78 columns: ASCII as it is, + * folded between words; anything else as RFC 2047 encoded words of at + * most 75 characters each, split between characters. + */ +function foldedValue(name: string, value: string): string { + if (/^[\x20-\x7e]*$/.test(value)) { + let line = name.length + 1; + let out = ''; + for (const word of value.split(' ')) { + if (out && line + 1 + word.length > 78) { + out += `\r\n ${word}`; + line = 1 + word.length; + } else { + out += ` ${word}`; + line += 1 + word.length; + } + } + return out; + } + const encoder = new TextEncoder(); + const words: string[] = []; + let chunk: number[] = []; + const flush = () => { + if (chunk.length === 0) return; + words.push(`=?UTF-8?B?${btoa(String.fromCharCode(...chunk))}?=`); + chunk = []; + }; + // 45 bytes are 60 base64 characters: 72 with "=?UTF-8?B?" and "?=", + // which fits a folded line. The first word shares its line with the name. + const firstLimit = Math.max(3, Math.min(45, Math.floor((78 - name.length - 2 - 12) / 4) * 3)); + for (const char of value) { + const bytes = encoder.encode(char); + if (chunk.length + bytes.length > (words.length === 0 ? firstLimit : 45)) flush(); + chunk.push(...bytes); + } + flush(); + return ` ${words.join('\r\n ')}`; } -/** The label's headers as raw header lines (CRLF-free each), for raw MIME. */ +/** The label's headers as raw header lines (folded with CRLF and a space), for raw MIME. */ export function labelHeaderLines(label: ClassificationLabel, extraHeader: string): string[] { return labelHeaderPairs(label, extraHeader).map(([name, value, form]) => - form === 'raw' ? `${name}:${value}` : `${name}: ${encodedWord(value)}`); + form === 'raw' ? `${name}:${value}` : `${name}:${foldedValue(name, value)}`); } /** The draft marker property: the label id, or `none` for no label. */ diff --git a/lib/outgoing/__tests__/classification-raw.test.ts b/lib/outgoing/__tests__/classification-raw.test.ts new file mode 100644 index 000000000..b1be8b538 --- /dev/null +++ b/lib/outgoing/__tests__/classification-raw.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; +import { usePolicyStore } from '@/stores/policy-store'; +import { beginLabelTakeover, endLabelTakeover, labelRawSend, resetLabelTakeovers } from '../classification-raw'; + +// Labels on raw sends (api.jmap.sendRaw / submitRaw), the way S/MIME and PGP +// plugins send after a takeover: the takeover's label goes into the outer +// header block, matched by the token the request carries, or by identity +// when the plugin doesn't pass it (refused when that is ambiguous). Label +// headers a plugin wrote are always taken out, and a raw send outside a +// takeover gets the default or is refused when a label is required. + +const generic = LABEL_PRESETS[0]; +const labels = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' }; +const label = (id: string) => generic.list.find((l) => l.id === id)!; + +function useLabels(over: Partial = {}) { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...labels, ...over } } } }); +} + +const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`); +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const headOf = (bytes: Uint8Array) => decode(bytes).split('\r\n\r\n')[0]; +const refusal = (fn: () => unknown) => { + try { fn(); } catch (error) { return error as Error; } + return null; +}; + +afterEach(() => { + usePolicyStore.setState({ policy: DEFAULT_POLICY }); + resetLabelTakeovers(); +}); + +describe('a raw send during a takeover', () => { + it('gets the label in the outer header block, the signed part untouched', () => { + useLabels(); + const token = beginLabelTakeover(labels, label('confidential'), 'id-1')!; + const out = decode(labelRawSend(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1', token)); + const [head, ...rest] = out.split('\r\n\r\n'); + expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"'); + expect(head).toContain('Sensitivity: Company-Confidential'); + expect(head).toContain('X-Classification: Confidential'); + expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n'); + endLabelTakeover(token); + }); + + it('matches two takeovers at once by their tokens, whatever the subjects are', () => { + useLabels(); + const first = beginLabelTakeover(labels, label('public'), 'id-1')!; + const second = beginLabelTakeover(labels, label('confidential'), 'id-1')!; + // Subjects as plugins write them: raw UTF-8 and an encoded word. + const a = headOf(labelRawSend(raw('Subject: Grüße aus Köln'), 'id-1', second)); + const b = headOf(labelRawSend(raw('Subject: =?UTF-8?B?R3LDvMOfZQ==?='), 'id-1', first)); + expect(a).toContain('marking="CONFIDENTIAL"'); + expect(a).toContain('Subject: Grüße aus Köln'); + expect(b).toContain('marking="PUBLIC"'); + }); + + it('matches a plugin that passes no token by its identity, and refuses when two are open', () => { + useLabels(); + const one = beginLabelTakeover(labels, label('confidential'), 'acct-2::id-1')!; + // The request's identity carries the other account's prefix; the plugin passes the raw id. + expect(headOf(labelRawSend(raw('Subject: Grüße'), 'id-1'))).toContain('marking="CONFIDENTIAL"'); + beginLabelTakeover(labels, label('public'), 'id-1'); + expect(refusal(() => labelRawSend(raw('Subject: Grüße'), 'id-1'))?.name).toBe('RawSendLabelError'); + endLabelTakeover(one); + }); + + it('takes out label headers the plugin wrote, also for "no label"', () => { + useLabels(); + const token = beginLabelTakeover(labels, null, 'id-1')!; + const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nSensitivity: Private\r\nX-Classification: Secret'), 'id-1', token)); + expect(out).toBe('Subject: x'); + }); + + it('refuses a token that is no longer open', () => { + useLabels(); + const token = beginLabelTakeover(labels, label('public'), 'id-1')!; + endLabelTakeover(token); + expect(refusal(() => labelRawSend(raw('Subject: x'), 'id-1', token))?.name).toBe('RawSendLabelError'); + }); +}); + +describe('a raw send outside any takeover', () => { + it('gets the default label and loses forged ones', () => { + useLabels(); + const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="PUBLIC"'), 'id-1')); + expect(out).toContain('marking="INTERNAL"'); + expect(out).not.toContain('PUBLIC'); + }); + + it('is refused when a label is required', () => { + useLabels({ required: true, defaultId: '' }); + const error = refusal(() => labelRawSend(raw('Subject: x'), 'id-1')); + expect(error?.name).toBe('RawSendLabelError'); + expect(error?.message).toContain('requires a classification label'); + }); + + it('is left alone while the organisation has no labels', () => { + const bytes = raw('Subject: x\r\nSensitivity: Private'); + expect(labelRawSend(bytes, 'id-1')).toBe(bytes); + }); +}); + +describe('the extra header on raw sends', () => { + it('is folded, non-ASCII as encoded words of at most 75 characters', () => { + useLabels(); + const long = { ...label('confidential'), extraHeaderValue: 'Vertraulich – nur für den internen Gebrauch der Abteilung Öffentlichkeitsarbeit und Presse' }; + const token = beginLabelTakeover(labels, long, 'id-1')!; + const head = headOf(labelRawSend(raw('Subject: x'), 'id-1', token)); + const extra = head.slice(head.indexOf('X-Classification:')).split('\r\n').filter((line, i) => i === 0 || line.startsWith(' ')); + expect(extra.length).toBeGreaterThan(1); + for (const line of extra) expect(line.length).toBeLessThanOrEqual(78); + for (const word of head.match(/=\?UTF-8\?B\?[^?]*\?=/g) ?? []) expect(word.length).toBeLessThanOrEqual(75); + const decoded = (head.match(/=\?UTF-8\?B\?([^?]*)\?=/g) ?? []) + .map((w) => Buffer.from(w.slice(10, -2), 'base64')); + expect(Buffer.concat(decoded).toString('utf8')).toBe(long.extraHeaderValue); + }); +}); diff --git a/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts index 7e470eabf..0a215bc4a 100644 --- a/lib/outgoing/__tests__/classification.test.ts +++ b/lib/outgoing/__tests__/classification.test.ts @@ -7,20 +7,16 @@ import { usePolicyStore } from '@/stores/policy-store'; import { customHeaderProperties, withLabelHeaders } from '../custom-headers'; import { automaticLabel, - beginLabelTakeover, classifyOutgoing, - endLabelTakeover, - injectTakeoverLabel, isLabelRequiredError, labelSourceHeaders, - resetLabelTakeovers, reopenedLabelChoice, resolveLabel, } from '../classification'; // The label stage of sending: which label goes out, the required label // failing closed, plugin headers that can't override it, the policy of the -// sending account, and the headers a crypto takeover's raw send gets. +// sending account (raw sends: classification-raw.test.ts). const generic = LABEL_PRESETS[0]; const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' }; @@ -37,7 +33,6 @@ beforeEach(() => { afterEach(() => { usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null }); useAuthStore.setState({ identities: [] } as never); - resetLabelTakeovers(); }); describe('the label of the original', () => { @@ -125,46 +120,3 @@ describe('the policy of the sending account', () => { expect(labelSourceHeaders({ Received: 'z' })).toBeUndefined(); }); }); - -describe('crypto takeovers', () => { - const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`); - const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); - - it('add the label headers to the top-level header block, outside the signed part', () => { - const token = beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Budget'); - const out = decode(injectTakeoverLabel(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1')); - const [head, ...rest] = out.split('\r\n\r\n'); - expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"'); - expect(head).toContain('Sensitivity: Company-Confidential'); - expect(head).toContain('X-Classification: Confidential'); - expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n'); - endLabelTakeover(token); - }); - - it('replace label headers the plugin wrote, and leave 8-bit header bytes alone', () => { - beginLabelTakeover(policy, label('public'), 'id-1', 'Grüße'); - const input = raw('Subject: Grüße\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nX-Classification: Secret'); - const out = decode(injectTakeoverLabel(input, 'id-1')); - expect(out).not.toContain('FORGED'); - expect(out).not.toContain('bgcolor=red'); - expect(out).not.toContain('Secret'); - expect(out).toContain('Subject: Grüße'); - expect(out.match(/SIO-Label:/g)).toHaveLength(1); - }); - - it('touch nothing outside a takeover, or for another identity', () => { - const input = raw('Subject: x'); - expect(injectTakeoverLabel(input, 'id-1')).toBe(input); - const token = beginLabelTakeover(policy, label('public'), 'id-1', 'x'); - expect(injectTakeoverLabel(input, 'id-2')).toBe(input); - endLabelTakeover(token); - expect(injectTakeoverLabel(input, 'id-1')).toBe(input); - }); - - it('pick the right takeover by subject when two run at once', () => { - beginLabelTakeover(policy, label('public'), 'id-1', 'First'); - beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Second'); - const out = decode(injectTakeoverLabel(raw('Subject: [Confidential] Second'), 'id-1')); - expect(out).toContain('marking="CONFIDENTIAL"'); - }); -}); diff --git a/lib/outgoing/classification-raw.ts b/lib/outgoing/classification-raw.ts new file mode 100644 index 000000000..e9d27e31c --- /dev/null +++ b/lib/outgoing/classification-raw.ts @@ -0,0 +1,134 @@ +/** + * Classification labels on raw sends (api.jmap.sendRaw and submitRaw), the + * way S/MIME and PGP plugins send after they took the send over from the + * composer (onComposeSend). They build the MIME themselves, so the host + * puts the label into the top-level header block of what they hand it, + * outside the signed or encrypted part. + * + * - The composer registers each takeover before it runs + * (beginLabelTakeover) and hands the plugin its `labelToken` in the + * request. A raw send that passes the token back gets that takeover's + * label. Plugins that don't pass it yet are matched by their sending + * identity; with more than one takeover open for it, the send is refused + * rather than guessed. + * - While the organisation uses labels, label headers a plugin wrote itself + * are always taken out. + * - A raw send outside any takeover gets the default label, and is refused + * when a label is required: it can't carry the user's pick. + */ + +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; +import { labelHeaderLines, type LabelsPolicy } from '@/lib/classification/labels'; +import { stripCrossAccountIdentityPrefix } from '@/hooks/use-pro-multi-account-identities'; +import { generateUUID } from '@/lib/utils'; +import { labelsPolicyFor } from './classification'; +import { LABEL_HEADER_NAMES } from './custom-headers'; + +interface Takeover { + identityId: string; + /** The label's header lines; empty for "no label". */ + lines: string[]; +} + +const takeovers = new Map(); + +/** An identity id without the "::" prefix of another signed-in account's identities. */ +function rawIdentityId(identityId: string): string { + return stripCrossAccountIdentityPrefix(identityId).rawId ?? identityId; +} + +/** A raw send the host refuses; the plugin gets the message. */ +export class RawSendLabelError extends Error { + constructor(message: string) { + super(message); + this.name = 'RawSendLabelError'; + } +} + +/** + * Registers a takeover about to run, with the label the message goes out + * with (null: none). Returns the token to put in the request and to pass + * to endLabelTakeover when the takeover ended; null while labels are off. + */ +export function beginLabelTakeover( + policy: LabelsPolicy, + label: ClassificationLabel | null, + identityId: string, +): string | null { + if (policy.list.length === 0) return null; + const token = generateUUID(); + takeovers.set(token, { identityId: rawIdentityId(identityId), lines: label ? labelHeaderLines(label, policy.extraHeader) : [] }); + return token; +} + +export function endLabelTakeover(token: string | null): void { + if (token) takeovers.delete(token); +} + +/** For tests. */ +export function resetLabelTakeovers(): void { + takeovers.clear(); +} + +/** The header lines a raw send gets: its takeover's, or the default's outside one. */ +function linesFor(policy: LabelsPolicy, identityId: string, token: string | undefined): string[] { + if (token) { + const takeover = takeovers.get(token); + if (!takeover) throw new RawSendLabelError('This send is no longer open in the composer. Send the message again.'); + return takeover.lines; + } + const id = rawIdentityId(identityId); + const open = [...takeovers.values()].filter((t) => t.identityId === id); + if (open.length === 1) return open[0].lines; + if (open.length > 1) { + throw new RawSendLabelError('Several messages are being sent from this address at once, and this one can\'t be matched to its classification label. Send it again.'); + } + if (policy.required) { + throw new RawSendLabelError('Your organisation requires a classification label, and a message sent outside the composer can\'t carry one.'); + } + const fallback = policy.list.find((label) => label.id === policy.defaultId); + return fallback ? labelHeaderLines(fallback, policy.extraHeader) : []; +} + +/** Where the header block of raw message bytes ends, and its line break. */ +function headerEnd(bytes: Uint8Array): { end: number; eol: string } | null { + for (let i = 0; i < bytes.length - 1; i++) { + if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) return { end: i, eol: '\r\n' }; + if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) return { end: i, eol: '\n' }; + } + return null; +} + +/** + * The raw message a plugin hands to sendRaw or submitRaw, with the label + * the organisation's policy (of the active account, which sends it) asks + * for. The same bytes while labels are off. Throws RawSendLabelError when + * the send has to be refused. + */ +export function labelRawSend(bytes: Uint8Array, identityId: string, token?: string): Uint8Array { + const policy = labelsPolicyFor({}); + if (policy.list.length === 0) return bytes; + const lines = linesFor(policy, identityId, typeof token === 'string' && token ? token : undefined); + const block = headerEnd(bytes); + if (!block) throw new RawSendLabelError('The message has no header block.'); + // Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged. + let head = ''; + for (let i = 0; i < block.end; i++) head += String.fromCharCode(bytes[i]); + const reserved = new Set([...LABEL_HEADER_NAMES, ...(policy.extraHeader ? [policy.extraHeader.toLowerCase()] : [])]); + const kept: string[] = []; + let skipping = false; + for (const line of head.split(/\r?\n/)) { + if (/^[ \t]/.test(line)) { + if (!skipping) kept.push(line); + continue; + } + skipping = reserved.has(line.slice(0, line.indexOf(':')).trim().toLowerCase()); + if (!skipping) kept.push(line); + } + const text = [...kept, ...lines.map((line) => line.replace(/\r\n/g, block.eol))].join(block.eol); + const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff); + const out = new Uint8Array(newHead.length + (bytes.length - block.end)); + out.set(newHead, 0); + out.set(bytes.subarray(block.end), newHead.length); + return out; +} diff --git a/lib/outgoing/classification.ts b/lib/outgoing/classification.ts index f93e3f529..ec3b89e10 100644 --- a/lib/outgoing/classification.ts +++ b/lib/outgoing/classification.ts @@ -14,10 +14,8 @@ * replies, read receipts, List-Unsubscribe mails and phishing reports. * * S/MIME and PGP plugins take over the send (onComposeSend) and build raw - * MIME themselves: the composer hands them the prefixed subject, and the - * host adds the label headers to the top-level header block of the raw - * message in api.jmap.sendRaw / submitRaw while that takeover runs, keyed - * to its token (beginLabelTakeover), outside the signed or encrypted part. + * MIME themselves: the composer hands them the prefixed subject, and + * lib/outgoing/classification-raw.ts puts the label into what they send. */ import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; @@ -27,11 +25,9 @@ import { policyForSender } from '@/lib/org/signature-state'; import { activeExternalMailContext, contextInternalDomains, externalSenders, isOtherAccount } from '@/lib/external-mail'; import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities'; import { usePolicyStore } from '@/stores/policy-store'; -import { generateUUID } from '@/lib/utils'; import { withoutLabelHeaders } from './custom-headers'; import { applyLabelPrefix, - labelHeaderLines, labelHeaderProperties, draftLabelChoice, receivedLabel, @@ -183,105 +179,6 @@ export function automaticLabel( return { policy, label, missing: labelMissing(policy, label) }; } -// ─── Crypto takeovers ───────────────────────────────────────── - -interface Takeover { - identityId: string; - subject: string; - lines: string[]; -} - -const takeovers = new Map(); - -/** - * Registers the label of a takeover about to run (onComposeSend). Raw - * sends during it from the same identity get the label's header lines. - * Returns the token; pass it to endLabelTakeover when the takeover ended. - */ -export function beginLabelTakeover( - policy: LabelsPolicy, - label: ClassificationLabel | null, - identityId: string, - subject: string, -): string | null { - if (!label) return null; - const token = generateUUID(); - takeovers.set(token, { identityId, subject, lines: labelHeaderLines(label, policy.extraHeader) }); - return token; -} - -export function endLabelTakeover(token: string | null): void { - if (token) takeovers.delete(token); -} - -const LABEL_LINE_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label']; - -/** The Subject header of a raw message's top-level block, unfolded and decoded enough to compare. */ -function rawSubject(head: string): string { - const m = /(?:^|\r?\n)Subject:([^\r\n]*(?:\r?\n[ \t][^\r\n]*)*)/i.exec(head); - return m ? m[1].replace(/\r?\n[ \t]+/g, ' ').trim() : ''; -} - -/** - * The takeover a raw send belongs to: the one running for `identityId`; - * when several are (two composers sending at once), the one whose subject - * is the message's. - */ -function takeoverFor(identityId: string, head: string): Takeover | null { - const candidates = [...takeovers.values()].filter((t) => t.identityId === identityId); - if (candidates.length <= 1) return candidates[0] ?? null; - const subject = rawSubject(head); - return candidates.find((t) => t.subject === subject) ?? candidates[0]; -} - -/** - * `bytes` (a raw RFC 5322 message) with the label's headers added to its - * top-level header block, when a labelled takeover is running for - * `identityId`; the same bytes otherwise. Label headers the plugin wrote - * itself are replaced. - */ -export function injectTakeoverLabel(bytes: Uint8Array, identityId: string): Uint8Array { - if (takeovers.size === 0) return bytes; - // The header block is ASCII (RFC 5322 §2.2); find its end without decoding the body. - let end = -1; - let breakLength = 0; - for (let i = 0; i < bytes.length - 1; i++) { - if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) { end = i; breakLength = 4; break; } - if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) { end = i; breakLength = 2; break; } - } - if (end < 0) return bytes; - // Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged. - let head = ''; - for (let i = 0; i < end; i++) head += String.fromCharCode(bytes[i]); - const takeover = takeoverFor(identityId, head); - if (!takeover) return bytes; - const eol = breakLength === 4 ? '\r\n' : '\n'; - // Drop the label headers already there (with their folded lines). - const kept: string[] = []; - let skipping = false; - for (const line of head.split(/\r?\n/)) { - if (/^[ \t]/.test(line)) { - if (!skipping) kept.push(line); - continue; - } - const name = line.slice(0, line.indexOf(':')).trim().toLowerCase(); - skipping = LABEL_LINE_NAMES.includes(name) || takeover.lines.some((l) => l.slice(0, l.indexOf(':')).toLowerCase() === name); - if (!skipping) kept.push(line); - } - const added = takeover.lines.map((line) => line.replace(/\r\n/g, eol)); - const text = [...kept, ...added].join(eol); - const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff); - const out = new Uint8Array(newHead.length + (bytes.length - end)); - out.set(newHead, 0); - out.set(bytes.subarray(end), newHead.length); - return out; -} - -/** For tests. */ -export function resetLabelTakeovers(): void { - takeovers.clear(); -} - /** * The original's headers a reply or forward needs for its label: SIO-Label * and every extra header name any scope of the policy uses. Small enough diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 415c20679..4b727dc50 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -3,7 +3,7 @@ // structured-cloneable data back to the iframe. import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types'; -import { injectTakeoverLabel } from '@/lib/outgoing/classification'; +import { labelRawSend } from '@/lib/outgoing/classification-raw'; import { fetchConfig } from '@/hooks/use-config'; import { IS_LITE } from '../lite'; import { doAppGetSettings, doAppSearchSettings } from './host-api-app'; @@ -801,6 +801,8 @@ async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown): interface JmapSubmitRawOptions { delayedUntil?: string; envelopeRecipients?: string[]; + /** The `labelToken` of the onComposeSend request this send completes. */ + labelToken?: string; } /** @@ -823,9 +825,9 @@ async function doJmapSendRaw( // ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view. const copy = new Uint8Array(view.byteLength); copy.set(view); - // During a labelled takeover (S/MIME, PGP) the label's headers go into the - // top-level header block, outside what the plugin signed or encrypted. - const labelled = injectTakeoverLabel(copy, identityId); + // The classification label goes into the top-level header block, outside + // what the plugin signed or encrypted (lib/outgoing/classification-raw.ts). + const labelled = labelRawSend(copy, identityId, opts?.labelToken); const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return useEmailStore.getState().sendRawEmail( client, @@ -860,7 +862,7 @@ async function doJmapSubmitRaw( const copy = new Uint8Array(view.byteLength); copy.set(view); - const labelled = injectTakeoverLabel(copy, identityId); + const labelled = labelRawSend(copy, identityId, opts?.labelToken); const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return client.submitRawEmail( @@ -2045,12 +2047,12 @@ export async function dispatchApiCall( case 'jmap.sendRaw': return doJmapSendRaw( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, - args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, + args[2] as JmapSubmitRawOptions | undefined, ); case 'jmap.submitRaw': return doJmapSubmitRaw( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, - args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, + args[2] as JmapSubmitRawOptions | undefined, ); case 'jmap.importRaw': return doJmapImportRaw( args[0] as ArrayBuffer | ArrayBufferView,