Repository navigation
Email 1/7 (PR 1): explicit provider config, the provider contract and job identity #419
Copy link
Copy link
Open
Open
Copy link
Labels
Description
Part of #418. Read the parent first. Its Design sections Layers, Config, Provider contract and Job identity are the contract for this work. Step 1 of 7. server/ is short for apps/hocuspocus.server/.
Goal
Make the email provider an explicit choice from .env, behind one small contract, with one stable identity per job. Production behavior stays the same: the provider is still SMTP after this PR.
Owner and gate
- Builds it: an agent or engineer.
- Before merge (maintainer): set
EMAIL_PROVIDERandEMAIL_FROMin both host env files,/opt/projects/stage.docs.plus/.envand/opt/projects/prod.docs.plus/.env.- Use the value that live
GET /api/email/healthreports in itsproviderfield. Do not guess: today a leftover key can silently win. - Setting it early is safe on the old code, which honors an explicit
EMAIL_PROVIDER(server/src/lib/email/providers/index.ts:18-23). - Post a comment here when done: "Env set in stage and prod: EMAIL_PROVIDER=".
- Use the value that live
- The PR may be built before that comment exists. It must not merge before it.
Steps
- Env parse (
server/src/config/env.schema.ts).- Keep
EMAIL_PROVIDERasz.string(). KeepSENDGRID_API_KEY, only so a leftover key resolves toinvalid. - Make
SMTP_SECUREz.enum(['true', 'false']).optional()with no default. - Delete
SMTP_FROM_NAME. Rewrite the comment at:108-110: providers now readconfig.email.delivery.
- Keep
- Email config (
server/src/config/email.ts, create). Pure. Import theEnvtype only.resolveEmailConfig(env): EmailConfig, exactly as the parent's Config table.describeEmailEnv(env): EmailEnvFacts, booleans and non-secret values only.- In
server/src/config/env.ts, call both once and exposeconfig.email.deliveryandconfig.email.facts. Delete the deadconfig.email.smtp,fromEmailandfromName(env.ts:88-101).
- Contract (
server/src/lib/email/providers/types.ts). Write the parent's Provider contract as given. Rename the old types as the parent says, and update the re-export atserver/src/lib/email/index.ts:25. - Adapters (
providers/resend.ts,providers/smtp.ts).createResendProvider(cfg)andcreateSmtpProvider(cfg)take their values fromcfgand never readprocess.env.- Resend passes
options.signaltofetchand sendsIdempotency-Key. SMTP ignores the signal and does not race it. - In this PR, every failure is thrown as
new EmailSendError('transient', code, message, { cause, responseCode }).codeis the provider's own code, verbatim (EAUTH, or the Resendname). That keeps today's retry-everything behavior and the liveincident-email-brokenquery. Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421 (PR 2) adds the real classification. - Rename
verifytocheckConnection. Addclose.
- Factory (
providers/index.ts). Replace auto-detect withcreateEmailProvider(cfg), a plainswitch, andgetEmailProvider(), which memoizes it fromconfig.email.delivery. DeletegetProviderStatus,isAnyProviderConfiguredandverifyProvider. Their callers inserver/src/lib/email/service.ts(:16,:26,:34,:92,:96,:105,:109) andserver/src/api/email.ts(/healthat:109-117,/statusat:121) readconfig.email.deliveryinstead./api/email/healthkeeps its response keys, because the admin Notifications page readsproviderandsmtp_configured(apps/admin-dashboard/src/services/api.ts:476,:493-495).providercomes from the config.smtp_configurednow meansstatus === 'ready'.
- Delete SendGrid:
providers/sendgrid.tsand every reference. - From address.
server/src/lib/email/sender.ts:79readsconfig.email.delivery.from. No fallback. - Job identity (
server/src/lib/email/jobIdentity.ts, create). Pure. ExportEMAIL_QUEUE_NAME,EMAIL_DLQ_NAME,sentLogKey(jobId)andproviderIdempotencyKey(namespace, id), per the parent.queueEmail(server/src/lib/email/queue.ts:239-243) always sets a job id: the caller's id, elserandomUUID().- In
queue.ts, rename the localidempotencyKey(:105,:110) tosentLogKeyand build it withsentLogKey(job.id). - The worker passes
providerIdempotencyKey(namespace, job.id)to the provider. Inline sends use<namespace>/inline/<uuid>.
- Same body on every retry.
sender.ts:64: passnowSeconds: Math.floor(Date.parse(data.created_at) / 1000)tosignUnsubscribeToken.packages/email-templates/src/engine.ts:25: takeyearfrom a time the caller passes in, and pass the job'screated_at. Search the templates for any other clock read and fix it the same way. - Status behavior in
EmailGatewayService(service.ts), per the parent:ready: logemail config readywithprovider.off: the pgmq consumer and the BullMQ worker both settle rowsskippedwithemail not configured.invalid: start neither the pgmq consumer (server/src/lib/email/pgmqConsumer.ts:375) nor the BullMQ email worker. Logemail config invalidwith the problems at error level, at boot and every 5 min.- In
server/src/hocuspocus.worker.ts, the health check (:173-180) treats email as healthy when the status is notready. Addworkers.email.configStatusbesidepending,failedandprovider(:182-200).
- Age limit. In
processMessage(pgmqConsumer.ts:365), a message whoseenqueued_atis more than 24 h old settlesskippedwithstaleand returnstrue. Name the constantEMAIL_MAX_AGE_MS. Apply it to notification and digest messages alike. - Deploy gate.
- In
.github/workflows/prod.docs.plus.yml, next to the loop at:543, check thatEMAIL_PROVIDERandEMAIL_FROMare set in${ENV_FILE}. WhenEMAIL_PROVIDERis set, also checkEMAIL_UNSUBSCRIBE_SECRETandPUBLIC_RESTAPI_URL: without them every unsubscribe link is rejected and the one-click header is dropped (server/src/lib/email/sender.ts:58-61,:74). The error names the host env file. Do not reuse the GlitchTip message. - Add the same check after
.github/workflows/stage.docs.plus.yml:230.
- In
- Alert. In
scripts/observability/grafana/provisioning/alerting/rules-app-logs.yml, addincident-email-config-invalid, per the parent's alert table. Leaveincident-email-brokenas it is. - Templates and dev mail.
server/docker/config-templates/env.{development,staging,production}: addEMAIL_PROVIDER, dropSMTP_FROM_NAME.- In
env.development, setSMTP_PORT=54325and leaveSMTP_USERandSMTP_PASSempty. - In
packages/supabase/config.toml, uncommentsmtp_port = 54325(:105). Dev mail then shows in the Supabase catcher at http://localhost:54324. - Change the commented SendGrid example at
config.toml:217-224to Resend: hostsmtp.resend.com, port 465, userresend. - Root
.env.example: the two blocks from the parent's Self-hoster.env, with blank values.
Test (one file)
server/src/config/__tests__/email.test.ts for resolveEmailConfig. It is dense branching that can hold all mail. Cover:
- each row of the parent's Config table;
SMTP_SECUREunset with port 465 and 587, and an explicitfalseon 465;SMTP_USERunset gives noauth.
Docs (same PR)
server/ENV.md§Email: explicit provider, requiredEMAIL_FROM, theoffandinvalidrules, optional SMTP auth, theSMTP_SECURErule, no SendGrid, noSMTP_FROM_NAME.docs/self-hosting/install.md§3: addEMAIL_PROVIDER,EMAIL_FROM,EMAIL_UNSUBSCRIBE_SECRETandPUBLIC_RESTAPI_URLto "the values you cannot skip". Or say plainly that leaving email unset meansoff.docs/self-hosting/configuration.mdanddocs/self-hosting/README.md: an "Email in 5 minutes" section. Cover the variables, DNS (SPF, DKIM, DMARCp=none), and the fact that users must opt in to email.docs/self-hosting/configuration.md, a short "Your privacy duties" paragraph:- you are the controller of your users' data;
- sign your email provider's DPA;
- mail carries short extracts of document and chat text;
- edit
apps/webapp/src/components/pages/legal/legalMetadata.tsand/privacybefore going live.
AGENTS.md:386andserver/Readme.md: "SMTP/Resend/SendGrid" becomes "SMTP or Resend".server/src/modules/openapi/domain/paths/email.ts: keep thesend-genericdescription true.server/CHANGELOG.mdunder## [Unreleased]:### Breaking:EMAIL_PROVIDERandEMAIL_FROMare required.### Migration: the before and after.envlines. SendGrid users switch to the SendGrid SMTP relay withEMAIL_PROVIDER=smtp.### Removed: SendGrid,SMTP_FROM_NAME.### Fixed: jobs queued without an id could be skipped as already sent after a Redis reset.
Do not
- Do not change retry counts or add error classification. Those belong to Email 3/7 (PR 2): typed failures, delivery and a safe DLQ drain #421 (PR 2).
- Do not import
server/src/lib/email/queue.tsor thelib/emailindex from a new pure file.queue.tsopens Redis at import (queue.ts:17).
Checks before push
bun run checkat the repo root.bun testinserver/.- Import each new pure file (
config/email.ts,jobIdentity.ts) withREDIS_HOST,REDIS_PORTandDATABASE_URLunset. - Locally: send one mail with
EMAIL_PROVIDER=smtpand see it in the Supabase catcher.
Deploy
The deploy subject is exactly (build): back observability.
Acceptance
- Dev mail reaches the catcher at http://localhost:54324.
- Set
SMTP_HOSTand leaveEMAIL_PROVIDERunset. The worker logsemail config invalid,/healthsayshealthywithworkers.email.configStatus: "invalid", documents still save, and pgmq email messages stay unread. - With
EMAIL_PROVIDER=sendgrid, the problem text names the SMTP relay. - A staging deploy with
EMAIL_PROVIDERremoved from the host env file stops at the gate. Restore it after. - A message enqueued more than 24 h ago settles
skippedwithstale. - After the prod deploy, one
send-genericmail settlessent, and the worker logs itsmessageId. -
bun testis green, including the new file. - Every doc listed above is updated in this PR.
Exit evidence
Post as a comment here:
bun run checkandbun testoutput.- A screenshot of a dev mail in the catcher.
- The staging worker line
email config readywithprovider. - The staging gate failure log from the acceptance check.
Revert
git revert the merge, then redeploy. The old code honors the explicit EMAIL_PROVIDER that the gate step set.
Reactions are currently unavailable
Activity
[-]Email 1/7 (PR 1): explicit provider config and the provider contract[/-][+]Email 1/7 (PR 1): explicit provider config, the provider contract and job identity[/+]feat(email): require an explicit provider behind one small contract
fix(email): pass Reply-To and simplify the provider contract
HMarzban commentedon Oct 9, 2026
Reopened: the board automation closed this issue before the push. The code is
907aa44f6ande628680ff. It stays open untilEMAIL_PROVIDER,EMAIL_FROM,EMAIL_UNSUBSCRIBE_SECRETandPUBLIC_RESTAPI_URLare set in both host env files, and the deploy(build): back observabilityruns. The deploy workflows refuse to deploy until those four keys are set.HMarzban commentedon Oct 9, 2026
Exit evidence, 2026-10-09
EMAIL_PROVIDERwas empty, so the gate works as designed.GET /api/email/healthreports"provider":"resend", 0 pending, 0 failed.mail.docs.plus, eu-west-1, verified) happened in the same deploy, ahead of the Tracking: move email to Resend behind a pluggable provider #418 sequence, by maintainer decision. TheSMTP_*lines stay in the host env file for rollback.Still owed before #421 can start: 48 h on prod with no email alert.