Get your API key
All integrations Framework

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

Get the starter running

The full app — an HTML form plus a JSON endpoint — lives in starters/fastapi/ in the MailKite monorepo:

terminal
cd starters/fastapi
pip install -r requirements.txt
cp .env.example .env # then edit .env with your real key
.env
# .env
MAILKITE_API_KEY=mk_live_your_key_here
MAILKITE_FROM=hello@yourdomain.com
terminal
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:

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:

main.py (don't)
# 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
# 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
# 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:

main.py
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:

config
# 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 an async def route. Route it through run_in_threadpool (or use a plain def route) as shown above.
  • 401 / "invalid api key" — the SDK takes your mk_live_… API key, not a separate SMTP credential; check MAILKITE_API_KEY.
  • 403 sending — the from address must be on a domain you've verified with SPF/DKIM published.
  • python-multipart missing — FastAPI's Form(...) parsing needs it installed; it's in requirements.txt.

Full write-up with file-by-file detail: docs/integrations/fastapi.md. See also the SMTP relay docs and the Send API reference.