All posts
Linux email servers with Python: SMTP, IMAP, and APIs
Gabe 12 min read

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.

Python connects at the protocol boundary A Python application submits outgoing MIME through SMTP to Postfix or Exim. Incoming mail is read from Dovecot, mailcow or Mailu over IMAP and parsed into a durable application inbox. HTTP APIs or signed webhooks provide alternative boundaries; the implementation language of the server is independent. Python application smtplib → MIME submission SMTP + TLS Postfix / Exim Queue, route, deliver to storage Local delivery Dovecot mailbox Also inside mailcow / Mailu IMAP + TLS Python ingestion worker imaplib → email parser → database Alternative: HTTP API / webhook Different boundary, same application job
The demo collapses routing and storage into loopback fixtures. A production mail stack separates those jobs.
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 stackPython sending boundaryPython receiving boundaryBest fit / cost you retain
Postfix / Eximsmtplib, authenticated submissionAdd mailbox service or delivery integrationExisting MTA operations; no mailbox API implied
Dovecot / mailcow / MailuStack’s SMTP submission serviceimaplib plus email parserHuman mailboxes; polling and synchronization are yours
PostalHTTP send API or SMTPIncoming HTTP endpoint, raw or processedSelf-hosted transactional platform; delivery infrastructure remains yours
WildDuckUser submission REST API with outbound infrastructureMailbox/message REST API or IMAPBuilding a mail product; MongoDB and SMTP edges to operate
StalwartSMTP or JMAP submissionJMAP or IMAPStateful mailbox applications; capability and state handling required
ModoboaUnderlying Postfix submissionUnderlying Dovecot IMAPPython control plane for accounts/domains, not a transport replacement
MailKite hostedPython SDK sendSigned, parsed inbound webhookApplication-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.

An IMAP ingestion cursor advances only with durable storage Select a mailbox and compare its UIDVALIDITY with the saved epoch. Keep the saved UID when unchanged; reset the scan on a new epoch. Fetch each later UID and parse its MIME. Commit the inbox row and cursor together. A fetch failure stops progress; downstream workers have a separate completion state. EXAMINE INBOX Read the mailbox's UIDVALIDITY Compare saved epoch Same epoch → keep last UID New epoch → rescan from zero Fetch later UIDs + parse MIME Missing literal → stop, don't skip One SQLite transaction INSERT inbox(scope, epoch, UID) UPDATE cursor after durable storage Crash before commit → retry that UID Separate application worker Ticket / CSV import / support action Track processing success independently
A cursor records what reached your durable inbox. It cannot prove a later ticket update or reply succeeded.

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.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts