Four faults, all in the path between the compose screen and the mailbox.
**Every message sent from the webmail had an empty body.** The HTTP handler
read a field named "body"; the composer has always sent "html". The two never
matched, so the payload handed to the queue contained headers and nothing
else. The handler now reads html and text, and still accepts body so an older
client is not broken. Sending was otherwise working, which is why this went
unnoticed: the mail left, it just said nothing.
**Bcc was discarded.** The field was absent from the request struct and from
ComposeInput, so a blind copy typed into the webmail reached nobody. It now
joins the envelope recipients and is deliberately kept out of the headers,
which is what makes it blind.
**HTML was labelled text/plain.** Even once the body was carried, the composer
produces markup and the message declared text/plain, so a recipient would see
raw tags. Messages with HTML now go out as multipart/alternative with a
plain-text part derived from the markup and ordered first per RFC 2046 5.1.4.
An empty or missing text alternative is itself a spam signal.
**Sent mail left no trace.** Nothing wrote to the Sent folder, so a delivered
message vanished from the sender's view entirely. A copy is now filed after
the message is durably queued - not before, or a send refused by the rate
limit would still leave a copy - reusing the inbound persistence path so it
gets a real IMAP UID and appears over IMAP too. A failure to file is logged
and swallowed: the mail is already on its way, and reporting failure would
invite the user to send it twice.
**Drafts were never saved.** There was no endpoint at all. The composer showed
"draft saved automatically" from a setTimeout with no request behind it, so
closing the tab lost the message the screen had just promised was safe. Adds
POST /api/v1/mail/draft, storing the draft as a whole RFC 5322 message in the
Drafts folder, and debounces the composer onto it.
The composer also navigated to Sent whether or not the send succeeded, which
is how a rejected message disappears silently. It now reports the error and
stays put.
**Delay notifications were being filed as spam.** A DSN this server writes is
injected through the same inbound path as mail from the internet, but without
a client IP - so SPF can only be "none" - and its bounce-shaped content scores
highly. The warning telling you your mail was delayed landed in your own Junk
folder. Messages the server generates itself now skip content scanning; the
exemption is narrow, and a test covers that ordinary mail is still scanned so
this cannot become a way past the filter. The DSN also declared
Auto-Submitted: auto-replied, which belongs to vacation responders; a delivery
report is auto-generated (RFC 3834 section 5), and filters read that header.
Not fixed here, because it is not a code fault: outbound port 25 is blocked by
the provider, which is what the delay notification was reporting
("i/o timeout" to Google's MX - a timeout, not a refusal, is the signature of
a block). Receiving is unaffected and queued mail keeps retrying. Documented
with the two ways out, including the relay path, which is verified working
code rather than another stub.
Verified: 22 Go packages pass, 5 new compose tests and 3 for the DSN handling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 lintenforces 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.