Skip to main content

Tiny SMTP sink that saves incoming mail straight to a Maildir or JSON — no UI, no forwarding

Project description

maildirsink

PyPI Python License

A tiny SMTP server that catches mail and writes it straight to disk — as a standard Maildir or as JSON. No web UI, no forwarding, no database. It does one job: receive and store.

日本語版は README-ja.md を参照してください。

Why

When you develop something that sends mail, you need somewhere safe for it to land — not a real inbox. Tools like mailpit and MailHog solve this with a web UI you open in a browser.

maildirsink takes the opposite approach: it writes standard Maildir and then gets out of the way. Because the output is a plain, boring Maildir:

  • Read it with a Maildir-native MUA — mutt, neomutt — with no extra setup.
  • Point existing tooling at it: procmail, mbsync, and friends.
  • Or just use ls, grep, and cat. It's one file per message.

There is no UI to learn and no format to export from. If you'd rather have structured data than RFC 5322, use --format json and get one JSON file per message instead.

Quick start

pipx install maildirsink          # or: pip install maildirsink
maildirsink --dir ~/Maildir

Point your app's SMTP config at localhost:1025 and send. Mail shows up in ~/Maildir/new/ immediately.

To try it without an app, send one from Python:

python - <<'EOF'
import smtplib
from email.message import EmailMessage

m = EmailMessage()
m["Subject"] = "Hello"
m["From"] = "app@example.com"
m["To"] = "you@example.com"
m.set_content("It works.")
smtplib.SMTP("localhost", 1025).send_message(m)
EOF

Install

pipx install maildirsink     # recommended: isolated, and puts `maildirsink` on PATH
pip install maildirsink      # or into a venv of your choice

Requires Python 3.9+.

Usage

maildirsink [--port PORT] [--host HOST] [--format maildir|json] [--dir DIR]
Option Meaning Default
--port SMTP listen port 1025
--host Listen host localhost
--format Storage format (maildir / json) maildir
--dir Storage directory ./mail

The storage directory is created on startup if it doesn't exist.

Exposing on a LAN

The default localhost only accepts mail from the same machine. To receive from another host — a container, an app on another machine — listen on all interfaces:

maildirsink --host 0.0.0.0 --port 1025

Warning: maildirsink has no authentication and no TLS, and it accepts every message it is handed. Use it only on a trusted network. Never expose it to the internet.

Storage formats

--format maildir (default)

A standard Maildir. Each message is written to tmp/ and then moved into new/ — the atomic two-step delivery the format requires — with a filename that is guaranteed unique (timestamp.Ppid.hostname).

~/Maildir/
├── cur/
├── new/     ← delivered mail lands here
│   └── 1784265259.M961931P358986Q1.hostname
└── tmp/

Mail lands in new/, which is exactly where MUAs look for unread messages, so they pick it up with no extra configuration.

--format json

One JSON file per message, for when you want to assert on mail in tests rather than read it. Headers are MIME-decoded and the text/plain body is extracted:

{
  "subject": "Hello",
  "from": "app@example.com",
  "to": "you@example.com",
  "date": "Fri, 17 Jul 2026 08:48:00 +0900",
  "message_id": "<abc123@example.com>",
  "body": "It works.\n"
}

Reading mail with mutt / neomutt

Point your MUA at the directory maildirsink writes to — that's the whole setup:

set mbox_type = Maildir
set folder    = "/path/to/maildir"
set spoolfile = "+."
mailboxes     = "/path/to/maildir"

# Pick up mail that arrives while mutt is running
set mail_check = 5      # seconds
set timeout    = 10

Mail that lands in new/ shows up automatically, including while mutt is open.

Platform support

Platform Status
Linux Supported
macOS Should work (not regularly tested)
Windows 11 + WSL2 Supported — see below
Windows (native) Not supported

Native Windows is out of scope: there's no systemd, and Maildir-native MUAs aren't realistically available there. Use WSL2 — it runs a real Linux kernel, so maildirsink and the systemd unit below work unmodified.

Windows 11 (WSL2)

Three WSL2-specific things to get right:

1. Enable systemd. It's off by default. Add this to /etc/wsl.conf inside your distro, then run wsl --shutdown from Windows to restart it:

[boot]
systemd=true

2. Keep the Maildir on the Linux filesystem. Use ~/Maildir, never a path under /mnt/c/.... Windows drives are mounted via drvfs, where the atomic rename/link semantics Maildir depends on are unreliable — and it's slow.

3. Networking. WSL2 sits behind NAT by default. Sending from a Windows app to localhost:1025 reaches maildirsink via localhost forwarding, so the default setup just works. If you need to receive from other machines on the LAN, note that the WSL2 VM's IP changes on each boot; on Windows 11 22H2+ you can avoid that with mirrored networking in %UserProfile%\.wslconfig:

[wsl2]
networkingMode=mirrored

Running as a service (systemd --user)

To keep maildirsink running without starting it by hand, drop a user unit at ~/.config/systemd/user/maildirsink.service:

[Unit]
Description=maildirsink - SMTP sink saving mail to Maildir
After=network.target

[Service]
Type=simple
ExecStart=%h/.local/bin/maildirsink --host localhost --port 1025 --format maildir --dir %h/Maildir
Restart=on-failure
RestartSec=2

[Install]
WantedBy=default.target

The ExecStart path assumes pipx install maildirsink, which puts the command in ~/.local/bin. Adjust it if you installed elsewhere — e.g. point it at <your-venv>/bin/maildirsink.

systemctl --user daemon-reload
systemctl --user enable --now maildirsink.service
systemctl --user status maildirsink        # check
journalctl --user -u maildirsink -f        # follow logs

This starts maildirsink when you log in. To keep it running without an active login — and to start it at boot — enable lingering:

sudo loginctl enable-linger "$USER"

How it works

Roughly 200 lines of Python over two well-worn libraries:

  • SMTP: aiosmtpd — the stdlib smtpd was removed in Python 3.12.
  • Maildir: the stdlib mailbox.Maildir, whose add() already implements the tmp/new/ delivery sequence and the filename uniqueness rules.
  • Headers: MIME-encoded headers (=?UTF-8?B?...?=) are decoded on the way into JSON. A malformed header degrades to its raw value rather than dropping the message.

If storing a message fails, maildirsink returns SMTP 451 so the sender knows the mail wasn't kept, and logs the traceback.

Development

git clone https://github.com/sogatat/maildirsink
cd maildirsink
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

maildirsink-0.1.3.tar.gz (13.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

maildirsink-0.1.3-py3-none-any.whl (10.4 kB view details)

Uploaded Python 3

File details

Details for the file maildirsink-0.1.3.tar.gz.

File metadata

  • Download URL: maildirsink-0.1.3.tar.gz
  • Upload date:
  • Size: 13.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for maildirsink-0.1.3.tar.gz
Algorithm Hash digest
SHA256 ea090e7c6853f24305afa97b7344a4278232e6b5fb78cb519c48358a7a264c90
MD5 d3681798934d1ef93a1a4f318c5cc6de
BLAKE2b-256 0e292795da79ca1f61ea04e1593a300de2da520b44b9fd002d8e51fb2d044ee2

See more details on using hashes here.

File details

Details for the file maildirsink-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: maildirsink-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 10.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for maildirsink-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 468f39b8983ea131720b300490e6cb89b6feef2b52ae8fe174b64c40fd134e9e
MD5 48d1f7881d034aeccf096cc4f3a4c3a5
BLAKE2b-256 0f3115d3a64590212045618f854a057fa147939717603e1ce41d7ee433d10952

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page