jelius/infra

Mail Infrastructure

Architecture, routing, and maintenance guide for the serverless ingress mail pipeline.

View on Git

The email architecture for this domain relies on a decoupled, serverless ingress pipeline. Instead of running a public-facing MTA (Mail Transfer Agent) on the VPS, incoming mail is handled entirely by AWS SES, stored in S3, queued via SQS, and pulled down internally by a custom fetcher daemon before being handed off to Dovecot.

This guarantees that even if the mail server goes offline for maintenance or a reboot, no incoming emails are lost. They simply queue up in AWS SQS until the server comes back online to process them.

Architecture Overview

The exact flow of an incoming email looks like this:

Internet → SES Receiving → S3 (raw email stored) → S3 Event → SQS → Mailfetcher → LMTP → Dovecot

  1. Internet & DNS: An external sender routes an email to @jelius.dev based on public MX records pointing to AWS SES.
  2. SES Receiving: AWS SES accepts the email, scans it for spam/viruses, and evaluates it against a defined Receipt Rule.
  3. S3 Storage: If the rule matches, SES writes the raw .eml payload to an S3 bucket.
  4. SQS Queuing: The S3 bucket fires an ObjectCreated:Put event directly to an SQS queue.
  5. Mailfetcher: A custom internal service running on the VPS continuously polls the SQS queue. When an event arrives, it downloads the .eml file from S3, parses the headers to determine the recipient, and pushes it via LMTP to the local Dovecot instance.
  6. Dovecot: Dovecot authenticates the internal delivery and sorts the email into the correct virtual maildir.

Infrastructure Provisioning (AWS)

Setting this up requires wiring together DNS and a few specific AWS services with strict IAM policies.

1. DNS Configuration

Before SES will accept mail on your behalf, the domain's DNS must route traffic to it and authorize it.

  • MX Record: Point the domain's MX record to the SES inbound endpoint for your region (e.g., 10 inbound-smtp.ap-northeast-1.amazonaws.com).
  • TXT Records (Identity & Auth): You must configure the standard suite of email authentication records. SES will provide a TXT record to verify domain ownership, plus three CNAME records for DKIM. You also need a standard SPF record authorizing SES (v=spf1 include:amazonses.com ~all) and a DMARC policy.

2. S3 Bucket

Create a standard, private S3 bucket. Block all public access. This acts purely as a temporary holding zone for incoming .eml files.

3. SQS Queue & Policy

Create a standard SQS queue. This is what gives the architecture its durability.

You must attach an access policy that explicitly allows the S3 bucket to publish events to this queue:

{
  "Principal": { "Service": "s3.amazonaws.com" },
  "Action": "SQS:SendMessage",
  "Condition": {
    "ArnLike": { "aws:SourceArn": "arn:aws:s3:::<your-s3-bucket-name>" }
  }
}

Once the policy is attached, go to the S3 bucket's properties and configure an Event Notification that sends all ObjectCreated:Put events to this SQS queue.

4. SES Receipt Rule

In the AWS Console under SES Email Receiving, create an active rule set and add a rule named PushToS3.

  • TLS: Optional (enforces TLS if the sender supports it).
  • Spam/Virus Scanning: Enabled.
  • Recipient Conditions: Explicitly list the addresses you want to receive mail for (e.g., contact@jelius.dev, dmarc@, personal@, trash@, work@).
  • Action: Add an S3 Action targeting <your-s3-bucket-name>.

Important: The action must be S3 only. Do not add an SNS action here, as that fundamentally changes the event structure the fetcher relies on.


The Mailfetcher Daemon

Because the pipeline goes S3 → SQS, the SQS message is an S3 Event Notification, not an SES event notification.

An S3 event contains only metadata (Bucket name, Object key, file size). It contains absolutely no SMTP envelope information. The Mailfetcher has no idea who the email is for just by reading the queue. It has to download the raw .eml file and manually parse the headers to figure out the routing.

Recipient Parsing Logic

The ExtractRecipientsFromHeaders function in the Mailfetcher parses the raw email in this strict priority order to ensure accurate delivery:

  1. Delivered-To header
  2. X-Original-To header
  3. Envelope-To header
  4. Received headers (scans for the for <address> pattern, newest first)
  5. Fallback: Parses To and Cc headers and collects all @jelius.dev addresses. (Note: This was patched on 2026-04-30 to collect an array of all matching addresses, fixing a bug where CC'd internal users were dropped if they weren't the first address found).
  6. Trash Fallback: If all else fails, it routes the payload to trash@jelius.dev.

Installation

The Mailfetcher is deployed as a standalone binary managed by systemd:

Note: The following command will not work as the repository is private for now, I will clean it up later and make it public.

# Download the binary
sudo curl -L https://git.jelius.dev/jelius-sama/mailfetcher/releases/download/v2.0.0/mailfetcher_linux_amd64 -o /usr/local/bin/mailfetcher
sudo chmod +x /usr/local/bin/mailfetcher

# Download and install the systemd unit
sudo curl -L https://git.jelius.dev/jelius-sama/mailfetcher/releases/download/v2.0.0/mailfetcher.service -o /etc/systemd/system/mailfetcher.service

# Enable and start the daemon
sudo systemctl daemon-reload
sudo systemctl enable --now mailfetcher.service

Known Limitations: The Internal BCC Quirk

There is one specific architectural blind spot: BCCing from your own domain to your own domain does not work.

If you send an email from personal@jelius.dev and BCC work@jelius.dev, the work address will never receive the email.

Why? Per RFC 5322, SES (and any compliant mail server) strips BCC recipients from the email headers before transmission to maintain anonymity. Because our SES receiving rule relies strictly on dropping the raw file into S3 (which has no envelope data), the Mailfetcher parses a file where the work@ address literally does not exist. It sees only the To and Cc fields, and ignores the BCC entirely.

(Note: External BCCs to Gmail/Proton work perfectly fine. SES handles those via separate SMTP envelope transactions directly to those external MX servers. Our internal fetcher is completely bypassed in that scenario.)

The Hypothetical Fix: If internal BCCs ever become a hard requirement (e.g., for compliance archiving or audit logging), the architecture must change to: SES Receiving → SNS → Mailfetcher.

SES publishes to SNS with the full event format, which includes ses.receipt.recipients (the true SMTP envelope, retaining BCCs). However, introducing SNS to the path means that if the VPS goes down, the SNS push notification could be missed, resulting in potential data loss. I currently prefer the absolute durability of the S3→SQS route over the ability to internally BCC.


Outbound Mail (SES Sending)

Outbound mail is completely divorced from the complex ingress pipeline. It operates as a standard, dead-simple SMTP relay.

To send mail, local clients (or applications) simply authenticate against the AWS SES SMTP endpoint for the region (e.g., email-smtp.ap-northeast-1.amazonaws.com) on port 587 using the SMTP credentials generated in the AWS SES console. It behaves exactly like any standard outgoing mail server. For automated services, the AWS SDK can also be used to send mail directly via API without touching SMTP.


Dovecot Access & Maintenance

Dovecot acts as the local storage and IMAP server for client access. Mailboxes are virtual and entirely disjointed from system users.

  • User Database: Virtual users are stored in /etc/dovecot/passwd.
  • Password Format: Hashes must be generated in {SHA512-CRYPT} format.
  • PAM Fallback Warning: Dovecot is configured to fall back to PAM. If you check the auth logs and see pam_unix(dovecot:auth): authentication failure, it means the password failed the local /etc/dovecot/passwd check and fell through to PAM (which will also fail). Ensure the hash in the passwd file is correct.
  • User Management: I wrote a custom wrapper script located at /usr/local/sbin/mail-mgmt to handle account creation and password resets cleanly.

Certificate Hygiene

Dovecot loads TLS certificates into memory on startup and does not automatically reload them when they renew on disk. A post-run hook is required in the certbot cron job that renews certificate to run systemctl restart dovecot whenever a new certificate is issued.

Key Debugging Commands

# Test Dovecot authentication bypassing the mail client
doveadm auth test work@jelius.dev yourpassword

# Manually generate a new password hash to paste into /etc/dovecot/passwd
doveadm pw -s SHA512-CRYPT

# Watch Dovecot logs live
sudo journalctl -u dovecot -f

# Force resync a mailbox index if a client gets out of sync
doveadm force-resync -u work@jelius.dev INBOX

# Check account password/expiry status (if PAM user applies)
sudo passwd -S work
sudo chage -l work

On this page