Skip to main content

Typed Python CLI for administering Postfix + Dovecot mail servers (PostfixAdmin schema). Pluggable identity backend.

Project description

postino

postino — il postino delivers your mail config

PyPI Python License: MIT

Typed Python CLI for administering Postfix + Dovecot mail servers that use the PostfixAdmin SQL schema as user / alias / domain backend.

Built for FreeBSD mail hosts but portable to Linux. Pluggable identity backend — local password column today, external IdP (Zitadel / SCIM) planned for V2.

pipx install il-postino
postino domain add example.com --max-mailboxes 100 --default-quota 5G
postino user add foo@example.com --quota 5G   # prompts for password
postino check

Why postino

PostfixAdmin's web UI is fine for casual ops, but if you administer mail at scale you want the operations scriptable, idempotent, type-safe, and auditable. Existing alternatives either reimplement the schema (drift risk), shell out to mysql (footgun), or wrap PHP (lol no). postino sits directly on top of the PostfixAdmin schema using SQLAlchemy 2.0 reflection and exposes it as a proper CLI:

  • Pydantic v2 boundary types — every input validated, every row strict-typed
  • All ops transactional — add, delete, status / quota / password updates
  • Filesystem rollback on partial failure (maildir mkdir + DB insert atomicity)
  • Provider abstraction — swap local-pwd for Zitadel without touching services
  • postino check — read-only consistency validator (DB ↔ config ↔ filesystem)
  • Postfix is the canonical source for SQL credentials — postino parses /usr/local/etc/postfix/sql-virtual_*.cf. No password duplication.

Install

Via pipx (workstation, daily admin)

pipx install il-postino

Import name remains postino. PyPI distribution is il-postino because the bare postino name is squatted by an unrelated 2017 package.

From git (mail host / production)

For a host where you want a pinned, auditable checkout:

git clone https://github.com/vjt/postino.git /root/postino
cd /root/postino
python3.13 -m venv .venv
.venv/bin/pip install .

# invoke directly:
/root/postino/.venv/bin/postino check
# or symlink:
ln -s /root/postino/.venv/bin/postino /root/bin/postino

To upgrade later:

cd /root/postino && git pull && .venv/bin/pip install .

FreeBSD notes

pydantic-core is a Rust extension and FreeBSD has no prebuilt wheel. You need:

pkg install -y python313 git rust llvm19
export CC=/usr/local/llvm19/bin/clang
export TMPDIR=/root/build-tmp  # if /tmp is noexec
mkdir -p /root/build-tmp
.venv/bin/pip install .

llvm19 is required because the base clang ships incomplete intrinsic headers (emmintrin.h etc. missing) on slimmed-down systems.

The first install caches all compiled wheels into wheels/:

.venv/bin/pip wheel --wheel-dir wheels/ .

Future updates can use the cache and skip rust:

git pull
.venv/bin/pip install --no-build-isolation --find-links wheels/ .

Configuration

postino reads, in order of increasing precedence:

  1. /usr/local/etc/postino/postino.toml
  2. ~/.config/postino/postino.toml
  3. POSTINO_* environment variables

Example postino.toml:

identity_backend = "local"
postfix_sql_dir = "/usr/local/etc/postfix"
virtual_mailbox_base = "/srv/mail"
postcreation_hook = "/usr/local/sbin/postfixadmin-mailbox-postcreation.sh"
vmail_uid = 1006
vmail_gid = 1006
default_password_scheme = "BLF-CRYPT"
default_quota_bytes = 1073741824

Or via env (CI / containers):

export POSTINO_IDENTITY_BACKEND=local
export POSTINO_POSTFIX_SQL_DIR=/usr/local/etc/postfix
export POSTINO_VIRTUAL_MAILBOX_BASE=/srv/mail
# ...

DB credentials are NOT in postino.toml — postino parses postfix_sql_dir/sql-virtual_mailbox_maps.cf to extract host / user / password / dbname. Single source of truth.

Usage

Domain CRUD

postino domain add example.com \
    --description "Example domain" \
    --max-mailboxes 100 \
    --max-aliases 200 \
    --default-quota 5G \
    --max-quota 50G \
    --transport virtual

postino domain list
postino domain del example.com --yes

User (mailbox) CRUD

postino user add foo@example.com \
    --name "Foo Bar" \
    --quota 5G \
    --scheme BLF-CRYPT
# Password is prompted twice (no echo). Never accepted on the command
# line: argv leaks via `ps`, shell history, syslog, and CI logs.

postino user list --domain example.com
postino user list --all                # include disabled
postino user show foo@example.com
postino user passwd foo@example.com    # prompts for new password
postino user enable foo@example.com
postino user disable foo@example.com
postino user quota foo@example.com --set 10G
postino user del foo@example.com --keep-maildir

Aliases

postino alias add foo@example.com forwarded@elsewhere.test
postino alias list --domain example.com
postino alias del foo@example.com --yes

Quota usage

postino quota show foo@example.com    # one user
postino quota show                    # all users

Operations

postino check          # shallow: DB reachable, schema present, hook safe,
                       #          postfix sql-virtual_*.cf credentials match engine.
postino check --deep   # also reconcile mailbox rows ↔ maildirs on disk,
                       # quota2 pairing, alias/mailbox domain FK substitutes,
                       # maildir ownership and Maildir++ skeleton.
postino status         # row counts (domains / mailboxes / aliases / quota2)

postino check exits 0 when every finding is severity info, 4 (ConfigError) when at least one finding is severity error. JSON output (--json) returns the full {findings:[…], ok:bool} payload for scripting.

Output formats

All read commands accept --json for scripting:

postino user list --domain example.com --json | jq '.[] | .username'
postino check --json

Exit codes

Code Cause
0 success
1 NotFoundError — entity does not exist
2 AlreadyExistsError — uniqueness conflict
3 CapacityErrormax_mailboxes / max_aliases exceeded
4 ConfigError — bad / missing config
5 DBError — DB connectivity / schema drift
6 FilesystemError — maildir mkdir / chown / rm
7 HookError — postcreation script returned non-zero
8 DeadlockError — MySQL deadlock / lock-wait timeout
9 MlmmjError — mlmmj subprocess failed
99 unexpected — bug; full traceback

Architecture

Two-package wheel, hard separation between library (postino_core) and CLI (postino):

src/postino_core/    # library, no Typer dep
    enums, errors, quota, password, models, config, db
    fs, hooks, output
    providers/{base,local}
    services/{mailbox,alias,domain,quota,bundle}
    check/consistency

src/postino/         # CLI, depends on postino_core
    cli, commands/{user,alias,domain,quota,check,status,reconcile}

Constructor injection throughout. SQL Engine, identity provider, filesystem adapter, hook runner and clock are all injected — every service is unit testable in isolation, every integration test starts from a clean TRUNCATE'd DB. See docs/superpowers/specs/2026-05-09-postino-design.md for the full design.

Development

git clone https://github.com/vjt/postino.git
cd postino
python3.13 -m venv .venv
. .venv/bin/activate
pip install -e '.[dev]'

Test database

Integration + CLI tests need a real MySQL / MariaDB schema where the runner has full privileges:

CREATE SCHEMA postino_test
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;
CREATE USER 'postino_test'@'localhost' IDENTIFIED BY 'postino_test_dev';
GRANT ALL ON postino_test.* TO 'postino_test'@'localhost';
FLUSH PRIVILEGES;
export POSTINO_TEST_DB_URL='mysql+pymysql://postino_test:postino_test_dev@localhost/postino_test'

Unit tests do not need this and always run.

The schema fixture (tests/fixtures/postfixadmin.sql) is a mysqldump --no-data of a real PostfixAdmin DB — kept minimal so tests exercise the actual production schema, not a hand-maintained copy.

Run the suite

./scripts/check.sh   # ruff + ruff format --check + pyright + pytest

The check script must stay green on every commit. Pyright is in strict mode, ruff has E F W I B UP RUF SIM selected.

Releasing

# bump version in pyproject.toml
git tag vX.Y.Z
git push origin vX.Y.Z
rm -rf dist/ && python -m build
twine check dist/* && twine upload dist/*

Token in ~/.pypirc under [pypi] with username = __token__.

Running postinod (daemon)

postinod is the litestar daemon shipped alongside the CLI. It exposes two HTTP surfaces:

  • POST /zitadel/events — Zitadel Actions HMAC webhook. Inbound only.
  • /scim/v2/* — JWT-bearer SCIM 2.0 for non-Zitadel clients (scim-cli, audit scripts).

HMAC secret and rotation

The Zitadel HMAC secret is env-only: postinod refuses to start if POSTINOD_ZITADEL_HMAC_SECRET is unset or shorter than 32 bytes. The secret never lives in TOML. Generate with:

openssl rand -hex 32

To rotate without an outage, publish the new secret to Zitadel as the Action's signing secret, then run postinod with both secrets comma-separated so signatures under either one verify:

export POSTINOD_ZITADEL_HMAC_SECRET="$OLD,$NEW"
systemctl restart postinod
# wait until Zitadel has flipped to $NEW for all targets
export POSTINOD_ZITADEL_HMAC_SECRET="$NEW"
systemctl restart postinod

The replay window (POSTINOD_ZITADEL_REPLAY_WINDOW_SEC, default 300s) rejects events whose created_at is too far from the server clock — keep the postinod host's clock in NTP sync.

Status

MVP shipping (v0.1.0 on PyPI). Local identity backend implemented.

Next:

  • V2: ZitadelProvider — write identity to Zitadel, leave mailbox.password as {NOAUTH} sentinel
  • postino reconcile — drift detector vs identity source of truth
  • TOML config schema validation at startup with helpful errors

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

il_postino-0.4.0.tar.gz (375.8 kB view details)

Uploaded Source

Built Distribution

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

il_postino-0.4.0-py3-none-any.whl (99.3 kB view details)

Uploaded Python 3

File details

Details for the file il_postino-0.4.0.tar.gz.

File metadata

  • Download URL: il_postino-0.4.0.tar.gz
  • Upload date:
  • Size: 375.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for il_postino-0.4.0.tar.gz
Algorithm Hash digest
SHA256 9d3f7e305306723c4e3e86f8fbc5b4f5417ab433c0917c286a98ccdc40754bac
MD5 b2c020e116e7fb30c5181275357d42db
BLAKE2b-256 e377abc02452966c1267f45d73310a84686181b64d0de77e1fb673c8497a6ecd

See more details on using hashes here.

Provenance

The following attestation bundles were made for il_postino-0.4.0.tar.gz:

Publisher: release.yml on vjt/postino

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file il_postino-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: il_postino-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 99.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for il_postino-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fd5bd26ae920459d1c6023bd25eab5cffc2c43a360e169ef4194050f538b08fb
MD5 2bffa58f3a3d0df81e543c8e2ab86e1f
BLAKE2b-256 f2339f2fbf00100b5a527551a5e54139b51960ed90a87815458fdbb624322b38

See more details on using hashes here.

Provenance

The following attestation bundles were made for il_postino-0.4.0-py3-none-any.whl:

Publisher: release.yml on vjt/postino

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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