Linux email servers with Python: SMTP, IMAP, and APIs
For Python backend developers, open-source Linux email servers integrate through protocols, not shared implementation languages. This tutorial compares SMTP, IMAP, REST, and JMAP, then runs a local send-and-receive pipeline with durable cursors and a hosted MailKite SDK alternative.
from fixtures import mail_servers
from mailflow import connect_imap, message, open_db, poll, submit
with mail_servers() as (box, smtp_port, imap_port):
submit(message(), "127.0.0.1", smtp_port, local=True)
db = open_db(":memory:")
with connect_imap("127.0.0.1", imap_port, "fixture", "fixture", local=True) as conn:
print(poll(conn, db, "loopback/fixture/INBOX"))
print("Repeat poll:", poll(conn, db, "loopback/fixture/INBOX"))
db.close()
Run quickstart.py in the companion demo after installing its pinned requirements. It starts real TCP listeners, submits a multipart message, retrieves an IMAP literal, and records it in SQLite. The second poll returns no new records. Nothing leaves your machine.
Choose Linux email servers by their Python integration boundary
The server doesn’t need to be written in Python. Postfix and Exim speak SMTP; a Python application speaks SMTP through smtplib. Modoboa being written in Python helps when extending its management layer, but doesn’t make its underlying mail transport a Python library. The Linux email-server roundup covers the broader project selection.
| Server or stack | Python sending boundary | Python receiving boundary | Best fit / cost you retain |
|---|---|---|---|
| Postfix / Exim | smtplib, authenticated submission | Add mailbox service or delivery integration | Existing MTA operations; no mailbox API implied |
| Dovecot / mailcow / Mailu | Stack’s SMTP submission service | imaplib plus email parser | Human mailboxes; polling and synchronization are yours |
| Postal | HTTP send API or SMTP | Incoming HTTP endpoint, raw or processed | Self-hosted transactional platform; delivery infrastructure remains yours |
| WildDuck | User submission REST API with outbound infrastructure | Mailbox/message REST API or IMAP | Building a mail product; MongoDB and SMTP edges to operate |
| Stalwart | SMTP or JMAP submission | JMAP or IMAP | Stateful mailbox applications; capability and state handling required |
| Modoboa | Underlying Postfix submission | Underlying Dovecot IMAP | Python control plane for accounts/domains, not a transport replacement |
| MailKite hosted | Python SDK send | Signed, parsed inbound webhook | Application-owned addresses; service dependency and hosted data path |
Use Dovecot’s IMAP interface for mailbox contents, not a bundle’s administrative API. mailcow’s client documentation specifies IMAPS on 993 and submission on 587 or 465. Mailu’s container architecture changes deployment, not the Python client protocol. Modoboa explicitly describes itself as a management interface for Postfix and Dovecot.
Send through Postfix or Exim, not directly to every recipient’s MX
Submission delegates delivery to the MTA. In mailflow.py, submit() uses SMTP(host, port, timeout=10), EHLO, STARTTLS with a verified SSL context, EHLO again, then login and send_message(). The local fixture takes an explicit loopback-only cleartext branch. For an implicit-TLS listener on 465, use SMTP_SSL rather than upgrading the connection.
Python’s SMTP documentation specifies the post-STARTTLS EHLO and distinguishes the envelope from headers. send_message() builds the envelope from message headers unless you supply addresses explicitly. It also removes Bcc headers from the transmitted message. Its return value is a mapping of refused recipients; partial acceptance is possible, so an empty exception log isn’t sufficient accounting.
Both Postfix’s SASL guide and Exim’s TLS guide expose the server-side pieces. Authentication doesn’t itself authorize arbitrary relay or arbitrary From addresses. Configure those policies on the server; the Python login cannot override them.
A successful SMTP response means the next hop accepted responsibility, not that the recipient read the message. Keep your application job ID, submission outcome, and later bounce state separate. A disconnect after DATA can leave acceptance uncertain. Blindly retrying that job can duplicate mail; SMTP offers no universal application idempotency key.
Receive with imaplib, then parse MIME as bytes
IMAP retrieves stored messages; it doesn’t replace the inbound MX. A support ingestion worker connects to the mailbox service after the MTA has delivered there. connect_imap() uses IMAP4_SSL with ssl.create_default_context() for production, sets a read timeout, and logs in. poll() selects INBOX read-only and fetches BODY.PEEK[] to avoid marking messages read.
The returned literal belongs in Python’s MIME parser, not a UTF-8 decode followed by string splitting:
from email import policy
from email.parser import BytesParser
from mailflow import message
raw = message().as_bytes(policy=policy.SMTP)
msg = BytesParser(policy=policy.default).parsebytes(raw)
plain = msg.get_body(preferencelist=("plain",))
print(str(msg["Subject"]))
print(plain.get_content() if plain else None)
print([part.get_filename() for part in msg.iter_attachments()])
This is mime_example.py. The parser decodes the accented subject, selects the plain-text body, and identifies ticket.csv. Explicit policy.default gives an EmailMessage rather than relying on the legacy default. HTML-only mail has no plain body; don’t silently relabel HTML as text.
Preserve raw bytes alongside parsed fields so you can reprocess parser defects later. Attachment filenames aren’t safe filesystem paths. The demo only records names; a production data pipeline should bound message and attachment sizes before downloading or processing them, and inspect parser defects instead of treating successful parsing as proof of valid or trustworthy mail.
A persistent UID cursor is ingestion state, not mailbox synchronization
Resume with server/account/folder scope, UIDVALIDITY, and UID. A message sequence number is a position that changes when earlier messages disappear. A UID is stable within a mailbox epoch; UIDVALIDITY identifies that epoch. The IMAP specification also makes clear that UIDs needn’t be contiguous.
The demo persists each inbox row and cursor update in one SQLite transaction. Tests reopen the database, inject a missing literal halfway through a batch, and rebuild a mailbox with a new UIDVALIDITY. The failed UID isn’t skipped. A changed epoch triggers a rescan, which can ingest old logical messages under new identities.
There’s another trap: with IMAP4rev1, a last+1:* range can return the current maximum even when no new mail exists. The demo filters returned UIDs against the saved high-water mark. It checks command status and scans FETCH tuples for the requested UID rather than assuming data[0][1] is always the message; imaplib documents unsolicited responses.
This remains a new-message ingester. It misses later flag changes and deletions, can’t recover messages expunged before polling, and needs one worker owner per scope. Full mirroring needs reconciliation or supported CONDSTORE/QRESYNC capabilities. IDLE notifications reduce waiting, but aren’t a durable event log; reconnect and rescan after interruption.
Predict the next poll: UID 1 commits, but fetching UID 2 returns no message literal. Should the saved cursor advance to 2? What happens when that fetch recovers?
Reveal the interrupted-batch exercise and verify cursor recovery
The failed poll raises an error for UID 2 and leaves last_uid at 1. Once the fixture's fetch failure is cleared, the next poll ingests one record; the following poll returns an empty list. Earlier committed work survives, while the failed UID remains eligible. Advancing the cursor before storing the literal would lose that retry path.
In python/, with the README's CPython environment and requirements installed, run python -m unittest discover -s tests -v -k partial_batch_failure. Inspect the cursor and recovery assertions in test_partial_batch_failure_does_not_skip_failed_uid.
For a contrasting identity exercise, run python -m unittest discover -s tests -v -k uid_gap_and_epoch_rebuild. A UIDVALIDITY change makes the same MIME a new mailbox occurrence and leaves two inbox rows. This is ingestion identity, not logical-message deduplication or proof that downstream processing finished.
Postal, WildDuck, and Stalwart expose different HTTP contracts
HTTP removes the IMAP conversation, not state or retry semantics. The demo’s http_adapters.py contains tested request builders for these alternatives, using local HTTP captures rather than installed servers.
Postal’s send endpoint is /api/v1/send/message, authenticated with X-Server-API-Key. Send plain_body, not MailKite’s text, and inspect JSON status as well as the HTTP outcome. For receiving, configure a processed incoming HTTP endpoint and consume rcpt_to and plain_body. Its documentation says 5xx responses fail immediately, unlike many webhook providers’ retry behavior. Don’t transplant another provider’s retry assumptions.
WildDuck lists mailbox messages through GET /users/:user/mailboxes/:mailbox/messages, with configured access-token authentication. Fetch message source when you need original MIME. Sending uses the distinct user submission endpoint; storing or uploading a message isn’t sending it. Its Haraka/ZoneMTA edges remain separate operational components.
Stalwart supports JMAP, a standardized JSON mailbox protocol. Discover the Session object, use its apiUrl and account IDs, then issue Email/query and Email/get; sending uses EmailSubmission/set for an existing email and identity. Check per-method errors inside a successful HTTP response. JMAP state tokens and Email/changes support richer synchronization than a UID high-water mark, but the client guide still requires fallback when changes cannot be calculated.
MailKite’s hosted Python SDK trades server operations for a service boundary
An application inbox needs durable acceptance, parsing, authentication, and retry handling. You can assemble those around Postfix/Dovecot or run Postal. MailKite, which we build, is a hosted alternative when the recipient is your application and you want parsed, signed events instead of operating the receiving stack. It’s not a replacement for mailcow’s groupware.
Install the official distribution mailkite-dev==0.20.0; import MailKite from mailkite. The constructor takes apiKey and optional baseUrl. send(message) takes a dictionary, not keyword arguments. The runnable hosted_demo.py directs that real SDK at a loopback capture endpoint and feeds synthetic signed inbound data into this handler from hosted.py:
import json
from mailkite import MailKite, reply_ok
def accept(signature, raw, secret, db):
if not MailKite.verifyWebhook(signature, raw, secret):
raise PermissionError("invalid or stale signature")
event = json.loads(raw)
if event.get("type") != "email.received":
raise ValueError("expected hosted email.received, not self-host metadata")
# Single durable inbox row; a separate worker handles it after acknowledgement.
with db:
db.execute("INSERT OR IGNORE INTO webhook_inbox VALUES (?,?)", (event["id"], raw))
return reply_ok()
In Django, pass the signature header and untouched request.body; acknowledge only after the inbox transaction commits. Hosted events carry from.address, to[].address, nullable text, html, textFromHtml, and threadId. Deduplicate with stable id, not threadId. Treat a null authentication verdict as unknown. The SDK source and signature documentation define the verification boundary.
The self-hosted Server contract is narrower than hosted API parity. Its inspected local webhook implementation emits event: inbound, scalar from, rcpt, uid, and raw_url, with seconds-based signatures. The hosted SDK verifier expects milliseconds. Reusing this handler unchanged fails; disabling freshness isn’t a fix. Use IMAP for a portable receive adapter or implement the local contract explicitly. See building a Linux email provider for that operational path.
Keep blocking email work outside async request handlers
Synchronous clients belong in synchronous workers or bounded executor threads. smtplib, imaplib, and the inspected MailKite Python SDK block. An async def wrapper doesn’t change that. Use Celery for submission and inbox processing; an async service can offload a bounded call with asyncio.to_thread, but cancellation doesn’t necessarily terminate the underlying socket operation.
For Django support tickets, store the inbound event, then let a worker update the ticket and create an outbound outbox job. Queue dispatch alone isn’t atomic with a database commit; a durable outbox lets a dispatcher recover missed scheduling. For CSV pipelines, checkpoint ingestion separately from import completion. For automatic replies, consult Linux email reply automation before adding model work to the receive path.
The demo README documents setup, limits, and local tests. It proves protocol exchanges and application persistence, not production TLS, DNS, or deliverability. The programmatic-server guide covers boundary selection; the Node.js sibling compares another client’s concurrency model. Open a repository issue with a failing fixture when an integration contract changes.
Sources and contracts checked October 5, 2026. Recheck SDK releases, server payloads, and linked protocol documentation by November 4, 2026; the demo’s research ledger records retrieval dates and exact source paths.