email-admin.ts138 lines · main
| 1 | import { getEmailSenderInfo, type EmailSenderInfo } from '../lib/email.js'; |
| 2 | import { 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. */ |
| 24 | interface 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`. */ |
| 30 | const SEND_ACTION_RE = /^(?:mittera|smtp)\.(.+)\.sent$/; |
| 31 | /** A `.sent` suffix marks an outbound send (vs an inbound webhook outcome). */ |
| 32 | const SENT_SUFFIX_RE = /\.sent$/; |
| 33 | |
| 34 | export interface EmailTemplateStat { |
| 35 | template: string; |
| 36 | sends: number; |
| 37 | delivered: number; |
| 38 | bounced: number; |
| 39 | complained: number; |
| 40 | } |
| 41 | |
| 42 | function 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 | */ |
| 54 | export 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 | |
| 97 | export 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 | |
| 104 | export interface EmailAdminSummary { |
| 105 | sender: EmailSenderStatus; |
| 106 | templates: EmailTemplateStat[]; |
| 107 | } |
| 108 | |
| 109 | const 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 | */ |
| 120 | export 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 | } |