- Go 89.9%
- templ 7%
- CSS 1.2%
- JavaScript 1.1%
- Just 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| assets | ||
| cmd | ||
| internal | ||
| .gitignore | ||
| .golangci.yml | ||
| go.mod | ||
| go.sum | ||
| Justfile | ||
| letterbox.example.yaml | ||
| README.md | ||
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}/letterboxinstead of in the tree, andjust buildsays 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. SetTAILWIND_BIN=/path/to/tailwindcssto 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=LaxandSecurebehind anhttps://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 fromapp.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_limitfailed attempts perlimits.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.