rmail

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

SMTP — Sending Mail

Authenticated submission (port 587)

import smtplib, ssl

s = smtplib.SMTP("mail.example.com", 587, timeout=10)
s.ehlo("client.example.com")
s.starttls(context=ssl.create_default_context())
s.ehlo("client.example.com")
s.login("alice@example.com", "password")
s.sendmail(
    "alice@example.com",
    ["bob@example.com"],
    "From: alice@example.com\r\nTo: bob@example.com\r\nSubject: Hello\r\n\r\nBody\r\n"
)
s.quit()

Outbound relay to external domains

Authenticated users may send to any external address. rmail resolves MX records for the recipient domain and delivers directly, with opportunistic STARTTLS. Unauthenticated senders to external domains are always rejected.

s.login("alice@example.com", "password")
s.sendmail(
    "alice@example.com",
    ["recipient@gmail.com"],
    "From: alice@example.com\r\nTo: recipient@gmail.com\r\nSubject: External\r\n\r\nBody\r\n"
)
If relay.enabled is false, sending to external domains returns 550 Relay not permitted even for authenticated users.

Telnet session (port 25)

$ telnet 127.0.0.1 25
220 mail.example.com ESMTP rmail
EHLO client.example.com
250-mail.example.com
250-SIZE 52428800
250-8BITMIME
250-STARTTLS
250 ENHANCEDSTATUSCODES
MAIL FROM:<sender@external.com>
250 2.1.0 OK
RCPT TO:<alice@example.com>
250 2.1.5 OK
DATA
354 End data with <CR><LF>.<CR><LF>
From: sender@external.com
To: alice@example.com
Subject: Inbound test

Body text.
.
250 2.0.0 OK: message accepted for delivery
QUIT
221 2.0.0 mail.example.com closing connection

IMAP — Reading Mail

Python imaplib

import imaplib

imap = imaplib.IMAP4("mail.example.com", 143)
imap.login("alice@example.com", "password")
imap.select("INBOX")

# Search all messages
status, data = imap.search(None, "ALL")
uids = data[0].split()

# Fetch latest message
if uids:
    status, data = imap.fetch(uids[-1], "(BODY.PEEK[])")
    print(data[0][1].decode())

# Mark as seen
imap.store(uids[-1], "+FLAGS", "\\Seen")

imap.logout()

UID operations

# Search by subject
status, data = imap.uid("SEARCH", "SUBJECT", '"Hello"')

# Fetch by UID
imap.uid("FETCH", data[0].decode(), "(FLAGS RFC822.SIZE)")

# Move to Trash
imap.uid("MOVE", uid, "Trash")

IDLE (push notifications)

imap.select("INBOX")
imap.send(b"a001 IDLE\r\n")
# Server sends: + idling
# Wait for EXISTS untagged response when new mail arrives
# Send DONE to terminate IDLE
imap.send(b"DONE\r\n")

Exchange HTTP API

Authenticate

curl -s -X POST http://mail.example.com:9002/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"password","domain":"example.com"}'

# Response: {"token": "..."}

List folders

curl -s http://mail.example.com:9002/folders \
  -H "Authorization: Bearer $TOKEN"

List messages with pagination

curl -s "http://mail.example.com:9002/folders/INBOX/messages?offset=0&limit=25" \
  -H "Authorization: Bearer $TOKEN"

Get a message (raw RFC822)

curl -s http://mail.example.com:9002/folders/INBOX/messages/42 \
  -H "Authorization: Bearer $TOKEN"

Update flags

curl -s -X PATCH http://mail.example.com:9002/folders/INBOX/messages/42/flags \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"flags":["\\Seen","\\Flagged"]}'

Send a message

curl -s -X POST http://mail.example.com:9002/messages/send \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"to":["bob@example.com"],"subject":"Hello","body":"Message body."}'

Delete a message

curl -s -X DELETE http://mail.example.com:9002/folders/INBOX/messages/42 \
  -H "Authorization: Bearer $TOKEN"

API endpoints reference

MethodPathAuthDescription
POST/auth/loginNoneAuthenticate, returns JWT
GET/foldersUserList folders with counts
GET/folders/{folder}/messagesUserList messages (offset, limit)
POST/folders/{folder}/messagesUserAppend raw RFC822 message
GET/folders/{folder}/messages/{uid}UserFetch message body
DELETE/folders/{folder}/messages/{uid}UserDelete message
PATCH/folders/{folder}/messages/{uid}/flagsUserReplace flags
POST/messages/sendUserCompose and send
GET/aliasesUserList domain aliases
POST/aliasesAdminAdd alias rule
DELETE/aliases/{pattern}AdminRemove alias by pattern
GET/.well-known/autoconfig/mail/config-v1.1.xmlNoneThunderbird autoconfig
POST/autodiscover/autodiscover.xmlNoneExchange autodiscover
OPTIONS*NoneCORS preflight (204)

CLI Commands

CommandDescription
python run.pyStart the mail server (uses config.json)
python run.py /path/to/config.jsonStart with a specific config file
python run.py initGenerate default config.json
python run.py installInstall as systemd service
python run.py uninstallRemove systemd service
python run.py migrateMigrate from index.json storage to shard storage
python run.py diagnoseRun 8-phase diagnostic suite
python run.py testRun integration test suite
python run.py alias list example.comList aliases for domain
python run.py alias add example.com ".*" aliceAdd catch-all alias
python run.py alias remove example.com "sales-.*"Remove alias

Terminal client

python -m rmail.client

Curses-based TUI for browsing mailboxes, reading and composing messages. Connects over IMAP and SMTP.

Aliases and Catch-All

Aliases route addresses matching a regex pattern to an existing local mailbox. Resolution follows exact user lookup first, then pattern rules in declaration order — consistent with the Postfix model.

# Route all unmatched addresses to alice
python run.py alias add example.com ".*" alice

# Route sales-* to the sales mailbox
python run.py alias add example.com "sales-.*" sales

# Remove a rule
python run.py alias remove example.com "sales-.*"
Alias rules are per-domain and stored in {mail_root}/domains/{domain}/aliases.json.

Signal Handling