Design: Invitation Mail Server
Design: Invitation Mail Server
Section titled “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-modulegains a server-identity-agnostic email-sending library (lib/infra/email):EmailSenderbuilt byEmailSenderFactoryfrom anEmailServerconnection value, produced either from the component-levelemail {}HOCON block (system path) or fromoperations’ DB-basedEmailConfiguration(per-tenant path). The classes are lifted and generalized fromoperations’ 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).operationsships the secret projection and an internally exposed system sender to prove the seam end-to-end (DQ-006);accountsadoption is a follow-up project.
Glossary
Section titled “Glossary”| Term | Meaning |
|---|---|
| SystemEmailServer | The per-partition Postmark Server dedicated to system-originated (administrative) mail; Postmark server Name SystemEmailServer-{fqn}. |
| System sending domain | system.{partition}.{base} with base = ardamails.com (parametrized per email-integration DQ-009). |
EmailServer | Library value type describing a configured server connection (API base URL, server token, message stream) — the single input both construction paths produce. |
| δ.1 pattern | The 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-Deploy | The amm.sh-invoked step running tools/register-partition-mail-signature.ts before cdk deploy. |
1. Position in Arda’s Architecture
Section titled “1. Position in Arda’s Architecture”| Viewpoint | This design’s position |
|---|---|
| Product | Serves 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). |
| Functional | New 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). |
| Artifacts | infrastructure CDK app (Al1x partition apps), common-module jar (13.2.0, additive), operations Docker image + Helm chart, documentation site. |
| Runtime | All four active partitions (dev, stage, demo, prod); operations component pods; Postmark accounts (PostmarkProd / PostmarkNonProd). Rollout dev → {stage ∥ demo} → prod. |
| OAM | New drift checks (server + secret existence), send-outcome telemetry, token-rotation runbook; capability off-switch = absence of the email {} block. Detail in §11. |
| Technology | Infrastructure layer (CDK/TypeScript, bash, Postmark API, 1Password, SM/ESO) + Backend library layer (Kotlin, Ktor client). No new technology added. |
2. Module-Interaction Mechanics
Section titled “2. Module-Interaction Mechanics”| Mechanism | Added by this design | Consumed by this design |
|---|---|---|
| API Endpoints / Services | None (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. |
| Bindings | New 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.
3. Overview
Section titled “3. Overview”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.
4. Decision Summary
Section titled “4. Decision Summary”| # | Decision | Chosen Option |
|---|---|---|
| DQ-001 | Sender Signature for system.{partition}? | Inherit the partition Signature — no new DNS records or signatures |
| DQ-002 | System server token custody | Partition-vault 1Password item SystemEmailServer (corporate precedent) |
| DQ-003 | CDK placement | Extend PartitionEmailStack (additive) |
| DQ-004 | Library class sourcing | Lift & generalize from operations; operations adopts |
| DQ-005 | Sender acquisition | Component email {} block; library is server-identity-agnostic; factory also buildable from DB config |
| DQ-006 | operations scope | Secret projection + internal system sender (no REST) |
| DQ-007 | Webhook posture v1 | None — fire-and-forget; console follow-up PDEV-1250 |
Full rationale and rejected alternatives in decision-log.md.
5. Constraints
Section titled “5. Constraints”- Established email-integration architecture is binding: thin-wrapper Postmark constructs,
amm.shidempotency/security rules (DQ-R1-022), δ.1 secret delivery, CFN stack-name immutability ({fqn}-Emailstays). - System sending domain is
system.{partition}.{base},baseparametrized (ardamails.com, email-integration DQ-009). - The system server token is never stored in a database; it reaches consumers only as a deployment-delivered secret.
cdk diffagainst deployed partitions must be clean-or-additive; both existing SM secrets and the zone areRemovalPolicy.RETAIN— no logical-id or secretName changes to existing resources.- Rollout order
dev → {stage ∥ demo} → prod; production partitions on PostmarkProd, non-production on PostmarkNonProd. - CI drift checks may use only
Arda-SystemsOAMshared account tokens — they can assert server/secret existence, never token values. common-modulechanges are additive-only (minor 13.2.0); no new runtime dependencies (Ktor client stack already present).- Secret-handling in bash replicates the existing discipline:
set +x, 0600 temp files,::add-mask::, NoEcho parameters,--forcedeploys.
6. Quality Attributes / Non-Functional Requirements
Section titled “6. Quality Attributes / Non-Functional Requirements”| Attribute | Target | Satisfied by |
|---|---|---|
| Idempotent provisioning | Re-running amm.sh for a partition converges with no duplicate Postmark servers or 1P items | Pre-Deploy list-by-name-then-create + custody healing (§9.1) |
| Secret hygiene | Token never in logs, templates, change sets, stack events, or DB | δ.1 NoEcho flow + masking discipline (constraint 8); no persistence path exists |
| Send robustness | Transient Postmark failures absorbed without caller involvement | SendRetryPolicy backoff in PostmarkEmailSender (§9.4) |
| Adoptability | A new component acquires the capability with config + one factory call | email {} block binding + EmailSenderFactory (DQ-005, §9.3) |
| Degradability | Components without the email {} block boot and run normally | Optional-block binding, null capability (§9.5) |
8. Structural Design
Section titled “8. Structural Design”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.
Key Elements
Section titled “Key Elements”PartitionMail (modified)
Section titled “PartitionMail (modified)”- 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: OpReferenceper partition (op://Arda-{Env}OAM/SystemEmailServer/credential) and accessorsystemEmailServerTokenOpReference(partition)beside the existingpostmarkCredentialOpReference/encryptionKeyOpReference. - Design decisions referenced: DQ-002.
partition-system-server (new)
Section titled “partition-system-server (new)”- Role in the diagram: infrastructure — provisioning; Pre-Deploy logic module
tools/lib/partition-system-server.ts, invoked from the existing entrytools/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 NameSystemEmailServer-{fqn}; absent →POST /servers(captureApiTokens[0], write 1P itemSystemEmailServer, fieldcredential); present →op readthe 1P item. Custody-healing and mismatch behaviors in §9.1/§9.7. Updates the “no servers at provisioning time” header comment + test intools/lib/partition-mail-signature.ts(deliberate invariant change). - Design decisions referenced: DQ-002.
PartitionEmailStack (modified)
Section titled “PartitionEmailStack (modified)”- Role in the diagram: infrastructure — provisioning; existing stack
{fqn}-Emailinsrc/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
CfnParameterEmailSystemServerToken; newsm.Secret{fqn}-I-EmailSystemServerToken(secretObjectValue: { token: … },RemovalPolicy.RETAIN— same shape as the account-token secret so ESO usesproperty: token); new export keyemailSystemServerTokenArnAPI→{fqn}-API-EmailSystemServerTokenArn;BuiltgainssystemServerTokenSecret. As-built note: the Secret’s CDK construct id isEmailSystemServerTokenSecret(the CfnParameter owns the idEmailSystemServerTokenin the same scope); physical names are exactly as specified here. - Design decisions referenced: DQ-003.
amm.sh partition mail step (modified)
Section titled “amm.sh partition mail step (modified)”- Role in the diagram: infrastructure — provisioning; operator surface (DQ-R1-022).
- Responsibility: passes
--system-token-outto Pre-Deploy, reads the third temp file underset +x, masks it, appends--parameters "{fqn}-Email:EmailSystemServerToken=…"to the existing--forcedeploy. - Design decisions referenced: DQ-002, DQ-003.
runtime-platform-drift (modified)
Section titled “runtime-platform-drift (modified)”- 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-EmailSystemServerTokenexists. Token values are out of reach by design (constraint 6). - Design decisions referenced: DQ-007 (drift is the v1 observability floor).
EmailSender (new)
Section titled “EmailSender (new)”- Role in the diagram: common-module — lib/infra/email; the public sending interface with its message/outcome types.
- Responsibility: send one
EmailMessagethrough 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) }
EmailMessageconstruction sanitizes header-bound fields viasanitizeHeader(smart constructor returningResult<EmailMessage>);fromHeader(displayName, address)is lifted alongside.SendEmailOutcomeis lifted as-is fromoperations(DQ-004). - Design decisions referenced: DQ-004, DQ-005.
EmailServer (new)
Section titled “EmailServer (new)”- 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(defaulthttps://api.postmarkapp.com),serverToken(sensitive; excluded fromtoString),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.
EmailSenderFactory (new)
Section titled “EmailSenderFactory (new)”- Role in the diagram: common-module — lib/infra/email; builds senders from
EmailServervalues. - Public surface:
EmailSenderFactory.from(server: EmailServer, retryPolicy: SendRetryPolicy = SendRetryPolicy.DEFAULT, engine: HttpClientEngine? = null): Result<EmailSender>. The nullable engine is the test seam (MockEngine), mirroringhttpClient(...). - 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 fromoperations) performs the statelessPOST /emailwithX-Postmark-Server-Tokenand maps responses toSendEmailOutcome;PostmarkEmailSenderwraps it with the retry loop (liftedattemptSendcore, monadic single-exit), header assembly, and optional caller hooks (onSendSuccess/onTransientFailure/onPermanentFailure) replacing the tenant-lifecycle coupling. - Members of note: wire types
PostmarkSendEmailRequest,PostmarkSendAttachment,PostmarkSendEmailResponsemove here unchanged; built onhttpClient(logger, engine)— CallId, MDC propagation, perf monitoring,JsonConfig.standardJsoncome free. - Design decisions referenced: DQ-004.
EmailConfig binding (modified)
Section titled “EmailConfig binding (modified)”- Role in the diagram: common-module — component boot;
ConfigurationProvidergains the optional component-levelemail {}block, mirroringglobalDsConfiguration. - Responsibility:
EmailConfig.fromConfig(cfg): Result<EmailConfig>binds{ serverToken, apiBaseUrl?, messageStream?, from { domain, localPart, displayName }? }; exposesemailConfiguration: EmailConfig?(lazy; absent block →null+ WARN; malformed block → boot failure viaAppError.GeneralValidation, same posture as the dataSource binding).EmailConfig.server: EmailServeryields the connection value; the optionalfromsub-block is convenience data for consumers, not interpreted by the library. - Design decisions referenced: DQ-005.
Helm chart (secrets.yaml) (modified)
Section titled “Helm chart (secrets.yaml) (modified)”- Role in the diagram: operations — runtime; the ExternalSecret that templates
secrets.properties. - Responsibility: adds a
dataentrySystemEmailServerToken(remoteRef{fqn}-I-EmailSystemServerToken,property: token) and the template lineemail.serverToken={{ .SystemEmailServerToken }}; local-mode fallback adds the same line with a dummy value. Non-secret from-address values ridevalues.yaml→ theemail.fromblock (domain derived assystem.+ 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.emailConfigurationis non-null, buildsystemEmailSender: EmailSenderviaEmailSenderFactory.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.
Per-tenant email module (modified)
Section titled “Per-tenant email module (modified)”- Role in the diagram: operations — runtime; existing
shopaccess/emailmodule. - Responsibility: re-imports the lifted types (
SendEmailOutcome, wire types, send proxy,fromHeader); its tenant send service (service/EmailSender.kt) delegates its retry core toPostmarkEmailSender, keeping tenant-specific behavior (per-attempt token fetch fromConfigurationService,canSendrecheck, 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.
9. Behavioral Design
Section titled “9. Behavioral Design”Behaviors are grouped below; each cross-links to the owning Key Element and maps to entries in Behavior Verification.
9.1 Provisioning
Section titled “9.1 Provisioning”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), captureApiTokens[0]. Present → no Postmark mutation. Re-runs converge. - Token custody (DQ-002). On create, the token is written to the partition vault item
SystemEmailServer(fieldcredential) 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.shreads the token file underset +x, masks it, and passesEmailSystemServerTokenas a NoEcho parameter on the existing--forcedeploy;PartitionEmailStackupserts{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.
9.2 Secret Delivery and Config Binding
Section titled “9.2 Secret Delivery and Config Binding”Owner: Helm chart (secrets.yaml), EmailConfig binding.
- ESO projection. The ExternalSecret adds
SystemEmailServerTokensourced from{fqn}-I-EmailSystemServerToken(property: token), templated intosecrets.propertiesasemail.serverToken=…— the same mechanism that injects the per-database credentials today. - HOCON layering.
secrets.propertiesreaches the component via the existingarda.config.locationlayering (CompositeConfigFactory.load()); the token becomes an ordinary config value without any component file-reading code. - Lazy binding.
ConfigurationProvider.emailConfigurationbinds the optional top-levelemail {}block on first access: absent →null+ WARN; present-but-malformed →AppError.GeneralValidationboot failure (mirror ofglobalDsConfiguration).
9.3 Sender Construction
Section titled “9.3 Sender Construction”Owner: EmailSenderFactory, EmailServer.
- Config-bound path (system).
emailConfiguration.server→EmailSenderFactory.from(server)→PostmarkEmailSender. One factory call at boot (DQ-005/DQ-006). - DB-bound path (per-tenant).
operationsbuildsEmailServer.of(apiBase, decryptedTenantToken)from itsEmailConfigurationrows and calls the same factory — two producers, one input type, one sender implementation (DQ-005 A′). - Validation at the edge.
EmailServer.ofandEmailConfig.fromConfigare smart constructors returningResult; no partially-configured sender can exist.
9.4 Sending
Section titled “9.4 Sending”Owner: PostmarkEmailSender + PostmarkServerProxy, EmailSender.
- Header hygiene.
EmailMessageconstruction passes subject, display names, reply-to, and custom headers throughsanitizeHeader; invalid input fails construction (Result), never reaches the wire. - Outcome classification.
POST /emailresponses 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 toSendRetryPolicy.maxAttemptswith exponential backoff (500 ms × 2, cap 10 s), honoringRetry-Afterwhen present;Rejectednever retries. LiftedattemptSendrecursion, monadic single-exit. - Idempotent sends. Callers mint an
IdempotencyKeyvia the existingIdempotencyKeyMinterand pass it as theIdempotency-Keycustom header onEmailMessage.headers; the library treats it as an opaque header (recording outcomes stays a caller concern, as inoperationstoday). - MessageID capture.
Sent.messageIdis returned to the caller; per DQ-007 it is the hook for future status interrogation (GET /messages/outbound/{messageid}/details, see PDEV-1250).
9.5 Gating and Degraded Modes
Section titled “9.5 Gating and Degraded Modes”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 emptyserverTokenfails pod boot loudly — a partition where provisioning ran but ESO projection broke is detected at rollout, not at first send. - Local mode.
values-local.yamlsupplies a dummy token line; local sends hit Postmark with an invalid token and classify asRejected— acceptable for local (no live sending expected), matching the existing local dummy-secret posture.
9.6 End-to-end Propagation
Section titled “9.6 End-to-end Propagation”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.
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.
9.7 Robustness and Coexistence
Section titled “9.7 Robustness and Coexistence”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
TransportFailureafter retry exhaustion; callers decide policy. - Token rotation propagation. Rotation = new server token in Postmark (regenerate) → update vault item → re-run partition deploy (
--forcepropagates the NoEcho parameter) → ESO refresh interval updatessecrets.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.comunder the same partition Signature (DQ-001) — a shared-reputation trade recorded in the decision log’s reopen trigger. - Drift floor.
runtime-platform-driftasserts 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’sTotalCountexceeds the returned page — a truncated list can never produce a false “server missing” verdict (as-implemented refinements from PR #499 review).
10. Behavior Verification
Section titled “10. Behavior Verification”Subsections mirror Behavioral Design; IDs follow BV-<group>-<seq>. Fixture descriptions in Testing Elements.
10.1 Provisioning
Section titled “10.1 Provisioning”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-1-01 | Ensure-server idempotency — absent → create; present → no mutation | Jest; fake Postmark client returning empty / populated server lists | FakePostmarkAccountApi |
| BV-1-02 | Token custody — create path writes 1P before emitting token file | Fake 1P CLI recorder | FakeOpCli |
| BV-1-03 | Custody healing — server present + item missing → GET /servers/{id} re-read + item creation | FakePostmarkAccountApi + FakeOpCli in divergent states | FakePostmarkAccountApi, FakeOpCli |
| BV-1-04 | Custody divergence — item present + server absent → hard error, no creation | Same fixtures, inverse divergence | FakePostmarkAccountApi, 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-identical | CDK Template.fromStack on buildFixture() | PartitionEmailTemplateFixture |
| BV-1-06 | Invariant update — partition-mail-signature no longer asserts zero server creation; system-server step is the only server-creating path | Existing Jest suite amended intentionally | FakePostmarkAccountApi |
Integration
Section titled “Integration”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-1-07 | End-to-end provisioning on dev — first run creates server + vault item + secret; immediate re-run is a no-op | Operator-driven dev deploy (rollout gate, not CI) | live PostmarkNonProd + Arda-DevOAM |
10.2 Secret Delivery and Config Binding
Section titled “10.2 Secret Delivery and Config Binding”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-2-01 | Lazy binding — absent email {} → null + WARN; malformed → AppError.GeneralValidation; valid → EmailConfig | Kotest; HOCON strings | HoconFixtures |
| BV-2-02 | EmailConfig.fromConfig field semantics — defaults (apiBaseUrl, messageStream), required serverToken, optional from block | Kotest; HOCON strings | HoconFixtures |
| BV-2-03 | ESO projection — chart renders the SystemEmailServerToken data entry + email.serverToken line (both ESO and local-fallback branches) | helm template golden assertions | HelmRenderFixture |
10.3 Sender Construction
Section titled “10.3 Sender Construction”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-3-01 | Validation at the edge — EmailServer.of rejects blank token / invalid URL; token excluded from toString | Kotest | — |
| BV-3-02 | Config-bound path — factory yields a sender wired to the configured apiBaseUrl/token | MockEngine capturing request | MockPostmarkServerEngine |
| BV-3-03 | DB-bound path — tenant service builds EmailServer from EmailConfiguration and reaches the same factory (operations) | Existing email module fixtures post-lift | operations EmailSendServiceTestFixture |
10.4 Sending
Section titled “10.4 Sending”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-4-01 | Outcome classification — 200/4xx/429/5xx → Sent/Rejected/RateLimited/TransportFailure | MockEngine per status | MockPostmarkServerEngine |
| BV-4-02 | Retry with backoff — transient retries ≤ maxAttempts with growing delays; Rejected short-circuits | MockEngine scripted sequences; virtual time | MockPostmarkServerEngine |
| BV-4-03 | Header hygiene — control chars / oversized headers fail EmailMessage construction; fromHeader RFC 5322 quoting pinned | Kotest property + example tests | — |
| BV-4-04 | Wire-format pin — serialized PostmarkSendEmailRequest (nulls omitted, Cc/Bcc, stream) matches the pre-lift golden JSON | Kotest; lifted from PostmarkSendCcBccTest | MockPostmarkServerEngine |
| BV-4-05 | Per-tenant regression — operations email module suite passes unchanged after the lift (DQ-004’s net) | Existing operations unit + integration suites | operations existing fixtures |
| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-4-06 | End-to-end send — deployed dev operations sends a real message through the dev SystemEmailServer; MessageID returned | Dev smoke (Kotest tag, excluded from default build), extends the DevSmoke* pattern | dev partition + PostmarkNonProd |
10.5 Gating and Degraded Modes
Section titled “10.5 Gating and Degraded Modes”Unit / Integration
Section titled “Unit / Integration”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-5-01 | Capability off by absence — component boots with no email {} block; no sender constructed | operations ComponentStartupTest variant without the email block | operations startup fixtures |
| BV-5-02 | Fail-fast on misconfiguration — present block with empty token fails boot with GeneralValidation | ComponentStartupTest negative variant | operations startup fixtures |
| BV-5-03 | Startup with valid block — boot constructs systemEmailSender; config endpoint sanitizes the token | ComponentStartupTest + ConfigurationEndpointTest with email {} in the test config map | operations startup fixtures |
10.7 Robustness
Section titled “10.7 Robustness”| Test ID | Behavior Tested | Required Setup | Test Fixtures |
|---|---|---|---|
| BV-7-01 | Drift floor — driver flags missing server / missing secret per partition | Jest; fake Postmark + fake SM clients | FakePostmarkAccountApi |
| BV-7-02 | Postmark unavailable at runtime — network failure → retries → TransportFailure(ExternalService) | MockEngine throwing IOExceptions | MockPostmarkServerEngine |
Testing Elements
Section titled “Testing Elements”FakePostmarkAccountApi
Section titled “FakePostmarkAccountApi”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.
FakeOpCli
Section titled “FakeOpCli”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.
PartitionEmailTemplateFixture
Section titled “PartitionEmailTemplateFixture”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.
HoconFixtures
Section titled “HoconFixtures”Inline HOCON strings (valid / absent / malformed email {} variants) parsed via Typesafe ConfigFactory.parseString in Kotest specs.
MockPostmarkServerEngine
Section titled “MockPostmarkServerEngine”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.
HelmRenderFixture
Section titled “HelmRenderFixture”helm template invocations of the operations chart with ESO-on and local values, asserting rendered ExternalSecret data entries and secrets.properties lines.
11. Operations Impact
Section titled “11. Operations Impact”Telemetry / observability
Section titled “Telemetry / observability”- 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/onPermanentFailurecallbacks are the metric attachment points (operations wires them to its existing signal pattern). runtime-platform-driftgains two assertions per partition (server exists, secret exists); failures raise the workflow’s existing auto-issue.
Operational controls
Section titled “Operational controls”| Control | Mechanism | Effect when set |
|---|---|---|
| Capability off | Remove/omit the email {} block (Helm values / config layering) | Component boots normally; no system sender constructed; callers see no capability |
| Token rotation | Regenerate server token in Postmark → update vault item SystemEmailServer → re-run amm.sh partition deploy → restart pods | New token propagated via SM/ESO; old token invalid from regeneration moment (see the System Email Server runbook) |
| Send policy | SendRetryPolicy via factory parameter (code-level; config exposure deferred) | Alters retry ceiling/backoff |
Cost / capacity
Section titled “Cost / capacity”- 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.
Runbook hooks
Section titled “Runbook hooks”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
SystemEmailServerexists 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-runamm.shfor the partition, restart operations pods after ESO refresh. Expect send failures (TransportFailure/Rejected401) between regeneration and pod restart; sequence tightly. - Sends failing with
Rejected401/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.
12. Implementation Artifacts
Section titled “12. Implementation Artifacts”Subsections mirror Key Elements; one row per (file, construct) pair. Paths verified against the worktrees at design time.
PartitionMail
Section titled “PartitionMail”| File | Construct | Content |
|---|---|---|
infrastructure/src/main/cdk/platforms.ts | PartitionMail (type) | Add systemServerTokenOpReference field; populate for all four partitions (op://Arda-{Env}OAM/SystemEmailServer/credential) |
infrastructure/src/main/cdk/platforms.ts | systemEmailServerTokenOpReference() (function) | Partition-aware accessor beside postmarkCredentialOpReference |
partition-system-server
Section titled “partition-system-server”| File | Construct | Content |
|---|---|---|
infrastructure/tools/lib/partition-system-server.ts | ensureSystemEmailServer() (module, new) | List-by-name → create/read flow, 1P custody + healing, token temp-file emission |
infrastructure/tools/register-partition-mail-signature.ts | entry script | --system-token-out flag; invoke the new module after the Signature step |
infrastructure/tools/lib/partition-mail-signature.ts | header comment + test | Intentional removal of the “no servers at provisioning time” invariant |
infrastructure/tools/lib/partition-system-server.test.ts | Jest suite (new) | BV-1-01..04, BV-1-06 |
PartitionEmailStack
Section titled “PartitionEmailStack”| File | Construct | Content |
|---|---|---|
infrastructure/src/main/cdk/stacks/purpose/partition-email.ts | PartitionEmailStack | NoEcho param EmailSystemServerToken; secret {fqn}-I-EmailSystemServerToken ({token: …}, RETAIN); Built.systemServerTokenSecret; export key emailSystemServerTokenArnAPI |
infrastructure/src/main/cdk/stacks/purpose/partition-email.test.ts | Jest suite | BV-1-05 additions to buildFixture() assertions |
amm.sh partition mail step
Section titled “amm.sh partition mail step”| File | Construct | Content |
|---|---|---|
infrastructure/amm.sh | partition mail step | Third temp file + mask + --parameters "{fqn}-Email:EmailSystemServerToken=…" |
runtime-platform-drift
Section titled “runtime-platform-drift”| File | Construct | Content |
|---|---|---|
infrastructure/tools/runtime-platform-drift.ts | drift driver | Per-partition checks: server SystemEmailServer-{fqn} exists; SM secret exists (BV-7-01) |
EmailSender / EmailServer / EmailSenderFactory (library core)
Section titled “EmailSender / EmailServer / EmailSenderFactory (library core)”| File | Construct | Content |
|---|---|---|
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailMessage.kt | EmailMessage, 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.kt | EmailSender (interface), SendEmailOutcome (sealed, lifted) | Public sending surface |
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailServer.kt | EmailServer (value type) | of() smart constructor; token-redacting toString |
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailSenderFactory.kt | EmailSenderFactory, SendRetryPolicy | from(server, retryPolicy, engine): Result<EmailSender> |
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/EmailConfig.kt | EmailConfig | fromConfig(cfg): Result<EmailConfig>; server: EmailServer; optional from sub-block data |
PostmarkEmailSender + PostmarkServerProxy (library impl)
Section titled “PostmarkEmailSender + PostmarkServerProxy (library impl)”| File | Construct | Content |
|---|---|---|
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/postmark/PostmarkServerProxy.kt | PostmarkServerProxy (lifted) | Stateless sendEmail; outcome mapping |
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/postmark/PostmarkWire.kt | PostmarkSendEmailRequest/Response, PostmarkSendAttachment (lifted) | Wire types, serialization pinned |
common-module/lib/src/main/kotlin/cards/arda/common/lib/infra/email/postmark/PostmarkEmailSender.kt | PostmarkEmailSender | Retry core (lifted attemptSend), hooks, token from EmailServer |
common-module/gradle/libs.versions.toml | dependency | ktor-client-mock (testImplementation) |
common-module/CHANGELOG.md | entry | Added → 13.2.0 |
EmailConfig binding
Section titled “EmailConfig binding”| File | Construct | Content |
|---|---|---|
common-module/lib/src/main/kotlin/cards/arda/common/lib/component/ConfigurationProvider.kt | emailConfiguration (lazy val) | Optional email {} binding mirroring globalDsConfiguration |
operations wiring
Section titled “operations wiring”| File | Construct | Content |
|---|---|---|
operations/gradle/libs.versions.toml | arda-common-version | Bump to 13.2.0 |
operations/src/main/helm/templates/secrets.yaml | ExternalSecret + local fallback | SystemEmailServerToken data entry (property: token) + email.serverToken line in both branches |
operations/src/main/helm/values.yaml (+ per-env values) | email.from values | From-domain system.{partition-mail-zone}, localPart, displayName |
operations/src/main/resources/application.conf | email {} block | serverToken (layered), from from values/env substitution |
operations/src/main/kotlin/cards/arda/operations/runtime/Main.kt | composition root | Conditional systemEmailSender construction + in-process exposure |
operations/src/main/kotlin/cards/arda/operations/shopaccess/email/… | lifted-class re-imports | Replace 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.kt | test config maps | email {} entries (BV-5-01..03); no dataSource plumbing (block is dataSource-less) |
Testing Artifacts
Section titled “Testing Artifacts”| File | Construct | Content |
|---|---|---|
common-module/lib/src/test/kotlin/cards/arda/common/lib/infra/email/… | Kotest specs | BV-2-01/02, BV-3-01/02, BV-4-01..04, BV-7-02 with MockEngine |
infrastructure/tools/lib/partition-system-server.test.ts | Jest | BV-1-01..04, BV-1-06 |
infrastructure/src/main/cdk/stacks/purpose/partition-email.test.ts | Jest | BV-1-05 |
operations/src/test/kotlin/…/smoke/ | Dev smoke spec | BV-4-06 (tagged, excluded from default build) |
Out of Scope
Section titled “Out of Scope”accountsadoption (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;
EmailMessagetakes literal bodies. Revisit when: theaccountsfollow-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).
EmailAddressvalidated value type.lang/Primitives.kthastypealias EmailAddress = String; a smart-constructed type is a candidate improvement flagged during recon, deliberately not bundled into this design.
13. Open Questions
Section titled “13. Open Questions”- None blocking. All seven DQs are settled in decision-log.md.
References
Section titled “References”- Decision Log — settled decisions for this design.
- Goal — project goal this design serves.
- Email Integration phases — Phase 4/5a/5b deliverables this design extends.
- Email Integration decision log — inherited decisions (DQ-009, DQ-012 non-applicability, DQ-R1-009, DQ-R1-017, DQ-R1-022).
- Secret delivery pattern — the δ.1 flow.
- Invite User to Tenant — the flow the follow-up project changes.
- Skills:
structured-design,decision-log,plantuml-diagrams,kotlin-coding,cdk-infrastructure,unit-tests-backend.
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved