Skip to content

Resend bounce/complaint webhook: the email half of #311 #322

Description

@Jolah1

Split out of #311, which landed the Twilio half in #321.

What's missing

#321 records delivery verdicts for SMS and WhatsApp via Twilio's StatusCallback. Email has no equivalent wired up, so a hard bounce or a spam complaint is still invisible: the row reads status='sent' forever.

This matters as much as the Twilio half. Resend accepts at SMTP, bounces later, and then adds the address to its account-level suppression list — after which every subsequent send to that address is silently dropped. An owner or heir can go permanently unreachable while the dashboard reports every message as sent.

Why it wasn't done in #321

Two unknowns that need checking against the live account rather than guessing:

  1. Correlation. We send over SMTP, so we have no Resend message id at handoff. fix(server): record what the provider says happened, not just handoff #321 now stores the SMTP 250 reply in provider_message_id on the theory that Resend puts a queue id there, but that is unverified. Needs one real send inspected to confirm the format, and whether it matches data.email_id in the webhook payload.

    If it doesn't match, the fallback is matching on recipient — which is awkward because recipient_ciphertext is AEAD-sealed with a random nonce, so it isn't searchable. That would need a deterministic lookup column (e.g. an HMAC of the address under the master key), which is a design decision, not a patch.

  2. Secret. Resend signs webhooks with Svix (svix-id, svix-timestamp, svix-signature, HMAC-SHA256 over a base64 secret). That's a new RESEND_WEBHOOK_SECRET env var and a Resend dashboard endpoint configuration — unlike the Twilio path, this one is not zero-config.

Scope

  • POST /webhooks/resend with Svix signature verification, including the timestamp tolerance check (replay protection)
  • handle email.bounced and email.complained; optionally email.delivered
  • reuse delivery::record_delivery, which is already provider-agnostic
  • document the secret and the dashboard step in DEPLOY.md

Blocks

#311 shouldn't close until this lands — half the channels reporting delivery is not the same as knowing whether messages arrive.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:serverghostkey-serverbugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions