Lucas Rayan caf52c0225 refactor(mail): one MailStore changes message state for every path
Flags, expunge, move, copy and folder deletion each existed twice: once for IMAP
(FlagRepository, ExpungeRepository, CopyRepository) and once for the webmail
(WebmailRepository). They kept the cached numbers true on their own and drifted:

- reading a message over IMAP never moved the folder's unread counter
- deleting in the webmail, or deleting a folder, never gave the quota back
- an IMAP expunge could push a counter below zero

MailStore now holds each state change once, in one transaction, with the same
rules everywhere: counters never go negative, a change that alters what a client
can observe raises the folder's modseq (one that alters nothing does not), the
quota follows the bytes, and a message is located by folder. The four repositories
are thin adapters that only translate how each path names a message.

The contract test added before this change (which failed on 8 of its cases)
now passes, as does the full suite.
2026-10-16 13:15:32 -03:00
2026-07-23 02:18:24 -03:00

LambdaMail

A self-hosted mail server with an integrated webmail and admin console, built to run on Coolify 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.

Status: pre-release. 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 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
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. It owns folder, UID, flag and blob semantics.
  • auth (TypeScript) — logins, sessions, two-factor, account management and the admin console API.
  • web (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 ─────────▶│   web    │──┬─▶ auth      (sessions, accounts, admin)
                    └──────────┘  └─▶ protocols (mail read/send, DKIM, TLS)
                                        │
  internet ──25/587/465/143/993──────────┘
                                        │
                              Postgres ─┴─ Redis ─ Rspamd ─ ClamAV

Deploying

For a real VPS, follow 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:

cp .env.production.example .env      # then replace every CHANGE
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.
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 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

cp .env.example .env
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.

Testing

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.

Configuration

Every variable is documented inline in .env.production.example. The ones without a safe default:

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:

  • DMARC at p=quarantine or p=reject, which this server already sets;
  • a logo in SVG Tiny Portable/Secure — square viewBox, one <title>, nothing active or external. /admin/domains validates an upload against every rule at once and names the ones it fails, because a receiver that rejects a mark simply shows nothing;
  • a Verified Mark Certificate for Gmail and Outlook to render it. It is issued against a registered trademark, costs on the order of US$1000 a year, and without it the record is still valid — some clients use it, those two do not.

The logo is served from https://<mail host>/.well-known/bimi/default.svg and the record is published with the rest of the zone.

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:

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 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.
  • Not implemented. JMAP, CalDAV/CardDAV, multi-node. Backups are documented but not automated.
  • DANE is off by default. See 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.
  • Tests come with the change that needs them; the suites above must stay green.
  • Commits reference their issue: #N: what changed and why.

License

MIT — see LICENSE.

S
Description
Your own mail server, with a webmail that is a pleasure to use.
Readme MIT
3.5 MiB
0 Stars 1 Watchers 0 Forks
2026-10-30 15:26:52 +00:00
Languages
Go 60.5%
TypeScript 37.2%
JavaScript 0.9%
CSS 0.4%
Dockerfile 0.3%
Other 0.7%