Merge branch 'feat/enterprise-pack-a'

This commit is contained in:
Linus Rath
2026-11-01 07:36:59 +01:00
99 changed files with 6640 additions and 154 deletions
+2
View File
@@ -11,6 +11,7 @@ import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm";
import { PhishingReportHost } from "@/components/email/phishing-report-host";
import { PluginPageNavigator } from "@/components/plugins/plugin-page-navigator";
import { PaneCycleHost } from "@/components/layout/pane-cycle";
import { FocusRescueHost } from "@/components/layout/focus-rescue";
@@ -81,6 +82,7 @@ export default async function LocaleLayout({
{children}
<PluginDialogHost />
<ExternalRecipientConfirmHost />
<PhishingReportHost />
<CommandPaletteHost />
<AccessibilityQuickPanelHost />
<PaneCycleHost />
@@ -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<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
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<typeof useOrgPolicyDraft> | null = null;
function Harness() {
const draft = useOrgPolicyDraft('test');
latest = draft;
if (draft.loading) return null;
return <LabelsSection draft={draft} />;
}
async function renderSection() {
render(<Harness />);
await waitFor(() => expect(screen.getByTestId('org-labels-vsnfd-warning')).toBeInTheDocument());
}
beforeEach(() => {
puts.length = 0;
stored = JSON.parse(JSON.stringify(POLICY));
latest = null;
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
describe('Classification section', () => {
it('says plainly that a label does not make mail meet the VS-NfD rules', async () => {
await renderSection();
const warning = screen.getByTestId('org-labels-vsnfd-warning').textContent ?? '';
expect(warning).toContain('VSA § 50, § 55');
expect(warning).toContain('BSI-approved encryption');
expect(warning).toContain('are not BSI-approved products');
});
it('starts from the generic preset and saves it with the default', async () => {
await renderSection();
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'generic' } });
expect(screen.getAllByTestId('org-label-item')).toHaveLength(4);
fireEvent.click(screen.getByTestId('org-labels-required'));
await act(async () => { await latest!.save(); });
const labels = (puts[0].organisation as { labels: { list: Array<{ id: string; subjectPrefix: string }>; defaultId: string; required: boolean } }).labels;
expect(labels.list.map((l) => l.id)).toEqual(['public', 'internal', 'confidential', 'strictly-confidential']);
expect(labels.defaultId).toBe('internal');
expect(labels.required).toBe(true);
// What the server keeps of it is the same.
expect(sanitizeOrganisationPolicy(puts[0].organisation).labels.list[2].subjectPrefix).toBe('[Confidential]');
});
it('offers no grade above VS-NfD in the German preset', async () => {
await renderSection();
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } });
const names = latest!.orgValue('labels', 'list').value.map((l) => l.name);
expect(names).toEqual(['OFFEN', 'VS-NfD']);
expect(latest!.orgValue('labels', 'list').value[1].marking).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
});
it('clears the default and the requirement with the last label', async () => {
stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true } } };
await renderSection();
fireEvent.click(screen.getByRole('button', { name: 'Remove A' }));
expect(latest!.orgValue('labels', 'defaultId').value).toBe('');
expect(latest!.orgValue('labels', 'required').value).toBe(false);
});
it('stores the extra header name only when it is a header Bulwark doesn’t write', async () => {
await renderSection();
const field = screen.getByTestId('org-labels-extra-header');
fireEvent.change(field, { target: { value: 'Sensitivity' } });
expect(screen.getAllByRole('alert').some((el) => el.textContent?.includes('Bulwark writes this one itself'))).toBe(true);
expect(latest!.orgValue('labels', 'extraHeader').value).toBe('');
fireEvent.change(field, { target: { value: 'X-Classification' } });
expect(latest!.orgValue('labels', 'extraHeader').value).toBe('X-Classification');
});
it('gives a scope its own scheme as a whole, and back', async () => {
stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true, extraHeader: 'X-Class' } } };
await renderSection();
act(() => latest!.setScopeId('gov'));
expect(screen.getByTestId('org-labels-inherited').textContent).toContain('A');
fireEvent.click(screen.getByTestId('org-labels-own'));
expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toEqual({
list: [{ id: 'a', name: 'A', color: '#000000', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }],
defaultId: 'a', required: true, extraHeader: 'X-Class',
});
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } });
const resolved = resolvePolicy({ ...DEFAULT_POLICY, ...(latest!.policy as object) } as never, { email: 'x@gov.example', groups: [] }).policy;
expect(resolved.organisation!.labels.list.map((l) => l.id)).toEqual(['offen', 'vs-nfd']);
expect(resolved.organisation!.labels.defaultId).toBe('offen');
expect(resolved.organisation!.labels.extraHeader).toBe('X-Class');
fireEvent.click(screen.getByRole('button', { name: /Use Everyone/ }));
expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toBeUndefined();
});
});
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
// Organisation > Security > Phishing reports: the address (stored only when
// it is one), the format, the postmaster warning, and a scope override.
const puts: Array<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
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<typeof useOrgPolicyDraft> | null = null;
function Harness() {
const draft = useOrgPolicyDraft('test');
latest = draft;
if (draft.loading) return null;
return <PhishingReportSection draft={draft} />;
}
async function renderSection() {
render(<Harness />);
await waitFor(() => expect(screen.getByTestId('org-phishing-address')).toBeInTheDocument());
}
beforeEach(() => {
puts.length = 0;
stored = JSON.parse(JSON.stringify(POLICY));
latest = null;
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
describe('Phishing reports section', () => {
it('stores the address once it is one, and saves it with the format', async () => {
await renderSection();
const field = screen.getByTestId('org-phishing-address');
fireEvent.change(field, { target: { value: 'security@' } });
// The field's own alert; other alerts (announcements) may be on the page.
await waitFor(() => expect(screen.getAllByRole('alert').some((el) => el.textContent?.includes("isn't an email address"))).toBe(true));
expect(latest!.orgValue('phishingReport', 'address').value).toBe('');
fireEvent.change(field, { target: { value: 'Security@Example.org' } });
expect(screen.queryAllByRole('alert').some((el) => el.textContent?.includes("isn't an email address"))).toBe(false);
fireEvent.click(screen.getByTestId('org-phishing-format-arf'));
await act(async () => { await latest!.save(); });
expect((puts[0].organisation as { phishingReport: unknown }).phishingReport).toEqual({ address: 'security@example.org', format: 'arf' });
});
it('turns reporting off when the address is emptied', async () => {
stored = { ...POLICY, organisation: { phishingReport: { address: 'soc@example.org', format: 'forward' } } };
await renderSection();
const field = screen.getByTestId('org-phishing-address') as HTMLInputElement;
expect(field.value).toBe('soc@example.org');
fireEvent.change(field, { target: { value: '' } });
expect(latest!.orgValue('phishingReport', 'address').value).toBe('');
});
it('warns about postmaster@', async () => {
await renderSection();
expect(screen.queryByTestId('org-phishing-postmaster')).toBeNull();
fireEvent.change(screen.getByTestId('org-phishing-address'), { target: { value: 'postmaster@example.org' } });
expect(screen.getByTestId('org-phishing-postmaster').textContent).toContain('Stalwart analyses mail to it as reports');
});
it('writes a scope override only into that scope', async () => {
await renderSection();
act(() => latest!.setScopeId('team'));
fireEvent.change(screen.getByTestId('org-phishing-address'), { target: { value: 'team-soc@example.org' } });
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ phishingReport: { address: 'team-soc@example.org' } });
expect(latest!.policy?.organisation?.phishingReport.address).toBe('');
});
});
@@ -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<LabelSensitivity, string> = {
'': 'None',
Personal: 'Personal',
Private: 'Private',
'Company-Confidential': 'Company-Confidential (Outlook: Confidential)',
};
/** The VS-NfD warning, here and in the docs. */
function VsNfdWarning() {
return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200" data-testid="org-labels-vsnfd-warning">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p>
A label only marks the message. It doesn&apos;t make mail meet the VS-NfD handling rules (VSA § 50, § 55): that
needs approved IT and BSI-approved encryption. Bulwark&apos;s S/MIME and PGP plugins and Stalwart are not
BSI-approved products. Anyone can forge a received label, so Bulwark only shows it and never relaxes anything because of it.
</p>
</div>
);
}
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<ClassificationLabel>) => onChange({ ...label, ...patch });
const name = label.name || 'New label';
return (
<li className="rounded-md border border-border" data-testid="org-label-item">
<div className="flex items-center gap-1 p-2">
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-1 text-start text-sm hover:bg-muted"
>
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
<span className="h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: label.color }} aria-hidden="true" />
<span className={cn('truncate font-medium', !label.name && 'text-muted-foreground')}>{name}</span>
{label.subjectPrefix && <span className="hidden truncate text-xs text-muted-foreground sm:inline">{label.subjectPrefix}</span>}
</button>
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" disabled={index === 0} onClick={() => onMove(-1)} aria-label={`Move ${name} up`}>
<ChevronUp className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" size="sm" className="h-8 px-2" disabled={index === count - 1} onClick={() => onMove(1)} aria-label={`Move ${name} down`}>
<ChevronDown className="h-4 w-4" />
</Button>
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-8 px-2 hover:text-red-600" aria-label={`Remove ${name}`}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{!label.name.trim() && <p className="px-3 pb-2 text-xs text-red-600 dark:text-red-400">A label without a name is not saved. Give it one.</p>}
{open && (
<div className="grid grid-cols-1 gap-3 border-t border-border p-3 sm:grid-cols-2">
<div>
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
<input id={`${id}-name`} data-testid="org-label-name" className={inputClass} value={label.name} maxLength={100} onChange={(e) => set({ name: e.target.value })} />
</div>
<div>
<label htmlFor={`${id}-color`} className={labelClass}>Colour</label>
<div className="flex items-center gap-2">
<input id={`${id}-color`} type="color" className="h-9 w-12 shrink-0 cursor-pointer rounded border border-input bg-background" value={label.color} onChange={(e) => set({ color: e.target.value.toLowerCase() })} />
<span className="font-mono text-xs text-muted-foreground">{label.color}</span>
</div>
</div>
<div className="sm:col-span-2">
<label htmlFor={`${id}-description`} className={labelClass}>Description</label>
<input id={`${id}-description`} className={inputClass} value={label.description} maxLength={1000} placeholder="Shown under the label in the composer's picker" onChange={(e) => set({ description: e.target.value })} />
</div>
<div>
<label htmlFor={`${id}-marking`} className={labelClass}>Marking (SIO-Label header)</label>
<input id={`${id}-marking`} data-testid="org-label-marking" className={inputClass} value={label.marking} maxLength={200} placeholder={label.name.toUpperCase() || 'CONFIDENTIAL'} onChange={(e) => set({ marking: e.target.value.replace(/["\\]/g, '') })} />
<p className="mt-1 text-xs text-muted-foreground">Empty uses the name. Received mail is matched by this text.</p>
</div>
<div>
<label htmlFor={`${id}-sensitivity`} className={labelClass}>Sensitivity header</label>
<select id={`${id}-sensitivity`} data-testid="org-label-sensitivity" className={inputClass} value={label.sensitivity} onChange={(e) => set({ sensitivity: e.target.value as LabelSensitivity })}>
{LABEL_SENSITIVITIES.map((value) => <option key={value} value={value}>{SENSITIVITY_NAMES[value]}</option>)}
</select>
<p className="mt-1 text-xs text-muted-foreground">Outlook shows it above the message.</p>
</div>
<div>
<label htmlFor={`${id}-prefix`} className={labelClass}>Subject prefix</label>
<input id={`${id}-prefix`} data-testid="org-label-prefix" className={inputClass} value={label.subjectPrefix} maxLength={60} placeholder="[Confidential]" onChange={(e) => set({ subjectPrefix: e.target.value })} />
<p className="mt-1 text-xs text-muted-foreground">Added in front of the subject when the mail goes out, once.</p>
</div>
{extraHeader && (
<div>
<label htmlFor={`${id}-extra`} className={labelClass}>{extraHeader} value</label>
<input id={`${id}-extra`} data-testid="org-label-extra" className={inputClass} value={label.extraHeaderValue} maxLength={500} onChange={(e) => set({ extraHeaderValue: e.target.value })} />
</div>
)}
</div>
)}
</li>
);
}
/**
* 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 <SettingsSection id="labels" title="Classification labels" description="Not set up yet.">{null}</SettingsSection>;
const id = useId();
const inScope = !!draft.scope;
const scopeOwn = inScope && KEYS.some((key) => draft.orgValue('labels', key).own);
const editing = !inScope || scopeOwn;
const value = <K extends keyof Labels>(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 = <K extends keyof Labels>(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 (
<SettingsSection
id="labels"
title="Classification labels"
description="Users pick a label in the composer, and sent mail carries it in its headers and subject. Received mail with a known label shows it. No labels turns this off."
>
<div className="space-y-4">
<VsNfdWarning />
{inScope && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm" data-testid="org-labels-scope">
<span>{scopeOwn ? 'This scope has its own labels. They replace Everyone\u2019s.' : 'This scope uses Everyone\u2019s labels.'}</span>
{scopeOwn ? (
<Button type="button" variant="outline" size="sm" onClick={useEveryone}>
<RotateCcw className="me-1 h-3.5 w-3.5" /> Use Everyone&apos;s labels
</Button>
) : (
<Button type="button" variant="outline" size="sm" onClick={useOwn} data-testid="org-labels-own">
Set labels for this scope
</Button>
)}
</div>
)}
{editing ? (
<>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor={`${id}-preset`} className="text-sm text-muted-foreground">Start from a preset</label>
<select
id={`${id}-preset`}
data-testid="org-labels-preset"
className="h-9 max-w-full rounded-md border border-input bg-background px-2 text-sm"
value=""
onChange={(e) => applyPreset(e.target.value)}
>
<option value="">Choose…</option>
{LABEL_PRESETS.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
</select>
<span className="text-xs text-muted-foreground">Replaces the list below.</span>
</div>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="org-labels-empty">No labels. The composer shows no label picker.</p>
) : (
<ul className="space-y-2" data-testid="org-labels-list">
{list.map((label, index) => (
<LabelEditor
key={label.id}
label={label}
index={index}
count={list.length}
extraHeader={extraHeader}
onChange={(l) => change(index, l)}
onMove={(delta) => move(index, delta)}
onRemove={() => remove(index)}
/>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" onClick={add} disabled={list.length >= MAX_LABELS} data-testid="org-labels-add">
<Plus className="me-1 h-4 w-4" /> Add label
</Button>
{list.length >= MAX_LABELS && <span className="text-xs text-muted-foreground">At most {MAX_LABELS} labels.</span>}
</div>
<div>
<SettingItem label="Default label" description="New messages start with it. Replies and forwards keep the original's label.">
<select
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-56"
data-testid="org-labels-default"
value={defaultId}
disabled={list.length === 0}
onChange={(e) => set('defaultId', e.target.value)}
>
<option value="">None</option>
{list.filter((l) => l.name.trim()).map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</SettingItem>
<SettingItem
label="Require a label"
description="Sending asks for a label until one is picked. A quick reply without one opens the full composer."
>
<ToggleSwitch checked={required} disabled={list.length === 0} onChange={(v) => set('required', v)} testId="org-labels-required" />
</SettingItem>
<SettingItem
label="Extra header"
description="A header of your own each label can set, for a mail gateway or DLP (for example X-Classification, or msip_labels for Microsoft Purview). Empty writes none."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<input
type="text"
spellCheck={false}
autoComplete="off"
placeholder="X-Classification"
value={headerText}
onChange={(e) => 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 && (
<p className="text-xs text-red-600 dark:text-red-400" role="alert">
A header name has no spaces or colons. Bulwark writes this one itself, or the mail server won't take it.
</p>
)}
</div>
</SettingItem>
</div>
</>
) : (
<div data-testid="org-labels-inherited">
{everyone.list.length === 0 ? (
<p className="text-sm text-muted-foreground">Everyone has no labels.</p>
) : (
<ul className="flex flex-wrap gap-2">
{everyone.list.map((l) => (
<li key={l.id} className="inline-flex items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs">
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: l.color }} aria-hidden="true" />
{l.name}
</li>
))}
</ul>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
Calendar invitations, read receipts, phishing reports and unsubscribe mails go out without a label.{' '}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
How labels work
</a>
</p>
</div>
</SettingsSection>
);
}
@@ -1,13 +1,145 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import { useEffect, useId, useState } from 'react';
import { Info } from '@/components/icons';
import { SettingItem, SettingsSection } from '@/components/settings/settings-section';
import { isPhishingReportAddress, type PhishingReportFormat } from '@/lib/admin/organisation-policy';
import { cn } from '@/lib/utils';
import { InheritNote } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/phishing-reports';
const FORMATS: Array<{ value: PhishingReportFormat; label: string; description: string }> = [
{
value: 'forward',
label: 'Forward as an attachment',
description: 'The message goes out whole as an .eml attachment, with the subject "Phishing: <subject>". 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 <SettingsSection id="phishing" title="Phishing reports" description="Not set up yet.">{null}</SettingsSection>;
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 (
<SettingsSection
id="phishing"
title="Phishing reports"
description="Adds “Report phishing” to the message menus. Bulwark sends the message to the address below, one report per message, without a copy in Sent, and moves it to Junk. Users confirm each report."
>
<SettingItem
label="Send reports to"
description="An address of your security team or ticket system. Empty turns reporting off."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<input
id={`${id}-address`}
type="email"
inputMode="email"
autoComplete="off"
spellCheck={false}
placeholder="security@example.com"
value={text}
onChange={(e) => 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 && (
<p id={`${id}-error`} className="text-xs text-red-600 dark:text-red-400" role="alert">
This isn&apos;t an email address. The saved address stays until you enter a valid one.
</p>
)}
{postmaster && (
<p className="text-xs text-amber-700 dark:text-amber-400" data-testid="org-phishing-postmaster">
Don&apos;t use postmaster@: Stalwart analyses mail to it as reports.
</p>
)}
<InheritNote
draft={draft}
own={address.own}
everyone={address.everyone || 'off'}
onReset={() => draft.clearOrgValue('phishingReport', 'address')}
/>
</div>
</SettingItem>
<SettingItem label="Report format" description="What the report looks like when it arrives.">
<div className="flex w-full flex-col gap-2 sm:w-80" role="radiogroup" aria-label="Report format">
{FORMATS.map((option) => (
<label
key={option.value}
className={cn(
'flex cursor-pointer items-start gap-2 rounded-md border p-2 text-sm',
format.value === option.value ? 'border-primary bg-primary/5' : 'border-border',
)}
>
<input
type="radio"
name={`${id}-format`}
value={option.value}
checked={format.value === option.value}
onChange={() => draft.setOrgValue('phishingReport', 'format', option.value)}
className="mt-0.5"
data-testid={`org-phishing-format-${option.value}`}
/>
<span className="min-w-0">
<span className="block font-medium text-foreground">{option.label}</span>
<span className="block text-xs text-muted-foreground">{option.description}</span>
</span>
</label>
))}
<InheritNote
draft={draft}
own={format.own}
everyone={FORMATS.find((f) => f.value === format.everyone)?.label ?? format.everyone}
onReset={() => draft.clearOrgValue('phishingReport', 'format')}
/>
</div>
</SettingItem>
<div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p>
Reports go out without the organisation signature, disclaimer, label or plugin checks. A reported message
shows a banner and its links are turned off.{' '}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-primary underline-offset-2 hover:underline">
How reporting works
</a>
</p>
</div>
</SettingsSection>
);
}
@@ -0,0 +1,241 @@
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { LABEL_PRESETS } from '@/lib/classification/presets';
// The composer's classification label: a required label blocks sending
// (fail closed) until one is picked, the label goes on after the plugins'
// onTransformOutgoingEmail so none can drop it, a reply starts with the
// original's label, and a crypto takeover gets the labelled subject.
const hooks = vi.hoisted(() => ({
transform: vi.fn(async (email: unknown) => email),
takeOver: vi.fn(async (..._args: unknown[]) => 'default'),
toastError: vi.fn(),
}));
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }) }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
function storeMock<T extends object>(state: T) {
const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
hook.getState = () => state;
hook.setState = (p: Partial<T>) => Object.assign(state, p);
hook.subscribe = () => () => {};
return hook;
}
vi.mock('@/stores/auth-store', () => ({
useAuthStore: storeMock({
client: null, identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], primaryIdentity: null,
isAuthenticated: false, isDemoMode: false, activeAccountId: null, connectionLost: false,
getClientForAccount: () => undefined, getAllConnectedClients: () => new Map(),
syncIdentities: () => {}, refreshIdentities: async () => {},
}),
}));
vi.mock('@/stores/identity-store', () => ({
useIdentityStore: storeMock({ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], defaultIdentityId: 'id-me' }),
}));
vi.mock('@/stores/account-store', () => ({ useAccountStore: storeMock({ accounts: [], getAccountById: () => undefined }) }));
vi.mock('@/stores/email-store', () => ({ useEmailStore: storeMock({ draftSaveEnabled: false, sendRawEmail: async () => ({ sent: true }) }) }));
vi.mock('@/stores/settings-store', () => ({
useSettingsStore: storeMock({
timeFormat: '24h', plainTextMode: false, subAddressDelimiter: '+', autoSelectReplyIdentity: true,
attachmentReminderEnabled: false, attachmentReminderKeywords: [], emptySubjectWarningEnabled: false,
sendDelaySeconds: 0, signaturePosition: 'below_quote', signatureSeparatorEnabled: true,
requestReadReceiptDefault: false, externalContentPolicy: 'ask',
addTrustedSender: () => {}, isSenderTrusted: () => false,
trustedSendersAddressBook: null, updateSetting: () => {},
}),
}));
vi.mock('@/stores/contact-store', () => ({
useContactStore: storeMock({
contacts: [], addressBooks: [], trustedSendersBookId: null, getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
}),
}));
vi.mock('@/stores/template-store', () => ({ useTemplateStore: storeMock({ templates: [], addTemplate: async () => {} }) }));
vi.mock('@/stores/toast-store', () => ({ toast: { info: () => {}, error: hooks.toastError, success: () => {}, warning: () => {} } }));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: hooks.takeOver },
onTransformOutgoingEmail: { transform: hooks.transform },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeSignatureHtmlForDisplay: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
sanitizePluginBodyHtml: (v: string) => v,
escapeHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }) }));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({ buildQuotedHtmlBlock: () => '', serializeEditorContent: () => '' }));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
const generic = LABEL_PRESETS[0];
function useLabels(labels: Partial<typeof DEFAULT_ORGANISATION_POLICY.labels>) {
usePolicyStore.setState({
loaded: true,
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: generic.list, extraHeader: 'X-Classification', ...labels } },
},
});
}
const DRAFT = {
to: 'bob@example.org', cc: '', bcc: '', subject: 'Budget', body: '<p>Numbers</p>',
showCc: false, showBcc: false, selectedIdentityId: 'id-me', subAddressTag: '', mode: 'compose' as const, draftId: null,
};
type SendData = { subject: string; headers?: Record<string, string>; labelHeaders?: Record<string, string> };
function renderComposer(props: Partial<React.ComponentProps<typeof EmailComposer>> = {}) {
const onSend = vi.fn<(data: SendData) => void>();
render(<EmailComposer mode="compose" initialData={DRAFT} onSend={onSend} {...props} />);
return onSend;
}
const send = async () => {
await act(async () => { fireEvent.click(screen.getAllByTestId('composer-send')[0]); });
};
afterEach(() => {
cleanup();
resetSendPipeline();
usePolicyStore.setState({ policy: DEFAULT_POLICY });
vi.clearAllMocks();
hooks.transform.mockImplementation(async (email: unknown) => email);
hooks.takeOver.mockImplementation(async () => 'default');
});
describe('composer: classification label', () => {
it('shows no picker and changes nothing while the organisation has no labels', async () => {
const onSend = renderComposer();
expect(screen.queryByTestId('composer-classification')).toBeNull();
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].subject).toBe('Budget');
expect(onSend.mock.calls[0][0].labelHeaders).toBeUndefined();
});
it('blocks sending until a required label is picked, then stamps it', async () => {
useLabels({ required: true, defaultId: '' });
const onSend = renderComposer();
await send();
expect(onSend).not.toHaveBeenCalled();
expect(hooks.toastError).toHaveBeenCalledWith('required_toast');
// The picker opened, as a radio group without "No label".
expect(screen.queryByTestId('composer-classification-option-none')).toBeNull();
fireEvent.click(screen.getByTestId('composer-classification-option-confidential'));
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const data = onSend.mock.calls[0][0];
expect(data.subject).toBe('[Confidential] Budget');
expect(data.labelHeaders?.['header:SIO-Label:asRaw']).toContain('marking="CONFIDENTIAL"');
expect(data.labelHeaders?.['header:Sensitivity:asText']).toBe('Company-Confidential');
expect(data.labelHeaders?.['header:X-Classification:asText']).toBe('Confidential');
});
it('labels after the plugins: a plugin can neither drop the prefix nor set the label headers', async () => {
useLabels({ defaultId: 'strictly-confidential' });
hooks.transform.mockImplementation(async (email: unknown) => ({
...(email as object),
subject: 'Budget (rewritten)',
headers: { 'SIO-Label': 'marking="PUBLIC"', Sensitivity: 'Personal', 'X-Classification': 'Public', 'X-Other': '1' },
}));
const onSend = renderComposer();
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
const data = onSend.mock.calls[0][0];
expect(data.subject).toBe('[Strictly confidential] Budget (rewritten)');
expect(data.headers).toEqual({ 'X-Other': '1' });
expect(data.labelHeaders?.['header:SIO-Label:asRaw']).toContain('STRICTLY CONFIDENTIAL');
});
it('starts a reply with the original’s label', async () => {
useLabels({ defaultId: 'public' });
const onSend = renderComposer({
mode: 'reply',
initialData: { ...DRAFT, mode: 'reply', subject: 'Re: [Confidential] Budget' },
replyTo: { subject: '[Confidential] Budget', from: [{ email: 'boss@example.com' }], headers: { 'SIO-Label': ' marking="CONFIDENTIAL"; bgcolor=#d97706' } },
});
expect(screen.getByTestId('composer-classification-trigger').textContent).toContain('Confidential');
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].subject).toBe('[Confidential] Re: Budget');
});
it('ignores a label an outside sender wrote, and starts with the default', async () => {
useLabels({ defaultId: 'internal' });
renderComposer({
mode: 'reply',
initialData: { ...DRAFT, mode: 'reply', subject: 'Re: Offer' },
replyTo: { subject: 'Offer', from: [{ email: 'shop@outside.example' }], headers: { 'SIO-Label': ' marking="PUBLIC"' } },
});
expect(screen.getByTestId('composer-classification-trigger').textContent).toContain('Internal');
});
it('lets the user take the label off when none is required', async () => {
useLabels({ defaultId: 'confidential' });
const onSend = renderComposer();
fireEvent.click(screen.getByTestId('composer-classification-trigger'));
fireEvent.keyDown(screen.getByTestId('composer-classification-option-confidential'), { key: 'Home' });
fireEvent.keyDown(screen.getByTestId('composer-classification-option-none'), { key: 'Enter' });
expect(screen.queryByTestId('composer-classification-options')).toBeNull();
await send();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].subject).toBe('Budget');
expect(onSend.mock.calls[0][0].labelHeaders).toBeUndefined();
});
it('hands a crypto takeover the labelled subject', async () => {
useLabels({ defaultId: 'confidential' });
hooks.takeOver.mockImplementation(async () => 'handled');
const onSend = renderComposer();
await send();
await waitFor(() => expect(hooks.takeOver).toHaveBeenCalledTimes(1));
expect((hooks.takeOver.mock.calls[0][0] as { subject: string }).subject).toBe('[Confidential] Budget');
expect(onSend).not.toHaveBeenCalled();
});
});
@@ -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 <img> 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 = '<p>Thanks</p><div data-quoted-html=""><p>Hi <img src="https://tracker.example/p.gif"></p></div>';
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('<p style="margin:1em 0;">Thanks</p>');
expect(out).toContain('<img src="https://tracker.example/p.gif">');
});
});
@@ -0,0 +1,198 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email } from '@/lib/jmap/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { usePolicyStore } from '@/stores/policy-store';
import { useToastStore } from '@/stores/toast-store';
import { usePhishingReportStore } from '@/stores/phishing-report-store';
// The report flow through the real dialog: it names the address, Cancel
// sends nothing, each message gets its own report, each moves to Junk with
// $phishing (read), and Undo moves it back and clears the mark, while the
// toast says the report already went.
const email = (id: string, subject: string) => ({
id, blobId: `blob-${id}`, threadId: `t-${id}`, size: 100, subject, keywords: {}, mailboxIds: { inbox: true },
receivedAt: '2026-10-01T10:00:00Z', from: [{ email: 'bad@phish.example' }],
}) as unknown as Email;
const e1 = email('e1', 'Parcel');
const e2 = email('e2', 'Invoice');
const hoisted = vi.hoisted(() => {
const client = {
getIdentities: async () => [{ id: 'id-me', name: 'Me', email: 'me@example.org' }],
getUsername: () => 'me@example.org',
getAccountId: () => 'acc',
resolveSendAccountId: async () => 'acc',
copyBlobs: async () => ({}),
sendEmail: vi.fn(async () => ({ scheduled: false })),
submitRawEmail: vi.fn(async () => ({ scheduled: false })),
fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode('Subject: x' + String.fromCharCode(13, 10, 13, 10) + 'hi').buffer),
markAsSpam: vi.fn(async () => {}),
undoSpam: vi.fn(async () => {}),
};
const store = {
emails: [] as Email[],
mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'sent-box', role: 'sent' }],
selectedEmail: null as Email | null,
markAsSpam: vi.fn(async (..._args: unknown[]) => true),
undoSpam: vi.fn(async (..._args: unknown[]) => true),
};
return { client, store };
});
vi.mock('@/stores/auth-store', () => {
const state = { client: hoisted.client, activeAccountId: null, getClientForAccount: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state);
hook.getState = () => state;
hook.subscribe = () => () => {};
return { useAuthStore: hook };
});
vi.mock('@/stores/email-store', () => ({
useEmailStore: Object.assign(
(sel?: (s: typeof hoisted.store) => unknown) => (sel ? sel(hoisted.store) : hoisted.store),
{ getState: () => hoisted.store },
),
resolveEmailActionContext: () => ({ client: hoisted.client, mailboxes: [], accountId: undefined }),
}));
const { PhishingReportHost } = await import('../phishing-report-host');
const { requestPhishingReport } = await import('@/hooks/use-phishing-report');
function setAddress(address: string, format: 'forward' | 'arf' = 'forward') {
usePolicyStore.setState({
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format } } },
});
}
beforeEach(() => {
hoisted.store.emails = [e1, e2];
setAddress('soc@example.org');
});
afterEach(() => {
usePhishingReportStore.setState({ reported: {} });
cleanup();
usePolicyStore.setState({ policy: DEFAULT_POLICY });
useToastStore.setState({ toasts: [] });
usePhishingReportStore.setState({ pending: null, busy: false, opener: null });
vi.clearAllMocks();
});
describe('Report phishing', () => {
it('asks nothing while the admin set no address', () => {
setAddress('');
render(<PhishingReportHost />);
act(() => requestPhishingReport([e1], 'inbox'));
expect(screen.queryByTestId('phishing-report-dialog')).toBeNull();
});
it('names the address and sends nothing on Cancel or Escape', () => {
render(<PhishingReportHost />);
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(<PhishingReportHost />);
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(<PhishingReportHost />);
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(<PhishingReportHost />);
act(() => requestPhishingReport([e1], 'inbox'));
await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); });
expect(hoisted.client.markAsSpam).toHaveBeenCalledWith('e1', undefined, true, ['$phishing']);
await act(async () => { useToastStore.getState().toasts.at(-1)!.action!.onClick(); });
expect(hoisted.client.undoSpam).toHaveBeenCalledWith('e1', 'inbox', undefined, ['$phishing']);
});
it('sends an ARF report raw when the admin picked ARF', async () => {
setAddress('abuse@example.org', 'arf');
render(<PhishingReportHost />);
act(() => requestPhishingReport([e1], 'inbox'));
await act(async () => { fireEvent.click(screen.getByTestId('phishing-report-confirm')); });
await waitFor(() => expect(hoisted.client.submitRawEmail).toHaveBeenCalledTimes(1));
expect(hoisted.client.sendEmail).not.toHaveBeenCalled();
const raw = await ((hoisted.client.submitRawEmail.mock.calls[0] as unknown[])[0] as Blob).text();
expect(raw).toContain('report-type=feedback-report');
await waitFor(() => expect(hoisted.store.markAsSpam).toHaveBeenCalledTimes(1));
});
it('says how many came back when only some could be moved back', async () => {
render(<PhishingReportHost />);
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(<PhishingReportHost />);
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(<PhishingReportHost />);
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(<PhishingReportHost />);
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(<PhishingReportHost />);
act(() => requestPhishingReport([e1], 'sent'));
expect(screen.queryByTestId('phishing-report-dialog')).toBeNull();
});
});
@@ -0,0 +1,200 @@
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { usePolicyStore } from '@/stores/policy-store';
import { usePhishingReportStore } from '@/stores/phishing-report-store';
import { useAuthStore } from '@/stores/auth-store';
import { resetAccountSubjects, useAccountSubjects } from '@/lib/org/account-subjects';
import { requestPhishingReport } from '@/hooks/use-phishing-report';
import { htmlAttachmentDocument } from '@/components/files/file-preview-modal';
import { stampProSource } from '@/components/pro/pro-email-tab-body';
// Where "Report phishing" shows: the row menu, the reader's ⋮ and a
// selection's menu, only while the admin set an address and not in your own
// folders. A reported message ($phishing) shows a banner and its links are off.
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { EmailViewer } = await import('../email-viewer');
const { EmailContextMenu } = await import('../email-context-menu');
const email = {
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Phish', email: 'bad@phish.example' }], to: [{ email: 'me@example.org' }],
subject: 'Parcel', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
bodyValues: { '1': { value: '<p>Track it <a href="https://phish.example/login">here</a></p>' } },
} as unknown as Email;
const other = { ...email, id: 'e2', blobId: 'b2', subject: 'Invoice' } as Email;
const other_message = other;
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
function setAddress(address: string) {
usePolicyStore.setState({
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address, format: 'forward' } } },
});
}
function rowMenu(role: string, props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) {
return render(
<EmailContextMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole={role}
{...props}
/>,
);
}
afterEach(() => {
cleanup();
usePolicyStore.setState({ policy: DEFAULT_POLICY });
usePhishingReportStore.setState({ pending: null, busy: false, opener: null, reported: {} });
useAuthStore.setState({ activeAccountId: null, getClientForAccount: () => undefined } as never);
resetAccountSubjects();
});
describe('several accounts', () => {
it('names every address a selection across accounts goes to', () => {
const other = { getUsername: () => 'b@other.example', getSharedAccounts: () => [] };
useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never);
const org = { ...DEFAULT_ORGANISATION_POLICY, phishingReport: { address: 'soc@example.org', format: 'forward' as const } };
usePolicyStore.setState({
policy: { ...DEFAULT_POLICY, organisation: org },
rawPolicy: {
...DEFAULT_POLICY,
organisation: org,
scopes: [{ id: 'other', label: 'Other', match: { domains: ['other.example'] }, policy: { organisation: { phishingReport: { address: 'abuse@other.example' } } } }],
} as never,
subject: { email: 'me@example.org', groups: [] },
});
useAccountSubjects.setState({ subjects: { 'acct-b': { email: 'b@other.example', groups: [] } }, settled: { 'acct-b': true } });
const fromB = { ...other_message, sourceClientAccountId: 'acct-b', sourceAccountId: 'jmap-b' } as Email;
act(() => requestPhishingReport([email, fromB], 'inbox'));
expect(usePhishingReportStore.getState().pending?.addresses).toEqual(['soc@example.org', 'abuse@other.example']);
});
it('a Pro tab stamps a message of another login or a shared account with where it came from', () => {
useAuthStore.setState({ activeAccountId: 'acct-a' } as never);
const own = { getAccountId: () => 'jmap-a' };
expect(stampProSource(email, { client: own as never }, undefined)).toBe(email);
expect(stampProSource(email, { client: own as never, accountId: 'jmap-team' }, undefined)).toMatchObject({ sourceClientAccountId: 'acct-a', sourceAccountId: 'jmap-team' });
expect(stampProSource(email, { client: { getAccountId: () => 'jmap-b' } as never }, 'acct-b')).toMatchObject({ sourceClientAccountId: 'acct-b', sourceAccountId: 'jmap-b' });
});
});
describe('the focus after the dialog', () => {
it('goes back to the button that opened the menu, not to the menu item that is gone', () => {
document.body.innerHTML = '<button aria-haspopup="menu" aria-expanded="true" id="more">More</button><div role="menu"><button id="item">Report</button></div>';
(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('<a href="https://phish.example/x">go</a>', true, { title: 'link turned off', note: '(link turned off)' });
expect(doc).not.toContain('https://phish.example/x');
expect(htmlAttachmentDocument('<a href="https://ok.example/x">go</a>', true)).toContain('https://ok.example/x');
});
it('the unsubscribe banner doesn\u2019t show', async () => {
const withList = { ...email, headers: { 'List-Unsubscribe': '<https://phish.example/unsub>', 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click' } } as Email;
const { unmount } = render(<EmailViewer email={withList} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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(<EmailViewer email={reported} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="junk" />);
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(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
expect(screen.getByTestId('phishing-mark-banner')).toBeTruthy();
});
});
describe('the row menu', () => {
it('has no Report phishing while the admin set no address', () => {
rowMenu('inbox');
expect(screen.queryByTestId('ctx-report-phishing')).toBeNull();
});
it('offers it once an address is set, and asks with that address', () => {
setAddress('soc@example.org');
rowMenu('inbox');
fireEvent.click(screen.getByTestId('ctx-report-phishing'));
expect(usePhishingReportStore.getState().pending).toEqual({ emails: [email], addresses: ['soc@example.org'] });
});
it('shows up when the policy arrives after the menu opened', () => {
rowMenu('inbox');
expect(screen.queryByTestId('ctx-report-phishing')).toBeNull();
act(() => setAddress('soc@example.org'));
expect(screen.getByTestId('ctx-report-phishing')).toBeTruthy();
});
it('is not offered in Sent', () => {
setAddress('soc@example.org');
rowMenu('sent');
expect(screen.queryByTestId('ctx-report-phishing')).toBeNull();
});
it('reports a whole selection, one message each', () => {
setAddress('soc@example.org');
rowMenu('inbox', { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other], onBatchMarkAsSpam: vi.fn() });
fireEvent.click(screen.getByTestId('ctx-batch-report-phishing'));
expect(usePhishingReportStore.getState().pending?.emails.map((e) => e.id)).toEqual(['e1', 'e2']);
});
});
describe('the reader', () => {
it("offers it in the ⋮ menu", () => {
setAddress('soc@example.org');
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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(<EmailViewer email={reported} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="junk" />);
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(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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');
});
});
@@ -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<string, string>) => ({
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(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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(<ThreadConversationView thread={{ threadId: 't1', emails: [email], latestEmail: email } as never} emails={[email] as never} onBack={() => {}} />);
await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
expect(screen.getAllByTestId('received-label-chip')[0].textContent).toBe('Public');
});
});
@@ -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: '<p>Hello</p><img src="https://track.shop.example/pixel.gif">' } },
...(account ? { sourceClientAccountId: account, sourceAccountId: 'jmap-b' } : {}),
}) as unknown as Email;
function policy(scopes: unknown[], state: Record<string, unknown> = {}) {
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(<EmailViewer email={message('acct-b')} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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(<EmailViewer email={message()} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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(<EmlPreview message={{ subject: 's', html: '<img src="https://t.example/p.gif">', 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(<EmlPreview message={{ subject: 's', html: '<img src="https://t.example/p.gif">', attachments: [] }} />);
expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').not.toMatch(/\ssrc="https:\/\/t\.example/);
});
});
describe('remote images in a draft’s own content', () => {
const HTML = '<p>Hi <img src="https://tracker.example/p.gif"></p>';
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 <EditorContent editor={editor} />;
}
const { container } = render(<Box />);
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);
});
});
@@ -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(<ReadingSettings />);
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', () => {
@@ -0,0 +1,172 @@
'use client';
import { forwardRef, useEffect, useId, useImperativeHandle, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Check, ChevronDown, LabelIcon } from '@/components/icons';
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
import { textColorOn } from '@/lib/classification/labels';
import { cn } from '@/lib/utils';
export interface ClassificationPickerHandle {
/** Opens the picker with the focus on it (Send without a required label). */
open: () => void;
}
interface Props {
labels: ClassificationLabel[];
selected: ClassificationLabel | null;
required: boolean;
/** Shows the trigger as missing (Send was pressed without a label). */
invalid?: boolean;
onPick: (id: string | null) => void;
className?: string;
}
/**
* The composer's label picker: a button in the label's colour that opens a
* radio group (arrow keys move the pick, Enter or Escape closes, the focus
* goes back to the button). Keys stay inside, away from the composer's and
* the mail page's shortcuts. "No label" is offered unless one is required.
*/
export const ClassificationLabelPicker = forwardRef<ClassificationPickerHandle, Props>(function ClassificationLabelPicker(
{ labels, selected, required, invalid, onPick, className },
ref,
) {
const t = useTranslations('classification');
const id = useId();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const groupRef = useRef<HTMLDivElement>(null);
const options: Array<ClassificationLabel | null> = required ? labels : [null, ...labels];
const selectedIndex = Math.max(0, options.findIndex((o) => (o?.id ?? null) === (selected?.id ?? null)));
useImperativeHandle(ref, () => ({ open: () => setOpen(true) }), []);
useEffect(() => {
if (!open) return;
const radios = groupRef.current?.querySelectorAll<HTMLElement>('[role="radio"]');
radios?.[selectedIndex]?.focus();
const onDown = (e: MouseEvent) => {
const target = e.target as Node;
if (!groupRef.current?.parentElement?.contains(target)) setOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
// Focus only when it opens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const close = () => {
setOpen(false);
triggerRef.current?.focus();
};
const pick = (option: ClassificationLabel | null) => onPick(option?.id ?? null);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
e.stopPropagation();
const radios = [...(groupRef.current?.querySelectorAll<HTMLElement>('[role="radio"]') ?? [])];
const current = radios.indexOf(document.activeElement as HTMLElement);
const move = (to: number) => {
const next = (to + options.length) % options.length;
pick(options[next]);
radios[next]?.focus();
};
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault();
move(current + 1);
break;
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault();
move(current - 1);
break;
case 'Home':
e.preventDefault();
move(0);
break;
case 'End':
e.preventDefault();
move(options.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
if (current >= 0) pick(options[current]);
close();
break;
case 'Escape':
e.preventDefault();
close();
break;
case 'Tab':
setOpen(false);
break;
}
};
const name = selected?.name ?? (required ? t('pick_required') : t('none'));
return (
<div className={cn('relative', className)} data-testid="composer-classification">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={t('chip_label', { name })}
aria-invalid={invalid || undefined}
data-testid="composer-classification-trigger"
className={cn(
'inline-flex h-7 max-w-full items-center gap-1.5 rounded-full border px-2.5 text-xs font-medium transition-colors max-md:h-9',
selected ? 'border-transparent' : 'border-dashed border-border text-muted-foreground hover:bg-muted',
invalid && !selected && 'border-destructive text-destructive animate-pulse',
)}
style={selected ? { backgroundColor: selected.color, color: textColorOn(selected.color) } : undefined}
>
<LabelIcon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{name}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-70" aria-hidden="true" />
</button>
{open && (
<div
role="dialog"
aria-label={t('picker_label')}
className="absolute end-0 top-full z-50 mt-1 w-72 max-w-[calc(100vw-2rem)] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg max-md:start-0 max-md:end-auto"
onKeyDown={onKeyDown}
>
{required && <p className="px-2 py-1 text-xs text-muted-foreground">{t('required_hint')}</p>}
<div ref={groupRef} role="radiogroup" aria-labelledby={`${id}-label`} data-testid="composer-classification-options">
<span id={`${id}-label`} className="sr-only">{t('picker_label')}</span>
{options.map((option, index) => {
const checked = (option?.id ?? null) === (selected?.id ?? null);
return (
<div
key={option?.id ?? 'none'}
role="radio"
aria-checked={checked}
tabIndex={index === selectedIndex ? 0 : -1}
data-testid={`composer-classification-option-${option?.id ?? 'none'}`}
onClick={() => { pick(option); close(); }}
className="flex cursor-pointer items-start gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={cn('mt-1 h-3 w-3 shrink-0 rounded-full', !option && 'border border-dashed border-muted-foreground')}
style={option ? { backgroundColor: option.color } : undefined}
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block font-medium">{option?.name ?? t('none')}</span>
{option?.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}
</span>
{checked && <Check className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />}
</div>
);
})}
</div>
</div>
)}
</div>
);
});
+127 -9
View File
@@ -111,6 +111,11 @@ import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils";
import { wantsSignatureSeparator } from "@/lib/signature-delimiter";
import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline";
import { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification";
import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw";
import { whenPolicySettled } from "@/stores/policy-store";
import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker";
import { useComposerClassification } from "./use-composer-classification";
import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients";
import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading";
@@ -253,6 +258,11 @@ export interface ComposerDraftData {
references?: string[];
/** Priority of a re-opened draft, read back from its headers (#970). */
priority?: MessagePriority;
/**
* Classification label of a re-opened draft (X-Bulwark-Label): an id,
* null for "no label", absent when the draft says nothing.
*/
classification?: string | null;
/**
* Handed to the host when the composer went away with edits the server may
* not have: the composer opened again with it counts them as unsaved, so
@@ -299,6 +309,8 @@ interface EmailComposerProps {
priority?: MessagePriority;
/** Extra headers plugins added in onTransformOutgoingEmail. */
headers?: Record<string, string>;
/** The classification label's headers (lib/outgoing/classification.ts), written after `headers`. */
labelHeaders?: Record<string, string>;
/** The id plugins know this composer by, for onAfterEmailSend. */
composerId?: string;
}) => void | false | Promise<void | false>;
@@ -379,6 +391,10 @@ interface EmailComposerProps {
quoteHeaderText?: string;
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
quoteWrapInBlockquote?: boolean;
/** The original's headers: a reply or forward starts with its classification label. */
headers?: Record<string, string | string[]>;
/** The original's From, when `from` is left out (a forward as attachment): whether its label counts. */
originalFrom?: { email?: string; name?: string }[];
};
}
@@ -470,6 +486,7 @@ export function EmailComposer({
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode);
// Per-message format (#1022). Seeded from the "plain text only" setting -
@@ -748,7 +765,9 @@ export function EmailComposer({
// Whether the quoted original may show its remote images in the editor:
// the viewer's rule, minus the per-message "load" click it doesn't know.
const { externalContentPolicy, isSenderTrusted } = useSettingsStore.getState();
const quoteRemoteAllowed = !remoteContentHeldBack()
// The original's own account decides (replyTo.accountId), and the
// active one, which the composer may send from.
const quoteRemoteAllowed = !remoteContentHeldBack() && !remoteContentHeldBack(replyTo?.accountId)
&& (externalContentPolicy === 'allow' || (!!from?.email && isSenderTrusted(from.email)));
const replyHeader = (html: string) => mode === 'reply' || mode === 'replyAll'
@@ -1102,6 +1121,25 @@ export function EmailComposer({
// scopes decide the organisation signature and disclaimer: the identity's
// own account, else the one the composer was opened in.
const composingAccountId = currentIdentityParts.localAccountId ?? composerAccountIdRef.current;
// Classification label (enterprise pack): the labels of the account and
// identity the message goes out from; replies and forwards start with
// the original's label, re-opened drafts with theirs.
const labelOriginal = useMemo<OriginalMessage | null>(
() => (mode === 'compose' || !replyTo?.headers
? null
: { headers: replyTo.headers, from: replyTo.from ?? replyTo.originalFrom, accountId: replyTo.accountId }),
[mode, replyTo?.headers, replyTo?.from, replyTo?.originalFrom, replyTo?.accountId],
);
const classification = useComposerClassification({
accountId: composingAccountId,
senderEmail: currentIdentity?.email,
original: labelOriginal,
initialChoice: initialData?.classification,
});
const classificationRef = useRef(classification);
classificationRef.current = classification;
const classificationPickerRef = useRef<ClassificationPickerHandle>(null);
const [labelInvalid, setLabelInvalid] = useState(false);
// The upload callbacks below list only `client` as a dependency, so they read
// the composing identity's client through a ref: uploads must land in the
// account that owns the draft, and a direct substitution would capture a
@@ -1687,9 +1725,20 @@ export function EmailComposer({
// `!==`, not `>`: a re-opened draft starts with hydrated attachments, and
// removing one must count as dirty or the removal never reaches the server.
body !== initialValuesRef.current.body || attachments.length !== initialValuesRef.current.attachmentCount;
// A priority change alone is an edit worth saving (#970).
// A priority change alone is an edit worth saving (#970), and so is a label pick.
const initialPriorityRef = useRef(priority);
if (priority !== initialPriorityRef.current) isDirtyRef.current = true;
const initialLabelChoiceRef = useRef(classification.choice);
if (classification.choice !== initialLabelChoiceRef.current) isDirtyRef.current = true;
const firstLabelRunRef = useRef(true);
useEffect(() => {
if (firstLabelRunRef.current) {
firstLabelRunRef.current = false;
return;
}
changeVersionRef.current += 1;
setLabelInvalid(false);
}, [classification.choice]);
const firstPriorityRunRef = useRef(true);
useEffect(() => {
if (firstPriorityRunRef.current) {
@@ -1745,6 +1794,7 @@ export function EmailComposer({
mode,
replyTo,
priority: priorityRef.current,
classification: classificationRef.current.draftChoice,
// Edits the last save didn't cover; a minimised composer with
// none needs no save before an automatic sign-out.
unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
@@ -2700,7 +2750,7 @@ export function EmailComposer({
savedDraft.htmlBody,
replyThreadingHeaders?.inReplyTo,
replyThreadingHeaders?.references,
{ priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId },
{ priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId, classification: classificationRef.current.draftChoice },
);
if (replacedElsewhere) {
await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId);
@@ -3319,6 +3369,22 @@ export function EmailComposer({
return;
}
// A required classification label (enterprise pack), read from the
// policy once it has loaded: no label, no send. Not a pre-send check,
// which would be skipped when it throws.
await whenPolicySettled();
{
const labelSender = { email: currentIdentity?.email, accountId: composingAccountId };
const labelPolicy = labelsPolicyFor(labelSender);
const label = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal);
if (labelMissing(labelPolicy, label)) {
setLabelInvalid(true);
toast.error(tClassification('required_toast'));
classificationPickerRef.current?.open();
return;
}
}
// Catch addresses the server would reject before asking it. Its answer
// ("No recipients found in email") does not say which one is wrong.
const invalidRecipients = invalidRecipientAddresses([
@@ -3578,11 +3644,20 @@ export function EmailComposer({
// draft, and never fall back to sending it unsigned (#1207). It signs
// or encrypts the message with the `last` finalisers' additions.
const sealed = lastStage(finalHtmlBody, finalBody);
// The label as it stands now, from the policy of the sending account.
const labelPolicy = labelsPolicyFor({ email: currentIdentity?.email, accountId: composingAccountId });
const sendLabel = resolveLabel(labelPolicy, classificationRef.current.choice, labelOriginal);
// A crypto plugin builds the MIME itself: it gets the labelled
// subject, and its raw send gets the label's headers, matched by the
// token it can pass back (lib/outgoing/classification-raw.ts).
const takeoverLabelled = classifyOutgoing(labelPolicy, sendLabel, { subject });
const labelToken = beginLabelTakeover(labelPolicy, sendLabel, currentIdentity?.id ?? '');
const composeSendRequest = {
labelToken: labelToken ?? undefined,
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
subject,
subject: takeoverLabelled.subject,
htmlBody: sealed.html || '',
textBody: sealed.text,
identityId: currentIdentity?.id || '',
@@ -3598,7 +3673,12 @@ export function EmailComposer({
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
],
};
const pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest);
let pluginOutcome: Awaited<ReturnType<typeof emailHooks.onComposeSend.takeOver>>;
try {
pluginOutcome = await emailHooks.onComposeSend.takeOver(composeSendRequest);
} finally {
endLabelTakeover(labelToken);
}
if (pluginOutcome === 'aborted') {
// The plugin shows its own error, or the user cancelled its prompt.
debug.log('email', 'Send aborted by an onComposeSend plugin handler');
@@ -3651,6 +3731,8 @@ export function EmailComposer({
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
// Below whatever the plugins added (a footer), the `last` finalisers.
const sealedOutgoing = lastStage(outgoing.htmlBody, outgoing.textBody);
// The label after the plugins had their say, so none can drop it.
const labelled = classifyOutgoing(labelPolicy, sendLabel, { subject: outgoing.subject, headers: outgoing.headers });
// Strip the cross-account namespace from the identity id before
// handing it to the parent - the JMAP server only knows the raw
@@ -3682,7 +3764,7 @@ export function EmailComposer({
to: outgoing.to,
cc: outgoing.cc,
bcc: outgoing.bcc,
subject: outgoing.subject,
subject: labelled.subject,
body: sealedOutgoing.text,
htmlBody: sealedOutgoing.html || undefined,
draftId: finalDraftId && !draftElsewhere ? finalDraftId : undefined,
@@ -3699,7 +3781,8 @@ export function EmailComposer({
requestDsn: requestDsn || undefined,
requireTls: requireTls || undefined,
priority: priority !== 'normal' ? priority : undefined,
headers: outgoing.headers,
headers: labelled.headers,
labelHeaders: labelled.labelHeaders,
delayedUntil: effectiveDelayedUntil,
composerId: pluginComposerIdRef.current ?? undefined,
});
@@ -3709,7 +3792,7 @@ export function EmailComposer({
// Replying trusts the recipients, but not while the organisation
// blocks remote content: the list would take effect the moment the
// block is lifted, with senders nobody chose to trust.
if ((mode === 'reply' || mode === 'replyAll') && !remoteContentBlockedByOrg()) {
if ((mode === 'reply' || mode === 'replyAll') && !remoteContentBlockedByOrg() && !remoteContentBlockedByOrg(composingAccountId)) {
for (const recipient of [...outgoing.to, ...outgoing.cc].filter(Boolean)) {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, recipient).catch(err => {
@@ -3749,6 +3832,11 @@ export function EmailComposer({
explicitCloseRef.current = false;
sentRef.current = false;
debug.error('Failed to send email:', err);
if (isLabelRequiredError(err)) {
setLabelInvalid(true);
toast.error(tClassification('required_toast'));
return;
}
if (err instanceof RecipientsRejectedError) {
// Nothing went out; the message stays open here so the addresses
// can be fixed. The SMTP replies say why each one was refused (#1123).
@@ -4494,7 +4582,7 @@ export function EmailComposer({
)}
{/* Subject field */}
<div className="flex items-center gap-2 px-4 py-2.5">
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5">
<label htmlFor={`${fieldIdBase}-subject`} className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</label>
<Input
id={`${fieldIdBase}-subject`}
@@ -4512,6 +4600,18 @@ export function EmailComposer({
}}
className="flex-1 border-0 h-8 px-0 text-sm"
/>
{classification.policy.list.length > 0 && (
// A chip at the end of the subject row; on a phone on its own line under it.
<ClassificationLabelPicker
ref={classificationPickerRef}
labels={classification.policy.list}
selected={classification.label}
required={classification.policy.required}
invalid={labelInvalid}
onPick={classification.setChoice}
className="shrink-0 max-md:order-last max-md:basis-full max-md:ps-14"
/>
)}
</div>
</div>
@@ -4885,6 +4985,24 @@ export function EmailComposer({
</ComposerToolbarMenuRadioItem>
))}
</div>
{classification.policy.list.length > 0 && (
<>
<div role="separator" className="my-1 h-px bg-border" />
<div role="group" aria-label={tClassification('picker_label')} data-testid="composer-options-classification">
<div className="px-3 pt-1 pb-0.5 text-xs font-medium text-muted-foreground" aria-hidden="true">{tClassification('picker_label')}</div>
{[...(classification.policy.required ? [] : [null]), ...classification.policy.list].map((option) => (
<ComposerToolbarMenuRadioItem
key={option?.id ?? 'none'}
checked={(classification.label?.id ?? null) === (option?.id ?? null)}
onSelect={() => classification.setChoice(option?.id ?? null)}
testId={`composer-options-label-${option?.id ?? 'none'}`}
>
{option?.name ?? tClassification('none')}
</ComposerToolbarMenuRadioItem>
))}
</div>
</>
)}
<ComposerPluginMenuItems surface="composer-options-menu" context={composerActionContext} onSelect={close} separator />
<ComposerPluginMenuItems surface="composer-toolbar-actions" context={composerActionContext} onSelect={close} separator onlyWhenNarrow />
</>}
+13
View File
@@ -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 && (
<ContextMenuItem
icon={Fish}
label={tPhishing("action")}
testId="ctx-batch-report-phishing"
onClick={() => handleAction(batchPhishing.report)}
/>
)}
</>
)}
</>
+15 -1
View File
@@ -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' ? <ShieldCheck /> : <ShieldAlert />}
</IconButton>
)}
{selectionPhishing.available && !batchNarrow && (
<IconButton
label={tPhishing('action')}
onClick={selectionPhishing.report}
disabled={isProcessing}
data-testid="batch-report-phishing"
>
<Fish />
</IconButton>
)}
{!batchNarrow && (
<>
<IconButton label={t('batch_actions.mark_read')} onClick={() => handleBatchMarkAsRead(true)} disabled={isProcessing}>
+29 -9
View File
@@ -32,6 +32,9 @@ import { ContextMenu } from "@/components/ui/context-menu";
import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu";
import { EmailContextMenu } from "./email-context-menu";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
import { PhishingMarkBanner } from "./phishing-mark-banner";
import { turnOffLinks } from "@/lib/phishing-links";
import { usePhishingMarked } from "@/hooks/use-phishing-report";
import { useCopyToAccount, useEmailExport, usePrintRequestStore } from "@/hooks/use-message-file-actions";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import { useComposingSurface } from "@/hooks/use-composing-surface";
@@ -132,6 +135,7 @@ import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlo
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { ExternalSenderChip } from "./external-sender-chip";
import { ReceivedLabelChip } from "./received-label-chip";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { ReadReceiptBanner } from "./read-receipt-banner";
@@ -858,6 +862,7 @@ export function MvExpandedConversationCard({
)}
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} className="self-center" />
<ReceivedLabelChip email={email} className="self-center" />
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
@@ -1313,9 +1318,11 @@ export function EmailViewer({
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
// No third-party traffic (#903), or the organisation locked "block": remote
// content never loads, whatever trust says, and nothing offers to load it.
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
// The policy of the message's own account: in the unified inbox or a Pro
// tab that may be another signed-in account than the active one.
const remoteBlockedByOrg = useRemoteContentBlockedByOrg(email?.sourceClientAccountId);
// Also true while the policy is still loading: trust can't unblock yet.
const remoteHeldBack = useRemoteContentHeldBack();
const remoteHeldBack = useRemoteContentHeldBack(email?.sourceClientAccountId);
const tThirdParty = useTranslations('third_party');
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
@@ -2647,10 +2654,15 @@ export function EmailViewer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, allowExternalContent, remoteHeldBack]);
// Reported as phishing ($phishing): a banner, its links turned off and
// no cards (a phishing mail's "booking" is the lure).
const phishingMarked = usePhishingMarked(email);
const tPhishing = useTranslations('phishing_report');
// Booking, parcel and order cards of the message itself (never of an
// unwrapped attached .eml); empty unless the sender is verified and the
// admin gate is on.
const structuredCards = useStructuredMail(email, blobSource);
const structuredCards = useStructuredMail(phishingMarked ? null : email, blobSource);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
@@ -2807,14 +2819,14 @@ export function EmailViewer({
return text?.trim() ? text : htmlToReadableText(decodedEmailContent.html);
}, [decodedEmailContent, emailContent, email]);
const effectiveEmailContent = useMemo(() => {
if (!plainView || !decodedEmailContent.isHtml) return decodedEmailContent;
return {
const shown = !plainView || !decodedEmailContent.isHtml ? decodedEmailContent : {
html: collapsePlainTextQuotes(plainTextToSafeHtml(readableText()), { show: t('show_quoted_text'), hide: t('hide_quoted_text') }),
isHtml: false,
hasStyleTag: false,
externalBlocked: false,
};
}, [plainView, decodedEmailContent, readableText, t]);
return phishingMarked ? { ...shown, html: turnOffLinks(shown.html, { title: tPhishing('link_off'), note: tPhishing('link_off_note') }) } : shown;
}, [plainView, decodedEmailContent, readableText, t, phishingMarked, tPhishing]);
// Reader view: the message full screen in the app's own look.
const [readerViewOpen, setReaderViewOpen] = useState(false);
// Reading mask: dims the body except a band that follows pointer and caret.
@@ -2822,8 +2834,9 @@ export function EmailViewer({
const bodyWrapRef = useRef<HTMLDivElement>(null);
const readerViewHtml = useMemo(() => {
if (!readerViewOpen) return '';
return decodedEmailContent.isHtml ? plainTextToSafeHtml(readableText()) : decodedEmailContent.html;
}, [readerViewOpen, decodedEmailContent, readableText]);
const html = decodedEmailContent.isHtml ? plainTextToSafeHtml(readableText()) : decodedEmailContent.html;
return phishingMarked ? turnOffLinks(html, { title: tPhishing('link_off'), note: tPhishing('link_off_note') }) : html;
}, [readerViewOpen, decodedEmailContent, readableText, phishingMarked, tPhishing]);
const resolveAttachmentName = useCallback(
(attachment: EffectiveAttachment) => {
@@ -3742,7 +3755,8 @@ export function EmailViewer({
return extractListHeaders(email.headers);
}, [email?.headers]);
const shouldShowUnsubBanner =
// Not for a reported message: its unsubscribe link is the phisher's.
const shouldShowUnsubBanner = !phishingMarked &&
listHeaders?.listUnsubscribe?.preferred &&
!dismissedUnsubBanners.has(email?.messageId || '');
@@ -4914,6 +4928,7 @@ export function EmailViewer({
)}
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<ReceivedLabelChip email={email} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5095,6 +5110,7 @@ export function EmailViewer({
)}
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<ReceivedLabelChip email={email} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5315,6 +5331,7 @@ export function EmailViewer({
/>
<BrandVerifiedBadge domain={brandIndicatorDomain(email)} />
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<ReceivedLabelChip email={email} />
{senderVerification && (
<span
className={cn(
@@ -5604,6 +5621,8 @@ export function EmailViewer({
</div>
)}
{phishingMarked && <PhishingMarkBanner />}
{/* Draft Banner: a scheduled message has its own. */}
{isDraft && !isScheduled && (
<div className="border-b border-border bg-warning/10">
@@ -6138,6 +6157,7 @@ export function EmailViewer({
onClose={() => setInAppPreview(null)}
onDownload={downloadInAppPreview}
getFileContent={getInAppPreviewContent}
linksOff={phishingMarked}
/>
)}
</>
+12 -2
View File
@@ -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,
];
}
+14
View File
@@ -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 && (
<ToolbarButton
icon={Fish}
label={tPhishing("action")}
onClick={phishing.report}
disabled={isProcessing}
/>
)}
{selectionCanLeaveFolder && (
<ToolbarButton
icon={FolderX}
+7 -3
View File
@@ -92,8 +92,12 @@ const MAIL_PARAGRAPH_MARGIN = "margin:1em 0;";
export function keepLoadedParagraphSpacing(html: string): string {
if (!html.includes("<p")) return html;
const root = document.createElement("div");
root.innerHTML = html;
// A template is inert: parsed into a live element, the quoted original's
// remote images (trackers) would load in the app document right here,
// whatever the remote-content setting or the organisation's lock says.
const template = document.createElement("template");
template.innerHTML = html;
const root = template.content;
let stamped = false;
root.querySelectorAll("p").forEach((p) => {
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. */
+20
View File
@@ -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 (
<div
role="note"
data-testid="phishing-mark-banner"
className={cn('flex items-start gap-2 border-b border-destructive/30 bg-destructive/10 px-6 py-2.5 text-sm text-destructive', className)}
>
<Fish className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 font-medium">{t('banner')}</span>
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
'use client';
import { useId, type KeyboardEvent } from 'react';
import { useTranslations } from 'next-intl';
import { Loader2, ShieldAlert } from '@/components/icons';
import { Button } from '@/components/ui/button';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { reportEmailsAsPhishing, undoPhishingReports, type PhishingReportOutcome } from '@/lib/phishing-report-flow';
import { usePhishingReportStore } from '@/stores/phishing-report-store';
import { toast } from '@/stores/toast-store';
/**
* "Report phishing?" with the address the report goes to, then the
* reports, one per message, and a toast with Undo. Mounted once in the app
* layout; every menu asks through usePhishingReportStore.
*/
export function PhishingReportHost() {
const t = useTranslations('phishing_report');
const pending = usePhishingReportStore((s) => s.pending);
const busy = usePhishingReportStore((s) => s.busy);
const id = useId();
const dialogRef = useFocusTrap({ isActive: !!pending, restoreFocus: false });
const cancel = () => {
if (!busy) usePhishingReportStore.getState().close();
};
const showOutcome = (outcome: PhishingReportOutcome) => {
const { reported, failed, notMoved, moved } = outcome;
const address = outcome.addresses.join(', ');
if (reported.length > 0) {
const undo = moved.length > 0
? {
label: t('undo'),
onClick: () => {
void undoPhishingReports(outcome).then((back) => {
if (back > 0) toast.info(t('undone', { count: back }));
else toast.error(t('undo_failed'));
});
},
}
: undefined;
if (notMoved.length > 0) {
toast.warning(t('not_moved', { address, count: notMoved.length }), { action: undo });
} else {
toast.success(t('done', { address, count: reported.length }), { action: undo });
}
}
if (failed.length > 0) {
toast.error(t('failed', { count: failed.length }), {
message: failed.map((email) => email.subject || t('no_subject')).join(', '),
});
}
};
const confirm = async () => {
const state = usePhishingReportStore.getState();
if (!state.pending || state.busy) return;
state.setBusy(true);
let outcome: PhishingReportOutcome | null = null;
try {
outcome = await reportEmailsAsPhishing(state.pending.emails);
} catch {
outcome = null;
}
const emails = state.pending.emails;
usePhishingReportStore.getState().close();
if (outcome) showOutcome(outcome);
else toast.error(t('failed', { count: emails.length }));
};
// Keys stay in the dialog (Escape must not close the message behind it,
// j/k must not move the list); Enter and Space press the focused button.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
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 (
<div
className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[1px] animate-in fade-in duration-150"
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel();
}}
onKeyDown={onKeyDown}
>
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-message`}
aria-busy={busy || undefined}
data-testid="phishing-report-dialog"
className="w-full max-w-md overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="px-5 pt-5 sm:px-6 sm:pt-6">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<ShieldAlert className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">{t('confirm_title')}</h2>
<p id={`${id}-message`} className="mt-1 text-sm text-muted-foreground [overflow-wrap:anywhere]">
{t('confirm_message', { address: pending.addresses.join(', '), count })}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 px-5 py-4 sm:px-6">
<Button variant="outline" onClick={cancel} disabled={busy} data-testid="phishing-report-cancel">
{t('cancel')}
</Button>
<Button variant="destructive" onClick={() => void confirm()} disabled={busy} data-testid="phishing-report-confirm">
{busy && <Loader2 className="me-2 h-4 w-4 animate-spin" aria-hidden="true" />}
{busy ? t('sending') : t('confirm')}
</Button>
</div>
</div>
</div>
);
}
+10 -4
View File
@@ -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 <img> 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 <br> 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);
+39
View File
@@ -0,0 +1,39 @@
'use client';
import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { LabelIcon } from '@/components/icons';
import type { Email } from '@/lib/jmap/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { receivedLabel, textColorOn } from '@/lib/classification/labels';
import { useAccountPolicy } from '@/hooks/use-account-policy';
import { cn } from '@/lib/utils';
/**
* The classification label a received message carries, when it is one of
* the labels of the message's own account (SIO-Label marking, else the
* admin's extra header). Information only: anyone can write the header,
* so nothing is relaxed because of it. List rows don't show it (they don't
* fetch the headers).
*/
export function ReceivedLabelChip({ email, className }: { email: Pick<Email, 'headers' | 'sourceClientAccountId'>; 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 (
<span
data-testid="received-label-chip"
title={`${name}. ${t('received_hint')}`}
aria-label={name}
role="img"
className={cn('inline-flex max-w-full shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem] font-medium leading-4', className)}
style={{ backgroundColor: label.color, color: textColorOn(label.color) }}
>
<LabelIcon className="h-3 w-3 shrink-0" aria-hidden="true" />
<span className="truncate" aria-hidden="true">{label.name}</span>
</span>
);
}
+10 -1
View File
@@ -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<HTMLImageElement>(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
>
<img
ref={imgRef}
src={node.attrs.src}
src={parked ? TRANSPARENT_BLOCKED_PIXEL : node.attrs.src}
data-remote-parked={parked ? "" : undefined}
alt={node.attrs.alt || ""}
title={node.attrs.title || undefined}
style={{ width: "100%", height: "auto", display: "block" }}
+23 -11
View File
@@ -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";
@@ -66,8 +67,11 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { useRemoteContentBlockedByOrg, useRemoteContentHeldBack } from "@/hooks/use-third-party-mode";
import { useRemoteContentBlockedByOrg, useRemoteContentChecks } from "@/hooks/use-third-party-mode";
import { ConversationHeaderActions, type ConversationActions } from "./conversation-header-actions";
import { PhishingMarkBanner } from "./phishing-mark-banner";
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,
@@ -107,11 +111,10 @@ export function useConversationExternalContent() {
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
// No third-party traffic (#903), or the organisation locked "block":
// nothing remote loads and nothing offers to.
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
// Also true until the policy has loaded: trust can't unblock before it.
const remoteHeldBack = useRemoteContentHeldBack();
// No third-party traffic (#903), or the organisation locked "block" for
// the message's own account: nothing remote loads and nothing offers to.
// Also held back until that account's policy is known.
const remoteContent = useRemoteContentChecks();
const allow = useCallback((emailId: string) => {
setAllowedIds(prev => new Set(prev).add(emailId));
@@ -122,13 +125,13 @@ export function useConversationExternalContent() {
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
if (remoteHeldBack) return false;
if (remoteContent.heldBack(email.sourceClientAccountId)) return false;
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
};
const trustSender = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
if (!senderEmail || remoteBlockedByOrg) return undefined;
if (!senderEmail || remoteContent.blockedByOrg(email.sourceClientAccountId)) return undefined;
return () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
@@ -395,6 +398,7 @@ function EmailCard({
{sender?.name || (sender?.email && formatAddress(sender.email)) || t('email_viewer.unknown_sender')}
</span>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
<ReceivedLabelChip email={email} />
{email.isScheduled && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-primary/10 text-primary shrink-0">
<CalendarClock className="w-3.5 h-3.5" />
@@ -545,7 +549,7 @@ export function ConversationEmailBody({
bodyStyle,
className,
}: ConversationEmailBodyProps) {
const remoteBlockedByOrg = useRemoteContentBlockedByOrg();
const remoteBlockedByOrg = useRemoteContentBlockedByOrg(email.sourceClientAccountId);
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
@@ -572,8 +576,11 @@ export function ConversationEmailBody({
const fallbackSource = useEmailStore(useShallow(() => resolveEmailBlobContext(email, client)));
const source = blobSource ?? fallbackSource;
const { client: blobClient, accountId } = source;
// Reported as phishing: a banner, links off, no cards (as in the reader).
const phishingMarked = usePhishingMarked(email);
const tPhishing = useTranslations('phishing_report');
// Booking, parcel and order cards; only an expanded message mounts this body.
const structuredCards = useStructuredMail(email, source);
const structuredCards = useStructuredMail(phishingMarked ? null : email, source);
// Local CID URLs belong to a particular client/owner/message/part list.
// Hide the previous set during render, before the new fetch's effect runs.
const cidRequest = useMemo(() => ({ blobClient, accountId, emailId: email.id, attachments: email.attachments }),
@@ -740,7 +747,7 @@ export function ConversationEmailBody({
// "Show mail as plain text" (Settings > Accessibility): the message's own
// text/plain part, or a readable conversion of the HTML.
const mailViewMode = useSettingsStore((state) => state.mailViewMode);
const emailContent = useMemo(() => {
const shownContent = useMemo(() => {
if (mailViewMode !== 'plain' || !formattedContent.isHtml) return formattedContent;
const textPart = !pluginBody.html ? email.textBody?.[0] : undefined;
let text = textPart?.partId && textPart.partId !== email.htmlBody?.[0]?.partId && (textPart.type || 'text/plain').toLowerCase() === 'text/plain'
@@ -755,6 +762,10 @@ export function ConversationEmailBody({
isHtml: false,
};
}, [mailViewMode, formattedContent, pluginBody.html, email, t]);
const emailContent = useMemo(
() => (phishingMarked ? { ...shownContent, html: turnOffLinks(shownContent.html, { title: tPhishing('link_off'), note: tPhishing('link_off_note') }) } : shownContent),
[shownContent, phishingMarked, tPhishing],
);
// Parts the body embeds via cid: stay out of the attachment row while the
// user hides inline images - the desktop viewer's rule, shared through
@@ -897,6 +908,7 @@ export function ConversationEmailBody({
return (
<div className={className}>
{phishingMarked && <PhishingMarkBanner className="px-4" />}
{/* External content warning */}
{hasBlockedContent && !allowExternal && emailContent.isHtml && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
@@ -0,0 +1,55 @@
'use client';
import { useMemo, useState } from 'react';
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
import { useAccountSubjects } from '@/lib/org/account-subjects';
import { labelsPolicyFor, resolveLabel, labelMissing, type OriginalMessage } from '@/lib/outgoing/classification';
import type { LabelsPolicy } from '@/lib/classification/labels';
import { usePolicyStore } from '@/stores/policy-store';
export interface ComposerClassification {
policy: LabelsPolicy;
/** The label the message goes out with as things stand. */
label: ClassificationLabel | null;
/** The user's pick: an id, null for none, undefined for "not picked" (original's or default). */
choice: string | null | undefined;
setChoice: (choice: string | null) => void;
/** Sending needs a label and there is none. */
missing: boolean;
/** What a saved draft keeps (X-Bulwark-Label); undefined while labels are off. */
draftChoice: string | null | undefined;
}
/**
* The composer's classification label: the labels of the account and
* identity the message goes out from, the user's pick, else the original's
* label (replies and forwards), else the default. Follows the policy as it
* loads and the From as it changes.
*/
export function useComposerClassification(input: {
accountId: string | null | undefined;
senderEmail: string | undefined;
/** The original of a reply or forward. */
original?: OriginalMessage | null;
initialChoice?: string | null;
}): ComposerClassification {
const policyState = usePolicyStore((s) => s.policy);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const subjects = useAccountSubjects((s) => s.subjects);
const [choice, setChoice] = useState<string | null | undefined>(input.initialChoice);
const policy = useMemo(
() => labelsPolicyFor({ email: input.senderEmail, accountId: input.accountId }),
// Re-read from the stores these follow.
// eslint-disable-next-line react-hooks/exhaustive-deps
[input.senderEmail, input.accountId, policyState, rawPolicy, subjects],
);
const label = useMemo(() => resolveLabel(policy, choice, input.original), [policy, choice, input.original]);
return {
policy,
label,
choice,
setChoice,
missing: labelMissing(policy, label),
draftChoice: policy.list.length === 0 ? undefined : (label?.id ?? null),
};
}
+13 -5
View File
@@ -3,11 +3,12 @@
import { useTranslations } from "next-intl";
import { Paperclip, Download } from "@/components/icons";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
import { turnOffLinks } from "@/lib/phishing-links";
import { getEffectiveTimeZone } from "@/lib/timezone";
import { stripBidiControls } from "@/lib/bidi";
import { isFilePreviewable } from "@/lib/file-preview";
import { useSettingsStore } from "@/stores/settings-store";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
// this preview renders are typed.
@@ -48,20 +49,27 @@ function escapeHtml(s: string): string {
// can show - often another message: a certified-mail (PEC) envelope wraps the
// original, and a forwarded PEC wraps that envelope - opens through
// onOpenAttachment, with download beside it.
export function EmlPreview({ message, onOpenAttachment }: {
export function EmlPreview({ message, onOpenAttachment, linksOff }: {
message: ParsedEml;
onOpenAttachment?: (attachment: EmlAttachment) => void;
/** Attached to a message reported as phishing: links are off. */
linksOff?: boolean;
}) {
const t = useTranslations("email_viewer");
// Always blocked with no third-party traffic (#903).
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const tPhishing = useTranslations("phishing_report");
// Always blocked with no third-party traffic (#903), while an
// organisation locks "block" in any signed-in account (the attachment's
// account isn't known here), and until the policy is known.
const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
const tFiles = useTranslations("files");
// dir="auto" on a wrapper we control lets the first strong character pick
// the direction, matching the main viewer.
const body = message.html
? `<div dir="auto">${sanitizeEmailBodyForIframe(message.html, blockRemote).html}</div>`
? `<div dir="auto">${linksOff
? turnOffLinks(sanitizeEmailBodyForIframe(message.html, blockRemote).html, { title: tPhishing("link_off"), note: tPhishing("link_off_note") })
: sanitizeEmailBodyForIframe(message.html, blockRemote).html}</div>`
: message.text
? `<pre dir="auto" style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
: "";
+22 -9
View File
@@ -14,10 +14,11 @@ import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
import { stripBidiControls } from "@/lib/bidi";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
import { turnOffLinks, type LinksOffText } from "@/lib/phishing-links";
import { useSettingsStore } from "@/stores/settings-store";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import type { FileResourceView } from "@/lib/plugin-types";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useRemoteContentHeldBackAnywhere } from "@/hooks/use-third-party-mode";
/**
* An HTML attachment as a document for a fully locked frame (sandbox="",
@@ -29,8 +30,10 @@ import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
* (the mechanism of GHSA-xvjh-v9c6-qcvc), while nothing sanitised it or
* stopped its remote images and meta refreshes.
*/
export function htmlAttachmentDocument(html: string, blockRemote: boolean): string {
const body = sanitizeEmailBodyForIframe(html, blockRemote).html;
export function htmlAttachmentDocument(html: string, blockRemote: boolean, linksOff?: LinksOffText): string {
const clean = sanitizeEmailBodyForIframe(html, blockRemote).html;
// An attachment of a message reported as phishing: its links are off too.
const body = linksOff ? turnOffLinks(clean, linksOff) : clean;
return `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${emailIframeCsp(blockRemote)}"><meta name="referrer" content="no-referrer"></head><body>${body}</body></html>`;
}
@@ -51,6 +54,8 @@ interface FilePreviewModalProps {
file?: FileResourceView | null;
/** The previous and next file of the folder, for ←/→. */
neighbours?: PreviewNeighbours;
/** The file is an attachment of a message reported as phishing: links in it are off. */
linksOff?: boolean;
}
function SimpleMarkdown({ content }: { content: string }) {
@@ -144,8 +149,13 @@ function processInline(text: string): React.ReactNode {
return parts.length === 1 ? parts[0] : <>{parts}</>;
}
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours }: FilePreviewModalProps) {
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours, linksOff }: FilePreviewModalProps) {
const t = useTranslations("files");
const tPhishing = useTranslations("phishing_report");
const linksOffText = useMemo<LinksOffText | undefined>(
() => (linksOff ? { title: tPhishing("link_off"), note: tPhishing("link_off_note") } : undefined),
[linksOff, tPhishing],
);
const [content, setContent] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -163,8 +173,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
// open-in-new-tab fallback use.
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [htmlDoc, setHtmlDoc] = useState<string | null>(null);
// Always blocked with no third-party traffic (#903).
const remoteBlockedByOrg = useNoThirdPartyTraffic();
// Always blocked with no third-party traffic (#903), while an
// organisation locks "block" in any signed-in account (the attachment's
// account isn't known here), and until the policy is known.
const remoteBlockedByOrg = useRemoteContentHeldBackAnywhere();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
// An attachment of the previewed .eml, shown in a preview of its own in
// place of this one. It holds a Back entry of its own, so Escape and Back
@@ -225,7 +237,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
// Sanitised text in a srcdoc frame, like the .eml preview; never a
// blob: URL of the sender's bytes (see htmlAttachmentDocument).
const text = await blob.text();
if (!cancelled) setHtmlDoc(htmlAttachmentDocument(text, blockRemote));
if (!cancelled) setHtmlDoc(htmlAttachmentDocument(text, blockRemote, linksOffText));
} else if (previewType === "eml") {
// Parse the embedded message (postal-mime, dynamic-imported so it
// stays off the bundle) and render it like an email via EmlPreview.
@@ -269,7 +281,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
URL.revokeObjectURL(revokeUrl);
}
};
}, [getFileContent, name, blockRemote]);
}, [getFileContent, name, blockRemote, linksOffText]);
const openEmlAttachment = (att: EmlAttachment) => {
if (!att.content) return;
@@ -308,6 +320,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
onClose={() => setNestedPreview(null)}
onDownload={downloadNested}
getFileContent={nestedContent}
linksOff={linksOff}
/>
);
}
@@ -352,7 +365,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
)}
{!loading && !error && fileType === "eml" && emlContent && (
<EmlPreview message={emlContent} onOpenAttachment={openEmlAttachment} />
<EmlPreview message={emlContent} onOpenAttachment={openEmlAttachment} linksOff={linksOff} />
)}
{!loading && !error && fileType === "image" && objectUrl && (
+3
View File
@@ -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,
+42 -6
View File
@@ -31,7 +31,7 @@ import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_MAILBOX_IDS, UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID, CROSS_VIEW_IDS, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore, waitForConnectedAccount } from "@/stores/account-store";
import { useShallow } from "zustand/react/shallow";
import { usePolicyStore } from "@/stores/policy-store";
import { usePolicyStore, whenPolicySettled } from "@/stores/policy-store";
import { isArchiveActionEnabled, useArchiveActionEnabled } from "@/lib/archive-gate";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { connectedAccountsGrew } from "@/lib/unified-mailbox";
@@ -121,6 +121,9 @@ import { WopiEditor } from "@/components/files/wopi-editor";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { isFilePreviewable } from "@/lib/file-preview";
import { prepareQuickReply, type QuickReplyQuestion } from "@/lib/outgoing/quick-reply";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
import { isPhishingMarked } from "@/lib/phishing-links";
import { automaticLabel, classifyOutgoing, labelSourceHeaders, reopenedLabelChoice } from "@/lib/outgoing/classification";
import enMessages from "@/locales/en/common.json";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
@@ -300,7 +303,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; client: NonNullable<typeof client> } | 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<typeof client>; linksOff?: boolean } | null>(null);
// Office attachments open read-only in the configured WOPI editor (#1047).
const [officeAttachment, setOfficeAttachment] = useState<{ blobId: string; name: string; type?: string; size?: number; accountId: string; slot: number | null } | null>(null);
const wopiStatus = useWopiStatus(true);
@@ -1333,6 +1337,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
messageId: selectedEmail.messageId,
inReplyTo: selectedEmail.inReplyTo,
references: selectedEmail.references,
// Its label, for the reply's or forward's classification label.
headers: labelSourceHeaders(selectedEmail.headers),
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
@@ -2158,6 +2164,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
requireTls?: boolean;
priority?: MessagePriority;
headers?: Record<string, string>;
labelHeaders?: Record<string, string>;
composerId?: string;
}) => {
// Nothing is sent: the composer keeps what only goes once the mail is out.
@@ -2182,7 +2189,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = composerSourceEmail?.id;
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt, requestDsn: data.requestDsn, requireTls: data.requireTls, priority: data.priority, headers: data.headers, localAccountId: data.localAccountId, draftAccountId: data.draftAccountId });
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data));
submitted = true;
announceSent(result, data);
setShowComposer(false);
@@ -2495,6 +2502,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
inReplyTo: draft.inReplyTo ?? undefined,
references: draft.references ?? undefined,
priority: messagePriorityFromHeaders(draft.headers),
// A sent message brought back (Undo, Cancel and edit) has no draft
// marker: its SIO-Label says which label it went out with.
classification: reopenedLabelChoice(draft.headers, localAccountId),
// Existing server-side attachments must ride along, or the composer
// starts empty and the next save/send silently rebuilds the draft
// without them (#849).
@@ -2846,6 +2856,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
originalFrom: email.from,
},
});
setComposerMode('forward');
@@ -3959,7 +3971,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
try {
const { mailAttachmentAction } = useSettingsStore.getState();
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) {
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient });
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, client: blobClient, linksOff: isPhishingMarked(email) });
return;
}
if (mailAttachmentAction === 'preview' && openOfficeAttachment(
@@ -4019,7 +4031,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const { mailAttachmentAction } = useSettingsStore.getState();
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
setPreviewAttachment({ blobId, name, type, accountId, client: blobClient });
setPreviewAttachment({ blobId, name, type, accountId, client: blobClient, linksOff: isPhishingMarked(selectedEmail) });
return;
}
if (!forceDownload && mailAttachmentAction === 'preview' && openOfficeAttachment(
@@ -4084,6 +4096,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const signatureIdentity = (sendingIdentity?.htmlSignature || sendingIdentity?.textSignature)
? sendingIdentity
: primaryIdentity;
// The classification label (enterprise pack): the original's, else the
// default. A required label with neither: the full composer, with the
// typed text, to pick one. Nothing is asked or sent before that.
await whenPolicySettled();
const quickLabel = automaticLabel({ email: headerFromEmail }, {
headers: selectedEmail.headers,
from: selectedEmail.from,
accountId: selectedEmail.sourceClientAccountId,
});
if (quickLabel.missing) {
toast.info(t('classification.quick_reply_needs_label'));
await handleReply(body);
return true;
}
// The same warnings, checks and finalisers as the composer's send
// (lib/outgoing/send-pipeline.ts): a quick reply is no way around them.
const settings = useSettingsStore.getState();
@@ -4155,11 +4182,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
references: selectedEmail.references,
});
// The label goes on last, as in the composer.
const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, {
subject: buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')),
});
// Send reply with just the body text
const result = await sendEmail(
client,
recipients,
buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')),
labelled.subject,
finalBody,
undefined,
undefined,
@@ -4173,6 +4205,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
threading?.references,
delayedUntil,
undefined,
labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined,
);
// As after the composer's send: the server refused some recipients, held
// reply or not, and nothing else would say they never get it.
@@ -4822,6 +4855,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
messageId: composerSourceEmail.messageId,
inReplyTo: composerSourceEmail.inReplyTo,
references: composerSourceEmail.references,
// Its label, for the reply's or forward's classification label.
headers: labelSourceHeaders(composerSourceEmail.headers),
quoteHeaderHtml: composerQuoteHeader?.html,
quoteHeaderText: composerQuoteHeader?.text,
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
@@ -5765,6 +5800,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onClose={() => setPreviewAttachment(null)}
onDownload={handlePreviewAttachmentDownload}
getFileContent={getPreviewAttachmentContent}
linksOff={previewAttachment.linksOff}
/>
)}
@@ -180,3 +180,35 @@ describe('Pro compose tab - cross-account send routing (#461)', () => {
}
});
});
describe('Pro compose tab - what the composer chose', () => {
beforeEach(() => {
sendEmail.mockClear();
});
it('sends with the priority, read receipt, DSN, REQUIRETLS, plugin headers and label the composer chose', async () => {
payloadRef.current = {
...basePayload,
requestReadReceipt: true,
requestDsn: true,
requireTls: true,
priority: 'high',
headers: { 'X-Plugin': '1' },
labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' },
};
render(<ProComposeTabBody tabId="tab-1" data={TAB_DATA} />);
fireEvent.click(screen.getByText('send'));
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
const args = sendEmail.mock.calls[0] as unknown[];
expect(args[args.length - 1]).toMatchObject({
requestReadReceipt: true,
requestDsn: true,
requireTls: true,
priority: 'high',
headers: { 'X-Plugin': '1' },
labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"' },
});
});
});
+5 -1
View File
@@ -14,6 +14,7 @@ import { debug } from "@/lib/debug";
import { threadKeyFor } from "@/lib/thread-utils";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { announceSent } from "@/lib/plugin-after-send";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
interface ProComposeTabBodyProps {
tabId: string;
@@ -89,7 +90,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
sendData.references,
sendData.delayedUntil,
sendData.envelopeMailFrom,
{ localAccountId: sendData.localAccountId, draftAccountId: sendData.draftAccountId },
// The composer's whole options, as the mail page passes them: the
// tab used to send without priority, read receipt, DSN, REQUIRETLS,
// plugin headers and the classification label.
composerSendOptions(sendData),
);
submitted = true;
if (result.rejectedRecipients?.length) {
+26 -2
View File
@@ -24,6 +24,7 @@ import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY, emailKeyFor } from "@/lib/thread
import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
import { messagePriorityFromHeaders } from '@/lib/message-priority';
import { labelSourceHeaders, reopenedLabelChoice } from '@/lib/outgoing/classification';
import { useArchiveActionEnabled } from '@/lib/archive-gate';
import { confirmMailRemoval } from '@/lib/mail-removal-confirm';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
@@ -67,6 +68,25 @@ interface ProEmailViewProps {
className?: string;
}
/**
* A message the tab read through another login, or from a shared or group
* account, carries where it came from (sourceClientAccountId,
* sourceAccountId), as rows of the unified inbox do: then actions that
* route by the message (Report phishing, the remote-content lock of that
* account) reach its own account. A message of the active login's own
* account stays as it is.
*/
export function stampProSource(email: Email, source: EmailBlobContext, loginId: string | undefined): Email {
if (email.sourceClientAccountId && email.sourceAccountId) return email;
const activeLogin = useAuthStore.getState().activeAccountId ?? undefined;
const login = loginId ?? source.clientAccountId ?? activeLogin;
const ownAccount = source.client?.getAccountId?.();
const account = source.accountId ?? ownAccount;
const foreign = (!!login && login !== activeLogin) || (!!account && !!ownAccount && account !== ownAccount);
if (!foreign || !login || !account) return email;
return { ...email, sourceClientAccountId: login, sourceAccountId: account };
}
function buildReplyContext(email: Email): ProReplyContext {
return {
from: email.from,
@@ -83,6 +103,7 @@ function buildReplyContext(email: Email): ProReplyContext {
messageId: email.messageId,
inReplyTo: email.inReplyTo,
references: email.references,
headers: labelSourceHeaders(email.headers),
};
}
@@ -200,7 +221,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
.then((loaded) => {
if (cancelled) return;
pending.applied = true;
setEmail(loaded);
setEmail(loaded ? stampProSource(loaded, pending.blobSource, clientOverride ? clientAccountId : undefined) : loaded);
setBlobSource(pending.blobSource);
if (loaded) onLoadedRef.current?.(loaded);
})
@@ -213,7 +234,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
return () => {
cancelled = true;
};
}, [client, clientOverride, emailId, accountId, fetchEmailContent]);
}, [client, clientOverride, clientAccountId, emailId, accountId, fetchEmailContent]);
const currentMailboxRole = useMemo(() => {
if (!email) return undefined;
@@ -287,6 +308,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
replyTo: {
subject: email.subject,
attachments: [payload.attachment],
headers: labelSourceHeaders(email.headers),
originalFrom: email.from,
},
sourceEmailId: email.id,
sourceEmailSource: writeSource,
@@ -467,6 +490,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
// composer without its files and the next save stripped them.
attachments: email.attachments,
priority: messagePriorityFromHeaders(email.headers),
classification: reopenedLabelChoice(email.headers, localAccountId),
},
}, { to: addresses(email.to), cc: addresses(email.cc), subject: draftSubject, body: bodyText }, onClose);
}, [email, accountId, localAccountId, identities, multiAccountIdentities, openComposeTab, onClose, t]);
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { useCallback, useMemo } from 'react';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { isReportable } from '@/lib/phishing-report';
import { isPhishingMarked } from '@/lib/phishing-links';
import { phishingSetupForEmail } from '@/lib/phishing-report-flow';
import { useAccountSubjects } from '@/lib/org/account-subjects';
import { emailKeyFor } from '@/lib/thread-utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useEmailStore } from '@/stores/email-store';
import { usePhishingReportStore } from '@/stores/phishing-report-store';
/**
* The roles of the folders a message is in, from the mailboxes the store
* knows: in search results or All mail the open folder says nothing about
* whether a message is the user's own sent mail.
*/
function folderRoles(email: Email, mailboxes: Mailbox[]): string[] {
const roles: string[] = [];
for (const id of Object.keys(email.mailboxIds ?? {})) {
const box = mailboxes.find((m) => m.id === id || m.originalId === id);
if (box?.role) roles.push(box.role);
}
return roles;
}
function reportable(email: Email, mailboxRole: string | null | undefined, mailboxes: Mailbox[]): boolean {
return isReportable(email, mailboxRole, folderRoles(email, mailboxes)) && !!phishingSetupForEmail(email);
}
/** Asks "Report phishing?" for the messages of `emails` that can be reported. */
export function requestPhishingReport(emails: Email[], mailboxRole?: string | null): void {
const mailboxes = useEmailStore.getState().mailboxes;
const list = emails.filter((email) => reportable(email, mailboxRole, mailboxes));
if (list.length === 0) return;
// A selection across accounts may go to more than one address: name each.
const addresses = [...new Set(list.map((email) => phishingSetupForEmail(email)!.address))];
usePhishingReportStore.getState().ask(list, addresses);
}
/**
* "Report phishing" for a set of messages in a folder of `mailboxRole`:
* available while the admin set an address for at least one of their
* accounts and one of them can be reported. Follows the policy as it loads.
*/
export function usePhishingReport(emails: ReadonlyArray<Email | null | undefined>, mailboxRole?: string | null) {
const policy = usePolicyStore((s) => s.policy);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const subjects = useAccountSubjects((s) => s.subjects);
const mailboxes = useEmailStore((s) => s.mailboxes);
const reported = usePhishingReportStore((s) => s.reported);
const list = useMemo(() => emails.filter((e): e is Email => !!e), [emails]);
const candidates = useMemo(
() => list.filter((email) => !reported[emailKeyFor(email)] && reportable(email, mailboxRole, mailboxes)),
// The setup is read from the stores these follow.
// eslint-disable-next-line react-hooks/exhaustive-deps
[list, mailboxRole, mailboxes, reported, policy, rawPolicy, subjects],
);
const report = useCallback(() => requestPhishingReport(candidates, mailboxRole), [candidates, mailboxRole]);
return { available: candidates.length > 0, count: candidates.length, report };
}
/**
* Whether a message shows as reported: its $phishing keyword, or reported
* in this session (a view that holds its own copy of the message, like a
* Pro tab, doesn't see the new keyword until it loads the message again).
*/
export function usePhishingMarked(email: Email | null | undefined): boolean {
const reported = usePhishingReportStore((s) => (email ? s.reported[emailKeyFor(email)] === true : false));
return isPhishingMarked(email) || reported;
}
+98 -25
View File
@@ -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<typeof usePolicyStore.getState>;
/**
* 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 };
}
+641
View File
@@ -0,0 +1,641 @@
import { test, expect, type Browser, type Page } from '@playwright/test';
import { folderRow } from './helpers/app';
import {
ENT,
ENT_PASSWORD,
adminContext,
contextOptions,
deliver,
getPolicy,
jmapAs,
putPolicy,
rawMessage,
requireEntEnv,
shot,
userPage,
} from './helpers/ent';
import { neutralizeDevOverlay, accountSwitcher } from './helpers/app';
/**
* Enterprise pack, package A (mail security core), through the real UI on a
* VM 106 test Stalwart with the ent-* accounts:
* - item 1: the organisation's locked "block remote content" holds for
* trusted senders (list and address book; passes on main since
* f8805b8d5), the trusted-senders list offers no Add under the lock, and
* a reply draft started before the lock loads no remote image when
* reopened;
* - item 2: Report phishing sends the message to ent-sec as an attachment
* (and as ARF), with no copy in Sent, and moves it to Junk with $phishing;
* - item 3: a required classification label blocks sending until picked,
* SIO-Label and Sensitivity arrive, quick reply and a Pro compose tab keep
* it, a group scope gets its own scheme.
* Setup goes through the admin API and JMAP; the policy is put back at the end.
*/
test.describe.configure({ mode: 'serial' });
test.setTimeout(180_000);
const RUN = Date.now().toString(36);
const PIXEL = `https://ent-pixel.example.org/${RUN}.png`;
const LOCK = { externalContentPolicy: { locked: true, value: 'block' } };
const GENERIC_LABELS = [
{ id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' },
{ id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' },
{ id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', description: '', sensitivity: 'Company-Confidential', subjectPrefix: '[Confidential]', extraHeaderValue: '' },
];
const GERMAN_LABELS = [
{ id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' },
{ id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', description: '', sensitivity: 'Company-Confidential', subjectPrefix: 'VS-NfD', extraHeaderValue: '' },
];
let original: Record<string, unknown>;
interface Msg {
id: string;
subject: string;
blobId: string;
keywords: Record<string, boolean>;
mailboxIds: Record<string, boolean>;
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<Msg[]> {
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<Msg> {
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<string> {
return (await (await jmapAs(account)).mailboxByRole(role))!.id;
}
async function policy(patch: { organisation?: Record<string, unknown>; restrictions?: Record<string, unknown>; 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<string, unknown>, phone = false): Promise<Page> {
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<void> {
// 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<typeof deliver>[0]): Promise<void> {
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<void> {
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) => `<p>${text}</p><p><img src="${PIXEL}" alt="pixel"></p><p><a href="https://ent-link.example.org/login">Log in here</a></p>`;
async function composeAndSend(page: Page, to: string, subject: string, beforeSend?: () => Promise<void>): Promise<void> {
await page.keyboard.press('c');
const composer = page.locator('[data-testid="email-composer"]');
await composer.waitFor({ state: 'visible', timeout: 15_000 });
const input = page.locator('[data-testid="composer-to"] input').first();
await input.click();
await input.fill(to);
await input.press('Enter');
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
await composer.locator('[contenteditable="true"]').first().click();
await page.keyboard.type(`Body of ${subject}`);
if (beforeSend) await beforeSend();
await page.locator('[data-testid="composer-subject"]').first().focus();
await page.keyboard.press('Control+Enter');
}
test.beforeAll(async ({ browser }) => {
requireEntEnv();
const admin = await adminContext(browser);
original = await getPolicy(admin);
await putPolicy(admin, { ...original, scopes: [] });
await admin.close();
});
test.afterAll(async ({ browser }) => {
if (!original) return;
const admin = await adminContext(browser);
await putPolicy(admin, original);
await admin.close();
});
// ─── Item 1: trusted senders vs the organisation's lock ──────────────────
test('item 1: a sender trusted in the list stays blocked once the organisation locks remote content (passes on main)', async ({ browser }) => {
const subject = `Trust list ${RUN}`;
await policy({}, browser);
await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, html: html('Newsletter') });
const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: false });
const pixel = pixelRequests(alice);
await openMessage(alice, subject);
await trustSender(alice);
await expect.poll(pixel.count, { timeout: 15_000 }).toBeGreaterThan(0);
// Before the lock: a reply draft whose quote may load the image.
await alice.keyboard.press('r');
await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
await alice.locator('[data-testid="email-composer"] [contenteditable="true"]').first().click();
await alice.keyboard.type(`Draft before the lock ${RUN}`);
await expect(alice.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', { timeout: 30_000 });
await alice.keyboard.press('Escape');
await alice.context().close();
await policy({ restrictions: LOCK }, browser);
const locked = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block' });
const seen = await locked.evaluate(async () => (await (await fetch('/api/admin/policy')).json()).restrictions?.externalContentPolicy);
expect(seen, 'the user gets the lock').toMatchObject({ locked: true, value: 'block' });
const lockedPixel = pixelRequests(locked);
await openMessage(locked, subject);
await expect(locked.getByTestId('external-content-notice')).toContainText('Your organisation blocks content from other servers');
await expect(locked.getByRole('button', { name: 'Always trust this sender' })).toHaveCount(0);
const srcdoc = await locked.locator('iframe').first().getAttribute('srcdoc');
// Blocked images keep the address only in data-blocked-src.
expect(srcdoc ?? '').not.toMatch(/\ssrc="https:\/\/ent-pixel/);
expect(lockedPixel.count()).toBe(0);
await shot(locked, 'a-item1-locked-trusted');
await locked.context().close();
});
test('item 1: under the lock the trusted senders list offers no Add (fails on main)', async ({ browser }) => {
await policy({ restrictions: LOCK }, browser);
const alice = await userPage(browser, ENT.alice);
await alice.locator('a[href$="/settings"]').first().click();
await alice.locator('[data-testid="settings-tab-reading"]').first().click();
await expect(alice.getByText('Trusted senders').first()).toBeVisible();
await alice.getByRole('button', { name: /^\d+ senders?$|None/ }).first().click();
await expect(alice.getByText('Add sender manually')).toHaveCount(0);
await shot(alice, 'a-item1-trusted-list-locked');
await alice.context().close();
});
test('item 1: a reply draft from before the lock loads no remote image when reopened (fails on main)', async ({ browser }) => {
await policy({ restrictions: LOCK }, browser);
const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block' });
const pixel = pixelRequests(alice);
await folderRow(alice, { role: 'drafts' }).first().click();
const row = alice.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: `Trust list ${RUN}` }).first();
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await alice.getByTestId('edit-draft').waitFor({ state: 'visible' });
await alice.waitForTimeout(2000);
// Viewing the draft loads nothing either.
expect(pixel.count(), 'requests while the draft is shown in the reader').toBe(0);
await alice.getByTestId('edit-draft').click();
await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 20_000 });
await alice.waitForTimeout(3000);
expect(pixel.count()).toBe(0);
const remote = await alice.locator(`[data-testid="email-composer"] img[src="${PIXEL}"]`).count();
expect(remote).toBe(0);
await alice.context().close();
});
test('item 1: a sender trusted in the address book stays blocked under the lock (passes on main)', async ({ browser }) => {
const subject = `Trust book ${RUN}`;
await policy({}, browser);
await deliverAndWait({ from: ENT.carol, to: [ENT.alice], subject, html: html('From the book') });
const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: true });
await openMessage(alice, subject);
await trustSender(alice);
await alice.context().close();
await policy({ restrictions: LOCK }, browser);
const locked = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSendersAddressBook: true });
const pixel = pixelRequests(locked);
await openMessage(locked, subject);
await expect(locked.getByTestId('external-content-notice')).toContainText('Your organisation blocks content');
expect(pixel.count()).toBe(0);
await locked.context().close();
});
// ─── Item 2: Report phishing ─────────────────────────────────────────────
test('item 1: a lock set in a group scope holds for its members only, also before their groups are known', async ({ browser }) => {
const subject = `Scoped lock ${RUN}`;
await policy({ scopes: [{ id: 'team-lock', label: 'Team lock', match: { groups: [ENT.team] }, policy: { restrictions: LOCK } }] }, browser);
await deliverAndWait({ from: ENT.bob, to: [ENT.alice, ENT.carol], subject, html: html('Scoped') });
// ent-alice is in ent-team: blocked, with ent-bob trusted in the list.
const alice = await seededPage(browser, ENT.alice, { externalContentPolicy: 'block', trustedSenders: [ENT.bob] });
const pixel = pixelRequests(alice);
await openMessage(alice, subject);
await expect(alice.getByTestId('external-content-notice')).toContainText('Your organisation blocks content');
expect(pixel.count()).toBe(0);
await alice.context().close();
// ent-carol isn't: her own choice applies, no organisation note.
const carol = await seededPage(browser, ENT.carol, { externalContentPolicy: 'ask' });
await openMessage(carol, subject);
await expect(carol.getByTestId('external-content-notice')).toBeVisible();
await expect(carol.getByTestId('external-content-notice')).not.toContainText('Your organisation blocks content');
await carol.context().close();
});
test('item 2: the admin sets the report address on Organisation > Security', async ({ browser }) => {
await policy({}, browser);
const admin = await adminContext(browser);
const page = await admin.newPage();
await page.goto('/admin/org-security');
const field = page.getByTestId('org-phishing-address');
await field.scrollIntoViewIfNeeded();
await field.fill('ent-sec@example.org');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
await shot(page, 'a-phishing-admin');
const stored = await getPolicy(admin);
expect((stored.organisation as { phishingReport: unknown }).phishingReport).toEqual({ address: 'ent-sec@example.org', format: 'forward' });
await admin.close();
});
test('item 2: Report phishing sends the message to ent-sec as an attachment and moves it to Junk with $phishing', async ({ browser }) => {
const subject = `Phish one ${RUN}`;
await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, html: html('Your account is locked') });
const alice = await userPage(browser, ENT.alice);
await openMessage(alice, subject);
await alice.getByRole('button', { name: 'More actions' }).first().click();
await alice.getByTestId('ctx-report-phishing').click();
const dialog = alice.getByTestId('phishing-report-dialog');
await expect(dialog).toContainText(ENT.sec);
await shot(alice, 'a-phishing-confirm');
await alice.getByTestId('phishing-report-confirm').click();
await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 });
const report = await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`);
expect(report.attachments?.map((a) => a.type)).toEqual(['message/rfc822']);
const raw = await rawMessage(ENT.sec, report.blobId);
expect(raw).toContain('Reported as phishing by');
expect(attachedMessage(raw)).toContain(`Subject: ${subject}`);
const junk = await roleMailboxId(ENT.alice, 'junk');
const moved = await waitForMessage(ENT.alice, RUN, (m) => m.subject === subject && !!m.mailboxIds[junk] && m.keywords.$phishing === true);
expect(moved.keywords.$junk).toBe(true);
// No copy in Sent.
const sent = await roleMailboxId(ENT.alice, 'sent');
expect((await messages(ENT.alice, RUN)).filter((m) => m.subject.startsWith('Phishing:') && m.mailboxIds[sent])).toEqual([]);
// In Junk: the banner, and the link is off.
await folderRow(alice, { role: 'junk' }).first().click();
await openMessage(alice, subject);
await expect(alice.getByTestId('phishing-mark-banner')).toContainText('You reported this message as phishing');
const srcdoc = await alice.locator('iframe').first().getAttribute('srcdoc');
expect(srcdoc ?? '').toContain('Log in here');
expect(srcdoc ?? '').not.toContain('https://ent-link.example.org/login');
await shot(alice, 'a-phishing-banner');
await alice.context().close();
});
test('item 2: Undo after a report moves the message back and clears $phishing', async ({ browser }) => {
const subject = `Phish undo ${RUN}`;
await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Undo check' });
const alice = await userPage(browser, ENT.alice);
await openMessage(alice, subject);
await alice.getByRole('button', { name: 'More actions' }).first().click();
await alice.getByTestId('ctx-report-phishing').click();
await alice.getByTestId('phishing-report-confirm').click();
const toast = alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first();
await expect(toast).toBeVisible({ timeout: 30_000 });
await alice.getByRole('button', { name: 'Undo', exact: true }).first().click();
await expect(alice.getByText('Moved back. The report was already sent.').first()).toBeVisible({ timeout: 30_000 });
const inbox = await roleMailboxId(ENT.alice, 'inbox');
const back = await waitForMessage(ENT.alice, RUN, (m) => m.subject === subject && !!m.mailboxIds[inbox]);
expect(back.keywords.$phishing).toBeUndefined();
expect(back.keywords.$junk).toBeUndefined();
// The report itself went out.
await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`);
await alice.context().close();
});
test('item 2: the ARF format arrives as a feedback report', async ({ browser }) => {
const subject = `Phish arf ${RUN}`;
await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'arf' } } }, browser);
await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Click here' });
const alice = await userPage(browser, ENT.alice);
await alice.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`).first().click({ button: 'right', timeout: 60_000 });
await alice.getByTestId('ctx-report-phishing').click();
await alice.getByTestId('phishing-report-confirm').click();
await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 });
const report = await waitForMessage(ENT.sec, RUN, (m) => m.subject === `Phishing: ${subject}`);
const raw = await rawMessage(ENT.sec, report.blobId);
expect(raw).toMatch(/Content-Type: multipart\/report; report-type=feedback-report/);
expect(raw).toContain('Feedback-Type: fraud');
expect(raw).toContain('Version: 1');
expect(raw).toMatch(/User-Agent: Bulwark\//);
expect(raw).toContain('Content-Type: message/rfc822');
expect(attachedMessage(raw)).toContain(`Subject: ${subject}`);
await alice.context().close();
});
test('item 2: a group scope reports for ent-team only; ent-carol has no action', async ({ browser }) => {
const subject = `Phish scope ${RUN}`;
await policy({
organisation: { phishingReport: { address: '', format: 'forward' } },
scopes: [{ id: 'team', label: 'Team', match: { groups: [ENT.team] }, policy: { organisation: { phishingReport: { address: ENT.sec } } } }],
}, browser);
await deliverAndWait({ from: ENT.bob, to: [ENT.carol, ENT.alice], subject, text: 'Scope check' });
const carol = await userPage(browser, ENT.carol);
await openMessage(carol, subject);
await carol.getByRole('button', { name: 'More actions' }).first().click();
await expect(carol.getByTestId('ctx-report-phishing')).toHaveCount(0);
await carol.context().close();
const alice = await userPage(browser, ENT.alice);
await openMessage(alice, subject);
await alice.getByRole('button', { name: 'More actions' }).first().click();
await expect(alice.getByTestId('ctx-report-phishing')).toBeVisible();
await alice.context().close();
});
test('item 2: the reader action and the confirm fit a phone', async ({ browser }) => {
const subject = `Phish phone ${RUN}`;
await policy({ organisation: { phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Phone check' });
const alice = await userPage(browser, ENT.alice, true);
await openMessage(alice, subject);
await alice.getByRole('button', { name: 'More actions' }).first().click();
const action = alice.getByText('Report phishing…').first();
await expect(action).toBeVisible();
await shot(alice, 'a-phishing-phone-menu');
await action.click();
const dialog = alice.getByTestId('phishing-report-dialog');
const box = await dialog.boundingBox();
expect(box && box.x).toBeGreaterThanOrEqual(0);
expect(box && box.x + box.width).toBeLessThanOrEqual(390);
await shot(alice, 'a-phishing-phone-confirm');
await alice.getByTestId('phishing-report-cancel').click();
await expect(dialog).toHaveCount(0);
expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await alice.context().close();
});
// ─── Item 3: classification labels ───────────────────────────────────────
test('item 3: the admin applies a preset and requires a label on Organisation > Classification', async ({ browser }) => {
await policy({}, browser);
const admin = await adminContext(browser);
const page = await admin.newPage();
await page.goto('/admin/org-labels');
await expect(page.getByTestId('org-labels-vsnfd-warning')).toBeVisible();
await page.getByTestId('org-labels-preset').selectOption('generic');
await expect(page.getByTestId('org-label-item')).toHaveCount(4);
await page.getByTestId('org-labels-required').click();
await page.getByTestId('org-labels-default').selectOption('');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
await shot(page, 'a-labels-admin');
const stored = (await getPolicy(admin)).organisation as { labels: { list: unknown[]; required: boolean; defaultId: string } };
expect(stored.labels.list).toHaveLength(4);
expect(stored.labels).toMatchObject({ required: true, defaultId: '' });
await admin.close();
});
test('item 3: a required label blocks sending until one is picked; SIO-Label and Sensitivity arrive', async ({ browser }) => {
const subject = `Label send ${RUN}`;
await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser);
const alice = await userPage(browser, ENT.alice);
await composeAndSend(alice, ENT.bob, subject);
await expect(alice.getByText('Pick a classification label before sending.').first()).toBeVisible();
await expect(alice.getByTestId('composer-classification-options')).toBeVisible();
await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
await shot(alice, 'a-labels-required');
await alice.getByTestId('composer-classification-option-confidential').click();
await expect(alice.getByTestId('composer-classification-trigger')).toContainText('Confidential');
await alice.locator('[data-testid="composer-subject"]').first().focus();
await alice.keyboard.press('Control+Enter');
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 });
const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`);
const raw = await rawMessage(ENT.bob, got.blobId);
expect(raw).toMatch(/^SIO-Label: marking="CONFIDENTIAL"; fgcolor=#[0-9a-f]{6}; bgcolor=#d97706\r?$/m);
expect(raw).toMatch(/^Sensitivity: Company-Confidential\r?$/m);
await alice.context().close();
// The recipient sees the label.
const bob = await userPage(browser, ENT.bob);
await openMessage(bob, `[Confidential] ${subject}`);
await expect(bob.getByTestId('received-label-chip').first()).toHaveText('Confidential');
await shot(bob, 'a-labels-received-chip');
await bob.context().close();
});
test('item 3: Undo send brings the message back with its label', async ({ browser }) => {
const subject = `Undo label ${RUN}`;
await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: 'internal', required: true, extraHeader: '' } } }, browser);
const alice = await userPage(browser, ENT.alice);
// Undo send on, through Settings (a synced setting).
await alice.locator('a[href$="/settings"]').first().click();
await alice.locator('[data-testid="settings-tab-composing"]').first().click();
const delay = alice.locator('[data-search-label="Undo send / send delay"] select').first();
await delay.selectOption('30');
await alice.getByText('Back to mail').first().click();
await folderRow(alice, { role: 'inbox' }).first().waitFor({ state: 'visible', timeout: 30_000 });
await composeAndSend(alice, ENT.bob, subject, async () => {
await alice.getByTestId('composer-classification-trigger').click();
await alice.getByTestId('composer-classification-option-confidential').click();
});
await alice.getByRole('button', { name: 'Undo send' }).first().click({ timeout: 20_000 });
await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible({ timeout: 20_000 });
// Not the default (Internal): the label it was sent with.
await expect(alice.getByTestId('composer-classification-trigger')).toContainText('Confidential');
await alice.locator('[data-testid="composer-subject"]').first().focus();
await alice.keyboard.press('Control+Enter');
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 });
const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`, 90_000);
expect(await rawMessage(ENT.bob, got.blobId)).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m);
// Undo send off again, for the other tests.
await alice.locator('a[href$="/settings"]').first().click();
await alice.locator('[data-testid="settings-tab-composing"]').first().click();
await alice.locator('[data-search-label="Undo send / send delay"] select').first().selectOption('0');
await alice.waitForTimeout(1500);
await alice.context().close();
});
test('item 3: a quick reply keeps the original’s label', async ({ browser }) => {
const subject = `[Confidential] Label send ${RUN}`;
await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser);
const bob = await userPage(browser, ENT.bob);
await openMessage(bob, subject);
const box = bob.getByPlaceholder('Write a quick reply...');
await box.fill(`Quick reply ${RUN}`);
await box.press('Control+Enter');
await expect(box).toHaveValue('', { timeout: 30_000 });
const reply = await waitForMessage(ENT.alice, RUN, (m) => m.subject === `[Confidential] Re: Label send ${RUN}`);
expect(await rawMessage(ENT.alice, reply.blobId)).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m);
await bob.context().close();
});
test('item 3: a quick reply with no label to keep opens the composer to pick one', async ({ browser }) => {
const subject = `Unlabelled ${RUN}`;
await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' } } }, browser);
await deliverAndWait({ from: ENT.carol, to: [ENT.bob], subject, text: 'No label' });
const bob = await userPage(browser, ENT.bob);
await openMessage(bob, subject);
const box = bob.getByPlaceholder('Write a quick reply...');
await box.fill(`Needs a label ${RUN}`);
await box.press('Control+Enter');
await expect(bob.locator('[data-testid="email-composer"]')).toBeVisible({ timeout: 20_000 });
await expect(bob.getByTestId('composer-classification-trigger')).toContainText('Pick a label');
// Nothing went out.
await bob.waitForTimeout(3000);
expect((await messages(ENT.carol, RUN)).filter((m) => m.subject.includes(`Re: ${subject}`))).toEqual([]);
await bob.context().close();
});
test('item 3: a group scope gets its own scheme (VS-NfD, RFC 2231 marking); ent-carol keeps Everyone’s', async ({ browser }) => {
const subject = `Scheme ${RUN}`;
await policy({
organisation: { labels: { list: GENERIC_LABELS, defaultId: 'internal', required: true, extraHeader: '' } },
scopes: [{ id: 'team', label: 'Team', match: { groups: [ENT.team] }, policy: { organisation: { labels: { list: GERMAN_LABELS, defaultId: 'offen', required: true, extraHeader: '' } } } }],
}, browser);
const carol = await userPage(browser, ENT.carol);
await carol.keyboard.press('c');
await carol.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
await expect(carol.getByTestId('composer-classification-trigger')).toContainText('Internal');
await carol.keyboard.press('Escape');
await carol.context().close();
const alice = await userPage(browser, ENT.alice);
await composeAndSend(alice, ENT.bob, subject, async () => {
await expect(alice.getByTestId('composer-classification-trigger')).toContainText('OFFEN');
await alice.getByTestId('composer-classification-trigger').click();
// Arrow keys move the pick; Enter closes.
await alice.keyboard.press('ArrowDown');
await alice.keyboard.press('Enter');
await expect(alice.getByTestId('composer-classification-trigger')).toContainText('VS-NfD');
});
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30_000 });
const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `VS-NfD ${subject}`);
const raw = await rawMessage(ENT.bob, got.blobId);
expect(raw).toContain("marking*=utf-8''VS-NUR%20F%C3%9CR%20DEN%20DIENSTGEBRAUCH");
await alice.context().close();
const bob = await userPage(browser, ENT.bob);
await openMessage(bob, `VS-NfD ${subject}`);
await expect(bob.getByTestId('received-label-chip').first()).toHaveText('VS-NfD');
await bob.context().close();
});
test('item 3: a Pro compose tab sends with its label and priority', async ({ browser }) => {
const subject = `Pro tab ${RUN}`;
await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: 'confidential', required: true, extraHeader: '' } } }, browser);
const alice = await seededPage(browser, ENT.alice, { proInterface: true, advancedSettings: true });
await alice.goto('/pro').catch(() => {});
await accountSwitcher(alice).waitFor({ state: 'visible', timeout: 60_000 }).catch(() => {});
await composeAndSend(alice, ENT.bob, subject, async () => {
await alice.getByTestId('composer-options-menu').first().click();
await alice.getByTestId('composer-priority-high').click();
await alice.keyboard.press('Escape');
});
const got = await waitForMessage(ENT.bob, RUN, (m) => m.subject === `[Confidential] ${subject}`);
const raw = await rawMessage(ENT.bob, got.blobId);
expect(raw).toMatch(/^SIO-Label: marking="CONFIDENTIAL"/m);
expect(raw).toMatch(/^X-Priority: 1/m);
await alice.context().close();
});
test('item 3: the picker and the admin pages fit a phone', async ({ browser }) => {
await policy({ organisation: { labels: { list: GENERIC_LABELS, defaultId: '', required: true, extraHeader: '' }, phishingReport: { address: ENT.sec, format: 'forward' } } }, browser);
const alice = await userPage(browser, ENT.alice, true);
await alice.getByRole('button', { name: /Compose|New message|Write/ }).first().click().catch(async () => { await alice.keyboard.press('c'); });
await alice.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15_000 });
const trigger = alice.getByTestId('composer-classification-trigger');
await expect(trigger).toBeVisible();
await trigger.click();
const options = alice.getByTestId('composer-classification-options');
const box = await options.boundingBox();
expect(box && box.x).toBeGreaterThanOrEqual(0);
expect(box && box.x + box.width).toBeLessThanOrEqual(390);
await shot(alice, 'a-labels-phone-picker');
await alice.keyboard.press('Escape');
expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await alice.context().close();
const admin = await adminContext(browser, true);
for (const [path, name] of [['/admin/org-labels', 'a-labels-admin-phone'], ['/admin/org-security', 'a-phishing-admin-phone']] as const) {
const page = await admin.newPage();
await page.goto(path);
await page.waitForLoadState('networkidle').catch(() => {});
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await shot(page, name);
await page.close();
}
await admin.close();
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
import { LABEL_PRESETS } from '@/lib/classification/presets';
import {
applyLabelPrefix,
draftLabelChoice,
draftLabelProperty,
labelHeaderLines,
labelHeaderProperties,
parseSioLabelMarking,
receivedLabel,
sioLabelValue,
textColorOn,
} from '@/lib/classification/labels';
// The headers a classification label writes (SIO-Label per RFC 7444 with
// RFC 2231 for non-ASCII markings, Sensitivity per RFC 2156, the admin's
// extra header), reading them back, and the subject prefix without doubling.
const generic = LABEL_PRESETS.find((p) => p.id === 'generic')!;
const german = LABEL_PRESETS.find((p) => p.id === 'german-public-sector')!;
const byId = (list: ClassificationLabel[], id: string) => list.find((l) => l.id === id)!;
const confidential = byId(generic.list, 'confidential');
const vsnfd = byId(german.list, 'vs-nfd');
describe('SIO-Label', () => {
it('writes an ASCII marking quoted, with readable colours', () => {
expect(sioLabelValue(confidential)).toBe(' marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706');
expect(textColorOn('#1e3a8a')).toBe('#ffffff');
expect(textColorOn('#fde68a')).toBe('#000000');
});
it('writes a non-ASCII marking with RFC 2231, never an encoded word, folded under 78 columns', () => {
const value = sioLabelValue(vsnfd);
expect(value).toContain("marking*=utf-8''VS-NUR%20F%C3%9CR%20DEN%20DIENSTGEBRAUCH;");
expect(value).not.toContain('=?');
for (const line of `SIO-Label:${value}`.split('\r\n')) expect(line.length).toBeLessThanOrEqual(78);
expect(parseSioLabelMarking(value)).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
});
it('continues a long encoded marking over several parameters and reads it back', () => {
const long = { ...vsnfd, marking: 'ÄÖÜ äöü ß – sehr lange Kennzeichnung für einen Test der Fortsetzung' };
const value = sioLabelValue(long);
expect(value).toMatch(/marking\*0\*=utf-8''/);
expect(value).toMatch(/marking\*1\*=/);
expect(value).not.toMatch(/%[0-9A-F]?(;|\r|$)/);
expect(parseSioLabelMarking(value)).toBe(long.marking);
});
it('reads the forms servers hand back: raw, refolded, a token, RFC 2047 inside quotes', () => {
expect(parseSioLabelMarking(' marking="INTERNAL"; fgcolor=#ffffff; bgcolor=#2563eb')).toBe('INTERNAL');
expect(parseSioLabelMarking(' marking="STRICTLY\r\n CONFIDENTIAL"; bgcolor=red')).toBe('STRICTLY CONFIDENTIAL');
expect(parseSioLabelMarking('marking=PUBLIC;fgcolor=black')).toBe('PUBLIC');
expect(parseSioLabelMarking(' marking="=?UTF-8?B?VlMtTlVSIEbDnFIgREVOIERJRU5TVEdFQlJBVUNI?="')).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
expect(parseSioLabelMarking(' type=":ess"; label="MQYGASkCAQM="')).toBeNull();
expect(parseSioLabelMarking(undefined)).toBeNull();
});
it('writes Sensitivity and the extra header only when the label sets them', () => {
expect(labelHeaderProperties(confidential, 'X-Classification')).toEqual({
// Without the leading space: the server writes "SIO-Label: " itself.
'header:SIO-Label:asRaw': 'marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706',
'header:Sensitivity:asText': 'Company-Confidential',
'header:X-Classification:asText': 'Confidential',
});
expect(Object.keys(labelHeaderProperties(byId(generic.list, 'public'), ''))).toEqual(['header:SIO-Label:asRaw']);
expect(labelHeaderLines(vsnfd, 'X-Classification')).toEqual([
`SIO-Label:${sioLabelValue(vsnfd)}`,
'Sensitivity: Company-Confidential',
'X-Classification: VS-NfD',
]);
expect(labelHeaderLines({ ...vsnfd, extraHeaderValue: 'Geheim für alle' }, 'X-Class')[2]).toMatch(/^X-Class: =\?UTF-8\?B\?/);
});
});
describe('received labels', () => {
const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
it('are matched by marking first, case and spacing aside', () => {
expect(receivedLabel({ 'SIO-Label': ' marking="confidential "' }, policy)?.id).toBe('confidential');
expect(receivedLabel({ 'sio-label': [' marking="PUBLIC"'] }, policy)?.id).toBe('public');
});
it('then by the extra header value', () => {
expect(receivedLabel({ 'X-Classification': ' Strictly confidential' }, policy)?.id).toBe('strictly-confidential');
});
it('show nothing for an unknown marking or without labels', () => {
expect(receivedLabel({ 'SIO-Label': ' marking="TOP SECRET"' }, policy)).toBeNull();
expect(receivedLabel({ 'SIO-Label': ' marking="PUBLIC"' }, { ...policy, list: [] })).toBeNull();
});
});
describe('the subject prefix', () => {
const list = generic.list;
const strictly = byId(list, 'strictly-confidential');
it('is added once', () => {
expect(applyLabelPrefix('Budget', confidential, list)).toBe('[Confidential] Budget');
expect(applyLabelPrefix('[Confidential] Budget', confidential, list)).toBe('[Confidential] Budget');
});
it('is not doubled across reply and forward chains', () => {
expect(applyLabelPrefix('Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] Re: Budget');
expect(applyLabelPrefix('AW: WG: [Confidential] Re: [Confidential] Budget', confidential, list)).toBe('[Confidential] AW: WG: Re: Budget');
});
it('changes with the label, and goes with no label', () => {
expect(applyLabelPrefix('Re: [Confidential] Budget', strictly, list)).toBe('[Strictly confidential] Re: Budget');
expect(applyLabelPrefix('[Confidential] Re: Budget', null, list)).toBe('Re: Budget');
expect(applyLabelPrefix('Re: Budget', byId(list, 'internal'), list)).toBe('Re: Budget');
});
it('knows prefixes without brackets, like VS-NfD', () => {
expect(applyLabelPrefix('AW: VS-NfD Lagebericht', vsnfd, german.list)).toBe('VS-NfD AW: Lagebericht');
expect(applyLabelPrefix('VS-NfD: Lagebericht', vsnfd, german.list)).toBe('VS-NfD Lagebericht');
// A word that only starts like a prefix stays.
expect(applyLabelPrefix('VS-NfDx test', vsnfd, german.list)).toBe('VS-NfD VS-NfDx test');
});
});
describe('drafts', () => {
it('keep the pick, "none" and nothing apart', () => {
expect(draftLabelProperty('confidential')).toEqual({ 'header:X-Bulwark-Label:asText': 'confidential' });
expect(draftLabelProperty(null)).toEqual({ 'header:X-Bulwark-Label:asText': 'none' });
expect(draftLabelProperty(undefined)).toEqual({});
expect(draftLabelChoice({ 'X-Bulwark-Label': ' confidential' })).toBe('confidential');
expect(draftLabelChoice({ 'x-bulwark-label': 'none' })).toBeNull();
expect(draftLabelChoice({})).toBeUndefined();
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// The client writes a classification label's headers on send (over any
// plugin header of the same name) and keeps the pick in a draft.
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, { apiUrl: 'https://jmap.example.com/api', accountId: 'account-1', username: 'user@example.com' });
return client;
}
type Call = [string, Record<string, unknown>, string];
function mockServer() {
const captured: Call[][] = [];
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const calls = (JSON.parse((init as { body: string }).body) as { methodCalls: Call[] }).methodCalls;
captured.push(calls);
const name = calls[0][0];
let responses: unknown[];
if (name === 'Mailbox/get') responses = [['Mailbox/get', { list: [{ id: 'mb-drafts', role: 'drafts' }, { id: 'mb-sent', role: 'sent' }] }, '0']];
else if (name === 'Identity/get') responses = [['Identity/get', { list: [{ id: 'identity-1', email: 'user@example.com' }] }, '0']];
else if (name === 'Email/set' && calls[0][1].create) {
const id = Object.keys(calls[0][1].create as object)[0];
responses = [['Email/set', { created: { [id]: { id: 'new-1' } } }, '0'], ['EmailSubmission/set', { created: { '1': { id: 'sub-1' } } }, '1']];
} else responses = calls.map(([method, , id]) => [method, {}, id]);
const payload = { methodResponses: responses };
return { ok: true, status: 200, text: () => Promise.resolve(JSON.stringify(payload)), json: () => Promise.resolve(payload) } as Response;
});
return captured;
}
function created(captured: Call[][]): Record<string, unknown> {
for (const calls of captured) {
const set = calls.find(([name, args]) => name === 'Email/set' && args.create);
if (set) return Object.values(set[1].create as Record<string, Record<string, unknown>>)[0];
}
throw new Error('no create');
}
describe('label headers', () => {
beforeEach(() => vi.restoreAllMocks());
it('are written on send, and win over a plugin header of the same name', async () => {
const captured = mockServer();
await createClient().sendEmail(
['bob@example.org'], '[Confidential] x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined,
{
headers: { Sensitivity: 'Personal', 'X-Other': 'kept' },
labelHeaders: { 'header:SIO-Label:asRaw': ' marking="CONFIDENTIAL"', 'header:Sensitivity:asText': 'Company-Confidential' },
},
);
const email = created(captured);
expect(email['header:SIO-Label:asRaw']).toBe(' marking="CONFIDENTIAL"');
expect(email['header:Sensitivity:asText']).toBe('Company-Confidential');
expect(email['header:X-Other:asText']).toBe('kept');
expect(Object.keys(email).filter((k) => k.toLowerCase().startsWith('header:sensitivity'))).toHaveLength(1);
});
it('are kept in a draft as the pick', async () => {
const captured = mockServer();
await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined, undefined, undefined, { classification: 'confidential' });
expect(created(captured)['header:X-Bulwark-Label:asText']).toBe('confidential');
const again = mockServer();
await createClient().createDraft(['bob@example.org'], 'x', 'body', undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined, undefined, undefined, { classification: null });
expect(created(again)['header:X-Bulwark-Label:asText']).toBe('none');
});
});
@@ -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, unknown>, 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<string, unknown> {
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<string, Record<string, unknown>>).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<string, Record<string, unknown>>).e1;
expect(back).toMatchObject({ mailboxIds: { 'mb-inbox': true }, 'keywords/$junk': null, 'keywords/$notjunk': true, 'keywords/$phishing': null });
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest';
import type { Email, Identity } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { buildArfParts, buildArfReportBlob, rawEncoding } from '../phishing-report-arf';
import { sendPhishingReport } from '../phishing-report';
// The ARF report (RFC 5965): multipart/report with report-type
// feedback-report, a text part, message/feedback-report with
// Feedback-Type: fraud, User-Agent and Version: 1, and the original whole
// as message/rfc822; submitted raw without a Sent copy.
const email = {
id: 'e1', blobId: 'blob-1', size: 40, headers: { 'Delivered-To': 'alice@example.org' }, subject: 'Konto gesperrt – jetzt prüfen', receivedAt: '2026-10-01T10:00:00Z',
from: [{ email: 'bad@phish.example' }], keywords: {}, mailboxIds: { inbox: true },
} as unknown as Email;
const reporter = { id: 'id-me', name: 'Alice', email: 'alice@example.org' } as Identity;
const ORIGINAL = 'From: bad@phish.example\r\nSubject: x\r\n\r\nclick here\r\n';
describe('buildArfParts', () => {
it('has the three parts and the required feedback fields', () => {
const { head, tail } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit', recipient: 'alice@example.org', now: new Date('2026-11-01T08:00:00Z') });
expect(head).toMatch(/^Date: Sun, 01 Nov 2026 08:00:00 \+0000\r\n/);
expect(head).toContain('From: Alice <alice@example.org>\r\n');
expect(head).toContain('To: abuse@example.org\r\n');
// Non-ASCII subject as an encoded word, after "Phishing:".
expect(head).toMatch(/\r\nSubject: =\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=\r\n/);
const subject = /Subject: =\?UTF-8\?B\?([^?]+)\?=/.exec(head)![1];
expect(new TextDecoder().decode(Uint8Array.from(atob(subject), (c) => c.charCodeAt(0)))).toBe('Phishing: Konto gesperrt – jetzt prüfen');
expect(head).toContain('Content-Type: multipart/report; report-type=feedback-report;');
const boundary = /boundary="([^"]+)"/.exec(head)![1];
expect(head.split(`--${boundary}\r\n`)).toHaveLength(4);
expect(head).toContain('Content-Type: message/feedback-report\r\n');
expect(head).toContain('\r\nFeedback-Type: fraud\r\n');
expect(head).toMatch(/\r\nUser-Agent: Bulwark\/\S+\r\n/);
expect(head).toContain('\r\nVersion: 1\r\n');
expect(head).toContain('\r\nArrival-Date: Thu, 01 Oct 2026 10:00:00 +0000\r\n');
expect(head).toContain('\r\nOriginal-Rcpt-To: alice@example.org\r\n');
expect(head).toContain('\r\nReported-Domain: phish.example\r\n');
expect(head).toMatch(/Content-Type: message\/rfc822\r\n[\s\S]*Content-Transfer-Encoding: 7bit\r\n\r\n$/);
expect(tail).toBe(`\r\n--${boundary}--\r\n`);
});
it('names the address the message was delivered to, and leaves the field out when unknown', () => {
const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '7bit' });
expect(head).not.toContain('Original-Rcpt-To');
});
it('says binary for bare line breaks or long lines, 8bit for 8-bit bytes, never base64', () => {
const enc = (text: string) => rawEncoding(new TextEncoder().encode(text));
expect(enc('a\r\nb\r\n')).toBe('7bit');
expect(enc('Grüße\r\n')).toBe('8bit');
expect(enc('a\nb')).toBe('binary');
expect(enc('a\rb')).toBe('binary');
expect(enc(`${'x'.repeat(999)}\r\n`)).toBe('binary');
expect(enc('a\u0000b')).toBe('binary');
});
it('marks an 8-bit original as 8bit, never base64', () => {
const { head } = buildArfParts({ email, reporter, to: 'abuse@example.org', encoding: '8bit' });
expect(head).toMatch(/Content-Transfer-Encoding: 8bit\r\n\r\n$/);
});
});
describe('an ARF report', () => {
it('carries the original byte for byte and is submitted raw, from the original account', async () => {
const client = {
getMaxSizeUpload: () => 50_000_000,
getIdentities: vi.fn(async () => [reporter]),
getUsername: () => 'alice@example.org',
getAccountId: () => 'acc-own',
fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode(ORIGINAL).buffer),
submitRawEmail: vi.fn(async (..._args: unknown[]) => ({ scheduled: false })),
sendEmail: vi.fn(),
};
await sendPhishingReport(email, {
client: client as unknown as IJMAPClient,
sourceAccountId: 'acc-group',
setup: { address: 'abuse@example.org', format: 'arf' },
buildArf: buildArfReportBlob,
});
expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-1', 'message.eml', 'message/rfc822', 'acc-group');
expect(client.sendEmail).not.toHaveBeenCalled();
const [blob, identityId, delayed, envelope] = client.submitRawEmail.mock.calls[0] as [Blob, string, unknown, string[]];
expect(identityId).toBe('id-me');
expect(delayed).toBeUndefined();
expect(envelope).toEqual(['abuse@example.org']);
const text = await blob.text();
const boundary = /boundary="([^"]+)"/.exec(text)![1];
expect(text).toContain(`message/rfc822\r\nContent-Disposition: attachment; filename="reported-message.eml"\r\nContent-Transfer-Encoding: 7bit\r\n\r\n${ORIGINAL}--${boundary}--\r\n`);
});
});
describe('a message too big for an ARF report', () => {
it('is refused before it is downloaded', async () => {
const client = { getMaxSizeUpload: () => 1000, fetchBlobArrayBuffer: vi.fn() };
await expect(buildArfReportBlob({
client: client as unknown as IJMAPClient, email: { ...email, size: 5000 } as Email, sourceAccountId: 'a', reporter, to: 'abuse@example.org',
})).rejects.toThrow(/too big/);
expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
});
});
+115
View File
@@ -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<Record<string, unknown>> = {}) {
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 <alice@example.org> with Bulwark');
expect(args[5]).toBe('id-me');
const attachments = args[10] as Array<Record<string, unknown>>;
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 = '<p>Hi <a href="https://phish.example/login" target="_blank" rel="noopener">log in</a> <map><area href="https://x.example" alt="x"></map></p>';
const out = turnOffLinks(html, { title: 'link turned off', note: '(link turned off)' });
expect(out).not.toContain('href=');
expect(out).not.toContain('phish.example');
expect(out).toContain('data-link-off');
expect(out).toContain('(link turned off)');
const doc = new DOMParser().parseFromString(out, 'text/html');
expect(doc.querySelectorAll('a[href], area[href]')).toHaveLength(0);
});
it('follow the $phishing keyword', () => {
expect(isPhishingMarked({ keywords: { [PHISHING_KEYWORD]: true } })).toBe(true);
expect(isPhishingMarked({ keywords: { $junk: true } })).toBe(false);
});
});
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import type { IJMAPClient } from '../jmap/client-interface';
import type { Email } from '../jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore } from '@/stores/account-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { LABEL_PRESETS } from '@/lib/classification/presets';
import { head, resolveHead, resetDialogLimits } from '../plugin-sandbox/host-dialog';
import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
import { beginLabelTakeover, endLabelTakeover, resetLabelTakeovers } from '../outgoing/classification-raw';
// Classification labels on the plugin paths: api.email.forward carries the
// original's label (else the default) and refuses when a required one is
// missing; a crypto plugin's raw send during a labelled takeover gets the
// label headers in its top-level header block.
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('@/stores/toast-store', () => ({ toast }));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(perms: string[], id: string): InstalledPlugin {
return {
id, name: id, version: '1.0.0', author: 'test', description: '',
type: 'ui-extension', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
};
}
const FORWARDER = plugin(['email:forward'], 'e-invoice');
const SMIME = plugin(['email:raw-send'], 'smime');
const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args, { privileged: p.id === 'smime' });
const original = (headers: Record<string, string>, sender = 'colleague@example.com') => ({
id: 'E1', threadId: 'T1', mailboxIds: { inbox: true }, keywords: {}, size: 900, receivedAt: '2026-10-20T08:00:00Z',
from: [{ email: sender }], to: [{ email: 'ann@example.com' }], subject: 'Invoice',
textBody: [{ partId: '1', blobId: 'tb', size: 20, type: 'text/plain' }], bodyValues: { 1: { value: 'Hello' } },
attachments: [], headers,
}) as unknown as Email;
function useLabels(labels: Partial<typeof DEFAULT_ORGANISATION_POLICY.labels>) {
const org = { ...DEFAULT_ORGANISATION_POLICY, labels: { ...DEFAULT_ORGANISATION_POLICY.labels, list: LABEL_PRESETS[0].list, ...labels } };
usePolicyStore.setState({ loaded: true, policy: { ...DEFAULT_POLICY, organisation: org }, rawPolicy: { ...DEFAULT_POLICY, organisation: org } });
}
describe('labels on the plugin paths', () => {
let client: Record<string, ReturnType<typeof vi.fn>>;
let sendEmail: ReturnType<typeof vi.fn>;
let sendRawEmail: ReturnType<typeof vi.fn>;
let headers: Record<string, string> = {};
let sender = 'colleague@example.com';
beforeEach(() => {
resetPluginRateLimits();
resetDialogLimits();
localStorage.clear();
// The forward to this address is approved already: no dialog.
localStorage.setItem('plugin-forward-approvals', '{}');
client = {
getAccountId: vi.fn(() => 'own'),
getEmail: vi.fn(async () => original(headers, sender)),
getMailboxes: vi.fn(async () => []),
resolveSendAccountId: vi.fn(async () => 'own'),
copyBlobs: vi.fn(async () => ({})),
hasDelayedSend: vi.fn(() => false),
batchUpdateKeywords: vi.fn(async () => undefined),
submitRawEmail: vi.fn(async () => ({ scheduled: false })),
};
sendEmail = vi.fn(async () => ({ emailId: 'S1', scheduled: false }));
sendRawEmail = vi.fn(async () => ({ scheduled: false }));
useAuthStore.setState({ client: client as unknown as IJMAPClient });
useAccountStore.setState({ activeAccountId: 'acct-1' });
useEmailStore.setState({ sendEmail, sendRawEmail, emails: [], selectedEmail: null, mailboxes: [] } as never);
useIdentityStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] as never, preferredPrimaryId: 'id-a' });
useAuthStore.setState({ identities: [{ id: 'id-a', name: 'Ann', email: 'ann@example.com' }] } as never);
useSettingsStore.setState({ sendDelaySeconds: 0 });
});
afterEach(() => {
while (head()) resolveHead(false);
useAuthStore.setState({ client: null });
usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY });
resetLabelTakeovers();
headers = {};
sender = 'colleague@example.com';
vi.clearAllMocks();
});
async function forward() {
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] });
await vi.waitFor(() => expect(head()).not.toBeNull());
resolveHead(true);
return pending;
}
it('forwards with the original’s label', async () => {
useLabels({ defaultId: 'internal' });
headers = { 'SIO-Label': ' marking="CONFIDENTIAL"' };
expect(await forward()).toMatchObject({ sent: true });
const args = sendEmail.mock.calls[0] as unknown[];
expect(args[2]).toBe('[Confidential] Fwd: Invoice');
expect((args[16] as { labelHeaders: Record<string, string> }).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<string, string> }).labelHeaders;
expect(labelHeaders['header:SIO-Label:asRaw']).toContain('INTERNAL');
});
it('refuses a forward that needs a label it has no way to get', async () => {
useLabels({ required: true, defaultId: '' });
expect(await call(FORWARDER, 'email.forward', 'E1', { to: ['acc@example.com'] })).toEqual({ sent: false, reason: 'stopped' });
expect(sendEmail).not.toHaveBeenCalled();
expect(toast.error).toHaveBeenCalled();
});
it('puts the takeover’s label into its raw sends and takes forged ones out of every raw send', async () => {
useLabels({});
const forged = new TextEncoder().encode('From: ann@example.com\r\nSubject: [Confidential] Hi\r\nSIO-Label: marking="PUBLIC"\r\n\r\nENCRYPTED\r\n');
// Outside a takeover, no default label: nothing stamped, the forged header gone.
await call(SMIME, 'jmap.sendRaw', forged, 'id-a', {});
expect(await (sendRawEmail.mock.calls[0][1] as Blob).text()).not.toContain('SIO-Label');
const token = beginLabelTakeover(usePolicyStore.getState().policy.organisation!.labels, LABEL_PRESETS[0].list[2], 'id-a')!;
await call(SMIME, 'jmap.sendRaw', forged, 'id-a', { labelToken: token });
await call(SMIME, 'jmap.submitRaw', forged, 'id-a', { labelToken: token });
endLabelTakeover(token);
const sent = await (sendRawEmail.mock.calls[1][1] as Blob).text();
expect(sent).toMatch(/^From: ann@example.com\r\nSubject: \[Confidential\] Hi\r\nSIO-Label: marking="CONFIDENTIAL"[^\r]*\r\nSensitivity: Company-Confidential\r\n\r\nENCRYPTED\r\n$/);
expect(await (client.submitRawEmail.mock.calls[0][0] as Blob).text()).toContain('SIO-Label: marking="CONFIDENTIAL"');
});
it('refuses a raw send outside the composer when a label is required', async () => {
useLabels({ required: true });
const bytes = new TextEncoder().encode('Subject: Hi\r\n\r\nX\r\n');
await expect(call(SMIME, 'jmap.sendRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/);
await expect(call(SMIME, 'jmap.submitRaw', bytes, 'id-a', {})).rejects.toThrow(/requires a classification label/);
expect(sendRawEmail).not.toHaveBeenCalled();
expect(client.submitRawEmail).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
isLabelExtraHeaderName,
DEFAULT_ORGANISATION_POLICY,
MIN_FEED_REFRESH_MINUTES,
mergeOrganisation,
@@ -58,6 +59,21 @@ describe('enterprise sections: defaults and sanitising', () => {
}
});
it('takes any RFC 5322 header name, msip_labels too, but not one the server refuses', () => {
expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'msip_labels' } }).labels.extraHeader).toBe('msip_labels');
expect(isLabelExtraHeaderName('msip_labels')).toBe(true);
for (const name of ['Content-Classification', 'List-Id', 'Resent-From', 'ARC-Seal', 'Auto-Submitted', 'X:Bad', 'X Bad']) {
expect(isLabelExtraHeaderName(name)).toBe(false);
expect(sanitizeOrganisationPolicy({ labels: { extraHeader: name } }).labels.extraHeader).toBe('');
}
});
it('refuses the header Bulwark keeps the label of a draft in', () => {
expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'X-Bulwark-Label' } }).labels.extraHeader).toBe('');
expect(isLabelExtraHeaderName('X-Bulwark-Label')).toBe(false);
expect(isLabelExtraHeaderName('X-Classification')).toBe(true);
});
it('keeps announcement text per locale and normalises times', () => {
const { announcement } = sanitizeOrganisationPolicy({ announcement: {
id: 'maint-1', severity: 'warning', text: { en: 'Maintenance tonight', 'pt-BR': 'Manutenção', 'x y': 'no', de: ' ' },
+23 -3
View File
@@ -287,14 +287,17 @@ const EMAIL_RE = /^[^\s@<>"(),;:]+@[^\s@<>"(),;:]+\.[^\s@<>"(),;:]+$/;
const COLOR_RE = /^#[0-9a-f]{6}$/i;
const ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
const LOCALE_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/;
const HEADER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,75}$/;
// RFC 5322 field-name: printable ASCII but the colon (msip_labels has an underscore).
const HEADER_NAME_RE = /^[\x21-\x39\x3b-\x7e]{1,76}$/;
// Prefixes JMAP refuses at the top level or that say how mail travelled.
const RESERVED_HEADER_PREFIXES = ['content-', 'list-', 'resent-', 'arc-'];
// Headers the extra classification header may not be: they carry the
// message itself, or the labels Bulwark writes anyway.
const RESERVED_HEADERS = new Set([
'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'sender', 'reply-to', 'in-reply-to',
'references', 'mime-version', 'content-type', 'content-transfer-encoding', 'content-disposition',
'received', 'return-path', 'dkim-signature', 'arc-seal', 'arc-message-signature', 'authentication-results',
'sio-label', 'sio-label-history', 'sensitivity', 'importance', 'x-priority', 'disposition-notification-to',
'sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label', 'auto-submitted', 'precedence', 'importance', 'x-priority', 'disposition-notification-to',
]);
const MAX_LABELS = 50;
const MAX_FEEDS = 50;
@@ -312,6 +315,12 @@ function email(value: unknown): string | undefined {
return v === '' || EMAIL_RE.test(v) ? v.slice(0, 320) : undefined;
}
/** Whether the admin page may store this as `phishingReport.address` (the sanitiser's rule). */
export function isPhishingReportAddress(value: string): boolean {
const v = value.trim().toLowerCase();
return v.length <= 320 && EMAIL_RE.test(v);
}
function color(value: unknown, fallback: string): string {
return typeof value === 'string' && COLOR_RE.test(value.trim()) ? value.trim().toLowerCase() : fallback;
}
@@ -368,11 +377,22 @@ function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined {
});
}
function reservedHeader(name: string): boolean {
const lower = name.toLowerCase();
return RESERVED_HEADERS.has(lower) || RESERVED_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
/** Whether the admin page may store this as `labels.extraHeader` (the sanitiser's rule). */
export function isLabelExtraHeaderName(value: string): boolean {
const v = value.trim();
return HEADER_NAME_RE.test(v) && !reservedHeader(v);
}
function headerName(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const v = value.trim();
if (v === '') return '';
return HEADER_NAME_RE.test(v) && !RESERVED_HEADERS.has(v.toLowerCase()) ? v : undefined;
return HEADER_NAME_RE.test(v) && !reservedHeader(v) ? v : undefined;
}
function sanitizeLocaleText(value: unknown): Record<string, string> | undefined {
+337
View File
@@ -0,0 +1,337 @@
/**
* Classification labels on the wire (enterprise pack item 3). Pure
* functions: the headers a label writes, reading a received label back,
* the subject prefix, and the draft marker.
*
* Headers written:
* - `SIO-Label` (RFC 7444) in the marking-only form with colours, as a raw
* header: `marking="CONFIDENTIAL"; fgcolor=#000000; bgcolor=#d97706`.
* A marking with non-ASCII characters uses RFC 2231
* (`marking*=utf-8''VS-NUR%20F%C3%9CR...`): an RFC 2047 encoded word
* inside a quoted parameter is invalid (RFC 2047 §5), which is what the
* server's text form would produce.
* - `Sensitivity` (RFC 2156 §5.3.4), what Outlook shows, when the label
* sets one.
* - The admin's extra header (X-Classification, msip_labels) when the label
* has a value for it.
* No SIO-Label-History: it needs who changed it and when, and RFC 7444
* says it may leak.
*
* Drafts keep the user's choice in `X-Bulwark-Label` (the label id, or
* `none` for a choice of no label), the way they keep the priority.
*/
import type { ClassificationLabel, OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { stripSubjectPrefixes } from '@/lib/subject-prefix';
export type LabelsPolicy = OrganisationPolicy['labels'];
export const SIO_LABEL_HEADER = 'SIO-Label';
export const SENSITIVITY_HEADER = 'Sensitivity';
export const DRAFT_LABEL_HEADER = 'X-Bulwark-Label';
/** A draft whose sender picked "no label". */
export const NO_LABEL = 'none';
/** The marking a label writes: its own, else its name. */
export function labelMarking(label: Pick<ClassificationLabel, 'marking' | 'name'>): string {
return (label.marking || label.name).trim();
}
/** Black or white, whichever reads better on `bg` (#rrggbb). */
export function textColorOn(bg: string): '#000000' | '#ffffff' {
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(bg);
if (!m) return '#ffffff';
const [r, g, b] = [m[1], m[2], m[3]].map((h) => {
const c = parseInt(h, 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.179 ? '#000000' : '#ffffff';
}
// RFC 2231 attribute-char: anything but specials, space, controls, *, ' and %.
const ATTRIBUTE_CHAR = /[A-Za-z0-9!#$&+\-.^_`|~]/;
function percentEncode(value: string): string {
let out = '';
for (const byte of new TextEncoder().encode(value)) {
const ch = String.fromCharCode(byte);
out += byte < 0x80 && ATTRIBUTE_CHAR.test(ch) ? ch : `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
}
return out;
}
/** The marking parameter(s): quoted when ASCII, RFC 2231 (with continuations when long) otherwise. */
function markingParameters(marking: string): string[] {
const clean = marking.replace(/["\\\r\n]/g, '');
if (/^[\x20-\x7e]*$/.test(clean)) return [`marking="${clean}"`];
const encoded = percentEncode(clean);
const MAX = 60;
if (encoded.length + 15 <= MAX) return [`marking*=utf-8''${encoded}`];
// Split between encoded characters, never inside a %XX.
const units = encoded.match(/%[0-9A-F]{2}|[^%]/g) ?? [];
const pieces: string[] = [];
let piece = '';
for (const unit of units) {
if (piece.length + unit.length > MAX - 15) {
pieces.push(piece);
piece = '';
}
piece += unit;
}
if (piece) pieces.push(piece);
return pieces.map((piece, i) => (i === 0 ? `marking*0*=utf-8''${piece}` : `marking*${i}*=${piece}`));
}
/** The SIO-Label value after "SIO-Label:", folded (leading space included). */
export function sioLabelValue(label: Pick<ClassificationLabel, 'marking' | 'name' | 'color'>): string {
const params = [...markingParameters(labelMarking(label)), `fgcolor=${textColorOn(label.color)}`, `bgcolor=${label.color}`];
// "SIO-Label: " is 11 characters; fold between parameters past 78.
let line = 11;
let out = '';
params.forEach((param, i) => {
const piece = i === params.length - 1 ? param : `${param};`;
if (i > 0 && line + 1 + piece.length > 78) {
out += `\r\n ${piece}`;
line = 1 + piece.length;
} else {
out += ` ${piece}`;
line += 1 + piece.length;
}
});
return out;
}
/** The label's headers as [name, value] pairs, the extra header's value as plain text. */
function labelHeaderPairs(label: ClassificationLabel, extraHeader: string): Array<[string, string, 'raw' | 'text']> {
const pairs: Array<[string, string, 'raw' | 'text']> = [[SIO_LABEL_HEADER, sioLabelValue(label), 'raw']];
if (label.sensitivity) pairs.push([SENSITIVITY_HEADER, label.sensitivity, 'text']);
const extraValue = label.extraHeaderValue.trim();
if (extraHeader && extraValue) pairs.push([extraHeader, extraValue, 'text']);
return pairs;
}
/** The label's headers as JMAP Email/set create properties (RFC 8621 §4.1.3). */
export function labelHeaderProperties(label: ClassificationLabel, extraHeader: string): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, value, form] of labelHeaderPairs(label, extraHeader)) {
// The server writes "Name: " itself; a raw value keeps only its own folds.
out[form === 'raw' ? `header:${name}:asRaw` : `header:${name}:asText`] = form === 'raw' ? value.trimStart() : value;
}
return out;
}
/**
* A header value for raw MIME, folded under 78 columns: ASCII as it is,
* folded between words; anything else as RFC 2047 encoded words of at
* most 75 characters each, split between characters.
*/
function foldedValue(name: string, value: string): string {
if (/^[\x20-\x7e]*$/.test(value)) {
let line = name.length + 1;
let out = '';
for (const word of value.split(' ')) {
if (out && line + 1 + word.length > 78) {
out += `\r\n ${word}`;
line = 1 + word.length;
} else {
out += ` ${word}`;
line += 1 + word.length;
}
}
return out;
}
const encoder = new TextEncoder();
const words: string[] = [];
let chunk: number[] = [];
const flush = () => {
if (chunk.length === 0) return;
words.push(`=?UTF-8?B?${btoa(String.fromCharCode(...chunk))}?=`);
chunk = [];
};
// 45 bytes are 60 base64 characters: 72 with "=?UTF-8?B?" and "?=",
// which fits a folded line. The first word shares its line with the name.
const firstLimit = Math.max(3, Math.min(45, Math.floor((78 - name.length - 2 - 12) / 4) * 3));
for (const char of value) {
const bytes = encoder.encode(char);
if (chunk.length + bytes.length > (words.length === 0 ? firstLimit : 45)) flush();
chunk.push(...bytes);
}
flush();
return ` ${words.join('\r\n ')}`;
}
/** The label's headers as raw header lines (folded with CRLF and a space), for raw MIME. */
export function labelHeaderLines(label: ClassificationLabel, extraHeader: string): string[] {
return labelHeaderPairs(label, extraHeader).map(([name, value, form]) =>
form === 'raw' ? `${name}:${value}` : `${name}:${foldedValue(name, value)}`);
}
/** The draft marker property: the label id, or `none` for no label. */
export function draftLabelProperty(choice: string | null | undefined): Record<string, string> {
if (choice === undefined) return {};
return { [`header:${DRAFT_LABEL_HEADER}:asText`]: choice ?? NO_LABEL };
}
type HeaderRecord = Record<string, string | string[]> | null | undefined;
function header(headers: HeaderRecord, name: string): string | undefined {
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined;
const lower = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() !== lower) continue;
const first = Array.isArray(value) ? value[0] : value;
if (typeof first === 'string') return first;
}
return undefined;
}
/**
* The choice a re-opened draft carries: a label id, null for "no label",
* undefined when the draft says nothing (a draft of another client, or one
* saved before labels).
*/
export function draftLabelChoice(headers: HeaderRecord): string | null | undefined {
const value = header(headers, DRAFT_LABEL_HEADER)?.trim();
if (!value) return undefined;
return value === NO_LABEL ? null : value;
}
function decodePercent(value: string, charset: string): string {
const bytes: number[] = [];
for (let i = 0; i < value.length; i++) {
if (value[i] === '%' && /^[0-9a-f]{2}$/i.test(value.slice(i + 1, i + 3))) {
bytes.push(parseInt(value.slice(i + 1, i + 3), 16));
i += 2;
} else {
bytes.push(value.charCodeAt(i) & 0xff);
}
}
try {
return new TextDecoder(charset || 'utf-8').decode(Uint8Array.from(bytes));
} catch {
return new TextDecoder('utf-8').decode(Uint8Array.from(bytes));
}
}
/** Decodes RFC 2047 encoded words a server may have put into a value. */
function decodeEncodedWords(value: string): string {
return value.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (whole, charset: string, enc: string, text: string) => {
try {
let bytes: Uint8Array;
if (enc.toLowerCase() === 'b') bytes = Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
else bytes = Uint8Array.from(text.replace(/_/g, ' ').replace(/=([0-9a-f]{2})/gi, (_, h: string) => String.fromCharCode(parseInt(h, 16))), (c) => c.charCodeAt(0));
return new TextDecoder(charset).decode(bytes);
} catch {
return whole;
}
});
}
/**
* The `marking` of a raw SIO-Label value (RFC 7444 §2): a quoted or token
* value, or the RFC 2231 forms (encoded and continued). Null without one.
*/
export function parseSioLabelMarking(raw: string | undefined | null): string | null {
if (!raw) return null;
const value = raw.replace(/\r?\n[ \t]+/g, ' ');
const params = new Map<string, string>();
const re = /([A-Za-z0-9*-]+)\s*=\s*("(?:[^"\\]|\\.)*"|[^;\s]*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(value))) {
let v = m[2];
if (v.startsWith('"')) v = v.slice(1, -1).replace(/\\(.)/g, '$1');
params.set(m[1].toLowerCase(), v);
}
if (params.has('marking')) return decodeEncodedWords(params.get('marking')!).trim() || null;
if (params.has('marking*')) {
const [charset, , text] = splitExtended(params.get('marking*')!);
return decodePercent(text, charset).trim() || null;
}
// Continuations: marking*0*, marking*1, ... joined before decoding (a
// character's bytes may be split across two of them, RFC 2231 §4.1).
let joinedEncoded = '';
let charset = 'utf-8';
for (let i = 0; i < 100; i++) {
const encoded = params.get(`marking*${i}*`);
const plain = params.get(`marking*${i}`);
if (encoded === undefined && plain === undefined) break;
if (encoded !== undefined) {
let text = encoded;
if (i === 0) [charset, , text] = splitExtended(encoded);
joinedEncoded += text;
} else {
joinedEncoded += percentEncode(plain!);
}
}
const joined = decodePercent(joinedEncoded, charset).trim();
return joined || null;
}
function splitExtended(value: string): [string, string, string] {
const first = value.indexOf("'");
const second = first >= 0 ? value.indexOf("'", first + 1) : -1;
if (first < 0 || second < 0) return ['utf-8', '', value];
return [value.slice(0, first), value.slice(first + 1, second), value.slice(second + 1)];
}
const normalise = (s: string) => s.normalize('NFC').replace(/\s+/g, ' ').trim().toLowerCase();
/**
* The known label a received message carries: by its SIO-Label marking
* first, then by the admin's extra header value. Unknown values show
* nothing. Shown as information only: anyone can write these headers.
*/
export function receivedLabel(headers: HeaderRecord, policy: LabelsPolicy | null | undefined): ClassificationLabel | null {
if (!policy || policy.list.length === 0) return null;
const marking = parseSioLabelMarking(header(headers, SIO_LABEL_HEADER));
if (marking) {
const wanted = normalise(marking);
const byMarking = policy.list.find((label) => normalise(labelMarking(label)) === wanted);
if (byMarking) return byMarking;
}
if (policy.extraHeader) {
const raw = header(headers, policy.extraHeader);
if (raw) {
const wanted = normalise(decodeEncodedWords(raw.replace(/\r?\n[ \t]+/g, ' ')));
const byExtra = policy.list.find((label) => label.extraHeaderValue && normalise(label.extraHeaderValue) === wanted);
if (byExtra) return byExtra;
}
}
return null;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* The subject with `label`'s prefix in front, once: every known label
* prefix in the leading chain of reply and forward prefixes is taken out
* first ("Re: [Confidential] x" → "[Strictly confidential] Re: x"), so a
* reply doesn't double it and a changed label replaces the old one.
*/
export function applyLabelPrefix(subject: string, label: ClassificationLabel | null, labels: ClassificationLabel[]): string {
const prefixes = [...new Set(labels.map((l) => l.subjectPrefix.trim()).filter(Boolean))].sort((a, b) => b.length - a.length);
let rest = subject.trimStart();
const kept: string[] = [];
const labelRes = prefixes.map((p) => new RegExp(`^${escapeRegExp(p)}(?:\\s*:)?(?:\\s+|$)`, 'i'));
for (let i = 0; i < 40; i++) {
const labelMatch = labelRes.map((re) => re.exec(rest)).find(Boolean);
if (labelMatch) {
rest = rest.slice(labelMatch[0].length).trimStart();
continue;
}
const stripped = stripSubjectPrefixes(rest);
if (stripped === rest) break;
// Keep the reply/forward prefix as written, one token at a time.
const token = rest.slice(0, rest.length - stripped.length);
const one = /^\s*[^::]+[::]\s*/.exec(token)?.[0] ?? token;
kept.push(one.trim());
rest = rest.slice(one.length).trimStart();
}
const chain = [...kept, rest].filter(Boolean).join(' ');
const prefix = label?.subjectPrefix.trim();
return prefix ? (chain ? `${prefix} ${chain}` : prefix) : chain;
}
+63
View File
@@ -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<ClassificationLabel> & Pick<ClassificationLabel, 'id' | 'name' | 'color' | 'marking'>): 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',
}),
],
},
];
+10 -5
View File
@@ -383,8 +383,10 @@ export interface IJMAPClient {
moveMailboxContents(fromMailboxId: string, toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<number>;
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
/** `keywords`: more keywords set with the move ($phishing). */
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean, keywords?: string[]): Promise<void>;
/** `clearKeywords`: more keywords removed with the move back ($phishing). */
undoSpam(emailId: string, originalMailboxId: string, accountId?: string, clearKeywords?: string[]): Promise<void>;
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
@@ -413,8 +415,10 @@ export interface IJMAPClient {
* resolveSendAccountId). `previousDraftAccountId`: where `draftId` lives,
* so the replaced version is destroyed there (default: the primary
* account). Draft ids are only unique within one account.
* `classification`: the label choice (an id, null for none) kept in
* X-Bulwark-Label.
*/
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string },
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null },
): Promise<string>;
/**
@@ -448,8 +452,9 @@ export interface IJMAPClient {
// and need the matching `submissionExtensions` entry (see
// supportsSubmissionExtension).
// `draftAccountId`: the account `draftId` lives in, so the sent draft is
// destroyed there (default: the primary account).
options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; 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<string, string>; draftAccountId?: string; noSentCopy?: boolean; labelHeaders?: Record<string, string> },
): Promise<SendEmailResult>;
/** Whether the submission account advertises an SMTP extension ("DSN", "REQUIRETLS", …). */
supportsSubmissionExtension?(extension: string, accountId?: string): boolean;
+38 -16
View File
@@ -44,7 +44,8 @@ import { parseAuthenticationResults } from "@/lib/email-headers";
import { IS_LITE_STALWART } from "@/lib/lite";
import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota";
import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority";
import { customHeaderProperties } from "@/lib/outgoing/custom-headers";
import { customHeaderProperties, withLabelHeaders } from "@/lib/outgoing/custom-headers";
import { draftLabelProperty } from "@/lib/classification/labels";
import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body";
import { addBodyMediaToAttachments } from "./body-media-attachments";
@@ -1426,6 +1427,17 @@ export interface SubmissionSendOptions {
headers?: Record<string, string>;
/** The account the replaced draft (`draftId`) lives in; default the primary. */
draftAccountId?: string;
/**
* Leave no copy in Sent: the copy made for the send is destroyed once it
* has gone (a phishing report, which the user doesn't keep).
*/
noSentCopy?: boolean;
/**
* Classification label headers (lib/outgoing/classification.ts) as
* Email/set create properties. Written after `headers`; a plugin header
* of the same name is dropped, so a plugin can't change the label.
*/
labelHeaders?: Record<string, string>;
}
/** The MAIL FROM / RCPT TO parameters the options translate to. */
@@ -3931,7 +3943,8 @@ export class JMAPClient implements IJMAPClient {
return totalMarked;
}
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
/** `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<void> {
const targetAccountId = accountId || this.accountId;
const mailboxes = await this.getMailboxes(accountId);
@@ -3958,6 +3971,7 @@ export class JMAPClient implements IJMAPClient {
"keywords/$notjunk": null,
};
if (markAsRead) patch["keywords/$seen"] = true;
for (const keyword of keywords ?? []) patch[`keywords/${keyword}`] = true;
const response = await this.request([
["Email/set", {
@@ -3968,21 +3982,22 @@ export class JMAPClient implements IJMAPClient {
this.assertEmailSetSucceeded(response, "mark as spam");
}
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
/** `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<void> {
const targetAccountId = accountId || this.accountId;
const patch: Record<string, unknown> = {
mailboxIds: { [originalMailboxId]: true },
// Moving alone leaves $junk set, so the message shows up in the
// Inbox while still flagged as junk for every other client. (#850)
"keywords/$junk": null,
"keywords/$notjunk": true,
};
for (const keyword of clearKeywords ?? []) patch[`keywords/${keyword}`] = null;
const response = await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [originalMailboxId]: true },
// Moving alone leaves $junk set, so the message shows up in the
// Inbox while still flagged as junk for every other client. (#850)
"keywords/$junk": null,
"keywords/$notjunk": true,
},
},
update: { [emailId]: patch },
}, "0"],
]);
this.assertEmailSetSucceeded(response, "mark as not spam");
@@ -4689,7 +4704,9 @@ export class JMAPClient implements IJMAPClient {
htmlBody?: string,
inReplyTo?: string[],
references?: string[],
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string },
// `classification`: the composer's label choice (an id, null for none),
// kept in X-Bulwark-Label so a re-opened draft restores it.
options?: { priority?: MessagePriority; accountId?: string; previousDraftAccountId?: string; classification?: string | null },
): Promise<string> {
// A draft for a group identity belongs in the group's Drafts, like its
// Sent copy (#1090).
@@ -4748,6 +4765,7 @@ export class JMAPClient implements IJMAPClient {
...(draftReferences?.length ? { references: draftReferences } : {}),
// A re-opened draft reads its priority back from these (#970).
...messagePriorityHeaders(options?.priority),
...draftLabelProperty(options?.classification),
};
// Images the HTML shows by cid: go into a multipart/related beside it
@@ -4951,7 +4969,7 @@ export class JMAPClient implements IJMAPClient {
}
Object.assign(emailCreate, messagePriorityHeaders(options?.priority));
Object.assign(emailCreate, customHeaderProperties(options?.headers));
Object.assign(emailCreate, withLabelHeaders(customHeaderProperties(options?.headers), options?.labelHeaders));
// Images the HTML shows by cid: go into a multipart/related beside it,
// or recipients don't see them (#1451).
@@ -4992,6 +5010,10 @@ export class JMAPClient implements IJMAPClient {
"keywords/$draft": null,
},
};
// Without a Sent copy the copy goes once the submission is made.
const onSuccess = options?.noSentCopy
? { onSuccessDestroyEmail: ["#1"] }
: { onSuccessUpdateEmail };
// An explicit envelope is only sent when MAIL FROM should differ from the
// identity's address (a From override, e.g. replying from a catch-all
@@ -5034,7 +5056,7 @@ export class JMAPClient implements IJMAPClient {
methodCalls.push(["EmailSubmission/set", {
accountId: submissionAccountId,
create: buildSubmissionCreate(`#${emailId}`, firstMailFrom),
onSuccessUpdateEmail,
...onSuccess,
}, "1"]);
// Which recipients the server took. A refused one is lost without a
// bounce while the create succeeds and files the message in Sent.
@@ -5073,7 +5095,7 @@ export class JMAPClient implements IJMAPClient {
const retry = await this.request([["EmailSubmission/set", {
accountId: submissionAccountId,
create: buildSubmissionCreate(draftCopyId, mailFrom),
onSuccessUpdateEmail,
...onSuccess,
}, "1"], deliveryStatusCall(submissionAccountId, "1")]);
response = {
...retry,
+7 -7
View File
@@ -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}`;
}
+27 -6
View File
@@ -17,9 +17,11 @@ import { usePolicyStore } from '@/stores/policy-store';
interface AccountSubjectsState {
/** Resolved subjects by local account id. */
subjects: Record<string, PolicySubject>;
/** Accounts whose subject was worked out, found or not. */
settled: Record<string, true>;
}
export const useAccountSubjects = create<AccountSubjectsState>()(() => ({ subjects: {} }));
export const useAccountSubjects = create<AccountSubjectsState>()(() => ({ subjects: {}, settled: {} }));
const pending = new Map<string, unknown>();
@@ -29,10 +31,29 @@ type SubjectSource = Parameters<typeof policySubjectForClient>[0];
function resolveSubject(accountId: string, client: NonNullable<SubjectSource>): 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<SubjectSource>);
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: {} });
}
@@ -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<typeof labels> = {}) {
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);
});
});
@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountSubjects } from '@/lib/org/account-subjects';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { LABEL_PRESETS } from '@/lib/classification/presets';
import { usePolicyStore } from '@/stores/policy-store';
import { customHeaderProperties, withLabelHeaders } from '../custom-headers';
import {
automaticLabel,
classifyOutgoing,
isLabelRequiredError,
labelSourceHeaders,
labelsPolicyFor,
reopenedLabelChoice,
resolveLabel,
} from '../classification';
// The label stage of sending: which label goes out, the required label
// failing closed, plugin headers that can't override it, the policy of the
// sending account (raw sends: classification-raw.test.ts).
const generic = LABEL_PRESETS[0];
const policy = { list: generic.list, defaultId: 'internal', required: false, extraHeader: 'X-Classification' };
const required = { ...policy, defaultId: '', required: true };
const label = (id: string) => generic.list.find((l) => l.id === id)!;
const fromColleague = (marking: string) => ({ headers: { 'SIO-Label': ` marking="${marking}"` }, from: [{ email: 'colleague@example.org' }] });
const fromOutsider = (marking: string) => ({ headers: { 'SIO-Label': ` marking="${marking}"` }, from: [{ email: 'news@outside.example' }] });
beforeEach(() => {
useAuthStore.setState({ identities: [{ id: 'id-me', name: 'Me', email: 'me@example.org' }] } as never);
});
afterEach(() => {
usePolicyStore.setState({ policy: DEFAULT_POLICY, rawPolicy: DEFAULT_POLICY, subject: null });
useAuthStore.setState({ identities: [] } as never);
});
describe('the label of the original', () => {
it('counts only when an internal sender set it: an outsider can write any header', () => {
expect(resolveLabel(policy, undefined, fromOutsider('PUBLIC'))?.id).toBe('internal');
expect(resolveLabel(policy, undefined, fromOutsider('CONFIDENTIAL'))?.id).toBe('internal');
expect(resolveLabel(policy, undefined, { headers: { 'SIO-Label': ' marking="CONFIDENTIAL"' } })?.id).toBe('internal');
});
it('never goes below the default', () => {
expect(resolveLabel(policy, undefined, fromColleague('PUBLIC'))?.id).toBe('internal');
expect(resolveLabel(policy, undefined, fromColleague('STRICTLY CONFIDENTIAL'))?.id).toBe('strictly-confidential');
// Without a default, an internal label counts whatever it is.
expect(resolveLabel({ ...policy, defaultId: '' }, undefined, fromColleague('PUBLIC'))?.id).toBe('public');
});
});
describe('a message opened again', () => {
it('keeps the label a sent copy went out with (Undo, Cancel and edit), a draft’s own pick first', () => {
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } } });
expect(reopenedLabelChoice({ 'SIO-Label': 'marking="CONFIDENTIAL"; bgcolor=#d97706' })).toBe('confidential');
expect(reopenedLabelChoice({ 'X-Bulwark-Label': 'none', 'SIO-Label': 'marking="CONFIDENTIAL"' })).toBeNull();
expect(reopenedLabelChoice({ 'X-Bulwark-Label': 'public' })).toBe('public');
expect(reopenedLabelChoice({})).toBeUndefined();
});
});
describe('resolveLabel', () => {
it('takes the pick, then the original’s label, then the default', () => {
expect(resolveLabel(policy, 'public')?.id).toBe('public');
expect(resolveLabel(policy, undefined, fromColleague('CONFIDENTIAL'))?.id).toBe('confidential');
expect(resolveLabel(policy, undefined)?.id).toBe('internal');
expect(resolveLabel(policy, null, fromColleague('CONFIDENTIAL'))).toBeNull();
// A pick that isn't in this account's list counts as nothing picked.
expect(resolveLabel(policy, 'gone')?.id).toBe('internal');
});
});
describe('classifyOutgoing', () => {
it('fails closed when a label is required and there is none', () => {
let error: unknown;
try { classifyOutgoing(required, null, { subject: 'x' }); } catch (e) { error = e; }
expect(isLabelRequiredError(error)).toBe(true);
});
it('prefixes the subject and adds the headers', () => {
const out = classifyOutgoing(policy, label('confidential'), { subject: 'Re: Budget' });
expect(out.subject).toBe('[Confidential] Re: Budget');
expect(out.labelHeaders).toMatchObject({ 'header:Sensitivity:asText': 'Company-Confidential', 'header:X-Classification:asText': 'Confidential' });
});
it('keeps a plugin from setting or dropping the label', () => {
const out = classifyOutgoing(policy, label('internal'), {
subject: 'Budget',
headers: { 'SIO-Label': 'marking="PUBLIC"', Sensitivity: 'Personal', 'x-classification': 'Public', 'X-Mailer-Note': 'ok' },
});
expect(out.headers).toEqual({ 'X-Mailer-Note': 'ok' });
const props = withLabelHeaders(customHeaderProperties({ ...out.headers, 'SIO-Label': 'forged' }), out.labelHeaders);
expect(props['header:SIO-Label:asRaw']).toContain('INTERNAL');
expect(Object.keys(props).filter((k) => k.toLowerCase().startsWith('header:sio-label'))).toHaveLength(1);
expect(props['header:X-Mailer-Note:asText']).toBe('ok');
});
it('changes nothing while the organisation has no labels', () => {
const headers = { Sensitivity: 'Personal' };
expect(classifyOutgoing({ ...policy, list: [] }, null, { subject: 'x', headers })).toEqual({ subject: 'x', headers });
});
});
describe('the policy of the sending account', () => {
it('quick reply and forward take the original’s label, and report a missing required one', () => {
usePolicyStore.setState({
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: required } },
subject: { email: 'me@example.org', groups: [] },
});
expect(automaticLabel({ email: 'me@example.org' }, fromColleague('PUBLIC'))).toMatchObject({ missing: false, label: { id: 'public' } });
expect(automaticLabel({ email: 'me@example.org' }, { headers: {} })).toMatchObject({ missing: true, label: null });
// An outsider's label doesn't count: a required label is still missing.
expect(automaticLabel({ email: 'me@example.org' }, fromOutsider('PUBLIC'))).toMatchObject({ missing: true, label: null });
});
it('keeps only the headers a reply needs for its label', () => {
usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } } });
expect(labelSourceHeaders({ 'SIO-Label': 'x', 'X-Classification': 'y', Received: 'z', Subject: 's' })).toEqual({ 'SIO-Label': 'x', 'X-Classification': 'y' });
expect(labelSourceHeaders({ Received: 'z' })).toBeUndefined();
});
});
describe('the labels of another account', () => {
it('come from that account’s own scopes, not the active one’s', () => {
const team = { ...policy, list: [label('confidential')], defaultId: 'confidential' };
const other = { getUsername: () => 'b@team.example', getSharedAccounts: () => [] };
useAuthStore.setState({ activeAccountId: 'acct-a', getClientForAccount: (id: string) => (id === 'acct-b' ? other : undefined) } as never);
usePolicyStore.setState({
policy: { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy } },
rawPolicy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, labels: policy },
scopes: [{ id: 'team', label: 'Team', match: { domains: ['team.example'] }, policy: { organisation: { labels: team } } }],
} as never,
subject: { email: 'me@example.org', groups: [] },
});
useAccountSubjects.setState({ subjects: { 'acct-b': { email: 'b@team.example', groups: [] } }, settled: { 'acct-b': true } });
expect(labelsPolicyFor({ accountId: 'acct-b', email: 'b@team.example' }).defaultId).toBe('confidential');
expect(labelsPolicyFor({}).defaultId).toBe('internal');
useAuthStore.setState({ activeAccountId: null, getClientForAccount: () => undefined } as never);
});
});
+134
View File
@@ -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<string, Takeover>();
/** An identity id without the "<account>::" 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<string>([...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;
}
+197
View File
@@ -0,0 +1,197 @@
/**
* The classification label stage of sending (enterprise pack item 3).
* Every surface that sends a user's mail runs it last, after plugins'
* onTransformOutgoingEmail, so a plugin can't drop or change the label:
*
* - the composer (inline, pop-out and Pro tab), with the user's pick;
* - quick reply, with the original's label, else the default;
* - a plugin's forward (api.email.forward), the same way.
*
* A required label fails closed: with none, the stage throws
* LabelRequiredError and nothing is sent. Labels come from the policy of
* the account and identity the mail goes out from, never just the active
* account. Automatic mail carries none: calendar invitations and RSVP
* replies, read receipts, List-Unsubscribe mails and phishing reports.
*
* S/MIME and PGP plugins take over the send (onComposeSend) and build raw
* MIME themselves: the composer hands them the prefixed subject, and
* lib/outgoing/classification-raw.ts puts the label into what they send.
*/
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { subjectForAccount } from '@/lib/org/account-subjects';
import { policyForSender } from '@/lib/org/signature-state';
import { activeExternalMailContext, contextInternalDomains, externalSenders, isOtherAccount } from '@/lib/external-mail';
import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities';
import { usePolicyStore } from '@/stores/policy-store';
import { withoutLabelHeaders } from './custom-headers';
import {
applyLabelPrefix,
labelHeaderProperties,
draftLabelChoice,
receivedLabel,
type LabelsPolicy,
} from '@/lib/classification/labels';
/** A send stopped because the organisation requires a label and none was picked. */
export class LabelRequiredError extends Error {
constructor() {
super('A classification label is required');
this.name = 'LabelRequiredError';
}
}
export function isLabelRequiredError(error: unknown): boolean {
return (error as { name?: string } | null)?.name === 'LabelRequiredError';
}
export interface LabelSender {
/** The From identity's address (a group's own picks the group's scopes). */
email?: string;
/** The signed-in account (AccountEntry id) it goes out through; the active one when left out. */
accountId?: string | null;
}
/** The labels section that applies to mail from `sender`, read from the stores now. */
export function labelsPolicyFor(sender: LabelSender): LabelsPolicy {
const policy = policyForSender(usePolicyStore.getState(), sender.email, subjectForAccount(sender.accountId));
return policy.organisation?.labels ?? DEFAULT_ORGANISATION_POLICY.labels;
}
/** The message a reply or forward answers, as far as its label goes. */
export interface OriginalMessage {
/** Its headers (labelSourceHeaders keeps the ones that matter). */
headers?: Record<string, string | string[]> | null;
from?: ReadonlyArray<{ email?: string | null }> | null;
/** The signed-in account it was received in; the active one when left out. */
accountId?: string | null;
}
/**
* Whether every From address of a message is inside the organisation of
* the account that received it (its identities' domains and the admin's
* organisation domains). False without a From or without known domains.
*/
export function senderIsInternal(
from: OriginalMessage['from'],
accountId?: string | null,
): boolean {
if (!from || from.length === 0) return false;
const context = isOtherAccount(accountId)
? {
policy: (usePolicyStore.getState().resolveFor(subjectForAccount(accountId)).organisation
?? DEFAULT_ORGANISATION_POLICY).externalMail,
identityEmails: (cachedIdentitiesForAccount(accountId) ?? []).map((identity) => identity.email),
}
: activeExternalMailContext();
const internal = contextInternalDomains(context);
return internal.length > 0 && externalSenders(from, internal).length === 0;
}
/**
* The label a reply or forward takes over from its original: the
* original's known label, only when an internal sender set it (anyone
* outside can write the header) and never one below the default (the
* list runs from least to most protected).
*/
export function originalLabel(policy: LabelsPolicy, original: OriginalMessage | null | undefined): ClassificationLabel | null {
if (!original?.headers || policy.list.length === 0) return null;
const label = receivedLabel(original.headers, policy);
if (!label || !senderIsInternal(original.from, original.accountId)) return null;
const floor = policy.list.findIndex((l) => l.id === policy.defaultId);
return floor >= 0 && policy.list.indexOf(label) < floor ? null : label;
}
/**
* The label a message goes out with. `choice`: the user's pick (an id),
* null for "no label", undefined when they picked nothing, which takes the
* original's label (originalLabel), else the default. A pick that isn't in
* the list (another account's, an old one) counts as nothing picked.
*/
export function resolveLabel(
policy: LabelsPolicy,
choice: string | null | undefined,
original?: OriginalMessage | null,
): ClassificationLabel | null {
if (policy.list.length === 0) return null;
if (choice === null) return null;
const picked = choice ? policy.list.find((label) => label.id === choice) : undefined;
if (picked) return picked;
return originalLabel(policy, original) ?? policy.list.find((label) => label.id === policy.defaultId) ?? null;
}
/**
* The pick a message opened again in the composer starts with: a draft's
* X-Bulwark-Label, else the label its headers carry (a sent message
* brought back by Undo, "Cancel and edit" or edit-as-new carries SIO-Label
* only), read with the labels of the account it is in.
*/
export function reopenedLabelChoice(
headers: Record<string, string | string[]> | null | undefined,
accountId?: string | null,
): string | null | undefined {
const marked = draftLabelChoice(headers);
if (marked !== undefined) return marked;
return receivedLabel(headers, labelsPolicyFor({ accountId }))?.id;
}
/** Whether sending needs a label that `label` doesn't give. */
export function labelMissing(policy: LabelsPolicy, label: ClassificationLabel | null): boolean {
return policy.required && policy.list.length > 0 && !label;
}
export interface LabelledMessage {
subject: string;
/** The plugins' headers, without any the label writes. */
headers?: Record<string, string>;
/** The label's headers as JMAP create properties (SubmissionSendOptions.labelHeaders). */
labelHeaders?: Record<string, string>;
}
/**
* The stage: the subject with the label's prefix (once), the plugins'
* headers without the label's names, and the label's headers. Throws
* LabelRequiredError when the policy requires a label and there is none.
*/
export function classifyOutgoing(
policy: LabelsPolicy,
label: ClassificationLabel | null,
message: { subject: string; headers?: Record<string, string> },
): LabelledMessage {
if (policy.list.length === 0) return { subject: message.subject, headers: message.headers };
if (labelMissing(policy, label)) throw new LabelRequiredError();
const reserved = policy.extraHeader ? [policy.extraHeader] : [];
return {
subject: applyLabelPrefix(message.subject, label, policy.list),
headers: withoutLabelHeaders(message.headers, reserved),
labelHeaders: label ? labelHeaderProperties(label, policy.extraHeader) : undefined,
};
}
/** The label of a quick reply or a plugin's forward: the original's (originalLabel), else the default. */
export function automaticLabel(
sender: LabelSender,
original?: OriginalMessage | null,
): { policy: LabelsPolicy; label: ClassificationLabel | null; missing: boolean } {
const policy = labelsPolicyFor(sender);
const label = resolveLabel(policy, undefined, original);
return { policy, label, missing: labelMissing(policy, label) };
}
/**
* The original's headers a reply or forward needs for its label: SIO-Label
* and every extra header name any scope of the policy uses. Small enough
* to keep in a Pro tab's saved state.
*/
export function labelSourceHeaders(headers: Record<string, string | string[]> | null | undefined): Record<string, string | string[]> | undefined {
if (!headers || typeof headers !== 'object' || Array.isArray(headers)) return undefined;
const raw = usePolicyStore.getState().rawPolicy;
const names = new Set(['sio-label']);
const add = (name: string | undefined) => { if (name) names.add(name.toLowerCase()); };
add(raw.organisation?.labels?.extraHeader);
for (const scope of raw.scopes ?? []) add(scope.policy.organisation?.labels?.extraHeader);
const out: Record<string, string | string[]> = {};
for (const [name, value] of Object.entries(headers)) if (names.has(name.toLowerCase())) out[name] = value;
return Object.keys(out).length > 0 ? out : undefined;
}
+32
View File
@@ -0,0 +1,32 @@
import type { MessagePriority } from '@/lib/message-priority';
/** What the composer's onSend hands its host beyond the message itself. */
export interface ComposerSendExtras {
requestReadReceipt?: boolean;
requestDsn?: boolean;
requireTls?: boolean;
priority?: MessagePriority;
headers?: Record<string, string>;
labelHeaders?: Record<string, string>;
localAccountId?: string;
draftAccountId?: string;
}
/**
* The options object of useEmailStore.sendEmail for a composer send. Every
* host of the composer (the mail page, a Pro compose tab) passes it whole,
* so none drops the read receipt, DSN, REQUIRETLS, priority, plugin headers
* or the classification label: the Pro tab used to send without them.
*/
export function composerSendOptions(data: ComposerSendExtras): ComposerSendExtras {
return {
requestReadReceipt: data.requestReadReceipt,
requestDsn: data.requestDsn,
requireTls: data.requireTls,
priority: data.priority,
headers: data.headers,
labelHeaders: data.labelHeaders,
localAccountId: data.localAccountId,
draftAccountId: data.draftAccountId,
};
}
+50
View File
@@ -65,3 +65,53 @@ export function customHeaderProperties(headers: unknown): Record<string, string>
}
return out;
}
/**
* The headers a classification label writes (lib/classification/labels.ts).
* While the organisation uses labels, plugins can't set them: the label
* stage drops them from the plugins' headers (withoutLabelHeaders), along
* with the admin's extra header, and withLabelHeaders below lets the
* label's own win over anything left.
*/
export const LABEL_HEADER_NAMES = ['sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label'] as const;
/** `headers` (OutgoingEmail.headers) without the label headers and `extra` names. */
export function withoutLabelHeaders(
headers: Record<string, string> | undefined,
extra: readonly string[] = [],
): Record<string, string> | undefined {
if (!headers) return headers;
const reserved = new Set<string>([...LABEL_HEADER_NAMES, ...extra.map((name) => name.toLowerCase())]);
const out: Record<string, string> = {};
for (const [name, value] of Object.entries(headers)) {
if (reserved.has(name.toLowerCase())) {
debug.warn(`Outgoing header "${name}" from a plugin was not sent: the classification label sets it`);
continue;
}
out[name] = value;
}
return out;
}
/** The name in a JMAP header property (`header:SIO-Label:asRaw` → `sio-label`). */
function propertyHeaderName(property: string): string {
return property.split(':')[1]?.toLowerCase() ?? '';
}
/**
* Plugin header properties with the label's added on top: a plugin header
* of a name the label writes is dropped, since JMAP refuses a create that
* sets one header twice and the label must win.
*/
export function withLabelHeaders(
custom: Record<string, string>,
label: Record<string, string> | undefined,
): Record<string, string> {
if (!label || Object.keys(label).length === 0) return custom;
const taken = new Set(Object.keys(label).map(propertyHeaderName));
const out: Record<string, string> = {};
for (const [property, value] of Object.entries(custom)) {
if (!taken.has(propertyHeaderName(property))) out[property] = value;
}
return { ...out, ...label };
}
+5 -1
View File
@@ -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;
+50
View File
@@ -0,0 +1,50 @@
/**
* Links in a message the user reported as phishing ($phishing, RFC 9051
* §2.3.2: clients should warn and disable links) are turned off: each
* `<a>` and `<area>` 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<Email, 'keywords'> | null | undefined): boolean {
return email?.keywords?.[PHISHING_KEYWORD] === true;
}
const LINK_OFF_STYLE = 'color:inherit !important;text-decoration:underline dotted !important;cursor:not-allowed !important';
/** What a turned-off link says: its tooltip, and the note a screen reader hears after its text. */
export interface LinksOffText {
title: string;
note: string;
}
/** `html` with every link turned off. */
export function turnOffLinks(html: string, text: LinksOffText): string {
if (!html || typeof DOMParser === 'undefined') return html;
if (!/<(a|area)\b/i.test(html)) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
const links = doc.querySelectorAll('a, area');
if (links.length === 0) return html;
links.forEach((link) => {
for (const name of ['href', 'xlink:href', 'target', 'rel', 'ping', 'download', 'tabindex']) link.removeAttribute(name);
link.setAttribute('data-link-off', '');
link.setAttribute('aria-disabled', 'true');
link.setAttribute('title', text.title);
if (link.tagName === 'A') {
// Looks like text that was a link; a screen reader hears the note.
const style = link.getAttribute('style');
link.setAttribute('style', `${style ? `${style};` : ''}${LINK_OFF_STYLE}`);
const note = doc.createElement('span');
note.setAttribute('data-link-off-note', '');
note.setAttribute('style', 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap');
note.textContent = ` ${text.note}`;
link.appendChild(note);
}
});
// A full document keeps its <head> (styles); a fragment stays a fragment.
return /<html[\s>]/i.test(html) ? doc.documentElement.outerHTML : doc.body.innerHTML;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* The ARF format of a phishing report (RFC 5965, an admin option of
* Organisation > Security > Phishing reports): a multipart/report with
* report-type=feedback-report and three parts, a short text for people, the
* machine-readable message/feedback-report (Feedback-Type: fraud, which
* RFC 5965 §7.3 defines as "fraud or phishing"), and the original whole and
* unchanged as message/rfc822.
*
* JMAP can't set the report-type parameter through Email/set, so this is
* raw MIME: the original's bytes are downloaded, wrapped and uploaded, then
* submitted with submitRawEmail (no Sent copy). The upload counts against
* the server's upload quota and is limited by maxSizeUpload.
*/
import type { Email } from '@/lib/jmap/types';
import { base64Body, displayName, encodeHeaderWord, headerAtom, randomToken, rfc5322Date } from '@/lib/mdn';
import { deliveryAddresses } from '@/lib/reply-identity';
import { reportSubject, type ArfInput } from './phishing-report';
export interface ArfParts {
/** Everything before the original's bytes. */
head: string;
/** Everything after them. */
tail: string;
}
function appVersion(): string {
return (process.env.NEXT_PUBLIC_APP_VERSION || '').trim() || 'unknown';
}
/** "Reported-Domain": the domain of the original's From, when it has one. */
function senderDomain(email: Pick<Email, 'from'>): string | null {
const address = email.from?.[0]?.email ?? '';
const domain = address.slice(address.lastIndexOf('@') + 1);
return address.includes('@') && /^[A-Za-z0-9.-]+$/.test(domain) ? domain.toLowerCase() : null;
}
/** How the original goes in: message/rfc822 may not be base64-encoded (RFC 2046 §5.2.1). */
export type RawEncoding = '7bit' | '8bit' | 'binary';
/**
* The report around the original. `encoding`: what the original's bytes
* are (rawEncoding). `recipient`: the address the original was delivered
* to, for Original-Rcpt-To; left out when unknown.
*/
export function buildArfParts(input: {
email: Pick<Email, 'subject' | 'receivedAt' | 'from' | 'messageId'>;
reporter: { name?: string; email: string };
to: string;
encoding: RawEncoding;
recipient?: string;
now?: Date;
}): ArfParts {
const { email, reporter } = input;
const fromEmail = headerAtom(reporter.email);
const domain = fromEmail.split('@')[1] || 'localhost';
const boundary = `----=_ARF_${randomToken()}`;
const fromName = reporter.name?.trim() ? displayName(reporter.name.trim()) : '';
const arrival = email.receivedAt ? new Date(email.receivedAt) : null;
const reportedDomain = senderDomain(email);
const human = [
`This is a phishing report (RFC 5965) sent by ${fromEmail} with Bulwark.`,
'',
'The reported message is attached unchanged.',
].join('\r\n');
const report = [
'Feedback-Type: fraud',
`User-Agent: Bulwark/${appVersion()}`,
'Version: 1',
...(arrival && !Number.isNaN(arrival.getTime()) ? [`Arrival-Date: ${rfc5322Date(arrival)}`] : []),
...(input.recipient ? [`Original-Rcpt-To: ${headerAtom(input.recipient)}`] : []),
...(reportedDomain ? [`Reported-Domain: ${reportedDomain}`] : []),
].join('\r\n');
const head = [
`Date: ${rfc5322Date(input.now ?? new Date())}`,
`From: ${fromName ? `${fromName} <${fromEmail}>` : fromEmail}`,
`To: ${headerAtom(input.to)}`,
`Subject: ${encodeHeaderWord(reportSubject(email.subject))}`,
`Message-ID: <arf.${randomToken()}@${domain}>`,
'MIME-Version: 1.0',
'Content-Type: multipart/report; report-type=feedback-report;',
`\tboundary="${boundary}"`,
'',
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'Content-Transfer-Encoding: base64',
'',
base64Body(human),
'',
`--${boundary}`,
'Content-Type: message/feedback-report',
'Content-Transfer-Encoding: 7bit',
'',
report,
'',
`--${boundary}`,
'Content-Type: message/rfc822',
'Content-Disposition: attachment; filename="reported-message.eml"',
`Content-Transfer-Encoding: ${input.encoding}`,
'',
'',
].join('\r\n');
const tail = `\r\n--${boundary}--\r\n`;
return { head, tail };
}
/**
* 7bit or 8bit lines of at most 998 octets ending in CRLF (RFC 2045 §2.7,
* §2.8); anything else (a NUL, a bare CR or LF, a longer line) is binary.
*/
export function rawEncoding(bytes: Uint8Array): RawEncoding {
let eightBit = false;
let line = 0;
for (let i = 0; i < bytes.length; i++) {
const b = bytes[i];
if (b === 0x00) return 'binary';
if (b === 0x0d) {
if (bytes[i + 1] !== 0x0a) return 'binary';
i++;
line = 0;
continue;
}
if (b === 0x0a) return 'binary';
if (b > 0x7f) eightBit = true;
if (++line > 998) return 'binary';
}
return eightBit ? '8bit' : '7bit';
}
/** The whole report as a Blob, the original downloaded from its own account. */
export async function buildArfReportBlob(input: ArfInput): Promise<Blob> {
const { client, email, sourceAccountId } = input;
if (!email.blobId) throw new Error('The message has no content to report');
// The report is uploaded: one bigger than the server takes fails anyway,
// so it isn't downloaded either.
const maxUpload = client.getMaxSizeUpload?.() ?? 0;
if (maxUpload > 0 && email.size + 8192 > maxUpload) {
throw new Error('The message is too big for an ARF report on this server');
}
const original = new Uint8Array(await client.fetchBlobArrayBuffer(email.blobId, 'message.eml', 'message/rfc822', sourceAccountId));
const parts = buildArfParts({
email,
reporter: input.reporter,
to: input.to,
encoding: rawEncoding(original),
recipient: deliveryAddresses(email.headers)[0]
?? email.to?.find((addr) => addr.email?.toLowerCase() === input.reporter.email.toLowerCase())?.email,
});
// The original goes in byte for byte; the closing boundary needs a line break before it.
const endsWithBreak = original.length >= 2 && original[original.length - 2] === 0x0d && original[original.length - 1] === 0x0a;
const report = new Blob([parts.head, original, endsWithBreak ? parts.tail.slice(2) : parts.tail], { type: 'message/rfc822' });
if (maxUpload > 0 && report.size > maxUpload) throw new Error('The message is too big for an ARF report on this server');
return report;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* The app side of "Report phishing" (lib/phishing-report.ts sends one
* report): which policy applies to a message, sending one report per
* message, moving each to Junk with $phishing, and undoing the move.
*
* The policy is the one of the account the message is reached through
* (its own login in the unified inbox), so a domain or group scope with its
* own report address applies to its own mail.
*/
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email } from '@/lib/jmap/types';
import { subjectForAccount } from '@/lib/org/account-subjects';
import { emailKeyFor } from '@/lib/thread-utils';
import { debug } from '@/lib/debug';
import { useAuthStore } from '@/stores/auth-store';
import { resolveEmailActionContext, useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { usePhishingReportStore } from '@/stores/phishing-report-store';
import { emailHooks } from '@/lib/plugin-hooks';
import {
PHISHING_KEYWORD,
phishingReportSetup,
sendPhishingReport,
type ArfInput,
type PhishingReportSetup,
} from './phishing-report';
/** The report setup for a message, from the policy of the account it is in. */
export function phishingSetupForEmail(email: Pick<Email, 'sourceClientAccountId'> | null | undefined): PhishingReportSetup | null {
const view = usePolicyStore.getState();
const subject = subjectForAccount(email?.sourceClientAccountId ?? null);
const policy = subject === view.subject ? view.policy : view.resolveFor(subject);
return phishingReportSetup(policy);
}
interface MovedReport {
email: Email;
/** Moved through the store (its undo cache knows the folder) or straight through the client. */
via: 'store' | { client: IJMAPClient; accountId?: string; mailboxId: string };
}
export interface PhishingReportOutcome {
/** Every address the reports went to (accounts with different policies differ). */
addresses: string[];
/** Reports that went out. */
reported: Email[];
/** Messages whose report didn't go out. */
failed: Email[];
/** Reported, but the move to Junk failed or a plugin stopped it. */
notMoved: Email[];
/** What undo moves back. */
moved: MovedReport[];
}
// Loaded only when an admin picked the ARF format.
async function loadArfBuilder(): Promise<(input: ArfInput) => Promise<Blob>> {
const { buildArfReportBlob } = await import('./phishing-report-arf');
return buildArfReportBlob;
}
/** The folder a message is in, for a move back that the store can't do. */
function firstMailboxId(email: Email): string | null {
const ids = Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]);
return ids[0] ?? null;
}
/**
* Reports each message on its own, then moves it to Junk. Messages that
* can't be reported (reporting off for their account) count as failed.
*/
export async function reportEmailsAsPhishing(emails: Email[]): Promise<PhishingReportOutcome> {
const active = useAuthStore.getState().client;
if (!active) throw new Error('Not signed in');
const outcome: PhishingReportOutcome = { addresses: [], reported: [], failed: [], notMoved: [], moved: [] };
let buildArf: ((input: ArfInput) => Promise<Blob>) | undefined;
for (const email of emails) {
const setup = phishingSetupForEmail(email);
if (!setup) {
outcome.failed.push(email);
continue;
}
if (!outcome.addresses.includes(setup.address)) outcome.addresses.push(setup.address);
const { client, accountId } = resolveEmailActionContext(email, active);
try {
if (setup.format === 'arf' && !buildArf) buildArf = await loadArfBuilder();
await sendPhishingReport(email, { client, sourceAccountId: accountId ?? client.getAccountId(), setup, buildArf });
} catch (error) {
debug.error('Phishing report failed:', error);
outcome.failed.push(email);
continue;
}
outcome.reported.push(email);
// Every open view of it shows the banner and turns its links off now.
usePhishingReportStore.getState().markReported([emailKeyFor(email)], true);
// Like "Mark as spam" (plugins' onBeforeEmailSpamToggle still runs),
// with $phishing, and read: it shouldn't count as new mail in Junk.
const store = useEmailStore.getState();
const known = store.emails.some((e) => emailKeyFor(e) === emailKeyFor(email))
|| (!!store.selectedEmail && emailKeyFor(store.selectedEmail) === emailKeyFor(email));
try {
if (known) {
const moved = await store.markAsSpam(active, email.id, emailKeyFor(email), { keywords: [PHISHING_KEYWORD], markRead: true });
if (moved) outcome.moved.push({ email, via: 'store' });
else outcome.notMoved.push(email);
} else {
// A message the list doesn't hold (a Pro tab, a conversation's older
// message): straight to its account, with the plugins' spam hooks
// as "Mark as spam" runs them.
const toggle = { emailIds: [email.id], spam: true };
if (emailHooks.onBeforeEmailSpamToggle.size > 0 && !(await emailHooks.onBeforeEmailSpamToggle.intercept(toggle))) {
outcome.notMoved.push(email);
continue;
}
const mailboxId = firstMailboxId(email);
await client.markAsSpam(email.id, accountId, true, [PHISHING_KEYWORD]);
void emailHooks.onEmailSpamToggle.emit(toggle);
if (mailboxId) outcome.moved.push({ email, via: { client, accountId, mailboxId } });
}
} catch (error) {
debug.error('Moving a reported message to Junk failed:', error);
outcome.notMoved.push(email);
}
}
return outcome;
}
/** Moves the reported messages back and clears $junk and $phishing. Resolves to how many came back. */
export async function undoPhishingReports(outcome: PhishingReportOutcome): Promise<number> {
const active = useAuthStore.getState().client;
if (!active) return 0;
let back = 0;
for (const { email, via } of outcome.moved) {
try {
let done = false;
if (via === 'store') {
done = await useEmailStore.getState().undoSpam(active, email.id, emailKeyFor(email), { clearKeywords: [PHISHING_KEYWORD] });
} else {
const toggle = { emailIds: [email.id], spam: false };
if (emailHooks.onBeforeEmailSpamToggle.size > 0 && !(await emailHooks.onBeforeEmailSpamToggle.intercept(toggle))) continue;
await via.client.undoSpam(email.id, via.mailboxId, via.accountId, [PHISHING_KEYWORD]);
void emailHooks.onEmailSpamToggle.emit(toggle);
done = true;
}
if (done) {
back++;
usePhishingReportStore.getState().markReported([emailKeyFor(email)], false);
}
} catch (error) {
debug.error('Moving a reported message back failed:', error);
}
}
return back;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* "Report phishing" (enterprise pack item 2): sends a message to the
* address the admin set (policy.json `organisation.phishingReport`) and
* moves it to Junk with $junk and $phishing. A sibling of
* lib/plugin-sandbox/host-api-forward.ts: it sends without the composer.
*
* - One report per message, also for a selection: report desks (Microsoft
* Defender among them) discard reports with several messages attached,
* and RFC 5965 §2(e) says one message per report.
* - No copy in Sent: a live phishing message shouldn't sit there to be
* clicked again; the admin's mailbox is the record.
* - The forward format attaches the original by its blobId (Email/set in
* Drafts, then EmailSubmission with onSuccessDestroyEmail), so nothing is
* uploaded and nothing counts against the upload quota. A message of
* another account (a shared or group mailbox) has its blob copied to the
* sending account first (Blob/copy).
* - The report skips the pre-send checks and finalisers of the send
* pipeline (external-recipient confirm, organisation signature and
* disclaimer), classification labels and plugins' onBeforeEmailSend: the
* address is the admin's, and the report is not the user's mail (send
* surface 'report-phishing', never passed to the pipeline).
*/
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Identity } from '@/lib/jmap/types';
import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY, type PhishingReportFormat } from '@/lib/admin/organisation-policy';
import { emailExportFilename } from '@/lib/download-filename';
import { sortIdentities } from '@/lib/identity-order';
/** RFC 9051 §2.3.2: the user reported the message as phishing. */
export const PHISHING_KEYWORD = '$phishing';
export interface PhishingReportSetup {
address: string;
format: PhishingReportFormat;
}
/** The setup in a policy; null while reporting is off (no address). */
export function phishingReportSetup(policy: SettingsPolicy | null | undefined): PhishingReportSetup | null {
const section = policy?.organisation?.phishingReport ?? DEFAULT_ORGANISATION_POLICY.phishingReport;
const address = section.address.trim();
return address ? { address, format: section.format === 'arf' ? 'arf' : 'forward' } : null;
}
/** Folders whose messages are the user's own: nothing to report there. */
const OWN_MAIL_ROLES = new Set(['sent', 'drafts', 'scheduled']);
/**
* Whether `email`, shown in a folder of `mailboxRole`, can be reported.
* `folderRoles`: the roles of the folders the message itself is in (search
* results and All mail show the user's own sent mail too).
*/
export function isReportable(
email: Pick<Email, 'blobId' | 'keywords' | 'isScheduled'>,
mailboxRole?: string | null,
folderRoles: readonly string[] = [],
): boolean {
if (!email.blobId || email.isScheduled) return false;
if (mailboxRole && OWN_MAIL_ROLES.has(mailboxRole)) return false;
if (folderRoles.some((role) => OWN_MAIL_ROLES.has(role))) return false;
if (email.keywords?.$draft) return false;
// Reported already: it is in Junk with its links off.
return email.keywords?.[PHISHING_KEYWORD] !== true;
}
/** The subject of the report: Microsoft's reporting mailboxes read "Phishing:" as the verdict. */
export function reportSubject(subject: string | undefined | null): string {
const clean = (subject ?? '').replace(/[\r\n]+/g, ' ').trim();
return clean ? `Phishing: ${clean}` : 'Phishing';
}
/** The short text of a forwarded report. English: it is read by the security team, not the user. */
export function reportText(reporter: { name?: string; email: string }): string {
const who = reporter.name?.trim() ? `${reporter.name.trim()} <${reporter.email}>` : reporter.email;
return `Reported as phishing by ${who} with Bulwark.\r\nThe reported message is attached unchanged.\r\n`;
}
/** The identity a report goes out from: the login's own address first. */
export function reporterIdentity(identities: Identity[], username: string): Identity | null {
const usable = identities.filter((identity) => identity.email && !identity.email.includes('*'));
return sortIdentities(usable, username)[0] ?? null;
}
export interface ReportContext {
/** The login the message is reachable through. */
client: IJMAPClient;
/** The JMAP account the message lives in (a shared one, for a group mailbox). */
sourceAccountId: string;
setup: PhishingReportSetup;
/** Builds the raw ARF report (lib/phishing-report-arf.ts); needed for format 'arf'. */
buildArf?: (input: ArfInput) => Promise<Blob>;
}
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<void> {
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 },
);
}
+22 -4
View File
@@ -11,7 +11,9 @@
// quick reply (external recipients, the organisation's disclaimer) and
// other plugins' onBeforeEmailSend, and is held for the user's undo delay;
// - the copy lands in Sent and the original is marked $forwarded, as after
// a forward from the composer.
// a forward from the composer;
// - it carries the original's classification label, else the default; when
// the organisation requires one and there is neither, nothing is sent.
import type { EmailForwardResult, InstalledPlugin, OutgoingEmail } from '../plugin-types';
import type { Attachment, Email } from '../jmap/types';
@@ -24,6 +26,9 @@ import { emailHooks } from '../plugin-hooks';
import { announceSent } from '../plugin-after-send';
import { buildForwardSubject } from '../subject-prefix';
import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline';
import { automaticLabel, classifyOutgoing } from '../outgoing/classification';
import { whenPolicySettled } from '@/stores/policy-store';
import { toast } from '@/stores/toast-store';
// Registers the organisation signature check and the disclaimer.
import '@/lib/org/outgoing';
import { plainTextFromEmail } from '../plugin-projection';
@@ -139,6 +144,15 @@ export async function doEmailForward(
const messages = await hostMessages();
const subject = buildForwardSubject(email.subject || text(messages, 'email_viewer.no_subject'), text(messages, 'email_composer.prefix.forward'));
// The classification label: a required one that isn't there stops the
// forward before anything is asked.
await whenPolicySettled();
const label = automaticLabel({ email: identity.email }, { headers: email.headers, from: email.from });
if (label.missing) {
toast.error(text(messages, 'classification.forward_needs_label'));
return { sent: false, reason: 'stopped' };
}
// Ask before the first forward to an address.
const addresses = to.map((entry) => addressOf(entry).toLowerCase());
const account = approvalAccount();
@@ -198,12 +212,15 @@ export async function doEmailForward(
});
}
// Last, after the plugins' checks: the label's prefix and headers.
const labelled = classifyOutgoing(label.policy, label.label, { subject });
const delaySeconds = useSettingsStore.getState().sendDelaySeconds;
const delayedUntil = delaySeconds > 0 && client.hasDelayedSend()
? new Date(Date.now() + delaySeconds * 1000).toISOString()
: undefined;
const result = await useEmailStore.getState().sendEmail(
client, to, subject, body, undefined, undefined, identity.id, identity.email, undefined,
client, to, labelled.subject, body, undefined, undefined, identity.id, identity.email, undefined,
identity.name || undefined, undefined,
attachments.map((a, i) => ({
blobId: blobIds[i],
@@ -212,9 +229,10 @@ export async function doEmailForward(
size: a.size,
disposition: 'attachment' as const,
})),
undefined, undefined, delayedUntil,
undefined, undefined, delayedUntil, undefined,
labelled.labelHeaders ? { labelHeaders: labelled.labelHeaders } : undefined,
);
announceSent(result, { to: addresses, cc: [], bcc: [], subject });
announceSent(result, { to: addresses, cc: [], bcc: [], subject: labelled.subject });
try {
await deps.markForwarded(emailId, sourceAccountId);
+11 -4
View File
@@ -3,6 +3,7 @@
// structured-cloneable data back to the iframe.
import type { AppInfoView, InstalledPlugin, Permission } from '../plugin-types';
import { labelRawSend } from '@/lib/outgoing/classification-raw';
import { fetchConfig } from '@/hooks/use-config';
import { IS_LITE } from '../lite';
import { doAppGetSettings, doAppSearchSettings } from './host-api-app';
@@ -800,6 +801,8 @@ async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown):
interface JmapSubmitRawOptions {
delayedUntil?: string;
envelopeRecipients?: string[];
/** The `labelToken` of the onComposeSend request this send completes. */
labelToken?: string;
}
/**
@@ -822,7 +825,10 @@ async function doJmapSendRaw(
// ArrayBuffer (not SharedArrayBuffer) — also detaches from the caller's view.
const copy = new Uint8Array(view.byteLength);
copy.set(view);
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
// The classification label goes into the top-level header block, outside
// what the plugin signed or encrypted (lib/outgoing/classification-raw.ts).
const labelled = labelRawSend(copy, identityId, opts?.labelToken);
const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
return useEmailStore.getState().sendRawEmail(
client,
blob,
@@ -856,7 +862,8 @@ async function doJmapSubmitRaw(
const copy = new Uint8Array(view.byteLength);
copy.set(view);
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
const labelled = labelRawSend(copy, identityId, opts?.labelToken);
const blob = new Blob([labelled.buffer as ArrayBuffer], { type: 'message/rfc822' });
return client.submitRawEmail(
blob,
@@ -2040,12 +2047,12 @@ export async function dispatchApiCall(
case 'jmap.sendRaw': return doJmapSendRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
args[2] as JmapSubmitRawOptions | undefined,
);
case 'jmap.submitRaw': return doJmapSubmitRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
args[2] as JmapSubmitRawOptions | undefined,
);
case 'jmap.importRaw': return doJmapImportRaw(
args[0] as ArrayBuffer | ArrayBufferView,
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "الإرسال على أي حال",
"back": "رجوع"
},
"phishing_report": {
"action": "الإبلاغ عن تصيّد احتيالي…",
"confirm_title": "الإبلاغ عن تصيّد احتيالي؟",
"confirm_message": "{count, plural, zero {لا توجد رسائل لإرسالها إلى {address}.} one {ستُرسل الرسالة إلى {address} وتُنقل إلى البريد المزعج.} two {ستُرسل كل من الرسالتين إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} few {ستُرسل كل رسالة من # رسائل إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} many {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.} other {ستُرسل كل رسالة من # رسالة إلى {address} كبلاغ مستقل وتُنقل إلى البريد المزعج.}}",
"confirm": "إبلاغ",
"cancel": "إلغاء",
"sending": "جارٍ الإبلاغ…",
"done": "{count, plural, zero {لم تُرسل أي رسالة إلى {address}.} one {تم إبلاغ {address} عن الرسالة ونُقلت إلى البريد المزعج.} two {تم إبلاغ {address} عن رسالتين ونُقلتا إلى البريد المزعج.} few {تم إبلاغ {address} عن # رسائل ونُقلت إلى البريد المزعج.} many {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.} other {تم إبلاغ {address} عن # رسالة ونُقلت إلى البريد المزعج.}}",
"not_moved": "{count, plural, zero {تم إبلاغ {address} ونُقلت كل الرسائل إلى البريد المزعج.} one {تم إبلاغ {address}، لكن تعذّر نقل الرسالة إلى البريد المزعج.} two {تم إبلاغ {address}، لكن تعذّر نقل رسالتين إلى البريد المزعج.} few {تم إبلاغ {address}، لكن تعذّر نقل # رسائل إلى البريد المزعج.} many {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.} other {تم إبلاغ {address}، لكن تعذّر نقل # رسالة إلى البريد المزعج.}}",
"failed": "{count, plural, zero {تم الإبلاغ عن كل الرسائل.} one {تعذّر الإبلاغ عن الرسالة.} two {تعذّر الإبلاغ عن رسالتين.} few {تعذّر الإبلاغ عن # رسائل.} many {تعذّر الإبلاغ عن # رسالة.} other {تعذّر الإبلاغ عن # رسالة.}}",
"undo": "تراجع",
"undone": "{count, plural, zero {تمت الإعادة إلى مكانها.} one {أُعيدت الرسالة إلى مكانها. البلاغ أُرسل بالفعل.} two {أُعيدت الرسالتان إلى مكانهما. البلاغان أُرسلا بالفعل.} few {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} many {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.} other {أُعيدت الرسائل إلى مكانها. البلاغات أُرسلت بالفعل.}}",
"undo_failed": "تعذّرت الإعادة إلى المكان السابق.",
"banner": "أبلغت عن هذه الرسالة كتصيّد احتيالي. تم تعطيل روابطها.",
"link_off": "الرابط معطّل",
"link_off_note": "(الرابط معطّل)",
"no_subject": "(بلا موضوع)"
},
"classification": {
"picker_label": "التصنيف الأمني",
"chip_label": "التصنيف الأمني: {name}",
"none": "بدون تصنيف أمني",
"pick_required": "اختر تصنيفًا أمنيًا",
"required_hint": "تشترط مؤسستك تصنيفًا أمنيًا.",
"required_toast": "اختر تصنيفًا أمنيًا قبل الإرسال.",
"quick_reply_needs_label": "يحتاج هذا الرد إلى تصنيف أمني. اختر واحدًا هنا، ثم أرسل.",
"forward_needs_label": "التصنيف الأمني مطلوب. أعد توجيه الرسالة من محرر الرسائل.",
"received_hint": "عيّنه برنامج البريد لدى المرسل. يمكن لأي شخص تعيين تصنيف أمني، لذا فهو لا يثبت شيئًا."
},
"email_composer": {
"select_from_directory": "الاختيار من الدليل",
"minimize": "تصغير",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Envia igualment",
"back": "Enrere"
},
"phishing_report": {
"action": "Denuncia com a pesca de credencials…",
"confirm_title": "Voleu denunciar-ho com a pesca de credencials?",
"confirm_message": "{count, plural, one {El missatge s'envia a {address} i es mou al correu brossa.} other {Cadascun dels # missatges s'envia a {address} com a denúncia independent i es mou al correu brossa.}}",
"confirm": "Denuncia",
"cancel": "Cancel·la",
"sending": "S'està denunciant…",
"done": "{count, plural, one {S'ha denunciat a {address} i s'ha mogut al correu brossa.} other {S'han denunciat # missatges a {address} i s'han mogut al correu brossa.}}",
"not_moved": "{count, plural, one {S'ha denunciat a {address}, però el missatge no s'ha pogut moure al correu brossa.} other {S'ha denunciat a {address}, però # missatges no s'han pogut moure al correu brossa.}}",
"failed": "{count, plural, one {No s'ha pogut denunciar el missatge.} other {No s'han pogut denunciar # missatges.}}",
"undo": "Desfés",
"undone": "{count, plural, one {S'ha tornat al lloc. La denúncia ja s'havia enviat.} other {S'han tornat al lloc. Les denúncies ja s'havien enviat.}}",
"undo_failed": "No s'ha pogut tornar al lloc.",
"banner": "Heu denunciat aquest missatge com a pesca de credencials. Els enllaços estan desactivats.",
"link_off": "enllaç desactivat",
"link_off_note": "(enllaç desactivat)",
"no_subject": "(sense assumpte)"
},
"classification": {
"picker_label": "Classificació",
"chip_label": "Classificació: {name}",
"none": "Sense classificació",
"pick_required": "Trieu una classificació",
"required_hint": "La vostra organització exigeix una classificació.",
"required_toast": "Trieu una classificació abans d'enviar.",
"quick_reply_needs_label": "Aquesta resposta necessita una classificació. Trieu-ne una aquí i després envieu-la.",
"forward_needs_label": "Cal una classificació. Reenvieu el missatge des del redactor.",
"received_hint": "La defineix el programa de correu del remitent. Qualsevol pot definir una classificació, així que no demostra res."
},
"email_composer": {
"select_from_directory": "Selecciona del directori",
"minimize": "Minimitza",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Přesto odeslat",
"back": "Zpět"
},
"phishing_report": {
"action": "Nahlásit phishing…",
"confirm_title": "Nahlásit phishing?",
"confirm_message": "{count, plural, one {Zpráva bude odeslána na {address} a přesunuta do složky Spam.} few {# zprávy budou odeslány na {address}, každá jako samostatné hlášení, a přesunuty do složky Spam.} many {# zprávy bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.} other {# zpráv bude odesláno na {address}, každá jako samostatné hlášení, a přesunuto do složky Spam.}}",
"confirm": "Nahlásit",
"cancel": "Zrušit",
"sending": "Nahlašování…",
"done": "{count, plural, one {Nahlášeno na {address} a přesunuto do složky Spam.} few {# zprávy nahlášeny na {address} a přesunuty do složky Spam.} many {# zprávy nahlášeno na {address} a přesunuto do složky Spam.} other {# zpráv nahlášeno na {address} a přesunuto do složky Spam.}}",
"not_moved": "{count, plural, one {Nahlášeno na {address}, ale zprávu se nepodařilo přesunout do složky Spam.} few {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} many {Nahlášeno na {address}, ale # zprávy se nepodařilo přesunout do složky Spam.} other {Nahlášeno na {address}, ale # zpráv se nepodařilo přesunout do složky Spam.}}",
"failed": "{count, plural, one {Zprávu se nepodařilo nahlásit.} few {# zprávy se nepodařilo nahlásit.} many {# zprávy se nepodařilo nahlásit.} other {# zpráv se nepodařilo nahlásit.}}",
"undo": "Zpět",
"undone": "{count, plural, one {Vráceno zpět. Hlášení už bylo odesláno.} few {Vráceno zpět. Hlášení už byla odeslána.} many {Vráceno zpět. Hlášení už byla odeslána.} other {Vráceno zpět. Hlášení už byla odeslána.}}",
"undo_failed": "Nepodařilo se vrátit zpět.",
"banner": "Tuto zprávu jste nahlásili jako phishing. Odkazy v ní jsou vypnuté.",
"link_off": "odkaz vypnut",
"link_off_note": "(odkaz vypnut)",
"no_subject": "(bez předmětu)"
},
"classification": {
"picker_label": "Klasifikace",
"chip_label": "Klasifikace: {name}",
"none": "Bez klasifikace",
"pick_required": "Vyberte klasifikaci",
"required_hint": "Vaše organizace vyžaduje klasifikaci.",
"required_toast": "Před odesláním vyberte klasifikaci.",
"quick_reply_needs_label": "Tato odpověď potřebuje klasifikaci. Vyberte ji zde a pak odešlete.",
"forward_needs_label": "Klasifikace je povinná. Přepošlete zprávu z editoru zpráv.",
"received_hint": "Nastavil ji poštovní program odesílatele. Klasifikaci může nastavit kdokoli, takže nic nedokazuje."
},
"email_composer": {
"select_from_directory": "Vybrat z adresáře",
"minimize": "Minimalizovat",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Send alligevel",
"back": "Tilbage"
},
"phishing_report": {
"action": "Rapportér phishing…",
"confirm_title": "Rapportér phishing?",
"confirm_message": "{count, plural, one {Beskeden sendes til {address} og flyttes til Spam.} other {Hver af de # beskeder sendes til {address} som en separat rapport og flyttes til Spam.}}",
"confirm": "Rapportér",
"cancel": "Annuller",
"sending": "Rapporterer…",
"done": "{count, plural, one {Rapporteret til {address} og flyttet til Spam.} other {# beskeder rapporteret til {address} og flyttet til Spam.}}",
"not_moved": "{count, plural, one {Rapporteret til {address}, men beskeden kunne ikke flyttes til Spam.} other {Rapporteret til {address}, men # beskeder kunne ikke flyttes til Spam.}}",
"failed": "{count, plural, one {Beskeden kunne ikke rapporteres.} other {# beskeder kunne ikke rapporteres.}}",
"undo": "Fortryd",
"undone": "{count, plural, one {Flyttet tilbage. Rapporten var allerede sendt.} other {Flyttet tilbage. Rapporterne var allerede sendt.}}",
"undo_failed": "Kunne ikke flyttes tilbage.",
"banner": "Du har rapporteret denne besked som phishing. Links i den er slået fra.",
"link_off": "link slået fra",
"link_off_note": "(link slået fra)",
"no_subject": "(intet emne)"
},
"classification": {
"picker_label": "Klassificering",
"chip_label": "Klassificering: {name}",
"none": "Ingen klassificering",
"pick_required": "Vælg en klassificering",
"required_hint": "Din organisation kræver en klassificering.",
"required_toast": "Vælg en klassificering, før du sender.",
"quick_reply_needs_label": "Dette svar skal have en klassificering. Vælg en her, og send derefter.",
"forward_needs_label": "En klassificering er påkrævet. Videresend beskeden fra skrivevinduet.",
"received_hint": "Sat af afsenderens mailprogram. Alle kan sætte en klassificering, så den beviser intet."
},
"email_composer": {
"select_from_directory": "Vælg fra adressefortegnelse",
"minimize": "Minimer",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Trotzdem senden",
"back": "Zurück"
},
"phishing_report": {
"action": "Phishing melden…",
"confirm_title": "Phishing melden?",
"confirm_message": "{count, plural, one {Die Nachricht geht an {address} und wird in den Spam-Ordner verschoben.} other {Jede der # Nachrichten geht als eigene Meldung an {address} und wird in den Spam-Ordner verschoben.}}",
"confirm": "Melden",
"cancel": "Abbrechen",
"sending": "Wird gemeldet…",
"done": "{count, plural, one {An {address} gemeldet und in den Spam-Ordner verschoben.} other {# Nachrichten an {address} gemeldet und in den Spam-Ordner verschoben.}}",
"not_moved": "{count, plural, one {An {address} gemeldet, aber die Nachricht konnte nicht in den Spam-Ordner verschoben werden.} other {An {address} gemeldet, aber # Nachrichten konnten nicht in den Spam-Ordner verschoben werden.}}",
"failed": "{count, plural, one {Die Nachricht konnte nicht gemeldet werden.} other {# Nachrichten konnten nicht gemeldet werden.}}",
"undo": "Rückgängig",
"undone": "{count, plural, one {Zurückverschoben. Die Meldung wurde bereits gesendet.} other {Zurückverschoben. Die Meldungen wurden bereits gesendet.}}",
"undo_failed": "Konnte nicht zurückverschoben werden.",
"banner": "Sie haben diese Nachricht als Phishing gemeldet. Die Links darin sind deaktiviert.",
"link_off": "Link deaktiviert",
"link_off_note": "(Link deaktiviert)",
"no_subject": "(kein Betreff)"
},
"classification": {
"picker_label": "Einstufung",
"chip_label": "Einstufung: {name}",
"none": "Keine Einstufung",
"pick_required": "Einstufung wählen",
"required_hint": "Ihre Organisation verlangt eine Einstufung.",
"required_toast": "Wählen Sie vor dem Senden eine Einstufung.",
"quick_reply_needs_label": "Diese Antwort braucht eine Einstufung. Wählen Sie sie hier aus und senden Sie dann.",
"forward_needs_label": "Eine Einstufung ist vorgeschrieben. Leiten Sie die Nachricht im Editor weiter.",
"received_hint": "Vom E-Mail-Programm des Absenders gesetzt. Jeder kann eine Einstufung setzen, sie beweist also nichts."
},
"email_composer": {
"select_from_directory": "Aus Verzeichnis auswählen",
"minimize": "Minimieren",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Send anyway",
"back": "Back"
},
"phishing_report": {
"action": "Report phishing…",
"confirm_title": "Report phishing?",
"confirm_message": "{count, plural, one {The message goes to {address} and moves to Junk.} other {Each of the # messages goes to {address} as its own report and moves to Junk.}}",
"confirm": "Report",
"cancel": "Cancel",
"sending": "Reporting…",
"done": "{count, plural, one {Reported to {address} and moved to Junk.} other {# messages reported to {address} and moved to Junk.}}",
"not_moved": "{count, plural, one {Reported to {address}, but the message couldn't be moved to Junk.} other {Reported to {address}, but # messages couldn't be moved to Junk.}}",
"failed": "{count, plural, one {The message couldn't be reported.} other {# messages couldn't be reported.}}",
"undo": "Undo",
"undone": "{count, plural, one {Moved back. The report was already sent.} other {Moved back. The reports were already sent.}}",
"undo_failed": "Couldn't move it back.",
"banner": "You reported this message as phishing. Its links are turned off.",
"link_off": "link turned off",
"link_off_note": "(link turned off)",
"no_subject": "(no subject)"
},
"classification": {
"picker_label": "Classification",
"chip_label": "Classification: {name}",
"none": "No label",
"pick_required": "Pick a label",
"required_hint": "Your organisation requires a label.",
"required_toast": "Pick a classification label before sending.",
"quick_reply_needs_label": "This reply needs a classification label. Pick one here, then send.",
"forward_needs_label": "A classification label is required. Forward the message from the composer.",
"received_hint": "Set by the sender's mail program. Anyone can set a label, so it proves nothing."
},
"email_composer": {
"select_from_directory": "Select from directory",
"minimize": "Minimize",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Enviar de todos modos",
"back": "Atrás"
},
"phishing_report": {
"action": "Reportar phishing…",
"confirm_title": "¿Reportar phishing?",
"confirm_message": "{count, plural, one {El mensaje se enviará a {address} y se moverá a Spam.} other {Cada uno de los # mensajes se enviará a {address} como un reporte aparte y se moverá a Spam.}}",
"confirm": "Reportar",
"cancel": "Cancelar",
"sending": "Reportando…",
"done": "{count, plural, one {Reportado a {address} y movido a Spam.} other {# mensajes reportados a {address} y movidos a Spam.}}",
"not_moved": "{count, plural, one {Reportado a {address}, pero el mensaje no se pudo mover a Spam.} other {Reportados a {address}, pero # mensajes no se pudieron mover a Spam.}}",
"failed": "{count, plural, one {No se pudo reportar el mensaje.} other {No se pudieron reportar # mensajes.}}",
"undo": "Deshacer",
"undone": "{count, plural, one {Devuelto a su sitio. El reporte ya se había enviado.} other {Devueltos a su sitio. Los reportes ya se habían enviado.}}",
"undo_failed": "No se pudo devolver a su sitio.",
"banner": "Has reportado este mensaje como phishing. Sus enlaces están desactivados.",
"link_off": "enlace desactivado",
"link_off_note": "(enlace desactivado)",
"no_subject": "(sin asunto)"
},
"classification": {
"picker_label": "Clasificación",
"chip_label": "Clasificación: {name}",
"none": "Sin clasificación",
"pick_required": "Elige una clasificación",
"required_hint": "Tu organización exige una clasificación.",
"required_toast": "Elige una clasificación antes de enviar.",
"quick_reply_needs_label": "Esta respuesta necesita una clasificación. Elige una aquí y luego envía.",
"forward_needs_label": "Se requiere una clasificación. Reenvía el mensaje desde el redactor.",
"received_hint": "La pone el programa de correo del remitente. Cualquiera puede poner una clasificación, así que no demuestra nada."
},
"email_composer": {
"select_from_directory": "Seleccionar del directorio",
"minimize": "Minimizar",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Saada ikkagi",
"back": "Tagasi"
},
"phishing_report": {
"action": "Teata õngitsusest…",
"confirm_title": "Teatada õngitsusest?",
"confirm_message": "{count, plural, one {Kiri saadetakse aadressile {address} ja teisaldatakse rämpsposti.} other {Kõik # kirja saadetakse aadressile {address} eraldi teadetena ja teisaldatakse rämpsposti.}}",
"confirm": "Teata",
"cancel": "Tühista",
"sending": "Teatamine…",
"done": "{count, plural, one {Teatatud aadressile {address} ja teisaldatud rämpsposti.} other {# kirjast teatatud aadressile {address} ja teisaldatud rämpsposti.}}",
"not_moved": "{count, plural, one {Teatatud aadressile {address}, kuid kirja ei õnnestunud rämpsposti teisaldada.} other {Teatatud aadressile {address}, kuid # kirja ei õnnestunud rämpsposti teisaldada.}}",
"failed": "{count, plural, one {Kirjast ei õnnestunud teatada.} other {# kirjast ei õnnestunud teatada.}}",
"undo": "Võta tagasi",
"undone": "{count, plural, one {Tõstetud tagasi. Teade oli juba saadetud.} other {Tõstetud tagasi. Teated olid juba saadetud.}}",
"undo_failed": "Tagasi tõstmine ei õnnestunud.",
"banner": "Teatasid sellest kirjast kui õngitsusest. Selle lingid on välja lülitatud.",
"link_off": "link välja lülitatud",
"link_off_note": "(link välja lülitatud)",
"no_subject": "(teema puudub)"
},
"classification": {
"picker_label": "Klassifikatsioon",
"chip_label": "Klassifikatsioon: {name}",
"none": "Klassifikatsioonita",
"pick_required": "Vali klassifikatsioon",
"required_hint": "Sinu organisatsioon nõuab klassifikatsiooni.",
"required_toast": "Vali enne saatmist klassifikatsioon.",
"quick_reply_needs_label": "See vastus vajab klassifikatsiooni. Vali see siin ja saada siis.",
"forward_needs_label": "Klassifikatsioon on nõutud. Saada kiri edasi kirjakoostajast.",
"received_hint": "Selle määras saatja meiliprogramm. Klassifikatsiooni võib määrata igaüks, seega ei tõesta see midagi."
},
"email_composer": {
"select_from_directory": "Vali kataloogist",
"minimize": "Minimeeri",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "ارسال به هر حال",
"back": "بازگشت"
},
"phishing_report": {
"action": "گزارش فیشینگ…",
"confirm_title": "به‌عنوان فیشینگ گزارش شود؟",
"confirm_message": "{count, plural, one {پیام به {address} فرستاده و به هرزنامه منتقل می‌شود.} other {هر یک از # پیام به‌صورت گزارشی جداگانه به {address} فرستاده و به هرزنامه منتقل می‌شود.}}",
"confirm": "گزارش",
"cancel": "انصراف",
"sending": "در حال گزارش…",
"done": "{count, plural, one {به {address} گزارش و به هرزنامه منتقل شد.} other {# پیام به {address} گزارش و به هرزنامه منتقل شد.}}",
"not_moved": "{count, plural, one {به {address} گزارش شد، اما پیام به هرزنامه منتقل نشد.} other {به {address} گزارش شد، اما # پیام به هرزنامه منتقل نشد.}}",
"failed": "{count, plural, one {گزارش پیام ممکن نشد.} other {گزارش # پیام ممکن نشد.}}",
"undo": "بازگردانی",
"undone": "{count, plural, one {به جای قبلی برگردانده شد. گزارش قبلاً فرستاده شده است.} other {به جای قبلی برگردانده شدند. گزارش‌ها قبلاً فرستاده شده‌اند.}}",
"undo_failed": "برگرداندن ممکن نشد.",
"banner": "شما این پیام را به‌عنوان فیشینگ گزارش کردید. پیوندهای آن غیرفعال شده‌اند.",
"link_off": "پیوند غیرفعال شد",
"link_off_note": "(پیوند غیرفعال شد)",
"no_subject": "(بدون موضوع)"
},
"classification": {
"picker_label": "طبقه‌بندی",
"chip_label": "طبقه‌بندی: {name}",
"none": "بدون طبقه‌بندی",
"pick_required": "انتخاب طبقه‌بندی",
"required_hint": "سازمان شما طبقه‌بندی را الزامی کرده است.",
"required_toast": "پیش از ارسال یک طبقه‌بندی انتخاب کنید.",
"quick_reply_needs_label": "این پاسخ به طبقه‌بندی نیاز دارد. همین‌جا یکی انتخاب کنید و سپس ارسال کنید.",
"forward_needs_label": "طبقه‌بندی الزامی است. پیام را از ویرایشگر ارسال مجدد کنید.",
"received_hint": "برنامه ایمیل فرستنده آن را تعیین کرده است. هر کسی می‌تواند طبقه‌بندی تعیین کند، پس چیزی را ثابت نمی‌کند."
},
"email_composer": {
"select_from_directory": "انتخاب از فهرست",
"minimize": "کوچک کردن",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Envoyer quand même",
"back": "Retour"
},
"phishing_report": {
"action": "Signaler comme hameçonnage…",
"confirm_title": "Signaler comme hameçonnage ?",
"confirm_message": "{count, plural, one {Le message sera envoyé à {address} et déplacé dans le dossier Spam.} other {Chacun des # messages sera envoyé à {address} dans un signalement distinct et déplacé dans le dossier Spam.}}",
"confirm": "Signaler",
"cancel": "Annuler",
"sending": "Signalement en cours…",
"done": "{count, plural, one {Signalé à {address} et déplacé dans le dossier Spam.} other {# messages signalés à {address} et déplacés dans le dossier Spam.}}",
"not_moved": "{count, plural, one {Signalé à {address}, mais le message n'a pas pu être déplacé dans le dossier Spam.} other {Signalés à {address}, mais # messages n'ont pas pu être déplacés dans le dossier Spam.}}",
"failed": "{count, plural, one {Le message n'a pas pu être signalé.} other {# messages n'ont pas pu être signalés.}}",
"undo": "Annuler",
"undone": "{count, plural, one {Remis à sa place. Le signalement a déjà été envoyé.} other {Remis à leur place. Les signalements ont déjà été envoyés.}}",
"undo_failed": "Impossible de le remettre à sa place.",
"banner": "Vous avez signalé ce message comme hameçonnage. Ses liens sont désactivés.",
"link_off": "lien désactivé",
"link_off_note": "(lien désactivé)",
"no_subject": "(sans objet)"
},
"classification": {
"picker_label": "Classification",
"chip_label": "Classification : {name}",
"none": "Aucune classification",
"pick_required": "Choisir une classification",
"required_hint": "Votre organisation exige une classification.",
"required_toast": "Choisissez une classification avant d'envoyer.",
"quick_reply_needs_label": "Cette réponse nécessite une classification. Choisissez-en une ici, puis envoyez.",
"forward_needs_label": "Une classification est obligatoire. Transférez le message depuis l'éditeur.",
"received_hint": "Définie par le programme de messagerie de l'expéditeur. N'importe qui peut définir une classification, elle ne prouve donc rien."
},
"email_composer": {
"select_from_directory": "Choisir dans l'annuaire",
"minimize": "Réduire",
+29
View File
@@ -848,6 +848,35 @@
"send_anyway": "שלח בכל זאת",
"back": "חזרה"
},
"phishing_report": {
"action": "דווח על פישינג…",
"confirm_title": "לדווח על פישינג?",
"confirm_message": "{count, plural, one {ההודעה תישלח אל {address} ותועבר לספאם.} two {כל אחת משתי ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.} other {כל אחת מ-# ההודעות תישלח אל {address} כדיווח נפרד ותועבר לספאם.}}",
"confirm": "דווח",
"cancel": "ביטול",
"sending": "שולח דיווח…",
"done": "{count, plural, one {ההודעה דווחה אל {address} והועברה לספאם.} two {שתי הודעות דווחו אל {address} והועברו לספאם.} other {# הודעות דווחו אל {address} והועברו לספאם.}}",
"not_moved": "{count, plural, one {דווח אל {address}, אבל לא ניתן היה להעביר את ההודעה לספאם.} two {דווח אל {address}, אבל לא ניתן היה להעביר שתי הודעות לספאם.} other {דווח אל {address}, אבל לא ניתן היה להעביר # הודעות לספאם.}}",
"failed": "{count, plural, one {לא ניתן היה לדווח על ההודעה.} two {לא ניתן היה לדווח על שתי הודעות.} other {לא ניתן היה לדווח על # הודעות.}}",
"undo": "בטל",
"undone": "{count, plural, one {הוחזרה למקומה. הדיווח כבר נשלח.} two {הוחזרו למקומן. הדיווחים כבר נשלחו.} other {הוחזרו למקומן. הדיווחים כבר נשלחו.}}",
"undo_failed": "לא ניתן היה להחזיר למקום.",
"banner": "דיווחת על ההודעה הזו כפישינג. הקישורים שבה מושבתים.",
"link_off": "הקישור מושבת",
"link_off_note": "(הקישור מושבת)",
"no_subject": "(ללא נושא)"
},
"classification": {
"picker_label": "סיווג",
"chip_label": "סיווג: {name}",
"none": "ללא סיווג",
"pick_required": "בחירת סיווג",
"required_hint": "הארגון שלך מחייב סיווג.",
"required_toast": "יש לבחור סיווג לפני השליחה.",
"quick_reply_needs_label": "התשובה הזו צריכה סיווג. יש לבחור סיווג כאן ואז לשלוח.",
"forward_needs_label": "נדרש סיווג. יש להעביר את ההודעה מהעורך.",
"received_hint": "נקבע על ידי תוכנת הדואר של השולח. כל אחד יכול לקבוע סיווג, ולכן הוא לא מוכיח דבר."
},
"email_composer": {
"select_from_directory": "בחירה מהספרייה",
"minimize": "מזעור",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Küldés így is",
"back": "Vissza"
},
"phishing_report": {
"action": "Adathalászat jelentése…",
"confirm_title": "Jelented adathalászatként?",
"confirm_message": "{count, plural, one {Az üzenet a(z) {address} címre megy, és a Spam mappába kerül.} other {Mind a(z) # üzenet külön jelentésként megy a(z) {address} címre, és a Spam mappába kerül.}}",
"confirm": "Jelentés",
"cancel": "Mégse",
"sending": "Jelentés folyamatban…",
"done": "{count, plural, one {Jelentve a(z) {address} címre, és áthelyezve a Spam mappába.} other {# üzenet jelentve a(z) {address} címre, és áthelyezve a Spam mappába.}}",
"not_moved": "{count, plural, one {Jelentve a(z) {address} címre, de az üzenetet nem sikerült áthelyezni a Spam mappába.} other {Jelentve a(z) {address} címre, de # üzenetet nem sikerült áthelyezni a Spam mappába.}}",
"failed": "{count, plural, one {Az üzenetet nem sikerült jelenteni.} other {# üzenetet nem sikerült jelenteni.}}",
"undo": "Visszavonás",
"undone": "{count, plural, one {Visszahelyezve. A jelentés már elment.} other {Visszahelyezve. A jelentések már elmentek.}}",
"undo_failed": "Nem sikerült visszahelyezni.",
"banner": "Ezt az üzenetet adathalászatként jelentetted. A benne lévő linkek ki vannak kapcsolva.",
"link_off": "link kikapcsolva",
"link_off_note": "(link kikapcsolva)",
"no_subject": "(nincs tárgy)"
},
"classification": {
"picker_label": "Besorolás",
"chip_label": "Besorolás: {name}",
"none": "Nincs besorolás",
"pick_required": "Válassz besorolást",
"required_hint": "A szervezeted besorolást ír elő.",
"required_toast": "Küldés előtt válassz besorolást.",
"quick_reply_needs_label": "Ehhez a válaszhoz besorolás kell. Itt válassz egyet, aztán küldd el.",
"forward_needs_label": "A besorolás kötelező. Az üzenetet a szerkesztőből továbbítsd.",
"received_hint": "A feladó levelezőprogramja állította be. Bárki megadhat besorolást, így semmit sem bizonyít."
},
"email_composer": {
"select_from_directory": "Kiválasztás a címtárból",
"minimize": "Kis méret",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Invia comunque",
"back": "Indietro"
},
"phishing_report": {
"action": "Segnala come phishing…",
"confirm_title": "Segnalare come phishing?",
"confirm_message": "{count, plural, one {Il messaggio viene inviato a {address} e spostato in Spam.} other {Ognuno dei # messaggi viene inviato a {address} come segnalazione separata e spostato in Spam.}}",
"confirm": "Segnala",
"cancel": "Annulla",
"sending": "Segnalazione in corso…",
"done": "{count, plural, one {Segnalato a {address} e spostato in Spam.} other {# messaggi segnalati a {address} e spostati in Spam.}}",
"not_moved": "{count, plural, one {Segnalato a {address}, ma non è stato possibile spostare il messaggio in Spam.} other {Segnalati a {address}, ma non è stato possibile spostare # messaggi in Spam.}}",
"failed": "{count, plural, one {Non è stato possibile segnalare il messaggio.} other {Non è stato possibile segnalare # messaggi.}}",
"undo": "Annulla",
"undone": "{count, plural, one {Rimesso al suo posto. La segnalazione era già stata inviata.} other {Rimessi al loro posto. Le segnalazioni erano già state inviate.}}",
"undo_failed": "Impossibile rimetterlo al suo posto.",
"banner": "Hai segnalato questo messaggio come phishing. I suoi link sono disattivati.",
"link_off": "link disattivato",
"link_off_note": "(link disattivato)",
"no_subject": "(nessun oggetto)"
},
"classification": {
"picker_label": "Classificazione",
"chip_label": "Classificazione: {name}",
"none": "Nessuna classificazione",
"pick_required": "Scegli una classificazione",
"required_hint": "La tua organizzazione richiede una classificazione.",
"required_toast": "Scegli una classificazione prima di inviare.",
"quick_reply_needs_label": "Questa risposta richiede una classificazione. Sceglila qui, poi invia.",
"forward_needs_label": "È richiesta una classificazione. Inoltra il messaggio dall'editor.",
"received_hint": "Impostata dal programma di posta del mittente. Chiunque può impostare una classificazione, quindi non prova nulla."
},
"email_composer": {
"select_from_directory": "Seleziona dalla rubrica aziendale",
"minimize": "Riduci a icona",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "そのまま送信",
"back": "戻る"
},
"phishing_report": {
"action": "フィッシングを報告…",
"confirm_title": "フィッシングとして報告しますか?",
"confirm_message": "{count, plural, =1 {メールを {address} に送信し、迷惑メールに移動します。} other {# 件のメールをそれぞれ個別の報告として {address} に送信し、迷惑メールに移動します。}}",
"confirm": "報告",
"cancel": "キャンセル",
"sending": "報告中…",
"done": "{count, plural, =1 {{address} に報告し、迷惑メールに移動しました。} other {# 件のメールを {address} に報告し、迷惑メールに移動しました。}}",
"not_moved": "{count, plural, =1 {{address} に報告しましたが、メールを迷惑メールに移動できませんでした。} other {{address} に報告しましたが、# 件のメールを迷惑メールに移動できませんでした。}}",
"failed": "{count, plural, =1 {メールを報告できませんでした。} other {# 件のメールを報告できませんでした。}}",
"undo": "元に戻す",
"undone": "{count, plural, =1 {元の場所に戻しました。報告はすでに送信済みです。} other {元の場所に戻しました。報告はすでに送信済みです。}}",
"undo_failed": "元に戻せませんでした。",
"banner": "このメールをフィッシングとして報告しました。メール内のリンクは無効になっています。",
"link_off": "リンクは無効です",
"link_off_note": "(リンク無効)",
"no_subject": "(件名なし)"
},
"classification": {
"picker_label": "機密区分",
"chip_label": "機密区分:{name}",
"none": "機密区分なし",
"pick_required": "機密区分を選択",
"required_hint": "組織では機密区分の指定が必要です。",
"required_toast": "送信する前に機密区分を選択してください。",
"quick_reply_needs_label": "この返信には機密区分が必要です。ここで選択してから送信してください。",
"forward_needs_label": "機密区分の指定が必要です。メール作成画面から転送してください。",
"received_hint": "送信者のメールプログラムが設定したものです。機密区分は誰でも設定できるため、何の証明にもなりません。"
},
"email_composer": {
"select_from_directory": "ディレクトリから選択",
"minimize": "最小化",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "그래도 보내기",
"back": "뒤로"
},
"phishing_report": {
"action": "피싱 신고…",
"confirm_title": "피싱으로 신고할까요?",
"confirm_message": "{count, plural, =1 {메시지를 {address} 주소로 보내고 스팸함으로 옮겨요.} other {메시지 #개를 각각 별도의 신고로 {address} 주소로 보내고 스팸함으로 옮겨요.}}",
"confirm": "신고",
"cancel": "취소",
"sending": "신고하는 중…",
"done": "{count, plural, =1 {{address} 주소로 신고하고 스팸함으로 옮겼어요.} other {메시지 #개를 {address} 주소로 신고하고 스팸함으로 옮겼어요.}}",
"not_moved": "{count, plural, =1 {{address} 주소로 신고했지만 메시지를 스팸함으로 옮기지 못했어요.} other {{address} 주소로 신고했지만 메시지 #개를 스팸함으로 옮기지 못했어요.}}",
"failed": "{count, plural, =1 {메시지를 신고하지 못했어요.} other {메시지 #개를 신고하지 못했어요.}}",
"undo": "실행 취소",
"undone": "{count, plural, =1 {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.} other {원래 위치로 되돌렸어요. 신고는 이미 보내졌어요.}}",
"undo_failed": "되돌리지 못했어요.",
"banner": "이 메시지를 피싱으로 신고했어요. 메시지의 링크는 꺼져 있어요.",
"link_off": "링크 꺼짐",
"link_off_note": "(링크 꺼짐)",
"no_subject": "(제목 없음)"
},
"classification": {
"picker_label": "보안 등급",
"chip_label": "보안 등급: {name}",
"none": "보안 등급 없음",
"pick_required": "보안 등급 선택",
"required_hint": "조직에서 보안 등급을 지정하도록 요구해요.",
"required_toast": "보내기 전에 보안 등급을 선택하세요.",
"quick_reply_needs_label": "이 답장에는 보안 등급이 필요해요. 여기서 선택한 다음 보내세요.",
"forward_needs_label": "보안 등급이 필요해요. 메일 쓰기 창에서 메시지를 전달하세요.",
"received_hint": "보낸 사람의 메일 프로그램이 지정한 값이에요. 누구나 보안 등급을 지정할 수 있어서 아무것도 증명하지 않아요."
},
"email_composer": {
"select_from_directory": "디렉터리에서 선택",
"minimize": "최소화",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Sūtīt tik un tā",
"back": "Atpakaļ"
},
"phishing_report": {
"action": "Ziņot par pikšķerēšanu…",
"confirm_title": "Ziņot par pikšķerēšanu?",
"confirm_message": "{count, plural, =1 {Ziņojums tiks nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.} one {# ziņojums tiks nosūtīts uz {address}, katrs atsevišķi, un pārvietots uz mapi Mēstules.} other {# ziņojumi tiks nosūtīti uz {address}, katrs atsevišķi, un pārvietoti uz mapi Mēstules.}}",
"confirm": "Ziņot",
"cancel": "Atcelt",
"sending": "Notiek ziņošana…",
"done": "{count, plural, =1 {Nosūtīts uz {address} un pārvietots uz mapi Mēstules.} zero {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.} one {# ziņojums nosūtīts uz {address} un pārvietots uz mapi Mēstules.} other {# ziņojumi nosūtīti uz {address} un pārvietoti uz mapi Mēstules.}}",
"not_moved": "{count, plural, =1 {Nosūtīts uz {address}, bet ziņojumu neizdevās pārvietot uz mapi Mēstules.} zero {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.} one {Nosūtīti uz {address}, bet # ziņojumu neizdevās pārvietot uz mapi Mēstules.} other {Nosūtīti uz {address}, bet # ziņojumus neizdevās pārvietot uz mapi Mēstules.}}",
"failed": "{count, plural, =1 {Neizdevās ziņot par ziņojumu.} zero {Neizdevās ziņot par # ziņojumiem.} one {Neizdevās ziņot par # ziņojumu.} other {Neizdevās ziņot par # ziņojumiem.}}",
"undo": "Atsaukt",
"undone": "{count, plural, =1 {Pārvietots atpakaļ. Ziņa par pikšķerēšanu jau ir nosūtīta.} zero {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} one {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.} other {Pārvietoti atpakaļ. Ziņas par pikšķerēšanu jau ir nosūtītas.}}",
"undo_failed": "Neizdevās pārvietot atpakaļ.",
"banner": "Jūs ziņojāt par šo ziņojumu kā par pikšķerēšanu. Tā saites ir izslēgtas.",
"link_off": "saite izslēgta",
"link_off_note": "(saite izslēgta)",
"no_subject": "(bez temata)"
},
"classification": {
"picker_label": "Klasifikācija",
"chip_label": "Klasifikācija: {name}",
"none": "Bez klasifikācijas",
"pick_required": "Izvēlieties klasifikāciju",
"required_hint": "Jūsu organizācija pieprasa klasifikāciju.",
"required_toast": "Pirms sūtīšanas izvēlieties klasifikāciju.",
"quick_reply_needs_label": "Šai atbildei vajadzīga klasifikācija. Izvēlieties to šeit un tad sūtiet.",
"forward_needs_label": "Klasifikācija ir obligāta. Pārsūtiet vēstuli no redaktora.",
"received_hint": "To iestatīja sūtītāja pasta programma. Klasifikāciju var iestatīt ikviens, tāpēc tā neko nepierāda."
},
"email_composer": {
"select_from_directory": "Izvēlēties no kataloga",
"minimize": "Minimizēt",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Ямар ч байсан илгээх",
"back": "Буцах"
},
"phishing_report": {
"action": "Фишинг мэдээлэх…",
"confirm_title": "Фишинг гэж мэдээлэх үү?",
"confirm_message": "{count, plural, one {Мессеж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.} other {# мессеж тус бүр тусдаа мэдээлэл болж {address} хаяг руу илгээгдэж, Спам руу шилжинэ.}}",
"confirm": "Мэдээлэх",
"cancel": "Цуцлах",
"sending": "Мэдээлж байна…",
"done": "{count, plural, one {{address} хаяг руу мэдээлж, Спам руу шилжүүллээ.} other {# мессежийг {address} хаяг руу мэдээлж, Спам руу шилжүүллээ.}}",
"not_moved": "{count, plural, one {{address} хаяг руу мэдээлсэн ч мессежийг Спам руу шилжүүлж чадсангүй.} other {{address} хаяг руу мэдээлсэн ч # мессежийг Спам руу шилжүүлж чадсангүй.}}",
"failed": "{count, plural, one {Мессежийг мэдээлж чадсангүй.} other {# мессежийг мэдээлж чадсангүй.}}",
"undo": "Буцаах",
"undone": "{count, plural, one {Буцааж шилжүүллээ. Мэдээлэл аль хэдийн илгээгдсэн.} other {Буцааж шилжүүллээ. Мэдээллүүд аль хэдийн илгээгдсэн.}}",
"undo_failed": "Буцааж шилжүүлж чадсангүй.",
"banner": "Та энэ мессежийг фишинг гэж мэдээлсэн. Үүний холбоосуудыг идэвхгүй болгосон.",
"link_off": "холбоосыг идэвхгүй болгосон",
"link_off_note": "(холбоосыг идэвхгүй болгосон)",
"no_subject": "(сэдэв байхгүй)"
},
"classification": {
"picker_label": "Нууцын зэрэглэл",
"chip_label": "Нууцын зэрэглэл: {name}",
"none": "Зэрэглэлгүй",
"pick_required": "Зэрэглэл сонгох",
"required_hint": "Таны байгууллага нууцын зэрэглэл шаарддаг.",
"required_toast": "Илгээхээсээ өмнө нууцын зэрэглэл сонгоно уу.",
"quick_reply_needs_label": "Энэ хариунд нууцын зэрэглэл шаардлагатай. Энд сонгоод илгээнэ үү.",
"forward_needs_label": "Нууцын зэрэглэл заавал шаардлагатай. Захидлыг бичих цонхноос дамжуулна уу.",
"received_hint": "Илгээгчийн шуудангийн програм тохируулсан. Хэн ч зэрэглэл тохируулж болох тул энэ юуг ч нотлохгүй."
},
"email_composer": {
"select_from_directory": "Лавлахаас сонгох",
"minimize": "Багасгах",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Send likevel",
"back": "Tilbake"
},
"phishing_report": {
"action": "Rapporter nettfisking…",
"confirm_title": "Rapportere nettfisking?",
"confirm_message": "{count, plural, one {Meldingen sendes til {address} og flyttes til Søppelpost.} other {Hver av de # meldingene sendes til {address} som en egen rapport og flyttes til Søppelpost.}}",
"confirm": "Rapporter",
"cancel": "Avbryt",
"sending": "Rapporterer…",
"done": "{count, plural, one {Rapportert til {address} og flyttet til Søppelpost.} other {# meldinger rapportert til {address} og flyttet til Søppelpost.}}",
"not_moved": "{count, plural, one {Rapportert til {address}, men meldingen kunne ikke flyttes til Søppelpost.} other {Rapportert til {address}, men # meldinger kunne ikke flyttes til Søppelpost.}}",
"failed": "{count, plural, one {Meldingen kunne ikke rapporteres.} other {# meldinger kunne ikke rapporteres.}}",
"undo": "Angre",
"undone": "{count, plural, one {Flyttet tilbake. Rapporten var allerede sendt.} other {Flyttet tilbake. Rapportene var allerede sendt.}}",
"undo_failed": "Kunne ikke flytte den tilbake.",
"banner": "Du har rapportert denne meldingen som nettfisking. Lenkene i den er slått av.",
"link_off": "lenke slått av",
"link_off_note": "(lenke slått av)",
"no_subject": "(uten emne)"
},
"classification": {
"picker_label": "Klassifisering",
"chip_label": "Klassifisering: {name}",
"none": "Ingen klassifisering",
"pick_required": "Velg en klassifisering",
"required_hint": "Organisasjonen din krever en klassifisering.",
"required_toast": "Velg en klassifisering før du sender.",
"quick_reply_needs_label": "Dette svaret trenger en klassifisering. Velg en her, og send deretter.",
"forward_needs_label": "En klassifisering er påkrevd. Videresend meldingen fra meldingsvinduet.",
"received_hint": "Satt av avsenderens e-postprogram. Hvem som helst kan sette en klassifisering, så den beviser ingenting."
},
"email_composer": {
"select_from_directory": "Velg fra katalog",
"minimize": "Minimer",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Toch verzenden",
"back": "Terug"
},
"phishing_report": {
"action": "Phishing melden…",
"confirm_title": "Phishing melden?",
"confirm_message": "{count, plural, one {Het bericht gaat naar {address} en wordt naar Spam verplaatst.} other {Elk van de # berichten gaat als aparte melding naar {address} en wordt naar Spam verplaatst.}}",
"confirm": "Melden",
"cancel": "Annuleren",
"sending": "Bezig met melden…",
"done": "{count, plural, one {Gemeld bij {address} en naar Spam verplaatst.} other {# berichten gemeld bij {address} en naar Spam verplaatst.}}",
"not_moved": "{count, plural, one {Gemeld bij {address}, maar het bericht kon niet naar Spam worden verplaatst.} other {Gemeld bij {address}, maar # berichten konden niet naar Spam worden verplaatst.}}",
"failed": "{count, plural, one {Het bericht kon niet worden gemeld.} other {# berichten konden niet worden gemeld.}}",
"undo": "Ongedaan maken",
"undone": "{count, plural, one {Teruggezet. De melding was al verstuurd.} other {Teruggezet. De meldingen waren al verstuurd.}}",
"undo_failed": "Kon niet worden teruggezet.",
"banner": "Je hebt dit bericht als phishing gemeld. De links erin zijn uitgeschakeld.",
"link_off": "link uitgeschakeld",
"link_off_note": "(link uitgeschakeld)",
"no_subject": "(geen onderwerp)"
},
"classification": {
"picker_label": "Classificatie",
"chip_label": "Classificatie: {name}",
"none": "Geen classificatie",
"pick_required": "Kies een classificatie",
"required_hint": "Je organisatie vereist een classificatie.",
"required_toast": "Kies een classificatie voordat je verzendt.",
"quick_reply_needs_label": "Dit antwoord heeft een classificatie nodig. Kies er hier een en verzend het dan.",
"forward_needs_label": "Een classificatie is verplicht. Stuur het bericht door vanuit de opsteller.",
"received_hint": "Ingesteld door het e-mailprogramma van de afzender. Iedereen kan een classificatie instellen, dus het bewijst niets."
},
"email_composer": {
"select_from_directory": "Kiezen uit adresboek van de organisatie",
"minimize": "Minimaliseren",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Wyślij mimo to",
"back": "Wstecz"
},
"phishing_report": {
"action": "Zgłoś phishing…",
"confirm_title": "Zgłosić phishing?",
"confirm_message": "{count, plural, one {Wiadomość trafi do {address} i zostanie przeniesiona do folderu Spam.} few {# wiadomości trafią do {address}, każda jako osobne zgłoszenie, i zostaną przeniesione do folderu Spam.} many {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.} other {# wiadomości trafi do {address}, każda jako osobne zgłoszenie, i zostanie przeniesionych do folderu Spam.}}",
"confirm": "Zgłoś",
"cancel": "Anuluj",
"sending": "Zgłaszanie…",
"done": "{count, plural, one {Zgłoszono do {address} i przeniesiono do folderu Spam.} few {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} many {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.} other {Zgłoszono # wiadomości do {address} i przeniesiono je do folderu Spam.}}",
"not_moved": "{count, plural, one {Zgłoszono do {address}, ale nie udało się przenieść wiadomości do folderu Spam.} few {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} many {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.} other {Zgłoszono do {address}, ale nie udało się przenieść # wiadomości do folderu Spam.}}",
"failed": "{count, plural, one {Nie udało się zgłosić wiadomości.} few {Nie udało się zgłosić # wiadomości.} many {Nie udało się zgłosić # wiadomości.} other {Nie udało się zgłosić # wiadomości.}}",
"undo": "Cofnij",
"undone": "{count, plural, one {Przeniesiono z powrotem. Zgłoszenie zostało już wysłane.} few {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} many {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.} other {Przeniesiono z powrotem. Zgłoszenia zostały już wysłane.}}",
"undo_failed": "Nie udało się przenieść z powrotem.",
"banner": "Ta wiadomość została zgłoszona jako phishing. Linki w niej są wyłączone.",
"link_off": "link wyłączony",
"link_off_note": "(link wyłączony)",
"no_subject": "(brak tematu)"
},
"classification": {
"picker_label": "Klasyfikacja",
"chip_label": "Klasyfikacja: {name}",
"none": "Bez klasyfikacji",
"pick_required": "Wybierz klasyfikację",
"required_hint": "Twoja organizacja wymaga klasyfikacji.",
"required_toast": "Przed wysłaniem wybierz klasyfikację.",
"quick_reply_needs_label": "Ta odpowiedź wymaga klasyfikacji. Wybierz ją tutaj, a potem wyślij.",
"forward_needs_label": "Klasyfikacja jest wymagana. Przekaż wiadomość dalej z edytora wiadomości.",
"received_hint": "Ustawiona przez program pocztowy nadawcy. Każdy może ustawić klasyfikację, więc niczego nie dowodzi."
},
"email_composer": {
"select_from_directory": "Wybierz z katalogu",
"minimize": "Minimalizuj",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Enviar mesmo assim",
"back": "Voltar"
},
"phishing_report": {
"action": "Reportar phishing…",
"confirm_title": "Reportar phishing?",
"confirm_message": "{count, plural, one {A mensagem vai para {address} e é movida para Spam.} other {Cada uma das # mensagens vai para {address} como uma denúncia separada e é movida para Spam.}}",
"confirm": "Reportar",
"cancel": "Cancelar",
"sending": "Reportando…",
"done": "{count, plural, one {Reportada para {address} e movida para Spam.} other {# mensagens reportadas para {address} e movidas para Spam.}}",
"not_moved": "{count, plural, one {Reportada para {address}, mas não foi possível mover a mensagem para Spam.} other {Reportadas para {address}, mas não foi possível mover # mensagens para Spam.}}",
"failed": "{count, plural, one {Não foi possível reportar a mensagem.} other {Não foi possível reportar # mensagens.}}",
"undo": "Desfazer",
"undone": "{count, plural, one {Devolvida ao lugar. A denúncia já foi enviada.} other {Devolvidas ao lugar. As denúncias já foram enviadas.}}",
"undo_failed": "Não foi possível devolver ao lugar.",
"banner": "Você reportou esta mensagem como phishing. Os links dela estão desativados.",
"link_off": "link desativado",
"link_off_note": "(link desativado)",
"no_subject": "(sem assunto)"
},
"classification": {
"picker_label": "Classificação",
"chip_label": "Classificação: {name}",
"none": "Sem classificação",
"pick_required": "Escolha uma classificação",
"required_hint": "Sua organização exige uma classificação.",
"required_toast": "Escolha uma classificação antes de enviar.",
"quick_reply_needs_label": "Esta resposta precisa de uma classificação. Escolha uma aqui e depois envie.",
"forward_needs_label": "É obrigatória uma classificação. Encaminhe a mensagem pelo editor.",
"received_hint": "Definida pelo programa de e-mail do remetente. Qualquer pessoa pode definir uma classificação, então ela não prova nada."
},
"email_composer": {
"select_from_directory": "Selecionar do diretório",
"minimize": "Minimizar",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Trimite oricum",
"back": "Înapoi"
},
"phishing_report": {
"action": "Raportează ca phishing…",
"confirm_title": "Raportați ca phishing?",
"confirm_message": "{count, plural, one {Mesajul ajunge la {address} și este mutat în Spam.} few {Fiecare dintre cele # mesaje ajunge la {address} ca raport separat și este mutat în Spam.} other {Fiecare dintre cele # de mesaje ajunge la {address} ca raport separat și este mutat în Spam.}}",
"confirm": "Raportează",
"cancel": "Anulează",
"sending": "Se raportează…",
"done": "{count, plural, one {Raportat la {address} și mutat în Spam.} few {# mesaje raportate la {address} și mutate în Spam.} other {# de mesaje raportate la {address} și mutate în Spam.}}",
"not_moved": "{count, plural, one {Raportat la {address}, dar mesajul nu a putut fi mutat în Spam.} few {Raportate la {address}, dar # mesaje nu au putut fi mutate în Spam.} other {Raportate la {address}, dar # de mesaje nu au putut fi mutate în Spam.}}",
"failed": "{count, plural, one {Mesajul nu a putut fi raportat.} few {# mesaje nu au putut fi raportate.} other {# de mesaje nu au putut fi raportate.}}",
"undo": "Anulează",
"undone": "{count, plural, one {Mutat înapoi. Raportul a fost deja trimis.} few {Mutate înapoi. Rapoartele au fost deja trimise.} other {Mutate înapoi. Rapoartele au fost deja trimise.}}",
"undo_failed": "Nu a putut fi mutat înapoi.",
"banner": "Ați raportat acest mesaj ca phishing. Linkurile din el sunt dezactivate.",
"link_off": "link dezactivat",
"link_off_note": "(link dezactivat)",
"no_subject": "(fără subiect)"
},
"classification": {
"picker_label": "Clasificare",
"chip_label": "Clasificare: {name}",
"none": "Fără clasificare",
"pick_required": "Alegeți o clasificare",
"required_hint": "Organizația dvs. cere o clasificare.",
"required_toast": "Alegeți o clasificare înainte de trimitere.",
"quick_reply_needs_label": "Acest răspuns are nevoie de o clasificare. Alegeți una aici, apoi trimiteți.",
"forward_needs_label": "Este necesară o clasificare. Redirecționați mesajul din fereastra de redactare.",
"received_hint": "Setată de programul de e-mail al expeditorului. Oricine poate seta o clasificare, deci nu dovedește nimic."
},
"email_composer": {
"select_from_directory": "Selectează din director",
"minimize": "Minimizează",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Всё равно отправить",
"back": "Назад"
},
"phishing_report": {
"action": "Сообщить о фишинге…",
"confirm_title": "Сообщить о фишинге?",
"confirm_message": "{count, plural, =1 {Письмо будет отправлено на {address} и перемещено в папку «Спам».} one {# письмо будет отправлено на {address}, каждое отдельным отчётом, и перемещено в папку «Спам».} few {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} many {# писем будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».} other {# письма будут отправлены на {address}, каждое отдельным отчётом, и перемещены в папку «Спам».}}",
"confirm": "Сообщить",
"cancel": "Отмена",
"sending": "Отправка отчёта…",
"done": "{count, plural, =1 {Отправлено на {address} и перемещено в папку «Спам».} one {# письмо отправлено на {address} и перемещено в папку «Спам».} few {# письма отправлены на {address} и перемещены в папку «Спам».} many {# писем отправлено на {address} и перемещено в папку «Спам».} other {# письма отправлены на {address} и перемещены в папку «Спам».}}",
"not_moved": "{count, plural, =1 {Отчёт отправлен на {address}, но письмо не удалось переместить в папку «Спам».} one {Отчёты отправлены на {address}, но # письмо не удалось переместить в папку «Спам».} few {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».} many {Отчёты отправлены на {address}, но # писем не удалось переместить в папку «Спам».} other {Отчёты отправлены на {address}, но # письма не удалось переместить в папку «Спам».}}",
"failed": "{count, plural, =1 {Не удалось сообщить о письме.} one {Не удалось сообщить о # письме.} few {Не удалось сообщить о # письмах.} many {Не удалось сообщить о # письмах.} other {Не удалось сообщить о # письмах.}}",
"undo": "Отменить",
"undone": "{count, plural, =1 {Возвращено на место. Отчёт уже был отправлен.} one {Возвращено на место. Отчёты уже были отправлены.} few {Возвращено на место. Отчёты уже были отправлены.} many {Возвращено на место. Отчёты уже были отправлены.} other {Возвращено на место. Отчёты уже были отправлены.}}",
"undo_failed": "Не удалось вернуть на место.",
"banner": "Вы сообщили, что это письмо фишинговое. Ссылки в нём отключены.",
"link_off": "ссылка отключена",
"link_off_note": "(ссылка отключена)",
"no_subject": "(без темы)"
},
"classification": {
"picker_label": "Классификация",
"chip_label": "Классификация: {name}",
"none": "Без классификации",
"pick_required": "Выберите классификацию",
"required_hint": "Ваша организация требует указывать классификацию.",
"required_toast": "Выберите классификацию перед отправкой.",
"quick_reply_needs_label": "Для этого ответа нужна классификация. Выберите её здесь, затем отправьте.",
"forward_needs_label": "Классификация обязательна. Перешлите письмо из редактора.",
"received_hint": "Указана почтовой программой отправителя. Классификацию может указать кто угодно, поэтому она ничего не доказывает."
},
"email_composer": {
"select_from_directory": "Выбрать из справочника",
"minimize": "Свернуть",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Odoslať aj tak",
"back": "Späť"
},
"phishing_report": {
"action": "Nahlásiť phishing…",
"confirm_title": "Nahlásiť phishing?",
"confirm_message": "{count, plural, one {Správa sa odošle na {address} a presunie do priečinka Spam.} few {# správy sa odošlú na {address}, každá ako samostatné hlásenie, a presunú do priečinka Spam.} many {# správy sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.} other {# správ sa odošle na {address}, každá ako samostatné hlásenie, a presunie do priečinka Spam.}}",
"confirm": "Nahlásiť",
"cancel": "Zrušiť",
"sending": "Nahlasuje sa…",
"done": "{count, plural, one {Nahlásené na {address} a presunuté do priečinka Spam.} few {# správy nahlásené na {address} a presunuté do priečinka Spam.} many {# správy nahlásené na {address} a presunuté do priečinka Spam.} other {# správ nahlásených na {address} a presunutých do priečinka Spam.}}",
"not_moved": "{count, plural, one {Nahlásené na {address}, ale správu sa nepodarilo presunúť do priečinka Spam.} few {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} many {Nahlásené na {address}, ale # správy sa nepodarilo presunúť do priečinka Spam.} other {Nahlásené na {address}, ale # správ sa nepodarilo presunúť do priečinka Spam.}}",
"failed": "{count, plural, one {Správu sa nepodarilo nahlásiť.} few {# správy sa nepodarilo nahlásiť.} many {# správy sa nepodarilo nahlásiť.} other {# správ sa nepodarilo nahlásiť.}}",
"undo": "Späť",
"undone": "{count, plural, one {Presunuté späť. Hlásenie už bolo odoslané.} few {Presunuté späť. Hlásenia už boli odoslané.} many {Presunuté späť. Hlásenia už boli odoslané.} other {Presunuté späť. Hlásenia už boli odoslané.}}",
"undo_failed": "Nepodarilo sa presunúť späť.",
"banner": "Túto správu ste nahlásili ako phishing. Odkazy v nej sú vypnuté.",
"link_off": "odkaz vypnutý",
"link_off_note": "(odkaz vypnutý)",
"no_subject": "(bez predmetu)"
},
"classification": {
"picker_label": "Klasifikácia",
"chip_label": "Klasifikácia: {name}",
"none": "Bez klasifikácie",
"pick_required": "Vyberte klasifikáciu",
"required_hint": "Vaša organizácia vyžaduje klasifikáciu.",
"required_toast": "Pred odoslaním vyberte klasifikáciu.",
"quick_reply_needs_label": "Táto odpoveď potrebuje klasifikáciu. Vyberte ju tu a potom odošlite.",
"forward_needs_label": "Klasifikácia je povinná. Prepošlite správu z editora správ.",
"received_hint": "Nastavil ju poštový program odosielateľa. Klasifikáciu môže nastaviť ktokoľvek, takže nič nedokazuje."
},
"email_composer": {
"select_from_directory": "Vybrať z adresára",
"minimize": "Minimalizovať",
+29
View File
@@ -1820,6 +1820,35 @@
"send_anyway": "ส่งต่อไป",
"back": "ย้อนกลับ"
},
"phishing_report": {
"action": "รายงานฟิชชิง…",
"confirm_title": "รายงานเป็นฟิชชิงหรือไม่",
"confirm_message": "{count, plural, =1 {ข้อความจะถูกส่งไปยัง {address} และย้ายไปที่จดหมายขยะ} other {ข้อความแต่ละฉบับจากทั้งหมด # ฉบับจะถูกส่งไปยัง {address} เป็นรายงานแยกกัน และย้ายไปที่จดหมายขยะ}}",
"confirm": "รายงาน",
"cancel": "ยกเลิก",
"sending": "กำลังรายงาน…",
"done": "{count, plural, =1 {รายงานไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว} other {รายงานข้อความ # ฉบับไปยัง {address} และย้ายไปที่จดหมายขยะแล้ว}}",
"not_moved": "{count, plural, =1 {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความไปที่จดหมายขยะได้} other {รายงานไปยัง {address} แล้ว แต่ไม่สามารถย้ายข้อความ # ฉบับไปที่จดหมายขยะได้}}",
"failed": "{count, plural, =1 {ไม่สามารถรายงานข้อความได้} other {ไม่สามารถรายงานข้อความ # ฉบับได้}}",
"undo": "เลิกทำ",
"undone": "{count, plural, =1 {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว} other {ย้ายกลับแล้ว รายงานถูกส่งไปแล้ว}}",
"undo_failed": "ไม่สามารถย้ายกลับได้",
"banner": "คุณรายงานข้อความนี้ว่าเป็นฟิชชิง ลิงก์ในข้อความถูกปิดใช้งานแล้ว",
"link_off": "ปิดใช้งานลิงก์แล้ว",
"link_off_note": "(ปิดใช้งานลิงก์แล้ว)",
"no_subject": "(ไม่มีเรื่อง)"
},
"classification": {
"picker_label": "ระดับความลับ",
"chip_label": "ระดับความลับ: {name}",
"none": "ไม่มีระดับความลับ",
"pick_required": "เลือกระดับความลับ",
"required_hint": "องค์กรของคุณกำหนดให้ต้องระบุระดับความลับ",
"required_toast": "เลือกระดับความลับก่อนส่ง",
"quick_reply_needs_label": "การตอบกลับนี้ต้องมีระดับความลับ เลือกที่นี่ แล้วกดส่ง",
"forward_needs_label": "ต้องระบุระดับความลับ ส่งต่อข้อความจากหน้าต่างเขียนจดหมาย",
"received_hint": "ตั้งโดยโปรแกรมอีเมลของผู้ส่ง ใครก็ตั้งระดับความลับได้ จึงพิสูจน์อะไรไม่ได้"
},
"welcome": {
"title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ",
"tip_compose": "กด c เพื่อเขียนอีเมลใหม่",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Yine de gönder",
"back": "Geri"
},
"phishing_report": {
"action": "Kimlik avı bildir…",
"confirm_title": "Kimlik avı olarak bildirilsin mi?",
"confirm_message": "{count, plural, one {İleti {address} adresine gönderilir ve Spam klasörüne taşınır.} other {# iletinin her biri ayrı bir bildirim olarak {address} adresine gönderilir ve Spam klasörüne taşınır.}}",
"confirm": "Bildir",
"cancel": "İptal",
"sending": "Bildiriliyor…",
"done": "{count, plural, one {{address} adresine bildirildi ve Spam klasörüne taşındı.} other {# ileti {address} adresine bildirildi ve Spam klasörüne taşındı.}}",
"not_moved": "{count, plural, one {{address} adresine bildirildi ancak ileti Spam klasörüne taşınamadı.} other {{address} adresine bildirildi ancak # ileti Spam klasörüne taşınamadı.}}",
"failed": "{count, plural, one {İleti bildirilemedi.} other {# ileti bildirilemedi.}}",
"undo": "Geri al",
"undone": "{count, plural, one {Geri taşındı. Bildirim zaten gönderilmişti.} other {Geri taşındı. Bildirimler zaten gönderilmişti.}}",
"undo_failed": "Geri taşınamadı.",
"banner": "Bu iletiyi kimlik avı olarak bildirdiniz. İçindeki bağlantılar devre dışı.",
"link_off": "bağlantı devre dışı",
"link_off_note": "(bağlantı devre dışı)",
"no_subject": "(konu yok)"
},
"classification": {
"picker_label": "Sınıflandırma",
"chip_label": "Sınıflandırma: {name}",
"none": "Sınıflandırma yok",
"pick_required": "Sınıflandırma seçin",
"required_hint": "Kuruluşunuz bir sınıflandırma gerektiriyor.",
"required_toast": "Göndermeden önce bir sınıflandırma seçin.",
"quick_reply_needs_label": "Bu yanıt için sınıflandırma gerekiyor. Burada bir tane seçin, sonra gönderin.",
"forward_needs_label": "Sınıflandırma zorunlu. İletiyi ileti düzenleyiciden iletin.",
"received_hint": "Gönderenin posta programı tarafından ayarlandı. Herkes sınıflandırma ayarlayabilir, bu yüzden hiçbir şeyi kanıtlamaz."
},
"email_composer": {
"select_from_directory": "Dizinden seç",
"minimize": "Simge durumuna küçült",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Усе одно надіслати",
"back": "Назад"
},
"phishing_report": {
"action": "Повідомити про фішинг…",
"confirm_title": "Повідомити про фішинг?",
"confirm_message": "{count, plural, =1 {Лист буде надіслано на {address} і переміщено до папки «Спам».} one {# лист буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} few {# листи буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} many {# листів буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».} other {# листа буде надіслано на {address}, кожен окремим звітом, і переміщено до папки «Спам».}}",
"confirm": "Повідомити",
"cancel": "Скасувати",
"sending": "Надсилання звіту…",
"done": "{count, plural, =1 {Надіслано на {address} і переміщено до папки «Спам».} one {# лист надіслано на {address} і переміщено до папки «Спам».} few {# листи надіслано на {address} і переміщено до папки «Спам».} many {# листів надіслано на {address} і переміщено до папки «Спам».} other {# листа надіслано на {address} і переміщено до папки «Спам».}}",
"not_moved": "{count, plural, =1 {Звіт надіслано на {address}, але лист не вдалося перемістити до папки «Спам».} one {Звіти надіслано на {address}, але # лист не вдалося перемістити до папки «Спам».} few {Звіти надіслано на {address}, але # листи не вдалося перемістити до папки «Спам».} many {Звіти надіслано на {address}, але # листів не вдалося перемістити до папки «Спам».} other {Звіти надіслано на {address}, але # листа не вдалося перемістити до папки «Спам».}}",
"failed": "{count, plural, =1 {Не вдалося повідомити про лист.} one {Не вдалося повідомити про # лист.} few {Не вдалося повідомити про # листи.} many {Не вдалося повідомити про # листів.} other {Не вдалося повідомити про # листа.}}",
"undo": "Скасувати",
"undone": "{count, plural, =1 {Повернуто на місце. Звіт уже надіслано.} one {Повернуто на місце. Звіти вже надіслано.} few {Повернуто на місце. Звіти вже надіслано.} many {Повернуто на місце. Звіти вже надіслано.} other {Повернуто на місце. Звіти вже надіслано.}}",
"undo_failed": "Не вдалося повернути на місце.",
"banner": "Ви повідомили, що цей лист фішинговий. Посилання в ньому вимкнено.",
"link_off": "посилання вимкнено",
"link_off_note": "(посилання вимкнено)",
"no_subject": "(без теми)"
},
"classification": {
"picker_label": "Класифікація",
"chip_label": "Класифікація: {name}",
"none": "Без класифікації",
"pick_required": "Виберіть класифікацію",
"required_hint": "Ваша організація вимагає вказувати класифікацію.",
"required_toast": "Виберіть класифікацію перед надсиланням.",
"quick_reply_needs_label": "Для цієї відповіді потрібна класифікація. Виберіть її тут, а потім надішліть.",
"forward_needs_label": "Класифікація обов'язкова. Перешліть лист із редактора.",
"received_hint": "Вказано поштовою програмою відправника. Класифікацію може вказати будь-хто, тому вона нічого не доводить."
},
"email_composer": {
"select_from_directory": "Вибрати з довідника",
"minimize": "Згорнути",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "Vẫn gửi",
"back": "Quay lại"
},
"phishing_report": {
"action": "Báo cáo lừa đảo…",
"confirm_title": "Báo cáo lừa đảo?",
"confirm_message": "{count, plural, =1 {Thư sẽ được gửi đến {address} và chuyển vào Thư rác.} other {Mỗi thư trong số # thư sẽ được gửi đến {address} thành một báo cáo riêng và chuyển vào Thư rác.}}",
"confirm": "Báo cáo",
"cancel": "Hủy",
"sending": "Đang báo cáo…",
"done": "{count, plural, =1 {Đã báo cáo đến {address} và chuyển vào Thư rác.} other {Đã báo cáo # thư đến {address} và chuyển vào Thư rác.}}",
"not_moved": "{count, plural, =1 {Đã báo cáo đến {address}, nhưng không thể chuyển thư vào Thư rác.} other {Đã báo cáo đến {address}, nhưng không thể chuyển # thư vào Thư rác.}}",
"failed": "{count, plural, =1 {Không thể báo cáo thư.} other {Không thể báo cáo # thư.}}",
"undo": "Hoàn tác",
"undone": "{count, plural, =1 {Đã chuyển về. Báo cáo đã được gửi trước đó.} other {Đã chuyển về. Các báo cáo đã được gửi trước đó.}}",
"undo_failed": "Không thể chuyển về.",
"banner": "Bạn đã báo cáo thư này là lừa đảo. Các liên kết trong thư đã bị tắt.",
"link_off": "đã tắt liên kết",
"link_off_note": "(đã tắt liên kết)",
"no_subject": "(không có tiêu đề)"
},
"classification": {
"picker_label": "Phân loại bảo mật",
"chip_label": "Phân loại bảo mật: {name}",
"none": "Chưa chọn phân loại",
"pick_required": "Chọn phân loại",
"required_hint": "Tổ chức của bạn yêu cầu chọn phân loại bảo mật.",
"required_toast": "Hãy chọn phân loại bảo mật trước khi gửi.",
"quick_reply_needs_label": "Thư trả lời này cần phân loại bảo mật. Hãy chọn ở đây rồi gửi.",
"forward_needs_label": "Cần có phân loại bảo mật. Hãy chuyển tiếp thư từ khung soạn thư.",
"received_hint": "Do chương trình thư của người gửi đặt. Ai cũng có thể đặt phân loại, nên nó không chứng minh được gì."
},
"email_composer": {
"select_from_directory": "Chọn từ danh bạ",
"minimize": "Thu nhỏ",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "仍然傳送",
"back": "返回"
},
"phishing_report": {
"action": "回報網路釣魚…",
"confirm_title": "要回報為網路釣魚嗎?",
"confirm_message": "{count, plural, =1 {這封郵件會傳送至 {address},並移至垃圾郵件。} other {# 封郵件會各自作為單獨的回報傳送至 {address},並移至垃圾郵件。}}",
"confirm": "回報",
"cancel": "取消",
"sending": "正在回報…",
"done": "{count, plural, =1 {已回報至 {address},並移至垃圾郵件。} other {已將 # 封郵件回報至 {address},並移至垃圾郵件。}}",
"not_moved": "{count, plural, =1 {已回報至 {address},但無法將郵件移至垃圾郵件。} other {已回報至 {address},但有 # 封郵件無法移至垃圾郵件。}}",
"failed": "{count, plural, =1 {無法回報這封郵件。} other {有 # 封郵件無法回報。}}",
"undo": "復原",
"undone": "{count, plural, =1 {已移回。回報已送出。} other {已移回。回報已送出。}}",
"undo_failed": "無法移回。",
"banner": "您已將這封郵件回報為網路釣魚。其中的連結已停用。",
"link_off": "連結已停用",
"link_off_note": "(連結已停用)",
"no_subject": "(無主題)"
},
"classification": {
"picker_label": "機密等級",
"chip_label": "機密等級:{name}",
"none": "未設定機密等級",
"pick_required": "選擇機密等級",
"required_hint": "您的組織要求設定機密等級。",
"required_toast": "傳送前請選擇機密等級。",
"quick_reply_needs_label": "此回覆需要設定機密等級。請在這裡選擇,然後傳送。",
"forward_needs_label": "必須設定機密等級。請從撰寫視窗轉寄此郵件。",
"received_hint": "由寄件者的郵件程式設定。任何人都能設定機密等級,因此無法證明任何事。"
},
"email_composer": {
"select_from_directory": "從目錄中選取",
"minimize": "最小化",
+29
View File
@@ -883,6 +883,35 @@
"send_anyway": "仍然发送",
"back": "返回"
},
"phishing_report": {
"action": "举报网络钓鱼…",
"confirm_title": "举报为网络钓鱼?",
"confirm_message": "{count, plural, =1 {该邮件将发送至 {address},并移至垃圾邮件。} other {# 封邮件将分别作为单独的举报发送至 {address},并移至垃圾邮件。}}",
"confirm": "举报",
"cancel": "取消",
"sending": "正在举报…",
"done": "{count, plural, =1 {已举报至 {address},并移至垃圾邮件。} other {已将 # 封邮件举报至 {address},并移至垃圾邮件。}}",
"not_moved": "{count, plural, =1 {已举报至 {address},但无法将邮件移至垃圾邮件。} other {已举报至 {address},但有 # 封邮件无法移至垃圾邮件。}}",
"failed": "{count, plural, =1 {无法举报该邮件。} other {有 # 封邮件无法举报。}}",
"undo": "撤销",
"undone": "{count, plural, =1 {已移回。举报已发出。} other {已移回。举报已发出。}}",
"undo_failed": "无法移回。",
"banner": "您已将此邮件举报为网络钓鱼。其中的链接已停用。",
"link_off": "链接已停用",
"link_off_note": "(链接已停用)",
"no_subject": "(无主题)"
},
"classification": {
"picker_label": "密级",
"chip_label": "密级:{name}",
"none": "未设置密级",
"pick_required": "选择密级",
"required_hint": "您的组织要求设置密级。",
"required_toast": "发送前请选择密级。",
"quick_reply_needs_label": "此回复需要设置密级。请在这里选择,然后发送。",
"forward_needs_label": "必须设置密级。请从撰写窗口转发此邮件。",
"received_hint": "由发件人的邮件程序设置。任何人都可以设置密级,因此它不能证明任何事情。"
},
"email_composer": {
"select_from_directory": "从目录中选择",
"minimize": "最小化",
+1
View File
@@ -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
+18 -10
View File
@@ -444,7 +444,7 @@ interface EmailStore {
/** `ownerAccountId`: the JMAP account a link names for the message (#1351). */
fetchEmailContent: (client: IJMAPClient, emailId: string, ownerAccountId?: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; localAccountId?: string; draftAccountId?: string }) => Promise<SendEmailResult>;
sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; headers?: Record<string, string>; labelHeaders?: Record<string, string>; localAccountId?: string; draftAccountId?: string }) => Promise<SendEmailResult>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise<boolean>;
@@ -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<string, { emailId: string; originalMailboxId: string; accountId?: string; sourceClientAccountId?: string }>;
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
markAsSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise<boolean>;
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
undoSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise<boolean>;
/**
* `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<boolean>;
/** `emailKey` as for markAsSpam; `options.clearKeywords` are removed with the move back. */
undoSpam: (client: IJMAPClient, emailId: string, emailKey?: string, options?: { clearKeywords?: string[] }) => Promise<boolean>;
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<boolean>;
batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise<boolean>;
@@ -6367,7 +6372,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
// Spam operations
markAsSpam: async (client, emailId, emailKey): Promise<boolean> => {
markAsSpam: async (client, emailId, emailKey, options): Promise<boolean> => {
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<EmailStore>((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<EmailStore>((set, get) => ({
return true;
},
undoSpam: async (client, emailId, emailKey): Promise<boolean> => {
undoSpam: async (client, emailId, emailKey, options): Promise<boolean> => {
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<EmailStore>((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
+64
View File
@@ -0,0 +1,64 @@
import { create } from 'zustand';
import type { Email } from '@/lib/jmap/types';
/**
* The "Report phishing?" question waiting for an answer. Any menu or
* toolbar asks through `requestPhishingReport`; the one host mounted in the
* app layout (components/email/phishing-report-host.tsx) shows the dialog,
* sends the reports and shows the outcome.
*/
interface PhishingReportState {
/** The messages and every address their reports go to (one per account policy). */
pending: { emails: Email[]; addresses: string[] } | null;
/** The reports are on their way; the dialog stays open and can't be sent twice. */
busy: boolean;
/** Where the focus goes back to when the dialog closes. */
opener: HTMLElement | null;
/**
* Messages reported in this session (emailKeyFor), so every open view
* of them (a Pro tab, a conversation) shows the banner and turns its
* links off at once, before the server's $phishing reaches it.
*/
reported: Record<string, true>;
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<HTMLElement>('[aria-haspopup="menu"][aria-expanded="true"], [aria-haspopup="true"][aria-expanded="true"]');
}
export const usePhishingReportStore = create<PhishingReportState>()((set, get) => ({
pending: null,
busy: false,
opener: null,
reported: {},
ask: (emails, addresses) => {
if (get().busy || emails.length === 0) return;
const active = typeof document !== 'undefined' ? document.activeElement : null;
set({ pending: { emails, addresses }, opener: openerOf(active) });
},
close: () => {
const { opener } = get();
set({ pending: null, busy: false, opener: null });
if (opener?.isConnected) opener.focus();
},
setBusy: (busy) => set({ busy }),
markReported: (keys, reported) => set((state) => {
const next = { ...state.reported };
for (const key of keys) {
if (reported) next[key] = true;
else delete next[key];
}
return { reported: next };
}),
}));
+10
View File
@@ -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<PolicyState>()((set, get) => ({
...INITIAL,
subject: null,
subjectPending: false,
scopeDomains: null,
loaded: false,
fetching: false,
@@ -210,7 +217,10 @@ export const usePolicyStore = create<PolicyState>()((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 });
},
+4
View File
@@ -51,6 +51,10 @@ export interface ProReplyContext {
quoteHeaderHtml?: string;
quoteHeaderText?: string;
quoteWrapInBlockquote?: boolean;
/** The original's label headers (lib/outgoing/classification.ts labelSourceHeaders). */
headers?: Record<string, string | string[]>;
/** The original's From for a forward as attachment, whose `from` stays empty. */
originalFrom?: { email?: string; name?: string }[];
}
export interface ProComposeTabData {