A clean, self-hosted webmail client for IMAP servers
letrvu ("letter view") is a lightweight, modern webmail client that connects to any standard IMAP/SMTP server. No bundled mail server, no PHP.
Log in with demo@letrvu.demo / demo@letrvu.demo. The inbox is a sandbox — emails you send stay within it and the inbox resets automatically every few minutes.
- Connects to any IMAP server (Dovecot, Cyrus, Gmail, Fastmail, etc.)
- Three-panel layout: folders → message list → message view
- HTML email rendered in a sandboxed iframe
- Real-time new mail via IMAP IDLE + Server-Sent Events
- Compose, reply, forward, delete, search
- Attachment download
- Address book with vCard import/export and compose autocomplete
- SQLite (default) or PostgreSQL session/settings/contacts storage
- Dark mode
- Ships as a single Go binary
| Layer | Technology |
|---|---|
| Backend | Go 1.26 net/http |
| IMAP | emersion/go-imap/v2 |
| SMTP | emersion/go-smtp |
| Frontend | Vue 3 + Vite + Pinia |
| Database | SQLite (modernc.org/sqlite) or PostgreSQL (pgx) |
| Deploy | Single binary or Docker |
cmd/letrvu/ main entrypoint
internal/
api/ HTTP router + handlers
imap/ IMAP client wrapper + IDLE
smtp/ outbound mail
session/ DB-backed session store (AES-256-GCM)
contacts/ address book store + vCard codec
settings/ per-user key/value settings
db/ database wrapper (SQLite / PostgreSQL)
web/
src/
pages/ LoginPage.vue, MailPage.vue, ContactsPage.vue
components/ FolderList, MessageList, MessageView,
ComposeModal, AddressInput, ContactModal
stores/ auth.js, mail.js, contacts.js (Pinia)
composables/ useMailEvents.js (SSE), useDarkMode.js
web/public/assets/ logo files (SVG)
Dockerfile multi-stage build
- Go 1.26+
- Node.js 20+
# 1. Start the Go backend (port 8080)
go run ./cmd/letrvu
# 2. In a second terminal, start the Vue dev server (port 5173)
cd web
npm install
npm run devThe Vite dev server proxies /api/* to localhost:8080, so you only visit http://localhost:5173.
# Go backend tests
go test ./...
# Frontend tests (Vitest)
cd web
npm test # run once
npm run test:watch # watch modecd web && npm run build # outputs to internal/api/static/
go build -o letrvu ./cmd/letrvu
./letrvu -addr :8080docker build -t letrvu .
docker run -p 8080:8080 letrvucp .env.example .env
# edit .env: set SESSION_SECRET, POSTGRES_PASSWORD, IMAP_HOST, SMTP_HOST
# Create the external volume once — persists even if the stack is removed
docker volume create db_data
docker compose up -dThe db_data volume is declared external so that docker compose down (or even docker compose down -v) cannot accidentally delete your database. Only docker volume rm db_data will remove it.
letrvu speaks plain HTTP and must sit behind a TLS-terminating reverse proxy in production. Set SECURE_COOKIES=true in your .env once HTTPS is in place — this adds the Secure flag to session and CSRF cookies so they are never sent over plain HTTP.
Traefik integrates directly with Docker Compose via container labels — no separate config file needed. Add a Traefik service to your docker-compose.yml and annotate the letrvu service with routing labels:
services:
traefik:
image: traefik:latest
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.websecure.address=:443
- --certificatesresolvers.le.acme.tlschallenge=true
- --certificatesresolvers.le.acme.email=you@example.com
- --certificatesresolvers.le.acme.storage=/acme/acme.json
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- acme_data:/acme
restart: unless-stopped
letrvu:
image: sutoj/letrvu:latest
labels:
- traefik.enable=true
- traefik.http.routers.letrvu.rule=Host(`mail.example.com`)
- traefik.http.routers.letrvu.entrypoints=websecure
- traefik.http.routers.letrvu.tls.certresolver=le
# Required for SSE (real-time new mail push)
- traefik.http.services.letrvu.loadbalancer.responseforwarding.flushinterval=-1
# ... rest of letrvu service config
volumes:
acme_data:
external: trueCreate the ACME volume before first run:
docker volume create acme_dataSet TRUSTED_PROXY to Traefik's container IP or the Docker bridge subnet (e.g. 172.17.0.0/16) so letrvu reads the real client IP from X-Forwarded-For:
TRUSTED_PROXY=172.17.0.0/16Caddy obtains and renews Let's Encrypt certificates automatically with zero config.
# /etc/caddy/Caddyfile
mail.example.com {
reverse_proxy localhost:8080
}
sudo systemctl reload caddy# /etc/nginx/sites-available/letrvu
server {
listen 80;
server_name mail.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name mail.example.com;
ssl_certificate /etc/letsencrypt/live/mail.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mail.example.com/privkey.pem;
# Required for SSE (real-time new mail push)
proxy_buffering off;
proxy_read_timeout 3600s;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}sudo certbot --nginx -d mail.example.com
sudo systemctl reload nginxSet TRUSTED_PROXY=127.0.0.1 in .env so letrvu reads the real client IP from X-Forwarded-For for accurate audit logs and brute-force protection.
SESSION_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 16)
SECURE_COOKIES=true
WEBMAIL_HOSTNAME=mail.example.com
TRUSTED_PROXY=127.0.0.1 # or 172.17.0.0/16 when using Traefik
IMAP_INSECURE_TLS=false # only if your mail server has a valid certificateCopy .env.example to .env and adjust as needed:
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR |
:8080 |
HTTP listen address |
DB_DRIVER |
sqlite |
sqlite or postgres |
DATABASE_URL |
./letrvu.db |
SQLite path or Postgres DSN |
SESSION_SECRET |
(ephemeral) | 32-byte hex secret — set this in production |
IMAP_HOST / IMAP_PORT |
— / 993 |
Pre-fill login form |
SMTP_HOST / SMTP_PORT |
— / 587 |
Pre-fill login form |
IMAP_INSECURE_TLS |
true |
Skip TLS cert verification (self-signed certs) |
WEBMAIL_HOSTNAME |
localhost |
Right-hand side of generated Message-ID headers |
LOGIN_MAX_ATTEMPTS |
5 |
Failed logins per IP before lockout |
LOGIN_WINDOW |
1m |
Sliding window for counting failures |
LOGIN_LOCKOUT |
15m |
Lockout duration after max failures |
- IMAP folder listing (alphabetical)
- Message list with pagination
- Message view (HTML + plain text, RFC 2047 encoded headers)
- Compose / reply / forward
- Delete + mark read/unread
- IMAP IDLE → SSE push notifications
- Attachments (view + download)
- Search (server-side IMAP SEARCH)
- Embed frontend via
go:embed - Dark mode
- DB-backed sessions (SQLite / PostgreSQL)
- Per-user settings (display name, signature)
- Address book with vCard import/export
- Compose autocomplete from address book
- Calendar (month + week view, add/edit/delete events)
- iCal import/export
- Email invite detection ("Add to calendar" button)
- Signature insertion in compose
- Save sent messages to Sent folder (IMAP APPEND)
- Draft saving (IMAP APPEND to Drafts)
- Reply-all
- IMAP folder subscription handling
- Calendar recurring events (RRULE)
- Calendar outgoing invites (attach iCal to composed email)
- Multi-account support
- Move messages between folders
- Show message source
- Flag messages
- Multiple identities
- Attachment preview
- Brute force login protection
- Folder management (create / rename / delete IMAP folders)
- Bulk actions (select multiple messages → delete / move / mark read)
- Keyboard shortcuts (n/p next/prev, r reply, d delete, c compose)
- HTML compose (rich text editor)
- Conversation / thread view
- Unread count in browser tab title
- Desktop notifications (Browser Notification API + IMAP IDLE)
- Mobile-responsive layout
- Mark as spam (move to Junk folder)
- Cross-folder search
- Undo send (configurable delay before SMTP submission)
- Vacation / autoresponder (Sieve)
- Contact groups / distribution lists
- Per-sender image trust ("always show images from this sender")
- Print view
- PGP signing and encryption (openpgp.js, server-stored encrypted key, WKD lookup)
- Docker scout scanning
- Session timeout / logout all devices
- Spam flag feedback (
$JunkIMAP flag)
- Bump the version in
VERSION:echo "0.2" > VERSION git add VERSION git commit -m "Release v0.2"
- Tag and push — this triggers the release workflow:
git tag v0.2 git push origin master --tags
The workflow will build Linux binaries (amd64, arm64), run a Docker Scout CVE scan, push a multi-platform Docker image (sutoj/letrvu:<version> and sutoj/letrvu:latest), and create a GitHub Release with the binaries and a sha256sums.txt attached.
| Key | Action |
|---|---|
c |
Compose new message |
r |
Reply to current message |
n |
Next message (older) |
p |
Previous message (newer) |
d |
Delete current message |
Esc |
Close modal / overlay (compose, attachment preview, message source) |
Shortcuts are disabled when focus is inside a text field or the compose window is open.
MIT