All posts
Receive email in real time with Python
Gabe 7 min read

Receive email in real time with Python

A Flask receiver can turn each new email into a stored JSON event without polling an inbox. This Python walkthrough verifies MailKite's signed webhook, commits the message to SQLite before acknowledging it, and handles retries with a unique message id. MailKite is the platform we build.

Email reaches MailKiteSigned JSON → Flask request.get_data()SDK verifies raw bytes SQLite commitsThe inbox owns a copy Return 200 OKAccepted, not processed
The acknowledgement belongs after storage, not after a print statement.

The runnable source and delivery checks live in the shared receiving-email demo repository.

Save this as receive.py. It is the same file offered by the Python receiving guide and the downloadable receiver.

import json
import os
import sqlite3
from flask import Flask, request
from mailkite import verify_webhook

app = Flask(__name__)
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
DB = os.environ.get("INBOX_DB", "inbox.sqlite")
with sqlite3.connect(DB) as db:
    db.execute("CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, payload TEXT NOT NULL)")

@app.post("/hooks/mailkite")
def receive():
    raw = request.get_data()
    signature = request.headers.get("x-mailkite-signature", "")
    if not verify_webhook(signature, raw, SECRET):
        return "bad signature", 401
    try:
        event = json.loads(raw)
    except (ValueError, UnicodeDecodeError):
        return "bad JSON", 400
    if not isinstance(event, dict):
        return "bad event", 400
    if event.get("type") != "email.received":
        return {"status": "ok"}
    if not isinstance(event.get("id"), str) or not event["id"]:
        return "missing id", 400
    try:
        with sqlite3.connect(DB, timeout=5) as db:
            db.execute("INSERT INTO inbox (id, payload) VALUES (?, ?) ON CONFLICT(id) DO NOTHING",
                       (event["id"], raw.decode("utf-8")))
    except sqlite3.Error:
        return "storage unavailable", 503
    return {"status": "ok"}  # The transaction has committed before this response.

if __name__ == "__main__":
    app.run(port=int(os.environ.get("PORT", "3000")))

Install the distribution, import the module

Install mailkite-dev from PyPI; the module you import is mailkite. Before starting Flask, add and verify a receiving domain, create an exact-address route such as inbound@your-receiving-domain to your public HTTPS /hooks/mailkite URL, and obtain that route’s signing secret. A tunnel can forward the public URL to local port 3000. Supply the secret as MAILKITE_WEBHOOK_SECRET through your environment or secret manager. Then use a virtual environment:

python3 -m venv .venv
source .venv/bin/activate
python -m pip install mailkite-dev flask
python receive.py

This receiver makes no authenticated API calls, so it needs no MailKite API key. Use the exact route’s secret, not another route’s secret. INBOX_DB optionally selects the SQLite file. The default is inbox.sqlite in the current directory.

Flask’s development server is for the local demonstration. Deploy the application through your production WSGI server, expose an HTTPS URL, and mount persistent storage for SQLite. An ephemeral filesystem is not a durable inbox across redeployments.

Keep the signed request separate from the parsed email

The signature covers the original body, not an equivalent JSON object. request.get_data() returns the bytes that arrived; json.loads(raw) parses those same bytes only after verification. Re-serializing a parsed object can change whitespace or Unicode escaping and invalidate the signature.

The SDK also checks the timestamp’s freshness. A stale request and a valid request with a modified body should both fail. A webhook signature proves that MailKite delivered the event; it doesn’t certify every claim made by the email’s sender. Use the separate auth verdicts and your application policy when sender trust matters.

The webhook security reference explains the signing protocol. Keep the verification secret out of client-side code and avoid logging full request bodies in production, especially when the mailbox contains account confirmations or customer data.

A retry is another delivery of the same message

The primary key is the stable message id. The insert ignores a conflict on that key, so a repeated delivery can return success without adding another inbox row. It does not overwrite the earlier accepted payload.

First delivery insertsOne id, one inbox row Retry has the same idUnique key: no new row Both return 200After storage accepts Separate workerProcesses stored rows
Deduplicating storage is a boundary, not a promise of exactly-once external actions.

The SQLite context manager commits before the response is returned. If storage fails, the handler returns 503 rather than claiming success. A background worker can then process accepted rows and track its own completion state. Calling an external API still needs its own idempotency strategy: a crash after that API call but before recording completion can repeat the action.

For several replicas, replace the local database with shared durable storage or a queue whose acceptance you can await. Don’t start a detached thread, return 200, and assume the thread will finish if the process is terminated.

Choose the email entry point

For a new application address, use the exact MX records supplied in the dashboard or an available, verified hosted subdomain. DNS propagation and SMTP delivery have no fixed duration. The route created before launch should match the receiving address exactly.

Download the synthetic checker as check.mjs. With Node.js 18+ and the receiver’s MAILKITE_WEBHOOK_SECRET set in a second terminal, check the running Flask endpoint:

node check.mjs http://127.0.0.1:3000/hooks/mailkite

It checks valid and duplicate events, tampered bodies, missing signatures and signed malformed JSON. This checks HTTP behavior without sending email. It does not prove MX or route matching works. The dashboard’s domain test sends to the catch-all *@domain webhook, so it is not an exact-route check. Next, send a real email directly to your exact receiving address and check its delivery record.

Download the inbox inspector and inspect the stored payload locally:

python3 inspect-inbox.py inbox.sqlite

Pass the actual database path if you set INBOX_DB. The inspector prints the latest ten messages, including their bodies; keep the terminal output private and never expose it as a web route. Its output helps inspect content but is not a database row-count assertion. Replay the real delivery and verify that its id still has only one row. The checker returning 200 for a duplicate alone does not prove that storage deduplicated it.

For an existing personal inbox, keep its current provider and forward new messages to that separate receiving address. The Gmail forwarding guide includes the destination-confirmation step; the iCloud Mail guide uses server-side forwarding. Neither path imports historical mail or synchronizes mailbox read state.

“Real time” here means push delivery after arrival instead of a polling interval. If you need Gmail labels, Gmail drafts or operations performed as the Gmail user, use the Gmail API instead. For a domain you control, Postmark inbound or SES receipt rules are alternatives; a self-hosted Haraka edge can POST mail into your application, but you own MIME parsing and delivery retries.

Process the event without inventing missing values

The event fields are at the top level. from is an address object and to is an array of address objects. The subject and body can be null. Use text first and textFromHtml when the original message has only an HTML part.

Store attachment metadata with the event, then download needed files before their signed URLs expire. Do not make the webhook wait on every attachment download. Preserve null authentication results as unknown rather than interpreting them as a pass.

Use the receiving event reference for the complete shape and delivery reference for retries and replay. After adding a worker, check its job count and side effects separately from the inbox row count. The downloadable Python receiver is the code used throughout this article.

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

Related posts