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,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);
}
}),
);