Repository navigation
Tracking: move email to Resend behind a pluggable provider #418
Description
Goal
Move production email to Resend, and keep the email provider a small pluggable module. Self-hosters must still be able to run SMTP or add their own provider.
At the end:
- Product mail (digests, notifications, generic sends) goes out through Resend, with safe retries and no double sends.
- Sign-in mail (magic links) goes out through Resend's SMTP relay, set in Supabase Auth.
- Resend reports hard bounces and complaints back to us through a signed webhook.
- An admin Email setup page shows whether email is set up correctly and can send a test email.
.envstays the only source of the provider choice and of every secret.
How to execute this
- Read this issue in full. The Design section is the contract for every sub-issue.
- Work the sub-issues in the order listed under Sequence. Each one names its owner, entry gate, exit evidence and revert. Tick it in Sequence when it closes.
- A sub-issue is done only when every acceptance box is checked and its exit evidence is posted as a comment on that sub-issue.
- Get the maintainer's answer to each Decision before the step it blocks.
Rulings are posted as comments headed Maintainer ruling, <date>. A later change uses Maintainer ruling update, <date>. The newest ruling wins over this body.
Volume numbers were measured on production on 2026-10-06 (see Measured volume). Other numbers were read from code or from vendor docs. Resend facts marked (unverified) come from search excerpts, because the review sandbox could not reach resend.com. Check each one in the Resend dashboard before relying on it. Paths are from the repository root. server/ is short for apps/hocuspocus.server/.
Decisions
The default applies until the maintainer rules. D1 and D2 cannot change later, so #420 (Ops 0) waits for them.
D1. Which Resend sending region?
Recommended: eu-west-1. It sends from EU infrastructure close to Supabase production (eu-west-2) and most users. It does not give EU data residency: Resend stores account data, logs and message content in the US in every region (unverified). The transfer basis is the UK Extension to the EU-US Data Privacy Framework plus SCCs (see the privacy step in #424). A domain's region cannot change after it is created. Blocks: #420 (Ops 0).
D2. Which Resend teams and domains?
Recommended: three teams, one domain each.
| Team | Domain | Sends |
|---|---|---|
| product | mail.docs.plus |
digests, notifications, test emails |
| auth | auth.docs.plus |
Supabase Auth magic links |
| staging | mail.stage.docs.plus |
staging only |
Why: suppression, quota and account standing are per team (Resend suppressions). With one team, a digest complaint would block that person's magic links. A sign-in flood could pause product mail. Staging would also use prod reputation and quota. One domain per team also fits the Free plan's domain limit, whatever it is today. Check that Resend's terms allow several Free teams under one account (unverified). Fallback: one team holding mail. and auth., plus a staging team. Blocks: #420 (Ops 0).
D3. Supabase Auth email rate limit after the switch?
Recommended: 100 per hour, plus CAPTCHA before any launch push. Saving custom SMTP settings resets the limit to 30 per hour (Supabase SMTP guide). Blocks: #425 (Ops B).
D4. Cutover thresholds?
Recommended: no hard bounce and no complaint from the opted-in list over 7 days. Investigate any single event; do not auto-fail on it. At about 10 product sends a week (see Measured volume), one bounce is a 10 % rate, so rate limits carry no meaning. Switch to rates (bounce under 2 %, complaint under 0.1 %) above 1,000 sends a week. Blocks: the #424 (Ops A) exit.
D5. Record the rule in root CLAUDE.md §Settled?
Recommended: yes. Text: "Email secrets come from .env only. No UI write, no secrets in Postgres." It stops a later agent from proposing a credential form again. Blocks: the closure checks in Done means.
D6. Which Resend plan?
Recommended: Free for all three D2 teams now. Product mail is about 30 a month, at most 7 a day. Sign-in mail is at most 4 to 9 a day. Both fit Free's 3,000 a month and 100 a day (unverified) many times over. Upgrade the product team to Pro when product mail passes 50 a day, or email opt-ins pass 100 users. Pro is about $20 a month with no daily cap (unverified). Blocks: #420 (Ops 0).
Already decided. A ruling can overturn these:
- SendGrid is deleted. Its users move to the SendGrid SMTP relay with
EMAIL_PROVIDER=smtp. - A suppression check on other send paths comes later.
- The webhook and the setup page are two small modules.
- Mail that waits on an
invalidconfig has no special expiry. The general 24 h age limit covers it. - The admin page is called Email setup, not "connector". In
CONTEXT.md, "connector" already means the MCP connector at/api/mcp.
Terms
| Term | Meaning |
|---|---|
rest-api |
Compose service for HTTP routes. Hosts the webhook, the setup routes and the test email. |
hocuspocus-worker |
Compose service that consumes pgmq and BullMQ and sends mail. Runs 2 replicas (docker-compose.prod.yml:468). |
| host env file | /opt/projects/prod.docs.plus/.env and /opt/projects/stage.docs.plus/.env. Each deploy copies it over .env.production or .env.staging (.github/workflows/prod.docs.plus.yml:537, .github/workflows/stage.docs.plus.yml:230). The only place to change config. |
| redeploy | Run the production workflow by workflow_dispatch on main with force_deploy=true (prod.docs.plus.yml:46-50). Keep skip_quality_gates=false. Used for every env-only change. |
| dead-letter queue (DLQ) | email-notifications-dlq. "Email DLQ depth" is its entry count, the number the worker-dlq-depth alert reads. |
| drain | server/scripts/drain-email-dlq.ts, added in #421 (PR 2). |
| failure summary | The existing email failure panel on the admin Notifications page. |
| deploy subject | The first line of the merge commit. It must be exactly the tokens named, for example (build): back observability. Any other word on that line fails the deploy (.github/scripts/parse-build-trigger.sh:52-60). Put the description in the commit body. |
Why: current state
Keep:
- one send path through
server/src/lib/email/sender.ts; - stable pgmq job ids and the sent log (
EmailSentLog,server/prisma/schema.prisma:110-117); - the DLQ condition at
server/src/lib/email/queue.ts:180; - templates with no provider knowledge;
record_email_bounce, which already turns off email onhardandcomplaint(packages/supabase/scripts/07-5-email-notifications-pgmq.sql:226-245);- email secrets from
env_fileonly (docker-compose.prod.yml:428-431).
| # | Problem | Evidence |
|---|---|---|
| 1 | The body changes on every retry, so a Resend idempotency key would answer 409. The token expiry and the footer year read the clock. | server/src/lib/email/sender.ts:64 (no nowSeconds), server/src/lib/unsubscribeToken.ts:46, packages/email-templates/src/engine.ts:25 |
| 2 | A typed error cannot reach the worker. The sender catches everything and returns a string. | sender.ts:185-196, queue.ts:169-171 |
| 3 | Provider choice is silent: auto-detect prefers resend, then sendgrid, then smtp. A leftover key wins. | server/src/lib/email/providers/index.ts:18-32, server/src/config/env.schema.ts:108-110 |
| 4 | Two email config trees. config.email.smtp, fromEmail and fromName have no reader; providers read process.env directly. |
server/src/config/env.ts:88-101, env.schema.ts:108 |
| 5 | Jobs queued without an id get BullMQ counter ids. They restart after a Redis reset, but the sent log keeps email:<id> for 7 days, so a new mail can be skipped as already sent. |
queue.ts:239-243, server/src/lib/email/service.ts:57, :72, :86, schema.prisma:112, :117 |
| 6 | The retry window is about 15 s: 3 attempts, 5 s exponential. | queue.ts:28-31 |
| 7 | Resend send has no timeout. SMTP has no socketTimeout. |
providers/resend.ts:16; providers/smtp.ts:26-27 |
| 8 | Two noreply@docs.plus fallbacks, so self-hosters fail DMARC. SMTP_SECURE defaults to false before any code can apply the port-465 rule. |
sender.ts:79, env.ts:89; env.schema.ts:3-7, :120 |
| 9 | No-Redis mode sends twice after a failure. | queue.ts:244-249; service.ts:59, :73, :87 |
| 10 | Logs carry full recipient addresses. The DLQ admin route returns full job data. Its Reason column is always empty: the route reads failedReason, and the workers write failureReason. Push has the same bug. |
resend.ts:37, :42, :46; server/src/api/controllers/admin-audit.controller.ts:268-285; queue.ts:193; server/src/lib/push/queue.ts:185 |
| 11 | Resend tags are sent as { name: tag, value: 'true' }. |
resend.ts:29 |
| 12 | Local dev mail never sends. SMTP requires a user and a password, and the dev template leaves both empty. | smtp.ts:15; server/docker/config-templates/env.development:186-187 |
| 13 | The only "Email broken" alert matches nodemailer EAUTH. A Resend failure would never page. |
scripts/observability/grafana/provisioning/alerting/rules-app-logs.yml:131-153 |
| 14 | Resend cannot report bounces: /bounce needs the service-role key. |
server/src/api/email.ts:146-150 |
| 15 | SendGrid sends a joined to string and an unparsed from, and has no tests. |
providers/sendgrid.ts:25, :28 |
| 16 | The privacy page names no email provider and says nothing about transfers outside the UK. | apps/webapp/src/pages/privacy.tsx:54-58 |
Measured volume
Read-only aggregate queries on Supabase prod, 2026-10-06. Counts only; no personal data was read.
| Source | Measured |
|---|---|
Product mail (public.email_queue) |
45 sent rows in 30 days, about 30 distinct sends; at most 7 rows and 4 sends a day; 3 distinct recipients |
| Opted in to email | 3 of 183 active users (email_enabled unset means off, 07-5…sql:100) |
Sign-in mail (auth.audit_log_entries) |
confirmation and recovery: at most 4 an hour and 4 a day; upper bound with email logins: 9 a day |
| New-document mail | proxy: 20 new workspaces in 30 days, at most 7 a day, times the recipients in NEW_DOCUMENT_NOTIFICATION_EMAILS |
Bounces (public.email_bounces) |
0 rows |
Limits of the data: cleanup_email_queue deletes sent and skipped rows after 30 days (07-5…sql:811-825), so there is no longer history. EmailSentLog lives in the server database, not in Supabase. Staging was not measured.
Design
Layers
Each layer imports only from layers above it. Each concern has one owner.
| # | Layer | Home | Owns |
|---|---|---|---|
| 1 | Env parse | server/src/config/env.schema.ts |
Parsing only. No email decisions. |
| 2 | Email config | server/src/config/email.ts (pure) |
resolveEmailConfig, describeEmailEnv, resolveWebhookSecret. config/env.ts calls them once and exposes config.email.delivery, config.email.facts and config.email.webhookSecret. |
| 3 | Provider contract and adapters | server/src/lib/email/providers/ |
The types below, createResendProvider, createSmtpProvider, createEmailProvider, and getEmailProvider() (one instance per process). Adapters never read env, never log recipients and never set deadlines. |
| 4 | Job identity | server/src/lib/email/jobIdentity.ts (pure) |
Queue names, sentLogKey(jobId), providerIdempotencyKey(namespace, id). |
| 5 | Message and delivery | server/src/lib/email/sender.ts |
buildEmailMessage(data) renders from the job's created_at only. deliverEmail(message, { idempotencyKey, jobId }) owns the deadline, the one log line per send, masking and the typed rethrow. |
| 6 | Failure policy (pure) | server/src/lib/email/failureDecision.ts, server/src/lib/email/dlqDisposition.ts |
decideEmailFailure, judgeEmailDlqEntry. |
| 7 | Queue | server/src/lib/email/queue.ts |
queueEmail (always sets a job id), the worker, drainEmailDeadLetterQueue. |
| 8 | Consumer | server/src/lib/email/pgmqConsumer.ts |
Maps pgmq messages to queueEmail. Applies the age limit. |
| 9 | Gateway | server/src/lib/email/service.ts (EmailGatewayService) |
Applies config.email.delivery.status to the pgmq consumer and the BullMQ worker. Owns the 5-min email config invalid log. |
| 10 | HTTP modules | server/src/modules/email-webhooks/, server/src/modules/email-setup/ |
Resend webhook; admin setup view and test email. Runtime collaborators arrive as init deps. Domain files import types only and never import the lib/email index, which opens Redis through queue.ts. |
| 11 | Admin UI | apps/admin-dashboard/src/pages/email.tsx |
Reads GET /api/admin/email/setup only. |
Shared helpers: server/src/lib/maskEmail.ts (same output as SQL internal.mask_email, 07-5…sql:164-175), server/src/lib/email/bounces.ts (recordEmailBounce, used by /bounce and the webhook).
Every new pure file is proved safe by importing it with REDIS_HOST, REDIS_PORT and DATABASE_URL unset (server/CLAUDE.md §Testability Seams).
Config
resolveEmailConfig(env) in server/src/config/email.ts is pure and never throws. It imports the Env type only, because env.schema.ts validates and may exit at import. EMAIL_PROVIDER stays z.string(), so an old value cannot crash the boot. SMTP_SECURE becomes optional 'true' | 'false' with no default.
| Input | Result |
|---|---|
EMAIL_PROVIDER unset, and none of RESEND_API_KEY, SMTP_HOST, SENDGRID_API_KEY set |
off |
EMAIL_PROVIDER unset, and any of them set |
invalid: "set EMAIL_PROVIDER=resend or smtp" |
EMAIL_PROVIDER=sendgrid or any other unknown value |
invalid: "sendgrid was removed; set EMAIL_PROVIDER=smtp and use the SendGrid SMTP relay" |
A known provider, but EMAIL_FROM or that provider's required value is missing |
invalid, naming the variable |
| Otherwise | ready |
// server/src/config/email.ts
export type EmailConfig =
| { status: 'ready'; from: string; provider: EmailProviderConfig; keyNamespace: string }
| { status: 'off' }
| { status: 'invalid'; problems: string[] }
/** Presence only. No secret value enters this object. */
export interface EmailEnvFacts {
from: string | null
smtpHost: string | null
smtpPort: number | null
set: Record<'RESEND_API_KEY' | 'RESEND_WEBHOOK_SECRET' | 'SMTP_PASS' | 'EMAIL_UNSUBSCRIBE_SECRET' | 'PUBLIC_RESTAPI_URL', boolean>
}EMAIL_FROMis required and acceptsName <addr>. Both fallbacks,SMTP_FROM_NAME, and the deadconfig.email.smtp,fromEmailandfromNameare deleted.- SMTP needs only
SMTP_HOST.authexists only whenSMTP_USERis set. An explicitSMTP_SECUREwins; otherwisesecureisport === 465. keyNamespaceis the host ofPUBLIC_RESTAPI_URL, or of the app URL when that is unset. Staging and prod then never share idempotency keys. Every product mail also carries it as thenstag, so the webhook can ignore events from another environment.
Status behavior (EmailGatewayService)
ready: normal. One info lineemail config readywithprovider.off: the pgmq consumer and the BullMQ worker both settle rowsskippedwithemail not configured. One info line at boot.invalid: neither the pgmq consumer nor the BullMQ email worker starts, so mail waits. Push has its own consumer and is not affected. The worker/healthstayshealthy, becauseserver/src/hocuspocus.worker.ts:178would otherwise fail every deploy. The body addsworkers.email.configStatus: 'ready' | 'off' | 'invalid'. The worker logsemail config invalidwith the problem list at error level every 5 min, and that line pages.
Self-hoster .env
# Resend
EMAIL_PROVIDER=resend
EMAIL_FROM="Acme Docs <notify@mail.acme.com>"
RESEND_API_KEY=re_...
RESEND_WEBHOOK_SECRET=whsec_... # optional: bounces and complaints
# or SMTP
EMAIL_PROVIDER=smtp
EMAIL_FROM="Acme Docs <notify@acme.com>"
SMTP_HOST=smtp.acme.com
SMTP_PORT=465
SMTP_USER=... # omit both for Mailpit or an open relay
SMTP_PASS=...
Both also need EMAIL_UNSUBSCRIBE_SECRET and PUBLIC_RESTAPI_URL. Without that URL, the one-click List-Unsubscribe header is dropped (sender.ts:74).
Provider contract
server/src/lib/email/providers/types.ts is the only home of these types. It ships in #419 (PR 1). The old EmailProvider string union becomes EmailProviderName, and the old EmailProviderInterface becomes EmailProvider. Update the re-export at server/src/lib/email/index.ts:25.
export type EmailProviderConfig =
| { name: 'resend'; apiKey: string }
| { name: 'smtp'; host: string; port: number; secure: boolean; auth?: { user: string; pass: string } }
export type EmailProviderName = EmailProviderConfig['name']
/** What to send. No attachments, cc or bcc: no sender needs them yet. */
export interface EmailMessage {
from: string
to: string[]
subject: string
html: string
text: string
replyTo?: string
headers?: Record<string, string>
tags?: Record<string, string> // { job_id, email_type, ns }
}
/** How this one call runs. */
export interface SendOptions {
signal: AbortSignal
idempotencyKey: string
}
export interface SentEmail {
/** Opaque. Resend: the API email id. SMTP: the Message-ID header nodemailer wrote. */
messageId: string
}
export type ConnectionCheck =
| { ok: true; note?: 'send_only_key' }
| { ok: false; kind: EmailErrorKind; code: string; message: string }
export interface EmailProvider {
readonly name: EmailProviderName
/**
* Throws EmailSendError on any failure. An adapter that cannot cancel
* ignores `signal` and relies on its own transport timeouts.
*/
send(message: EmailMessage, options: SendOptions): Promise<SentEmail>
checkConnection(options: { signal: AbortSignal }): Promise<ConnectionCheck>
close(): Promise<void>
}
/** What makes the send succeed: time, nothing, or an operator. */
export type EmailErrorKind = 'transient' | 'permanent' | 'operator'
export class EmailSendError extends Error {
readonly responseCode?: number
constructor(
readonly kind: EmailErrorKind,
readonly code: string, // the provider's own code, verbatim: 'EAUTH', 'rate_limit_exceeded'
message: string,
options?: ErrorOptions & { responseCode?: number }
) {
super(message, options)
this.name = 'EmailSendError'
this.responseCode = options?.responseCode
}
}kind, code and responseCode are own properties, so pino.stdSerializers.err (server/src/lib/logger.ts:36) emits them as err_kind, err_code and err_responseCode. The existing incident-email-broken query (rules-app-logs.yml:153) keeps working unchanged.
Adding a provider (for example Postmark):
- Add
providers/postmark.tsexportingcreatePostmarkProvider(cfg): EmailProvider. Map its errors to the three kinds. - Add its branch to
EmailProviderConfig, and its checks toresolveEmailConfig. - Add one
casetocreateEmailProvider. - Add its rows to
server/ENV.mdand.env.example.
There is no registry and no loader.
Job identity and the same body on every retry
queueEmailalways sets a job id: the caller's deterministic pgmq id, elserandomUUID(). BullMQ counter ids are never used.jobIdentity.tsderives both keys from that id:sentLogKey(jobId)isemail:<jobId>, andproviderIdempotencyKey(namespace, jobId)is<namespace>/email/<jobId>. A send with no job (no-Redis mode, new-document mail) uses<namespace>/inline/<uuid>. The test email uses<namespace>/test/<uuid>.buildEmailMessagesigns the unsubscribe token withnowSecondsfrom the job'screated_at(server/src/types/email.types.ts:64), and passes the same time to the templateyear. The digest "ago" line is already stable: it readsperiod_end, set at enqueue (pgmqConsumer.ts:244).buildEmailMessagestrips control characters from the subject and cuts it to 150 characters. A user-set name forms part of some subjects (packages/email-templates/src/engine.ts:119-124).- Resend keeps a key for 24 h and answers a repeat with the first result (Resend idempotency keys).
Failures and retries
deliverEmail rethrows EmailSendError. The worker passes any error to decideEmailFailure(err, attemptsMade, attempts): EmailFailureAction, where EmailFailureAction = 'retry' | 'dead-letter'. An error that is not an EmailSendError, such as a template or Prisma error, counts as transient. On 'dead-letter' before the last attempt, the worker throws BullMQ UnrecoverableError (exported by bullmq 6.3.4).
Retry policy: 6 attempts, 30 s exponential backoff: 30 + 60 + 120 + 240 + 480 s, about 15 min.
Rate: the BullMQ limiter stays at EMAIL_RATE_LIMIT_MAX=50 per EMAIL_RATE_LIMIT_DURATION=60000 (server/src/config/env.schema.ts:133-135), about 0.8 a second. That is far below Resend's 10 requests a second per team (unverified). A retry reuses its key, so it adds requests, not billed mail.
Deadlines: deliverEmail owns EMAIL_SEND_TIMEOUT_MS = 15_000, below lockDuration: 60000 (queue.ts:211). checkConnection callers use EMAIL_CHECK_TIMEOUT_MS = 10_000. Adapters never start their own timer. SMTP takes no AbortSignal, so it ignores the signal and relies on connectionTimeout and greetingTimeout (10 s each, already set) plus a new socketTimeout: 30_000. Never race an SMTP send against the signal: SMTP has no idempotency key, so a race plus a retry sends twice.
Resend error map, keyed by the error name in the body. An unknown name falls back to the status code. Source: RESEND_ERROR_CODE_KEY in the resend-node SDK, and the Resend errors page.
| Kind | Resend names |
|---|---|
transient |
rate_limit_exceeded, concurrent_idempotent_requests, application_error, internal_server_error; network error; timeout; any 5xx |
permanent |
validation_error on 422 when the message names to, subject, html or text; security_error |
operator |
missing_api_key, invalid_api_key, restricted_api_key, invalid_from_address, invalid_access, invalid_region, daily_quota_exceeded, monthly_quota_exceeded, invalid_idempotent_request, invalid_idempotency_key, missing_required_field, invalid_parameter, invalid_attachment, not_found, method_not_allowed; any other validation_error, including 403 for an unverified domain |
SMTP error map:
- A 4xx response code is
transient, even withEAUTH. nodemailer stampsEAUTHon a transient 454 too (rules-app-logs.yml:148). - An enhanced status
5.1.xispermanent. EAUTHwith a 5xx or no code, and any other 5xx, isoperator.
Age limit. In processMessage (pgmqConsumer.ts:365), a message whose enqueued_at is more than 24 h old (EMAIL_MAX_AGE_MS) settles skipped with stale. The SQL checks read-state only at enqueue (07-5…sql:455). An invalid config or a worker outage can stop the consumer for days. Without this limit, it would then mail people about things they already read.
Adapters
- Resend uses plain
fetch, not the SDK. The SDK adds no retry or timeout logic we need, and SMTP-only installs then carry no extra package. It sendsPOST /emailswith theIdempotency-Keyheader. Tags go as[{ name, value }], each value cleaned to[A-Za-z0-9_-], at most 256 characters. - Resend
checkConnectioncallsGET /domains. A401 restricted_api_keyanswer means a valid send-only key:{ ok: true, note: 'send_only_key' }. - SMTP
checkConnectioncalls nodemailertransporter.verify(). SMTPclosecloses the pool. Resendclosedoes nothing. - Open and click tracking stay off. Click tracking would rewrite the signed unsubscribe link.
Smaller fixes
- Mask every address in logs with
maskEmail, in the fieldto. No raw address may appear under any key. - The DLQ route returns a summary, not the job data, for both queues. It keeps the response key
failedReasonand fills it fromdata.failureReason ?? failedReason, so the admin page needs no change. New DLQ fields:failureKind,failureCode. - In no-Redis mode an inline failure is final, so the service never sends a second time.
replyTopasses through.
Dead-letter drain
drainEmailDeadLetterQueue({ apply }) lives in server/src/lib/email/queue.ts, beside the DLQ it owns. server/scripts/drain-email-dlq.ts only parses --apply, calls it and prints one line per entry, like server/scripts/drain-store-dlq.ts. It is a dry run by default.
judgeEmailDlqEntry(facts, now): EmailDlqDisposition, with EmailDlqDisposition = 'replay' | 'discard' | 'unresolved', follows judgeStoreDlqEntry (server/src/lib/storeDlqDisposition.ts:9, :45).
| DLQ entry | Disposition |
|---|---|
failureKind=operator |
replay |
failureKind=transient, failed less than 24 h ago |
replay |
failureKind=transient, older |
unresolved. Resend may have accepted it before a timeout, and its key has expired. |
failureKind=permanent |
discard |
No failureKind (written before #421) |
unresolved |
Replay goes through queueEmail with the original job id and data, so it gets the full retry ladder. The sent log still blocks a double send. Do not use job.retry(): it keeps attemptsMade unless told otherwise.
Resend webhook
Module server/src/modules/email-webhooks/, mounted in server/src/index.ts at /api/email/webhooks only when config.email.webhookSecret is set. The route is POST /api/email/webhooks/resend.
| File | Role |
|---|---|
domain/types.ts |
Provider-neutral DeliveryEvent: { type: 'bounce' | 'complaint' | 'suppressed' | 'failed'; email; reason; jobId? } or { type: 'ignored' }. |
domain/resend/verifySignature.ts |
Standard Webhooks check, Web Crypto only. Decode the whsec_ secret. HMAC-SHA256 over ${svix-id}.${svix-timestamp}.${rawBody}. Constant-time compare of each v1, signature. 5 min clock window. |
domain/resend/toDeliveryEvent.ts |
Maps a Resend payload to a DeliveryEvent. |
infra/webhookDedupe.ts |
createWebhookDedupe(redis) with claim, markDone, release. |
http/controller.ts, http/router.ts |
bodyLimit (64 KB) from hono/body-limit, then the raw body, then the signature, then the dedupe claim. Never claim a key before the signature is valid. |
A second provider adds domain/<name>/ and one route. No Resend file changes.
Input rules: svix-id must match ^msg_[A-Za-z0-9]{1,64}$. Reject a timestamp that is not a number or is more than 5 min off in either direction. Every rejection gets the same 401 body. resolveWebhookSecret(raw) in server/src/config/email.ts treats a RESEND_WEBHOOK_SECRET that does not decode to at least 24 bytes as unset. The route is then not mounted, and the boot logs email webhook secret invalid at error level. Product mail is never held for it.
Environment check: a product-mail event whose ns tag is not this server's keyNamespace maps to ignored.
Dedupe, key email:webhook:<svix-id>. withRedisLease is deliberately not used: it runs work unleased on a Redis error and has no done state.
- Absent: set
pending(NX, 60 s), record the event, then setdone(24 h) and answer 200. pending: answer 409, so Svix retries later.done: answer 200 with no write.- Record failure: delete the key and answer 5xx.
- No Redis: no dedupe. A duplicate adds only a second
system_alertrow.
| Resend event | DeliveryEvent |
Action |
|---|---|---|
email.bounced with data.bounce.type === 'Permanent' |
bounce |
recordEmailBounce(email, 'hard', 'resend', subType) |
email.bounced, other types |
ignored |
none |
email.complained |
complaint |
recordEmailBounce(…, 'complaint', …) |
email.suppressed |
suppressed |
recordEmailBounce(…, 'hard', …), reason resend-suppressed |
email.failed |
failed |
No write. Log { err: new EmailSendError('operator', data.reason, …) }, so the operator alert covers it. Resend can accept a send and fail it later, for example on a daily quota. |
| anything else | ignored |
none |
Bad signature: 401. Ignored event: 200. Record failure: 5xx.
Supabase Auth email (magic link)
Magic links do not pass through our code. apps/webapp/src/components/auth/SignInForm.tsx:116 calls supabaseClient.auth.signInWithOtp, and Supabase Auth sends the mail with its own SMTP settings. Production already uses some custom SMTP, because the built-in sender refuses addresses outside the project team.
Use the custom SMTP relay, not the Send Email Hook. The hook would make sign-in depend on rest-api being up, and email links are the only sign-in besides Google. Settings: host smtp.resend.com, port 465, user resend, password a separate send-only key from the D2 auth team. Use the auth domain, with tracking off. Link tracking rewrites the confirmation link and breaks it.
Email setup page
Module server/src/modules/email-setup/; page apps/admin-dashboard/src/pages/email.tsx; nav label Email setup.
Routes, mounted in server/src/api/routers/admin.router.ts with admin.route('/email/setup', emailSetup.init(deps).router). This is a deliberate exception to "src/index.ts mounts modules": the admin namespace owns one auth boundary (admin.use('*', adminAuthMiddleware), admin.router.ts:31), and it already hosts /email/digest-grouping. Mounting elsewhere would run is_admin twice.
GET /api/admin/email/setupPOST /api/admin/email/setup/test-send
toEmailSetupView(delivery, facts, extras): EmailSetupView is a pure projection and the only code that builds the page data. Its inputs are config.email.delivery and config.email.facts, so no secret string ever enters the module.
It shows:
- Provider and config status. For
invalid, each problem in plain words. EMAIL_FROMin full.setormissingfor each secret inEmailEnvFacts.set.SMTP_HOSTandSMTP_PORT.- A live
checkConnectionon each load. - Whether the webhook secret is set, and the newest row of
get_email_bounces(admin-audit.controller.ts:195-199). The row shows a masked address and no username. Its reason is cut to 80 characters. - The email DLQ depth, and a link to the failure summary.
- A copy-ready
.envblock with a blank line for each missing value, and "Edit the host env file, then redeploy".
One action, Send test email:
- It goes only to the signed-in admin's own address and takes no body.
- It calls
deliverEmailinrest-api, so it skips only the queue. - It shows the message id, or the error kind and code.
- Limit: 1 per minute per admin, through
RateLimiterRedis(keyPrefix: 'email-test-send') andconsumeWithin(server/src/lib/consumeWithin.ts), asserver/src/modules/mcp/infra/toolBudget.tsdoes. It is refused when Redis is absent or unavailable.
The page reads only from rest-api, so the admin-dashboard container needs no email env (compatible with #403).
Why the UI never writes credentials:
- Each deploy copies the host env file over
.env.production(prod.docs.plus.yml:537), and containers do not mount the host file (server/docker/Dockerfile.bun:99), so a write is lost. - A write would not reach the second worker replica.
- It would turn a stolen admin session into a lasting credential change.
- It adds a second writer, which is the drift class behind the 535 outage (
docker-compose.prod.yml:428-431). - Secrets in Postgres would end ".env is the only source", and would need an encryption key and cache invalidation.
Observability
Logs: one line per send from deliverEmail: jobId, provider, messageId, durationMs, to (masked). A failure logs { err }, so Loki reads err_kind, err_code and err_responseCode. No new log field names, no new Prometheus metrics.
Alerts in rules-app-logs.yml:
| uid | Title | Query | Severity | Ships in |
|---|---|---|---|---|
incident-email-config-invalid |
Email config invalid (mail waiting) | any email config invalid line in 10 min |
critical |
#419 (PR 1) |
incident-email-operator |
Email send needs operator action | any err_kind = "operator" in 10 min |
critical |
#421 (PR 2) |
incident-email-transient-surge |
Email transient failures | 3 or more err_kind = "transient" in 15 min (raise when volume passes 500 a day) |
warning |
#421 (PR 2) |
incident-email-webhook-unauthorized |
Email webhook signature failures | more than 50 email webhook signature rejected lines in 10 min |
warning |
#422 (PR 3) |
incident-email-broken (existing) |
Email broken (SMTP 535/EAUTH) | unchanged | critical |
Kept until incident-email-operator fires once on staging; removed later in its own observability change |
Sequence
Work these in order. Each sub-issue has the full steps.
- Email 1/7 (PR 1): explicit provider config, the provider contract and job identity #419 Email 1/7 (PR 1): explicit provider config, the provider contract and job identity
- Email 2/7 (Ops 0): Resend teams, domains, records and staging credentials #420 Email 2/7 (Ops 0): Resend teams, domains, records and staging credentials
- Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421 Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain
- Email 4/7 (PR 3): Resend webhook for bounces and complaints #422 Email 4/7 (PR 3): Resend webhook for bounces and complaints
- Email 5/7 (PR 4): admin Email setup page with a test email #423 Email 5/7 (PR 4): admin Email setup page with a test email
- Email 6/7 (Ops A): production product mail cutover to Resend #424 Email 6/7 (Ops A): production product mail cutover to Resend
- Email 7/7 (Ops B): Supabase Auth magic links through Resend SMTP #425 Email 7/7 (Ops B): Supabase Auth magic links through Resend SMTP
Accountability
| Step | Owner | Entry gate | Exit evidence | Revert |
|---|---|---|---|---|
| #419 (PR 1) | Agent builds. Maintainer sets env. | Both host env files set EMAIL_PROVIDER and EMAIL_FROM. |
bun test green; dev mail in the catcher; staging line email config ready. |
git revert. Old code honors an explicit EMAIL_PROVIDER (providers/index.ts:18-23). |
| #420 (Ops 0) | Maintainer | D1, D2 and D6 ruled | Every D2 domain Verified in Resend; DPA and records filed; staging key and webhook secret in the staging host env file; SMTP test with the auth key. | Delete the teams, domains and keys. |
| #421 (PR 2) | Agent; maintainer runs the staging checks | #419 on prod for 48 h with no email alert; #420 closed | bun test green; staging DLQ entry with failureKind; one killed-worker retry in the Resend log; a drain dry-run listing. |
git revert. Queued jobs keep 6 attempts; harmless. |
| #422 (PR 3) | Agent; maintainer runs the staging checks | #421 merged | bun test green; one email_bounces row per test address; none from a replay. |
Disable the webhook in Resend, then git revert. |
| #423 (PR 4) | Agent | #421 merged (it uses deliverEmail) |
bun test green; a page screenshot and the route JSON, every secret only set or missing. |
git revert. |
| #424 (Ops A) | Maintainer | #419, #421, #422 and #423 on prod; D4 ruled; privacy page updated and deployed | 7-day exit signals hold. | Restore the recorded lines (see #424). |
| #425 (Ops B) | Maintainer | #424 exit signals held for 7 days; D3 ruled; CAPTCHA on for Supabase Auth | Magic links received at Gmail and Outlook, with message ids in Resend. | Restore the recorded relay values and rate limit. |
Rules for every PR
- Each PR changes production when it deploys. The provider stays SMTP until Email 6/7 (Ops A): production product mail cutover to Resend #424 (Ops A).
- Docs, alerts and env templates ship in the same PR as the code they describe.
- Five new test files, no more (
AGENTS.md§Test Policy):server/src/config/__tests__/email.test.ts,server/src/lib/email/__tests__/failureDecision.test.ts,server/src/lib/email/__tests__/dlqDisposition.test.ts,server/src/modules/email-webhooks/__tests__/unit/verifySignature.test.ts,server/src/modules/email-setup/__tests__/unit/toEmailSetupView.test.ts. Each mirrors its source file name. Exit evidence includesbun testoutput for each, observed green. - CHANGELOG entries go under
## [Unreleased]inserver/CHANGELOG.md, using### Breaking,### Migration,### Changed,### Fixed,### Removed(RELEASE_POLICY.md:203,:224). - Prose goes through the
tech-writerskill and its Simplified English standard. Use American spelling. - Each sub-issue names its exact deploy subject (see Terms).
- After each deploy, the maintainer watches the email DLQ depth and the
err_kindlogs for 1 h. Revert means a code revert plus the rolling deploy. - Exit evidence is public. Mask every email address with
maskEmail's format (j***@example.com), and check each screenshot before posting it. Never paste a key or a secret.
Done means
- Every sub-issue is closed with its exit evidence posted.
- Email 6/7 (Ops A): production product mail cutover to Resend #424 (Ops A) exit signals held for 7 days, and the day-30 SMTP decision is posted here as a comment.
- Closure checks pass:
-
git grep -n -i -E "sendgrid|SENDGRID_API_KEY|SMTP_FROM_NAME"finds only CHANGELOG history. -
git grep -n "noreply@docs.plus"finds nothing. -
git grep -n -E "email_kind|email_code|connector-test"finds nothing. -
GET /openapi.jsonlistsPOST /api/email/webhooks/resend,GET /api/admin/email/setupandPOST /api/admin/email/setup/test-send. - A fresh reader follows
docs/self-hosting/install.mdwith Resend only and gets a test email from the Email setup page. - If D5 is yes: the root
CLAUDE.md§Settled bullet is added. - A follow-up issue is filed to delete
POST /api/email/send-generic, or to limit it to admin addresses. It has no caller in the repo; staging checks are its only use. - The old SMTP provider is offboarded: account closed or stored mail deleted, and its record-of-processing row removed (Email 6/7 (Ops A): production product mail cutover to Resend #424 Day 30).
-
Not touched: scripts/observability/.env.example:12 is GlitchTip's own SMTP. Files under docs/roadmap/ are history.
Out of scope
| Item | Why not now |
|---|---|
| A plugin loader, a registry or a provider package API | Four steps to add a provider is enough. |
| Multi-provider failover | It doubles the config and the failure modes. Rollback to SMTP is one env change. |
| Writing secrets from any UI, or storing them in Postgres or Redis | See the Email setup page section. |
| The Resend SDK; the Supabase Send Email Hook | See Adapters; see Supabase Auth email. |
| Attachments, cc and bcc | No sender uses them. Add them to EmailMessage when a caller needs them. |
| Open or click tracking; storing delivered or opened events | Tracking breaks signed links. Nothing reads those events. |
| Domain status on the Email setup page | Needs a full-access key in rest-api. A send-only key cannot read domains. |
| A worker status key in Redis | Both services load the same env_file. The failure summary shows the worker's state. |
| New Prometheus metrics | Logs and existing queue metrics cover the alerts. |
SQL event_id dedupe for webhooks; a suppression UI |
Redis dedupe and the Resend dashboard cover this round. |
A suppression check on send-generic and the worker |
record_email_bounce turns notifications off, and Resend skips suppressed addresses. |
| Moving new-document mail to the queue | Operator-only mail. It does not block the move. |
Committing the auth templates to packages/supabase/templates/ |
Worth doing, but separate. |
| An OTP code or confirm page against link scanners | Only if users report "link expired". |
| Shorter Resend retention than 30 days | Needs the Enterprise plan (unverified). The privacy page states the 30 days. |
| A configurable legal operator for self-hosters | apps/webapp/src/components/pages/legal/legalMetadata.ts:2-4 hard-codes the operator. File it as its own issue. |
| A postal address in the mail footer | These mails are transactional or relationship messages (CAN-SPAM, 16 CFR 316.3). Revisit before any marketing send. |
Related
- Stop REST /health from leaking error text or taking every /api route down #405 public REST
/health. Email 5/7 (PR 4): admin Email setup page with a test email #423 (PR 4) applies the same rule to/api/email/health: public routes return status only. - Keep server secrets out of the webapp and admin-dashboard containers #403 removes
env_filefrom the admin-dashboard container. This plan needs no email env there.
Source
Local plan, team reports and the decision log: Notes/local-docs/email-resend/ (gitignored, maintainer machine). This issue is complete without them. The architecture review and the security, compliance and cost review of 2026-10-06 are summarized in comments on this issue. Security details stay private, per SECURITY.md.
Activity
HMarzban commentedon Oct 6, 2026
v3: architecture and naming review, 2026-10-06
Four reviewers each checked #418–#425 through one lens: house conventions, industry architecture, abstraction layers, and copy-editing. They returned 73 findings. Each design-changing claim was checked in code before it was accepted. The issue bodies now hold the result. This comment records what changed and why.
Contract and architecture
server/src/config/email.ts, exposed asconfig.email.deliveryandconfig.email.facts; deadconfig.email.smtp,fromEmailandfromNamedeletedenv.ts:88-101), and providers readprocess.envdirectly.send(message, { signal, idempotencyKey })returnsSentEmail; the key leftEmailMessagetransient/permanent/operatorretryable/permanent/config) mixed three axes, andconfigalso held quota and our own request bugs.EmailSendErrorkeeps the provider code verbatim and carriesresponseCodeandcauseincident-email-brokenquery readserr_codeanderr_responseCode(rules-app-logs.yml:153). Wrapping without them would page on a transient 454.signaland is never racedAbortSignal. SMTP has no idempotency key, so a race plus a retry sends twice.verifybecamecheckConnectionwith aConnectionCheckunion; adapters gainedclosequeueEmailalways sets a job id;jobIdentity.tsderives the sent-log key and the provider keyschema.prisma:112,:117).Idempotency-Key. A changing body would get 409s in between.sender.tssplit intobuildEmailMessageanddeliverEmailinvalidnow stops the BullMQ email worker too;offskips on both pathsSMTP_SECUREis now tri-state with no defaultfalse(env.schema.ts:3-7) made the port-465 rule impossible to build.doneanswers 200DeliveryEventplusdomain/resend/maskEmail,recordEmailBounce,bodyLimit,RateLimiterRedis+consumeWithin{ job_id, email_type }queue_idis a row id: a digest has many, a generic mail has none.Names
email-connector,/connectoremail-setup,/api/admin/email/setup,/setup/test-sendCONTEXT.mdalready defines "connector" as the MCP connector.email-events, "Delivery webhook"email-webhooks, "Resend webhook"emailDlqDisposition→'hold'judgeEmailDlqEntry→'unresolved', indlqDisposition.tsjudgeStoreDlqEntry,storeDlqDisposition.ts:9,:45.{ retry, deadLetter }EmailFailureAction = 'retry' | 'dead-letter'decideDigestOutcome,decideDocumentAccess.email_kind,email_codelog fieldserr_kind,err_codefrom the error itselfpino.stdSerializers.err(logger.ts:36); the existing alert readserr_code./healthkeyemail: "held"workers.email.configStatushocuspocus.worker.ts:182-200nests email underworkers.createProvidercreateEmailProvidercreateToolBudget,createRevertCooldown.kind,codefailureKind,failureCodeEmailDLQData.failureReason.lib/__tests__/emailFailureDecision.test.tslib/email/__tests__/failureDecision.test.ts, plusdlqDisposition.test.tsserver/CLAUDE.md).__tests__/__tests__/unit/modules/link-metadata/__tests__/unit/.critical/warning,incident-email-*uidspolicies.yml:18,:22;rules-app-logs.yml:91.### Breaking+### MigrationRELEASE_POLICY.md:203,:224.Process and copy
parse-build-trigger.sh:52-60).server/as the stated short form. Stale line cites were fixed.#421 (PR 2). Titles are noun phrases, and Sequence copies them verbatim.email_kind,email_codeorconnector-testleft in the repo.resolveEmailConfiggained one, because it is dense branching that can hold all mail.Deliberate exception
The Email setup routes are mounted in
admin.router.ts, notsrc/index.ts. The admin namespace owns one auth boundary (admin.router.ts:31) and already hosts/email/digest-grouping. Mounting elsewhere would runis_admintwice. A ruling can overturn this.Generated by Claude Code
HMarzban commentedon Oct 6, 2026
v4: compliance, cost and security review, 2026-10-06
Three lenses that earlier rounds had not covered: privacy and compliance, cost and capacity, and security. Each lens had one reviewer and one adversarial verifier. 32 findings; the verifiers confirmed 30 and corrected many severities. The issue bodies now hold the result.
Security findings that describe live weaknesses stay in a private local file, per
SECURITY.md. Only their design changes appear here. #424 now gates on the maintainer's private pre-cutover checklist.Decisions changed
eu-west-1); the rationale is correctedMeasured, not guessed
Read-only aggregate counts on Supabase
prod, with no personal data read:These replace "None was measured on production" in the parent. The limits of the data are listed with them.
Compliance
/privacybefore cutover. Name Resend, add a "Where your data goes" section on US storage and its safeguards, and state the mail extracts and the 30-day retention. Wording to be confirmed with counsel.EMAIL_UNSUBSCRIBE_SECRETandPUBLIC_RESTAPI_URL, so opt-out links cannot ship broken.Design changes from the security lens
nstag on every product mail; the webhook ignores events from another environment.svix-idformat, a timestamp window in both directions, and one 401 body.subject,htmlortextispermanent, notoperator./api/email/healthreturns status only, whatever the state of Stop REST /health from leaking error text or taking every /api route down #405.swaksrun and never a web tester. Email 7/7 (Ops B): Supabase Auth magic links through Resend SMTP #425 gates on CAPTCHA and checks the OTP expiry.POST /api/email/send-generic.Rejected or dropped
Generated by Claude Code
feat(email): require an explicit provider behind one small contract
HMarzban commentedon Oct 9, 2026
Maintainer ruling, 2026-10-09
Source: the maintainer, in the delivery session: "today is the friday and we have to deliver all tasks."
eu-west-1(Ireland). The domain is created and verified there.mail.docs.plus, in one team. The separateauth.andmail.stage.teams are deferred. Email 7/7 (Ops B): Supabase Auth magic links through Resend SMTP #425 uses the same domain unless a later ruling says otherwise.EMAIL_PROVIDER=resend). TheSMTP_*lines stay in the host env file for rollback. The first real send was Delivered.smtp_configuredkey onGET /api/email/healthis not renamed in a separate change. Email 5/7 (PR 4): admin Email setup page with a test email #423 replaces it with the Email setup view.docs(webapp): name Resend as the email provider on /privacy
feat(email): type send failures and add a safe dead-letter drain
feat(email): record Resend bounces and complaints through a signed we…
feat(email): add the admin Email setup page with a test send
docs(openapi): match the spec to today's admin, email, media and heal…
HMarzban commentedon Oct 12, 2026
Closure checks, 2026-10-12
sendgrid|SENDGRID_API_KEY|SMTP_FROM_NAMEoutside CHANGELOGSENDGRID_API_KEYstays in the schema so a leftover key readsinvalid, andapps/hocuspocus.server/ENV.mdsays so. The check text is older than that design. It needs a ruling: accept these hits, or reword the check.noreply@docs.plusapps/hocuspocus.server/CHANGELOG.mdhistory. Pass.email_kind|email_code|connector-testpaths/email.ts,paths/admin.ts). Prod does not expose/openapi.json, because Traefik routes only/apiand/healthonprodback.docs.plus. Pass in code.docs/self-hosting/install.mdwith Resend onlyCLAUDE.md§Settled bulletea21dc459(default applies). Pass once pushed.POST /api/email/send-genericOther facts found today:
POST https://prodback.docs.plus/api/email/webhooks/resendanswers 404, soRESEND_WEBHOOK_SECRETis not set.public.email_bouncesholds 0 rows, ever. Until the secret is set, an empty table does not prove the Email 6/7 (Ops A): production product mail cutover to Resend #424 exit signal. Read bounces and complaints in the Resend log instead.prodand domainmail.docs.plus, per the 2026-10-09 D2 ruling.docs(agents): settle that email secrets come from .env only