Blog
Mail-in-a-Box at Scale: Why I Love It, Why SQLite Hurt, and How I Moved Everything to MariaDB (Plus a Faster Go API)
Mail-in-a-Box at Scale: Why I Love It, Why SQLite Hurt, and How I Moved Everything to MariaDB (Plus a Faster Go API)🔗
Mail-in-a-Box (MIAB) is one of those rare projects that makes running your own mail stack feel… boring—in the best possible way. You get sane defaults, DNS automation, Dovecot + Postfix glued together correctly, and a web UI that doesn't make you cry. For single-tenant or small multi-domain setups, MIAB is perfect.
But once you start pushing beyond "a handful of inboxes," cracks appear. For me, all of those cracks radiated from one place:
SQLite.
I say that with love. SQLite is phenomenal; I use it constantly. But on an always-on mail server where you want to scale users, automate administration, and run batch jobs, SQLite becomes the bottleneck and the blast radius for lock contention. This post is the story of:
- what failed for me,
- how I migrated MIAB's data to MariaDB,
- and how I replaced the slow control plane with a tiny Golang API that made everything snappy again.
What Started Hurting🔗
-
Write Contention & Busy Errors Nightly scripts, bulk user changes, and automated alias updates began colliding. SQLite's "one writer" story is fair, but not great when Postfix/Dovecot lookups, admin UI writes, and background jobs all happen together.
-
Operational Friction
- Backups grew awkward once I wanted near-zero-downtime exports.
- Any schema evolution felt like threading a needle during quiet hours.
- Reporting across many domains/users/aliases became slow or overly careful.
-
Slow Control Plane The Python admin API/UI is convenient but not fast when you're making lots of small changes from automation. End-to-end, it felt laggy: HTTP call → Python → SQLite → Python → system calls.
The Plan🔗
-
Keep the good parts of MIAB (DNS, Dovecot, Postfix, TLS layout).
-
Migrate the core identity & routing data to MariaDB:
users(email, password hash, privileges, status)aliases(source, destination, permitted_senders)
-
Switch Postfix/Dovecot lookups to SQL maps (MySQL/MariaDB drivers).
-
Replace the slow admin endpoints with a small Go service that:
- exposes idempotent endpoints for users, aliases, and domains,
- hashes passwords with doveadm to maintain compatibility,
- can reload Postfix/Dovecot quickly,
- and returns JSON fast.
MariaDB Schema (lightweight & boring—intentionally)🔗
I kept it simple and indexed the obvious fields:
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL, -- dovecot SHA512-CRYPT hashes
privileges TEXT NOT NULL DEFAULT '',
status ENUM('active','disabled') NOT NULL DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (email)
);
CREATE TABLE aliases (
id INT AUTO_INCREMENT PRIMARY KEY,
source VARCHAR(255) NOT NULL UNIQUE, -- e.g. sales@domain.tld
destination TEXT NOT NULL, -- comma-separated list
permitted_senders TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX (source)
);
You could normalize aliases.destination into a join table if you need per-destination analytics. I didn't need it initially; the faster path was "ship it, measure, evolve."
Postfix & Dovecot: Switching to SQL Maps🔗
-
Postfix: use
mysql_virtual_*map files forvirtual_mailbox_maps,virtual_alias_maps, etc.-
Example
mysql-virtual-alias-maps.cf:user = postfix password = ******** hosts = 127.0.0.1 dbname = users query = SELECT destination FROM aliases WHERE source = '%s'
-
-
Dovecot: update
dovecot-sql.conf.extto authenticate against MariaDB:driver = mysql connect = host=127.0.0.1 dbname=users user=postfix password=******** default_pass_scheme = SHA512-CRYPT password_query = SELECT email as user, password FROM users WHERE email='%u' AND status='active';
This lets Postfix/Dovecot do lookups without touching SQLite, and without the Python admin layer in the hot path.
Migrating Data off SQLite🔗
There are a dozen ways to do this; mine was: read → validate → write, with checksums and dry-runs.
-
Export existing data from SQLite:
- dump users, aliases (and any adjunct tables you rely on).
-
Normalize emails (lowercase, trim).
-
Validate referential logic (aliases must resolve to real users or external targets).
-
Import into MariaDB.
-
Shadow Mode: point reporting tools at MariaDB first; compare results to SQLite for a day.
-
Flip Lookups (Postfix/Dovecot config reload).
-
Decommission SQLite consumers incrementally.
I scheduled flips during low-traffic windows and kept a rollback path (switch map files back, reload).
Replacing the Sluggish Admin Plane with a Tiny Go API🔗
The control plane needed to be:
- stateless,
- fast,
- simple to audit,
- safe by default (API key, allowlist, etc.),
- and able to hash passwords exactly like MIAB (so no surprises for Dovecot).
So I wrote a single-binary Go service with endpoints like:
GET /users→ domain-grouped users (for dashboards)POST /create→ create a user with a Dovecot-compatible hashPOST /set-password→ rotate a password (same hasher)DELETE /delete-user→ remove a userDELETE /delete-domain→ bulk delete by domainPOST /create-alias/DELETE /delete-aliasGET /get-domains/GET /get-emails?domain=…→ handy for toolingPOST /reload-services→systemctl reload postfix+systemctl reload dovecotGET /get-version→ quick sanity check from other services
Notes that mattered🔗
- Password hashing: I shell out to
doveadm pw -s SHA512-CRYPTunder the hood so the resulting hash is exactly what Dovecot wants. No guesswork. - API auth: a simple API key works fine inside a private network segment or behind a reverse proxy; add mTLS or OAuth if you expose it wider.
- Error paths: Fail fast, return JSON with useful messages, and log enough context to debug without dumping secrets.
Why it's faster🔗
- Go's HTTP server + MariaDB driver + simple queries = low latency.
- No ORM gymnastics. Just tight SQL.
- The process is short-lived per request and doesn't drag the admin UI along for the ride.
- Bulk ops feel instant compared to the previous path (HTTP → Python → SQLite).
Results🔗
- No more lock pile-ups. Postfix & Dovecot talk to MariaDB directly and happily.
- Automation is instant. Creating 100+ inboxes/aliases programmatically is just a handful of POSTs to the Go API.
- Operational sanity. Backups (mysqldump or physical) are straightforward, and I can replicate MariaDB or stick it behind HA in the future.
- Cleaner separation. MIAB stays great at what it's great at; my control plane and reporting are now mine to evolve.
Pitfalls & Gotchas🔗
- Don't expose secrets in logs (DB DSN, API keys, password plaintext). Mask aggressively.
- Dovecot schemes must match. If you change the hash scheme later, plan a rolling password rotation with dual-accept logic.
- Reload vs Restart. I prefer
reloadfor Postfix/Dovecot, but test carefully after schema or map changes. - Schema drift. If you add features (e.g., per-alias rate limits), remember to evolve both the DB and lookup queries together.
What I'd Do Next🔗
- Add structured audit logs to every API call (who/what/when), shipped to a centralized log index.
- Implement domain quotas and per-alias policy knobs (forwarding limits, allowed senders as a proper join table).
- Introduce read replicas for reporting if/when analytics grows.
Closing🔗
Mail-in-a-Box will always have my respect—it's a phenomenal "batteries-included" project. For small setups, I still recommend it exactly as-is.
But when your use case grows into "lots of domains, lots of automation, and tight SLAs," moving identities and routing to MariaDB and putting a tiny Go control plane in front of it is the cleanest way I've found to keep MIAB's strengths while removing the scaling foot-guns.
If you're in the same boat and want a starting point (schemas, Dovecot/Postfix map samples, and Go API patterns), I'm happy to share more specifics and hard-won edge cases.
AI-assisted writing
I draft and edit all articles myself, and I use AI as an assistant for outlining, phrasing, and cleanup. Curious how I use it—and where I draw the lines?