Receive email in Python (Django & FastAPI)
Install mailkite-dev, point a domain at MailKite, and receive parsed inbound email as JSON in a Django view or a FastAPI handler. The one rule that matters: verify the signature against the raw request body — request.body in Django, await request.body() in FastAPI.
Receiving email in Python means MailKite parses the incoming MIME at the edge and POSTs your Django or FastAPI app one webhook with the whole message already decoded to JSON — you verify the x-mailkite-signature header against the raw request body, then read from, subject, text, html, and attachments as plain dict fields. No email module, no quoted-printable decoding, no charset guessing. The only Python-specific detail is grabbing the raw bytes: request.body in Django, await request.body() in FastAPI.
I’ve written the email.parser version of this in Python more than once, and it’s the kind of code that works on your test messages and then falls over the first time a real Outlook client sends a winmail.dat. This is the version where the parsing already happened before your code runs.
What arrives
Point a domain at MailKite (add the MX record, verify), set your webhook URL, and every inbound message to any address on that domain gets parsed and POSTed to you:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "support@myapp.ai" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{
"id": "msg_2Hk9…:0",
"filename": "po.pdf",
"contentType": "application/pdf",
"size": 18213,
"url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…"
}
]
}
text and html come pre-decoded — the £ is a £. Attachments are handed to you as short-lived signed URLs so a big PDF never rides along in the request body. And auth gives you SPF/DKIM/DMARC results, which is how you know a From: address is real.
Install
pip install mailkite-dev
import os
from mailkite import MailKite, verify_webhook
mk = MailKite(os.environ["MAILKITE_API_KEY"])
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
verify_webhook(sig, raw_body, secret) recomputes the HMAC, compares it in constant time, and rejects anything outside a ±5-minute replay window. The one thing you must get right is feeding it the raw body bytes — not a re-serialized dict.
FastAPI
FastAPI (and Starlette) give you the raw bytes with await request.body(). Read those for verification; only parse to a dict after the signature checks out.
# main.py
import os, json
from fastapi import FastAPI, Request, Response
from mailkite import MailKite, verify_webhook
app = FastAPI()
mk = MailKite(os.environ["MAILKITE_API_KEY"])
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
@app.post("/hooks/mailkite")
async def mailkite_hook(request: Request):
raw = await request.body() # exact bytes, for the HMAC
sig = request.headers.get("x-mailkite-signature")
if not verify_webhook(sig, raw, SECRET):
return Response(status_code=401)
event = json.loads(raw)
if event["type"] == "email.received":
handle_email(event)
return Response(status_code=200) # ack fast; queue the slow work
def handle_email(event: dict) -> None:
sender = event["from"]["address"]
subject = event.get("subject", "")
body = event.get("text", "") # already decoded
auth = event.get("auth", {})
trusted = auth.get("spf") == "pass" and auth.get("dmarc") == "pass"
for att in event.get("attachments", []):
# att["url"] is a short-lived signed link — fetch on demand
print("attachment:", att["filename"], att["contentType"], att["size"])
# …persist a ticket, enqueue a job, hand it to an agent
if trusted:
mk.send({
"from": "support@myapp.ai",
"to": sender,
"subject": f"Re: {subject}",
"html": "<p>Thanks — we got your message and will reply shortly.</p>",
})
The Flask version is identical in spirit: use request.get_data() for the raw body, verify_webhook(sig, request.get_data(), SECRET), then abort(401) on failure.
Django
Django hands you the raw request body on request.body — it’s bytes, untouched. That’s exactly what the signature is computed over, so no middleware gymnastics are needed. Exempt the view from CSRF (this is a machine-to-machine POST, authenticated by HMAC, not a browser form).
# views.py
import os, json
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from mailkite import MailKite, verify_webhook
mk = MailKite(os.environ["MAILKITE_API_KEY"])
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
@csrf_exempt
@require_POST
def mailkite_hook(request):
raw = request.body # raw bytes — verify these
sig = request.headers.get("x-mailkite-signature")
if not verify_webhook(sig, raw, SECRET):
return HttpResponseForbidden("bad signature")
event = json.loads(raw)
if event["type"] == "email.received":
auth = event.get("auth", {})
Ticket.objects.create(
from_address=event["from"]["address"],
subject=event.get("subject", ""),
body=event.get("text", ""), # pre-decoded text
html=event.get("html", ""),
thread_id=event.get("threadId"), # groups replies together
spam=auth.get("spam"),
trusted=auth.get("spf") == "pass" and auth.get("dmarc") == "pass",
)
return HttpResponse(status=200) # ack fast
# urls.py
from django.urls import path
from .views import mailkite_hook
urlpatterns = [path("hooks/mailkite", mailkite_hook)]
One Django gotcha worth knowing: if any middleware or a request.POST access reads the stream first, request.body can raise RawPostDataException. Read request.body early in the view (as above) and you’re fine.
The two rules
Verify the raw bytes. Both request.body (Django) and await request.body() (FastAPI) give you the exact bytes MailKite signed. If you parse to a dict and re-serialize with json.dumps, key order and whitespace change and the HMAC won’t match. Verify first, parse second.
Ack fast, work later. Return 200 immediately, then do slow work — AI summarization, a third-party API call, image processing — in a Celery task or a background worker. Senders retry on timeouts, and a nine-second handler earns you duplicate messages.
FAQ
How do I get the raw request body in Django vs FastAPI?
Django exposes it as request.body (bytes, unparsed). FastAPI/Starlette give it via await request.body(). Pass those exact bytes to verify_webhook — don’t reconstruct the body from a parsed dict, or the signature check fails.
Which package do I install?
pip install mailkite-dev, then from mailkite import MailKite, verify_webhook. The import name is mailkite; the distribution on PyPI is mailkite-dev.
Do I have to disable CSRF on the Django view?
Yes — decorate it with @csrf_exempt. The webhook is a server-to-server POST with no browser session or CSRF token; its authenticity comes from the HMAC signature you verify, which is stronger than CSRF for this case.
How do I handle attachments in Python?
Each attachment in event["attachments"] has a short-lived signed url. Fetch it on demand (e.g. with httpx or requests) and stream it to storage. Nothing large rides inside the webhook body, so your handler stays fast.
How do I know the sender isn’t spoofed?
Read event["auth"] — spf, dkim, dmarc, and a spam verdict. Don’t trust the From: header on its own (it’s plain text), and always verify the webhook signature so you know the POST really came from MailKite.
That’s inbound email in Python: parsed JSON in, tickets or replies out, in a Django view or a FastAPI handler. Point a domain at MailKite and send a test message — the webhook fires in seconds.
Related: Receiving email is the part nobody warns you about — why inbound is the hard direction, and Build a support inbox in Next.js for the JavaScript side of the same loop.