Lucas RayanandClaude Opus 5 ee240ae227 #23: show real data, unify the surfaces and make enrolment scannable
The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.

Fabricated data, removed:

- The outbound queue was two invented jobs held in component state. Retry and
  Freeze only edited that local array, so the screen showed the same two rows
  on every deployment and reported success for actions that never happened.
  "Freeze" named an operation the backend has never implemented. It now reads
  /api/v1/admin/queue, which was already serving the real thing, and offers
  retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
  sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
  plausible traffic for a server that might have delivered nothing. Two of
  those fields were never returned by the endpoint at all, so the fake disk
  figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
  VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
  real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
  swallowed and a made-up TXT record displayed as though a key had been
  issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
  DANE from static markup, on a deployment currently serving a self-signed
  one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
  what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
  accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
  "SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
  the first edit wrote that invented filter into the account's real script.

The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.

Surfaces:

The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.

Enrolment:

Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.

The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.

Language:

translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.

The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.

Healthchecks:

web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.

Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:13:53 -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. Several console panels are still read-only, and some features are recorded but not yet acted on — 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 13 records, idempotent, with drift detection
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

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.

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.

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:

  • Recorded but not acted on. Vacation responder, signature and the Sieve rules built in the UI are stored, but nothing consumes them yet — enabling a vacation reply does not send one.
  • Read-only console panels. The guided domain onboarding checklist, the DNS desired-vs-actual diff and the CSV import report on state rather than driving it.
  • Not implemented. JMAP, BIMI, CalDAV/CardDAV, multi-node. Backups are documented but not automated.
  • DANE is off unless TLS_MODE=acme, and deliberately so: under a Traefik-managed certificate the key changes at every renewal and the published TLSA record stops matching, which permanently rejects mail at every validating MTA.

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%