Skip to main content

MailingKit

Transactional email for Python, without the plumbing.

MailingKit is a small, typed library you drop into any Python application that needs to send email. Your code talks to one clean API. MailingKit renders the template, validates the message, picks the provider, retries transient failures and gives you one consistent result and one set of errors, whichever provider sits underneath.

from mailingkit import MailingKit

mail = MailingKit.from_env()

await mail.send_template("welcome", {"name": "Ada"}, to="ada@example.com")
  • Provider-agnostic. SMTP, Resend, a console printer for development, or your own adapter. Switching provider is an environment variable, not a code change.
  • Framework-independent. Plain Python, FastAPI, Django, Celery, scripts, workers. The core depends only on Jinja2.
  • Safe by default. Prints instead of sending until you configure a provider. Blocks header injection, escapes HTML, never logs recipients or secrets.
  • Typed. Bind templates to dataclasses or Pydantic models and catch missing data before an email goes out. Ships py.typed, checked with mypy --strict.

Documentation, workshop and API reference: mailingkit.jishanahmed.in

Status: 0.1.0, alpha. The API is small on purpose and may still change before 1.0.

Contents

Install

pip install mailingkit             # core: console, file, memory and SMTP providers
pip install "mailingkit[resend]"   # adds the Resend provider (httpx)

Python 3.11 or newer, on Linux, macOS and Windows.

Quick start

1. Configure with environment variables (or pass the same values to MailingKit(...)):

MAILINGKIT_PROVIDER=smtp
MAILINGKIT_FROM_EMAIL=no-reply@example.com
MAILINGKIT_FROM_NAME="Example App"
MAILINGKIT_SMTP_HOST=smtp.example.com
MAILINGKIT_SMTP_USERNAME=apikey
MAILINGKIT_SMTP_PASSWORD=...

Leave MAILINGKIT_PROVIDER unset during development and every email is printed to the terminal instead of sent.

2. Create one client when your application starts:

from mailingkit import MailingKit

mail = MailingKit.from_env()

3. Send:

result = await mail.send(
    to="ada@example.com",
    subject="Your export is ready",
    html="<p>Hi Ada, <a href='https://example.com/exports/42'>download it here</a>.</p>",
)
print(result.id, result.provider_message_id)

A plain text part is generated from the HTML automatically. Not in async code? Use the blocking client, which has the same methods:

from mailingkit import SyncMailingKit

mail = SyncMailingKit.from_env()
mail.send(to="ada@example.com", subject="Hi", text="Hello Ada")

Templates

Templates live in a folder, one subfolder per email:

templates/
    _layouts/base.html        shared layout ({% extends "_layouts/base.html" %})
    welcome/
        subject.txt           required
        body.html             autoescaped HTML
        body.txt              optional, generated from body.html when missing
mail = MailingKit.from_env(templates="templates")   # or MAILINGKIT_TEMPLATES_DIR

await mail.send_template("welcome", {"name": "Ada"}, to="ada@example.com")

Rendering uses a sandboxed Jinja2 environment with HTML escaping on and undefined variables treated as errors, so a missing value raises TemplateDataError instead of sending "Hello ,".

Typed template data

Bind a template to a dataclass or a Pydantic model and the data is checked before rendering:

from dataclasses import dataclass
from mailingkit import Template

@dataclass
class CandidateSelected:
    candidate_name: str
    company_name: str
    next_round: str
    interview_url: str

CANDIDATE_SELECTED = Template("candidate-selected", CandidateSelected)

await mail.send_template(
    CANDIDATE_SELECTED,
    CandidateSelected(
        candidate_name="Ada",
        company_name="Acme",
        next_round="Technical interview",
        interview_url="https://acme.example/interview/123",
    ),
    to="ada@example.com",
)

A plain dict works too and is validated against the model. Every missing and unknown field is reported at once:

TemplateDataError: Template 'candidate-selected' data is invalid:
missing required fields: interview_url, next_round

Preview a template without sending it with mail.render(template, data).

Providers

Name Sends real email Install Use it for
console no core local development (the default)
file no core previewing .eml files in a mail client
memory no core automated tests
smtp yes core any SMTP service, Mailpit, Amazon SES SMTP
resend yes mailingkit[resend] the Resend HTTP API

Pick one with MAILINGKIT_PROVIDER, or pass an instance:

from mailingkit import MailingKit
from mailingkit.providers import SmtpProvider

mail = MailingKit(
    SmtpProvider("smtp.example.com", username="apikey", password="..."),
    default_sender="no-reply@example.com",
)

Writing your own provider

A provider is one async method. Map failures to MailingKit errors and say whether a retry could help:

from mailingkit import EmailProvider, ProviderResponse, RateLimitError, TemporaryProviderError

class AcmeMailProvider(EmailProvider):
    name = "acme"

    async def send(self, message, *, idempotency_key=None):
        response = await acme_client.post_email(...)  # message is rendered and validated
        if response.status == 429:
            raise RateLimitError("Acme is throttling", retry_after=response.retry_after)
        if response.status >= 500:
            raise TemporaryProviderError(f"Acme returned {response.status}")
        return ProviderResponse(provider_message_id=response.id)

mail = MailingKit(AcmeMailProvider(), default_sender="no-reply@example.com")

To make it selectable with MAILINGKIT_PROVIDER=acme, implement from_env() and publish an entry point from your package:

[project.entry-points."mailingkit.providers"]
acme = "acme_mailingkit:AcmeMailProvider"

Development mode

Accidentally emailing real customers from a laptop is the classic mistake. MailingKit makes it hard:

  • No provider configured means console. Messages are printed, nothing leaves the machine.
  • MAILINGKIT_PROVIDER=file writes each email to .mailingkit/outbox/*.eml. Double-click one to see exactly what the recipient would see.
  • MAILINGKIT_REDIRECT_TO=qa@example.com sends everything, from any code path, to one inbox. Staging uses real delivery without reaching real users. The original recipients are kept in an X-MailingKit-Original-To header.

Errors, retries and idempotency

Every provider failure is translated into the same exceptions, so switching providers never changes your error handling:

MailingKitError
├── ConfigurationError
├── ValidationError                 bad address, header injection, too large...
├── TemplateError                   not found, invalid data, render failure
└── ProviderError                   .retryable  .provider  .status_code
    ├── AuthenticationError
    ├── RateLimitError              .retry_after
    ├── RecipientRejectedError
    ├── TemporaryProviderError
    └── PermanentProviderError

Retries. Errors marked retryable are retried inside the call with exponential backoff and jitter (3 attempts by default, Retry-After honoured). Tune or disable with MailingKit(retry=RetryPolicy(max_attempts=5)) or retry=NO_RETRY.

Idempotency. Pass a key that identifies the business event, and the same key never sends twice:

await mail.send_template(
    "order-shipped", data, to=customer.email, idempotency_key=f"order-shipped:{order.id}"
)

A repeat returns the first SendResult with duplicate=True. Providers that support it (Resend) receive the key too. The default store is in process memory; pass your own IdempotencyStore to share keys across processes.

Hooks. Observe sends for metrics or tracing:

def on_event(event):          # sync or async
    metrics.increment(event.name, tags={"provider": event.provider})

mail = MailingKit.from_env(hooks=[on_event])

Events are mail.sent, mail.failed, mail.retry and mail.duplicate. They carry ids, provider and template name, never recipients or content. A failing hook is logged and never breaks sending.

Framework integration

MailingKit does not depend on any web framework. Create one client at startup and use it.

FastAPI

from contextlib import asynccontextmanager
from fastapi import FastAPI
from mailingkit import MailingKit

mail = MailingKit.from_env()

@asynccontextmanager
async def lifespan(app: FastAPI):
    yield
    await mail.aclose()

app = FastAPI(lifespan=lifespan)

@app.post("/signup")
async def signup(email: str):
    await mail.send_template("welcome", {"email": email}, to=email)
    return {"ok": True}

Django. Route all of Django's email through MailingKit, including send_mail(), password resets and admin error emails, without changing application code:

# settings.py, Django 6.1 and newer
MAILERS = {"default": {"BACKEND": "mailingkit.contrib.django.EmailBackend"}}

# older Django
EMAIL_BACKEND = "mailingkit.contrib.django.EmailBackend"

Celery, RQ, scripts. Use SyncMailingKit. It is safe to call from any thread.

See examples/ for complete, runnable apps.

Testing your application

Use the in-memory provider. No network, no credentials:

from mailingkit import MailingKit
from mailingkit.providers import MemoryProvider

async def test_signup_sends_welcome_email():
    provider = MemoryProvider()
    mail = MailingKit(provider, default_sender="app@example.com", templates="templates")

    await signup(mail, "ada@example.com")

    assert provider.last.subject == "Welcome to Acme"
    assert provider.sent_to("ada@example.com")

provider.fail_next(TemporaryProviderError("boom")) scripts failures to test your retry and error paths.

Configuration reference

Variable Meaning Default
MAILINGKIT_PROVIDER console, file, memory, smtp, resend, plugin console
MAILINGKIT_FROM_EMAIL default sender address
MAILINGKIT_FROM_NAME default sender display name
MAILINGKIT_TEMPLATES_DIR template folder
MAILINGKIT_REDIRECT_TO send every email here instead
MAILINGKIT_MAX_ATTEMPTS attempts per send for transient failures 3
MAILINGKIT_SMTP_HOST SMTP server (required for smtp)
MAILINGKIT_SMTP_PORT SMTP port 587 / 465 / 25
MAILINGKIT_SMTP_SECURITY starttls, ssl or none starttls
MAILINGKIT_SMTP_USERNAME SMTP username
MAILINGKIT_SMTP_PASSWORD SMTP password
MAILINGKIT_SMTP_TIMEOUT seconds 30
MAILINGKIT_RESEND_API_KEY Resend API key (required for resend)
MAILINGKIT_FILE_DIR output folder for file .mailingkit/outbox
MAILINGKIT_CONSOLE_SHOW_HTML also print the HTML body false

The MAILINGKIT_ prefix keeps these from colliding with other libraries' MAIL_* settings.

Security

  • Header injection: line breaks are rejected in subjects, display names, addresses and custom headers. Reserved headers (From, To, Bcc...) cannot be overridden.
  • HTML: template data is escaped in HTML bodies. Templates render in a Jinja2 sandbox and are only loaded from your template folder, never from user input.
  • Secrets: credentials are held in a Secret type that never appears in a repr, log line or error message.
  • Logs and hooks contain ids, provider and template names only. Recipients, subjects and template data stay out by default.
  • TLS: SMTP uses STARTTLS with certificate verification by default and refuses to send credentials over a plain connection.
  • Limits: recipient count and message size are capped (Limits), and attachment filenames are checked for path tricks.

Roadmap

MailingKit grows in small steps. The library stays the product.

  • 0.1 (this release): core client, templates, console, file, memory, SMTP and Resend providers, retries, idempotency, hooks, Django backend.
  • 0.2: mail.enqueue() plus an optional mailingkit worker for background sending (Redis first), a shared idempotency store, more providers (Amazon SES, Postmark).
  • 0.3: normalized delivery events from provider webhooks, a local preview inbox, OpenTelemetry hooks.

Contributing

See CONTRIBUTING.md. Issues and pull requests are welcome.

License

MIT © Jishanahmed AR Shaikh (JARS)

Metadata

Release files for mailingkit 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for mailingkit 0.1.0
File Size Uploaded
mailingkit-0.1.0.tar.gz 41.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for mailingkit 0.1.0
File Interpreter ABI Platform
mailingkit-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 81.6 kB

Release files / mailingkit-0.1.0.tar.gz

Download URL mailingkit-0.1.0.tar.gz
Size 41.1 kB
Tags Source
SHA-256 checksum
How to use checksums
554eaee329a4be6e17ee9efbdda022c29ea825613e2da83cea2b544ecb8e5501
BLAKE2b-256 checksum
How to use checksums
e4362cec93e15e852d0fb1ee4be52e780a726df8914c7e7f623ab46f3e192a0f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release files / mailingkit-0.1.0-py3-none-any.whl

Download URL mailingkit-0.1.0-py3-none-any.whl
Size 40.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
50a9b9661ce145e9a547ae6f5512ad4201b473627397908359d6d1473806eb39
BLAKE2b-256 checksum
How to use checksums
b3fbd2acd7aae760c27fb301a20fd3be13a3bcd39f0d65574af44c6c56afab50
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page