email-admin.ts138 lines · main
1import { getEmailSenderInfo, type EmailSenderInfo } from '../lib/email.js';
2import { listAuditByActionPrefix } from './audit.js';
3
4/**
5 * Email Admin cockpit aggregation (Phase 8 §1 + §3).
6 *
7 * Everything here is derived from the audit-log rows the send path and the
8 * mittera webhook already write — no new table. Two surfaces:
9 *
10 * 1. Sender / transport status — the live From: + which leg Briven drives
11 * (mittera vs SMTP fallback) + how recent sends actually split across
12 * them. See lib/email.ts:getEmailSenderInfo for the truthfully-knowable
13 * fields and why provider is intentionally absent.
14 *
15 * 2. Per-template stats — sends · deliveries · bounces · complaints grouped
16 * by template/email-type. Sends are tagged with their template directly
17 * in the action (`mittera.<label>.sent` / `smtp.<label>.sent`); delivery
18 * outcomes (`mittera.email.delivered|bounced|complained`) carry only the
19 * opaque messageId, so we correlate them back to a template via the
20 * messageId captured at send time.
21 */
22
23/** A minimal audit-row shape — just what the aggregation reads. */
24interface StatRow {
25 action: string;
26 metadata: Record<string, unknown> | null;
27}
28
29/** Send rows look like `mittera.magic_link.sent` or `smtp.invitation.sent`. */
30const SEND_ACTION_RE = /^(?:mittera|smtp)\.(.+)\.sent$/;
31/** A `.sent` suffix marks an outbound send (vs an inbound webhook outcome). */
32const SENT_SUFFIX_RE = /\.sent$/;
33
34export interface EmailTemplateStat {
35 template: string;
36 sends: number;
37 delivered: number;
38 bounced: number;
39 complained: number;
40}
41
42function readMessageId(metadata: Record<string, unknown> | null): string | null {
43 return metadata && typeof metadata.messageId === 'string' ? metadata.messageId : null;
44}
45
46/**
47 * Pure join — kept free of any DB call so it's unit-testable in isolation
48 * (mirrors suppressions.test.ts: pin the decision logic in CI, leave the
49 * DB-touching wrapper to the integration smoke).
50 *
51 * @param sendRows outbound `*.sent` rows (mittera + smtp), each carrying a template + messageId
52 * @param outcomeRows mittera webhook rows (delivered / bounced / complained), each carrying a messageId
53 */
54export function aggregateTemplateStats(
55 sendRows: StatRow[],
56 outcomeRows: StatRow[],
57): EmailTemplateStat[] {
58 const messageTemplate = new Map<string, string>();
59 const stats = new Map<string, EmailTemplateStat>();
60
61 const ensure = (template: string): EmailTemplateStat => {
62 let s = stats.get(template);
63 if (!s) {
64 s = { template, sends: 0, delivered: 0, bounced: 0, complained: 0 };
65 stats.set(template, s);
66 }
67 return s;
68 };
69
70 for (const r of sendRows) {
71 const m = SEND_ACTION_RE.exec(r.action);
72 if (!m) continue;
73 const template = m[1]!;
74 ensure(template).sends += 1;
75 const mid = readMessageId(r.metadata);
76 if (mid) messageTemplate.set(mid, template);
77 }
78
79 for (const r of outcomeRows) {
80 // Strip whichever prefix fired (old `mittera.email.*`, new `mittera.*`).
81 const type = r.action.replace(/^mittera\.(email\.)?/, '');
82 if (type !== 'delivered' && type !== 'bounced' && type !== 'complained') continue;
83 const mid = readMessageId(r.metadata);
84 if (!mid) continue;
85 const template = messageTemplate.get(mid);
86 // Outcome whose send is outside our window — can't attribute it, skip.
87 if (!template) continue;
88 const s = ensure(template);
89 if (type === 'delivered') s.delivered += 1;
90 else if (type === 'bounced') s.bounced += 1;
91 else s.complained += 1;
92 }
93
94 return [...stats.values()].sort((a, b) => b.sends - a.sends);
95}
96
97export interface EmailSenderStatus extends EmailSenderInfo {
98 /** How the most recent sends actually split across the two transports. */
99 recentTransport: { mittera: number; smtp: number };
100 /** Why no provider field — surfaced verbatim in the cockpit. */
101 providerNote: string;
102}
103
104export interface EmailAdminSummary {
105 sender: EmailSenderStatus;
106 templates: EmailTemplateStat[];
107}
108
109const PROVIDER_NOTE =
110 'Briven sends through mittera.eu, which abstracts the underlying provider ' +
111 '(SES / Mailgun / Pando). That provider is not reported back to Briven, so ' +
112 'it is not shown here — check the mittera dashboard for the live provider. ' +
113 '"active transport" below is the leg Briven itself drives.';
114
115/**
116 * One round-trip for the whole Email Admin cockpit: fetches the recent
117 * mittera + smtp audit rows once and derives both the sender/transport
118 * status and the per-template stats from them. Read-only.
119 */
120export async function getEmailAdminSummary(limit = 1000): Promise<EmailAdminSummary> {
121 const [mitteraRows, smtpRows] = await Promise.all([
122 listAuditByActionPrefix('mittera.', limit),
123 listAuditByActionPrefix('smtp.', limit),
124 ]);
125
126 const sender: EmailSenderStatus = {
127 ...getEmailSenderInfo(),
128 recentTransport: {
129 mittera: mitteraRows.filter((r) => SENT_SUFFIX_RE.test(r.action)).length,
130 smtp: smtpRows.filter((r) => SENT_SUFFIX_RE.test(r.action)).length,
131 },
132 providerNote: PROVIDER_NOTE,
133 };
134
135 const templates = aggregateTemplateStats([...mitteraRows, ...smtpRows], mitteraRows);
136
137 return { sender, templates };
138}