Skip to content

Tracking: move email to Resend behind a pluggable provider #418

Open
4 of 8 issues completed
Open

Description

@HMarzban

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.
  • .env stays the only source of the provider choice and of every secret.

How to execute this

  1. Read this issue in full. The Design section is the contract for every sub-issue.
  2. 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.
  3. 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.
  4. 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 invalid config 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 on hard and complaint (packages/supabase/scripts/07-5-email-notifications-pgmq.sql:226-245);
  • email secrets from env_file only (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_FROM is required and accepts Name <addr>. Both fallbacks, SMTP_FROM_NAME, and the dead config.email.smtp, fromEmail and fromName are deleted.
  • SMTP needs only SMTP_HOST. auth exists only when SMTP_USER is set. An explicit SMTP_SECURE wins; otherwise secure is port === 465.
  • keyNamespace is the host of PUBLIC_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 the ns tag, so the webhook can ignore events from another environment.

Status behavior (EmailGatewayService)

  • ready: normal. One info line email config ready with provider.
  • off: the pgmq consumer and the BullMQ worker both settle rows skipped with email 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 /health stays healthy, because server/src/hocuspocus.worker.ts:178 would otherwise fail every deploy. The body adds workers.email.configStatus: 'ready' | 'off' | 'invalid'. The worker logs email config invalid with 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):

  1. Add providers/postmark.ts exporting createPostmarkProvider(cfg): EmailProvider. Map its errors to the three kinds.
  2. Add its branch to EmailProviderConfig, and its checks to resolveEmailConfig.
  3. Add one case to createEmailProvider.
  4. Add its rows to server/ENV.md and .env.example.

There is no registry and no loader.

Job identity and the same body on every retry

  • queueEmail always sets a job id: the caller's deterministic pgmq id, else randomUUID(). BullMQ counter ids are never used.
  • jobIdentity.ts derives both keys from that id: sentLogKey(jobId) is email:<jobId>, and providerIdempotencyKey(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>.
  • buildEmailMessage signs the unsubscribe token with nowSeconds from the job's created_at (server/src/types/email.types.ts:64), and passes the same time to the template year. The digest "ago" line is already stable: it reads period_end, set at enqueue (pgmqConsumer.ts:244).
  • buildEmailMessage strips 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 with EAUTH. nodemailer stamps EAUTH on a transient 454 too (rules-app-logs.yml:148).
  • An enhanced status 5.1.x is permanent.
  • EAUTH with a 5xx or no code, and any other 5xx, is operator.

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 sends POST /emails with the Idempotency-Key header. Tags go as [{ name, value }], each value cleaned to [A-Za-z0-9_-], at most 256 characters.
  • Resend checkConnection calls GET /domains. A 401 restricted_api_key answer means a valid send-only key: { ok: true, note: 'send_only_key' }.
  • SMTP checkConnection calls nodemailer transporter.verify(). SMTP close closes the pool. Resend close does 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 field to. 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 failedReason and fills it from data.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.
  • replyTo passes 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 set done (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_alert row.
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/setup
  • POST /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_FROM in full.
  • set or missing for each secret in EmailEnvFacts.set.
  • SMTP_HOST and SMTP_PORT.
  • A live checkConnection on 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 .env block 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 deliverEmail in rest-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') and consumeWithin (server/src/lib/consumeWithin.ts), as server/src/modules/mcp/infra/toolBudget.ts does. 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.

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 includes bun test output for each, observed green.
  • CHANGELOG entries go under ## [Unreleased] in server/CHANGELOG.md, using ### Breaking, ### Migration, ### Changed, ### Fixed, ### Removed (RELEASE_POLICY.md:203, :224).
  • Prose goes through the tech-writer skill 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_kind logs 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.json lists POST /api/email/webhooks/resend, GET /api/admin/email/setup and POST /api/admin/email/setup/test-send.
    • A fresh reader follows docs/self-hosting/install.md with 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

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

  1. self-assigned this
    on Oct 6, 2026
  2. added theissue type on Oct 6, 2026
  3. HMarzban commented on Oct 6, 2026

    @HMarzban
    CollaboratorAuthor

    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

    Change Why
    New Layers table: one owner per concern, imports flow one way only Config, identity, deadlines and status were spread across several files.
    Email config resolved once in server/src/config/email.ts, exposed as config.email.delivery and config.email.facts; dead config.email.smtp, fromEmail and fromName deleted Two config trees existed (env.ts:88-101), and providers read process.env directly.
    send(message, { signal, idempotencyKey }) returns SentEmail; the key left EmailMessage A message says what to send; options say how one call runs. This follows the Resend SDK and AWS SDK v3.
    Error kinds are now transient / permanent / operator The old set (retryable / permanent / config) mixed three axes, and config also held quota and our own request bugs.
    EmailSendError keeps the provider code verbatim and carries responseCode and cause The live incident-email-broken query reads err_code and err_responseCode (rules-app-logs.yml:153). Wrapping without them would page on a transient 454.
    The caller owns deadlines; SMTP ignores signal and is never raced nodemailer takes no AbortSignal. SMTP has no idempotency key, so a race plus a retry sends twice.
    verify became checkConnection with a ConnectionCheck union; adapters gained close One taxonomy for the check and the send. The memoized SMTP pool needs a close.
    queueEmail always sets a job id; jobIdentity.ts derives the sent-log key and the provider key BullMQ counter ids restart after a Redis reset, while the sent log keeps keys for 7 days (schema.prisma:112, :117).
    Deterministic body and job identity moved from PR 2 into #419 (PR 1) PR 1 already sends Idempotency-Key. A changing body would get 409s in between.
    sender.ts split into buildEmailMessage and deliverEmail One owner for delivery. The test email reuses it instead of copying logging, masking and timeouts.
    invalid now stops the BullMQ email worker too; off skips on both paths Before, only the pgmq consumer was held. Generic sends still tried and dead-lettered.
    SMTP_SECURE is now tri-state with no default The schema default false (env.schema.ts:3-7) made the port-465 rule impossible to build.
    Webhook dedupe has three states; done answers 200 Answering 409 to a stored event makes Svix retry it and count failures.
    Webhook module split: provider-neutral DeliveryEvent plus domain/resend/ A second provider adds a folder and one route, with no Resend file changes.
    Shared helpers: maskEmail, recordEmailBounce, bodyLimit, RateLimiterRedis + consumeWithin These reuse house primitives instead of adding second shapes for the same jobs.
    Tags are { job_id, email_type } queue_id is a row id: a digest has many, a generic mail has none.

    Names

    Before After Precedent
    Email connector page, email-connector, /connector Email setup page, email-setup, /api/admin/email/setup, /setup/test-send CONTEXT.md already defines "connector" as the MCP connector.
    email-events, "Delivery webhook" email-webhooks, "Resend webhook" One name for one route.
    emailDlqDisposition → 'hold' judgeEmailDlqEntry → 'unresolved', in dlqDisposition.ts judgeStoreDlqEntry, storeDlqDisposition.ts:9, :45.
    { retry, deadLetter } EmailFailureAction = 'retry' | 'dead-letter' decideDigestOutcome, decideDocumentAccess.
    email_kind, email_code log fields err_kind, err_code from the error itself pino.stdSerializers.err (logger.ts:36); the existing alert reads err_code.
    top-level /health key email: "held" workers.email.configStatus hocuspocus.worker.ts:182-200 nests email under workers.
    createProvider createEmailProvider createToolBudget, createRevertCooldown.
    DLQ kind, code failureKind, failureCode EmailDLQData.failureReason.
    lib/__tests__/emailFailureDecision.test.ts lib/email/__tests__/failureDecision.test.ts, plus dlqDisposition.test.ts Test files mirror source files one-to-one (server/CLAUDE.md).
    Module tests under __tests__/ __tests__/unit/ modules/link-metadata/__tests__/unit/.
    Alerts: "page" / "warn", no uids critical / warning, incident-email-* uids policies.yml:18, :22; rules-app-logs.yml:91.
    CHANGELOG "upgrade note" ### Breaking + ### Migration RELEASE_POLICY.md:203, :224.

    Process and copy

    • Deploy subjects: "starts with" became exactly. The parser fails on any extra word (parse-build-trigger.sh:52-60).
    • Paths: written from the repo root, with server/ as the stated short form. Stale line cites were fixed.
    • Step names: cross-references now use #421 (PR 2). Titles are noun phrases, and Sequence copies them verbatim.
    • Acceptance boxes: three that could not be verified now name an action and an artifact.
    • New closure check: no email_kind, email_code or connector-test left in the repo.
    • Tests: five new test files in total. resolveEmailConfig gained one, because it is dense branching that can hold all mail.
    • Spelling: American throughout.

    Deliberate exception

    The Email setup routes are mounted in admin.router.ts, not src/index.ts. The admin namespace owns one auth boundary (admin.router.ts:31) and already hosts /email/digest-grouping. Mounting elsewhere would run is_admin twice. A ruling can overturn this.


    Generated by Claude Code

  4. HMarzban commented on Oct 6, 2026

    @HMarzban
    CollaboratorAuthor

    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

    Decision Change Why
    D1 region Recommendation kept (eu-west-1); the rationale is corrected Resend stores data in the US in every region (unverified). The old text promised EU data residency, a false premise for an irreversible choice.
    D2 domains Now three Resend teams: product, auth and staging, one domain each Suppression, quota and account standing are per team. One team let a digest complaint block magic links, and let staging share prod reputation.
    D4 thresholds Absolute counts: no hard bounce and no complaint in 7 days At about 10 product sends a week, one bounce is a 10 % rate.
    D6 (new) Resend plan Free per team now; product team to Pro past 50 mails a day or 100 opt-ins Measured volume fits Free many times over.

    Measured, not guessed

    Read-only aggregate counts on Supabase prod, with no personal data read:

    • product mail: about 30 sends in 30 days, at most 7 queue rows a day, 3 recipients;
    • opt-ins: 3 of 183 active users;
    • sign-in mail: at most 4 to 9 a day;
    • bounces: 0 rows.

    These replace "None was measured on production" in the parent. The limits of the data are listed with them.

    Compliance

    Design changes from the security lens

    • An ns tag on every product mail; the webhook ignores events from another environment.
    • Webhook input rules: claim the dedupe key only after a valid signature, a strict svix-id format, a timestamp window in both directions, and one 401 body.
    • A weak webhook secret counts as unset. The route is not mounted. Product mail is never held for it.
    • Subjects are stripped of control characters and capped at 150 characters. A 422 that names subject, html or text is permanent, not operator.
    • The Email setup page masks the bounce row and drops the username. Public /api/email/health returns status only, whatever the state of Stop REST /health from leaking error text or taking every /api route down #405.
    • Masking rule: no raw address under any log key. A new rule: exit evidence is public, so mask every address before posting.
    • Email 2/7 (Ops 0): Resend teams, domains, records and staging credentials #420 tests the auth key with a local swaks run 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.
    • Alert thresholds fit real volume: 3 transient failures in 15 min (was 20); 50 webhook rejections in 10 min (was 5).
    • Closure adds a follow-up issue to delete or restrict POST /api/email/send-generic.

    Rejected or dropped

    • A separate rate-limit bucket for the webhook. Measured volume is tiny, and a 429 only makes Svix retry.
    • Extra log-redaction paths. No code path logs request headers.
    • A reviewer's side note about a Supabase advisor warning on an internal table. A re-run of the advisor did not reproduce it.

    Generated by Claude Code

  5. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Maintainer ruling, 2026-10-09

    Source: the maintainer, in the delivery session: "today is the friday and we have to deliver all tasks."

    1. Gates waived. Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421, Email 4/7 (PR 3): Resend webhook for bounces and complaints #422 and Email 5/7 (PR 4): admin Email setup page with a test email #423 are built and shipped today. The 48 h soak after Email 1/7 (PR 1): explicit provider config, the provider contract and job identity #419 and the Email 2/7 (Ops 0): Resend teams, domains, records and staging credentials #420 gate no longer apply. They still land in order: Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421, then Email 4/7 (PR 3): Resend webhook for bounces and complaints #422, then Email 5/7 (PR 4): admin Email setup page with a test email #423.
    2. D1: eu-west-1 (Ireland). The domain is created and verified there.
    3. D2: one domain for now, mail.docs.plus, in one team. The separate auth. and mail.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.
    4. Order change: the prod cutover (Email 6/7 (Ops A): production product mail cutover to Resend #424) came first. It shipped in run 37906965365, with the deploy job finishing at 09:28:41Z. Prod sends through Resend (EMAIL_PROVIDER=resend). The SMTP_* lines stay in the host env file for rollback. The first real send was Delivered.
    5. Health field. The smtp_configured key on GET /api/email/health is 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.
  6. HMarzban commented on Oct 12, 2026

    @HMarzban
    CollaboratorAuthor

    Closure checks, 2026-10-12

    Check Result
    sendgrid|SENDGRID_API_KEY|SMTP_FROM_NAME outside CHANGELOG 18 hits, all deliberate. SENDGRID_API_KEY stays in the schema so a leftover key reads invalid, and apps/hocuspocus.server/ENV.md says so. The check text is older than that design. It needs a ruling: accept these hits, or reword the check.
    noreply@docs.plus Only in apps/hocuspocus.server/CHANGELOG.md history. Pass.
    email_kind|email_code|connector-test 0 hits. Pass.
    OpenAPI lists the webhook and both setup routes All three are in the spec code (paths/email.ts, paths/admin.ts). Prod does not expose /openapi.json, because Traefik routes only /api and /health on prodback.docs.plus. Pass in code.
    Fresh reader follows docs/self-hosting/install.md with Resend only Not run. Needs a human.
    D5 CLAUDE.md §Settled bullet Added in ea21dc459 (default applies). Pass once pushed.
    Follow-up for POST /api/email/send-generic Filed as #472. Pass.
    Old SMTP provider offboarded Owed on #424 Day 30, 2026-11-08.

    Other facts found today:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions