📖 Full write-up, history & guided tour: https://deb.myguard.nl/2026/06/vimbadmin-postfix-dovecot-mailbox-admin-panel/
Virtual Mailbox Administration that runs on a PHP version released this decade.
ViMbAdmin (vim-be-admin, and yes the editor war is intentional) is a web
panel for managing the virtual domains, mailboxes and aliases in a
Postfix + Dovecot mail server backed by a SQL database. It sits between
your tired hands and the mailbox table so you stop editing production mail
with raw INSERT statements at 02:00. You know who you are.
This is the eilandert fork. Upstream (opensolutions/ViMbAdmin) is a fine piece of software that stopped getting commits years ago and no longer runs cleanly on a modern PHP. We needed it on PHP 8.5, on a hardened stack, so we fixed it. Then we kept fixing it until the audit log went quiet.
📖 Read the full story, the why, and a guided tour: ViMbAdmin: The Postfix + Dovecot Mailbox Admin Panel (Modernised for PHP 8.5) on deb.myguard.nl — history, who it's for, and how it fits a real mail stack.
The short version: it runs, and it's hard to break into.
🆕 No helper scripts, no Dovecot-side cron — mailbox ops are native
Mailbox maintenance no longer needs any external scripts or Dovecot-side cron jobs. Everything is driven straight from the panel:
- Repair / optimize / archive / delete run against Dovecot's built-in
doveadm HTTP API (
force-resync,index,purge,backup,mailbox delete) — no shared mail filesystem, norm -rf, no tar scripts. Each request is queued in a dedicated table and drained by a single throttled runner, so a bulk action can't hammer Dovecot. A Queue tab shows progress and lets you run it on demand; an optional key+IP-gated endpoint lets a remote cron kick it. - Passwords are hashed natively in PHP (
BLF-CRYPT,SHA512-CRYPT,SHA256-CRYPT) — thedoveadm pwbinary and the olddovecotpasswd.phpworkaround are gone. - Quota usage comes live from Dovecot's quota-clone
dovecot_quotatable — the old maildir-scan accounting cron is retired.
Net result: the Dovecot container ships zero ViMbAdmin scripts/cron, and the only optional cron left is the panel's own queue-runner.
Brought into this decade
- PHP 8.1 → 8.5 clean. Every implicit-nullable parameter fixed, every PHP-8-removed function call replaced.
- Smarty 4 → 5. Templating bridge ported to the new API (setters, the
nofilterflag, the{if}-can't-call-PHP-functions rule, and the delightful clone bug where Smarty 5's BC plugin loader drops every custom plugin from a cloned view — which is why your forms used to render blank). - Doctrine ORM 2.8 → 2.20 (latest 2.x LTS) + DBAL 3. CLI and query API rewritten to match.
- Cache layer rebuilt on Symfony Cache.
doctrine/cache2.x dropped the old concrete*Cacheproviders, so the metadata/query cache now wraps a Symfony PSR-6 pool (ArrayAdapter/ApcuAdapter/RedisAdapter) inDoctrineProvider— pick the backend inapplication.ini. The Docker image ships APCu + a tuned OPcache for a persistent, per-request-free cache.
See Security below for the full list of what was hardened, and Performance for the caching notes.
Everything this fork does to keep the panel hard to break into, by layer. The stock upstream had none of the application-layer items below.
- Two-factor authentication (TOTP). Opt-in per admin at
/admin/two-factor.- Secret encrypted at rest with libsodium (
crypto_secretbox), keyed off the appsecuritysalt— a DB read alone yields no usable secrets. - QR-code enrolment + manual secret entry; one-time backup codes (bcrypt-hashed, single-use).
- Replay protection — a TOTP time-slice is accepted once; a captured code can't be replayed inside its validity window.
- Super-admin management of other accounts: provision (show secret/QR to hand over), regenerate, disable, and force enrolment at next login.
- Lost-device recovery without DB surgery: backup codes, a CLI reset
(
vimbtool.php -a admin.cli-reset-totp --username=…|--all), orapplication.ini(twofactor.force_disable).
- Secret encrypted at rest with libsodium (
- Passwords. Admin passwords bcrypt-hashed and compared in constant time
(
hash_equals). Mailbox passwords hashed in a Dovecot-accepted scheme (doveadm pw). - Session-fixation defence — the session id is regenerated on every successful login (and again after the 2FA step).
- Brute-force protection — per-source-IP attempt counter with lockout
window; a fully successful login clears it. IP/CIDR allowlist and all
thresholds configurable in
application.ini([bruteforce]). 429 when locked. - CSRF — a per-session token on every form (auto-validated by
Zend_Form::isValid()) and on every destructive GET link (purge/delete/cancel/restore); forged request → 403.
- XSS auto-escaping — Smarty
setEscapeHtml(true)globally; only deliberately-HTML output isnofilter. A stored<script>payload renders as inert text. - SQL injection — the app uses Doctrine ORM with parameterised queries; the four unreferenced raw-SQL "OSS API" integration classes (one with an actual injection) were deleted.
- Command injection — every shell-out (Dovecot
doveadm, archive tar/bzip2/du) isescapeshellarg'd. - Deserialisation —
unserialize()of archive blobs is restricted with['allowed_classes' => false]. - CSPRNG — tokens, salts and backup codes use
random_int()(the oldstr_shuffle/mt_randwas replaced). - Real client IP — a spoof-resistant trusted-proxy resolver
(
trustedproxy.mode, defaultauto) feeds the brute-force limiter and the MCP IP allowlist the actual client, not the reverse proxy. See Real client IP behind a proxy.
- Bearer-token JSON-RPC API at
/mcpfor agents: SHA-256-hashed tokens (raw shown once), scoped read/write, optional per-token IP/CIDR allowlist, expiry + revoke, and a per-token rate limit on destructive operations. Edge IP-allowlisted in the vhost; bearer-only (no admin session). See the MCP adapter section and docs/mcp-auth.md.
- A code-derived
vimbadmin-strict.listruleset: bans every dangerous function the app doesn't use, allow-scopes theexecit does, blocks RFI/LFI wrappers, eval/base64_decodewebshell pipes, mail-header injection, env hijacking, world-writable chmod, writing PHP-loadable files, and insecure cURL/SSRF. Logs/encrypts cookies as available. A uniquesecret_keymust be set per deployment.
- Hardened PHP-FPM pool (
contrib/php-fpm/vimbadmin.conf) —open_basedir, empty nativedisable_functions(Snuffleupagus owns the policy), strict session-cookie flags,security.limit_extensions=.php, resource limits. - Hardened Angie/nginx vhost (
contrib/angie/vimbadmin.conf) — a native positive-security gate: only known HTTP methods, the real route map (controllers + ZF1 param URLs), and the app's known argument names reach PHP; scanner/empty user-agents are dropped. Plus TLS, strict CSP + security headers, a rate-limited login, internal-path/dotfile denies, and BREACH mitigation (no compression of secret-bearing dynamic responses). - OWASP CRS / ModSecurity plugin (optional, belt-and-braces) — payload signature scanning on top of the vhost, only where you already run libmodsecurity: vimbadmin-crs-plugin.
- Docker image — read-only rootfs, root-owned read-only codebase, per-deployment secrets generated at first run, all caps dropped bar the few needed, docs/repos/setuid stripped. See the image README.
- Dead Doctrine 1 code, an unused PDF chain, the Yubico/Invoice/GeoIP/Csv/Phone/ Acl/Curl/Crypt_OpenSSL utilities, and four unreferenced "OSS API" classes (one carrying SQLi). ~1,600+ lines gone.
- Fixed real latent bugs surfaced on the way: AJAX toggle guards that printed
"ko" but toggled anyway (privilege bug), and
$this->getLogger->property- access fatals on the archive paths.
- On current LTS lines (doctrine/orm 2.20, dbal 3, symfony/cache 6.4/7,
smarty 5, zf1-future 1.25, robthree/twofactorauth 3, bacon/bacon-qr-code 3);
composer auditreports no advisories.
The fastest way to a running panel. You bring a MariaDB/MySQL database; the image bundles the app, PHP-FPM and the web server.
# docker-compose.yml -- change the passwords. "vimbpass" is not a password.
services:
db:
image: mariadb:lts
environment:
MARIADB_ROOT_PASSWORD: change-me
MARIADB_DATABASE: vimbadmin
MARIADB_USER: vimbadmin
MARIADB_PASSWORD: change-me-too
vimbadmin:
image: eilandert/vimbadmin:latest
depends_on: [db]
ports:
- "8080:80"
environment:
TZ: Europe/Amsterdamdocker compose up -d
# wait for MariaDB's first-boot, then browse to http://localhost:8080/Put it behind TLS in production — ideally behind the hardened vhost and the ModSecurity plugin shipped alongside this repo.
PHP 8.4.1+ (the dependency-tree floor) with pdo_mysql, mbstring,
intl, gettext, dom, ctype, iconv and sodium (the 2FA secrets are
encrypted with libsodium). apcu is optional but recommended (see
Performance).
git clone https://github.com/eilandert/ViMbAdmin.git
cd ViMbAdmin
composer install --no-dev
cp application/configs/application.ini.dist application/configs/application.ini
# edit application.ini: point resources.doctrine2.connection.options.* at your DB
# create the schema (this is the modernised CLI; the old one used a dead API)
./bin/doctrine2-cli.php orm:schema-tool:createPoint your web server's docroot at public/, wire PHP-FPM to it, and browse
to the site.
ViMbAdmin notices it has no admins and sends you to a setup page. Do this immediately, on a trusted network.
- It generates a security salt — keep the one it gives you.
- Create the super-admin. The username is an email address
(
you@yourdomain.com), not the word "admin". The field is literally labelled "Email". People miss this hourly. Don't be people. - Pick a real password. It's bcrypt-hashed and constant-time-compared; the strength is on you.
Pulling a newer version of the fork may add columns, indexes or tables. Bring your database in line in one of two ways:
# A) let Doctrine reconcile the DB with the entity mappings (shows the SQL):
./bin/doctrine2-cli.php orm:schema-tool:update --dump-sql # preview
./bin/doctrine2-cli.php orm:schema-tool:update --force # apply# B) apply the consolidated fork-schema migration from contrib/migrations/
mysql -u<user> -p <database> < contrib/migrations/2026-06-fork-schema.sqlcontrib/migrations/ holds one idempotent, consolidated SQL file —
2026-06-fork-schema.sql — the
standalone mirror of everything the fork adds above upstream (DBVERSION 3). It
is the hand-written equivalent of orm:schema-tool:update --force plus the
FK/collation steps the schema-tool can't express, and is safe to re-run. In
dependency order it covers: (1) the dovecot_quota table + retirement of
the legacy maildir-scan columns; (2) the UNIQUE index on mailbox.username
(Postfix/Dovecot look that up on every delivery and login, and it is the FK
target in step 3); (3) ON DELETE CASCADE FKs dovecot_quota /
dovecot_last_login → mailbox(username) plus the collation alignment they
need; (4) the archive.autoprune column. Fresh installs build all of this
automatically; only DBs seeded from older dumps need the file. Always back up
first; step 2's index is UNIQUE, so dedupe any duplicate usernames before
applying:
SELECT username, COUNT(*) c FROM mailbox GROUP BY username HAVING c > 1;The dovecot_quota part of that migration lets this fork retire the old
nightly maildir-scan (mailbox.cli-get-sizes) and get live mailbox usage
straight from Dovecot's quota-clone plugin instead: it creates the
dovecot_quota table, seeds it from the old maildir_size values, then drops
the retired maildir_size / homedir_size /
size_at columns. See
Live quota usage (Dovecot quota-clone)
for the Dovecot config.
In order, because the order matters:
- Domains → Add. The
@example.com. Set per-domain limits and quotas. (Postfix still has to be configured to readvirtual_mailbox_domainsfrom the DB — ViMbAdmin maintains the data, it can't make Postfix care.) - Mailboxes → Add. Local part, password, quota. The password is hashed
in a scheme Dovecot accepts (it can shell out to
doveadm pw). - Aliases → Add. Address → comma-separated
gotolist. This is yourpostmaster@, your role addresses, your distribution lists.
Every action is logged, validated, and CSRF-protected.
Opt-in, per admin. Each admin enables it on themselves at /admin/two-factor:
- Scan the QR with an authenticator app (Aegis, Google Authenticator, 1Password, …) or type the shown secret in by hand.
- Enter the 6-digit code to confirm and enable.
- Save the one-time backup codes. They're shown once. Each works once, for when your phone inevitably ends up in a washing machine.
After that, login is password → 6-digit code. The TOTP secret is stored
encrypted (libsodium, keyed off securitysalt); a database read alone
doesn't yield usable secrets.
Lost your second factor? Two escape hatches, no DB surgery required:
# CLI (immediate):
./bin/vimbtool.php -a admin.cli-reset-totp --username=admin@example.com
./bin/vimbtool.php -a admin.cli-reset-totp --all
# or in application.ini (applied at that admin's next login):
twofactor.force_disable = "admin@example.com" ; or "*" for everyoneOn by default. Counts failed logins per source IP and locks the source out
once it crosses the threshold; a fully successful login (password + 2FA)
clears the counter. Configure in application.ini:
bruteforce.enabled = 1
bruteforce.max_attempts = 5 ; failures before lockout
bruteforce.window = 900 ; seconds the counter accumulates over
bruteforce.lockout = 900 ; seconds locked
bruteforce.whitelist[] = "127.0.0.1"
bruteforce.whitelist[] = "10.0.0.0/8" ; IPs or CIDRs never countedThe brute-force limiter (and the MCP per-token IP allowlist) need the real
client IP, not your reverse proxy's. Controlled by trustedproxy.mode in
application.ini:
trustedproxy.mode = "auto" ; auto | on | off
;trustedproxy.proxies[] = "10.0.0.0/8" ; for mode "on"auto(default) — trustX-Forwarded-Foronly when the request reaches PHP from a private/loopback address (a local reverse proxy). Standalone (publicREMOTE_ADDR) just usesREMOTE_ADDR. No config needed for the usual "proxy on the same host/LAN" setup.on— trustX-Forwarded-Foronly from the proxies you list.off— always useREMOTE_ADDR.
X-Forwarded-For is client-spoofable, so the client is taken as the right-most
address in the chain that isn't a trusted proxy. Alternatively, let the web
server rewrite REMOTE_ADDR (Angie/nginx realip; see the commented block in
contrib/angie/vimbadmin.conf) and leave the mode at auto.
An optional JSON-RPC API at /mcp so an agent can read and manage the
mailbox database. Off by default (mcp.enabled = 1 to turn on). Guarded in
depth: an edge IP allowlist, a bearer token (only its SHA-256 hash is
stored, scoped + revocable + expirable), a per-token IP/CIDR allowlist, and a
per-token rate limit on destructive calls. Read methods (domains.list,
mailboxes.list, …) and write methods (mailbox.create, mailbox.archive, …)
are scope-gated. Manage tokens from the CLI:
./bin/vimbtool.php -a mcp.cli-token-generate --name=agent1 --scope="read"
./bin/vimbtool.php -a mcp.cli-token-list
./bin/vimbtool.php -a mcp.cli-token-revoke --name=agent1Full method list, auth model and examples: docs/mcp-auth.md.
The panel is light, but two things keep it snappy:
-
OPcache — caches compiled PHP bytecode. The Docker image tunes it for an immutable codebase (
opcache.validate_timestamps=0, no stat() per include). -
Doctrine metadata/query cache. Without a persistent cache Doctrine re-parses the XML entity mappings on every request. Set a real backend in
application.ini:; per-request only (default; fine for dev) resources.doctrine2cache.type = "ArrayCache" ; persistent, in-process shared memory (recommended single-host) -- needs ext-apcu resources.doctrine2cache.type = "ApcuCache" ; shared across hosts/replicas -- needs ext-redis ;resources.doctrine2cache.type = "RedisCache" ;resources.doctrine2cache.redis.dsn = "redis://127.0.0.1:6379"
The Docker image defaults to
ApcuCache. For a single container APCu beats Redis (in-process, no socket); reach for Redis only when you run multiple replicas that must share a cache. A configured backend whose PHP extension is missing degrades toArrayCacheinstead of fataling.
Archive and delete are queue-driven over the doveadm HTTP API — no tarballs,
no shell tools, no mail-host checkout. The web panel never touches the mail
filesystem; it writes a mailbox_task row and the queue runner does the work on
the Dovecot side:
- Archive (keeps the account) —
doveadm backupcopies the store to a zstd-compressed maildir underdoveadm.backup.dest(e.g./backups/%d/%u), then the live store is emptied. Anarchiverow (status Archived) appears on the Archives tab. - Delete (removes the account) — same backup, then the mailbox + account row
are removed. The archive row is flagged autoprune, so the backup is pruned
automatically after
queue.autoprune.days(default 90;queue.autoprune.days = 0means instant — delete takes no backup and removes the mailbox immediately).
On the Archives tab each backup shows when it was archived, whether the
account still exists, and its autoprune state (toggle per-row). From there you
can restore it (recreates the mailbox from a stored snapshot — original
password hash included — then doveadm syncs the mail back from the backup) or
delete the backup (doveadm fs delete removes the /backups maildir). The
Maintenance tab has Run autoprune now (expired) and Delete all autoprune
backups buttons; a cron can call the same maintenance.prune-expired action.
doveadm fs delete needs a fs posix { driver = posix } filter in the Dovecot
config (the prune removes a backup maildir over the HTTP API rather than sharing
the filesystem with the panel).
ViMbAdmin runs no daemon — you MUST install a cron. The queue is only
drained when something invokes queue.cli-run. As a convenience the panel also
trigger-checks (spawns a background runner if there's pending work and a free
slot) on five events — container start, any login, opening the Maintenance
tab, an MCP archive call, and the HTTP trigger — but those are best-effort
nudges, not a substitute for the cron. Pick one cron form below; every
2 minutes is typical. Full requirements + an autoprune cron are in
contrib/cron/.
Concurrency is capped by queue.runner.max_concurrent (default 1 =
strictly serial). A DB lease (queue_runner table) enforces it across CLI, web
and containers, so overlapping cron ticks or trigger-checks never run more than
the configured number of runners at once; a crashed runner's lease is reaped
after a timeout so a slot is never lost.
1. Docker (docker exec) — run from the host crontab against the
container:
*/2 * * * * root docker exec vimbadmin php /opt/vimbadmin/bin/vimbtool.php -a queue.cli-run2. Bare metal / inside the container — plain PHP CLI, from an
application.ini that points at the panel's DB + doveadm HTTP endpoint:
*/2 * * * * vmail php /opt/vimbadmin/bin/vimbtool.php -a queue.cli-run3. HTTP trigger — when the cron host can't run the CLI at all. Set
queue.runner.key + queue.runner.allowed_ips in application.ini, then have
any host on the allowlist POST the key as a Bearer token:
*/2 * * * * root curl -fsS -X POST -H "Authorization: Bearer <key>" \
https://mail.example.com/vimbadmin/queue/trigger >/dev/null(Empty queue.runner.key disables the HTTP endpoint; the CLI runner and the
in-panel "Run now" button always work.)
A separate, default-off CLI on-disk purge (mailbox_deletion_fs_enabled,
binary.path.rm_rf) still exists for direct maildir removal on a host that can
see the mail; it is unrelated to the queue archive/delete flow above and the
hardened Docker image deliberately can't use it.
Mailbox usage in the panel does not need a maildir scan — it is fed live by Dovecot's quota-clone plugin. See below.
There are two separate quota concerns — keep them apart:
| What | Where it lives | Who writes it | |
|---|---|---|---|
| Limit | the cap per mailbox | mailbox.quota (bytes) |
ViMbAdmin (you, in the GUI) |
| Usage | how full the mailbox is now | dovecot_quota table |
Dovecot, live |
ViMbAdmin sets the limit; Dovecot enforces it and reports back the usage.
The panel reads dovecot_quota and shows usage (and a % of the limit) in the
mailbox list and per-domain totals.
Older ViMbAdmin scanned every maildir from a nightly mailbox.cli-get-sizes
cron and stored the result in mailbox.maildir_size. That was only as fresh as
the cron, and meant a full du walk of every maildir. This fork drops that
entirely. Usage now comes straight from Dovecot 2.4's
quota-clone plugin,
which writes each user's current storage + message count into the database on
every change — real-time, no cron, no scan.
quota-clone writes with INSERT .. ON DUPLICATE KEY UPDATE. Pointed straight at
the mailbox table that fails, because mailbox has NOT NULL columns with no
default (password, quota, local_part) that the upsert can't supply. So
quota-clone gets its own clean table — dovecot_quota(username, bytes, messages, updated_at), keyed by the full email address (= mailbox.username).
ViMbAdmin reads that table directly; it never writes it (Dovecot is the
authority and replaces the row on every change). A mailbox shows 0 until
Dovecot writes its first figure.
The table is created on fresh installs by the entity mapping
(orm:schema-tool:create); existing DBs apply the consolidated
contrib/migrations/2026-06-fork-schema.sql
(its step 1 creates dovecot_quota, seeds it from the old maildir_size, and
drops the retired maildir_size / homedir_size / size_at columns).
This is a complete, working setup. Two plugins, two jobs:
quota = the enforcement backend (rejects over-quota mail). The per-user
limit comes from your SQL userdb — ViMbAdmin exposes the mailbox's limit as
userdb_quota_rule = *:bytes=N, so this just needs to be enabled:
mail_plugins {
quota = yes
}
quota "User quota" {
driver = count # recommended for maildir; index-based, no du
}
quota_full_tempfail = yes # 4xx on backend error instead of bouncing mail
quota_clone = the reporting half that feeds ViMbAdmin's display. Point its
dict at the ViMbAdmin database, writing storage→bytes and messages→messages
in dovecot_quota:
mail_plugins {
quota_clone = yes
}
dict_server {
dict vimbadmin {
driver = sql
sql_driver = mysql
mysql <db-host> {
user = vimbadmin
password = <password>
dbname = vimbadmin
}
dict_map priv/quota/storage {
sql_table = dovecot_quota
username_field = username
value_field bytes {
}
}
dict_map priv/quota/messages {
sql_table = dovecot_quota
username_field = username
value_field messages {
}
}
}
}
quota_clone {
dict proxy {
name = vimbadmin
}
}
Don't confuse the two plugins.
quotaenforces and is authoritative;quota_cloneonly mirrors usage for display. The per-mailbox limit is always the value ViMbAdmin sets (mailbox.quota), surfaced to Dovecot via the userdbquota_rule.
Not a mail-server appliance. It manages the user database; it does not install or configure Postfix or Dovecot, and it does not filter spam (for that, Rspamd). It's a deliberately narrow component, which is exactly why it can be audited and trusted.
application/ ZF1 controllers (incl. McpController), entities, views (Smarty)
library/ OSS + ViMbAdmin framework (Doctrine, auth, Net, Mcp/)
public/ web docroot (index.php front controller)
bin/ CLI tools (doctrine2-cli.php, vimbtool.php, crons)
contrib/ deploy configs: php-fpm pool, Angie vhost, mail-host crons,
snuffleupagus/ (the validated SP ruleset), migrations/, theming
doctrine2/xml/ Doctrine XML mappings (the schema source of truth)
docs/ extra documentation (mcp-auth.md)
A separate, optional OWASP CRS / ModSecurity plugin lives at vimbadmin-crs-plugin — payload-signature scanning on top of the vhost, only if you already run libmodsecurity.
Originally written by Open Solutions on the Zend Framework, Doctrine ORM and Smarty. GPLv3 — same as it always was. This fork keeps the licence and the gratitude; it just keeps the lights on too.