Send email from FastAPI

FastAPI is async, so a blocking send stalls the event loop and every other request on that worker. This page uses httpx.AsyncClient held on the app lifespan, plus BackgroundTasks so the response returns before the send finishes.

Setup

  1. 01

    Create a key

    https://emails.sh/dashboard issues a key starting with esh_.

  2. 02

    Read it with pydantic-settings

    A Settings class gives you a startup error naming the missing variable instead of a KeyError on the first send.

  3. 03

    Create the client in lifespan

    One httpx.AsyncClient for the process, created on startup and closed on shutdown, so connections are reused rather than opened per send.

  4. 04

    Send with BackgroundTasks

    background_tasks.add_task queues the coroutine to run after the response is sent, so the caller does not wait on the API round trip.

Install
pip install emailssh httpx
settings.py
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    emailssh_api_key: str
    emailssh_from: str = "Acme <onboarding@emails.sh>"

    class Config:
        env_file = ".env"


settings = Settings()

The app

main.py (FastAPI 0.11x, httpx 0.27+)

The app
import logging
from contextlib import asynccontextmanager

import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel, EmailStr

from settings import settings

log = logging.getLogger("uvicorn.error")


@asynccontextmanager
async def lifespan(app: FastAPI):
    # One client for the whole process. Creating an AsyncClient per request
    # throws away connection pooling and the TLS handshake every time.
    app.state.http = httpx.AsyncClient(
        base_url="https://emails.sh/v1",
        headers={"Authorization": f"Bearer {settings.emailssh_api_key}"},
        timeout=15.0,
    )
    try:
        yield
    finally:
        await app.state.http.aclose()


app = FastAPI(lifespan=lifespan)


class SignupRequest(BaseModel):
    email: EmailStr
    name: str = "there"


async def send_welcome(client: httpx.AsyncClient, email: str, name: str) -> None:
    response = await client.post(
        "/emails",
        json={
            "from": settings.emailssh_from,
            "to": [email],
            "subject": "Welcome to Acme",
            "html": f"<p>Hi {name}, your Acme account is ready.</p>",
            "text": f"Hi {name}, your Acme account is ready.",
            "idempotency_key": f"welcome:{email.lower()}",
        },
    )

    if response.is_error:
        # The body is prose and says what to do next, so log all of it.
        log.error("emails.sh %s: %s", response.status_code, response.text)
        return

    log.info("emails.sh queued %s", response.json()["id"])


@app.post("/signup")
async def signup(payload: SignupRequest, background_tasks: BackgroundTasks):
    if not settings.emailssh_api_key:
        raise HTTPException(status_code=500, detail="Email is not configured")

    background_tasks.add_task(send_welcome, app.state.http, payload.email, payload.name)
    return {"ok": True}

The webhook receiver

webhooks.py, included with app.include_router(router)

The webhook receiver
import hashlib
import hmac
import os

from fastapi import APIRouter, Header, HTTPException, Request

router = APIRouter()
SECRET = os.environ["EMAILSSH_WEBHOOK_SECRET"].encode()


@router.post("/webhooks/emailssh", status_code=204)
async def emailssh_events(request: Request, x_emailssh_signature: str = Header(default="")):
    # Read the raw body, not the parsed JSON. Re-serialising changes the bytes
    # and the signature will never match.
    raw = await request.body()
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, x_emailssh_signature):
        raise HTTPException(status_code=401, detail="bad signature")

    event = await request.json()

    if event["type"] in ("email.bounced", "email.complained"):
        # Mark the address unsendable here.
        pass

    return None

Worth knowing

01

requests inside an async endpoint blocks the event loop

requests is synchronous, so calling it from an async def freezes every concurrent request on that worker for the duration. Use httpx.AsyncClient, or define the endpoint as def and let FastAPI run it in a threadpool.

02

Do not build a new AsyncClient per request

Each one starts a fresh connection pool and repeats the TLS handshake. Put it on app.state in lifespan and reuse it, as above.

03

BackgroundTasks are in-process

They run in the same worker after the response, so a crash or a deploy loses them, and they do not retry. Anything that must arrive belongs in Celery, arq, or another queue.

04

Sign verification needs the raw body

await request.body() before parsing. The HMAC covers the exact bytes sent, and json.dumps of the parsed object almost never reproduces them.

Questions

How do I send an email in FastAPI?

POST to https://emails.sh/v1/emails with httpx.AsyncClient and a Bearer token, from a BackgroundTask so the response is not held up.

Should I use fastapi-mail?

It is an SMTP wrapper. On the HTTP API you do not need a mail library, an async HTTP client is the whole dependency.

How do I know whether the email arrived?

Keep the id from the response and GET https://emails.sh/v1/emails/{id}, or subscribe to email.delivered and email.bounced on the webhook.

The rest of the API

POST /v1/emails sends one and POST /v1/emails/batch sends up to 100 in a call. GET /v1/emails/:id returns the delivery events for a message, which is how you answer "did it arrive" without a support ticket. Webhooks push the same events to you as they happen.