mittera-webhook.ts189 lines · main
1import { Hono } from 'hono';
2
3import { env } from '../env.js';
4import { log } from '../lib/logger.js';
5import { verifySignature } from '../lib/email.js';
6import { audit } from '../services/audit.js';
7import { suppress } from '../services/suppressions.js';
8
9/**
10 * Inbound webhook receiver for mittera.eu — registered at the URL
11 * configured on the mittera side (https://api.briven.tech/mittera-webhook,
12 * which bypasses the Cloudflare-edge mangling we saw on POST bodies
13 * through the Next.js rewrite).
14 *
15 * Wire format (per mittera spec):
16 * X-mittera-Signature: v1=<hex_hmac_sha256(`${ts_ms}.${body}`)>
17 * X-mittera-Timestamp: <unix_milliseconds>
18 * X-mittera-Event: email.delivered (etc.)
19 * X-mittera-Call: <unique attempt id>
20 * X-mittera-Retry: true | false
21 *
22 * Body shape (envelope):
23 * { id, type, createdAt, data: { id, status, from, to, ... } }
24 *
25 * Behaviour:
26 * - verify HMAC signature first; reject unsigned + replays >5min
27 * - audit-log every event (action="mittera.email.<type>")
28 * - mutate the suppression list for events that should stop sends:
29 * email.bounced + bounce.type==="Permanent" → permanent_bounce
30 * email.complained → complaint
31 * email.suppressed → mittera_suppressed
32 * - everything else acks 200 (don't 4xx — mittera will retry)
33 */
34
35interface MitteraEnvelope {
36 id?: string;
37 type?: string;
38 createdAt?: string;
39 data?: MitteraEmailData;
40}
41
42interface MitteraEmailData {
43 id?: string;
44 status?: string;
45 from?: string;
46 to?: string | string[];
47 occurredAt?: string;
48 subject?: string;
49 bounce?: { type?: string; subType?: string; message?: string };
50 open?: { timestamp?: string; userAgent?: string; ip?: string; platform?: string };
51 click?: { timestamp?: string; url?: string };
52 suppression?: { type?: string; reason?: string; source?: string };
53 failed?: { reason?: string };
54 metadata?: Record<string, unknown>;
55}
56
57function recipientList(to: MitteraEmailData['to']): string[] {
58 if (!to) return [];
59 if (Array.isArray(to)) return to.filter((s): s is string => typeof s === 'string');
60 return typeof to === 'string' ? [to] : [];
61}
62
63export const mitteraWebhookRouter = new Hono();
64
65mitteraWebhookRouter.post('/mittera-webhook', async (c) => {
66 if (!env.BRIVEN_MITTERA_WEBHOOK_SECRET) {
67 log.warn('mittera_webhook_unconfigured');
68 return c.json({ code: 'not_configured', message: 'mittera webhook secret unset' }, 503);
69 }
70
71 // Read the raw body — verifySignature operates on the exact bytes
72 // mittera signed, so we must not let JSON.parse round-trip and lose
73 // whitespace/escape ordering.
74 const raw = await c.req.text();
75 const sigHeader =
76 c.req.header('x-mittera-signature') ?? c.req.header('mittera-signature') ?? null;
77 const tsHeader =
78 c.req.header('x-mittera-timestamp') ?? c.req.header('mittera-timestamp') ?? null;
79
80 const ok = verifySignature({
81 secret: env.BRIVEN_MITTERA_WEBHOOK_SECRET,
82 signatureHeader: sigHeader,
83 timestampHeader: tsHeader,
84 body: raw,
85 });
86
87 if (!ok) {
88 log.warn('mittera_webhook_signature_invalid', {
89 hasSig: Boolean(sigHeader),
90 hasTs: Boolean(tsHeader),
91 bodyLen: raw.length,
92 });
93 return c.json({ code: 'invalid_signature', message: 'signature invalid or expired' }, 401);
94 }
95
96 // Best-effort parse — mittera's signature already proves authenticity,
97 // so we still ack 200 even on a malformed body (logs the issue).
98 let envelope: MitteraEnvelope = {};
99 try {
100 envelope = JSON.parse(raw) as MitteraEnvelope;
101 } catch {
102 log.warn('mittera_webhook_unparseable_body', { bodyLen: raw.length });
103 }
104
105 const eventType = envelope.type ?? 'unknown';
106 const eventId = envelope.id ?? null;
107 const data = envelope.data ?? {};
108 const recipients = recipientList(data.to);
109
110 // Mittera's handshake when you save a webhook URL — return 200,
111 // skip the rest. Don't audit-log noise.
112 if (eventType === 'webhook.test') {
113 log.info('mittera_webhook_handshake', { eventId });
114 return c.json({ ok: true });
115 }
116
117 log.info('mittera_webhook_received', {
118 type: eventType,
119 eventId,
120 messageId: data.id ?? null,
121 recipients: recipients.length,
122 status: data.status ?? null,
123 });
124
125 // Persist to audit_logs so the admin dashboard can render history.
126 // §5.1: never store recipient addresses here. messageId is opaque to
127 // us; bounce/complaint/suppression context is event-meta, not PII.
128 // Action shape: `mittera.<eventType>` (eventType already carries the
129 // `email.` / `domain.` / `contact.` prefix, so we don't double it).
130 await audit({
131 actorId: null,
132 projectId: null,
133 action: `mittera.${eventType}`,
134 ipHash: null,
135 userAgent: 'mittera-webhook',
136 metadata: {
137 eventId,
138 messageId: data.id ?? null,
139 status: data.status ?? null,
140 bounceType: data.bounce?.type ?? null,
141 bounceSubType: data.bounce?.subType ?? null,
142 bounceMessage: data.bounce?.message?.slice(0, 240) ?? null,
143 suppressionType: data.suppression?.type ?? null,
144 suppressionReason: data.suppression?.reason ?? null,
145 failedReason: data.failed?.reason?.slice(0, 240) ?? null,
146 },
147 });
148
149 // ─── suppression rules (per mittera spec §6) ────────────────────────
150 // Permanent bounces, complaints, and mittera-side suppressions all
151 // mean "stop sending to this recipient". Transient bounces don't
152 // suppress — retries can succeed (mailbox full etc.).
153 if (eventType === 'email.bounced' && data.bounce?.type === 'Permanent') {
154 for (const r of recipients) {
155 await suppress({
156 email: r,
157 reason: 'permanent_bounce',
158 detail: data.bounce?.message?.slice(0, 240) ?? null,
159 sourceEventId: eventId,
160 });
161 }
162 } else if (eventType === 'email.complained') {
163 for (const r of recipients) {
164 await suppress({
165 email: r,
166 reason: 'complaint',
167 detail: data.suppression?.reason ?? null,
168 sourceEventId: eventId,
169 });
170 }
171 } else if (eventType === 'email.suppressed') {
172 for (const r of recipients) {
173 await suppress({
174 email: r,
175 reason: 'mittera_suppressed',
176 detail:
177 [data.suppression?.type, data.suppression?.reason, data.suppression?.source]
178 .filter(Boolean)
179 .join(' · ') || null,
180 sourceEventId: eventId,
181 });
182 }
183 }
184 // email.delivered, email.opened, email.clicked, email.queued, etc.
185 // are already audit-logged above. Transient bounces are too — no
186 // suppression action needed.
187
188 return c.json({ ok: true });
189});