132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 };
|
||
|
|
}
|