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,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 &quot;Sales send-from
account&quot; 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>
);
}