FastAPI + MailKite
FastAPI has no swappable "mailer" interface the way Django or Laravel do —
fastapi-mail and fastapi-mailman both wrap
aiosmtplib directly. So there's no MailKite package to install here — instead,
a runnable starter app and the one thing that's easy to get wrong: calling a
synchronous SDK from an async framework without blocking
the event loop.
What you need
- A verified domain with SPF + DKIM published
- Your API key (
mk_live_…) - Python 3.9+, FastAPI, and
uvicorn
Get the starter running
The full app — an HTML form plus a JSON endpoint — lives in
starters/fastapi/ in the MailKite monorepo:
cd starters/fastapi
pip install -r requirements.txt
cp .env.example .env # then edit .env with your real key # .env
MAILKITE_API_KEY=mk_live_your_key_here
MAILKITE_FROM=hello@yourdomain.com export MAILKITE_API_KEY=mk_live_your_key_here
uvicorn main:app --reload
Open http://127.0.0.1:8000/ for the form, or send straight from the terminal:
curl -X POST http://127.0.0.1:8000/api/send \
-H "Content-Type: application/json" \
-d '{"to":"ada@example.com","subject":"Hi","html":"<p>Hello</p>"}' The one thing to get right: don't block the event loop
MailKite's Python SDK (mailkite-dev, import mailkite) is
deliberately zero-dependency — it calls urllib.request under the hood, not an
async HTTP client. Call it directly from an async def path operation and you
block the whole event loop for the length of the request:
# Don't do this in an async def route — mk.send() blocks on network I/O,
# so it stalls the event loop (and every other concurrent request) for the
# length of the HTTP round trip.
@app.post("/send")
async def send(body: SendRequest):
return mk.send({"from": body.sender, "to": body.to, "subject": body.subject, "html": body.html})
The fix: wrap the call in Starlette's threadpool with
fastapi.concurrency.run_in_threadpool, so the blocking network I/O runs on a
worker thread instead of the event loop thread:
# mailkite_client.py
from fastapi.concurrency import run_in_threadpool
from mailkite import MailKite, MailKiteError
_client: MailKite | None = None
def get_client() -> MailKite:
global _client
if _client is None:
_client = MailKite(os.environ["MAILKITE_API_KEY"])
return _client
async def send_email(*, to: str, subject: str, html: str, sender: str) -> dict:
client = get_client()
# The SDK is sync (urllib under the hood) — run it off the event loop
# so one slow send doesn't stall every other concurrent request.
return await run_in_threadpool(
client.send,
{"from": sender, "to": to, "subject": subject, "html": html},
)
A plain def (non-async) route gets this for free — FastAPI
detects sync path operations and runs them in a threadpool automatically. The starter wraps
the call explicitly instead and keeps routes async def, because that's the shape
you want the moment a handler also awaits something else (another API call, an async DB
session) — mixing sync and async routes in the same router is a common source of "why is
this one endpoint slow" bugs.
Send email
The route itself just calls the wrapped helper and turns SDK errors into proper HTTP responses:
# main.py
from fastapi import FastAPI, HTTPException
from mailkite_client import MailKiteError, send_email
app = FastAPI()
@app.post("/api/send")
async def send_api(body: SendRequest) -> dict:
try:
return await send_email(
to=body.to, subject=body.subject, html=body.html, sender=body.sender
)
except MailKiteError as exc:
status = exc.status if isinstance(exc.status, int) and exc.status >= 400 else 502
raise HTTPException(status_code=status, detail=exc.message) from exc Fire-and-forget sends
If a response shouldn't wait on delivery at all — a signup confirmation, say — use
BackgroundTasks instead of awaiting the send inline:
from fastapi import BackgroundTasks
@app.post("/signup")
async def signup(background_tasks: BackgroundTasks, ...):
create_user(...)
# Fire-and-forget: the response returns before the send completes.
background_tasks.add_task(get_client().send, {...})
return {"ok": True} SMTP relay alternative
MailKite also speaks SMTP directly. If an app already uses fastapi-mail or
fastapi-mailman against another ESP, pointing its SMTP config at MailKite is a
smaller change than adopting the SDK — and that path runs entirely over
aiosmtplib's own async transport, so it never touches the sync SDK at all:
# aiosmtplib / fastapi-mail, pointed at MailKite instead of another ESP
MAIL_SERVER = "smtp.mailkite.dev"
MAIL_PORT = 587
MAIL_USERNAME = "mailkite"
MAIL_PASSWORD = "mk_live_..." # your API key doubles as the SMTP password
MAIL_STARTTLS = True See the SMTP relay docs for the full connection reference. The API-mode SDK call above is still the more idiomatic FastAPI story — it's the only path that gets you templates, batch sends, scheduled sends, and inbound webhooks.
Troubleshooting
- One request makes every other request slow — you're calling
mk.send()directly from anasync defroute. Route it throughrun_in_threadpool(or use a plaindefroute) as shown above. - 401 / "invalid api key" — the SDK takes your
mk_live_…API key, not a separate SMTP credential; checkMAILKITE_API_KEY. - 403 sending — the
fromaddress must be on a domain you've verified with SPF/DKIM published. python-multipartmissing — FastAPI'sForm(...)parsing needs it installed; it's inrequirements.txt.
Full write-up with file-by-file detail: docs/integrations/fastapi.md. See also the SMTP relay docs and the Send API reference.