A fresh deployment published "v=DKIM1; k=rsa; p=" for its own domain. An empty p= is not an absent key, it is a revoked one (RFC 6376 section 3.6.1): every verifier reading it treats all signatures from that selector as invalid. On a domain whose DMARC policy is p=quarantine with strict alignment - which this reconciler generates - that quarantines all of its own outbound mail. Worse than publishing nothing. The chain: DkimRepository.Insert is an INSERT ... SELECT conditional on the domain existing in the domains table, so on an empty database it inserts no rows and returns no error. FindPublicKey then finds nothing, ensure() returns an empty string with no error, and the reconciler faithfully published the empty value. Every field along that path was behaving as written; nothing checked that the result meant anything. Two changes: - The record builder omits a DKIM record when its key is empty, so the revocation cannot be published regardless of why the key is missing. - Provisioning logs when it produced no key, naming the likely cause. The insert being conditional on an onboarded domain is exactly the case a new operator hits, and "no key, domain probably not onboarded" is the difference between a diagnosis and unsigned mail with no explanation. It stays non-fatal: erroring here would abort the whole reconcile and leave a new install with no A, MX or SPF records either. Found while migrating to a new host: the fresh database had no domain row, so the first reconcile revoked the domain's DKIM. The two published records have been deleted from the zone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
10 KiB
Markdown
201 lines
10 KiB
Markdown
# LambdaMail
|
|
|
|
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.
|
|
|
|
> **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](#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](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
|
|
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](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** must be readable by the `protocols`
|
|
container. Under `TLS_MODE=traefik` it reads the certificate the proxy
|
|
obtained, from the directory given by `COOLIFY_PROXY_DIR`, mounted read-only.
|
|
The images run as a non-root user, so an `acme.json` left at mode `600` and
|
|
owned by root cannot be read, and the mail listeners fall back to a
|
|
self-signed certificate while HTTPS keeps working — a confusing failure that
|
|
looks like a certificate problem and is a permissions problem.
|
|
- **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
|
|
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
|
|
|
|
```bash
|
|
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`](.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](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](LICENSE).
|