Integrating with System Email
This guide walks you through adopting the System Email capability in a backend module deployed within a component. It documents the exact wiring operations used to prove the capability end-to-end — the wiring was subsequently removed from operations (PR #234) because operations has no production consumer yet, but it remains the reference pattern the next consumer follows.
You need four things: a configuration block, a Helm secret-delivery gate, a composition-root wiring call, and a call to send() wherever you need to send.
1. Configure
Section titled “1. Configure”Add an optional top-level email {} block to your component’s HOCON configuration file (src/main/resources/application.conf — the same resource that holds the component’s other top-level config blocks):
email { serverToken = ${?EMAIL_SERVER_TOKEN} # required; delivered as a secret — never hard-code it apiBaseUrl = "https://api.postmarkapp.com" # optional; this is the default messageStream = "outbound" # optional; this is the default from { # optional convenience block domain = "system.example.ardamails.com" localPart = "no-reply" displayName = "Arda" }}Field reference (cards.arda.common.lib.infra.email.EmailConfig):
| Field | Required | Default | Notes |
|---|---|---|---|
serverToken | Yes | — | The partition’s SystemEmailServer token. Reaches the component only as a deployment-delivered secret (see §3) — never write a literal value in a committed config file. |
apiBaseUrl | No | https://api.postmarkapp.com | Postmark API base. |
messageStream | No | outbound | Postmark message stream. |
from.domain | Yes, if from present | — | Uninterpreted by the library — a convenience value for your own from-address composition. |
from.localPart | No | — | Uninterpreted by the library. |
from.displayName | No | — | Uninterpreted by the library. |
This block is bound by ConfigurationProvider.emailConfiguration: EmailConfig?:
- Block absent →
emailConfigurationisnull. This is the capability’s kill switch — your component boots normally and constructs no sender. - Block present but malformed (e.g. blank
serverToken) → component boot fails loudly withAppError.GeneralValidation, the same fail-fast posture as the platform’s other required configuration bindings. A partition where provisioning ran but secret projection broke is caught at rollout, not at first send. - Block present and valid →
emailConfigurationyields a readyEmailConfig.
2. Wire at the composition root
Section titled “2. Wire at the composition root”Bind the configuration once, at your component’s composition root (where ConfigurationProvider is already available), and hold the resulting EmailSender for in-process consumers. This is the pattern operations used in SystemEmailWiring.kt:
package cards.arda.operations.runtime
import cards.arda.common.lib.component.ConfigurationProviderimport cards.arda.common.lib.infra.email.EmailSenderimport cards.arda.common.lib.infra.email.EmailSenderFactoryimport cards.arda.common.lib.util.log.LogEnabledimport cards.arda.common.lib.util.log.LogProvider
private object SystemEmailWiringLog : LogEnabled by LogProvider(SystemEmailWiringLog::class)
fun systemEmailSender(cfgProvider: ConfigurationProvider): EmailSender? = cfgProvider.emailConfiguration?.let { emailCfg -> EmailSenderFactory.from(emailCfg.server).fold( onSuccess = { sender -> SystemEmailWiringLog.log.info( "System email capability ON (from-domain defaults: {})", emailCfg.from?.domain ?: "unset", ) sender }, onFailure = { throw it }, ) }Call it once from your main/application-module entry point and branch on the null case:
val systemEmail = systemEmailSender(cfgProvider)if (systemEmail == null) { environment.log.info("System email capability OFF (no email {} configuration)")}// hand `systemEmail` to whichever in-process modules need to sendThere is no REST surface to mount — the capability is consumed by handing the constructed EmailSender directly to in-process consumers, not by exposing an endpoint.
3. Deploy and gate secret delivery
Section titled “3. Deploy and gate secret delivery”The token reaches your pod through the platform’s δ.1 secret-delivery flow (1Password → amm.sh → CloudFormation NoEcho parameter → Secrets Manager → ESO), projected into secrets.properties as email.serverToken.
Add an email block to your Helm values.yaml, gated by an enabled flag that defaults to false:
# Component-level system-email capability.# `enabled` gates the SystemEmailServerToken projection into secrets.properties:# it must stay false for a partition until that partition's provisioning deploy# (PartitionEmailStack) has created the {fqn}-I-EmailSystemServerToken secret —# ESO fails the WHOLE application ExternalSecret on a missing remoteRef, so an# early flip breaks every app secret in the namespace.email: enabled: false # Dummy token for the local (non-ESO) fallback Secret; Postmark is not # exercised locally. localDummyToken: "local-dummy-system-email-server-token" from: localPart: "no-reply" displayName: "Arda"In your chart’s templates/secrets.yaml, add the ESO data entry and the templated config line, guarded by the same flag:
{{- if .Values.email.enabled }} email.serverToken={{ `{{ .SystemEmailServerToken }}` }} email.from.domain=system.{{ .Values.<yourZoneValue> }} email.from.localPart={{ .Values.email.from.localPart }} email.from.displayName={{ .Values.email.from.displayName }}{{- end }} data: ...{{- if .Values.email.enabled }} - secretKey: "SystemEmailServerToken" remoteRef: key: {{ printf "%s-%s-I-EmailSystemServerToken" .Values.global.infrastructure .Release.Namespace | quote }} property: token version: "AWSCURRENT"{{- end }}And the local (non-ESO) fallback branch, so make localInstall renders without touching Postmark:
{{- if .Values.email.enabled }} email.serverToken={{ .Values.email.localDummyToken }} email.from.domain=system.local.invalid email.from.localPart={{ .Values.email.from.localPart }} email.from.displayName={{ .Values.email.from.displayName }}{{- end }}Turn it on locally in values-local.yaml:
email: enabled: trueLocally, sends hit Postmark with the dummy token and classify as Rejected — that’s expected; no live sending is exercised in local mode.
4. Use
Section titled “4. Use”Call send() on the constructed EmailSender and handle the returned SendEmailOutcome — match the Sent leaf for a delivered message and the Transient / Permanent groups for the failure classification, rather than re-deriving retryable-vs-terminal at the call site:
import cards.arda.common.lib.infra.email.EmailMessageimport cards.arda.common.lib.infra.email.SendEmailOutcome
suspend fun sendSystemNotice(sender: EmailSender, to: String) { val message = EmailMessage( from = "no-reply@system.dev.ardamails.com", to = to, subject = "You've been invited", htmlBody = "<p>...</p>", ).getOrThrow() // EmailMessage() is a smart constructor returning Result
when (val outcome = sender.send(message)) { is SendEmailOutcome.Sent -> log.info("Sent (messageId={})", outcome.messageId) is SendEmailOutcome.Transient -> log.warn("Send failed after retries: {}", outcome) is SendEmailOutcome.Permanent -> log.error("Send rejected: {}", outcome) }}A few things worth knowing before you send:
EmailMessage(...)is a smart constructor that returns aResult<EmailMessage>— every header-bound field and both bodies are validated, so unacceptable content fails construction rather than reaching the wire. What it accepts is a contract your own service must document to its users — see §5.- Transient outcomes (
RateLimited,TransportFailure) have already been retried inside the sender perSendRetryPolicy(3 attempts, exponential backoff from 500 ms, capped at 10 s) before you see them — the outcome you receive is terminal for that call. - If your token can rotate at runtime instead of being fixed at boot (for example, a per-tenant token read from a database — the pattern the Shop Access — Email module uses), build your sender with
EmailSenderFactory.fromTokenProvider { ... }instead ofEmailSenderFactory.from(server). The token is then re-acquired on each attempt rather than captured once at construction.
5. Content constraints
Section titled “5. Content constraints”EmailMessage validates everything it is given and refuses what it does not accept — it never silently repairs. Construction returns Result.failure and nothing is sent.
This matters beyond your own call site: if your service lets a user compose any part of a message — a body, a subject, a recipient, a reply-to — then these constraints are that user’s constraints too, and your service is responsible for saying so. A rejection surfacing as an opaque 500 three layers up is the outcome to avoid.
Bodies
Section titled “Bodies”Both htmlBody and textBody are validated against an allow-list of markup.
| Constraint | What the library accepts or refuses |
|---|---|
| Permitted | Basic text formatting (p, br, strong, em, b, i, u, code, blockquote, …), block layout (div, h1–h4), lists (ul, ol, li, dl, dt, dd), tables (table, thead, tbody, tfoot, tr, th, td, caption, col, colgroup, with colspan / rowspan / scope), links via a[href], and the inline style attribute on any tag |
| Link schemes | http, https, mailto only |
| Rejected | script, iframe, style (as an element), object, form, meta, svg; inline event handlers (onclick= and friends); javascript: and data: URLs; img |
Rejected inside a style value | url(), image-set(), expression(), behavior:, -moz-binding, javascript:, vbscript:, and any backslash (CSS escapes) |
| Maximum length | 1,000,000 characters per body (MAX_BODY_LENGTH) |
Plain text with no markup at all is always acceptable — the allow-list constrains what markup may appear, it does not require any.
Two constraints are worth calling out because they surprise people:
imgis not permitted. A remote image is fetched by the recipient’s mail client when the message is opened, which leaks open-time and IP. No current template needs one.- Inline
styleis permitted, but its values are not free. Email has no practical alternative for layout, so the attribute is allowed — but anything that would make the recipient’s client fetch a remote resource or run script is refused.background:#f3f4f6is fine;background:url(https://…)is not. That constraint is what makes excludingimgcoherent:url()in CSS is the same remote fetch by another route, so permitting one while banning the other would be a guard in name only.
Backslashes are refused inside a style value because a CSS escape would otherwise spell any of the above past the check (\75 rl( is url(), and nothing legitimate needs an escape in an email inline style.
Neither constraint is a fixed security boundary — both are a small change in EmailBody.kt if a real template needs more. If your service’s templates need something outside this, raise it before adopting rather than working around it, so the decision is made once, centrally, rather than by each consumer escaping the check.
One rule governs changes in the other direction. This library rejects, while a consumer that composes bodies upstream may well sanitize and forward — the Backend-for-Frontend (BFF) in arda-frontend-app does exactly that for order email. Where both apply to one path, this allow-list has to stay a superset of the upstream one: anything the upstream layer happily composes but this one refuses is a message the caller cannot fix, because it never sees what was stripped. Being wider here is fine; being narrower anywhere is a live defect. div and h1–h4 were that gap until they were added.
Header-bound fields
Section titled “Header-bound fields”from, to, cc, bcc, subject, replyTo, and every custom header are validated for header hygiene:
- No control characters — C0 (
0x00–0x1F, CR and LF included) and C1 (0x7F–0x9F), except HTAB. This is the header-injection guard, and the check runs on the raw value before trimming so an edge-of-string CR/LF cannot be silently stripped. - Encodable text — an unpaired surrogate is rejected. A matched high/low pair is fine: it encodes a supplementary character (an emoji, say) and converts to UTF-8 cleanly. A lone surrogate is not a Unicode scalar value and therefore has no UTF-8 encoding at all. The check is on the string’s own well-formedness, not on incoming bytes.
- Maximum 1,024 characters after trimming (
MAX_VALUE_LENGTH). - Leading and trailing whitespace is trimmed; a
cc/bcc/replyTothat trims to blank is treated as absent rather than sent as an empty header.
Failure shape
Section titled “Failure shape”A body violation fails with AppError.ArgumentValidation; a header violation with AppError.GeneralValidation. Both name the offending field. Neither echoes the offending value — caller-supplied content must not leak into error logs — so if your service needs to show the user what was wrong, it has the field name and must supply its own guidance.
Validating earlier than send
Section titled “Validating earlier than send”If your service accepts and stores a body before sending it — a queued job, a draft, a scheduled message — validating only at send time is too late twice over: the store holds content that will be refused, and the user learns about it asynchronously instead of at submit.
Call the same validator directly at your own boundary:
import cards.arda.common.lib.infra.email.sanitizeEmailBody
// In your request validation, before persisting:sanitizeEmailBody("htmlBody", request.htmlBody) // Result<String?>One policy, enforced twice: at your boundary for a fast, specific rejection, and inside EmailMessage as the backstop no sender can bypass. sanitizeEmailBody returns the body unchanged on success, so it is safe to call on the value you are about to store.
Related
Section titled “Related”- System Email — the capability page (what’s delivered, key design facts).
- Design: Invitation Mail Server — full design, including the operations wiring’s design rationale (§8,
Composition root (systemEmailSender)) and the DQ-006 decision to keepoperationswiring minimal. - Secret Delivery Pattern — the δ.1 flow in full.
- System Email Server Runbook — the operator side: provisioning a partition, the 1Password write-token dependency, drift verification, and Postmark token rotation.
Copyright: © Arda Systems 2025-2026, All rights reserved