Metadata-Version: 2.4
Name: il-postino
Version: 0.12.3
Summary: Typed Python CLI for administering Postfix + Dovecot mail servers (PostfixAdmin schema). Pluggable identity backend.
Project-URL: Homepage, https://github.com/vjt/postino
Project-URL: Issues, https://github.com/vjt/postino/issues
Author-email: Marcello Barnaba <marcello.barnaba@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: System :: Systems Administration
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: bcrypt<5,>=4.0
Requires-Dist: cryptography>=42
Requires-Dist: email-validator>=2.2
Requires-Dist: jinja2<4,>=3.1
Requires-Dist: packaging>=21
Requires-Dist: passlib>=1.7
Requires-Dist: pydantic-settings>=2.3
Requires-Dist: pydantic>=2.7
Requires-Dist: pymysql>=1.1
Requires-Dist: rich>=13.7
Requires-Dist: sqlalchemy>=2.0
Requires-Dist: tomlkit<1,>=0.13
Requires-Dist: typer>=0.12
Provides-Extra: cli
Provides-Extra: daemon
Requires-Dist: httpx>=0.27; extra == 'daemon'
Requires-Dist: litestar<3,>=2.13; extra == 'daemon'
Requires-Dist: pyjwt[crypto]>=2.8; extra == 'daemon'
Requires-Dist: uvicorn>=0.30; extra == 'daemon'
Provides-Extra: dev
Requires-Dist: import-linter>=2.0; extra == 'dev'
Requires-Dist: pyright>=1.1.370; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest-freezer>=0.4; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Requires-Dist: scim2-models>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# postino

![postino — il postino delivers your mail config](https://cdn.jsdelivr.net/gh/vjt/postino@v0.2.0/docs/assets/cover.jpg)

[![PyPI](https://img.shields.io/pypi/v/il-postino.svg)](https://pypi.org/project/il-postino/)
[![Python](https://img.shields.io/pypi/pyversions/il-postino.svg)](https://pypi.org/project/il-postino/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![codecov](https://codecov.io/gh/vjt/postino/branch/main/graph/badge.svg)](https://codecov.io/gh/vjt/postino)

Typed Python CLI for administering Postfix + Dovecot mail servers that use
the [PostfixAdmin](https://github.com/postfixadmin/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.

```sh
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)

```sh
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:

```sh
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:

```sh
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:

```sh
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/`:

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

Future updates can use the cache and skip rust:

```sh
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.

```sh
v=0.9.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 (.pkg)

FreeBSD 14, amd64. Brings `pkg`-installed `py311-pydantic`,
`py311-bcrypt`, `py311-cryptography` etc. as runtime deps so the venv
doesn't carry duplicate C extensions. Ships an `rc(8)` script disabled
by default; enable with `postinod_enable=YES` in `/etc/rc.conf`.

```sh
v=0.9.0
fetch -o /tmp/il-postino.pkg \
  "https://github.com/vjt/postino/releases/download/v${v}/il-postino-${v}.pkg"
sudo pkg add /tmp/il-postino.pkg
```

## Configuration

postino reads, in order of increasing precedence:

1. `~/.config/postino/postino.toml`
2. `/usr/local/etc/postino/postino.toml`
3. The file pointed at by `$POSTINO_CONFIG`, if set
4. `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`:

```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):

```sh
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.

## Scripting

postino is non-interactive-friendly. Recommended pattern for
automated provisioning:

```sh
echo "$NEW_PASSWORD" | postino --json --no-color \
  user add marketing@example.com --password-stdin \
  --quota 1G --name "Marketing"
```

Global flags:
- `--json`  — machine-readable output (stdout) on every command
- `--quiet` — suppress banner output (currently no-op; reserved for future banner-emitting commands)
- `--no-color` — strip ANSI; honored automatically when `NO_COLOR`
  or `CI` is set in the environment

Global flags (`--json`, `--quiet`, `--no-color`) may appear anywhere
in the command line. `postino user list --json` and
`postino --json user list` are equivalent.

Password-taking commands (`user add`, `user passwd`) accept
`--password-stdin` to read one line from stdin in lieu of the TTY
prompt. The password is not echoed and is consumed only once (no
confirmation re-read — the script either has it right or doesn't).

Rejected by design: `--password-file PATH` (file footgun),
`POSTINO_PASSWORD` env var (leaks via `/proc/<pid>/environ`).

## Identity backends

postino supports three identity-backend modes. Set in `postino.toml`:

```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 `/Users` with `"password": "..."` provisions an SQL-authed
  mailbox; omit `password` to 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> --claim` transitions 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

```sh
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

```sh
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

```sh
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`.

```sh
# 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.

### Mailing lists (mlmmj)

postino manages [mlmmj](https://mlmmj.org/) lists via the `postino list`
subcommand. Lists can live on a dedicated subdomain (`team@lists.example.org`)
or on a shared domain alongside regular mailboxes (`soci@example.org`).
Routing is SQL-driven via the `routes` table — no per-domain `transport`
setting is needed.

**v0.10+ requires the `routes` table and specific Postfix wiring.** Run
`postino schema migrate` once to apply the new table, then configure
`main.cf` / `master.cf` before creating any list. See `CHANGELOG.md
[0.10.0]` for the full migration steps and
[`docs/postino-mlmmj.md`](docs/postino-mlmmj.md) for the wiring reference.

```sh
# Create a list.
postino list add team@lists.example.org \
  --owner alice@example.org \
  --owner bob@example.org

# Add / remove subscribers.
postino list sub team@lists.example.org carol@example.org
postino list unsub team@lists.example.org carol@example.org

# Inspect lists.
postino list show team@lists.example.org
postino list ls --domain lists.example.org

# Delete a list (refuses non-empty unless --force).
postino list rm team@lists.example.org --yes --force
```

Spool directories follow the two-level layout
`<mlmmj_spool_dir>/<domain>/<localpart>/`
(e.g. `/var/spool/mlmmj/lists.example.org/team/`).

### Quota usage

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

### Operations

```sh
postino check          # shallow: DB reachable, schema present, hook safe,
                       #          postfix sql-virtual_*.cf credentials match engine,
                       #          mlmmj transport_maps + recipient_delimiter + master.cf pipes.
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:

```sh
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            |
| 10   | `RuleViolationError` — domain rule violation     |
| 11   | `PreflightFailed` — `config gen` preflight refused |
| 12   | `CollisionRefused` — `config gen` would overwrite without `--in-place` |
| 13   | `RenderError` — `config gen` template render failed |
| 14   | `PostCheckFailed` — `config gen` post-emit check 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`](docs/superpowers/specs/2026-05-09-postino-design.md)
for the full design.

## Development

```sh
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:

```sql
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;
```

```sh
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

```sh
./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:

```sh
./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

```sh
# 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:

```sh
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:

```sh
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](LICENSE).
