diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 7f3703728..7a3c9e3e2 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -11,6 +11,7 @@ import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; +import { PhishingReportHost } from "@/components/email/phishing-report-host"; import { PluginPageNavigator } from "@/components/plugins/plugin-page-navigator"; import { PaneCycleHost } from "@/components/layout/pane-cycle"; import { FocusRescueHost } from "@/components/layout/focus-rescue"; @@ -81,6 +82,7 @@ export default async function LocaleLayout({ {children} + diff --git a/components/admin/org/__tests__/labels-section.test.tsx b/components/admin/org/__tests__/labels-section.test.tsx new file mode 100644 index 000000000..373ed7bb5 --- /dev/null +++ b/components/admin/org/__tests__/labels-section.test.tsx @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Classification: presets, the list editor, default and +// required, the extra header, the VS-NfD warning, and a scope that has its +// own scheme as a whole (labels merge as one section). + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { LabelsSection } from '../sections/labels-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; +import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: {}, + scopes: [{ id: 'gov', label: 'Gov', match: { domains: ['gov.example'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-labels-vsnfd-warning')).toBeInTheDocument()); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Classification section', () => { + it('says plainly that a label does not make mail meet the VS-NfD rules', async () => { + await renderSection(); + const warning = screen.getByTestId('org-labels-vsnfd-warning').textContent ?? ''; + expect(warning).toContain('VSA § 50, § 55'); + expect(warning).toContain('BSI-approved encryption'); + expect(warning).toContain('are not BSI-approved products'); + }); + + it('starts from the generic preset and saves it with the default', async () => { + await renderSection(); + fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'generic' } }); + expect(screen.getAllByTestId('org-label-item')).toHaveLength(4); + fireEvent.click(screen.getByTestId('org-labels-required')); + await act(async () => { await latest!.save(); }); + const labels = (puts[0].organisation as { labels: { list: Array<{ id: string; subjectPrefix: string }>; defaultId: string; required: boolean } }).labels; + expect(labels.list.map((l) => l.id)).toEqual(['public', 'internal', 'confidential', 'strictly-confidential']); + expect(labels.defaultId).toBe('internal'); + expect(labels.required).toBe(true); + // What the server keeps of it is the same. + expect(sanitizeOrganisationPolicy(puts[0].organisation).labels.list[2].subjectPrefix).toBe('[Confidential]'); + }); + + it('offers no grade above VS-NfD in the German preset', async () => { + await renderSection(); + fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } }); + const names = latest!.orgValue('labels', 'list').value.map((l) => l.name); + expect(names).toEqual(['OFFEN', 'VS-NfD']); + expect(latest!.orgValue('labels', 'list').value[1].marking).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH'); + }); + + it('clears the default and the requirement with the last label', async () => { + stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true } } }; + await renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Remove A' })); + expect(latest!.orgValue('labels', 'defaultId').value).toBe(''); + expect(latest!.orgValue('labels', 'required').value).toBe(false); + }); + + it('stores the extra header name only when it is a header Bulwark doesn’t write', async () => { + await renderSection(); + const field = screen.getByTestId('org-labels-extra-header'); + fireEvent.change(field, { target: { value: 'Sensitivity' } }); + expect(screen.getAllByRole('alert').some((el) => el.textContent?.includes('Bulwark writes this one itself'))).toBe(true); + expect(latest!.orgValue('labels', 'extraHeader').value).toBe(''); + fireEvent.change(field, { target: { value: 'X-Classification' } }); + expect(latest!.orgValue('labels', 'extraHeader').value).toBe('X-Classification'); + }); + + it('gives a scope its own scheme as a whole, and back', async () => { + stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true, extraHeader: 'X-Class' } } }; + await renderSection(); + act(() => latest!.setScopeId('gov')); + expect(screen.getByTestId('org-labels-inherited').textContent).toContain('A'); + fireEvent.click(screen.getByTestId('org-labels-own')); + expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toEqual({ + list: [{ id: 'a', name: 'A', color: '#000000', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }], + defaultId: 'a', required: true, extraHeader: 'X-Class', + }); + fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } }); + const resolved = resolvePolicy({ ...DEFAULT_POLICY, ...(latest!.policy as object) } as never, { email: 'x@gov.example', groups: [] }).policy; + expect(resolved.organisation!.labels.list.map((l) => l.id)).toEqual(['offen', 'vs-nfd']); + expect(resolved.organisation!.labels.defaultId).toBe('offen'); + expect(resolved.organisation!.labels.extraHeader).toBe('X-Class'); + fireEvent.click(screen.getByRole('button', { name: /Use Everyone/ })); + expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toBeUndefined(); + }); +}); diff --git a/components/admin/org/__tests__/phishing-report-section.test.tsx b/components/admin/org/__tests__/phishing-report-section.test.tsx new file mode 100644 index 000000000..4f4c47e92 --- /dev/null +++ b/components/admin/org/__tests__/phishing-report-section.test.tsx @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Security > Phishing reports: the address (stored only when +// it is one), the format, the postmaster warning, and a scope override. + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { PhishingReportSection } from '../sections/phishing-report-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: {}, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-phishing-address')).toBeInTheDocument()); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Phishing reports section', () => { + it('stores the address once it is one, and saves it with the format', async () => { + await renderSection(); + const field = screen.getByTestId('org-phishing-address'); + fireEvent.change(field, { target: { value: 'security@' } }); + // The field's own alert; other alerts (announcements) may be on the page. + await waitFor(() => expect(screen.getAllByRole('alert').some((el) => el.textContent?.includes("isn't an email address"))).toBe(true)); + expect(latest!.orgValue('phishingReport', 'address').value).toBe(''); + fireEvent.change(field, { target: { value: 'Security@Example.org' } }); + expect(screen.queryAllByRole('alert').some((el) => el.textContent?.includes("isn't an email address"))).toBe(false); + fireEvent.click(screen.getByTestId('org-phishing-format-arf')); + await act(async () => { await latest!.save(); }); + expect((puts[0].organisation as { phishingReport: unknown }).phishingReport).toEqual({ address: 'security@example.org', format: 'arf' }); + }); + + it('turns reporting off when the address is emptied', async () => { + stored = { ...POLICY, organisation: { phishingReport: { address: 'soc@example.org', format: 'forward' } } }; + await renderSection(); + const field = screen.getByTestId('org-phishing-address') as HTMLInputElement; + expect(field.value).toBe('soc@example.org'); + fireEvent.change(field, { target: { value: '' } }); + expect(latest!.orgValue('phishingReport', 'address').value).toBe(''); + }); + + it('warns about postmaster@', async () => { + await renderSection(); + expect(screen.queryByTestId('org-phishing-postmaster')).toBeNull(); + fireEvent.change(screen.getByTestId('org-phishing-address'), { target: { value: 'postmaster@example.org' } }); + expect(screen.getByTestId('org-phishing-postmaster').textContent).toContain('Stalwart analyses mail to it as reports'); + }); + + it('writes a scope override only into that scope', async () => { + await renderSection(); + act(() => latest!.setScopeId('team')); + fireEvent.change(screen.getByTestId('org-phishing-address'), { target: { value: 'team-soc@example.org' } }); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ phishingReport: { address: 'team-soc@example.org' } }); + expect(latest!.policy?.organisation?.phishingReport.address).toBe(''); + }); +}); diff --git a/components/admin/org/sections/labels-section.tsx b/components/admin/org/sections/labels-section.tsx index fd1cc1d3c..42c446a21 100644 --- a/components/admin/org/sections/labels-section.tsx +++ b/components/admin/org/sections/labels-section.tsx @@ -1,13 +1,338 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { useEffect, useId, useState } from 'react'; +import { AlertTriangle, ChevronDown, ChevronRight, ChevronUp, Plus, RotateCcw, Trash2 } from '@/components/icons'; +import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { + DEFAULT_ORGANISATION_POLICY, + LABEL_SENSITIVITIES, + isLabelExtraHeaderName, + type ClassificationLabel, + type LabelSensitivity, + type OrganisationPolicy, +} from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; +import { cn, generateUUID } from '@/lib/utils'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +type Labels = OrganisationPolicy['labels']; +const KEYS = ['list', 'defaultId', 'required', 'extraHeader'] as const; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/classification-labels'; +/** Same cap as the sanitiser. */ +const MAX_LABELS = 50; + +const inputClass = + 'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' + + 'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring'; +const labelClass = 'mb-1 block text-xs font-medium text-foreground'; + +const SENSITIVITY_NAMES: Record = { + '': 'None', + Personal: 'Personal', + Private: 'Private', + 'Company-Confidential': 'Company-Confidential (Outlook: Confidential)', +}; + +/** The VS-NfD warning, here and in the docs. */ +function VsNfdWarning() { + return ( +
+
+ ); +} + +function LabelEditor({ label, index, count, extraHeader, onChange, onMove, onRemove }: { + label: ClassificationLabel; + index: number; + count: number; + extraHeader: string; + onChange: (label: ClassificationLabel) => void; + onMove: (delta: -1 | 1) => void; + onRemove: () => void; +}) { + const id = useId(); + const [open, setOpen] = useState(!label.name); + const set = (patch: Partial) => onChange({ ...label, ...patch }); + const name = label.name || 'New label'; + return ( +
  • +
    + + + + +
    + {!label.name.trim() &&

    A label without a name is not saved. Give it one.

    } + {open && ( +
    +
    + + set({ name: e.target.value })} /> +
    +
    + +
    + set({ color: e.target.value.toLowerCase() })} /> + {label.color} +
    +
    +
    + + set({ description: e.target.value })} /> +
    +
    + + set({ marking: e.target.value.replace(/["\\]/g, '') })} /> +

    Empty uses the name. Received mail is matched by this text.

    +
    +
    + + +

    Outlook shows it above the message.

    +
    +
    + + set({ subjectPrefix: e.target.value })} /> +

    Added in front of the subject when the mail goes out, once.

    +
    + {extraHeader && ( +
    + + set({ extraHeaderValue: e.target.value })} /> +
    + )} +
    + )} +
  • + ); +} + /** - * Placeholder from the enterprise-pack foundation, replaced by package A: - * Classification labels, their default, and whether a label is required. + * Organisation > Classification (enterprise pack item 3): the labels users + * pick in the composer (policy.json `organisation.labels`), the default, + * whether a label is required, and an extra header for gateways. A scope + * that sets labels replaces Everyone's scheme as a whole, so the scope + * either uses Everyone's labels or has all of its own. */ export function LabelsSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; - return {null}; + const id = useId(); + const inScope = !!draft.scope; + const scopeOwn = inScope && KEYS.some((key) => draft.orgValue('labels', key).own); + const editing = !inScope || scopeOwn; + const value = (key: K): Labels[K] => { + const v = draft.orgValue('labels', key); + if (!inScope) return v.value; + if (!scopeOwn) return v.everyone; + return v.own ? v.value : DEFAULT_ORGANISATION_POLICY.labels[key]; + }; + const everyone = { + list: draft.orgValue('labels', 'list').everyone, + }; + const list = value('list'); + const defaultId = value('defaultId'); + const required = value('required'); + const extraHeader = value('extraHeader'); + + const set = (key: K, next: Labels[K]) => draft.setOrgValue('labels', key, next); + const setAll = (labels: Labels) => { for (const key of KEYS) draft.setOrgValue('labels', key, labels[key] as never); }; + const useOwn = () => setAll(JSON.parse(JSON.stringify({ + list: everyone.list, + defaultId: draft.orgValue('labels', 'defaultId').everyone, + required: draft.orgValue('labels', 'required').everyone, + extraHeader: draft.orgValue('labels', 'extraHeader').everyone, + })) as Labels); + const useEveryone = () => { for (const key of KEYS) draft.clearOrgValue('labels', key); }; + + const setList = (next: ClassificationLabel[]) => { + set('list', next); + if (defaultId && !next.some((l) => l.id === defaultId)) set('defaultId', ''); + if (next.length === 0 && required) set('required', false); + }; + const add = () => setList([...list, { id: generateUUID().slice(0, 12), name: '', color: '#6b7280', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }]); + const change = (index: number, label: ClassificationLabel) => setList(list.map((l, i) => (i === index ? label : l))); + const remove = (index: number) => setList(list.filter((_, i) => i !== index)); + const move = (index: number, delta: -1 | 1) => { + const next = [...list]; + const [item] = next.splice(index, 1); + next.splice(index + delta, 0, item); + setList(next); + }; + const applyPreset = (presetId: string) => { + const preset = LABEL_PRESETS.find((p) => p.id === presetId); + if (!preset) return; + set('list', JSON.parse(JSON.stringify(preset.list))); + set('defaultId', preset.defaultId); + }; + + // The extra header name is typed freely and stored once it is a valid name. + const [headerText, setHeaderText] = useState(extraHeader); + useEffect(() => { setHeaderText(extraHeader); }, [extraHeader]); + const headerInvalid = headerText.trim() !== '' && !isLabelExtraHeaderName(headerText); + const onHeader = (text: string) => { + setHeaderText(text); + const next = text.trim(); + if (next === '' || isLabelExtraHeaderName(next)) set('extraHeader', next); + }; + + return ( + +
    + + + {inScope && ( +
    + {scopeOwn ? 'This scope has its own labels. They replace Everyone\u2019s.' : 'This scope uses Everyone\u2019s labels.'} + {scopeOwn ? ( + + ) : ( + + )} +
    + )} + + {editing ? ( + <> +
    + + + Replaces the list below. +
    + + {list.length === 0 ? ( +

    No labels. The composer shows no label picker.

    + ) : ( +
      + {list.map((label, index) => ( + change(index, l)} + onMove={(delta) => move(index, delta)} + onRemove={() => remove(index)} + /> + ))} +
    + )} +
    + + {list.length >= MAX_LABELS && At most {MAX_LABELS} labels.} +
    + +
    + + + + + set('required', v)} testId="org-labels-required" /> + + +
    + onHeader(e.target.value)} + aria-invalid={headerInvalid || undefined} + data-testid="org-labels-extra-header" + className={cn(inputClass, 'sm:w-56', headerInvalid && 'border-red-500')} + /> + {headerInvalid && ( +

    + A header name has no spaces or colons. Bulwark writes this one itself, or the mail server won't take it. +

    + )} +
    +
    +
    + + ) : ( +
    + {everyone.list.length === 0 ? ( +

    Everyone has no labels.

    + ) : ( +
      + {everyone.list.map((l) => ( +
    • +
    • + ))} +
    + )} +
    + )} + +

    + Calendar invitations, read receipts, phishing reports and unsubscribe mails go out without a label.{' '} + + How labels work + +

    +
    +
    + ); } diff --git a/components/admin/org/sections/phishing-report-section.tsx b/components/admin/org/sections/phishing-report-section.tsx index 5d1b47d85..2ee66f0a6 100644 --- a/components/admin/org/sections/phishing-report-section.tsx +++ b/components/admin/org/sections/phishing-report-section.tsx @@ -1,13 +1,145 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { useEffect, useId, useState } from 'react'; +import { Info } from '@/components/icons'; +import { SettingItem, SettingsSection } from '@/components/settings/settings-section'; +import { isPhishingReportAddress, type PhishingReportFormat } from '@/lib/admin/organisation-policy'; +import { cn } from '@/lib/utils'; +import { InheritNote } from '../org-page'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/phishing-reports'; + +const FORMATS: Array<{ value: PhishingReportFormat; label: string; description: string }> = [ + { + value: 'forward', + label: 'Forward as an attachment', + description: 'The message goes out whole as an .eml attachment, with the subject "Phishing: ". Security mailboxes, ticket systems and Microsoft Defender read this.', + }, + { + value: 'arf', + label: 'Abuse report (ARF)', + description: 'A machine-readable report (RFC 5965, Feedback-Type: fraud) with the message attached. For abuse desks and tools that parse feedback reports.', + }, +]; + +const inputClass = + 'h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground ' + + 'focus-visible:outline-hidden focus-visible:border-ring sm:w-72'; + /** - * Placeholder from the enterprise-pack foundation, replaced by package A: - * Report phishing: where reports go and in which format. + * Organisation > Security > Phishing reports (enterprise pack item 2): + * where "Report phishing" sends a message and in which format + * (policy.json `organisation.phishingReport`). Users see the action only + * while an address is set. */ export function PhishingReportSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; - return {null}; + const address = draft.orgValue('phishingReport', 'address'); + const format = draft.orgValue('phishingReport', 'format'); + const id = useId(); + + // Typed freely, stored only once it is an address (or empty, which turns + // the action off): the policy never holds half an address. + const [text, setText] = useState(address.value); + useEffect(() => { setText(address.value); }, [address.value]); + const trimmed = text.trim().toLowerCase(); + const invalid = trimmed !== '' && !isPhishingReportAddress(trimmed); + const postmaster = /^postmaster@/i.test(trimmed); + const onText = (value: string) => { + setText(value); + const next = value.trim().toLowerCase(); + if (next !== '' && !isPhishingReportAddress(next)) return; + if (next !== address.value || !address.own) draft.setOrgValue('phishingReport', 'address', next); + }; + + return ( + + +
    + onText(e.target.value)} + aria-invalid={invalid || undefined} + aria-describedby={invalid ? `${id}-error` : undefined} + data-testid="org-phishing-address" + className={cn(inputClass, invalid ? 'border-red-500' : 'border-input')} + /> + {invalid && ( + + )} + {postmaster && ( +

    + Don't use postmaster@: Stalwart analyses mail to it as reports. +

    + )} + draft.clearOrgValue('phishingReport', 'address')} + /> +
    +
    + + +
    + {FORMATS.map((option) => ( + + ))} + f.value === format.everyone)?.label ?? format.everyone} + onReset={() => draft.clearOrgValue('phishingReport', 'format')} + /> +
    +
    + +
    +
    +
    + ); } diff --git a/components/email/__tests__/composer-classification.test.tsx b/components/email/__tests__/composer-classification.test.tsx new file mode 100644 index 000000000..39fd0017e --- /dev/null +++ b/components/email/__tests__/composer-classification.test.tsx @@ -0,0 +1,241 @@ +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', from: [{ email: 'boss@example.com' }], 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('ignores a label an outside sender wrote, and starts with the default', async () => { + useLabels({ defaultId: 'internal' }); + renderComposer({ + mode: 'reply', + initialData: { ...DRAFT, mode: 'reply', subject: 'Re: Offer' }, + replyTo: { subject: 'Offer', from: [{ email: 'shop@outside.example' }], headers: { 'SIO-Label': ' marking="PUBLIC"' } }, + }); + expect(screen.getByTestId('composer-classification-trigger').textContent).toContain('Internal'); + }); + + 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/__tests__/paragraph-breaks-inert.test.ts b/components/email/__tests__/paragraph-breaks-inert.test.ts new file mode 100644 index 000000000..b872c786f --- /dev/null +++ b/components/email/__tests__/paragraph-breaks-inert.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { keepLoadedParagraphSpacing } from '../paragraph-breaks'; + +// Loading a reply or a re-opened draft into the composer goes through +// keepLoadedParagraphSpacing. It must parse the HTML inertly: an parsed +// into an element of the app document starts loading at once, so a quoted +// message's tracking pixel fired the moment the composer opened, also while +// the organisation blocks remote content (found by the enterprise spec). + +afterEach(() => vi.restoreAllMocks()); + +describe('keepLoadedParagraphSpacing', () => { + it('never puts the message into the live document', () => { + const created: Element[] = []; + const original = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation(((tag: string, options?: ElementCreationOptions) => { + const el = original(tag, options); + created.push(el); + return el; + }) as typeof document.createElement); + + const html = '

    Thanks

    Hi

    '; + const out = keepLoadedParagraphSpacing(html); + + const liveImages = created.flatMap((el) => [...el.querySelectorAll('img')]).filter((img) => img.ownerDocument === document); + expect(liveImages).toEqual([]); + // It still does its job. + expect(out).toContain('

    Thanks

    '); + expect(out).toContain(''); + }); +}); diff --git a/components/email/__tests__/phishing-report-flow.test.tsx b/components/email/__tests__/phishing-report-flow.test.tsx new file mode 100644 index 000000000..29ad5bd98 --- /dev/null +++ b/components/email/__tests__/phishing-report-flow.test.tsx @@ -0,0 +1,198 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email } from '@/lib/jmap/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useToastStore } from '@/stores/toast-store'; +import { usePhishingReportStore } from '@/stores/phishing-report-store'; + +// The report flow through the real dialog: it names the address, Cancel +// sends nothing, each message gets its own report, each moves to Junk with +// $phishing (read), and Undo moves it back and clears the mark, while the +// toast says the report already went. + +const email = (id: string, subject: string) => ({ + id, blobId: `blob-${id}`, threadId: `t-${id}`, size: 100, subject, keywords: {}, mailboxIds: { inbox: true }, + receivedAt: '2026-10-01T10:00:00Z', from: [{ email: 'bad@phish.example' }], +}) as unknown as Email; +const e1 = email('e1', 'Parcel'); +const e2 = email('e2', 'Invoice'); + +const hoisted = vi.hoisted(() => { + const client = { + getIdentities: async () => [{ id: 'id-me', name: 'Me', email: 'me@example.org' }], + getUsername: () => 'me@example.org', + getAccountId: () => 'acc', + resolveSendAccountId: async () => 'acc', + copyBlobs: async () => ({}), + sendEmail: vi.fn(async () => ({ scheduled: false })), + submitRawEmail: vi.fn(async () => ({ scheduled: false })), + fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode('Subject: x' + String.fromCharCode(13, 10, 13, 10) + 'hi').buffer), + markAsSpam: vi.fn(async () => {}), + undoSpam: vi.fn(async () => {}), + }; + const store = { + emails: [] as Email[], + mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'sent-box', role: 'sent' }], + selectedEmail: null as Email | null, + markAsSpam: vi.fn(async (..._args: unknown[]) => true), + undoSpam: vi.fn(async (..._args: unknown[]) => true), + }; + return { client, store }; +}); + +vi.mock('@/stores/auth-store', () => { + const state = { client: hoisted.client, activeAccountId: null, getClientForAccount: () => undefined }; + const hook = (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state); + hook.getState = () => state; + hook.subscribe = () => () => {}; + return { useAuthStore: hook }; +}); +vi.mock('@/stores/email-store', () => ({ + useEmailStore: Object.assign( + (sel?: (s: typeof hoisted.store) => unknown) => (sel ? sel(hoisted.store) : hoisted.store), + { getState: () => hoisted.store }, + ), + resolveEmailActionContext: () => ({ client: hoisted.client, mailboxes: [], accountId: undefined }), +})); + +const { PhishingReportHost } = await import('../phishing-report-host'); +const { requestPhishingReport } = await import('@/hooks/use-phishing-report'); + +function setAddress(address: string, format: 'forward' | 'arf' = 'forward') { + usePolicyStore.setState({ + policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format } } }, + }); +} + +beforeEach(() => { + hoisted.store.emails = [e1, e2]; + setAddress('soc@example.org'); +}); +afterEach(() => { + usePhishingReportStore.setState({ reported: {} }); + cleanup(); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); + useToastStore.setState({ toasts: [] }); + usePhishingReportStore.setState({ pending: null, busy: false, opener: null }); + vi.clearAllMocks(); +}); + +describe('Report phishing', () => { + it('asks nothing while the admin set no address', () => { + setAddress(''); + render(); + act(() => requestPhishingReport([e1], 'inbox')); + expect(screen.queryByTestId('phishing-report-dialog')).toBeNull(); + }); + + it('names the address and sends nothing on Cancel or Escape', () => { + render(); + act(() => requestPhishingReport([e1], 'inbox')); + expect(screen.getByRole('alertdialog').textContent).toContain('confirm_message'); + fireEvent.click(screen.getByTestId('phishing-report-cancel')); + expect(screen.queryByTestId('phishing-report-dialog')).toBeNull(); + act(() => requestPhishingReport([e1], 'inbox')); + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }); + expect(screen.queryByTestId('phishing-report-dialog')).toBeNull(); + expect(hoisted.client.sendEmail).not.toHaveBeenCalled(); + }); + + it('sends one report per message, moves each to Junk with $phishing, and undoes the move', async () => { + render(); + act(() => requestPhishingReport([e1, e2], 'inbox')); + await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); }); + expect(hoisted.client.sendEmail).toHaveBeenCalledTimes(2); + expect((hoisted.client.sendEmail.mock.calls as unknown[][]).map((c) => c[1])).toEqual(['Phishing: Parcel', 'Phishing: Invoice']); + expect(hoisted.store.markAsSpam).toHaveBeenCalledTimes(2); + expect(hoisted.store.markAsSpam).toHaveBeenCalledWith(hoisted.client, 'e1', 'e1', { keywords: ['$phishing'], markRead: true }); + const toast = useToastStore.getState().toasts.at(-1)!; + expect(toast.type).toBe('success'); + expect(toast.title).toBe('done'); + await act(async () => { toast.action!.onClick(); }); + expect(hoisted.store.undoSpam).toHaveBeenCalledWith(hoisted.client, 'e1', 'e1', { clearKeywords: ['$phishing'] }); + expect(hoisted.store.undoSpam).toHaveBeenCalledTimes(2); + expect(useToastStore.getState().toasts.at(-1)!.title).toBe('undone'); + }); + + it('says so when the report went but the move to Junk failed', async () => { + hoisted.store.markAsSpam.mockRejectedValueOnce(new Error('forbidden')); + render(); + act(() => requestPhishingReport([e1], 'inbox')); + await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); }); + expect(hoisted.client.sendEmail).toHaveBeenCalledTimes(1); + expect(useToastStore.getState().toasts.at(-1)).toMatchObject({ type: 'warning', title: 'not_moved' }); + }); + + it('moves a message the list does not hold straight through its account', async () => { + hoisted.store.emails = []; + render(); + act(() => requestPhishingReport([e1], 'inbox')); + await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); }); + expect(hoisted.client.markAsSpam).toHaveBeenCalledWith('e1', undefined, true, ['$phishing']); + await act(async () => { useToastStore.getState().toasts.at(-1)!.action!.onClick(); }); + expect(hoisted.client.undoSpam).toHaveBeenCalledWith('e1', 'inbox', undefined, ['$phishing']); + }); + + it('sends an ARF report raw when the admin picked ARF', async () => { + setAddress('abuse@example.org', 'arf'); + render(); + act(() => requestPhishingReport([e1], 'inbox')); + await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); }); + await waitFor(() => expect(hoisted.client.submitRawEmail).toHaveBeenCalledTimes(1)); + expect(hoisted.client.sendEmail).not.toHaveBeenCalled(); + const raw = await ((hoisted.client.submitRawEmail.mock.calls[0] as unknown[])[0] as Blob).text(); + expect(raw).toContain('report-type=feedback-report'); + await waitFor(() => expect(hoisted.store.markAsSpam).toHaveBeenCalledTimes(1)); + }); + + it('says how many came back when only some could be moved back', async () => { + render(); + act(() => requestPhishingReport([e1, e2], 'inbox')); + await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); }); + hoisted.store.undoSpam.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + await act(async () => { useToastStore.getState().toasts.at(-1)!.action!.onClick(); }); + // The "undone" toast counts what came back, not what was reported. + expect(useToastStore.getState().toasts.at(-1)!.title).toBe('undone'); + expect(hoisted.store.undoSpam).toHaveBeenCalledTimes(2); + expect(usePhishingReportStore.getState().reported).toEqual({ e2: true }); + }); + + it('marks the reported messages for every open view, and unmarks them on Undo', async () => { + render(); + act(() => requestPhishingReport([e1], 'inbox')); + await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); }); + expect(usePhishingReportStore.getState().reported).toEqual({ e1: true }); + await act(async () => { useToastStore.getState().toasts.at(-1)!.action!.onClick(); }); + expect(usePhishingReportStore.getState().reported).toEqual({}); + }); + + it('runs the plugins\u2019 spam hook for a message the list doesn\u2019t hold, and stops the move when it says no', async () => { + hoisted.store.emails = []; + const { emailHooks } = await import('@/lib/plugin-hooks'); + const off = emailHooks.onBeforeEmailSpamToggle.register('test-plugin', async () => false); + try { + render(); + act(() => requestPhishingReport([e1], 'inbox')); + await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); }); + expect(hoisted.client.sendEmail).toHaveBeenCalledTimes(1); + expect(hoisted.client.markAsSpam).not.toHaveBeenCalled(); + expect(useToastStore.getState().toasts.at(-1)).toMatchObject({ type: 'warning', title: 'not_moved' }); + } finally { + off.dispose(); + } + }); + + it('skips the user\u2019s own sent mail found by search', () => { + render(); + act(() => requestPhishingReport([{ ...e1, mailboxIds: { 'sent-box': true } } as Email], null)); + expect(screen.queryByTestId('phishing-report-dialog')).toBeNull(); + }); + + it('skips messages of your own folders', () => { + render(); + act(() => requestPhishingReport([e1], 'sent')); + expect(screen.queryByTestId('phishing-report-dialog')).toBeNull(); + }); +}); diff --git a/components/email/__tests__/phishing-report-menus.test.tsx b/components/email/__tests__/phishing-report-menus.test.tsx new file mode 100644 index 000000000..9a25f5627 --- /dev/null +++ b/components/email/__tests__/phishing-report-menus.test.tsx @@ -0,0 +1,200 @@ +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { usePolicyStore } from '@/stores/policy-store'; +import { usePhishingReportStore } from '@/stores/phishing-report-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { resetAccountSubjects, useAccountSubjects } from '@/lib/org/account-subjects'; +import { requestPhishingReport } from '@/hooks/use-phishing-report'; +import { htmlAttachmentDocument } from '@/components/files/file-preview-modal'; +import { stampProSource } from '@/components/pro/pro-email-tab-body'; + +// Where "Report phishing" shows: the row menu, the reader's ⋮ and a +// selection's menu, only while the admin set an address and not in your own +// folders. A reported message ($phishing) shows a banner and its links are off. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, + useIsDesktop: () => true, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); +const { EmailContextMenu } = await import('../email-context-menu'); + +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Phish', email: 'bad@phish.example' }], to: [{ email: 'me@example.org' }], + subject: 'Parcel', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: '

    Track it here

    ' } }, +} as unknown as Email; +const other = { ...email, id: 'e2', blobId: 'b2', subject: 'Invoice' } as Email; +const other_message = other; +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; + +function setAddress(address: string) { + usePolicyStore.setState({ + policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format: 'forward' } } }, + }); +} + +function rowMenu(role: string, props: Partial> = {}) { + return render( + , + ); +} + +afterEach(() => { + cleanup(); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); + usePhishingReportStore.setState({ pending: null, busy: false, opener: null, reported: {} }); + useAuthStore.setState({ activeAccountId: null, getClientForAccount: () => undefined } as never); + resetAccountSubjects(); +}); + +describe('several accounts', () => { + it('names every address a selection across accounts goes to', () => { + const other = { getUsername: () => 'b@other.example', getSharedAccounts: () => [] }; + useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never); + const org = { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address: 'soc@example.org', format: 'forward' as const } }; + usePolicyStore.setState({ + policy: { ...DEFAULT_POLICY, organisation: org }, + rawPolicy: { + ...DEFAULT_POLICY, + organisation: org, + scopes: [{ id: 'other', label: 'Other', match: { domains: ['other.example'] }, policy: { organisation: { phishingReport: { address: 'abuse@other.example' } } } }], + } as never, + subject: { email: 'me@example.org', groups: [] }, + }); + useAccountSubjects.setState({ subjects: { 'acct-b': { email: 'b@other.example', groups: [] } }, settled: { 'acct-b': true } }); + const fromB = { ...other_message, sourceClientAccountId: 'acct-b', sourceAccountId: 'jmap-b' } as Email; + act(() => requestPhishingReport([email, fromB], 'inbox')); + expect(usePhishingReportStore.getState().pending?.addresses).toEqual(['soc@example.org', 'abuse@other.example']); + }); + + it('a Pro tab stamps a message of another login or a shared account with where it came from', () => { + useAuthStore.setState({ activeAccountId: 'acct-a' } as never); + const own = { getAccountId: () => 'jmap-a' }; + expect(stampProSource(email, { client: own as never }, undefined)).toBe(email); + expect(stampProSource(email, { client: own as never, accountId: 'jmap-team' }, undefined)).toMatchObject({ sourceClientAccountId: 'acct-a', sourceAccountId: 'jmap-team' }); + expect(stampProSource(email, { client: { getAccountId: () => 'jmap-b' } as never }, 'acct-b')).toMatchObject({ sourceClientAccountId: 'acct-b', sourceAccountId: 'jmap-b' }); + }); +}); + +describe('the focus after the dialog', () => { + it('goes back to the button that opened the menu, not to the menu item that is gone', () => { + document.body.innerHTML = '
    '; + (document.getElementById('item') as HTMLElement).focus(); + act(() => usePhishingReportStore.getState().ask([email], ['soc@example.org'])); + expect(usePhishingReportStore.getState().opener?.id).toBe('more'); + document.body.innerHTML = ''; + }); +}); + +describe('links elsewhere in a reported message', () => { + it('are off in an attachment preview', () => { + const doc = htmlAttachmentDocument('go', true, { title: 'link turned off', note: '(link turned off)' }); + expect(doc).not.toContain('https://phish.example/x'); + expect(htmlAttachmentDocument('go', true)).toContain('https://ok.example/x'); + }); + + it('the unsubscribe banner doesn\u2019t show', async () => { + const withList = { ...email, headers: { 'List-Unsubscribe': '', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' } } as Email; + const { unmount } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + const shown = document.body.textContent?.includes('phish.example') || screen.queryAllByRole('button', { name: /unsubscribe/i }).length > 0; + expect(shown).toBe(true); + unmount(); + const reported = { ...withList, keywords: { $seen: true, $phishing: true } } as Email; + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + expect(screen.queryAllByRole('button', { name: /unsubscribe/i })).toHaveLength(0); + }); + + it('a message reported in this session shows as reported before its keyword arrives', async () => { + usePhishingReportStore.setState({ reported: { e1: true } }); + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + expect(screen.getByTestId('phishing-mark-banner')).toBeTruthy(); + }); +}); + +describe('the row menu', () => { + it('has no Report phishing while the admin set no address', () => { + rowMenu('inbox'); + expect(screen.queryByTestId('ctx-report-phishing')).toBeNull(); + }); + + it('offers it once an address is set, and asks with that address', () => { + setAddress('soc@example.org'); + rowMenu('inbox'); + fireEvent.click(screen.getByTestId('ctx-report-phishing')); + expect(usePhishingReportStore.getState().pending).toEqual({ emails: [email], addresses: ['soc@example.org'] }); + }); + + it('shows up when the policy arrives after the menu opened', () => { + rowMenu('inbox'); + expect(screen.queryByTestId('ctx-report-phishing')).toBeNull(); + act(() => setAddress('soc@example.org')); + expect(screen.getByTestId('ctx-report-phishing')).toBeTruthy(); + }); + + it('is not offered in Sent', () => { + setAddress('soc@example.org'); + rowMenu('sent'); + expect(screen.queryByTestId('ctx-report-phishing')).toBeNull(); + }); + + it('reports a whole selection, one message each', () => { + setAddress('soc@example.org'); + rowMenu('inbox', { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other], onBatchMarkAsSpam: vi.fn() }); + fireEvent.click(screen.getByTestId('ctx-batch-report-phishing')); + expect(usePhishingReportStore.getState().pending?.emails.map((e) => e.id)).toEqual(['e1', 'e2']); + }); +}); + +describe('the reader', () => { + it("offers it in the ⋮ menu", () => { + setAddress('soc@example.org'); + render(); + fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); + expect(within(screen.getByRole('menu', { name: 'more_actions' })).getByTestId('ctx-report-phishing')).toBeTruthy(); + }); + + it('shows a reported message with a banner and its links turned off', async () => { + const reported = { ...email, keywords: { $seen: true, $junk: true, $phishing: true } } as Email; + const { container } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + expect(screen.getByTestId('phishing-mark-banner').textContent).toContain('banner'); + const srcdoc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? ''; + expect(srcdoc).toContain('Track it'); + expect(srcdoc).not.toContain('https://phish.example/login'); + expect(srcdoc).toContain('data-link-off'); + }); + + it('leaves the links of other messages alone', async () => { + const { container } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + expect(screen.queryByTestId('phishing-mark-banner')).toBeNull(); + expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('https://phish.example/login'); + }); +}); diff --git a/components/email/__tests__/received-label-chip.test.tsx b/components/email/__tests__/received-label-chip.test.tsx new file mode 100644 index 000000000..292deaa1d --- /dev/null +++ b/components/email/__tests__/received-label-chip.test.tsx @@ -0,0 +1,88 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +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'; + +// A received message with a known label shows it next to the sender, in +// the reader and the conversation view; an unknown one, or labels off, +// shows nothing. Headers as both servers hand them back (raw, leading space). + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); +const { ThreadConversationView } = await import('../thread-conversation-view'); + +const message = (headers: Record) => ({ + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Bob', email: 'bob@example.org' }], to: [{ email: 'me@example.org' }], + subject: 'Budget', receivedAt: '2026-01-01T00:00:00Z', preview: 'x', hasAttachment: false, size: 100, + textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], attachments: [], bodyValues: { '1': { value: 'Numbers' } }, + headers, +}) as unknown as Email; +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; + +function useLabels(on: boolean) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: on ? LABEL_PRESETS[0].list : [], extraHeader: 'X-Classification' } }, + }, + }); +} + +afterEach(() => { + cleanup(); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); +}); + +async function viewer(email: Email) { + render(); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); +} + +describe('the received label chip', () => { + it('names a known label in the reader, for screen readers too', async () => { + useLabels(true); + await viewer(message({ 'SIO-Label': ' marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706' })); + const chips = screen.getAllByTestId('received-label-chip'); + expect(chips[0].getAttribute('aria-label')).toBe('chip_label'); + expect(chips[0].textContent).toBe('Confidential'); + expect(chips[0].getAttribute('style')).toContain('background-color'); + }); + + it('matches by the extra header when there is no SIO-Label', async () => { + useLabels(true); + await viewer(message({ 'X-Classification': ' Strictly confidential' })); + expect(screen.getAllByTestId('received-label-chip')[0].textContent).toBe('Strictly confidential'); + }); + + it('shows nothing for an unknown label or while labels are off', async () => { + useLabels(true); + await viewer(message({ 'SIO-Label': ' marking="TOP SECRET"' })); + expect(screen.queryByTestId('received-label-chip')).toBeNull(); + cleanup(); + useLabels(false); + await viewer(message({ 'SIO-Label': ' marking="CONFIDENTIAL"' })); + expect(screen.queryByTestId('received-label-chip')).toBeNull(); + }); + + it('shows in the conversation view', async () => { + useLabels(true); + const email = message({ 'sio-label': ' marking="PUBLIC"' }); + render( {}} />); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + expect(screen.getAllByTestId('received-label-chip')[0].textContent).toBe('Public'); + }); +}); diff --git a/components/email/__tests__/remote-content-account-hold.test.tsx b/components/email/__tests__/remote-content-account-hold.test.tsx new file mode 100644 index 000000000..2f3368490 --- /dev/null +++ b/components/email/__tests__/remote-content-account-hold.test.tsx @@ -0,0 +1,175 @@ +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { Editor } from '@tiptap/core'; +import { EditorContent, useEditor } from '@tiptap/react'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { resetAccountSubjects, useAccountSubjects } from '@/lib/org/account-subjects'; +import { remoteContentHeldBack } from '@/hooks/use-third-party-mode'; +import { setNoThirdPartyTraffic } from '@/lib/third-party-mode'; +import { ResizableImage } from '../resizable-image'; +import { serializeEditorContent } from '../quoted-html'; + +// The organisation's remote-content lock follows the account a message +// belongs to and holds while that account's scopes aren't known yet: +// - a lock in a domain or group scope (not only Everyone's), during the +// window before the account's groups are known and while only the public +// policy subset is in; +// - another signed-in account's message (unified inbox, Pro tab) under that +// account's lock while the active account has none; +// - attachment previews (.eml) and remote images in a draft's own content. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + +const { EmailViewer } = await import('../email-viewer'); +const { EmlPreview } = await import('@/components/files/eml-preview'); + +const LOCK = { externalContentPolicy: { locked: true, value: 'block' } }; +const lockedScope = { id: 'locked', label: 'Locked', match: { domains: ['locked.example'] }, policy: { restrictions: LOCK } }; +const groupScope = { id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: { restrictions: LOCK } }; + +const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[]; +const message = (account?: string) => ({ + id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }], + subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100, + textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [], + bodyValues: { '1': { value: '

    Hello

    ' } }, + ...(account ? { sourceClientAccountId: account, sourceAccountId: 'jmap-b' } : {}), +}) as unknown as Email; + +function policy(scopes: unknown[], state: Record = {}) { + const raw = { ...DEFAULT_POLICY, scopes } as never; + usePolicyStore.setState({ rawPolicy: raw, policy: DEFAULT_POLICY, loaded: true, fetching: false, partial: false, subjectPending: false, ...state }); +} + +afterEach(() => { + cleanup(); + usePolicyStore.setState({ rawPolicy: DEFAULT_POLICY, policy: DEFAULT_POLICY, subject: null, subjectPending: false, partial: false, loaded: false, fetching: false }); + useSettingsStore.setState({ externalContentPolicy: 'ask', trustedSenders: [] }); + useAuthStore.setState({ activeAccountId: null, getClientForAccount: () => undefined } as never); + resetAccountSubjects(); +}); + +describe('a lock in a scope', () => { + it('holds while the active account’s groups are being worked out, then follows the scope', () => { + policy([groupScope], { subject: null, subjectPending: true }); + expect(remoteContentHeldBack()).toBe(true); + act(() => usePolicyStore.getState().setPolicySubject({ email: 'me@example.org', groups: [] })); + // Not in the group: nothing held back. + expect(remoteContentHeldBack()).toBe(false); + act(() => usePolicyStore.getState().setPolicySubject({ email: 'me@example.org', groups: ['team@example.org'] })); + expect(remoteContentHeldBack()).toBe(true); + }); + + it('holds while only the public part of the policy is in', () => { + policy([lockedScope], { partial: true, subject: { email: 'me@example.org', groups: [] } }); + expect(remoteContentHeldBack()).toBe(true); + }); + + it('doesn’t hold without scopes once the policy is in', () => { + policy([], { subject: null, subjectPending: true }); + expect(remoteContentHeldBack()).toBe(false); + }); +}); + +describe('another signed-in account’s message', () => { + function twoAccounts() { + const other = { getUsername: () => 'b@locked.example', getSharedAccounts: () => [], getAccountId: () => 'jmap-b', getBlobDownloadUrl: () => '', fetchBlob: async () => new Blob() }; + useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never); + policy([lockedScope], { subject: { email: 'a@free.example', groups: [] } }); + useAccountSubjects.setState({ subjects: { 'acct-b': { email: 'b@locked.example', groups: [] } }, settled: { 'acct-b': true } }); + } + + it('stays blocked under that account’s lock although the active account has none', async () => { + twoAccounts(); + useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: ['news@shop.example'] }); + const { container } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + expect((await screen.findByTestId('external-content-notice')).textContent).toContain('remote_content_blocked'); + expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').not.toMatch(/\ssrc="https:\/\/track/); + }); + + it('loads for the active account’s own trusted sender', async () => { + twoAccounts(); + useSettingsStore.setState({ externalContentPolicy: 'block', trustedSenders: ['news@shop.example'] }); + const { container } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + expect(screen.queryByTestId('external-content-notice')).toBeNull(); + expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toMatch(/\ssrc="https:\/\/track/); + }); + + it('holds while that account’s groups are still being worked out', () => { + const other = { getUsername: () => 'b@x.example', getSharedAccounts: () => [] }; + useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never); + policy([groupScope], { subject: { email: 'a@free.example', groups: [] } }); + useAccountSubjects.setState({ subjects: {}, settled: {} }); + expect(remoteContentHeldBack('acct-b')).toBe(true); + expect(remoteContentHeldBack()).toBe(false); + }); +}); + +describe('attachment previews', () => { + it('keep an attached message’s images blocked until a scoped lock is known, even with "allow"', () => { + setNoThirdPartyTraffic(false); + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + // Without the lock the image loads (the control). + const open = render(', attachments: [] }} />); + expect(open.container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toMatch(/\ssrc="https:\/\/t\.example/); + cleanup(); + // The policy with a scope that may lock it isn't known for this account yet. + policy([groupScope], { subject: null, subjectPending: true }); + useSettingsStore.setState({ externalContentPolicy: 'allow' }); + const { container } = render(', attachments: [] }} />); + expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').not.toMatch(/\ssrc="https:\/\/t\.example/); + }); +}); + +describe('remote images in a draft’s own content', () => { + const HTML = '

    Hi

    '; + + it('are serialised without loading them in the app document', () => { + const editor = new Editor({ extensions: [Document, Paragraph, Text, ResizableImage], content: HTML }); + const created: Element[] = []; + const original = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation(((tag: string, options?: ElementCreationOptions) => { + const el = original(tag, options); + created.push(el); + return el; + }) as typeof document.createElement); + const html = serializeEditorContent(editor); + vi.restoreAllMocks(); + expect(html).toContain('src="https://tracker.example/p.gif"'); + expect(created.filter((el) => el.tagName === 'IMG' || el.querySelector('img'))).toEqual([]); + editor.destroy(); + }); + + it('are parked in the editor while the organisation blocks remote content', async () => { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: LOCK as never }, loaded: true }); + function Box() { + const editor = useEditor({ extensions: [Document, Paragraph, Text, ResizableImage], content: HTML, immediatelyRender: true }); + return ; + } + const { container } = render(); + await act(async () => { await new Promise((r) => setTimeout(r, 30)); }); + const img = container.querySelector('img'); + expect(img?.getAttribute('src')).not.toContain('tracker.example'); + expect(img?.hasAttribute('data-remote-parked')).toBe(true); + }); +}); diff --git a/components/email/__tests__/remote-content-lock-gaps.test.tsx b/components/email/__tests__/remote-content-lock-gaps.test.tsx index 9461911eb..9ac15a5c7 100644 --- a/components/email/__tests__/remote-content-lock-gaps.test.tsx +++ b/components/email/__tests__/remote-content-lock-gaps.test.tsx @@ -141,6 +141,17 @@ describe('trusted senders settings', () => { expect(screen.queryByText('add_manually')).toBeNull(); expect(screen.getAllByText('org_locked').length).toBeGreaterThan(0); }); + + it('still lets users take a sender off the list while it blocks', async () => { + restrict({ locked: true, value: 'block' }); + useSettingsStore.setState({ trustedSenders: ['news@shop.example'], trustedSendersAddressBook: false }); + render(); + fireEvent.click(screen.getByText('trusted_senders.count_one')); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + fireEvent.click(screen.getByTitle('remove')); + await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + expect(useSettingsStore.getState().trustedSenders).toEqual([]); + }); }); describe('a re-opened draft', () => { diff --git a/components/email/classification-label-picker.tsx b/components/email/classification-label-picker.tsx new file mode 100644 index 000000000..e336c8305 --- /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..9864a3cce 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -111,6 +111,11 @@ 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 { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; +import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw"; +import { whenPolicySettled } from "@/stores/policy-store"; +import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker"; +import { useComposerClassification } from "./use-composer-classification"; 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 +258,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 +309,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 +391,10 @@ 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; + /** The original's From, when `from` is left out (a forward as attachment): whether its label counts. */ + originalFrom?: { email?: string; name?: string }[]; }; } @@ -470,6 +486,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 - @@ -748,7 +765,9 @@ export function EmailComposer({ // Whether the quoted original may show its remote images in the editor: // the viewer's rule, minus the per-message "load" click it doesn't know. const { externalContentPolicy, isSenderTrusted } = useSettingsStore.getState(); - const quoteRemoteAllowed = !remoteContentHeldBack() + // The original's own account decides (replyTo.accountId), and the + // active one, which the composer may send from. + const quoteRemoteAllowed = !remoteContentHeldBack() && !remoteContentHeldBack(replyTo?.accountId) && (externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email))); const replyHeader = (html: string) => mode === 'reply' || mode === 'replyAll' @@ -1102,6 +1121,25 @@ 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 labelOriginal = useMemo( + () => (mode === 'compose' || !replyTo?.headers + ? null + : { headers: replyTo.headers, from: replyTo.from ?? replyTo.originalFrom, accountId: replyTo.accountId }), + [mode, replyTo?.headers, replyTo?.from, replyTo?.originalFrom, replyTo?.accountId], + ); + const classification = useComposerClassification({ + accountId: composingAccountId, + senderEmail: currentIdentity?.email, + original: labelOriginal, + 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 +1725,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 +1794,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 +2750,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 +3369,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, labelOriginal); + 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 +3644,20 @@ 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, labelOriginal); + // A crypto plugin builds the MIME itself: it gets the labelled + // subject, and its raw send gets the label's headers, matched by the + // token it can pass back (lib/outgoing/classification-raw.ts). + const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject }); + const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentity?.id ?? ''); const composeSendRequest = { + labelToken: labelToken ?? undefined, to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), - subject, + subject: takeoverLabelled.subject, htmlBody: sealed.html || '', textBody: sealed.text, identityId: currentIdentity?.id || '', @@ -3598,7 +3673,12 @@ 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); + 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 +3731,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 +3764,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 +3781,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, }); @@ -3709,7 +3792,7 @@ export function EmailComposer({ // Replying trusts the recipients, but not while the organisation // blocks remote content: the list would take effect the moment the // block is lifted, with senders nobody chose to trust. - if ((mode === 'reply' || mode === 'replyAll') && !remoteContentBlockedByOrg()) { + if ((mode === 'reply' || mode === 'replyAll') && !remoteContentBlockedByOrg() && !remoteContentBlockedByOrg(composingAccountId)) { for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) { if (trustedSendersAddressBook && client) { addToTrustedSendersBook(client, recipient).catch(err => { @@ -3749,6 +3832,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 +4582,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 +4985,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/email-context-menu.tsx b/components/email/email-context-menu.tsx index 054018ece..5b4684da0 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -27,6 +27,7 @@ import { ShieldCheck, Paperclip, Copy, + Fish, } from "@/components/icons"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; import { buildMailboxTree, MailboxNode } from "@/lib/utils"; @@ -37,6 +38,7 @@ import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; import { RulesContextSubMenu } from "./rules-menu"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; +import { usePhishingReport } from "@/hooks/use-phishing-report"; interface Position { x: number; @@ -203,6 +205,9 @@ export function EmailContextMenu({ // in Sent, Drafts and Scheduled. const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || ''); const isScheduled = email.isScheduled === true; + // One report per selected message, behind one confirm (enterprise pack). + const batchPhishing = usePhishingReport(showBatchActions ? (batchEmails ?? []) : [], currentMailboxRole); + const tPhishing = useTranslations("phishing_report"); // "Remove from this folder" needs one open folder and messages that are // also in another one; for a selection, every selected message must be. const folderToLeave = useEmailStore(removalFolder); @@ -449,6 +454,14 @@ export function EmailContextMenu({ disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam} destructive={!isInJunkFolder && !mountainView} /> + {batchPhishing.available && ( + handleAction(batchPhishing.report)} + /> + )} )} diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index ab3fa6986..ae5cc3050 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -14,7 +14,8 @@ import { ViewSourceDialog } from "./view-source-dialog"; import { useCopyToAccount, useEmailExport, requestPrint } from "@/hooks/use-message-file-actions"; import { BatchTagButton } from "./batch-tag-button"; import { cn, cleanPreview } from "@/lib/utils"; -import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX } from "@/components/icons"; +import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX, Fish } from "@/components/icons"; +import { usePhishingReport } from "@/hooks/use-phishing-report"; import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; @@ -552,6 +553,9 @@ export function EmailList({ () => (hasSelection ? resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }) : []), [hasSelection, emails, threadEmailsCache, selectedEmailKeys], ); + // Report phishing for the selection, one report per message (enterprise pack). + const selectionPhishing = usePhishingReport(selectedEmailsForRemoval, effectiveMailboxRole); + const tPhishing = useTranslations('phishing_report'); const selectionCanLeaveFolder = selectedEmailsForRemoval.length > 0 && selectedEmailsForRemoval.every((email) => canRemoveFromFolder(email, folderToLeave)); const handleBatchRemoveFromFolder = async () => { @@ -1058,6 +1062,16 @@ export function EmailList({ {effectiveMailboxRole === 'junk' ? : } )} + {selectionPhishing.available && !batchNarrow && ( + + + + )} {!batchNarrow && ( <> handleBatchMarkAsRead(true)} disabled={isProcessing}> diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 9f384b391..6fc55b0ec 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -32,6 +32,9 @@ import { ContextMenu } from "@/components/ui/context-menu"; import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu"; import { EmailContextMenu } from "./email-context-menu"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions"; +import { PhishingMarkBanner } from "./phishing-mark-banner"; +import { turnOffLinks } from "@/lib/phishing-links"; +import { usePhishingMarked } from "@/hooks/use-phishing-report"; import { useCopyToAccount, useEmailExport, usePrintRequestStore } from "@/hooks/use-message-file-actions"; import { useBottomControls } from "@/hooks/use-bottom-controls"; import { useComposingSurface } from "@/hooks/use-composing-surface"; @@ -132,6 +135,7 @@ import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlo import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { ExternalSenderChip } from "./external-sender-chip"; +import { ReceivedLabelChip } from "./received-label-chip"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; import { ReadReceiptBanner } from "./read-receipt-banner"; @@ -858,6 +862,7 @@ export function MvExpandedConversationCard({ )} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -1313,9 +1318,11 @@ export function EmailViewer({ const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy); // No third-party traffic (#903), or the organisation locked "block": remote // content never loads, whatever trust says, and nothing offers to load it. - const remoteBlockedByOrg = useRemoteContentBlockedByOrg(); + // The policy of the message's own account: in the unified inbox or a Pro + // tab that may be another signed-in account than the active one. + const remoteBlockedByOrg = useRemoteContentBlockedByOrg(email?.sourceClientAccountId); // Also true while the policy is still loading: trust can't unblock yet. - const remoteHeldBack = useRemoteContentHeldBack(); + const remoteHeldBack = useRemoteContentHeldBack(email?.sourceClientAccountId); const tThirdParty = useTranslations('third_party'); const messageSpacing = useSettingsStore((state) => state.messageSpacing); const plainTextFont = useSettingsStore((state) => state.plainTextFont); @@ -2647,10 +2654,15 @@ export function EmailViewer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [email, externalContentPolicy, allowExternalContent, remoteHeldBack]); + // Reported as phishing ($phishing): a banner, its links turned off and + // no cards (a phishing mail's "booking" is the lure). + const phishingMarked = usePhishingMarked(email); + const tPhishing = useTranslations('phishing_report'); + // Booking, parcel and order cards of the message itself (never of an // unwrapped attached .eml); empty unless the sender is verified and the // admin gate is on. - const structuredCards = useStructuredMail(email, blobSource); + const structuredCards = useStructuredMail(phishingMarked ? null : email, blobSource); // Sanitize and prepare email HTML content const emailContent = useMemo(() => { @@ -2807,14 +2819,14 @@ export function EmailViewer({ return text?.trim() ? text : htmlToReadableText(decodedEmailContent.html); }, [decodedEmailContent, emailContent, email]); const effectiveEmailContent = useMemo(() => { - if (!plainView || !decodedEmailContent.isHtml) return decodedEmailContent; - return { + const shown = !plainView || !decodedEmailContent.isHtml ? decodedEmailContent : { html: collapsePlainTextQuotes(plainTextToSafeHtml(readableText()), { show: t('show_quoted_text'), hide: t('hide_quoted_text') }), isHtml: false, hasStyleTag: false, externalBlocked: false, }; - }, [plainView, decodedEmailContent, readableText, t]); + return phishingMarked ? { ...shown, html: turnOffLinks(shown.html, { title: tPhishing('link_off'), note: tPhishing('link_off_note') }) } : shown; + }, [plainView, decodedEmailContent, readableText, t, phishingMarked, tPhishing]); // Reader view: the message full screen in the app's own look. const [readerViewOpen, setReaderViewOpen] = useState(false); // Reading mask: dims the body except a band that follows pointer and caret. @@ -2822,8 +2834,9 @@ export function EmailViewer({ const bodyWrapRef = useRef(null); const readerViewHtml = useMemo(() => { if (!readerViewOpen) return ''; - return decodedEmailContent.isHtml ? plainTextToSafeHtml(readableText()) : decodedEmailContent.html; - }, [readerViewOpen, decodedEmailContent, readableText]); + const html = decodedEmailContent.isHtml ? plainTextToSafeHtml(readableText()) : decodedEmailContent.html; + return phishingMarked ? turnOffLinks(html, { title: tPhishing('link_off'), note: tPhishing('link_off_note') }) : html; + }, [readerViewOpen, decodedEmailContent, readableText, phishingMarked, tPhishing]); const resolveAttachmentName = useCallback( (attachment: EffectiveAttachment) => { @@ -3742,7 +3755,8 @@ export function EmailViewer({ return extractListHeaders(email.headers); }, [email?.headers]); - const shouldShowUnsubBanner = + // Not for a reported message: its unsubscribe link is the phisher's. + const shouldShowUnsubBanner = !phishingMarked && listHeaders?.listUnsubscribe?.preferred && !dismissedUnsubBanners.has(email?.messageId || ''); @@ -4914,6 +4928,7 @@ export function EmailViewer({ )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + {senderVerification && ( )} + {phishingMarked && } + {/* Draft Banner: a scheduled message has its own. */} {isDraft && !isScheduled && (
    @@ -6138,6 +6157,7 @@ export function EmailViewer({ onClose={() => setInAppPreview(null)} onDownload={downloadInAppPreview} getFileContent={getInAppPreviewContent} + linksOff={phishingMarked} /> )} diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 8b853ab15..fdfa78da4 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -7,6 +7,7 @@ import { emailLinkSource } from "@/stores/email-store"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { buildMailPath } from "@/lib/deep-links"; import { useCopyLink } from "@/hooks/use-copy-link"; +import { usePhishingReport } from "@/hooks/use-phishing-report"; import type { Email } from "@/lib/jmap/types"; import { Reply, @@ -36,6 +37,7 @@ import { Download, Printer, Code, + Fish, } from "@/components/icons"; type Icon = ComponentType<{ className?: string }>; @@ -61,6 +63,7 @@ export type MessageActionKey = | "star" | "pin" | "spam" + | "report_phishing" | "find_from_sender" | "export" | "print" @@ -145,10 +148,14 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const t = useTranslations("context_menu"); const tViewer = useTranslations("email_viewer"); const tDeepLink = useTranslations("deep_link"); + const tPhishing = useTranslations("phishing_report"); const copyLink = useCopyLink(); const archiveEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); const { email } = o; + // Report phishing (enterprise pack): while the admin set an address for + // the message's account. It sends mail, so it opens a confirm first. + const phishing = usePhishingReport([email], o.currentMailboxRole); const isUnread = !email.keywords?.$seen; const isStarred = !!email.keywords?.$flagged; @@ -248,6 +255,9 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr testId: inJunk ? "ctx-not-spam" : "ctx-spam", })] : []; + const reportPhishing = phishing.available + ? [item("report_phishing", Fish, tPhishing("action"), phishing.report, { testId: "ctx-report-phishing" })] + : []; const sender = email.from?.[0]; const find = o.onFindFromSender && sender?.email ? [SEPARATOR, item("find_from_sender", Search, t("find_emails_from", { name: sender.name || sender.email }), o.onFindFromSender, { testId: "ctx-find-from" })] @@ -263,12 +273,12 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr ? [ ...draft, ...replies, ...archive, del, read, SEPARATOR, ...move, tag, ...rules, ...copy, SEPARATOR, - star, ...pin, ...spam, ...find, SEPARATOR, + star, ...pin, ...spam, ...reportPhishing, ...find, SEPARATOR, ...links, ...file, ] : [ ...draft, ...replies, ...links, ...archive, del, SEPARATOR, - ...move, ...copy, ...rules, star, ...pin, tag, ...spam, SEPARATOR, + ...move, ...copy, ...rules, star, ...pin, tag, ...spam, ...reportPhishing, SEPARATOR, read, SEPARATOR, ...file, ]; } diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index c95c8dcab..e3ddc9dcf 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -18,7 +18,9 @@ import { Tag, Trash2, RefreshCw, + Fish, } from "@/components/icons"; +import { usePhishingReport } from "@/hooks/use-phishing-report"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; import { useShallow } from "zustand/react/shallow"; @@ -202,6 +204,10 @@ export function MvListToolbar({ const isInTrash = currentRole === "trash"; // Marking your own drafts or sent mail as spam is meaningless. const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? ""); + // Report phishing for the selection, one report per message (enterprise pack). + const selectionForPhishing = useEmailStore(useShallow((state) => resolveSelectedEmails(state))); + const phishing = usePhishingReport(selectionForPhishing, currentRole); + const tPhishing = useTranslations("phishing_report"); // Every action reports its outcome the way the default toolbar does: a // rejected promise or a store error turns into an error toast instead of @@ -516,6 +522,14 @@ export function MvListToolbar({ disabled={isProcessing} /> )} + {phishing.available && ( + + )} {selectionCanLeaveFolder && ( { if (p.hasAttribute(SIGNATURE_RANGE_MARKER)) return; @@ -110,7 +114,7 @@ export function keepLoadedParagraphSpacing(html: string): string { p.setAttribute("style", MAIL_PARAGRAPH_MARGIN + style); stamped = true; }); - return stamped ? root.innerHTML : html; + return stamped ? template.innerHTML : html; } /** A paragraph's `style` without the margin addParagraphSpacing put in front. */ diff --git a/components/email/phishing-mark-banner.tsx b/components/email/phishing-mark-banner.tsx new file mode 100644 index 000000000..b8cd02896 --- /dev/null +++ b/components/email/phishing-mark-banner.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Fish } from '@/components/icons'; +import { cn } from '@/lib/utils'; + +/** "You reported this message as phishing. Its links are turned off." above a message with $phishing. */ +export function PhishingMarkBanner({ className }: { className?: string }) { + const t = useTranslations('phishing_report'); + return ( +
    +
    + ); +} diff --git a/components/email/phishing-report-host.tsx b/components/email/phishing-report-host.tsx new file mode 100644 index 000000000..06d31e925 --- /dev/null +++ b/components/email/phishing-report-host.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useId, type KeyboardEvent } from 'react'; +import { useTranslations } from 'next-intl'; +import { Loader2, ShieldAlert } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { useFocusTrap } from '@/hooks/use-focus-trap'; +import { reportEmailsAsPhishing, undoPhishingReports, type PhishingReportOutcome } from '@/lib/phishing-report-flow'; +import { usePhishingReportStore } from '@/stores/phishing-report-store'; +import { toast } from '@/stores/toast-store'; + +/** + * "Report phishing?" with the address the report goes to, then the + * reports, one per message, and a toast with Undo. Mounted once in the app + * layout; every menu asks through usePhishingReportStore. + */ +export function PhishingReportHost() { + const t = useTranslations('phishing_report'); + const pending = usePhishingReportStore((s) => s.pending); + const busy = usePhishingReportStore((s) => s.busy); + const id = useId(); + const dialogRef = useFocusTrap({ isActive: !!pending, restoreFocus: false }); + + const cancel = () => { + if (!busy) usePhishingReportStore.getState().close(); + }; + + const showOutcome = (outcome: PhishingReportOutcome) => { + const { reported, failed, notMoved, moved } = outcome; + const address = outcome.addresses.join(', '); + if (reported.length > 0) { + const undo = moved.length > 0 + ? { + label: t('undo'), + onClick: () => { + void undoPhishingReports(outcome).then((back) => { + if (back > 0) toast.info(t('undone', { count: back })); + else toast.error(t('undo_failed')); + }); + }, + } + : undefined; + if (notMoved.length > 0) { + toast.warning(t('not_moved', { address, count: notMoved.length }), { action: undo }); + } else { + toast.success(t('done', { address, count: reported.length }), { action: undo }); + } + } + if (failed.length > 0) { + toast.error(t('failed', { count: failed.length }), { + message: failed.map((email) => email.subject || t('no_subject')).join(', '), + }); + } + }; + + const confirm = async () => { + const state = usePhishingReportStore.getState(); + if (!state.pending || state.busy) return; + state.setBusy(true); + let outcome: PhishingReportOutcome | null = null; + try { + outcome = await reportEmailsAsPhishing(state.pending.emails); + } catch { + outcome = null; + } + const emails = state.pending.emails; + usePhishingReportStore.getState().close(); + if (outcome) showOutcome(outcome); + else toast.error(t('failed', { count: emails.length })); + }; + + // Keys stay in the dialog (Escape must not close the message behind it, + // j/k must not move the list); Enter and Space press the focused button. + const onKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + if (e.key === 'Escape') { + e.preventDefault(); + cancel(); + return; + } + if ((e.key === 'Enter' || e.key === ' ') && e.target instanceof HTMLButtonElement) { + e.preventDefault(); + e.target.click(); + } + }; + + if (!pending) return null; + const count = pending.emails.length; + + return ( +
    { + if (e.target === e.currentTarget) cancel(); + }} + onKeyDown={onKeyDown} + > +
    +
    +
    +
    +
    +
    +

    {t('confirm_title')}

    +

    + {t('confirm_message', { address: pending.addresses.join(', '), count })} +

    +
    +
    +
    +
    + + +
    +
    +
    + ); +} diff --git a/components/email/quoted-html.ts b/components/email/quoted-html.ts index 426d6c7f4..d1aa476df 100644 --- a/components/email/quoted-html.ts +++ b/components/email/quoted-html.ts @@ -9,7 +9,7 @@ import { addParagraphSpacing, addTrailingParagraphBreaks } from "@/components/em import { buildSignatureBlock } from "@/components/email/signature-block"; import { parkRemoteResources, restoreRemoteResources, sanitizeComposerIslandHtml } from "@/lib/email-sanitization"; import { useSettingsStore } from "@/stores/settings-store"; -import { remoteContentHeldBack } from "@/hooks/use-third-party-mode"; +import { remoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode"; // Marker attribute that identifies the quoted-original wrapper in serialized // HTML, so parseHTML can recognise it on the way back in. @@ -170,7 +170,9 @@ export const QuotedHtml = TiptapNode.create({ // A draft re-opened after the organisation blocked remote content (or // before its policy loaded) keeps its quote parked, whatever the // wrapper remembered from when the draft was started. - const blockRemote = remoteContentHeldBack() + // The draft doesn't say which account it quotes: held back in any + // signed-in account keeps it parked. + const blockRemote = remoteContentHeldBackAnywhere() || (!node.attrs.remoteAllowed && useSettingsStore.getState().externalContentPolicy !== "allow"); let renderedHtml = ""; const render = (html: string) => { @@ -257,6 +259,10 @@ export const QuotedHtml = TiptapNode.create({ */ export function serializeEditorContent(editor: Editor): string { const serializer = DOMSerializer.fromSchema(editor.schema); + // Built in an inert document: an made in the app document starts + // loading at once, so serialising (on every change) fired every remote + // image of the content, also while remote content is blocked. + const inert = document.implementation.createHTMLDocument(""); const parts: string[] = []; // Empty paragraphs at the very end (the editor keeps one after a quote) // stay invisible; every other empty line gets a
    so it keeps its @@ -280,8 +286,8 @@ export function serializeEditorContent(editor: Editor): string { parts.push(buildSignatureBlock((node.attrs.html as string) || "")); return; } - const fragment = serializer.serializeNode(node); - const tmp = document.createElement("div"); + const fragment = serializer.serializeNode(node, { document: inert }); + const tmp = inert.createElement("div"); tmp.appendChild(fragment); if (index <= lastLine) addTrailingParagraphBreaks(tmp); addParagraphSpacing(tmp); diff --git a/components/email/received-label-chip.tsx b/components/email/received-label-chip.tsx new file mode 100644 index 000000000..f28d02b54 --- /dev/null +++ b/components/email/received-label-chip.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { LabelIcon } from '@/components/icons'; +import type { Email } from '@/lib/jmap/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { receivedLabel, textColorOn } from '@/lib/classification/labels'; +import { useAccountPolicy } from '@/hooks/use-account-policy'; +import { cn } from '@/lib/utils'; + +/** + * The classification label a received message carries, when it is one of + * the labels of the message's own account (SIO-Label marking, else the + * admin's extra header). Information only: anyone can write the header, + * so nothing is relaxed because of it. List rows don't show it (they don't + * fetch the headers). + */ +export function ReceivedLabelChip({ email, className }: { email: Pick; className?: string }) { + const t = useTranslations('classification'); + const policy = useAccountPolicy(email.sourceClientAccountId); + const labels = policy.organisation?.labels ?? DEFAULT_ORGANISATION_POLICY.labels; + const label = useMemo(() => receivedLabel(email.headers, labels), [email.headers, labels]); + if (!label) return null; + const name = t('chip_label', { name: label.name }); + return ( + + + ); +} diff --git a/components/email/resizable-image.tsx b/components/email/resizable-image.tsx index 906b441a5..bbed84d3c 100644 --- a/components/email/resizable-image.tsx +++ b/components/email/resizable-image.tsx @@ -4,6 +4,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { Node, mergeAttributes } from "@tiptap/core"; import { NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; import type { NodeViewProps } from "@tiptap/react"; +import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode"; +import { TRANSPARENT_BLOCKED_PIXEL } from "@/lib/email-sanitization"; export interface ResizableImageOptions { /** Open the alt text dialog for the image at this position. */ @@ -16,6 +18,12 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo const { onEditAlt, editAltLabel } = extension.options as ResizableImageOptions; const imgRef = useRef(null); const [resizing, setResizing] = useState(false); + // A remote image (a draft from another client, pasted web content) stays + // parked while the organisation blocks remote content or its policy isn't + // known yet; the address stays in the message. + const heldBack = useRemoteContentHeldBackAnywhere(); + const src = typeof node.attrs.src === "string" ? node.attrs.src : ""; + const parked = heldBack && /^(https?:)?\/\//i.test(src.trim()); const startState = useRef<{ x: number; y: number; width: number; height: number; handle: string }>({ x: 0, y: 0, width: 0, height: 0, handle: "", }); @@ -78,7 +86,8 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo > {node.attrs.alt state.addToTrustedSendersBook); const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); const [allowedIds, setAllowedIds] = useState>(new Set()); - // No third-party traffic (#903), or the organisation locked "block": - // nothing remote loads and nothing offers to. - const remoteBlockedByOrg = useRemoteContentBlockedByOrg(); - // Also true until the policy has loaded: trust can't unblock before it. - const remoteHeldBack = useRemoteContentHeldBack(); + // No third-party traffic (#903), or the organisation locked "block" for + // the message's own account: nothing remote loads and nothing offers to. + // Also held back until that account's policy is known. + const remoteContent = useRemoteContentChecks(); const allow = useCallback((emailId: string) => { setAllowedIds(prev => new Set(prev).add(emailId)); @@ -122,13 +125,13 @@ export function useConversationExternalContent() { const senderIsTrusted = senderEmail ? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail)) : false; - if (remoteHeldBack) return false; + if (remoteContent.heldBack(email.sourceClientAccountId)) return false; return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id); }; const trustSender = (email: Email) => { const senderEmail = email.from?.[0]?.email?.toLowerCase(); - if (!senderEmail || remoteBlockedByOrg) return undefined; + if (!senderEmail || remoteContent.blockedByOrg(email.sourceClientAccountId)) return undefined; return () => { if (trustedSendersAddressBook && client) { addToTrustedSendersBook(client, senderEmail).catch(console.error); @@ -395,6 +398,7 @@ function EmailCard({ {sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')} + {email.isScheduled && ( @@ -545,7 +549,7 @@ export function ConversationEmailBody({ bodyStyle, className, }: ConversationEmailBodyProps) { - const remoteBlockedByOrg = useRemoteContentBlockedByOrg(); + const remoteBlockedByOrg = useRemoteContentBlockedByOrg(email.sourceClientAccountId); const t = useTranslations(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction); @@ -572,8 +576,11 @@ export function ConversationEmailBody({ const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client))); const source = blobSource ?? fallbackSource; const { client: blobClient, accountId } = source; + // Reported as phishing: a banner, links off, no cards (as in the reader). + const phishingMarked = usePhishingMarked(email); + const tPhishing = useTranslations('phishing_report'); // Booking, parcel and order cards; only an expanded message mounts this body. - const structuredCards = useStructuredMail(email, source); + const structuredCards = useStructuredMail(phishingMarked ? null : email, source); // Local CID URLs belong to a particular client/owner/message/part list. // Hide the previous set during render, before the new fetch's effect runs. const cidRequest = useMemo(() => ({ blobClient, accountId, emailId: email.id, attachments: email.attachments }), @@ -740,7 +747,7 @@ export function ConversationEmailBody({ // "Show mail as plain text" (Settings > Accessibility): the message's own // text/plain part, or a readable conversion of the HTML. const mailViewMode = useSettingsStore((state) => state.mailViewMode); - const emailContent = useMemo(() => { + const shownContent = useMemo(() => { if (mailViewMode !== 'plain' || !formattedContent.isHtml) return formattedContent; const textPart = !pluginBody.html ? email.textBody?.[0] : undefined; let text = textPart?.partId && textPart.partId !== email.htmlBody?.[0]?.partId && (textPart.type || 'text/plain').toLowerCase() === 'text/plain' @@ -755,6 +762,10 @@ export function ConversationEmailBody({ isHtml: false, }; }, [mailViewMode, formattedContent, pluginBody.html, email, t]); + const emailContent = useMemo( + () => (phishingMarked ? { ...shownContent, html: turnOffLinks(shownContent.html, { title: tPhishing('link_off'), note: tPhishing('link_off_note') }) } : shownContent), + [shownContent, phishingMarked, tPhishing], + ); // Parts the body embeds via cid: stay out of the attachment row while the // user hides inline images - the desktop viewer's rule, shared through @@ -897,6 +908,7 @@ export function ConversationEmailBody({ return (
    + {phishingMarked && } {/* External content warning */} {hasBlockedContent && !allowExternal && emailContent.isHtml && (
    diff --git a/components/email/use-composer-classification.ts b/components/email/use-composer-classification.ts new file mode 100644 index 000000000..86850bb19 --- /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, type OriginalMessage } 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 of a reply or forward. */ + original?: OriginalMessage | 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.original), [policy, choice, input.original]); + return { + policy, + label, + choice, + setChoice, + missing: labelMissing(policy, label), + draftChoice: policy.list.length === 0 ? undefined : (label?.id ?? null), + }; +} diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index 0ff73b06e..c41622166 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -3,11 +3,12 @@ import { useTranslations } from "next-intl"; import { Paperclip, Download } from "@/components/icons"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; +import { turnOffLinks } from "@/lib/phishing-links"; import { getEffectiveTimeZone } from "@/lib/timezone"; import { stripBidiControls } from "@/lib/bidi"; import { isFilePreviewable } from "@/lib/file-preview"; import { useSettingsStore } from "@/stores/settings-store"; -import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; +import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode"; // A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields // this preview renders are typed. @@ -48,20 +49,27 @@ function escapeHtml(s: string): string { // can show - often another message: a certified-mail (PEC) envelope wraps the // original, and a forwarded PEC wraps that envelope - opens through // onOpenAttachment, with download beside it. -export function EmlPreview({ message, onOpenAttachment }: { +export function EmlPreview({ message, onOpenAttachment, linksOff }: { message: ParsedEml; onOpenAttachment?: (attachment: EmlAttachment) => void; + /** Attached to a message reported as phishing: links are off. */ + linksOff?: boolean; }) { const t = useTranslations("email_viewer"); - // Always blocked with no third-party traffic (#903). - const remoteBlockedByOrg = useNoThirdPartyTraffic(); + const tPhishing = useTranslations("phishing_report"); + // Always blocked with no third-party traffic (#903), while an + // organisation locks "block" in any signed-in account (the attachment's + // account isn't known here), and until the policy is known. + const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere(); const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; const tFiles = useTranslations("files"); // dir="auto" on a wrapper we control lets the first strong character pick // the direction, matching the main viewer. const body = message.html - ? `
    ${sanitizeEmailBodyForIframe(message.html, blockRemote).html}
    ` + ? `
    ${linksOff + ? turnOffLinks(sanitizeEmailBodyForIframe(message.html, blockRemote).html, { title: tPhishing("link_off"), note: tPhishing("link_off_note") }) + : sanitizeEmailBodyForIframe(message.html, blockRemote).html}
    ` : message.text ? `
    ${escapeHtml(message.text)}
    ` : ""; diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index b1bbc10c1..d8442635f 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -14,10 +14,11 @@ import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; import { stripBidiControls } from "@/lib/bidi"; import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization"; +import { turnOffLinks, type LinksOffText } from "@/lib/phishing-links"; import { useSettingsStore } from "@/stores/settings-store"; import { useCloseOnBack } from "@/hooks/use-close-on-back"; import type { FileResourceView } from "@/lib/plugin-types"; -import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; +import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode"; /** * An HTML attachment as a document for a fully locked frame (sandbox="", @@ -29,8 +30,10 @@ import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode"; * (the mechanism of GHSA-xvjh-v9c6-qcvc), while nothing sanitised it or * stopped its remote images and meta refreshes. */ -export function htmlAttachmentDocument(html: string, blockRemote: boolean): string { - const body = sanitizeEmailBodyForIframe(html, blockRemote).html; +export function htmlAttachmentDocument(html: string, blockRemote: boolean, linksOff?: LinksOffText): string { + const clean = sanitizeEmailBodyForIframe(html, blockRemote).html; + // An attachment of a message reported as phishing: its links are off too. + const body = linksOff ? turnOffLinks(clean, linksOff) : clean; return `${body}`; } @@ -51,6 +54,8 @@ interface FilePreviewModalProps { file?: FileResourceView | null; /** The previous and next file of the folder, for ←/→. */ neighbours?: PreviewNeighbours; + /** The file is an attachment of a message reported as phishing: links in it are off. */ + linksOff?: boolean; } function SimpleMarkdown({ content }: { content: string }) { @@ -144,8 +149,13 @@ function processInline(text: string): React.ReactNode { return parts.length === 1 ? parts[0] : <>{parts}; } -export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours }: FilePreviewModalProps) { +export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff }: FilePreviewModalProps) { const t = useTranslations("files"); + const tPhishing = useTranslations("phishing_report"); + const linksOffText = useMemo( + () => (linksOff ? { title: tPhishing("link_off"), note: tPhishing("link_off_note") } : undefined), + [linksOff, tPhishing], + ); const [content, setContent] = useState(null); const [objectUrl, setObjectUrl] = useState(null); const [loading, setLoading] = useState(true); @@ -163,8 +173,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // open-in-new-tab fallback use. const [pdfBlob, setPdfBlob] = useState(null); const [htmlDoc, setHtmlDoc] = useState(null); - // Always blocked with no third-party traffic (#903). - const remoteBlockedByOrg = useNoThirdPartyTraffic(); + // Always blocked with no third-party traffic (#903), while an + // organisation locks "block" in any signed-in account (the attachment's + // account isn't known here), and until the policy is known. + const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere(); const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg; // An attachment of the previewed .eml, shown in a preview of its own in // place of this one. It holds a Back entry of its own, so Escape and Back @@ -225,7 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi // Sanitised text in a srcdoc frame, like the .eml preview; never a // blob: URL of the sender's bytes (see htmlAttachmentDocument). const text = await blob.text(); - if (!cancelled) setHtmlDoc(htmlAttachmentDocument(text, blockRemote)); + if (!cancelled) setHtmlDoc(htmlAttachmentDocument(text, blockRemote, linksOffText)); } else if (previewType === "eml") { // Parse the embedded message (postal-mime, dynamic-imported so it // stays off the bundle) and render it like an email via EmlPreview. @@ -269,7 +281,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi URL.revokeObjectURL(revokeUrl); } }; - }, [getFileContent, name, blockRemote]); + }, [getFileContent, name, blockRemote, linksOffText]); const openEmlAttachment = (att: EmlAttachment) => { if (!att.content) return; @@ -308,6 +320,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi onClose={() => setNestedPreview(null)} onDownload={downloadNested} getFileContent={nestedContent} + linksOff={linksOff} /> ); } @@ -352,7 +365,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi )} {!loading && !error && fileType === "eml" && emlContent && ( - + )} {!loading && !error && fileType === "image" && objectUrl && ( diff --git a/components/icons/index.ts b/components/icons/index.ts index a110750dd..ad4e220f4 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -102,6 +102,7 @@ export { IconFileText as FileText, IconVideo as FileVideo, IconFilter as Filter, + IconFish as Fish, IconFlag as Flag, IconFlask as FlaskConical, IconFold as FoldVertical, @@ -134,6 +135,7 @@ export { IconKey as KeyRound, IconKeyboard as Keyboard, IconLanguage as Languages, + IconLabel as LabelIcon, IconStack as Layers, IconLayoutDashboard as LayoutDashboard, IconLayoutGrid as LayoutGrid, @@ -141,6 +143,7 @@ export { IconTemplate as LayoutTemplate, IconBulb as Lightbulb, IconLink as Link, + IconLinkOff as LinkOff, IconList as List, IconListNumbers as ListOrdered, IconChecklist as ListTodo, diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 3718674e6..cecc35ceb 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 { isPhishingMarked } from "@/lib/phishing-links"; +import { automaticLabel, classifyOutgoing, labelSourceHeaders, reopenedLabelChoice } from "@/lib/outgoing/classification"; import enMessages from "@/locales/en/common.json"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; @@ -300,7 +303,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); - const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable } | null>(null); + // linksOff: the attachment of a message reported as phishing. + const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable; linksOff?: boolean } | null>(null); // Office attachments open read-only in the configured WOPI editor (#1047). const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null); const wopiStatus = useWopiStatus(true); @@ -1333,6 +1337,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 +2164,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 +2189,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 +2502,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { inReplyTo: draft.inReplyTo ?? undefined, references: draft.references ?? undefined, priority: messagePriorityFromHeaders(draft.headers), + // A sent message brought back (Undo, Cancel and edit) has no draft + // marker: its SIO-Label says which label it went out with. + classification: reopenedLabelChoice(draft.headers, localAccountId), // 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 +2856,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { replyTo: { subject: email.subject, attachments: [payload.attachment], + headers: labelSourceHeaders(email.headers), + originalFrom: email.from, }, }); setComposerMode('forward'); @@ -3959,7 +3971,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { try { const { mailAttachmentAction } = useSettingsStore.getState(); if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) { - setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient }); + setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient, linksOff: isPhishingMarked(email) }); return; } if (mailAttachmentAction === 'preview' && openOfficeAttachment( @@ -4019,7 +4031,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const { mailAttachmentAction } = useSettingsStore.getState(); if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) { - setPreviewAttachment({ blobId, name, type, accountId, client: blobClient }); + setPreviewAttachment({ blobId, name, type, accountId, client: blobClient, linksOff: isPhishingMarked(selectedEmail) }); return; } if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment( @@ -4084,6 +4096,21 @@ 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 }, { + headers: selectedEmail.headers, + from: selectedEmail.from, + accountId: selectedEmail.sourceClientAccountId, + }); + 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 +4182,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 +4205,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 +4855,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, @@ -5765,6 +5800,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onClose={() => setPreviewAttachment(null)} onDownload={handlePreviewAttachmentDownload} getFileContent={getPreviewAttachmentContent} + linksOff={previewAttachment.linksOff} /> )} 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..7a9537028 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -24,6 +24,7 @@ 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, reopenedLabelChoice } from '@/lib/outgoing/classification'; import { useArchiveActionEnabled } from '@/lib/archive-gate'; import { confirmMailRemoval } from '@/lib/mail-removal-confirm'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; @@ -67,6 +68,25 @@ interface ProEmailViewProps { className?: string; } +/** + * A message the tab read through another login, or from a shared or group + * account, carries where it came from (sourceClientAccountId, + * sourceAccountId), as rows of the unified inbox do: then actions that + * route by the message (Report phishing, the remote-content lock of that + * account) reach its own account. A message of the active login's own + * account stays as it is. + */ +export function stampProSource(email: Email, source: EmailBlobContext, loginId: string | undefined): Email { + if (email.sourceClientAccountId && email.sourceAccountId) return email; + const activeLogin = useAuthStore.getState().activeAccountId ?? undefined; + const login = loginId ?? source.clientAccountId ?? activeLogin; + const ownAccount = source.client?.getAccountId?.(); + const account = source.accountId ?? ownAccount; + const foreign = (!!login && login !== activeLogin) || (!!account && !!ownAccount && account !== ownAccount); + if (!foreign || !login || !account) return email; + return { ...email, sourceClientAccountId: login, sourceAccountId: account }; +} + function buildReplyContext(email: Email): ProReplyContext { return { from: email.from, @@ -83,6 +103,7 @@ function buildReplyContext(email: Email): ProReplyContext { messageId: email.messageId, inReplyTo: email.inReplyTo, references: email.references, + headers: labelSourceHeaders(email.headers), }; } @@ -200,7 +221,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, .then((loaded) => { if (cancelled) return; pending.applied = true; - setEmail(loaded); + setEmail(loaded ? stampProSource(loaded, pending.blobSource, clientOverride ? clientAccountId : undefined) : loaded); setBlobSource(pending.blobSource); if (loaded) onLoadedRef.current?.(loaded); }) @@ -213,7 +234,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, return () => { cancelled = true; }; - }, [client, clientOverride, emailId, accountId, fetchEmailContent]); + }, [client, clientOverride, clientAccountId, emailId, accountId, fetchEmailContent]); const currentMailboxRole = useMemo(() => { if (!email) return undefined; @@ -287,6 +308,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, replyTo: { subject: email.subject, attachments: [payload.attachment], + headers: labelSourceHeaders(email.headers), + originalFrom: email.from, }, sourceEmailId: email.id, sourceEmailSource: writeSource, @@ -467,6 +490,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: reopenedLabelChoice(email.headers, localAccountId), }, }, { to: addresses(email.to), cc: addresses(email.cc), subject: draftSubject, body: bodyText }, onClose); }, [email, accountId, localAccountId, identities, multiAccountIdentities, openComposeTab, onClose, t]); diff --git a/hooks/use-phishing-report.ts b/hooks/use-phishing-report.ts new file mode 100644 index 000000000..4cdd5801f --- /dev/null +++ b/hooks/use-phishing-report.ts @@ -0,0 +1,72 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { isReportable } from '@/lib/phishing-report'; +import { isPhishingMarked } from '@/lib/phishing-links'; +import { phishingSetupForEmail } from '@/lib/phishing-report-flow'; +import { useAccountSubjects } from '@/lib/org/account-subjects'; +import { emailKeyFor } from '@/lib/thread-utils'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePhishingReportStore } from '@/stores/phishing-report-store'; + +/** + * The roles of the folders a message is in, from the mailboxes the store + * knows: in search results or All mail the open folder says nothing about + * whether a message is the user's own sent mail. + */ +function folderRoles(email: Email, mailboxes: Mailbox[]): string[] { + const roles: string[] = []; + for (const id of Object.keys(email.mailboxIds ?? {})) { + const box = mailboxes.find((m) => m.id === id || m.originalId === id); + if (box?.role) roles.push(box.role); + } + return roles; +} + +function reportable(email: Email, mailboxRole: string | null | undefined, mailboxes: Mailbox[]): boolean { + return isReportable(email, mailboxRole, folderRoles(email, mailboxes)) && !!phishingSetupForEmail(email); +} + +/** Asks "Report phishing?" for the messages of `emails` that can be reported. */ +export function requestPhishingReport(emails: Email[], mailboxRole?: string | null): void { + const mailboxes = useEmailStore.getState().mailboxes; + const list = emails.filter((email) => reportable(email, mailboxRole, mailboxes)); + if (list.length === 0) return; + // A selection across accounts may go to more than one address: name each. + const addresses = [...new Set(list.map((email) => phishingSetupForEmail(email)!.address))]; + usePhishingReportStore.getState().ask(list, addresses); +} + +/** + * "Report phishing" for a set of messages in a folder of `mailboxRole`: + * available while the admin set an address for at least one of their + * accounts and one of them can be reported. Follows the policy as it loads. + */ +export function usePhishingReport(emails: ReadonlyArray, mailboxRole?: string | null) { + const policy = usePolicyStore((s) => s.policy); + const rawPolicy = usePolicyStore((s) => s.rawPolicy); + const subjects = useAccountSubjects((s) => s.subjects); + const mailboxes = useEmailStore((s) => s.mailboxes); + const reported = usePhishingReportStore((s) => s.reported); + const list = useMemo(() => emails.filter((e): e is Email => !!e), [emails]); + const candidates = useMemo( + () => list.filter((email) => !reported[emailKeyFor(email)] && reportable(email, mailboxRole, mailboxes)), + // The setup is read from the stores these follow. + // eslint-disable-next-line react-hooks/exhaustive-deps + [list, mailboxRole, mailboxes, reported, policy, rawPolicy, subjects], + ); + const report = useCallback(() => requestPhishingReport(candidates, mailboxRole), [candidates, mailboxRole]); + return { available: candidates.length > 0, count: candidates.length, report }; +} + +/** + * Whether a message shows as reported: its $phishing keyword, or reported + * in this session (a view that holds its own copy of the message, like a + * Pro tab, doesn't see the new keyword until it loads the message again). + */ +export function usePhishingMarked(email: Email | null | undefined): boolean { + const reported = usePhishingReportStore((s) => (email ? s.reported[emailKeyFor(email)] === true : false)); + return isPhishingMarked(email) || reported; +} diff --git a/hooks/use-third-party-mode.ts b/hooks/use-third-party-mode.ts index f021184b4..5937513e4 100644 --- a/hooks/use-third-party-mode.ts +++ b/hooks/use-third-party-mode.ts @@ -2,7 +2,10 @@ import { useSyncExternalStore } from 'react'; import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { accountSubjectPending, subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects'; import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; /** * Whether the browser must stay off built-in outside hosts (#903), as React @@ -15,12 +18,22 @@ export function useNoThirdPartyTraffic(): boolean { type PolicyState = ReturnType; +/** + * The effective policy of a signed-in account (AccountEntry id): the active + * one's from the store, another one's (a message of it in the unified + * inbox or a Pro tab) resolved for its own domain and group scopes. + */ +function policyForAccount(state: PolicyState, accountId: string | null | undefined): SettingsPolicy { + const subject = subjectForAccount(accountId ?? null); + return subject === state.subject ? state.policy : state.resolveFor(subject); +} + /** * The external content setting locked to "block" by the organisation, or * limited to "block" as its only allowed value. */ -function externalContentLockedToBlock(state: PolicyState): boolean { - const restriction = state.getRestriction('externalContentPolicy'); +function externalContentLockedToBlock(policy: SettingsPolicy): boolean { + const restriction = policy.restrictions?.externalContentPolicy; const allowed = restriction?.allowedValues; if (Array.isArray(allowed) && allowed.length > 0 && !allowed.includes('allow') && !allowed.includes('ask')) return true; if (restriction?.locked !== true) return false; @@ -28,43 +41,103 @@ function externalContentLockedToBlock(state: PolicyState): boolean { // settings store applies it). const value = Object.prototype.hasOwnProperty.call(restriction, 'value') ? restriction.value - : state.getEffectiveDefault('externalContentPolicy'); + : policy.defaults?.externalContentPolicy; return value === 'block'; } +/** Another signed-in account than the active one. */ +function isOtherAccount(accountId: string | null | undefined): accountId is string { + const auth = useAuthStore.getState(); + return !!accountId && accountId !== auth.activeAccountId && !!auth.getClientForAccount?.(accountId); +} + /** - * Whether the organisation keeps remote content in mail from loading at all: - * no third-party traffic, or the external content setting locked to "block". - * Then neither a one-off load nor a trusted sender lets it through. + * Whether the policy that may lock remote content for `accountId` isn't + * known yet: the first fetch hasn't answered, only the public subset is in + * (just after sign-in), or the policy has domain or group scopes and the + * account's groups are still being worked out. A later refetch keeps the + * policy it already has, so it doesn't count. */ -export function useRemoteContentBlockedByOrg(): boolean { - const noThirdParty = useNoThirdPartyTraffic(); - const lockedToBlock = usePolicyStore(externalContentLockedToBlock); - return noThirdParty || lockedToBlock; +function policyPending(state: PolicyState, accountId: string | null | undefined): boolean { + if (state.fetching && !state.loaded) return true; + if (state.partial) return true; + if ((state.rawPolicy.scopes ?? []).length === 0) return false; + return isOtherAccount(accountId) ? accountSubjectPending(accountId) : state.subjectPending; } -/** useRemoteContentBlockedByOrg, read once outside React. */ -export function remoteContentBlockedByOrg(): boolean { - return noThirdPartyTraffic() || externalContentLockedToBlock(usePolicyStore.getState()); +/** Re-renders on anything the remote-content decisions read. */ +function usePolicyInputs(): void { + useSyncExternalStore(subscribeThirdPartyMode, noThirdPartyTraffic, () => true); + usePolicyStore((s) => s.policy); + usePolicyStore((s) => s.rawPolicy); + usePolicyStore((s) => s.subject); + usePolicyStore((s) => s.subjectPending); + usePolicyStore((s) => s.fetching); + usePolicyStore((s) => s.loaded); + usePolicyStore((s) => s.partial); + useAccountSubjects((s) => s.subjects); + useAccountSubjects((s) => s.settled); + useAuthStore((s) => s.activeAccountId); } -// The first policy fetch hasn't answered yet (a later refetch keeps the -// policy it already has, so it doesn't count). -const policyPending = (state: PolicyState) => state.fetching && !state.loaded; +/** + * Whether the organisation keeps remote content in mail from loading at all + * for the account a message belongs to (the active one when left out): no + * third-party traffic, or the external content setting locked to "block" in + * that account's policy. Then neither a one-off load nor a trusted sender + * lets it through. + */ +export function remoteContentBlockedByOrg(accountId?: string | null): boolean { + return noThirdPartyTraffic() || externalContentLockedToBlock(policyForAccount(usePolicyStore.getState(), accountId)); +} /** * Whether remote content stays blocked whatever the user chose: blocked by - * the organisation, or the policy that may lock it hasn't loaded yet. Until - * it has, neither a trusted sender nor a one-off load lets anything through, + * the organisation, or the policy that may lock it isn't known yet. Until + * it is, neither a trusted sender nor a one-off load lets anything through, * so the first paint can't fire a tracker the organisation blocks. */ -export function useRemoteContentHeldBack(): boolean { - const blockedByOrg = useRemoteContentBlockedByOrg(); - const pending = usePolicyStore(policyPending); - return blockedByOrg || pending; +export function remoteContentHeldBack(accountId?: string | null): boolean { + return remoteContentBlockedByOrg(accountId) || policyPending(usePolicyStore.getState(), accountId); } -/** useRemoteContentHeldBack, read once outside React. */ -export function remoteContentHeldBack(): boolean { - return remoteContentBlockedByOrg() || policyPending(usePolicyStore.getState()); +/** + * remoteContentHeldBack for every signed-in account: for content whose + * account isn't known (a re-opened draft's quote, which may come from any). + */ +export function remoteContentHeldBackAnywhere(): boolean { + const connected = useAuthStore.getState().getAllConnectedClients?.(); + const ids = connected ? [...connected.keys()] : []; + return remoteContentHeldBack() || ids.some((id) => remoteContentHeldBack(id)); +} + +/** remoteContentHeldBackAnywhere as React state (attachment previews, whose account isn't known). */ +export function useRemoteContentHeldBackAnywhere(): boolean { + usePolicyInputs(); + useAuthStore((s) => s.connectedAccountsRevision); + return remoteContentHeldBackAnywhere(); +} + +/** remoteContentBlockedByOrg as React state, for the account a message belongs to. */ +export function useRemoteContentBlockedByOrg(accountId?: string | null): boolean { + usePolicyInputs(); + return remoteContentBlockedByOrg(accountId); +} + +/** remoteContentHeldBack as React state, for the account a message belongs to. */ +export function useRemoteContentHeldBack(accountId?: string | null): boolean { + usePolicyInputs(); + return remoteContentHeldBack(accountId); +} + +/** + * For views with messages of several accounts (a conversation): re-renders + * on what the decisions read and hands back the per-account checks. + */ +export function useRemoteContentChecks(): { + heldBack: (accountId?: string | null) => boolean; + blockedByOrg: (accountId?: string | null) => boolean; +} { + usePolicyInputs(); + return { heldBack: remoteContentHeldBack, blockedByOrg: remoteContentBlockedByOrg }; } diff --git a/integration/tests/22-ent-security.spec.ts b/integration/tests/22-ent-security.spec.ts new file mode 100644 index 000000000..051556ec5 --- /dev/null +++ b/integration/tests/22-ent-security.spec.ts @@ -0,0 +1,641 @@ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { folderRow } from './helpers/app'; +import { + ENT, + ENT_PASSWORD, + adminContext, + contextOptions, + deliver, + getPolicy, + jmapAs, + putPolicy, + rawMessage, + requireEntEnv, + shot, + userPage, +} from './helpers/ent'; +import { neutralizeDevOverlay, accountSwitcher } from './helpers/app'; + +/** + * Enterprise pack, package A (mail security core), through the real UI on a + * VM 106 test Stalwart with the ent-* accounts: + * - item 1: the organisation's locked "block remote content" holds for + * trusted senders (list and address book; passes on main since + * f8805b8d5), the trusted-senders list offers no Add under the lock, and + * a reply draft started before the lock loads no remote image when + * reopened; + * - item 2: Report phishing sends the message to ent-sec as an attachment + * (and as ARF), with no copy in Sent, and moves it to Junk with $phishing; + * - item 3: a required classification label blocks sending until picked, + * SIO-Label and Sensitivity arrive, quick reply and a Pro compose tab keep + * it, a group scope gets its own scheme. + * Setup goes through the admin API and JMAP; the policy is put back at the end. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); + +const RUN = Date.now().toString(36); +const PIXEL = `https://ent-pixel.example.org/${RUN}.png`; +const LOCK = { externalContentPolicy: { locked: true, value: 'block' } }; +const GENERIC_LABELS = [ + { id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }, + { id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }, + { id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', description: '', sensitivity: 'Company-Confidential', subjectPrefix: '[Confidential]', extraHeaderValue: '' }, +]; +const GERMAN_LABELS = [ + { id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }, + { id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', description: '', sensitivity: 'Company-Confidential', subjectPrefix: 'VS-NfD', extraHeaderValue: '' }, +]; + +let original: Record; + +interface Msg { + id: string; + subject: string; + blobId: string; + keywords: Record; + mailboxIds: Record; + attachments?: Array<{ type: string; name?: string }>; +} + +/** Messages of `account` whose text has `needle` (the subject filter doesn't find "Re:" subjects). */ +async function messages(account: string, needle: string): Promise { + const client = await jmapAs(account); + const r = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { text: needle }, limit: 50 }, '0'], + ['Email/get', { + accountId: client.accountId, + '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, + properties: ['id', 'subject', 'blobId', 'keywords', 'mailboxIds', 'attachments'], + }, '1'], + ]); + return r.methodResponses[1][1].list as Msg[]; +} + +async function waitForMessage(account: string, needle: string, match: (m: Msg) => boolean, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const found = (await messages(account, needle)).find(match); + if (found) return found; + if (Date.now() > deadline) { + throw new Error(`${account}: nothing for "${needle}" (has: ${(await messages(account, needle)).map((m) => m.subject).join(' | ')})`); + } + await new Promise((r) => setTimeout(r, 1000)); + } +} + +async function roleMailboxId(account: string, role: string): Promise { + return (await (await jmapAs(account)).mailboxByRole(role))!.id; +} + +async function policy(patch: { organisation?: Record; restrictions?: Record; scopes?: unknown[] }, browser: Browser) { + const admin = await adminContext(browser); + await putPolicy(admin, { + ...original, + organisation: { ...((original.organisation as object) ?? {}), ...(patch.organisation ?? {}) }, + restrictions: { ...((original.restrictions as object) ?? {}), ...(patch.restrictions ?? {}) }, + scopes: patch.scopes ?? [], + }); + await admin.close(); +} + +/** Signs in with settings seeded before the app boots. */ +async function seededPage(browser: Browser, email: string, settings: Record, phone = false): Promise { + const context = await browser.newContext(contextOptions(phone)); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.addInitScript((s) => { + if (!sessionStorage.getItem('ent-seeded')) { + localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 })); + sessionStorage.setItem('ent-seeded', '1'); + } + }, settings); + await page.goto('/'); + await page.fill('#username', email); + await page.fill('#password', ENT_PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + return page; +} + +async function openMessage(page: Page, subject: string): Promise { + // By text: a thread row carries its newest message's subject. + const row = page.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: subject }).first(); + await expect(row).toBeVisible({ timeout: 60_000 }).catch(async (error) => { + const rows = await page.locator('[data-testid="email-list-item"]').evaluateAll((els) => els.map((e) => `${e.getAttribute('data-subject')} | ${(e as HTMLElement).innerText.replace(/\s+/g, ' ').slice(0, 80)}`)); + console.log('rows:', JSON.stringify(rows), 'url:', page.url()); + await page.screenshot({ path: `C:/Users/info/AppData/Local/Temp/claude/ent-a-fail-${Date.now()}.png` }).catch(() => {}); + throw error; + }); + await row.click(); + await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 20_000 }); +} + +/** Counts the requests the page makes to the test pixel. */ +function pixelRequests(page: Page): { count: () => number } { + let n = 0; + void page.context().route('https://ent-pixel.example.org/**', (route) => { + n++; + void route.fulfill({ status: 200, contentType: 'image/gif', body: Buffer.from('R0lGODlhAQABAAAAACw=', 'base64') }); + }); + return { count: () => n }; +} + +/** Delivers and waits until every recipient has it, so the list shows it on sign-in. */ +async function deliverAndWait(opts: Parameters[0]): Promise { + await deliver(opts); + for (const to of opts.to) await waitForMessage(to, RUN, (m) => m.subject === opts.subject); +} + +/** + * The message/rfc822 part of a raw report, decoded. Stalwart writes a + * message/rfc822 attachment base64-encoded (RFC 2046 §5.2.1 allows only + * 7bit, 8bit or binary there); readers decode it either way. + */ +function attachedMessage(raw: string): string { + const m = /Content-Type: message\/rfc822[\s\S]*?\r?\n\r?\n([\s\S]*?)\r?\n--/i.exec(raw); + if (!m) return ''; + const head = raw.slice(m.index, m.index + m[0].length - m[1].length); + return /Content-Transfer-Encoding:\s*base64/i.test(head) ? Buffer.from(m[1].replace(/\s+/g, ''), 'base64').toString('utf8') : m[1]; +} + +/** Trusts the open message's sender (a sender trusted in an earlier run already is). */ +async function trustSender(page: Page): Promise { + const button = page.getByRole('button', { name: 'Always trust this sender' }); + await page.waitForTimeout(1500); + if (await button.isVisible()) await button.click(); + await expect(page.getByTestId('external-content-notice')).toBeHidden({ timeout: 20_000 }); +} + +const html = (text: string) => `

    ${text}

    pixel

    Log in here

    `; + +async function composeAndSend(page: Page, to: string, subject: string, beforeSend?: () => Promise): Promise { + await page.keyboard.press('c'); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.waitFor({ state: 'visible', timeout: 15_000 }); + const input = page.locator('[data-testid="composer-to"] input').first(); + await input.click(); + await input.fill(to); + await input.press('Enter'); + await page.locator('[data-testid="composer-subject"]').first().fill(subject); + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.type(`Body of ${subject}`); + if (beforeSend) await beforeSend(); + await page.locator('[data-testid="composer-subject"]').first().focus(); + await page.keyboard.press('Control+Enter'); +} + +test.beforeAll(async ({ browser }) => { + requireEntEnv(); + const admin = await adminContext(browser); + original = await getPolicy(admin); + await putPolicy(admin, { ...original, scopes: [] }); + await admin.close(); +}); + +test.afterAll(async ({ browser }) => { + if (!original) return; + const admin = await adminContext(browser); + await putPolicy(admin, original); + await admin.close(); +}); + +// ─── Item 1: trusted senders vs the organisation's lock ────────────────── + +test('item 1: a sender trusted in the list stays blocked once the organisation locks remote content (passes on main)', async ({ browser }) => { + const subject = `Trust list ${RUN}`; + await policy({}, browser); + await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, html: html('Newsletter') }); + const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: false }); + const pixel = pixelRequests(alice); + await openMessage(alice, subject); + await trustSender(alice); + await expect.poll(pixel.count, { timeout: 15_000 }).toBeGreaterThan(0); + + // Before the lock: a reply draft whose quote may load the image. + await alice.keyboard.press('r'); + await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' }); + await alice.locator('[data-testid="email-composer"] [contenteditable="true"]').first().click(); + await alice.keyboard.type(`Draft before the lock ${RUN}`); + await expect(alice.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', { timeout: 30_000 }); + await alice.keyboard.press('Escape'); + await alice.context().close(); + + await policy({ restrictions: LOCK }, browser); + const locked = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block' }); + const seen = await locked.evaluate(async () => (await (await fetch('/api/admin/policy')).json()).restrictions?.externalContentPolicy); + expect(seen, 'the user gets the lock').toMatchObject({ locked: true, value: 'block' }); + const lockedPixel = pixelRequests(locked); + await openMessage(locked, subject); + await expect(locked.getByTestId('external-content-notice')).toContainText('Your organisation blocks content from other servers'); + await expect(locked.getByRole('button', { name: 'Always trust this sender' })).toHaveCount(0); + const srcdoc = await locked.locator('iframe').first().getAttribute('srcdoc'); + // Blocked images keep the address only in data-blocked-src. + expect(srcdoc ?? '').not.toMatch(/\ssrc="https:\/\/ent-pixel/); + expect(lockedPixel.count()).toBe(0); + await shot(locked, 'a-item1-locked-trusted'); + await locked.context().close(); +}); + +test('item 1: under the lock the trusted senders list offers no Add (fails on main)', async ({ browser }) => { + await policy({ restrictions: LOCK }, browser); + const alice = await userPage(browser, ENT.alice); + await alice.locator('a[href$="/settings"]').first().click(); + await alice.locator('[data-testid="settings-tab-reading"]').first().click(); + await expect(alice.getByText('Trusted senders').first()).toBeVisible(); + await alice.getByRole('button', { name: /^\d+ senders?$|None/ }).first().click(); + await expect(alice.getByText('Add sender manually')).toHaveCount(0); + await shot(alice, 'a-item1-trusted-list-locked'); + await alice.context().close(); +}); + +test('item 1: a reply draft from before the lock loads no remote image when reopened (fails on main)', async ({ browser }) => { + await policy({ restrictions: LOCK }, browser); + const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block' }); + const pixel = pixelRequests(alice); + await folderRow(alice, { role: 'drafts' }).first().click(); + const row = alice.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: `Trust list ${RUN}` }).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + await alice.getByTestId('edit-draft').waitFor({ state: 'visible' }); + await alice.waitForTimeout(2000); + // Viewing the draft loads nothing either. + expect(pixel.count(), 'requests while the draft is shown in the reader').toBe(0); + await alice.getByTestId('edit-draft').click(); + await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 20_000 }); + await alice.waitForTimeout(3000); + expect(pixel.count()).toBe(0); + const remote = await alice.locator(`[data-testid="email-composer"] img[src="${PIXEL}"]`).count(); + expect(remote).toBe(0); + await alice.context().close(); +}); + +test('item 1: a sender trusted in the address book stays blocked under the lock (passes on main)', async ({ browser }) => { + const subject = `Trust book ${RUN}`; + await policy({}, browser); + await deliverAndWait({ from: ENT.carol, to: [ENT.alice], subject, html: html('From the book') }); + const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: true }); + await openMessage(alice, subject); + await trustSender(alice); + await alice.context().close(); + + await policy({ restrictions: LOCK }, browser); + const locked = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: true }); + const pixel = pixelRequests(locked); + await openMessage(locked, subject); + await expect(locked.getByTestId('external-content-notice')).toContainText('Your organisation blocks content'); + expect(pixel.count()).toBe(0); + await locked.context().close(); +}); + +// ─── Item 2: Report phishing ───────────────────────────────────────────── + +test('item 1: a lock set in a group scope holds for its members only, also before their groups are known', async ({ browser }) => { + const subject = `Scoped lock ${RUN}`; + await policy({ scopes: [{ id: 'team-lock', label: 'Team lock', match: { groups: [ENT.team] }, policy: { restrictions: LOCK } }] }, browser); + await deliverAndWait({ from: ENT.bob, to: [ENT.alice, ENT.carol], subject, html: html('Scoped') }); + // ent-alice is in ent-team: blocked, with ent-bob trusted in the list. + const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSenders: [ENT.bob] }); + const pixel = pixelRequests(alice); + await openMessage(alice, subject); + await expect(alice.getByTestId('external-content-notice')).toContainText('Your organisation blocks content'); + expect(pixel.count()).toBe(0); + await alice.context().close(); + // ent-carol isn't: her own choice applies, no organisation note. + const carol = await seededPage(browser, ENT.carol, { externalContentPolicy: 'ask' }); + await openMessage(carol, subject); + await expect(carol.getByTestId('external-content-notice')).toBeVisible(); + await expect(carol.getByTestId('external-content-notice')).not.toContainText('Your organisation blocks content'); + await carol.context().close(); +}); + +test('item 2: the admin sets the report address on Organisation > Security', async ({ browser }) => { + await policy({}, browser); + const admin = await adminContext(browser); + const page = await admin.newPage(); + await page.goto('/admin/org-security'); + const field = page.getByTestId('org-phishing-address'); + await field.scrollIntoViewIfNeeded(); + await field.fill('ent-sec@example.org'); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved.')).toBeVisible(); + await shot(page, 'a-phishing-admin'); + const stored = await getPolicy(admin); + expect((stored.organisation as { phishingReport: unknown }).phishingReport).toEqual({ address: 'ent-sec@example.org', format: 'forward' }); + await admin.close(); +}); + +test('item 2: Report phishing sends the message to ent-sec as an attachment and moves it to Junk with $phishing', async ({ browser }) => { + const subject = `Phish one ${RUN}`; + await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser); + await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, html: html('Your account is locked') }); + const alice = await userPage(browser, ENT.alice); + await openMessage(alice, subject); + await alice.getByRole('button', { name: 'More actions' }).first().click(); + await alice.getByTestId('ctx-report-phishing').click(); + const dialog = alice.getByTestId('phishing-report-dialog'); + await expect(dialog).toContainText(ENT.sec); + await shot(alice, 'a-phishing-confirm'); + await alice.getByTestId('phishing-report-confirm').click(); + await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 }); + + const report = await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`); + expect(report.attachments?.map((a) => a.type)).toEqual(['message/rfc822']); + const raw = await rawMessage(ENT.sec, report.blobId); + expect(raw).toContain('Reported as phishing by'); + expect(attachedMessage(raw)).toContain(`Subject: ${subject}`); + + const junk = await roleMailboxId(ENT.alice, 'junk'); + const moved = await waitForMessage(ENT.alice, RUN, (m) => m.subject === subject && !!m.mailboxIds[junk] && m.keywords.$phishing === true); + expect(moved.keywords.$junk).toBe(true); + // No copy in Sent. + const sent = await roleMailboxId(ENT.alice, 'sent'); + expect((await messages(ENT.alice, RUN)).filter((m) => m.subject.startsWith('Phishing:') && m.mailboxIds[sent])).toEqual([]); + + // In Junk: the banner, and the link is off. + await folderRow(alice, { role: 'junk' }).first().click(); + await openMessage(alice, subject); + await expect(alice.getByTestId('phishing-mark-banner')).toContainText('You reported this message as phishing'); + const srcdoc = await alice.locator('iframe').first().getAttribute('srcdoc'); + expect(srcdoc ?? '').toContain('Log in here'); + expect(srcdoc ?? '').not.toContain('https://ent-link.example.org/login'); + await shot(alice, 'a-phishing-banner'); + await alice.context().close(); +}); + +test('item 2: Undo after a report moves the message back and clears $phishing', async ({ browser }) => { + const subject = `Phish undo ${RUN}`; + await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser); + await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Undo check' }); + const alice = await userPage(browser, ENT.alice); + await openMessage(alice, subject); + await alice.getByRole('button', { name: 'More actions' }).first().click(); + await alice.getByTestId('ctx-report-phishing').click(); + await alice.getByTestId('phishing-report-confirm').click(); + const toast = alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first(); + await expect(toast).toBeVisible({ timeout: 30_000 }); + await alice.getByRole('button', { name: 'Undo', exact: true }).first().click(); + await expect(alice.getByText('Moved back. The report was already sent.').first()).toBeVisible({ timeout: 30_000 }); + const inbox = await roleMailboxId(ENT.alice, 'inbox'); + const back = await waitForMessage(ENT.alice, RUN, (m) => m.subject === subject && !!m.mailboxIds[inbox]); + expect(back.keywords.$phishing).toBeUndefined(); + expect(back.keywords.$junk).toBeUndefined(); + // The report itself went out. + await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`); + await alice.context().close(); +}); + +test('item 2: the ARF format arrives as a feedback report', async ({ browser }) => { + const subject = `Phish arf ${RUN}`; + await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'arf' } } }, browser); + await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Click here' }); + const alice = await userPage(browser, ENT.alice); + await alice.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`).first().click({ button: 'right', timeout: 60_000 }); + await alice.getByTestId('ctx-report-phishing').click(); + await alice.getByTestId('phishing-report-confirm').click(); + await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 }); + const report = await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`); + const raw = await rawMessage(ENT.sec, report.blobId); + expect(raw).toMatch(/Content-Type: multipart\/report; report-type=feedback-report/); + expect(raw).toContain('Feedback-Type: fraud'); + expect(raw).toContain('Version: 1'); + expect(raw).toMatch(/User-Agent: Bulwark\//); + expect(raw).toContain('Content-Type: message/rfc822'); + expect(attachedMessage(raw)).toContain(`Subject: ${subject}`); + await alice.context().close(); +}); + +test('item 2: a group scope reports for ent-team only; ent-carol has no action', async ({ browser }) => { + const subject = `Phish scope ${RUN}`; + await policy({ + organisation: { phishingReport: { address: '', format: 'forward' } }, + scopes: [{ id: 'team', label: 'Team', match: { groups: [ENT.team] }, policy: { organisation: { phishingReport: { address: ENT.sec } } } }], + }, browser); + await deliverAndWait({ from: ENT.bob, to: [ENT.carol, ENT.alice], subject, text: 'Scope check' }); + const carol = await userPage(browser, ENT.carol); + await openMessage(carol, subject); + await carol.getByRole('button', { name: 'More actions' }).first().click(); + await expect(carol.getByTestId('ctx-report-phishing')).toHaveCount(0); + await carol.context().close(); + const alice = await userPage(browser, ENT.alice); + await openMessage(alice, subject); + await alice.getByRole('button', { name: 'More actions' }).first().click(); + await expect(alice.getByTestId('ctx-report-phishing')).toBeVisible(); + await alice.context().close(); +}); + +test('item 2: the reader action and the confirm fit a phone', async ({ browser }) => { + const subject = `Phish phone ${RUN}`; + await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser); + await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Phone check' }); + const alice = await userPage(browser, ENT.alice, true); + await openMessage(alice, subject); + await alice.getByRole('button', { name: 'More actions' }).first().click(); + const action = alice.getByText('Report phishing…').first(); + await expect(action).toBeVisible(); + await shot(alice, 'a-phishing-phone-menu'); + await action.click(); + const dialog = alice.getByTestId('phishing-report-dialog'); + const box = await dialog.boundingBox(); + expect(box && box.x).toBeGreaterThanOrEqual(0); + expect(box && box.x + box.width).toBeLessThanOrEqual(390); + await shot(alice, 'a-phishing-phone-confirm'); + await alice.getByTestId('phishing-report-cancel').click(); + await expect(dialog).toHaveCount(0); + expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); + await alice.context().close(); +}); + +// ─── Item 3: classification labels ─────────────────────────────────────── + +test('item 3: the admin applies a preset and requires a label on Organisation > Classification', async ({ browser }) => { + await policy({}, browser); + const admin = await adminContext(browser); + const page = await admin.newPage(); + await page.goto('/admin/org-labels'); + await expect(page.getByTestId('org-labels-vsnfd-warning')).toBeVisible(); + await page.getByTestId('org-labels-preset').selectOption('generic'); + await expect(page.getByTestId('org-label-item')).toHaveCount(4); + await page.getByTestId('org-labels-required').click(); + await page.getByTestId('org-labels-default').selectOption(''); + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved.')).toBeVisible(); + await shot(page, 'a-labels-admin'); + const stored = (await getPolicy(admin)).organisation as { labels: { list: unknown[]; required: boolean; defaultId: string } }; + expect(stored.labels.list).toHaveLength(4); + expect(stored.labels).toMatchObject({ required: true, defaultId: '' }); + await admin.close(); +}); + +test('item 3: a required label blocks sending until one is picked; SIO-Label and Sensitivity arrive', async ({ browser }) => { + const subject = `Label send ${RUN}`; + await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser); + const alice = await userPage(browser, ENT.alice); + await composeAndSend(alice, ENT.bob, subject); + await expect(alice.getByText('Pick a classification label before sending.').first()).toBeVisible(); + await expect(alice.getByTestId('composer-classification-options')).toBeVisible(); + await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible(); + await shot(alice, 'a-labels-required'); + await alice.getByTestId('composer-classification-option-confidential').click(); + await expect(alice.getByTestId('composer-classification-trigger')).toContainText('Confidential'); + await alice.locator('[data-testid="composer-subject"]').first().focus(); + await alice.keyboard.press('Control+Enter'); + await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 }); + + const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`); + const raw = await rawMessage(ENT.bob, got.blobId); + expect(raw).toMatch(/^SIO-Label: marking="CONFIDENTIAL"; fgcolor=#[0-9a-f]{6}; bgcolor=#d97706\r?$/m); + expect(raw).toMatch(/^Sensitivity: Company-Confidential\r?$/m); + await alice.context().close(); + + // The recipient sees the label. + const bob = await userPage(browser, ENT.bob); + await openMessage(bob, `[Confidential] ${subject}`); + await expect(bob.getByTestId('received-label-chip').first()).toHaveText('Confidential'); + await shot(bob, 'a-labels-received-chip'); + await bob.context().close(); +}); + +test('item 3: Undo send brings the message back with its label', async ({ browser }) => { + const subject = `Undo label ${RUN}`; + await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: 'internal', required: true, extraHeader: '' } } }, browser); + const alice = await userPage(browser, ENT.alice); + // Undo send on, through Settings (a synced setting). + await alice.locator('a[href$="/settings"]').first().click(); + await alice.locator('[data-testid="settings-tab-composing"]').first().click(); + const delay = alice.locator('[data-search-label="Undo send / send delay"] select').first(); + await delay.selectOption('30'); + await alice.getByText('Back to mail').first().click(); + await folderRow(alice, { role: 'inbox' }).first().waitFor({ state: 'visible', timeout: 30_000 }); + await composeAndSend(alice, ENT.bob, subject, async () => { + await alice.getByTestId('composer-classification-trigger').click(); + await alice.getByTestId('composer-classification-option-confidential').click(); + }); + await alice.getByRole('button', { name: 'Undo send' }).first().click({ timeout: 20_000 }); + await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible({ timeout: 20_000 }); + // Not the default (Internal): the label it was sent with. + await expect(alice.getByTestId('composer-classification-trigger')).toContainText('Confidential'); + await alice.locator('[data-testid="composer-subject"]').first().focus(); + await alice.keyboard.press('Control+Enter'); + await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 }); + const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`, 90_000); + expect(await rawMessage(ENT.bob, got.blobId)).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m); + // Undo send off again, for the other tests. + await alice.locator('a[href$="/settings"]').first().click(); + await alice.locator('[data-testid="settings-tab-composing"]').first().click(); + await alice.locator('[data-search-label="Undo send / send delay"] select').first().selectOption('0'); + await alice.waitForTimeout(1500); + await alice.context().close(); +}); + +test('item 3: a quick reply keeps the original’s label', async ({ browser }) => { + const subject = `[Confidential] Label send ${RUN}`; + await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser); + const bob = await userPage(browser, ENT.bob); + await openMessage(bob, subject); + const box = bob.getByPlaceholder('Write a quick reply...'); + await box.fill(`Quick reply ${RUN}`); + await box.press('Control+Enter'); + await expect(box).toHaveValue('', { timeout: 30_000 }); + const reply = await waitForMessage(ENT.alice, RUN, (m) => m.subject === `[Confidential] Re: Label send ${RUN}`); + expect(await rawMessage(ENT.alice, reply.blobId)).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m); + await bob.context().close(); +}); + +test('item 3: a quick reply with no label to keep opens the composer to pick one', async ({ browser }) => { + const subject = `Unlabelled ${RUN}`; + await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser); + await deliverAndWait({ from: ENT.carol, to: [ENT.bob], subject, text: 'No label' }); + const bob = await userPage(browser, ENT.bob); + await openMessage(bob, subject); + const box = bob.getByPlaceholder('Write a quick reply...'); + await box.fill(`Needs a label ${RUN}`); + await box.press('Control+Enter'); + await expect(bob.locator('[data-testid="email-composer"]')).toBeVisible({ timeout: 20_000 }); + await expect(bob.getByTestId('composer-classification-trigger')).toContainText('Pick a label'); + // Nothing went out. + await bob.waitForTimeout(3000); + expect((await messages(ENT.carol, RUN)).filter((m) => m.subject.includes(`Re: ${subject}`))).toEqual([]); + await bob.context().close(); +}); + +test('item 3: a group scope gets its own scheme (VS-NfD, RFC 2231 marking); ent-carol keeps Everyone’s', async ({ browser }) => { + const subject = `Scheme ${RUN}`; + await policy({ + organisation: { labels: { list: GENERIC_LABELS, defaultId: 'internal', required: true, extraHeader: '' } }, + scopes: [{ id: 'team', label: 'Team', match: { groups: [ENT.team] }, policy: { organisation: { labels: { list: GERMAN_LABELS, defaultId: 'offen', required: true, extraHeader: '' } } } }], + }, browser); + const carol = await userPage(browser, ENT.carol); + await carol.keyboard.press('c'); + await carol.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' }); + await expect(carol.getByTestId('composer-classification-trigger')).toContainText('Internal'); + await carol.keyboard.press('Escape'); + await carol.context().close(); + + const alice = await userPage(browser, ENT.alice); + await composeAndSend(alice, ENT.bob, subject, async () => { + await expect(alice.getByTestId('composer-classification-trigger')).toContainText('OFFEN'); + await alice.getByTestId('composer-classification-trigger').click(); + // Arrow keys move the pick; Enter closes. + await alice.keyboard.press('ArrowDown'); + await alice.keyboard.press('Enter'); + await expect(alice.getByTestId('composer-classification-trigger')).toContainText('VS-NfD'); + }); + await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 }); + const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `VS-NfD ${subject}`); + const raw = await rawMessage(ENT.bob, got.blobId); + expect(raw).toContain("marking*=utf-8''VS-NUR%20F%C3%9CR%20DEN%20DIENSTGEBRAUCH"); + await alice.context().close(); + const bob = await userPage(browser, ENT.bob); + await openMessage(bob, `VS-NfD ${subject}`); + await expect(bob.getByTestId('received-label-chip').first()).toHaveText('VS-NfD'); + await bob.context().close(); +}); + +test('item 3: a Pro compose tab sends with its label and priority', async ({ browser }) => { + const subject = `Pro tab ${RUN}`; + await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: 'confidential', required: true, extraHeader: '' } } }, browser); + const alice = await seededPage(browser, ENT.alice, { proInterface: true, advancedSettings: true }); + await alice.goto('/pro').catch(() => {}); + await accountSwitcher(alice).waitFor({ state: 'visible', timeout: 60_000 }).catch(() => {}); + await composeAndSend(alice, ENT.bob, subject, async () => { + await alice.getByTestId('composer-options-menu').first().click(); + await alice.getByTestId('composer-priority-high').click(); + await alice.keyboard.press('Escape'); + }); + const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`); + const raw = await rawMessage(ENT.bob, got.blobId); + expect(raw).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m); + expect(raw).toMatch(/^X-Priority: 1/m); + await alice.context().close(); +}); + +test('item 3: the picker and the admin pages fit a phone', async ({ browser }) => { + await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' }, phishingReport: { address: ENT.sec, format: 'forward' } } }, browser); + const alice = await userPage(browser, ENT.alice, true); + await alice.getByRole('button', { name: /Compose|New message|Write/ }).first().click().catch(async () => { await alice.keyboard.press('c'); }); + await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 }); + const trigger = alice.getByTestId('composer-classification-trigger'); + await expect(trigger).toBeVisible(); + await trigger.click(); + const options = alice.getByTestId('composer-classification-options'); + const box = await options.boundingBox(); + expect(box && box.x).toBeGreaterThanOrEqual(0); + expect(box && box.x + box.width).toBeLessThanOrEqual(390); + await shot(alice, 'a-labels-phone-picker'); + await alice.keyboard.press('Escape'); + expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); + await alice.context().close(); + + const admin = await adminContext(browser, true); + for (const [path, name] of [['/admin/org-labels', 'a-labels-admin-phone'], ['/admin/org-security', 'a-phishing-admin-phone']] as const) { + const page = await admin.newPage(); + await page.goto(path); + await page.waitForLoadState('networkidle').catch(() => {}); + expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0); + await shot(page, name); + await page.close(); + } + await admin.close(); +}); diff --git a/lib/__tests__/classification-labels.test.ts b/lib/__tests__/classification-labels.test.ts new file mode 100644 index 000000000..25a7ebb6c --- /dev/null +++ b/lib/__tests__/classification-labels.test.ts @@ -0,0 +1,131 @@ +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({ + // Without the leading space: the server writes "SIO-Label: " itself. + 'header:SIO-Label:asRaw': 'marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706', + '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__/jmap-phishing-report-send.test.ts b/lib/__tests__/jmap-phishing-report-send.test.ts new file mode 100644 index 000000000..7a82e9232 --- /dev/null +++ b/lib/__tests__/jmap-phishing-report-send.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// The client pieces of a phishing report: a send that leaves no copy in +// Sent (onSuccessDestroyEmail instead of the move to Sent), and the move +// to Junk and back with $phishing in the same patch. + +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(answer: (calls: Call[]) => unknown[]) { + const captured: Call[][] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const body = JSON.parse((init as { body: string }).body) as { methodCalls: Call[] }; + captured.push(body.methodCalls); + const payload = { methodResponses: answer(body.methodCalls) }; + return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(payload)), json: () => Promise.resolve(payload) } as Response; + }); + return captured; +} + +function sendAnswers(calls: Call[]): unknown[] { + const name = calls[0][0]; + if (name === 'Mailbox/get') { + return [['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }, { id: 'mb-junk', role: 'junk' }] }, '0']]; + } + if (name === 'Identity/get') return [['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, '0']]; + if (name === 'Email/set' && calls[0][1].create) { + const id = Object.keys(calls[0][1].create as object)[0]; + return [ + ['Email/set', { created: { [id]: { id: 'copy-1' } } }, '0'], + ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1'], + ]; + } + return calls.map(([method, , id]) => [method, { updated: {}, list: [], destroyed: [] }, id]); +} + +function submission(captured: Call[][]): Record { + for (const calls of captured) { + const found = calls.find(([name]) => name === 'EmailSubmission/set'); + if (found) return found[1]; + } + throw new Error('no EmailSubmission/set'); +} + +describe('sendEmail without a Sent copy', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('files a normal send in Sent', async () => { + const captured = mockServer(sendAnswers); + await createClient().sendEmail(['soc@example.org'], 'Phishing: x', 'body', undefined, undefined, 'identity-1', 'user@example.com'); + const sub = submission(captured); + expect(sub.onSuccessUpdateEmail).toBeTruthy(); + expect(sub.onSuccessDestroyEmail).toBeUndefined(); + }); + + it('destroys the copy once submitted with noSentCopy', async () => { + const captured = mockServer(sendAnswers); + await createClient().sendEmail( + ['soc@example.org'], 'Phishing: x', 'body', undefined, undefined, 'identity-1', 'user@example.com', + undefined, undefined, undefined, [{ blobId: 'orig', name: 'x.eml', type: 'message/rfc822', size: 10, disposition: 'attachment' }], + undefined, undefined, undefined, undefined, { noSentCopy: true }, + ); + const sub = submission(captured); + expect(sub.onSuccessDestroyEmail).toEqual(['#1']); + expect(sub.onSuccessUpdateEmail).toBeUndefined(); + }); +}); + +describe('markAsSpam and undoSpam with more keywords', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('sets $phishing with the move to Junk, and clears it with the move back', async () => { + const client = createClient(); + vi.spyOn(client, 'getMailboxes').mockResolvedValue([{ id: 'mb-junk', role: 'junk', isShared: false }] as never); + const captured = mockServer((calls) => calls.map(([method, args, id]) => [method, { updated: Object.fromEntries(Object.keys((args.update as object) ?? {}).map((k) => [k, null])) }, id])); + await client.markAsSpam('e1', undefined, true, ['$phishing']); + const patch = (captured[0][0][1].update as Record>).e1; + expect(patch).toMatchObject({ mailboxIds: { 'mb-junk': true }, 'keywords/$junk': true, 'keywords/$seen': true, 'keywords/$phishing': true }); + await client.undoSpam('e1', 'mb-inbox', undefined, ['$phishing']); + const back = (captured[1][0][1].update as Record>).e1; + expect(back).toMatchObject({ mailboxIds: { 'mb-inbox': true }, 'keywords/$junk': null, 'keywords/$notjunk': true, 'keywords/$phishing': null }); + }); +}); diff --git a/lib/__tests__/phishing-report-arf.test.ts b/lib/__tests__/phishing-report-arf.test.ts new file mode 100644 index 000000000..007d5ede3 --- /dev/null +++ b/lib/__tests__/phishing-report-arf.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Email, Identity } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { buildArfParts, buildArfReportBlob, rawEncoding } from '../phishing-report-arf'; +import { sendPhishingReport } from '../phishing-report'; + +// The ARF report (RFC 5965): multipart/report with report-type +// feedback-report, a text part, message/feedback-report with +// Feedback-Type: fraud, User-Agent and Version: 1, and the original whole +// as message/rfc822; submitted raw without a Sent copy. + +const email = { + id: 'e1', blobId: 'blob-1', size: 40, headers: { 'Delivered-To': 'alice@example.org' }, subject: 'Konto gesperrt – jetzt prüfen', receivedAt: '2026-10-01T10:00:00Z', + from: [{ email: 'bad@phish.example' }], keywords: {}, mailboxIds: { inbox: true }, +} as unknown as Email; +const reporter = { id: 'id-me', name: 'Alice', email: 'alice@example.org' } as Identity; +const ORIGINAL = 'From: bad@phish.example\r\nSubject: x\r\n\r\nclick here\r\n'; + +describe('buildArfParts', () => { + it('has the three parts and the required feedback fields', () => { + const { head, tail } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit', recipient: 'alice@example.org', now: new Date('2026-11-01T08:00:00Z') }); + expect(head).toMatch(/^Date: Sun, 01 Nov 2026 08:00:00 \+0000\r\n/); + expect(head).toContain('From: Alice \r\n'); + expect(head).toContain('To: abuse@example.org\r\n'); + // Non-ASCII subject as an encoded word, after "Phishing:". + expect(head).toMatch(/\r\nSubject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=\r\n/); + const subject = /Subject: =\?UTF-8\?B\?([^?]+)\?=/.exec(head)![1]; + expect(new TextDecoder().decode(Uint8Array.from(atob(subject), (c) => c.charCodeAt(0)))).toBe('Phishing: Konto gesperrt – jetzt prüfen'); + expect(head).toContain('Content-Type: multipart/report; report-type=feedback-report;'); + const boundary = /boundary="([^"]+)"/.exec(head)![1]; + expect(head.split(`--${boundary}\r\n`)).toHaveLength(4); + expect(head).toContain('Content-Type: message/feedback-report\r\n'); + expect(head).toContain('\r\nFeedback-Type: fraud\r\n'); + expect(head).toMatch(/\r\nUser-Agent: Bulwark\/\S+\r\n/); + expect(head).toContain('\r\nVersion: 1\r\n'); + expect(head).toContain('\r\nArrival-Date: Thu, 01 Oct 2026 10:00:00 +0000\r\n'); + expect(head).toContain('\r\nOriginal-Rcpt-To: alice@example.org\r\n'); + expect(head).toContain('\r\nReported-Domain: phish.example\r\n'); + expect(head).toMatch(/Content-Type: message\/rfc822\r\n[\s\S]*Content-Transfer-Encoding: 7bit\r\n\r\n$/); + expect(tail).toBe(`\r\n--${boundary}--\r\n`); + }); + + it('names the address the message was delivered to, and leaves the field out when unknown', () => { + const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit' }); + expect(head).not.toContain('Original-Rcpt-To'); + }); + + it('says binary for bare line breaks or long lines, 8bit for 8-bit bytes, never base64', () => { + const enc = (text: string) => rawEncoding(new TextEncoder().encode(text)); + expect(enc('a\r\nb\r\n')).toBe('7bit'); + expect(enc('Grüße\r\n')).toBe('8bit'); + expect(enc('a\nb')).toBe('binary'); + expect(enc('a\rb')).toBe('binary'); + expect(enc(`${'x'.repeat(999)}\r\n`)).toBe('binary'); + expect(enc('a\u0000b')).toBe('binary'); + }); + + it('marks an 8-bit original as 8bit, never base64', () => { + const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '8bit' }); + expect(head).toMatch(/Content-Transfer-Encoding: 8bit\r\n\r\n$/); + }); +}); + +describe('an ARF report', () => { + it('carries the original byte for byte and is submitted raw, from the original account', async () => { + const client = { + getMaxSizeUpload: () => 50_000_000, + getIdentities: vi.fn(async () => [reporter]), + getUsername: () => 'alice@example.org', + getAccountId: () => 'acc-own', + fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode(ORIGINAL).buffer), + submitRawEmail: vi.fn(async (..._args: unknown[]) => ({ scheduled: false })), + sendEmail: vi.fn(), + }; + await sendPhishingReport(email, { + client: client as unknown as IJMAPClient, + sourceAccountId: 'acc-group', + setup: { address: 'abuse@example.org', format: 'arf' }, + buildArf: buildArfReportBlob, + }); + expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'message.eml', 'message/rfc822', 'acc-group'); + expect(client.sendEmail).not.toHaveBeenCalled(); + const [blob, identityId, delayed, envelope] = client.submitRawEmail.mock.calls[0] as [Blob, string, unknown, string[]]; + expect(identityId).toBe('id-me'); + expect(delayed).toBeUndefined(); + expect(envelope).toEqual(['abuse@example.org']); + const text = await blob.text(); + const boundary = /boundary="([^"]+)"/.exec(text)![1]; + expect(text).toContain(`message/rfc822\r\nContent-Disposition: attachment; filename="reported-message.eml"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n${ORIGINAL}--${boundary}--\r\n`); + }); +}); + +describe('a message too big for an ARF report', () => { + it('is refused before it is downloaded', async () => { + const client = { getMaxSizeUpload: () => 1000, fetchBlobArrayBuffer: vi.fn() }; + await expect(buildArfReportBlob({ + client: client as unknown as IJMAPClient, email: { ...email, size: 5000 } as Email, sourceAccountId: 'a', reporter, to: 'abuse@example.org', + })).rejects.toThrow(/too big/); + expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/__tests__/phishing-report.test.ts b/lib/__tests__/phishing-report.test.ts new file mode 100644 index 000000000..fae89b7ab --- /dev/null +++ b/lib/__tests__/phishing-report.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Email, Identity } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { + PHISHING_KEYWORD, + isReportable, + phishingReportSetup, + reportSubject, + sendPhishingReport, +} from '../phishing-report'; +import { isPhishingMarked, turnOffLinks } from '../phishing-links'; + +// Report phishing: one report per message, the original attached by its +// blobId (no upload), no copy in Sent, the blob copied over for a message +// of another account, and which messages can be reported at all. + +const me: Identity = { id: 'id-me', name: 'Alice', email: 'alice@example.org' } as Identity; +const email = { + id: 'e1', blobId: 'blob-1', threadId: 't1', size: 2048, subject: 'Your parcel', + receivedAt: '2026-10-01T10:00:00Z', keywords: {}, mailboxIds: { inbox: true }, + from: [{ email: 'bad@phish.example' }], +} as unknown as Email; + +function fakeClient(over: Partial> = {}) { + const client = { + getIdentities: vi.fn(async () => [me]), + getUsername: () => 'alice@example.org', + getAccountId: () => 'acc-own', + resolveSendAccountId: vi.fn(async () => 'acc-own'), + copyBlobs: vi.fn(async () => ({ 'blob-1': 'blob-copied' })), + sendEmail: vi.fn(async () => ({ scheduled: false })), + submitRawEmail: vi.fn(async () => ({ scheduled: false })), + ...over, + }; + return client as typeof client & IJMAPClient; +} + +const setup = { address: 'soc@example.org', format: 'forward' as const }; + +describe('the forwarded report', () => { + it('attaches the original by blobId, to the admin address, with "Phishing:" and no Sent copy', async () => { + const client = fakeClient(); + await sendPhishingReport(email, { client, sourceAccountId: 'acc-own', setup }); + expect(client.sendEmail).toHaveBeenCalledTimes(1); + const args = client.sendEmail.mock.calls[0] as unknown[]; + expect(args[0]).toEqual(['soc@example.org']); + expect(args[1]).toBe('Phishing: Your parcel'); + expect(String(args[2])).toContain('Reported as phishing by Alice with Bulwark'); + expect(args[5]).toBe('id-me'); + const attachments = args[10] as Array>; + expect(attachments).toHaveLength(1); + expect(attachments[0]).toMatchObject({ blobId: 'blob-1', type: 'message/rfc822', size: 2048, disposition: 'attachment' }); + expect(String(attachments[0].name)).toMatch(/\.eml$/); + expect(args[15]).toEqual({ noSentCopy: true }); + expect(client.copyBlobs).not.toHaveBeenCalled(); + expect(client.submitRawEmail).not.toHaveBeenCalled(); + }); + + it('copies the blob first when the message is in another account (a group mailbox)', async () => { + const client = fakeClient(); + await sendPhishingReport(email, { client, sourceAccountId: 'acc-group', setup }); + expect(client.copyBlobs).toHaveBeenCalledWith('acc-group', ['blob-1'], 'acc-own'); + const attachments = (client.sendEmail.mock.calls[0] as unknown[])[10] as Array<{ blobId: string }>; + expect(attachments[0].blobId).toBe('blob-copied'); + }); + + it('sends nothing when the copy fails', async () => { + const client = fakeClient({ copyBlobs: vi.fn(async () => ({})) }); + await expect(sendPhishingReport(email, { client, sourceAccountId: 'acc-group', setup })).rejects.toThrow(); + expect(client.sendEmail).not.toHaveBeenCalled(); + }); + + it('names an empty subject plainly', () => { + expect(reportSubject('')).toBe('Phishing'); + expect(reportSubject('a\r\nBcc: x@evil.example')).toBe('Phishing: a Bcc: x@evil.example'); + }); +}); + +describe('which messages can be reported', () => { + it('not your own mail, drafts, scheduled mail, or a message reported already', () => { + expect(isReportable(email, 'inbox')).toBe(true); + expect(isReportable(email, 'junk')).toBe(true); + for (const role of ['sent', 'drafts', 'scheduled']) expect(isReportable(email, role)).toBe(false); + expect(isReportable({ ...email, keywords: { $draft: true } }, 'inbox')).toBe(false); + expect(isReportable({ ...email, isScheduled: true }, 'inbox')).toBe(false); + expect(isReportable({ ...email, blobId: '' }, 'inbox')).toBe(false); + expect(isReportable({ ...email, keywords: { [PHISHING_KEYWORD]: true } }, 'junk')).toBe(false); + }); + + it('only while the admin set an address', () => { + expect(phishingReportSetup(DEFAULT_POLICY)).toBeNull(); + const on = { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address: 'soc@example.org', format: 'arf' as const } } }; + expect(phishingReportSetup(on)).toEqual({ address: 'soc@example.org', format: 'arf' }); + }); +}); + +describe('links of a reported message', () => { + it('are turned off: no address, not reachable by Tab, and say so', () => { + const html = '

    Hi log in x

    '; + const out = turnOffLinks(html, { title: 'link turned off', note: '(link turned off)' }); + expect(out).not.toContain('href='); + expect(out).not.toContain('phish.example'); + expect(out).toContain('data-link-off'); + expect(out).toContain('(link turned off)'); + const doc = new DOMParser().parseFromString(out, 'text/html'); + expect(doc.querySelectorAll('a[href], area[href]')).toHaveLength(0); + }); + + it('follow the $phishing keyword', () => { + expect(isPhishingMarked({ keywords: { [PHISHING_KEYWORD]: true } })).toBe(true); + expect(isPhishingMarked({ keywords: { $junk: true } })).toBe(false); + }); +}); diff --git a/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts new file mode 100644 index 000000000..18bc81c2d --- /dev/null +++ b/lib/__tests__/plugin-host-labels.test.ts @@ -0,0 +1,150 @@ +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-raw'; + +// Classification labels on the plugin paths: api.email.forward carries the +// original's label (else the default) and refuses when a required one is +// 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, sender = 'colleague@example.com') => ({ + id: 'E1', threadId: 'T1', mailboxIds: { inbox: true }, keywords: {}, size: 900, receivedAt: '2026-10-20T08:00:00Z', + from: [{ email: sender }], 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 = {}; + let sender = 'colleague@example.com'; + + 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, sender)), + 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' }); + useAuthStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] } as never); + useSettingsStore.setState({ sendDelaySeconds: 0 }); + }); + afterEach(() => { + while (head()) resolveHead(false); + useAuthStore.setState({ client: null }); + usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY }); + resetLabelTakeovers(); + headers = {}; + sender = 'colleague@example.com'; + 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('takes the default, not an outside sender’s label', async () => { + useLabels({ defaultId: 'internal' }); + headers = { 'SIO-Label': ' marking="PUBLIC"' }; + sender = 'billing@supplier.example'; + expect(await forward()).toMatchObject({ sent: true }); + expect((sendEmail.mock.calls[0] as unknown[])[2]).toBe('Fwd: Invoice'); + const labelHeaders = ((sendEmail.mock.calls[0] as unknown[])[16] as { labelHeaders: Record }).labelHeaders; + expect(labelHeaders['header:SIO-Label:asRaw']).toContain('INTERNAL'); + }); + + 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 takeover’s label into its raw sends and takes forged ones out of every raw send', async () => { + useLabels({}); + const forged = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\nSIO-Label: marking="PUBLIC"\r\n\r\nENCRYPTED\r\n'); + // Outside a takeover, no default label: nothing stamped, the forged header gone. + await call(SMIME, 'jmap.sendRaw', forged, 'id-a', {}); + expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label'); + + const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a')!; + await call(SMIME, 'jmap.sendRaw', forged, 'id-a', { labelToken: token }); + await call(SMIME, 'jmap.submitRaw', forged, 'id-a', { labelToken: token }); + endLabelTakeover(token); + const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text(); + expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/); + expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"'); + }); + + it('refuses a raw send outside the composer when a label is required', async () => { + useLabels({ required: true }); + const bytes = new TextEncoder().encode('Subject: Hi\r\n\r\nX\r\n'); + await expect(call(SMIME, 'jmap.sendRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/); + await expect(call(SMIME, 'jmap.submitRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/); + expect(sendRawEmail).not.toHaveBeenCalled(); + expect(client.submitRawEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts index 91a5395ef..35f7339e2 100644 --- a/lib/admin/__tests__/organisation-policy-enterprise.test.ts +++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + isLabelExtraHeaderName, DEFAULT_ORGANISATION_POLICY, MIN_FEED_REFRESH_MINUTES, mergeOrganisation, @@ -58,6 +59,21 @@ describe('enterprise sections: defaults and sanitising', () => { } }); + it('takes any RFC 5322 header name, msip_labels too, but not one the server refuses', () => { + expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'msip_labels' } }).labels.extraHeader).toBe('msip_labels'); + expect(isLabelExtraHeaderName('msip_labels')).toBe(true); + for (const name of ['Content-Classification', 'List-Id', 'Resent-From', 'ARC-Seal', 'Auto-Submitted', 'X:Bad', 'X Bad']) { + expect(isLabelExtraHeaderName(name)).toBe(false); + expect(sanitizeOrganisationPolicy({ labels: { extraHeader: name } }).labels.extraHeader).toBe(''); + } + }); + + it('refuses the header Bulwark keeps the label of a draft in', () => { + expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'X-Bulwark-Label' } }).labels.extraHeader).toBe(''); + expect(isLabelExtraHeaderName('X-Bulwark-Label')).toBe(false); + expect(isLabelExtraHeaderName('X-Classification')).toBe(true); + }); + it('keeps announcement text per locale and normalises times', () => { const { announcement } = sanitizeOrganisationPolicy({ announcement: { id: 'maint-1', severity: 'warning', text: { en: 'Maintenance tonight', 'pt-BR': 'Manutenção', 'x y': 'no', de: ' ' }, diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index ad9a9f5a9..8467c42c8 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -287,14 +287,17 @@ const EMAIL_RE = /^[^\s@<>"(),;:]+@[^\s@<>"(),;:]+\.[^\s@<>"(),;:]+$/; const COLOR_RE = /^#[0-9a-f]{6}$/i; const ID_RE = /^[A-Za-z0-9._-]{1,64}$/; const LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/; -const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,75}$/; +// RFC 5322 field-name: printable ASCII but the colon (msip_labels has an underscore). +const HEADER_NAME_RE = /^[\x21-\x39\x3b-\x7e]{1,76}$/; +// Prefixes JMAP refuses at the top level or that say how mail travelled. +const RESERVED_HEADER_PREFIXES = ['content-', 'list-', 'resent-', 'arc-']; // Headers the extra classification header may not be: they carry the // message itself, or the labels Bulwark writes anyway. const RESERVED_HEADERS = new Set([ 'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'sender', 'reply-to', 'in-reply-to', 'references', 'mime-version', 'content-type', 'content-transfer-encoding', 'content-disposition', 'received', 'return-path', 'dkim-signature', 'arc-seal', 'arc-message-signature', 'authentication-results', - 'sio-label', 'sio-label-history', 'sensitivity', 'importance', 'x-priority', 'disposition-notification-to', + 'sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label', 'auto-submitted', 'precedence', 'importance', 'x-priority', 'disposition-notification-to', ]); const MAX_LABELS = 50; const MAX_FEEDS = 50; @@ -312,6 +315,12 @@ function email(value: unknown): string | undefined { return v === '' || EMAIL_RE.test(v) ? v.slice(0, 320) : undefined; } +/** Whether the admin page may store this as `phishingReport.address` (the sanitiser's rule). */ +export function isPhishingReportAddress(value: string): boolean { + const v = value.trim().toLowerCase(); + return v.length <= 320 && EMAIL_RE.test(v); +} + function color(value: unknown, fallback: string): string { return typeof value === 'string' && COLOR_RE.test(value.trim()) ? value.trim().toLowerCase() : fallback; } @@ -368,11 +377,22 @@ function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined { }); } +function reservedHeader(name: string): boolean { + const lower = name.toLowerCase(); + return RESERVED_HEADERS.has(lower) || RESERVED_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix)); +} + +/** Whether the admin page may store this as `labels.extraHeader` (the sanitiser's rule). */ +export function isLabelExtraHeaderName(value: string): boolean { + const v = value.trim(); + return HEADER_NAME_RE.test(v) && !reservedHeader(v); +} + function headerName(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const v = value.trim(); if (v === '') return ''; - return HEADER_NAME_RE.test(v) && !RESERVED_HEADERS.has(v.toLowerCase()) ? v : undefined; + return HEADER_NAME_RE.test(v) && !reservedHeader(v) ? v : undefined; } function sanitizeLocaleText(value: unknown): Record | undefined { diff --git a/lib/classification/labels.ts b/lib/classification/labels.ts new file mode 100644 index 000000000..851c4c595 --- /dev/null +++ b/lib/classification/labels.ts @@ -0,0 +1,337 @@ +/** + * 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=#000000; 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, ''); + + 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)) { + // The server writes "Name: " itself; a raw value keeps only its own folds. + out[form === 'raw' ? `header:${name}:asRaw` : `header:${name}:asText`] = form === 'raw' ? value.trimStart() : value; + } + return out; +} + +/** + * A header value for raw MIME, folded under 78 columns: ASCII as it is, + * folded between words; anything else as RFC 2047 encoded words of at + * most 75 characters each, split between characters. + */ +function foldedValue(name: string, value: string): string { + if (/^[\x20-\x7e]*$/.test(value)) { + let line = name.length + 1; + let out = ''; + for (const word of value.split(' ')) { + if (out && line + 1 + word.length > 78) { + out += `\r\n ${word}`; + line = 1 + word.length; + } else { + out += ` ${word}`; + line += 1 + word.length; + } + } + return out; + } + const encoder = new TextEncoder(); + const words: string[] = []; + let chunk: number[] = []; + const flush = () => { + if (chunk.length === 0) return; + words.push(`=?UTF-8?B?${btoa(String.fromCharCode(...chunk))}?=`); + chunk = []; + }; + // 45 bytes are 60 base64 characters: 72 with "=?UTF-8?B?" and "?=", + // which fits a folded line. The first word shares its line with the name. + const firstLimit = Math.max(3, Math.min(45, Math.floor((78 - name.length - 2 - 12) / 4) * 3)); + for (const char of value) { + const bytes = encoder.encode(char); + if (chunk.length + bytes.length > (words.length === 0 ? firstLimit : 45)) flush(); + chunk.push(...bytes); + } + flush(); + return ` ${words.join('\r\n ')}`; +} + +/** The label's headers as raw header lines (folded with CRLF and a space), for raw MIME. */ +export function labelHeaderLines(label: ClassificationLabel, extraHeader: string): string[] { + return labelHeaderPairs(label, extraHeader).map(([name, value, form]) => + form === 'raw' ? `${name}:${value}` : `${name}:${foldedValue(name, 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/classification/presets.ts b/lib/classification/presets.ts new file mode 100644 index 000000000..f4703e46a --- /dev/null +++ b/lib/classification/presets.ts @@ -0,0 +1,63 @@ +/** + * The two label schemes the admin page offers to start from (enterprise + * pack item 3). Markings follow RFC 7444 (SIO-Label), Sensitivity values + * RFC 2156 (what Outlook shows). + * + * The German public-sector preset stops at VS-NfD on purpose: higher grades + * (VS-VERTRAULICH and up) need IT approved for them (VSA § 50 Abs. 4) and + * must not be offered in a mail client that isn't. Even VS-NfD needs + * approved IT and BSI-approved encryption outside an approved network + * (§ 50, § 55); the label only marks the message. + */ + +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; + +export interface LabelPreset { + id: 'generic' | 'german-public-sector'; + name: string; + defaultId: string; + list: ClassificationLabel[]; +} + +const label = (l: Partial & Pick): ClassificationLabel => ({ + description: '', + sensitivity: '', + subjectPrefix: '', + extraHeaderValue: '', + ...l, +}); + +export const LABEL_PRESETS: LabelPreset[] = [ + { + id: 'generic', + name: 'Generic: Public / Internal / Confidential / Strictly confidential', + defaultId: 'internal', + list: [ + label({ id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: 'Can be shared with anyone.', extraHeaderValue: 'Public' }), + label({ id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: 'For people in the organisation and those working with it.', extraHeaderValue: 'Internal' }), + label({ + id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', sensitivity: 'Company-Confidential', + subjectPrefix: '[Confidential]', description: 'Only for the people it is meant for.', extraHeaderValue: 'Confidential', + }), + label({ + id: 'strictly-confidential', name: 'Strictly confidential', color: '#dc2626', marking: 'STRICTLY CONFIDENTIAL', + sensitivity: 'Company-Confidential', subjectPrefix: '[Strictly confidential]', + description: 'Only for the named recipients. Don’t forward.', extraHeaderValue: 'Strictly confidential', + }), + ], + }, + { + id: 'german-public-sector', + name: 'German public sector: OFFEN / VS-NfD', + defaultId: 'offen', + list: [ + label({ id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: 'Nicht eingestuft.', extraHeaderValue: 'OFFEN' }), + label({ + id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', sensitivity: 'Company-Confidential', + subjectPrefix: 'VS-NfD', + description: 'Verschlusssache – nur für den Dienstgebrauch. Nur nach der VSA behandeln und versenden.', + extraHeaderValue: 'VS-NfD', + }), + ], + }, +]; diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 90d95dc90..dcbaecc48 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -383,8 +383,10 @@ export interface IJMAPClient { moveMailboxContents(fromMailboxId: string, toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; markMailboxAsRead(mailboxId: string, accountId?: string): Promise; markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise; - markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise; - undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise; + /** `keywords`: more keywords set with the move ($phishing). */ + markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean, keywords?: string[]): Promise; + /** `clearKeywords`: more keywords removed with the move back ($phishing). */ + undoSpam(emailId: string, originalMailboxId: string, accountId?: string, clearKeywords?: string[]): Promise; // ── Threads ─────────────────────────────────────────────────── getThread(threadId: string, accountId?: string): Promise; @@ -413,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; /** @@ -448,8 +452,9 @@ export interface IJMAPClient { // and need the matching `submissionExtensions` entry (see // supportsSubmissionExtension). // `draftAccountId`: the account `draftId` lives in, so the sent draft is - // destroyed there (default: the primary account). - options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; draftAccountId?: string }, + // 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; 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 eb659cb27..838e2ab0b 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"; @@ -1426,6 +1427,17 @@ export interface SubmissionSendOptions { headers?: Record; /** The account the replaced draft (`draftId`) lives in; default the primary. */ draftAccountId?: string; + /** + * Leave no copy in Sent: the copy made for the send is destroyed once it + * 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. */ @@ -3931,7 +3943,8 @@ export class JMAPClient implements IJMAPClient { return totalMarked; } - async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise { + /** `keywords`: more keywords to set in the same patch ($phishing for a phishing report). */ + async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean, keywords?: string[]): Promise { const targetAccountId = accountId || this.accountId; const mailboxes = await this.getMailboxes(accountId); @@ -3958,6 +3971,7 @@ export class JMAPClient implements IJMAPClient { "keywords/$notjunk": null, }; if (markAsRead) patch["keywords/$seen"] = true; + for (const keyword of keywords ?? []) patch[`keywords/${keyword}`] = true; const response = await this.request([ ["Email/set", { @@ -3968,21 +3982,22 @@ export class JMAPClient implements IJMAPClient { this.assertEmailSetSucceeded(response, "mark as spam"); } - async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise { + /** `clearKeywords`: more keywords to remove in the same patch ($phishing when a report is undone). */ + async undoSpam(emailId: string, originalMailboxId: string, accountId?: string, clearKeywords?: string[]): Promise { const targetAccountId = accountId || this.accountId; + const patch: Record = { + mailboxIds: { [originalMailboxId]: true }, + // Moving alone leaves $junk set, so the message shows up in the + // Inbox while still flagged as junk for every other client. (#850) + "keywords/$junk": null, + "keywords/$notjunk": true, + }; + for (const keyword of clearKeywords ?? []) patch[`keywords/${keyword}`] = null; const response = await this.request([ ["Email/set", { accountId: targetAccountId, - update: { - [emailId]: { - mailboxIds: { [originalMailboxId]: true }, - // Moving alone leaves $junk set, so the message shows up in the - // Inbox while still flagged as junk for every other client. (#850) - "keywords/$junk": null, - "keywords/$notjunk": true, - }, - }, + update: { [emailId]: patch }, }, "0"], ]); this.assertEmailSetSucceeded(response, "mark as not spam"); @@ -4689,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). @@ -4748,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 @@ -4951,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). @@ -4992,6 +5010,10 @@ export class JMAPClient implements IJMAPClient { "keywords/$draft": null, }, }; + // Without a Sent copy the copy goes once the submission is made. + const onSuccess = options?.noSentCopy + ? { onSuccessDestroyEmail: ["#1"] } + : { onSuccessUpdateEmail }; // An explicit envelope is only sent when MAIL FROM should differ from the // identity's address (a From override, e.g. replying from a catch-all @@ -5034,7 +5056,7 @@ export class JMAPClient implements IJMAPClient { methodCalls.push(["EmailSubmission/set", { accountId: submissionAccountId, create: buildSubmissionCreate(`#${emailId}`, firstMailFrom), - onSuccessUpdateEmail, + ...onSuccess, }, "1"]); // Which recipients the server took. A refused one is lost without a // bounce while the create succeeds and files the message in Sent. @@ -5073,7 +5095,7 @@ export class JMAPClient implements IJMAPClient { const retry = await this.request([["EmailSubmission/set", { accountId: submissionAccountId, create: buildSubmissionCreate(draftCopyId, mailFrom), - onSuccessUpdateEmail, + ...onSuccess, }, "1"], deliveryStatusCall(submissionAccountId, "1")]); response = { ...retry, diff --git a/lib/mdn.ts b/lib/mdn.ts index 5e05ab0d6..c255e4944 100644 --- a/lib/mdn.ts +++ b/lib/mdn.ts @@ -40,7 +40,7 @@ const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */ -function rfc5322Date(d: Date = new Date()): string { +export function rfc5322Date(d: Date = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`; @@ -55,7 +55,7 @@ function utf8ToBase64(value: string): string { } /** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */ -function base64Body(text: string): string { +export function base64Body(text: string): string { return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n"); } @@ -71,13 +71,13 @@ function base64Body(text: string): string { * append headers and a body of their choosing to a message the victim's own * account submits (GHSA-w38p-hpqv-g89c). */ -function stripHeaderControls(value: string): string { +export function stripHeaderControls(value: string): string { // eslint-disable-next-line no-control-regex return value.replace(/[\x00-\x1F\x7F]+/g, " ").trim(); } /** Address-like atoms (mailboxes, message-ids) may contain no whitespace at all. */ -function headerAtom(value: string): string { +export function headerAtom(value: string): string { // eslint-disable-next-line no-control-regex return value.replace(/[\x00-\x20\x7F]+/g, ""); } @@ -86,14 +86,14 @@ function headerAtom(value: string): string { * Header value safe to place after "Name: ". Control characters are stripped * first; the result is RFC 2047-encoded when it contains non-ASCII. */ -function encodeHeaderWord(value: string): string { +export function encodeHeaderWord(value: string): string { const clean = stripHeaderControls(value); if (!/[^\x20-\x7E]/.test(clean)) return clean; return `=?UTF-8?B?${utf8ToBase64(clean)}?=`; } /** Display-name for a mailbox: encoded-word if non-ASCII, quoted if it uses specials. */ -function displayName(value: string): string { +export function displayName(value: string): string { const encoded = encodeHeaderWord(value); if (encoded.startsWith("=?") || !/[()<>[\]:;@\\,."]/.test(encoded)) return encoded; return `"${encoded.replace(/["\\]/g, "\\$&")}"`; @@ -109,7 +109,7 @@ function ensureAngles(messageId: string | string[] | undefined): string { return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`; } -function randomToken(): string { +export function randomToken(): string { const rnd = Math.random().toString(36).slice(2); return `${Date.now().toString(36)}.${rnd}`; } diff --git a/lib/org/account-subjects.ts b/lib/org/account-subjects.ts index 74885eaf6..e6332b7f6 100644 --- a/lib/org/account-subjects.ts +++ b/lib/org/account-subjects.ts @@ -17,9 +17,11 @@ import { usePolicyStore } from '@/stores/policy-store'; interface AccountSubjectsState { /** Resolved subjects by local account id. */ subjects: Record; + /** Accounts whose subject was worked out, found or not. */ + settled: Record; } -export const useAccountSubjects = create()(() => ({ subjects: {} })); +export const useAccountSubjects = create()(() => ({ subjects: {}, settled: {} })); const pending = new Map(); @@ -29,10 +31,29 @@ type SubjectSource = Parameters[0]; function resolveSubject(accountId: string, client: NonNullable): void { if (pending.get(accountId) === client) return; pending.set(accountId, client); - void policySubjectForClient(client).then((subject) => { - if (pending.get(accountId) !== client || !subject) return; - useAccountSubjects.setState((state) => ({ subjects: { ...state.subjects, [accountId]: subject } })); - }).catch(() => {}); + const settle = (subject: PolicySubject | null) => { + if (pending.get(accountId) !== client) return; + useAccountSubjects.setState((state) => ({ + subjects: subject ? { ...state.subjects, [accountId]: subject } : state.subjects, + settled: { ...state.settled, [accountId]: true }, + })); + }; + void policySubjectForClient(client).then(settle, () => settle(null)); +} + +/** + * Whether another signed-in account's subject is still being worked out, + * so its group scopes may not apply yet. False for the active account + * (see the policy store's subjectPending) and for accounts not signed in. + */ +export function accountSubjectPending(accountId: string | null | undefined): boolean { + const auth = useAuthStore.getState(); + if (!accountId || accountId === auth.activeAccountId) return false; + const client = auth.getClientForAccount?.(accountId); + if (!client) return false; + if (useAccountSubjects.getState().settled[accountId]) return false; + resolveSubject(accountId, client as unknown as NonNullable); + return !useAccountSubjects.getState().settled[accountId]; } /** @@ -57,5 +78,5 @@ export function subjectForAccount(accountId: string | null | undefined): PolicyS /** For tests: forget every subject worked out. */ export function resetAccountSubjects(): void { pending.clear(); - useAccountSubjects.setState({ subjects: {} }); + useAccountSubjects.setState({ subjects: {}, settled: {} }); } diff --git a/lib/outgoing/__tests__/classification-raw.test.ts b/lib/outgoing/__tests__/classification-raw.test.ts new file mode 100644 index 000000000..b1be8b538 --- /dev/null +++ b/lib/outgoing/__tests__/classification-raw.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; +import { usePolicyStore } from '@/stores/policy-store'; +import { beginLabelTakeover, endLabelTakeover, labelRawSend, resetLabelTakeovers } from '../classification-raw'; + +// Labels on raw sends (api.jmap.sendRaw / submitRaw), the way S/MIME and PGP +// plugins send after a takeover: the takeover's label goes into the outer +// header block, matched by the token the request carries, or by identity +// when the plugin doesn't pass it (refused when that is ambiguous). Label +// headers a plugin wrote are always taken out, and a raw send outside a +// takeover gets the default or is refused when a label is required. + +const generic = LABEL_PRESETS[0]; +const labels = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' }; +const label = (id: string) => generic.list.find((l) => l.id === id)!; + +function useLabels(over: Partial = {}) { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...labels, ...over } } } }); +} + +const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`); +const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); +const headOf = (bytes: Uint8Array) => decode(bytes).split('\r\n\r\n')[0]; +const refusal = (fn: () => unknown) => { + try { fn(); } catch (error) { return error as Error; } + return null; +}; + +afterEach(() => { + usePolicyStore.setState({ policy: DEFAULT_POLICY }); + resetLabelTakeovers(); +}); + +describe('a raw send during a takeover', () => { + it('gets the label in the outer header block, the signed part untouched', () => { + useLabels(); + const token = beginLabelTakeover(labels, label('confidential'), 'id-1')!; + const out = decode(labelRawSend(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1', token)); + const [head, ...rest] = out.split('\r\n\r\n'); + expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"'); + expect(head).toContain('Sensitivity: Company-Confidential'); + expect(head).toContain('X-Classification: Confidential'); + expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n'); + endLabelTakeover(token); + }); + + it('matches two takeovers at once by their tokens, whatever the subjects are', () => { + useLabels(); + const first = beginLabelTakeover(labels, label('public'), 'id-1')!; + const second = beginLabelTakeover(labels, label('confidential'), 'id-1')!; + // Subjects as plugins write them: raw UTF-8 and an encoded word. + const a = headOf(labelRawSend(raw('Subject: Grüße aus Köln'), 'id-1', second)); + const b = headOf(labelRawSend(raw('Subject: =?UTF-8?B?R3LDvMOfZQ==?='), 'id-1', first)); + expect(a).toContain('marking="CONFIDENTIAL"'); + expect(a).toContain('Subject: Grüße aus Köln'); + expect(b).toContain('marking="PUBLIC"'); + }); + + it('matches a plugin that passes no token by its identity, and refuses when two are open', () => { + useLabels(); + const one = beginLabelTakeover(labels, label('confidential'), 'acct-2::id-1')!; + // The request's identity carries the other account's prefix; the plugin passes the raw id. + expect(headOf(labelRawSend(raw('Subject: Grüße'), 'id-1'))).toContain('marking="CONFIDENTIAL"'); + beginLabelTakeover(labels, label('public'), 'id-1'); + expect(refusal(() => labelRawSend(raw('Subject: Grüße'), 'id-1'))?.name).toBe('RawSendLabelError'); + endLabelTakeover(one); + }); + + it('takes out label headers the plugin wrote, also for "no label"', () => { + useLabels(); + const token = beginLabelTakeover(labels, null, 'id-1')!; + const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nSensitivity: Private\r\nX-Classification: Secret'), 'id-1', token)); + expect(out).toBe('Subject: x'); + }); + + it('refuses a token that is no longer open', () => { + useLabels(); + const token = beginLabelTakeover(labels, label('public'), 'id-1')!; + endLabelTakeover(token); + expect(refusal(() => labelRawSend(raw('Subject: x'), 'id-1', token))?.name).toBe('RawSendLabelError'); + }); +}); + +describe('a raw send outside any takeover', () => { + it('gets the default label and loses forged ones', () => { + useLabels(); + const out = headOf(labelRawSend(raw('Subject: x\r\nSIO-Label: marking="PUBLIC"'), 'id-1')); + expect(out).toContain('marking="INTERNAL"'); + expect(out).not.toContain('PUBLIC'); + }); + + it('is refused when a label is required', () => { + useLabels({ required: true, defaultId: '' }); + const error = refusal(() => labelRawSend(raw('Subject: x'), 'id-1')); + expect(error?.name).toBe('RawSendLabelError'); + expect(error?.message).toContain('requires a classification label'); + }); + + it('is left alone while the organisation has no labels', () => { + const bytes = raw('Subject: x\r\nSensitivity: Private'); + expect(labelRawSend(bytes, 'id-1')).toBe(bytes); + }); +}); + +describe('the extra header on raw sends', () => { + it('is folded, non-ASCII as encoded words of at most 75 characters', () => { + useLabels(); + const long = { ...label('confidential'), extraHeaderValue: 'Vertraulich – nur für den internen Gebrauch der Abteilung Öffentlichkeitsarbeit und Presse' }; + const token = beginLabelTakeover(labels, long, 'id-1')!; + const head = headOf(labelRawSend(raw('Subject: x'), 'id-1', token)); + const extra = head.slice(head.indexOf('X-Classification:')).split('\r\n').filter((line, i) => i === 0 || line.startsWith(' ')); + expect(extra.length).toBeGreaterThan(1); + for (const line of extra) expect(line.length).toBeLessThanOrEqual(78); + for (const word of head.match(/=\?UTF-8\?B\?[^?]*\?=/g) ?? []) expect(word.length).toBeLessThanOrEqual(75); + const decoded = (head.match(/=\?UTF-8\?B\?([^?]*)\?=/g) ?? []) + .map((w) => Buffer.from(w.slice(10, -2), 'base64')); + expect(Buffer.concat(decoded).toString('utf8')).toBe(long.extraHeaderValue); + }); +}); diff --git a/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts new file mode 100644 index 000000000..7a277d10d --- /dev/null +++ b/lib/outgoing/__tests__/classification.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountSubjects } from '@/lib/org/account-subjects'; +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, + classifyOutgoing, + isLabelRequiredError, + labelSourceHeaders, + labelsPolicyFor, + reopenedLabelChoice, + resolveLabel, +} from '../classification'; + +// The label stage of sending: which label goes out, the required label +// failing closed, plugin headers that can't override it, the policy of the +// sending account (raw sends: classification-raw.test.ts). + +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)!; + +const fromColleague = (marking: string) => ({ headers: { 'SIO-Label': ` marking="${marking}"` }, from: [{ email: 'colleague@example.org' }] }); +const fromOutsider = (marking: string) => ({ headers: { 'SIO-Label': ` marking="${marking}"` }, from: [{ email: 'news@outside.example' }] }); + +beforeEach(() => { + useAuthStore.setState({ identities: [{ id: 'id-me', name: 'Me', email: 'me@example.org' }] } as never); +}); + +afterEach(() => { + usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null }); + useAuthStore.setState({ identities: [] } as never); +}); + +describe('the label of the original', () => { + it('counts only when an internal sender set it: an outsider can write any header', () => { + expect(resolveLabel(policy, undefined, fromOutsider('PUBLIC'))?.id).toBe('internal'); + expect(resolveLabel(policy, undefined, fromOutsider('CONFIDENTIAL'))?.id).toBe('internal'); + expect(resolveLabel(policy, undefined, { headers: { 'SIO-Label': ' marking="CONFIDENTIAL"' } })?.id).toBe('internal'); + }); + + it('never goes below the default', () => { + expect(resolveLabel(policy, undefined, fromColleague('PUBLIC'))?.id).toBe('internal'); + expect(resolveLabel(policy, undefined, fromColleague('STRICTLY CONFIDENTIAL'))?.id).toBe('strictly-confidential'); + // Without a default, an internal label counts whatever it is. + expect(resolveLabel({ ...policy, defaultId: '' }, undefined, fromColleague('PUBLIC'))?.id).toBe('public'); + }); +}); + +describe('a message opened again', () => { + it('keeps the label a sent copy went out with (Undo, Cancel and edit), a draft’s own pick first', () => { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } } }); + expect(reopenedLabelChoice({ 'SIO-Label': 'marking="CONFIDENTIAL"; bgcolor=#d97706' })).toBe('confidential'); + expect(reopenedLabelChoice({ 'X-Bulwark-Label': 'none', 'SIO-Label': 'marking="CONFIDENTIAL"' })).toBeNull(); + expect(reopenedLabelChoice({ 'X-Bulwark-Label': 'public' })).toBe('public'); + expect(reopenedLabelChoice({})).toBeUndefined(); + }); +}); + +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, fromColleague('CONFIDENTIAL'))?.id).toBe('confidential'); + expect(resolveLabel(policy, undefined)?.id).toBe('internal'); + expect(resolveLabel(policy, null, fromColleague('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' }, fromColleague('PUBLIC'))).toMatchObject({ missing: false, label: { id: 'public' } }); + expect(automaticLabel({ email: 'me@example.org' }, { headers: {} })).toMatchObject({ missing: true, label: null }); + // An outsider's label doesn't count: a required label is still missing. + expect(automaticLabel({ email: 'me@example.org' }, fromOutsider('PUBLIC'))).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('the labels of another account', () => { + it('come from that account’s own scopes, not the active one’s', () => { + const team = { ...policy, list: [label('confidential')], defaultId: 'confidential' }; + const other = { getUsername: () => 'b@team.example', getSharedAccounts: () => [] }; + useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never); + usePolicyStore.setState({ + policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } }, + rawPolicy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy }, + scopes: [{ id: 'team', label: 'Team', match: { domains: ['team.example'] }, policy: { organisation: { labels: team } } }], + } as never, + subject: { email: 'me@example.org', groups: [] }, + }); + useAccountSubjects.setState({ subjects: { 'acct-b': { email: 'b@team.example', groups: [] } }, settled: { 'acct-b': true } }); + expect(labelsPolicyFor({ accountId: 'acct-b', email: 'b@team.example' }).defaultId).toBe('confidential'); + expect(labelsPolicyFor({}).defaultId).toBe('internal'); + useAuthStore.setState({ activeAccountId: null, getClientForAccount: () => undefined } as never); + }); +}); diff --git a/lib/outgoing/classification-raw.ts b/lib/outgoing/classification-raw.ts new file mode 100644 index 000000000..e9d27e31c --- /dev/null +++ b/lib/outgoing/classification-raw.ts @@ -0,0 +1,134 @@ +/** + * Classification labels on raw sends (api.jmap.sendRaw and submitRaw), the + * way S/MIME and PGP plugins send after they took the send over from the + * composer (onComposeSend). They build the MIME themselves, so the host + * puts the label into the top-level header block of what they hand it, + * outside the signed or encrypted part. + * + * - The composer registers each takeover before it runs + * (beginLabelTakeover) and hands the plugin its `labelToken` in the + * request. A raw send that passes the token back gets that takeover's + * label. Plugins that don't pass it yet are matched by their sending + * identity; with more than one takeover open for it, the send is refused + * rather than guessed. + * - While the organisation uses labels, label headers a plugin wrote itself + * are always taken out. + * - A raw send outside any takeover gets the default label, and is refused + * when a label is required: it can't carry the user's pick. + */ + +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; +import { labelHeaderLines, type LabelsPolicy } from '@/lib/classification/labels'; +import { stripCrossAccountIdentityPrefix } from '@/hooks/use-pro-multi-account-identities'; +import { generateUUID } from '@/lib/utils'; +import { labelsPolicyFor } from './classification'; +import { LABEL_HEADER_NAMES } from './custom-headers'; + +interface Takeover { + identityId: string; + /** The label's header lines; empty for "no label". */ + lines: string[]; +} + +const takeovers = new Map(); + +/** An identity id without the "::" prefix of another signed-in account's identities. */ +function rawIdentityId(identityId: string): string { + return stripCrossAccountIdentityPrefix(identityId).rawId ?? identityId; +} + +/** A raw send the host refuses; the plugin gets the message. */ +export class RawSendLabelError extends Error { + constructor(message: string) { + super(message); + this.name = 'RawSendLabelError'; + } +} + +/** + * Registers a takeover about to run, with the label the message goes out + * with (null: none). Returns the token to put in the request and to pass + * to endLabelTakeover when the takeover ended; null while labels are off. + */ +export function beginLabelTakeover( + policy: LabelsPolicy, + label: ClassificationLabel | null, + identityId: string, +): string | null { + if (policy.list.length === 0) return null; + const token = generateUUID(); + takeovers.set(token, { identityId: rawIdentityId(identityId), lines: label ? labelHeaderLines(label, policy.extraHeader) : [] }); + return token; +} + +export function endLabelTakeover(token: string | null): void { + if (token) takeovers.delete(token); +} + +/** For tests. */ +export function resetLabelTakeovers(): void { + takeovers.clear(); +} + +/** The header lines a raw send gets: its takeover's, or the default's outside one. */ +function linesFor(policy: LabelsPolicy, identityId: string, token: string | undefined): string[] { + if (token) { + const takeover = takeovers.get(token); + if (!takeover) throw new RawSendLabelError('This send is no longer open in the composer. Send the message again.'); + return takeover.lines; + } + const id = rawIdentityId(identityId); + const open = [...takeovers.values()].filter((t) => t.identityId === id); + if (open.length === 1) return open[0].lines; + if (open.length > 1) { + throw new RawSendLabelError('Several messages are being sent from this address at once, and this one can\'t be matched to its classification label. Send it again.'); + } + if (policy.required) { + throw new RawSendLabelError('Your organisation requires a classification label, and a message sent outside the composer can\'t carry one.'); + } + const fallback = policy.list.find((label) => label.id === policy.defaultId); + return fallback ? labelHeaderLines(fallback, policy.extraHeader) : []; +} + +/** Where the header block of raw message bytes ends, and its line break. */ +function headerEnd(bytes: Uint8Array): { end: number; eol: string } | null { + for (let i = 0; i < bytes.length - 1; i++) { + if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) return { end: i, eol: '\r\n' }; + if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) return { end: i, eol: '\n' }; + } + return null; +} + +/** + * The raw message a plugin hands to sendRaw or submitRaw, with the label + * the organisation's policy (of the active account, which sends it) asks + * for. The same bytes while labels are off. Throws RawSendLabelError when + * the send has to be refused. + */ +export function labelRawSend(bytes: Uint8Array, identityId: string, token?: string): Uint8Array { + const policy = labelsPolicyFor({}); + if (policy.list.length === 0) return bytes; + const lines = linesFor(policy, identityId, typeof token === 'string' && token ? token : undefined); + const block = headerEnd(bytes); + if (!block) throw new RawSendLabelError('The message has no header block.'); + // Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged. + let head = ''; + for (let i = 0; i < block.end; i++) head += String.fromCharCode(bytes[i]); + const reserved = new Set([...LABEL_HEADER_NAMES, ...(policy.extraHeader ? [policy.extraHeader.toLowerCase()] : [])]); + const kept: string[] = []; + let skipping = false; + for (const line of head.split(/\r?\n/)) { + if (/^[ \t]/.test(line)) { + if (!skipping) kept.push(line); + continue; + } + skipping = reserved.has(line.slice(0, line.indexOf(':')).trim().toLowerCase()); + if (!skipping) kept.push(line); + } + const text = [...kept, ...lines.map((line) => line.replace(/\r\n/g, block.eol))].join(block.eol); + const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff); + const out = new Uint8Array(newHead.length + (bytes.length - block.end)); + out.set(newHead, 0); + out.set(bytes.subarray(block.end), newHead.length); + return out; +} diff --git a/lib/outgoing/classification.ts b/lib/outgoing/classification.ts new file mode 100644 index 000000000..ec3b89e10 --- /dev/null +++ b/lib/outgoing/classification.ts @@ -0,0 +1,197 @@ +/** + * The classification label stage of sending (enterprise pack item 3). + * Every surface that sends a user's mail runs it last, after plugins' + * onTransformOutgoingEmail, so a plugin can't drop or change the label: + * + * - the composer (inline, pop-out and Pro tab), with the user's pick; + * - quick reply, with the original's label, else the default; + * - a plugin's forward (api.email.forward), the same way. + * + * A required label fails closed: with none, the stage throws + * LabelRequiredError and nothing is sent. Labels come from the policy of + * the account and identity the mail goes out from, never just the active + * account. Automatic mail carries none: calendar invitations and RSVP + * replies, read receipts, List-Unsubscribe mails and phishing reports. + * + * S/MIME and PGP plugins take over the send (onComposeSend) and build raw + * MIME themselves: the composer hands them the prefixed subject, and + * lib/outgoing/classification-raw.ts puts the label into what they send. + */ + +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { subjectForAccount } from '@/lib/org/account-subjects'; +import { policyForSender } from '@/lib/org/signature-state'; +import { activeExternalMailContext, contextInternalDomains, externalSenders, isOtherAccount } from '@/lib/external-mail'; +import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities'; +import { usePolicyStore } from '@/stores/policy-store'; +import { withoutLabelHeaders } from './custom-headers'; +import { + applyLabelPrefix, + labelHeaderProperties, + draftLabelChoice, + receivedLabel, + type LabelsPolicy, +} from '@/lib/classification/labels'; + +/** A send stopped because the organisation requires a label and none was picked. */ +export class LabelRequiredError extends Error { + constructor() { + super('A classification label is required'); + this.name = 'LabelRequiredError'; + } +} + +export function isLabelRequiredError(error: unknown): boolean { + return (error as { name?: string } | null)?.name === 'LabelRequiredError'; +} + +export interface LabelSender { + /** The From identity's address (a group's own picks the group's scopes). */ + email?: string; + /** The signed-in account (AccountEntry id) it goes out through; the active one when left out. */ + accountId?: string | null; +} + +/** The labels section that applies to mail from `sender`, read from the stores now. */ +export function labelsPolicyFor(sender: LabelSender): LabelsPolicy { + const policy = policyForSender(usePolicyStore.getState(), sender.email, subjectForAccount(sender.accountId)); + return policy.organisation?.labels ?? DEFAULT_ORGANISATION_POLICY.labels; +} + +/** The message a reply or forward answers, as far as its label goes. */ +export interface OriginalMessage { + /** Its headers (labelSourceHeaders keeps the ones that matter). */ + headers?: Record | null; + from?: ReadonlyArray<{ email?: string | null }> | null; + /** The signed-in account it was received in; the active one when left out. */ + accountId?: string | null; +} + +/** + * Whether every From address of a message is inside the organisation of + * the account that received it (its identities' domains and the admin's + * organisation domains). False without a From or without known domains. + */ +export function senderIsInternal( + from: OriginalMessage['from'], + accountId?: string | null, +): boolean { + if (!from || from.length === 0) return false; + const context = isOtherAccount(accountId) + ? { + policy: (usePolicyStore.getState().resolveFor(subjectForAccount(accountId)).organisation + ?? DEFAULT_ORGANISATION_POLICY).externalMail, + identityEmails: (cachedIdentitiesForAccount(accountId) ?? []).map((identity) => identity.email), + } + : activeExternalMailContext(); + const internal = contextInternalDomains(context); + return internal.length > 0 && externalSenders(from, internal).length === 0; +} + +/** + * The label a reply or forward takes over from its original: the + * original's known label, only when an internal sender set it (anyone + * outside can write the header) and never one below the default (the + * list runs from least to most protected). + */ +export function originalLabel(policy: LabelsPolicy, original: OriginalMessage | null | undefined): ClassificationLabel | null { + if (!original?.headers || policy.list.length === 0) return null; + const label = receivedLabel(original.headers, policy); + if (!label || !senderIsInternal(original.from, original.accountId)) return null; + const floor = policy.list.findIndex((l) => l.id === policy.defaultId); + return floor >= 0 && policy.list.indexOf(label) < floor ? null : label; +} + +/** + * The label a message goes out with. `choice`: the user's pick (an id), + * null for "no label", undefined when they picked nothing, which takes the + * original's label (originalLabel), else the default. A pick that isn't in + * the list (another account's, an old one) counts as nothing picked. + */ +export function resolveLabel( + policy: LabelsPolicy, + choice: string | null | undefined, + original?: OriginalMessage | null, +): ClassificationLabel | null { + if (policy.list.length === 0) return null; + if (choice === null) return null; + const picked = choice ? policy.list.find((label) => label.id === choice) : undefined; + if (picked) return picked; + return originalLabel(policy, original) ?? policy.list.find((label) => label.id === policy.defaultId) ?? null; +} + +/** + * The pick a message opened again in the composer starts with: a draft's + * X-Bulwark-Label, else the label its headers carry (a sent message + * brought back by Undo, "Cancel and edit" or edit-as-new carries SIO-Label + * only), read with the labels of the account it is in. + */ +export function reopenedLabelChoice( + headers: Record | null | undefined, + accountId?: string | null, +): string | null | undefined { + const marked = draftLabelChoice(headers); + if (marked !== undefined) return marked; + return receivedLabel(headers, labelsPolicyFor({ accountId }))?.id; +} + +/** Whether sending needs a label that `label` doesn't give. */ +export function labelMissing(policy: LabelsPolicy, label: ClassificationLabel | null): boolean { + return policy.required && policy.list.length > 0 && !label; +} + +export interface LabelledMessage { + subject: string; + /** The plugins' headers, without any the label writes. */ + headers?: Record; + /** 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 (originalLabel), else the default. */ +export function automaticLabel( + sender: LabelSender, + original?: OriginalMessage | null, +): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } { + const policy = labelsPolicyFor(sender); + const label = resolveLabel(policy, undefined, original); + return { policy, label, missing: labelMissing(policy, label) }; +} + +/** + * The original's headers a reply or forward needs for its label: SIO-Label + * and every extra header name any scope of the policy uses. Small enough + * to keep in a Pro tab's saved state. + */ +export function labelSourceHeaders(headers: Record | 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/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts index 5ef1ecc9e..957f1b38f 100644 --- a/lib/outgoing/send-pipeline.ts +++ b/lib/outgoing/send-pipeline.ts @@ -20,7 +20,11 @@ import { debug } from '@/lib/debug'; import { whenPolicySettled } from '@/stores/policy-store'; // 'plugin-forward': a plugin forwarding a message (api.email.forward). -export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward'; +// 'report-phishing': a phishing report (lib/phishing-report.ts). It goes to +// the admin's address and is not the user's mail, so it never runs the +// checks, the finalisers, the classification label or plugins' +// onBeforeEmailSend; the surface names it for code that has to tell. +export type SendSurface = 'composer' | 'quick-reply' | 'calendar-invitation' | 'plugin-forward' | 'report-phishing'; export interface OutgoingContext { surface: SendSurface; diff --git a/lib/phishing-links.ts b/lib/phishing-links.ts new file mode 100644 index 000000000..e899bbf7b --- /dev/null +++ b/lib/phishing-links.ts @@ -0,0 +1,50 @@ +/** + * Links in a message the user reported as phishing ($phishing, RFC 9051 + * §2.3.2: clients should warn and disable links) are turned off: each + * `` and `` loses its address, so it can't be clicked, opened in + * a new tab or reached with Tab, and says that it is off. Runs on the + * sanitised body, before it goes into the reader's frame. + */ + +import type { Email } from '@/lib/jmap/types'; +import { PHISHING_KEYWORD } from '@/lib/phishing-report'; + +/** Whether the message carries the phishing mark. */ +export function isPhishingMarked(email: Pick | null | undefined): boolean { + return email?.keywords?.[PHISHING_KEYWORD] === true; +} + +const LINK_OFF_STYLE = 'color:inherit !important;text-decoration:underline dotted !important;cursor:not-allowed !important'; + +/** What a turned-off link says: its tooltip, and the note a screen reader hears after its text. */ +export interface LinksOffText { + title: string; + note: string; +} + +/** `html` with every link turned off. */ +export function turnOffLinks(html: string, text: LinksOffText): string { + if (!html || typeof DOMParser === 'undefined') return html; + if (!/<(a|area)\b/i.test(html)) return html; + const doc = new DOMParser().parseFromString(html, 'text/html'); + const links = doc.querySelectorAll('a, area'); + if (links.length === 0) return html; + links.forEach((link) => { + for (const name of ['href', 'xlink:href', 'target', 'rel', 'ping', 'download', 'tabindex']) link.removeAttribute(name); + link.setAttribute('data-link-off', ''); + link.setAttribute('aria-disabled', 'true'); + link.setAttribute('title', text.title); + if (link.tagName === 'A') { + // Looks like text that was a link; a screen reader hears the note. + const style = link.getAttribute('style'); + link.setAttribute('style', `${style ? `${style};` : ''}${LINK_OFF_STYLE}`); + const note = doc.createElement('span'); + note.setAttribute('data-link-off-note', ''); + note.setAttribute('style', 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap'); + note.textContent = ` ${text.note}`; + link.appendChild(note); + } + }); + // A full document keeps its (styles); a fragment stays a fragment. + return /]/i.test(html) ? doc.documentElement.outerHTML : doc.body.innerHTML; +} diff --git a/lib/phishing-report-arf.ts b/lib/phishing-report-arf.ts new file mode 100644 index 000000000..7950a5885 --- /dev/null +++ b/lib/phishing-report-arf.ts @@ -0,0 +1,155 @@ +/** + * The ARF format of a phishing report (RFC 5965, an admin option of + * Organisation > Security > Phishing reports): a multipart/report with + * report-type=feedback-report and three parts, a short text for people, the + * machine-readable message/feedback-report (Feedback-Type: fraud, which + * RFC 5965 §7.3 defines as "fraud or phishing"), and the original whole and + * unchanged as message/rfc822. + * + * JMAP can't set the report-type parameter through Email/set, so this is + * raw MIME: the original's bytes are downloaded, wrapped and uploaded, then + * submitted with submitRawEmail (no Sent copy). The upload counts against + * the server's upload quota and is limited by maxSizeUpload. + */ + +import type { Email } from '@/lib/jmap/types'; +import { base64Body, displayName, encodeHeaderWord, headerAtom, randomToken, rfc5322Date } from '@/lib/mdn'; +import { deliveryAddresses } from '@/lib/reply-identity'; +import { reportSubject, type ArfInput } from './phishing-report'; + +export interface ArfParts { + /** Everything before the original's bytes. */ + head: string; + /** Everything after them. */ + tail: string; +} + +function appVersion(): string { + return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim() || 'unknown'; +} + +/** "Reported-Domain": the domain of the original's From, when it has one. */ +function senderDomain(email: Pick): string | null { + const address = email.from?.[0]?.email ?? ''; + const domain = address.slice(address.lastIndexOf('@') + 1); + return address.includes('@') && /^[A-Za-z0-9.-]+$/.test(domain) ? domain.toLowerCase() : null; +} + +/** How the original goes in: message/rfc822 may not be base64-encoded (RFC 2046 §5.2.1). */ +export type RawEncoding = '7bit' | '8bit' | 'binary'; + +/** + * The report around the original. `encoding`: what the original's bytes + * are (rawEncoding). `recipient`: the address the original was delivered + * to, for Original-Rcpt-To; left out when unknown. + */ +export function buildArfParts(input: { + email: Pick; + reporter: { name?: string; email: string }; + to: string; + encoding: RawEncoding; + recipient?: string; + now?: Date; +}): ArfParts { + const { email, reporter } = input; + const fromEmail = headerAtom(reporter.email); + const domain = fromEmail.split('@')[1] || 'localhost'; + const boundary = `----=_ARF_${randomToken()}`; + const fromName = reporter.name?.trim() ? displayName(reporter.name.trim()) : ''; + const arrival = email.receivedAt ? new Date(email.receivedAt) : null; + const reportedDomain = senderDomain(email); + const human = [ + `This is a phishing report (RFC 5965) sent by ${fromEmail} with Bulwark.`, + '', + 'The reported message is attached unchanged.', + ].join('\r\n'); + const report = [ + 'Feedback-Type: fraud', + `User-Agent: Bulwark/${appVersion()}`, + 'Version: 1', + ...(arrival && !Number.isNaN(arrival.getTime()) ? [`Arrival-Date: ${rfc5322Date(arrival)}`] : []), + ...(input.recipient ? [`Original-Rcpt-To: ${headerAtom(input.recipient)}`] : []), + ...(reportedDomain ? [`Reported-Domain: ${reportedDomain}`] : []), + ].join('\r\n'); + + const head = [ + `Date: ${rfc5322Date(input.now ?? new Date())}`, + `From: ${fromName ? `${fromName} <${fromEmail}>` : fromEmail}`, + `To: ${headerAtom(input.to)}`, + `Subject: ${encodeHeaderWord(reportSubject(email.subject))}`, + `Message-ID: `, + 'MIME-Version: 1.0', + 'Content-Type: multipart/report; report-type=feedback-report;', + `\tboundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/plain; charset=utf-8', + 'Content-Transfer-Encoding: base64', + '', + base64Body(human), + '', + `--${boundary}`, + 'Content-Type: message/feedback-report', + 'Content-Transfer-Encoding: 7bit', + '', + report, + '', + `--${boundary}`, + 'Content-Type: message/rfc822', + 'Content-Disposition: attachment; filename="reported-message.eml"', + `Content-Transfer-Encoding: ${input.encoding}`, + '', + '', + ].join('\r\n'); + const tail = `\r\n--${boundary}--\r\n`; + return { head, tail }; +} + +/** + * 7bit or 8bit lines of at most 998 octets ending in CRLF (RFC 2045 §2.7, + * §2.8); anything else (a NUL, a bare CR or LF, a longer line) is binary. + */ +export function rawEncoding(bytes: Uint8Array): RawEncoding { + let eightBit = false; + let line = 0; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (b === 0x00) return 'binary'; + if (b === 0x0d) { + if (bytes[i + 1] !== 0x0a) return 'binary'; + i++; + line = 0; + continue; + } + if (b === 0x0a) return 'binary'; + if (b > 0x7f) eightBit = true; + if (++line > 998) return 'binary'; + } + return eightBit ? '8bit' : '7bit'; +} + +/** The whole report as a Blob, the original downloaded from its own account. */ +export async function buildArfReportBlob(input: ArfInput): Promise { + const { client, email, sourceAccountId } = input; + if (!email.blobId) throw new Error('The message has no content to report'); + // The report is uploaded: one bigger than the server takes fails anyway, + // so it isn't downloaded either. + const maxUpload = client.getMaxSizeUpload?.() ?? 0; + if (maxUpload > 0 && email.size + 8192 > maxUpload) { + throw new Error('The message is too big for an ARF report on this server'); + } + const original = new Uint8Array(await client.fetchBlobArrayBuffer(email.blobId, 'message.eml', 'message/rfc822', sourceAccountId)); + const parts = buildArfParts({ + email, + reporter: input.reporter, + to: input.to, + encoding: rawEncoding(original), + recipient: deliveryAddresses(email.headers)[0] + ?? email.to?.find((addr) => addr.email?.toLowerCase() === input.reporter.email.toLowerCase())?.email, + }); + // The original goes in byte for byte; the closing boundary needs a line break before it. + const endsWithBreak = original.length >= 2 && original[original.length - 2] === 0x0d && original[original.length - 1] === 0x0a; + const report = new Blob([parts.head, original, endsWithBreak ? parts.tail.slice(2) : parts.tail], { type: 'message/rfc822' }); + if (maxUpload > 0 && report.size > maxUpload) throw new Error('The message is too big for an ARF report on this server'); + return report; +} diff --git a/lib/phishing-report-flow.ts b/lib/phishing-report-flow.ts new file mode 100644 index 000000000..e5abf4be0 --- /dev/null +++ b/lib/phishing-report-flow.ts @@ -0,0 +1,156 @@ +/** + * The app side of "Report phishing" (lib/phishing-report.ts sends one + * report): which policy applies to a message, sending one report per + * message, moving each to Junk with $phishing, and undoing the move. + * + * The policy is the one of the account the message is reached through + * (its own login in the unified inbox), so a domain or group scope with its + * own report address applies to its own mail. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email } from '@/lib/jmap/types'; +import { subjectForAccount } from '@/lib/org/account-subjects'; +import { emailKeyFor } from '@/lib/thread-utils'; +import { debug } from '@/lib/debug'; +import { useAuthStore } from '@/stores/auth-store'; +import { resolveEmailActionContext, useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { usePhishingReportStore } from '@/stores/phishing-report-store'; +import { emailHooks } from '@/lib/plugin-hooks'; +import { + PHISHING_KEYWORD, + phishingReportSetup, + sendPhishingReport, + type ArfInput, + type PhishingReportSetup, +} from './phishing-report'; + +/** The report setup for a message, from the policy of the account it is in. */ +export function phishingSetupForEmail(email: Pick | null | undefined): PhishingReportSetup | null { + const view = usePolicyStore.getState(); + const subject = subjectForAccount(email?.sourceClientAccountId ?? null); + const policy = subject === view.subject ? view.policy : view.resolveFor(subject); + return phishingReportSetup(policy); +} + +interface MovedReport { + email: Email; + /** Moved through the store (its undo cache knows the folder) or straight through the client. */ + via: 'store' | { client: IJMAPClient; accountId?: string; mailboxId: string }; +} + +export interface PhishingReportOutcome { + /** Every address the reports went to (accounts with different policies differ). */ + addresses: string[]; + /** Reports that went out. */ + reported: Email[]; + /** Messages whose report didn't go out. */ + failed: Email[]; + /** Reported, but the move to Junk failed or a plugin stopped it. */ + notMoved: Email[]; + /** What undo moves back. */ + moved: MovedReport[]; +} + +// Loaded only when an admin picked the ARF format. +async function loadArfBuilder(): Promise<(input: ArfInput) => Promise> { + const { buildArfReportBlob } = await import('./phishing-report-arf'); + return buildArfReportBlob; +} + +/** The folder a message is in, for a move back that the store can't do. */ +function firstMailboxId(email: Email): string | null { + const ids = Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]); + return ids[0] ?? null; +} + +/** + * Reports each message on its own, then moves it to Junk. Messages that + * can't be reported (reporting off for their account) count as failed. + */ +export async function reportEmailsAsPhishing(emails: Email[]): Promise { + const active = useAuthStore.getState().client; + if (!active) throw new Error('Not signed in'); + const outcome: PhishingReportOutcome = { addresses: [], reported: [], failed: [], notMoved: [], moved: [] }; + let buildArf: ((input: ArfInput) => Promise) | undefined; + + for (const email of emails) { + const setup = phishingSetupForEmail(email); + if (!setup) { + outcome.failed.push(email); + continue; + } + if (!outcome.addresses.includes(setup.address)) outcome.addresses.push(setup.address); + const { client, accountId } = resolveEmailActionContext(email, active); + try { + if (setup.format === 'arf' && !buildArf) buildArf = await loadArfBuilder(); + await sendPhishingReport(email, { client, sourceAccountId: accountId ?? client.getAccountId(), setup, buildArf }); + } catch (error) { + debug.error('Phishing report failed:', error); + outcome.failed.push(email); + continue; + } + outcome.reported.push(email); + // Every open view of it shows the banner and turns its links off now. + usePhishingReportStore.getState().markReported([emailKeyFor(email)], true); + + // Like "Mark as spam" (plugins' onBeforeEmailSpamToggle still runs), + // with $phishing, and read: it shouldn't count as new mail in Junk. + const store = useEmailStore.getState(); + const known = store.emails.some((e) => emailKeyFor(e) === emailKeyFor(email)) + || (!!store.selectedEmail && emailKeyFor(store.selectedEmail) === emailKeyFor(email)); + try { + if (known) { + const moved = await store.markAsSpam(active, email.id, emailKeyFor(email), { keywords: [PHISHING_KEYWORD], markRead: true }); + if (moved) outcome.moved.push({ email, via: 'store' }); + else outcome.notMoved.push(email); + } else { + // A message the list doesn't hold (a Pro tab, a conversation's older + // message): straight to its account, with the plugins' spam hooks + // as "Mark as spam" runs them. + const toggle = { emailIds: [email.id], spam: true }; + if (emailHooks.onBeforeEmailSpamToggle.size > 0 && !(await emailHooks.onBeforeEmailSpamToggle.intercept(toggle))) { + outcome.notMoved.push(email); + continue; + } + const mailboxId = firstMailboxId(email); + await client.markAsSpam(email.id, accountId, true, [PHISHING_KEYWORD]); + void emailHooks.onEmailSpamToggle.emit(toggle); + if (mailboxId) outcome.moved.push({ email, via: { client, accountId, mailboxId } }); + } + } catch (error) { + debug.error('Moving a reported message to Junk failed:', error); + outcome.notMoved.push(email); + } + } + return outcome; +} + +/** Moves the reported messages back and clears $junk and $phishing. Resolves to how many came back. */ +export async function undoPhishingReports(outcome: PhishingReportOutcome): Promise { + const active = useAuthStore.getState().client; + if (!active) return 0; + let back = 0; + for (const { email, via } of outcome.moved) { + try { + let done = false; + if (via === 'store') { + done = await useEmailStore.getState().undoSpam(active, email.id, emailKeyFor(email), { clearKeywords: [PHISHING_KEYWORD] }); + } else { + const toggle = { emailIds: [email.id], spam: false }; + if (emailHooks.onBeforeEmailSpamToggle.size > 0 && !(await emailHooks.onBeforeEmailSpamToggle.intercept(toggle))) continue; + await via.client.undoSpam(email.id, via.mailboxId, via.accountId, [PHISHING_KEYWORD]); + void emailHooks.onEmailSpamToggle.emit(toggle); + done = true; + } + if (done) { + back++; + usePhishingReportStore.getState().markReported([emailKeyFor(email)], false); + } + } catch (error) { + debug.error('Moving a reported message back failed:', error); + } + } + return back; +} diff --git a/lib/phishing-report.ts b/lib/phishing-report.ts new file mode 100644 index 000000000..add09c241 --- /dev/null +++ b/lib/phishing-report.ts @@ -0,0 +1,155 @@ +/** + * "Report phishing" (enterprise pack item 2): sends a message to the + * address the admin set (policy.json `organisation.phishingReport`) and + * moves it to Junk with $junk and $phishing. A sibling of + * lib/plugin-sandbox/host-api-forward.ts: it sends without the composer. + * + * - One report per message, also for a selection: report desks (Microsoft + * Defender among them) discard reports with several messages attached, + * and RFC 5965 §2(e) says one message per report. + * - No copy in Sent: a live phishing message shouldn't sit there to be + * clicked again; the admin's mailbox is the record. + * - The forward format attaches the original by its blobId (Email/set in + * Drafts, then EmailSubmission with onSuccessDestroyEmail), so nothing is + * uploaded and nothing counts against the upload quota. A message of + * another account (a shared or group mailbox) has its blob copied to the + * sending account first (Blob/copy). + * - The report skips the pre-send checks and finalisers of the send + * pipeline (external-recipient confirm, organisation signature and + * disclaimer), classification labels and plugins' onBeforeEmailSend: the + * address is the admin's, and the report is not the user's mail (send + * surface 'report-phishing', never passed to the pipeline). + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Identity } from '@/lib/jmap/types'; +import type { SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY, type PhishingReportFormat } from '@/lib/admin/organisation-policy'; +import { emailExportFilename } from '@/lib/download-filename'; +import { sortIdentities } from '@/lib/identity-order'; + +/** RFC 9051 §2.3.2: the user reported the message as phishing. */ +export const PHISHING_KEYWORD = '$phishing'; + +export interface PhishingReportSetup { + address: string; + format: PhishingReportFormat; +} + +/** The setup in a policy; null while reporting is off (no address). */ +export function phishingReportSetup(policy: SettingsPolicy | null | undefined): PhishingReportSetup | null { + const section = policy?.organisation?.phishingReport ?? DEFAULT_ORGANISATION_POLICY.phishingReport; + const address = section.address.trim(); + return address ? { address, format: section.format === 'arf' ? 'arf' : 'forward' } : null; +} + +/** Folders whose messages are the user's own: nothing to report there. */ +const OWN_MAIL_ROLES = new Set(['sent', 'drafts', 'scheduled']); + +/** + * Whether `email`, shown in a folder of `mailboxRole`, can be reported. + * `folderRoles`: the roles of the folders the message itself is in (search + * results and All mail show the user's own sent mail too). + */ +export function isReportable( + email: Pick, + mailboxRole?: string | null, + folderRoles: readonly string[] = [], +): boolean { + if (!email.blobId || email.isScheduled) return false; + if (mailboxRole && OWN_MAIL_ROLES.has(mailboxRole)) return false; + if (folderRoles.some((role) => OWN_MAIL_ROLES.has(role))) return false; + if (email.keywords?.$draft) return false; + // Reported already: it is in Junk with its links off. + return email.keywords?.[PHISHING_KEYWORD] !== true; +} + +/** The subject of the report: Microsoft's reporting mailboxes read "Phishing:" as the verdict. */ +export function reportSubject(subject: string | undefined | null): string { + const clean = (subject ?? '').replace(/[\r\n]+/g, ' ').trim(); + return clean ? `Phishing: ${clean}` : 'Phishing'; +} + +/** The short text of a forwarded report. English: it is read by the security team, not the user. */ +export function reportText(reporter: { name?: string; email: string }): string { + const who = reporter.name?.trim() ? `${reporter.name.trim()} <${reporter.email}>` : reporter.email; + return `Reported as phishing by ${who} with Bulwark.\r\nThe reported message is attached unchanged.\r\n`; +} + +/** The identity a report goes out from: the login's own address first. */ +export function reporterIdentity(identities: Identity[], username: string): Identity | null { + const usable = identities.filter((identity) => identity.email && !identity.email.includes('*')); + return sortIdentities(usable, username)[0] ?? null; +} + +export interface ReportContext { + /** The login the message is reachable through. */ + client: IJMAPClient; + /** The JMAP account the message lives in (a shared one, for a group mailbox). */ + sourceAccountId: string; + setup: PhishingReportSetup; + /** Builds the raw ARF report (lib/phishing-report-arf.ts); needed for format 'arf'. */ + buildArf?: (input: ArfInput) => Promise; +} + +export interface ArfInput { + client: IJMAPClient; + email: Email; + sourceAccountId: string; + reporter: Identity; + to: string; +} + +/** + * Sends one report for `email`. Throws when nothing went out; resolves once + * the server took the submission. + */ +export async function sendPhishingReport(email: Email, ctx: ReportContext): Promise { + const { client, setup } = ctx; + if (!email.blobId) throw new Error('The message has no content to report'); + const identity = reporterIdentity(await client.getIdentities(), client.getUsername()); + if (!identity) throw new Error('No sender address to report from'); + + if (setup.format === 'arf') { + if (!ctx.buildArf) throw new Error('ARF reports are not available'); + const raw = await ctx.buildArf({ client, email, sourceAccountId: ctx.sourceAccountId, reporter: identity, to: setup.address }); + // Imported into Drafts and destroyed once submitted: no Sent copy. + await client.submitRawEmail(raw, identity.id, undefined, [setup.address]); + return; + } + + // The original's own blob, attached by id; copied over when the message + // lives in another account than the one the report goes out from. + const sendAccountId = (await client.resolveSendAccountId?.(identity.email)) ?? client.getAccountId(); + let blobId = email.blobId; + if (ctx.sourceAccountId !== sendAccountId) { + const copied = await client.copyBlobs(ctx.sourceAccountId, [blobId], sendAccountId); + const copy = copied[blobId]; + if (!copy) throw new Error('The message could not be copied to the account that sends the report'); + blobId = copy; + } + await client.sendEmail( + [setup.address], + reportSubject(email.subject), + reportText(identity), + undefined, + undefined, + identity.id, + identity.email, + undefined, + identity.name || undefined, + undefined, + [{ + blobId, + name: emailExportFilename(email, { template: '{date}-{subject}' }), + type: 'message/rfc822', + size: email.size, + disposition: 'attachment', + }], + undefined, + undefined, + undefined, + undefined, + { noSentCopy: true }, + ); +} diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts index 09d2cbb53..98ae6c4ef 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 }, { headers: email.headers, from: email.from }); + 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..4b727dc50 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 { labelRawSend } from '@/lib/outgoing/classification-raw'; import { fetchConfig } from '@/hooks/use-config'; import { IS_LITE } from '../lite'; import { doAppGetSettings, doAppSearchSettings } from './host-api-app'; @@ -800,6 +801,8 @@ async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown): interface JmapSubmitRawOptions { delayedUntil?: string; envelopeRecipients?: string[]; + /** The `labelToken` of the onComposeSend request this send completes. */ + labelToken?: string; } /** @@ -822,7 +825,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' }); + // The classification label goes into the top-level header block, outside + // what the plugin signed or encrypted (lib/outgoing/classification-raw.ts). + const labelled = labelRawSend(copy, identityId, opts?.labelToken); + const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return useEmailStore.getState().sendRawEmail( client, blob, @@ -856,7 +862,8 @@ async function doJmapSubmitRaw( const copy = new Uint8Array(view.byteLength); copy.set(view); - const blob = new Blob([copy.buffer], { type: 'message/rfc822' }); + const labelled = labelRawSend(copy, identityId, opts?.labelToken); + const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return client.submitRawEmail( blob, @@ -2040,12 +2047,12 @@ export async function dispatchApiCall( case 'jmap.sendRaw': return doJmapSendRaw( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, - args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, + args[2] as JmapSubmitRawOptions | undefined, ); case 'jmap.submitRaw': return doJmapSubmitRaw( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, - args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, + args[2] as JmapSubmitRawOptions | undefined, ); case 'jmap.importRaw': return doJmapImportRaw( args[0] as ArrayBuffer | ArrayBufferView, diff --git a/locales/ar/common.json b/locales/ar/common.json index 4cf40542a..0d3b61d88 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -883,6 +883,35 @@ "send_anyway": "الإرسال على أي حال", "back": "رجوع" }, + "phishing_report": { + "action": "الإبلاغ عن تصيّد احتيالي…", + "confirm_title": "الإبلاغ عن تصيّد احتيالي؟", + "confirm_message": "{count, plural, zero {لا توجد رسائل لإرسالها إلى {address}.} one {ستُرسل الرسالة إلى {address} وتُنقل إلى البريد المزعج.} two {ستُرسل كل من الرسالتين إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} few {ستُرسل كل رسالة من # رسائل إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} many {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} other {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.}}", + "confirm": "إبلاغ", + "cancel": "إلغاء", + "sending": "جارٍ الإبلاغ…", + "done": "{count, plural, zero {لم تُرسل أي رسالة إلى {address}.} one {تم إبلاغ {address} عن الرسالة ونُقلت إلى البريد المزعج.} two {تم إبلاغ {address} عن رسالتين ونُقلتا إلى البريد المزعج.} few {تم إبلاغ {address} عن # رسائل ونُقلت إلى البريد المزعج.} many {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.} other {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.}}", + "not_moved": "{count, plural, zero {تم إبلاغ {address} ونُقلت كل الرسائل إلى البريد المزعج.} one {تم إبلاغ {address}، لكن تعذّر نقل الرسالة إلى البريد المزعج.} two {تم إبلاغ {address}، لكن تعذّر نقل رسالتين إلى البريد المزعج.} few {تم إبلاغ {address}، لكن تعذّر نقل # رسائل إلى البريد المزعج.} many {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.} other {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.}}", + "failed": "{count, plural, zero {تم الإبلاغ عن كل الرسائل.} one {تعذّر الإبلاغ عن الرسالة.} two {تعذّر الإبلاغ عن رسالتين.} few {تعذّر الإبلاغ عن # رسائل.} many {تعذّر الإبلاغ عن # رسالة.} other {تعذّر الإبلاغ عن # رسالة.}}", + "undo": "تراجع", + "undone": "{count, plural, zero {تمت الإعادة إلى مكانها.} one {أُعيدت الرسالة إلى مكانها. البلاغ أُرسل بالفعل.} two {أُعيدت الرسالتان إلى مكانهما. البلاغان أُرسلا بالفعل.} few {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} many {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} other {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.}}", + "undo_failed": "تعذّرت الإعادة إلى المكان السابق.", + "banner": "أبلغت عن هذه الرسالة كتصيّد احتيالي. تم تعطيل روابطها.", + "link_off": "الرابط معطّل", + "link_off_note": "(الرابط معطّل)", + "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 788b0ccbf..1301700b9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -883,6 +883,35 @@ "send_anyway": "Envia igualment", "back": "Enrere" }, + "phishing_report": { + "action": "Denuncia com a pesca de credencials…", + "confirm_title": "Voleu denunciar-ho com a pesca de credencials?", + "confirm_message": "{count, plural, one {El missatge s'envia a {address} i es mou al correu brossa.} other {Cadascun dels # missatges s'envia a {address} com a denúncia independent i es mou al correu brossa.}}", + "confirm": "Denuncia", + "cancel": "Cancel·la", + "sending": "S'està denunciant…", + "done": "{count, plural, one {S'ha denunciat a {address} i s'ha mogut al correu brossa.} other {S'han denunciat # missatges a {address} i s'han mogut al correu brossa.}}", + "not_moved": "{count, plural, one {S'ha denunciat a {address}, però el missatge no s'ha pogut moure al correu brossa.} other {S'ha denunciat a {address}, però # missatges no s'han pogut moure al correu brossa.}}", + "failed": "{count, plural, one {No s'ha pogut denunciar el missatge.} other {No s'han pogut denunciar # missatges.}}", + "undo": "Desfés", + "undone": "{count, plural, one {S'ha tornat al lloc. La denúncia ja s'havia enviat.} other {S'han tornat al lloc. Les denúncies ja s'havien enviat.}}", + "undo_failed": "No s'ha pogut tornar al lloc.", + "banner": "Heu denunciat aquest missatge com a pesca de credencials. Els enllaços estan desactivats.", + "link_off": "enllaç desactivat", + "link_off_note": "(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 1169c5353..3d7cbfdfd 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -883,6 +883,35 @@ "send_anyway": "Přesto odeslat", "back": "Zpět" }, + "phishing_report": { + "action": "Nahlásit phishing…", + "confirm_title": "Nahlásit phishing?", + "confirm_message": "{count, plural, one {Zpráva bude odeslána na {address} a přesunuta do složky Spam.} few {# zprávy budou odeslány na {address}, každá jako samostatné hlášení, a přesunuty do složky Spam.} many {# zprávy bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.} other {# zpráv bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.}}", + "confirm": "Nahlásit", + "cancel": "Zrušit", + "sending": "Nahlašování…", + "done": "{count, plural, one {Nahlášeno na {address} a přesunuto do složky Spam.} few {# zprávy nahlášeny na {address} a přesunuty do složky Spam.} many {# zprávy nahlášeno na {address} a přesunuto do složky Spam.} other {# zpráv nahlášeno na {address} a přesunuto do složky Spam.}}", + "not_moved": "{count, plural, one {Nahlášeno na {address}, ale zprávu se nepodařilo přesunout do složky Spam.} few {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} many {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} other {Nahlášeno na {address}, ale # zpráv se nepodařilo přesunout do složky Spam.}}", + "failed": "{count, plural, one {Zprávu se nepodařilo nahlásit.} few {# zprávy se nepodařilo nahlásit.} many {# zprávy se nepodařilo nahlásit.} other {# zpráv se nepodařilo nahlásit.}}", + "undo": "Zpět", + "undone": "{count, plural, one {Vráceno zpět. Hlášení už bylo odesláno.} few {Vráceno zpět. Hlášení už byla odeslána.} many {Vráceno zpět. Hlášení už byla odeslána.} other {Vráceno zpět. Hlášení už byla odeslána.}}", + "undo_failed": "Nepodařilo se vrátit zpět.", + "banner": "Tuto zprávu jste nahlásili jako phishing. Odkazy v ní jsou vypnuté.", + "link_off": "odkaz vypnut", + "link_off_note": "(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 587317f1e..2dfb3fa80 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -883,6 +883,35 @@ "send_anyway": "Send alligevel", "back": "Tilbage" }, + "phishing_report": { + "action": "Rapportér phishing…", + "confirm_title": "Rapportér phishing?", + "confirm_message": "{count, plural, one {Beskeden sendes til {address} og flyttes til Spam.} other {Hver af de # beskeder sendes til {address} som en separat rapport og flyttes til Spam.}}", + "confirm": "Rapportér", + "cancel": "Annuller", + "sending": "Rapporterer…", + "done": "{count, plural, one {Rapporteret til {address} og flyttet til Spam.} other {# beskeder rapporteret til {address} og flyttet til Spam.}}", + "not_moved": "{count, plural, one {Rapporteret til {address}, men beskeden kunne ikke flyttes til Spam.} other {Rapporteret til {address}, men # beskeder kunne ikke flyttes til Spam.}}", + "failed": "{count, plural, one {Beskeden kunne ikke rapporteres.} other {# beskeder kunne ikke rapporteres.}}", + "undo": "Fortryd", + "undone": "{count, plural, one {Flyttet tilbage. Rapporten var allerede sendt.} other {Flyttet tilbage. Rapporterne var allerede sendt.}}", + "undo_failed": "Kunne ikke flyttes tilbage.", + "banner": "Du har rapporteret denne besked som phishing. Links i den er slået fra.", + "link_off": "link slået fra", + "link_off_note": "(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 5d939cba5..99226d759 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -883,6 +883,35 @@ "send_anyway": "Trotzdem senden", "back": "Zurück" }, + "phishing_report": { + "action": "Phishing melden…", + "confirm_title": "Phishing melden?", + "confirm_message": "{count, plural, one {Die Nachricht geht an {address} und wird in den Spam-Ordner verschoben.} other {Jede der # Nachrichten geht als eigene Meldung an {address} und wird in den Spam-Ordner verschoben.}}", + "confirm": "Melden", + "cancel": "Abbrechen", + "sending": "Wird gemeldet…", + "done": "{count, plural, one {An {address} gemeldet und in den Spam-Ordner verschoben.} other {# Nachrichten an {address} gemeldet und in den Spam-Ordner verschoben.}}", + "not_moved": "{count, plural, one {An {address} gemeldet, aber die Nachricht konnte nicht in den Spam-Ordner verschoben werden.} other {An {address} gemeldet, aber # Nachrichten konnten nicht in den Spam-Ordner verschoben werden.}}", + "failed": "{count, plural, one {Die Nachricht konnte nicht gemeldet werden.} other {# Nachrichten konnten nicht gemeldet werden.}}", + "undo": "Rückgängig", + "undone": "{count, plural, one {Zurückverschoben. Die Meldung wurde bereits gesendet.} other {Zurückverschoben. Die Meldungen wurden bereits gesendet.}}", + "undo_failed": "Konnte nicht zurückverschoben werden.", + "banner": "Sie haben diese Nachricht als Phishing gemeldet. Die Links darin sind deaktiviert.", + "link_off": "Link deaktiviert", + "link_off_note": "(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 5f5507dfe..af8c1afdd 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -883,6 +883,35 @@ "send_anyway": "Send anyway", "back": "Back" }, + "phishing_report": { + "action": "Report phishing…", + "confirm_title": "Report phishing?", + "confirm_message": "{count, plural, one {The message goes to {address} and moves to Junk.} other {Each of the # messages goes to {address} as its own report and moves to Junk.}}", + "confirm": "Report", + "cancel": "Cancel", + "sending": "Reporting…", + "done": "{count, plural, one {Reported to {address} and moved to Junk.} other {# messages reported to {address} and moved to Junk.}}", + "not_moved": "{count, plural, one {Reported to {address}, but the message couldn't be moved to Junk.} other {Reported to {address}, but # messages couldn't be moved to Junk.}}", + "failed": "{count, plural, one {The message couldn't be reported.} other {# messages couldn't be reported.}}", + "undo": "Undo", + "undone": "{count, plural, one {Moved back. The report was already sent.} other {Moved back. The reports were already sent.}}", + "undo_failed": "Couldn't move it back.", + "banner": "You reported this message as phishing. Its links are turned off.", + "link_off": "link turned off", + "link_off_note": "(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 9882934e1..dd2838d92 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -883,6 +883,35 @@ "send_anyway": "Enviar de todos modos", "back": "Atrás" }, + "phishing_report": { + "action": "Reportar phishing…", + "confirm_title": "¿Reportar phishing?", + "confirm_message": "{count, plural, one {El mensaje se enviará a {address} y se moverá a Spam.} other {Cada uno de los # mensajes se enviará a {address} como un reporte aparte y se moverá a Spam.}}", + "confirm": "Reportar", + "cancel": "Cancelar", + "sending": "Reportando…", + "done": "{count, plural, one {Reportado a {address} y movido a Spam.} other {# mensajes reportados a {address} y movidos a Spam.}}", + "not_moved": "{count, plural, one {Reportado a {address}, pero el mensaje no se pudo mover a Spam.} other {Reportados a {address}, pero # mensajes no se pudieron mover a Spam.}}", + "failed": "{count, plural, one {No se pudo reportar el mensaje.} other {No se pudieron reportar # mensajes.}}", + "undo": "Deshacer", + "undone": "{count, plural, one {Devuelto a su sitio. El reporte ya se había enviado.} other {Devueltos a su sitio. Los reportes ya se habían enviado.}}", + "undo_failed": "No se pudo devolver a su sitio.", + "banner": "Has reportado este mensaje como phishing. Sus enlaces están desactivados.", + "link_off": "enlace desactivado", + "link_off_note": "(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 39bbc270f..9c29e3278 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -883,6 +883,35 @@ "send_anyway": "Saada ikkagi", "back": "Tagasi" }, + "phishing_report": { + "action": "Teata õngitsusest…", + "confirm_title": "Teatada õngitsusest?", + "confirm_message": "{count, plural, one {Kiri saadetakse aadressile {address} ja teisaldatakse rämpsposti.} other {Kõik # kirja saadetakse aadressile {address} eraldi teadetena ja teisaldatakse rämpsposti.}}", + "confirm": "Teata", + "cancel": "Tühista", + "sending": "Teatamine…", + "done": "{count, plural, one {Teatatud aadressile {address} ja teisaldatud rämpsposti.} other {# kirjast teatatud aadressile {address} ja teisaldatud rämpsposti.}}", + "not_moved": "{count, plural, one {Teatatud aadressile {address}, kuid kirja ei õnnestunud rämpsposti teisaldada.} other {Teatatud aadressile {address}, kuid # kirja ei õnnestunud rämpsposti teisaldada.}}", + "failed": "{count, plural, one {Kirjast ei õnnestunud teatada.} other {# kirjast ei õnnestunud teatada.}}", + "undo": "Võta tagasi", + "undone": "{count, plural, one {Tõstetud tagasi. Teade oli juba saadetud.} other {Tõstetud tagasi. Teated olid juba saadetud.}}", + "undo_failed": "Tagasi tõstmine ei õnnestunud.", + "banner": "Teatasid sellest kirjast kui õngitsusest. Selle lingid on välja lülitatud.", + "link_off": "link välja lülitatud", + "link_off_note": "(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 ad8328a19..83e789a49 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -883,6 +883,35 @@ "send_anyway": "ارسال به هر حال", "back": "بازگشت" }, + "phishing_report": { + "action": "گزارش فیشینگ…", + "confirm_title": "به‌عنوان فیشینگ گزارش شود؟", + "confirm_message": "{count, plural, one {پیام به {address} فرستاده و به هرزنامه منتقل می‌شود.} other {هر یک از # پیام به‌صورت گزارشی جداگانه به {address} فرستاده و به هرزنامه منتقل می‌شود.}}", + "confirm": "گزارش", + "cancel": "انصراف", + "sending": "در حال گزارش…", + "done": "{count, plural, one {به {address} گزارش و به هرزنامه منتقل شد.} other {# پیام به {address} گزارش و به هرزنامه منتقل شد.}}", + "not_moved": "{count, plural, one {به {address} گزارش شد، اما پیام به هرزنامه منتقل نشد.} other {به {address} گزارش شد، اما # پیام به هرزنامه منتقل نشد.}}", + "failed": "{count, plural, one {گزارش پیام ممکن نشد.} other {گزارش # پیام ممکن نشد.}}", + "undo": "بازگردانی", + "undone": "{count, plural, one {به جای قبلی برگردانده شد. گزارش قبلاً فرستاده شده است.} other {به جای قبلی برگردانده شدند. گزارش‌ها قبلاً فرستاده شده‌اند.}}", + "undo_failed": "برگرداندن ممکن نشد.", + "banner": "شما این پیام را به‌عنوان فیشینگ گزارش کردید. پیوندهای آن غیرفعال شده‌اند.", + "link_off": "پیوند غیرفعال شد", + "link_off_note": "(پیوند غیرفعال شد)", + "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 bd0edd0a4..fe68ab68e 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -883,6 +883,35 @@ "send_anyway": "Envoyer quand même", "back": "Retour" }, + "phishing_report": { + "action": "Signaler comme hameçonnage…", + "confirm_title": "Signaler comme hameçonnage ?", + "confirm_message": "{count, plural, one {Le message sera envoyé à {address} et déplacé dans le dossier Spam.} other {Chacun des # messages sera envoyé à {address} dans un signalement distinct et déplacé dans le dossier Spam.}}", + "confirm": "Signaler", + "cancel": "Annuler", + "sending": "Signalement en cours…", + "done": "{count, plural, one {Signalé à {address} et déplacé dans le dossier Spam.} other {# messages signalés à {address} et déplacés dans le dossier Spam.}}", + "not_moved": "{count, plural, one {Signalé à {address}, mais le message n'a pas pu être déplacé dans le dossier Spam.} other {Signalés à {address}, mais # messages n'ont pas pu être déplacés dans le dossier Spam.}}", + "failed": "{count, plural, one {Le message n'a pas pu être signalé.} other {# messages n'ont pas pu être signalés.}}", + "undo": "Annuler", + "undone": "{count, plural, one {Remis à sa place. Le signalement a déjà été envoyé.} other {Remis à leur place. Les signalements ont déjà été envoyés.}}", + "undo_failed": "Impossible de le remettre à sa place.", + "banner": "Vous avez signalé ce message comme hameçonnage. Ses liens sont désactivés.", + "link_off": "lien désactivé", + "link_off_note": "(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 4eaa76960..78919a882 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -848,6 +848,35 @@ "send_anyway": "שלח בכל זאת", "back": "חזרה" }, + "phishing_report": { + "action": "דווח על פישינג…", + "confirm_title": "לדווח על פישינג?", + "confirm_message": "{count, plural, one {ההודעה תישלח אל {address} ותועבר לספאם.} two {כל אחת משתי ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.} other {כל אחת מ-# ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.}}", + "confirm": "דווח", + "cancel": "ביטול", + "sending": "שולח דיווח…", + "done": "{count, plural, one {ההודעה דווחה אל {address} והועברה לספאם.} two {שתי הודעות דווחו אל {address} והועברו לספאם.} other {# הודעות דווחו אל {address} והועברו לספאם.}}", + "not_moved": "{count, plural, one {דווח אל {address}, אבל לא ניתן היה להעביר את ההודעה לספאם.} two {דווח אל {address}, אבל לא ניתן היה להעביר שתי הודעות לספאם.} other {דווח אל {address}, אבל לא ניתן היה להעביר # הודעות לספאם.}}", + "failed": "{count, plural, one {לא ניתן היה לדווח על ההודעה.} two {לא ניתן היה לדווח על שתי הודעות.} other {לא ניתן היה לדווח על # הודעות.}}", + "undo": "בטל", + "undone": "{count, plural, one {הוחזרה למקומה. הדיווח כבר נשלח.} two {הוחזרו למקומן. הדיווחים כבר נשלחו.} other {הוחזרו למקומן. הדיווחים כבר נשלחו.}}", + "undo_failed": "לא ניתן היה להחזיר למקום.", + "banner": "דיווחת על ההודעה הזו כפישינג. הקישורים שבה מושבתים.", + "link_off": "הקישור מושבת", + "link_off_note": "(הקישור מושבת)", + "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 ba382be6a..ca54bcb2c 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -883,6 +883,35 @@ "send_anyway": "Küldés így is", "back": "Vissza" }, + "phishing_report": { + "action": "Adathalászat jelentése…", + "confirm_title": "Jelented adathalászatként?", + "confirm_message": "{count, plural, one {Az üzenet a(z) {address} címre megy, és a Spam mappába kerül.} other {Mind a(z) # üzenet külön jelentésként megy a(z) {address} címre, és a Spam mappába kerül.}}", + "confirm": "Jelentés", + "cancel": "Mégse", + "sending": "Jelentés folyamatban…", + "done": "{count, plural, one {Jelentve a(z) {address} címre, és áthelyezve a Spam mappába.} other {# üzenet jelentve a(z) {address} címre, és áthelyezve a Spam mappába.}}", + "not_moved": "{count, plural, one {Jelentve a(z) {address} címre, de az üzenetet nem sikerült áthelyezni a Spam mappába.} other {Jelentve a(z) {address} címre, de # üzenetet nem sikerült áthelyezni a Spam mappába.}}", + "failed": "{count, plural, one {Az üzenetet nem sikerült jelenteni.} other {# üzenetet nem sikerült jelenteni.}}", + "undo": "Visszavonás", + "undone": "{count, plural, one {Visszahelyezve. A jelentés már elment.} other {Visszahelyezve. A jelentések már elmentek.}}", + "undo_failed": "Nem sikerült visszahelyezni.", + "banner": "Ezt az üzenetet adathalászatként jelentetted. A benne lévő linkek ki vannak kapcsolva.", + "link_off": "link kikapcsolva", + "link_off_note": "(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 337d6c200..918f8d378 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -883,6 +883,35 @@ "send_anyway": "Invia comunque", "back": "Indietro" }, + "phishing_report": { + "action": "Segnala come phishing…", + "confirm_title": "Segnalare come phishing?", + "confirm_message": "{count, plural, one {Il messaggio viene inviato a {address} e spostato in Spam.} other {Ognuno dei # messaggi viene inviato a {address} come segnalazione separata e spostato in Spam.}}", + "confirm": "Segnala", + "cancel": "Annulla", + "sending": "Segnalazione in corso…", + "done": "{count, plural, one {Segnalato a {address} e spostato in Spam.} other {# messaggi segnalati a {address} e spostati in Spam.}}", + "not_moved": "{count, plural, one {Segnalato a {address}, ma non è stato possibile spostare il messaggio in Spam.} other {Segnalati a {address}, ma non è stato possibile spostare # messaggi in Spam.}}", + "failed": "{count, plural, one {Non è stato possibile segnalare il messaggio.} other {Non è stato possibile segnalare # messaggi.}}", + "undo": "Annulla", + "undone": "{count, plural, one {Rimesso al suo posto. La segnalazione era già stata inviata.} other {Rimessi al loro posto. Le segnalazioni erano già state inviate.}}", + "undo_failed": "Impossibile rimetterlo al suo posto.", + "banner": "Hai segnalato questo messaggio come phishing. I suoi link sono disattivati.", + "link_off": "link disattivato", + "link_off_note": "(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 ae2774b8f..abc5b5956 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -883,6 +883,35 @@ "send_anyway": "そのまま送信", "back": "戻る" }, + "phishing_report": { + "action": "フィッシングを報告…", + "confirm_title": "フィッシングとして報告しますか?", + "confirm_message": "{count, plural, =1 {メールを {address} に送信し、迷惑メールに移動します。} other {# 件のメールをそれぞれ個別の報告として {address} に送信し、迷惑メールに移動します。}}", + "confirm": "報告", + "cancel": "キャンセル", + "sending": "報告中…", + "done": "{count, plural, =1 {{address} に報告し、迷惑メールに移動しました。} other {# 件のメールを {address} に報告し、迷惑メールに移動しました。}}", + "not_moved": "{count, plural, =1 {{address} に報告しましたが、メールを迷惑メールに移動できませんでした。} other {{address} に報告しましたが、# 件のメールを迷惑メールに移動できませんでした。}}", + "failed": "{count, plural, =1 {メールを報告できませんでした。} other {# 件のメールを報告できませんでした。}}", + "undo": "元に戻す", + "undone": "{count, plural, =1 {元の場所に戻しました。報告はすでに送信済みです。} other {元の場所に戻しました。報告はすでに送信済みです。}}", + "undo_failed": "元に戻せませんでした。", + "banner": "このメールをフィッシングとして報告しました。メール内のリンクは無効になっています。", + "link_off": "リンクは無効です", + "link_off_note": "(リンク無効)", + "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 c0c174b28..c9faa5ecf 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -883,6 +883,35 @@ "send_anyway": "그래도 보내기", "back": "뒤로" }, + "phishing_report": { + "action": "피싱 신고…", + "confirm_title": "피싱으로 신고할까요?", + "confirm_message": "{count, plural, =1 {메시지를 {address} 주소로 보내고 스팸함으로 옮겨요.} other {메시지 #개를 각각 별도의 신고로 {address} 주소로 보내고 스팸함으로 옮겨요.}}", + "confirm": "신고", + "cancel": "취소", + "sending": "신고하는 중…", + "done": "{count, plural, =1 {{address} 주소로 신고하고 스팸함으로 옮겼어요.} other {메시지 #개를 {address} 주소로 신고하고 스팸함으로 옮겼어요.}}", + "not_moved": "{count, plural, =1 {{address} 주소로 신고했지만 메시지를 스팸함으로 옮기지 못했어요.} other {{address} 주소로 신고했지만 메시지 #개를 스팸함으로 옮기지 못했어요.}}", + "failed": "{count, plural, =1 {메시지를 신고하지 못했어요.} other {메시지 #개를 신고하지 못했어요.}}", + "undo": "실행 취소", + "undone": "{count, plural, =1 {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.} other {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.}}", + "undo_failed": "되돌리지 못했어요.", + "banner": "이 메시지를 피싱으로 신고했어요. 메시지의 링크는 꺼져 있어요.", + "link_off": "링크 꺼짐", + "link_off_note": "(링크 꺼짐)", + "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 aa9237d5a..c3f93c784 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -883,6 +883,35 @@ "send_anyway": "Sūtīt tik un tā", "back": "Atpakaļ" }, + "phishing_report": { + "action": "Ziņot par pikšķerēšanu…", + "confirm_title": "Ziņot par pikšķerēšanu?", + "confirm_message": "{count, plural, =1 {Ziņojums tiks nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.} one {# ziņojums tiks nosūtīts uz {address}, katrs atsevišķi, un pārvietots uz mapi Mēstules.} other {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.}}", + "confirm": "Ziņot", + "cancel": "Atcelt", + "sending": "Notiek ziņošana…", + "done": "{count, plural, =1 {Nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.} one {# ziņojums nosūtīts uz {address} un pārvietots uz mapi Mēstules.} other {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.}}", + "not_moved": "{count, plural, =1 {Nosūtīts uz {address}, bet ziņojumu neizdevās pārvietot uz mapi Mēstules.} zero {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.} one {Nosūtīti uz {address}, bet # ziņojumu neizdevās pārvietot uz mapi Mēstules.} other {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.}}", + "failed": "{count, plural, =1 {Neizdevās ziņot par ziņojumu.} zero {Neizdevās ziņot par # ziņojumiem.} one {Neizdevās ziņot par # ziņojumu.} other {Neizdevās ziņot par # ziņojumiem.}}", + "undo": "Atsaukt", + "undone": "{count, plural, =1 {Pārvietots atpakaļ. Ziņa par pikšķerēšanu jau ir nosūtīta.} zero {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} one {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} other {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.}}", + "undo_failed": "Neizdevās pārvietot atpakaļ.", + "banner": "Jūs ziņojāt par šo ziņojumu kā par pikšķerēšanu. Tā saites ir izslēgtas.", + "link_off": "saite izslēgta", + "link_off_note": "(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 308fc4af1..ec7e567db 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -883,6 +883,35 @@ "send_anyway": "Ямар ч байсан илгээх", "back": "Буцах" }, + "phishing_report": { + "action": "Фишинг мэдээлэх…", + "confirm_title": "Фишинг гэж мэдээлэх үү?", + "confirm_message": "{count, plural, one {Мессеж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.} other {# мессеж тус бүр тусдаа мэдээлэл болж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.}}", + "confirm": "Мэдээлэх", + "cancel": "Цуцлах", + "sending": "Мэдээлж байна…", + "done": "{count, plural, one {{address} хаяг руу мэдээлж, Спам руу шилжүүллээ.} other {# мессежийг {address} хаяг руу мэдээлж, Спам руу шилжүүллээ.}}", + "not_moved": "{count, plural, one {{address} хаяг руу мэдээлсэн ч мессежийг Спам руу шилжүүлж чадсангүй.} other {{address} хаяг руу мэдээлсэн ч # мессежийг Спам руу шилжүүлж чадсангүй.}}", + "failed": "{count, plural, one {Мессежийг мэдээлж чадсангүй.} other {# мессежийг мэдээлж чадсангүй.}}", + "undo": "Буцаах", + "undone": "{count, plural, one {Буцааж шилжүүллээ. Мэдээлэл аль хэдийн илгээгдсэн.} other {Буцааж шилжүүллээ. Мэдээллүүд аль хэдийн илгээгдсэн.}}", + "undo_failed": "Буцааж шилжүүлж чадсангүй.", + "banner": "Та энэ мессежийг фишинг гэж мэдээлсэн. Үүний холбоосуудыг идэвхгүй болгосон.", + "link_off": "холбоосыг идэвхгүй болгосон", + "link_off_note": "(холбоосыг идэвхгүй болгосон)", + "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 7ebbdf1d1..3c7951466 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -883,6 +883,35 @@ "send_anyway": "Send likevel", "back": "Tilbake" }, + "phishing_report": { + "action": "Rapporter nettfisking…", + "confirm_title": "Rapportere nettfisking?", + "confirm_message": "{count, plural, one {Meldingen sendes til {address} og flyttes til Søppelpost.} other {Hver av de # meldingene sendes til {address} som en egen rapport og flyttes til Søppelpost.}}", + "confirm": "Rapporter", + "cancel": "Avbryt", + "sending": "Rapporterer…", + "done": "{count, plural, one {Rapportert til {address} og flyttet til Søppelpost.} other {# meldinger rapportert til {address} og flyttet til Søppelpost.}}", + "not_moved": "{count, plural, one {Rapportert til {address}, men meldingen kunne ikke flyttes til Søppelpost.} other {Rapportert til {address}, men # meldinger kunne ikke flyttes til Søppelpost.}}", + "failed": "{count, plural, one {Meldingen kunne ikke rapporteres.} other {# meldinger kunne ikke rapporteres.}}", + "undo": "Angre", + "undone": "{count, plural, one {Flyttet tilbake. Rapporten var allerede sendt.} other {Flyttet tilbake. Rapportene var allerede sendt.}}", + "undo_failed": "Kunne ikke flytte den tilbake.", + "banner": "Du har rapportert denne meldingen som nettfisking. Lenkene i den er slått av.", + "link_off": "lenke slått av", + "link_off_note": "(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 b3145d86d..6c2701c7e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -883,6 +883,35 @@ "send_anyway": "Toch verzenden", "back": "Terug" }, + "phishing_report": { + "action": "Phishing melden…", + "confirm_title": "Phishing melden?", + "confirm_message": "{count, plural, one {Het bericht gaat naar {address} en wordt naar Spam verplaatst.} other {Elk van de # berichten gaat als aparte melding naar {address} en wordt naar Spam verplaatst.}}", + "confirm": "Melden", + "cancel": "Annuleren", + "sending": "Bezig met melden…", + "done": "{count, plural, one {Gemeld bij {address} en naar Spam verplaatst.} other {# berichten gemeld bij {address} en naar Spam verplaatst.}}", + "not_moved": "{count, plural, one {Gemeld bij {address}, maar het bericht kon niet naar Spam worden verplaatst.} other {Gemeld bij {address}, maar # berichten konden niet naar Spam worden verplaatst.}}", + "failed": "{count, plural, one {Het bericht kon niet worden gemeld.} other {# berichten konden niet worden gemeld.}}", + "undo": "Ongedaan maken", + "undone": "{count, plural, one {Teruggezet. De melding was al verstuurd.} other {Teruggezet. De meldingen waren al verstuurd.}}", + "undo_failed": "Kon niet worden teruggezet.", + "banner": "Je hebt dit bericht als phishing gemeld. De links erin zijn uitgeschakeld.", + "link_off": "link uitgeschakeld", + "link_off_note": "(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 25a74acaf..7538eddb7 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -883,6 +883,35 @@ "send_anyway": "Wyślij mimo to", "back": "Wstecz" }, + "phishing_report": { + "action": "Zgłoś phishing…", + "confirm_title": "Zgłosić phishing?", + "confirm_message": "{count, plural, one {Wiadomość trafi do {address} i zostanie przeniesiona do folderu Spam.} few {# wiadomości trafią do {address}, każda jako osobne zgłoszenie, i zostaną przeniesione do folderu Spam.} many {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.} other {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.}}", + "confirm": "Zgłoś", + "cancel": "Anuluj", + "sending": "Zgłaszanie…", + "done": "{count, plural, one {Zgłoszono do {address} i przeniesiono do folderu Spam.} few {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} many {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} other {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.}}", + "not_moved": "{count, plural, one {Zgłoszono do {address}, ale nie udało się przenieść wiadomości do folderu Spam.} few {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} many {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} other {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.}}", + "failed": "{count, plural, one {Nie udało się zgłosić wiadomości.} few {Nie udało się zgłosić # wiadomości.} many {Nie udało się zgłosić # wiadomości.} other {Nie udało się zgłosić # wiadomości.}}", + "undo": "Cofnij", + "undone": "{count, plural, one {Przeniesiono z powrotem. Zgłoszenie zostało już wysłane.} few {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} many {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} other {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.}}", + "undo_failed": "Nie udało się przenieść z powrotem.", + "banner": "Ta wiadomość została zgłoszona jako phishing. Linki w niej są wyłączone.", + "link_off": "link wyłączony", + "link_off_note": "(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 06b42881a..a6f4b27df 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -883,6 +883,35 @@ "send_anyway": "Enviar mesmo assim", "back": "Voltar" }, + "phishing_report": { + "action": "Reportar phishing…", + "confirm_title": "Reportar phishing?", + "confirm_message": "{count, plural, one {A mensagem vai para {address} e é movida para Spam.} other {Cada uma das # mensagens vai para {address} como uma denúncia separada e é movida para Spam.}}", + "confirm": "Reportar", + "cancel": "Cancelar", + "sending": "Reportando…", + "done": "{count, plural, one {Reportada para {address} e movida para Spam.} other {# mensagens reportadas para {address} e movidas para Spam.}}", + "not_moved": "{count, plural, one {Reportada para {address}, mas não foi possível mover a mensagem para Spam.} other {Reportadas para {address}, mas não foi possível mover # mensagens para Spam.}}", + "failed": "{count, plural, one {Não foi possível reportar a mensagem.} other {Não foi possível reportar # mensagens.}}", + "undo": "Desfazer", + "undone": "{count, plural, one {Devolvida ao lugar. A denúncia já foi enviada.} other {Devolvidas ao lugar. As denúncias já foram enviadas.}}", + "undo_failed": "Não foi possível devolver ao lugar.", + "banner": "Você reportou esta mensagem como phishing. Os links dela estão desativados.", + "link_off": "link desativado", + "link_off_note": "(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 2636c6532..d928de7c6 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -883,6 +883,35 @@ "send_anyway": "Trimite oricum", "back": "Înapoi" }, + "phishing_report": { + "action": "Raportează ca phishing…", + "confirm_title": "Raportați ca phishing?", + "confirm_message": "{count, plural, one {Mesajul ajunge la {address} și este mutat în Spam.} few {Fiecare dintre cele # mesaje ajunge la {address} ca raport separat și este mutat în Spam.} other {Fiecare dintre cele # de mesaje ajunge la {address} ca raport separat și este mutat în Spam.}}", + "confirm": "Raportează", + "cancel": "Anulează", + "sending": "Se raportează…", + "done": "{count, plural, one {Raportat la {address} și mutat în Spam.} few {# mesaje raportate la {address} și mutate în Spam.} other {# de mesaje raportate la {address} și mutate în Spam.}}", + "not_moved": "{count, plural, one {Raportat la {address}, dar mesajul nu a putut fi mutat în Spam.} few {Raportate la {address}, dar # mesaje nu au putut fi mutate în Spam.} other {Raportate la {address}, dar # de mesaje nu au putut fi mutate în Spam.}}", + "failed": "{count, plural, one {Mesajul nu a putut fi raportat.} few {# mesaje nu au putut fi raportate.} other {# de mesaje nu au putut fi raportate.}}", + "undo": "Anulează", + "undone": "{count, plural, one {Mutat înapoi. Raportul a fost deja trimis.} few {Mutate înapoi. Rapoartele au fost deja trimise.} other {Mutate înapoi. Rapoartele au fost deja trimise.}}", + "undo_failed": "Nu a putut fi mutat înapoi.", + "banner": "Ați raportat acest mesaj ca phishing. Linkurile din el sunt dezactivate.", + "link_off": "link dezactivat", + "link_off_note": "(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 ffd7f4e89..2bbd5bdf3 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -883,6 +883,35 @@ "send_anyway": "Всё равно отправить", "back": "Назад" }, + "phishing_report": { + "action": "Сообщить о фишинге…", + "confirm_title": "Сообщить о фишинге?", + "confirm_message": "{count, plural, =1 {Письмо будет отправлено на {address} и перемещено в папку «Спам».} one {# письмо будет отправлено на {address}, каждое отдельным отчётом, и перемещено в папку «Спам».} few {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} many {# писем будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} other {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».}}", + "confirm": "Сообщить", + "cancel": "Отмена", + "sending": "Отправка отчёта…", + "done": "{count, plural, =1 {Отправлено на {address} и перемещено в папку «Спам».} one {# письмо отправлено на {address} и перемещено в папку «Спам».} few {# письма отправлены на {address} и перемещены в папку «Спам».} many {# писем отправлено на {address} и перемещено в папку «Спам».} other {# письма отправлены на {address} и перемещены в папку «Спам».}}", + "not_moved": "{count, plural, =1 {Отчёт отправлен на {address}, но письмо не удалось переместить в папку «Спам».} one {Отчёты отправлены на {address}, но # письмо не удалось переместить в папку «Спам».} few {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».} many {Отчёты отправлены на {address}, но # писем не удалось переместить в папку «Спам».} other {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».}}", + "failed": "{count, plural, =1 {Не удалось сообщить о письме.} one {Не удалось сообщить о # письме.} few {Не удалось сообщить о # письмах.} many {Не удалось сообщить о # письмах.} other {Не удалось сообщить о # письмах.}}", + "undo": "Отменить", + "undone": "{count, plural, =1 {Возвращено на место. Отчёт уже был отправлен.} one {Возвращено на место. Отчёты уже были отправлены.} few {Возвращено на место. Отчёты уже были отправлены.} many {Возвращено на место. Отчёты уже были отправлены.} other {Возвращено на место. Отчёты уже были отправлены.}}", + "undo_failed": "Не удалось вернуть на место.", + "banner": "Вы сообщили, что это письмо фишинговое. Ссылки в нём отключены.", + "link_off": "ссылка отключена", + "link_off_note": "(ссылка отключена)", + "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 631d88822..88a52ff71 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -883,6 +883,35 @@ "send_anyway": "Odoslať aj tak", "back": "Späť" }, + "phishing_report": { + "action": "Nahlásiť phishing…", + "confirm_title": "Nahlásiť phishing?", + "confirm_message": "{count, plural, one {Správa sa odošle na {address} a presunie do priečinka Spam.} few {# správy sa odošlú na {address}, každá ako samostatné hlásenie, a presunú do priečinka Spam.} many {# správy sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.} other {# správ sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.}}", + "confirm": "Nahlásiť", + "cancel": "Zrušiť", + "sending": "Nahlasuje sa…", + "done": "{count, plural, one {Nahlásené na {address} a presunuté do priečinka Spam.} few {# správy nahlásené na {address} a presunuté do priečinka Spam.} many {# správy nahlásené na {address} a presunuté do priečinka Spam.} other {# správ nahlásených na {address} a presunutých do priečinka Spam.}}", + "not_moved": "{count, plural, one {Nahlásené na {address}, ale správu sa nepodarilo presunúť do priečinka Spam.} few {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} many {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} other {Nahlásené na {address}, ale # správ sa nepodarilo presunúť do priečinka Spam.}}", + "failed": "{count, plural, one {Správu sa nepodarilo nahlásiť.} few {# správy sa nepodarilo nahlásiť.} many {# správy sa nepodarilo nahlásiť.} other {# správ sa nepodarilo nahlásiť.}}", + "undo": "Späť", + "undone": "{count, plural, one {Presunuté späť. Hlásenie už bolo odoslané.} few {Presunuté späť. Hlásenia už boli odoslané.} many {Presunuté späť. Hlásenia už boli odoslané.} other {Presunuté späť. Hlásenia už boli odoslané.}}", + "undo_failed": "Nepodarilo sa presunúť späť.", + "banner": "Túto správu ste nahlásili ako phishing. Odkazy v nej sú vypnuté.", + "link_off": "odkaz vypnutý", + "link_off_note": "(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 b6b89d9f5..ad299d215 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1820,6 +1820,35 @@ "send_anyway": "ส่งต่อไป", "back": "ย้อนกลับ" }, + "phishing_report": { + "action": "รายงานฟิชชิง…", + "confirm_title": "รายงานเป็นฟิชชิงหรือไม่", + "confirm_message": "{count, plural, =1 {ข้อความจะถูกส่งไปยัง {address} และย้ายไปที่จดหมายขยะ} other {ข้อความแต่ละฉบับจากทั้งหมด # ฉบับจะถูกส่งไปยัง {address} เป็นรายงานแยกกัน และย้ายไปที่จดหมายขยะ}}", + "confirm": "รายงาน", + "cancel": "ยกเลิก", + "sending": "กำลังรายงาน…", + "done": "{count, plural, =1 {รายงานไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว} other {รายงานข้อความ # ฉบับไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว}}", + "not_moved": "{count, plural, =1 {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความไปที่จดหมายขยะได้} other {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความ # ฉบับไปที่จดหมายขยะได้}}", + "failed": "{count, plural, =1 {ไม่สามารถรายงานข้อความได้} other {ไม่สามารถรายงานข้อความ # ฉบับได้}}", + "undo": "เลิกทำ", + "undone": "{count, plural, =1 {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว} other {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว}}", + "undo_failed": "ไม่สามารถย้ายกลับได้", + "banner": "คุณรายงานข้อความนี้ว่าเป็นฟิชชิง ลิงก์ในข้อความถูกปิดใช้งานแล้ว", + "link_off": "ปิดใช้งานลิงก์แล้ว", + "link_off_note": "(ปิดใช้งานลิงก์แล้ว)", + "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 0492d468d..fd1d098a7 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -883,6 +883,35 @@ "send_anyway": "Yine de gönder", "back": "Geri" }, + "phishing_report": { + "action": "Kimlik avı bildir…", + "confirm_title": "Kimlik avı olarak bildirilsin mi?", + "confirm_message": "{count, plural, one {İleti {address} adresine gönderilir ve Spam klasörüne taşınır.} other {# iletinin her biri ayrı bir bildirim olarak {address} adresine gönderilir ve Spam klasörüne taşınır.}}", + "confirm": "Bildir", + "cancel": "İptal", + "sending": "Bildiriliyor…", + "done": "{count, plural, one {{address} adresine bildirildi ve Spam klasörüne taşındı.} other {# ileti {address} adresine bildirildi ve Spam klasörüne taşındı.}}", + "not_moved": "{count, plural, one {{address} adresine bildirildi ancak ileti Spam klasörüne taşınamadı.} other {{address} adresine bildirildi ancak # ileti Spam klasörüne taşınamadı.}}", + "failed": "{count, plural, one {İleti bildirilemedi.} other {# ileti bildirilemedi.}}", + "undo": "Geri al", + "undone": "{count, plural, one {Geri taşındı. Bildirim zaten gönderilmişti.} other {Geri taşındı. Bildirimler zaten gönderilmişti.}}", + "undo_failed": "Geri taşınamadı.", + "banner": "Bu iletiyi kimlik avı olarak bildirdiniz. İçindeki bağlantılar devre dışı.", + "link_off": "bağlantı devre dışı", + "link_off_note": "(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 1245df517..970d2d85d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -883,6 +883,35 @@ "send_anyway": "Усе одно надіслати", "back": "Назад" }, + "phishing_report": { + "action": "Повідомити про фішинг…", + "confirm_title": "Повідомити про фішинг?", + "confirm_message": "{count, plural, =1 {Лист буде надіслано на {address} і переміщено до папки «Спам».} one {# лист буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} few {# листи буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} many {# листів буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} other {# листа буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».}}", + "confirm": "Повідомити", + "cancel": "Скасувати", + "sending": "Надсилання звіту…", + "done": "{count, plural, =1 {Надіслано на {address} і переміщено до папки «Спам».} one {# лист надіслано на {address} і переміщено до папки «Спам».} few {# листи надіслано на {address} і переміщено до папки «Спам».} many {# листів надіслано на {address} і переміщено до папки «Спам».} other {# листа надіслано на {address} і переміщено до папки «Спам».}}", + "not_moved": "{count, plural, =1 {Звіт надіслано на {address}, але лист не вдалося перемістити до папки «Спам».} one {Звіти надіслано на {address}, але # лист не вдалося перемістити до папки «Спам».} few {Звіти надіслано на {address}, але # листи не вдалося перемістити до папки «Спам».} many {Звіти надіслано на {address}, але # листів не вдалося перемістити до папки «Спам».} other {Звіти надіслано на {address}, але # листа не вдалося перемістити до папки «Спам».}}", + "failed": "{count, plural, =1 {Не вдалося повідомити про лист.} one {Не вдалося повідомити про # лист.} few {Не вдалося повідомити про # листи.} many {Не вдалося повідомити про # листів.} other {Не вдалося повідомити про # листа.}}", + "undo": "Скасувати", + "undone": "{count, plural, =1 {Повернуто на місце. Звіт уже надіслано.} one {Повернуто на місце. Звіти вже надіслано.} few {Повернуто на місце. Звіти вже надіслано.} many {Повернуто на місце. Звіти вже надіслано.} other {Повернуто на місце. Звіти вже надіслано.}}", + "undo_failed": "Не вдалося повернути на місце.", + "banner": "Ви повідомили, що цей лист фішинговий. Посилання в ньому вимкнено.", + "link_off": "посилання вимкнено", + "link_off_note": "(посилання вимкнено)", + "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 d207d1ef2..c594d51f7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -883,6 +883,35 @@ "send_anyway": "Vẫn gửi", "back": "Quay lại" }, + "phishing_report": { + "action": "Báo cáo lừa đảo…", + "confirm_title": "Báo cáo lừa đảo?", + "confirm_message": "{count, plural, =1 {Thư sẽ được gửi đến {address} và chuyển vào Thư rác.} other {Mỗi thư trong số # thư sẽ được gửi đến {address} thành một báo cáo riêng và chuyển vào Thư rác.}}", + "confirm": "Báo cáo", + "cancel": "Hủy", + "sending": "Đang báo cáo…", + "done": "{count, plural, =1 {Đã báo cáo đến {address} và chuyển vào Thư rác.} other {Đã báo cáo # thư đến {address} và chuyển vào Thư rác.}}", + "not_moved": "{count, plural, =1 {Đã báo cáo đến {address}, nhưng không thể chuyển thư vào Thư rác.} other {Đã báo cáo đến {address}, nhưng không thể chuyển # thư vào Thư rác.}}", + "failed": "{count, plural, =1 {Không thể báo cáo thư.} other {Không thể báo cáo # thư.}}", + "undo": "Hoàn tác", + "undone": "{count, plural, =1 {Đã chuyển về. Báo cáo đã được gửi trước đó.} other {Đã chuyển về. Các báo cáo đã được gửi trước đó.}}", + "undo_failed": "Không thể chuyển về.", + "banner": "Bạn đã báo cáo thư này là lừa đảo. Các liên kết trong thư đã bị tắt.", + "link_off": "đã tắt liên kết", + "link_off_note": "(đã 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 327767e44..75cb21dca 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -883,6 +883,35 @@ "send_anyway": "仍然傳送", "back": "返回" }, + "phishing_report": { + "action": "回報網路釣魚…", + "confirm_title": "要回報為網路釣魚嗎?", + "confirm_message": "{count, plural, =1 {這封郵件會傳送至 {address},並移至垃圾郵件。} other {# 封郵件會各自作為單獨的回報傳送至 {address},並移至垃圾郵件。}}", + "confirm": "回報", + "cancel": "取消", + "sending": "正在回報…", + "done": "{count, plural, =1 {已回報至 {address},並移至垃圾郵件。} other {已將 # 封郵件回報至 {address},並移至垃圾郵件。}}", + "not_moved": "{count, plural, =1 {已回報至 {address},但無法將郵件移至垃圾郵件。} other {已回報至 {address},但有 # 封郵件無法移至垃圾郵件。}}", + "failed": "{count, plural, =1 {無法回報這封郵件。} other {有 # 封郵件無法回報。}}", + "undo": "復原", + "undone": "{count, plural, =1 {已移回。回報已送出。} other {已移回。回報已送出。}}", + "undo_failed": "無法移回。", + "banner": "您已將這封郵件回報為網路釣魚。其中的連結已停用。", + "link_off": "連結已停用", + "link_off_note": "(連結已停用)", + "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 5233ea14d..60216697e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -883,6 +883,35 @@ "send_anyway": "仍然发送", "back": "返回" }, + "phishing_report": { + "action": "举报网络钓鱼…", + "confirm_title": "举报为网络钓鱼?", + "confirm_message": "{count, plural, =1 {该邮件将发送至 {address},并移至垃圾邮件。} other {# 封邮件将分别作为单独的举报发送至 {address},并移至垃圾邮件。}}", + "confirm": "举报", + "cancel": "取消", + "sending": "正在举报…", + "done": "{count, plural, =1 {已举报至 {address},并移至垃圾邮件。} other {已将 # 封邮件举报至 {address},并移至垃圾邮件。}}", + "not_moved": "{count, plural, =1 {已举报至 {address},但无法将邮件移至垃圾邮件。} other {已举报至 {address},但有 # 封邮件无法移至垃圾邮件。}}", + "failed": "{count, plural, =1 {无法举报该邮件。} other {有 # 封邮件无法举报。}}", + "undo": "撤销", + "undone": "{count, plural, =1 {已移回。举报已发出。} other {已移回。举报已发出。}}", + "undo_failed": "无法移回。", + "banner": "您已将此邮件举报为网络钓鱼。其中的链接已停用。", + "link_off": "链接已停用", + "link_off_note": "(链接已停用)", + "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/auth-store.ts b/stores/auth-store.ts index c78734dc5..f68bcd660 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -3291,6 +3291,7 @@ useAuthStore.subscribe((state, prev) => { usePolicyStore.getState().setPolicySubject(null); return; } + usePolicyStore.getState().setSubjectPending(true); void policySubjectForClient(state.client).then(async (subject) => { if (request !== policySubjectRequest) return; // The server sends only the scopes of the domains signed in when the diff --git a/stores/email-store.ts b/stores/email-store.ts index 902cb5428..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; @@ -580,10 +580,15 @@ interface EmailStore { // owning JMAP account (passed to JMAP), `sourceClientAccountId` is the login the // email is reachable through (used to pick the right client). (#281) spamUndoCache: Map; - /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */ - markAsSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise; - /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */ - undoSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise; + /** + * `emailKey` (emailKeyFor of the row) picks the right row when the id + * repeats across accounts. `options.keywords`: more keywords set with the + * move, `options.markRead` marks it read too ($phishing and $seen for a + * phishing report, lib/phishing-report.ts). + */ + markAsSpam: (client: IJMAPClient, emailId: string, emailKey?: string, options?: { keywords?: string[]; markRead?: boolean }) => Promise; + /** `emailKey` as for markAsSpam; `options.clearKeywords` are removed with the move back. */ + undoSpam: (client: IJMAPClient, emailId: string, emailKey?: string, options?: { clearKeywords?: string[] }) => Promise; batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise; batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise; @@ -6367,7 +6372,7 @@ export const useEmailStore = create((set, get) => ({ }, // Spam operations - markAsSpam: async (client, emailId, emailKey): Promise => { + markAsSpam: async (client, emailId, emailKey, options): Promise => { const isTarget = singleEmailMatcher(emailId, emailKey); // The viewer may show an email the list no longer holds (e.g. the unread // quick-filter dropped it once it was read), so fall back to the selected @@ -6410,8 +6415,10 @@ export const useEmailStore = create((set, get) => ({ try { const isUnread = !email.keywords?.$seen; - const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread; - await actionClient.markAsSpam(emailId, accountId, alsoMarkRead); + const alsoMarkRead = isUnread && (options?.markRead === true || useSettingsStore.getState().deleteAction === 'trash-and-read'); + // Extra keywords only when asked for: the plain call stays as it was. + if (options?.keywords?.length) await actionClient.markAsSpam(emailId, accountId, alsoMarkRead, options.keywords); + else await actionClient.markAsSpam(emailId, accountId, alsoMarkRead); // Same junk resolution the client used for the move itself. const junkMailbox = mailboxes.find(m => @@ -6459,7 +6466,7 @@ export const useEmailStore = create((set, get) => ({ return true; }, - undoSpam: async (client, emailId, emailKey): Promise => { + undoSpam: async (client, emailId, emailKey, options): Promise => { const isTarget = singleEmailMatcher(emailId, emailKey); if (emailHooks.onBeforeEmailSpamToggle.size > 0 && !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: false }))) { @@ -6515,7 +6522,8 @@ export const useEmailStore = create((set, get) => ({ } try { - await undoClient.undoSpam(emailId, targetMailboxId, accountId); + if (options?.clearKeywords?.length) await undoClient.undoSpam(emailId, targetMailboxId, accountId, options.clearKeywords); + else await undoClient.undoSpam(emailId, targetMailboxId, accountId); // Drop the restored mail from the current junk view and advance the open // message to the next one, mirroring markAsSpam. Without this the viewer diff --git a/stores/phishing-report-store.ts b/stores/phishing-report-store.ts new file mode 100644 index 000000000..0096237da --- /dev/null +++ b/stores/phishing-report-store.ts @@ -0,0 +1,64 @@ +import { create } from 'zustand'; +import type { Email } from '@/lib/jmap/types'; + +/** + * The "Report phishing?" question waiting for an answer. Any menu or + * toolbar asks through `requestPhishingReport`; the one host mounted in the + * app layout (components/email/phishing-report-host.tsx) shows the dialog, + * sends the reports and shows the outcome. + */ +interface PhishingReportState { + /** The messages and every address their reports go to (one per account policy). */ + pending: { emails: Email[]; addresses: string[] } | null; + /** The reports are on their way; the dialog stays open and can't be sent twice. */ + busy: boolean; + /** Where the focus goes back to when the dialog closes. */ + opener: HTMLElement | null; + /** + * Messages reported in this session (emailKeyFor), so every open view + * of them (a Pro tab, a conversation) shows the banner and turns its + * links off at once, before the server's $phishing reaches it. + */ + reported: Record; + ask: (emails: Email[], addresses: string[]) => void; + close: () => void; + setBusy: (busy: boolean) => void; + markReported: (keys: string[], reported: boolean) => void; +} + +/** + * The control to give the focus back to. A menu item unmounts when the + * menu closes: then the button that opened the menu, else none (the + * page's focus rescue takes over). + */ +function openerOf(active: Element | null): HTMLElement | null { + if (!(active instanceof HTMLElement)) return null; + if (!active.closest('[role="menu"], [role="menubar"]')) return active; + return document.querySelector('[aria-haspopup="menu"][aria-expanded="true"], [aria-haspopup="true"][aria-expanded="true"]'); +} + +export const usePhishingReportStore = create()((set, get) => ({ + pending: null, + busy: false, + opener: null, + reported: {}, + ask: (emails, addresses) => { + if (get().busy || emails.length === 0) return; + const active = typeof document !== 'undefined' ? document.activeElement : null; + set({ pending: { emails, addresses }, opener: openerOf(active) }); + }, + close: () => { + const { opener } = get(); + set({ pending: null, busy: false, opener: null }); + if (opener?.isConnected) opener.focus(); + }, + setBusy: (busy) => set({ busy }), + markReported: (keys, reported) => set((state) => { + const next = { ...state.reported }; + for (const key of keys) { + if (reported) next[key] = true; + else delete next[key]; + } + return { reported: next }; + }), +})); diff --git a/stores/policy-store.ts b/stores/policy-store.ts index 864436af4..64a9ecc21 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -18,6 +18,12 @@ interface PolicyState { rawPolicy: SettingsPolicy; /** The account `policy` was resolved for; null before one is known. */ subject: PolicySubject | null; + /** + * The active account's subject is being worked out (signed in, its groups + * not known yet). Until it is, a group scope's lock may not be applied yet. + */ + subjectPending: boolean; + setSubjectPending: (pending: boolean) => void; /** Ids of the scopes that apply to `subject`. */ matchedScopes: string[]; /** What set each non-default value of `policy` (see ResolvedPolicy.sources). */ @@ -150,6 +156,7 @@ const INITIAL = resolved({ ...DEFAULT_POLICY }, null); export const usePolicyStore = create()((set, get) => ({ ...INITIAL, subject: null, + subjectPending: false, scopeDomains: null, loaded: false, fetching: false, @@ -210,7 +217,10 @@ export const usePolicyStore = create()((set, get) => ({ } }, + setSubjectPending: (pending) => { if (get().subjectPending !== pending) set({ subjectPending: pending }); }, + setPolicySubject: (subject) => { + if (get().subjectPending) set({ subjectPending: false }); if (sameSubject(subject, get().subject)) return; set({ ...resolved(get().rawPolicy, subject), subject }); }, diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index d5d08a364..ecc393c05 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -51,6 +51,10 @@ export interface ProReplyContext { quoteHeaderHtml?: string; quoteHeaderText?: string; quoteWrapInBlockquote?: boolean; + /** The original's label headers (lib/outgoing/classification.ts labelSourceHeaders). */ + headers?: Record; + /** The original's From for a forward as attachment, whose `from` stays empty. */ + originalFrom?: { email?: string; name?: string }[]; } export interface ProComposeTabData {