feat: per-domain branding overrides on /api/config, manifest, pwa-icon #332

This commit is contained in:
Linus Rath
2026-05-28 20:06:47 +02:00
parent 8ae5ecba41
commit 1da04c254b
8 changed files with 509 additions and 49 deletions
+81 -2
View File
@@ -36,16 +36,29 @@ describe('config API route', () => {
delete process.env.LOGIN_IMPRINT_URL;
delete process.env.LOGIN_PRIVACY_POLICY_URL;
delete process.env.LOGIN_WEBSITE_URL;
delete process.env.DOMAIN_BRANDING;
});
afterEach(() => {
process.env = { ...originalEnv };
});
async function getConfig() {
function mockRequest(headers: Record<string, string> = {}): unknown {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) lc[k.toLowerCase()] = v;
return {
headers: {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
},
};
}
async function getConfig(headers?: Record<string, string>) {
// Re-import to pick up env changes
const { GET } = await import('@/app/api/config/route');
const response = await GET();
const response = await GET(mockRequest(headers) as Parameters<typeof GET>[0]);
return response.json();
}
@@ -192,4 +205,70 @@ describe('config API route', () => {
expect(config.appLogoLightUrl).toBe('/branding/my-logo.svg');
expect(config.appLogoDarkUrl).toBe('/branding/my-logo-white.svg');
});
describe('per-domain branding overrides', () => {
it('applies overrides for the matching host', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{
host: 'mail1.example.com',
loginCompanyName: 'Brand One',
loginWebsiteUrl: 'https://one.example',
},
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://one.example');
});
it('falls through to the global value when the host has no entry', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'unmapped.example.com' });
expect(config.loginCompanyName).toBe('Default Co');
});
it('falls through field-by-field when the matching entry omits a field', async () => {
process.env.LOGIN_COMPANY_NAME = 'Default Co';
process.env.LOGIN_WEBSITE_URL = 'https://default.example';
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com' });
expect(config.loginCompanyName).toBe('Brand One');
expect(config.loginWebsiteUrl).toBe('https://default.example');
});
it('prefers X-Forwarded-Host over Host', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'public.example.com', loginCompanyName: 'Public' },
]);
const config = await getConfig({
host: 'internal.example.com',
'x-forwarded-host': 'public.example.com',
});
expect(config.loginCompanyName).toBe('Public');
});
it('strips the port from the host header before matching', async () => {
process.env.DOMAIN_BRANDING = JSON.stringify([
{ host: 'mail1.example.com', loginCompanyName: 'Brand One' },
]);
const config = await getConfig({ host: 'mail1.example.com:8443' });
expect(config.loginCompanyName).toBe('Brand One');
});
});
});
+151
View File
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest';
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
type DomainBrandingEntry,
} from '@/lib/admin/domain-branding';
function mockHeaders(map: Record<string, string>): Headers {
const lc: Record<string, string> = {};
for (const [k, v] of Object.entries(map)) lc[k.toLowerCase()] = v;
return {
get(name: string) {
return lc[name.toLowerCase()] ?? null;
},
} as unknown as Headers;
}
describe('parseDomainBranding', () => {
it('returns [] for null/undefined/empty', () => {
expect(parseDomainBranding(null)).toEqual([]);
expect(parseDomainBranding(undefined)).toEqual([]);
expect(parseDomainBranding('')).toEqual([]);
expect(parseDomainBranding([])).toEqual([]);
});
it('parses a stringified JSON array', () => {
const raw = JSON.stringify([{ host: 'mail.example.com', loginCompanyName: 'Acme' }]);
expect(parseDomainBranding(raw)).toEqual([
{ host: 'mail.example.com', loginCompanyName: 'Acme' },
]);
});
it('lower-cases hosts and strips trailing dots', () => {
expect(parseDomainBranding([{ host: 'Mail.Example.COM.' }])).toEqual([
{ host: 'mail.example.com' },
]);
});
it('accepts wildcard hosts', () => {
expect(parseDomainBranding([{ host: '*.example.com', loginCompanyName: 'Wild' }])).toEqual([
{ host: '*.example.com', loginCompanyName: 'Wild' },
]);
});
it('drops entries with invalid hosts', () => {
const out = parseDomainBranding([
{ host: '' },
{ host: 'has space.com' },
{ host: 'foo..bar' },
{ host: '*.*.example.com' }, // embedded wildcard not allowed
{ host: 'good.example.com' },
]);
expect(out.map(e => e.host)).toEqual(['good.example.com']);
});
it('drops duplicate hosts, keeping the first', () => {
const out = parseDomainBranding([
{ host: 'foo.com', loginCompanyName: 'First' },
{ host: 'FOO.com', loginCompanyName: 'Second' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'First' }]);
});
it('ignores non-string and empty-string override fields', () => {
const out = parseDomainBranding([
{
host: 'foo.com',
loginCompanyName: '',
loginImprintUrl: 42,
loginWebsiteUrl: 'https://foo.com',
},
]);
expect(out).toEqual([{ host: 'foo.com', loginWebsiteUrl: 'https://foo.com' }]);
});
it('ignores unknown fields', () => {
const out = parseDomainBranding([
{ host: 'foo.com', notARealField: 'x', loginCompanyName: 'OK' },
]);
expect(out).toEqual([{ host: 'foo.com', loginCompanyName: 'OK' }]);
});
});
describe('pickRequestHost', () => {
it('returns null when no host headers are set', () => {
expect(pickRequestHost(mockHeaders({}))).toBeNull();
});
it('prefers X-Forwarded-Host over Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'forwarded.example.com',
host: 'origin.example.com',
}))).toBe('forwarded.example.com');
});
it('falls back to Host when X-Forwarded-Host is absent', () => {
expect(pickRequestHost(mockHeaders({ host: 'origin.example.com' }))).toBe('origin.example.com');
});
it('strips the port', () => {
expect(pickRequestHost(mockHeaders({ host: 'example.com:8080' }))).toBe('example.com');
});
it('takes the first entry of a comma-separated X-Forwarded-Host', () => {
expect(pickRequestHost(mockHeaders({
'x-forwarded-host': 'first.example.com, second.example.com',
}))).toBe('first.example.com');
});
it('lower-cases the result', () => {
expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com');
});
});
describe('matchDomainBranding', () => {
const entries: DomainBrandingEntry[] = [
{ host: 'mail.example.com', loginCompanyName: 'Exact' },
{ host: '*.example.com', loginCompanyName: 'Wildcard' },
{ host: '*.dev.example.com', loginCompanyName: 'Specific Wildcard' },
{ host: 'other.com', loginCompanyName: 'Other' },
];
it('returns {} when host is null', () => {
expect(matchDomainBranding(null, entries)).toEqual({});
});
it('returns {} when no entries match', () => {
expect(matchDomainBranding('unknown.org', entries)).toEqual({});
});
it('prefers exact match over wildcard', () => {
expect(matchDomainBranding('mail.example.com', entries).loginCompanyName).toBe('Exact');
});
it('matches wildcards on subdomains', () => {
expect(matchDomainBranding('foo.example.com', entries).loginCompanyName).toBe('Wildcard');
});
it('prefers the longest wildcard suffix', () => {
expect(matchDomainBranding('app.dev.example.com', entries).loginCompanyName).toBe('Specific Wildcard');
});
it('does not match the wildcard host against the apex domain', () => {
expect(matchDomainBranding('example.com', entries)).toEqual({});
});
it('is case-insensitive on the request host', () => {
expect(matchDomainBranding('Mail.Example.COM', entries).loginCompanyName).toBe('Exact');
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* Per-domain branding overrides: schema, parsing, host extraction, and match.
*
* The webmail can be served on multiple hostnames (e.g. mail1.example.com,
* mail2.other.com). Each hostname can override a subset of branding fields;
* unset fields fall back to the global admin/env/default value.
*/
import type { NextRequest } from 'next/server';
/** Config keys that can be overridden per domain. */
export const BRANDING_OVERRIDE_KEYS = [
'appName',
'appShortName',
'appDescription',
'faviconUrl',
'pwaIconUrl',
'pwaThemeColor',
'pwaBackgroundColor',
'appLogoLightUrl',
'appLogoDarkUrl',
'loginLogoLightUrl',
'loginLogoDarkUrl',
'loginCompanyName',
'loginImprintUrl',
'loginPrivacyPolicyUrl',
'loginWebsiteUrl',
] as const;
export type BrandingOverrideKey = (typeof BRANDING_OVERRIDE_KEYS)[number];
export interface DomainBrandingEntry {
/**
* Hostname this entry applies to. Either an exact host like
* "mail.example.com" or a wildcard like "*.example.com" (matches any
* direct or deeper subdomain). Case-insensitive; trailing dots are
* stripped on parse.
*/
host: string;
appName?: string;
appShortName?: string;
appDescription?: string;
faviconUrl?: string;
pwaIconUrl?: string;
pwaThemeColor?: string;
pwaBackgroundColor?: string;
appLogoLightUrl?: string;
appLogoDarkUrl?: string;
loginLogoLightUrl?: string;
loginLogoDarkUrl?: string;
loginCompanyName?: string;
loginImprintUrl?: string;
loginPrivacyPolicyUrl?: string;
loginWebsiteUrl?: string;
}
// Accepts plain hostnames (foo, foo.bar, foo.bar.baz) and one-level wildcards
// at the leftmost label (*.example.com). Rejects IPs, scheme/path/userinfo,
// and embedded wildcards.
const HOST_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
function normalizeHost(host: string): string {
return host.trim().toLowerCase().replace(/\.+$/, '');
}
/** Parse the raw config value (array of entries, or string-JSON). Invalid entries are dropped. */
export function parseDomainBranding(raw: unknown): DomainBrandingEntry[] {
if (!raw) return [];
let value = raw;
if (typeof value === 'string') {
if (!value.trim()) return [];
try {
value = JSON.parse(value);
} catch {
return [];
}
}
if (!Array.isArray(value)) return [];
const seen = new Set<string>();
const out: DomainBrandingEntry[] = [];
for (const item of value) {
if (!item || typeof item !== 'object') continue;
const rec = item as Record<string, unknown>;
const rawHost = typeof rec.host === 'string' ? rec.host : '';
const host = normalizeHost(rawHost);
if (!host || !HOST_RE.test(host)) continue;
if (seen.has(host)) continue;
seen.add(host);
const entry: DomainBrandingEntry = { host };
const writable = entry as unknown as Record<string, string>;
for (const key of BRANDING_OVERRIDE_KEYS) {
const v = rec[key];
if (typeof v === 'string' && v.length > 0) {
writable[key] = v;
}
}
out.push(entry);
}
return out;
}
type HeadersLike = Headers | { get(name: string): string | null };
/**
* Pick the request's host, preferring X-Forwarded-Host (first entry if
* comma-separated) over Host. Strips the port. Returns null when no usable
* host header is set.
*/
export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null {
const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq;
const raw = headers.get('x-forwarded-host') || headers.get('host');
if (!raw) return null;
const first = raw.split(',')[0]?.trim();
if (!first) return null;
return normalizeHost(first.split(':')[0]);
}
/**
* Find the entry whose host matches `host`. Exact match always wins; among
* wildcards the longest (most-specific) suffix wins. Returns {} when no
* entry matches.
*/
export function matchDomainBranding(
host: string | null,
entries: DomainBrandingEntry[],
): Partial<DomainBrandingEntry> {
if (!host || entries.length === 0) return {};
const lower = normalizeHost(host);
let wildcardMatch: DomainBrandingEntry | undefined;
for (const entry of entries) {
if (entry.host === lower) return entry;
if (entry.host.startsWith('*.')) {
const suffix = entry.host.slice(1); // ".example.com"
if (lower.endsWith(suffix) && lower.length > suffix.length) {
if (!wildcardMatch || entry.host.length > wildcardMatch.host.length) {
wildcardMatch = entry;
}
}
}
}
return wildcardMatch ?? {};
}
+1
View File
@@ -163,6 +163,7 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
domainBranding: { envVar: 'DOMAIN_BRANDING', type: 'json', defaultValue: [] },
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },