Secure Webmail for docker-mailserver
A full-stack webmail application built with Bun, ElysiaJS, React, and Vite. The app is designed for public-internet exposure and treats docker-mailserver as an untrusted integration boundary that must be wrapped carefully.
Stack
- Backend: Bun + ElysiaJS
- Frontend: React + Vite running on Bun
- Storage: Bun SQLite for app metadata, sessions, user preferences, and encrypted TOTP secrets
- Mail integration: Docker CLI with fixed argument arrays only
Security-first design
Critical risks and mitigations
| Risk | Mitigation |
|---|---|
| Command injection through Docker CLI | All Docker calls use Bun.spawn() with explicit argument arrays. No shell string concatenation, no sh -c, no raw user input in command strings. |
| Session theft or token leakage | Server-side opaque sessions in httpOnly, SameSite=Strict cookies. The frontend never stores session identifiers in localStorage. |
| CSRF | Every state-changing route requires an in-memory CSRF token in X-CSRF-Token plus the authenticated cookie. |
| XSS from HTML email | HTML email is sanitized on the backend, dangerous tags are removed, remote images are not allowed, and the frontend renders sanitized HTML only in a sandboxed iframe. |
| Brute-force login | Dedicated login rate limiter plus a global limiter. |
| Unsafe file uploads | Avatar uploads are size-limited, magic-byte checked, renamed with random tokens, stored outside the public root, and served through an authenticated endpoint. |
| Forwarding abuse | Forwarding targets are strictly validated, self-forwarding is blocked, and allowed target domains are policy-controlled. |
| Secret exposure | No passwords or TOTP secrets are logged. TOTP secrets are encrypted at rest with AES-GCM via TOTP_ENCRYPTION_KEY. |
| Information leakage | API errors return generic messages for internal failures and use explicit HTTP status codes for validation and auth issues. |
Why server-side sessions instead of JWTs
This project uses secure server-side sessions instead of bearer tokens or long-lived JWTs. That keeps revocation simple, reduces token exposure risk in the browser, and avoids putting sensitive auth state in JavaScript storage.
Features
- Mailbox login against docker-mailserver via
doveadm auth test - Optional seeded local admin account with Argon2id-hashed password
- Inbox listing and message preview from Maildir inside the mail container
- Plain-text compose flow using
sendmailinside the container - Safe HTML email preview
- Profile settings, avatar upload, and forwarding management
- Optional TOTP two-factor authentication
- Security headers and restricted CORS
Project layout
apps/
api/ Elysia backend
web/ React/Vite frontend
packages/
shared/ Shared TypeScript types
infra/
nginx/ Reverse proxy example with security headers
data/
avatars/ Stored outside the public web root
Local development
1. Configure environment
Copy .env.example to .env and update the values:
HOSTED_DOMAINS: domains served by docker-mailserverMAILSERVER_CONTAINER: container name to rundocker execagainstAPP_ORIGINS: allowed frontend originsTOTP_ENCRYPTION_KEY: base64-encoded 32-byte keyAPP_ADMIN_EMAIL/APP_ADMIN_PASSWORD: optional local admin bootstrap account
Generate a 32-byte base64 key:
openssl rand -base64 32
2. Install dependencies
If Bun needs an explicit temp dir on your machine:
BUN_TMPDIR=/tmp bun install
3. Start the apps
Backend:
bun run --cwd apps/api dev
Frontend:
bun run --cwd apps/web dev
The Vite dev server proxies /api to http://localhost:3001.
Important en local:
- le compte admin local est créé au démarrage de l'API
- il faut donc avoir un fichier
.envà la racine du repo - et redémarrer l'API après tout changement de
APP_ADMIN_EMAILouAPP_ADMIN_PASSWORD
Docker CLI assumptions
The backend assumes docker-mailserver is available in a running container and that the following commands are present:
doveadm auth testfor mailbox authenticationfindandcatfor safe Maildir readssendmailfor sending plain-text messagessetup alias add|delfor forwarding aliases
The mailbox reader currently targets the standard Maildir layout:
/var/mail/<domain>/<local-part>/Maildir
If your container layout differs, update apps/api/src/docker/mailserver.ts.
Production deployment guidance
Reverse proxy
Use a reverse proxy such as Nginx or Traefik in front of both the frontend and API.
- Terminate TLS at the proxy
- Forward only
/apito the API container - Serve the frontend over HTTPS only
- Strip hop-by-hop headers
- Preserve the real client IP only if you trust the proxy chain
A hardened Nginx example lives at infra/nginx/default.conf.
Container and network hardening
- Run the web and API containers as non-root users
- Mount only the minimum filesystem paths required
- Put docker-mailserver on an internal Docker network
- Do not expose unnecessary internal ports
- Restrict who can access the Docker socket; ideally expose a tightly scoped Docker API proxy instead of mounting the raw socket directly
- Set
TRUST_PROXY=trueonly behind a trusted reverse proxy
TLS and browser protections
- Enforce HTTPS everywhere
- Keep
Strict-Transport-Securityenabled in production - Preserve
X-Frame-Options: DENY - Preserve CSP,
X-Content-Type-Options: nosniff, andReferrer-Policy: no-referrer - Avoid adding third-party scripts unless you also update CSP intentionally
Secret handling
- Store secrets in environment variables or a secrets manager
- Rotate
TOTP_ENCRYPTION_KEYcarefully; rotating it invalidates stored TOTP secrets unless you build a migration path - Never log plaintext credentials
- Treat Docker access as privileged infrastructure access
Verification
Verified locally:
bun run typecheckinpackages/sharedbun run typecheckinapps/apibun run typecheckinapps/webbun run buildinapps/apibun run buildinapps/web
Residual risks and next steps
- Direct Docker socket access remains a high-value target. The safest next step is replacing raw socket exposure with a restricted sidecar or service account boundary.
- Maildir parsing is intentionally conservative and currently optimized for
INBOX. If you need full IMAP semantics, move message access behind a dedicated service abstraction instead of expanding ad hoc container reads. - Forwarding rules are managed locally and then applied to docker-mailserver. If you need full bidirectional sync with existing aliases, add an audited reconciliation job.