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

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

View File

@@ -23,6 +23,10 @@ const DEFAULT_PRIMARY_COLOR = '#0F4C81';
export interface BrandingShell {
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;
emailHeaderHtml: string | null;
emailFooterHtml: string | null;
@@ -36,6 +40,7 @@ interface ShellOpts {
export function renderShell({ title, body, branding }: ShellOpts): string {
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL;
const headerHtml = branding?.emailHeaderHtml ?? '';
const footerHtml = branding?.emailFooterHtml ?? '';
@@ -52,7 +57,7 @@ export function renderShell({ title, body, branding }: ShellOpts): string {
</style>
</head>
<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>
<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);">

View File

@@ -99,6 +99,11 @@ export const SETTING_KEYS = {
brandingAppName: 'branding_app_name',
brandingEmailHeaderHtml: 'branding_email_header_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)
reminderDefaultDays: 'reminder_default_days',
@@ -518,6 +523,7 @@ export async function listDocumensoWebhookSecrets(): Promise<DocumensoSecretEntr
*/
export interface PortBrandingConfig {
logoUrl: string | null;
emailBackgroundUrl: string | null;
primaryColor: string;
appName: string;
emailHeaderHtml: string | null;
@@ -526,6 +532,7 @@ export interface PortBrandingConfig {
const DEFAULT_BRANDING: PortBrandingConfig = {
logoUrl: null,
emailBackgroundUrl: null,
primaryColor: '#1e293b',
appName: 'Port Nimara CRM',
emailHeaderHtml: null,
@@ -533,16 +540,19 @@ const DEFAULT_BRANDING: PortBrandingConfig = {
};
export async function getPortBrandingConfig(portId: string): Promise<PortBrandingConfig> {
const [logoUrl, primaryColor, appName, emailHeaderHtml, emailFooterHtml] = await Promise.all([
readSetting<string>(SETTING_KEYS.brandingLogoUrl, portId),
readSetting<string>(SETTING_KEYS.brandingPrimaryColor, portId),
readSetting<string>(SETTING_KEYS.brandingAppName, portId),
readSetting<string>(SETTING_KEYS.brandingEmailHeaderHtml, portId),
readSetting<string>(SETTING_KEYS.brandingEmailFooterHtml, portId),
]);
const [logoUrl, emailBackgroundUrl, primaryColor, appName, emailHeaderHtml, emailFooterHtml] =
await Promise.all([
readSetting<string>(SETTING_KEYS.brandingLogoUrl, portId),
readSetting<string>(SETTING_KEYS.brandingEmailBackgroundUrl, portId),
readSetting<string>(SETTING_KEYS.brandingPrimaryColor, portId),
readSetting<string>(SETTING_KEYS.brandingAppName, portId),
readSetting<string>(SETTING_KEYS.brandingEmailHeaderHtml, portId),
readSetting<string>(SETTING_KEYS.brandingEmailFooterHtml, portId),
]);
return {
logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl,
emailBackgroundUrl: emailBackgroundUrl ?? DEFAULT_BRANDING.emailBackgroundUrl,
primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor,
appName: appName ?? DEFAULT_BRANDING.appName,
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,