Hands-on · about 45 minutes

MailingKit workshop

Twelve short steps that take a fresh folder to a tested, provider-independent mailing setup. Every step works offline until step 06, and nothing is ever sent to a real person.

You need

Python 3.11 or newer and uv (or pip). Docker is optional, for a local inbox in step 06.

01Install MailingKit

3 minutes

bash
mkdir mailing-workshop && cd mailing-workshop
uv init --bare
uv add mailingkit

Check it imports and see which version you have:

bash
uv run python -c "import mailingkit; print(mailingkit.__version__)"

02Send your first email

3 minutes

Create send.py:

send.py
import asyncio
from mailingkit import MailingKit

async def main():
    mail = MailingKit.from_env(default_sender="Workshop <no-reply@example.com>")
    result = await mail.send(
        to="ada@example.com",
        subject="Hello from MailingKit",
        html="<p>It <strong>works</strong>.</p>",
    )
    print("accepted:", result.id, "via", result.provider)

asyncio.run(main())
bash
uv run python send.py

The email is printed, not sent: no provider is configured, so MailingKit uses the console provider. Notice the text body was generated from your HTML.

03Preview it like a recipient

3 minutes

Switch to the file provider with one variable and run again:

bash
MAILINGKIT_PROVIDER=file uv run python send.py

Open the new file in .mailingkit/outbox/. Your mail client shows exactly what the recipient would see. On Windows PowerShell, set the variable with $env:MAILINGKIT_PROVIDER="file" first.

04Create a template

5 minutes

Move the copy out of your code. Create these two files:

templates/welcome/subject.txt
Welcome to {{ product }}, {{ name }}
templates/welcome/body.html
<h1>Welcome, {{ name }}</h1>
<p>Your {{ product }} account is ready.</p>
<p><a href="{{ login_url }}">Sign in</a></p>

Then send it:

send.py
mail = MailingKit.from_env(
    default_sender="Workshop <no-reply@example.com>",
    templates="templates",
)
await mail.send_template(
    "welcome",
    {"name": "Ada", "product": "Acme", "login_url": "https://acme.example/login"},
    to="ada@example.com",
)

Now remove "product" from the data and run it again. MailingKit refuses to send an email with a hole in it and tells you which variable is missing.

05Add typed template data

5 minutes

Bind the template to a dataclass so mistakes are caught before rendering, with every problem listed:

send.py
from dataclasses import dataclass
from mailingkit import Template

@dataclass
class Welcome:
    name: str
    product: str
    login_url: str

WELCOME = Template("welcome", Welcome)

await mail.send_template(
    WELCOME,
    Welcome(name="Ada", product="Acme", login_url="https://acme.example/login"),
    to="ada@example.com",
)

Try passing {"name": "Ada"} instead of a Welcome. The error lists both missing fields. Your editor and type checker now understand the data too.

06Connect a real provider

5 minutes

Start Mailpit, a local inbox that speaks SMTP:

bash
docker run --rm -p 8025:8025 -p 1025:1025 axllent/mailpit

Create a .env file and load it (or export the variables):

.env
MAILINGKIT_PROVIDER=smtp
MAILINGKIT_SMTP_HOST=localhost
MAILINGKIT_SMTP_PORT=1025
MAILINGKIT_SMTP_SECURITY=none
bash
uv run --env-file .env python send.py

Open http://localhost:8025: the email arrived over real SMTP. Your code did not change. Switching to production later is the same move: different variables, for example MAILINGKIT_PROVIDER=resend and MAILINGKIT_RESEND_API_KEY.

07Handle errors and retries

4 minutes

Stop Mailpit and run send.py again. MailingKit retries the connection three times with backoff, then raises one clear error. Handle it:

send.py
from mailingkit import ProviderError, ValidationError

data = Welcome(name="Ada", product="Acme", login_url="https://acme.example/login")
try:
    await mail.send_template(WELCOME, data, to="ada@example.com")
except ValidationError as error:
    print("bad message:", error)
except ProviderError as error:
    print(f"{error.provider} failed (retryable={error.retryable}): {error}")

The same except blocks work for every provider, because each adapter translates its own failures.

08Make sends idempotent

3 minutes

Send the same email twice with the same key:

send.py
for _ in range(2):
    result = await mail.send_template(
        WELCOME, data, to="ada@example.com", idempotency_key="welcome:ada"
    )
    print(result.id, "duplicate" if result.duplicate else "sent")

Only one email arrives in Mailpit. Use keys that name the business event, such as order-shipped:1001, so retries and double clicks never spam anyone.

09Integrate with FastAPI

5 minutes

bash
uv add fastapi uvicorn
app.py
from fastapi import FastAPI
from mailingkit import MailingKit

from send import WELCOME, Welcome

mail = MailingKit.from_env(default_sender="Workshop <no-reply@example.com>", templates="templates")
app = FastAPI()

@app.post("/signup")
async def signup(name: str, email: str):
    await mail.send_template(
        WELCOME,
        Welcome(name=name, product="Acme", login_url="https://acme.example/login"),
        to=email,
        idempotency_key=f"welcome:{email}",
    )
    return {"ok": True}

So app.py can import from send.py, keep Welcome and WELCOME at the top level of send.py and change its last line to if __name__ == "__main__": asyncio.run(main()). Then run:

bash
uv run --env-file .env uvicorn app:app --reload
curl -X POST "localhost:8000/signup?name=Grace&email=grace@example.com"

10Integrate with Django

4 minutes

In a Django project, one setting routes all of Django's email through MailingKit:

settings.py
MAILERS = {"default": {"BACKEND": "mailingkit.contrib.django.EmailBackend"}}
# Django before 6.1: EMAIL_BACKEND = "mailingkit.contrib.django.EmailBackend"

Trigger a password reset from the admin login page, or call send_mail() in a shell. It arrives in Mailpit through MailingKit, with no other code changes. For templates and idempotency in views, use SyncMailingKit.

11Test your email code

4 minutes

bash
uv add --dev pytest pytest-asyncio
test_welcome.py
import pytest
from mailingkit import MailingKit
from mailingkit.providers import MemoryProvider

from send import WELCOME, Welcome

@pytest.mark.asyncio
async def test_welcome_email_has_login_link():
    outbox = MemoryProvider()
    mail = MailingKit(outbox, default_sender="app@example.com", templates="templates")

    await mail.send_template(
        WELCOME,
        Welcome(name="Ada", product="Acme", login_url="https://acme.example/login"),
        to="ada@example.com",
    )

    assert outbox.last.subject == "Welcome to Acme, Ada"
    assert "https://acme.example/login" in outbox.last.html
bash
uv run pytest

No network, no credentials, no Mailpit needed.

12Write your own provider

5 minutes

A provider is one async method. This one posts each email to a chat webhook, which is handy for internal alerts:

webhook_provider.py
import json
import urllib.request

from mailingkit import EmailProvider, ProviderResponse, TemporaryProviderError

class WebhookProvider(EmailProvider):
    name = "webhook"

    def __init__(self, url):
        self.url = url

    async def send(self, message, *, idempotency_key=None):
        body = json.dumps({"text": f"{message.subject}\n\n{message.text}"}).encode()
        request = urllib.request.Request(self.url, body, {"Content-Type": "application/json"})
        try:
            urllib.request.urlopen(request, timeout=10)
        except OSError as error:
            raise TemporaryProviderError("Webhook unreachable") from error
        return ProviderResponse()
python
mail = MailingKit(WebhookProvider("https://hooks.example.com/..."), default_sender="alerts@example.com")

Everything you built in steps 04 to 11 (templates, typed data, retries, idempotency, tests) works with it unchanged. That is the point of the adapter design.

Where next

  • Read How it fits for the design and what is core versus optional.
  • Browse the API reference.
  • Coming in 0.2: mail.enqueue() and an optional worker for background sending.
  • Found a rough edge? Open an issue.