mittera-webhook.ts189 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | |
| 3 | import { env } from '../env.js'; |
| 4 | import { log } from '../lib/logger.js'; |
| 5 | import { verifySignature } from '../lib/email.js'; |
| 6 | import { audit } from '../services/audit.js'; |
| 7 | import { 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 | |
| 35 | interface MitteraEnvelope { |
| 36 | id?: string; |
| 37 | type?: string; |
| 38 | createdAt?: string; |
| 39 | data?: MitteraEmailData; |
| 40 | } |
| 41 | |
| 42 | interface 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 | |
| 57 | function 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 | |
| 63 | export const mitteraWebhookRouter = new Hono(); |
| 64 | |
| 65 | mitteraWebhookRouter.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 | }); |