From 1c7f81bcc66fd6f89235d2fed983adae4f3ce415 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:33:12 +0100 Subject: [PATCH] feat: pick a classification label in the composer and stamp it on sent mail --- .../composer-classification.test.tsx | 231 +++++++++++++ .../email/classification-label-picker.tsx | 172 ++++++++++ components/email/email-composer.tsx | 119 ++++++- .../email/use-composer-classification.ts | 55 ++++ components/mail/mail-app.tsx | 33 +- .../cross-account-send-routing.test.tsx | 32 ++ components/pro/pro-compose-tab-body.tsx | 6 +- components/pro/pro-email-tab-body.tsx | 5 + lib/__tests__/classification-labels.test.ts | 130 ++++++++ lib/__tests__/jmap-label-headers.test.ts | 72 +++++ lib/__tests__/plugin-host-labels.test.ts | 127 ++++++++ lib/classification/labels.ts | 304 ++++++++++++++++++ lib/jmap/client-interface.ts | 6 +- lib/jmap/client.ts | 16 +- lib/outgoing/__tests__/classification.test.ts | 133 ++++++++ lib/outgoing/classification.ts | 241 ++++++++++++++ lib/outgoing/composer-send-options.ts | 32 ++ lib/outgoing/custom-headers.ts | 50 +++ lib/plugin-sandbox/host-api-forward.ts | 26 +- lib/plugin-sandbox/host-api.ts | 9 +- locales/ar/common.json | 11 + locales/ca/common.json | 11 + locales/cs/common.json | 11 + locales/da/common.json | 11 + locales/de/common.json | 11 + locales/en/common.json | 11 + locales/es/common.json | 11 + locales/et/common.json | 11 + locales/fa/common.json | 11 + locales/fr/common.json | 11 + locales/he/common.json | 11 + locales/hu/common.json | 11 + locales/it/common.json | 11 + locales/ja/common.json | 11 + locales/ko/common.json | 11 + locales/lv/common.json | 11 + locales/mn/common.json | 11 + locales/nb/common.json | 11 + locales/nl/common.json | 11 + locales/pl/common.json | 11 + locales/pt/common.json | 11 + locales/ro/common.json | 11 + locales/ru/common.json | 11 + locales/sk/common.json | 11 + locales/th/common.json | 11 + locales/tr/common.json | 11 + locales/uk/common.json | 11 + locales/vi/common.json | 11 + locales/zh-TW/common.json | 11 + locales/zh/common.json | 11 + stores/email-store.ts | 2 +- stores/pro-tab-store.ts | 2 + 52 files changed, 2110 insertions(+), 23 deletions(-) create mode 100644 components/email/__tests__/composer-classification.test.tsx create mode 100644 components/email/classification-label-picker.tsx create mode 100644 components/email/use-composer-classification.ts create mode 100644 lib/__tests__/classification-labels.test.ts create mode 100644 lib/__tests__/jmap-label-headers.test.ts create mode 100644 lib/__tests__/plugin-host-labels.test.ts create mode 100644 lib/classification/labels.ts create mode 100644 lib/outgoing/__tests__/classification.test.ts create mode 100644 lib/outgoing/classification.ts create mode 100644 lib/outgoing/composer-send-options.ts diff --git a/components/email/__tests__/composer-classification.test.tsx b/components/email/__tests__/composer-classification.test.tsx new file mode 100644 index 000000000..528cbbbf3 --- /dev/null +++ b/components/email/__tests__/composer-classification.test.tsx @@ -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(state: T) { + const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + hook.setState = (p: Partial) => 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) { + 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: '

Numbers

', + showCc: false, showBcc: false, selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null, +}; + +type SendData = { subject: string; headers?: Record; labelHeaders?: Record }; + +function renderComposer(props: Partial> = {}) { + const onSend = vi.fn<(data: SendData) => void>(); + render(); + 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(); + }); +}); diff --git a/components/email/classification-label-picker.tsx b/components/email/classification-label-picker.tsx new file mode 100644 index 000000000..7a3c2ab86 --- /dev/null +++ b/components/email/classification-label-picker.tsx @@ -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(function ClassificationLabelPicker( + { labels, selected, required, invalid, onPick, className }, + ref, +) { + const t = useTranslations('classification'); + const id = useId(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const groupRef = useRef(null); + const options: Array = 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('[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) => { + e.stopPropagation(); + const radios = [...(groupRef.current?.querySelectorAll('[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 ( +
+ + {open && ( +
+ {required &&

{t('required_hint')}

} +
+ {t('picker_label')} + {options.map((option, index) => { + const checked = (option?.id ?? null) === (selected?.id ?? null); + return ( +
{ 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" + > +
+ ); + })} +
+
+ )} +
+ ); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 97555db82..bcae27e81 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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; + /** The classification label's headers (lib/outgoing/classification.ts), written after `headers`. */ + labelHeaders?: Record; /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; }) => void | false | Promise; @@ -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; }; } @@ -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(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>; + 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 */} -
+
+ {classification.policy.list.length > 0 && ( + // A chip at the end of the subject row; on a phone on its own line under it. + + )}
@@ -4885,6 +4972,24 @@ export function EmailComposer({ ))} + {classification.policy.list.length > 0 && ( + <> +
+
+ + {[...(classification.policy.required ? [] : [null]), ...classification.policy.list].map((option) => ( + classification.setChoice(option?.id ?? null)} + testId={`composer-options-label-${option?.id ?? 'none'}`} + > + {option?.name ?? tClassification('none')} + + ))} +
+ + )} } diff --git a/components/email/use-composer-classification.ts b/components/email/use-composer-classification.ts new file mode 100644 index 000000000..82b9522f3 --- /dev/null +++ b/components/email/use-composer-classification.ts @@ -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 | 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(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), + }; +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 3718674e6..bc4c7e8c9 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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; + labelHeaders?: Record; 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, diff --git a/components/pro/__tests__/cross-account-send-routing.test.tsx b/components/pro/__tests__/cross-account-send-routing.test.tsx index 90dc2ad4c..abe6edab4 100644 --- a/components/pro/__tests__/cross-account-send-routing.test.tsx +++ b/components/pro/__tests__/cross-account-send-routing.test.tsx @@ -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(); + + 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"' }, + }); + }); +}); diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a3f1c72ce..a49e0f2df 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -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) { diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index d5a6bea15..5f5f34344 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -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]); diff --git a/lib/__tests__/classification-labels.test.ts b/lib/__tests__/classification-labels.test.ts new file mode 100644 index 000000000..ffd41572c --- /dev/null +++ b/lib/__tests__/classification-labels.test.ts @@ -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(); + }); +}); diff --git a/lib/__tests__/jmap-label-headers.test.ts b/lib/__tests__/jmap-label-headers.test.ts new file mode 100644 index 000000000..ef44b4d95 --- /dev/null +++ b/lib/__tests__/jmap-label-headers.test.ts @@ -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]; + +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 { + 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>)[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'); + }); +}); diff --git a/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts new file mode 100644 index 000000000..f643ea0ed --- /dev/null +++ b/lib/__tests__/plugin-host-labels.test.ts @@ -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) => ({ + 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) { + 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>; + let sendEmail: ReturnType; + let sendRawEmail: ReturnType; + let headers: Record = {}; + + 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 }).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"'); + }); +}); diff --git a/lib/classification/labels.ts b/lib/classification/labels.ts new file mode 100644 index 000000000..85a34d03a --- /dev/null +++ b/lib/classification/labels.ts @@ -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): 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): 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 { + const out: Record = {}; + 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 { + if (choice === undefined) return {}; + return { [`header:${DRAFT_LABEL_HEADER}:asText`]: choice ?? NO_LABEL }; +} + +type HeaderRecord = Record | 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(); + 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; +} diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 3cd909678..9953bb041 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -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; /** @@ -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; draftAccountId?: string; noSentCopy?: boolean }, + options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; draftAccountId?: string; noSentCopy?: boolean; labelHeaders?: Record }, ): Promise; /** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */ supportsSubmissionExtension?(extension: string, accountId?: string): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 116bb822f..163bcfa47 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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; } /** 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 { // 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). diff --git a/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts new file mode 100644 index 000000000..2762b7863 --- /dev/null +++ b/lib/outgoing/__tests__/classification.test.ts @@ -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"'); + }); +}); diff --git a/lib/outgoing/classification.ts b/lib/outgoing/classification.ts new file mode 100644 index 000000000..5f5d93756 --- /dev/null +++ b/lib/outgoing/classification.ts @@ -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 | 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; + /** The label's headers as JMAP create properties (SubmissionSendOptions.labelHeaders). */ + labelHeaders?: Record; +} + +/** + * 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 }, +): 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 | 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(); + +/** + * 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 | null | undefined): Record | 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 = {}; + for (const [name, value] of Object.entries(headers)) if (names.has(name.toLowerCase())) out[name] = value; + return Object.keys(out).length > 0 ? out : undefined; +} diff --git a/lib/outgoing/composer-send-options.ts b/lib/outgoing/composer-send-options.ts new file mode 100644 index 000000000..61245e74e --- /dev/null +++ b/lib/outgoing/composer-send-options.ts @@ -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; + labelHeaders?: Record; + 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, + }; +} diff --git a/lib/outgoing/custom-headers.ts b/lib/outgoing/custom-headers.ts index 0058d8aee..c1a4ca5d8 100644 --- a/lib/outgoing/custom-headers.ts +++ b/lib/outgoing/custom-headers.ts @@ -65,3 +65,53 @@ export function customHeaderProperties(headers: unknown): Record } 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 | undefined, + extra: readonly string[] = [], +): Record | undefined { + if (!headers) return headers; + const reserved = new Set([...LABEL_HEADER_NAMES, ...extra.map((name) => name.toLowerCase())]); + const out: Record = {}; + 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, + label: Record | undefined, +): Record { + if (!label || Object.keys(label).length === 0) return custom; + const taken = new Set(Object.keys(label).map(propertyHeaderName)); + const out: Record = {}; + for (const [property, value] of Object.entries(custom)) { + if (!taken.has(propertyHeaderName(property))) out[property] = value; + } + return { ...out, ...label }; +} diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts index 09d2cbb53..37e828f43 100644 --- a/lib/plugin-sandbox/host-api-forward.ts +++ b/lib/plugin-sandbox/host-api-forward.ts @@ -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); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 70b4551e1..415c20679 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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, diff --git a/locales/ar/common.json b/locales/ar/common.json index 27d6eec20..0c9257610 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "تصغير", diff --git a/locales/ca/common.json b/locales/ca/common.json index 2919c2a14..ca972e093 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5179ab878..be09f8751 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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", diff --git a/locales/da/common.json b/locales/da/common.json index 890c491e0..36cee6e0e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 607034982..28bcb9b39 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index 8601b2290..2a5cf213b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index c560da5ae..b0e78b62d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/et/common.json b/locales/et/common.json index 98c71938d..79616ddfc 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index d7355b1bd..bcc3d9a61 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "کوچک کردن", diff --git a/locales/fr/common.json b/locales/fr/common.json index 826061ff3..cd26905bf 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index ffe99a9c6..05248cb69 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "מזעור", diff --git a/locales/hu/common.json b/locales/hu/common.json index 58b5dd782..0a750cc2a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index 74f69d9a6..fe8ad65c9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 1cee43a57..a2e8be841 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "最小化", diff --git a/locales/ko/common.json b/locales/ko/common.json index 18b20ec95..0b55f5ccc 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "최소화", diff --git a/locales/lv/common.json b/locales/lv/common.json index 884078152..138a7a38e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index 372387f40..db5b2d2c9 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Багасгах", diff --git a/locales/nb/common.json b/locales/nb/common.json index cfbc36690..969527d93 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index da4c14853..1683d6ed0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index dcd72b975..10cfd0bf2 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index ab73e5552..0b3535142 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 4ed1e81aa..3a47dac2f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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ă", diff --git a/locales/ru/common.json b/locales/ru/common.json index 709312e93..4681b7d08 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "Свернуть", diff --git a/locales/sk/common.json b/locales/sk/common.json index cf962cd03..657982b41 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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ť", diff --git a/locales/th/common.json b/locales/th/common.json index 22d5206a0..033f2873a 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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 เพื่อเขียนอีเมลใหม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index e26a9939a..ec6f63919 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index 071819051..ebfafa9e6 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Згорнути", diff --git a/locales/vi/common.json b/locales/vi/common.json index ee8647a84..6e78956a7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -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ỏ", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b8db29def..5bc86feff 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "最小化", diff --git a/locales/zh/common.json b/locales/zh/common.json index f54a372c2..85a21ed2f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "最小化", diff --git a/stores/email-store.ts b/stores/email-store.ts index ed6876722..03db84e8e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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; fetchQuota: (client: IJMAPClient) => Promise; - 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; localAccountId?: string; draftAccountId?: string }) => Promise; + 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; labelHeaders?: Record; localAccountId?: string; draftAccountId?: string }) => Promise; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise; /** `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; diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index d5d08a364..4283af9be 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -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; } export interface ProComposeTabData {