Cabalmail

Host your own email and enhance your privacy

View the Project on GitHub cabalmail/cabal-infra

Private IMAP and SMTP-Submission Plan

Context

Today the NLB in terraform/infra/modules/elb exposes three mail tiers to the public internet:

Listener Tier Purpose Public?
25/tcp smtp-in Inbound MTA-to-MTA relay yes (must remain)
465/tcp smtp-out Submission (implicit TLS) yes (closing)
587/tcp smtp-out Submission (STARTTLS) yes (closing)
143/tcp imap Cleartext IMAP (per local.tiers) yes (closing)
993/tls imap IMAPS, ACM-terminated yes (closing)

Every first-party Cabalmail client now reaches IMAP and SMTP submission through the Lambda API, not directly:

The only consumers of the public 993/465/587 listeners are therefore third-party IMAP/SMTP clients (Apple Mail.app, iOS Mail, Outlook, K-9, Thunderbird, etc.). The product decision recorded by this plan is that we are no longer going to support them: Cabalmail users use a native Cabalmail client, and the IMAP/submission surface stops being a public attack surface.

This is a posture change, not a feature: the Lambda API surface stays exactly the same; only the wire path between Lambdas and the mail containers moves inside the VPC.

The roadmap parks this under 0.10.x because the per-phase changes are individually small and reversible. The risky moments are the cutover of the NLB listeners (Phase 4) and the moment the Lambdas start depending on VPC connectivity (Phase 3); each is staged so it can be rolled back by reverting a single PR.

Goals

Erratum (2026-10-25): there is no longer a private Route 53 zone for the control domain to fall back on. It was named for the whole control domain, which made it authoritative inside the VPC for every address subdomain too and 553-rejected those addresses as envelope senders at smtp-in (#2064); it was removed rather than narrowed. Cloud Map remains, and is what every in-VPC IMAP caller already uses. A phase that deletes the public imap/smtp-out records and still wants those names resolvable inside the VPC must create a private zone named for that one name (imap.<control_domain>), not for the control domain.

  • Every Lambda that reaches IMAP, SMTP submission, S3, DynamoDB, SSM, SNS, Cognito, or CloudWatch Logs runs in private subnets (vpc_config { subnet_ids = var.private_subnet_ids, ... }).
  • High-volume / per-request AWS API traffic from those Lambdas (S3, DynamoDB, SSM, CloudWatch Logs, Cognito, SNS) goes through VPC endpoints, not the NAT instance. Low-volume / global-service traffic (Route 53) keeps egressing through NAT.
  • Lambda code stops trusting client-supplied body['host'] / body['smtp_host'] for IMAP/SMTP target selection. The container host is pinned server-side, derived from configuration (env var or Cloud Map name).
  • smtp-in remains public on port 25. The plan does not change anything about how the rest of the internet delivers mail to Cabalmail.
  • Each phase is independently revertable. A failed Phase N is rolled back without touching Phases 1..N-1.

Non-goals

Current state (audit)

Lambda inventory

Two groups, organized by what each function touches today:

Lambdas that open IMAP sessions (grep -l IMAPClient lambda/api/*/function.py):

delete_folder, folder_status, list_envelopes, list_folders, list_messages, move_messages, new_folder, process_dmarc, search, send, set_flag, subscribe_folder, unsubscribe_folder. Thirteen functions.

Of these, only send also opens an SMTP submission session (smtplib.SMTP_SSL(smtp_host)); the rest are IMAP-only.

process_dmarc is fired by EventBridge Scheduler every six hours, not by API Gateway. It pulls DMARC aggregate reports from the dmarc@ mailbox over IMAP. Same VPC requirement, different invocation path.

Lambdas that touch private AWS data planes but not IMAP/SMTP:

Every other entry under lambda/api/ plus lambda/counter/assign_osid. Per the policy in the request — “Lambdas that require access to private resources (IMAP, SMTP submission, S3, etc.) should be VPC-attached” — these qualify because they all read/write S3 (cache bucket, presigned URLs) or DynamoDB (addresses, preferences, domain access). The IAM policy in modules/app/modules/call/lambda.tf already grants every API Lambda the full S3+DynamoDB+SNS+Cognito surface, so the scope here is “all 40+ API Lambdas,” not a subset.

Lambdas that are pure control-plane and can stay on Lambda-managed networking:

The full per-Lambda IAM picture lives in terraform/infra/modules/app/modules/call/lambda.tf; the existing policy is uniform across every API Lambda, so we do not have to reason per-function about the IAM surface.

Existing VPC-attached Lambda precedent

healthchecks_iac is already VPC-attached. It uses:

The 443-to-anywhere rule is the only piece that should change once VPC endpoints exist — once SSM, Logs, etc. have interface endpoints, the SG can scope its 443 egress to the endpoints’ SG. Reusing this pattern for the API fleet keeps the topology consistent.

Network topology

From terraform/infra/modules/vpc:

Erratum (2026-10-25): two claims here are wrong. imap was not absent from the private zone – the zone held A aliases for all three tiers, each byte-identical to its public sibling, and modules/elb/dns.tf said so in the comment on the private record. And the zone itself is gone as of #2064: it was named for the control domain, so it shadowed every address subdomain and made smtp-in refuse those addresses as envelope senders. The three tier names now resolve from the public zone, inside the VPC as well as outside it.

  • A Cloud Map private DNS namespace cabal.internal is registered for the IMAP service (modules/ecs/service_discovery.tf). There is no Cloud Map registration for smtp-out yet.
  • No aws_vpc_endpoint resources exist anywhere in the stack. Lambda-to-AWS traffic from the VPC today goes over NAT.

Listener / DNS shape after the change (target)

Listener Tier Public? Notes
25/tcp smtp-in yes Unchanged
465/tcp smtp-out no Listener removed; Cloud Map name + SG-to-SG only
587/tcp smtp-out no Listener removed
993/tls imap no Listener removed (TLS termination moves to Dovecot or to an internal NLB; see decision point below)

Public DNS:

Private DNS / service discovery:

Decision: where does IMAPS TLS terminate?

Today the NLB terminates TLS for 993 (the only protocol = "TLS" listener; 465/587 are TCP passthrough and Dovecot/Sendmail terminate TLS in-container). Two options for the internalized world:

  1. Move TLS termination into Dovecot. The certbot-renewal Lambda already produces Let’s Encrypt certs for the container. Dovecot serves IMAPS on 993 directly to its callers. The NLB IMAP listener is deleted outright; no internal NLB is needed for IMAP.
  2. Keep an internal NLB with the ACM-terminated IMAPS listener. Create a second NLB with internal = true in the private subnets and put the 993 (TLS) and 465/587 (TCP) listeners on it. The public NLB keeps only port 25.

Option 1 is structurally simpler: zero NLB listeners for IMAP/submission, one less load balancer to reason about, no ACM cert churn between NLB and container. Option 2 keeps the ACM-managed cert lifecycle in place and matches the existing shape. Recommendation: Option 1. The cert lifecycle inside Dovecot is already battle-tested (certbot-renewal writes the cert into the container’s EFS volume and rolls the service via forceNewDeployment), and the NLB TLS terminator was useful primarily for offloading TLS from third-party clients we are no longer supporting. Phase 4 includes the listener cleanup; Phase 5 retires the NLB target groups and (if Option 1) drops the IMAP-on-NLB plumbing entirely. Phase 4 is also the moment we verify Dovecot terminates 993 the way we expect — if it doesn’t, we fall back to Option 2 before merging.

VPC endpoint inventory

Service Endpoint type Mandatory? Volume Justification
S3 Gateway yes high (per-message caching, attachments) Free (route table entry); huge NAT savings
DynamoDB Gateway yes high (every address/preferences lookup) Free; ditto
SSM Messages Interface yes low-but-hot (one get_parameter per cold start, then cached) Lambdas read /cabal/master_password and other SSM params at module-load time
SSM Interface yes same “SSM” needs both ssm and ssmmessages endpoints for full coverage; in this case just com.amazonaws.<region>.ssm is enough since the Lambdas only do get_parameter / put_parameter
CloudWatch Logs Interface yes high (every Lambda execution writes logs) Logs would otherwise hammer NAT
SNS Interface recommended medium (address-change publishes) Quieter than logs but lives in the hot path for new/revoke
Cognito-IDP Interface recommended low (admin endpoints only) Available in all regions Cabalmail runs in; avoids NAT for admin paths
ECS Interface optional very low (only assign_osid triggers update_service) Single Lambda; can stay on NAT
Route 53 (data plane) Interface n/a n/a No public Resolver/data-plane endpoint exists; lookups stay on VPC Resolver (which is in-VPC anyway)
Route 53 (API) n/a not available n/a Lambdas that write Route 53 records (new, revoke, new_address_admin, repair_dns_record, certbot-renewal) must keep NAT egress
STS Interface yes-if low boto3 resolves credentials via STS in some auth chains; without an endpoint they fall back to NAT, which works but means a cold-start STS call goes outbound

Three AZs × five interface endpoints (SSM + Logs + SNS + Cognito + STS) ≈ five ENIs per AZ × $7.30/month each ≈ $110/month. The two gateway endpoints (S3, DDB) are free. The bill scales with environments, so the dev environment should drop interface endpoints while quiesced.

Out of scope for the endpoint policy: VPC endpoint policies. The default “*” policy is fine for the first ship; tightening to “this VPC only” / “these IAM principals only” is a follow-on.

Latent issues that surface during this work

  1. Client-supplied body['host'] / body['smtp_host']. Every Lambda that calls get_imap_client reads the IMAP hostname out of the API request body and passes it to IMAPClient(host=...). Today this is bounded by the fact that the IMAP host’s master-user credential is in SSM and the public DNS resolves only to the Cabalmail NLB. Once the IMAP target is internal, this becomes ambient trust: a malicious or misbehaving client could in principle point the Lambda at any hostname the Lambda can resolve. The fix is to pin the target server-side in an env var (IMAP_HOST=imap.cabal.internal, SMTP_HOST=smtp-out.cabal.internal) and stop reading those fields from the request. The body['host'] is also used to derive the cache bucket name (bucket = body['host'].replace('imap', 'cache') in send/function.py:37); that derivation can move to an env var too. Folding this into the privatization is natural and removes a footgun that wasn’t worth its own ticket.

  2. LiveImapClient in CabalmailKit. Compiles and tested but unused in production. Once IMAPS is private, even a future operator who wires up LiveImapClient for diagnostics from outside the VPC will fail. Document that it’s now also a build-time-only artifact; deletion is a follow-up.

  3. Healthchecks/Kuma probes of the public IMAP/submission ports. The Phase 1 monitor set probes smtp-in.<control>:25 and the API endpoint, not 993/465/587. Worth re-checking when Phase 4 lands so we’re not staring at a flat-lined-but-not-broken IMAP monitor.

  4. MX-then-A check on the IMAP container’s check_mail rule. This is on the inbound-delivery path (smtp-in -> sendmail -> local delivery -> IMAP storage), not the public IMAP listener — unaffected. Worth recording so the reader does not assume it.

  5. Documentation that points users at standard mail clients. docs/mua_setup.md explains how to wire up Apple Mail / Thunderbird against imap.<control_domain>:993. After Phase 4 that document is wrong; it gets a clear “this guide is historical; use a native Cabalmail client” banner in Phase 6.

Phased rollout

Each phase is a single PR (or a tightly coupled pair). Phases are ordered cheap/risk-low first; later phases assume earlier ones.

Phase 1 — Pin IMAP/SMTP targets server-side. Stop trusting body['host'] / body['smtp_host']. The modules/app/modules/call/ Lambda module adds two env vars (IMAP_HOST, SMTP_HOST) populated by Terraform from the control domain. _shared/helper.py gains IMAP_HOST = os.environ.get('IMAP_HOST', ...) and the per-Lambda call sites stop reading body['host']. The cache-bucket derivation moves to an env var too. The wire contract from clients keeps accepting host / smtp_host for backwards compatibility but the Lambda ignores them. Both the React client and the Apple ApiBackedImapClient keep sending the field for now; a follow-up trims it. No infrastructure change. Ships through stage -> main.

Phase 2 — VPC endpoints. Add aws_vpc_endpoint resources for S3 and DynamoDB (gateway) and for SSM, CloudWatch Logs, SNS, Cognito-IDP, and STS (interface). Interface endpoints get a dedicated SG that accepts 443/tcp from the VPC CIDR. Gateway endpoints associate with the private route tables. Endpoint policies default to "*". No Lambdas use the endpoints yet — they keep going over NAT — but the endpoints exist so Phase 3 doesn’t have to wait. Direct-to-prod is not allowed here: this is an additive infra change but it touches the data-plane reachability surface for Lambdas in private subnets, so route through stage. The dev environment also gets the endpoints applied when it’s unquiesced for the Phase 3 validation pass.

Phase 3 — VPC-attach the API Lambda fleet. The call module is the right place: every API Lambda runs through this submodule, so adding vpc_config once flips them all. Mirror the healthchecks_iac pattern: dedicated SG per call, the AWSLambdaVPCAccessExecutionRole attachment, subnet_ids = var.private_subnet_ids, security-group rules that allow:

Phase 3.5 — smtp-out.cabal.internal Cloud Map registration. Mirror the existing imap registration in modules/ecs/service_discovery.tf for the smtp-out service. The smtp-out task definition’s service registration gets a registry_arn. Verify that smtp-out.cabal.internal:465 is reachable from a test invocation of the VPC-attached send Lambda. No public-facing impact.

Phase 4 — Cut the listeners and DNS. Three changes in one PR:

  1. In modules/elb, delete aws_lb_listener.imap, aws_lb_listener.submission, aws_lb_listener.starttls. The relay listener (port 25) stays.
  2. In modules/ecs/locals.tf, remove 143 and 993 from imap.public_ports, remove 465 and 587 from smtp-out.public_ports. The ECS tier security groups stop opening those ports from 0.0.0.0/0. (smtp-in.public_ports = [25] stays.) Add private_ports = [993] for the imap tier (so the API Lambdas’ SG can reach it via SG-to-SG; the existing private_ingress rule allows VPC CIDR, which already covers Lambda ENIs in private subnets — explicit SG-to-SG is the tighter form and goes in Phase 3 SG rules).
  3. In modules/elb/dns.tf, remove imap and smtp-out from the for_each set of aws_route53_record.cname (deleting the public A records). Update the _imaps._tcp and _submission._tcp SRV records to 0 1 0 . so autodiscoverers get a definitive “absent” answer. Update the env vars from Phase 1 to point at the internal Cloud Map names (imap.cabal.internal, smtp-out.cabal.internal). Critically: this is the only phase that removes public ingress. Verify in stage first; only promote to prod after a watching-window of at least 24h and after explicitly checking that the React webmail and the Apple client both function. Rollback is “revert the PR” — listeners and DNS records come back as they were.

Phase 5 — NLB cleanup. With the IMAPS and submission listeners gone, the ACM cert resource for the IMAP listener (and the IMAP target group, if Option 1 was chosen) is orphaned. Delete the dead Terraform: aws_lb_target_group.tier["imap"], aws_lb_target_group.tier["submission"], aws_lb_target_group.tier["starttls"], and the IMAP/submission entries in locals.tf:target_groups. The NLB itself stays (it still hosts the port-25 listener). If Option 2 was chosen (internal NLB), this phase instead promotes the staged internal NLB to be the canonical one.

Phase 6 — Docs and follow-ups.

Risks and trade-offs