Files
LambdaMail/README.md
T
Lucas RayanandClaude Sonnet 5.5 5bbbb85128
CI / Lint de idioma (R9) (push) Successful in 5s
CI / Go (protocols) (push) Successful in 2m34s
CI / TypeScript (storage) (push) Successful in 15s
CI / Testes (webmail) (push) Successful in 51s
CI / TypeScript (auth) (push) Successful in 2m39s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Successful in 9s
CI / Imagem (migrations) (push) Successful in 5s
CI / Stack completa (push) Successful in 1m11s
CI / Imagem (auth) (push) Successful in 17s
CI / Imagem (protocols) (push) Successful in 7s
CI / Imagem (rspamd) (push) Successful in 5s
CI / Imagem (storage) (push) Successful in 17s
CI / Imagem (webmail) (push) Successful in 1m52s
release: 1.0.0, a changelog, one .env.example and the docs to match
- CHANGELOG.md and CHANGELOG.pt-BR.md, from the project history.
- .env.example now serves development and production; scripts/init-env.sh
  (make env) creates .env with random secrets. .env.production.example is gone.
- docs/RELEASING.md: the pipeline, how a version is cut and deployed, the runners.
- DEPLOYMENT.md and both READMEs updated for Gitea and the new env flow.
- Service versions set to 1.0.0.
- CI: the proxy network step is idempotent and the stack is torn down always,
  since the runner's Docker daemon now outlives a job.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 11:36:22 -03:00

392 lines
23 KiB
Markdown

<p align="center"><img src="docs/img/lambdamail-logo.svg" width="120" alt="LambdaMail"></p>
<h1 align="center">LambdaMail</h1>
<p align="center">Your own mail server, with a webmail that is a pleasure to use.<br>SMTP, IMAP and POP3 · SPF, DKIM, DMARC and ARC · Webmail and admin console · One <code>docker-compose.yaml</code>, made for Coolify.</p>
<p align="center"><a href="docs/WEBMAIL.md">Webmail</a> · <a href="docs/DEPLOYMENT.md">Deploying</a> · <a href="docs/OPERATIONS.md">Operating</a> · <a href="docs/MAILBOX-CORE.md">Mailbox core</a> · <a href="docs/RELEASING.md">Releasing</a> · <a href="CHANGELOG.md">Changelog</a> · <a href="LICENSE">License</a></p>
<p align="center"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
<p align="center"><img src="docs/img/webmail-dark-en.png" width="900" alt="The LambdaMail webmail in the dark theme: the inbox as conversations on the left, and the Launch checklist conversation open on the right with the newest message expanded and the older ones as a line each"><br><sub>The real interface running on an invented mailbox: names, addresses and messages are made up.</sub></p>
---
LambdaMail is a self-hosted mail server with an integrated webmail and admin
console, built to run on [Coolify](https://coolify.io) from a single
`docker-compose.yaml`.
SMTP, IMAP, POP3 and ManageSieve, with SPF, DKIM, DMARC, ARC, MTA-STS and
TLS-RPT, DNS automation through Cloudflare, and spam and virus filtering. No
paid tiers and no artificial limits on domains or mailboxes.
> **Version 1.0.0** ([changelog](CHANGELOG.md)). The mail path, the security posture and the web
> interfaces work end to end, and there is an automated test suite behind them.
> See [What is not finished](#what-is-not-finished) before putting real mail
> through it.
## What it does
| Area | |
| :--- | :--- |
| **Protocols** | SMTP MX (25), Submission (587 STARTTLS, 465 implicit TLS), IMAP (143/993) with UID, flags, IDLE and CONDSTORE, POP3 (110/995), ManageSieve (4190) |
| **Authentication** | SPF, DKIM (Ed25519 + RSA dual signature), DMARC, ARC sealing, DANE/TLSA and MTA-STS on delivery, TLS-RPT ingestion |
| **Filtering** | Rspamd scoring with greylisting, RBLs and rate limiting; ClamAV; Sieve |
| **Delivery** | Durable outbound queue in Postgres with the retry ladder and RFC 3464 bounces; smarthost fallback for hosts with port 25 blocked |
| **DNS** | Cloudflare reconciliation of the expected records for every active domain, idempotent, with per-record verification against public resolvers |
| **Filing** | Sieve rules built in the UI, custom folders, and an RFC 5230 vacation responder that replies in thread and once per sender per week |
| **Identity** | Per-user profile photos with a circular crop editor, shown inside the webmail; BIMI logo hosting and validation for the mark external clients render |
| **Web** | Webmail at `/user/*` and an admin console at `/admin/*`, isolated by session audience, with TOTP two-factor, recovery codes, app passwords, and English, Portuguese and Spanish |
| **Sessions** | Webmail sessions last 30 days and renew while used; the console stays at 8 hours. Several accounts can be signed in at once and switched from the sidebar, without signing out |
| **Trusted senders** | Trusting a sender keeps their mail out of spam and always shows their images, kept on the server per mailbox. "Not spam" in the Spam folder does both in one click. A forged From is not rescued: delivery needs DMARC, or SPF for the From domain |
| **Conversations** | Mail is listed one row per conversation, with who wrote, how many, and whether you have answered. The messages stack in the reader and the reply is written under them. One switch turns it off |
| **Reading** | A message written plainly takes the app's own colours; a designed one (newsletter, receipt) keeps its white sheet. Quoted history folds away, remote images are blocked until you say so, and a plain message can be seen as sent |
| **Appearance** | Dark (default) and light themes, a command menu on `Ctrl K` / `Cmd K`, `c` and `/` shortcuts, and an installable app with its own icon. Fonts are served by the app itself |
| **Console** | Server-side paging and filtering, user editing (role, quota, language, password, second factor), per-user aliases, and a diagnostics page at `/admin/tests` |
## Architecture
Two runtimes, split by what each is good at:
- **`protocols`** (Go) — every protocol listener, the delivery worker, TLS
certificate handling, DNS reconciliation, and the webmail's mail API
(messages, conversations, drafts). It owns folder, UID, flag and blob
semantics, and decides at delivery which conversation a message joins and
whether a trusted sender's mail skips spam.
- **`auth`** (TypeScript) — logins, sessions and the accounts signed in on a
browser, two-factor, trusted senders, preferences, and the admin console API.
- **`webmail`** (Next.js) — both web surfaces, and the proxy that gives the
browser a single origin.
- **`storage`** (TypeScript) — health only today.
Postgres is the source of truth for everything, including the outbound queue.
Redis is a cache and dispatch index; losing it loses no mail.
```
┌──────────┐
browser ─────────▶│ webmail │──┬─▶ auth (sessions, accounts, trusted senders, admin)
└──────────┘ └─▶ protocols (mail read/send, conversations, DKIM, TLS)
│
internet ──25/587/465/143/993──────────┘
│
Postgres ─┴─ Redis ─ Rspamd ─ ClamAV
```
## Using the webmail
The reference is **[docs/WEBMAIL.md](docs/WEBMAIL.md)**. The short version:
- **Stay signed in.** A session lasts 30 days and renews each day it is used.
Add another account from the menu at the foot of the sidebar and switch
between them; sign-out leaves the others signed in.
- **Trust a sender.** The shield beside a sender's name trusts them: their mail
stops going to spam and their images load. In the Spam folder, **Not spam**
moves the message to the inbox and trusts the sender.
- **Read as conversations.** One row per conversation; open it and reply under
the messages. Settings → Language turns this off.
- **Press `Ctrl K` (or `Cmd K`).** Folders, Compose, Settings, accounts and
appearance, by typing. `c` writes a message and `/` searches.
- **Pick a theme** (dark, light or system) from the same menu at the foot of the
sidebar. Dark is the default.
| Key | |
| :--- | :--- |
| `Ctrl K` / `Cmd K` | Command menu |
| `c` | New message |
| `/` | Search the list |
<p align="center"><img src="docs/img/webmail-light-en.png" width="440" alt="The same conversation in the light theme"> <img src="docs/img/designed-light-en.png" width="440" alt="A designed newsletter keeping its own white sheet, in the light theme"></p>
<p align="center"><sub>The light theme, and a designed message (a newsletter, a receipt) that keeps the sheet it was drawn for. A message written plainly takes the app's own colours instead.</sub></p>
<p align="center"><img src="docs/img/menu-dark-en.png" width="620" alt="The command menu open over a conversation, listing actions, folders and appearance"><br><sub>The command menu, on <code>Ctrl K</code> or <code>Cmd K</code>.</sub></p>
## Deploying
For a real VPS, follow **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)**. It covers the
three things no code can do for you — reverse DNS, the DNSSEC delegation and
getting port 25 unblocked — and the order the rest has to happen in.
The short version:
```bash
scripts/init-env.sh --production # .env with random secrets; lists what to set
make preflight # checks port 25, PTR, RBLs, token scope
docker compose up -d
make create-admin EMAIL=you@example.com PASSWORD='...'
```
`make create-admin` is not optional: a fresh install has no mailbox, and every
route into the console needs one.
### Ports
Everything below is TCP. "Inbound" is what the host's firewall and the
provider's security group must allow; "outbound" is what the host must be able
to reach.
| Port | Direction | Who connects | Required? | Notes |
| ---: | :--- | :--- | :--- | :--- |
| 25 | in | Any sending MTA | **Yes** to receive mail | Must be open to the world. Rate-limited, not authenticated — that is how SMTP works. |
| 25 | **out** | This host → remote MTAs | **Yes** to send mail | The one most providers block. Without it, set a relay (`RELAY_HOST`); see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). |
| 80 | in | Let's Encrypt, browsers | **Yes** | ACME HTTP-01 challenge, and the redirect to HTTPS. Closing it means no certificate. |
| 443 | in | Browsers | **Yes** | Webmail, admin console, MTA-STS policy, autodiscover. |
| 587 | in | Mail clients | Recommended | Submission with STARTTLS. The port clients should use. |
| 465 | in | Mail clients | Optional | Submission with implicit TLS, for clients that insist. |
| 143 | in | Mail clients | Optional | IMAP with STARTTLS. Prefer 993. |
| 993 | in | Mail clients | Recommended | IMAPS. |
| 110 / 995 | in | Mail clients | Optional | POP3 / POP3S. Leave closed unless something needs POP3. |
| 4190 | in | Mail clients | Optional | ManageSieve, for editing filters from a desktop client. |
| 53 | out | This host → resolver | **Yes** | MX, SPF, DKIM, DANE and MTA-STS lookups. UDP **and** TCP — DNSSEC answers overflow UDP. |
| 443 | out | This host → APIs | If used | Cloudflare DNS automation, MTA-STS policy fetch, ClamAV signature updates. |
Ports 110, 995 and 4190 are published by the compose file but are worth closing
at the firewall unless a client actually needs them — every open port is one
more thing to keep patched.
### What the host has to allow
- **Reverse DNS (PTR)** for the public IPv4 must resolve to `PRIMARY_MAIL_HOST`,
and that name must resolve back to the same address. Set by whoever owns the
IP block — your provider's panel, never Cloudflare. Gmail and Outlook
spam-folder or refuse mail without it. If the host has a public IPv6 and
publishes an AAAA record, it needs a PTR too: senders that connect over IPv6
are judged on the IPv6 reverse.
- **Outbound port 25 unblocked.** Ask the provider; a timeout rather than a
refusal when connecting to a remote MX is the signature of a block.
- **Docker** with the compose plugin, and a user in the `docker` group or root.
- **Ports below 1024** are bound by Docker's proxy on the host, so the daemon
needs the privilege — the default on a normal install. The containers
themselves run unprivileged.
- **The proxy's certificate directory**, under `TLS_MODE=traefik` only — see
[Certificates](#certificates) below, and prefer `TLS_MODE=acme` if you can.
- **Disk** for the mail spool (`protocols_spool`) and Postgres. Mail is stored
once and reference-counted, but plan for growth.
- **A firewall that does not rate-limit port 25 into uselessness.** Some
providers apply aggressive SYN limits that look like intermittent delivery
failures.
## Running locally
```bash
make env # .env from .env.example, with random secrets
make gen-dev-cert # self-signed certificate for the listeners
make up # builds from source via the build overlay
make seed # a postmaster@example.test fixture
```
The webmail is on `http://localhost:3000`. The seeded password is
`dev-password-only` — it is a fixture, not a credential.
To see or work on the interface without any mail services, run the invented
mailbox in [`scripts/screenshots/`](scripts/screenshots/README.md) and point the
webmail at it; the same stub produced the pictures above.
## Testing
```bash
make test-db-up # a throwaway Postgres on :55432; prints the two variables to export
make test # Go suite, auth and webmail
make lint # go vet, tsc, and the language lint
```
The Go and auth suites include integration tests that need Postgres. They skip
without `TEST_DATABASE_URL` and run against a real database in CI, which is
where the security regressions are enforced — wrong password, wrong TOTP code,
session revocation, surface isolation, cross-mailbox reads, and now sessions
that renew, accounts that switch, trusted senders and which conversation a
reply joins. A green run **without** the variable skips all of that, so
`make test` says so loudly; run it with the database before trusting it.
The webmail suite is file- and logic-level (`vitest`): it guards the colour
system, the sanitiser, the reader's modes, the composer's undo and discard, and
that every string exists in all three languages. The screens themselves are
checked in a real browser; see
[docs/WEBMAIL.md](docs/WEBMAIL.md#testing-it).
## Configuration
Every variable is documented inline in
[`.env.example`](.env.example), the one file for development and production.
The ones without a safe default (`scripts/init-env.sh` generates the secrets):
| Variable | |
| :--- | :--- |
| `JWT_SECRET` | Signs sessions. At least 32 characters; the service refuses to start below that rather than fall back to a constant. |
| `LAMBDAMAIL_MASTER_KEY` | Encrypts DKIM keys, TOTP secrets and provider tokens. **Back it up separately from the database** — losing it makes every stored secret unrecoverable. |
| `POSTGRES_PASSWORD`, `REDIS_PASSWORD` | No defaults; compose fails without them. |
| `MAIL_DOMAIN`, `PRIMARY_MAIL_HOST`, `PUBLIC_IPV4` | Identity. `PRIMARY_MAIL_HOST` must match the PTR of `PUBLIC_IPV4`. |
### Certificates
Two modes, and the choice matters more than it looks.
**`TLS_MODE=acme` (recommended).** LambdaMail obtains its own certificate for
`PRIMARY_MAIL_HOST`, `mta-sts.<domain>` and `autoconfig.<domain>` over DNS-01,
using the Cloudflare token already configured for DNS. It needs
`LAMBDAMAIL_MASTER_KEY` to seal the account and certificate keys, and
`ACME_EMAIL`. It is the only mode DANE can run in, because it is the only one
where the key for the next certificate exists before that certificate does.
**`TLS_MODE=traefik`.** The mail listeners read the certificate the reverse
proxy already obtained, from `COOLIFY_PROXY_DIR`, mounted read-only. Two things
have to be true, and neither is obvious when they are not:
- the proxy must have a router for `PRIMARY_MAIL_HOST`, or that name never
appears in `acme.json` and no certificate exists to read;
- `acme.json` must be readable by the container's non-root user. Left at mode
`600` owned by root — which is how the proxy writes it — it cannot be.
Either failure is silent in the same way: HTTPS keeps working, because the
proxy is serving it, while the mail listeners fall back to a **self-signed
certificate** and every client that verifies refuses SMTP, IMAP and POP3. It
reads as a certificate problem and is usually a permissions problem.
`/admin/security` names the issuer and says outright when the certificate is
self-signed, which is the fastest way to tell the two apart.
### Profile photos and BIMI
These are two different things, and the difference is the whole story.
A **profile photo** is per user and is shown inside LambdaMail. No external
mail client reads it, and no protocol exists that would let one: the avatar
Gmail or Outlook shows beside a message comes from the *recipient's* own
contacts, or from the sender's Google or Microsoft account. A self-hosted
server cannot put an image there.
**BIMI** is what external clients do read, and it is per domain: one mark for
everyone who sends from it. Publishing one needs three things, and the console
(`/admin/domains`, under the domain) handles each:
1. **The logo.** Upload a PNG or an SVG. The studio converts it to what BIMI
accepts, SVG Tiny Portable/Secure on a square canvas of at most 32 KB with
nothing active or external in it, and lets you place it the way the profile
photo editor does: drag, zoom, background colour, with a preview inside the
circle or rounded square clients draw. An SVG has what the format forbids
removed (scripts, links, embedded bitmaps, editor metadata) and says what; a
PNG is traced into vector paths, which loses some detail, so send the SVG
when you have it. The server validates the result again against every rule
at once, because a receiver that rejects a mark simply shows nothing.
2. **DMARC at `p=quarantine` or `p=reject`** with `pct=100`, which this server
already sets.
3. **A certificate, for Gmail and Apple Mail to render it.** A Verified Mark
Certificate (VMC, for a registered trademark) or a Common Mark Certificate
(CMC, for a mark in use) is **issued by a certificate authority**
(DigiCert, Entrust, SSL.com and others) after it checks the organisation.
It cannot be generated here from an image, and nothing signed here would be
trusted. What the console does: gives you the exact SVG to send the
authority, takes the `.pem` it returns, and refuses one that would not work
(the logo inside it must be byte for byte the published one, it must cover
the domain, be in date, and never contain a private key). It then serves it
from `https://<mail host>/.well-known/bimi/vmc.pem` and points the `a=` tag
there. Without a certificate the record is still valid and some clients use
it; Gmail and Apple Mail do not.
"Verificar configuracao" in the panel looks at what a receiver would: the logo
file, DMARC, the BIMI record in DNS, the public URL of the logo, the
certificate and its public URL, and names the one that is wrong.
The logo is served from `https://<mail host>/.well-known/bimi/<domain>/default.svg`
and the certificate from `.../<domain>/vmc.pem`: one mail host, a path per
domain, so every domain the server hosts can publish its own. (The earlier
`/.well-known/bimi/default.svg` chose the domain from the host name, which could
name only the primary domain; every other domain's record pointed at a 404. It
still answers, for records already published.)
**The DNS record follows the picture.** With a Cloudflare token configured, the
`default._bimi.<domain>` TXT record is part of what the DNS reconciler
publishes, and saving or removing the logo or the certificate in the console
triggers that reconciliation by itself ("Publicar no DNS agora" does the same
on demand). The record is created when a logo exists, updated when the
certificate is added, and removed when the logo is removed, but only a record
this reconciler published: one made by hand is left alone.
A held certificate is advertised (`a=`) only while it is valid for the logo
being served. The certificate vouches for one exact SVG, so replacing the logo
ends it, and a record that kept naming it would make every receiver reject BIMI,
including those that would have shown the logo without a certificate. The record
then goes back to `l=` alone, and the console says why the certificate is held
back. The panel, the setup check and the reconciler all take that decision from
the same function (`bimi.Publish`), so they cannot disagree about it.
A changed profile photo shows up at once everywhere in the webmail: every
photo carries a version that is bumped on save, in this tab and the others. The
server lets a browser reuse one for a minute and then revalidates it.
### DANE
Off by default, and worth understanding before turning on: a TLSA record that
does not match the key being served does not degrade delivery, it **stops** it
at every validating sender. Two things have to be true first.
- **DNSSEC on the zone, with the DS record published at the registrar.** DANE
trusts the TLSA record because DNSSEC signs it; without the chain there is
nothing to trust, and validators treat the record as absent.
- **`TLS_MODE=acme`.** LambdaMail has to own the key to publish the association
for the *next* one before switching to it. Under `TLS_MODE=traefik` the proxy
renews on its own schedule and the record would silently stop matching.
Then set `OUTBOUND_DANE_ENABLED=true`. The manager publishes `3 1 1` — DANE-EE
over the SubjectPublicKeyInfo, the only usage a receiving MTA should offer —
and handles the rollover by publishing the current and next associations
together, waiting for propagation before issuing against the new key.
`/admin/security` shows the digest of the certificate actually being served.
That number and the TLSA record in DNS must be the same; when they drift, mail
is refused and nothing else reports why:
```bash
dig +short TLSA _25._tcp.mail.example.com
```
## Operating
| | |
| :--- | :--- |
| `make preflight` | Port 25 egress, PTR and FCrDNS, RBL listings, Cloudflare token scope, certificate presence |
| `make create-admin EMAIL=… PASSWORD='…'` | First administrator on a fresh install |
| `make reset-password EMAIL=… PASSWORD='…'` | Recovers a locked-out account and signs out its sessions |
| `docker compose logs -f protocols` | Delivery, TLS and DNS reconciliation |
| `GET /health` on `protocols` | `200` healthy, `200` with `X-LambdaMail-Health: degraded` when a certificate needs attention, `503` when a dependency is down |
[docs/OPERATIONS.md](docs/OPERATIONS.md) covers backups, certificate rotation,
what the alerts mean and how to read a delivery failure.
## What is not finished
Stated plainly, because a mail server that half works loses mail:
- **Read-only console panels.** The guided domain onboarding checklist reports
on state rather than driving it.
- **Attachments from the composer.** The picker lists files but nothing is
uploaded, and the app says so on screen. Reading and downloading received
attachments works.
- **Spam filter learning.** "Not spam" stops condemning that sender, but Rspamd
does not learn from it, so similar mail from strangers is judged as before.
- **Conversations are grouped at delivery.** Older mail is grouped by subject,
and only where a reply exists; a conversation whose replies lost their
`References` and subject may show as separate rows.
- **Not implemented.** IMAP `APPEND`, JMAP, CalDAV/CardDAV, multi-node. Backups
are documented but not automated.
- **DANE** is off by default. See [DANE](#dane) for turning it on; under
`TLS_MODE=traefik` it stays off deliberately, because the proxy's key changes
at every renewal and the published TLSA record stops matching.
## Contributing
- All code, comments and identifiers are in English. `make lint` enforces it
(and, for code, ASCII only: write `\u00e9`, not an accented letter).
- Colours come from the theme variables in `globals.css`, never a literal, and a
new one is defined in both themes; see
[docs/WEBMAIL.md](docs/WEBMAIL.md#the-colour-system). The logo is generated
from [`scripts/brand/`](scripts/brand/README.md).
- New migrations are numbered in `migrations/` and run on deploy. Delivery code
must not depend on one that may not have run: the end-to-end tests apply only
the first.
- Tests come with the change that needs them; the suites above must stay green.
- Commits reference their issue: `#N: what changed and why`.
- A change someone would notice goes under *Unreleased* in [CHANGELOG.md](CHANGELOG.md)
(and its Portuguese twin). How a version is cut: [docs/RELEASING.md](docs/RELEASING.md).
## License
MIT — see [LICENSE](LICENSE).