- Go 86.1%
- templ 8.8%
- JavaScript 3%
- CSS 1.5%
- Just 0.4%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The same race as the watcher tests, one layer up: three of these delivered a message and waited for a subscription to report it, and lost under load. Watching is select-then-idle, only the second makes the server announce anything, and the moment between them cannot be seen from outside — the server confirms idling to the client before it starts listening. A message appended in that gap is announced to nobody. It needed the whole tree running at once to show itself, which is why it looked like somebody else's flake. Two of the three failed in two runs out of three under that load; they now survive four. Mail keeps arriving until it is noticed. Where one delivery had to reach two subscribers, only the first waits that way: once it has heard, everything delivered so far has already been fanned out to the other, so the second's event is sitting in its queue. |
||
| assets | ||
| cmd/letterbox | ||
| 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 — though "nothing at all" means cleartext HTTP, and the session cookie
holds the mailbox password, so put it behind something that terminates TLS
before anyone else can reach it. See Behind a reverse proxy.
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
Letterbox speaks HTTP only; there is no inbound TLS in the binary and no setting that adds one. Something in front of it has to do that, and until it does, both the sign-in form and the session cookie that carries the mailbox password cross the network in the clear. The startup output says so, in those words, every time it is true.
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 — and it is also
what tells Letterbox its own address, which it uses to refuse a message that
tries to point the reader's browser back at it.
If TLS is terminated at the proxy and app.base_url cannot be an https URL for
some other reason, set app.cookie_secure: true to mark the cookie anyway.
Set app.trusted_proxies to the addresses your proxy sends from, written
either way — 10.0.0.0/8 for a range or 10.0.0.5 for the one machine. 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.
A proxy that names the sender outright is believed ahead of the chain:
CF-Connecting-IP from a trusted peer is taken as the answer, because it says
one thing rather than listing hops anybody may have added to.
The two address families are separate. 127.0.0.1 does not name ::1, and a
proxy on the same machine may reach Letterbox over either — so name both if you
are not sure which it will use. An IPv4 address written the way RFC 4291 embeds
one in IPv6 works, prefix and all: ::ffff:10.10.5.0/120 is 10.10.5.0/24.
Anything shorter than /96 in that form is refused at startup, because the mask
then cuts into the mapping itself: ::ffff:10.10.5.0/24 reads as the 10.10.5
network and means ::/24, which holds the loopback address and no IPv4 address
at all.
Behind Cloudflare
app:
trust_cloudflare: true
cloudflare_refresh: 168h
Cloudflare publishes the addresses it proxies from and asks that they be
fetched rather than written down, because they change. With this on, Letterbox
reads both lists at startup and again on the interval, and treats those
addresses exactly as an entry in app.trusted_proxies would be. You do not
have to list them, and you do not have to notice when they change.
The lists change over months, so the default interval is a week. Anything from
24h to 30d is accepted: more often than daily is no use and no manners,
and less often than monthly is a list written down with extra steps.
It is off by default: fetching an address list from a third party on a timer is not something to do on an operator's behalf without being asked. A fetch that fails keeps whatever was read last, so a Cloudflare outage does not suddenly turn every reader into one rate-limit bucket. Until the first read succeeds, nothing is trusted — which is the same answer as leaving it off.
Webfonts
A message that asks for a font is asking the reader's browser to fetch it from somebody else's server, which says the message was opened just as plainly as a tracking pixel does. So a font is held back with the rest of the remote content and released with it.
Released, most of them still will not load. A font is fetched under CORS rules whatever the policy says, and a host that serves fonts for its own pages has had no reason to think about anybody else's. Settings → Remote content offers to fetch them here instead: the font arrives, and the sender's host sees this server rather than the reader.
It is off by default, because it means Letterbox making a request to a third
party on somebody's behalf. When it is on, only an address Letterbox itself
chose can be asked for — each one carries a tag derived from app.key — and
every connection is refused unless it lands on the public internet, so a sender
cannot use it to reach into the network Letterbox runs in.
Mailing lists
A message that arrived through a list says so above itself, named where the list named itself, with the way out beside it. Which way out depends on what the list offered: a one-click unsubscribe where it promised RFC 8058, a prepared message where it gave an address to write to, and its own page only when there is nothing better — that last usually wants a password.
One-click is a POST made in your name to a server you have not visited, so it asks first. The address is read from the message on the server and is never accepted from the browser: what the button sends is a folder and a number. Only https is used, which is what the RFC requires and why — the request says who reads this list, and over plain http it says it to every hop on the way. Connections go through the same check that refuses anything but the public internet, so a sender cannot use it to reach into the network Letterbox runs in.
A list also changes what replying means. Where one redirects replies to itself — most do — the reply menu offers to write to the author alone, which is otherwise impossible without retyping the address. A list that rewrites the sender to get past DMARC leaves no author address to write to, and the offer is absent rather than wrong.
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
just 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.
Run the real thing with just run, not go run ./cmd/letterbox. The templates
and the stylesheet are generated — just rebuilds them, go run does not, and
neither is in the repository, so a pull that changes the markup does not bring
the CSS that markup needs. The result is silent: every class is on every
element, nothing in the stylesheet matches, and the interface renders perfectly
while doing none of what it says. The server checks a few of the utilities it
depends on at startup and says so if they are missing, but just run avoids
the question.
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.