From 123018f322107a408be5b62bad86e2e44f8a6d71 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Sun, 1 Nov 2026 04:20:02 +0100
Subject: [PATCH] feat: add the policy sections and admin pages for the
enterprise features
---
app/(main)/admin/[[...segments]]/page.tsx | 8 +
app/(main)/admin/_pages/org-calendars.tsx | 18 +
app/(main)/admin/_pages/org-devices.tsx | 16 +
app/(main)/admin/_pages/org-labels.tsx | 14 +
app/(main)/admin/_pages/org-notices.tsx | 16 +
app/(main)/admin/_pages/org-security.tsx | 2 +
app/(main)/admin/_pages/org-templates.tsx | 2 +
components/admin/admin-shell.tsx | 8 +
.../org/sections/announcement-section.tsx | 13 +
.../sections/company-calendars-section.tsx | 13 +
.../admin/org/sections/devices-section.tsx | 13 +
.../admin/org/sections/free-busy-section.tsx | 13 +
.../admin/org/sections/labels-section.tsx | 13 +
.../admin/org/sections/offline-section.tsx | 13 +
.../out-of-office-calendar-section.tsx | 13 +
.../org/sections/phishing-report-section.tsx | 13 +
.../admin/org/sections/retention-section.tsx | 13 +
.../sections/vacation-templates-section.tsx | 13 +
.../organisation-policy-enterprise.test.ts | 147 ++++++++
lib/admin/admin-nav.ts | 52 ++-
lib/admin/organisation-policy.ts | 342 +++++++++++++++++-
lib/admin/restriction-keys.ts | 10 +
22 files changed, 755 insertions(+), 10 deletions(-)
create mode 100644 app/(main)/admin/_pages/org-calendars.tsx
create mode 100644 app/(main)/admin/_pages/org-devices.tsx
create mode 100644 app/(main)/admin/_pages/org-labels.tsx
create mode 100644 app/(main)/admin/_pages/org-notices.tsx
create mode 100644 components/admin/org/sections/announcement-section.tsx
create mode 100644 components/admin/org/sections/company-calendars-section.tsx
create mode 100644 components/admin/org/sections/devices-section.tsx
create mode 100644 components/admin/org/sections/free-busy-section.tsx
create mode 100644 components/admin/org/sections/labels-section.tsx
create mode 100644 components/admin/org/sections/offline-section.tsx
create mode 100644 components/admin/org/sections/out-of-office-calendar-section.tsx
create mode 100644 components/admin/org/sections/phishing-report-section.tsx
create mode 100644 components/admin/org/sections/retention-section.tsx
create mode 100644 components/admin/org/sections/vacation-templates-section.tsx
create mode 100644 lib/admin/__tests__/organisation-policy-enterprise.test.ts
diff --git a/app/(main)/admin/[[...segments]]/page.tsx b/app/(main)/admin/[[...segments]]/page.tsx
index 4d71528bc..9af174495 100644
--- a/app/(main)/admin/[[...segments]]/page.tsx
+++ b/app/(main)/admin/[[...segments]]/page.tsx
@@ -13,6 +13,10 @@ import { OrgSignaturesPage } from '../_pages/org-signatures';
import { OrgTemplatesPage } from '../_pages/org-templates';
import { OrgSecurityPage } from '../_pages/org-security';
import { OrgSharingPage } from '../_pages/org-sharing';
+import { OrgLabelsPage } from '../_pages/org-labels';
+import { OrgCalendarsPage } from '../_pages/org-calendars';
+import { OrgNoticesPage } from '../_pages/org-notices';
+import { OrgDevicesPage } from '../_pages/org-devices';
import { PluginsPage } from '../_pages/plugins';
import { ThemesPage } from '../_pages/themes';
import { MarketplacePage } from '../_pages/marketplace';
@@ -51,6 +55,10 @@ export default function AdminPage() {
case 'org-templates': return ;
case 'org-security': return ;
case 'org-sharing': return ;
+ case 'org-labels': return ;
+ case 'org-calendars': return ;
+ case 'org-notices': return ;
+ case 'org-devices': return ;
case 'plugins': return detail ? : ;
case 'themes': return detail ? : ;
case 'marketplace': return detail ? : ;
diff --git a/app/(main)/admin/_pages/org-calendars.tsx b/app/(main)/admin/_pages/org-calendars.tsx
new file mode 100644
index 000000000..b1abdc451
--- /dev/null
+++ b/app/(main)/admin/_pages/org-calendars.tsx
@@ -0,0 +1,18 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { CompanyCalendarsSection } from '@/components/admin/org/sections/company-calendars-section';
+import { FreeBusySection } from '@/components/admin/org/sections/free-busy-section';
+import { OutOfOfficeCalendarSection } from '@/components/admin/org/sections/out-of-office-calendar-section';
+
+export function OrgCalendarsPage() {
+ const draft = useOrgPolicyDraft('org-calendars');
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/org-devices.tsx b/app/(main)/admin/_pages/org-devices.tsx
new file mode 100644
index 000000000..a7cf2834d
--- /dev/null
+++ b/app/(main)/admin/_pages/org-devices.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { DevicesSection } from '@/components/admin/org/sections/devices-section';
+import { OfflineSection } from '@/components/admin/org/sections/offline-section';
+
+export function OrgDevicesPage() {
+ const draft = useOrgPolicyDraft('org-devices');
+ return (
+
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/org-labels.tsx b/app/(main)/admin/_pages/org-labels.tsx
new file mode 100644
index 000000000..f60056ef5
--- /dev/null
+++ b/app/(main)/admin/_pages/org-labels.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { LabelsSection } from '@/components/admin/org/sections/labels-section';
+
+export function OrgLabelsPage() {
+ const draft = useOrgPolicyDraft('org-labels');
+ return (
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/org-notices.tsx b/app/(main)/admin/_pages/org-notices.tsx
new file mode 100644
index 000000000..f5c9c819d
--- /dev/null
+++ b/app/(main)/admin/_pages/org-notices.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import { OrgPage } from '@/components/admin/org/org-page';
+import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+import { AnnouncementSection } from '@/components/admin/org/sections/announcement-section';
+import { RetentionSection } from '@/components/admin/org/sections/retention-section';
+
+export function OrgNoticesPage() {
+ const draft = useOrgPolicyDraft('org-notices');
+ return (
+
+
+
+
+ );
+}
diff --git a/app/(main)/admin/_pages/org-security.tsx b/app/(main)/admin/_pages/org-security.tsx
index af778d1ee..5d8f65f55 100644
--- a/app/(main)/admin/_pages/org-security.tsx
+++ b/app/(main)/admin/_pages/org-security.tsx
@@ -3,6 +3,7 @@
import { OrgPage } from '@/components/admin/org/org-page';
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
import { ExternalMailSection } from '@/components/admin/org/sections/external-mail-section';
+import { PhishingReportSection } from '@/components/admin/org/sections/phishing-report-section';
import { SessionsSection } from '@/components/admin/org/sections/sessions-section';
import { ThirdPartySection } from '@/components/admin/org/sections/third-party-section';
@@ -11,6 +12,7 @@ export function OrgSecurityPage() {
return (
+
diff --git a/app/(main)/admin/_pages/org-templates.tsx b/app/(main)/admin/_pages/org-templates.tsx
index bc63b0578..084f67d91 100644
--- a/app/(main)/admin/_pages/org-templates.tsx
+++ b/app/(main)/admin/_pages/org-templates.tsx
@@ -3,12 +3,14 @@
import { OrgPage } from '@/components/admin/org/org-page';
import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
import { TemplatesSection } from '@/components/admin/org/sections/templates-section';
+import { VacationTemplatesSection } from '@/components/admin/org/sections/vacation-templates-section';
export function OrgTemplatesPage() {
const draft = useOrgPolicyDraft('org-templates');
return (
+
);
}
diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx
index 8a5074ba3..9b721d17f 100644
--- a/components/admin/admin-shell.tsx
+++ b/components/admin/admin-shell.tsx
@@ -7,7 +7,9 @@ import {
ArrowLeft,
ArrowUpCircle,
Activity,
+ Bell,
Building2,
+ CalendarDays,
ChevronRight,
KeyRound,
LayoutDashboard,
@@ -23,8 +25,10 @@ import {
Share2,
Shield,
ShieldCheck,
+ Smartphone,
Store,
SwatchBook,
+ Tag,
X,
type AppIcon,
} from '@/components/icons';
@@ -54,6 +58,10 @@ const PAGE_ICONS: Record = {
'org-templates': LayoutTemplate,
'org-security': ShieldCheck,
'org-sharing': Share2,
+ 'org-labels': Tag,
+ 'org-calendars': CalendarDays,
+ 'org-notices': Bell,
+ 'org-devices': Smartphone,
plugins: Puzzle,
themes: SwatchBook,
marketplace: Store,
diff --git a/components/admin/org/sections/announcement-section.tsx b/components/admin/org/sections/announcement-section.tsx
new file mode 100644
index 000000000..b6b3f392f
--- /dev/null
+++ b/components/admin/org/sections/announcement-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package D:
+ * An announcement across the apps.
+ */
+export function AnnouncementSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/company-calendars-section.tsx b/components/admin/org/sections/company-calendars-section.tsx
new file mode 100644
index 000000000..82602702a
--- /dev/null
+++ b/components/admin/org/sections/company-calendars-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package B:
+ * ICS feeds every user in scope sees read-only.
+ */
+export function CompanyCalendarsSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/devices-section.tsx b/components/admin/org/sections/devices-section.tsx
new file mode 100644
index 000000000..bb4864de0
--- /dev/null
+++ b/components/admin/org/sections/devices-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package D:
+ * Device gates: downloads, export, printing, install prompt, push text, watermark.
+ */
+export function DevicesSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/free-busy-section.tsx b/components/admin/org/sections/free-busy-section.tsx
new file mode 100644
index 000000000..a46bc30e9
--- /dev/null
+++ b/components/admin/org/sections/free-busy-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package B:
+ * A group that gets free/busy access to every user's default calendar.
+ */
+export function FreeBusySection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/labels-section.tsx b/components/admin/org/sections/labels-section.tsx
new file mode 100644
index 000000000..fd1cc1d3c
--- /dev/null
+++ b/components/admin/org/sections/labels-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package A:
+ * Classification labels, their default, and whether a label is required.
+ */
+export function LabelsSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx
new file mode 100644
index 000000000..f53ae1cb1
--- /dev/null
+++ b/components/admin/org/sections/offline-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package F:
+ * Whether users may keep mail on their device (devices.noOffline).
+ */
+export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/out-of-office-calendar-section.tsx b/components/admin/org/sections/out-of-office-calendar-section.tsx
new file mode 100644
index 000000000..e64fcfcc8
--- /dev/null
+++ b/components/admin/org/sections/out-of-office-calendar-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package B:
+ * Whether an out-of-office reply blocks the calendar (restriction vacationBlockCalendar).
+ */
+export function OutOfOfficeCalendarSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/phishing-report-section.tsx b/components/admin/org/sections/phishing-report-section.tsx
new file mode 100644
index 000000000..5d1b47d85
--- /dev/null
+++ b/components/admin/org/sections/phishing-report-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package A:
+ * Report phishing: where reports go and in which format.
+ */
+export function PhishingReportSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/retention-section.tsx b/components/admin/org/sections/retention-section.tsx
new file mode 100644
index 000000000..de6b8f561
--- /dev/null
+++ b/components/admin/org/sections/retention-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package D:
+ * How long Trash and Junk keep mail, as set in the mail server.
+ */
+export function RetentionSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/components/admin/org/sections/vacation-templates-section.tsx b/components/admin/org/sections/vacation-templates-section.tsx
new file mode 100644
index 000000000..c974fef78
--- /dev/null
+++ b/components/admin/org/sections/vacation-templates-section.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { SettingsSection } from '@/components/settings/settings-section';
+import type { OrgPolicyDraft } from '../use-org-policy-draft';
+
+/**
+ * Placeholder from the enterprise-pack foundation, replaced by package D:
+ * Out-of-office reply texts users can pick.
+ */
+export function VacationTemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
+ void draft;
+ return {null};
+}
diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts
new file mode 100644
index 000000000..76d0cc746
--- /dev/null
+++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts
@@ -0,0 +1,147 @@
+import { describe, expect, it } from 'vitest';
+import {
+ DEFAULT_ORGANISATION_POLICY,
+ MIN_FEED_REFRESH_MINUTES,
+ mergeOrganisation,
+ sanitizeOrganisationPartial,
+ sanitizeOrganisationPolicy,
+} from '../organisation-policy';
+import { resolvePolicy } from '../policy-scopes';
+import { DEFAULT_POLICY } from '../types';
+
+// The enterprise-pack sections of policy.json: everything off by default,
+// hand-written input sanitised, and the merge rules across scopes.
+
+const label = (id: string, extra: Record = {}) => ({ id, name: id.toUpperCase(), ...extra });
+
+describe('enterprise sections: defaults and sanitising', () => {
+ it('keeps everything off when nothing is set', () => {
+ const org = sanitizeOrganisationPolicy({});
+ expect(org.phishingReport).toEqual({ address: '', format: 'forward' });
+ expect(org.labels).toEqual({ list: [], defaultId: '', required: false, extraHeader: '' });
+ expect(org.announcement.text).toEqual({});
+ expect(Object.values(org.devices).every((v) => v === false)).toBe(true);
+ expect(org.calendars).toEqual({ feeds: [], freeBusyGroup: '' });
+ expect(org.retention).toEqual({ trashDays: 0, junkDays: 0 });
+ });
+
+ it('takes a valid phishing address and format, and refuses anything else', () => {
+ expect(sanitizeOrganisationPolicy({ phishingReport: { address: ' SOC@Example.org ', format: 'arf' } }).phishingReport)
+ .toEqual({ address: 'soc@example.org', format: 'arf' });
+ expect(sanitizeOrganisationPolicy({ phishingReport: { address: 'not an address', format: 'pdf' } }).phishingReport)
+ .toEqual(DEFAULT_ORGANISATION_POLICY.phishingReport);
+ });
+
+ it('cleans labels: no line breaks or quotes in header values, a fallback colour, unique ids', () => {
+ const { labels } = sanitizeOrganisationPolicy({ labels: {
+ list: [
+ label('conf', { marking: 'CONFIDENTIAL"\r\nBcc: x@evil.test', color: 'red', sensitivity: 'Company-Confidential', subjectPrefix: '[Conf]\n' }),
+ label('conf'),
+ { id: 'bad id!', name: 'x' },
+ { id: 'noname' },
+ ],
+ defaultId: 'conf',
+ required: true,
+ extraHeader: 'X-Classification',
+ } });
+ expect(labels.list).toHaveLength(1);
+ expect(labels.list[0].marking).toBe('CONFIDENTIAL Bcc: x@evil.test');
+ expect(labels.list[0].marking).not.toMatch(/[\r\n"]/);
+ expect(labels.list[0].subjectPrefix).toBe('[Conf]');
+ expect(labels.list[0].color).toBe('#6b7280');
+ expect(labels).toMatchObject({ defaultId: 'conf', required: true, extraHeader: 'X-Classification' });
+ });
+
+ it('refuses an extra header that would replace a real one', () => {
+ for (const name of ['Subject', 'bcc', 'SIO-Label', 'Content-Type', 'X Bad']) {
+ expect(sanitizeOrganisationPolicy({ labels: { extraHeader: name } }).labels.extraHeader).toBe('');
+ }
+ });
+
+ 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: ' ' },
+ fallbackLocale: 'en', startsAt: '2026-11-01T20:00:00+01:00', endsAt: 'tomorrow', dismissible: false,
+ } });
+ expect(announcement.text).toEqual({ en: 'Maintenance tonight', 'pt-BR': 'Manutenção' });
+ expect(announcement.startsAt).toBe('2026-11-01T19:00:00.000Z');
+ expect(announcement.endsAt).toBe('');
+ expect(announcement).toMatchObject({ id: 'maint-1', severity: 'warning', dismissible: false });
+ });
+
+ it('takes http and https feeds (webcal as https), and a refresh of at least an hour', () => {
+ const { calendars } = sanitizeOrganisationPolicy({ calendars: {
+ feeds: [
+ { id: 'hol', name: 'Holidays', url: 'webcal://cal.example.org/holidays.ics', refreshMinutes: 5 },
+ { id: 'intra', name: 'Intranet', url: 'http://intranet.local/team.ics', color: '#ABCDEF', refreshMinutes: 240 },
+ { id: 'js', name: 'Bad', url: 'javascript:alert(1)' },
+ ],
+ freeBusyGroup: 'All-Staff@example.org',
+ } });
+ expect(calendars.feeds.map((f) => f.id)).toEqual(['hol', 'intra']);
+ expect(calendars.feeds[0].url).toBe('https://cal.example.org/holidays.ics');
+ expect(calendars.feeds[0].refreshMinutes).toBe(MIN_FEED_REFRESH_MINUTES);
+ expect(calendars.feeds[1]).toMatchObject({ color: '#abcdef', refreshMinutes: 240 });
+ expect(calendars.freeBusyGroup).toBe('all-staff@example.org');
+ });
+
+ it('keeps device switches only as booleans', () => {
+ const { devices } = sanitizeOrganisationPolicy({ devices: { noDownloads: true, noPrint: 'yes', watermark: 1 } });
+ expect(devices.noDownloads).toBe(true);
+ expect(devices.noPrint).toBe(false);
+ expect(devices.watermark).toBe(false);
+ });
+});
+
+describe('enterprise sections: merging scopes', () => {
+ const base = sanitizeOrganisationPolicy({
+ labels: { list: [label('pub'), label('int')], defaultId: 'int', required: true, extraHeader: 'X-Class' },
+ announcement: { id: 'all', severity: 'info', text: { en: 'For everyone' }, endsAt: '2026-12-01T00:00:00Z' },
+ vacationTemplates: { library: [{ id: 'a', name: 'Company' }] },
+ calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics' }] },
+ });
+
+ it('replaces the label scheme as a whole and clears a default the new list lacks', () => {
+ const merged = mergeOrganisation(base, sanitizeOrganisationPartial({ labels: { list: [label('vsnfd')] } }));
+ expect(merged.labels.list.map((l) => l.id)).toEqual(['vsnfd']);
+ expect(merged.labels.defaultId).toBe('');
+ expect(merged.labels.extraHeader).toBe('');
+ expect(merged.labels.required).toBe(false);
+ });
+
+ it('drops "required" when a scope leaves no label to pick', () => {
+ const merged = mergeOrganisation(base, sanitizeOrganisationPartial({ labels: { required: true } }));
+ expect(merged.labels.list).toEqual([]);
+ expect(merged.labels.required).toBe(false);
+ });
+
+ it("doesn't mix a scope's announcement with Everyone's", () => {
+ const merged = mergeOrganisation(base, sanitizeOrganisationPartial({ announcement: { id: 'dept', text: { en: 'Only us' } } }));
+ expect(merged.announcement).toMatchObject({ id: 'dept', text: { en: 'Only us' }, endsAt: '' });
+ });
+
+ it('adds a scope\'s out-of-office templates and company calendars to Everyone\'s', () => {
+ const merged = mergeOrganisation(base, sanitizeOrganisationPartial({
+ vacationTemplates: { library: [{ id: 'b', name: 'Sales' }] },
+ calendars: { feeds: [{ id: 'sales', name: 'Sales events', url: 'https://cal.example.org/s.ics' }] },
+ }));
+ expect(merged.vacationTemplates.library.map((t) => t.id)).toEqual(['a', 'b']);
+ expect(merged.calendars.feeds.map((f) => f.id)).toEqual(['hol', 'sales']);
+ });
+
+ it('applies through resolvePolicy for a group scope', () => {
+ const policy = {
+ ...DEFAULT_POLICY,
+ organisation: { calendars: { feeds: [{ id: 'hol', name: 'Holidays', url: 'https://cal.example.org/h.ics' }] } },
+ scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {
+ organisation: { calendars: { feeds: [{ id: 'team', name: 'Team', url: 'https://cal.example.org/t.ics' }] }, devices: { noDownloads: true } },
+ } }],
+ };
+ const member = resolvePolicy(policy as never, { email: 'a@example.org', groups: ['team@example.org'] }).policy.organisation!;
+ const other = resolvePolicy(policy as never, { email: 'c@example.org', groups: [] }).policy.organisation!;
+ expect(member.calendars.feeds.map((f) => f.id)).toEqual(['hol', 'team']);
+ expect(member.devices.noDownloads).toBe(true);
+ expect(other.calendars.feeds.map((f) => f.id)).toEqual(['hol']);
+ expect(other.devices.noDownloads).toBe(false);
+ });
+});
diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts
index 5b336e453..e0eb282bc 100644
--- a/lib/admin/admin-nav.ts
+++ b/lib/admin/admin-nav.ts
@@ -22,6 +22,10 @@ export const ADMIN_PAGE_IDS = [
'org-templates',
'org-security',
'org-sharing',
+ 'org-labels',
+ 'org-calendars',
+ 'org-notices',
+ 'org-devices',
'plugins',
'themes',
'marketplace',
@@ -157,16 +161,18 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
sections: [
{ id: 'library', title: 'Organisation library', keywords: 'templates read-only shared add edit' },
{ id: 'user-templates', title: 'Personal and team templates', keywords: 'personal team group lock hide' },
+ { id: 'vacation-templates', title: 'Out-of-office replies', keywords: 'vacation away auto reply out of office absence deputy placeholder' },
],
},
{
id: 'org-security',
label: 'Security',
- description: 'Warnings about mail from and to outside the organisation, sign-out times and outside connections',
+ description: 'Warnings about mail from and to outside the organisation, phishing reports, sign-out times and outside connections',
group: 'organisation',
keywords: 'security external warning label recipients domains session timeout idle sign out third party traffic privacy',
sections: [
{ id: 'external', title: 'External mail', keywords: 'external sender label recipient warning organisation domains contacts' },
+ { id: 'phishing', title: 'Phishing reports', keywords: 'phishing report spam abuse security desk arf forward' },
{ id: 'sessions', title: 'Sessions', keywords: 'idle timeout maximum length sign out warning' },
{ id: 'third-party', title: 'Third-party traffic', keywords: 'telemetry favicon relay directory outgoing hosts privacy gdpr' },
],
@@ -183,6 +189,50 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
{ id: 'recovery', title: 'Recover deleted items', keywords: 'recover restore deleted archive enterprise' },
],
},
+ {
+ id: 'org-labels',
+ label: 'Classification',
+ description: 'Labels such as Internal or Confidential that users pick when they write, and that arrive with the message',
+ group: 'organisation',
+ keywords: 'classification label sensitivity confidential internal public vs-nfd sio-label header subject prefix required',
+ sections: [
+ { id: 'labels', title: 'Classification labels', keywords: 'labels presets default required header prefix colour' },
+ ],
+ },
+ {
+ id: 'org-calendars',
+ label: 'Calendars',
+ description: 'Company calendars every user sees, free/busy sharing and out-of-office in the calendar',
+ group: 'organisation',
+ keywords: 'calendar ics feed subscription holidays company free busy availability out of office vacation',
+ sections: [
+ { id: 'company-calendars', title: 'Company calendars', keywords: 'ics feed url subscription holidays read-only colour' },
+ { id: 'free-busy', title: 'Free/busy sharing', keywords: 'free busy availability group share find a time' },
+ { id: 'out-of-office', title: 'Out-of-office', keywords: 'vacation away busy block calendar lock' },
+ ],
+ },
+ {
+ id: 'org-notices',
+ label: 'Notices',
+ description: 'An announcement across the apps, and how long Trash and Junk keep mail',
+ group: 'organisation',
+ keywords: 'announcement banner notice maintenance message retention trash junk days',
+ sections: [
+ { id: 'announcement', title: 'Announcement', keywords: 'banner message severity start end dismiss languages' },
+ { id: 'retention', title: 'Trash and Junk', keywords: 'retention days deleted expunge trash junk' },
+ ],
+ },
+ {
+ id: 'org-devices',
+ label: 'Devices',
+ description: 'What users may save, print or keep on their devices',
+ group: 'organisation',
+ keywords: 'device download export print watermark install pwa push notification offline data loss',
+ sections: [
+ { id: 'devices', title: 'Downloads, printing and notifications', keywords: 'download attachment export print watermark install prompt push sender subject' },
+ { id: 'offline', title: 'Offline mode', keywords: 'offline cache device keep mail outbox' },
+ ],
+ },
{
id: 'plugins',
label: 'Plugins',
diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts
index c61bcc89c..c6c22e93f 100644
--- a/lib/admin/organisation-policy.ts
+++ b/lib/admin/organisation-policy.ts
@@ -20,6 +20,57 @@ export type SignatureField = (typeof SIGNATURE_FIELDS)[number];
/** A template of the admin library: read-only for users. */
export type OrgTemplate = Pick;
+/** RFC 2156 Sensitivity values (what Outlook shows); '' writes none. */
+export const LABEL_SENSITIVITIES = ['', 'Personal', 'Private', 'Company-Confidential'] as const;
+export type LabelSensitivity = (typeof LABEL_SENSITIVITIES)[number];
+
+/** A classification label the admin defines (item 3 of the enterprise pack). */
+export interface ClassificationLabel {
+ id: string;
+ name: string;
+ /** #rrggbb */
+ color: string;
+ description: string;
+ /** The SIO-Label `marking` (RFC 7444); the name when empty. */
+ marking: string;
+ sensitivity: LabelSensitivity;
+ /** Added in front of the subject, e.g. "[Confidential]"; '' for none. */
+ subjectPrefix: string;
+ /** Value of the admin's extra header for this label; '' writes none. */
+ extraHeaderValue: string;
+}
+
+/** An out-of-office reply text of the admin library. */
+export interface VacationTemplate {
+ id: string;
+ name: string;
+ category: string;
+ subject: string;
+ /** Plain text with placeholders like {name}, {end}, {deputy}. */
+ text: string;
+ /** Optional HTML version; '' for none. */
+ html: string;
+}
+
+/** An ICS feed every user in scope sees read-only. */
+export interface CompanyFeed {
+ id: string;
+ name: string;
+ url: string;
+ /** #rrggbb */
+ color: string;
+ refreshMinutes: number;
+}
+
+export const ANNOUNCEMENT_SEVERITIES = ['info', 'warning', 'critical'] as const;
+export type AnnouncementSeverity = (typeof ANNOUNCEMENT_SEVERITIES)[number];
+
+export const PHISHING_REPORT_FORMATS = ['forward', 'arf'] as const;
+export type PhishingReportFormat = (typeof PHISHING_REPORT_FORMATS)[number];
+
+/** The shortest refresh interval for company calendars (each refresh may cost an upload). */
+export const MIN_FEED_REFRESH_MINUTES = 60;
+
export interface OrganisationPolicy {
externalMail: {
/** "External" chip on mail from senders outside the organisation domains. */
@@ -62,6 +113,61 @@ export interface OrganisationPolicy {
/** How far back "Recover deleted items" lists; 0 is everything the server keeps. */
days: number;
};
+ phishingReport: {
+ /** Where "Report phishing" sends; '' turns the action off. */
+ address: string;
+ /** Forward the message as an attachment, or an ARF report (RFC 5965). */
+ format: PhishingReportFormat;
+ };
+ /** A scope that sets this section replaces it as a whole. */
+ labels: {
+ /** [] turns classification labels off. */
+ list: ClassificationLabel[];
+ /** The label new messages start with; '' for none. */
+ defaultId: string;
+ /** Sending needs a label. Dropped when the list is empty. */
+ required: boolean;
+ /** Name of an extra header each label may set (e.g. X-Classification); '' for none. */
+ extraHeader: string;
+ };
+ /** A scope that sets this section replaces it as a whole. */
+ announcement: {
+ /** Changes when the admin saves a new announcement, so dismissals reset. */
+ id: string;
+ severity: AnnouncementSeverity;
+ /** Locale (en, de, pt-BR) to plain text with links; {} shows nothing. */
+ text: Record;
+ /** The locale shown when the user's isn't in `text`. */
+ fallbackLocale: string;
+ /** ISO times; '' for no limit. */
+ startsAt: string;
+ endsAt: string;
+ dismissible: boolean;
+ };
+ vacationTemplates: {
+ /** Adds up across scopes by id, like templates.library. */
+ library: VacationTemplate[];
+ };
+ retention: {
+ /** Days the server keeps mail in Trash and Junk, as the admin set it in the mail server; 0 shows no line. */
+ trashDays: number;
+ junkDays: number;
+ };
+ devices: {
+ noDownloads: boolean;
+ noExport: boolean;
+ noPrint: boolean;
+ noInstallPrompt: boolean;
+ pushWithoutContent: boolean;
+ watermark: boolean;
+ noOffline: boolean;
+ };
+ calendars: {
+ /** Adds up across scopes by id. */
+ feeds: CompanyFeed[];
+ /** A group address that gets free/busy access to every user's default calendar; '' is off. */
+ freeBusyGroup: string;
+ };
}
export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
@@ -71,8 +177,21 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
templates: { library: [] },
session: { idleMinutes: 0, maxHours: 0, warnSeconds: 60 },
recovery: { days: 0 },
+ phishingReport: { address: '', format: 'forward' },
+ labels: { list: [], defaultId: '', required: false, extraHeader: '' },
+ announcement: { id: '', severity: 'info', text: {}, fallbackLocale: 'en', startsAt: '', endsAt: '', dismissible: true },
+ vacationTemplates: { library: [] },
+ retention: { trashDays: 0, junkDays: 0 },
+ devices: {
+ noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false,
+ pushWithoutContent: false, watermark: false, noOffline: false,
+ },
+ calendars: { feeds: [], freeBusyGroup: '' },
};
+/** Sections a scope replaces as a whole instead of per leaf. */
+const WHOLE_SECTIONS = new Set(['labels', 'announcement']);
+
export type OrganisationSection = keyof OrganisationPolicy;
export type DeepPartial = {
@@ -157,6 +276,144 @@ function sanitizeLibrary(value: unknown): OrgTemplate[] | undefined {
return out;
}
+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}$/;
+// 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',
+]);
+const MAX_LABELS = 50;
+const MAX_FEEDS = 50;
+
+/** Text for a header value or a one-line field: no line breaks or control characters. */
+function line(value: unknown, max: number): string | undefined {
+ const t = text(value);
+ // eslint-disable-next-line no-control-regex
+ return t === undefined ? undefined : t.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, max);
+}
+
+function email(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ const v = value.trim().toLowerCase();
+ return v === '' || EMAIL_RE.test(v) ? v.slice(0, 320) : undefined;
+}
+
+function color(value: unknown, fallback: string): string {
+ return typeof value === 'string' && COLOR_RE.test(value.trim()) ? value.trim().toLowerCase() : fallback;
+}
+
+function oneOf(value: unknown, allowed: readonly T[]): T | undefined {
+ return typeof value === 'string' && (allowed as readonly string[]).includes(value) ? (value as T) : undefined;
+}
+
+function isoTime(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ const v = value.trim();
+ if (v === '') return '';
+ const t = Date.parse(v);
+ return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
+}
+
+function itemId(value: unknown): string | null {
+ return typeof value === 'string' && ID_RE.test(value.trim()) ? value.trim() : null;
+}
+
+/** Keeps the items with a valid, unique id, capped at `max`. */
+function byIdList(value: unknown, max: number, build: (item: Record, id: string) => T | null): T[] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ const seen = new Set();
+ const out: T[] = [];
+ for (const item of value) {
+ if (!isRecord(item)) continue;
+ const id = itemId(item.id);
+ if (!id || seen.has(id)) continue;
+ const built = build(item, id);
+ if (!built) continue;
+ seen.add(id);
+ out.push(built);
+ if (out.length >= max) break;
+ }
+ return out;
+}
+
+function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined {
+ return byIdList(value, MAX_LABELS, (item, id) => {
+ const name = line(item.name, 100);
+ if (!name) return null;
+ return {
+ id,
+ name,
+ color: color(item.color, '#6b7280'),
+ description: (text(item.description) ?? '').slice(0, 1000),
+ // A quoted header parameter: no double quotes or backslashes.
+ marking: (line(item.marking, 200) ?? '').replace(/["\\]/g, ''),
+ sensitivity: oneOf(item.sensitivity, LABEL_SENSITIVITIES) ?? '',
+ subjectPrefix: line(item.subjectPrefix, 60) ?? '',
+ extraHeaderValue: line(item.extraHeaderValue, 500) ?? '',
+ };
+ });
+}
+
+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;
+}
+
+function sanitizeLocaleText(value: unknown): Record | undefined {
+ if (!isRecord(value)) return undefined;
+ const out: Record = {};
+ for (const [locale, body] of Object.entries(value)) {
+ const t = text(body)?.trim();
+ if (LOCALE_RE.test(locale) && t) out[locale] = t.slice(0, 4000);
+ }
+ return out;
+}
+
+function sanitizeVacationLibrary(value: unknown): VacationTemplate[] | undefined {
+ return byIdList(value, MAX_LIBRARY, (item, id) => {
+ const name = line(item.name, 200);
+ if (!name) return null;
+ return {
+ id,
+ name,
+ category: line(item.category, 100) ?? '',
+ subject: line(item.subject, 511) ?? '',
+ text: text(item.text) ?? '',
+ html: text(item.html) ?? '',
+ };
+ });
+}
+
+function feedUrl(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const v = value.trim().replace(/^webcals?:\/\//i, 'https://');
+ try {
+ const u = new URL(v);
+ return (u.protocol === 'https:' || u.protocol === 'http:') && u.hostname ? u.toString() : null;
+ } catch {
+ return null;
+ }
+}
+
+function sanitizeFeeds(value: unknown): CompanyFeed[] | undefined {
+ return byIdList(value, MAX_FEEDS, (item, id) => {
+ const name = line(item.name, 100);
+ const url = feedUrl(item.url);
+ if (!name || !url) return null;
+ const minutes = count(item.refreshMinutes, 7 * 24 * 60) ?? MIN_FEED_REFRESH_MINUTES;
+ return { id, name, url, color: color(item.color, '#2563eb'), refreshMinutes: Math.max(MIN_FEED_REFRESH_MINUTES, minutes) };
+ });
+}
+
/**
* The parts of `raw` that are valid organisation settings, without
* defaults filled in. Used for scope overrides, where a missing key means
@@ -204,33 +461,100 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial = { templates: 'library', vacationTemplates: 'library', calendars: 'feeds' };
+
+/** Settings that can't hold together after a merge: a default label that isn't in the list, a required label with none to pick. */
+function normaliseMerged(policy: OrganisationPolicy): OrganisationPolicy {
+ const labels = policy.labels;
+ if (!labels) return policy;
+ const hasDefault = labels.list.some((label) => label.id === labels.defaultId);
+ const required = labels.required && labels.list.length > 0;
+ if (hasDefault === (labels.defaultId !== '') && required === labels.required) return policy;
+ return { ...policy, labels: { ...labels, defaultId: hasDefault ? labels.defaultId : '', required } };
+}
+
+/**
+ * Merges `override` into `base` per leaf of plain objects; arrays and values
+ * replace. Exceptions: the lists in ADDITIVE_LISTS add up by id, and the
+ * sections in WHOLE_SECTIONS are replaced as a whole (a scope's own label
+ * scheme or announcement never mixes with the inherited one), with the
+ * built-in defaults for keys the override leaves out.
+ */
export function mergeOrganisation(
base: OrganisationPolicy,
override: DeepPartial,
): OrganisationPolicy {
const out = { ...base } as Record>;
+ const defaults = DEFAULT_ORGANISATION_POLICY as unknown as Record>;
for (const [sectionKey, values] of Object.entries(override)) {
if (!isRecord(values) || !isRecord(out[sectionKey])) continue;
- const merged = { ...out[sectionKey] };
+ const merged = WHOLE_SECTIONS.has(sectionKey as OrganisationSection)
+ ? { ...defaults[sectionKey] }
+ : { ...out[sectionKey] };
for (const [key, value] of Object.entries(values)) {
if (value === undefined) continue;
- if (sectionKey === 'templates' && key === 'library' && Array.isArray(value)) {
- // The one array that adds up: a scope's templates join the ones it
- // inherits; the same id replaces the inherited template.
- const byId = new Map((merged.library as OrgTemplate[]).map((t) => [t.id, t]));
- for (const template of value as OrgTemplate[]) byId.set(template.id, template);
- merged.library = [...byId.values()];
+ if (ADDITIVE_LISTS[sectionKey] === key && Array.isArray(value)) {
+ // A scope's items join the ones it inherits; the same id replaces
+ // the inherited item.
+ const byId = new Map((merged[key] as Array<{ id: string }>).map((t) => [t.id, t]));
+ for (const item of value as Array<{ id: string }>) byId.set(item.id, item);
+ merged[key] = [...byId.values()];
} else {
merged[key] = value;
}
}
out[sectionKey] = merged;
}
- return out as unknown as OrganisationPolicy;
+ return normaliseMerged(out as unknown as OrganisationPolicy);
}
/** A complete organisation policy from anything: defaults for whatever is missing or invalid. */
diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts
index a65cabdee..745ee663b 100644
--- a/lib/admin/restriction-keys.ts
+++ b/lib/admin/restriction-keys.ts
@@ -126,6 +126,16 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
{ key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
{ key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] },
{ key: 'deletedItemsRecovery', label: 'Recover deleted items', description: 'Hide removes Settings > Recover deleted items and its link in Trash, so users can no longer restore mail they deleted for good.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
+ {
+ key: 'vacationBlockCalendar',
+ label: 'Out-of-office blocks the calendar',
+ description: 'Lock: an out-of-office reply with dates puts a busy event in the user\'s calendar, or never does, as set here. Users see the choice as set by the organisation.',
+ category: 'Calendar',
+ type: 'boolean',
+ page: 'org-calendars',
+ modes: ['locked'],
+ defaultValue: true,
+ },
];
export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] {