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
mkdir mailing-workshop && cd mailing-workshop
uv init --bare
uv add mailingkitCheck it imports and see which version you have:
uv run python -c "import mailingkit; print(mailingkit.__version__)"02Send your first email
Create 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())uv run python send.pyThe 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
Switch to the file provider with one variable and run again:
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
Move the copy out of your code. Create these two files:
Welcome to {{ product }}, {{ name }}<h1>Welcome, {{ name }}</h1>
<p>Your {{ product }} account is ready.</p>
<p><a href="{{ login_url }}">Sign in</a></p>Then send it:
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
Bind the template to a dataclass so mistakes are caught before rendering, with every problem listed:
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
Start Mailpit, a local inbox that speaks SMTP:
docker run --rm -p 8025:8025 -p 1025:1025 axllent/mailpitCreate a .env file and load it (or export the variables):
MAILINGKIT_PROVIDER=smtp
MAILINGKIT_SMTP_HOST=localhost
MAILINGKIT_SMTP_PORT=1025
MAILINGKIT_SMTP_SECURITY=noneuv 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
Stop Mailpit and run send.py again. MailingKit retries the connection three times with backoff, then raises one clear error. Handle it:
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
Send the same email twice with the same key:
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
uv add fastapi uvicornfrom 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:
uv run --env-file .env uvicorn app:app --reload
curl -X POST "localhost:8000/signup?name=Grace&email=grace@example.com"10Integrate with Django
In a Django project, one setting routes all of Django's email through MailingKit:
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
uv add --dev pytest pytest-asyncioimport 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.htmluv run pytestNo network, no credentials, no Mailpit needed.
12Write your own provider
A provider is one async method. This one posts each email to a chat webhook, which is handy for internal alerts:
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()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.