Skip to content

Email 4/7 (PR 3): Resend webhook for bounces and complaints #422

Closed

Description

@HMarzban

Part of #418. Read the parent first. Its Resend webhook section is the contract here. Step 4 of 7. server/ is short for apps/hocuspocus.server/.

Goal

Let Resend tell us about hard bounces, complaints, suppressions and late failures. The webhook writes through the existing record_email_bounce, which already turns off email for that user. No SQL migration.

Owner and gate

Steps

  1. Shared bounce write. Create server/src/lib/email/bounces.ts with recordEmailBounce(client, event: EmailBounceEvent) (server/src/types/email.types.ts:103-108). Move the RPC call out of /bounce (server/src/api/email.ts:146-166) so both paths use it. Its log lines use to: maskEmail(email); today's lines at :168 and :172 log the raw address.
  2. Module server/src/modules/email-webhooks/. Copy the shape of server/src/modules/link-metadata/. Read server/CLAUDE.md §HTTP Modules first.
    • domain/types.ts: DeliveryEvent, per the parent.
    • domain/resend/verifySignature.ts: pure, Web Crypto only, per the parent.
    • domain/resend/toDeliveryEvent.ts: pure. Hard bounce means data.bounce.type === 'Permanent'; subType goes into reason. A product-mail event whose ns tag is not this server's keyNamespace maps to ignored.
    • infra/webhookDedupe.ts: createWebhookDedupe(redis) with claim, markDone and release, per the parent's three states. Do not use withRedisLease.
    • http/controller.ts and http/router.ts: bodyLimit({ maxSize: 64 * 1024 }) from hono/body-limit, as server/src/modules/document-content/http/controller.ts:40 does. Then read the raw body, then check the signature, then claim the dedupe key. Never claim a key before the signature is valid. Apply the parent's Input rules. Every rejection gets the same 401 body and logs email webhook signature rejected. Answer 200 on an ignored event.
    • module.ts: init(deps: InitDeps): InitResult, where InitResult is { router }. Deps: webhookSecret, redis, recordEmailBounce, logger. The module reads no env.
    • index.ts exports init only.
  3. Mount in server/src/index.ts: app.route('/api/email/webhooks', emailWebhooks.init({ … }).router), only when config.email.webhookSecret is set. The router serves POST /resend. With no secret, the path answers 404.
  4. email.failed: no write. Log { err: new EmailSendError('operator', data.reason, …) } with the masked to, so incident-email-operator fires.
  5. Env: add RESEND_WEBHOOK_SECRET to server/src/config/env.schema.ts as an optional string, and expose it as config.email.webhookSecret. Add a pure resolveWebhookSecret(raw): string | null to server/src/config/email.ts. It returns null for a secret that does not decode to at least 24 bytes, and the boot then logs email webhook secret invalid at error level. Product mail is never held for it. Add its cases to server/src/config/__tests__/email.test.ts.
  6. Alert: add incident-email-webhook-unauthorized to rules-app-logs.yml, per the parent's alert table.

Test (one file)

server/src/modules/email-webhooks/__tests__/unit/verifySignature.test.ts. Use a fixed published vector: the secret, headers, body and signature from the Standard Webhooks or Svix docs. Cover:

  • the vector verifies;
  • one changed body byte fails;
  • a timestamp outside the 5 min window fails, in the past and in the future;
  • a timestamp that is not a number fails;
  • an svix-id that does not match the pattern fails.

A test that signs with our own code and then verifies proves nothing. The toDeliveryEvent branches are proven by the staging checks below.

Docs (same PR)

  • server/API.md: the route.
  • server/ENV.md: RESEND_WEBHOOK_SECRET.
  • server/src/modules/openapi/domain/paths/email.ts: the route, with security: [{}] and the three svix-* headers.
  • docs/api/authentication.md:37: the webhook is authenticated by its signature, not the service role.
  • docs/self-hosting/configuration.md: the webhook URL, the four events to pick, and where to find the secret.

Checks before push

  • bun run check at the repo root, and bun test in server/.
  • Import each domain/ file with REDIS_HOST, REDIS_PORT and DATABASE_URL unset.

Deploy

The deploy subject is exactly (build): back observability, because this PR also changes an alert rule.

Staging checks (maintainer)

The staging webhook from #420 (Ops 0) points at this route. Send each mail with POST /api/email/send-generic and the service-role key:

  • bounced@resend.dev writes one email_bounces row with type hard.
  • complained@resend.dev writes one row with type complaint.
  • suppressed@resend.dev writes one row with type hard and reason resend-suppressed.
  • Resending one event from the Resend dashboard answers 200 and writes no new row.
  • A request with a wrong signature gets 401.

Afterwards, reset the test rows if needed: delete from email_bounces where email like '%@resend.dev'.

Acceptance

  • The staging checks above pass.
  • bun test is green, including the new file.
  • GET /openapi.json on staging lists the route.
  • Every doc listed above is updated in this PR.

Exit evidence

Post as a comment here:

  • bun run check and bun test output.
  • The email_bounces rows from the staging checks. The @resend.dev test addresses need no mask; mask any other address.
  • The 401 answer.

Revert

Disable the webhook endpoint in Resend first, so events wait in Svix and are not lost. Then git revert and redeploy.

Activity

  1. added theissue type on Oct 6, 2026
  2. changed the title [-]Email 4/7 (PR 3): Resend delivery webhook for bounces and complaints[/-] [+]Email 4/7 (PR 3): Resend webhook for bounces and complaints[/+] on Oct 6, 2026
  3. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Fixed and live.

    • Commits: 043dda292, 0fc3e7dfa
    • Deployed in bbdaae66c (production run 37976785981, green).
    • The signed Resend webhook is live code. It turns on once RESEND_WEBHOOK_SECRET is set in the host .env; without it the route answers 404 and mail still sends.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions