feat: let admins define classification labels with presets

This commit is contained in:
Linus Rath
2026-11-01 05:07:33 +01:00
parent 6262e58ef9
commit 59f8e462cd
5 changed files with 531 additions and 6 deletions
@@ -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 approved products');
});
it('starts from the generic preset and saves it with the default', async () => {
await renderSection();
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'generic' } });
expect(screen.getAllByTestId('org-label-item')).toHaveLength(4);
fireEvent.click(screen.getByTestId('org-labels-required'));
await act(async () => { await latest!.save(); });
const labels = (puts[0].organisation as { labels: { list: Array<{ id: string; subjectPrefix: string }>; defaultId: string; required: boolean } }).labels;
expect(labels.list.map((l) => l.id)).toEqual(['public', 'internal', 'confidential', 'strictly-confidential']);
expect(labels.defaultId).toBe('internal');
expect(labels.required).toBe(true);
// What the server keeps of it is the same.
expect(sanitizeOrganisationPolicy(puts[0].organisation).labels.list[2].subjectPrefix).toBe('[Confidential]');
});
it('offers no grade above VS-NfD in the German preset', async () => {
await renderSection();
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } });
const names = latest!.orgValue('labels', 'list').value.map((l) => l.name);
expect(names).toEqual(['OFFEN', 'VS-NfD']);
expect(latest!.orgValue('labels', 'list').value[1].marking).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH');
});
it('clears the default and the requirement with the last label', async () => {
stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true } } };
await renderSection();
fireEvent.click(screen.getByRole('button', { name: 'Remove A' }));
expect(latest!.orgValue('labels', 'defaultId').value).toBe('');
expect(latest!.orgValue('labels', 'required').value).toBe(false);
});
it('stores the extra header name only when it is a header Bulwark doesn’t write', async () => {
await renderSection();
const field = screen.getByTestId('org-labels-extra-header');
fireEvent.change(field, { target: { value: 'Sensitivity' } });
expect(screen.getAllByRole('alert').some((el) => el.textContent?.includes('not a header Bulwark writes itself'))).toBe(true);
expect(latest!.orgValue('labels', 'extraHeader').value).toBe('');
fireEvent.change(field, { target: { value: 'X-Classification' } });
expect(latest!.orgValue('labels', 'extraHeader').value).toBe('X-Classification');
});
it('gives a scope its own scheme as a whole, and back', async () => {
stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true, extraHeader: 'X-Class' } } };
await renderSection();
act(() => latest!.setScopeId('gov'));
expect(screen.getByTestId('org-labels-inherited').textContent).toContain('A');
fireEvent.click(screen.getByTestId('org-labels-own'));
expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toEqual({
list: [{ id: 'a', name: 'A', color: '#000000', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }],
defaultId: 'a', required: true, extraHeader: 'X-Class',
});
fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } });
const resolved = resolvePolicy({ ...DEFAULT_POLICY, ...(latest!.policy as object) } as never, { email: 'x@gov.example', groups: [] }).policy;
expect(resolved.organisation!.labels.list.map((l) => l.id)).toEqual(['offen', 'vs-nfd']);
expect(resolved.organisation!.labels.defaultId).toBe('offen');
expect(resolved.organisation!.labels.extraHeader).toBe('X-Class');
fireEvent.click(screen.getByRole('button', { name: /Use Everyone/ }));
expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toBeUndefined();
});
});
@@ -1,13 +1,338 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import { useEffect, useId, useState } from 'react';
import { AlertTriangle, ChevronDown, ChevronRight, ChevronUp, Plus, RotateCcw, Trash2 } from '@/components/icons';
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import {
DEFAULT_ORGANISATION_POLICY,
LABEL_SENSITIVITIES,
isLabelExtraHeaderName,
type ClassificationLabel,
type LabelSensitivity,
type OrganisationPolicy,
} from '@/lib/admin/organisation-policy';
import { LABEL_PRESETS } from '@/lib/classification/presets';
import { cn, generateUUID } from '@/lib/utils';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
type Labels = OrganisationPolicy['labels'];
const KEYS = ['list', 'defaultId', 'required', 'extraHeader'] as const;
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/classification-labels';
/** Same cap as the sanitiser. */
const MAX_LABELS = 50;
const inputClass =
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring';
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
const SENSITIVITY_NAMES: Record<LabelSensitivity, string> = {
'': 'None',
Personal: 'Personal',
Private: 'Private',
'Company-Confidential': 'Company-Confidential (Outlook: Confidential)',
};
/** The VS-NfD warning, here and in the docs. */
function VsNfdWarning() {
return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200" data-testid="org-labels-vsnfd-warning">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p>
A label only marks the message. It doesn&apos;t make mail meet the VS-NfD handling rules (VSA § 50, § 55): that
needs approved IT and BSI-approved encryption. Bulwark&apos;s S/MIME and PGP plugins and Stalwart are not
approved products. A received label can be forged by anyone: Bulwark shows it, it 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. Bulwark writes it into the SIO-Label header (RFC 7444), the Sensitivity header Outlook reads when the label sets one, and your extra header, and adds the subject prefix once. Received mail with a known label shows it. No labels turns this off."
>
<div className="space-y-4">
<VsNfdWarning />
{inScope && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm" data-testid="org-labels-scope">
<span>{scopeOwn ? 'This scope has its own labels. They replace Everyone\u2019s.' : 'This scope uses Everyone\u2019s labels.'}</span>
{scopeOwn ? (
<Button type="button" variant="outline" size="sm" onClick={useEveryone}>
<RotateCcw className="me-1 h-3.5 w-3.5" /> Use Everyone&apos;s labels
</Button>
) : (
<Button type="button" variant="outline" size="sm" onClick={useOwn} data-testid="org-labels-own">
Set labels for this scope
</Button>
)}
</div>
)}
{editing ? (
<>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor={`${id}-preset`} className="text-sm text-muted-foreground">Start from a preset</label>
<select
id={`${id}-preset`}
data-testid="org-labels-preset"
className="h-9 max-w-full rounded-md border border-input bg-background px-2 text-sm"
value=""
onChange={(e) => applyPreset(e.target.value)}
>
<option value="">Choose…</option>
{LABEL_PRESETS.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
</select>
<span className="text-xs text-muted-foreground">Replaces the list below.</span>
</div>
{list.length === 0 ? (
<p className="text-sm text-muted-foreground" data-testid="org-labels-empty">No labels. The composer shows no label picker.</p>
) : (
<ul className="space-y-2" data-testid="org-labels-list">
{list.map((label, index) => (
<LabelEditor
key={label.id}
label={label}
index={index}
count={list.length}
extraHeader={extraHeader}
onChange={(l) => change(index, l)}
onMove={(delta) => move(index, delta)}
onRemove={() => remove(index)}
/>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-3">
<Button type="button" variant="outline" size="sm" onClick={add} disabled={list.length >= MAX_LABELS} data-testid="org-labels-add">
<Plus className="me-1 h-4 w-4" /> Add label
</Button>
{list.length >= MAX_LABELS && <span className="text-xs text-muted-foreground">At most {MAX_LABELS} labels.</span>}
</div>
<div>
<SettingItem label="Default label" description="New messages start with it. Replies and forwards keep the original's label.">
<select
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm sm:w-56"
data-testid="org-labels-default"
value={defaultId}
disabled={list.length === 0}
onChange={(e) => set('defaultId', e.target.value)}
>
<option value="">None</option>
{list.filter((l) => l.name.trim()).map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</SettingItem>
<SettingItem
label="Require a label"
description="Sending asks for a label until one is picked. A quick reply without one opens the full composer."
>
<ToggleSwitch checked={required} disabled={list.length === 0} onChange={(v) => set('required', v)} testId="org-labels-required" />
</SettingItem>
<SettingItem
label="Extra header"
description="A header of your own each label can set, for a mail gateway or DLP (for example X-Classification, or msip_labels for Microsoft Purview). Empty writes none."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<input
type="text"
spellCheck={false}
autoComplete="off"
placeholder="X-Classification"
value={headerText}
onChange={(e) => onHeader(e.target.value)}
aria-invalid={headerInvalid || undefined}
data-testid="org-labels-extra-header"
className={cn(inputClass, 'sm:w-56', headerInvalid && 'border-red-500')}
/>
{headerInvalid && (
<p className="text-xs text-red-600 dark:text-red-400" role="alert">
Letters, digits and dashes only, and not a header Bulwark writes itself.
</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,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
isLabelExtraHeaderName,
DEFAULT_ORGANISATION_POLICY,
MIN_FEED_REFRESH_MINUTES,
mergeOrganisation,
@@ -58,6 +59,12 @@ describe('enterprise sections: defaults and sanitising', () => {
}
});
it('refuses the header Bulwark keeps the label of a draft in', () => {
expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'X-Bulwark-Label' } }).labels.extraHeader).toBe('');
expect(isLabelExtraHeaderName('X-Bulwark-Label')).toBe(false);
expect(isLabelExtraHeaderName('X-Classification')).toBe(true);
});
it('keeps announcement text per locale and normalises times', () => {
const { announcement } = sanitizeOrganisationPolicy({ announcement: {
id: 'maint-1', severity: 'warning', text: { en: 'Maintenance tonight', 'pt-BR': 'Manutenção', 'x y': 'no', de: ' ' },
+7 -1
View File
@@ -287,7 +287,7 @@ const RESERVED_HEADERS = new Set([
'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'sender', 'reply-to', 'in-reply-to',
'references', 'mime-version', 'content-type', 'content-transfer-encoding', 'content-disposition',
'received', 'return-path', 'dkim-signature', 'arc-seal', 'arc-message-signature', 'authentication-results',
'sio-label', 'sio-label-history', 'sensitivity', 'importance', 'x-priority', 'disposition-notification-to',
'sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label', 'importance', 'x-priority', 'disposition-notification-to',
]);
const MAX_LABELS = 50;
const MAX_FEEDS = 50;
@@ -367,6 +367,12 @@ function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined {
});
}
/** Whether the admin page may store this as `labels.extraHeader` (the sanitiser's rule). */
export function isLabelExtraHeaderName(value: string): boolean {
const v = value.trim();
return HEADER_NAME_RE.test(v) && !RESERVED_HEADERS.has(v.toLowerCase());
}
function headerName(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const v = value.trim();
+63
View File
@@ -0,0 +1,63 @@
/**
* The two label schemes the admin page offers to start from (enterprise
* pack item 3). Markings follow RFC 7444 (SIO-Label), Sensitivity values
* RFC 2156 (what Outlook shows).
*
* The German public-sector preset stops at VS-NfD on purpose: higher grades
* (VS-VERTRAULICH and up) need IT approved for them (VSA § 50 Abs. 4) and
* must not be offered in a mail client that isn't. Even VS-NfD needs
* approved IT and BSI-approved encryption outside an approved network
* (§ 50, § 55); the label only marks the message.
*/
import type { ClassificationLabel } from '@/lib/admin/organisation-policy';
export interface LabelPreset {
id: 'generic' | 'german-public-sector';
name: string;
defaultId: string;
list: ClassificationLabel[];
}
const label = (l: Partial<ClassificationLabel> & Pick<ClassificationLabel, 'id' | 'name' | 'color' | 'marking'>): ClassificationLabel => ({
description: '',
sensitivity: '',
subjectPrefix: '',
extraHeaderValue: '',
...l,
});
export const LABEL_PRESETS: LabelPreset[] = [
{
id: 'generic',
name: 'Generic: Public / Internal / Confidential / Strictly confidential',
defaultId: 'internal',
list: [
label({ id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: 'Can be shared with anyone.', extraHeaderValue: 'Public' }),
label({ id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: 'For people in the organisation and those working with it.', extraHeaderValue: 'Internal' }),
label({
id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', sensitivity: 'Company-Confidential',
subjectPrefix: '[Confidential]', description: 'Only for the people it is meant for.', extraHeaderValue: 'Confidential',
}),
label({
id: 'strictly-confidential', name: 'Strictly confidential', color: '#dc2626', marking: 'STRICTLY CONFIDENTIAL',
sensitivity: 'Company-Confidential', subjectPrefix: '[Strictly confidential]',
description: 'Only for the named recipients. Don’t forward.', extraHeaderValue: 'Strictly confidential',
}),
],
},
{
id: 'german-public-sector',
name: 'German public sector: OFFEN / VS-NfD',
defaultId: 'offen',
list: [
label({ id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: 'Nicht eingestuft.', extraHeaderValue: 'OFFEN' }),
label({
id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', sensitivity: 'Company-Confidential',
subjectPrefix: 'VS-NfD',
description: 'Verschlusssache – nur für den Dienstgebrauch. Nur nach der VSA behandeln und versenden.',
extraHeaderValue: 'VS-NfD',
}),
],
},
];