mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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)
93 lines
2.7 KiB
TypeScript
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 });
|
|
});
|