Mailbox Management UI (F4.3) β
Feature: F4.3 β Mailbox Management UI Status: π’ COMPLETED Package:
@vexlyx/api,@vexlyx/dashboard,@vexlyx/sharedPrisma Models:Domain,MailboxDepends on: F4.1 (Postfix SMTP), F4.2 (Dovecot IMAP)
1. Overview β
F4.3 gives admins self-service mailbox provisioning on top of the Mailbox Prisma model that F4.1/F4.2 already synced into Postfix/Dovecot but never let anyone actually create through the UI.
- Passwords are generated server-side and shown exactly once, in a copy-to-clipboard dialog, right after create/reset β never re-displayed, never accepted from the client on create.
- Quota is a fixed preset (256MB / 512MB / 1GB / 5GB / 10GB / Unlimited), stored in
Mailbox.quotaas megabytes (0= unlimited). - Usage stats are real bytes on disk, computed by walking each mailbox's Maildir directory β not an estimate.
- Every mutation (create/quota/reset/delete) re-syncs Postfix's virtual maps and Dovecot's passwd-file, so a mailbox is immediately usable without a separate manual "Sync with Postfix" click.
2. Architecture & Data Flow β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Next.js Dashboard UI β
β /mail β "Mailboxes" tab (MailboxesPanel.tsx) β
β create / quota / reset-password / delete dialogs β
ββββββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββ
β HTTP / JSON
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Fastify API Server β
β GET /api/mailboxes list + usage β
β POST /api/mailboxes create (returns password once)β
β PATCH /api/mailboxes/:id/quota update quota β
β POST /api/mailboxes/:id/reset-password β
β DELETE /api/mailboxes/:id β
βββββββββββββββββ¬ββββββββββββββββββββββββββββββ¬ββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββ
β PostgreSQL (Prisma) β β MailboxService calls into β
β model Mailbox (address, β β MailService.syncVirtualDomainsβ
β password hash, quota) β β (the same routine the F4.1 β
βββββββββββββββββββββββββββββββββ β "Sync with Postfix" button β
β uses) after every mutation β
βββββββββββββββββ¬βββββββββββββββββ
βΌ
system/python/postfix_manager.py
sync_virtual_domains(domains, mailboxes)
system/python/dovecot_manager.py
sync_mailboxes(mailboxes, domains)
get_usage(addresses) β real Maildir bytes
β
βββββββββββββββββββββββββββββββββ΄ββββββββββββββββββββββββββββββββ
βΌ βΌ
/etc/postfix/virtual_domains, virtual_mailbox_maps (+.lmdb) /etc/dovecot/users (passdb+userdb)
/etc/opendkim/KeyTable, SigningTable (ARGON2ID hash, quota rule)
β β
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββ
βΌ
./docker/mail-data/vhosts/<domain>/<local>/Maildir
(shared bind mount, written by Postfix,
read by Dovecot AND by get_usage)MailboxService deliberately reuses MailService.syncVirtualDomains(userId) (from the F4.1 mail module) rather than a lighter partial sync β Postfix's sync_virtual_domains fully rewrites virtual_domains/virtual_mailbox_maps from whatever list it's given, so a mailbox mutation must always push the user's complete domain/mailbox set, not just the one domain that changed, or it would silently wipe out unrelated domains' entries.
3. API Contract β
POST /api/mailboxes β
// request
{ "localPart": "jane", "domainId": "clxyz...", "quota": 1024 }
// response (201)
{
"mailbox": { "id": "...", "address": "jane@example.com", "domainId": "...", "hostname": "example.com", "quota": 1024, "status": "ACTIVE", "usedBytes": 0, "createdAt": "..." },
"password": "S-v5wDdzvLhpPD01VOS16mDZ" // shown once β not recoverable afterward
}GET /api/mailboxes?domainId=... β
Returns { mailboxes: MailboxResponse[] } with usedBytes populated from a single batched get_usage call (one Python spawn for the whole list, not one per mailbox).
PATCH /api/mailboxes/:id/quota β { "quota": 5120 } β { "success": true } β
POST /api/mailboxes/:id/reset-password β { "password": "..." } (new plaintext, one time) β
DELETE /api/mailboxes/:id β 204 β
All routes require app.requireAuth and scope every query by request.userId.
4. Bugs Found & Fixed While Building This β
Wiring real mailbox creation up to real Postfix + Dovecot surfaced eight pre-existing bugs in the F4.1/F4.2 mail stack β none of them were exercised before because nothing had ever actually created a mailbox and sent it real mail end-to-end. All are fixed; this section exists so nobody re-discovers them the hard way.
virtual_mailbox_mapsnever recompiled. It's anlmdb:table inmain.cf; Postfix reads the compiled.lmdbfile, not the text source.sync_virtual_domainswrote the text file and reloaded Postfix, but never ranpostmapβ so mailbox changes never took effect (550 5.1.1 ... Recipient address rejected). Fixed inpostfix_manager.py.- DKIM
KeyTablestored a Windows host path.generate_dkim_keyswrotestr(priv_file)β a literalD:\...\default.privatepath on a Windows dev host β into OpenDKIM'sKeyTable, which is read by OpenDKIM inside the Linux container. Fixed to always write the container-native/etc/opendkim/keys/<domain>/<selector>.private, and made the write idempotent so a stale entry self-heals on the next DKIM generate/lookup call. - OpenDKIM caches its tables in memory. Even with a correct
KeyTable, OpenDKIM doesn't notice a plain file edit β it needsSIGHUP. Addedpkill -HUP opendkimafter every table write (reload_opendkim()). .lmdbfile permission mismatch.postmapruns viadocker exec(root), producing aroot:rootmode640file β but actual delivery happens in Postfix's unprivilegedvirtualservice (postfix:postfix), which then can't read it (451 4.7.0, log:open database ...lmdb: Permission denied). Fixed withchmod 644after everypostmap, in both the live sync path andentrypoint.sh(so a fresh container boot doesn't reintroduce it).- CRLF corruption from Windows Python.
Path.write_text()defaults to OS line endings β on Windows that's\r\n. Every config file this script writes for the Linux container was getting silently corrupted with embedded\r(postfix/trivial-rewrite: fatal: match_list_parse: read file ...: No data available). Fixed by addingnewline="\n"to every text write in bothpostfix_manager.pyanddovecot_manager.py. - Docker Desktop Windows bind-mount flakiness. Even with clean files, the shared bind mount (gRPC-FUSE/virtiofs) occasionally serves a transient read error for a moment right after a host-side write β the same class of issue already documented in
dovecot.conffor Dovecot's index files. Postfix'strivial-rewritehas no retry logic: hitting this window once means Postfix throttles respawning it for up to 60s. Mitigated with a short settle delay beforepostfix reload. - Wrong Maildir path order.
virtual_mailbox_maps's right-hand side was built aslocal/domain/with noMaildirsegment. Dovecot'smail_location(dovecot.conf) ismaildir:/var/mail/vhosts/%d/%n/Maildirβ domain, then local part, then aMaildirfolder. Mail was landing on disk (Postfix reported success) at a path Dovecot's IMAP would never look in. Fixed the path builder todomain/local/Maildir/; the two already-misdelivered test messages were manually relocated (see git history around this fix if you need the recovery steps for a similar situation). - Argon2 PHC parameter order. Node's
argon2package (apps/api, v0.45.1) encodes hashes as$argon2id$v=19$m=...,p=...,t=...$salt$hashβ but Dovecot's ARGON2ID passdb parser requires the canonicalm=...,t=...,p=...order and silently derives the wrongt/pvalues otherwise. The hash looks well-formed and Node's ownargon2.verify()confirms it's correct (self-consistent within Node), butdoveadm pw -treports "Password mismatch" on the exact same hash β proven by reordering only the parameter string (same salt/digest) and watching verification flip from failing to passing. Fixed indovecot_manager.py's_format_passwd_linewith a regex that reordersm=X,p=Y,t=Zβm=X,t=Z,p=Ybefore writing to the passwd-file. This affects every Argon2id hash Dovecot ever consumes, not just mailboxes β if a future feature writes to the Dovecot passwd-file from a different code path, route it through_format_passwd_line(or replicate the reorder) rather than writing hashes directly.
5. Testing & Verification β
No automated test file exists yet for this module (unlike F4.1/F4.2's tests/test_postfix_smtp.py / tests/test_dovecot_imap.py) β everything below was verified manually against the live dev stack (docker-compose up -d) with real HTTP calls, real IMAPS connections, and real doveadm/postqueue inspection. Consider porting this into a tests/test_mailbox_management.py using the same unittest pattern as the sibling suites.
Manual verification sequence (all of this passed on the current codebase):
# 1. Create a mailbox
curl -b cookies.txt -X POST http://localhost:5000/api/mailboxes \
-H "Content-Type: application/json" \
-d '{"localPart":"jane","domainId":"<id>","quota":256}'
# β 201, { mailbox, password }
# 2. Sync (also happens automatically on every mutation)
curl -b cookies.txt -X POST http://localhost:5000/api/mail/sync
# 3. Real IMAPS login with the returned password (proves Argon2id order fix)
python -c "
import socket, ssl
ctx = ssl.create_default_context(); ctx.check_hostname = False; ctx.verify_mode = ssl.CERT_NONE
s = ctx.wrap_socket(socket.create_connection(('127.0.0.1', 993)))
print(s.recv(200))
s.send(b'a1 LOGIN jane@example.com <password>\r\n'); print(s.recv(200))
"
# β "a1 OK ... Logged in"
# 4. Send real mail via the F4.1 test-send endpoint, confirm it lands correctly
curl -b cookies.txt -X POST http://localhost:5000/api/mail/test-send \
-H "Content-Type: application/json" \
-d '{"from":"admin@example.com","to":"jane@example.com","subject":"test","body":"hi","port":25,"useTls":false}'
docker exec vexlyx-dovecot doveadm mailbox status -u jane@example.com messages INBOX
# β "INBOX messages=1"
# 5. Usage stats reflect the real message
curl -b cookies.txt http://localhost:5000/api/mailboxes
# β usedBytes > 0
# 6. Quota is really wired to Dovecot's quota engine
docker exec vexlyx-dovecot doveadm quota get -u jane@example.com
# β Limit column matches quota * 1024 (KB)
# 7. Delete β inaccessible
curl -b cookies.txt -X DELETE http://localhost:5000/api/mailboxes/<id>
curl -b cookies.txt -X POST http://localhost:5000/api/mail/sync
# retry step 3's IMAPS login β "a1 NO [AUTHENTICATIONFAILED]"Dev credentials for the seeded admin user: admin@vexlyx.local / admin123 (apps/api/prisma/seed.ts).
6. How to Extend β
- F4.4 Webmail (Roundcube): no changes needed here β Roundcube just needs IMAP/SMTP pointed at
dovecot:143/postfix:587, and mailboxes created through this feature are immediately usable. - F4.6 Aliases/forwarding: this module only manages real mailboxes (
Mailboxrows with a login). Aliases are a separate concept (virtual_alias_maps, currently unused) β don't conflate the two; an alias shouldn't get aMailboxrow or a password. - Suspend/reactivate:
Mailbox.statusalready supportsSUSPENDED/DELETED, but the UI currently only exposesACTIVEmailboxes with a delete action. A suspend toggle would needsync_mailboxes(dovecot) andsync_virtual_domains(postfix) to both skip non-ACTIVEmailboxes when building their maps β neither currently filters on status. - Custom (non-preset) quotas:
QuotaPresetSchemais a closedz.unionof literals by design (per-project decision, see conversation history) β if a future need arises for arbitrary quotas, that schema and theMailboxesPanelquotaSelectare the two places to change; the backend/Dovecot plumbing already accepts any integer megabyte value. - If you touch
dovecot_manager.py's password-writing path again, keep the Argon2 parameter reorder (_ARGON2_PARAM_ORDER_REin_format_passwd_line) β removing it silently breaks every IMAP login without any error at write time, only at login time.