Bridge Training

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

StatusMeaning
queuedWaits for the worker, a first time or after a failed attempt.
sendingClaimed by a worker, attempt counted.
sentThe provider accepted it; externalId holds its id.
deliveredThe provider reported it delivered.
bouncedThe provider reported a bounce; lastError holds its message.
complainedThe recipient reported it as spam.
failedThe last allowed attempt failed; lastError says why.
skippedNo provider is configured.
suppressedWithheld: the address is on the suppression list.
throttledWithheld: 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.

MethodSignatureDescription
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).

PortSurface
OutboundEmailsRepositorywriteOne(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.
OutboundEmailsReadRepositoryfindMany(query, projection) with an OutboundEmailQuery (withIdempotencyKey, withExternalId, staleInStatus) and OutboundEmailProjections.entity(); aggregate(OutboundEmailAggregations.count(criteria)).
EmailSuppressionsRepositorywriteOne(input), keeping an address already suppressed.
EmailSuppressionsReadRepositoryfindMany(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) registers email.delivered, email.bounced and email.complained, each read into an EmailFeedback and handed to recordFeedback. A bounce of type Permanent is permanent. Every other event of Resend is stored skipped.
  • The route answers 503 until both RESEND_API_KEY and RESEND_WEBHOOK_SECRET are 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. EmailLayout holds 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, through createStringsProvider of @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 to Partial<XxxEmailStrings>; a missing field keeps its English default. The API builds it from the emails namespace of @workspace/i18n-messages with 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/api builds the service in its composition root, with the Resend adapter when RESEND_API_KEY is set (sender EMAIL_FROM, copy from createEmailStrings). The worker subscribes deliver to the send topic and schedules sweep.
  • apps/cli builds a requesting-only service on a publish-only queue, with no sender: pnpm cli send-test-email --to <address> --apply journals a test email that the worker of the target environment sends.
  • packages/emails holds the templates, their Strings Providers, the renderer and the Resend adapter.
  • apps/emails previews every template on demo data from the factories: pnpm dev:emails serves the React Email preview on port 3008, pnpm --filter emails export renders them to static HTML.