Letterbox, a Go webmail solution
  • Go 86.1%
  • templ 8.8%
  • JavaScript 3%
  • CSS 1.5%
  • Just 0.4%
  • Other 0.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Elisamuel Resto cc27337662
test(mailbroker): stop the subscriber tests racing the watcher into IDLE
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.
2026-09-05 16:45:35 -05:00
assets fix(web): stop the autosave filing drafts nobody wrote 2026-08-28 16:41:09 -05:00
cmd/letterbox fix(config): keep a reload to the file and the servers it started with 2026-09-05 12:17:15 -05:00
internal test(mailbroker): stop the subscriber tests racing the watcher into IDLE 2026-09-05 16:45:35 -05:00
.gitignore build: leave docs/ ignored 2026-08-19 23:02:09 -05:00
.golangci.yml chore: bootstrap go module and build tooling 2026-08-18 16:11:35 -05:00
go.mod fix: update go module domain (#1) 2026-08-19 16:06:14 +00:00
go.sum feat(sievex): mail filters over managesieve 2026-08-19 01:26:12 -05:00
Justfile build: keep the icons as files rather than as a build step 2026-08-19 22:53:31 -05:00
letterbox.example.yaml fix(config): keep a reload to the file and the servers it started with 2026-09-05 12:17:15 -05:00
README.md fix(config): keep a reload to the file and the servers it started with 2026-09-05 12:17:15 -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 — 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}/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

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=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

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.