feat: pick a classification label in the composer and stamp it on sent mail

This commit is contained in:
Linus Rath
2026-11-01 05:33:12 +01:00
parent 59f8e462cd
commit 1c7f81bcc6
52 changed files with 2110 additions and 23 deletions
@@ -0,0 +1,231 @@
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { LABEL_PRESETS } from '@/lib/classification/presets';
// The composer's classification label: a required label blocks sending
// (fail closed) until one is picked, the label goes on after the plugins'
// onTransformOutgoingEmail so none can drop it, a reply starts with the
// original's label, and a crypto takeover gets the labelled subject.
const hooks = vi.hoisted(() => ({
transform: vi.fn(async (email: unknown) => email),
takeOver: vi.fn(async (..._args: unknown[]) => 'default'),
toastError: vi.fn(),
}));
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }) }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
function storeMock<T extends object>(state: T) {
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
hook.setState = (p: Partial<T>) => Object.assign(state, p);
hook.subscribe = () => () => {};
return hook;
}
vi.mock('@/stores/auth-store', () => ({
useAuthStore: storeMock({
client: null, identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], primaryIdentity: null,
isAuthenticated: false, isDemoMode: false, activeAccountId: null, connectionLost: false,
getClientForAccount: () => undefined, getAllConnectedClients: () => new Map(),
syncIdentities: () => {}, refreshIdentities: async () => {},
}),
}));
vi.mock('@/stores/identity-store', () => ({
useIdentityStore: storeMock({ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], defaultIdentityId: 'id-me' }),
}));
vi.mock('@/stores/account-store', () => ({ useAccountStore: storeMock({ accounts: [], getAccountById: () => undefined }) }));
vi.mock('@/stores/email-store', () => ({ useEmailStore: storeMock({ draftSaveEnabled: false, sendRawEmail: async () => ({ sent: true }) }) }));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: storeMock({
timeFormat: '24h', plainTextMode: false, subAddressDelimiter: '+', autoSelectReplyIdentity: true,
attachmentReminderEnabled: false, attachmentReminderKeywords: [], emptySubjectWarningEnabled: false,
sendDelaySeconds: 0, signaturePosition: 'below_quote', signatureSeparatorEnabled: true,
requestReadReceiptDefault: false, externalContentPolicy: 'ask',
addTrustedSender: () => {}, isSenderTrusted: () => false,
trustedSendersAddressBook: null, updateSetting: () => {},
}),
}));
vi.mock('@/stores/contact-store', () => ({
useContactStore: storeMock({
contacts: [], addressBooks: [], trustedSendersBookId: null, getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
}),
}));
vi.mock('@/stores/template-store', () => ({ useTemplateStore: storeMock({ templates: [], addTemplate: async () => {} }) }));
vi.mock('@/stores/toast-store', () => ({ toast: { info: () => {}, error: hooks.toastError, success: () => {}, warning: () => {} } }));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: hooks.takeOver },
onTransformOutgoingEmail: { transform: hooks.transform },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeSignatureHtmlForDisplay: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
sanitizePluginBodyHtml: (v: string) => v,
escapeHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }) }));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', serializeEditorContent: () => '' }));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
const generic = LABEL_PRESETS[0];
function useLabels(labels: Partial<typeof DEFAULT_ORGANISATION_POLICY.labels>) {
usePolicyStore.setState({
loaded: true,
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: generic.list, extraHeader: 'X-Classification', ...labels } },
},
});
}
const DRAFT = {
to: 'bob@example.org', cc: '', bcc: '', subject: 'Budget', body: '<p>Numbers</p>',
showCc: false, showBcc: false, selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null,
};
type SendData = { subject: string; headers?: Record<string, string>; labelHeaders?: Record<string, string> };
function renderComposer(props: Partial<React.ComponentProps<typeof EmailComposer>> = {}) {
const onSend = vi.fn<(data: SendData) => void>();
render(<EmailComposer mode="compose" initialData={DRAFT} onSend={onSend} {...props} />);
return onSend;
}
const send = async () => {
await act(async () => { fireEvent.click(screen.getAllByTestId('composer-send')[0]); });
};
afterEach(() => {
cleanup();
resetSendPipeline();
usePolicyStore.setState({ policy: DEFAULT_POLICY });
vi.clearAllMocks();
hooks.transform.mockImplementation(async (email: unknown) => email);
hooks.takeOver.mockImplementation(async () => 'default');
});
describe('composer: classification label', () => {
it('shows no picker and changes nothing while the organisation has no labels', async () => {
const onSend = renderComposer();
expect(screen.queryByTestId('composer-classification')).toBeNull();
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].subject).toBe('Budget');
expect(onSend.mock.calls[0][0].labelHeaders).toBeUndefined();
});
it('blocks sending until a required label is picked, then stamps it', async () => {
useLabels({ required: true, defaultId: '' });
const onSend = renderComposer();
await send();
expect(onSend).not.toHaveBeenCalled();
expect(hooks.toastError).toHaveBeenCalledWith('required_toast');
// The picker opened, as a radio group without "No label".
expect(screen.queryByTestId('composer-classification-option-none')).toBeNull();
fireEvent.click(screen.getByTestId('composer-classification-option-confidential'));
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const data = onSend.mock.calls[0][0];
expect(data.subject).toBe('[Confidential] Budget');
expect(data.labelHeaders?.['header:SIO-Label:asRaw']).toContain('marking="CONFIDENTIAL"');
expect(data.labelHeaders?.['header:Sensitivity:asText']).toBe('Company-Confidential');
expect(data.labelHeaders?.['header:X-Classification:asText']).toBe('Confidential');
});
it('labels after the plugins: a plugin can neither drop the prefix nor set the label headers', async () => {
useLabels({ defaultId: 'strictly-confidential' });
hooks.transform.mockImplementation(async (email: unknown) => ({
...(email as object),
subject: 'Budget (rewritten)',
headers: { 'SIO-Label': 'marking="PUBLIC"', Sensitivity: 'Personal', 'X-Classification': 'Public', 'X-Other': '1' },
}));
const onSend = renderComposer();
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const data = onSend.mock.calls[0][0];
expect(data.subject).toBe('[Strictly confidential] Budget (rewritten)');
expect(data.headers).toEqual({ 'X-Other': '1' });
expect(data.labelHeaders?.['header:SIO-Label:asRaw']).toContain('STRICTLY CONFIDENTIAL');
});
it('starts a reply with the original’s label', async () => {
useLabels({ defaultId: 'public' });
const onSend = renderComposer({
mode: 'reply',
initialData: { ...DRAFT, mode: 'reply', subject: 'Re: [Confidential] Budget' },
replyTo: { subject: '[Confidential] Budget', headers: { 'SIO-Label': ' marking="CONFIDENTIAL"; bgcolor=#d97706' } },
});
expect(screen.getByTestId('composer-classification-trigger').textContent).toContain('Confidential');
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].subject).toBe('[Confidential] Re: Budget');
});
it('lets the user take the label off when none is required', async () => {
useLabels({ defaultId: 'confidential' });
const onSend = renderComposer();
fireEvent.click(screen.getByTestId('composer-classification-trigger'));
fireEvent.keyDown(screen.getByTestId('composer-classification-option-confidential'), { key: 'Home' });
fireEvent.keyDown(screen.getByTestId('composer-classification-option-none'), { key: 'Enter' });
expect(screen.queryByTestId('composer-classification-options')).toBeNull();
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].subject).toBe('Budget');
expect(onSend.mock.calls[0][0].labelHeaders).toBeUndefined();
});
it('hands a crypto takeover the labelled subject', async () => {
useLabels({ defaultId: 'confidential' });
hooks.takeOver.mockImplementation(async () => 'handled');
const onSend = renderComposer();
await send();
await waitFor(() => expect(hooks.takeOver).toHaveBeenCalledTimes(1));
expect((hooks.takeOver.mock.calls[0][0] as { subject: string }).subject).toBe('[Confidential] Budget');
expect(onSend).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,172 @@
'use client';
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Check, ChevronDown, LabelIcon } from '@/components/icons';
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
import { textColorOn } from '@/lib/classification/labels';
import { cn } from '@/lib/utils';
export interface ClassificationPickerHandle {
/** Opens the picker with the focus on it (Send without a required label). */
open: () => void;
}
interface Props {
labels: ClassificationLabel[];
selected: ClassificationLabel | null;
required: boolean;
/** Shows the trigger as missing (Send was pressed without a label). */
invalid?: boolean;
onPick: (id: string | null) => void;
className?: string;
}
/**
* The composer's label picker: a button in the label's colour that opens a
* radio group (arrow keys move the pick, Enter or Escape closes, the focus
* goes back to the button). Keys stay inside, away from the composer's and
* the mail page's shortcuts. "No label" is offered unless one is required.
*/
export const ClassificationLabelPicker = forwardRef<ClassificationPickerHandle, Props>(function ClassificationLabelPicker(
{ labels, selected, required, invalid, onPick, className },
ref,
) {
const t = useTranslations('classification');
const id = useId();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const groupRef = useRef<HTMLDivElement>(null);
const options: Array<ClassificationLabel | null> = required ? labels : [null, ...labels];
const selectedIndex = Math.max(0, options.findIndex((o) => (o?.id ?? null) === (selected?.id ?? null)));
useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []);
useEffect(() => {
if (!open) return;
const radios = groupRef.current?.querySelectorAll<HTMLElement>('[role="radio"]');
radios?.[selectedIndex]?.focus();
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (!groupRef.current?.parentElement?.contains(target)) setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
// Focus only when it opens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const pick = (option: ClassificationLabel | null) => onPick(option?.id ?? null);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
const radios = [...(groupRef.current?.querySelectorAll<HTMLElement>('[role="radio"]') ?? [])];
const current = radios.indexOf(document.activeElement as HTMLElement);
const move = (to: number) => {
const next = (to + options.length) % options.length;
pick(options[next]);
radios[next]?.focus();
};
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
move(current + 1);
break;
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
move(current - 1);
break;
case 'Home':
e.preventDefault();
move(0);
break;
case 'End':
e.preventDefault();
move(options.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (current >= 0) pick(options[current]);
close();
break;
case 'Escape':
e.preventDefault();
close();
break;
case 'Tab':
setOpen(false);
break;
}
};
const name = selected?.name ?? (required ? t('pick_required') : t('none'));
return (
<div className={cn('relative', className)} data-testid="composer-classification">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={selected ? t('chip_label', { name: selected.name }) : `${t('picker_label')}: ${name}`}
aria-invalid={invalid || undefined}
data-testid="composer-classification-trigger"
className={cn(
'inline-flex h-7 max-w-full items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium transition-colors max-md:h-9',
selected ? 'border-transparent' : 'border-dashed border-border text-muted-foreground hover:bg-muted',
invalid && !selected && 'border-destructive text-destructive animate-pulse',
)}
style={selected ? { backgroundColor: selected.color, color: textColorOn(selected.color) } : undefined}
>
<LabelIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{name}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-70" aria-hidden="true" />
</button>
{open && (
<div
role="dialog"
aria-label={t('picker_label')}
className="absolute end-0 top-full z-50 mt-1 w-72 max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg max-md:start-0 max-md:end-auto"
onKeyDown={onKeyDown}
>
{required && <p className="px-2 py-1 text-xs text-muted-foreground">{t('required_hint')}</p>}
<div ref={groupRef} role="radiogroup" aria-labelledby={`${id}-label`} data-testid="composer-classification-options">
<span id={`${id}-label`} className="sr-only">{t('picker_label')}</span>
{options.map((option, index) => {
const checked = (option?.id ?? null) === (selected?.id ?? null);
return (
<div
key={option?.id ?? 'none'}
role="radio"
aria-checked={checked}
tabIndex={index === selectedIndex ? 0 : -1}
data-testid={`composer-classification-option-${option?.id ?? 'none'}`}
onClick={() => { pick(option); close(); }}
className="flex cursor-pointer items-start gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={cn('mt-1 h-3 w-3 shrink-0 rounded-full', !option && 'border border-dashed border-muted-foreground')}
style={option ? { backgroundColor: option.color } : undefined}
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block font-medium">{option?.name ?? t('none')}</span>
{option?.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}
</span>
{checked && <Check className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />}
</div>
);
})}
</div>
</div>
)}
</div>
);
});
+112 -7
View File
@@ -111,6 +111,10 @@ 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 } from "@/lib/outgoing/classification";
import { whenPolicySettled } from "@/stores/policy-store";
import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker";
import { useComposerClassification } from "./use-composer-classification";
import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients";
import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading";
@@ -253,6 +257,11 @@ export interface ComposerDraftData {
references?: string[];
/** Priority of a re-opened draft, read back from its headers (#970). */
priority?: MessagePriority;
/**
* Classification label of a re-opened draft (X-Bulwark-Label): an id,
* null for "no label", absent when the draft says nothing.
*/
classification?: string | null;
/**
* Handed to the host when the composer went away with edits the server may
* not have: the composer opened again with it counts them as unsaved, so
@@ -299,6 +308,8 @@ interface EmailComposerProps {
priority?: MessagePriority;
/** Extra headers plugins added in onTransformOutgoingEmail. */
headers?: Record<string, string>;
/** The classification label's headers (lib/outgoing/classification.ts), written after `headers`. */
labelHeaders?: Record<string, string>;
/** The id plugins know this composer by, for onAfterEmailSend. */
composerId?: string;
}) => void | false | Promise<void | false>;
@@ -379,6 +390,8 @@ interface EmailComposerProps {
quoteHeaderText?: string;
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
quoteWrapInBlockquote?: boolean;
/** The original's headers: a reply or forward starts with its classification label. */
headers?: Record<string, string | string[]>;
};
}
@@ -470,6 +483,7 @@ export function EmailComposer({
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode);
// Per-message format (#1022). Seeded from the "plain text only" setting -
@@ -1102,6 +1116,19 @@ export function EmailComposer({
// scopes decide the organisation signature and disclaimer: the identity's
// own account, else the one the composer was opened in.
const composingAccountId = currentIdentityParts.localAccountId ?? composerAccountIdRef.current;
// Classification label (enterprise pack): the labels of the account and
// identity the message goes out from; replies and forwards start with
// the original's label, re-opened drafts with theirs.
const classification = useComposerClassification({
accountId: composingAccountId,
senderEmail: currentIdentity?.email,
originalHeaders: mode === 'compose' ? null : replyTo?.headers,
initialChoice: initialData?.classification,
});
const classificationRef = useRef(classification);
classificationRef.current = classification;
const classificationPickerRef = useRef<ClassificationPickerHandle>(null);
const [labelInvalid, setLabelInvalid] = useState(false);
// The upload callbacks below list only `client` as a dependency, so they read
// the composing identity's client through a ref: uploads must land in the
// account that owns the draft, and a direct substitution would capture a
@@ -1687,9 +1714,20 @@ export function EmailComposer({
// `!==`, not `>`: a re-opened draft starts with hydrated attachments, and
// removing one must count as dirty or the removal never reaches the server.
body !== initialValuesRef.current.body || attachments.length !== initialValuesRef.current.attachmentCount;
// A priority change alone is an edit worth saving (#970).
// A priority change alone is an edit worth saving (#970), and so is a label pick.
const initialPriorityRef = useRef(priority);
if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
const initialLabelChoiceRef = useRef(classification.choice);
if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true;
const firstLabelRunRef = useRef(true);
useEffect(() => {
if (firstLabelRunRef.current) {
firstLabelRunRef.current = false;
return;
}
changeVersionRef.current += 1;
setLabelInvalid(false);
}, [classification.choice]);
const firstPriorityRunRef = useRef(true);
useEffect(() => {
if (firstPriorityRunRef.current) {
@@ -1745,6 +1783,7 @@ export function EmailComposer({
mode,
replyTo,
priority: priorityRef.current,
classification: classificationRef.current.draftChoice,
// Edits the last save didn't cover; a minimised composer with
// none needs no save before an automatic sign-out.
unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
@@ -2700,7 +2739,7 @@ export function EmailComposer({
savedDraft.htmlBody,
replyThreadingHeaders?.inReplyTo,
replyThreadingHeaders?.references,
{ priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId },
{ priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId, classification: classificationRef.current.draftChoice },
);
if (replacedElsewhere) {
await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId);
@@ -3319,6 +3358,22 @@ export function EmailComposer({
return;
}
// A required classification label (enterprise pack), read from the
// policy once it has loaded: no label, no send. Not a pre-send check,
// which would be skipped when it throws.
await whenPolicySettled();
{
const labelSender = { email: currentIdentity?.email, accountId: composingAccountId };
const labelPolicy = labelsPolicyFor(labelSender);
const label = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers);
if (labelMissing(labelPolicy, label)) {
setLabelInvalid(true);
toast.error(tClassification('required_toast'));
classificationPickerRef.current?.open();
return;
}
}
// Catch addresses the server would reject before asking it. Its answer
// ("No recipients found in email") does not say which one is wrong.
const invalidRecipients = invalidRecipientAddresses([
@@ -3578,11 +3633,17 @@ export function EmailComposer({
// draft, and never fall back to sending it unsigned (#1207). It signs
// or encrypts the message with the `last` finalisers' additions.
const sealed = lastStage(finalHtmlBody, finalBody);
// The label as it stands now, from the policy of the sending account.
const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId });
const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers);
// 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).
const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject });
const composeSendRequest = {
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)),
subject,
subject: takeoverLabelled.subject,
htmlBody: sealed.html || '',
textBody: sealed.text,
identityId: currentIdentity?.id || '',
@@ -3598,7 +3659,13 @@ export function EmailComposer({
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
],
};
const pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest);
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);
} finally {
endLabelTakeover(labelToken);
}
if (pluginOutcome === 'aborted') {
// The plugin shows its own error, or the user cancelled its prompt.
debug.log('email', 'Send aborted by an onComposeSend plugin handler');
@@ -3651,6 +3718,8 @@ export function EmailComposer({
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
// Below whatever the plugins added (a footer), the `last` finalisers.
const sealedOutgoing = lastStage(outgoing.htmlBody, outgoing.textBody);
// The label after the plugins had their say, so none can drop it.
const labelled = classifyOutgoing(labelPolicy, sendLabel, { subject: outgoing.subject, headers: outgoing.headers });
// Strip the cross-account namespace from the identity id before
// handing it to the parent - the JMAP server only knows the raw
@@ -3682,7 +3751,7 @@ export function EmailComposer({
to: outgoing.to,
cc: outgoing.cc,
bcc: outgoing.bcc,
subject: outgoing.subject,
subject: labelled.subject,
body: sealedOutgoing.text,
htmlBody: sealedOutgoing.html || undefined,
draftId: finalDraftId && !draftElsewhere ? finalDraftId : undefined,
@@ -3699,7 +3768,8 @@ export function EmailComposer({
requestDsn: requestDsn || undefined,
requireTls: requireTls || undefined,
priority: priority !== 'normal' ? priority : undefined,
headers: outgoing.headers,
headers: labelled.headers,
labelHeaders: labelled.labelHeaders,
delayedUntil: effectiveDelayedUntil,
composerId: pluginComposerIdRef.current ?? undefined,
});
@@ -3749,6 +3819,11 @@ export function EmailComposer({
explicitCloseRef.current = false;
sentRef.current = false;
debug.error('Failed to send email:', err);
if (isLabelRequiredError(err)) {
setLabelInvalid(true);
toast.error(tClassification('required_toast'));
return;
}
if (err instanceof RecipientsRejectedError) {
// Nothing went out; the message stays open here so the addresses
// can be fixed. The SMTP replies say why each one was refused (#1123).
@@ -4494,7 +4569,7 @@ export function EmailComposer({
)}
{/* Subject field */}
<div className="flex items-center gap-2 px-4 py-2.5">
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5">
<label htmlFor={`${fieldIdBase}-subject`} className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</label>
<Input
id={`${fieldIdBase}-subject`}
@@ -4512,6 +4587,18 @@ export function EmailComposer({
}}
className="flex-1 border-0 h-8 px-0 text-sm"
/>
{classification.policy.list.length > 0 && (
// A chip at the end of the subject row; on a phone on its own line under it.
<ClassificationLabelPicker
ref={classificationPickerRef}
labels={classification.policy.list}
selected={classification.label}
required={classification.policy.required}
invalid={labelInvalid}
onPick={classification.setChoice}
className="shrink-0 max-md:order-last max-md:basis-full max-md:ps-14"
/>
)}
</div>
</div>
@@ -4885,6 +4972,24 @@ export function EmailComposer({
</ComposerToolbarMenuRadioItem>
))}
</div>
{classification.policy.list.length > 0 && (
<>
<div role="separator" className="my-1 h-px bg-border" />
<div role="group" aria-label={tClassification('picker_label')} data-testid="composer-options-classification">
<div className="px-3 pt-1 pb-0.5 text-xs font-medium text-muted-foreground" aria-hidden="true">{tClassification('picker_label')}</div>
{[...(classification.policy.required ? [] : [null]), ...classification.policy.list].map((option) => (
<ComposerToolbarMenuRadioItem
key={option?.id ?? 'none'}
checked={(classification.label?.id ?? null) === (option?.id ?? null)}
onSelect={() => classification.setChoice(option?.id ?? null)}
testId={`composer-options-label-${option?.id ?? 'none'}`}
>
{option?.name ?? tClassification('none')}
</ComposerToolbarMenuRadioItem>
))}
</div>
</>
)}
<ComposerPluginMenuItems surface="composer-options-menu" context={composerActionContext} onSelect={close} separator />
<ComposerPluginMenuItems surface="composer-toolbar-actions" context={composerActionContext} onSelect={close} separator onlyWhenNarrow />
</>}
@@ -0,0 +1,55 @@
'use client';
import { useMemo, useState } from 'react';
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
import { useAccountSubjects } from '@/lib/org/account-subjects';
import { labelsPolicyFor, resolveLabel, labelMissing } from '@/lib/outgoing/classification';
import type { LabelsPolicy } from '@/lib/classification/labels';
import { usePolicyStore } from '@/stores/policy-store';
export interface ComposerClassification {
policy: LabelsPolicy;
/** The label the message goes out with as things stand. */
label: ClassificationLabel | null;
/** The user's pick: an id, null for none, undefined for "not picked" (original's or default). */
choice: string | null | undefined;
setChoice: (choice: string | null) => void;
/** Sending needs a label and there is none. */
missing: boolean;
/** What a saved draft keeps (X-Bulwark-Label); undefined while labels are off. */
draftChoice: string | null | undefined;
}
/**
* The composer's classification label: the labels of the account and
* identity the message goes out from, the user's pick, else the original's
* label (replies and forwards), else the default. Follows the policy as it
* loads and the From as it changes.
*/
export function useComposerClassification(input: {
accountId: string | null | undefined;
senderEmail: string | undefined;
/** The original's headers, for a reply or forward. */
originalHeaders?: Record<string, string | string[]> | null;
initialChoice?: string | null;
}): ComposerClassification {
const policyState = usePolicyStore((s) => s.policy);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const subjects = useAccountSubjects((s) => s.subjects);
const [choice, setChoice] = useState<string | null | undefined>(input.initialChoice);
const policy = useMemo(
() => labelsPolicyFor({ email: input.senderEmail, accountId: input.accountId }),
// Re-read from the stores these follow.
// eslint-disable-next-line react-hooks/exhaustive-deps
[input.senderEmail, input.accountId, policyState, rawPolicy, subjects],
);
const label = useMemo(() => resolveLabel(policy, choice, input.originalHeaders), [policy, choice, input.originalHeaders]);
return {
policy,
label,
choice,
setChoice,
missing: labelMissing(policy, label),
draftChoice: policy.list.length === 0 ? undefined : (label?.id ?? null),
};
}
+30 -3
View File
@@ -31,7 +31,7 @@ import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
import { useShallow } from "zustand/react/shallow";
import { usePolicyStore } from "@/stores/policy-store";
import { usePolicyStore, whenPolicySettled } from "@/stores/policy-store";
import { isArchiveActionEnabled, useArchiveActionEnabled } from "@/lib/archive-gate";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
@@ -121,6 +121,9 @@ import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { isFilePreviewable } from "@/lib/file-preview";
import { prepareQuickReply, type QuickReplyQuestion } from "@/lib/outgoing/quick-reply";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
import { automaticLabel, classifyOutgoing, labelSourceHeaders } from "@/lib/outgoing/classification";
import { draftLabelChoice } from "@/lib/classification/labels";
import enMessages from "@/locales/en/common.json";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
@@ -1333,6 +1336,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
// Its label, for the reply's or forward's classification label.
headers: labelSourceHeaders(selectedEmail.headers),
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
@@ -2158,6 +2163,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
requireTls?: boolean;
priority?: MessagePriority;
headers?: Record<string, string>;
labelHeaders?: Record<string, string>;
composerId?: string;
}) => {
// Nothing is sent: the composer keeps what only goes once the mail is out.
@@ -2182,7 +2188,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, headers: data.headers, 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, composerSendOptions(data));
submitted = true;
announceSent(result, data);
setShowComposer(false);
@@ -2495,6 +2501,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
inReplyTo: draft.inReplyTo ?? undefined,
references: draft.references ?? undefined,
priority: messagePriorityFromHeaders(draft.headers),
classification: draftLabelChoice(draft.headers),
// Existing server-side attachments must ride along, or the composer
// starts empty and the next save/send silently rebuilds the draft
// without them (#849).
@@ -2846,6 +2853,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
},
});
setComposerMode('forward');
@@ -4084,6 +4092,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const signatureIdentity = (sendingIdentity?.htmlSignature || sendingIdentity?.textSignature)
? sendingIdentity
: primaryIdentity;
// The classification label (enterprise pack): the original's, else the
// default. A required label with neither: the full composer, with the
// typed text, to pick one. Nothing is asked or sent before that.
await whenPolicySettled();
const quickLabel = automaticLabel({ email: headerFromEmail }, selectedEmail.headers);
if (quickLabel.missing) {
toast.info(t('classification.quick_reply_needs_label'));
await handleReply(body);
return true;
}
// The same warnings, checks and finalisers as the composer's send
// (lib/outgoing/send-pipeline.ts): a quick reply is no way around them.
const settings = useSettingsStore.getState();
@@ -4155,11 +4174,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
references: selectedEmail.references,
});
// The label goes on last, as in the composer.
const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, {
subject: buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')),
});
// Send reply with just the body text
const result = await sendEmail(
client,
recipients,
buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')),
labelled.subject,
finalBody,
undefined,
undefined,
@@ -4173,6 +4197,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
threading?.references,
delayedUntil,
undefined,
labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined,
);
// As after the composer's send: the server refused some recipients, held
// reply or not, and nothing else would say they never get it.
@@ -4822,6 +4847,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
messageId: composerSourceEmail.messageId,
inReplyTo: composerSourceEmail.inReplyTo,
references: composerSourceEmail.references,
// Its label, for the reply's or forward's classification label.
headers: labelSourceHeaders(composerSourceEmail.headers),
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
@@ -180,3 +180,35 @@ describe('Pro compose tab - cross-account send routing (#461)', () => {
}
});
});
describe('Pro compose tab - what the composer chose', () => {
beforeEach(() => {
sendEmail.mockClear();
});
it('sends with the priority, read receipt, DSN, REQUIRETLS, plugin headers and label the composer chose', async () => {
payloadRef.current = {
...basePayload,
requestReadReceipt: true,
requestDsn: true,
requireTls: true,
priority: 'high',
headers: { 'X-Plugin': '1' },
labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' },
};
render(<ProComposeTabBody tabId="tab-1" data={TAB_DATA} />);
fireEvent.click(screen.getByText('send'));
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
const args = sendEmail.mock.calls[0] as unknown[];
expect(args[args.length - 1]).toMatchObject({
requestReadReceipt: true,
requestDsn: true,
requireTls: true,
priority: 'high',
headers: { 'X-Plugin': '1' },
labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' },
});
});
});
+5 -1
View File
@@ -14,6 +14,7 @@ import { debug } from "@/lib/debug";
import { threadKeyFor } from "@/lib/thread-utils";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { announceSent } from "@/lib/plugin-after-send";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
interface ProComposeTabBodyProps {
tabId: string;
@@ -89,7 +90,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom,
{ localAccountId: sendData.localAccountId, draftAccountId: sendData.draftAccountId },
// The composer's whole options, as the mail page passes them: the
// tab used to send without priority, read receipt, DSN, REQUIRETLS,
// plugin headers and the classification label.
composerSendOptions(sendData),
);
submitted = true;
if (result.rejectedRecipients?.length) {
+5
View File
@@ -24,6 +24,8 @@ import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor } from "@/lib/thread
import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
import { messagePriorityFromHeaders } from '@/lib/message-priority';
import { labelSourceHeaders } from '@/lib/outgoing/classification';
import { draftLabelChoice } from '@/lib/classification/labels';
import { useArchiveActionEnabled } from '@/lib/archive-gate';
import { confirmMailRemoval } from '@/lib/mail-removal-confirm';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
@@ -83,6 +85,7 @@ function buildReplyContext(email: Email): ProReplyContext {
messageId: email.messageId,
inReplyTo: email.inReplyTo,
references: email.references,
headers: labelSourceHeaders(email.headers),
};
}
@@ -287,6 +290,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
},
sourceEmailId: email.id,
sourceEmailSource: writeSource,
@@ -467,6 +471,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
// composer without its files and the next save stripped them.
attachments: email.attachments,
priority: messagePriorityFromHeaders(email.headers),
classification: draftLabelChoice(email.headers),
},
}, { to: addresses(email.to), cc: addresses(email.cc), subject: draftSubject, body: bodyText }, onClose);
}, [email, accountId, localAccountId, identities, multiAccountIdentities, openComposeTab, onClose, t]);
+130
View File
@@ -0,0 +1,130 @@
import { describe, expect, it } from 'vitest';
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
import { LABEL_PRESETS } from '@/lib/classification/presets';
import {
applyLabelPrefix,
draftLabelChoice,
draftLabelProperty,
labelHeaderLines,
labelHeaderProperties,
parseSioLabelMarking,
receivedLabel,
sioLabelValue,
textColorOn,
} from '@/lib/classification/labels';
// The headers a classification label writes (SIO-Label per RFC 7444 with
// RFC 2231 for non-ASCII markings, Sensitivity per RFC 2156, the admin's
// extra header), reading them back, and the subject prefix without doubling.
const generic = LABEL_PRESETS.find((p) => p.id === 'generic')!;
const german = LABEL_PRESETS.find((p) => p.id === 'german-public-sector')!;
const byId = (list: ClassificationLabel[], id: string) => list.find((l) => l.id === id)!;
const confidential = byId(generic.list, 'confidential');
const vsnfd = byId(german.list, 'vs-nfd');
describe('SIO-Label', () => {
it('writes an ASCII marking quoted, with readable colours', () => {
expect(sioLabelValue(confidential)).toBe(' marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706');
expect(textColorOn('#1e3a8a')).toBe('#ffffff');
expect(textColorOn('#fde68a')).toBe('#000000');
});
it('writes a non-ASCII marking with RFC 2231, never an encoded word, folded under 78 columns', () => {
const value = sioLabelValue(vsnfd);
expect(value).toContain("marking*=utf-8''VS-NUR%20F%C3%9CR%20DEN%20DIENSTGEBRAUCH;");
expect(value).not.toContain('=?');
for (const line of `SIO-Label:${value}`.split('\r\n')) expect(line.length).toBeLessThanOrEqual(78);
expect(parseSioLabelMarking(value)).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
});
it('continues a long encoded marking over several parameters and reads it back', () => {
const long = { ...vsnfd, marking: 'ÄÖÜ äöü ß – sehr lange Kennzeichnung für einen Test der Fortsetzung' };
const value = sioLabelValue(long);
expect(value).toMatch(/marking\*0\*=utf-8''/);
expect(value).toMatch(/marking\*1\*=/);
expect(value).not.toMatch(/%[0-9A-F]?(;|\r|$)/);
expect(parseSioLabelMarking(value)).toBe(long.marking);
});
it('reads the forms servers hand back: raw, refolded, a token, RFC 2047 inside quotes', () => {
expect(parseSioLabelMarking(' marking="INTERNAL"; fgcolor=#ffffff; bgcolor=#2563eb')).toBe('INTERNAL');
expect(parseSioLabelMarking(' marking="STRICTLY\r\n CONFIDENTIAL"; bgcolor=red')).toBe('STRICTLY CONFIDENTIAL');
expect(parseSioLabelMarking('marking=PUBLIC;fgcolor=black')).toBe('PUBLIC');
expect(parseSioLabelMarking(' marking="=?UTF-8?B?VlMtTlVSIEbDnFIgREVOIERJRU5TVEdFQlJBVUNI?="')).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
expect(parseSioLabelMarking(' type=":ess"; label="MQYGASkCAQM="')).toBeNull();
expect(parseSioLabelMarking(undefined)).toBeNull();
});
it('writes Sensitivity and the extra header only when the label sets them', () => {
expect(labelHeaderProperties(confidential, 'X-Classification')).toEqual({
'header:SIO-Label:asRaw': sioLabelValue(confidential),
'header:Sensitivity:asText': 'Company-Confidential',
'header:X-Classification:asText': 'Confidential',
});
expect(Object.keys(labelHeaderProperties(byId(generic.list, 'public'), ''))).toEqual(['header:SIO-Label:asRaw']);
expect(labelHeaderLines(vsnfd, 'X-Classification')).toEqual([
`SIO-Label:${sioLabelValue(vsnfd)}`,
'Sensitivity: Company-Confidential',
'X-Classification: VS-NfD',
]);
expect(labelHeaderLines({ ...vsnfd, extraHeaderValue: 'Geheim für alle' }, 'X-Class')[2]).toMatch(/^X-Class: =\?UTF-8\?B\?/);
});
});
describe('received labels', () => {
const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
it('are matched by marking first, case and spacing aside', () => {
expect(receivedLabel({ 'SIO-Label': ' marking="confidential "' }, policy)?.id).toBe('confidential');
expect(receivedLabel({ 'sio-label': [' marking="PUBLIC"'] }, policy)?.id).toBe('public');
});
it('then by the extra header value', () => {
expect(receivedLabel({ 'X-Classification': ' Strictly confidential' }, policy)?.id).toBe('strictly-confidential');
});
it('show nothing for an unknown marking or without labels', () => {
expect(receivedLabel({ 'SIO-Label': ' marking="TOP SECRET"' }, policy)).toBeNull();
expect(receivedLabel({ 'SIO-Label': ' marking="PUBLIC"' }, { ...policy, list: [] })).toBeNull();
});
});
describe('the subject prefix', () => {
const list = generic.list;
const strictly = byId(list, 'strictly-confidential');
it('is added once', () => {
expect(applyLabelPrefix('Budget', confidential, list)).toBe('[Confidential] Budget');
expect(applyLabelPrefix('[Confidential] Budget', confidential, list)).toBe('[Confidential] Budget');
});
it('is not doubled across reply and forward chains', () => {
expect(applyLabelPrefix('Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] Re: Budget');
expect(applyLabelPrefix('AW: WG: [Confidential] Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] AW: WG: Re: Budget');
});
it('changes with the label, and goes with no label', () => {
expect(applyLabelPrefix('Re: [Confidential] Budget', strictly, list)).toBe('[Strictly confidential] Re: Budget');
expect(applyLabelPrefix('[Confidential] Re: Budget', null, list)).toBe('Re: Budget');
expect(applyLabelPrefix('Re: Budget', byId(list, 'internal'), list)).toBe('Re: Budget');
});
it('knows prefixes without brackets, like VS-NfD', () => {
expect(applyLabelPrefix('AW: VS-NfD Lagebericht', vsnfd, german.list)).toBe('VS-NfD AW: Lagebericht');
expect(applyLabelPrefix('VS-NfD: Lagebericht', vsnfd, german.list)).toBe('VS-NfD Lagebericht');
// A word that only starts like a prefix stays.
expect(applyLabelPrefix('VS-NfDx test', vsnfd, german.list)).toBe('VS-NfD VS-NfDx test');
});
});
describe('drafts', () => {
it('keep the pick, "none" and nothing apart', () => {
expect(draftLabelProperty('confidential')).toEqual({ 'header:X-Bulwark-Label:asText': 'confidential' });
expect(draftLabelProperty(null)).toEqual({ 'header:X-Bulwark-Label:asText': 'none' });
expect(draftLabelProperty(undefined)).toEqual({});
expect(draftLabelChoice({ 'X-Bulwark-Label': ' confidential' })).toBe('confidential');
expect(draftLabelChoice({ 'x-bulwark-label': 'none' })).toBeNull();
expect(draftLabelChoice({})).toBeUndefined();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// The client writes a classification label's headers on send (over any
// plugin header of the same name) and keeps the pick in a draft.
function createClient(): JMAPClient {
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' });
return client;
}
type Call = [string, Record<string, unknown>, string];
function mockServer() {
const captured: Call[][] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const calls = (JSON.parse((init as { body: string }).body) as { methodCalls: Call[] }).methodCalls;
captured.push(calls);
const name = calls[0][0];
let responses: unknown[];
if (name === 'Mailbox/get') responses = [['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }] }, '0']];
else if (name === 'Identity/get') responses = [['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, '0']];
else if (name === 'Email/set' && calls[0][1].create) {
const id = Object.keys(calls[0][1].create as object)[0];
responses = [['Email/set', { created: { [id]: { id: 'new-1' } } }, '0'], ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1']];
} else responses = calls.map(([method, , id]) => [method, {}, id]);
const payload = { methodResponses: responses };
return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(payload)), json: () => Promise.resolve(payload) } as Response;
});
return captured;
}
function created(captured: Call[][]): Record<string, unknown> {
for (const calls of captured) {
const set = calls.find(([name, args]) => name === 'Email/set' && args.create);
if (set) return Object.values(set[1].create as Record<string, Record<string, unknown>>)[0];
}
throw new Error('no create');
}
describe('label headers', () => {
beforeEach(() => vi.restoreAllMocks());
it('are written on send, and win over a plugin header of the same name', async () => {
const captured = mockServer();
await createClient().sendEmail(
['bob@example.org'], '[Confidential] x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
{
headers: { Sensitivity: 'Personal', 'X-Other': 'kept' },
labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"', 'header:Sensitivity:asText': 'Company-Confidential' },
},
);
const email = created(captured);
expect(email['header:SIO-Label:asRaw']).toBe(' marking="CONFIDENTIAL"');
expect(email['header:Sensitivity:asText']).toBe('Company-Confidential');
expect(email['header:X-Other:asText']).toBe('kept');
expect(Object.keys(email).filter((k) => k.toLowerCase().startsWith('header:sensitivity'))).toHaveLength(1);
});
it('are kept in a draft as the pick', async () => {
const captured = mockServer();
await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined, undefined, undefined, { classification: 'confidential' });
expect(created(captured)['header:X-Bulwark-Label:asText']).toBe('confidential');
const again = mockServer();
await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined, undefined, undefined, { classification: null });
expect(created(again)['header:X-Bulwark-Label:asText']).toBe('none');
});
});
+127
View File
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import type { IJMAPClient } from '../jmap/client-interface';
import type { Email } from '../jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_POLICY } from '@/lib/admin/types';
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';
// Classification labels on the plugin paths: api.email.forward carries the
// original's label (else the default) and refuses when a required one is
// missing; a crypto plugin's raw send during a labelled takeover gets the
// label headers in its top-level header block.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(perms: string[], id: string): InstalledPlugin {
return {
id, name: id, version: '1.0.0', author: 'test', description: '',
type: 'ui-extension', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
}
const FORWARDER = plugin(['email:forward'], 'e-invoice');
const SMIME = plugin(['email:raw-send'], 'smime');
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args, { privileged: p.id === 'smime' });
const original = (headers: Record<string, string>) => ({
id: 'E1', threadId: 'T1', mailboxIds: { inbox: true }, keywords: {}, size: 900, receivedAt: '2026-10-20T08:00:00Z',
from: [{ email: 'billing@supplier.example' }], to: [{ email: 'ann@example.com' }], subject: 'Invoice',
textBody: [{ partId: '1', blobId: 'tb', size: 20, type: 'text/plain' }], bodyValues: { 1: { value: 'Hello' } },
attachments: [], headers,
}) as unknown as Email;
function useLabels(labels: Partial<typeof DEFAULT_ORGANISATION_POLICY.labels>) {
const org = { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: LABEL_PRESETS[0].list, ...labels } };
usePolicyStore.setState({ loaded: true, policy: { ...DEFAULT_POLICY, organisation: org }, rawPolicy: { ...DEFAULT_POLICY, organisation: org } });
}
describe('labels on the plugin paths', () => {
let client: Record<string, ReturnType<typeof vi.fn>>;
let sendEmail: ReturnType<typeof vi.fn>;
let sendRawEmail: ReturnType<typeof vi.fn>;
let headers: Record<string, string> = {};
beforeEach(() => {
resetPluginRateLimits();
resetDialogLimits();
localStorage.clear();
// The forward to this address is approved already: no dialog.
localStorage.setItem('plugin-forward-approvals', '{}');
client = {
getAccountId: vi.fn(() => 'own'),
getEmail: vi.fn(async () => original(headers)),
getMailboxes: vi.fn(async () => []),
resolveSendAccountId: vi.fn(async () => 'own'),
copyBlobs: vi.fn(async () => ({})),
hasDelayedSend: vi.fn(() => false),
batchUpdateKeywords: vi.fn(async () => undefined),
submitRawEmail: vi.fn(async () => ({ scheduled: false })),
};
sendEmail = vi.fn(async () => ({ emailId: 'S1', scheduled: false }));
sendRawEmail = vi.fn(async () => ({ scheduled: false }));
useAuthStore.setState({ client: client as unknown as IJMAPClient });
useAccountStore.setState({ activeAccountId: 'acct-1' });
useEmailStore.setState({ sendEmail, sendRawEmail, emails: [], selectedEmail: null, mailboxes: [] } as never);
useIdentityStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] as never, preferredPrimaryId: 'id-a' });
useSettingsStore.setState({ sendDelaySeconds: 0 });
});
afterEach(() => {
while (head()) resolveHead(false);
useAuthStore.setState({ client: null });
usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY });
resetLabelTakeovers();
headers = {};
vi.clearAllMocks();
});
async function forward() {
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] });
await vi.waitFor(() => expect(head()).not.toBeNull());
resolveHead(true);
return pending;
}
it('forwards with the original’s label', async () => {
useLabels({ defaultId: 'internal' });
headers = { 'SIO-Label': ' marking="CONFIDENTIAL"' };
expect(await forward()).toMatchObject({ sent: true });
const args = sendEmail.mock.calls[0] as unknown[];
expect(args[2]).toBe('[Confidential] Fwd: Invoice');
expect((args[16] as { labelHeaders: Record<string, string> }).labelHeaders['header:SIO-Label:asRaw']).toContain('CONFIDENTIAL');
});
it('refuses a forward that needs a label it has no way to get', async () => {
useLabels({ required: true, defaultId: '' });
expect(await call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] })).toEqual({ sent: false, reason: 'stopped' });
expect(sendEmail).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalled();
});
it('puts the label into a raw send during a labelled takeover only', 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', {});
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', {});
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"');
});
});
+304
View File
@@ -0,0 +1,304 @@
/**
* Classification labels on the wire (enterprise pack item 3). Pure
* functions: the headers a label writes, reading a received label back,
* the subject prefix, and the draft marker.
*
* Headers written:
* - `SIO-Label` (RFC 7444) in the marking-only form with colours, as a raw
* header: `marking="CONFIDENTIAL"; fgcolor=#ffffff; bgcolor=#d97706`.
* A marking with non-ASCII characters uses RFC 2231
* (`marking*=utf-8''VS-NUR%20F%C3%9CR...`): an RFC 2047 encoded word
* inside a quoted parameter is invalid (RFC 2047 §5), which is what the
* server's text form would produce.
* - `Sensitivity` (RFC 2156 §5.3.4), what Outlook shows, when the label
* sets one.
* - The admin's extra header (X-Classification, msip_labels) when the label
* has a value for it.
* No SIO-Label-History: it needs who changed it and when, and RFC 7444
* says it may leak.
*
* Drafts keep the user's choice in `X-Bulwark-Label` (the label id, or
* `none` for a choice of no label), the way they keep the priority.
*/
import type { ClassificationLabel, OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { stripSubjectPrefixes } from '@/lib/subject-prefix';
export type LabelsPolicy = OrganisationPolicy['labels'];
export const SIO_LABEL_HEADER = 'SIO-Label';
export const SENSITIVITY_HEADER = 'Sensitivity';
export const DRAFT_LABEL_HEADER = 'X-Bulwark-Label';
/** A draft whose sender picked "no label". */
export const NO_LABEL = 'none';
/** The marking a label writes: its own, else its name. */
export function labelMarking(label: Pick<ClassificationLabel, 'marking' | 'name'>): string {
return (label.marking || label.name).trim();
}
/** Black or white, whichever reads better on `bg` (#rrggbb). */
export function textColorOn(bg: string): '#000000' | '#ffffff' {
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(bg);
if (!m) return '#ffffff';
const [r, g, b] = [m[1], m[2], m[3]].map((h) => {
const c = parseInt(h, 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.179 ? '#000000' : '#ffffff';
}
// RFC 2231 attribute-char: anything but specials, space, controls, *, ' and %.
const ATTRIBUTE_CHAR = /[A-Za-z0-9!#$&+\-.^_`|~]/;
function percentEncode(value: string): string {
let out = '';
for (const byte of new TextEncoder().encode(value)) {
const ch = String.fromCharCode(byte);
out += byte < 0x80 && ATTRIBUTE_CHAR.test(ch) ? ch : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
}
return out;
}
/** 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, '');
// eslint-disable-next-line no-control-regex
if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`];
const encoded = percentEncode(clean);
const MAX = 60;
if (encoded.length + 15 <= MAX) return [`marking*=utf-8''${encoded}`];
// Split between encoded characters, never inside a %XX.
const units = encoded.match(/%[0-9A-F]{2}|[^%]/g) ?? [];
const pieces: string[] = [];
let piece = '';
for (const unit of units) {
if (piece.length + unit.length > MAX - 15) {
pieces.push(piece);
piece = '';
}
piece += unit;
}
if (piece) pieces.push(piece);
return pieces.map((piece, i) => (i === 0 ? `marking*0*=utf-8''${piece}` : `marking*${i}*=${piece}`));
}
/** The SIO-Label value after "SIO-Label:", folded (leading space included). */
export function sioLabelValue(label: Pick<ClassificationLabel, 'marking' | 'name' | 'color'>): string {
const params = [...markingParameters(labelMarking(label)), `fgcolor=${textColorOn(label.color)}`, `bgcolor=${label.color}`];
// "SIO-Label: " is 11 characters; fold between parameters past 78.
let line = 11;
let out = '';
params.forEach((param, i) => {
const piece = i === params.length - 1 ? param : `${param};`;
if (i > 0 && line + 1 + piece.length > 78) {
out += `\r\n ${piece}`;
line = 1 + piece.length;
} else {
out += ` ${piece}`;
line += 1 + piece.length;
}
});
return out;
}
/** The label's headers as [name, value] pairs, the extra header's value as plain text. */
function labelHeaderPairs(label: ClassificationLabel, extraHeader: string): Array<[string, string, 'raw' | 'text']> {
const pairs: Array<[string, string, 'raw' | 'text']> = [[SIO_LABEL_HEADER, sioLabelValue(label), 'raw']];
if (label.sensitivity) pairs.push([SENSITIVITY_HEADER, label.sensitivity, 'text']);
const extraValue = label.extraHeaderValue.trim();
if (extraHeader && extraValue) pairs.push([extraHeader, extraValue, 'text']);
return pairs;
}
/** The label's headers as JMAP Email/set create properties (RFC 8621 §4.1.3). */
export function labelHeaderProperties(label: ClassificationLabel, extraHeader: string): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, value, form] of labelHeaderPairs(label, extraHeader)) {
out[form === 'raw' ? `header:${name}:asRaw` : `header:${name}:asText`] = value;
}
return out;
}
function encodedWord(value: string): string {
// eslint-disable-next-line no-control-regex
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)}?=`;
}
/** The label's headers as raw header lines (CRLF-free each), 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)}`);
}
/** The draft marker property: the label id, or `none` for no label. */
export function draftLabelProperty(choice: string | null | undefined): Record<string, string> {
if (choice === undefined) return {};
return { [`header:${DRAFT_LABEL_HEADER}:asText`]: choice ?? NO_LABEL };
}
type HeaderRecord = Record<string, string | string[]> | null | undefined;
function header(headers: HeaderRecord, name: string): string | undefined {
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined;
const lower = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() !== lower) continue;
const first = Array.isArray(value) ? value[0] : value;
if (typeof first === 'string') return first;
}
return undefined;
}
/**
* The choice a re-opened draft carries: a label id, null for "no label",
* undefined when the draft says nothing (a draft of another client, or one
* saved before labels).
*/
export function draftLabelChoice(headers: HeaderRecord): string | null | undefined {
const value = header(headers, DRAFT_LABEL_HEADER)?.trim();
if (!value) return undefined;
return value === NO_LABEL ? null : value;
}
function decodePercent(value: string, charset: string): string {
const bytes: number[] = [];
for (let i = 0; i < value.length; i++) {
if (value[i] === '%' && /^[0-9a-f]{2}$/i.test(value.slice(i + 1, i + 3))) {
bytes.push(parseInt(value.slice(i + 1, i + 3), 16));
i += 2;
} else {
bytes.push(value.charCodeAt(i) & 0xff);
}
}
try {
return new TextDecoder(charset || 'utf-8').decode(Uint8Array.from(bytes));
} catch {
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes));
}
}
/** Decodes RFC 2047 encoded words a server may have put into a value. */
function decodeEncodedWords(value: string): string {
return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (whole, charset: string, enc: string, text: string) => {
try {
let bytes: Uint8Array;
if (enc.toLowerCase() === 'b') bytes = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
else bytes = Uint8Array.from(text.replace(/_/g, ' ').replace(/=([0-9a-f]{2})/gi, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0));
return new TextDecoder(charset).decode(bytes);
} catch {
return whole;
}
});
}
/**
* The `marking` of a raw SIO-Label value (RFC 7444 §2): a quoted or token
* value, or the RFC 2231 forms (encoded and continued). Null without one.
*/
export function parseSioLabelMarking(raw: string | undefined | null): string | null {
if (!raw) return null;
const value = raw.replace(/\r?\n[ \t]+/g, ' ');
const params = new Map<string, string>();
const re = /([A-Za-z0-9*-]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;\s]*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(value))) {
let v = m[2];
if (v.startsWith('"')) v = v.slice(1, -1).replace(/\\(.)/g, '$1');
params.set(m[1].toLowerCase(), v);
}
if (params.has('marking')) return decodeEncodedWords(params.get('marking')!).trim() || null;
if (params.has('marking*')) {
const [charset, , text] = splitExtended(params.get('marking*')!);
return decodePercent(text, charset).trim() || null;
}
// Continuations: marking*0*, marking*1, ... joined before decoding (a
// character's bytes may be split across two of them, RFC 2231 §4.1).
let joinedEncoded = '';
let charset = 'utf-8';
for (let i = 0; i < 100; i++) {
const encoded = params.get(`marking*${i}*`);
const plain = params.get(`marking*${i}`);
if (encoded === undefined && plain === undefined) break;
if (encoded !== undefined) {
let text = encoded;
if (i === 0) [charset, , text] = splitExtended(encoded);
joinedEncoded += text;
} else {
joinedEncoded += percentEncode(plain!);
}
}
const joined = decodePercent(joinedEncoded, charset).trim();
return joined || null;
}
function splitExtended(value: string): [string, string, string] {
const first = value.indexOf("'");
const second = first >= 0 ? value.indexOf("'", first + 1) : -1;
if (first < 0 || second < 0) return ['utf-8', '', value];
return [value.slice(0, first), value.slice(first + 1, second), value.slice(second + 1)];
}
const normalise = (s: string) => s.normalize('NFC').replace(/\s+/g, ' ').trim().toLowerCase();
/**
* The known label a received message carries: by its SIO-Label marking
* first, then by the admin's extra header value. Unknown values show
* nothing. Shown as information only: anyone can write these headers.
*/
export function receivedLabel(headers: HeaderRecord, policy: LabelsPolicy | null | undefined): ClassificationLabel | null {
if (!policy || policy.list.length === 0) return null;
const marking = parseSioLabelMarking(header(headers, SIO_LABEL_HEADER));
if (marking) {
const wanted = normalise(marking);
const byMarking = policy.list.find((label) => normalise(labelMarking(label)) === wanted);
if (byMarking) return byMarking;
}
if (policy.extraHeader) {
const raw = header(headers, policy.extraHeader);
if (raw) {
const wanted = normalise(decodeEncodedWords(raw.replace(/\r?\n[ \t]+/g, ' ')));
const byExtra = policy.list.find((label) => label.extraHeaderValue && normalise(label.extraHeaderValue) === wanted);
if (byExtra) return byExtra;
}
}
return null;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* The subject with `label`'s prefix in front, once: every known label
* prefix in the leading chain of reply and forward prefixes is taken out
* first ("Re: [Confidential] x" → "[Strictly confidential] Re: x"), so a
* reply doesn't double it and a changed label replaces the old one.
*/
export function applyLabelPrefix(subject: string, label: ClassificationLabel | null, labels: ClassificationLabel[]): string {
const prefixes = [...new Set(labels.map((l) => l.subjectPrefix.trim()).filter(Boolean))].sort((a, b) => b.length - a.length);
let rest = subject.trimStart();
const kept: string[] = [];
const labelRes = prefixes.map((p) => new RegExp(`^${escapeRegExp(p)}(?:\\s*:)?(?:\\s+|$)`, 'i'));
for (let i = 0; i < 40; i++) {
const labelMatch = labelRes.map((re) => re.exec(rest)).find(Boolean);
if (labelMatch) {
rest = rest.slice(labelMatch[0].length).trimStart();
continue;
}
const stripped = stripSubjectPrefixes(rest);
if (stripped === rest) break;
// Keep the reply/forward prefix as written, one token at a time.
const token = rest.slice(0, rest.length - stripped.length);
const one = /^\s*[^::]+[::]\s*/.exec(token)?.[0] ?? token;
kept.push(one.trim());
rest = rest.slice(one.length).trimStart();
}
const chain = [...kept, rest].filter(Boolean).join(' ');
const prefix = label?.subjectPrefix.trim();
return prefix ? (chain ? `${prefix} ${chain}` : prefix) : chain;
}
+4 -2
View File
@@ -415,8 +415,10 @@ export interface IJMAPClient {
* resolveSendAccountId). `previousDraftAccountId`: where `draftId` lives,
* so the replaced version is destroyed there (default: the primary
* account). Draft ids are only unique within one account.
* `classification`: the label choice (an id, null for none) kept in
* X-Bulwark-Label.
*/
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string },
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null },
): Promise<string>;
/**
@@ -452,7 +454,7 @@ export interface IJMAPClient {
// `draftAccountId`: the account `draftId` lives in, so the sent draft is
// destroyed there (default: the primary account). `noSentCopy`: no copy
// stays in Sent (a phishing report).
options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; draftAccountId?: string; noSentCopy?: boolean },
options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; draftAccountId?: string; noSentCopy?: boolean; labelHeaders?: Record<string, string> },
): Promise<SendEmailResult>;
/** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */
supportsSubmissionExtension?(extension: string, accountId?: string): boolean;
+13 -3
View File
@@ -44,7 +44,8 @@ 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 { customHeaderProperties, withLabelHeaders } from "@/lib/outgoing/custom-headers";
import { draftLabelProperty } from "@/lib/classification/labels";
import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body";
import { addBodyMediaToAttachments } from "./body-media-attachments";
@@ -1431,6 +1432,12 @@ export interface SubmissionSendOptions {
* has gone (a phishing report, which the user doesn't keep).
*/
noSentCopy?: boolean;
/**
* Classification label headers (lib/outgoing/classification.ts) as
* Email/set create properties. Written after `headers`; a plugin header
* of the same name is dropped, so a plugin can't change the label.
*/
labelHeaders?: Record<string, string>;
}
/** The MAIL FROM / RCPT TO parameters the options translate to. */
@@ -4697,7 +4704,9 @@ export class JMAPClient implements IJMAPClient {
htmlBody?: string,
inReplyTo?: string[],
references?: string[],
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string },
// `classification`: the composer's label choice (an id, null for none),
// kept in X-Bulwark-Label so a re-opened draft restores it.
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null },
): Promise<string> {
// A draft for a group identity belongs in the group's Drafts, like its
// Sent copy (#1090).
@@ -4756,6 +4765,7 @@ export class JMAPClient implements IJMAPClient {
...(draftReferences?.length ? { references: draftReferences } : {}),
// A re-opened draft reads its priority back from these (#970).
...messagePriorityHeaders(options?.priority),
...draftLabelProperty(options?.classification),
};
// Images the HTML shows by cid: go into a multipart/related beside it
@@ -4959,7 +4969,7 @@ export class JMAPClient implements IJMAPClient {
}
Object.assign(emailCreate, messagePriorityHeaders(options?.priority));
Object.assign(emailCreate, customHeaderProperties(options?.headers));
Object.assign(emailCreate, withLabelHeaders(customHeaderProperties(options?.headers), options?.labelHeaders));
// Images the HTML shows by cid: go into a multipart/related beside it,
// or recipients don't see them (#1451).
@@ -0,0 +1,133 @@
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 { customHeaderProperties, withLabelHeaders } from '../custom-headers';
import {
automaticLabel,
beginLabelTakeover,
classifyOutgoing,
endLabelTakeover,
injectTakeoverLabel,
isLabelRequiredError,
labelSourceHeaders,
resetLabelTakeovers,
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.
const generic = LABEL_PRESETS[0];
const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
const required = { ...policy, defaultId: '', required: true };
const label = (id: string) => generic.list.find((l) => l.id === id)!;
afterEach(() => {
usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null });
resetLabelTakeovers();
});
describe('resolveLabel', () => {
it('takes the pick, then the original’s label, then the default', () => {
expect(resolveLabel(policy, 'public')?.id).toBe('public');
expect(resolveLabel(policy, undefined, { 'SIO-Label': ' marking="CONFIDENTIAL"' })?.id).toBe('confidential');
expect(resolveLabel(policy, undefined)?.id).toBe('internal');
expect(resolveLabel(policy, null, { 'SIO-Label': ' marking="CONFIDENTIAL"' })).toBeNull();
// A pick that isn't in this account's list counts as nothing picked.
expect(resolveLabel(policy, 'gone')?.id).toBe('internal');
});
});
describe('classifyOutgoing', () => {
it('fails closed when a label is required and there is none', () => {
let error: unknown;
try { classifyOutgoing(required, null, { subject: 'x' }); } catch (e) { error = e; }
expect(isLabelRequiredError(error)).toBe(true);
});
it('prefixes the subject and adds the headers', () => {
const out = classifyOutgoing(policy, label('confidential'), { subject: 'Re: Budget' });
expect(out.subject).toBe('[Confidential] Re: Budget');
expect(out.labelHeaders).toMatchObject({ 'header:Sensitivity:asText': 'Company-Confidential', 'header:X-Classification:asText': 'Confidential' });
});
it('keeps a plugin from setting or dropping the label', () => {
const out = classifyOutgoing(policy, label('internal'), {
subject: 'Budget',
headers: { 'SIO-Label': 'marking="PUBLIC"', Sensitivity: 'Personal', 'x-classification': 'Public', 'X-Mailer-Note': 'ok' },
});
expect(out.headers).toEqual({ 'X-Mailer-Note': 'ok' });
const props = withLabelHeaders(customHeaderProperties({ ...out.headers, 'SIO-Label': 'forged' }), out.labelHeaders);
expect(props['header:SIO-Label:asRaw']).toContain('INTERNAL');
expect(Object.keys(props).filter((k) => k.toLowerCase().startsWith('header:sio-label'))).toHaveLength(1);
expect(props['header:X-Mailer-Note:asText']).toBe('ok');
});
it('changes nothing while the organisation has no labels', () => {
const headers = { Sensitivity: 'Personal' };
expect(classifyOutgoing({ ...policy, list: [] }, null, { subject: 'x', headers })).toEqual({ subject: 'x', headers });
});
});
describe('the policy of the sending account', () => {
it('quick reply and forward take the original’s label, and report a missing required one', () => {
usePolicyStore.setState({
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: required } },
subject: { email: 'me@example.org', groups: [] },
});
expect(automaticLabel({ email: 'me@example.org' }, { 'SIO-Label': ' marking="PUBLIC"' })).toMatchObject({ missing: false, label: { id: 'public' } });
expect(automaticLabel({ email: 'me@example.org' }, {})).toMatchObject({ missing: true, label: null });
});
it('keeps only the headers a reply needs for its label', () => {
usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } } });
expect(labelSourceHeaders({ 'SIO-Label': 'x', 'X-Classification': 'y', Received: 'z', Subject: 's' })).toEqual({ 'SIO-Label': 'x', 'X-Classification': 'y' });
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"');
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* 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 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.
*/
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 { usePolicyStore } from '@/stores/policy-store';
import { generateUUID } from '@/lib/utils';
import { withoutLabelHeaders } from './custom-headers';
import {
applyLabelPrefix,
labelHeaderLines,
labelHeaderProperties,
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 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 (a reply or forward of `originalHeaders`), 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,
originalHeaders?: Record<string, string | string[]> | 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;
const original = originalHeaders ? receivedLabel(originalHeaders, policy) : null;
if (original) return original;
return policy.list.find((label) => label.id === policy.defaultId) ?? null;
}
/** 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, else the default. */
export function automaticLabel(
sender: LabelSender,
originalHeaders?: Record<string, string | string[]> | null,
): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } {
const policy = labelsPolicyFor(sender);
const label = resolveLabel(policy, undefined, originalHeaders);
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
* 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;
}
+32
View File
@@ -0,0 +1,32 @@
import type { MessagePriority } from '@/lib/message-priority';
/** What the composer's onSend hands its host beyond the message itself. */
export interface ComposerSendExtras {
requestReadReceipt?: boolean;
requestDsn?: boolean;
requireTls?: boolean;
priority?: MessagePriority;
headers?: Record<string, string>;
labelHeaders?: Record<string, string>;
localAccountId?: string;
draftAccountId?: string;
}
/**
* The options object of useEmailStore.sendEmail for a composer send. Every
* host of the composer (the mail page, a Pro compose tab) passes it whole,
* so none drops the read receipt, DSN, REQUIRETLS, priority, plugin headers
* or the classification label: the Pro tab used to send without them.
*/
export function composerSendOptions(data: ComposerSendExtras): ComposerSendExtras {
return {
requestReadReceipt: data.requestReadReceipt,
requestDsn: data.requestDsn,
requireTls: data.requireTls,
priority: data.priority,
headers: data.headers,
labelHeaders: data.labelHeaders,
localAccountId: data.localAccountId,
draftAccountId: data.draftAccountId,
};
}
+50
View File
@@ -65,3 +65,53 @@ export function customHeaderProperties(headers: unknown): Record<string, string>
}
return out;
}
/**
* The headers a classification label writes (lib/classification/labels.ts).
* While the organisation uses labels, plugins can't set them: the label
* stage drops them from the plugins' headers (withoutLabelHeaders), along
* with the admin's extra header, and withLabelHeaders below lets the
* label's own win over anything left.
*/
export const LABEL_HEADER_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label'] as const;
/** `headers` (OutgoingEmail.headers) without the label headers and `extra` names. */
export function withoutLabelHeaders(
headers: Record<string, string> | undefined,
extra: readonly string[] = [],
): Record<string, string> | undefined {
if (!headers) return headers;
const reserved = new Set<string>([...LABEL_HEADER_NAMES, ...extra.map((name) => name.toLowerCase())]);
const out: Record<string, string> = {};
for (const [name, value] of Object.entries(headers)) {
if (reserved.has(name.toLowerCase())) {
debug.warn(`Outgoing header "${name}" from a plugin was not sent: the classification label sets it`);
continue;
}
out[name] = value;
}
return out;
}
/** The name in a JMAP header property (`header:SIO-Label:asRaw` → `sio-label`). */
function propertyHeaderName(property: string): string {
return property.split(':')[1]?.toLowerCase() ?? '';
}
/**
* Plugin header properties with the label's added on top: a plugin header
* of a name the label writes is dropped, since JMAP refuses a create that
* sets one header twice and the label must win.
*/
export function withLabelHeaders(
custom: Record<string, string>,
label: Record<string, string> | undefined,
): Record<string, string> {
if (!label || Object.keys(label).length === 0) return custom;
const taken = new Set(Object.keys(label).map(propertyHeaderName));
const out: Record<string, string> = {};
for (const [property, value] of Object.entries(custom)) {
if (!taken.has(propertyHeaderName(property))) out[property] = value;
}
return { ...out, ...label };
}
+22 -4
View File
@@ -11,7 +11,9 @@
// quick reply (external recipients, the organisation's disclaimer) and
// other plugins' onBeforeEmailSend, and is held for the user's undo delay;
// - the copy lands in Sent and the original is marked $forwarded, as after
// a forward from the composer.
// a forward from the composer;
// - it carries the original's classification label, else the default; when
// the organisation requires one and there is neither, nothing is sent.
import type { EmailForwardResult, InstalledPlugin, OutgoingEmail } from '../plugin-types';
import type { Attachment, Email } from '../jmap/types';
@@ -24,6 +26,9 @@ import { emailHooks } from '../plugin-hooks';
import { announceSent } from '../plugin-after-send';
import { buildForwardSubject } from '../subject-prefix';
import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline';
import { automaticLabel, classifyOutgoing } from '../outgoing/classification';
import { whenPolicySettled } from '@/stores/policy-store';
import { toast } from '@/stores/toast-store';
// Registers the organisation signature check and the disclaimer.
import '@/lib/org/outgoing';
import { plainTextFromEmail } from '../plugin-projection';
@@ -139,6 +144,15 @@ export async function doEmailForward(
const messages = await hostMessages();
const subject = buildForwardSubject(email.subject || text(messages, 'email_viewer.no_subject'), text(messages, 'email_composer.prefix.forward'));
// The classification label: a required one that isn't there stops the
// forward before anything is asked.
await whenPolicySettled();
const label = automaticLabel({ email: identity.email }, email.headers);
if (label.missing) {
toast.error(text(messages, 'classification.forward_needs_label'));
return { sent: false, reason: 'stopped' };
}
// Ask before the first forward to an address.
const addresses = to.map((entry) => addressOf(entry).toLowerCase());
const account = approvalAccount();
@@ -198,12 +212,15 @@ export async function doEmailForward(
});
}
// Last, after the plugins' checks: the label's prefix and headers.
const labelled = classifyOutgoing(label.policy, label.label, { subject });
const delaySeconds = useSettingsStore.getState().sendDelaySeconds;
const delayedUntil = delaySeconds > 0 && client.hasDelayedSend()
? new Date(Date.now() + delaySeconds * 1000).toISOString()
: undefined;
const result = await useEmailStore.getState().sendEmail(
client, to, subject, body, undefined, undefined, identity.id, identity.email, undefined,
client, to, labelled.subject, body, undefined, undefined, identity.id, identity.email, undefined,
identity.name || undefined, undefined,
attachments.map((a, i) => ({
blobId: blobIds[i],
@@ -212,9 +229,10 @@ export async function doEmailForward(
size: a.size,
disposition: 'attachment' as const,
})),
undefined, undefined, delayedUntil,
undefined, undefined, delayedUntil, undefined,
labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined,
);
announceSent(result, { to: addresses, cc: [], bcc: [], subject });
announceSent(result, { to: addresses, cc: [], bcc: [], subject: labelled.subject });
try {
await deps.markForwarded(emailId, sourceAccountId);
+7 -2
View File
@@ -3,6 +3,7 @@
// structured-cloneable data back to the iframe.
import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types';
import { injectTakeoverLabel } from '@/lib/outgoing/classification';
import { fetchConfig } from '@/hooks/use-config';
import { IS_LITE } from '../lite';
import { doAppGetSettings, doAppSearchSettings } from './host-api-app';
@@ -822,7 +823,10 @@ async function doJmapSendRaw(
// ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
const copy = new Uint8Array(view.byteLength);
copy.set(view);
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
// 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);
const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
return useEmailStore.getState().sendRawEmail(
client,
blob,
@@ -856,7 +860,8 @@ async function doJmapSubmitRaw(
const copy = new Uint8Array(view.byteLength);
copy.set(view);
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
const labelled = injectTakeoverLabel(copy, identityId);
const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
return client.submitRawEmail(
blob,
+11
View File
@@ -900,6 +900,17 @@
"link_off": "الرابط معطّل",
"no_subject": "(بلا موضوع)"
},
"classification": {
"picker_label": "التصنيف الأمني",
"chip_label": "التصنيف الأمني: {name}",
"none": "بدون تصنيف أمني",
"pick_required": "اختر تصنيفًا أمنيًا",
"required_hint": "تشترط مؤسستك تصنيفًا أمنيًا.",
"required_toast": "اختر تصنيفًا أمنيًا قبل الإرسال.",
"quick_reply_needs_label": "يحتاج هذا الرد إلى تصنيف أمني. اختر واحدًا هنا، ثم أرسل.",
"forward_needs_label": "التصنيف الأمني مطلوب. أعد توجيه الرسالة من محرر الرسائل.",
"received_hint": "عيّنه برنامج البريد لدى المرسل. يمكن لأي شخص تعيين تصنيف أمني، لذا فهو لا يثبت شيئًا."
},
"email_composer": {
"select_from_directory": "الاختيار من الدليل",
"minimize": "تصغير",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "enllaç desactivat",
"no_subject": "(sense assumpte)"
},
"classification": {
"picker_label": "Classificació",
"chip_label": "Classificació: {name}",
"none": "Sense classificació",
"pick_required": "Trieu una classificació",
"required_hint": "La vostra organització exigeix una classificació.",
"required_toast": "Trieu una classificació abans d'enviar.",
"quick_reply_needs_label": "Aquesta resposta necessita una classificació. Trieu-ne una aquí i després envieu-la.",
"forward_needs_label": "Cal una classificació. Reenvieu el missatge des del redactor.",
"received_hint": "La defineix el programa de correu del remitent. Qualsevol pot definir una classificació, així que no demostra res."
},
"email_composer": {
"select_from_directory": "Selecciona del directori",
"minimize": "Minimitza",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "odkaz vypnut",
"no_subject": "(bez předmětu)"
},
"classification": {
"picker_label": "Klasifikace",
"chip_label": "Klasifikace: {name}",
"none": "Bez klasifikace",
"pick_required": "Vyberte klasifikaci",
"required_hint": "Vaše organizace vyžaduje klasifikaci.",
"required_toast": "Před odesláním vyberte klasifikaci.",
"quick_reply_needs_label": "Tato odpověď potřebuje klasifikaci. Vyberte ji zde a pak odešlete.",
"forward_needs_label": "Klasifikace je povinná. Přepošlete zprávu z editoru zpráv.",
"received_hint": "Nastavil ji poštovní program odesílatele. Klasifikaci může nastavit kdokoli, takže nic nedokazuje."
},
"email_composer": {
"select_from_directory": "Vybrat z adresáře",
"minimize": "Minimalizovat",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link slået fra",
"no_subject": "(intet emne)"
},
"classification": {
"picker_label": "Klassificering",
"chip_label": "Klassificering: {name}",
"none": "Ingen klassificering",
"pick_required": "Vælg en klassificering",
"required_hint": "Din organisation kræver en klassificering.",
"required_toast": "Vælg en klassificering, før du sender.",
"quick_reply_needs_label": "Dette svar skal have en klassificering. Vælg en her, og send derefter.",
"forward_needs_label": "En klassificering er påkrævet. Videresend beskeden fra skrivevinduet.",
"received_hint": "Sat af afsenderens mailprogram. Alle kan sætte en klassificering, så den beviser intet."
},
"email_composer": {
"select_from_directory": "Vælg fra adressefortegnelse",
"minimize": "Minimer",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "Link deaktiviert",
"no_subject": "(kein Betreff)"
},
"classification": {
"picker_label": "Einstufung",
"chip_label": "Einstufung: {name}",
"none": "Keine Einstufung",
"pick_required": "Einstufung wählen",
"required_hint": "Ihre Organisation verlangt eine Einstufung.",
"required_toast": "Wählen Sie vor dem Senden eine Einstufung.",
"quick_reply_needs_label": "Diese Antwort braucht eine Einstufung. Wählen Sie sie hier aus und senden Sie dann.",
"forward_needs_label": "Eine Einstufung ist vorgeschrieben. Leiten Sie die Nachricht im Editor weiter.",
"received_hint": "Vom E-Mail-Programm des Absenders gesetzt. Jeder kann eine Einstufung setzen, sie beweist also nichts."
},
"email_composer": {
"select_from_directory": "Aus Verzeichnis auswählen",
"minimize": "Minimieren",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link turned off",
"no_subject": "(no subject)"
},
"classification": {
"picker_label": "Classification",
"chip_label": "Classification: {name}",
"none": "No label",
"pick_required": "Pick a label",
"required_hint": "Your organisation requires a label.",
"required_toast": "Pick a classification label before sending.",
"quick_reply_needs_label": "This reply needs a classification label. Pick one here, then send.",
"forward_needs_label": "A classification label is required. Forward the message from the composer.",
"received_hint": "Set by the sender's mail program. Anyone can set a label, so it proves nothing."
},
"email_composer": {
"select_from_directory": "Select from directory",
"minimize": "Minimize",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "enlace desactivado",
"no_subject": "(sin asunto)"
},
"classification": {
"picker_label": "Clasificación",
"chip_label": "Clasificación: {name}",
"none": "Sin clasificación",
"pick_required": "Elige una clasificación",
"required_hint": "Tu organización exige una clasificación.",
"required_toast": "Elige una clasificación antes de enviar.",
"quick_reply_needs_label": "Esta respuesta necesita una clasificación. Elige una aquí y luego envía.",
"forward_needs_label": "Se requiere una clasificación. Reenvía el mensaje desde el redactor.",
"received_hint": "La pone el programa de correo del remitente. Cualquiera puede poner una clasificación, así que no demuestra nada."
},
"email_composer": {
"select_from_directory": "Seleccionar del directorio",
"minimize": "Minimizar",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link välja lülitatud",
"no_subject": "(teema puudub)"
},
"classification": {
"picker_label": "Klassifikatsioon",
"chip_label": "Klassifikatsioon: {name}",
"none": "Klassifikatsioonita",
"pick_required": "Vali klassifikatsioon",
"required_hint": "Sinu organisatsioon nõuab klassifikatsiooni.",
"required_toast": "Vali enne saatmist klassifikatsioon.",
"quick_reply_needs_label": "See vastus vajab klassifikatsiooni. Vali see siin ja saada siis.",
"forward_needs_label": "Klassifikatsioon on nõutud. Saada kiri edasi kirjakoostajast.",
"received_hint": "Selle määras saatja meiliprogramm. Klassifikatsiooni võib määrata igaüks, seega ei tõesta see midagi."
},
"email_composer": {
"select_from_directory": "Vali kataloogist",
"minimize": "Minimeeri",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "پیوند غیرفعال شد",
"no_subject": "(بدون موضوع)"
},
"classification": {
"picker_label": "طبقه‌بندی",
"chip_label": "طبقه‌بندی: {name}",
"none": "بدون طبقه‌بندی",
"pick_required": "انتخاب طبقه‌بندی",
"required_hint": "سازمان شما طبقه‌بندی را الزامی کرده است.",
"required_toast": "پیش از ارسال یک طبقه‌بندی انتخاب کنید.",
"quick_reply_needs_label": "این پاسخ به طبقه‌بندی نیاز دارد. همین‌جا یکی انتخاب کنید و سپس ارسال کنید.",
"forward_needs_label": "طبقه‌بندی الزامی است. پیام را از ویرایشگر ارسال مجدد کنید.",
"received_hint": "برنامه ایمیل فرستنده آن را تعیین کرده است. هر کسی می‌تواند طبقه‌بندی تعیین کند، پس چیزی را ثابت نمی‌کند."
},
"email_composer": {
"select_from_directory": "انتخاب از فهرست",
"minimize": "کوچک کردن",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "lien désactivé",
"no_subject": "(sans objet)"
},
"classification": {
"picker_label": "Classification",
"chip_label": "Classification : {name}",
"none": "Aucune classification",
"pick_required": "Choisir une classification",
"required_hint": "Votre organisation exige une classification.",
"required_toast": "Choisissez une classification avant d'envoyer.",
"quick_reply_needs_label": "Cette réponse nécessite une classification. Choisissez-en une ici, puis envoyez.",
"forward_needs_label": "Une classification est obligatoire. Transférez le message depuis l'éditeur.",
"received_hint": "Définie par le programme de messagerie de l'expéditeur. N'importe qui peut définir une classification, elle ne prouve donc rien."
},
"email_composer": {
"select_from_directory": "Choisir dans l'annuaire",
"minimize": "Réduire",
+11
View File
@@ -865,6 +865,17 @@
"link_off": "הקישור מושבת",
"no_subject": "(ללא נושא)"
},
"classification": {
"picker_label": "סיווג",
"chip_label": "סיווג: {name}",
"none": "ללא סיווג",
"pick_required": "בחירת סיווג",
"required_hint": "הארגון שלך מחייב סיווג.",
"required_toast": "יש לבחור סיווג לפני השליחה.",
"quick_reply_needs_label": "התשובה הזו צריכה סיווג. יש לבחור סיווג כאן ואז לשלוח.",
"forward_needs_label": "נדרש סיווג. יש להעביר את ההודעה מהעורך.",
"received_hint": "נקבע על ידי תוכנת הדואר של השולח. כל אחד יכול לקבוע סיווג, ולכן הוא לא מוכיח דבר."
},
"email_composer": {
"select_from_directory": "בחירה מהספרייה",
"minimize": "מזעור",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link kikapcsolva",
"no_subject": "(nincs tárgy)"
},
"classification": {
"picker_label": "Besorolás",
"chip_label": "Besorolás: {name}",
"none": "Nincs besorolás",
"pick_required": "Válassz besorolást",
"required_hint": "A szervezeted besorolást ír elő.",
"required_toast": "Küldés előtt válassz besorolást.",
"quick_reply_needs_label": "Ehhez a válaszhoz besorolás kell. Itt válassz egyet, aztán küldd el.",
"forward_needs_label": "A besorolás kötelező. Az üzenetet a szerkesztőből továbbítsd.",
"received_hint": "A feladó levelezőprogramja állította be. Bárki megadhat besorolást, így semmit sem bizonyít."
},
"email_composer": {
"select_from_directory": "Kiválasztás a címtárból",
"minimize": "Kis méret",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link disattivato",
"no_subject": "(nessun oggetto)"
},
"classification": {
"picker_label": "Classificazione",
"chip_label": "Classificazione: {name}",
"none": "Nessuna classificazione",
"pick_required": "Scegli una classificazione",
"required_hint": "La tua organizzazione richiede una classificazione.",
"required_toast": "Scegli una classificazione prima di inviare.",
"quick_reply_needs_label": "Questa risposta richiede una classificazione. Sceglila qui, poi invia.",
"forward_needs_label": "È richiesta una classificazione. Inoltra il messaggio dall'editor.",
"received_hint": "Impostata dal programma di posta del mittente. Chiunque può impostare una classificazione, quindi non prova nulla."
},
"email_composer": {
"select_from_directory": "Seleziona dalla rubrica aziendale",
"minimize": "Riduci a icona",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "リンクは無効です",
"no_subject": "(件名なし)"
},
"classification": {
"picker_label": "機密区分",
"chip_label": "機密区分:{name}",
"none": "機密区分なし",
"pick_required": "機密区分を選択",
"required_hint": "組織では機密区分の指定が必要です。",
"required_toast": "送信する前に機密区分を選択してください。",
"quick_reply_needs_label": "この返信には機密区分が必要です。ここで選択してから送信してください。",
"forward_needs_label": "機密区分の指定が必要です。メール作成画面から転送してください。",
"received_hint": "送信者のメールプログラムが設定したものです。機密区分は誰でも設定できるため、何の証明にもなりません。"
},
"email_composer": {
"select_from_directory": "ディレクトリから選択",
"minimize": "最小化",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "링크 꺼짐",
"no_subject": "(제목 없음)"
},
"classification": {
"picker_label": "보안 등급",
"chip_label": "보안 등급: {name}",
"none": "보안 등급 없음",
"pick_required": "보안 등급 선택",
"required_hint": "조직에서 보안 등급을 지정하도록 요구해요.",
"required_toast": "보내기 전에 보안 등급을 선택하세요.",
"quick_reply_needs_label": "이 답장에는 보안 등급이 필요해요. 여기서 선택한 다음 보내세요.",
"forward_needs_label": "보안 등급이 필요해요. 메일 쓰기 창에서 메시지를 전달하세요.",
"received_hint": "보낸 사람의 메일 프로그램이 지정한 값이에요. 누구나 보안 등급을 지정할 수 있어서 아무것도 증명하지 않아요."
},
"email_composer": {
"select_from_directory": "디렉터리에서 선택",
"minimize": "최소화",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "saite izslēgta",
"no_subject": "(bez temata)"
},
"classification": {
"picker_label": "Klasifikācija",
"chip_label": "Klasifikācija: {name}",
"none": "Bez klasifikācijas",
"pick_required": "Izvēlieties klasifikāciju",
"required_hint": "Jūsu organizācija pieprasa klasifikāciju.",
"required_toast": "Pirms sūtīšanas izvēlieties klasifikāciju.",
"quick_reply_needs_label": "Šai atbildei vajadzīga klasifikācija. Izvēlieties to šeit un tad sūtiet.",
"forward_needs_label": "Klasifikācija ir obligāta. Pārsūtiet vēstuli no redaktora.",
"received_hint": "To iestatīja sūtītāja pasta programma. Klasifikāciju var iestatīt ikviens, tāpēc tā neko nepierāda."
},
"email_composer": {
"select_from_directory": "Izvēlēties no kataloga",
"minimize": "Minimizēt",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "холбоосыг идэвхгүй болгосон",
"no_subject": "(сэдэв байхгүй)"
},
"classification": {
"picker_label": "Нууцын зэрэглэл",
"chip_label": "Нууцын зэрэглэл: {name}",
"none": "Зэрэглэлгүй",
"pick_required": "Зэрэглэл сонгох",
"required_hint": "Таны байгууллага нууцын зэрэглэл шаарддаг.",
"required_toast": "Илгээхээсээ өмнө нууцын зэрэглэл сонгоно уу.",
"quick_reply_needs_label": "Энэ хариунд нууцын зэрэглэл шаардлагатай. Энд сонгоод илгээнэ үү.",
"forward_needs_label": "Нууцын зэрэглэл заавал шаардлагатай. Захидлыг бичих цонхноос дамжуулна уу.",
"received_hint": "Илгээгчийн шуудангийн програм тохируулсан. Хэн ч зэрэглэл тохируулж болох тул энэ юуг ч нотлохгүй."
},
"email_composer": {
"select_from_directory": "Лавлахаас сонгох",
"minimize": "Багасгах",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "lenke slått av",
"no_subject": "(uten emne)"
},
"classification": {
"picker_label": "Klassifisering",
"chip_label": "Klassifisering: {name}",
"none": "Ingen klassifisering",
"pick_required": "Velg en klassifisering",
"required_hint": "Organisasjonen din krever en klassifisering.",
"required_toast": "Velg en klassifisering før du sender.",
"quick_reply_needs_label": "Dette svaret trenger en klassifisering. Velg en her, og send deretter.",
"forward_needs_label": "En klassifisering er påkrevd. Videresend meldingen fra meldingsvinduet.",
"received_hint": "Satt av avsenderens e-postprogram. Hvem som helst kan sette en klassifisering, så den beviser ingenting."
},
"email_composer": {
"select_from_directory": "Velg fra katalog",
"minimize": "Minimer",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link uitgeschakeld",
"no_subject": "(geen onderwerp)"
},
"classification": {
"picker_label": "Classificatie",
"chip_label": "Classificatie: {name}",
"none": "Geen classificatie",
"pick_required": "Kies een classificatie",
"required_hint": "Je organisatie vereist een classificatie.",
"required_toast": "Kies een classificatie voordat je verzendt.",
"quick_reply_needs_label": "Dit antwoord heeft een classificatie nodig. Kies er hier een en verzend het dan.",
"forward_needs_label": "Een classificatie is verplicht. Stuur het bericht door vanuit de opsteller.",
"received_hint": "Ingesteld door het e-mailprogramma van de afzender. Iedereen kan een classificatie instellen, dus het bewijst niets."
},
"email_composer": {
"select_from_directory": "Kiezen uit adresboek van de organisatie",
"minimize": "Minimaliseren",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link wyłączony",
"no_subject": "(brak tematu)"
},
"classification": {
"picker_label": "Klasyfikacja",
"chip_label": "Klasyfikacja: {name}",
"none": "Bez klasyfikacji",
"pick_required": "Wybierz klasyfikację",
"required_hint": "Twoja organizacja wymaga klasyfikacji.",
"required_toast": "Przed wysłaniem wybierz klasyfikację.",
"quick_reply_needs_label": "Ta odpowiedź wymaga klasyfikacji. Wybierz ją tutaj, a potem wyślij.",
"forward_needs_label": "Klasyfikacja jest wymagana. Przekaż wiadomość dalej z edytora wiadomości.",
"received_hint": "Ustawiona przez program pocztowy nadawcy. Każdy może ustawić klasyfikację, więc niczego nie dowodzi."
},
"email_composer": {
"select_from_directory": "Wybierz z katalogu",
"minimize": "Minimalizuj",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link desativado",
"no_subject": "(sem assunto)"
},
"classification": {
"picker_label": "Classificação",
"chip_label": "Classificação: {name}",
"none": "Sem classificação",
"pick_required": "Escolha uma classificação",
"required_hint": "Sua organização exige uma classificação.",
"required_toast": "Escolha uma classificação antes de enviar.",
"quick_reply_needs_label": "Esta resposta precisa de uma classificação. Escolha uma aqui e depois envie.",
"forward_needs_label": "É obrigatória uma classificação. Encaminhe a mensagem pelo editor.",
"received_hint": "Definida pelo programa de e-mail do remetente. Qualquer pessoa pode definir uma classificação, então ela não prova nada."
},
"email_composer": {
"select_from_directory": "Selecionar do diretório",
"minimize": "Minimizar",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "link dezactivat",
"no_subject": "(fără subiect)"
},
"classification": {
"picker_label": "Clasificare",
"chip_label": "Clasificare: {name}",
"none": "Fără clasificare",
"pick_required": "Alegeți o clasificare",
"required_hint": "Organizația dvs. cere o clasificare.",
"required_toast": "Alegeți o clasificare înainte de trimitere.",
"quick_reply_needs_label": "Acest răspuns are nevoie de o clasificare. Alegeți una aici, apoi trimiteți.",
"forward_needs_label": "Este necesară o clasificare. Redirecționați mesajul din fereastra de redactare.",
"received_hint": "Setată de programul de e-mail al expeditorului. Oricine poate seta o clasificare, deci nu dovedește nimic."
},
"email_composer": {
"select_from_directory": "Selectează din director",
"minimize": "Minimizează",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "ссылка отключена",
"no_subject": "(без темы)"
},
"classification": {
"picker_label": "Классификация",
"chip_label": "Классификация: {name}",
"none": "Без классификации",
"pick_required": "Выберите классификацию",
"required_hint": "Ваша организация требует указывать классификацию.",
"required_toast": "Выберите классификацию перед отправкой.",
"quick_reply_needs_label": "Для этого ответа нужна классификация. Выберите её здесь, затем отправьте.",
"forward_needs_label": "Классификация обязательна. Перешлите письмо из редактора.",
"received_hint": "Указана почтовой программой отправителя. Классификацию может указать кто угодно, поэтому она ничего не доказывает."
},
"email_composer": {
"select_from_directory": "Выбрать из справочника",
"minimize": "Свернуть",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "odkaz vypnutý",
"no_subject": "(bez predmetu)"
},
"classification": {
"picker_label": "Klasifikácia",
"chip_label": "Klasifikácia: {name}",
"none": "Bez klasifikácie",
"pick_required": "Vyberte klasifikáciu",
"required_hint": "Vaša organizácia vyžaduje klasifikáciu.",
"required_toast": "Pred odoslaním vyberte klasifikáciu.",
"quick_reply_needs_label": "Táto odpoveď potrebuje klasifikáciu. Vyberte ju tu a potom odošlite.",
"forward_needs_label": "Klasifikácia je povinná. Prepošlite správu z editora správ.",
"received_hint": "Nastavil ju poštový program odosielateľa. Klasifikáciu môže nastaviť ktokoľvek, takže nič nedokazuje."
},
"email_composer": {
"select_from_directory": "Vybrať z adresára",
"minimize": "Minimalizovať",
+11
View File
@@ -1837,6 +1837,17 @@
"link_off": "ปิดใช้งานลิงก์แล้ว",
"no_subject": "(ไม่มีเรื่อง)"
},
"classification": {
"picker_label": "ระดับความลับ",
"chip_label": "ระดับความลับ: {name}",
"none": "ไม่มีระดับความลับ",
"pick_required": "เลือกระดับความลับ",
"required_hint": "องค์กรของคุณกำหนดให้ต้องระบุระดับความลับ",
"required_toast": "เลือกระดับความลับก่อนส่ง",
"quick_reply_needs_label": "การตอบกลับนี้ต้องมีระดับความลับ เลือกที่นี่ แล้วกดส่ง",
"forward_needs_label": "ต้องระบุระดับความลับ ส่งต่อข้อความจากหน้าต่างเขียนจดหมาย",
"received_hint": "ตั้งโดยโปรแกรมอีเมลของผู้ส่ง ใครก็ตั้งระดับความลับได้ จึงพิสูจน์อะไรไม่ได้"
},
"welcome": {
"title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ",
"tip_compose": "กด c เพื่อเขียนอีเมลใหม่",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "bağlantı devre dışı",
"no_subject": "(konu yok)"
},
"classification": {
"picker_label": "Sınıflandırma",
"chip_label": "Sınıflandırma: {name}",
"none": "Sınıflandırma yok",
"pick_required": "Sınıflandırma seçin",
"required_hint": "Kuruluşunuz bir sınıflandırma gerektiriyor.",
"required_toast": "Göndermeden önce bir sınıflandırma seçin.",
"quick_reply_needs_label": "Bu yanıt için sınıflandırma gerekiyor. Burada bir tane seçin, sonra gönderin.",
"forward_needs_label": "Sınıflandırma zorunlu. İletiyi ileti düzenleyiciden iletin.",
"received_hint": "Gönderenin posta programı tarafından ayarlandı. Herkes sınıflandırma ayarlayabilir, bu yüzden hiçbir şeyi kanıtlamaz."
},
"email_composer": {
"select_from_directory": "Dizinden seç",
"minimize": "Simge durumuna küçült",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "посилання вимкнено",
"no_subject": "(без теми)"
},
"classification": {
"picker_label": "Класифікація",
"chip_label": "Класифікація: {name}",
"none": "Без класифікації",
"pick_required": "Виберіть класифікацію",
"required_hint": "Ваша організація вимагає вказувати класифікацію.",
"required_toast": "Виберіть класифікацію перед надсиланням.",
"quick_reply_needs_label": "Для цієї відповіді потрібна класифікація. Виберіть її тут, а потім надішліть.",
"forward_needs_label": "Класифікація обов'язкова. Перешліть лист із редактора.",
"received_hint": "Вказано поштовою програмою відправника. Класифікацію може вказати будь-хто, тому вона нічого не доводить."
},
"email_composer": {
"select_from_directory": "Вибрати з довідника",
"minimize": "Згорнути",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "đã tắt liên kết",
"no_subject": "(không có tiêu đề)"
},
"classification": {
"picker_label": "Phân loại bảo mật",
"chip_label": "Phân loại bảo mật: {name}",
"none": "Chưa chọn phân loại",
"pick_required": "Chọn phân loại",
"required_hint": "Tổ chức của bạn yêu cầu chọn phân loại bảo mật.",
"required_toast": "Hãy chọn phân loại bảo mật trước khi gửi.",
"quick_reply_needs_label": "Thư trả lời này cần phân loại bảo mật. Hãy chọn ở đây rồi gửi.",
"forward_needs_label": "Cần có phân loại bảo mật. Hãy chuyển tiếp thư từ khung soạn thư.",
"received_hint": "Do chương trình thư của người gửi đặt. Ai cũng có thể đặt phân loại, nên nó không chứng minh được gì."
},
"email_composer": {
"select_from_directory": "Chọn từ danh bạ",
"minimize": "Thu nhỏ",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "連結已停用",
"no_subject": "(無主題)"
},
"classification": {
"picker_label": "機密等級",
"chip_label": "機密等級:{name}",
"none": "未設定機密等級",
"pick_required": "選擇機密等級",
"required_hint": "您的組織要求設定機密等級。",
"required_toast": "傳送前請選擇機密等級。",
"quick_reply_needs_label": "此回覆需要設定機密等級。請在這裡選擇,然後傳送。",
"forward_needs_label": "必須設定機密等級。請從撰寫視窗轉寄此郵件。",
"received_hint": "由寄件者的郵件程式設定。任何人都能設定機密等級,因此無法證明任何事。"
},
"email_composer": {
"select_from_directory": "從目錄中選取",
"minimize": "最小化",
+11
View File
@@ -900,6 +900,17 @@
"link_off": "链接已停用",
"no_subject": "(无主题)"
},
"classification": {
"picker_label": "密级",
"chip_label": "密级:{name}",
"none": "未设置密级",
"pick_required": "选择密级",
"required_hint": "您的组织要求设置密级。",
"required_toast": "发送前请选择密级。",
"quick_reply_needs_label": "此回复需要设置密级。请在这里选择,然后发送。",
"forward_needs_label": "必须设置密级。请从撰写窗口转发此邮件。",
"received_hint": "由发件人的邮件程序设置。任何人都可以设置密级,因此它不能证明任何事情。"
},
"email_composer": {
"select_from_directory": "从目录中选择",
"minimize": "最小化",
+1 -1
View File
@@ -444,7 +444,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; headers?: Record<string, string>; 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>; labelHeaders?: 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>;
+2
View File
@@ -51,6 +51,8 @@ export interface ProReplyContext {
quoteHeaderHtml?: string;
quoteHeaderText?: string;
quoteWrapInBlockquote?: boolean;
/** The original's label headers (lib/outgoing/classification.ts labelSourceHeaders). */
headers?: Record<string, string | string[]>;
}
export interface ProComposeTabData {