diff --git a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx index bfdb7439..c940eafa 100644 --- a/src/app/(dashboard)/[portSlug]/admin/email/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/email/page.tsx @@ -4,6 +4,7 @@ import { } from '@/components/admin/shared/settings-form-card'; import { PageHeader } from '@/components/shared/page-header'; import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card'; +import { EmailRoutingCard } from '@/components/admin/email-routing-card'; const FIELDS: SettingFieldDef[] = [ { @@ -80,6 +81,7 @@ export default function EmailSettingsPage() { fields={FIELDS.slice(3)} /> + ); } diff --git a/src/app/api/v1/admin/email/routing/route.ts b/src/app/api/v1/admin/email/routing/route.ts new file mode 100644 index 00000000..f0787e0e --- /dev/null +++ b/src/app/api/v1/admin/email/routing/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { upsertSetting } from '@/lib/services/settings.service'; +import { + EMAIL_CATEGORIES, + EMAIL_ROUTING_KEY, + getEmailRoutingMatrix, +} from '@/lib/services/email-routing'; + +const senderSchema = z.enum(['noreply', 'sales']); +const updateSchema = z.object({ + routing: z.record(z.enum(EMAIL_CATEGORIES), senderSchema), +}); + +export const GET = withAuth( + withPermission('admin', 'manage_settings', async (_req, ctx) => { + try { + const matrix = await getEmailRoutingMatrix(ctx.portId); + return NextResponse.json({ + data: { ...matrix, categories: EMAIL_CATEGORIES }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const PATCH = withAuth( + withPermission('admin', 'manage_settings', async (req, ctx) => { + try { + const input = await parseBody(req, updateSchema); + await upsertSetting(EMAIL_ROUTING_KEY, input.routing, ctx.portId, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + const matrix = await getEmailRoutingMatrix(ctx.portId); + return NextResponse.json({ + data: { ...matrix, categories: EMAIL_CATEGORIES }, + }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/email-routing-card.tsx b/src/components/admin/email-routing-card.tsx new file mode 100644 index 00000000..551f8be4 --- /dev/null +++ b/src/components/admin/email-routing-card.tsx @@ -0,0 +1,200 @@ +'use client'; + +/** + * Per-category send-from routing matrix. + * + * Renders one row per email category with a `noreply | sales` dropdown. + * When the port has no sales SMTP credentials configured, the `sales` + * option is disabled across the board and the card explains why. + */ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { WarningCallout } from '@/components/ui/warning-callout'; +import { apiFetch } from '@/lib/api/client'; +import { toastError } from '@/lib/api/toast-error'; + +type Sender = 'noreply' | 'sales'; + +interface RoutingResponse { + data: { + routing: Record; + isSalesAvailable: boolean; + categories: readonly string[]; + }; +} + +const LABELS: Record = { + account_activation: 'Account activation', + password_reset: 'Password reset', + notification_digest: 'Notification digest', + eoi_signing_request: 'EOI signing request', + reservation_signing_request: 'Reservation signing request', + contract_signing_request: 'Contract signing request', + brochure_send: 'Brochure send', + berth_pdf_send: 'Berth PDF send', + signed_doc_completion: 'Signed-doc completion', + sales_sendout: 'Sales send-out', + manual_compose: 'Manual rep compose', + inquiry_confirmation: 'Inquiry confirmation (to client)', + inquiry_sales_notification: 'Inquiry notification (to sales)', + crm_invite: 'CRM user invite', + admin_email_change: 'Admin email-change notice', + gdpr_export: 'GDPR export delivery', + system_other: 'Other system-generated', +}; + +export function EmailRoutingCard() { + const qc = useQueryClient(); + const { data, isLoading, isError } = useQuery({ + queryKey: ['admin', 'email', 'routing'], + queryFn: () => apiFetch('/api/v1/admin/email/routing'), + staleTime: 30_000, + }); + + const update = useMutation({ + mutationFn: (routing: Record) => + apiFetch('/api/v1/admin/email/routing', { + method: 'PATCH', + body: JSON.stringify({ routing }), + }), + onSuccess: (resp) => { + qc.setQueryData(['admin', 'email', 'routing'], resp); + toast.success('Send-from routing saved'); + }, + onError: (err) => toastError(err, 'Failed to save send-from routing'), + }); + + if (isLoading) { + return ( + + + Send-from routing + + + + + + ); + } + + if (isError || !data) { + return ( + + + Send-from routing + + +

Failed to load routing matrix.

+
+
+ ); + } + + const { routing, isSalesAvailable, categories } = data.data; + + const setCategory = (category: string, sender: Sender) => { + update.mutate({ ...routing, [category]: sender }); + }; + + return ( + + + Send-from routing + + Pick which account each email category sends from. Automated traffic typically goes + through noreply; human-touch + traffic (brochures, send-outs) goes through{' '} + sales. + + + + {!isSalesAvailable ? ( + +

+ Sales sender is disabled — configure SMTP credentials in the "Sales send-from + account" card below to enable the sales option. +

+
+ ) : null} +
+ {categories.map((cat) => ( +
+
{LABELS[cat] ?? cat}
+
+ +
+
+ ))} +
+

+ Changes save automatically when you pick a new sender. The sales option falls + back to noreply at send-time if creds are removed. +

+ {update.isPending ? ( +
+ Saving… +
+ ) : null} +
+ + + +
+ ); +} diff --git a/src/lib/services/email-routing.ts b/src/lib/services/email-routing.ts new file mode 100644 index 00000000..fc6fa22b --- /dev/null +++ b/src/lib/services/email-routing.ts @@ -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 = { + 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 { + 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; + 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; + 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) + : {}; + + 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 }; +}