Integrating with System Email
This guide walks you through adopting the System Email capability in a backend module deployed within a
component. The composition-root example comes from the wiring accounts used to prove the capability end-to-end; the
deployment example comes from the first accounts integration (commit
db6d78bc) with amendment
from subsequent commits in the same pull-request.
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 = "" # 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 the pod through the platform’s δ.1 secret-delivery flow (1Password → amm.sh → CloudFormation
NoEcho parameter → Secrets Manager → ESO). The accounts chart projects it into secrets.properties as
email.serverToken only when the partition has a mail zone. There is no separate email.enabled value: the mail-zone
value is the capability gate.
First, import the partition mail-zone export during deployment in read-cloudFormation-values.cmd:
readExport .global.partitionMailZoneName ${PURPOSE}-API-PartitionMailZoneNamereadSecretName .global.emailSystemServerTokenArn ${PURPOSE}-API-EmailSystemServerTokenArnAt the referenced commit, the chart defines this helper for the gate and uses it around both the generated HOCON
properties and the ExternalSecret entry in templates/secrets.yaml:
{{ define "isEmailDefined" - }} {{- if .Values.global.partitionMailZoneName }}true{{ else }}false{{ end }}{{- end }}
# Inside spec.target.template.data["secrets.properties"]: {{- if eq (include "isEmailDefined" .) "true" }} email.from.localPart=no-reply email.from.displayName=Arda Invitation Service email.from.domain=system.{{ .Values.global.partitionMailZoneName }} email.serverToken={{ `{{ (.SystemEmailServerToken | fromJson).token }}` }} {{- end }}
# Inside spec.data: {{- if eq (include "isEmailDefined" .) "true" }}- secretKey: "SystemEmailServerToken" remoteRef: key: {{ .Values.global.emailSystemServerTokenArn | quote }} version: "AWSCURRENT" {{- end }}The Secrets Manager value is a JSON object with a token property. ESO retrieves the whole value; the ExternalSecret
template applies fromJson and writes only the property into secrets.properties. Do not add
remoteRef.property: token: the stored value is decoded by the template instead.
:::caution The mail-zone gate prevents ESO from requesting the email token before email is provisioned for a partition.
Keep the generated configuration and the remoteRef under the same gate: a missing remoteRef makes the entire
application ExternalSecret fail, affecting every secret it produces rather than email alone.
:::
For a local deployment, the commit supplies a JSON-shaped placeholder in values-local.yaml:
global: partitionMailZoneName: "local.domain.localhost" # leave blank to disable local sendsecrets: emailSystemServerToken: '{ "token": "local_email_system_server_token" }'Register that value in the fake SecretStore under the same key used by the ExternalSecret:
- key: {{ printf "%s-%s-API-EmailSystemServerTokenArn" .Values.global.infrastructure .Values.global.purpose | quote }} value: {{ .Values.secrets.emailSystemServerToken | quote }} version: "AWSCURRENT"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