ADR-0005: OCI Email Delivery for outbound transactional mail¶
- Status: Accepted
- Date: 2026-05-17
- Deciders: cloud-architect, oci-expert, secops-agent, finops-agent, marnissi.investments
Context¶
MGH needs outbound transactional email (password resets, billing notifications, admin alerts) from no-reply@marnissi-holdings.com. OCI Email Delivery is the correct placement: it is co-located with the ARM A1 VPS (same tenancy, same region eu-milan-1), Always-Free at 3,000 approved sends/month (~100/day average), and already listed in infra/CLAUDE.md §Stack and §Hostname catalog. No new cloud account or external dependency is introduced.
DKIM signing is mandatory for deliverability. OCI generates and manages the DKIM key; the resulting CNAME record must land in the Cloudflare DNS zone for marnissi-holdings.com. The modules/oci-email/ Tofu module exports the CNAME host and value as outputs so that modules/cloudflare-dns/ (ADR-0006) can consume them in PR5. This cross-module dependency is the load-bearing inter-PR constraint for this ADR.
SMTP credentials cannot be provisioned by Tofu. OCI SMTP credential generation is a one-time manual step (same lifecycle problem as OCIR Auth Tokens in ADR-0004): the credential is shown once in the console, must be stored in ansible/group_vars/all/vault.yml, and cannot be recovered after the console dialog closes. The manual procedure will be documented by infra-agent as infra/scripts/bootstrap-oci.md §Step 10.
Inbound email is a separate concern: *@marnissi-holdings.com routes via Cloudflare Email Routing to marnissi.investments@gmail.com (ADR-0009). This ADR covers outbound only.
Decision¶
Provision modules/oci-email/ using the Tofu oracle/oci provider, instantiated from infra/envs/<env>/. The module manages approved senders and DKIM configuration; SMTP credentials remain manual.
Approved sender topology¶
Critical OCI constraint (oci-expert finding): oci_email_email_domain.name is unique per region per tenancy. The provider rejects a duplicate domain registration. Two envs cannot each register marnissi-holdings.com — there can only be one oci_email_email_domain resource for the entire tenancy.
Resolution: domain + DKIM are created exactly once and physically placed in the prod compartment (durable, separate blast radius from dev). Each env's oci_email_sender lives in its own compartment but references the single domain's OCID.
| Resource | Dev compartment | Prod compartment | Created in |
|---|---|---|---|
oci_email_email_domain for marnissi-holdings.com |
— | yes (single registration) | PR4 (this PR) |
oci_email_dkim (DKIM key for the domain) |
— | yes (single key, domain-scoped) | PR4 (this PR) |
oci_email_sender for no-reply@marnissi-holdings.com |
yes | yes | dev sender in PR4; prod sender in PR8 |
Module implementation pattern: count = var.create_domain ? 1 : 0 on the domain + DKIM resources. envs/dev/main.tf sets create_domain = true (dev applies first, so it owns the durable resources, placed in var.compartment_prod_ocid). envs/prod/main.tf (PR8) sets create_domain = false and reads the domain OCID from dev state via a terraform_remote_state data source. The dev sender lives in var.compartment_dev_ocid; the prod sender lives in var.compartment_prod_ocid.
A new variable compartment_prod_ocid is added to envs/dev/variables.tf so the dev module call can place the domain + DKIM in the prod compartment.
Module outputs¶
The module must export these four values for downstream consumers:
| Output name | Example value | Consumer |
|---|---|---|
dkim_cname_host |
<selector>._domainkey.marnissi-holdings.com |
modules/cloudflare-dns/ (PR5) |
dkim_cname_value |
<long-key>.dkim.eu-milan-1.oci.oraclecloud.com |
modules/cloudflare-dns/ (PR5) |
smtp_endpoint |
smtp.email.eu-milan-1.oci.oraclecloud.com:465 |
ansible/group_vars/all/vars.yml |
approved_sender_id |
OCID of the oci_email_sender resource |
observability, rotation runbook |
DNS record handoff¶
modules/cloudflare-dns/ (ADR-0006) must create the following DNS records, consuming the outputs above:
| Record type | Name | Value | Purpose |
|---|---|---|---|
| CNAME | dkim_cname_host output |
dkim_cname_value output |
DKIM signature verification |
| TXT | marnissi-holdings.com (SPF) |
v=spf1 include:rp.oracleemaildelivery.com ~all |
SPF authorisation of OCI send hosts |
| TXT | _dmarc.marnissi-holdings.com |
v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@marnissi-holdings.com |
DMARC initial posture |
SPF include string for eu-milan-1: include:eu-milan-1.rp.oracleemaildelivery.com (region-prefixed form, oci-expert verified). The non-prefixed fallback include:rp.oracleemaildelivery.com may also be added as a defensive include.
DMARC progression (oci-expert recommendation, ramps deliberately to avoid mail loss during DKIM/SPF propagation):
- Day 0: v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@marnissi-holdings.com; aspf=s; adkim=s
- Day 30 (after reviewing aggregate reports): p=quarantine; pct=100
- Day 60: p=reject
The escalation lives in modules/cloudflare-dns/ (ADR-0006); ledger entries / ledger-driven sign-off at each step.
IAM policy — no changes needed¶
ADR-0002 already provisions both required statements in each compartment's policy bundle:
-
This covers<env>-ci-deployer-policyincludes:tofu applyofmodules/oci-email/(manage/create approved senders requiresmanage email-family;infra-agentmust verify the exact verb needed against the OCI IAM docs and confirmuseis sufficient for sender creation, or flag a policy adjustment in the module PR). -
This covers the<env>-app-runtime-policyincludes:admin-backendservice sending SMTP mail at runtime via the resource principal. No static SMTP credential needed for the runtime path if resource-principal SMTP auth is used; however, OCI Email Delivery uses username/password SMTP auth, not resource-principal signing — the runtime credential is the SMTP credential stored in vault, not a dynamic token. Theuse email-familypolicy remains correct: it permits the instance to use the approved sender, not to manage senders. Theapp-runtime-<env>policy must NOT be widened tomanage email-family— that verb would allow adding or deleting approved senders from application code.
infra-agent confirms both statements are present before closing the module PR. No IAM changes ship with this ADR's module PR.
SMTP credential — manual Phase A step¶
SMTP credentials cannot be Tofu-managed. The manual procedure:
- Console → Identity & Security → Identity → Users →
terraform-deployer(or the dedicated SMTP user) - SMTP Credentials → Generate SMTP Credentials
- Description:
OCI Email Delivery SMTP (mgh, <YYYY-MM-DD>) - Save the username and password immediately — shown once.
- Store in
ansible/group_vars/all/vault.ymlunder keysoci_smtp_usernameandoci_smtp_password. - Never paste these values into
.tfvars, README files, ADR bodies, PR descriptions, or chat.
OCI SMTP username format is of the form ocid1.user.oc1..<hash>@ocid1.tenancy.oc1..<hash>.<region>.oci.oraclecloud.com (the exact format is shown in the console at credential generation time). infra-agent documents the full procedure as infra/scripts/bootstrap-oci.md §Step 10.
PR5 dependency¶
modules/cloudflare-dns/ (ADR-0006) cannot create the DKIM CNAME, SPF TXT, or DMARC TXT records until modules/oci-email/ has been applied and its outputs are available. PR5 blocks on this module PR. The dependency must be noted in the ADR-0006 PR description.
Consequences¶
-
Cost (delta vs free tier): $0 within the 3,000 sends/month Always-Free ceiling (OCI documents the quota as monthly, not daily — oci-expert correction; the prior "100/day" figure was a daily average approximation). Operator estimate: <600 sends/month at v1 (under 20/day avg) — comfortable headroom (80% margin). Secondary rate limit: 10 emails/minute per tenancy. Cliff: per-1000-sends pricing applies beyond 3,000/month (verify rate at OCI Email Delivery price list before first paid send).
finops-agentsets a WARN trigger at 2,100 sends/month sustained in the ledger. -
Operational surface:
- New Phase A step:
infra/scripts/bootstrap-oci.md §Step 10(SMTP credential generation). Added byinfra-agentin the module PR. - Domain verification is async. After
tofu apply,oci_email_email_domain.domain_verification_statusisPENDING. Operator publishes the OCI-issued TXT record in the CF zone (visible in OCI Console → Email Delivery → Email Domains → select domain); OCI verifies asynchronously (minutes to hours). DKIM resource sits instate = CREATINGuntil verification completes. Document in §Step 10. - Sender approval is async.
oci_email_sender.statestarts atPENDING. New tenancies or first-time email sending may need 24h+ for OCI's first-time review. Operator must verify status reachesACTIVEbefore relying on transactional mail. Document in §Step 10. - SMTP credential cap: 2 per IAM user.
terraform-deployeralready holds 1 credential (Customer Secret Key for state backend doesn't count, but if any other SMTP cred exists this user has 1 slot left). Recommend creating a dedicatedemail-senderIAM user for SMTP credentials to keep credential lifecycle independent. Documented in §Step 10. - DKIM selector is immutable. Selector name baked into the CNAME host. Plan a stable name (default
mgh-v1); rotation requires creating a second DKIM resource (mgh-v2), publishing both CNAMEs, then deleting the old. -
SMTP credential rotation: re-run
infra/scripts/bootstrap-oci.md §Step 10, update vault, delete the old credential in the console. Cadence: every 6 months minimum. Runbook entry ininfra/scripts/rotate-secrets.md(separate PR — see infra#5). -
Security posture:
- DKIM signing is enforced end-to-end via OCI's managed key pair. Unsigned mail from
marnissi-holdings.comwill fail DMARC atp=quarantine(initial) andp=reject(after stabilisation). - SMTP credentials are vault-only (
ansible/group_vars/all/vault.yml). They must not appear in.tfvars, README files, ADR bodies, PR bodies, or chat. app-runtime-<env>holdsuse email-familyonly — cannot add or remove approved senders.secops-agentmust verify on each infra PR that no policy statement widens this tomanage email-familyfor the runtime dynamic group.- Sender address is fixed at
no-reply@marnissi-holdings.com. Adding any other approved sender requires a new ADR. -
SPF and DMARC enforcement lands in PR5 (
modules/cloudflare-dns/). Until PR5 is applied, outbound mail has no SPF or DMARC record — do not send production transactional mail before PR5 is applied. -
Reversibility: Deleting an approved sender is reversible (re-add via
tofu apply). SMTP credentials can be regenerated at any time (re-run §Step 10). The DKIM CNAME can be removed from CF DNS if OCI Email Delivery is decommissioned. No data is stored in this module — reversal has zero data-loss risk. -
Migration path if we revisit: If the 3,000 sends/month ceiling is breached durably, options: (1) stay on OCI Email Delivery, pay-per-send beyond 3,000 (same infrastructure, request quota increase if 10/min rate becomes the bottleneck); (2) migrate to AWS SES (
eu-south-1for latency parity) — requires new IAM surface and SMTP credential re-platform but DNS records are provider-agnostic; (3) switch from SMTP to OCI Email Delivery HTTPS submission API — uses resource principals (no static SMTP credential at all), bypassing the vault entirely. The HTTPS path is the cleanest forward direction; SMTP is chosen for v1 only because the existing Ansible/SMTP toolchain is well-trodden. Revisit after the first ARM A1 ops cycle.
Alternatives considered¶
| Option | Why rejected |
|---|---|
| AWS SES | Adds a second cloud (AWS) to the trust perimeter, requiring new IAM credentials, a new credential rotation surface, and new egress from the OCI VPS to AWS SES endpoints. OCI Email Delivery is co-located and already in scope for Always-Free. Retained as the migration path if the 100/day ceiling is breached durably. |
| SendGrid free tier | 100 sends/day ceiling identical to OCI Email Delivery, but adds an external SaaS dependency, a new API credential to rotate, and no co-location advantage. No meaningful upside over OCI for this workload. |
| Resend | No production-grade free tier with DKIM on custom domains. Paid for the feature set MGH requires. Adds an external SaaS dependency with no Always-Free path. |
| Inbound-only path (no outbound) | Not viable — password resets and billing notifications require outbound delivery to arbitrary recipient addresses. Inbound email routing (CF Email Routing → Gmail) is a separate, complementary concern addressed by ADR-0009. |
| Self-hosted Postfix on the bootstrap VPS | Cloud VPS IP ranges are pre-listed in major ISP blocklists (Spamhaus PBL, etc.). Mail from a self-hosted MTA on OCI ARM A1 public IPs would be rejected or deferred by Gmail, Outlook, and Apple Mail with high probability. OCI Email Delivery uses OCI-managed IPs with established sender reputation. Rejected outright. |