Your mailbox table has a row with a blank password field. You put it there at 23:45 last Thursday because you were “just testing” and you were going to fix it “in a minute.” It’s still there. I know because I’ve seen it on every mail server I’ve ever inherited, and I’ve inherited a lot of mail servers. ViMbAdmin is the web panel that finally broke me of that habit: a real interface for the Postfix and Dovecot virtual-mailbox tables, so you stop editing them by hand at quarter to midnight.
This is what happens when your mail server user management is “open a MySQL client and hope.” ViMbAdmin is the alternative, a web panel that sits between your caffeine-addled hands and the database that runs your virtual mail. Our modernised fork brings it up to PHP 8.5, adds a full JSON-RPC API, TOTP, brute-force protection, and a security posture that assumes, correctly, that the entire internet is trying to get in. Fork is here: github.com/eilandert/ViMbAdmin.

What ViMbAdmin actually is
Postfix and Dovecot do not care how the rows get into your database. They read virtual_mailbox_maps, they hash a password, they deliver mail, they go back to sleep. The database is the contract. How you maintain that contract is left, with magnificent Unix indifference, as an exercise for the reader.
Most people solve this one of three ways:
- Raw SQL, forever, on three hours’ sleep, one typo away from dropping the wrong domain.
- A full mail appliance that installs 47 things you didn’t ask for, writes its own Postfix config, and breaks every time you touch anything.
- ViMbAdmin.
ViMbAdmin is a CRUD app over three concepts: domains (@example.com, with quotas and mailbox limits), mailboxes (actual accounts with bcrypt-hashed passwords, maildirs, quotas), and aliases (the forwards, sales@ → wherever, postmaster@ → the person whose job it is to read bounces). It writes to the shared database. Postfix and Dovecot pick up the changes on their next lookup. That’s the whole transaction.
It does not configure Postfix. It does not install Dovecot. It does not filter spam, for that you want Rspamd. It is a component, deliberately narrow, which is exactly why it can be audited and trusted. Tools that try to do everything are the tools that have a buffer overflow in the “we didn’t think anyone would use this” codepath.
For password hashing it can shell out to doveadm pw, meaning the hash it stores is one Dovecot generated and will therefore accept without complaint. If you’ve ever spent an evening debugging why your hand-rolled {SHA512-CRYPT} prefix was subtly wrong, you know what this is worth.
The history: perfectly good software, abandoned mid-sentence
ViMbAdmin was written by Open Solutions, an Irish shop, around 2010. Zend Framework 1, Doctrine ORM, Smarty, a perfectly sensible stack for the era. Then, as happens to a great deal of genuinely useful software, the commits slowed. Then stopped. The last meaningful upstream release predates several PHP versions that have since been born, matured, and declared end-of-life.
Run the stock code on PHP 8.x and you get a wall of deprecation notices, a couple of fatals, and the special kind of silence where a form should have rendered. It didn’t die so much as sit down, mid-sentence, and stop talking.
We needed it on PHP 8.5 on a hardened production stack. So we fixed it. Then, because leaving a five-year-dormant admin panel on the internet with no security review is how you become a cautionary tale, we kept going.
What we actually changed (the itemised version, not the press release)
PHP 8.5, Smarty 5, Doctrine 2.20
The framework internals got a full transplant. Smarty 4 → 5: the templating layer changed under us in three distinct ways. It removed the public property API the old OSS_View_Smarty bridge poked at. It dropped bare PHP function calls inside {if} expressions. And, the landmine, its backward-compat plugin loader makes getPluginsDir() return an empty array, so the cloned view that Zend’s form-partial renderer uses silently lost every custom plugin. Forms rendered as blank space. No error, no warning, just nothing. We now track plugin directories manually and re-register them on clone.
Doctrine ORM 2.8 → 2.20 (latest 2.x LTS) brought DBAL 3, which renamed half the query API. CLI bootstrap, fetchAll() calls, the works. Every function f(Type $x = null) implicit-nullable across the entity and proxy trees got the ?Type treatment, because Zend_Session promotes that particular deprecation to a fatal during session start, and nothing tanks user trust in a mail admin panel like a white screen at login. Cache layer: doctrine/cache 2.x deleted its concrete providers, so metadata cache now wraps a Symfony Cache PSR-6 pool. Without a persistent backend Doctrine re-parses XML entity mappings on every single request. With APCu it parses them once. The Docker image defaults to APCu. For a single container APCu beats Redis, no socket, no hop. OPcache with validate_timestamps=0 rounds it out.
Security: the actual list, not “we hardened it”
The stock app had no CSRF protection. None. Every form, every destructive link, wide open. We added a per-session token to the base form class, every form inherits it, Zend’s isValid() checks it for free, then guarded every destructive GET link (purge, delete, cancel, restore) with an explicit token check. Forge a request without the token: 403, redirect, no mailbox deleted.
Smarty was running with output escaping off. Every {$variable} was a stored-XSS waiting room. We flipped setEscapeHtml(true) globally and marked the genuinely-HTML outputs as nofilter. A description field containing <script>alert(1)</script> now renders as inert text. We tested that payload. It does nothing, which is the point.
SQL injection: Doctrine ORM with parameterised queries throughout, plus we deleted four unreferenced “OSS API” integration classes that were carrying actual SQL concatenation (one with a live injection). ~1,600 lines removed. Dead code in an admin panel is attack surface.
Command injection: every shell-out (doveadm, archive tar/bzip2/du) is escapeshellarg()‘d. Deserialisation: unserialize() of archive blobs is restricted with ['allowed_classes' => false]. Tokens and backup codes use random_int(), the old str_shuffle/mt_rand was replaced. CSRF: covered above. Every session ID is regenerated on login, and again after the 2FA step.
TOTP two-factor auth
Opt-in per admin at /admin/two-factor. Scan QR, confirm a code, save the one-time backup codes (they’re shown once, they work once each, write them down, or find out the hard way). The TOTP secret is stored encrypted at rest with libsodium, keyed off securitysalt. A database read yields nothing usable.
The lockout-yourself scenario: two escape hatches, no SQL required.
# phone dead, authenticator gone — CLI reset:
./bin/vimbtool.php -a admin.cli-reset-totp --username=you@example.com
./bin/vimbtool.php -a admin.cli-reset-totp --all # bad day
# or, applied at next login:
; in application.ini:
twofactor.force_disable = "you@example.com" ; or "*"
Brute-force protection
Per-source-IP attempt counter, configurable lockout window. A fully successful login (password + 2FA, both) clears the counter. Configure in application.ini:
bruteforce.enabled = 1
bruteforce.max_attempts = 5
bruteforce.window = 900 ; seconds counter accumulates over
bruteforce.lockout = 900 ; seconds locked out
bruteforce.whitelist[] = "127.0.0.1"
bruteforce.whitelist[] = "10.0.0.0/8"
If you’re behind a reverse proxy, see the trusted-proxy section below, the limiter needs the actual client IP, and there’s a right way and a very wrong way to get it.
Defence in depth: Snuffleupagus, ModSecurity, hardened configs
Application-layer fixes are necessary. They’re not sufficient. The fork ships three more layers:
- Snuffleupagus ruleset (
contrib/snuffleupagus/vimbadmin-strict.list): code-derived, not copy-pasted from a blog post. Bans every dangerous PHP function the app doesn’t call, allow-scopes the few it does, blocks RFI/LFI wrappers,eval/base64_decodewebshell pipes, mail-header injection, world-writable chmod, writing PHP-loadable files, insecure cURL. Note: do not stack nativedisable_functionswith Snuffleupagus, they conflict and the worker SIGSEGVs. Ask how we know. - Hardened PHP-FPM pool (
contrib/php-fpm/vimbadmin.conf):open_basedir, empty nativedisable_functions(SP owns policy), strict session-cookie flags,security.limit_extensions=.php, sane resource limits. - Hardened Angie/nginx vhost (
contrib/angie/vimbadmin.conf): positive security gate: only known HTTP methods, the exact route map (controllers + ZF1 param URLs), and the app’s known argument names reach PHP. Scanner traffic, empty user-agents, and the eternal/.env//wp-login.phpprobe loop die at the edge. Plus strict CSP, security headers, BREACH mitigation (no compression on dynamic HTML), and a rate-limited login endpoint. Optional add-on: the ModSecurity CRS plugin for payload-signature scanning on top.
Quick start
Docker (recommended: fastest path to a running panel)
Bring a MariaDB/MySQL database. The image bundles the app, PHP-FPM, and the web server, pre-wired. First boot generates secrets and sets up the schema. Config lives in a mountable volume, edit application.ini without rebuilding, no files clobbered.
services:
db:
image: mariadb:lts
environment:
MARIADB_ROOT_PASSWORD: actually-change-this
MARIADB_DATABASE: vimbadmin
MARIADB_USER: vimbadmin
MARIADB_PASSWORD: also-change-this
vimbadmin:
image: eilandert/vimbadmin:latest
depends_on: [db]
ports:
- "8080:80"
environment:
TZ: Europe/Amsterdam
docker compose up -d
# wait for MariaDB's first-boot, then browse to http://localhost:8080/
Put it behind TLS in production. Behind the hardened vhost from contrib/ if you can. The point of shipping deployment configs is that you don’t have to invent them under pressure at 23:00.
From source
PHP 8.4.1+ with pdo_mysql, mbstring, intl, gettext, dom, ctype, iconv, sodium (2FA encryption). apcu optional but don’t skip it.
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
./bin/doctrine2-cli.php orm:schema-tool:create
That last command is the modernised CLI. The stock one called a Doctrine 2.8 API that no longer exists.
First run: claim the throne before someone else does
On first launch ViMbAdmin detects no administrators exist and routes you to a setup page. This is the one window where the panel is briefly unauthenticated. Do it immediately, on a network you trust, then never think about it again.
The setup page generates a security salt, use the one it gives you. Then it asks for your first super-admin’s credentials. The username is an email address. Not the word “admin.” Not “root.” An actual you@yourdomain.com. The field is labelled “Email.” Read the label. This trips up more people than you’d believe, and none of them believe it could trip them up until it does.
Pick a real password. It’s bcrypt-hashed and constant-time-compared on every login. The strength is entirely on you. The super-admin can see and touch everything. Treat the credentials accordingly.
Day-to-day: domain, mailbox, alias, in that order
The order matters because the foreign keys matter.
- Domains → Add. Set the limits (max mailboxes, max aliases, default quota), decide backup MX status. ViMbAdmin writes the row. Postfix will now accept mail for the domain: assuming your
virtual_mailbox_domainsis actually pointed at this database. The panel maintains the data; it can’t make Postfix care about a table you never configured it to read. That part is on you. - Mailboxes → Add. Local part, password, quota. ViMbAdmin hashes the password in your configured scheme (or via
doveadm pw), computes maildir path, stores it. User can now authenticate against Dovecot. If you enabled the welcome-email feature, it tells them: which beats texting them a plaintext password, a practice that should be punishable by having to read your own sent folder. - Aliases → Add. Address → comma-separated goto list. Build your
postmaster@(RFC 5321 requires it, you will forget until a remote server complains), your role addresses, your distribution lists.
Every action is logged, validated, and CSRF-protected. The delete button on a mailbox carries a token; a malicious page can’t trick your browser into purging an account behind your back.
MCP adapter: JSON-RPC API for when clicking is beneath you
Off by default. Enable with mcp.enabled = 1 in application.ini. This is a JSON-RPC 2.0 API at /mcp that lets an agent, a script, or a CI pipeline read and manage the mailbox database without a human in the loop. The full method set:
| Method | Scope | Does |
|---|---|---|
ping | read | Liveness check, pong + timestamp |
domains.list | read | All domains with mailbox/alias counts and quotas |
mailboxes.list | read | All mailboxes for a domain |
aliases.list | read | All aliases for a domain |
domain.create | write | Create a virtual domain |
domain.delete | write | Delete a domain and everything in it |
mailbox.create | write | Create a mailbox (hashes password, wires auto-alias) |
mailbox.delete | write | Delete a mailbox permanently |
alias.create | write | Create an alias (address → goto) |
alias.delete | write | Delete an alias |
mailbox.archive | write* | Queue mailbox for archive (purges live, schedules tar) |
archive.restore | write* | Queue archive for restore |
archive.delete | write* | Queue archive for deletion |
mcp.ratelimit.destructive).Auth is bearer-only, no session, no cookie. Only the SHA-256 hash of each token is stored; a database read yields nothing usable. Tokens are scoped (read or read write), per-token IP/CIDR allowlisted, expirable, and revocable from the CLI without touching the web panel:
# read-only token — printed once, store it now
./bin/vimbtool.php -a mcp.cli-token-generate --name=agent1 --scope="read"
# write-scoped, IP-locked, 90-day expiry
./bin/vimbtool.php -a mcp.cli-token-generate --name=provisioner \
--scope="read write" --ip="10.0.0.5" --days=90
./bin/vimbtool.php -a mcp.cli-token-list
./bin/vimbtool.php -a mcp.cli-token-revoke --name=agent1
The vhost should enforce an IP allowlist in front of /mcp as primary network defence (the contrib/angie/vimbadmin.conf has the block). Bearer is the application layer on top. Revoked names can be reused, the CLI drops the old row rather than refusing, so token rotation doesn’t require inventing new names.
Real client IP behind a proxy: do it right or your brute-force protection is a lie
If your reverse proxy sits in front of ViMbAdmin and you haven’t configured trusted-proxy handling, your brute-force limiter sees the proxy’s IP address, not the attacker’s. It will either lock out nobody (if the proxy is whitelisted) or lock out your entire office (if the proxy isn’t). Both outcomes are bad. The MCP per-token IP allowlist has the same problem.
Controlled by trustedproxy.mode in application.ini:
; auto (default) — trust X-Forwarded-For only when REMOTE_ADDR is private/loopback
trustedproxy.mode = "auto"
; on — trust X-Forwarded-For only from the listed proxy CIDRs
;trustedproxy.mode = "on"
;trustedproxy.proxies[] = "10.0.0.0/8"
;trustedproxy.proxies[] = "172.16.0.0/12"
; off — ignore X-Forwarded-For, always use raw REMOTE_ADDR
;trustedproxy.mode = "off"
auto(default): trustsX-Forwarded-Foronly whenREMOTE_ADDRis a private or loopback address. Covers the standard “proxy on the same host or LAN” setup with zero configuration. A publicREMOTE_ADDRbypasses the header entirely.on: trustX-Forwarded-Forfrom the CIDRs you list. Use when your proxy is on a public IP or a separate network segment.off: always use rawREMOTE_ADDR. Use when you handle IP rewriting at the web server layer instead (Angie/nginxreal_ipmodule, there’s a commented example incontrib/angie/vimbadmin.conf).
The client is taken as the right-most address in the X-Forwarded-For chain that isn’t a trusted proxy. A client can’t spoof it by prepending extra IPs to the header, the leftmost entries are attacker-controlled, the rightmost is what your proxy saw.
Upgrading and schema migrations
Pulling a new version may add columns, indexes, or tables. Two paths:
# Option A: Doctrine reconciles DB against entity mappings
./bin/doctrine2-cli.php orm:schema-tool:update --dump-sql # see what it wants to do
./bin/doctrine2-cli.php orm:schema-tool:update --force # do it
# Option B: targeted migration from contrib/migrations/
mysql -u<user> -p <database> < contrib/migrations/2026-06-mailbox-username-unique.sql
contrib/migrations/ holds idempotent SQL for changes that warrant a named file and a comment. Current one: UNIQUE index on mailbox.username. Postfix and Dovecot query that column on every delivery and login. Without the index they full-scan the mailbox table every time. Fresh installs have it; DBs created from older dumps don’t. Before applying, check for duplicates (yes, they happen):
SELECT username, COUNT(*) c FROM mailbox GROUP BY username HAVING c > 1;
schema-tool:update is additive (adds, doesn’t drop). The migration files do exactly what they say. Back up the database first regardless. This is not optional advice.
Where it fits in a real mail stack
The components and their responsibilities: Postfix handles SMTP. Dovecot handles IMAP/POP. Rspamd handles spam scoring before any of that. A web server fronts everything. ViMbAdmin keeps the shared user database coherent. That’s it. The deliberate narrowness is a feature, a tool with one job can be audited, hardened, and trusted in a way a sprawling appliance never can.
One boundary to be clear about: ViMbAdmin only writes to the database. It never touches maildirs. The “archive” button queues a row, the actual tarball is produced by a cron on the Dovecot host, where the mail lives. Same for maildir sizes and on-disk deletion. The fork ships shell script examples for all three in contrib/cron/, with their requirements documented inline. Without them the Archive button queues rows that never get processed. That’s by design, ignore it if you don’t archive.
If you’re running this stack on Debian or Ubuntu, the rest of our work slots in beside it: hardened nginx/Angie packages with HTTP/3, daily-rebuilt Docker images, and a general operating principle that default configs are a starting point for hardening, not a destination. ViMbAdmin, properly deployed, is the mail-admin-shaped piece of that.
Stop editing your mailbox table by hand. Future you, the one not reconstructing a dropped domain at 02:00 from a backup that’s three days old, will not thank you, because future you will be asleep.
Frequently asked questions
Does ViMbAdmin configure Postfix and Dovecot for me?
No. ViMbAdmin manages the SQL database of virtual domains, mailboxes and aliases. Postfix and Dovecot read that database independently, you still have to configure virtual_mailbox_maps, virtual_mailbox_domains, and the Dovecot userdb/passdb SQL queries yourself. ViMbAdmin maintains the data; the mail daemons consume it. It does not reload or signal them.
Why does “admin / admin” not work?
Because the username is an email address, not the string “admin”. The login field is labelled “Email”. The setup page asked you to create a super-admin using a real address like you@yourdomain.com. Use that address and the password you set. The form is telling you the truth.
Is the original ViMbAdmin still maintained?
Upstream went quiet years ago and the stock code doesn’t run on modern PHP. Our fork at github.com/eilandert/ViMbAdmin brings it to PHP 8.5, Smarty 5, and Doctrine 2.20, and adds CSRF, XSS auto-escaping, TOTP, brute-force protection, a Snuffleupagus ruleset, a ModSecurity CRS plugin, and the MCP JSON-RPC API.
What database do I need?
MySQL or MariaDB, the same one your Postfix/Dovecot setup already uses. ViMbAdmin creates its schema with ./bin/doctrine2-cli.php orm:schema-tool:create. Point it at your existing mail database (or a dedicated one) and it manages the relevant tables.
How are mailbox passwords hashed?
In whatever scheme you configure in application.ini, and it can shell out to doveadm pw so the stored hash is one Dovecot generated and will accept. Admin-account passwords use bcrypt, compared in constant time. CSPRNG for all tokens and backup codes.
Can I delegate management to domain owners?
Yes. Per-domain admins can manage their own domain’s mailboxes and aliases without seeing other domains or system-level settings. Saves you being a human ticket queue.
What is the MCP adapter?
An optional JSON-RPC 2.0 API at /mcp for agents and scripts. Off by default (mcp.enabled = 1 to enable). Bearer-token authenticated (SHA-256 hash stored, raw shown once), scoped read or read+write, per-token IP allowlist, expirable, revocable. Covers all 13 domain/mailbox/alias/archive operations. Tokens managed from the CLI, the web panel never touches them.
How do I update the schema after pulling a new version?
Run ./bin/doctrine2-cli.php orm:schema-tool:update --dump-sql to preview what Doctrine wants to add, then --force to apply. For specific migrations: hand-written, idempotent SQL in contrib/migrations/, each file documents why it exists and what to check first. Back up the DB before either.
The brute-force limiter is blocking my whole office. What broke?
Your reverse proxy. The limiter sees the proxy’s IP, not the real client’s, and locks the proxy out instead. Set trustedproxy.mode = auto in application.ini (default), it trusts X-Forwarded-For when the request comes from a private/loopback address. Add your office CIDR to bruteforce.whitelist[] as well.
Related reading
- Rspamd Explained: How Modern Spam Filtering Actually Works: the spam-filtering piece that sits beside ViMbAdmin in a real Postfix/Dovecot stack.
- What Is the BREACH Attack?: why we disable compression on dynamic HTML, from first principles.
- Docker Hardening for Self-Hosters: the ten-flag checklist for running the ViMbAdmin container properly.
- Angie and NGINX Docker Images: daily-rebuilt, fully-moduled web-server images to front the panel.