mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
Merge branch 'feat/enterprise-pack-a'
This commit is contained in:
@@ -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't make mail meet the VS-NfD handling rules (VSA § 50, § 55): that
|
||||
needs approved IT and BSI-approved encryption. Bulwark's S/MIME and PGP plugins and Stalwart are not
|
||||
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'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'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'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>
|
||||
);
|
||||
});
|
||||
@@ -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 />
|
||||
</>}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>`
|
||||
: "";
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: ' ' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "تصغير",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "کوچک کردن",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "מזעור",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "最小化",
|
||||
|
||||
@@ -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": "최소화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Багасгах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ă",
|
||||
|
||||
@@ -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": "Свернуть",
|
||||
|
||||
@@ -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ť",
|
||||
|
||||
@@ -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 เพื่อเขียนอีเมลใหม่",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Згорнути",
|
||||
|
||||
@@ -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ỏ",
|
||||
|
||||
@@ -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": "最小化",
|
||||
|
||||
@@ -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": "最小化",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
}));
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user