Skip to content

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.

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):

FieldRequiredDefaultNotes
serverTokenYesThe 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.
apiBaseUrlNohttps://api.postmarkapp.comPostmark API base.
messageStreamNooutboundPostmark message stream.
from.domainYes, if from presentUninterpreted by the library — a convenience value for your own from-address composition.
from.localPartNoUninterpreted by the library.
from.displayNameNoUninterpreted by the library.

This block is bound by ConfigurationProvider.emailConfiguration: EmailConfig?:

  • Block absentemailConfiguration is null. 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 with AppError.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 validemailConfiguration yields a ready EmailConfig.

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.ConfigurationProvider
import cards.arda.common.lib.infra.email.EmailSender
import cards.arda.common.lib.infra.email.EmailSenderFactory
import cards.arda.common.lib.util.log.LogEnabled
import 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 send

There 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.

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-PartitionMailZoneName
readSecretName .global.emailSystemServerTokenArn ${PURPOSE}-API-EmailSystemServerTokenArn

At 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 send
secrets:
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"

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.EmailMessage
import 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 a Result<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 per SendRetryPolicy (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 of EmailSenderFactory.from(server). The token is then re-acquired on each attempt rather than captured once at construction.

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.

Both htmlBody and textBody are validated against an allow-list of markup.

ConstraintWhat the library accepts or refuses
PermittedBasic text formatting (p, br, strong, em, b, i, u, code, blockquote, …), block layout (div, h1h4), 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 schemeshttp, https, mailto only
Rejectedscript, iframe, style (as an element), object, form, meta, svg; inline event handlers (onclick= and friends); javascript: and data: URLs; img
Rejected inside a style valueurl(), image-set(), expression(), behavior:, -moz-binding, javascript:, vbscript:, and any backslash (CSS escapes)
Maximum length1,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:

  • img is 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 style is 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:#f3f4f6 is fine; background:url(https://…) is not. That constraint is what makes excluding img coherent: 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 h1h4 were that gap until they were added.

from, to, cc, bcc, subject, replyTo, and every custom header are validated for header hygiene:

  • No control characters — C0 (0x000x1F, CR and LF included) and C1 (0x7F0x9F), 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 / replyTo that trims to blank is treated as absent rather than sent as an empty header.

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.

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.

  • 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 keep operations wiring 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.