Files
worldmonitor/convex/resendWebhookHandler.ts
Elie Habib 20c65a4f4f feat(email): add deliverability guards to reduce waitlist bounces (#2819)
* feat(email): add deliverability guards to reduce waitlist bounces

Analyzed 1,276 bounced waitlist emails and found typos (gamil.com),
disposable domains (passmail, guerrillamail), offensive submissions,
and non-existent domains account for the majority.

Four layers of protection:
- Frontend: mailcheck.js typo suggestions on email blur
- API: MX record check via Cloudflare DoH, disposable domain
  blocklist, offensive pattern filter, typo-TLD blocklist
- Webhook: api/resend-webhook.js captures bounce/complaint events,
  stores in Convex emailSuppressions table, checked before sending
- Tooling: import script for bulk-loading existing bounced emails

* fix(email): address review - auth, retry, CSV parsing

1. Security: Convert suppress/bulkSuppress/remove to internalMutation.
   Webhook now runs as Convex httpAction (matching Dodo pattern) with
   direct access to internal mutations. Bulk import uses relay shared
   secret. Only isEmailSuppressed remains public (read-only query).

2. Retry: Convex httpAction returns 500 on any mutation failure so
   Resend retries the webhook instead of silently losing bounce events.

3. CSV: Replace naive comma-split with RFC 4180 parser that handles
   quoted fields. Import script now calls Convex HTTP action
   authenticated via RELAY_SHARED_SECRET instead of public mutation.

* fix(email): make isEmailSuppressed internal, check inside mutation

Move suppression check into registerInterest:register mutation
(same transaction, no extra round-trip). Remove public query
entirely so no suppression data is exposed to browser clients.

* test(email): add coverage for validation, CSV parser, and suppressions

- 19 tests for validateEmail: disposable domains, offensive patterns,
  typo TLDs, MX fail-open, case insensitivity, privacy relay allowance
- 7 tests for parseCsvLine: RFC 4180 quoting, escaped quotes, empty
  fields, Resend CSV format with angle brackets and commas
- 11 Convex tests for emailSuppressions: suppress idempotency, case
  normalization, bulk dedup, remove, and registerInterest integration
  (emailSuppressed flag in mutation return)
2026-04-08 11:21:40 +04:00

93 lines
2.7 KiB
TypeScript

import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { requireEnv } from "./lib/env";
const HANDLED_EVENTS = new Set(["email.bounced", "email.complained"]);
async function verifySignature(
payload: string,
headers: Headers,
secret: string,
): Promise<boolean> {
const msgId = headers.get("svix-id");
const timestamp = headers.get("svix-timestamp");
const signature = headers.get("svix-signature");
if (!msgId || !timestamp || !signature) return false;
const ts = Number(timestamp);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const toSign = `${msgId}.${timestamp}.${payload}`;
const secretBytes = Uint8Array.from(atob(secret.replace("whsec_", "")), (c) =>
c.charCodeAt(0),
);
const key = await crypto.subtle.importKey(
"raw",
secretBytes,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(toSign),
);
const expected = btoa(String.fromCharCode(...new Uint8Array(sig)));
const signatures = signature.split(" ");
return signatures.some((s) => {
const [, val] = s.split(",");
return val === expected;
});
}
export const resendWebhookHandler = httpAction(async (ctx, request) => {
const secret = requireEnv("RESEND_WEBHOOK_SECRET");
const rawBody = await request.text();
const valid = await verifySignature(rawBody, request.headers, secret);
if (!valid) {
console.warn("[resend-webhook] Invalid signature");
return new Response("Invalid signature", { status: 401 });
}
let event: { type: string; data?: { to?: string[]; email_id?: string } };
try {
event = JSON.parse(rawBody);
} catch {
return new Response("Invalid JSON", { status: 400 });
}
if (!HANDLED_EVENTS.has(event.type)) {
return new Response(null, { status: 200 });
}
const recipients = event.data?.to;
if (!Array.isArray(recipients) || recipients.length === 0) {
return new Response(null, { status: 200 });
}
const reason = event.type === "email.bounced" ? "bounce" : "complaint";
for (const email of recipients) {
try {
await ctx.runMutation(internal.emailSuppressions.suppress, {
email,
reason: reason as "bounce" | "complaint",
source: `resend-webhook:${event.data?.email_id ?? "unknown"}`,
});
console.log(`[resend-webhook] Suppressed ${email} (${reason})`);
} catch (err) {
console.error(`[resend-webhook] Failed to suppress ${email}:`, err);
return new Response("Internal processing error", { status: 500 });
}
}
return new Response(null, { status: 200 });
});