Emails
The email outbox: a request is journaled before anything is sent, a queued job hands it to the worker, which renders and sends it once per idempotency key.
The emails module sends the platform's emails. A service asks for an email; the request is written to the outbound_emails journal, then a job on the queue hands it to the API worker, which renders the message and sends it through the provider. The request never waits on the provider, survives its outages through the queue's retries, and sends once per idempotency key.
Import from @workspace/training/domain/emails and @workspace/training/application/emails. The EmailSender and Queue ports it depends on are documented on the Ports page.
Why an outbox
Sending inside the request that asks for the email would tie three things that must stay apart:
- The business write and the email. The journal row is what the worker sends from, so an email exists only once its row is committed: a rolled-back request sends nothing.
- The request and the provider. A slow or unavailable provider never delays or fails the request; the queue retries the send.
- Two requests and one email. The idempotency key is unique in the journal and goes to the provider, so a double click or a replayed request sends one email.
The worker takes its jobs from pg-boss, a queue stored in Postgres and read with SKIP LOCKED; no listener watches the journal itself. pg-boss holds its own connection, so the job is published after the row is written, and the sweep re-enqueues an email whose job was lost in between.
Domain
EmailMessage
What a service asks to send: one recipient, the language to write in, and the data its template needs, typed by kind. Never the rendered content: rendering is the adapter's job.
Prop
Type
EmailMessage is the union of the kinds, EmailKind its kind. test is the only kind for now: it proves the whole chain from the operations CLI.
Status
| Status | Meaning |
|---|---|
queued | Waits for the worker, a first time or after a failed attempt. |
sending | Claimed by a worker, attempt counted. |
sent | The provider accepted it; externalId holds its id. |
delivered | The provider reported it delivered. |
bounced | The provider reported a bounce; lastError holds its message. |
complained | The recipient reported it as spam. |
failed | The last allowed attempt failed; lastError says why. |
skipped | No provider is configured. |
suppressed | Withheld: the address is on the suppression list. |
throttled | Withheld: the recipient already got the daily cap. |
SENT_EMAIL_STATUSES (sent, delivered, bounced, complained) are the emails the provider accepted, what the daily cap counts.
Feedback and suppression
EmailFeedback is what the provider reports about a sent email: { kind: "delivered" }, { kind: "bounced"; permanent; detail } or { kind: "complained" }. Reports arrive out of order, so EMAIL_FEEDBACK_TRANSITIONS says, for each kind, the status it moves to and the statuses it may move from: a delivery report moves a sent email only, a bounce or a complaint a sent or delivered one; a late report is ignored.
A permanent bounce or a complaint (suppressionReasonOf) puts the address on the suppression list: an EmailSuppression (recipient, reason bounced or complained, outboundEmailId, createdAt), one per address, the first reason kept. Addresses are compared normalized (normalizeEmailAddress: trimmed, lower-cased), on the list as in the journal's recipient.
OutboundEmail
One requested email and its delivery.
Prop
Type
NewOutboundEmail is the request: message, idempotencyKey and senderAccountId.
Application
EmailsService
createEmailsService(deps: EmailsServiceDeps): EmailsService. The service is not user-scoped: the account an email is sent on behalf of is data (senderAccountId), not a service context.
| Method | Signature | Description |
|---|---|---|
request | (input: NewOutboundEmail) => Promise<{ emailId: string; duplicate: boolean }> | Journals the email queued and enqueues { emailId } on the send topic. A journaled idempotency key answers that email with duplicate: true and enqueues nothing. |
deliver | (emailId: string) => Promise<void> | Worker entrypoint. Claims the email, sends it, marks it sent. An address on the suppression list makes it suppressed, a recipient who already got recipientDailyCap emails over the last 24 hours throttled; without a provider it is skipped. A failed attempt goes back to queued for the queue's next retry, or failed after the last one, and rethrows so the queue retries. An email that is not queued is left alone. |
recordFeedback | (input: RecordEmailFeedbackInput) => Promise<void> | Applies a provider report to the email whose externalId it names, along EMAIL_FEEDBACK_TRANSITIONS, and suppresses the address of a permanent bounce or a complaint. A report for an unknown email is ignored, so a replay converges. |
sweep | () => Promise<{ enqueued: number }> | Re-enqueues the emails stuck past sweepStaleMinutes: a queued one whose job was lost, a sending one whose worker died mid-send, put back to queued first. The provider's idempotency key keeps a resend of an email it already accepted from going out twice. |
Prop
Type
Configuration
DEFAULT_EMAILS_CONFIG is the one configuration every app uses, so the requesting side and the worker agree on the topics: email.send for the jobs, email.sweep for the recovery cron (every ten minutes, past fifteen minutes of staleness), five retries after the first attempt with an exponential backoff from thirty seconds, and a daily cap of ten emails per recipient, every sender together.
Prop
Type
Ports
The journal binds the canonical aliases plus one ratified exception, claim, an atomic status transition (see Bespoke by design).
| Port | Surface |
|---|---|
OutboundEmailsRepository | writeOne(input) inserts queued and answers { emailId }, or null for a journaled idempotency key; claim(id) turns a queued email sending in one statement, counting the attempt, and answers it, or null; update(criteria, patch) with an OutboundEmailPatch (status, externalId, lastError, sentAt), restamping updatedAt. |
OutboundEmailsReadRepository | findMany(query, projection) with an OutboundEmailQuery (withIdempotencyKey, withExternalId, staleInStatus) and OutboundEmailProjections.entity(); aggregate(OutboundEmailAggregations.count(criteria)). |
EmailSuppressionsRepository | writeOne(input), keeping an address already suppressed. |
EmailSuppressionsReadRepository | findMany(EmailSuppressionQuery.forRecipient(address), EmailSuppressionProjections.entity()). |
OutboundEmailCriteria scopes the updates and the count: byId(id), byIdInStatuses(id, statuses), sentToSince(recipient, since) and staleInStatus(status, cutoff). OutboundEmailWhere filters on id, idempotencyKey, externalId, recipient, status, updatedBefore and sentSince.
Provider reports
Resend reports each sent email through a webhook on POST /webhooks/resend, like Clerk and Stripe through the events pipeline: the delivery is verified, stored once per delivery id, then the worker runs its handler.
createResendWebhookVerifier({ apiKey, webhookSecret })checks the signature with the Resend SDK (resend.webhooks.verify); the delivery id (svix-id) is the event's idempotency key.createResendFeedbackHandlers(emails)registersemail.delivered,email.bouncedandemail.complained, each read into anEmailFeedbackand handed torecordFeedback. A bounce of typePermanentis permanent. Every other event of Resend is storedskipped.- The route answers 503 until both
RESEND_API_KEYandRESEND_WEBHOOK_SECRETare set.
Templates and copy
The rendering lives in @workspace/emails, never in the core: the message says what to send, the adapter decides how it looks.
- One template per kind, a React Email component in
packages/emails/src/templates/<kind>/, rendered to HTML and to plain text.EmailLayoutholds the frame every email shares: language, inbox preview, card, footer. - The copy goes through a Strings Provider. Each template declares its
XxxEmailStrings(static fields as strings, parameterised ones as functions of primitives) with English defaults, throughcreateStringsProviderof@workspace/shared-react. The package never translates. - The data are props, taken from the message (
data,locale). - The consuming app resolves the copy. The adapter takes an
EmailStringsResolver, one function per kind from a locale toPartial<XxxEmailStrings>; a missing field keeps its English default. The API builds it from theemailsnamespace of@workspace/i18n-messageswith an i18next instance, the language falling back to English when it has no copy.
Adding a kind: the message type in domain/emails, the template and its strings in packages/emails, its composer in the renderer's registry (a total record over EmailKind, so a missing kind does not compile), its entry in the resolver of apps/api and its keys in the emails namespace, and a preview in apps/emails.
Wiring
apps/apibuilds the service in its composition root, with the Resend adapter whenRESEND_API_KEYis set (senderEMAIL_FROM, copy fromcreateEmailStrings). The worker subscribesdeliverto the send topic and schedulessweep.apps/clibuilds a requesting-only service on a publish-only queue, with no sender:pnpm cli send-test-email --to <address> --applyjournals a test email that the worker of the target environment sends.packages/emailsholds the templates, their Strings Providers, the renderer and the Resend adapter.apps/emailspreviews every template on demo data from the factories:pnpm dev:emailsserves the React Email preview on port 3008,pnpm --filter emails exportrenders them to static HTML.