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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user