Skip to main content

MailDispatch

MailDispatch is a self-hosted email delivery service for applications that need a durable queue, multiple SMTP senders, priorities, rate limits, retries, idempotency, and an authenticated management UI.

It integrates directly with maglink for passwordless management login and exposes an HTTP API that other maglink applications can use to send authentication email.

Features

  • Durable database-backed mail queue
  • Separate HTTP server and worker processes
  • Multiple SMTP sender accounts
  • Implicit TLS, STARTTLS, and optionally plain SMTP
  • Sender selection weights and concurrency limits
  • Per-minute, per-hour, and per-day sender limits
  • Separate recipient limits for normal and authentication mail
  • Message priority and retry scheduling
  • Worker leases and recovery after worker failure
  • Per-recipient delivery state and delivery-attempt history
  • API keys with scopes and sender restrictions
  • Concurrent-safe idempotency keys
  • Recipient policies: any, allowlist, or enabled service users
  • maglink passwordless management authentication
  • Web UI for queues, users, senders, and API keys
  • SQLite by default; other SQLAlchemy databases can be configured

Installation

Publish/install maglink first, then install MailDispatch:

pip install maildispatch

Create a configuration file:

maildispatch init -c maildispatch.yml

Edit the non-secret settings and provide secrets through the environment:

export MAILDISPATCH_SESSION_SECRET="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
export MAILDISPATCH_MASTER_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')"
export MAILDISPATCH_SYSTEM_SMTP_PASSWORD="your-smtp-app-password"

Validate the configuration and initialize the database:

maildispatch check -c maildispatch.yml

Run the web server and one worker for a small deployment:

maildispatch run -c maildispatch.yml

Open the configured service.base_url, request a login email for the bootstrap administrator, and enter the displayed device code on the email confirmation page.

Production processes

For production, run the HTTP service and workers separately:

maildispatch serve -c /etc/maildispatch/maildispatch.yml
maildispatch worker -c /etc/maildispatch/maildispatch.yml

Run multiple worker processes when more throughput is required. All processes must use the same database and the same master key. Place the HTTP service behind an HTTPS reverse proxy and set service.base_url to the public HTTPS origin.

CLI

maildispatch init    Create an example configuration
maildispatch check   Validate configuration and initialize the database
maildispatch serve   Run only the HTTP management/API server
maildispatch worker  Run only a queue worker
maildispatch run     Run the HTTP server and one in-process worker

Common options:

maildispatch --log-level INFO serve -c /etc/maildispatch/maildispatch.yml
maildispatch --log-level DEBUG worker -c /etc/maildispatch/maildispatch.yml

Configuration

The generated maildispatch.yml is the reference configuration. Environment variables can be embedded as ${VARIABLE_NAME}. A secret can also be loaded from a file:

service:
  master_key:
    file: /run/secrets/maildispatch_master_key

Service

service:
  title: MailDispatch
  base_url: https://mail.example.com
  host: 127.0.0.1
  port: 5080
  secret_key: ${MAILDISPATCH_SESSION_SECRET}
  master_key: ${MAILDISPATCH_MASTER_KEY}
  • secret_key signs browser sessions.
  • master_key encrypts SMTP passwords stored in the database.
  • Changing master_key without re-encrypting sender passwords makes existing sender credentials unreadable.

Database

database:
  url: sqlite:///data/maildispatch.db

Relative SQLite paths are resolved relative to the configuration file. For a multi-host deployment, use a shared SQLAlchemy-supported database and install its Python driver.

Bootstrap administrator and sender

bootstrap:
  mode: initialize_only
  admin_email: admin@example.com
  initial_users:
    - email: admin@example.com
      role: admin
      enabled: true
      can_login: true
  system_sender:
    id: system
    name: Example Mail
    email: mailer@example.com
    enabled: true
    smtp:
      host: smtp.example.com
      port: 465
      username: mailer@example.com
      password: ${MAILDISPATCH_SYSTEM_SMTP_PASSWORD}
      tls_mode: implicit_tls
    rate_limit:
      per_minute: 20
      per_hour: 300
      per_day: 3000
      max_concurrency: 2

Bootstrap inserts missing initial records. It does not overwrite an existing sender on restart. Later changes should be made through the management UI/API or directly through an intentional migration.

Additional senders can be defined under bootstrap.initial_senders or added in the UI.

Authentication

auth:
  require_captcha: true
  token_ttl_seconds: 900
  session_ttl_seconds: 86400
  sender_id: system
  mail_priority: 1000
  request_rate_limit:
    count: 3
    window_seconds: 900
  confirm_max_attempts: 8

Authentication email uses the configured sender and receives authentication priority. The email link alone is insufficient: the user must enter the device code shown by the waiting browser.

Recipient policy

mail:
  recipient_policy:
    mode: service_users
    recipients: []

Modes:

  • any: any syntactically valid recipient is accepted;
  • service_users: recipient must be an enabled MailDispatch user with login permission;
  • allowlist: recipient must appear in recipients.

The policy is checked both when a message is queued and immediately before sending. A queued message is canceled if its recipient becomes disallowed.

When external applications use MailDispatch for maglink authentication, choose a policy that includes all application users. The external application must still enforce its own login eligibility through maglink's IdentityProvider.

Rate limits and retries

mail:
  default_priority: 100
  max_priority_for_api_keys: 500
  recipient_rate_limit:
    enabled: true
    normal:
      per_minute: 5
      per_hour: 30
      per_day: 100
    authentication:
      per_15_minutes: 3
      per_day: 20

worker:
  poll_interval_seconds: 1
  lease_seconds: 120
  retry:
    max_attempts: 5
    delays_seconds: [60, 300, 1800, 7200, 28800]

API clients cannot exceed max_priority_for_api_keys. Messages with purpose="authentication" require the authentication scope and use the configured authentication priority and recipient limits.

Management UI

The management page supports:

  • paginated queue summary and status filtering;
  • message status, sender, attempts, and errors;
  • retrying failed/canceled messages;
  • canceling queued messages;
  • creating and editing users;
  • changing user email, role, enabled state, and login permission;
  • creating and editing SMTP sender accounts;
  • changing sender host, TLS mode, limits, weight, and enabled state;
  • rotating sender SMTP passwords;
  • creating and editing API-key scopes, sender restrictions, and enabled state.

SMTP passwords are never returned to the browser. Leave the password field empty when editing a sender to keep the current password.

Send API

Create a message

POST /api/v1/messages
Authorization: Bearer md_live_...
Idempotency-Key: invoice-123
Content-Type: application/json
{
  "sender_id": "system",
  "to": ["user@example.com"],
  "cc": [],
  "bcc": [],
  "subject": "Your invoice",
  "text": "Your invoice is ready.",
  "html": "<p>Your invoice is ready.</p>",
  "reply_to": "support@example.com",
  "priority": 100,
  "purpose": "transactional",
  "metadata": {"invoice_id": "inv_123"}
}

Successful response:

{
  "ok": true,
  "message_id": "msg_...",
  "status": "queued"
}

HTTP 202 means the message was durably queued, not delivered.

Message status

GET /api/v1/messages/msg_...
Authorization: Bearer md_live_...

An API key can only read messages created by that key.

Idempotency

Send a stable Idempotency-Key for every logical message. Concurrent requests using the same API key and key value return the same message instead of creating duplicate deliveries.

Do not reuse a key for unrelated content.

API-key scopes

Scope Purpose
mail:send Submit normal messages
mail:authentication Submit messages with purpose="authentication"

Authentication integrations need both scopes. Sender restrictions can further limit a key to selected sender IDs.

curl example

curl -X POST "https://mail.example.com/api/v1/messages" \
  -H "Authorization: Bearer $MAILDISPATCH_API_KEY" \
  -H "Idempotency-Key: welcome-user-123" \
  -H "Content-Type: application/json" \
  -d '{
    "sender_id": "system",
    "to": ["user@example.com"],
    "subject": "Welcome",
    "text": "Welcome to the application.",
    "priority": 100,
    "purpose": "transactional"
  }'

Python example

The repository contains a dependency-free client:

export MAILDISPATCH_API_KEY="md_live_..."
export MAILDISPATCH_API_URL="https://mail.example.com/api/v1/messages"
python examples/send_email.py user@example.com \
  --subject "Hello" \
  --text "Sent through MailDispatch"
import os

from maglink import AuthCore, HttpMailer, SqliteStore

mailer = HttpMailer(
    endpoint=os.environ["MAILDISPATCH_API_URL"],
    api_key=os.environ["MAILDISPATCH_API_KEY"],
    sender_id="system",
)
core = AuthCore(
    store=SqliteStore("data/auth.db"),
    mailer=mailer,
    verify_url_base="https://app.example.com/api/auth/verify",
    identity_provider=application_identity_provider,
    login_sender_id="system",
)

The API key needs mail:send and mail:authentication. See examples/maglink_flask_login.py and the maglink agent skill for a complete implementation.

Operational behavior

Message states include:

queued
sending
waiting_rate_limit
retry_wait
sent
failed
canceled

A worker claims one message with a lease, selects an eligible sender, reserves rate-limit counters, records a delivery attempt, and sends through SMTP. Transient transport errors are retried according to the configured delays. Permanent errors or exhausted retries mark the message failed.

Monitor worker logs and the management UI for:

  • messages stuck in retry states;
  • authentication failures;
  • sender authentication errors;
  • disabled or rate-limited senders;
  • repeated recipient-policy cancellations;
  • queue growth and delivery latency.

Security checklist

  • Serve the web UI and API only through HTTPS.
  • Keep session secret, master key, SMTP passwords, and API keys outside Git.
  • Use SMTP app passwords or dedicated credentials.
  • Prefer implicit TLS or STARTTLS; plain SMTP is disabled by default.
  • Give API keys only the required scopes and sender IDs.
  • Rotate exposed API keys and SMTP credentials immediately.
  • Back up the database and master key separately and securely.
  • Protect the master key: it decrypts every stored SMTP password.
  • Use a restrictive recipient policy when possible.
  • Keep authentication recipient limits stricter than ordinary mail limits.
  • Run workers under a dedicated operating-system account.
  • Restrict database and configuration-file permissions.
  • Do not expose the built-in development server directly to the internet.

Development

python -m venv .venv
. .venv/bin/activate
pip install -e ../maglink
pip install -e ".[dev]"
pytest -q

Run locally:

maildispatch init
maildispatch check
maildispatch run

Release order

MailDispatch depends on maglink[flask]. Publish the required maglink version before publishing a MailDispatch release that depends on it.

License

MIT. See the LICENSE file.

Metadata

Release files for maildispatch 0.1.1

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

Source distribution (sdist)

Source distribution for maildispatch 0.1.1
File Size Uploaded
maildispatch-0.1.1.tar.gz 44.6 kB Details

Built distribution (wheel)

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

Total release size: 83.5 kB

Release files / maildispatch-0.1.1.tar.gz

Download URL maildispatch-0.1.1.tar.gz
Size 44.6 kB
Tags Source
SHA-256 checksum
How to use checksums
b66bf2f1aacbbba9b897f0dcc1c260074665a69f88d21d7f1ee9c1b3a00e592a
BLAKE2b-256 checksum
How to use checksums
3a7ba630fead2cd9c46b80fdbaacc3befa4ab6dcc88969ce0f1074c9e9c92c31
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.3

Release files / maildispatch-0.1.1-py3-none-any.whl

Download URL maildispatch-0.1.1-py3-none-any.whl
Size 38.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0d8474439ba62c74c879e8d0876b25f24cec773ea7fd4f9167a2991b63e73fed
BLAKE2b-256 checksum
How to use checksums
227725e0e71caaab3a10acd334fed2907f03f83698105beb43df24de8791eff4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

0.1.1 This release

2 release files

0.1.0

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