Skip to content

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:

  • <env>-ci-deployer-policy includes:

    Allow group ci-deployers to use email-family in compartment <env>
    
    This covers tofu apply of modules/oci-email/ (manage/create approved senders requires manage email-family; infra-agent must verify the exact verb needed against the OCI IAM docs and confirm use is sufficient for sender creation, or flag a policy adjustment in the module PR).

  • <env>-app-runtime-policy includes:

    Allow dynamic-group app-runtime-<env> to use email-family in compartment <env>
    
    This covers the admin-backend service 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. The use email-family policy remains correct: it permits the instance to use the approved sender, not to manage senders. The app-runtime-<env> policy must NOT be widened to manage 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:

  1. Console → Identity & Security → Identity → Users → terraform-deployer (or the dedicated SMTP user)
  2. SMTP Credentials → Generate SMTP Credentials
  3. Description: OCI Email Delivery SMTP (mgh, <YYYY-MM-DD>)
  4. Save the username and password immediately — shown once.
  5. Store in ansible/group_vars/all/vault.yml under keys oci_smtp_username and oci_smtp_password.
  6. 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-agent sets 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 by infra-agent in the module PR.
  • Domain verification is async. After tofu apply, oci_email_email_domain.domain_verification_status is PENDING. 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 in state = CREATING until verification completes. Document in §Step 10.
  • Sender approval is async. oci_email_sender.state starts at PENDING. New tenancies or first-time email sending may need 24h+ for OCI's first-time review. Operator must verify status reaches ACTIVE before relying on transactional mail. Document in §Step 10.
  • SMTP credential cap: 2 per IAM user. terraform-deployer already 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 dedicated email-sender IAM 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 in infra/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.com will fail DMARC at p=quarantine (initial) and p=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> holds use email-family only — cannot add or remove approved senders. secops-agent must verify on each infra PR that no policy statement widens this to manage email-family for 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-1 for 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.