Dovecot IMAP Server (F4.2)
Feature: F4.2 — Dovecot IMAP Server Status: 🟢 COMPLETED Package:
@vexlyx/api,@vexlyx/dashboard,@vexlyx/sharedPrisma Models:Domain,MailboxInfrastructure: Dovecot 2.3.x, Maildir++ storage, ARGON2ID passdb, TLS on 993
1. Overview
Dovecot IMAP Server provides incoming mail access (IMAP) for every mailbox managed in Vexlyx, serving the same Maildir storage that Postfix (F4.1) delivers into.
Vexlyx decouples IMAP access into:
- A single flat passwd-file acting as both passdb and userdb (
/etc/dovecot/users), synchronized from theMailboxtable bydovecot_manager.py— no live database connection from Dovecot itself, matching Postfix's own decoupled virtual-domain sync philosophy from F4.1. - ARGON2ID password verification, consistent with CLAUDE.md's Argon2id-everywhere rule. Mailbox passwords are stored pre-hashed (PHC format) and simply get an
{ARGON2ID}scheme prefix when written to the passwd-file. - Cross-container SASL integration: Postfix's submission port (587) authenticates against Dovecot's userdb over an internal TCP auth listener (
inet:dovecot:12345) rather than a shared unix socket, since the two services run in separate containers. - Maildir++ quota enforcement, per-mailbox, driven by a
userdb_quota_ruleextra field written alongside each passwd-file entry — kept in sync withMailbox.quota(interpreted in megabytes). - Hybrid dev & production parity: a full containerized dev environment in
docker-compose.yml(vexlyx-dovecot) exposing ports 143/993, alongside a dedicated bare-metal production installer (system/scripts/setup-dovecot.sh).
2. Architecture & Data Flow
┌─────────────────────────────────────────────────────────────┐
│ Next.js Dashboard UI │
│ - /mail → "IMAP Service (Dovecot)" status card │
│ - Sync with Postfix button also syncs Dovecot mailboxes │
└──────────────────────────────┬──────────────────────────────┘
│ HTTP / JSON
▼
┌─────────────────────────────────────────────────────────────┐
│ Fastify API Server │
│ - GET /api/mail/status → { ...smtp, imap: {...} } │
│ - POST /api/mail/sync → syncs Postfix AND Dovecot │
└───────────────┬──────────────────────────────┬──────────────┘
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────┐
│ PostgreSQL (Prisma) │ │ system/python/ │
│ - model Domain │ │ dovecot_manager.py │
│ - model Mailbox (quota, pw) │ │ - sync_mailboxes │
└───────────────────────────────┘ │ - status probing │
└─────────────┬─────────────┘
│ Writes
▼
/etc/dovecot/users (passwd-file: passdb + userdb)
│
▼
┌───────────────────────────┐
│ Dovecot Engine │
│ - Port 143 (IMAP) │
│ - Port 993 (IMAPS, TLS) │
│ - Port 12345 (SASL/auth) │◄── Postfix (F4.1) authenticates
│ for submission (587) │ SMTP submission here
└─────────────┬─────────────┘
│ Reads/writes
▼
./docker/mail-data/vhosts (shared bind mount)
Maildir tree, also written to by Postfix's
virtual delivery agent3. Configuration Specifications
Dovecot dovecot.conf (docker/dovecot/dovecot.conf)
Key directives:
protocols = imap
mail_location = maildir:/var/mail/vhosts/%d/%n/Maildir:INDEX=/var/indexes/%d/%n:CONTROL=/var/indexes/%d/%n
mail_home = /var/mail/vhosts/%d/%n
mail_uid = 5000
mail_gid = 5000
passdb {
driver = passwd-file
args = scheme=ARGON2ID username_format=%u /etc/dovecot/users
}
userdb {
driver = passwd-file
args = username_format=%u /etc/dovecot/users
}
disable_plaintext_auth = yes
ssl = required
ssl_cert = </etc/dovecot/certs/cert.pem
ssl_key = </etc/dovecot/certs/key.pem
mail_plugins = $mail_plugins quota
plugin {
quota = maildir:User quota
}
service imap-login {
inet_listener imap { port = 143 }
inet_listener imaps { port = 993; ssl = yes }
}
service auth {
inet_listener { port = 12345 }
}Why INDEX=/CONTROL= point elsewhere: Dovecot's dovecot-uidlist rewrite does an open → fstat → rename-temp-file sequence. On Docker Desktop's Windows bind-mount filesystem (virtiofs/gRPC-FUSE), that sequence reliably fails with ENOENT on the shared ./docker/mail-data/vhosts mount. Indexes and control files are Dovecot's private, disposable cache/metadata — safe to keep on a container-local path (/var/indexes) — only the actual Maildir message files need to be shared with Postfix. This was discovered and fixed during F4.2 development; without it, SELECT INBOX fails on every login.
Why uid/gid 5000:5000 everywhere: Postfix and Dovecot are separate containers writing to the same bind-mounted Maildir tree. Both must use identical numeric uid/gid — chosen as 5000 (rather than reusing whatever a package's auto-created system user gets) specifically to avoid collision with uids Alpine's apk add dovecot/apk add postfix might allocate to their own internal service accounts. See docker/postfix/main.cf's virtual_uid_maps/virtual_gid_maps for the matching Postfix-side setting.
Postfix SASL integration (docker/postfix/main.cf, added in F4.2)
smtpd_sasl_auth_enable = yes
smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:dovecot:12345Postfix and Dovecot are separate containers, so a shared unix socket (the traditional bare-metal approach) isn't available — Dovecot exposes its auth service over an internal TCP listener instead. The production installer (setup-dovecot.sh) uses the traditional unix socket at /var/spool/postfix/private/auth since bare-metal Postfix and Dovecot share a filesystem.
Virtual mailbox passwd-file (docker/dovecot/config/users)
One line per mailbox, format user:password:uid:gid:gecos:home:shell:extra:
test@vexlyx.local:{ARGON2ID}$argon2id$v=19$...:5000:5000::::userdb_quota_rule=*:storage=1024M- Regenerated by
dovecot_manager.py sync_mailboxes, scoped per-domain: entries for domains passed in are fully replaced (upsert), entries for domains not in the sync scope (e.g. the dev fixtures below) are left untouched. docker/dovecot/entrypoint.shseeds two dev-only fixtures on every container start if missing:test@vexlyx.local/admin@vexlyx.local, passwordvexlyx-dev, 1024M quota. This lets you test IMAPS end-to-end before F4.3 (Mailbox Management UI) ships real mailbox creation.
4. API Integration
GET /api/mail/status (existing F4.1 endpoint) now returns an additional imap field:
{
"service": "postfix",
"status": "active",
"...": "...",
"imap": {
"service": "dovecot",
"status": "active",
"port143Open": true,
"port993Open": true,
"tlsEnforced": true,
"saslAuthConnected": true,
"activeMailboxesCount": 2,
"lastChecked": "2026-09-05T12:00:00.000Z"
}
}POST /api/mail/sync (existing F4.1 endpoint) now also pushes each domain's Mailbox rows into Dovecot, returning an added mailboxesSynced count:
{ "success": true, "syncedCount": 3, "domains": ["example.com"], "mailboxesSynced": 5 }Contract for F4.3 (Mailbox Management UI): Mailbox.password is expected to already be an Argon2id PHC-format hash ($argon2id$v=19$...) when creating/updating a mailbox — dovecot_manager.py only adds the {ARGON2ID} scheme prefix, it does not hash. Mailbox.quota is interpreted in megabytes.
5. Testing & Verification
Run the dedicated test suite:
python -m unittest tests.test_dovecot_imap -vCovered tests:
TestDovecotSystemManager:sync_mailboxeswrites correct passwd-file lines, preserves out-of-scope domains, removes deleted mailboxes, andstatusreturns the fullImapStatusResponseshape.TestDovecotConfigurations: validatesdovecot.conf,Dockerfile,entrypoint.sh, Postfix's SASL wiring inmain.cf,docker-compose.ymlwiring, and the production installer script.TestDovecotLiveImaps(skipped gracefully if the stack isn't running): a live IMAPS login against the seededtest@vexlyx.localfixture, and a live SMTP AUTH over port 587 proving Postfix successfully authenticates against Dovecot's SASL backend.
Manual verification commands are in the F4.2 test-plan conversation; the short version:
docker compose up -d --build postfix dovecot
python -m unittest tests.test_dovecot_imap -v6. How to Extend
- F4.3 Mailbox Management UI: build
POST/GET/DELETE /api/mailboxeson top of the existingMailboxPrisma model. Hash new passwords with Argon2id (Nodeargon2package — confirm with the user before adding it, per CLAUDE.md's no-new-dependencies rule) and store the PHC string directly inMailbox.password; the existingPOST /api/mail/synccall already picks up anyMailboxrow and pushes it into Dovecot. - F4.4 Webmail (Roundcube): point Roundcube's IMAP/SMTP config at
dovecot:143/postfix:587on the shareddefaultDocker network — no changes needed here. - F4.6 Email Forwarding & Aliases / vacation responder: Dovecot's Pigeonhole Sieve plugin (
dovecot-pigeonholeon Alpine) is not installed yet; add it todocker/dovecot/Dockerfileand asieveblock todovecot.confwhen building that feature. - If a future feature needs a quota-exceeded warning to the user,
dovecot_manager.py'sstatuscommand already counts mailboxes from the passwd-file — extend it to also shell out todoveadm quota get -u <user>for per-mailbox usage.