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.
This commit is contained in:
Linus Rath
2026-11-01 06:52:44 +01:00
parent 3ef16f602e
commit 85deb006a8
8 changed files with 331 additions and 180 deletions
+6 -3
View File
@@ -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<ReturnType<typeof emailHooks.onComposeSend.takeOver>>;
try {
pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest);
+17 -7
View File
@@ -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();
});
});
+41 -9
View File
@@ -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. */
@@ -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<typeof labels> = {}) {
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);
});
});
+1 -49
View File
@@ -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"');
});
});
+134
View File
@@ -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<string, Takeover>();
/** An identity id without the "<account>::" 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<string>([...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;
}
+2 -105
View File
@@ -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<string, Takeover>();
/**
* 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
+9 -7
View File
@@ -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,