mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
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.
198 lines
8.7 KiB
TypeScript
198 lines
8.7 KiB
TypeScript
/**
|
|
* The classification label stage of sending (enterprise pack item 3).
|
|
* Every surface that sends a user's mail runs it last, after plugins'
|
|
* onTransformOutgoingEmail, so a plugin can't drop or change the label:
|
|
*
|
|
* - the composer (inline, pop-out and Pro tab), with the user's pick;
|
|
* - quick reply, with the original's label, else the default;
|
|
* - a plugin's forward (api.email.forward), the same way.
|
|
*
|
|
* A required label fails closed: with none, the stage throws
|
|
* LabelRequiredError and nothing is sent. Labels come from the policy of
|
|
* the account and identity the mail goes out from, never just the active
|
|
* account. Automatic mail carries none: calendar invitations and RSVP
|
|
* 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
|
|
* lib/outgoing/classification-raw.ts puts the label into what they send.
|
|
*/
|
|
|
|
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
|
|
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
|
import { subjectForAccount } from '@/lib/org/account-subjects';
|
|
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 { withoutLabelHeaders } from './custom-headers';
|
|
import {
|
|
applyLabelPrefix,
|
|
labelHeaderProperties,
|
|
draftLabelChoice,
|
|
receivedLabel,
|
|
type LabelsPolicy,
|
|
} from '@/lib/classification/labels';
|
|
|
|
/** A send stopped because the organisation requires a label and none was picked. */
|
|
export class LabelRequiredError extends Error {
|
|
constructor() {
|
|
super('A classification label is required');
|
|
this.name = 'LabelRequiredError';
|
|
}
|
|
}
|
|
|
|
export function isLabelRequiredError(error: unknown): boolean {
|
|
return (error as { name?: string } | null)?.name === 'LabelRequiredError';
|
|
}
|
|
|
|
export interface LabelSender {
|
|
/** The From identity's address (a group's own picks the group's scopes). */
|
|
email?: string;
|
|
/** The signed-in account (AccountEntry id) it goes out through; the active one when left out. */
|
|
accountId?: string | null;
|
|
}
|
|
|
|
/** The labels section that applies to mail from `sender`, read from the stores now. */
|
|
export function labelsPolicyFor(sender: LabelSender): LabelsPolicy {
|
|
const policy = policyForSender(usePolicyStore.getState(), sender.email, subjectForAccount(sender.accountId));
|
|
return policy.organisation?.labels ?? DEFAULT_ORGANISATION_POLICY.labels;
|
|
}
|
|
|
|
/** The message a reply or forward answers, as far as its label goes. */
|
|
export interface OriginalMessage {
|
|
/** Its headers (labelSourceHeaders keeps the ones that matter). */
|
|
headers?: Record<string, string | string[]> | null;
|
|
from?: ReadonlyArray<{ email?: string | null }> | null;
|
|
/** The signed-in account it was received in; the active one when left out. */
|
|
accountId?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Whether every From address of a message is inside the organisation of
|
|
* the account that received it (its identities' domains and the admin's
|
|
* organisation domains). False without a From or without known domains.
|
|
*/
|
|
export function senderIsInternal(
|
|
from: OriginalMessage['from'],
|
|
accountId?: string | null,
|
|
): boolean {
|
|
if (!from || from.length === 0) return false;
|
|
const context = isOtherAccount(accountId)
|
|
? {
|
|
policy: (usePolicyStore.getState().resolveFor(subjectForAccount(accountId)).organisation
|
|
?? DEFAULT_ORGANISATION_POLICY).externalMail,
|
|
identityEmails: (cachedIdentitiesForAccount(accountId) ?? []).map((identity) => identity.email),
|
|
}
|
|
: activeExternalMailContext();
|
|
const internal = contextInternalDomains(context);
|
|
return internal.length > 0 && externalSenders(from, internal).length === 0;
|
|
}
|
|
|
|
/**
|
|
* The label a reply or forward takes over from its original: the
|
|
* original's known label, only when an internal sender set it (anyone
|
|
* outside can write the header) and never one below the default (the
|
|
* list runs from least to most protected).
|
|
*/
|
|
export function originalLabel(policy: LabelsPolicy, original: OriginalMessage | null | undefined): ClassificationLabel | null {
|
|
if (!original?.headers || policy.list.length === 0) return null;
|
|
const label = receivedLabel(original.headers, policy);
|
|
if (!label || !senderIsInternal(original.from, original.accountId)) return null;
|
|
const floor = policy.list.findIndex((l) => l.id === policy.defaultId);
|
|
return floor >= 0 && policy.list.indexOf(label) < floor ? null : label;
|
|
}
|
|
|
|
/**
|
|
* The label a message goes out with. `choice`: the user's pick (an id),
|
|
* null for "no label", undefined when they picked nothing, which takes the
|
|
* original's label (originalLabel), else the default. A pick that isn't in
|
|
* the list (another account's, an old one) counts as nothing picked.
|
|
*/
|
|
export function resolveLabel(
|
|
policy: LabelsPolicy,
|
|
choice: string | null | undefined,
|
|
original?: OriginalMessage | null,
|
|
): ClassificationLabel | null {
|
|
if (policy.list.length === 0) return null;
|
|
if (choice === null) return null;
|
|
const picked = choice ? policy.list.find((label) => label.id === choice) : undefined;
|
|
if (picked) return picked;
|
|
return originalLabel(policy, original) ?? policy.list.find((label) => label.id === policy.defaultId) ?? null;
|
|
}
|
|
|
|
/**
|
|
* The pick a message opened again in the composer starts with: a draft's
|
|
* X-Bulwark-Label, else the label its headers carry (a sent message
|
|
* brought back by Undo, "Cancel and edit" or edit-as-new carries SIO-Label
|
|
* only), read with the labels of the account it is in.
|
|
*/
|
|
export function reopenedLabelChoice(
|
|
headers: Record<string, string | string[]> | null | undefined,
|
|
accountId?: string | null,
|
|
): string | null | undefined {
|
|
const marked = draftLabelChoice(headers);
|
|
if (marked !== undefined) return marked;
|
|
return receivedLabel(headers, labelsPolicyFor({ accountId }))?.id;
|
|
}
|
|
|
|
/** Whether sending needs a label that `label` doesn't give. */
|
|
export function labelMissing(policy: LabelsPolicy, label: ClassificationLabel | null): boolean {
|
|
return policy.required && policy.list.length > 0 && !label;
|
|
}
|
|
|
|
export interface LabelledMessage {
|
|
subject: string;
|
|
/** The plugins' headers, without any the label writes. */
|
|
headers?: Record<string, string>;
|
|
/** The label's headers as JMAP create properties (SubmissionSendOptions.labelHeaders). */
|
|
labelHeaders?: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* The stage: the subject with the label's prefix (once), the plugins'
|
|
* headers without the label's names, and the label's headers. Throws
|
|
* LabelRequiredError when the policy requires a label and there is none.
|
|
*/
|
|
export function classifyOutgoing(
|
|
policy: LabelsPolicy,
|
|
label: ClassificationLabel | null,
|
|
message: { subject: string; headers?: Record<string, string> },
|
|
): LabelledMessage {
|
|
if (policy.list.length === 0) return { subject: message.subject, headers: message.headers };
|
|
if (labelMissing(policy, label)) throw new LabelRequiredError();
|
|
const reserved = policy.extraHeader ? [policy.extraHeader] : [];
|
|
return {
|
|
subject: applyLabelPrefix(message.subject, label, policy.list),
|
|
headers: withoutLabelHeaders(message.headers, reserved),
|
|
labelHeaders: label ? labelHeaderProperties(label, policy.extraHeader) : undefined,
|
|
};
|
|
}
|
|
|
|
/** The label of a quick reply or a plugin's forward: the original's (originalLabel), else the default. */
|
|
export function automaticLabel(
|
|
sender: LabelSender,
|
|
original?: OriginalMessage | null,
|
|
): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } {
|
|
const policy = labelsPolicyFor(sender);
|
|
const label = resolveLabel(policy, undefined, original);
|
|
return { policy, label, missing: labelMissing(policy, label) };
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* to keep in a Pro tab's saved state.
|
|
*/
|
|
export function labelSourceHeaders(headers: Record<string, string | string[]> | null | undefined): Record<string, string | string[]> | undefined {
|
|
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined;
|
|
const raw = usePolicyStore.getState().rawPolicy;
|
|
const names = new Set(['sio-label']);
|
|
const add = (name: string | undefined) => { if (name) names.add(name.toLowerCase()); };
|
|
add(raw.organisation?.labels?.extraHeader);
|
|
for (const scope of raw.scopes ?? []) add(scope.policy.organisation?.labels?.extraHeader);
|
|
const out: Record<string, string | string[]> = {};
|
|
for (const [name, value] of Object.entries(headers)) if (names.has(name.toLowerCase())) out[name] = value;
|
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
}
|