rmail

Production-grade mail server stack in pure Python 3 asyncio — SMTP, IMAP4rev1, outbound relay, and Exchange HTTP API.

Outbound Relay Internals

Direct MX delivery

When relay.smarthost is empty, rmail resolves MX records for the recipient domain using a raw DNS UDP implementation in relay/mx.py. It reads the nameserver from /etc/resolv.conf and falls back to 8.8.8.8. Responses are cached by TTL (minimum 60 seconds). If no MX records are found, the domain itself is used as the mail host per RFC 5321 §5.1.

Opportunistic STARTTLS

The SMTP client in relay/client.py advertises its hostname, checks EHLO for STARTTLS, and upgrades if available. If the remote server does not support STARTTLS, delivery continues over plaintext. There is no option to require TLS for outbound connections in the current implementation.

Retry schedule

Failed messages are kept in an in-memory list with scheduled retry times. The scheduler wakes every 30 seconds and re-enqueues messages that are due. Retry delays between attempts:

AttemptDelay before retryCumulative time
21 minute1m
35 minutes6m
415 minutes21m
530 minutes51m
61 hour~2h
74 hours~6h
88 hours~14h
24 hours (final wait)~37.5h
Retry state is in-memory only. A server restart clears all pending retries. Messages in transit at shutdown are lost. For production use, consider this when planning maintenance windows.

DSN bounces

After all retries are exhausted, or on a permanent failure (5xx response code), a Delivery Status Notification is written directly to the original sender's INBOX via the backend. The bounce includes the failure reason, status code, and original message headers. Bounce-of-bounce suppression prevents loops: messages with empty senders or mailer-daemon@ as sender are never bounced. Bounces are only generated for local senders (domain in config.domains).

Smarthost configuration

To route all outbound mail through a relay host (e.g. when port 25 is blocked by the hosting provider):

{
  "relay": {
    "enabled": true,
    "smarthost": "smtp.sendgrid.net",
    "smarthost_port": 587,
    "smarthost_username": "apikey",
    "smarthost_password": "your-api-key"
  }
}

In smarthost mode, MX resolution is skipped entirely. All messages are delivered to the configured host with AUTH PLAIN credentials.

TLS Management

Auto-generated certificates

On first start with tls.cert_file: null, rmail calls openssl req -x509 to generate a 2048-bit RSA self-signed certificate valid for 3650 days. The certificate is written to {mail_root}/tls/cert.pem and the key to {mail_root}/tls/key.pem. Subsequent starts reuse the existing files.

Certificate renewal

To replace a certificate without a full restart, update the files on disk and send SIGHUP:

kill -HUP $(cat /var/run/rmail.pid)

Configuration reload re-reads tls.cert_file and tls.key_file. New connections use the new certificate; existing connections continue until closed.

Security behaviour

When TLS is configured, AUTH is omitted from EHLO responses and AUTH=PLAIN / AUTH=LOGIN from IMAP CAPABILITY on cleartext connections. Clients must issue STARTTLS and repeat EHLO/CAPABILITY before credentials are accepted. This prevents credential transmission over unencrypted channels.

Quota Enforcement

Each user has a quota_mb field in user.json (default 1024 MiB). Quotas are enforced on all append paths:

To update a user's quota, edit quota_mb in {mail_root}/domains/{domain}/users/{username}/user.json.

Scaling

The shard-based storage model supports multi-process deployments sharing a single mailstore on a network filesystem:

MetricValue
Messages per mailboxMillions (10M+ UIDs per mailbox)
Flag change latency<0.1ms
Message delivery latency~2ms
Concurrent flag changesUnlimited (atomic rename)
SELECT 100K messages~50ms
Delivery and relay queues are in-memory per-process. In multi-process deployments each process has its own independent queues.

Storage Migration

Earlier versions used per-mailbox index.json files with flat message directories. To migrate to the current shard-based format:

python run.py migrate

This converts each mailbox from index.json + msg-{uid}.eml to status.json + shard directories with flag-encoded filenames. The original index.json is renamed to index.json.migrated and preserved.

Back up the mailstore before running migration. The process is not reversible without restoring from backup.

Diagnostics

The 8-phase diagnostic suite creates a temporary test user, exercises all server components through real protocol connections, and cleans up afterward:

sudo python run.py diagnose
PhaseCoverage
1 — ConfigurationConfig load, port connectivity, TLS cert validity, relay status, MX resolver check
2 — User ManagementCreate/load/authenticate users, quota, admin flag, disabled accounts
3 — Backend StorageAppend, fetch, list, flag operations, copy, delete, quota enforcement, subscriptions, mailbox lifecycle
4 — SMTP ProtocolAUTH, delivery, relay acceptance/rejection, spoofing, rate limits, RFC compliance, STARTTLS
5 — Delivery VerificationPolls INBOX for all test messages, IDLE push notification
6 — IMAP ProtocolFull IMAP command coverage including RFC extensions: MOVE, COPY, SEARCH, IDLE, NAMESPACE, ID, ENABLE, UNSELECT, APPEND, CLOSE, RENAME
7 — Exchange APIAuth, folders, messages, flags, send, aliases, token expiry, CORS, rate limiter unit test
8 — CleanupRemove test user and any aliases created during the run

Logging

rmail uses Python's standard logging module. Logger names follow the module hierarchy:

Set log_level to DEBUG in config.json for verbose protocol-level output. This includes individual SMTP commands, IMAP exchanges, MX query results, and relay attempt details.

Custom Storage Backend

MailStoreBackend in rmail/backend/base.py is an abstract base class defining the full storage interface. All protocol code interacts only with this interface. To implement an alternative backend (e.g. database-backed or S3-backed), subclass MailStoreBackend and implement all abstract methods, then pass an instance to SMTPServer, IMAPServer, and ExchangeAPI in main.py.