Typed Python CLI for administering Postfix + Dovecot mail servers (PostfixAdmin schema). Pluggable identity backend.
Project description
postino
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/ .
Debian / Ubuntu (.deb)
Bookworm and trixie, amd64 and arm64. The whole CPython venv is bundled in /usr/share/postino/venv — no pip runs at install time.
v=0.8.0
arch=$(dpkg --print-architecture) # amd64 or arm64
codename=$(lsb_release -cs) # bookworm or trixie
url="https://github.com/vjt/postino/releases/download/v${v}/il-postino_${v}-1_${codename}_${arch}.deb"
curl -fLo /tmp/il-postino.deb "$url"
sudo apt install -y /tmp/il-postino.deb
FreeBSD
pip install il-postino is the supported path on FreeBSD. A native
.txz is on the v0.9 roadmap — pkg create blocked on maturin's
SOABI detection for pydantic-core (see docs/superpowers/specs/
or the README § FreeBSD notes for the pip-cached-wheels workaround).
Configuration
postino reads, in order of increasing precedence:
~/.config/postino/postino.toml/usr/local/etc/postino/postino.toml- The file pointed at by
$POSTINO_CONFIG, if set POSTINO_*environment variables
Subtable sections (e.g. [postinod]) inside any of the above TOML
files are silently dropped, so the same file can carry both the CLI's
top-level keys and the daemon's [postinod] block.
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.
Identity backends
postino supports three identity-backend modes. Set in postino.toml:
[core]
identity_backend = "local" # or "noauth" or "hybrid"
local — SQL-only auth
Every mailbox has a bcrypt hash in mailbox.password. Dovecot resolves
all users via passdb-sql. Use this when you have no IdP.
noauth — IdP-only auth
Every mailbox carries the {NOAUTH} sentinel. Dovecot's passdb-sql
sees the sentinel as a non-resolvable scheme and falls through to a
chained non-SQL passdb (LDAP, OIDC bridge, passwd-file, ...). Use this
when an external IdP owns every user.
Required Dovecot config snippet:
# /usr/local/etc/dovecot/conf.d/auth-sql.conf.ext
passdb {
driver = sql
args = /usr/local/etc/dovecot/sql-virtual_mailbox.cf
result_failure = continue # critical: defer on {NOAUTH}
}
# /usr/local/etc/dovecot/conf.d/auth-ldap.conf.ext (or similar)
passdb {
driver = ldap
args = /usr/local/etc/dovecot/dovecot-ldap.conf.ext
}
hybrid — per-row credential ownership
Same Dovecot config as noauth (the result_failure = continue on
passdb-sql + a chained non-SQL passdb is mandatory).
Operations:
- SCIM POST
/Userswith"password": "..."provisions an SQL-authed mailbox; omitpasswordto provision an IdP-managed (sentinel) one. - SCIM PATCH
/Users/{id}with{op:"replace", path:"password", value:"..."}rotates / claims;{op:"remove", path:"password"}or{op:"replace", path:"password", value:null}releases back to IdP. - CLI
postino user passwd <user> --claimtransitions an IdP-managed mailbox into SQL auth. - CLI
postino user release <user>transitions an SQL-authed mailbox back to IdP-managed.
Domain freedom: there is no per-domain identity setting. Partition by
sending Zitadel/SCIM events only for the users you want IdP-managed;
the rest live in SQL auth. postino check flags mailboxes whose
identity state appears inconsistent with the deployment (e.g. a row
with {NOAUTH} under identity_backend=local).
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 enable example.com
postino domain disable example.com
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 enable foo@example.com
postino alias disable foo@example.com
postino alias del foo@example.com --yes
Alias domains
Map one mail domain to another (PostfixAdmin's alias_domain table).
Mail to user@aliasdom.it is delivered as user@target.com by
postfix's virtual_alias_domain_maps.
# Both source and target domains must already exist in `postino domain list`.
postino domain alias add aliasdom.it --target target.com
postino domain alias list # active rows
postino domain alias list --all # include disabled
postino domain alias list --target target.com # filter by target
postino domain alias show aliasdom.it
postino domain alias retarget aliasdom.it --target other.com
postino domain alias disable aliasdom.it
postino domain alias enable aliasdom.it
postino domain alias del aliasdom.it --yes
postino enforces PostfixAdmin parity: no self-alias, no chains (source-already-target or target-already-source), both endpoint domains must exist, no duplicate rows. Mail loops are rejected at creation time. Exit code 10 indicates a rule violation.
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 | CapacityError — max_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.
mlmmj-dependent tests
The mailing-list integration + e2e CLI suites need the mlmmj binaries
(mlmmj-sub, mlmmj-unsub, mlmmj-list) on PATH. If you don't want
to install mlmmj on your workstation, run them inside docker against
the host's mariadb:
./scripts/test-mlmmj.sh # builds the image (cached), runs pytest
./scripts/test-mlmmj.sh -v -x # extra args forwarded to pytest
The script reuses tests/postinod_e2e/lists/Dockerfile.agent (already
exercised by CI) and uses --network=host to reach the local mariadb,
so it needs no separate DB sidecar. macOS/Windows: edit
POSTINO_TEST_DB_URL to use host.docker.internal instead of
localhost.
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.passwordas{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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file il_postino-0.8.1.tar.gz.
File metadata
- Download URL: il_postino-0.8.1.tar.gz
- Upload date:
- Size: 435.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fc55da99a22c3f4950a3f75f18151ca3dbc9ff4e032c142d3f35bad22f0e2219
|
|
| MD5 |
383a47e508fa04b82265e682bc2cd382
|
|
| BLAKE2b-256 |
b4e6eaa87ec011a2adbdb5d86f32403a47f35c0d717ba0ad7f864efde88a8e0e
|
Provenance
The following attestation bundles were made for il_postino-0.8.1.tar.gz:
Publisher:
release.yml on vjt/postino
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
il_postino-0.8.1.tar.gz -
Subject digest:
fc55da99a22c3f4950a3f75f18151ca3dbc9ff4e032c142d3f35bad22f0e2219 - Sigstore transparency entry: 1522898386
- Sigstore integration time:
-
Permalink:
vjt/postino@5e939c818e575d545f3299fc4e321294b24f33ce -
Branch / Tag:
refs/tags/v0.8.1 - Owner: https://github.com/vjt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5e939c818e575d545f3299fc4e321294b24f33ce -
Trigger Event:
push
-
Statement type:
File details
Details for the file il_postino-0.8.1-py3-none-any.whl.
File metadata
- Download URL: il_postino-0.8.1-py3-none-any.whl
- Upload date:
- Size: 131.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7ee379e6e520d95b0d70515af4c3327e1bf08bff48bc98a0eecaa2d255dd3b47
|
|
| MD5 |
7c09777d533166003da2d53c2779407d
|
|
| BLAKE2b-256 |
4d4bf14e8230615a3eb793a0d391b844c0f1ca3f9b0389db5d443e3cd6d411d9
|
Provenance
The following attestation bundles were made for il_postino-0.8.1-py3-none-any.whl:
Publisher:
release.yml on vjt/postino
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
il_postino-0.8.1-py3-none-any.whl -
Subject digest:
7ee379e6e520d95b0d70515af4c3327e1bf08bff48bc98a0eecaa2d255dd3b47 - Sigstore transparency entry: 1522898406
- Sigstore integration time:
-
Permalink:
vjt/postino@5e939c818e575d545f3299fc4e321294b24f33ce -
Branch / Tag:
refs/tags/v0.8.1 - Owner: https://github.com/vjt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5e939c818e575d545f3299fc4e321294b24f33ce -
Trigger Event:
push
-
Statement type: