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:
@@ -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)}
|
||||
/>
|
||||
<SalesEmailConfigCard />
|
||||
<EmailRoutingCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
50
src/app/api/v1/admin/email/routing/route.ts
Normal file
50
src/app/api/v1/admin/email/routing/route.ts
Normal file
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
200
src/components/admin/email-routing-card.tsx
Normal file
200
src/components/admin/email-routing-card.tsx
Normal file
@@ -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<string, Sender>;
|
||||
isSalesAvailable: boolean;
|
||||
categories: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
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<RoutingResponse>({
|
||||
queryKey: ['admin', 'email', 'routing'],
|
||||
queryFn: () => apiFetch<RoutingResponse>('/api/v1/admin/email/routing'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: (routing: Record<string, Sender>) =>
|
||||
apiFetch<RoutingResponse>('/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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send-from routing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send-from routing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">Failed to load routing matrix.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { routing, isSalesAvailable, categories } = data.data;
|
||||
|
||||
const setCategory = (category: string, sender: Sender) => {
|
||||
update.mutate({ ...routing, [category]: sender });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Send-from routing</CardTitle>
|
||||
<CardDescription>
|
||||
Pick which account each email category sends from. Automated traffic typically goes
|
||||
through <code className="rounded bg-muted px-1 text-xs">noreply</code>; human-touch
|
||||
traffic (brochures, send-outs) goes through{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">sales</code>.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isSalesAvailable ? (
|
||||
<WarningCallout>
|
||||
<p className="text-sm">
|
||||
Sales sender is disabled — configure SMTP credentials in the "Sales send-from
|
||||
account" card below to enable the <code>sales</code> option.
|
||||
</p>
|
||||
</WarningCallout>
|
||||
) : null}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_180px]">
|
||||
{categories.map((cat) => (
|
||||
<div
|
||||
key={cat}
|
||||
className="contents [&>*]:flex [&>*]:items-center [&>*]:border-b [&>*]:border-border [&>*]:py-2"
|
||||
>
|
||||
<div className="text-sm font-medium">{LABELS[cat] ?? cat}</div>
|
||||
<div>
|
||||
<Select
|
||||
value={routing[cat] ?? 'noreply'}
|
||||
onValueChange={(v) => setCategory(cat, v as Sender)}
|
||||
disabled={update.isPending}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="noreply">noreply</SelectItem>
|
||||
<SelectItem value="sales" disabled={!isSalesAvailable}>
|
||||
sales {!isSalesAvailable ? '(not configured)' : ''}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Changes save automatically when you pick a new sender. The <code>sales</code> option falls
|
||||
back to <code>noreply</code> at send-time if creds are removed.
|
||||
</p>
|
||||
{update.isPending ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin" aria-hidden /> Saving…
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
update.mutate({
|
||||
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',
|
||||
})
|
||||
}
|
||||
disabled={update.isPending}
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
131
src/lib/services/email-routing.ts
Normal file
131
src/lib/services/email-routing.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user