Repository navigation
Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421
Description
Part of #418. Read the parent first. Its Design sections Failures and retries and Dead-letter drain are the contract here. Step 3 of 7. server/ is short for apps/hocuspocus.server/.
Goal
Classify every failure so that only real outages retry, operator problems page, and bad recipients stop at once. Give the delivery path one owner, and give operators a safe drain for the dead-letter queue.
Owner and gate
- Builds it: an agent or engineer.
- Staging checks: the maintainer runs them, because they need the Resend staging key.
- Gate to merge:
- Email 1/7 (PR 1): explicit provider config, the provider contract and job identity #419 (PR 1) has run on prod for 48 h with no email alert.
- Email 2/7 (Ops 0): Resend teams, domains, records and staging credentials #420 (Ops 0) is closed.
Steps
- Split the sender (
server/src/lib/email/sender.ts:78-196).buildEmailMessage(data): EmailMessagerenders the mail from the job data and itscreated_atonly. It strips control characters from the subject and cuts it to 150 characters.deliverEmail(message, { idempotencyKey, jobId }): Promise<SentEmail>callsgetEmailProvider()withAbortSignal.timeout(EMAIL_SEND_TIMEOUT_MS). It logs one line per send (jobId,provider,messageId,durationMs,to) and rethrowsEmailSendError. It never turns an error into a string.- The worker, the no-Redis path and
server/src/lib/email/document-notification.ts:80call these two.sendEmailViaProvideris deleted.
- Classification. In
providers/resend.tsandproviders/smtp.ts, map errors with the parent's Resend error map table and SMTP error map rules. Keepcodeverbatim and setresponseCode. server/src/lib/email/failureDecision.ts(create). Pure, with no import ofqueue.ts.decideEmailFailure(err, attemptsMade, attempts): EmailFailureAction, whereEmailFailureAction = 'retry' | 'dead-letter'.- An error that is not an
EmailSendErrorcounts astransient.
server/src/lib/email/dlqDisposition.ts(create). Pure.judgeEmailDlqEntry(facts: EmailDlqFacts, now: number): EmailDlqDisposition, whereEmailDlqDisposition = 'replay' | 'discard' | 'unresolved', per the parent's drain table. Followserver/src/lib/storeDlqDisposition.ts.
- Worker (
server/src/lib/email/queue.ts:174-199).- Catch any error and call
decideEmailFailure. On'dead-letter'before the last attempt, throw BullMQUnrecoverableError. - The DLQ entry gets
failureKindandfailureCodebesidefailureReason(server/src/types/email.types.ts:67-71). - Retry policy (
queue.ts:28-31):attempts: 6,backoff: { type: 'exponential', delay: 30_000 }.
- Catch any error and call
- Timeouts.
EMAIL_SEND_TIMEOUT_MS = 15_000besidedeliverEmail. SMTP addssocketTimeout: 30_000and does not race the signal. - Resend request. Tags are
{ job_id, email_type, ns }, wherensisconfig.email.delivery.keyNamespace. They are sent as[{ name, value }], each value cleaned to[A-Za-z0-9_-], at most 256 characters. Passreply_tothrough. - Masking. Create
server/src/lib/maskEmail.tswithmaskEmail(address). Its output matches SQLinternal.mask_email(packages/supabase/scripts/07-5-email-notifications-pgmq.sql:164-175):j***@example.com. Every email log line puts the masked address in the fieldto. No raw address may appear under any key. - No-Redis path.
queue.ts:244-249andserver/src/lib/email/service.ts:59,:73,:87: an inline failure is final. The service must not send a second time. - DLQ route (
server/src/api/controllers/admin-audit.controller.ts:268-285), for both push and email:- Build the queue with
EMAIL_DLQ_NAMEfromjobIdentity.ts, not a string literal (:255). - Return a summary (
id,name,timestamp,failureKind,failureCode,type, maskedto), notdata. - Fill the response key
failedReasonfromj.data?.failureReason ?? j.failedReason. Keep the key name, so the admin page needs no change.
- Build the queue with
- Drain.
- Add
drainEmailDeadLetterQueue({ apply })toqueue.ts. ApplyjudgeEmailDlqEntryto each entry. Replay goes throughqueueEmailwith the original job id and data. Do not usejob.retry(), which keepsattemptsMade. - Create
server/scripts/drain-email-dlq.ts, a thin script likeserver/scripts/drain-store-dlq.ts. It is a dry run by default, acts only with--apply, and prints one line per entry: id,failureKind, disposition.
- Add
- Alerts in
scripts/observability/grafana/provisioning/alerting/rules-app-logs.yml: addincident-email-operatorandincident-email-transient-surge, per the parent's alert table. Keepincident-email-broken. Inrules-workers.yml:137, change theworker-dlq-depthsummary to name the email drain.
Tests (two files)
server/src/lib/email/__tests__/failureDecision.test.ts: each kind on the last and a non-last attempt; a non-EmailSendError; a 422validation_errornamingsubjectmaps topermanent.server/src/lib/email/__tests__/dlqDisposition.test.ts: each disposition, including atransiententry at 23 h and at 25 h, and an entry with nofailureKind.
The unit tests are the only proof of the permanent path. Staging cannot produce it, because send-generic refuses a malformed address before it is queued (server/src/schemas/email.schema.ts:4).
Docs (same PR)
server/CLAUDE.md:- the provider contract and the three kinds;
failureDecision.tsanddlqDisposition.tsas lifted pure decisions;- the same-body rule and
jobIdentity.ts; - "never write
.envfrom a web route".
docs/RUNBOOK-backend.md: extend §Dead-letter queue not empty with the email drain. Add a §Email section with these entries:- Key revoked: edit the host env file, redeploy, run the drain.
- Quota reached: sends fail as
operator, or Resend fails them later withemail.failed. Raise the plan or wait for the reset, then run the drain. - Config
invalid: the Email setup page and the worker log name the variable. Fix it and redeploy. Messages older than 24 h settlestale. - "I get no mail": check
users.notification_preferences, thenemail_bounces, then the Resend log by thejob_idtag. - "Email sign-in does nothing": check Resend Suppressions for the address in the auth team.
- Remove a suppression: remove it in Resend (Dashboard > Suppressions) first. Then reset
email_bouncesandnotification_preferences.email_enabled. In the other order, the next send triggersemail.suppressedand turns email off again.
server/CHANGELOG.mdunder## [Unreleased]:### Changedfor the retry window,### Fixedfor the DLQ Reason and the no-Redis double send.
Checks before push
bun run checkat the repo root. It includescheck:agent-docs, which this PR'sCLAUDE.mdedit must pass.bun testinserver/.- Import
failureDecision.ts,dlqDisposition.tsandmaskEmail.tswithREDIS_HOST,REDIS_PORTandDATABASE_URLunset.
Deploy
The deploy subject is exactly (build): back observability. The app deploys before the alert rules.
Staging checks (maintainer)
Set EMAIL_PROVIDER=resend and an EMAIL_FROM on the verified domain in the staging host env file, then redeploy staging.
- Send with
EMAIL_FROMon a domain that is not verified. Resend answers 403validation_error. The job dead-letters withfailureKind: operatorand no retries, andincident-email-operatorfires. RestoreEMAIL_FROM. - Kill the worker right after a send (
docker killduring the job). The retry gets the same Resend message id, and the Resend log shows one mail. - Set a wrong
RESEND_API_KEYand send. It dead-letters asoperator. Restore the key. The drain dry run lists the entry asreplay.--applysends it once. - The Notifications DLQ table shows a Reason.
Acceptance
- The staging checks above pass.
-
bun testis green, including both new files. - Every doc listed above is updated in this PR.
Exit evidence
Post as a comment here:
bun run checkandbun testoutput.- The DLQ entry JSON with
failureKind, with every address masked. - The Resend log line for the killed-worker retry.
- The drain dry-run output.
Revert
git revert the merge, then redeploy. Jobs already queued keep 6 attempts, which is harmless. The DLQ keeps its entries.
Activity
[-]Email 3/7 (PR 2): typed failures, idempotent retries and a safe DLQ drain[/-][+]Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain[/+]feat(email): type send failures and add a safe dead-letter drain
fix(admin): match the DLQ job type to the summary route
docs(openapi): match the spec to today's admin, email, media and heal…
docs(hocuspocus): sync server docs with the 9 October fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Fixed and live.
42cd2b9af,6c0c8e2ae,0fc3e7dfabbdaae66c(production run 37976785981, green).scripts/drain-email-dlq.tsis a dry run unless--apply. Logs and the admin DLQ route mask every address.