feat(email): Phase 5 — branding chain ext'd with per-port background

Surface hard-coded portnimara.com background image as a per-port
override:

- BrandingShell gains backgroundUrl; renderShell reads from
  branding.backgroundUrl with the existing Port Nimara overhead URL
  as the fallback default.
- getBrandingShell threads the value through from getPortBrandingConfig.
- PortBrandingConfig gains emailBackgroundUrl; SETTING_KEYS adds
  brandingEmailBackgroundUrl mapped to 'branding_email_background_url'.
- /admin/branding page exposes the new field as an image-upload below
  the logo with sizing guidance (1920x1080 JPG, pre-blurred).

This closes the last hard-coded portnimara.com asset URL in the email
shell — every transactional email now fully respects per-port branding
when the admin uploads their own assets. Logo override path was
already in place from R2-H15; the background was the missing piece.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:12:28 +02:00
parent 9f5786890e
commit df1594d596
4 changed files with 32 additions and 8 deletions

View File

@@ -45,6 +45,14 @@ const FIELDS: SettingFieldDef[] = [
imageAspect: 1, imageAspect: 1,
defaultValue: '', defaultValue: '',
}, },
{
key: 'branding_email_background_url',
label: 'Email background image',
description:
'Optional blurred photo shown behind the white email card. Leave blank to use the built-in Port Nimara overhead. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
type: 'image-upload',
defaultValue: '',
},
{ {
key: 'branding_primary_color', key: 'branding_primary_color',
label: 'Primary color', label: 'Primary color',

View File

@@ -18,6 +18,7 @@ export async function getBrandingShell(
const cfg = await getPortBrandingConfig(portId); const cfg = await getPortBrandingConfig(portId);
return { return {
logoUrl: cfg.logoUrl, logoUrl: cfg.logoUrl,
backgroundUrl: cfg.emailBackgroundUrl,
primaryColor: cfg.primaryColor, primaryColor: cfg.primaryColor,
emailHeaderHtml: cfg.emailHeaderHtml, emailHeaderHtml: cfg.emailHeaderHtml,
emailFooterHtml: cfg.emailFooterHtml, emailFooterHtml: cfg.emailFooterHtml,

View File

@@ -23,6 +23,10 @@ const DEFAULT_PRIMARY_COLOR = '#0F4C81';
export interface BrandingShell { export interface BrandingShell {
logoUrl: string | null; logoUrl: string | null;
/** Phase 5: blurred page-background image rendered behind the white
* card. Defaults to the Port Nimara overhead image. Ports with
* their own marina photography override via system_settings. */
backgroundUrl: string | null;
primaryColor: string | null; primaryColor: string | null;
emailHeaderHtml: string | null; emailHeaderHtml: string | null;
emailFooterHtml: string | null; emailFooterHtml: string | null;
@@ -36,6 +40,7 @@ interface ShellOpts {
export function renderShell({ title, body, branding }: ShellOpts): string { export function renderShell({ title, body, branding }: ShellOpts): string {
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL; const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL;
const headerHtml = branding?.emailHeaderHtml ?? ''; const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? ''; const footerHtml = branding?.emailFooterHtml ?? '';
@@ -52,7 +57,7 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
</style> </style>
</head> </head>
<body style="margin:0; padding:0; background-color:#f2f2f2;"> <body style="margin:0; padding:0; background-color:#f2f2f2;">
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${DEFAULT_BACKGROUND_URL}'); background-size: cover; background-position: center; background-color:#f2f2f2;"> <table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0" style="background-image: url('${backgroundUrl}'); background-size: cover; background-position: center; background-color:#f2f2f2;">
<tr> <tr>
<td align="center" style="padding:30px 16px;"> <td align="center" style="padding:30px 16px;">
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);"> <table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0" style="width:100%; max-width:600px; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 4px rgba(0,0,0,0.1);">

View File

@@ -99,6 +99,11 @@ export const SETTING_KEYS = {
brandingAppName: 'branding_app_name', brandingAppName: 'branding_app_name',
brandingEmailHeaderHtml: 'branding_email_header_html', brandingEmailHeaderHtml: 'branding_email_header_html',
brandingEmailFooterHtml: 'branding_email_footer_html', brandingEmailFooterHtml: 'branding_email_footer_html',
// Phase 5: per-port background image (the blurred overhead photo
// shown behind the white card in every transactional email + the
// branded auth shell). Defaults to the Port Nimara overhead photo
// when blank.
brandingEmailBackgroundUrl: 'branding_email_background_url',
// Reminders (port-level defaults) // Reminders (port-level defaults)
reminderDefaultDays: 'reminder_default_days', reminderDefaultDays: 'reminder_default_days',
@@ -518,6 +523,7 @@ export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntr
*/ */
export interface PortBrandingConfig { export interface PortBrandingConfig {
logoUrl: string | null; logoUrl: string | null;
emailBackgroundUrl: string | null;
primaryColor: string; primaryColor: string;
appName: string; appName: string;
emailHeaderHtml: string | null; emailHeaderHtml: string | null;
@@ -526,6 +532,7 @@ export interface PortBrandingConfig {
const DEFAULT_BRANDING: PortBrandingConfig = { const DEFAULT_BRANDING: PortBrandingConfig = {
logoUrl: null, logoUrl: null,
emailBackgroundUrl: null,
primaryColor: '#1e293b', primaryColor: '#1e293b',
appName: 'Port Nimara CRM', appName: 'Port Nimara CRM',
emailHeaderHtml: null, emailHeaderHtml: null,
@@ -533,8 +540,10 @@ const DEFAULT_BRANDING: PortBrandingConfig = {
}; };
export async function getPortBrandingConfig(portId: string): Promise<PortBrandingConfig> { export async function getPortBrandingConfig(portId: string): Promise<PortBrandingConfig> {
const [logoUrl, primaryColor, appName, emailHeaderHtml, emailFooterHtml] = await Promise.all([ const [logoUrl, emailBackgroundUrl, primaryColor, appName, emailHeaderHtml, emailFooterHtml] =
await Promise.all([
readSetting<string>(SETTING_KEYS.brandingLogoUrl, portId), readSetting<string>(SETTING_KEYS.brandingLogoUrl, portId),
readSetting<string>(SETTING_KEYS.brandingEmailBackgroundUrl, portId),
readSetting<string>(SETTING_KEYS.brandingPrimaryColor, portId), readSetting<string>(SETTING_KEYS.brandingPrimaryColor, portId),
readSetting<string>(SETTING_KEYS.brandingAppName, portId), readSetting<string>(SETTING_KEYS.brandingAppName, portId),
readSetting<string>(SETTING_KEYS.brandingEmailHeaderHtml, portId), readSetting<string>(SETTING_KEYS.brandingEmailHeaderHtml, portId),
@@ -543,6 +552,7 @@ export async function getPortBrandingConfig(portId: string): Promise<PortBrandin
return { return {
logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl, logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl,
emailBackgroundUrl: emailBackgroundUrl ?? DEFAULT_BRANDING.emailBackgroundUrl,
primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor, primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor,
appName: appName ?? DEFAULT_BRANDING.appName, appName: appName ?? DEFAULT_BRANDING.appName,
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml, emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,