From e66383de5b64774f2395860fd7f02992221ed59f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:33:38 +0100 Subject: [PATCH 01/17] feat: let admins set an address for phishing reports --- .../phishing-report-section.test.tsx | 90 +++++++++++ .../org/sections/phishing-report-section.tsx | 143 +++++++++++++++++- lib/admin/organisation-policy.ts | 6 + 3 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 components/admin/org/__tests__/phishing-report-section.test.tsx 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..e93781489 --- /dev/null +++ b/components/admin/org/__tests__/phishing-report-section.test.tsx @@ -0,0 +1,90 @@ +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@' } }); + expect(screen.getByRole('alert').textContent).toContain("isn't an email address"); + expect(latest!.orgValue('phishingReport', 'address').value).toBe(''); + fireEvent.change(field, { target: { value: 'Security@Example.org' } }); + expect(screen.queryByRole('alert')).toBeNull(); + 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/phishing-report-section.tsx b/components/admin/org/sections/phishing-report-section.tsx index 5d1b47d85..94be52104 100644 --- a/components/admin/org/sections/phishing-report-section.tsx +++ b/components/admin/org/sections/phishing-report-section.tsx @@ -1,13 +1,146 @@ '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/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index c6c22e93f..a201c5e2e 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -305,6 +305,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; } From 50f95f446397a185a96a83378f41ed47c7e53fb8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:58:01 +0100 Subject: [PATCH 02/17] feat: report a message as phishing and move it to Junk --- app/(main)/[locale]/layout.tsx | 2 + .../__tests__/phishing-report-flow.test.tsx | 135 ++++++++++++++++ .../__tests__/phishing-report-menus.test.tsx | 124 +++++++++++++++ components/email/email-context-menu.tsx | 13 ++ components/email/email-list.tsx | 16 +- components/email/email-viewer.tsx | 22 ++- components/email/message-actions.ts | 14 +- components/email/phishing-mark-banner.tsx | 20 +++ components/email/phishing-report-host.tsx | 132 ++++++++++++++++ components/email/thread-conversation-view.tsx | 14 +- components/icons/index.ts | 3 + hooks/use-phishing-report.ts | 36 +++++ .../jmap-phishing-report-send.test.ts | 89 +++++++++++ lib/__tests__/phishing-report.test.ts | 115 ++++++++++++++ lib/jmap/client-interface.ts | 11 +- lib/jmap/client.ts | 38 +++-- lib/outgoing/send-pipeline.ts | 6 +- lib/phishing-links.ts | 44 ++++++ lib/phishing-report-flow.ts | 136 ++++++++++++++++ lib/phishing-report.ts | 146 ++++++++++++++++++ locales/ar/common.json | 17 ++ locales/ca/common.json | 17 ++ locales/cs/common.json | 17 ++ locales/da/common.json | 17 ++ locales/de/common.json | 17 ++ locales/en/common.json | 17 ++ locales/es/common.json | 17 ++ locales/et/common.json | 17 ++ locales/fa/common.json | 17 ++ locales/fr/common.json | 17 ++ locales/he/common.json | 17 ++ locales/hu/common.json | 17 ++ locales/it/common.json | 17 ++ locales/ja/common.json | 17 ++ locales/ko/common.json | 17 ++ locales/lv/common.json | 17 ++ locales/mn/common.json | 17 ++ locales/nb/common.json | 17 ++ locales/nl/common.json | 17 ++ locales/pl/common.json | 17 ++ locales/pt/common.json | 17 ++ locales/ro/common.json | 17 ++ locales/ru/common.json | 17 ++ locales/sk/common.json | 17 ++ locales/th/common.json | 17 ++ locales/tr/common.json | 17 ++ locales/uk/common.json | 17 ++ locales/vi/common.json | 17 ++ locales/zh-TW/common.json | 17 ++ locales/zh/common.json | 17 ++ stores/email-store.ts | 26 ++-- stores/phishing-report-store.ts | 36 +++++ 52 files changed, 1650 insertions(+), 38 deletions(-) create mode 100644 components/email/__tests__/phishing-report-flow.test.tsx create mode 100644 components/email/__tests__/phishing-report-menus.test.tsx create mode 100644 components/email/phishing-mark-banner.tsx create mode 100644 components/email/phishing-report-host.tsx create mode 100644 hooks/use-phishing-report.ts create mode 100644 lib/__tests__/jmap-phishing-report-send.test.ts create mode 100644 lib/__tests__/phishing-report.test.ts create mode 100644 lib/phishing-links.ts create mode 100644 lib/phishing-report-flow.ts create mode 100644 lib/phishing-report.ts create mode 100644 stores/phishing-report-store.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 5014d7ea8..87319d0be 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"; @@ -79,6 +80,7 @@ export default async function LocaleLayout({ {children} + 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..7802efea7 --- /dev/null +++ b/components/email/__tests__/phishing-report-flow.test.tsx @@ -0,0 +1,135 @@ +import { act, cleanup, fireEvent, render, screen } 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'; + +// 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 })), + markAsSpam: vi.fn(async () => {}), + undoSpam: vi.fn(async () => {}), + }; + const store = { + emails: [] as Email[], + 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: { 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) { + usePolicyStore.setState({ + policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format: 'forward' } } }, + }); +} + +beforeEach(() => { + hoisted.store.emails = [e1, e2]; + setAddress('soc@example.org'); +}); +afterEach(() => { + cleanup(); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); + useToastStore.setState({ toasts: [] }); + 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('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..ec858683f --- /dev/null +++ b/components/email/__tests__/phishing-report-menus.test.tsx @@ -0,0 +1,124 @@ +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'; + +// 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, +})); +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 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 }); +}); + +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], address: '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/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..0bc677025 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -32,6 +32,8 @@ 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 { isPhishingMarked, turnOffLinks } from "@/lib/phishing-links"; import { useCopyToAccount, useEmailExport, usePrintRequestStore } from "@/hooks/use-message-file-actions"; import { useBottomControls } from "@/hooks/use-bottom-controls"; import { useComposingSurface } from "@/hooks/use-composing-surface"; @@ -2647,10 +2649,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 = isPhishingMarked(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 +2814,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, tPhishing('link_off')) } : 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 +2829,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, tPhishing('link_off')) : html; + }, [readerViewOpen, decodedEmailContent, readableText, phishingMarked, tPhishing]); const resolveAttachmentName = useCallback( (attachment: EffectiveAttachment) => { @@ -5604,6 +5612,8 @@ export function EmailViewer({ )} + {phishingMarked && } + {/* Draft Banner: a scheduled message has its own. */} {isDraft && !isScheduled && (
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/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..daa0d2bae --- /dev/null +++ b/components/email/phishing-report-host.tsx @@ -0,0 +1,132 @@ +'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 { address, reported, failed, notMoved, moved } = outcome; + 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: reported.length })); + 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.address, count })} +

+
+
+
+
+ + +
+
+
+ ); +} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 70a7d10e1..da420637b 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -68,6 +68,8 @@ import { emailToReadView } from "@/lib/plugin-projection"; import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces"; import { useRemoteContentBlockedByOrg, useRemoteContentHeldBack } from "@/hooks/use-third-party-mode"; import { ConversationHeaderActions, type ConversationActions } from "./conversation-header-actions"; +import { PhishingMarkBanner } from "./phishing-mark-banner"; +import { isPhishingMarked, turnOffLinks } from "@/lib/phishing-links"; export type ConversationAttachmentHandler = ( blobId: string, name: string, type: string | undefined, source: EmailBlobContext, @@ -572,8 +574,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 = isPhishingMarked(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 +745,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 +760,10 @@ export function ConversationEmailBody({ isHtml: false, }; }, [mailViewMode, formattedContent, pluginBody.html, email, t]); + const emailContent = useMemo( + () => (phishingMarked ? { ...shownContent, html: turnOffLinks(shownContent.html, tPhishing('link_off')) } : 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 +906,7 @@ export function ConversationEmailBody({ return (
+ {phishingMarked && } {/* External content warning */} {hasBlockedContent && !allowExternal && emailContent.isHtml && (
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/hooks/use-phishing-report.ts b/hooks/use-phishing-report.ts new file mode 100644 index 000000000..0fedf87f0 --- /dev/null +++ b/hooks/use-phishing-report.ts @@ -0,0 +1,36 @@ +'use client'; + +import { useCallback, useMemo } from 'react'; +import type { Email } from '@/lib/jmap/types'; +import { isReportable } from '@/lib/phishing-report'; +import { phishingSetupForEmail } from '@/lib/phishing-report-flow'; +import { useAccountSubjects } from '@/lib/org/account-subjects'; +import { usePolicyStore } from '@/stores/policy-store'; +import { usePhishingReportStore } from '@/stores/phishing-report-store'; + +/** Asks "Report phishing?" for the messages of `emails` that can be reported. */ +export function requestPhishingReport(emails: Email[], mailboxRole?: string | null): void { + const reportable = emails.filter((email) => isReportable(email, mailboxRole) && phishingSetupForEmail(email)); + if (reportable.length === 0) return; + usePhishingReportStore.getState().ask(reportable, phishingSetupForEmail(reportable[0])!.address); +} + +/** + * "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 list = useMemo(() => emails.filter((e): e is Email => !!e), [emails]); + const reportable = useMemo( + () => list.filter((email) => isReportable(email, mailboxRole) && !!phishingSetupForEmail(email)), + // The setup is read from the stores these follow. + // eslint-disable-next-line react-hooks/exhaustive-deps + [list, mailboxRole, policy, rawPolicy, subjects], + ); + const report = useCallback(() => requestPhishingReport(reportable, mailboxRole), [reportable, mailboxRole]); + return { available: reportable.length > 0, count: reportable.length, report }; +} 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.test.ts b/lib/__tests__/phishing-report.test.ts new file mode 100644 index 000000000..be12ac882 --- /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, '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/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 8e3ae5177..3cd909678 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; @@ -448,8 +450,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 }, ): 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 285bd9569..116bb822f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1426,6 +1426,11 @@ 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; } /** The MAIL FROM / RCPT TO parameters the options translate to. */ @@ -3931,7 +3936,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 +3964,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 +3975,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"); @@ -4992,6 +5000,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 +5046,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 +5085,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/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..6b05a05b6 --- /dev/null +++ b/lib/phishing-links.ts @@ -0,0 +1,44 @@ +/** + * 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'; + +/** `html` with every link turned off; `label` is what a turned-off link says it is. */ +export function turnOffLinks(html: string, label: string): 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', label); + 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 = ` (${label})`; + 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-flow.ts b/lib/phishing-report-flow.ts new file mode 100644 index 000000000..0ac3bdf8b --- /dev/null +++ b/lib/phishing-report-flow.ts @@ -0,0 +1,136 @@ +/** + * 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 { + 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 { + /** The address the reports went to (the first message's, when they differ). */ + address: 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[]; +} + +// ARF arrives in the next step; until then an 'arf' setup fails the report. +async function loadArfBuilder(): Promise<((input: ArfInput) => Promise) | undefined> { + return undefined; +} + +/** 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 = { address: '', 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.address) outcome.address = 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); + + // 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. + const mailboxId = firstMailboxId(email); + await client.markAsSpam(email.id, accountId, true, [PHISHING_KEYWORD]); + 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 { + if (via === 'store') { + if (await useEmailStore.getState().undoSpam(active, email.id, emailKeyFor(email), { clearKeywords: [PHISHING_KEYWORD] })) back++; + } else { + await via.client.undoSpam(email.id, via.mailboxId, via.accountId, [PHISHING_KEYWORD]); + back++; + } + } 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..ecc5029ef --- /dev/null +++ b/lib/phishing-report.ts @@ -0,0 +1,146 @@ +/** + * "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. */ +export function isReportable(email: Pick, mailboxRole?: string | null): boolean { + if (!email.blobId || email.isScheduled) return false; + if (mailboxRole && OWN_MAIL_ROLES.has(mailboxRole)) 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/locales/ar/common.json b/locales/ar/common.json index 2116d773b..27d6eec20 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -883,6 +883,23 @@ "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": "الرابط معطّل", + "no_subject": "(بلا موضوع)" + }, "email_composer": { "select_from_directory": "الاختيار من الدليل", "minimize": "تصغير", diff --git a/locales/ca/common.json b/locales/ca/common.json index e023d59f5..2919c2a14 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(sense assumpte)" + }, "email_composer": { "select_from_directory": "Selecciona del directori", "minimize": "Minimitza", diff --git a/locales/cs/common.json b/locales/cs/common.json index a93c1ce6a..5179ab878 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(bez předmětu)" + }, "email_composer": { "select_from_directory": "Vybrat z adresáře", "minimize": "Minimalizovat", diff --git a/locales/da/common.json b/locales/da/common.json index 9710e03a2..890c491e0 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(intet emne)" + }, "email_composer": { "select_from_directory": "Vælg fra adressefortegnelse", "minimize": "Minimer", diff --git a/locales/de/common.json b/locales/de/common.json index 04c1424a3..607034982 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(kein Betreff)" + }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", "minimize": "Minimieren", diff --git a/locales/en/common.json b/locales/en/common.json index 89aad41a5..8601b2290 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(no subject)" + }, "email_composer": { "select_from_directory": "Select from directory", "minimize": "Minimize", diff --git a/locales/es/common.json b/locales/es/common.json index 4b53c9222..c560da5ae 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -883,6 +883,23 @@ "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": "Ha reportado este mensaje como phishing. Sus enlaces están desactivados.", + "link_off": "enlace desactivado", + "no_subject": "(sin asunto)" + }, "email_composer": { "select_from_directory": "Seleccionar del directorio", "minimize": "Minimizar", diff --git a/locales/et/common.json b/locales/et/common.json index e12342f13..98c71938d 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(teema puudub)" + }, "email_composer": { "select_from_directory": "Vali kataloogist", "minimize": "Minimeeri", diff --git a/locales/fa/common.json b/locales/fa/common.json index f3604c96c..d7355b1bd 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -883,6 +883,23 @@ "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": "پیوند غیرفعال شد", + "no_subject": "(بدون موضوع)" + }, "email_composer": { "select_from_directory": "انتخاب از فهرست", "minimize": "کوچک کردن", diff --git a/locales/fr/common.json b/locales/fr/common.json index f0c00df6c..826061ff3 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -883,6 +883,23 @@ "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é", + "no_subject": "(sans objet)" + }, "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 8f1f998a3..ffe99a9c6 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -848,6 +848,23 @@ "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": "הקישור מושבת", + "no_subject": "(ללא נושא)" + }, "email_composer": { "select_from_directory": "בחירה מהספרייה", "minimize": "מזעור", diff --git a/locales/hu/common.json b/locales/hu/common.json index 6c3668404..58b5dd782 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(nincs tárgy)" + }, "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 99afff3f1..74f69d9a6 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(nessun oggetto)" + }, "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 3a8dcb529..1cee43a57 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -883,6 +883,23 @@ "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": "リンクは無効です", + "no_subject": "(件名なし)" + }, "email_composer": { "select_from_directory": "ディレクトリから選択", "minimize": "最小化", diff --git a/locales/ko/common.json b/locales/ko/common.json index 8dadb7040..18b20ec95 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -883,6 +883,23 @@ "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": "링크 꺼짐", + "no_subject": "(제목 없음)" + }, "email_composer": { "select_from_directory": "디렉터리에서 선택", "minimize": "최소화", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8160cd0b0..884078152 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(bez temata)" + }, "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 015e1f720..372387f40 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -883,6 +883,23 @@ "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": "холбоосыг идэвхгүй болгосон", + "no_subject": "(сэдэв байхгүй)" + }, "email_composer": { "select_from_directory": "Лавлахаас сонгох", "minimize": "Багасгах", diff --git a/locales/nb/common.json b/locales/nb/common.json index bea1f8ef8..cfbc36690 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(uten emne)" + }, "email_composer": { "select_from_directory": "Velg fra katalog", "minimize": "Minimer", diff --git a/locales/nl/common.json b/locales/nl/common.json index cf24997fc..da4c14853 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -883,6 +883,23 @@ "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": "U hebt dit bericht als phishing gemeld. De links erin zijn uitgeschakeld.", + "link_off": "link uitgeschakeld", + "no_subject": "(geen onderwerp)" + }, "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 eb67c8751..dcd72b975 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(brak tematu)" + }, "email_composer": { "select_from_directory": "Wybierz z katalogu", "minimize": "Minimalizuj", diff --git a/locales/pt/common.json b/locales/pt/common.json index a4b70cef5..ab73e5552 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(sem assunto)" + }, "email_composer": { "select_from_directory": "Selecionar do diretório", "minimize": "Minimizar", diff --git a/locales/ro/common.json b/locales/ro/common.json index ffdd16db2..4ed1e81aa 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(fără subiect)" + }, "email_composer": { "select_from_directory": "Selectează din director", "minimize": "Minimizează", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9061aba90..709312e93 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -883,6 +883,23 @@ "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": "ссылка отключена", + "no_subject": "(без темы)" + }, "email_composer": { "select_from_directory": "Выбрать из справочника", "minimize": "Свернуть", diff --git a/locales/sk/common.json b/locales/sk/common.json index 28f6b8cd0..cf962cd03 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -883,6 +883,23 @@ "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ý", + "no_subject": "(bez predmetu)" + }, "email_composer": { "select_from_directory": "Vybrať z adresára", "minimize": "Minimalizovať", diff --git a/locales/th/common.json b/locales/th/common.json index 60ccca46c..22d5206a0 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1820,6 +1820,23 @@ "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": "ปิดใช้งานลิงก์แล้ว", + "no_subject": "(ไม่มีเรื่อง)" + }, "welcome": { "title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ", "tip_compose": "กด c เพื่อเขียนอีเมลใหม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index 46613e997..e26a9939a 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -883,6 +883,23 @@ "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ışı", + "no_subject": "(konu yok)" + }, "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 6c1397a83..071819051 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -883,6 +883,23 @@ "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": "посилання вимкнено", + "no_subject": "(без теми)" + }, "email_composer": { "select_from_directory": "Вибрати з довідника", "minimize": "Згорнути", diff --git a/locales/vi/common.json b/locales/vi/common.json index 30fac8a94..ee8647a84 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -883,6 +883,23 @@ "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", + "no_subject": "(không có tiêu đề)" + }, "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 1c8ada23d..b8db29def 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -883,6 +883,23 @@ "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": "連結已停用", + "no_subject": "(無主題)" + }, "email_composer": { "select_from_directory": "從目錄中選取", "minimize": "最小化", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0c5b088d5..f54a372c2 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -883,6 +883,23 @@ "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": "链接已停用", + "no_subject": "(无主题)" + }, "email_composer": { "select_from_directory": "从目录中选择", "minimize": "最小化", diff --git a/stores/email-store.ts b/stores/email-store.ts index 902cb5428..ed6876722 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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..8f086c4ef --- /dev/null +++ b/stores/phishing-report-store.ts @@ -0,0 +1,36 @@ +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 { + pending: { emails: Email[]; address: 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; + ask: (emails: Email[], address: string) => void; + close: () => void; + setBusy: (busy: boolean) => void; +} + +export const usePhishingReportStore = create()((set, get) => ({ + pending: null, + busy: false, + opener: null, + ask: (emails, address) => { + if (get().busy || emails.length === 0) return; + const active = typeof document !== 'undefined' ? document.activeElement : null; + set({ pending: { emails, address }, opener: active instanceof HTMLElement ? active : null }); + }, + close: () => { + const { opener } = get(); + set({ pending: null, busy: false, opener: null }); + if (opener?.isConnected) opener.focus(); + }, + setBusy: (busy) => set({ busy }), +})); From 6262e58ef9b41957128a2840e4e7032edf43dbcf Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:02:32 +0100 Subject: [PATCH 03/17] feat: offer the ARF report format for phishing reports --- .../__tests__/phishing-report-flow.test.tsx | 21 +++- lib/__tests__/phishing-report-arf.test.ts | 75 +++++++++++ lib/mdn.ts | 14 +-- lib/phishing-report-arf.ts | 117 ++++++++++++++++++ lib/phishing-report-flow.ts | 7 +- 5 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 lib/__tests__/phishing-report-arf.test.ts create mode 100644 lib/phishing-report-arf.ts diff --git a/components/email/__tests__/phishing-report-flow.test.tsx b/components/email/__tests__/phishing-report-flow.test.tsx index 7802efea7..9d1b9f4e6 100644 --- a/components/email/__tests__/phishing-report-flow.test.tsx +++ b/components/email/__tests__/phishing-report-flow.test.tsx @@ -1,10 +1,11 @@ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +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 @@ -27,6 +28,7 @@ const hoisted = vi.hoisted(() => { 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 () => {}), }; @@ -54,9 +56,9 @@ vi.mock('@/stores/email-store', () => ({ const { PhishingReportHost } = await import('../phishing-report-host'); const { requestPhishingReport } = await import('@/hooks/use-phishing-report'); -function setAddress(address: string) { +function setAddress(address: string, format: 'forward' | 'arf' = 'forward') { usePolicyStore.setState({ - policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format: 'forward' } } }, + policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format } } }, }); } @@ -68,6 +70,7 @@ afterEach(() => { cleanup(); usePolicyStore.setState({ policy: DEFAULT_POLICY }); useToastStore.setState({ toasts: [] }); + usePhishingReportStore.setState({ pending: null, busy: false, opener: null }); vi.clearAllMocks(); }); @@ -127,6 +130,18 @@ describe('Report phishing', () => { 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('skips messages of your own folders', () => { render(); act(() => requestPhishingReport([e1], 'sent')); diff --git a/lib/__tests__/phishing-report-arf.test.ts b/lib/__tests__/phishing-report-arf.test.ts new file mode 100644 index 000000000..0e0a811ba --- /dev/null +++ b/lib/__tests__/phishing-report-arf.test.ts @@ -0,0 +1,75 @@ +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 } 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, 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', eightBit: false, 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('marks an 8-bit original as 8bit, never base64', () => { + const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', eightBit: true }); + 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 = { + 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`); + }); +}); 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/phishing-report-arf.ts b/lib/phishing-report-arf.ts new file mode 100644 index 000000000..e2424778f --- /dev/null +++ b/lib/phishing-report-arf.ts @@ -0,0 +1,117 @@ +/** + * 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 { 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; +} + +/** + * The report around the original. `eightBit`: the original has bytes + * outside 7-bit ASCII (message/rfc822 may not be base64-encoded, RFC 2046 + * §5.2.1, so its part says 8bit then). + */ +export function buildArfParts(input: { + email: Pick; + reporter: { name?: string; email: string }; + to: string; + eightBit: boolean; + 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)}`] : []), + `Original-Rcpt-To: ${fromEmail}`, + ...(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.eightBit ? '8bit' : '7bit'}`, + '', + '', + ].join('\r\n'); + const tail = `\r\n--${boundary}--\r\n`; + return { head, tail }; +} + +function hasEightBit(bytes: Uint8Array): boolean { + for (let i = 0; i < bytes.length; i++) if (bytes[i] > 0x7f) return true; + return false; +} + +/** 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'); + const original = new Uint8Array(await client.fetchBlobArrayBuffer(email.blobId, 'message.eml', 'message/rfc822', sourceAccountId)); + const parts = buildArfParts({ email, reporter: input.reporter, to: input.to, eightBit: hasEightBit(original) }); + // 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; + return new Blob([parts.head, original, endsWithBreak ? parts.tail.slice(2) : parts.tail], { type: 'message/rfc822' }); +} diff --git a/lib/phishing-report-flow.ts b/lib/phishing-report-flow.ts index 0ac3bdf8b..b894c525a 100644 --- a/lib/phishing-report-flow.ts +++ b/lib/phishing-report-flow.ts @@ -51,9 +51,10 @@ export interface PhishingReportOutcome { moved: MovedReport[]; } -// ARF arrives in the next step; until then an 'arf' setup fails the report. -async function loadArfBuilder(): Promise<((input: ArfInput) => Promise) | undefined> { - return undefined; +// 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. */ From 59f8e462cd6c13130ca986be94b3495be95012fd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:07:33 +0100 Subject: [PATCH 04/17] feat: let admins define classification labels with presets --- .../org/__tests__/labels-section.test.tsx | 124 +++++++ .../admin/org/sections/labels-section.tsx | 335 +++++++++++++++++- .../organisation-policy-enterprise.test.ts | 7 + lib/admin/organisation-policy.ts | 8 +- lib/classification/presets.ts | 63 ++++ 5 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 components/admin/org/__tests__/labels-section.test.tsx create mode 100644 lib/classification/presets.ts 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..1c7a3a07e --- /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 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('not a header Bulwark writes 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/sections/labels-section.tsx b/components/admin/org/sections/labels-section.tsx index fd1cc1d3c..dc4573a15 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 && ( +

    + Letters, digits and dashes only, and not a header Bulwark writes itself. +

    + )} +
    +
    +
    + + ) : ( +
    + {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/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts index 76d0cc746..9c5810dcd 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,12 @@ describe('enterprise sections: defaults and sanitising', () => { } }); + 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 a201c5e2e..e9aa61c8a 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -287,7 +287,7 @@ 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', 'importance', 'x-priority', 'disposition-notification-to', ]); const MAX_LABELS = 50; const MAX_FEEDS = 50; @@ -367,6 +367,12 @@ function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined { }); } +/** 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) && !RESERVED_HEADERS.has(v.toLowerCase()); +} + function headerName(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const v = value.trim(); 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', + }), + ], + }, +]; From 1c7f81bcc66fd6f89235d2fed983adae4f3ce415 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:33:12 +0100 Subject: [PATCH 05/17] feat: pick a classification label in the composer and stamp it on sent mail --- .../composer-classification.test.tsx | 231 +++++++++++++ .../email/classification-label-picker.tsx | 172 ++++++++++ components/email/email-composer.tsx | 119 ++++++- .../email/use-composer-classification.ts | 55 ++++ components/mail/mail-app.tsx | 33 +- .../cross-account-send-routing.test.tsx | 32 ++ components/pro/pro-compose-tab-body.tsx | 6 +- components/pro/pro-email-tab-body.tsx | 5 + lib/__tests__/classification-labels.test.ts | 130 ++++++++ lib/__tests__/jmap-label-headers.test.ts | 72 +++++ lib/__tests__/plugin-host-labels.test.ts | 127 ++++++++ lib/classification/labels.ts | 304 ++++++++++++++++++ lib/jmap/client-interface.ts | 6 +- lib/jmap/client.ts | 16 +- lib/outgoing/__tests__/classification.test.ts | 133 ++++++++ lib/outgoing/classification.ts | 241 ++++++++++++++ lib/outgoing/composer-send-options.ts | 32 ++ lib/outgoing/custom-headers.ts | 50 +++ lib/plugin-sandbox/host-api-forward.ts | 26 +- lib/plugin-sandbox/host-api.ts | 9 +- locales/ar/common.json | 11 + locales/ca/common.json | 11 + locales/cs/common.json | 11 + locales/da/common.json | 11 + locales/de/common.json | 11 + locales/en/common.json | 11 + locales/es/common.json | 11 + locales/et/common.json | 11 + locales/fa/common.json | 11 + locales/fr/common.json | 11 + locales/he/common.json | 11 + locales/hu/common.json | 11 + locales/it/common.json | 11 + locales/ja/common.json | 11 + locales/ko/common.json | 11 + locales/lv/common.json | 11 + locales/mn/common.json | 11 + locales/nb/common.json | 11 + locales/nl/common.json | 11 + locales/pl/common.json | 11 + locales/pt/common.json | 11 + locales/ro/common.json | 11 + locales/ru/common.json | 11 + locales/sk/common.json | 11 + locales/th/common.json | 11 + locales/tr/common.json | 11 + locales/uk/common.json | 11 + locales/vi/common.json | 11 + locales/zh-TW/common.json | 11 + locales/zh/common.json | 11 + stores/email-store.ts | 2 +- stores/pro-tab-store.ts | 2 + 52 files changed, 2110 insertions(+), 23 deletions(-) create mode 100644 components/email/__tests__/composer-classification.test.tsx create mode 100644 components/email/classification-label-picker.tsx create mode 100644 components/email/use-composer-classification.ts create mode 100644 lib/__tests__/classification-labels.test.ts create mode 100644 lib/__tests__/jmap-label-headers.test.ts create mode 100644 lib/__tests__/plugin-host-labels.test.ts create mode 100644 lib/classification/labels.ts create mode 100644 lib/outgoing/__tests__/classification.test.ts create mode 100644 lib/outgoing/classification.ts create mode 100644 lib/outgoing/composer-send-options.ts diff --git a/components/email/__tests__/composer-classification.test.tsx b/components/email/__tests__/composer-classification.test.tsx new file mode 100644 index 000000000..528cbbbf3 --- /dev/null +++ b/components/email/__tests__/composer-classification.test.tsx @@ -0,0 +1,231 @@ +import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { resetSendPipeline } from '@/lib/outgoing/send-pipeline'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; + +// The composer's classification label: a required label blocks sending +// (fail closed) until one is picked, the label goes on after the plugins' +// onTransformOutgoingEmail so none can drop it, a reply starts with the +// original's label, and a crypto takeover gets the labelled subject. + +const hooks = vi.hoisted(() => ({ + transform: vi.fn(async (email: unknown) => email), + takeOver: vi.fn(async (..._args: unknown[]) => 'default'), + toastError: vi.fn(), +})); + +vi.mock('@/components/email/rich-text-editor', () => ({ + RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null })); +vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null })); +vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null })); +vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null })); +vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }) })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ + useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }), + stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }), +})); + +function storeMock(state: T) { + const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state); + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + hook.subscribe = () => () => {}; + return hook; +} + +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: storeMock({ + client: null, identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], primaryIdentity: null, + isAuthenticated: false, isDemoMode: false, activeAccountId: null, connectionLost: false, + getClientForAccount: () => undefined, getAllConnectedClients: () => new Map(), + syncIdentities: () => {}, refreshIdentities: async () => {}, + }), +})); +vi.mock('@/stores/identity-store', () => ({ + useIdentityStore: storeMock({ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], defaultIdentityId: 'id-me' }), +})); +vi.mock('@/stores/account-store', () => ({ useAccountStore: storeMock({ accounts: [], getAccountById: () => undefined }) })); +vi.mock('@/stores/email-store', () => ({ useEmailStore: storeMock({ draftSaveEnabled: false, sendRawEmail: async () => ({ sent: true }) }) })); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: storeMock({ + timeFormat: '24h', plainTextMode: false, subAddressDelimiter: '+', autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, attachmentReminderKeywords: [], emptySubjectWarningEnabled: false, + sendDelaySeconds: 0, signaturePosition: 'below_quote', signatureSeparatorEnabled: true, + requestReadReceiptDefault: false, externalContentPolicy: 'ask', + addTrustedSender: () => {}, isSenderTrusted: () => false, + trustedSendersAddressBook: null, updateSetting: () => {}, + }), +})); +vi.mock('@/stores/contact-store', () => ({ + useContactStore: storeMock({ + contacts: [], addressBooks: [], trustedSendersBookId: null, getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }), +})); +vi.mock('@/stores/template-store', () => ({ useTemplateStore: storeMock({ templates: [], addTemplate: async () => {} }) })); +vi.mock('@/stores/toast-store', () => ({ toast: { info: () => {}, error: hooks.toastError, success: () => {}, warning: () => {} } })); +vi.mock('@/lib/plugin-hooks', () => ({ + identityHooks: { + onSignatureRender: { size: 0, transform: async (value: unknown) => value }, + onIdentitySelect: { size: 0, emit: async () => {} }, + }, + templateHooks: { + onTemplateRender: { size: 0, transform: async (value: unknown) => value }, + onTemplateApply: { size: 0, emit: async () => {} }, + }, + emailHooks: { + onComposerOpen: { call: async () => [] }, + onRecipientChange: { call: async () => [] }, + getRecipientSuggestions: { call: async () => [] }, + onRecipientChipsChange: { transform: async (chips: unknown) => chips }, + onDraftChange: { emit: () => {} }, + onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft }, + onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' }, + onComposeSend: { takeOver: hooks.takeOver }, + onTransformOutgoingEmail: { transform: hooks.transform }, + }, + contactHooks: { + search: { call: async () => [] }, + onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial }, + }, +})); +vi.mock('@/lib/email-sanitization', () => ({ + sanitizeSignatureHtml: (v: string) => v, + sanitizeSignatureHtmlForDisplay: (v: string) => v, + sanitizeEmailHtml: (v: string) => v, + sanitizePluginBodyHtml: (v: string) => v, + escapeHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); +vi.mock('@/lib/email-threading', () => ({ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }) })); +vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' })); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); +vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', serializeEditorContent: () => '' })); +vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s })); + +const generic = LABEL_PRESETS[0]; + +function useLabels(labels: Partial) { + usePolicyStore.setState({ + loaded: true, + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: generic.list, extraHeader: 'X-Classification', ...labels } }, + }, + }); +} + +const DRAFT = { + to: 'bob@example.org', cc: '', bcc: '', subject: 'Budget', body: '

    Numbers

    ', + showCc: false, showBcc: false, selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null, +}; + +type SendData = { subject: string; headers?: Record; labelHeaders?: Record }; + +function renderComposer(props: Partial> = {}) { + const onSend = vi.fn<(data: SendData) => void>(); + render(); + return onSend; +} + +const send = async () => { + await act(async () => { fireEvent.click(screen.getAllByTestId('composer-send')[0]); }); +}; + +afterEach(() => { + cleanup(); + resetSendPipeline(); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); + vi.clearAllMocks(); + hooks.transform.mockImplementation(async (email: unknown) => email); + hooks.takeOver.mockImplementation(async () => 'default'); +}); + +describe('composer: classification label', () => { + it('shows no picker and changes nothing while the organisation has no labels', async () => { + const onSend = renderComposer(); + expect(screen.queryByTestId('composer-classification')).toBeNull(); + await send(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0].subject).toBe('Budget'); + expect(onSend.mock.calls[0][0].labelHeaders).toBeUndefined(); + }); + + it('blocks sending until a required label is picked, then stamps it', async () => { + useLabels({ required: true, defaultId: '' }); + const onSend = renderComposer(); + await send(); + expect(onSend).not.toHaveBeenCalled(); + expect(hooks.toastError).toHaveBeenCalledWith('required_toast'); + // The picker opened, as a radio group without "No label". + expect(screen.queryByTestId('composer-classification-option-none')).toBeNull(); + fireEvent.click(screen.getByTestId('composer-classification-option-confidential')); + await send(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + const data = onSend.mock.calls[0][0]; + expect(data.subject).toBe('[Confidential] Budget'); + expect(data.labelHeaders?.['header:SIO-Label:asRaw']).toContain('marking="CONFIDENTIAL"'); + expect(data.labelHeaders?.['header:Sensitivity:asText']).toBe('Company-Confidential'); + expect(data.labelHeaders?.['header:X-Classification:asText']).toBe('Confidential'); + }); + + it('labels after the plugins: a plugin can neither drop the prefix nor set the label headers', async () => { + useLabels({ defaultId: 'strictly-confidential' }); + hooks.transform.mockImplementation(async (email: unknown) => ({ + ...(email as object), + subject: 'Budget (rewritten)', + headers: { 'SIO-Label': 'marking="PUBLIC"', Sensitivity: 'Personal', 'X-Classification': 'Public', 'X-Other': '1' }, + })); + const onSend = renderComposer(); + await send(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + const data = onSend.mock.calls[0][0]; + expect(data.subject).toBe('[Strictly confidential] Budget (rewritten)'); + expect(data.headers).toEqual({ 'X-Other': '1' }); + expect(data.labelHeaders?.['header:SIO-Label:asRaw']).toContain('STRICTLY CONFIDENTIAL'); + }); + + it('starts a reply with the original’s label', async () => { + useLabels({ defaultId: 'public' }); + const onSend = renderComposer({ + mode: 'reply', + initialData: { ...DRAFT, mode: 'reply', subject: 'Re: [Confidential] Budget' }, + replyTo: { subject: '[Confidential] Budget', headers: { 'SIO-Label': ' marking="CONFIDENTIAL"; bgcolor=#d97706' } }, + }); + expect(screen.getByTestId('composer-classification-trigger').textContent).toContain('Confidential'); + await send(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0].subject).toBe('[Confidential] Re: Budget'); + }); + + it('lets the user take the label off when none is required', async () => { + useLabels({ defaultId: 'confidential' }); + const onSend = renderComposer(); + fireEvent.click(screen.getByTestId('composer-classification-trigger')); + fireEvent.keyDown(screen.getByTestId('composer-classification-option-confidential'), { key: 'Home' }); + fireEvent.keyDown(screen.getByTestId('composer-classification-option-none'), { key: 'Enter' }); + expect(screen.queryByTestId('composer-classification-options')).toBeNull(); + await send(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + expect(onSend.mock.calls[0][0].subject).toBe('Budget'); + expect(onSend.mock.calls[0][0].labelHeaders).toBeUndefined(); + }); + + it('hands a crypto takeover the labelled subject', async () => { + useLabels({ defaultId: 'confidential' }); + hooks.takeOver.mockImplementation(async () => 'handled'); + const onSend = renderComposer(); + await send(); + await waitFor(() => expect(hooks.takeOver).toHaveBeenCalledTimes(1)); + expect((hooks.takeOver.mock.calls[0][0] as { subject: string }).subject).toBe('[Confidential] Budget'); + expect(onSend).not.toHaveBeenCalled(); + }); +}); diff --git a/components/email/classification-label-picker.tsx b/components/email/classification-label-picker.tsx new file mode 100644 index 000000000..7a3c2ab86 --- /dev/null +++ b/components/email/classification-label-picker.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState, type KeyboardEvent } from 'react'; +import { useTranslations } from 'next-intl'; +import { Check, ChevronDown, LabelIcon } from '@/components/icons'; +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; +import { textColorOn } from '@/lib/classification/labels'; +import { cn } from '@/lib/utils'; + +export interface ClassificationPickerHandle { + /** Opens the picker with the focus on it (Send without a required label). */ + open: () => void; +} + +interface Props { + labels: ClassificationLabel[]; + selected: ClassificationLabel | null; + required: boolean; + /** Shows the trigger as missing (Send was pressed without a label). */ + invalid?: boolean; + onPick: (id: string | null) => void; + className?: string; +} + +/** + * The composer's label picker: a button in the label's colour that opens a + * radio group (arrow keys move the pick, Enter or Escape closes, the focus + * goes back to the button). Keys stay inside, away from the composer's and + * the mail page's shortcuts. "No label" is offered unless one is required. + */ +export const ClassificationLabelPicker = forwardRef(function ClassificationLabelPicker( + { labels, selected, required, invalid, onPick, className }, + ref, +) { + const t = useTranslations('classification'); + const id = useId(); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const groupRef = useRef(null); + const options: Array = required ? labels : [null, ...labels]; + const selectedIndex = Math.max(0, options.findIndex((o) => (o?.id ?? null) === (selected?.id ?? null))); + + useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []); + + useEffect(() => { + if (!open) return; + const radios = groupRef.current?.querySelectorAll('[role="radio"]'); + radios?.[selectedIndex]?.focus(); + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (!groupRef.current?.parentElement?.contains(target)) setOpen(false); + }; + document.addEventListener('mousedown', onDown); + return () => document.removeEventListener('mousedown', onDown); + // Focus only when it opens. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const close = () => { + setOpen(false); + triggerRef.current?.focus(); + }; + const pick = (option: ClassificationLabel | null) => onPick(option?.id ?? null); + + const onKeyDown = (e: KeyboardEvent) => { + e.stopPropagation(); + const radios = [...(groupRef.current?.querySelectorAll('[role="radio"]') ?? [])]; + const current = radios.indexOf(document.activeElement as HTMLElement); + const move = (to: number) => { + const next = (to + options.length) % options.length; + pick(options[next]); + radios[next]?.focus(); + }; + switch (e.key) { + case 'ArrowDown': + case 'ArrowRight': + e.preventDefault(); + move(current + 1); + break; + case 'ArrowUp': + case 'ArrowLeft': + e.preventDefault(); + move(current - 1); + break; + case 'Home': + e.preventDefault(); + move(0); + break; + case 'End': + e.preventDefault(); + move(options.length - 1); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (current >= 0) pick(options[current]); + close(); + break; + case 'Escape': + e.preventDefault(); + close(); + break; + case 'Tab': + setOpen(false); + break; + } + }; + + const name = selected?.name ?? (required ? t('pick_required') : t('none')); + return ( +
    + + {open && ( +
    + {required &&

    {t('required_hint')}

    } +
    + {t('picker_label')} + {options.map((option, index) => { + const checked = (option?.id ?? null) === (selected?.id ?? null); + return ( +
    { pick(option); close(); }} + className="flex cursor-pointer items-start gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring" + > +
    + ); + })} +
    +
    + )} +
    + ); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 97555db82..bcae27e81 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -111,6 +111,10 @@ import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; +import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel } from "@/lib/outgoing/classification"; +import { whenPolicySettled } from "@/stores/policy-store"; +import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker"; +import { useComposerClassification } from "./use-composer-classification"; import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading"; @@ -253,6 +257,11 @@ export interface ComposerDraftData { references?: string[]; /** Priority of a re-opened draft, read back from its headers (#970). */ priority?: MessagePriority; + /** + * Classification label of a re-opened draft (X-Bulwark-Label): an id, + * null for "no label", absent when the draft says nothing. + */ + classification?: string | null; /** * Handed to the host when the composer went away with edits the server may * not have: the composer opened again with it counts them as unsaved, so @@ -299,6 +308,8 @@ interface EmailComposerProps { priority?: MessagePriority; /** Extra headers plugins added in onTransformOutgoingEmail. */ headers?: Record; + /** The classification label's headers (lib/outgoing/classification.ts), written after `headers`. */ + labelHeaders?: Record; /** The id plugins know this composer by, for onAfterEmailSend. */ composerId?: string; }) => void | false | Promise; @@ -379,6 +390,8 @@ interface EmailComposerProps { quoteHeaderText?: string; /** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */ quoteWrapInBlockquote?: boolean; + /** The original's headers: a reply or forward starts with its classification label. */ + headers?: Record; }; } @@ -470,6 +483,7 @@ export function EmailComposer({ const tCommon = useTranslations('common'); const tQuote = useTranslations('quote_header'); const tViewer = useTranslations('email_viewer'); + const tClassification = useTranslations('classification'); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode); // Per-message format (#1022). Seeded from the "plain text only" setting - @@ -1102,6 +1116,19 @@ export function EmailComposer({ // scopes decide the organisation signature and disclaimer: the identity's // own account, else the one the composer was opened in. const composingAccountId = currentIdentityParts.localAccountId ?? composerAccountIdRef.current; + // Classification label (enterprise pack): the labels of the account and + // identity the message goes out from; replies and forwards start with + // the original's label, re-opened drafts with theirs. + const classification = useComposerClassification({ + accountId: composingAccountId, + senderEmail: currentIdentity?.email, + originalHeaders: mode === 'compose' ? null : replyTo?.headers, + initialChoice: initialData?.classification, + }); + const classificationRef = useRef(classification); + classificationRef.current = classification; + const classificationPickerRef = useRef(null); + const [labelInvalid, setLabelInvalid] = useState(false); // The upload callbacks below list only `client` as a dependency, so they read // the composing identity's client through a ref: uploads must land in the // account that owns the draft, and a direct substitution would capture a @@ -1687,9 +1714,20 @@ export function EmailComposer({ // `!==`, not `>`: a re-opened draft starts with hydrated attachments, and // removing one must count as dirty or the removal never reaches the server. body !== initialValuesRef.current.body || attachments.length !== initialValuesRef.current.attachmentCount; - // A priority change alone is an edit worth saving (#970). + // A priority change alone is an edit worth saving (#970), and so is a label pick. const initialPriorityRef = useRef(priority); if (priority !== initialPriorityRef.current) isDirtyRef.current = true; + const initialLabelChoiceRef = useRef(classification.choice); + if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true; + const firstLabelRunRef = useRef(true); + useEffect(() => { + if (firstLabelRunRef.current) { + firstLabelRunRef.current = false; + return; + } + changeVersionRef.current += 1; + setLabelInvalid(false); + }, [classification.choice]); const firstPriorityRunRef = useRef(true); useEffect(() => { if (firstPriorityRunRef.current) { @@ -1745,6 +1783,7 @@ export function EmailComposer({ mode, replyTo, priority: priorityRef.current, + classification: classificationRef.current.draftChoice, // Edits the last save didn't cover; a minimised composer with // none needs no save before an automatic sign-out. unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current, @@ -2700,7 +2739,7 @@ export function EmailComposer({ savedDraft.htmlBody, replyThreadingHeaders?.inReplyTo, replyThreadingHeaders?.references, - { priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId }, + { priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId, classification: classificationRef.current.draftChoice }, ); if (replacedElsewhere) { await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId); @@ -3319,6 +3358,22 @@ export function EmailComposer({ return; } + // A required classification label (enterprise pack), read from the + // policy once it has loaded: no label, no send. Not a pre-send check, + // which would be skipped when it throws. + await whenPolicySettled(); + { + const labelSender = { email: currentIdentity?.email, accountId: composingAccountId }; + const labelPolicy = labelsPolicyFor(labelSender); + const label = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers); + if (labelMissing(labelPolicy, label)) { + setLabelInvalid(true); + toast.error(tClassification('required_toast')); + classificationPickerRef.current?.open(); + return; + } + } + // Catch addresses the server would reject before asking it. Its answer // ("No recipients found in email") does not say which one is wrong. const invalidRecipients = invalidRecipientAddresses([ @@ -3578,11 +3633,17 @@ export function EmailComposer({ // draft, and never fall back to sending it unsigned (#1207). It signs // or encrypts the message with the `last` finalisers' additions. const sealed = lastStage(finalHtmlBody, finalBody); + // The label as it stands now, from the policy of the sending account. + const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId }); + const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers); + // A crypto plugin builds the MIME itself: it gets the labelled + // subject, and its raw send gets the label's headers (keyed to the token). + const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject }); const composeSendRequest = { to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), - subject, + subject: takeoverLabelled.subject, htmlBody: sealed.html || '', textBody: sealed.text, identityId: currentIdentity?.id || '', @@ -3598,7 +3659,13 @@ export function EmailComposer({ ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), ], }; - const pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest); + const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentityParts.rawId ?? currentIdentity?.id ?? '', takeoverLabelled.subject); + let pluginOutcome: Awaited>; + try { + pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest); + } finally { + endLabelTakeover(labelToken); + } if (pluginOutcome === 'aborted') { // The plugin shows its own error, or the user cancelled its prompt. debug.log('email', 'Send aborted by an onComposeSend plugin handler'); @@ -3651,6 +3718,8 @@ export function EmailComposer({ const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); // Below whatever the plugins added (a footer), the `last` finalisers. const sealedOutgoing = lastStage(outgoing.htmlBody, outgoing.textBody); + // The label after the plugins had their say, so none can drop it. + const labelled = classifyOutgoing(labelPolicy, sendLabel, { subject: outgoing.subject, headers: outgoing.headers }); // Strip the cross-account namespace from the identity id before // handing it to the parent - the JMAP server only knows the raw @@ -3682,7 +3751,7 @@ export function EmailComposer({ to: outgoing.to, cc: outgoing.cc, bcc: outgoing.bcc, - subject: outgoing.subject, + subject: labelled.subject, body: sealedOutgoing.text, htmlBody: sealedOutgoing.html || undefined, draftId: finalDraftId && !draftElsewhere ? finalDraftId : undefined, @@ -3699,7 +3768,8 @@ export function EmailComposer({ requestDsn: requestDsn || undefined, requireTls: requireTls || undefined, priority: priority !== 'normal' ? priority : undefined, - headers: outgoing.headers, + headers: labelled.headers, + labelHeaders: labelled.labelHeaders, delayedUntil: effectiveDelayedUntil, composerId: pluginComposerIdRef.current ?? undefined, }); @@ -3749,6 +3819,11 @@ export function EmailComposer({ explicitCloseRef.current = false; sentRef.current = false; debug.error('Failed to send email:', err); + if (isLabelRequiredError(err)) { + setLabelInvalid(true); + toast.error(tClassification('required_toast')); + return; + } if (err instanceof RecipientsRejectedError) { // Nothing went out; the message stays open here so the addresses // can be fixed. The SMTP replies say why each one was refused (#1123). @@ -4494,7 +4569,7 @@ export function EmailComposer({ )} {/* Subject field */} -
    +
    + {classification.policy.list.length > 0 && ( + // A chip at the end of the subject row; on a phone on its own line under it. + + )}
    @@ -4885,6 +4972,24 @@ export function EmailComposer({ ))}
    + {classification.policy.list.length > 0 && ( + <> +
    +
    + + {[...(classification.policy.required ? [] : [null]), ...classification.policy.list].map((option) => ( + classification.setChoice(option?.id ?? null)} + testId={`composer-options-label-${option?.id ?? 'none'}`} + > + {option?.name ?? tClassification('none')} + + ))} +
    + + )} } diff --git a/components/email/use-composer-classification.ts b/components/email/use-composer-classification.ts new file mode 100644 index 000000000..82b9522f3 --- /dev/null +++ b/components/email/use-composer-classification.ts @@ -0,0 +1,55 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; +import { useAccountSubjects } from '@/lib/org/account-subjects'; +import { labelsPolicyFor, resolveLabel, labelMissing } from '@/lib/outgoing/classification'; +import type { LabelsPolicy } from '@/lib/classification/labels'; +import { usePolicyStore } from '@/stores/policy-store'; + +export interface ComposerClassification { + policy: LabelsPolicy; + /** The label the message goes out with as things stand. */ + label: ClassificationLabel | null; + /** The user's pick: an id, null for none, undefined for "not picked" (original's or default). */ + choice: string | null | undefined; + setChoice: (choice: string | null) => void; + /** Sending needs a label and there is none. */ + missing: boolean; + /** What a saved draft keeps (X-Bulwark-Label); undefined while labels are off. */ + draftChoice: string | null | undefined; +} + +/** + * The composer's classification label: the labels of the account and + * identity the message goes out from, the user's pick, else the original's + * label (replies and forwards), else the default. Follows the policy as it + * loads and the From as it changes. + */ +export function useComposerClassification(input: { + accountId: string | null | undefined; + senderEmail: string | undefined; + /** The original's headers, for a reply or forward. */ + originalHeaders?: Record | null; + initialChoice?: string | null; +}): ComposerClassification { + const policyState = usePolicyStore((s) => s.policy); + const rawPolicy = usePolicyStore((s) => s.rawPolicy); + const subjects = useAccountSubjects((s) => s.subjects); + const [choice, setChoice] = useState(input.initialChoice); + const policy = useMemo( + () => labelsPolicyFor({ email: input.senderEmail, accountId: input.accountId }), + // Re-read from the stores these follow. + // eslint-disable-next-line react-hooks/exhaustive-deps + [input.senderEmail, input.accountId, policyState, rawPolicy, subjects], + ); + const label = useMemo(() => resolveLabel(policy, choice, input.originalHeaders), [policy, choice, input.originalHeaders]); + return { + policy, + label, + choice, + setChoice, + missing: labelMissing(policy, label), + draftChoice: policy.list.length === 0 ? undefined : (label?.id ?? null), + }; +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 3718674e6..bc4c7e8c9 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -31,7 +31,7 @@ import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types"; import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store"; import { useShallow } from "zustand/react/shallow"; -import { usePolicyStore } from "@/stores/policy-store"; +import { usePolicyStore, whenPolicySettled } from "@/stores/policy-store"; import { isArchiveActionEnabled, useArchiveActionEnabled } from "@/lib/archive-gate"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; import { connectedAccountsGrew } from "@/lib/unified-mailbox"; @@ -121,6 +121,9 @@ import { WopiEditor } from "@/components/files/wopi-editor"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { isFilePreviewable } from "@/lib/file-preview"; import { prepareQuickReply, type QuickReplyQuestion } from "@/lib/outgoing/quick-reply"; +import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; +import { automaticLabel, classifyOutgoing, labelSourceHeaders } from "@/lib/outgoing/classification"; +import { draftLabelChoice } from "@/lib/classification/labels"; import enMessages from "@/locales/en/common.json"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; @@ -1333,6 +1336,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { messageId: selectedEmail.messageId, inReplyTo: selectedEmail.inReplyTo, references: selectedEmail.references, + // Its label, for the reply's or forward's classification label. + headers: labelSourceHeaders(selectedEmail.headers), quoteHeaderHtml: composerQuoteHeader?.html, quoteHeaderText: composerQuoteHeader?.text, quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote, @@ -2158,6 +2163,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { requireTls?: boolean; priority?: MessagePriority; headers?: Record; + labelHeaders?: Record; composerId?: string; }) => { // Nothing is sent: the composer keeps what only goes once the mail is out. @@ -2182,7 +2188,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = composerSourceEmail?.id; - const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, headers: data.headers, localAccountId: data.localAccountId, draftAccountId: data.draftAccountId }); + const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data)); submitted = true; announceSent(result, data); setShowComposer(false); @@ -2495,6 +2501,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { inReplyTo: draft.inReplyTo ?? undefined, references: draft.references ?? undefined, priority: messagePriorityFromHeaders(draft.headers), + classification: draftLabelChoice(draft.headers), // Existing server-side attachments must ride along, or the composer // starts empty and the next save/send silently rebuilds the draft // without them (#849). @@ -2846,6 +2853,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { replyTo: { subject: email.subject, attachments: [payload.attachment], + headers: labelSourceHeaders(email.headers), }, }); setComposerMode('forward'); @@ -4084,6 +4092,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const signatureIdentity = (sendingIdentity?.htmlSignature || sendingIdentity?.textSignature) ? sendingIdentity : primaryIdentity; + // The classification label (enterprise pack): the original's, else the + // default. A required label with neither: the full composer, with the + // typed text, to pick one. Nothing is asked or sent before that. + await whenPolicySettled(); + const quickLabel = automaticLabel({ email: headerFromEmail }, selectedEmail.headers); + if (quickLabel.missing) { + toast.info(t('classification.quick_reply_needs_label')); + await handleReply(body); + return true; + } + // The same warnings, checks and finalisers as the composer's send // (lib/outgoing/send-pipeline.ts): a quick reply is no way around them. const settings = useSettingsStore.getState(); @@ -4155,11 +4174,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { references: selectedEmail.references, }); + // The label goes on last, as in the composer. + const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, { + subject: buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')), + }); + // Send reply with just the body text const result = await sendEmail( client, recipients, - buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')), + labelled.subject, finalBody, undefined, undefined, @@ -4173,6 +4197,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { threading?.references, delayedUntil, undefined, + labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined, ); // As after the composer's send: the server refused some recipients, held // reply or not, and nothing else would say they never get it. @@ -4822,6 +4847,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { messageId: composerSourceEmail.messageId, inReplyTo: composerSourceEmail.inReplyTo, references: composerSourceEmail.references, + // Its label, for the reply's or forward's classification label. + headers: labelSourceHeaders(composerSourceEmail.headers), quoteHeaderHtml: composerQuoteHeader?.html, quoteHeaderText: composerQuoteHeader?.text, quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote, diff --git a/components/pro/__tests__/cross-account-send-routing.test.tsx b/components/pro/__tests__/cross-account-send-routing.test.tsx index 90dc2ad4c..abe6edab4 100644 --- a/components/pro/__tests__/cross-account-send-routing.test.tsx +++ b/components/pro/__tests__/cross-account-send-routing.test.tsx @@ -180,3 +180,35 @@ describe('Pro compose tab - cross-account send routing (#461)', () => { } }); }); + +describe('Pro compose tab - what the composer chose', () => { + beforeEach(() => { + sendEmail.mockClear(); + }); + + it('sends with the priority, read receipt, DSN, REQUIRETLS, plugin headers and label the composer chose', async () => { + payloadRef.current = { + ...basePayload, + requestReadReceipt: true, + requestDsn: true, + requireTls: true, + priority: 'high', + headers: { 'X-Plugin': '1' }, + labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' }, + }; + render(); + + fireEvent.click(screen.getByText('send')); + + await waitFor(() => expect(sendEmail).toHaveBeenCalled()); + const args = sendEmail.mock.calls[0] as unknown[]; + expect(args[args.length - 1]).toMatchObject({ + requestReadReceipt: true, + requestDsn: true, + requireTls: true, + priority: 'high', + headers: { 'X-Plugin': '1' }, + labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' }, + }); + }); +}); diff --git a/components/pro/pro-compose-tab-body.tsx b/components/pro/pro-compose-tab-body.tsx index a3f1c72ce..a49e0f2df 100644 --- a/components/pro/pro-compose-tab-body.tsx +++ b/components/pro/pro-compose-tab-body.tsx @@ -14,6 +14,7 @@ import { debug } from "@/lib/debug"; import { threadKeyFor } from "@/lib/thread-utils"; import { formatRejectedRecipients } from "@/lib/jmap/client"; import { announceSent } from "@/lib/plugin-after-send"; +import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; interface ProComposeTabBodyProps { tabId: string; @@ -89,7 +90,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) { sendData.references, sendData.delayedUntil, sendData.envelopeMailFrom, - { localAccountId: sendData.localAccountId, draftAccountId: sendData.draftAccountId }, + // The composer's whole options, as the mail page passes them: the + // tab used to send without priority, read receipt, DSN, REQUIRETLS, + // plugin headers and the classification label. + composerSendOptions(sendData), ); submitted = true; if (result.rejectedRecipients?.length) { diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index d5a6bea15..5f5f34344 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -24,6 +24,8 @@ import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor } from "@/lib/thread import { cn } from "@/lib/utils"; import { getDraftTextBody } from '@/lib/email-body-selection'; import { messagePriorityFromHeaders } from '@/lib/message-priority'; +import { labelSourceHeaders } from '@/lib/outgoing/classification'; +import { draftLabelChoice } from '@/lib/classification/labels'; import { useArchiveActionEnabled } from '@/lib/archive-gate'; import { confirmMailRemoval } from '@/lib/mail-removal-confirm'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; @@ -83,6 +85,7 @@ function buildReplyContext(email: Email): ProReplyContext { messageId: email.messageId, inReplyTo: email.inReplyTo, references: email.references, + headers: labelSourceHeaders(email.headers), }; } @@ -287,6 +290,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, replyTo: { subject: email.subject, attachments: [payload.attachment], + headers: labelSourceHeaders(email.headers), }, sourceEmailId: email.id, sourceEmailSource: writeSource, @@ -467,6 +471,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // composer without its files and the next save stripped them. attachments: email.attachments, priority: messagePriorityFromHeaders(email.headers), + classification: draftLabelChoice(email.headers), }, }, { to: addresses(email.to), cc: addresses(email.cc), subject: draftSubject, body: bodyText }, onClose); }, [email, accountId, localAccountId, identities, multiAccountIdentities, openComposeTab, onClose, t]); diff --git a/lib/__tests__/classification-labels.test.ts b/lib/__tests__/classification-labels.test.ts new file mode 100644 index 000000000..ffd41572c --- /dev/null +++ b/lib/__tests__/classification-labels.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; +import { + applyLabelPrefix, + draftLabelChoice, + draftLabelProperty, + labelHeaderLines, + labelHeaderProperties, + parseSioLabelMarking, + receivedLabel, + sioLabelValue, + textColorOn, +} from '@/lib/classification/labels'; + +// The headers a classification label writes (SIO-Label per RFC 7444 with +// RFC 2231 for non-ASCII markings, Sensitivity per RFC 2156, the admin's +// extra header), reading them back, and the subject prefix without doubling. + +const generic = LABEL_PRESETS.find((p) => p.id === 'generic')!; +const german = LABEL_PRESETS.find((p) => p.id === 'german-public-sector')!; +const byId = (list: ClassificationLabel[], id: string) => list.find((l) => l.id === id)!; +const confidential = byId(generic.list, 'confidential'); +const vsnfd = byId(german.list, 'vs-nfd'); + +describe('SIO-Label', () => { + it('writes an ASCII marking quoted, with readable colours', () => { + expect(sioLabelValue(confidential)).toBe(' marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706'); + expect(textColorOn('#1e3a8a')).toBe('#ffffff'); + expect(textColorOn('#fde68a')).toBe('#000000'); + }); + + it('writes a non-ASCII marking with RFC 2231, never an encoded word, folded under 78 columns', () => { + const value = sioLabelValue(vsnfd); + expect(value).toContain("marking*=utf-8''VS-NUR%20F%C3%9CR%20DEN%20DIENSTGEBRAUCH;"); + expect(value).not.toContain('=?'); + for (const line of `SIO-Label:${value}`.split('\r\n')) expect(line.length).toBeLessThanOrEqual(78); + expect(parseSioLabelMarking(value)).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH'); + }); + + it('continues a long encoded marking over several parameters and reads it back', () => { + const long = { ...vsnfd, marking: 'ÄÖÜ äöü ß – sehr lange Kennzeichnung für einen Test der Fortsetzung' }; + const value = sioLabelValue(long); + expect(value).toMatch(/marking\*0\*=utf-8''/); + expect(value).toMatch(/marking\*1\*=/); + expect(value).not.toMatch(/%[0-9A-F]?(;|\r|$)/); + expect(parseSioLabelMarking(value)).toBe(long.marking); + }); + + it('reads the forms servers hand back: raw, refolded, a token, RFC 2047 inside quotes', () => { + expect(parseSioLabelMarking(' marking="INTERNAL"; fgcolor=#ffffff; bgcolor=#2563eb')).toBe('INTERNAL'); + expect(parseSioLabelMarking(' marking="STRICTLY\r\n CONFIDENTIAL"; bgcolor=red')).toBe('STRICTLY CONFIDENTIAL'); + expect(parseSioLabelMarking('marking=PUBLIC;fgcolor=black')).toBe('PUBLIC'); + expect(parseSioLabelMarking(' marking="=?UTF-8?B?VlMtTlVSIEbDnFIgREVOIERJRU5TVEdFQlJBVUNI?="')).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH'); + expect(parseSioLabelMarking(' type=":ess"; label="MQYGASkCAQM="')).toBeNull(); + expect(parseSioLabelMarking(undefined)).toBeNull(); + }); + + it('writes Sensitivity and the extra header only when the label sets them', () => { + expect(labelHeaderProperties(confidential, 'X-Classification')).toEqual({ + 'header:SIO-Label:asRaw': sioLabelValue(confidential), + 'header:Sensitivity:asText': 'Company-Confidential', + 'header:X-Classification:asText': 'Confidential', + }); + expect(Object.keys(labelHeaderProperties(byId(generic.list, 'public'), ''))).toEqual(['header:SIO-Label:asRaw']); + expect(labelHeaderLines(vsnfd, 'X-Classification')).toEqual([ + `SIO-Label:${sioLabelValue(vsnfd)}`, + 'Sensitivity: Company-Confidential', + 'X-Classification: VS-NfD', + ]); + expect(labelHeaderLines({ ...vsnfd, extraHeaderValue: 'Geheim für alle' }, 'X-Class')[2]).toMatch(/^X-Class: =\?UTF-8\?B\?/); + }); +}); + +describe('received labels', () => { + const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' }; + + it('are matched by marking first, case and spacing aside', () => { + expect(receivedLabel({ 'SIO-Label': ' marking="confidential "' }, policy)?.id).toBe('confidential'); + expect(receivedLabel({ 'sio-label': [' marking="PUBLIC"'] }, policy)?.id).toBe('public'); + }); + + it('then by the extra header value', () => { + expect(receivedLabel({ 'X-Classification': ' Strictly confidential' }, policy)?.id).toBe('strictly-confidential'); + }); + + it('show nothing for an unknown marking or without labels', () => { + expect(receivedLabel({ 'SIO-Label': ' marking="TOP SECRET"' }, policy)).toBeNull(); + expect(receivedLabel({ 'SIO-Label': ' marking="PUBLIC"' }, { ...policy, list: [] })).toBeNull(); + }); +}); + +describe('the subject prefix', () => { + const list = generic.list; + const strictly = byId(list, 'strictly-confidential'); + + it('is added once', () => { + expect(applyLabelPrefix('Budget', confidential, list)).toBe('[Confidential] Budget'); + expect(applyLabelPrefix('[Confidential] Budget', confidential, list)).toBe('[Confidential] Budget'); + }); + + it('is not doubled across reply and forward chains', () => { + expect(applyLabelPrefix('Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] Re: Budget'); + expect(applyLabelPrefix('AW: WG: [Confidential] Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] AW: WG: Re: Budget'); + }); + + it('changes with the label, and goes with no label', () => { + expect(applyLabelPrefix('Re: [Confidential] Budget', strictly, list)).toBe('[Strictly confidential] Re: Budget'); + expect(applyLabelPrefix('[Confidential] Re: Budget', null, list)).toBe('Re: Budget'); + expect(applyLabelPrefix('Re: Budget', byId(list, 'internal'), list)).toBe('Re: Budget'); + }); + + it('knows prefixes without brackets, like VS-NfD', () => { + expect(applyLabelPrefix('AW: VS-NfD Lagebericht', vsnfd, german.list)).toBe('VS-NfD AW: Lagebericht'); + expect(applyLabelPrefix('VS-NfD: Lagebericht', vsnfd, german.list)).toBe('VS-NfD Lagebericht'); + // A word that only starts like a prefix stays. + expect(applyLabelPrefix('VS-NfDx test', vsnfd, german.list)).toBe('VS-NfD VS-NfDx test'); + }); +}); + +describe('drafts', () => { + it('keep the pick, "none" and nothing apart', () => { + expect(draftLabelProperty('confidential')).toEqual({ 'header:X-Bulwark-Label:asText': 'confidential' }); + expect(draftLabelProperty(null)).toEqual({ 'header:X-Bulwark-Label:asText': 'none' }); + expect(draftLabelProperty(undefined)).toEqual({}); + expect(draftLabelChoice({ 'X-Bulwark-Label': ' confidential' })).toBe('confidential'); + expect(draftLabelChoice({ 'x-bulwark-label': 'none' })).toBeNull(); + expect(draftLabelChoice({})).toBeUndefined(); + }); +}); diff --git a/lib/__tests__/jmap-label-headers.test.ts b/lib/__tests__/jmap-label-headers.test.ts new file mode 100644 index 000000000..ef44b4d95 --- /dev/null +++ b/lib/__tests__/jmap-label-headers.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// The client writes a classification label's headers on send (over any +// plugin header of the same name) and keeps the pick in a draft. + +function createClient(): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass'); + Object.assign(client, { apiUrl: 'https://jmap.example.com/api', accountId: 'account-1', username: 'user@example.com' }); + return client; +} + +type Call = [string, Record, string]; + +function mockServer() { + const captured: Call[][] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { + const calls = (JSON.parse((init as { body: string }).body) as { methodCalls: Call[] }).methodCalls; + captured.push(calls); + const name = calls[0][0]; + let responses: unknown[]; + if (name === 'Mailbox/get') responses = [['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }] }, '0']]; + else if (name === 'Identity/get') responses = [['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, '0']]; + else if (name === 'Email/set' && calls[0][1].create) { + const id = Object.keys(calls[0][1].create as object)[0]; + responses = [['Email/set', { created: { [id]: { id: 'new-1' } } }, '0'], ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1']]; + } else responses = calls.map(([method, , id]) => [method, {}, id]); + const payload = { methodResponses: responses }; + return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(payload)), json: () => Promise.resolve(payload) } as Response; + }); + return captured; +} + +function created(captured: Call[][]): Record { + for (const calls of captured) { + const set = calls.find(([name, args]) => name === 'Email/set' && args.create); + if (set) return Object.values(set[1].create as Record>)[0]; + } + throw new Error('no create'); +} + +describe('label headers', () => { + beforeEach(() => vi.restoreAllMocks()); + + it('are written on send, and win over a plugin header of the same name', async () => { + const captured = mockServer(); + await createClient().sendEmail( + ['bob@example.org'], '[Confidential] x', 'body', undefined, undefined, 'identity-1', 'user@example.com', + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, + { + headers: { Sensitivity: 'Personal', 'X-Other': 'kept' }, + labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"', 'header:Sensitivity:asText': 'Company-Confidential' }, + }, + ); + const email = created(captured); + expect(email['header:SIO-Label:asRaw']).toBe(' marking="CONFIDENTIAL"'); + expect(email['header:Sensitivity:asText']).toBe('Company-Confidential'); + expect(email['header:X-Other:asText']).toBe('kept'); + expect(Object.keys(email).filter((k) => k.toLowerCase().startsWith('header:sensitivity'))).toHaveLength(1); + }); + + it('are kept in a draft as the pick', async () => { + const captured = mockServer(); + await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com', + undefined, undefined, undefined, undefined, undefined, undefined, { classification: 'confidential' }); + expect(created(captured)['header:X-Bulwark-Label:asText']).toBe('confidential'); + const again = mockServer(); + await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com', + undefined, undefined, undefined, undefined, undefined, undefined, { classification: null }); + expect(created(again)['header:X-Bulwark-Label:asText']).toBe('none'); + }); +}); diff --git a/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts new file mode 100644 index 000000000..f643ea0ed --- /dev/null +++ b/lib/__tests__/plugin-host-labels.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { Email } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useIdentityStore } from '@/stores/identity-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; +import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; +import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification'; + +// Classification labels on the plugin paths: api.email.forward carries the +// original's label (else the default) and refuses when a required one is +// missing; a crypto plugin's raw send during a labelled takeover gets the +// label headers in its top-level header block. + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); + +import { dispatchApiCall } from '../plugin-sandbox/host-api'; + +function plugin(perms: string[], id: string): InstalledPlugin { + return { + id, name: id, version: '1.0.0', author: 'test', description: '', + type: 'ui-extension', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} +const FORWARDER = plugin(['email:forward'], 'e-invoice'); +const SMIME = plugin(['email:raw-send'], 'smime'); +const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args, { privileged: p.id === 'smime' }); + +const original = (headers: Record) => ({ + id: 'E1', threadId: 'T1', mailboxIds: { inbox: true }, keywords: {}, size: 900, receivedAt: '2026-10-20T08:00:00Z', + from: [{ email: 'billing@supplier.example' }], to: [{ email: 'ann@example.com' }], subject: 'Invoice', + textBody: [{ partId: '1', blobId: 'tb', size: 20, type: 'text/plain' }], bodyValues: { 1: { value: 'Hello' } }, + attachments: [], headers, +}) as unknown as Email; + +function useLabels(labels: Partial) { + const org = { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: LABEL_PRESETS[0].list, ...labels } }; + usePolicyStore.setState({ loaded: true, policy: { ...DEFAULT_POLICY, organisation: org }, rawPolicy: { ...DEFAULT_POLICY, organisation: org } }); +} + +describe('labels on the plugin paths', () => { + let client: Record>; + let sendEmail: ReturnType; + let sendRawEmail: ReturnType; + let headers: Record = {}; + + beforeEach(() => { + resetPluginRateLimits(); + resetDialogLimits(); + localStorage.clear(); + // The forward to this address is approved already: no dialog. + localStorage.setItem('plugin-forward-approvals', '{}'); + client = { + getAccountId: vi.fn(() => 'own'), + getEmail: vi.fn(async () => original(headers)), + getMailboxes: vi.fn(async () => []), + resolveSendAccountId: vi.fn(async () => 'own'), + copyBlobs: vi.fn(async () => ({})), + hasDelayedSend: vi.fn(() => false), + batchUpdateKeywords: vi.fn(async () => undefined), + submitRawEmail: vi.fn(async () => ({ scheduled: false })), + }; + sendEmail = vi.fn(async () => ({ emailId: 'S1', scheduled: false })); + sendRawEmail = vi.fn(async () => ({ scheduled: false })); + useAuthStore.setState({ client: client as unknown as IJMAPClient }); + useAccountStore.setState({ activeAccountId: 'acct-1' }); + useEmailStore.setState({ sendEmail, sendRawEmail, emails: [], selectedEmail: null, mailboxes: [] } as never); + useIdentityStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] as never, preferredPrimaryId: 'id-a' }); + useSettingsStore.setState({ sendDelaySeconds: 0 }); + }); + afterEach(() => { + while (head()) resolveHead(false); + useAuthStore.setState({ client: null }); + usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY }); + resetLabelTakeovers(); + headers = {}; + vi.clearAllMocks(); + }); + + async function forward() { + const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] }); + await vi.waitFor(() => expect(head()).not.toBeNull()); + resolveHead(true); + return pending; + } + + it('forwards with the original’s label', async () => { + useLabels({ defaultId: 'internal' }); + headers = { 'SIO-Label': ' marking="CONFIDENTIAL"' }; + expect(await forward()).toMatchObject({ sent: true }); + const args = sendEmail.mock.calls[0] as unknown[]; + expect(args[2]).toBe('[Confidential] Fwd: Invoice'); + expect((args[16] as { labelHeaders: Record }).labelHeaders['header:SIO-Label:asRaw']).toContain('CONFIDENTIAL'); + }); + + it('refuses a forward that needs a label it has no way to get', async () => { + useLabels({ required: true, defaultId: '' }); + expect(await call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] })).toEqual({ sent: false, reason: 'stopped' }); + expect(sendEmail).not.toHaveBeenCalled(); + expect(toast.error).toHaveBeenCalled(); + }); + + it('puts the label into a raw send during a labelled takeover only', async () => { + useLabels({}); + const raw = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\n\r\nENCRYPTED\r\n'); + await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {}); + expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label'); + + const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a', '[Confidential] Hi'); + await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {}); + await call(SMIME, 'jmap.submitRaw', raw, 'id-a', {}); + endLabelTakeover(token); + const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text(); + expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/); + expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"'); + }); +}); diff --git a/lib/classification/labels.ts b/lib/classification/labels.ts new file mode 100644 index 000000000..85a34d03a --- /dev/null +++ b/lib/classification/labels.ts @@ -0,0 +1,304 @@ +/** + * Classification labels on the wire (enterprise pack item 3). Pure + * functions: the headers a label writes, reading a received label back, + * the subject prefix, and the draft marker. + * + * Headers written: + * - `SIO-Label` (RFC 7444) in the marking-only form with colours, as a raw + * header: `marking="CONFIDENTIAL"; fgcolor=#ffffff; bgcolor=#d97706`. + * A marking with non-ASCII characters uses RFC 2231 + * (`marking*=utf-8''VS-NUR%20F%C3%9CR...`): an RFC 2047 encoded word + * inside a quoted parameter is invalid (RFC 2047 §5), which is what the + * server's text form would produce. + * - `Sensitivity` (RFC 2156 §5.3.4), what Outlook shows, when the label + * sets one. + * - The admin's extra header (X-Classification, msip_labels) when the label + * has a value for it. + * No SIO-Label-History: it needs who changed it and when, and RFC 7444 + * says it may leak. + * + * Drafts keep the user's choice in `X-Bulwark-Label` (the label id, or + * `none` for a choice of no label), the way they keep the priority. + */ + +import type { ClassificationLabel, OrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { stripSubjectPrefixes } from '@/lib/subject-prefix'; + +export type LabelsPolicy = OrganisationPolicy['labels']; + +export const SIO_LABEL_HEADER = 'SIO-Label'; +export const SENSITIVITY_HEADER = 'Sensitivity'; +export const DRAFT_LABEL_HEADER = 'X-Bulwark-Label'; +/** A draft whose sender picked "no label". */ +export const NO_LABEL = 'none'; + +/** The marking a label writes: its own, else its name. */ +export function labelMarking(label: Pick): string { + return (label.marking || label.name).trim(); +} + +/** Black or white, whichever reads better on `bg` (#rrggbb). */ +export function textColorOn(bg: string): '#000000' | '#ffffff' { + const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(bg); + if (!m) return '#ffffff'; + const [r, g, b] = [m[1], m[2], m[3]].map((h) => { + const c = parseInt(h, 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luminance > 0.179 ? '#000000' : '#ffffff'; +} + +// RFC 2231 attribute-char: anything but specials, space, controls, *, ' and %. +const ATTRIBUTE_CHAR = /[A-Za-z0-9!#$&+\-.^_`|~]/; + +function percentEncode(value: string): string { + let out = ''; + for (const byte of new TextEncoder().encode(value)) { + const ch = String.fromCharCode(byte); + out += byte < 0x80 && ATTRIBUTE_CHAR.test(ch) ? ch : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`; + } + return out; +} + +/** The marking parameter(s): quoted when ASCII, RFC 2231 (with continuations when long) otherwise. */ +function markingParameters(marking: string): string[] { + const clean = marking.replace(/["\\\r\n]/g, ''); + // eslint-disable-next-line no-control-regex + if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`]; + const encoded = percentEncode(clean); + const MAX = 60; + if (encoded.length + 15 <= MAX) return [`marking*=utf-8''${encoded}`]; + // Split between encoded characters, never inside a %XX. + const units = encoded.match(/%[0-9A-F]{2}|[^%]/g) ?? []; + const pieces: string[] = []; + let piece = ''; + for (const unit of units) { + if (piece.length + unit.length > MAX - 15) { + pieces.push(piece); + piece = ''; + } + piece += unit; + } + if (piece) pieces.push(piece); + return pieces.map((piece, i) => (i === 0 ? `marking*0*=utf-8''${piece}` : `marking*${i}*=${piece}`)); +} + +/** The SIO-Label value after "SIO-Label:", folded (leading space included). */ +export function sioLabelValue(label: Pick): string { + const params = [...markingParameters(labelMarking(label)), `fgcolor=${textColorOn(label.color)}`, `bgcolor=${label.color}`]; + // "SIO-Label: " is 11 characters; fold between parameters past 78. + let line = 11; + let out = ''; + params.forEach((param, i) => { + const piece = i === params.length - 1 ? param : `${param};`; + if (i > 0 && line + 1 + piece.length > 78) { + out += `\r\n ${piece}`; + line = 1 + piece.length; + } else { + out += ` ${piece}`; + line += 1 + piece.length; + } + }); + return out; +} + +/** The label's headers as [name, value] pairs, the extra header's value as plain text. */ +function labelHeaderPairs(label: ClassificationLabel, extraHeader: string): Array<[string, string, 'raw' | 'text']> { + const pairs: Array<[string, string, 'raw' | 'text']> = [[SIO_LABEL_HEADER, sioLabelValue(label), 'raw']]; + if (label.sensitivity) pairs.push([SENSITIVITY_HEADER, label.sensitivity, 'text']); + const extraValue = label.extraHeaderValue.trim(); + if (extraHeader && extraValue) pairs.push([extraHeader, extraValue, 'text']); + return pairs; +} + +/** The label's headers as JMAP Email/set create properties (RFC 8621 §4.1.3). */ +export function labelHeaderProperties(label: ClassificationLabel, extraHeader: string): Record { + const out: Record = {}; + for (const [name, value, form] of labelHeaderPairs(label, extraHeader)) { + out[form === 'raw' ? `header:${name}:asRaw` : `header:${name}:asText`] = value; + } + return out; +} + +function encodedWord(value: string): string { + // eslint-disable-next-line no-control-regex + if (/^[\x20-\x7e]*$/.test(value)) return value; + let binary = ''; + for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte); + return `=?UTF-8?B?${btoa(binary)}?=`; +} + +/** The label's headers as raw header lines (CRLF-free each), for raw MIME. */ +export function labelHeaderLines(label: ClassificationLabel, extraHeader: string): string[] { + return labelHeaderPairs(label, extraHeader).map(([name, value, form]) => + form === 'raw' ? `${name}:${value}` : `${name}: ${encodedWord(value)}`); +} + +/** The draft marker property: the label id, or `none` for no label. */ +export function draftLabelProperty(choice: string | null | undefined): Record { + if (choice === undefined) return {}; + return { [`header:${DRAFT_LABEL_HEADER}:asText`]: choice ?? NO_LABEL }; +} + +type HeaderRecord = Record | null | undefined; + +function header(headers: HeaderRecord, name: string): string | undefined { + if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined; + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() !== lower) continue; + const first = Array.isArray(value) ? value[0] : value; + if (typeof first === 'string') return first; + } + return undefined; +} + +/** + * The choice a re-opened draft carries: a label id, null for "no label", + * undefined when the draft says nothing (a draft of another client, or one + * saved before labels). + */ +export function draftLabelChoice(headers: HeaderRecord): string | null | undefined { + const value = header(headers, DRAFT_LABEL_HEADER)?.trim(); + if (!value) return undefined; + return value === NO_LABEL ? null : value; +} + +function decodePercent(value: string, charset: string): string { + const bytes: number[] = []; + for (let i = 0; i < value.length; i++) { + if (value[i] === '%' && /^[0-9a-f]{2}$/i.test(value.slice(i + 1, i + 3))) { + bytes.push(parseInt(value.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(value.charCodeAt(i) & 0xff); + } + } + try { + return new TextDecoder(charset || 'utf-8').decode(Uint8Array.from(bytes)); + } catch { + return new TextDecoder('utf-8').decode(Uint8Array.from(bytes)); + } +} + +/** Decodes RFC 2047 encoded words a server may have put into a value. */ +function decodeEncodedWords(value: string): string { + return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (whole, charset: string, enc: string, text: string) => { + try { + let bytes: Uint8Array; + if (enc.toLowerCase() === 'b') bytes = Uint8Array.from(atob(text), (c) => c.charCodeAt(0)); + else bytes = Uint8Array.from(text.replace(/_/g, ' ').replace(/=([0-9a-f]{2})/gi, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0)); + return new TextDecoder(charset).decode(bytes); + } catch { + return whole; + } + }); +} + +/** + * The `marking` of a raw SIO-Label value (RFC 7444 §2): a quoted or token + * value, or the RFC 2231 forms (encoded and continued). Null without one. + */ +export function parseSioLabelMarking(raw: string | undefined | null): string | null { + if (!raw) return null; + const value = raw.replace(/\r?\n[ \t]+/g, ' '); + const params = new Map(); + const re = /([A-Za-z0-9*-]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;\s]*)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(value))) { + let v = m[2]; + if (v.startsWith('"')) v = v.slice(1, -1).replace(/\\(.)/g, '$1'); + params.set(m[1].toLowerCase(), v); + } + if (params.has('marking')) return decodeEncodedWords(params.get('marking')!).trim() || null; + if (params.has('marking*')) { + const [charset, , text] = splitExtended(params.get('marking*')!); + return decodePercent(text, charset).trim() || null; + } + // Continuations: marking*0*, marking*1, ... joined before decoding (a + // character's bytes may be split across two of them, RFC 2231 §4.1). + let joinedEncoded = ''; + let charset = 'utf-8'; + for (let i = 0; i < 100; i++) { + const encoded = params.get(`marking*${i}*`); + const plain = params.get(`marking*${i}`); + if (encoded === undefined && plain === undefined) break; + if (encoded !== undefined) { + let text = encoded; + if (i === 0) [charset, , text] = splitExtended(encoded); + joinedEncoded += text; + } else { + joinedEncoded += percentEncode(plain!); + } + } + const joined = decodePercent(joinedEncoded, charset).trim(); + return joined || null; +} + +function splitExtended(value: string): [string, string, string] { + const first = value.indexOf("'"); + const second = first >= 0 ? value.indexOf("'", first + 1) : -1; + if (first < 0 || second < 0) return ['utf-8', '', value]; + return [value.slice(0, first), value.slice(first + 1, second), value.slice(second + 1)]; +} + +const normalise = (s: string) => s.normalize('NFC').replace(/\s+/g, ' ').trim().toLowerCase(); + +/** + * The known label a received message carries: by its SIO-Label marking + * first, then by the admin's extra header value. Unknown values show + * nothing. Shown as information only: anyone can write these headers. + */ +export function receivedLabel(headers: HeaderRecord, policy: LabelsPolicy | null | undefined): ClassificationLabel | null { + if (!policy || policy.list.length === 0) return null; + const marking = parseSioLabelMarking(header(headers, SIO_LABEL_HEADER)); + if (marking) { + const wanted = normalise(marking); + const byMarking = policy.list.find((label) => normalise(labelMarking(label)) === wanted); + if (byMarking) return byMarking; + } + if (policy.extraHeader) { + const raw = header(headers, policy.extraHeader); + if (raw) { + const wanted = normalise(decodeEncodedWords(raw.replace(/\r?\n[ \t]+/g, ' '))); + const byExtra = policy.list.find((label) => label.extraHeaderValue && normalise(label.extraHeaderValue) === wanted); + if (byExtra) return byExtra; + } + } + return null; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The subject with `label`'s prefix in front, once: every known label + * prefix in the leading chain of reply and forward prefixes is taken out + * first ("Re: [Confidential] x" → "[Strictly confidential] Re: x"), so a + * reply doesn't double it and a changed label replaces the old one. + */ +export function applyLabelPrefix(subject: string, label: ClassificationLabel | null, labels: ClassificationLabel[]): string { + const prefixes = [...new Set(labels.map((l) => l.subjectPrefix.trim()).filter(Boolean))].sort((a, b) => b.length - a.length); + let rest = subject.trimStart(); + const kept: string[] = []; + const labelRes = prefixes.map((p) => new RegExp(`^${escapeRegExp(p)}(?:\\s*:)?(?:\\s+|$)`, 'i')); + for (let i = 0; i < 40; i++) { + const labelMatch = labelRes.map((re) => re.exec(rest)).find(Boolean); + if (labelMatch) { + rest = rest.slice(labelMatch[0].length).trimStart(); + continue; + } + const stripped = stripSubjectPrefixes(rest); + if (stripped === rest) break; + // Keep the reply/forward prefix as written, one token at a time. + const token = rest.slice(0, rest.length - stripped.length); + const one = /^\s*[^::]+[::]\s*/.exec(token)?.[0] ?? token; + kept.push(one.trim()); + rest = rest.slice(one.length).trimStart(); + } + const chain = [...kept, rest].filter(Boolean).join(' '); + const prefix = label?.subjectPrefix.trim(); + return prefix ? (chain ? `${prefix} ${chain}` : prefix) : chain; +} diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 3cd909678..9953bb041 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -415,8 +415,10 @@ export interface IJMAPClient { * resolveSendAccountId). `previousDraftAccountId`: where `draftId` lives, * so the replaced version is destroyed there (default: the primary * account). Draft ids are only unique within one account. + * `classification`: the label choice (an id, null for none) kept in + * X-Bulwark-Label. */ - options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string }, + options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null }, ): Promise; /** @@ -452,7 +454,7 @@ export interface IJMAPClient { // `draftAccountId`: the account `draftId` lives in, so the sent draft is // destroyed there (default: the primary account). `noSentCopy`: no copy // stays in Sent (a phishing report). - options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; draftAccountId?: string; noSentCopy?: boolean }, + options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; draftAccountId?: string; noSentCopy?: boolean; labelHeaders?: Record }, ): Promise; /** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */ supportsSubmissionExtension?(extension: string, accountId?: string): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 116bb822f..163bcfa47 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -44,7 +44,8 @@ import { parseAuthenticationResults } from "@/lib/email-headers"; import { IS_LITE_STALWART } from "@/lib/lite"; import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority"; -import { customHeaderProperties } from "@/lib/outgoing/custom-headers"; +import { customHeaderProperties, withLabelHeaders } from "@/lib/outgoing/custom-headers"; +import { draftLabelProperty } from "@/lib/classification/labels"; import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body"; import { addBodyMediaToAttachments } from "./body-media-attachments"; @@ -1431,6 +1432,12 @@ export interface SubmissionSendOptions { * has gone (a phishing report, which the user doesn't keep). */ noSentCopy?: boolean; + /** + * Classification label headers (lib/outgoing/classification.ts) as + * Email/set create properties. Written after `headers`; a plugin header + * of the same name is dropped, so a plugin can't change the label. + */ + labelHeaders?: Record; } /** The MAIL FROM / RCPT TO parameters the options translate to. */ @@ -4697,7 +4704,9 @@ export class JMAPClient implements IJMAPClient { htmlBody?: string, inReplyTo?: string[], references?: string[], - options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string }, + // `classification`: the composer's label choice (an id, null for none), + // kept in X-Bulwark-Label so a re-opened draft restores it. + options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null }, ): Promise { // A draft for a group identity belongs in the group's Drafts, like its // Sent copy (#1090). @@ -4756,6 +4765,7 @@ export class JMAPClient implements IJMAPClient { ...(draftReferences?.length ? { references: draftReferences } : {}), // A re-opened draft reads its priority back from these (#970). ...messagePriorityHeaders(options?.priority), + ...draftLabelProperty(options?.classification), }; // Images the HTML shows by cid: go into a multipart/related beside it @@ -4959,7 +4969,7 @@ export class JMAPClient implements IJMAPClient { } Object.assign(emailCreate, messagePriorityHeaders(options?.priority)); - Object.assign(emailCreate, customHeaderProperties(options?.headers)); + Object.assign(emailCreate, withLabelHeaders(customHeaderProperties(options?.headers), options?.labelHeaders)); // Images the HTML shows by cid: go into a multipart/related beside it, // or recipients don't see them (#1451). diff --git a/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts new file mode 100644 index 000000000..2762b7863 --- /dev/null +++ b/lib/outgoing/__tests__/classification.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; +import { usePolicyStore } from '@/stores/policy-store'; +import { customHeaderProperties, withLabelHeaders } from '../custom-headers'; +import { + automaticLabel, + beginLabelTakeover, + classifyOutgoing, + endLabelTakeover, + injectTakeoverLabel, + isLabelRequiredError, + labelSourceHeaders, + resetLabelTakeovers, + resolveLabel, +} from '../classification'; + +// The label stage of sending: which label goes out, the required label +// failing closed, plugin headers that can't override it, the policy of the +// sending account, and the headers a crypto takeover's raw send gets. + +const generic = LABEL_PRESETS[0]; +const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' }; +const required = { ...policy, defaultId: '', required: true }; +const label = (id: string) => generic.list.find((l) => l.id === id)!; + +afterEach(() => { + usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null }); + resetLabelTakeovers(); +}); + +describe('resolveLabel', () => { + it('takes the pick, then the original’s label, then the default', () => { + expect(resolveLabel(policy, 'public')?.id).toBe('public'); + expect(resolveLabel(policy, undefined, { 'SIO-Label': ' marking="CONFIDENTIAL"' })?.id).toBe('confidential'); + expect(resolveLabel(policy, undefined)?.id).toBe('internal'); + expect(resolveLabel(policy, null, { 'SIO-Label': ' marking="CONFIDENTIAL"' })).toBeNull(); + // A pick that isn't in this account's list counts as nothing picked. + expect(resolveLabel(policy, 'gone')?.id).toBe('internal'); + }); +}); + +describe('classifyOutgoing', () => { + it('fails closed when a label is required and there is none', () => { + let error: unknown; + try { classifyOutgoing(required, null, { subject: 'x' }); } catch (e) { error = e; } + expect(isLabelRequiredError(error)).toBe(true); + }); + + it('prefixes the subject and adds the headers', () => { + const out = classifyOutgoing(policy, label('confidential'), { subject: 'Re: Budget' }); + expect(out.subject).toBe('[Confidential] Re: Budget'); + expect(out.labelHeaders).toMatchObject({ 'header:Sensitivity:asText': 'Company-Confidential', 'header:X-Classification:asText': 'Confidential' }); + }); + + it('keeps a plugin from setting or dropping the label', () => { + const out = classifyOutgoing(policy, label('internal'), { + subject: 'Budget', + headers: { 'SIO-Label': 'marking="PUBLIC"', Sensitivity: 'Personal', 'x-classification': 'Public', 'X-Mailer-Note': 'ok' }, + }); + expect(out.headers).toEqual({ 'X-Mailer-Note': 'ok' }); + const props = withLabelHeaders(customHeaderProperties({ ...out.headers, 'SIO-Label': 'forged' }), out.labelHeaders); + expect(props['header:SIO-Label:asRaw']).toContain('INTERNAL'); + expect(Object.keys(props).filter((k) => k.toLowerCase().startsWith('header:sio-label'))).toHaveLength(1); + expect(props['header:X-Mailer-Note:asText']).toBe('ok'); + }); + + it('changes nothing while the organisation has no labels', () => { + const headers = { Sensitivity: 'Personal' }; + expect(classifyOutgoing({ ...policy, list: [] }, null, { subject: 'x', headers })).toEqual({ subject: 'x', headers }); + }); +}); + +describe('the policy of the sending account', () => { + it('quick reply and forward take the original’s label, and report a missing required one', () => { + usePolicyStore.setState({ + policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: required } }, + subject: { email: 'me@example.org', groups: [] }, + }); + expect(automaticLabel({ email: 'me@example.org' }, { 'SIO-Label': ' marking="PUBLIC"' })).toMatchObject({ missing: false, label: { id: 'public' } }); + expect(automaticLabel({ email: 'me@example.org' }, {})).toMatchObject({ missing: true, label: null }); + }); + + it('keeps only the headers a reply needs for its label', () => { + usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } } }); + expect(labelSourceHeaders({ 'SIO-Label': 'x', 'X-Classification': 'y', Received: 'z', Subject: 's' })).toEqual({ 'SIO-Label': 'x', 'X-Classification': 'y' }); + expect(labelSourceHeaders({ Received: 'z' })).toBeUndefined(); + }); +}); + +describe('crypto takeovers', () => { + const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`); + const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); + + it('add the label headers to the top-level header block, outside the signed part', () => { + const token = beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Budget'); + const out = decode(injectTakeoverLabel(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1')); + const [head, ...rest] = out.split('\r\n\r\n'); + expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"'); + expect(head).toContain('Sensitivity: Company-Confidential'); + expect(head).toContain('X-Classification: Confidential'); + expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n'); + endLabelTakeover(token); + }); + + it('replace label headers the plugin wrote, and leave 8-bit header bytes alone', () => { + beginLabelTakeover(policy, label('public'), 'id-1', 'Grüße'); + const input = raw('Subject: Grüße\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nX-Classification: Secret'); + const out = decode(injectTakeoverLabel(input, 'id-1')); + expect(out).not.toContain('FORGED'); + expect(out).not.toContain('bgcolor=red'); + expect(out).not.toContain('Secret'); + expect(out).toContain('Subject: Grüße'); + expect(out.match(/SIO-Label:/g)).toHaveLength(1); + }); + + it('touch nothing outside a takeover, or for another identity', () => { + const input = raw('Subject: x'); + expect(injectTakeoverLabel(input, 'id-1')).toBe(input); + const token = beginLabelTakeover(policy, label('public'), 'id-1', 'x'); + expect(injectTakeoverLabel(input, 'id-2')).toBe(input); + endLabelTakeover(token); + expect(injectTakeoverLabel(input, 'id-1')).toBe(input); + }); + + it('pick the right takeover by subject when two run at once', () => { + beginLabelTakeover(policy, label('public'), 'id-1', 'First'); + beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Second'); + const out = decode(injectTakeoverLabel(raw('Subject: [Confidential] Second'), 'id-1')); + expect(out).toContain('marking="CONFIDENTIAL"'); + }); +}); diff --git a/lib/outgoing/classification.ts b/lib/outgoing/classification.ts new file mode 100644 index 000000000..5f5d93756 --- /dev/null +++ b/lib/outgoing/classification.ts @@ -0,0 +1,241 @@ +/** + * The classification label stage of sending (enterprise pack item 3). + * Every surface that sends a user's mail runs it last, after plugins' + * onTransformOutgoingEmail, so a plugin can't drop or change the label: + * + * - the composer (inline, pop-out and Pro tab), with the user's pick; + * - quick reply, with the original's label, else the default; + * - a plugin's forward (api.email.forward), the same way. + * + * A required label fails closed: with none, the stage throws + * LabelRequiredError and nothing is sent. Labels come from the policy of + * the account and identity the mail goes out from, never just the active + * account. Automatic mail carries none: calendar invitations and RSVP + * replies, read receipts, List-Unsubscribe mails and phishing reports. + * + * S/MIME and PGP plugins take over the send (onComposeSend) and build raw + * MIME themselves: the composer hands them the prefixed subject, and the + * host adds the label headers to the top-level header block of the raw + * message in api.jmap.sendRaw / submitRaw while that takeover runs, keyed + * to its token (beginLabelTakeover), outside the signed or encrypted part. + */ + +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { subjectForAccount } from '@/lib/org/account-subjects'; +import { policyForSender } from '@/lib/org/signature-state'; +import { usePolicyStore } from '@/stores/policy-store'; +import { generateUUID } from '@/lib/utils'; +import { withoutLabelHeaders } from './custom-headers'; +import { + applyLabelPrefix, + labelHeaderLines, + labelHeaderProperties, + receivedLabel, + type LabelsPolicy, +} from '@/lib/classification/labels'; + +/** A send stopped because the organisation requires a label and none was picked. */ +export class LabelRequiredError extends Error { + constructor() { + super('A classification label is required'); + this.name = 'LabelRequiredError'; + } +} + +export function isLabelRequiredError(error: unknown): boolean { + return (error as { name?: string } | null)?.name === 'LabelRequiredError'; +} + +export interface LabelSender { + /** The From identity's address (a group's own picks the group's scopes). */ + email?: string; + /** The signed-in account (AccountEntry id) it goes out through; the active one when left out. */ + accountId?: string | null; +} + +/** The labels section that applies to mail from `sender`, read from the stores now. */ +export function labelsPolicyFor(sender: LabelSender): LabelsPolicy { + const policy = policyForSender(usePolicyStore.getState(), sender.email, subjectForAccount(sender.accountId)); + return policy.organisation?.labels ?? DEFAULT_ORGANISATION_POLICY.labels; +} + +/** + * The label a message goes out with. `choice`: the user's pick (an id), + * null for "no label", undefined when they picked nothing, which takes the + * original's label (a reply or forward of `originalHeaders`), else the + * default. A pick that isn't in the list (another account's, an old one) + * counts as nothing picked. + */ +export function resolveLabel( + policy: LabelsPolicy, + choice: string | null | undefined, + originalHeaders?: Record | null, +): ClassificationLabel | null { + if (policy.list.length === 0) return null; + if (choice === null) return null; + const picked = choice ? policy.list.find((label) => label.id === choice) : undefined; + if (picked) return picked; + const original = originalHeaders ? receivedLabel(originalHeaders, policy) : null; + if (original) return original; + return policy.list.find((label) => label.id === policy.defaultId) ?? null; +} + +/** Whether sending needs a label that `label` doesn't give. */ +export function labelMissing(policy: LabelsPolicy, label: ClassificationLabel | null): boolean { + return policy.required && policy.list.length > 0 && !label; +} + +export interface LabelledMessage { + subject: string; + /** The plugins' headers, without any the label writes. */ + headers?: Record; + /** The label's headers as JMAP create properties (SubmissionSendOptions.labelHeaders). */ + labelHeaders?: Record; +} + +/** + * The stage: the subject with the label's prefix (once), the plugins' + * headers without the label's names, and the label's headers. Throws + * LabelRequiredError when the policy requires a label and there is none. + */ +export function classifyOutgoing( + policy: LabelsPolicy, + label: ClassificationLabel | null, + message: { subject: string; headers?: Record }, +): LabelledMessage { + if (policy.list.length === 0) return { subject: message.subject, headers: message.headers }; + if (labelMissing(policy, label)) throw new LabelRequiredError(); + const reserved = policy.extraHeader ? [policy.extraHeader] : []; + return { + subject: applyLabelPrefix(message.subject, label, policy.list), + headers: withoutLabelHeaders(message.headers, reserved), + labelHeaders: label ? labelHeaderProperties(label, policy.extraHeader) : undefined, + }; +} + +/** The label of a quick reply or a plugin's forward: the original's, else the default. */ +export function automaticLabel( + sender: LabelSender, + originalHeaders?: Record | null, +): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } { + const policy = labelsPolicyFor(sender); + const label = resolveLabel(policy, undefined, originalHeaders); + return { policy, label, missing: labelMissing(policy, label) }; +} + +// ─── Crypto takeovers ───────────────────────────────────────── + +interface Takeover { + identityId: string; + subject: string; + lines: string[]; +} + +const takeovers = new Map(); + +/** + * Registers the label of a takeover about to run (onComposeSend). Raw + * sends during it from the same identity get the label's header lines. + * Returns the token; pass it to endLabelTakeover when the takeover ended. + */ +export function beginLabelTakeover( + policy: LabelsPolicy, + label: ClassificationLabel | null, + identityId: string, + subject: string, +): string | null { + if (!label) return null; + const token = generateUUID(); + takeovers.set(token, { identityId, subject, lines: labelHeaderLines(label, policy.extraHeader) }); + return token; +} + +export function endLabelTakeover(token: string | null): void { + if (token) takeovers.delete(token); +} + +const LABEL_LINE_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label']; + +/** The Subject header of a raw message's top-level block, unfolded and decoded enough to compare. */ +function rawSubject(head: string): string { + const m = /(?:^|\r?\n)Subject:([^\r\n]*(?:\r?\n[ \t][^\r\n]*)*)/i.exec(head); + return m ? m[1].replace(/\r?\n[ \t]+/g, ' ').trim() : ''; +} + +/** + * The takeover a raw send belongs to: the one running for `identityId`; + * when several are (two composers sending at once), the one whose subject + * is the message's. + */ +function takeoverFor(identityId: string, head: string): Takeover | null { + const candidates = [...takeovers.values()].filter((t) => t.identityId === identityId); + if (candidates.length <= 1) return candidates[0] ?? null; + const subject = rawSubject(head); + return candidates.find((t) => t.subject === subject) ?? candidates[0]; +} + +/** + * `bytes` (a raw RFC 5322 message) with the label's headers added to its + * top-level header block, when a labelled takeover is running for + * `identityId`; the same bytes otherwise. Label headers the plugin wrote + * itself are replaced. + */ +export function injectTakeoverLabel(bytes: Uint8Array, identityId: string): Uint8Array { + if (takeovers.size === 0) return bytes; + // The header block is ASCII (RFC 5322 §2.2); find its end without decoding the body. + let end = -1; + let breakLength = 0; + for (let i = 0; i < bytes.length - 1; i++) { + if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) { end = i; breakLength = 4; break; } + if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) { end = i; breakLength = 2; break; } + } + if (end < 0) return bytes; + // Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged. + let head = ''; + for (let i = 0; i < end; i++) head += String.fromCharCode(bytes[i]); + const takeover = takeoverFor(identityId, head); + if (!takeover) return bytes; + const eol = breakLength === 4 ? '\r\n' : '\n'; + // Drop the label headers already there (with their folded lines). + const kept: string[] = []; + let skipping = false; + for (const line of head.split(/\r?\n/)) { + if (/^[ \t]/.test(line)) { + if (!skipping) kept.push(line); + continue; + } + const name = line.slice(0, line.indexOf(':')).trim().toLowerCase(); + skipping = LABEL_LINE_NAMES.includes(name) || takeover.lines.some((l) => l.slice(0, l.indexOf(':')).toLowerCase() === name); + if (!skipping) kept.push(line); + } + const added = takeover.lines.map((line) => line.replace(/\r\n/g, eol)); + const text = [...kept, ...added].join(eol); + const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff); + const out = new Uint8Array(newHead.length + (bytes.length - end)); + out.set(newHead, 0); + out.set(bytes.subarray(end), newHead.length); + return out; +} + +/** For tests. */ +export function resetLabelTakeovers(): void { + takeovers.clear(); +} + +/** + * The original's headers a reply or forward needs for its label: SIO-Label + * and every extra header name any scope of the policy uses. Small enough + * to keep in a Pro tab's saved state. + */ +export function labelSourceHeaders(headers: Record | null | undefined): Record | undefined { + if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined; + const raw = usePolicyStore.getState().rawPolicy; + const names = new Set(['sio-label']); + const add = (name: string | undefined) => { if (name) names.add(name.toLowerCase()); }; + add(raw.organisation?.labels?.extraHeader); + for (const scope of raw.scopes ?? []) add(scope.policy.organisation?.labels?.extraHeader); + const out: Record = {}; + for (const [name, value] of Object.entries(headers)) if (names.has(name.toLowerCase())) out[name] = value; + return Object.keys(out).length > 0 ? out : undefined; +} diff --git a/lib/outgoing/composer-send-options.ts b/lib/outgoing/composer-send-options.ts new file mode 100644 index 000000000..61245e74e --- /dev/null +++ b/lib/outgoing/composer-send-options.ts @@ -0,0 +1,32 @@ +import type { MessagePriority } from '@/lib/message-priority'; + +/** What the composer's onSend hands its host beyond the message itself. */ +export interface ComposerSendExtras { + requestReadReceipt?: boolean; + requestDsn?: boolean; + requireTls?: boolean; + priority?: MessagePriority; + headers?: Record; + labelHeaders?: Record; + localAccountId?: string; + draftAccountId?: string; +} + +/** + * The options object of useEmailStore.sendEmail for a composer send. Every + * host of the composer (the mail page, a Pro compose tab) passes it whole, + * so none drops the read receipt, DSN, REQUIRETLS, priority, plugin headers + * or the classification label: the Pro tab used to send without them. + */ +export function composerSendOptions(data: ComposerSendExtras): ComposerSendExtras { + return { + requestReadReceipt: data.requestReadReceipt, + requestDsn: data.requestDsn, + requireTls: data.requireTls, + priority: data.priority, + headers: data.headers, + labelHeaders: data.labelHeaders, + localAccountId: data.localAccountId, + draftAccountId: data.draftAccountId, + }; +} diff --git a/lib/outgoing/custom-headers.ts b/lib/outgoing/custom-headers.ts index 0058d8aee..c1a4ca5d8 100644 --- a/lib/outgoing/custom-headers.ts +++ b/lib/outgoing/custom-headers.ts @@ -65,3 +65,53 @@ export function customHeaderProperties(headers: unknown): Record } return out; } + +/** + * The headers a classification label writes (lib/classification/labels.ts). + * While the organisation uses labels, plugins can't set them: the label + * stage drops them from the plugins' headers (withoutLabelHeaders), along + * with the admin's extra header, and withLabelHeaders below lets the + * label's own win over anything left. + */ +export const LABEL_HEADER_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label'] as const; + +/** `headers` (OutgoingEmail.headers) without the label headers and `extra` names. */ +export function withoutLabelHeaders( + headers: Record | undefined, + extra: readonly string[] = [], +): Record | undefined { + if (!headers) return headers; + const reserved = new Set([...LABEL_HEADER_NAMES, ...extra.map((name) => name.toLowerCase())]); + const out: Record = {}; + for (const [name, value] of Object.entries(headers)) { + if (reserved.has(name.toLowerCase())) { + debug.warn(`Outgoing header "${name}" from a plugin was not sent: the classification label sets it`); + continue; + } + out[name] = value; + } + return out; +} + +/** The name in a JMAP header property (`header:SIO-Label:asRaw` → `sio-label`). */ +function propertyHeaderName(property: string): string { + return property.split(':')[1]?.toLowerCase() ?? ''; +} + +/** + * Plugin header properties with the label's added on top: a plugin header + * of a name the label writes is dropped, since JMAP refuses a create that + * sets one header twice and the label must win. + */ +export function withLabelHeaders( + custom: Record, + label: Record | undefined, +): Record { + if (!label || Object.keys(label).length === 0) return custom; + const taken = new Set(Object.keys(label).map(propertyHeaderName)); + const out: Record = {}; + for (const [property, value] of Object.entries(custom)) { + if (!taken.has(propertyHeaderName(property))) out[property] = value; + } + return { ...out, ...label }; +} diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts index 09d2cbb53..37e828f43 100644 --- a/lib/plugin-sandbox/host-api-forward.ts +++ b/lib/plugin-sandbox/host-api-forward.ts @@ -11,7 +11,9 @@ // quick reply (external recipients, the organisation's disclaimer) and // other plugins' onBeforeEmailSend, and is held for the user's undo delay; // - the copy lands in Sent and the original is marked $forwarded, as after -// a forward from the composer. +// a forward from the composer; +// - it carries the original's classification label, else the default; when +// the organisation requires one and there is neither, nothing is sent. import type { EmailForwardResult, InstalledPlugin, OutgoingEmail } from '../plugin-types'; import type { Attachment, Email } from '../jmap/types'; @@ -24,6 +26,9 @@ import { emailHooks } from '../plugin-hooks'; import { announceSent } from '../plugin-after-send'; import { buildForwardSubject } from '../subject-prefix'; import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline'; +import { automaticLabel, classifyOutgoing } from '../outgoing/classification'; +import { whenPolicySettled } from '@/stores/policy-store'; +import { toast } from '@/stores/toast-store'; // Registers the organisation signature check and the disclaimer. import '@/lib/org/outgoing'; import { plainTextFromEmail } from '../plugin-projection'; @@ -139,6 +144,15 @@ export async function doEmailForward( const messages = await hostMessages(); const subject = buildForwardSubject(email.subject || text(messages, 'email_viewer.no_subject'), text(messages, 'email_composer.prefix.forward')); + // The classification label: a required one that isn't there stops the + // forward before anything is asked. + await whenPolicySettled(); + const label = automaticLabel({ email: identity.email }, email.headers); + if (label.missing) { + toast.error(text(messages, 'classification.forward_needs_label')); + return { sent: false, reason: 'stopped' }; + } + // Ask before the first forward to an address. const addresses = to.map((entry) => addressOf(entry).toLowerCase()); const account = approvalAccount(); @@ -198,12 +212,15 @@ export async function doEmailForward( }); } + // Last, after the plugins' checks: the label's prefix and headers. + const labelled = classifyOutgoing(label.policy, label.label, { subject }); + const delaySeconds = useSettingsStore.getState().sendDelaySeconds; const delayedUntil = delaySeconds > 0 && client.hasDelayedSend() ? new Date(Date.now() + delaySeconds * 1000).toISOString() : undefined; const result = await useEmailStore.getState().sendEmail( - client, to, subject, body, undefined, undefined, identity.id, identity.email, undefined, + client, to, labelled.subject, body, undefined, undefined, identity.id, identity.email, undefined, identity.name || undefined, undefined, attachments.map((a, i) => ({ blobId: blobIds[i], @@ -212,9 +229,10 @@ export async function doEmailForward( size: a.size, disposition: 'attachment' as const, })), - undefined, undefined, delayedUntil, + undefined, undefined, delayedUntil, undefined, + labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined, ); - announceSent(result, { to: addresses, cc: [], bcc: [], subject }); + announceSent(result, { to: addresses, cc: [], bcc: [], subject: labelled.subject }); try { await deps.markForwarded(emailId, sourceAccountId); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 70b4551e1..415c20679 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -3,6 +3,7 @@ // structured-cloneable data back to the iframe. import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types'; +import { injectTakeoverLabel } from '@/lib/outgoing/classification'; import { fetchConfig } from '@/hooks/use-config'; import { IS_LITE } from '../lite'; import { doAppGetSettings, doAppSearchSettings } from './host-api-app'; @@ -822,7 +823,10 @@ async function doJmapSendRaw( // ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view. const copy = new Uint8Array(view.byteLength); copy.set(view); - const blob = new Blob([copy.buffer], { type: 'message/rfc822' }); + // During a labelled takeover (S/MIME, PGP) the label's headers go into the + // top-level header block, outside what the plugin signed or encrypted. + const labelled = injectTakeoverLabel(copy, identityId); + const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return useEmailStore.getState().sendRawEmail( client, blob, @@ -856,7 +860,8 @@ async function doJmapSubmitRaw( const copy = new Uint8Array(view.byteLength); copy.set(view); - const blob = new Blob([copy.buffer], { type: 'message/rfc822' }); + const labelled = injectTakeoverLabel(copy, identityId); + const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return client.submitRawEmail( blob, diff --git a/locales/ar/common.json b/locales/ar/common.json index 27d6eec20..0c9257610 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -900,6 +900,17 @@ "link_off": "الرابط معطّل", "no_subject": "(بلا موضوع)" }, + "classification": { + "picker_label": "التصنيف الأمني", + "chip_label": "التصنيف الأمني: {name}", + "none": "بدون تصنيف أمني", + "pick_required": "اختر تصنيفًا أمنيًا", + "required_hint": "تشترط مؤسستك تصنيفًا أمنيًا.", + "required_toast": "اختر تصنيفًا أمنيًا قبل الإرسال.", + "quick_reply_needs_label": "يحتاج هذا الرد إلى تصنيف أمني. اختر واحدًا هنا، ثم أرسل.", + "forward_needs_label": "التصنيف الأمني مطلوب. أعد توجيه الرسالة من محرر الرسائل.", + "received_hint": "عيّنه برنامج البريد لدى المرسل. يمكن لأي شخص تعيين تصنيف أمني، لذا فهو لا يثبت شيئًا." + }, "email_composer": { "select_from_directory": "الاختيار من الدليل", "minimize": "تصغير", diff --git a/locales/ca/common.json b/locales/ca/common.json index 2919c2a14..ca972e093 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -900,6 +900,17 @@ "link_off": "enllaç desactivat", "no_subject": "(sense assumpte)" }, + "classification": { + "picker_label": "Classificació", + "chip_label": "Classificació: {name}", + "none": "Sense classificació", + "pick_required": "Trieu una classificació", + "required_hint": "La vostra organització exigeix una classificació.", + "required_toast": "Trieu una classificació abans d'enviar.", + "quick_reply_needs_label": "Aquesta resposta necessita una classificació. Trieu-ne una aquí i després envieu-la.", + "forward_needs_label": "Cal una classificació. Reenvieu el missatge des del redactor.", + "received_hint": "La defineix el programa de correu del remitent. Qualsevol pot definir una classificació, així que no demostra res." + }, "email_composer": { "select_from_directory": "Selecciona del directori", "minimize": "Minimitza", diff --git a/locales/cs/common.json b/locales/cs/common.json index 5179ab878..be09f8751 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -900,6 +900,17 @@ "link_off": "odkaz vypnut", "no_subject": "(bez předmětu)" }, + "classification": { + "picker_label": "Klasifikace", + "chip_label": "Klasifikace: {name}", + "none": "Bez klasifikace", + "pick_required": "Vyberte klasifikaci", + "required_hint": "Vaše organizace vyžaduje klasifikaci.", + "required_toast": "Před odesláním vyberte klasifikaci.", + "quick_reply_needs_label": "Tato odpověď potřebuje klasifikaci. Vyberte ji zde a pak odešlete.", + "forward_needs_label": "Klasifikace je povinná. Přepošlete zprávu z editoru zpráv.", + "received_hint": "Nastavil ji poštovní program odesílatele. Klasifikaci může nastavit kdokoli, takže nic nedokazuje." + }, "email_composer": { "select_from_directory": "Vybrat z adresáře", "minimize": "Minimalizovat", diff --git a/locales/da/common.json b/locales/da/common.json index 890c491e0..36cee6e0e 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -900,6 +900,17 @@ "link_off": "link slået fra", "no_subject": "(intet emne)" }, + "classification": { + "picker_label": "Klassificering", + "chip_label": "Klassificering: {name}", + "none": "Ingen klassificering", + "pick_required": "Vælg en klassificering", + "required_hint": "Din organisation kræver en klassificering.", + "required_toast": "Vælg en klassificering, før du sender.", + "quick_reply_needs_label": "Dette svar skal have en klassificering. Vælg en her, og send derefter.", + "forward_needs_label": "En klassificering er påkrævet. Videresend beskeden fra skrivevinduet.", + "received_hint": "Sat af afsenderens mailprogram. Alle kan sætte en klassificering, så den beviser intet." + }, "email_composer": { "select_from_directory": "Vælg fra adressefortegnelse", "minimize": "Minimer", diff --git a/locales/de/common.json b/locales/de/common.json index 607034982..28bcb9b39 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -900,6 +900,17 @@ "link_off": "Link deaktiviert", "no_subject": "(kein Betreff)" }, + "classification": { + "picker_label": "Einstufung", + "chip_label": "Einstufung: {name}", + "none": "Keine Einstufung", + "pick_required": "Einstufung wählen", + "required_hint": "Ihre Organisation verlangt eine Einstufung.", + "required_toast": "Wählen Sie vor dem Senden eine Einstufung.", + "quick_reply_needs_label": "Diese Antwort braucht eine Einstufung. Wählen Sie sie hier aus und senden Sie dann.", + "forward_needs_label": "Eine Einstufung ist vorgeschrieben. Leiten Sie die Nachricht im Editor weiter.", + "received_hint": "Vom E-Mail-Programm des Absenders gesetzt. Jeder kann eine Einstufung setzen, sie beweist also nichts." + }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", "minimize": "Minimieren", diff --git a/locales/en/common.json b/locales/en/common.json index 8601b2290..2a5cf213b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -900,6 +900,17 @@ "link_off": "link turned off", "no_subject": "(no subject)" }, + "classification": { + "picker_label": "Classification", + "chip_label": "Classification: {name}", + "none": "No label", + "pick_required": "Pick a label", + "required_hint": "Your organisation requires a label.", + "required_toast": "Pick a classification label before sending.", + "quick_reply_needs_label": "This reply needs a classification label. Pick one here, then send.", + "forward_needs_label": "A classification label is required. Forward the message from the composer.", + "received_hint": "Set by the sender's mail program. Anyone can set a label, so it proves nothing." + }, "email_composer": { "select_from_directory": "Select from directory", "minimize": "Minimize", diff --git a/locales/es/common.json b/locales/es/common.json index c560da5ae..b0e78b62d 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -900,6 +900,17 @@ "link_off": "enlace desactivado", "no_subject": "(sin asunto)" }, + "classification": { + "picker_label": "Clasificación", + "chip_label": "Clasificación: {name}", + "none": "Sin clasificación", + "pick_required": "Elige una clasificación", + "required_hint": "Tu organización exige una clasificación.", + "required_toast": "Elige una clasificación antes de enviar.", + "quick_reply_needs_label": "Esta respuesta necesita una clasificación. Elige una aquí y luego envía.", + "forward_needs_label": "Se requiere una clasificación. Reenvía el mensaje desde el redactor.", + "received_hint": "La pone el programa de correo del remitente. Cualquiera puede poner una clasificación, así que no demuestra nada." + }, "email_composer": { "select_from_directory": "Seleccionar del directorio", "minimize": "Minimizar", diff --git a/locales/et/common.json b/locales/et/common.json index 98c71938d..79616ddfc 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -900,6 +900,17 @@ "link_off": "link välja lülitatud", "no_subject": "(teema puudub)" }, + "classification": { + "picker_label": "Klassifikatsioon", + "chip_label": "Klassifikatsioon: {name}", + "none": "Klassifikatsioonita", + "pick_required": "Vali klassifikatsioon", + "required_hint": "Sinu organisatsioon nõuab klassifikatsiooni.", + "required_toast": "Vali enne saatmist klassifikatsioon.", + "quick_reply_needs_label": "See vastus vajab klassifikatsiooni. Vali see siin ja saada siis.", + "forward_needs_label": "Klassifikatsioon on nõutud. Saada kiri edasi kirjakoostajast.", + "received_hint": "Selle määras saatja meiliprogramm. Klassifikatsiooni võib määrata igaüks, seega ei tõesta see midagi." + }, "email_composer": { "select_from_directory": "Vali kataloogist", "minimize": "Minimeeri", diff --git a/locales/fa/common.json b/locales/fa/common.json index d7355b1bd..bcc3d9a61 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -900,6 +900,17 @@ "link_off": "پیوند غیرفعال شد", "no_subject": "(بدون موضوع)" }, + "classification": { + "picker_label": "طبقه‌بندی", + "chip_label": "طبقه‌بندی: {name}", + "none": "بدون طبقه‌بندی", + "pick_required": "انتخاب طبقه‌بندی", + "required_hint": "سازمان شما طبقه‌بندی را الزامی کرده است.", + "required_toast": "پیش از ارسال یک طبقه‌بندی انتخاب کنید.", + "quick_reply_needs_label": "این پاسخ به طبقه‌بندی نیاز دارد. همین‌جا یکی انتخاب کنید و سپس ارسال کنید.", + "forward_needs_label": "طبقه‌بندی الزامی است. پیام را از ویرایشگر ارسال مجدد کنید.", + "received_hint": "برنامه ایمیل فرستنده آن را تعیین کرده است. هر کسی می‌تواند طبقه‌بندی تعیین کند، پس چیزی را ثابت نمی‌کند." + }, "email_composer": { "select_from_directory": "انتخاب از فهرست", "minimize": "کوچک کردن", diff --git a/locales/fr/common.json b/locales/fr/common.json index 826061ff3..cd26905bf 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -900,6 +900,17 @@ "link_off": "lien désactivé", "no_subject": "(sans objet)" }, + "classification": { + "picker_label": "Classification", + "chip_label": "Classification : {name}", + "none": "Aucune classification", + "pick_required": "Choisir une classification", + "required_hint": "Votre organisation exige une classification.", + "required_toast": "Choisissez une classification avant d'envoyer.", + "quick_reply_needs_label": "Cette réponse nécessite une classification. Choisissez-en une ici, puis envoyez.", + "forward_needs_label": "Une classification est obligatoire. Transférez le message depuis l'éditeur.", + "received_hint": "Définie par le programme de messagerie de l'expéditeur. N'importe qui peut définir une classification, elle ne prouve donc rien." + }, "email_composer": { "select_from_directory": "Choisir dans l'annuaire", "minimize": "Réduire", diff --git a/locales/he/common.json b/locales/he/common.json index ffe99a9c6..05248cb69 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -865,6 +865,17 @@ "link_off": "הקישור מושבת", "no_subject": "(ללא נושא)" }, + "classification": { + "picker_label": "סיווג", + "chip_label": "סיווג: {name}", + "none": "ללא סיווג", + "pick_required": "בחירת סיווג", + "required_hint": "הארגון שלך מחייב סיווג.", + "required_toast": "יש לבחור סיווג לפני השליחה.", + "quick_reply_needs_label": "התשובה הזו צריכה סיווג. יש לבחור סיווג כאן ואז לשלוח.", + "forward_needs_label": "נדרש סיווג. יש להעביר את ההודעה מהעורך.", + "received_hint": "נקבע על ידי תוכנת הדואר של השולח. כל אחד יכול לקבוע סיווג, ולכן הוא לא מוכיח דבר." + }, "email_composer": { "select_from_directory": "בחירה מהספרייה", "minimize": "מזעור", diff --git a/locales/hu/common.json b/locales/hu/common.json index 58b5dd782..0a750cc2a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -900,6 +900,17 @@ "link_off": "link kikapcsolva", "no_subject": "(nincs tárgy)" }, + "classification": { + "picker_label": "Besorolás", + "chip_label": "Besorolás: {name}", + "none": "Nincs besorolás", + "pick_required": "Válassz besorolást", + "required_hint": "A szervezeted besorolást ír elő.", + "required_toast": "Küldés előtt válassz besorolást.", + "quick_reply_needs_label": "Ehhez a válaszhoz besorolás kell. Itt válassz egyet, aztán küldd el.", + "forward_needs_label": "A besorolás kötelező. Az üzenetet a szerkesztőből továbbítsd.", + "received_hint": "A feladó levelezőprogramja állította be. Bárki megadhat besorolást, így semmit sem bizonyít." + }, "email_composer": { "select_from_directory": "Kiválasztás a címtárból", "minimize": "Kis méret", diff --git a/locales/it/common.json b/locales/it/common.json index 74f69d9a6..fe8ad65c9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -900,6 +900,17 @@ "link_off": "link disattivato", "no_subject": "(nessun oggetto)" }, + "classification": { + "picker_label": "Classificazione", + "chip_label": "Classificazione: {name}", + "none": "Nessuna classificazione", + "pick_required": "Scegli una classificazione", + "required_hint": "La tua organizzazione richiede una classificazione.", + "required_toast": "Scegli una classificazione prima di inviare.", + "quick_reply_needs_label": "Questa risposta richiede una classificazione. Sceglila qui, poi invia.", + "forward_needs_label": "È richiesta una classificazione. Inoltra il messaggio dall'editor.", + "received_hint": "Impostata dal programma di posta del mittente. Chiunque può impostare una classificazione, quindi non prova nulla." + }, "email_composer": { "select_from_directory": "Seleziona dalla rubrica aziendale", "minimize": "Riduci a icona", diff --git a/locales/ja/common.json b/locales/ja/common.json index 1cee43a57..a2e8be841 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -900,6 +900,17 @@ "link_off": "リンクは無効です", "no_subject": "(件名なし)" }, + "classification": { + "picker_label": "機密区分", + "chip_label": "機密区分:{name}", + "none": "機密区分なし", + "pick_required": "機密区分を選択", + "required_hint": "組織では機密区分の指定が必要です。", + "required_toast": "送信する前に機密区分を選択してください。", + "quick_reply_needs_label": "この返信には機密区分が必要です。ここで選択してから送信してください。", + "forward_needs_label": "機密区分の指定が必要です。メール作成画面から転送してください。", + "received_hint": "送信者のメールプログラムが設定したものです。機密区分は誰でも設定できるため、何の証明にもなりません。" + }, "email_composer": { "select_from_directory": "ディレクトリから選択", "minimize": "最小化", diff --git a/locales/ko/common.json b/locales/ko/common.json index 18b20ec95..0b55f5ccc 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -900,6 +900,17 @@ "link_off": "링크 꺼짐", "no_subject": "(제목 없음)" }, + "classification": { + "picker_label": "보안 등급", + "chip_label": "보안 등급: {name}", + "none": "보안 등급 없음", + "pick_required": "보안 등급 선택", + "required_hint": "조직에서 보안 등급을 지정하도록 요구해요.", + "required_toast": "보내기 전에 보안 등급을 선택하세요.", + "quick_reply_needs_label": "이 답장에는 보안 등급이 필요해요. 여기서 선택한 다음 보내세요.", + "forward_needs_label": "보안 등급이 필요해요. 메일 쓰기 창에서 메시지를 전달하세요.", + "received_hint": "보낸 사람의 메일 프로그램이 지정한 값이에요. 누구나 보안 등급을 지정할 수 있어서 아무것도 증명하지 않아요." + }, "email_composer": { "select_from_directory": "디렉터리에서 선택", "minimize": "최소화", diff --git a/locales/lv/common.json b/locales/lv/common.json index 884078152..138a7a38e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -900,6 +900,17 @@ "link_off": "saite izslēgta", "no_subject": "(bez temata)" }, + "classification": { + "picker_label": "Klasifikācija", + "chip_label": "Klasifikācija: {name}", + "none": "Bez klasifikācijas", + "pick_required": "Izvēlieties klasifikāciju", + "required_hint": "Jūsu organizācija pieprasa klasifikāciju.", + "required_toast": "Pirms sūtīšanas izvēlieties klasifikāciju.", + "quick_reply_needs_label": "Šai atbildei vajadzīga klasifikācija. Izvēlieties to šeit un tad sūtiet.", + "forward_needs_label": "Klasifikācija ir obligāta. Pārsūtiet vēstuli no redaktora.", + "received_hint": "To iestatīja sūtītāja pasta programma. Klasifikāciju var iestatīt ikviens, tāpēc tā neko nepierāda." + }, "email_composer": { "select_from_directory": "Izvēlēties no kataloga", "minimize": "Minimizēt", diff --git a/locales/mn/common.json b/locales/mn/common.json index 372387f40..db5b2d2c9 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -900,6 +900,17 @@ "link_off": "холбоосыг идэвхгүй болгосон", "no_subject": "(сэдэв байхгүй)" }, + "classification": { + "picker_label": "Нууцын зэрэглэл", + "chip_label": "Нууцын зэрэглэл: {name}", + "none": "Зэрэглэлгүй", + "pick_required": "Зэрэглэл сонгох", + "required_hint": "Таны байгууллага нууцын зэрэглэл шаарддаг.", + "required_toast": "Илгээхээсээ өмнө нууцын зэрэглэл сонгоно уу.", + "quick_reply_needs_label": "Энэ хариунд нууцын зэрэглэл шаардлагатай. Энд сонгоод илгээнэ үү.", + "forward_needs_label": "Нууцын зэрэглэл заавал шаардлагатай. Захидлыг бичих цонхноос дамжуулна уу.", + "received_hint": "Илгээгчийн шуудангийн програм тохируулсан. Хэн ч зэрэглэл тохируулж болох тул энэ юуг ч нотлохгүй." + }, "email_composer": { "select_from_directory": "Лавлахаас сонгох", "minimize": "Багасгах", diff --git a/locales/nb/common.json b/locales/nb/common.json index cfbc36690..969527d93 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -900,6 +900,17 @@ "link_off": "lenke slått av", "no_subject": "(uten emne)" }, + "classification": { + "picker_label": "Klassifisering", + "chip_label": "Klassifisering: {name}", + "none": "Ingen klassifisering", + "pick_required": "Velg en klassifisering", + "required_hint": "Organisasjonen din krever en klassifisering.", + "required_toast": "Velg en klassifisering før du sender.", + "quick_reply_needs_label": "Dette svaret trenger en klassifisering. Velg en her, og send deretter.", + "forward_needs_label": "En klassifisering er påkrevd. Videresend meldingen fra meldingsvinduet.", + "received_hint": "Satt av avsenderens e-postprogram. Hvem som helst kan sette en klassifisering, så den beviser ingenting." + }, "email_composer": { "select_from_directory": "Velg fra katalog", "minimize": "Minimer", diff --git a/locales/nl/common.json b/locales/nl/common.json index da4c14853..1683d6ed0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -900,6 +900,17 @@ "link_off": "link uitgeschakeld", "no_subject": "(geen onderwerp)" }, + "classification": { + "picker_label": "Classificatie", + "chip_label": "Classificatie: {name}", + "none": "Geen classificatie", + "pick_required": "Kies een classificatie", + "required_hint": "Je organisatie vereist een classificatie.", + "required_toast": "Kies een classificatie voordat je verzendt.", + "quick_reply_needs_label": "Dit antwoord heeft een classificatie nodig. Kies er hier een en verzend het dan.", + "forward_needs_label": "Een classificatie is verplicht. Stuur het bericht door vanuit de opsteller.", + "received_hint": "Ingesteld door het e-mailprogramma van de afzender. Iedereen kan een classificatie instellen, dus het bewijst niets." + }, "email_composer": { "select_from_directory": "Kiezen uit adresboek van de organisatie", "minimize": "Minimaliseren", diff --git a/locales/pl/common.json b/locales/pl/common.json index dcd72b975..10cfd0bf2 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -900,6 +900,17 @@ "link_off": "link wyłączony", "no_subject": "(brak tematu)" }, + "classification": { + "picker_label": "Klasyfikacja", + "chip_label": "Klasyfikacja: {name}", + "none": "Bez klasyfikacji", + "pick_required": "Wybierz klasyfikację", + "required_hint": "Twoja organizacja wymaga klasyfikacji.", + "required_toast": "Przed wysłaniem wybierz klasyfikację.", + "quick_reply_needs_label": "Ta odpowiedź wymaga klasyfikacji. Wybierz ją tutaj, a potem wyślij.", + "forward_needs_label": "Klasyfikacja jest wymagana. Przekaż wiadomość dalej z edytora wiadomości.", + "received_hint": "Ustawiona przez program pocztowy nadawcy. Każdy może ustawić klasyfikację, więc niczego nie dowodzi." + }, "email_composer": { "select_from_directory": "Wybierz z katalogu", "minimize": "Minimalizuj", diff --git a/locales/pt/common.json b/locales/pt/common.json index ab73e5552..0b3535142 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -900,6 +900,17 @@ "link_off": "link desativado", "no_subject": "(sem assunto)" }, + "classification": { + "picker_label": "Classificação", + "chip_label": "Classificação: {name}", + "none": "Sem classificação", + "pick_required": "Escolha uma classificação", + "required_hint": "Sua organização exige uma classificação.", + "required_toast": "Escolha uma classificação antes de enviar.", + "quick_reply_needs_label": "Esta resposta precisa de uma classificação. Escolha uma aqui e depois envie.", + "forward_needs_label": "É obrigatória uma classificação. Encaminhe a mensagem pelo editor.", + "received_hint": "Definida pelo programa de e-mail do remetente. Qualquer pessoa pode definir uma classificação, então ela não prova nada." + }, "email_composer": { "select_from_directory": "Selecionar do diretório", "minimize": "Minimizar", diff --git a/locales/ro/common.json b/locales/ro/common.json index 4ed1e81aa..3a47dac2f 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -900,6 +900,17 @@ "link_off": "link dezactivat", "no_subject": "(fără subiect)" }, + "classification": { + "picker_label": "Clasificare", + "chip_label": "Clasificare: {name}", + "none": "Fără clasificare", + "pick_required": "Alegeți o clasificare", + "required_hint": "Organizația dvs. cere o clasificare.", + "required_toast": "Alegeți o clasificare înainte de trimitere.", + "quick_reply_needs_label": "Acest răspuns are nevoie de o clasificare. Alegeți una aici, apoi trimiteți.", + "forward_needs_label": "Este necesară o clasificare. Redirecționați mesajul din fereastra de redactare.", + "received_hint": "Setată de programul de e-mail al expeditorului. Oricine poate seta o clasificare, deci nu dovedește nimic." + }, "email_composer": { "select_from_directory": "Selectează din director", "minimize": "Minimizează", diff --git a/locales/ru/common.json b/locales/ru/common.json index 709312e93..4681b7d08 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -900,6 +900,17 @@ "link_off": "ссылка отключена", "no_subject": "(без темы)" }, + "classification": { + "picker_label": "Классификация", + "chip_label": "Классификация: {name}", + "none": "Без классификации", + "pick_required": "Выберите классификацию", + "required_hint": "Ваша организация требует указывать классификацию.", + "required_toast": "Выберите классификацию перед отправкой.", + "quick_reply_needs_label": "Для этого ответа нужна классификация. Выберите её здесь, затем отправьте.", + "forward_needs_label": "Классификация обязательна. Перешлите письмо из редактора.", + "received_hint": "Указана почтовой программой отправителя. Классификацию может указать кто угодно, поэтому она ничего не доказывает." + }, "email_composer": { "select_from_directory": "Выбрать из справочника", "minimize": "Свернуть", diff --git a/locales/sk/common.json b/locales/sk/common.json index cf962cd03..657982b41 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -900,6 +900,17 @@ "link_off": "odkaz vypnutý", "no_subject": "(bez predmetu)" }, + "classification": { + "picker_label": "Klasifikácia", + "chip_label": "Klasifikácia: {name}", + "none": "Bez klasifikácie", + "pick_required": "Vyberte klasifikáciu", + "required_hint": "Vaša organizácia vyžaduje klasifikáciu.", + "required_toast": "Pred odoslaním vyberte klasifikáciu.", + "quick_reply_needs_label": "Táto odpoveď potrebuje klasifikáciu. Vyberte ju tu a potom odošlite.", + "forward_needs_label": "Klasifikácia je povinná. Prepošlite správu z editora správ.", + "received_hint": "Nastavil ju poštový program odosielateľa. Klasifikáciu môže nastaviť ktokoľvek, takže nič nedokazuje." + }, "email_composer": { "select_from_directory": "Vybrať z adresára", "minimize": "Minimalizovať", diff --git a/locales/th/common.json b/locales/th/common.json index 22d5206a0..033f2873a 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -1837,6 +1837,17 @@ "link_off": "ปิดใช้งานลิงก์แล้ว", "no_subject": "(ไม่มีเรื่อง)" }, + "classification": { + "picker_label": "ระดับความลับ", + "chip_label": "ระดับความลับ: {name}", + "none": "ไม่มีระดับความลับ", + "pick_required": "เลือกระดับความลับ", + "required_hint": "องค์กรของคุณกำหนดให้ต้องระบุระดับความลับ", + "required_toast": "เลือกระดับความลับก่อนส่ง", + "quick_reply_needs_label": "การตอบกลับนี้ต้องมีระดับความลับ เลือกที่นี่ แล้วกดส่ง", + "forward_needs_label": "ต้องระบุระดับความลับ ส่งต่อข้อความจากหน้าต่างเขียนจดหมาย", + "received_hint": "ตั้งโดยโปรแกรมอีเมลของผู้ส่ง ใครก็ตั้งระดับความลับได้ จึงพิสูจน์อะไรไม่ได้" + }, "welcome": { "title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ", "tip_compose": "กด c เพื่อเขียนอีเมลใหม่", diff --git a/locales/tr/common.json b/locales/tr/common.json index e26a9939a..ec6f63919 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -900,6 +900,17 @@ "link_off": "bağlantı devre dışı", "no_subject": "(konu yok)" }, + "classification": { + "picker_label": "Sınıflandırma", + "chip_label": "Sınıflandırma: {name}", + "none": "Sınıflandırma yok", + "pick_required": "Sınıflandırma seçin", + "required_hint": "Kuruluşunuz bir sınıflandırma gerektiriyor.", + "required_toast": "Göndermeden önce bir sınıflandırma seçin.", + "quick_reply_needs_label": "Bu yanıt için sınıflandırma gerekiyor. Burada bir tane seçin, sonra gönderin.", + "forward_needs_label": "Sınıflandırma zorunlu. İletiyi ileti düzenleyiciden iletin.", + "received_hint": "Gönderenin posta programı tarafından ayarlandı. Herkes sınıflandırma ayarlayabilir, bu yüzden hiçbir şeyi kanıtlamaz." + }, "email_composer": { "select_from_directory": "Dizinden seç", "minimize": "Simge durumuna küçült", diff --git a/locales/uk/common.json b/locales/uk/common.json index 071819051..ebfafa9e6 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -900,6 +900,17 @@ "link_off": "посилання вимкнено", "no_subject": "(без теми)" }, + "classification": { + "picker_label": "Класифікація", + "chip_label": "Класифікація: {name}", + "none": "Без класифікації", + "pick_required": "Виберіть класифікацію", + "required_hint": "Ваша організація вимагає вказувати класифікацію.", + "required_toast": "Виберіть класифікацію перед надсиланням.", + "quick_reply_needs_label": "Для цієї відповіді потрібна класифікація. Виберіть її тут, а потім надішліть.", + "forward_needs_label": "Класифікація обов'язкова. Перешліть лист із редактора.", + "received_hint": "Вказано поштовою програмою відправника. Класифікацію може вказати будь-хто, тому вона нічого не доводить." + }, "email_composer": { "select_from_directory": "Вибрати з довідника", "minimize": "Згорнути", diff --git a/locales/vi/common.json b/locales/vi/common.json index ee8647a84..6e78956a7 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -900,6 +900,17 @@ "link_off": "đã tắt liên kết", "no_subject": "(không có tiêu đề)" }, + "classification": { + "picker_label": "Phân loại bảo mật", + "chip_label": "Phân loại bảo mật: {name}", + "none": "Chưa chọn phân loại", + "pick_required": "Chọn phân loại", + "required_hint": "Tổ chức của bạn yêu cầu chọn phân loại bảo mật.", + "required_toast": "Hãy chọn phân loại bảo mật trước khi gửi.", + "quick_reply_needs_label": "Thư trả lời này cần phân loại bảo mật. Hãy chọn ở đây rồi gửi.", + "forward_needs_label": "Cần có phân loại bảo mật. Hãy chuyển tiếp thư từ khung soạn thư.", + "received_hint": "Do chương trình thư của người gửi đặt. Ai cũng có thể đặt phân loại, nên nó không chứng minh được gì." + }, "email_composer": { "select_from_directory": "Chọn từ danh bạ", "minimize": "Thu nhỏ", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b8db29def..5bc86feff 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -900,6 +900,17 @@ "link_off": "連結已停用", "no_subject": "(無主題)" }, + "classification": { + "picker_label": "機密等級", + "chip_label": "機密等級:{name}", + "none": "未設定機密等級", + "pick_required": "選擇機密等級", + "required_hint": "您的組織要求設定機密等級。", + "required_toast": "傳送前請選擇機密等級。", + "quick_reply_needs_label": "此回覆需要設定機密等級。請在這裡選擇,然後傳送。", + "forward_needs_label": "必須設定機密等級。請從撰寫視窗轉寄此郵件。", + "received_hint": "由寄件者的郵件程式設定。任何人都能設定機密等級,因此無法證明任何事。" + }, "email_composer": { "select_from_directory": "從目錄中選取", "minimize": "最小化", diff --git a/locales/zh/common.json b/locales/zh/common.json index f54a372c2..85a21ed2f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -900,6 +900,17 @@ "link_off": "链接已停用", "no_subject": "(无主题)" }, + "classification": { + "picker_label": "密级", + "chip_label": "密级:{name}", + "none": "未设置密级", + "pick_required": "选择密级", + "required_hint": "您的组织要求设置密级。", + "required_toast": "发送前请选择密级。", + "quick_reply_needs_label": "此回复需要设置密级。请在这里选择,然后发送。", + "forward_needs_label": "必须设置密级。请从撰写窗口转发此邮件。", + "received_hint": "由发件人的邮件程序设置。任何人都可以设置密级,因此它不能证明任何事情。" + }, "email_composer": { "select_from_directory": "从目录中选择", "minimize": "最小化", diff --git a/stores/email-store.ts b/stores/email-store.ts index ed6876722..03db84e8e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -444,7 +444,7 @@ interface EmailStore { /** `ownerAccountId`: the JMAP account a link names for the message (#1351). */ fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; - sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; localAccountId?: string; draftAccountId?: string }) => Promise; + sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record; labelHeaders?: Record; localAccountId?: string; draftAccountId?: string }) => Promise; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise; /** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */ deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise; diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index d5d08a364..4283af9be 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -51,6 +51,8 @@ export interface ProReplyContext { quoteHeaderHtml?: string; quoteHeaderText?: string; quoteWrapInBlockquote?: boolean; + /** The original's label headers (lib/outgoing/classification.ts labelSourceHeaders). */ + headers?: Record; } export interface ProComposeTabData { From 91246205402a9af57fd59c763697843acf371ea4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:35:29 +0100 Subject: [PATCH 06/17] feat: show the classification label of received mail --- .../__tests__/received-label-chip.test.tsx | 88 +++++++++++++++++++ components/email/email-viewer.tsx | 5 ++ components/email/received-label-chip.tsx | 39 ++++++++ components/email/thread-conversation-view.tsx | 2 + 4 files changed, 134 insertions(+) create mode 100644 components/email/__tests__/received-label-chip.test.tsx create mode 100644 components/email/received-label-chip.tsx 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/email-viewer.tsx b/components/email/email-viewer.tsx index 0bc677025..177f44be5 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -134,6 +134,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"; @@ -860,6 +861,7 @@ export function MvExpandedConversationCard({ )} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -4922,6 +4924,7 @@ export function EmailViewer({ )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + {senderVerification && ( ; 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/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index da420637b..87788bf9e 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -16,6 +16,7 @@ import { BookingCalendarActions } from "./structured-mail/booking-calendar-actio import { useStructuredMail } from "./structured-mail/use-structured-mail"; import { attachmentIcon } from "./attachment-chips"; import { ExternalSenderChip } from "./external-sender-chip"; +import { ReceivedLabelChip } from "./received-label-chip"; import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { measureEmailFrameHeight } from "@/lib/email-frame-height"; @@ -397,6 +398,7 @@ function EmailCard({ {sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')} + {email.isScheduled && ( From 624f8dd3e13d4355090b8580bacf095e69d457a8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:55:48 +0100 Subject: [PATCH 07/17] fix: stop a quoted message's remote images from loading when the composer opens --- .../__tests__/paragraph-breaks-inert.test.ts | 31 +++++++++++++++++++ components/email/paragraph-breaks.ts | 10 ++++-- 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 components/email/__tests__/paragraph-breaks-inert.test.ts 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/paragraph-breaks.ts b/components/email/paragraph-breaks.ts index 21029900e..c86169b43 100644 --- a/components/email/paragraph-breaks.ts +++ b/components/email/paragraph-breaks.ts @@ -92,8 +92,12 @@ const MAIL_PARAGRAPH_MARGIN = "margin:1em 0;"; export function keepLoadedParagraphSpacing(html: string): string { if (!html.includes(" { 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. */ From 9c60140e494344faf3a425362ba20b581e3ee3fa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:01:20 +0100 Subject: [PATCH 08/17] fix: write the SIO-Label header without a doubled space after the colon --- lib/__tests__/classification-labels.test.ts | 3 ++- lib/classification/labels.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/classification-labels.test.ts b/lib/__tests__/classification-labels.test.ts index ffd41572c..25a7ebb6c 100644 --- a/lib/__tests__/classification-labels.test.ts +++ b/lib/__tests__/classification-labels.test.ts @@ -58,7 +58,8 @@ describe('SIO-Label', () => { it('writes Sensitivity and the extra header only when the label sets them', () => { expect(labelHeaderProperties(confidential, 'X-Classification')).toEqual({ - 'header:SIO-Label:asRaw': sioLabelValue(confidential), + // 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', }); diff --git a/lib/classification/labels.ts b/lib/classification/labels.ts index 85a34d03a..86d13007a 100644 --- a/lib/classification/labels.ts +++ b/lib/classification/labels.ts @@ -64,7 +64,7 @@ function percentEncode(value: string): string { /** The marking parameter(s): quoted when ASCII, RFC 2231 (with continuations when long) otherwise. */ function markingParameters(marking: string): string[] { const clean = marking.replace(/["\\\r\n]/g, ''); - // eslint-disable-next-line no-control-regex + if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`]; const encoded = percentEncode(clean); const MAX = 60; @@ -116,13 +116,14 @@ function labelHeaderPairs(label: ClassificationLabel, extraHeader: string): Arra export function labelHeaderProperties(label: ClassificationLabel, extraHeader: string): Record { const out: Record = {}; for (const [name, value, form] of labelHeaderPairs(label, extraHeader)) { - out[form === 'raw' ? `header:${name}:asRaw` : `header:${name}:asText`] = value; + // 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; } function encodedWord(value: string): string { - // eslint-disable-next-line no-control-regex + if (/^[\x20-\x7e]*$/.test(value)) return value; let binary = ''; for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte); From 45d1a0041d9964d1f23000a897863ef04d02f611 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:07:53 +0100 Subject: [PATCH 09/17] test: cover phishing reports, labels and the locked remote-content block against the test servers --- integration/tests/22-ent-security.spec.ts | 568 ++++++++++++++++++++++ 1 file changed, 568 insertions(+) create mode 100644 integration/tests/22-ent-security.spec.ts diff --git a/integration/tests/22-ent-security.spec.ts b/integration/tests/22-ent-security.spec.ts new file mode 100644 index 000000000..dd9e84b6b --- /dev/null +++ b/integration/tests/22-ent-security.spec.ts @@ -0,0 +1,568 @@ +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 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: 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: 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(); +}); From 128f61646e8ceae7d93d91054e7515d588463ab7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:42:46 +0100 Subject: [PATCH 10/17] fix: hold remote content per account and until scoped locks are known, also in previews and drafts --- .../remote-content-account-hold.test.tsx | 175 ++++++++++++++++++ components/email/email-composer.tsx | 6 +- components/email/email-viewer.tsx | 6 +- components/email/quoted-html.ts | 14 +- components/email/resizable-image.tsx | 11 +- components/email/thread-conversation-view.tsx | 17 +- components/files/eml-preview.tsx | 8 +- components/files/file-preview-modal.tsx | 8 +- hooks/use-third-party-mode.ts | 123 +++++++++--- lib/org/account-subjects.ts | 33 +++- stores/auth-store.ts | 1 + stores/policy-store.ts | 10 + 12 files changed, 357 insertions(+), 55 deletions(-) create mode 100644 components/email/__tests__/remote-content-account-hold.test.tsx 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/email-composer.tsx b/components/email/email-composer.tsx index bcae27e81..c1ad4145f 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -762,7 +762,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' @@ -3779,7 +3781,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 => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 177f44be5..2a7f66aef 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1317,9 +1317,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); 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/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)); @@ -125,13 +124,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); @@ -549,7 +548,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); diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index 0ff73b06e..8c3256491 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -7,7 +7,7 @@ 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. @@ -53,8 +53,10 @@ export function EmlPreview({ message, onOpenAttachment }: { onOpenAttachment?: (attachment: EmlAttachment) => void; }) { const t = useTranslations("email_viewer"); - // 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; const tFiles = useTranslations("files"); diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index b1bbc10c1..43b5d2695 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -17,7 +17,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza 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="", @@ -163,8 +163,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 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/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/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/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 }); }, From 3ef16f602e7699c5f1fc4dce034cdb1e09a0f1e1 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:48:01 +0100 Subject: [PATCH 11/17] fix: keep a message's label through Undo send and take over labels only from internal senders A sent message brought back by Undo, Cancel and edit or edit-as-new reopened with the default label, because only drafts carried the X-Bulwark-Label marker; it now falls back to the SIO-Label it went out with. A reply, quick reply or plugin forward takes over the original's label only when an internal sender set it and never one below the default, since anyone outside can write the header. The tests written for the earlier rule (any received label counts) now pass an internal sender, which is why their originals changed. --- .../composer-classification.test.tsx | 12 ++- components/email/email-composer.tsx | 16 +++- .../email/use-composer-classification.ts | 8 +- components/mail/mail-app.tsx | 14 +++- components/pro/pro-email-tab-body.tsx | 6 +- lib/__tests__/plugin-host-labels.test.ts | 19 ++++- lib/outgoing/__tests__/classification.test.ts | 47 +++++++++-- lib/outgoing/classification.ts | 79 ++++++++++++++++--- lib/plugin-sandbox/host-api-forward.ts | 2 +- stores/pro-tab-store.ts | 2 + 10 files changed, 170 insertions(+), 35 deletions(-) diff --git a/components/email/__tests__/composer-classification.test.tsx b/components/email/__tests__/composer-classification.test.tsx index 528cbbbf3..39fd0017e 100644 --- a/components/email/__tests__/composer-classification.test.tsx +++ b/components/email/__tests__/composer-classification.test.tsx @@ -198,7 +198,7 @@ describe('composer: classification label', () => { const onSend = renderComposer({ mode: 'reply', initialData: { ...DRAFT, mode: 'reply', subject: 'Re: [Confidential] Budget' }, - replyTo: { subject: '[Confidential] Budget', headers: { 'SIO-Label': ' marking="CONFIDENTIAL"; bgcolor=#d97706' } }, + 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(); @@ -206,6 +206,16 @@ describe('composer: classification label', () => { 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(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c1ad4145f..0173ba66c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -111,7 +111,7 @@ import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; -import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel } from "@/lib/outgoing/classification"; +import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; import { whenPolicySettled } from "@/stores/policy-store"; import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker"; import { useComposerClassification } from "./use-composer-classification"; @@ -392,6 +392,8 @@ interface EmailComposerProps { 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 }[]; }; } @@ -1121,10 +1123,16 @@ export function EmailComposer({ // 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, - originalHeaders: mode === 'compose' ? null : replyTo?.headers, + original: labelOriginal, initialChoice: initialData?.classification, }); const classificationRef = useRef(classification); @@ -3367,7 +3375,7 @@ export function EmailComposer({ { const labelSender = { email: currentIdentity?.email, accountId: composingAccountId }; const labelPolicy = labelsPolicyFor(labelSender); - const label = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers); + const label = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal); if (labelMissing(labelPolicy, label)) { setLabelInvalid(true); toast.error(tClassification('required_toast')); @@ -3637,7 +3645,7 @@ export function EmailComposer({ const sealed = lastStage(finalHtmlBody, finalBody); // The label as it stands now, from the policy of the sending account. const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId }); - const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, mode === 'compose' ? null : replyTo?.headers); + const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal); // A crypto plugin builds the MIME itself: it gets the labelled // subject, and its raw send gets the label's headers (keyed to the token). const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject }); diff --git a/components/email/use-composer-classification.ts b/components/email/use-composer-classification.ts index 82b9522f3..86850bb19 100644 --- a/components/email/use-composer-classification.ts +++ b/components/email/use-composer-classification.ts @@ -3,7 +3,7 @@ import { useMemo, useState } from 'react'; import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; import { useAccountSubjects } from '@/lib/org/account-subjects'; -import { labelsPolicyFor, resolveLabel, labelMissing } from '@/lib/outgoing/classification'; +import { labelsPolicyFor, resolveLabel, labelMissing, type OriginalMessage } from '@/lib/outgoing/classification'; import type { LabelsPolicy } from '@/lib/classification/labels'; import { usePolicyStore } from '@/stores/policy-store'; @@ -29,8 +29,8 @@ export interface ComposerClassification { export function useComposerClassification(input: { accountId: string | null | undefined; senderEmail: string | undefined; - /** The original's headers, for a reply or forward. */ - originalHeaders?: Record | null; + /** The original of a reply or forward. */ + original?: OriginalMessage | null; initialChoice?: string | null; }): ComposerClassification { const policyState = usePolicyStore((s) => s.policy); @@ -43,7 +43,7 @@ export function useComposerClassification(input: { // eslint-disable-next-line react-hooks/exhaustive-deps [input.senderEmail, input.accountId, policyState, rawPolicy, subjects], ); - const label = useMemo(() => resolveLabel(policy, choice, input.originalHeaders), [policy, choice, input.originalHeaders]); + const label = useMemo(() => resolveLabel(policy, choice, input.original), [policy, choice, input.original]); return { policy, label, diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bc4c7e8c9..99ecfcba8 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -122,8 +122,7 @@ import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { isFilePreviewable } from "@/lib/file-preview"; import { prepareQuickReply, type QuickReplyQuestion } from "@/lib/outgoing/quick-reply"; import { composerSendOptions } from "@/lib/outgoing/composer-send-options"; -import { automaticLabel, classifyOutgoing, labelSourceHeaders } from "@/lib/outgoing/classification"; -import { draftLabelChoice } from "@/lib/classification/labels"; +import { 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"; @@ -2501,7 +2500,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { inReplyTo: draft.inReplyTo ?? undefined, references: draft.references ?? undefined, priority: messagePriorityFromHeaders(draft.headers), - classification: draftLabelChoice(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). @@ -2854,6 +2855,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { subject: email.subject, attachments: [payload.attachment], headers: labelSourceHeaders(email.headers), + originalFrom: email.from, }, }); setComposerMode('forward'); @@ -4096,7 +4098,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // default. A required label with neither: the full composer, with the // typed text, to pick one. Nothing is asked or sent before that. await whenPolicySettled(); - const quickLabel = automaticLabel({ email: headerFromEmail }, selectedEmail.headers); + 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); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 5f5f34344..e336ba877 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -24,8 +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 } from '@/lib/outgoing/classification'; -import { draftLabelChoice } from '@/lib/classification/labels'; +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'; @@ -291,6 +290,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, subject: email.subject, attachments: [payload.attachment], headers: labelSourceHeaders(email.headers), + originalFrom: email.from, }, sourceEmailId: email.id, sourceEmailSource: writeSource, @@ -471,7 +471,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // composer without its files and the next save stripped them. attachments: email.attachments, priority: messagePriorityFromHeaders(email.headers), - classification: draftLabelChoice(email.headers), + 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/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts index f643ea0ed..414128714 100644 --- a/lib/__tests__/plugin-host-labels.test.ts +++ b/lib/__tests__/plugin-host-labels.test.ts @@ -36,9 +36,9 @@ 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) => ({ +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: 'billing@supplier.example' }], to: [{ email: 'ann@example.com' }], subject: 'Invoice', + 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; @@ -53,6 +53,7 @@ describe('labels on the plugin paths', () => { let sendEmail: ReturnType; let sendRawEmail: ReturnType; let headers: Record = {}; + let sender = 'colleague@example.com'; beforeEach(() => { resetPluginRateLimits(); @@ -62,7 +63,7 @@ describe('labels on the plugin paths', () => { localStorage.setItem('plugin-forward-approvals', '{}'); client = { getAccountId: vi.fn(() => 'own'), - getEmail: vi.fn(async () => original(headers)), + getEmail: vi.fn(async () => original(headers, sender)), getMailboxes: vi.fn(async () => []), resolveSendAccountId: vi.fn(async () => 'own'), copyBlobs: vi.fn(async () => ({})), @@ -76,6 +77,7 @@ describe('labels on the plugin paths', () => { 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(() => { @@ -84,6 +86,7 @@ describe('labels on the plugin paths', () => { usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY }); resetLabelTakeovers(); headers = {}; + sender = 'colleague@example.com'; vi.clearAllMocks(); }); @@ -103,6 +106,16 @@ describe('labels on the plugin paths', () => { 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' }); diff --git a/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts index 2762b7863..7e470eabf 100644 --- a/lib/outgoing/__tests__/classification.test.ts +++ b/lib/outgoing/__tests__/classification.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from '@/stores/auth-store'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { LABEL_PRESETS } from '@/lib/classification/presets'; @@ -13,6 +14,7 @@ import { isLabelRequiredError, labelSourceHeaders, resetLabelTakeovers, + reopenedLabelChoice, resolveLabel, } from '../classification'; @@ -25,17 +27,50 @@ const policy = { list: generic.list, defaultId: 'internal', required: false, ext 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); resetLabelTakeovers(); }); +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, { 'SIO-Label': ' marking="CONFIDENTIAL"' })?.id).toBe('confidential'); + expect(resolveLabel(policy, undefined, fromColleague('CONFIDENTIAL'))?.id).toBe('confidential'); expect(resolveLabel(policy, undefined)?.id).toBe('internal'); - expect(resolveLabel(policy, null, { 'SIO-Label': ' marking="CONFIDENTIAL"' })).toBeNull(); + 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'); }); @@ -78,8 +113,10 @@ describe('the policy of the sending account', () => { policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: required } }, subject: { email: 'me@example.org', groups: [] }, }); - expect(automaticLabel({ email: 'me@example.org' }, { 'SIO-Label': ' marking="PUBLIC"' })).toMatchObject({ missing: false, label: { id: 'public' } }); - expect(automaticLabel({ email: 'me@example.org' }, {})).toMatchObject({ missing: true, label: null }); + 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', () => { diff --git a/lib/outgoing/classification.ts b/lib/outgoing/classification.ts index 5f5d93756..f93e3f529 100644 --- a/lib/outgoing/classification.ts +++ b/lib/outgoing/classification.ts @@ -24,6 +24,8 @@ 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 { generateUUID } from '@/lib/utils'; import { withoutLabelHeaders } from './custom-headers'; @@ -31,6 +33,7 @@ import { applyLabelPrefix, labelHeaderLines, labelHeaderProperties, + draftLabelChoice, receivedLabel, type LabelsPolicy, } from '@/lib/classification/labels'; @@ -60,25 +63,81 @@ export function labelsPolicyFor(sender: LabelSender): LabelsPolicy { 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 (a reply or forward of `originalHeaders`), else the - * default. A pick that isn't in the list (another account's, an old one) - * counts as nothing picked. + * 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, - originalHeaders?: Record | null, + 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; - const original = originalHeaders ? receivedLabel(originalHeaders, policy) : null; - if (original) return original; - return policy.list.find((label) => label.id === policy.defaultId) ?? null; + 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. */ @@ -114,13 +173,13 @@ export function classifyOutgoing( }; } -/** The label of a quick reply or a plugin's forward: the original's, else the default. */ +/** The label of a quick reply or a plugin's forward: the original's (originalLabel), else the default. */ export function automaticLabel( sender: LabelSender, - originalHeaders?: Record | null, + original?: OriginalMessage | null, ): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } { const policy = labelsPolicyFor(sender); - const label = resolveLabel(policy, undefined, originalHeaders); + const label = resolveLabel(policy, undefined, original); return { policy, label, missing: labelMissing(policy, label) }; } diff --git a/lib/plugin-sandbox/host-api-forward.ts b/lib/plugin-sandbox/host-api-forward.ts index 37e828f43..98ae6c4ef 100644 --- a/lib/plugin-sandbox/host-api-forward.ts +++ b/lib/plugin-sandbox/host-api-forward.ts @@ -147,7 +147,7 @@ export async function doEmailForward( // The classification label: a required one that isn't there stops the // forward before anything is asked. await whenPolicySettled(); - const label = automaticLabel({ email: identity.email }, email.headers); + 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' }; diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts index 4283af9be..ecc393c05 100644 --- a/stores/pro-tab-store.ts +++ b/stores/pro-tab-store.ts @@ -53,6 +53,8 @@ export interface ProReplyContext { 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 { From 85deb006a8821cc3dbe355edf4217364e9a798ba Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:52:44 +0100 Subject: [PATCH 12/17] fix: keep plugin raw sends from dropping or forging the classification label Raw sends (api.jmap.sendRaw and submitRaw) now always lose label headers a plugin wrote while the organisation uses labels. A crypto takeover is matched to its raw send by a labelToken the composer puts in the onComposeSend request (plugins that don't pass it back are matched by identity, and refused when two takeovers are open), not by comparing subjects. A raw send outside a takeover gets the default label, or is refused when a label is required. The extra header is folded on the raw path. The takeover code moves to lib/outgoing/classification-raw.ts; the old test that let a raw send outside a takeover through unlabelled now expects the forged header to be removed. --- components/email/email-composer.tsx | 9 +- lib/__tests__/plugin-host-labels.test.ts | 24 +++- lib/classification/labels.ts | 50 +++++-- .../__tests__/classification-raw.test.ts | 121 ++++++++++++++++ lib/outgoing/__tests__/classification.test.ts | 50 +------ lib/outgoing/classification-raw.ts | 134 ++++++++++++++++++ lib/outgoing/classification.ts | 107 +------------- lib/plugin-sandbox/host-api.ts | 16 ++- 8 files changed, 331 insertions(+), 180 deletions(-) create mode 100644 lib/outgoing/__tests__/classification-raw.test.ts create mode 100644 lib/outgoing/classification-raw.ts diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 0173ba66c..9864a3cce 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -111,7 +111,8 @@ import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; -import { beginLabelTakeover, classifyOutgoing, endLabelTakeover, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; +import { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification"; +import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw"; import { whenPolicySettled } from "@/stores/policy-store"; import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker"; import { useComposerClassification } from "./use-composer-classification"; @@ -3647,9 +3648,12 @@ export function EmailComposer({ const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId }); const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal); // A crypto plugin builds the MIME itself: it gets the labelled - // subject, and its raw send gets the label's headers (keyed to the token). + // subject, and its raw send gets the label's headers, matched by the + // token it can pass back (lib/outgoing/classification-raw.ts). const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject }); + const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentity?.id ?? ''); const composeSendRequest = { + labelToken: labelToken ?? undefined, to: toAddresses.map(r => formatRecipient(r.name, r.email)), cc: ccAddresses.map(r => formatRecipient(r.name, r.email)), bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)), @@ -3669,7 +3673,6 @@ export function EmailComposer({ ...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })), ], }; - const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentityParts.rawId ?? currentIdentity?.id ?? '', takeoverLabelled.subject); let pluginOutcome: Awaited>; try { pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest); diff --git a/lib/__tests__/plugin-host-labels.test.ts b/lib/__tests__/plugin-host-labels.test.ts index 414128714..18bc81c2d 100644 --- a/lib/__tests__/plugin-host-labels.test.ts +++ b/lib/__tests__/plugin-host-labels.test.ts @@ -13,7 +13,7 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { LABEL_PRESETS } from '@/lib/classification/presets'; import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog'; import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; -import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification'; +import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification-raw'; // Classification labels on the plugin paths: api.email.forward carries the // original's label (else the default) and refuses when a required one is @@ -123,18 +123,28 @@ describe('labels on the plugin paths', () => { expect(toast.error).toHaveBeenCalled(); }); - it('puts the label into a raw send during a labelled takeover only', async () => { + it('puts the takeover’s label into its raw sends and takes forged ones out of every raw send', async () => { useLabels({}); - const raw = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\n\r\nENCRYPTED\r\n'); - await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {}); + const forged = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\nSIO-Label: marking="PUBLIC"\r\n\r\nENCRYPTED\r\n'); + // Outside a takeover, no default label: nothing stamped, the forged header gone. + await call(SMIME, 'jmap.sendRaw', forged, 'id-a', {}); expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label'); - const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a', '[Confidential] Hi'); - await call(SMIME, 'jmap.sendRaw', raw, 'id-a', {}); - await call(SMIME, 'jmap.submitRaw', raw, 'id-a', {}); + const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a')!; + await call(SMIME, 'jmap.sendRaw', forged, 'id-a', { labelToken: token }); + await call(SMIME, 'jmap.submitRaw', forged, 'id-a', { labelToken: token }); endLabelTakeover(token); const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text(); expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/); expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"'); }); + + it('refuses a raw send outside the composer when a label is required', async () => { + useLabels({ required: true }); + const bytes = new TextEncoder().encode('Subject: Hi\r\n\r\nX\r\n'); + await expect(call(SMIME, 'jmap.sendRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/); + await expect(call(SMIME, 'jmap.submitRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/); + expect(sendRawEmail).not.toHaveBeenCalled(); + expect(client.submitRawEmail).not.toHaveBeenCalled(); + }); }); diff --git a/lib/classification/labels.ts b/lib/classification/labels.ts index 86d13007a..c814bcae2 100644 --- a/lib/classification/labels.ts +++ b/lib/classification/labels.ts @@ -64,7 +64,7 @@ function percentEncode(value: string): string { /** The marking parameter(s): quoted when ASCII, RFC 2231 (with continuations when long) otherwise. */ function markingParameters(marking: string): string[] { const clean = marking.replace(/["\\\r\n]/g, ''); - + if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`]; const encoded = percentEncode(clean); const MAX = 60; @@ -122,18 +122,50 @@ export function labelHeaderProperties(label: ClassificationLabel, extraHeader: s return out; } -function encodedWord(value: string): string { - - if (/^[\x20-\x7e]*$/.test(value)) return value; - let binary = ''; - for (const byte of new TextEncoder().encode(value)) binary += String.fromCharCode(byte); - return `=?UTF-8?B?${btoa(binary)}?=`; +/** + * A header value for raw MIME, folded under 78 columns: ASCII as it is, + * folded between words; anything else as RFC 2047 encoded words of at + * most 75 characters each, split between characters. + */ +function foldedValue(name: string, value: string): string { + if (/^[\x20-\x7e]*$/.test(value)) { + let line = name.length + 1; + let out = ''; + for (const word of value.split(' ')) { + if (out && line + 1 + word.length > 78) { + out += `\r\n ${word}`; + line = 1 + word.length; + } else { + out += ` ${word}`; + line += 1 + word.length; + } + } + return out; + } + const encoder = new TextEncoder(); + const words: string[] = []; + let chunk: number[] = []; + const flush = () => { + if (chunk.length === 0) return; + words.push(`=?UTF-8?B?${btoa(String.fromCharCode(...chunk))}?=`); + chunk = []; + }; + // 45 bytes are 60 base64 characters: 72 with "=?UTF-8?B?" and "?=", + // which fits a folded line. The first word shares its line with the name. + const firstLimit = Math.max(3, Math.min(45, Math.floor((78 - name.length - 2 - 12) / 4) * 3)); + for (const char of value) { + const bytes = encoder.encode(char); + if (chunk.length + bytes.length > (words.length === 0 ? firstLimit : 45)) flush(); + chunk.push(...bytes); + } + flush(); + return ` ${words.join('\r\n ')}`; } -/** The label's headers as raw header lines (CRLF-free each), for raw MIME. */ +/** The label's headers as raw header lines (folded with CRLF and a space), for raw MIME. */ export function labelHeaderLines(label: ClassificationLabel, extraHeader: string): string[] { return labelHeaderPairs(label, extraHeader).map(([name, value, form]) => - form === 'raw' ? `${name}:${value}` : `${name}: ${encodedWord(value)}`); + form === 'raw' ? `${name}:${value}` : `${name}:${foldedValue(name, value)}`); } /** The draft marker property: the label id, or `none` for no label. */ 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 index 7e470eabf..0a215bc4a 100644 --- a/lib/outgoing/__tests__/classification.test.ts +++ b/lib/outgoing/__tests__/classification.test.ts @@ -7,20 +7,16 @@ import { usePolicyStore } from '@/stores/policy-store'; import { customHeaderProperties, withLabelHeaders } from '../custom-headers'; import { automaticLabel, - beginLabelTakeover, classifyOutgoing, - endLabelTakeover, - injectTakeoverLabel, isLabelRequiredError, labelSourceHeaders, - resetLabelTakeovers, reopenedLabelChoice, resolveLabel, } from '../classification'; // The label stage of sending: which label goes out, the required label // failing closed, plugin headers that can't override it, the policy of the -// sending account, and the headers a crypto takeover's raw send gets. +// sending account (raw sends: classification-raw.test.ts). const generic = LABEL_PRESETS[0]; const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' }; @@ -37,7 +33,6 @@ beforeEach(() => { afterEach(() => { usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null }); useAuthStore.setState({ identities: [] } as never); - resetLabelTakeovers(); }); describe('the label of the original', () => { @@ -125,46 +120,3 @@ describe('the policy of the sending account', () => { expect(labelSourceHeaders({ Received: 'z' })).toBeUndefined(); }); }); - -describe('crypto takeovers', () => { - const raw = (head: string) => new TextEncoder().encode(`${head}\r\n\r\nContent-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n`); - const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes); - - it('add the label headers to the top-level header block, outside the signed part', () => { - const token = beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Budget'); - const out = decode(injectTakeoverLabel(raw('From: a@example.org\r\nSubject: [Confidential] Budget'), 'id-1')); - const [head, ...rest] = out.split('\r\n\r\n'); - expect(head).toContain('SIO-Label: marking="CONFIDENTIAL"'); - expect(head).toContain('Sensitivity: Company-Confidential'); - expect(head).toContain('X-Classification: Confidential'); - expect(rest.join('\r\n\r\n')).toBe('Content-Type: application/pkcs7-mime\r\n\r\nSIGNED-BYTES\r\n'); - endLabelTakeover(token); - }); - - it('replace label headers the plugin wrote, and leave 8-bit header bytes alone', () => { - beginLabelTakeover(policy, label('public'), 'id-1', 'Grüße'); - const input = raw('Subject: Grüße\r\nSIO-Label: marking="FORGED";\r\n bgcolor=red\r\nX-Classification: Secret'); - const out = decode(injectTakeoverLabel(input, 'id-1')); - expect(out).not.toContain('FORGED'); - expect(out).not.toContain('bgcolor=red'); - expect(out).not.toContain('Secret'); - expect(out).toContain('Subject: Grüße'); - expect(out.match(/SIO-Label:/g)).toHaveLength(1); - }); - - it('touch nothing outside a takeover, or for another identity', () => { - const input = raw('Subject: x'); - expect(injectTakeoverLabel(input, 'id-1')).toBe(input); - const token = beginLabelTakeover(policy, label('public'), 'id-1', 'x'); - expect(injectTakeoverLabel(input, 'id-2')).toBe(input); - endLabelTakeover(token); - expect(injectTakeoverLabel(input, 'id-1')).toBe(input); - }); - - it('pick the right takeover by subject when two run at once', () => { - beginLabelTakeover(policy, label('public'), 'id-1', 'First'); - beginLabelTakeover(policy, label('confidential'), 'id-1', '[Confidential] Second'); - const out = decode(injectTakeoverLabel(raw('Subject: [Confidential] Second'), 'id-1')); - expect(out).toContain('marking="CONFIDENTIAL"'); - }); -}); 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 index f93e3f529..ec3b89e10 100644 --- a/lib/outgoing/classification.ts +++ b/lib/outgoing/classification.ts @@ -14,10 +14,8 @@ * replies, read receipts, List-Unsubscribe mails and phishing reports. * * S/MIME and PGP plugins take over the send (onComposeSend) and build raw - * MIME themselves: the composer hands them the prefixed subject, and the - * host adds the label headers to the top-level header block of the raw - * message in api.jmap.sendRaw / submitRaw while that takeover runs, keyed - * to its token (beginLabelTakeover), outside the signed or encrypted part. + * MIME themselves: the composer hands them the prefixed subject, and + * lib/outgoing/classification-raw.ts puts the label into what they send. */ import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; @@ -27,11 +25,9 @@ import { policyForSender } from '@/lib/org/signature-state'; import { activeExternalMailContext, contextInternalDomains, externalSenders, isOtherAccount } from '@/lib/external-mail'; import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities'; import { usePolicyStore } from '@/stores/policy-store'; -import { generateUUID } from '@/lib/utils'; import { withoutLabelHeaders } from './custom-headers'; import { applyLabelPrefix, - labelHeaderLines, labelHeaderProperties, draftLabelChoice, receivedLabel, @@ -183,105 +179,6 @@ export function automaticLabel( return { policy, label, missing: labelMissing(policy, label) }; } -// ─── Crypto takeovers ───────────────────────────────────────── - -interface Takeover { - identityId: string; - subject: string; - lines: string[]; -} - -const takeovers = new Map(); - -/** - * Registers the label of a takeover about to run (onComposeSend). Raw - * sends during it from the same identity get the label's header lines. - * Returns the token; pass it to endLabelTakeover when the takeover ended. - */ -export function beginLabelTakeover( - policy: LabelsPolicy, - label: ClassificationLabel | null, - identityId: string, - subject: string, -): string | null { - if (!label) return null; - const token = generateUUID(); - takeovers.set(token, { identityId, subject, lines: labelHeaderLines(label, policy.extraHeader) }); - return token; -} - -export function endLabelTakeover(token: string | null): void { - if (token) takeovers.delete(token); -} - -const LABEL_LINE_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label']; - -/** The Subject header of a raw message's top-level block, unfolded and decoded enough to compare. */ -function rawSubject(head: string): string { - const m = /(?:^|\r?\n)Subject:([^\r\n]*(?:\r?\n[ \t][^\r\n]*)*)/i.exec(head); - return m ? m[1].replace(/\r?\n[ \t]+/g, ' ').trim() : ''; -} - -/** - * The takeover a raw send belongs to: the one running for `identityId`; - * when several are (two composers sending at once), the one whose subject - * is the message's. - */ -function takeoverFor(identityId: string, head: string): Takeover | null { - const candidates = [...takeovers.values()].filter((t) => t.identityId === identityId); - if (candidates.length <= 1) return candidates[0] ?? null; - const subject = rawSubject(head); - return candidates.find((t) => t.subject === subject) ?? candidates[0]; -} - -/** - * `bytes` (a raw RFC 5322 message) with the label's headers added to its - * top-level header block, when a labelled takeover is running for - * `identityId`; the same bytes otherwise. Label headers the plugin wrote - * itself are replaced. - */ -export function injectTakeoverLabel(bytes: Uint8Array, identityId: string): Uint8Array { - if (takeovers.size === 0) return bytes; - // The header block is ASCII (RFC 5322 §2.2); find its end without decoding the body. - let end = -1; - let breakLength = 0; - for (let i = 0; i < bytes.length - 1; i++) { - if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) { end = i; breakLength = 4; break; } - if (bytes[i] === 0x0a && bytes[i + 1] === 0x0a) { end = i; breakLength = 2; break; } - } - if (end < 0) return bytes; - // Byte for byte, so 8-bit header bytes (a raw UTF-8 subject) come back unchanged. - let head = ''; - for (let i = 0; i < end; i++) head += String.fromCharCode(bytes[i]); - const takeover = takeoverFor(identityId, head); - if (!takeover) return bytes; - const eol = breakLength === 4 ? '\r\n' : '\n'; - // Drop the label headers already there (with their folded lines). - const kept: string[] = []; - let skipping = false; - for (const line of head.split(/\r?\n/)) { - if (/^[ \t]/.test(line)) { - if (!skipping) kept.push(line); - continue; - } - const name = line.slice(0, line.indexOf(':')).trim().toLowerCase(); - skipping = LABEL_LINE_NAMES.includes(name) || takeover.lines.some((l) => l.slice(0, l.indexOf(':')).toLowerCase() === name); - if (!skipping) kept.push(line); - } - const added = takeover.lines.map((line) => line.replace(/\r\n/g, eol)); - const text = [...kept, ...added].join(eol); - const newHead = Uint8Array.from(text, (ch) => ch.charCodeAt(0) & 0xff); - const out = new Uint8Array(newHead.length + (bytes.length - end)); - out.set(newHead, 0); - out.set(bytes.subarray(end), newHead.length); - return out; -} - -/** For tests. */ -export function resetLabelTakeovers(): void { - takeovers.clear(); -} - /** * The original's headers a reply or forward needs for its label: SIO-Label * and every extra header name any scope of the policy uses. Small enough diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 415c20679..4b727dc50 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -3,7 +3,7 @@ // structured-cloneable data back to the iframe. import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types'; -import { injectTakeoverLabel } from '@/lib/outgoing/classification'; +import { labelRawSend } from '@/lib/outgoing/classification-raw'; import { fetchConfig } from '@/hooks/use-config'; import { IS_LITE } from '../lite'; import { doAppGetSettings, doAppSearchSettings } from './host-api-app'; @@ -801,6 +801,8 @@ async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown): interface JmapSubmitRawOptions { delayedUntil?: string; envelopeRecipients?: string[]; + /** The `labelToken` of the onComposeSend request this send completes. */ + labelToken?: string; } /** @@ -823,9 +825,9 @@ async function doJmapSendRaw( // ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view. const copy = new Uint8Array(view.byteLength); copy.set(view); - // During a labelled takeover (S/MIME, PGP) the label's headers go into the - // top-level header block, outside what the plugin signed or encrypted. - const labelled = injectTakeoverLabel(copy, identityId); + // The classification label goes into the top-level header block, outside + // what the plugin signed or encrypted (lib/outgoing/classification-raw.ts). + const labelled = labelRawSend(copy, identityId, opts?.labelToken); const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return useEmailStore.getState().sendRawEmail( client, @@ -860,7 +862,7 @@ async function doJmapSubmitRaw( const copy = new Uint8Array(view.byteLength); copy.set(view); - const labelled = injectTakeoverLabel(copy, identityId); + const labelled = labelRawSend(copy, identityId, opts?.labelToken); const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' }); return client.submitRawEmail( @@ -2045,12 +2047,12 @@ export async function dispatchApiCall( case 'jmap.sendRaw': return doJmapSendRaw( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, - args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, + args[2] as JmapSubmitRawOptions | undefined, ); case 'jmap.submitRaw': return doJmapSubmitRaw( args[0] as ArrayBuffer | ArrayBufferView, args[1] as string, - args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined, + args[2] as JmapSubmitRawOptions | undefined, ); case 'jmap.importRaw': return doJmapImportRaw( args[0] as ArrayBuffer | ArrayBufferView, From eb8fb84867cbd1de1c99e54dae31934314f9e844 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 06:54:50 +0100 Subject: [PATCH 13/17] fix: let admins name msip_labels as the extra label header and refuse names the server won't take The labels-section test's expected error text changes with the message, which now says why a name is refused. --- .../admin/org/__tests__/labels-section.test.tsx | 2 +- components/admin/org/sections/labels-section.tsx | 2 +- .../organisation-policy-enterprise.test.ts | 9 +++++++++ lib/admin/organisation-policy.ts | 16 ++++++++++++---- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/components/admin/org/__tests__/labels-section.test.tsx b/components/admin/org/__tests__/labels-section.test.tsx index 1c7a3a07e..017bcca83 100644 --- a/components/admin/org/__tests__/labels-section.test.tsx +++ b/components/admin/org/__tests__/labels-section.test.tsx @@ -97,7 +97,7 @@ describe('Classification section', () => { 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('not a header Bulwark writes itself'))).toBe(true); + 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'); diff --git a/components/admin/org/sections/labels-section.tsx b/components/admin/org/sections/labels-section.tsx index dc4573a15..1886b712b 100644 --- a/components/admin/org/sections/labels-section.tsx +++ b/components/admin/org/sections/labels-section.tsx @@ -302,7 +302,7 @@ export function LabelsSection({ draft }: { draft: OrgPolicyDraft }) { /> {headerInvalid && (

    - Letters, digits and dashes only, and not a header Bulwark writes itself. + A header name has no spaces or colons. Bulwark writes this one itself, or the mail server won't take it.

    )}
    diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts index 9c5810dcd..1ca0ebe06 100644 --- a/lib/admin/__tests__/organisation-policy-enterprise.test.ts +++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts @@ -59,6 +59,15 @@ 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); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index e9aa61c8a..be91f9cb1 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -280,14 +280,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', 'x-bulwark-label', '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; @@ -367,17 +370,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) && !RESERVED_HEADERS.has(v.toLowerCase()); + 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 { From 13c57414a2f6a0e85773bb5f47b8824a0ed1aeb8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:07:30 +0100 Subject: [PATCH 14/17] fix: report phishing per account from every view and keep a reported message's links off everywhere - Pro tabs stamp a message of another login or a shared account with where it came from, so the report and the move use its account. - A selection across accounts names every address its reports go to, and Undo says how many messages came back. - A message the list doesn't hold runs the plugins' spam hooks too, and every open view shows a reported message as reported at once. - A reported message hides its unsubscribe banner, and links are off in its attachment previews. - Search results and All mail don't offer reporting the user's own sent mail; the Mountain View selection bar offers it. - The focus goes back to the button that opened the menu. - ARF names the delivered-to address (or leaves it out), says binary for bare line breaks and long lines, and refuses a message bigger than the server's upload limit before downloading it. The phishing tests that compared one address now compare the list of addresses, and the ARF builder test passes the encoding, not a flag. --- .../__tests__/phishing-report-flow.test.tsx | 50 +++++++++++- .../__tests__/phishing-report-menus.test.tsx | 80 ++++++++++++++++++- components/email/email-viewer.tsx | 9 ++- components/email/mv-list-toolbar.tsx | 14 ++++ components/email/phishing-report-host.tsx | 7 +- components/email/thread-conversation-view.tsx | 5 +- components/files/eml-preview.tsx | 10 ++- components/files/file-preview-modal.tsx | 20 +++-- components/mail/mail-app.tsx | 9 ++- components/pro/pro-email-tab-body.tsx | 23 +++++- hooks/use-phishing-report.ts | 54 ++++++++++--- lib/__tests__/phishing-report-arf.test.ts | 34 +++++++- lib/phishing-report-arf.ts | 60 +++++++++++--- lib/phishing-report-flow.ts | 31 +++++-- lib/phishing-report.ts | 13 ++- stores/phishing-report-store.ts | 36 ++++++++- 16 files changed, 395 insertions(+), 60 deletions(-) diff --git a/components/email/__tests__/phishing-report-flow.test.tsx b/components/email/__tests__/phishing-report-flow.test.tsx index 9d1b9f4e6..29ad5bd98 100644 --- a/components/email/__tests__/phishing-report-flow.test.tsx +++ b/components/email/__tests__/phishing-report-flow.test.tsx @@ -34,6 +34,7 @@ const hoisted = vi.hoisted(() => { }; 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), @@ -49,7 +50,10 @@ vi.mock('@/stores/auth-store', () => { return { useAuthStore: hook }; }); vi.mock('@/stores/email-store', () => ({ - useEmailStore: { getState: () => hoisted.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 }), })); @@ -67,6 +71,7 @@ beforeEach(() => { setAddress('soc@example.org'); }); afterEach(() => { + usePhishingReportStore.setState({ reported: {} }); cleanup(); usePolicyStore.setState({ policy: DEFAULT_POLICY }); useToastStore.setState({ toasts: [] }); @@ -142,6 +147,49 @@ describe('Report phishing', () => { 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')); diff --git a/components/email/__tests__/phishing-report-menus.test.tsx b/components/email/__tests__/phishing-report-menus.test.tsx index ec858683f..8902a076b 100644 --- a/components/email/__tests__/phishing-report-menus.test.tsx +++ b/components/email/__tests__/phishing-report-menus.test.tsx @@ -5,6 +5,11 @@ 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 @@ -13,6 +18,7 @@ import { usePhishingReportStore } from '@/stores/phishing-report-store'; 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() }), @@ -32,6 +38,7 @@ const email = { 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) { @@ -59,7 +66,76 @@ function rowMenu(role: string, props: Partial { cleanup(); usePolicyStore.setState({ policy: DEFAULT_POLICY }); - usePhishingReportStore.setState({ pending: null, busy: false, opener: null }); + 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, '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', () => { @@ -72,7 +148,7 @@ describe('the row menu', () => { setAddress('soc@example.org'); rowMenu('inbox'); fireEvent.click(screen.getByTestId('ctx-report-phishing')); - expect(usePhishingReportStore.getState().pending).toEqual({ emails: [email], address: 'soc@example.org' }); + expect(usePhishingReportStore.getState().pending).toEqual({ emails: [email], addresses: ['soc@example.org'] }); }); it('shows up when the policy arrives after the menu opened', () => { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 2a7f66aef..0152f448b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -33,7 +33,8 @@ import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-m 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 { isPhishingMarked, turnOffLinks } from "@/lib/phishing-links"; +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"; @@ -2655,7 +2656,7 @@ export function EmailViewer({ // Reported as phishing ($phishing): a banner, its links turned off and // no cards (a phishing mail's "booking" is the lure). - const phishingMarked = isPhishingMarked(email); + const phishingMarked = usePhishingMarked(email); const tPhishing = useTranslations('phishing_report'); // Booking, parcel and order cards of the message itself (never of an @@ -3754,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 || ''); @@ -6155,6 +6157,7 @@ export function EmailViewer({ onClose={() => setInAppPreview(null)} onDownload={downloadInAppPreview} getFileContent={getInAppPreviewContent} + linksOff={phishingMarked} /> )} 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 && ( { - const { address, reported, failed, notMoved, moved } = outcome; + 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: reported.length })); + if (back > 0) toast.info(t('undone', { count: back })); else toast.error(t('undo_failed')); }); }, @@ -112,7 +113,7 @@ export function PhishingReportHost() {

    {t('confirm_title')}

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

    diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 154bc54ea..3f9d55ba7 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -70,7 +70,8 @@ import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surf import { useRemoteContentBlockedByOrg, useRemoteContentChecks } from "@/hooks/use-third-party-mode"; import { ConversationHeaderActions, type ConversationActions } from "./conversation-header-actions"; import { PhishingMarkBanner } from "./phishing-mark-banner"; -import { isPhishingMarked, turnOffLinks } from "@/lib/phishing-links"; +import { turnOffLinks } from "@/lib/phishing-links"; +import { usePhishingMarked } from "@/hooks/use-phishing-report"; export type ConversationAttachmentHandler = ( blobId: string, name: string, type: string | undefined, source: EmailBlobContext, @@ -576,7 +577,7 @@ export function ConversationEmailBody({ const source = blobSource ?? fallbackSource; const { client: blobClient, accountId } = source; // Reported as phishing: a banner, links off, no cards (as in the reader). - const phishingMarked = isPhishingMarked(email); + const phishingMarked = usePhishingMarked(email); const tPhishing = useTranslations('phishing_report'); // Booking, parcel and order cards; only an expanded message mounts this body. const structuredCards = useStructuredMail(phishingMarked ? null : email, source); diff --git a/components/files/eml-preview.tsx b/components/files/eml-preview.tsx index 8c3256491..d91a37911 100644 --- a/components/files/eml-preview.tsx +++ b/components/files/eml-preview.tsx @@ -3,6 +3,7 @@ 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"; @@ -48,11 +49,14 @@ 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"); + 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. @@ -63,7 +67,9 @@ export function EmlPreview({ message, onOpenAttachment }: { // 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, tPhishing("link_off")) + : 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 43b5d2695..5a0d2d24a 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -14,6 +14,7 @@ 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 } 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"; @@ -29,8 +30,10 @@ import { useRemoteContentHeldBackAnywhere } 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, linksOffLabel?: string): string { + const clean = sanitizeEmailBodyForIframe(html, blockRemote).html; + // An attachment of a message reported as phishing: its links are off too. + const body = linksOffLabel ? turnOffLinks(clean, linksOffLabel) : 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,10 @@ 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 linksOffLabel = linksOff ? tPhishing("link_off") : undefined; const [content, setContent] = useState(null); const [objectUrl, setObjectUrl] = useState(null); const [loading, setLoading] = useState(true); @@ -227,7 +234,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, linksOffLabel)); } 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. @@ -271,7 +278,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi URL.revokeObjectURL(revokeUrl); } }; - }, [getFileContent, name, blockRemote]); + }, [getFileContent, name, blockRemote, linksOffLabel]); const openEmlAttachment = (att: EmlAttachment) => { if (!att.content) return; @@ -310,6 +317,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi onClose={() => setNestedPreview(null)} onDownload={downloadNested} getFileContent={nestedContent} + linksOff={linksOff} /> ); } @@ -354,7 +362,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi )} {!loading && !error && fileType === "eml" && emlContent && ( - + )} {!loading && !error && fileType === "image" && objectUrl && ( diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 99ecfcba8..cecc35ceb 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -122,6 +122,7 @@ 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"; @@ -302,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); @@ -3969,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( @@ -4029,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( @@ -5798,6 +5800,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onClose={() => setPreviewAttachment(null)} onDownload={handlePreviewAttachmentDownload} getFileContent={getPreviewAttachmentContent} + linksOff={previewAttachment.linksOff} /> )} diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index e336ba877..7a9537028 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -68,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, @@ -202,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); }) @@ -215,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; diff --git a/hooks/use-phishing-report.ts b/hooks/use-phishing-report.ts index 0fedf87f0..4cdd5801f 100644 --- a/hooks/use-phishing-report.ts +++ b/hooks/use-phishing-report.ts @@ -1,18 +1,42 @@ 'use client'; import { useCallback, useMemo } from 'react'; -import type { Email } from '@/lib/jmap/types'; +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 reportable = emails.filter((email) => isReportable(email, mailboxRole) && phishingSetupForEmail(email)); - if (reportable.length === 0) return; - usePhishingReportStore.getState().ask(reportable, phishingSetupForEmail(reportable[0])!.address); + 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); } /** @@ -24,13 +48,25 @@ export function usePhishingReport(emails: ReadonlyArray 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 reportable = useMemo( - () => list.filter((email) => isReportable(email, mailboxRole) && !!phishingSetupForEmail(email)), + 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, policy, rawPolicy, subjects], + [list, mailboxRole, mailboxes, reported, policy, rawPolicy, subjects], ); - const report = useCallback(() => requestPhishingReport(reportable, mailboxRole), [reportable, mailboxRole]); - return { available: reportable.length > 0, count: reportable.length, report }; + 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/lib/__tests__/phishing-report-arf.test.ts b/lib/__tests__/phishing-report-arf.test.ts index 0e0a811ba..007d5ede3 100644 --- a/lib/__tests__/phishing-report-arf.test.ts +++ b/lib/__tests__/phishing-report-arf.test.ts @@ -1,7 +1,7 @@ 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 } from '../phishing-report-arf'; +import { buildArfParts, buildArfReportBlob, rawEncoding } from '../phishing-report-arf'; import { sendPhishingReport } from '../phishing-report'; // The ARF report (RFC 5965): multipart/report with report-type @@ -10,7 +10,7 @@ import { sendPhishingReport } from '../phishing-report'; // as message/rfc822; submitted raw without a Sent copy. const email = { - id: 'e1', blobId: 'blob-1', size: 40, subject: 'Konto gesperrt – jetzt prüfen', receivedAt: '2026-10-01T10:00:00Z', + 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; @@ -18,7 +18,7 @@ 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', eightBit: false, now: new Date('2026-11-01T08:00:00Z') }); + 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'); @@ -40,8 +40,23 @@ describe('buildArfParts', () => { 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', eightBit: true }); + const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '8bit' }); expect(head).toMatch(/Content-Transfer-Encoding: 8bit\r\n\r\n$/); }); }); @@ -49,6 +64,7 @@ describe('buildArfParts', () => { 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', @@ -73,3 +89,13 @@ describe('an ARF report', () => { 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/phishing-report-arf.ts b/lib/phishing-report-arf.ts index e2424778f..7950a5885 100644 --- a/lib/phishing-report-arf.ts +++ b/lib/phishing-report-arf.ts @@ -14,6 +14,7 @@ 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 { @@ -34,16 +35,20 @@ function senderDomain(email: Pick): string | null { 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. `eightBit`: the original has bytes - * outside 7-bit ASCII (message/rfc822 may not be base64-encoded, RFC 2046 - * §5.2.1, so its part says 8bit then). + * 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; - eightBit: boolean; + encoding: RawEncoding; + recipient?: string; now?: Date; }): ArfParts { const { email, reporter } = input; @@ -63,7 +68,7 @@ export function buildArfParts(input: { `User-Agent: Bulwark/${appVersion()}`, 'Version: 1', ...(arrival && !Number.isNaN(arrival.getTime()) ? [`Arrival-Date: ${rfc5322Date(arrival)}`] : []), - `Original-Rcpt-To: ${fromEmail}`, + ...(input.recipient ? [`Original-Rcpt-To: ${headerAtom(input.recipient)}`] : []), ...(reportedDomain ? [`Reported-Domain: ${reportedDomain}`] : []), ].join('\r\n'); @@ -92,7 +97,7 @@ export function buildArfParts(input: { `--${boundary}`, 'Content-Type: message/rfc822', 'Content-Disposition: attachment; filename="reported-message.eml"', - `Content-Transfer-Encoding: ${input.eightBit ? '8bit' : '7bit'}`, + `Content-Transfer-Encoding: ${input.encoding}`, '', '', ].join('\r\n'); @@ -100,18 +105,51 @@ export function buildArfParts(input: { return { head, tail }; } -function hasEightBit(bytes: Uint8Array): boolean { - for (let i = 0; i < bytes.length; i++) if (bytes[i] > 0x7f) return true; - return false; +/** + * 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, eightBit: hasEightBit(original) }); + 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; - return new Blob([parts.head, original, endsWithBreak ? parts.tail.slice(2) : parts.tail], { type: 'message/rfc822' }); + 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 index b894c525a..e5abf4be0 100644 --- a/lib/phishing-report-flow.ts +++ b/lib/phishing-report-flow.ts @@ -16,6 +16,8 @@ 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, @@ -39,8 +41,8 @@ interface MovedReport { } export interface PhishingReportOutcome { - /** The address the reports went to (the first message's, when they differ). */ - address: string; + /** 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. */ @@ -70,7 +72,7 @@ function firstMailboxId(email: Email): string | null { export async function reportEmailsAsPhishing(emails: Email[]): Promise { const active = useAuthStore.getState().client; if (!active) throw new Error('Not signed in'); - const outcome: PhishingReportOutcome = { address: '', reported: [], failed: [], notMoved: [], moved: [] }; + const outcome: PhishingReportOutcome = { addresses: [], reported: [], failed: [], notMoved: [], moved: [] }; let buildArf: ((input: ArfInput) => Promise) | undefined; for (const email of emails) { @@ -79,7 +81,7 @@ export async function reportEmailsAsPhishing(emails: Email[]): Promise 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) { @@ -123,11 +134,19 @@ export async function undoPhishingReports(outcome: PhishingReportOutcome): Promi let back = 0; for (const { email, via } of outcome.moved) { try { + let done = false; if (via === 'store') { - if (await useEmailStore.getState().undoSpam(active, email.id, emailKeyFor(email), { clearKeywords: [PHISHING_KEYWORD] })) back++; + 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); diff --git a/lib/phishing-report.ts b/lib/phishing-report.ts index ecc5029ef..add09c241 100644 --- a/lib/phishing-report.ts +++ b/lib/phishing-report.ts @@ -46,10 +46,19 @@ export function phishingReportSetup(policy: SettingsPolicy | null | undefined): /** 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. */ -export function isReportable(email: Pick, mailboxRole?: string | null): boolean { +/** + * 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; diff --git a/stores/phishing-report-store.ts b/stores/phishing-report-store.ts index 8f086c4ef..0096237da 100644 --- a/stores/phishing-report-store.ts +++ b/stores/phishing-report-store.ts @@ -8,24 +8,44 @@ import type { Email } from '@/lib/jmap/types'; * sends the reports and shows the outcome. */ interface PhishingReportState { - pending: { emails: Email[]; address: string } | null; + /** 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; - ask: (emails: Email[], address: string) => void; + /** + * 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, - ask: (emails, address) => { + reported: {}, + ask: (emails, addresses) => { if (get().busy || emails.length === 0) return; const active = typeof document !== 'undefined' ? document.activeElement : null; - set({ pending: { emails, address }, opener: active instanceof HTMLElement ? active : null }); + set({ pending: { emails, addresses }, opener: openerOf(active) }); }, close: () => { const { opener } = get(); @@ -33,4 +53,12 @@ export const usePhishingReportStore = create()((set, get) = 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 }; + }), })); From f0c0bbe0d2dc3b717495b15ad3a8d7800f2ac5af Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:08:45 +0100 Subject: [PATCH 15/17] test: cover another account's labels, removing trusted senders under the lock and the address field without timing --- .../phishing-report-section.test.tsx | 5 ++-- .../remote-content-lock-gaps.test.tsx | 11 +++++++++ lib/outgoing/__tests__/classification.test.ts | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/components/admin/org/__tests__/phishing-report-section.test.tsx b/components/admin/org/__tests__/phishing-report-section.test.tsx index e93781489..4f4c47e92 100644 --- a/components/admin/org/__tests__/phishing-report-section.test.tsx +++ b/components/admin/org/__tests__/phishing-report-section.test.tsx @@ -55,10 +55,11 @@ describe('Phishing reports section', () => { await renderSection(); const field = screen.getByTestId('org-phishing-address'); fireEvent.change(field, { target: { value: 'security@' } }); - expect(screen.getByRole('alert').textContent).toContain("isn't an email address"); + // 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.queryByRole('alert')).toBeNull(); + 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' }); 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/lib/outgoing/__tests__/classification.test.ts b/lib/outgoing/__tests__/classification.test.ts index 0a215bc4a..7a277d10d 100644 --- a/lib/outgoing/__tests__/classification.test.ts +++ b/lib/outgoing/__tests__/classification.test.ts @@ -1,5 +1,6 @@ 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'; @@ -10,6 +11,7 @@ import { classifyOutgoing, isLabelRequiredError, labelSourceHeaders, + labelsPolicyFor, reopenedLabelChoice, resolveLabel, } from '../classification'; @@ -120,3 +122,24 @@ describe('the policy of the sending account', () => { 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); + }); +}); From 1bb2a85de42c11844347784369db0f1f06282627 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 07:14:10 +0100 Subject: [PATCH 16/17] fix: translate the turned-off link note and tidy the label and phishing texts The VS-NfD warning test now expects "BSI-approved products", the corrected wording. --- .../admin/org/__tests__/labels-section.test.tsx | 2 +- components/admin/org/sections/labels-section.tsx | 4 ++-- .../org/sections/phishing-report-section.tsx | 5 ++--- .../__tests__/phishing-report-menus.test.tsx | 2 +- components/email/classification-label-picker.tsx | 2 +- components/email/email-viewer.tsx | 4 ++-- components/email/thread-conversation-view.tsx | 2 +- components/files/eml-preview.tsx | 2 +- components/files/file-preview-modal.tsx | 15 +++++++++------ lib/__tests__/phishing-report.test.ts | 2 +- lib/classification/labels.ts | 2 +- lib/phishing-links.ts | 14 ++++++++++---- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 3 ++- locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 3 ++- locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/vi/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + 42 files changed, 64 insertions(+), 26 deletions(-) diff --git a/components/admin/org/__tests__/labels-section.test.tsx b/components/admin/org/__tests__/labels-section.test.tsx index 017bcca83..373ed7bb5 100644 --- a/components/admin/org/__tests__/labels-section.test.tsx +++ b/components/admin/org/__tests__/labels-section.test.tsx @@ -60,7 +60,7 @@ describe('Classification section', () => { 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 approved products'); + expect(warning).toContain('are not BSI-approved products'); }); it('starts from the generic preset and saves it with the default', async () => { diff --git a/components/admin/org/sections/labels-section.tsx b/components/admin/org/sections/labels-section.tsx index 1886b712b..42c446a21 100644 --- a/components/admin/org/sections/labels-section.tsx +++ b/components/admin/org/sections/labels-section.tsx @@ -42,7 +42,7 @@ function VsNfdWarning() {

    A label only marks the message. It doesn't make mail meet the VS-NfD handling rules (VSA § 50, § 55): that needs approved IT and BSI-approved encryption. Bulwark's S/MIME and PGP plugins and Stalwart are not - approved products. A received label can be forged by anyone: Bulwark shows it, it never relaxes anything because of it. + BSI-approved products. Anyone can forge a received label, so Bulwark only shows it and never relaxes anything because of it.

    ); @@ -203,7 +203,7 @@ export function LabelsSection({ draft }: { draft: OrgPolicyDraft }) {
    diff --git a/components/admin/org/sections/phishing-report-section.tsx b/components/admin/org/sections/phishing-report-section.tsx index 94be52104..2ee66f0a6 100644 --- a/components/admin/org/sections/phishing-report-section.tsx +++ b/components/admin/org/sections/phishing-report-section.tsx @@ -133,9 +133,8 @@ export function PhishingReportSection({ draft }: { draft: OrgPolicyDraft }) {