mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
593 lines
23 KiB
TypeScript
593 lines
23 KiB
TypeScript
/**
|
|
* The organisation part of the user policy (policy.json `organisation`):
|
|
* settings an admin makes for everyone, as opposed to the per-setting
|
|
* restrictions. Every section has one default that works without
|
|
* configuration, and every section can be overridden per domain or group
|
|
* through `scopes` (lib/admin/policy-scopes.ts).
|
|
*
|
|
* policy.json is written by hand and Lite reads it as a static file, so
|
|
* sanitizeOrganisationPolicy accepts any input and keeps only known keys
|
|
* with the right type, falling back to the default for each.
|
|
*/
|
|
|
|
import type { EmailTemplate } from '@/lib/template-types';
|
|
import { toAsciiDomain } from '@/lib/idn';
|
|
|
|
/** Details a user fills in once for the organisation signature. */
|
|
export const SIGNATURE_FIELDS = ['title', 'department', 'phone', 'mobile'] as const;
|
|
export type SignatureField = (typeof SIGNATURE_FIELDS)[number];
|
|
|
|
/** A template of the admin library: read-only for users. */
|
|
export type OrgTemplate = Pick<EmailTemplate, 'id' | 'name' | 'subject' | 'body' | 'isHTML' | 'category'>;
|
|
|
|
/** 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;
|
|
/** The ICS address; '' when `urlHeld`. */
|
|
url: string;
|
|
/**
|
|
* Set in the policy full Bulwark hands to users: the server keeps the
|
|
* address back (it may hold a token) and fetches the feed by id
|
|
* (app/api/fetch-ical, lib/admin/company-feeds.ts).
|
|
*/
|
|
urlHeld?: boolean;
|
|
/** #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. */
|
|
senderLabel: boolean;
|
|
/** Confirm before sending to recipients outside the organisation domains. */
|
|
recipientWarning: boolean;
|
|
/**
|
|
* More organisation domains. The domains of the user's own identities
|
|
* always count as internal; these are added to them. A domain covers its
|
|
* subdomains (example.com covers eu.example.com). See lib/external-mail.ts.
|
|
*/
|
|
domains: string[];
|
|
/** Recipients in the user's contacts don't trigger the warning. */
|
|
trustContacts: boolean;
|
|
};
|
|
signature: {
|
|
/** HTML template with placeholders; empty html and text mean no organisation signature. */
|
|
html: string;
|
|
text: string;
|
|
/** The details users fill in, as the template uses them. */
|
|
fields: SignatureField[];
|
|
};
|
|
disclaimer: {
|
|
/** Added below everything when a message is sent; empty means none. */
|
|
html: string;
|
|
text: string;
|
|
};
|
|
templates: {
|
|
library: OrgTemplate[];
|
|
};
|
|
session: {
|
|
/** Sign out after this many minutes without activity; 0 is off. */
|
|
idleMinutes: number;
|
|
/** Sign out this many hours after signing in; 0 is off. */
|
|
maxHours: number;
|
|
/** How long the warning counts down before signing out. */
|
|
warnSeconds: number;
|
|
};
|
|
recovery: {
|
|
/** 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<string, string>;
|
|
/** 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 = {
|
|
externalMail: { senderLabel: false, recipientWarning: false, domains: [], trustContacts: true },
|
|
signature: { html: '', text: '', fields: [] },
|
|
disclaimer: { html: '', text: '' },
|
|
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<OrganisationSection>(['labels', 'announcement']);
|
|
|
|
export type OrganisationSection = keyof OrganisationPolicy;
|
|
|
|
export type DeepPartial<T> = {
|
|
[K in keyof T]?: T[K] extends readonly unknown[] ? T[K] : T[K] extends object ? DeepPartial<T[K]> : T[K];
|
|
};
|
|
|
|
const MAX_TEXT = 64 * 1024;
|
|
const MAX_LIBRARY = 200;
|
|
const MAX_DOMAINS = 500;
|
|
// Same shape as the branding host rule, without the wildcard: a bare domain.
|
|
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function text(value: unknown): string | undefined {
|
|
return typeof value === 'string' ? value.slice(0, MAX_TEXT) : undefined;
|
|
}
|
|
|
|
function bool(value: unknown): boolean | undefined {
|
|
return typeof value === 'boolean' ? value : undefined;
|
|
}
|
|
|
|
function count(value: unknown, max: number): number | undefined {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
|
return Math.min(max, Math.max(0, Math.round(value)));
|
|
}
|
|
|
|
/**
|
|
* A domain list taken apart: the usable domains, lowercased, deduplicated
|
|
* and in ASCII form (an IDN like bücher.de becomes xn--bcher-kva.de, the
|
|
* form mail servers use), and the entries that aren't domains.
|
|
* "@example.com" and "*.example.com" are taken as example.com. Undefined for
|
|
* anything that isn't a string or an array.
|
|
*/
|
|
export function parseDomainList(value: unknown): { domains: string[]; rejected: string[] } | undefined {
|
|
const raw = typeof value === 'string' ? value.split(/[\s,;]+/) : Array.isArray(value) ? value : undefined;
|
|
if (!raw) return undefined;
|
|
const out = new Set<string>();
|
|
const rejected: string[] = [];
|
|
for (const item of raw) {
|
|
if (typeof item !== 'string') continue;
|
|
const entry = item.trim();
|
|
if (!entry) continue;
|
|
const bare = entry.toLowerCase().replace(/^\*\./, '').replace(/^@/, '').replace(/\.+$/, '');
|
|
const domain = bare ? toAsciiDomain(bare) : null;
|
|
if (domain && DOMAIN_RE.test(domain)) {
|
|
if (out.size < MAX_DOMAINS) out.add(domain);
|
|
} else {
|
|
rejected.push(entry);
|
|
}
|
|
}
|
|
return { domains: [...out], rejected };
|
|
}
|
|
|
|
/** The usable domains of a list (see parseDomainList). */
|
|
export function normalizeDomainList(value: unknown): string[] | undefined {
|
|
return parseDomainList(value)?.domains;
|
|
}
|
|
|
|
function sanitizeLibrary(value: unknown): OrgTemplate[] | undefined {
|
|
if (!Array.isArray(value)) return undefined;
|
|
const seen = new Set<string>();
|
|
const out: OrgTemplate[] = [];
|
|
for (const item of value) {
|
|
if (!isRecord(item)) continue;
|
|
const id = typeof item.id === 'string' ? item.id.trim().slice(0, 128) : '';
|
|
const name = text(item.name)?.trim();
|
|
if (!id || !name || seen.has(id)) continue;
|
|
seen.add(id);
|
|
out.push({
|
|
id,
|
|
name: name.slice(0, 200),
|
|
subject: text(item.subject) ?? '',
|
|
body: text(item.body) ?? '',
|
|
isHTML: item.isHTML === true,
|
|
category: (text(item.category) ?? '').slice(0, 100),
|
|
});
|
|
if (out.length >= MAX_LIBRARY) break;
|
|
}
|
|
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})?$/;
|
|
// RFC 5322 field-name: printable ASCII but the colon (msip_labels has an underscore).
|
|
const HEADER_NAME_RE = /^[\x21-\x39\x3b-\x7e]{1,76}$/;
|
|
// Prefixes JMAP refuses at the top level or that say how mail travelled.
|
|
const RESERVED_HEADER_PREFIXES = ['content-', 'list-', 'resent-', 'arc-'];
|
|
// Headers the extra classification header may not be: they carry the
|
|
// message itself, or the labels Bulwark writes anyway.
|
|
const RESERVED_HEADERS = new Set([
|
|
'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'sender', 'reply-to', 'in-reply-to',
|
|
'references', 'mime-version', 'content-type', 'content-transfer-encoding', 'content-disposition',
|
|
'received', 'return-path', 'dkim-signature', 'arc-seal', 'arc-message-signature', 'authentication-results',
|
|
'sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label', 'auto-submitted', 'precedence', '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;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
function oneOf<T extends string>(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<T>(value: unknown, max: number, build: (item: Record<string, unknown>, id: string) => T | null): T[] | undefined {
|
|
if (!Array.isArray(value)) return undefined;
|
|
const seen = new Set<string>();
|
|
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 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) && !reservedHeader(v) ? v : undefined;
|
|
}
|
|
|
|
function sanitizeLocaleText(value: unknown): Record<string, string> | undefined {
|
|
if (!isRecord(value)) return undefined;
|
|
const out: Record<string, string> = {};
|
|
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);
|
|
// An empty address only for a feed whose address the server held back.
|
|
const held = item.urlHeld === true && item.url === '';
|
|
const url = held ? '' : feedUrl(item.url);
|
|
if (!name || url === null) 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), ...(held ? { urlHeld: true } : {}) };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The parts of `raw` that are valid organisation settings, without
|
|
* defaults filled in. Used for scope overrides, where a missing key means
|
|
* "inherit", and as the first step of sanitizeOrganisationPolicy.
|
|
*/
|
|
export function sanitizeOrganisationPartial(raw: unknown): DeepPartial<OrganisationPolicy> {
|
|
if (!isRecord(raw)) return {};
|
|
const out: DeepPartial<OrganisationPolicy> = {};
|
|
const section = (key: OrganisationSection) => (isRecord(raw[key]) ? (raw[key] as Record<string, unknown>) : null);
|
|
const assign = <S extends OrganisationSection>(key: S, values: Record<string, unknown>) => {
|
|
const kept = Object.fromEntries(Object.entries(values).filter(([, v]) => v !== undefined));
|
|
if (Object.keys(kept).length > 0) (out as Record<string, unknown>)[key] = kept;
|
|
};
|
|
|
|
const ext = section('externalMail');
|
|
if (ext) assign('externalMail', {
|
|
senderLabel: bool(ext.senderLabel),
|
|
recipientWarning: bool(ext.recipientWarning),
|
|
domains: normalizeDomainList(ext.domains),
|
|
trustContacts: bool(ext.trustContacts),
|
|
});
|
|
|
|
const sig = section('signature');
|
|
if (sig) assign('signature', {
|
|
html: text(sig.html),
|
|
text: text(sig.text),
|
|
fields: Array.isArray(sig.fields)
|
|
? SIGNATURE_FIELDS.filter((field) => (sig.fields as unknown[]).includes(field))
|
|
: undefined,
|
|
});
|
|
|
|
const disc = section('disclaimer');
|
|
if (disc) assign('disclaimer', { html: text(disc.html), text: text(disc.text) });
|
|
|
|
const tpl = section('templates');
|
|
if (tpl) assign('templates', { library: sanitizeLibrary(tpl.library) });
|
|
|
|
const ses = section('session');
|
|
if (ses) assign('session', {
|
|
idleMinutes: count(ses.idleMinutes, 7 * 24 * 60),
|
|
maxHours: count(ses.maxHours, 24 * 365),
|
|
warnSeconds: count(ses.warnSeconds, 600),
|
|
});
|
|
|
|
const rec = section('recovery');
|
|
if (rec) assign('recovery', { days: count(rec.days, 3650) });
|
|
|
|
const phish = section('phishingReport');
|
|
if (phish) assign('phishingReport', {
|
|
address: email(phish.address),
|
|
format: oneOf(phish.format, PHISHING_REPORT_FORMATS),
|
|
});
|
|
|
|
const lab = section('labels');
|
|
if (lab) assign('labels', {
|
|
list: sanitizeLabels(lab.list),
|
|
defaultId: typeof lab.defaultId === 'string' ? (itemId(lab.defaultId) ?? '') : undefined,
|
|
required: bool(lab.required),
|
|
extraHeader: headerName(lab.extraHeader),
|
|
});
|
|
|
|
const ann = section('announcement');
|
|
if (ann) assign('announcement', {
|
|
id: typeof ann.id === 'string' ? (itemId(ann.id) ?? '') : undefined,
|
|
severity: oneOf(ann.severity, ANNOUNCEMENT_SEVERITIES),
|
|
text: sanitizeLocaleText(ann.text),
|
|
fallbackLocale: typeof ann.fallbackLocale === 'string' && LOCALE_RE.test(ann.fallbackLocale) ? ann.fallbackLocale : undefined,
|
|
startsAt: isoTime(ann.startsAt),
|
|
endsAt: isoTime(ann.endsAt),
|
|
dismissible: bool(ann.dismissible),
|
|
});
|
|
|
|
const vac = section('vacationTemplates');
|
|
if (vac) assign('vacationTemplates', { library: sanitizeVacationLibrary(vac.library) });
|
|
|
|
const ret = section('retention');
|
|
if (ret) assign('retention', { trashDays: count(ret.trashDays, 3650), junkDays: count(ret.junkDays, 3650) });
|
|
|
|
const dev = section('devices');
|
|
if (dev) assign('devices', {
|
|
noDownloads: bool(dev.noDownloads),
|
|
noExport: bool(dev.noExport),
|
|
noPrint: bool(dev.noPrint),
|
|
noInstallPrompt: bool(dev.noInstallPrompt),
|
|
pushWithoutContent: bool(dev.pushWithoutContent),
|
|
watermark: bool(dev.watermark),
|
|
noOffline: bool(dev.noOffline),
|
|
});
|
|
|
|
const cal = section('calendars');
|
|
if (cal) assign('calendars', { feeds: sanitizeFeeds(cal.feeds), freeBusyGroup: email(cal.freeBusyGroup) });
|
|
|
|
return out;
|
|
}
|
|
|
|
/** Arrays that add up across scopes by id instead of replacing. */
|
|
const ADDITIVE_LISTS: Record<string, string> = { 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>,
|
|
): OrganisationPolicy {
|
|
const out = { ...base } as Record<string, Record<string, unknown>>;
|
|
const defaults = DEFAULT_ORGANISATION_POLICY as unknown as Record<string, Record<string, unknown>>;
|
|
for (const [sectionKey, values] of Object.entries(override)) {
|
|
if (!isRecord(values) || !isRecord(out[sectionKey])) continue;
|
|
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 (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 normaliseMerged(out as unknown as OrganisationPolicy);
|
|
}
|
|
|
|
/** A complete organisation policy from anything: defaults for whatever is missing or invalid. */
|
|
export function sanitizeOrganisationPolicy(raw: unknown): OrganisationPolicy {
|
|
return mergeOrganisation(DEFAULT_ORGANISATION_POLICY, sanitizeOrganisationPartial(raw));
|
|
}
|