feat(email-routing): per-category send-from routing infra + admin matrix

Per PRE-DEPLOY-PLAN § 1.3.7. Lays the foundation for admin-configurable
routing of every outbound email category to either the noreply or
sales sender account.

Pieces shipped:
- `src/lib/services/email-routing.ts` — EmailCategory enum (17
  categories covering every shipped surface), DEFAULT_CATEGORY_ROUTING
  map (auth/notifications/EOI-invite → noreply; brochure/PDF/sales
  send-outs → sales), `resolveSenderForCategory()` + a graceful
  fallback to noreply when the resolved sender is sales but creds
  aren't configured.
- `GET / PATCH /api/v1/admin/email/routing` endpoints — gated on
  `admin.manage_settings`. Returns the routing + sales-availability
  flag + canonical category list.
- `EmailRoutingCard` — matrix UI dropped into /admin/email below the
  sales-email-config card. Per-category dropdown auto-disables the
  `sales` option when the port has no sales SMTP creds; explains the
  state in an amber callout. Save-on-change with toast + "Reset to
  defaults" button.

Setting persisted as `system_settings.email_routing` (JSONB blob).
Followup: opportunistic migration of existing dispatchers (sendEmail,
createSalesTransporter callers) to use `resolveSenderForCategory()` —
the defaults preserve current behavior so this is non-blocking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 15:24:38 +02:00
parent bded8b21f1
commit d556bb88f7
4 changed files with 383 additions and 0 deletions

View File

@@ -0,0 +1,131 @@
/**
* Per-category email send-from routing.
*
* Each outbound email category (account activation, EOI signing request,
* brochure send, etc.) resolves to a `SenderAccount` — `noreply` for
* automation-shaped traffic, `sales` for human-touch traffic. The
* mapping is admin-configurable per port via the `email_routing`
* system_setting (JSONB blob).
*
* When a category routes to `sales` but the port has no configured
* sales account (no SMTP creds in `sales_email_config` settings),
* the resolver transparently falls back to `noreply` so a half-
* configured port still sends rather than throwing. Admin UI
* surfaces this by disabling the `sales` option when creds are
* absent.
*
* Defaults follow CLAUDE.md: auth/notifications/EOI-invite → noreply;
* brochure/berth-PDF/signed-doc completion/sales send-out → sales.
*/
import { getSetting } from '@/lib/services/settings.service';
import { getSalesEmailConfig, type SenderAccount } from '@/lib/services/sales-email-config.service';
export const EMAIL_CATEGORIES = [
'account_activation',
'password_reset',
'notification_digest',
'eoi_signing_request',
'reservation_signing_request',
'contract_signing_request',
'brochure_send',
'berth_pdf_send',
'signed_doc_completion',
'sales_sendout',
'manual_compose',
'inquiry_confirmation',
'inquiry_sales_notification',
'crm_invite',
'admin_email_change',
'gdpr_export',
'system_other',
] as const;
export type EmailCategory = (typeof EMAIL_CATEGORIES)[number];
/**
* Default sender per category. Categories not listed here fall back to
* `noreply`. The admin UI seeds the routing matrix from this map on
* first open.
*/
export const DEFAULT_CATEGORY_ROUTING: Record<EmailCategory, SenderAccount> = {
account_activation: 'noreply',
password_reset: 'noreply',
notification_digest: 'noreply',
eoi_signing_request: 'noreply',
reservation_signing_request: 'noreply',
contract_signing_request: 'noreply',
brochure_send: 'sales',
berth_pdf_send: 'sales',
signed_doc_completion: 'sales',
sales_sendout: 'sales',
manual_compose: 'sales',
inquiry_confirmation: 'noreply',
inquiry_sales_notification: 'noreply',
crm_invite: 'noreply',
admin_email_change: 'noreply',
gdpr_export: 'noreply',
system_other: 'noreply',
};
export const EMAIL_ROUTING_KEY = 'email_routing';
/**
* Resolve which sender account a given category should use for this
* port. Reads the per-port `email_routing` JSONB blob; falls back to
* the default mapping when unset, malformed, or missing the key.
* Further falls back to `noreply` if the resolved sender is `sales`
* but the port has no sales SMTP credentials configured (so a half-
* configured port still sends through noreply rather than throwing).
*/
export async function resolveSenderForCategory(
portId: string,
category: EmailCategory,
): Promise<SenderAccount> {
const setting = await getSetting(EMAIL_ROUTING_KEY, portId);
let configured: SenderAccount = DEFAULT_CATEGORY_ROUTING[category];
if (setting && typeof setting.value === 'object' && setting.value !== null) {
const routing = setting.value as Record<string, string>;
const v = routing[category];
if (v === 'noreply' || v === 'sales') {
configured = v;
}
}
if (configured === 'sales') {
const sales = await getSalesEmailConfig(portId);
if (!sales.isUsable) {
return 'noreply';
}
}
return configured;
}
/**
* Bulk-resolve the whole routing matrix for the admin UI. Returns the
* effective mapping (defaults overlaid with whatever the port has
* configured) plus an `isSalesAvailable` flag the UI uses to disable
* the `sales` option when no creds are set.
*/
export async function getEmailRoutingMatrix(portId: string): Promise<{
routing: Record<EmailCategory, SenderAccount>;
isSalesAvailable: boolean;
}> {
const [setting, sales] = await Promise.all([
getSetting(EMAIL_ROUTING_KEY, portId),
getSalesEmailConfig(portId),
]);
const stored =
setting && typeof setting.value === 'object' && setting.value !== null
? (setting.value as Record<string, string>)
: {};
const routing = { ...DEFAULT_CATEGORY_ROUTING };
for (const cat of EMAIL_CATEGORIES) {
const v = stored[cat];
if (v === 'noreply' || v === 'sales') {
routing[cat] = v;
}
}
return { routing, isSalesAvailable: sales.isUsable };
}