Skip to content

Design: Invitation Mail Server

Audience: infrastructure + backend engineers implementing PDEV-1235; on-call operators running partition deploys. Reading time: ~20 min full; ~5 min for §§1–5.

  • Partition create/update (amm.sh → Pre-Deploy → PartitionEmailStack) additionally provisions a per-partition SystemEmailServer (a Postmark Server) whose Postmark-generated token is custodied in the partition 1Password vault and delivered to runtime as the SM secret {fqn}-I-EmailSystemServerToken — never stored in any database.
  • common-module gains a server-identity-agnostic email-sending library (lib/infra/email): EmailSender built by EmailSenderFactory from an EmailServer connection value, produced either from the component-level email {} HOCON block (system path) or from operations’ DB-based EmailConfiguration (per-tenant path). The classes are lifted and generalized from operations’ existing send code (DQ-004).
  • No new DNS records or Sender Signatures: system mail sends from system.{partition}.{base} riding the existing partition Signature (DQ-001). operations ships the secret projection and an internally exposed system sender to prove the seam end-to-end (DQ-006); accounts adoption is a follow-up project.
TermMeaning
SystemEmailServerThe per-partition Postmark Server dedicated to system-originated (administrative) mail; Postmark server Name SystemEmailServer-{fqn}.
System sending domainsystem.{partition}.{base} with base = ardamails.com (parametrized per email-integration DQ-009).
EmailServerLibrary value type describing a configured server connection (API base URL, server token, message stream) — the single input both construction paths produce.
δ.1 patternThe established secret-delivery flow: 1Password → amm.sh → CFN NoEcho parameter → Secrets Manager → ESO.
{fqn}Kebab partition FQN {Infrastructure}-{partition} (e.g. Alpha002-dev), per purposeUtils.fqn.
Pre-DeployThe amm.sh-invoked step running tools/register-partition-mail-signature.ts before cdk deploy.

ViewpointThis design’s position
ProductServes OAM::IAM::0006::0004.UC (Invite User to Tenant) indirectly: delivers the sending capability; the manual copy/paste step is retired by the follow-up accounts adoption. Personas: inviting tenant user / invitee (future beneficiaries); platform operator (partition deploys).
FunctionalNew System — Messaging capability area (system-originated communication with users). Adds no functional Services/Endpoints; consumes the existing partition-mail provisioning (email-integration Phase 4) and the Shop Access — Email module’s send mechanics (lifted, DQ-004).
Artifactsinfrastructure CDK app (Al1x partition apps), common-module jar (13.2.0, additive), operations Docker image + Helm chart, documentation site.
RuntimeAll four active partitions (dev, stage, demo, prod); operations component pods; Postmark accounts (PostmarkProd / PostmarkNonProd). Rollout dev → {stage ∥ demo} → prod.
OAMNew drift checks (server + secret existence), send-outcome telemetry, token-rotation runbook; capability off-switch = absence of the email {} block. Detail in §11.
TechnologyInfrastructure layer (CDK/TypeScript, bash, Postmark API, 1Password, SM/ESO) + Backend library layer (Kotlin, Ktor client). No new technology added.
MechanismAdded by this designConsumed by this design
API Endpoints / ServicesNone (DQ-006 rules out REST surface; the system sender is exposed only in-process).Postmark Server API POST /email; Postmark Account API (Pre-Deploy: list/create servers).
References (cross-module entity references)None.None.
Data Types (strict value semantics)EmailMessage, EmailAttachment, SendEmailOutcome, EmailServer, EmailConfig, SendRetryPolicy (in-process, common-module). Postmark wire types (PostmarkSendEmailRequest/Response, PostmarkSendAttachment) move to the library as impl-level types.Result/AppError conventions, sanitizeHeader, IdempotencyKeyMinter.
BindingsNew CFN typed export {fqn}-API-EmailSystemServerTokenArn; new ESO data entry + secrets.properties line email.serverToken; component-level HOCON block email {} bound by ConfigurationProvider.δ.1 secret delivery; arda.config.location config layering; existing -API- export framework.

Capability framing: this design implements Messaging as a cross-cutting component capability — like persistence, any Module/Component acquires it through configuration + common-module classes, not through another module’s endpoints.


Today the platform cannot send administrative email. The email-integration project provisioned per-partition mail infrastructure (zones, Postmark account tokens, encryption keys) and a per-tenant email module in operations, but every Postmark Server is tenant-owned and runtime-created; the invitation flow still ends with a human copying a URL into their own mail client. There is no server the system owns, and no library a component can call to send.

This design adds both, deliberately smaller than the per-tenant machinery: the Pre-Deploy step ensures one SystemEmailServer per partition at provisioning time (breaking, intentionally, the prior “no servers at provisioning time” invariant), custodies its Postmark-generated token in the partition 1Password vault (DQ-002), and delivers it via the established δ.1 pattern as a new SM secret in PartitionEmailStack (DQ-003). On the consumption side, common-module gains a server-identity-agnostic sending library lifted from operations’ proven send code (DQ-004, DQ-005): components bind an optional email {} block and get a working EmailSender; operations’ per-tenant path constructs the same sender type from its DB-based configuration. Because the token is generated at deployment and reaches consumers only as a secret, none of the DB-storage/encryption machinery (email-integration DQ-012) applies.

In scope: infrastructure provisioning, the library, and just enough operations wiring to prove the seam end-to-end with a deployed sender (DQ-006). Out of scope: accounts adoption (follow-up project), webhooks/bounce ingestion for the system server (DQ-007 → PDEV-1250), any frontend or invitation-lifecycle change.

#DecisionChosen Option
DQ-001Sender Signature for system.{partition}?Inherit the partition Signature — no new DNS records or signatures
DQ-002System server token custodyPartition-vault 1Password item SystemEmailServer (corporate precedent)
DQ-003CDK placementExtend PartitionEmailStack (additive)
DQ-004Library class sourcingLift & generalize from operations; operations adopts
DQ-005Sender acquisitionComponent email {} block; library is server-identity-agnostic; factory also buildable from DB config
DQ-006operations scopeSecret projection + internal system sender (no REST)
DQ-007Webhook posture v1None — fire-and-forget; console follow-up PDEV-1250

Full rationale and rejected alternatives in decision-log.md.

  1. Established email-integration architecture is binding: thin-wrapper Postmark constructs, amm.sh idempotency/security rules (DQ-R1-022), δ.1 secret delivery, CFN stack-name immutability ({fqn}-Email stays).
  2. System sending domain is system.{partition}.{base}, base parametrized (ardamails.com, email-integration DQ-009).
  3. The system server token is never stored in a database; it reaches consumers only as a deployment-delivered secret.
  4. cdk diff against deployed partitions must be clean-or-additive; both existing SM secrets and the zone are RemovalPolicy.RETAIN — no logical-id or secretName changes to existing resources.
  5. Rollout order dev → {stage ∥ demo} → prod; production partitions on PostmarkProd, non-production on PostmarkNonProd.
  6. CI drift checks may use only Arda-SystemsOAM shared account tokens — they can assert server/secret existence, never token values.
  7. common-module changes are additive-only (minor 13.2.0); no new runtime dependencies (Ktor client stack already present).
  8. Secret-handling in bash replicates the existing discipline: set +x, 0600 temp files, ::add-mask::, NoEcho parameters, --force deploys.

6. Quality Attributes / Non-Functional Requirements

Section titled “6. Quality Attributes / Non-Functional Requirements”
AttributeTargetSatisfied by
Idempotent provisioningRe-running amm.sh for a partition converges with no duplicate Postmark servers or 1P itemsPre-Deploy list-by-name-then-create + custody healing (§9.1)
Secret hygieneToken never in logs, templates, change sets, stack events, or DBδ.1 NoEcho flow + masking discipline (constraint 8); no persistence path exists
Send robustnessTransient Postmark failures absorbed without caller involvementSendRetryPolicy backoff in PostmarkEmailSender (§9.4)
AdoptabilityA new component acquires the capability with config + one factory callemail {} block binding + EmailSenderFactory (DQ-005, §9.3)
DegradabilityComponents without the email {} block boot and run normallyOptional-block binding, null capability (§9.5)

C4 zoom level: Component, spanning the three repositories and the external services. Arrow convention: solid arrows are dependencies and form a DAG at every aggregation level — collapsed to packages the dependency edges are infrastructure → External, operations → External, operations → common-module, and common-module → External; dotted arrows are counter-dependency information flow (here: ESO asynchronously materializing secrets.properties into the pod — the runtime consumes the file but never depends on ESO). Green = new, yellow = modified, gray = external/unchanged.

PlantUML diagram

  • Role in the diagram: infrastructure — provisioning; the canonical per-partition mail configuration record in src/main/cdk/platforms.ts.
  • Responsibility: single source of truth for each partition’s Postmark account and 1Password references; presence of mail: is the mail-active flag.
  • Public surface: adds systemServerTokenOpReference: OpReference per partition (op://Arda-{Env}OAM/SystemEmailServer/credential) and accessor systemEmailServerTokenOpReference(partition) beside the existing postmarkCredentialOpReference / encryptionKeyOpReference.
  • Design decisions referenced: DQ-002.
  • Role in the diagram: infrastructure — provisioning; Pre-Deploy logic module tools/lib/partition-system-server.ts, invoked from the existing entry tools/register-partition-mail-signature.ts (new --system-token-out <file> flag).
  • Responsibility: idempotently ensure the partition’s SystemEmailServer exists in the partition’s Postmark account and that its token is custodied in the partition vault; emit the token to a 0600 temp file for amm.sh.
  • Members of note: ensureSystemEmailServer(account, fqn) — list servers by Name SystemEmailServer-{fqn}; absent → POST /servers (capture ApiTokens[0], write 1P item SystemEmailServer, field credential); present → op read the 1P item. Custody-healing and mismatch behaviors in §9.1/§9.7. Updates the “no servers at provisioning time” header comment + test in tools/lib/partition-mail-signature.ts (deliberate invariant change).
  • Design decisions referenced: DQ-002.
  • Role in the diagram: infrastructure — provisioning; existing stack {fqn}-Email in src/main/cdk/stacks/purpose/partition-email.ts.
  • Responsibility: additionally owns the system-server token secret and its export. No DNS changes (DQ-001).
  • Members of note: new NoEcho CfnParameter EmailSystemServerToken; new sm.Secret {fqn}-I-EmailSystemServerToken (secretObjectValue: { token: … }, RemovalPolicy.RETAIN — same shape as the account-token secret so ESO uses property: token); new export key emailSystemServerTokenArnAPI{fqn}-API-EmailSystemServerTokenArn; Built gains systemServerTokenSecret. As-built note: the Secret’s CDK construct id is EmailSystemServerTokenSecret (the CfnParameter owns the id EmailSystemServerToken in the same scope); physical names are exactly as specified here.
  • Design decisions referenced: DQ-003.
  • Role in the diagram: infrastructure — provisioning; operator surface (DQ-R1-022).
  • Responsibility: passes --system-token-out to Pre-Deploy, reads the third temp file under set +x, masks it, appends --parameters "{fqn}-Email:EmailSystemServerToken=…" to the existing --force deploy.
  • Design decisions referenced: DQ-002, DQ-003.
  • Role in the diagram: infrastructure — provisioning; scheduled drift workflow driver tools/runtime-platform-drift.ts.
  • Responsibility: per active partition, additionally asserts (a) a Postmark server named SystemEmailServer-{fqn} exists on the partition’s account, and (b) the SM secret {fqn}-I-EmailSystemServerToken exists. Token values are out of reach by design (constraint 6).
  • Design decisions referenced: DQ-007 (drift is the v1 observability floor).
  • Role in the diagram: common-module — lib/infra/email; the public sending interface with its message/outcome types.
  • Responsibility: send one EmailMessage through a configured server and classify the result.
  • Public surface:
    interface EmailSender {
    suspend fun send(message: EmailMessage): SendEmailOutcome
    }
    data class EmailMessage(from, to, cc, bcc, subject, htmlBody, textBody, replyTo, attachments, headers)
    sealed interface SendEmailOutcome { Sent(messageId); Rejected(errorCode, reason); RateLimited(retryAfter); TransportFailure(error: AppError) }
    EmailMessage construction sanitizes header-bound fields via sanitizeHeader (smart constructor returning Result<EmailMessage>); fromHeader(displayName, address) is lifted alongside. SendEmailOutcome is lifted as-is from operations (DQ-004).
  • Design decisions referenced: DQ-004, DQ-005.
  • Role in the diagram: common-module — lib/infra/email; the server-connection value type — the single input every construction path produces.
  • Responsibility: carry what is needed to talk to one configured server: apiBaseUrl (default https://api.postmarkapp.com), serverToken (sensitive; excluded from toString), messageStream (default "outbound"). No identity, domain, or account knowledge (DQ-005 A′).
  • Public surface: EmailServer.of(apiBaseUrl, serverToken, messageStream): Result<EmailServer> (smart constructor).
  • Design decisions referenced: DQ-005.
  • Role in the diagram: common-module — lib/infra/email; builds senders from EmailServer values.
  • Public surface: EmailSenderFactory.from(server: EmailServer, retryPolicy: SendRetryPolicy = SendRetryPolicy.DEFAULT, engine: HttpClientEngine? = null): Result<EmailSender>. The nullable engine is the test seam (MockEngine), mirroring httpClient(...).
  • Members of note: SendRetryPolicy(maxAttempts = 3, initialBackoff = 500ms, factor = 2.0, cap = 10s) — the lifted retry parameters.
  • Design decisions referenced: DQ-004, DQ-005.

PostmarkEmailSender + PostmarkServerProxy (new)

Section titled “PostmarkEmailSender + PostmarkServerProxy (new)”
  • Role in the diagram: common-module — lib/infra/email/postmark; the Postmark implementation (impl-named, allowed under lib/infra/).
  • Responsibility: PostmarkServerProxy (lifted from operations) performs the stateless POST /email with X-Postmark-Server-Token and maps responses to SendEmailOutcome; PostmarkEmailSender wraps it with the retry loop (lifted attemptSend core, monadic single-exit), header assembly, and optional caller hooks (onSendSuccess / onTransientFailure / onPermanentFailure) replacing the tenant-lifecycle coupling.
  • Members of note: wire types PostmarkSendEmailRequest, PostmarkSendAttachment, PostmarkSendEmailResponse move here unchanged; built on httpClient(logger, engine) — CallId, MDC propagation, perf monitoring, JsonConfig.standardJson come free.
  • Design decisions referenced: DQ-004.
  • Role in the diagram: common-module — component boot; ConfigurationProvider gains the optional component-level email {} block, mirroring globalDsConfiguration.
  • Responsibility: EmailConfig.fromConfig(cfg): Result<EmailConfig> binds { serverToken, apiBaseUrl?, messageStream?, from { domain, localPart, displayName }? }; exposes emailConfiguration: EmailConfig? (lazy; absent block → null + WARN; malformed block → boot failure via AppError.GeneralValidation, same posture as the dataSource binding). EmailConfig.server: EmailServer yields the connection value; the optional from sub-block is convenience data for consumers, not interpreted by the library.
  • Design decisions referenced: DQ-005.
  • Role in the diagram: operations — runtime; the ExternalSecret that templates secrets.properties.
  • Responsibility: adds a data entry SystemEmailServerToken (remoteRef {fqn}-I-EmailSystemServerToken, property: token) and the template line email.serverToken={{ .SystemEmailServerToken }}; local-mode fallback adds the same line with a dummy value. Non-secret from-address values ride values.yaml → the email.from block (domain derived as system. + the existing mail-zone value).
  • Design decisions referenced: DQ-005, DQ-006.

Composition root (systemEmailSender) (modified)

Section titled “Composition root (systemEmailSender) (modified)”
  • Role in the diagram: operations — runtime; Main.kt.
  • Responsibility: when cfgProvider.emailConfiguration is non-null, build systemEmailSender: EmailSender via EmailSenderFactory.from(config.server) and expose it to backend modules in-process (no REST). Absent config → capability off, no sender constructed.
  • Design decisions referenced: DQ-006.
  • Role in the diagram: operations — runtime; existing shopaccess/email module.
  • Responsibility: re-imports the lifted types (SendEmailOutcome, wire types, send proxy, fromHeader); its tenant send service (service/EmailSender.kt) delegates its retry core to PostmarkEmailSender, keeping tenant-specific behavior (per-attempt token fetch from ConfigurationService, canSend recheck, in-flight tracking, cancellation) via the factory’s hooks and token supplier. Public behavior unchanged; existing tests are the regression net.
  • Design decisions referenced: DQ-004.

Behaviors are grouped below; each cross-links to the owning Key Element and maps to entries in Behavior Verification.

Owner: partition-system-server, PartitionEmailStack, amm.sh partition mail step.

  • Ensure-server idempotency. Pre-Deploy looks up the server with Postmark’s name-filtered list (GET /servers?…&name=SystemEmailServer-{fqn}) and matches exactly — runtime-created tenant servers on the same account can never push the SystemEmailServer past the API’s single page and mis-declare absence (as-implemented refinement from PR #499 review). Absent → create (POST /servers), capture ApiTokens[0]. Present → no Postmark mutation. Re-runs converge.
  • Token custody (DQ-002). On create, the token is written to the partition vault item SystemEmailServer (field credential) before anything else consumes it. On subsequent runs the token is read from the vault, not from Postmark.
  • Custody healing. Server exists but vault item missing (crash between create and write): re-read the token via GET /servers/{id} and create the item — converges. Vault item exists but server missing: hard error requiring operator intervention (§9.7). The vault probe that drives these branches absorbs one transient resolution failure (bounded retry) and WARN-logs the reason whenever absence is inferred from an error rather than a clean not-found (as-implemented refinement from PR #499 review).
  • δ.1 delivery. amm.sh reads the token file under set +x, masks it, and passes EmailSystemServerToken as a NoEcho parameter on the existing --force deploy; PartitionEmailStack upserts {fqn}-I-EmailSystemServerToken. Rotation = update vault item (or delete/recreate server) + re-run deploy.
  • No DNS mutation (DQ-001). The system sending domain rides the partition Signature; Pre-Deploy’s existing Sender-Signature step and the placeholder-context machinery are untouched.

Owner: Helm chart (secrets.yaml), EmailConfig binding.

  • ESO projection. The ExternalSecret adds SystemEmailServerToken sourced from {fqn}-I-EmailSystemServerToken (property: token), templated into secrets.properties as email.serverToken=… — the same mechanism that injects the per-database credentials today.
  • HOCON layering. secrets.properties reaches the component via the existing arda.config.location layering (CompositeConfigFactory.load()); the token becomes an ordinary config value without any component file-reading code.
  • Lazy binding. ConfigurationProvider.emailConfiguration binds the optional top-level email {} block on first access: absent → null + WARN; present-but-malformed → AppError.GeneralValidation boot failure (mirror of globalDsConfiguration).

Owner: EmailSenderFactory, EmailServer.

  • Config-bound path (system). emailConfiguration.serverEmailSenderFactory.from(server)PostmarkEmailSender. One factory call at boot (DQ-005/DQ-006).
  • DB-bound path (per-tenant). operations builds EmailServer.of(apiBase, decryptedTenantToken) from its EmailConfiguration rows and calls the same factory — two producers, one input type, one sender implementation (DQ-005 A′).
  • Validation at the edge. EmailServer.of and EmailConfig.fromConfig are smart constructors returning Result; no partially-configured sender can exist.

Owner: PostmarkEmailSender + PostmarkServerProxy, EmailSender.

  • Header hygiene. EmailMessage construction passes subject, display names, reply-to, and custom headers through sanitizeHeader; invalid input fails construction (Result), never reaches the wire.
  • Outcome classification. POST /email responses map to the sealed outcome: 200 → Sent(MessageID); 4xx except 429 → Rejected(errorCode, reason) (permanent, no retry); 429 → RateLimited(retryAfter); 5xx/network → TransportFailure(AppError.ExternalService).
  • Retry with backoff. Transient outcomes (RateLimited, TransportFailure) retry up to SendRetryPolicy.maxAttempts with exponential backoff (500 ms × 2, cap 10 s), honoring Retry-After when present; Rejected never retries. Lifted attemptSend recursion, monadic single-exit.
  • Idempotent sends. Callers mint an IdempotencyKey via the existing IdempotencyKeyMinter and pass it as the Idempotency-Key custom header on EmailMessage.headers; the library treats it as an opaque header (recording outcomes stays a caller concern, as in operations today).
  • MessageID capture. Sent.messageId is returned to the caller; per DQ-007 it is the hook for future status interrogation (GET /messages/outbound/{messageid}/details, see PDEV-1250).

Owner: EmailConfig binding, Composition root (systemEmailSender).

  • Capability off by absence. No email {} block → emailConfiguration == null → the composition root constructs no sender; nothing else changes. This is also the operational kill switch (§11).
  • Fail-fast on misconfiguration. Malformed email {} block or empty serverToken fails pod boot loudly — a partition where provisioning ran but ESO projection broke is detected at rollout, not at first send.
  • Local mode. values-local.yaml supplies a dummy token line; local sends hit Postmark with an invalid token and classify as Rejected — acceptable for local (no live sending expected), matching the existing local dummy-secret posture.

Provisioning: amm.sh runs Pre-Deploy (Sender-Signature step unchanged; new SystemEmailServer step with first-run and steady-state branches), then deploys the stack with the third NoEcho parameter, which upserts the SM secret.

PlantUML diagram

Runtime: boot binds the email {} block — reading the ESO-projected secrets.properties through the arda.config.location layering — and constructs the sender once; a consumer sends and receives a classified outcome, with transient failures retried inside the sender. Participants follow the client-left-of-server convention: the consumer module (ultimate client) sits leftmost; the config-file read is drawn as the binder reading the projected file, not the file “delivering” itself.

PlantUML diagram

Owner: partition-system-server, runtime-platform-drift, PostmarkEmailSender + PostmarkServerProxy.

  • Custody divergence. Vault item present but server absent in Postmark (server deleted out-of-band): Pre-Deploy fails hard with a message naming both sides — recreating a server invalidates the old token, so silent recreation would strand the vault copy; the operator deletes the stale item and re-runs.
  • Postmark unavailable at deploy. Pre-Deploy failure aborts the partition run before any CFN change (existing behavior); re-run converges. Postmark unavailable at runtime: sends classify as TransportFailure after retry exhaustion; callers decide policy.
  • Token rotation propagation. Rotation = new server token in Postmark (regenerate) → update vault item → re-run partition deploy (--force propagates the NoEcho parameter) → ESO refresh interval updates secrets.properties → pod restart picks it up. Until restart, pods send with the old token; rotation runbook (§11) sequences this.
  • Coexistence with per-tenant email. The SystemEmailServer is a separate Postmark Server from every tenant server; suppression lists, message streams, and Postmark-side stats are naturally separate. Both send from leaves of {partition}.ardamails.com under the same partition Signature (DQ-001) — a shared-reputation trade recorded in the decision log’s reopen trigger.
  • Drift floor. runtime-platform-drift asserts server + secret existence per partition (constraint 6 limits it to existence, not values); deeper monitoring is PDEV-1250’s scope (DQ-007). The server-existence check uses the same name-filtered lookup as provisioning, and the shared server probe fails loudly with an explicit pagination-required reason when Postmark’s TotalCount exceeds the returned page — a truncated list can never produce a false “server missing” verdict (as-implemented refinements from PR #499 review).

Subsections mirror Behavioral Design; IDs follow BV-<group>-<seq>. Fixture descriptions in Testing Elements.

Test IDBehavior TestedRequired SetupTest Fixtures
BV-1-01Ensure-server idempotency — absent → create; present → no mutationJest; fake Postmark client returning empty / populated server listsFakePostmarkAccountApi
BV-1-02Token custody — create path writes 1P before emitting token fileFake 1P CLI recorderFakeOpCli
BV-1-03Custody healing — server present + item missing → GET /servers/{id} re-read + item creationFakePostmarkAccountApi + FakeOpCli in divergent statesFakePostmarkAccountApi, FakeOpCli
BV-1-04Custody divergence — item present + server absent → hard error, no creationSame fixtures, inverse divergenceFakePostmarkAccountApi, FakeOpCli
BV-1-05δ.1 delivery — stack synthesizes NoEcho param EmailSystemServerToken, secret {fqn}-I-EmailSystemServerToken (RETAIN, {token: …} shape), export {fqn}-API-EmailSystemServerTokenArn; existing resources byte-identicalCDK Template.fromStack on buildFixture()PartitionEmailTemplateFixture
BV-1-06Invariant update — partition-mail-signature no longer asserts zero server creation; system-server step is the only server-creating pathExisting Jest suite amended intentionallyFakePostmarkAccountApi
Test IDBehavior TestedRequired SetupTest Fixtures
BV-1-07End-to-end provisioning on dev — first run creates server + vault item + secret; immediate re-run is a no-opOperator-driven dev deploy (rollout gate, not CI)live PostmarkNonProd + Arda-DevOAM
Test IDBehavior TestedRequired SetupTest Fixtures
BV-2-01Lazy binding — absent email {} → null + WARN; malformed → AppError.GeneralValidation; valid → EmailConfigKotest; HOCON stringsHoconFixtures
BV-2-02EmailConfig.fromConfig field semantics — defaults (apiBaseUrl, messageStream), required serverToken, optional from blockKotest; HOCON stringsHoconFixtures
BV-2-03ESO projection — chart renders the SystemEmailServerToken data entry + email.serverToken line (both ESO and local-fallback branches)helm template golden assertionsHelmRenderFixture
Test IDBehavior TestedRequired SetupTest Fixtures
BV-3-01Validation at the edgeEmailServer.of rejects blank token / invalid URL; token excluded from toStringKotest
BV-3-02Config-bound path — factory yields a sender wired to the configured apiBaseUrl/tokenMockEngine capturing requestMockPostmarkServerEngine
BV-3-03DB-bound path — tenant service builds EmailServer from EmailConfiguration and reaches the same factory (operations)Existing email module fixtures post-liftoperations EmailSendServiceTestFixture
Test IDBehavior TestedRequired SetupTest Fixtures
BV-4-01Outcome classification — 200/4xx/429/5xx → Sent/Rejected/RateLimited/TransportFailureMockEngine per statusMockPostmarkServerEngine
BV-4-02Retry with backoff — transient retries ≤ maxAttempts with growing delays; Rejected short-circuitsMockEngine scripted sequences; virtual timeMockPostmarkServerEngine
BV-4-03Header hygiene — control chars / oversized headers fail EmailMessage construction; fromHeader RFC 5322 quoting pinnedKotest property + example tests
BV-4-04Wire-format pin — serialized PostmarkSendEmailRequest (nulls omitted, Cc/Bcc, stream) matches the pre-lift golden JSONKotest; lifted from PostmarkSendCcBccTestMockPostmarkServerEngine
BV-4-05Per-tenant regression — operations email module suite passes unchanged after the lift (DQ-004’s net)Existing operations unit + integration suitesoperations existing fixtures
Test IDBehavior TestedRequired SetupTest Fixtures
BV-4-06End-to-end send — deployed dev operations sends a real message through the dev SystemEmailServer; MessageID returnedDev smoke (Kotest tag, excluded from default build), extends the DevSmoke* patterndev partition + PostmarkNonProd
Test IDBehavior TestedRequired SetupTest Fixtures
BV-5-01Capability off by absence — component boots with no email {} block; no sender constructedoperations ComponentStartupTest variant without the email blockoperations startup fixtures
BV-5-02Fail-fast on misconfiguration — present block with empty token fails boot with GeneralValidationComponentStartupTest negative variantoperations startup fixtures
BV-5-03Startup with valid block — boot constructs systemEmailSender; config endpoint sanitizes the tokenComponentStartupTest + ConfigurationEndpointTest with email {} in the test config mapoperations startup fixtures
Test IDBehavior TestedRequired SetupTest Fixtures
BV-7-01Drift floor — driver flags missing server / missing secret per partitionJest; fake Postmark + fake SM clientsFakePostmarkAccountApi
BV-7-02Postmark unavailable at runtime — network failure → retries → TransportFailure(ExternalService)MockEngine throwing IOExceptionsMockPostmarkServerEngine

In-memory Postmark Account API double for infrastructure Jest tests (list/create/get servers, mutation recording, scriptable divergence states). Extends the existing fake used by partition-mail-signature.test.ts.

Records/plays 1Password CLI interactions (item get / item create) for Pre-Deploy tests without touching a real vault; supports present/absent item states for custody-healing scenarios.

The existing buildFixture() in partition-email.test.ts (cdk App + injected context + Template.fromStack), extended with the new NoEcho parameter; also anchors the existing-resources-unchanged assertion.

Inline HOCON strings (valid / absent / malformed email {} variants) parsed via Typesafe ConfigFactory.parseString in Kotest specs.

Ktor MockEngine (new testImplementation dependency ktor-client-mock, same Ktor version) scripted per test: fixed statuses, sequences (429 → 200), request capture for header/body pins. Injected via the factory’s engine parameter.

helm template invocations of the operations chart with ESO-on and local values, asserting rendered ExternalSecret data entries and secrets.properties lines.


  • Library sender logs (via LogEnabled): outcome-classified send completion (messageId on success, errorCode on rejection), retry attempts with backoff delays. No PII beyond recipient addresses already present in caller context; token never logged.
  • Counter hooks: the factory’s onSendSuccess / onTransientFailure / onPermanentFailure callbacks are the metric attachment points (operations wires them to its existing signal pattern).
  • runtime-platform-drift gains two assertions per partition (server exists, secret exists); failures raise the workflow’s existing auto-issue.
ControlMechanismEffect when set
Capability offRemove/omit the email {} block (Helm values / config layering)Component boots normally; no system sender constructed; callers see no capability
Token rotationRegenerate server token in Postmark → update vault item SystemEmailServer → re-run amm.sh partition deploy → restart podsNew token propagated via SM/ESO; old token invalid from regeneration moment (see the System Email Server runbook)
Send policySendRetryPolicy via factory parameter (code-level; config exposure deferred)Alters retry ceiling/backoff
  • Postmark: system mail rides existing account plans (Platform); invitation-scale volume (tens/day/partition) is negligible against plan limits. One additional Server object per partition (no per-server cost).
  • AWS: +1 SM secret per partition (~$0.40/mo each), +1 ESO key on an existing ExternalSecret — negligible.
  • No new compute, storage, or traffic paths.

Step-by-step operator procedures for all of the below — provision, verify, rotate, troubleshoot — live in the System Email Server runbook.

  • Partition deploy fails in Pre-Deploy with custody-divergence error. Vault item SystemEmailServer exists but no matching Postmark server. Verify in Postmark console; if the server was deliberately deleted, delete the stale vault item and re-run — a fresh server + token will be provisioned.
  • Token rotation. Regenerate the token in Postmark console (or delete/recreate server — destructive, avoid), update Arda-{Env}OAM/SystemEmailServer/credential, re-run amm.sh for the partition, restart operations pods after ESO refresh. Expect send failures (TransportFailure/Rejected 401) between regeneration and pod restart; sequence tightly.
  • Sends failing with Rejected 401/InvalidToken. SM secret / vault / Postmark disagree — run drift, compare vault item modified-time vs last partition deploy; re-run deploy to reconverge.
  • Drift auto-issue: system server or secret missing. Deleted out-of-band; re-run the partition deploy (idempotent) and investigate the deletion.

Subsections mirror Key Elements; one row per (file, construct) pair. Paths verified against the worktrees at design time.

FileConstructContent
infrastructure/src/main/cdk/platforms.tsPartitionMail (type)Add systemServerTokenOpReference field; populate for all four partitions (op://Arda-{Env}OAM/SystemEmailServer/credential)
infrastructure/src/main/cdk/platforms.tssystemEmailServerTokenOpReference() (function)Partition-aware accessor beside postmarkCredentialOpReference
FileConstructContent
infrastructure/tools/lib/partition-system-server.tsensureSystemEmailServer() (module, new)List-by-name → create/read flow, 1P custody + healing, token temp-file emission
infrastructure/tools/register-partition-mail-signature.tsentry script--system-token-out flag; invoke the new module after the Signature step
infrastructure/tools/lib/partition-mail-signature.tsheader comment + testIntentional removal of the “no servers at provisioning time” invariant
infrastructure/tools/lib/partition-system-server.test.tsJest suite (new)BV-1-01..04, BV-1-06
FileConstructContent
infrastructure/src/main/cdk/stacks/purpose/partition-email.tsPartitionEmailStackNoEcho param EmailSystemServerToken; secret {fqn}-I-EmailSystemServerToken ({token: …}, RETAIN); Built.systemServerTokenSecret; export key emailSystemServerTokenArnAPI
infrastructure/src/main/cdk/stacks/purpose/partition-email.test.tsJest suiteBV-1-05 additions to buildFixture() assertions
FileConstructContent
infrastructure/amm.shpartition mail stepThird temp file + mask + --parameters "{fqn}-Email:EmailSystemServerToken=…"
FileConstructContent
infrastructure/tools/runtime-platform-drift.tsdrift driverPer-partition checks: server SystemEmailServer-{fqn} exists; SM secret exists (BV-7-01)

EmailSender / EmailServer / EmailSenderFactory (library core)

Section titled “EmailSender / EmailServer / EmailSenderFactory (library core)”
FileConstructContent
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailMessage.ktEmailMessage, EmailAttachment (types)Smart-constructed message; sanitizeHeader on header-bound fields; fromHeader helper (lifted)
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailSender.ktEmailSender (interface), SendEmailOutcome (sealed, lifted)Public sending surface
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailServer.ktEmailServer (value type)of() smart constructor; token-redacting toString
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailSenderFactory.ktEmailSenderFactory, SendRetryPolicyfrom(server, retryPolicy, engine): Result<EmailSender>
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailConfig.ktEmailConfigfromConfig(cfg): Result<EmailConfig>; server: EmailServer; optional from sub-block data

PostmarkEmailSender + PostmarkServerProxy (library impl)

Section titled “PostmarkEmailSender + PostmarkServerProxy (library impl)”
FileConstructContent
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/postmark/PostmarkServerProxy.ktPostmarkServerProxy (lifted)Stateless sendEmail; outcome mapping
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/postmark/PostmarkWire.ktPostmarkSendEmailRequest/Response, PostmarkSendAttachment (lifted)Wire types, serialization pinned
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/postmark/PostmarkEmailSender.ktPostmarkEmailSenderRetry core (lifted attemptSend), hooks, token from EmailServer
common-module/gradle/libs.versions.tomldependencyktor-client-mock (testImplementation)
common-module/CHANGELOG.mdentryAdded → 13.2.0
FileConstructContent
common-module/lib/src/main/kotlin/cards/arda/common/lib/component/ConfigurationProvider.ktemailConfiguration (lazy val)Optional email {} binding mirroring globalDsConfiguration
FileConstructContent
operations/gradle/libs.versions.tomlarda-common-versionBump to 13.2.0
operations/src/main/helm/templates/secrets.yamlExternalSecret + local fallbackSystemEmailServerToken data entry (property: token) + email.serverToken line in both branches
operations/src/main/helm/values.yaml (+ per-env values)email.from valuesFrom-domain system.{partition-mail-zone}, localPart, displayName
operations/src/main/resources/application.confemail {} blockserverToken (layered), from from values/env substitution
operations/src/main/kotlin/cards/arda/operations/runtime/Main.ktcomposition rootConditional systemEmailSender construction + in-process exposure
operations/src/main/kotlin/cards/arda/operations/shopaccess/email/…lifted-class re-importsReplace local wire types/proxy/outcome/fromHeader with library imports; tenant send service delegates retry core to PostmarkEmailSender
operations/src/test/kotlin/cards/arda/operations/runtime/ComponentStartupTest.kt, ConfigurationEndpointTest.kttest config mapsemail {} entries (BV-5-01..03); no dataSource plumbing (block is dataSource-less)
FileConstructContent
common-module/lib/src/test/kotlin/cards/arda/common/lib/infra/email/…Kotest specsBV-2-01/02, BV-3-01/02, BV-4-01..04, BV-7-02 with MockEngine
infrastructure/tools/lib/partition-system-server.test.tsJestBV-1-01..04, BV-1-06
infrastructure/src/main/cdk/stacks/purpose/partition-email.test.tsJestBV-1-05
operations/src/test/kotlin/…/smoke/Dev smoke specBV-4-06 (tagged, excluded from default build)
  • accounts adoption (invitation flow sends through the capability). Revisit when: the follow-up project starts; this design’s library + config seam is its entry point. See goal.md § Out of Scope.
  • Webhook/bounce ingestion + monitoring console for system mail. Revisit when: PDEV-1250 is scheduled. See DQ-007.
  • Per-stream reputation isolation (dedicated Signature for system.{partition}). Revisit when: deliverability data shows divergence. See DQ-001.
  • Email templating. No mechanism exists anywhere today; EmailMessage takes literal bodies. Revisit when: the accounts follow-up defines invitation content.
  • Token-rotation automation. Manual runbook in v1 (§11). Revisit when: rotation cadence is mandated (cf. PDEV-807 precedent for the encryption-key registry).
  • EmailAddress validated value type. lang/Primitives.kt has typealias EmailAddress = String; a smart-constructed type is a candidate improvement flagged during recon, deliberately not bundled into this design.




Copyright: (c) Arda Systems 2025-2026, All rights reserved