Letterbox, a Go webmail solution
  • Go 89.9%
  • templ 7%
  • CSS 1.2%
  • JavaScript 1.1%
  • Just 0.8%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Elisamuel Resto 885adda0e1
config: make YAML the format Letterbox is configured in
The example file, the tests and the documentation are YAML now. TOML is still
parsed, because a deployment that has a .toml should not be broken by a change
of house style, but it is looked for after the YAML names at each searched
location — an old letterbox.toml left in a directory must not quietly shadow
the letterbox.yaml somebody has just written, which is the one way this change
could bite.

The test that checks the example documents no key that does not exist was
reading letterbox.example.toml. With the file renamed it began skipping, and a
test that passes when the thing it checks is missing is how the thing comes to
be missing — so it now fails rather than skips, and walks YAML: a top-level
mapping opens a section and anything indented under it is one of its keys,
commented-out keys included, since those are the ones somebody is most likely
to uncomment. Confirmed by adding two keys that do not exist and watching it
name both.

Every loader fixture is YAML, which is what the format being tested should be.
One is deliberately not: TestATOMLFileStillLoads is the compatibility promise
written down.
2026-08-19 10:29:18 -05:00
assets feat(web): settings, daemon mode and the hardening the rest assumed 2026-08-19 01:53:37 -05:00
cmd config: make YAML the format Letterbox is configured in 2026-08-19 10:29:18 -05:00
internal config: make YAML the format Letterbox is configured in 2026-08-19 10:29:18 -05:00
.gitignore config: make YAML the format Letterbox is configured in 2026-08-19 10:29:18 -05:00
.golangci.yml chore: bootstrap go module and build tooling 2026-08-18 16:11:35 -05:00
go.mod feat(sievex): mail filters over managesieve 2026-08-19 01:26:12 -05:00
go.sum feat(sievex): mail filters over managesieve 2026-08-19 01:26:12 -05:00
Justfile build: replace the Makefile with a Justfile 2026-08-19 10:21:53 -05:00
letterbox.example.yaml config: make YAML the format Letterbox is configured in 2026-08-19 10:29:18 -05:00
README.md config: make YAML the format Letterbox is configured in 2026-08-19 10:29:18 -05:00

Letterbox

Webmail for an IMAP/SMTP backend you already run.

Letterbox is a single Go binary. The operator points it at an IMAP server, an SMTP server and (optionally) a ManageSieve server; users log in with the credentials those servers already accept and never see a server setting. It serves on :8080 by default and runs happily behind Caddy, Traefik or nothing at all.

Status: early development. Read, write, send, file and filter all work against a real server; see What works for the honest list.

Design goals

  • Live mailbox. The message list updates itself when mail arrives or is filed, using IMAP NOTIFY where the server offers it, IDLE where it does not, and polling as a last resort — pushed to the browser over SSE.
  • Untrusted by default. Message HTML is sanitized, remote content is blocked until the user asks for it, and attachment names, types and dispositions are handled per RFC rather than per sender's wishes.
  • Operable. One logging facility with syslog severities and a configurable threshold; a config file where every key can be overridden by a LETTERBOX_ environment variable; foreground or daemonized.
  • No Node.js. Templates compile with templ and CSS with the standalone Tailwind CLI. Both are single binaries.

Building

Build automation is a Justfile. just on its own lists every recipe.

just tools    # fetch pinned templ, tailwindcss and golangci-lint
just build    # generate templates, compile CSS, build the binary
just check    # generate, gofmt, go vet, golangci-lint, go test -race
just all      # check and build
just run      # build and run in the foreground at debug level

Two environment quirks the Justfile handles on its own:

  • If the working tree is on a filesystem that cannot carry the execute bit — a CIFS/SMB share mounted file_mode=0644, for example — the binary and the downloaded tools are placed under ${XDG_CACHE_HOME:-~/.cache}/letterbox instead of in the tree, and just build says so.
  • The standalone Tailwind binary needs a musl or glibc loader that NixOS does not provide. On such systems the build falls back to nix run nixpkgs#tailwindcss_4. Set TAILWIND_BIN=/path/to/tailwindcss to force a specific CLI.

Configuration

Configuration is layered, lowest precedence first:

defaults  <  configuration file  <  LETTERBOX_* environment  <  command-line flags

Letterbox is configured in YAML. See letterbox.example.yaml for every key and its environment equivalent; a .toml file is still parsed if you have one, and is looked for after the YAML names so that an old one cannot shadow a new one. The only key with no default is app.key, which encrypts session data — including the cached IMAP credentials — at rest:

openssl rand -base64 32

Changing it ends every existing session, which is why Letterbox refuses to generate one for you.

Running

letterbox -config /etc/letterbox.yaml          # foreground
letterbox -config /etc/letterbox.yaml -daemon  # detach, print the pid, exit
letterbox -version
letterbox -help

-datadir, -listen and -loglevel override the file for a single run.

A detached process writes its pid to app.pid_file (by default <datadir>/letterbox.pid) and removes it on exit. Its standard streams go to the null device, so log.output decides where anything is written; the startup sequence goes into the log as well, since there is no terminal to print it to.

SIGHUP re-reads the configuration file. Limits, UI defaults, trusted proxies, rate limits and the SMTP and ManageSieve servers take effect immediately. The listening address, the data directory, the application key and the log settings are fixed when the process starts; a change to any of them is logged as needing a restart rather than half-applied.

SIGINT and SIGTERM drain in-flight requests, stop the mailbox watchers and close the database.

On Windows

It builds and runs, with two differences the signals cause. -daemon starts the child with no console attached, which is the nearest equivalent to leaving the controlling terminal, so nothing can send it a Ctrl+C — stop it through the pid file rather than the keyboard. And Windows never delivers SIGHUP, so configuration is re-read by restarting rather than by signalling.

GET /healthz answers 200 ok when the database is reachable and 503 otherwise. It needs no session.

Behind a reverse proxy

Set app.base_url to the URL users type. It decides whether the session cookie is marked Secure, so an https:// URL is what turns that on.

Set app.trusted_proxies to the networks your proxy sends from. Until you do, X-Forwarded-For is ignored entirely and rate limiting counts the proxy as one client — which, behind a proxy, means one bucket for everybody. Letterbox walks the forwarded chain from the right and stops at the first address that is not one of yours, so a client that writes its own header cannot pick its bucket.

Security

  • Sessions. The cookie is HttpOnly, SameSite=Lax and Secure behind an https:// base URL. Its token is hashed before it is used as a database key, and the session payload — the cached IMAP credentials included — is sealed with a key derived from app.key. Signing in renews both the session token and the CSRF token.
  • CSRF. Every unsafe request must carry the session's token, in a hidden field for forms the browser submits and in a header for the ones htmx makes. The check wraps the whole application rather than each route, so a route added later is protected by default. The standard library's cross-origin protection sits outside it as a second answer to the same question.
  • Sign-in throttling. limits.login_rate_limit failed attempts per limits.login_rate_window, per client address, as a token bucket. Successful sign-ins are refunded, so the budget is one of failures.
  • Message content. HTML is sanitized with a strict allowlist, remote content is blocked until the reader asks for it, and what they choose to allow is remembered per sender or per host. Attachments are served with the type and disposition the MIME headers actually justify.

What works

Read folder list by special-use role, message list, search, threading headers, attachments
Write reply, reply-all, forward, drafts saved on a timer, copy-on-send to Sent
Filter ManageSieve: list, edit, check, activate, delete
Live IMAP NOTIFY, IDLE or polling, pushed to the browser over SSE
Settings theme, page size, date format, time zone, poll and autosave intervals

Contacts and Calendar appear in the rail and do nothing yet. GPG and S/MIME are intended and not started. There is no multi-select in the message list and no file picker in the composer.

Development

go run ./cmd/letterbox-demo    # the whole application against in-memory servers

It listens on 127.0.0.1:8080 with sample mail; sign in as alice@example.com / letterbox. It stores nothing and needs no mail server.

reference/ holds shallow clones of Cypht and Roundcube, kept for comparison when a protocol detail or an interface decision needs a second opinion. Nothing in the build depends on them.

Disclosure

This codebase was written with the assistance of a large language model, under human direction and review. Every dependency was chosen against its current documentation rather than from the model's recollection, and the behaviour of each one that mattered — IMAP NOTIFY encoding, ManageSieve literal framing, the sanitizer's actual output — was verified against the RFC or on the wire rather than assumed.

Licence

To be decided before the first public release.