release: 1.0.0, a changelog, one .env.example and the docs to match
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

- 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>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-30 11:36:22 -03:00
1 parent f500534ac6
commit 5bbbb85128
17 files changed
+524 -182

No files matched your search

+95 -44
View File
@@ -1,81 +1,132 @@
# --- Container images ---
# Published to the Gitea registry by .gitea/workflows/release.yml, multi-architecture
# (linux/amd64 and linux/arm64). The deploy host pulls these instead of
# compiling: `docker compose pull && docker compose up -d`. Running
# `docker compose build` locally still overrides them with a local build.
# Pin IMAGE_TAG to a released version (e.g. 1.2.3) or a sha- tag to roll back.
# LambdaMail - environment
#
# One file for local development and for production.
#
# scripts/init-env.sh copy this to .env and generate the secrets
# scripts/init-env.sh --production the same, plus a list of what you still
# have to set before this is a real server
#
# Values below are the local-development defaults. Lines marked PRODUCTION say
# what a real deployment needs. In Coolify, paste the finished .env into the
# environment editor instead of keeping a file on the host.
#
# The secrets are left empty on purpose. The script fills them with random
# values, and compose refuses to start while one is empty, because a default
# secret in a published repository is not a secret.
# --- Container images -------------------------------------------------------
# Published to the Gitea registry by .gitea/workflows/release.yml for
# linux/amd64 and linux/arm64. The deploy host pulls instead of compiling:
# `docker compose pull && docker compose up -d`. `make up` builds from source
# through the build overlay and ignores these.
# PRODUCTION: pin IMAGE_TAG to a release (1.0.0) or to a sha-<commit> tag. Never
# leave `latest` on a server: a redeploy then silently changes what runs.
IMAGE_NAMESPACE=git.lucasrguerra.dev.br/lucasrguerra/lambdamail
IMAGE_TAG=latest
# --- Postgres ---
POSTGRES_DB=lambdamail
POSTGRES_USER=lambdamail
POSTGRES_PASSWORD=change-me-in-local-dev
# --- Redis ---
REDIS_PASSWORD=change-me-in-local-dev
# --- Shared secrets (local dev values — never reuse in production) ---
JWT_SECRET=local-dev-jwt-secret-change-me-at-least-32-chars
# Encrypts DKIM private keys and provider tokens at rest. Losing it makes every
# stored secret unrecoverable — back it up separately from the database.
LAMBDAMAIL_MASTER_KEY=local-dev-master-key-32-bytes-minimum-value
# --- Host identity ---
PRIMARY_MAIL_HOST=mail.localhost
# --- Identity ---------------------------------------------------------------
# MAIL_DOMAIN is the domain you send and receive as.
# PRIMARY_MAIL_HOST is the host the MX record points at; its PTR must match it.
# PRODUCTION: MAIL_DOMAIN=example.com PRIMARY_MAIL_HOST=mail.example.com
# WEBMAIL_HOST=mail.example.com PUBLIC_IPV4=<the VPS public IPv4>
MAIL_DOMAIN=localhost
PRIMARY_MAIL_HOST=mail.localhost
WEBMAIL_HOST=localhost:3000
# Optional separate host for /admin.
ADMIN_HOST=
PUBLIC_IPV4=127.0.0.1
# Leave empty unless IPv6 has a matching PTR and is covered by SPF. A half
# configured IPv6 is a common cause of rejection (PLAN.md premises).
PUBLIC_IPV6=
# --- DNS automation (Cloudflare) ---
# --- Secrets ----------------------------------------------------------------
# Filled in by scripts/init-env.sh. To do it by hand:
#
# POSTGRES_PASSWORD, REDIS_PASSWORD openssl rand -hex 24
# JWT_SECRET, LAMBDAMAIL_MASTER_KEY openssl rand -hex 32
#
# The first two are substituted into connection URIs (postgres://user:PASS@db/...
# and redis://:PASS@redis:6379), so they must hold no character that means
# something in a URL: use hex, NOT `openssl rand -base64`, whose alphabet has "/"
# and "+". A "/" ends the authority component, the host is parsed as part of the
# password and every connection fails authentication - which shows up as the
# migrations container exiting 1 while Postgres itself reports healthy.
POSTGRES_DB=lambdamail
POSTGRES_USER=lambdamail
POSTGRES_PASSWORD=
REDIS_PASSWORD=
# Signs webmail and admin sessions. At least 32 characters; the auth service
# refuses to start below that.
JWT_SECRET=
# Encrypts DKIM private keys, TOTP secrets and provider tokens at rest.
# LOSING THIS MAKES EVERY STORED SECRET UNRECOVERABLE: back it up separately
# from the database (PLAN.md risk R9).
LAMBDAMAIL_MASTER_KEY=
# --- DNS automation (Cloudflare) --------------------------------------------
# The token needs Zone.DNS:Edit plus Zone.Zone:Read on the zone. Leave it empty
# to disable reconciliation; the service then logs that DNS sync is off.
# to manage the records by hand; the service then logs that DNS sync is off.
CLOUDFLARE_API_TOKEN=
DNS_SYNC_INTERVAL_SECONDS=21600
# --- TLS ---
# traefik: read the certificate Traefik manages (default, DANE not possible).
# acme: obtain the certificate ourselves via DNS-01. Required for DANE,
# because only this mode can reuse a pre-generated key.
# --- TLS --------------------------------------------------------------------
# traefik: read the certificate the proxy already manages (default; DANE is not
# possible in this mode).
# acme: obtain the certificate here via DNS-01. Required for DANE, because
# only this mode can reuse a pre-generated key.
TLS_MODE=traefik
# Used only when TLS_MODE=acme. Leave ACME_DIRECTORY_URL empty for Let's
# Encrypt production; point it at the staging directory while testing, since
# production rate limits can lock a domain out for a week.
# Used only when TLS_MODE=acme. Leave ACME_DIRECTORY_URL empty for Let's Encrypt
# production; point it at the staging directory while testing, since production
# rate limits can lock a domain out for a week.
ACME_EMAIL=
ACME_DIRECTORY_URL=
# Where the proxy keeps its certificates, and the Docker network it listens on.
# PRODUCTION (Coolify): COOLIFY_PROXY_DIR=/data/coolify/proxy
COOLIFY_PROXY_DIR=./local/acme
COOLIFY_PROXY_NETWORK=coolify
TRAEFIK_ACME_FILENAME=acme.json
CERT_POLL_INTERVAL_SECONDS=60
# Static certificate files, used when no ACME store is available (local dev).
# Static certificate files, used when no ACME store is available (local dev;
# `make gen-dev-cert` writes them).
PROTOCOLS_CERT_PATH=/etc/lambdamail/certs/cert.pem
PROTOCOLS_KEY_PATH=/etc/lambdamail/certs/key.pem
PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool
# --- Transport security policy ---
# --- Transport security -----------------------------------------------------
# Start at "testing": senders honour the policy but do not hard-fail on it.
# Promote to "enforce" only once TLS-RPT reports come back clean.
# Promote to "enforce" only once TLS-RPT reports come back clean; enforce with a
# broken certificate silently blackholes inbound mail.
MTA_STS_MODE=testing
OUTBOUND_MTA_STS_ENABLED=true
# DANE stays off unless TLS_MODE=acme. Under Traefik-managed certificates the
# key changes at every renewal and the published TLSA record stops matching,
# which permanently rejects mail at every validating MTA.
# DANE stays off unless TLS_MODE=acme. Under proxy-managed certificates the key
# changes at every renewal and the published TLSA record stops matching, which
# permanently rejects mail at every validating MTA (PLAN.md section 5.1).
OUTBOUND_DANE_ENABLED=false
DNSSEC_RESOLVER=1.1.1.1:53
MAX_MESSAGE_BYTES=52428800
# ARC seals accepted messages so a downstream receiver can still tell that the
# mail authenticated at the first hop. Only worth enabling on a host that
# forwards mail onward.
ARC_SEAL_ENABLED=false
# --- Smarthost (for hosts where outbound port 25 is blocked) ---
# Setting RELAY_HOST routes all delivery through the relay and adds it to the
# published SPF record, without which every relayed message fails SPF.
# --- Smarthost (hosts where outbound port 25 is blocked) --------------------
# Most VPS providers block port 25. If `make preflight` reports it blocked, set a
# relay here: all delivery goes through it and it is added to the published SPF,
# without which every relayed message fails SPF.
RELAY_HOST=
RELAY_PORT=587
RELAY_USER=
RELAY_PASS=
# The SPF term that authorises the relay, exactly as the provider documents it.
# It cannot be derived from RELAY_HOST, and getting it wrong is worse than
# leaving it out. Brevo's relay is smtp-relay.brevo.com, but brevo.com publishes
# the SPF of their corporate mail: the sending ranges are at spf.brevo.com, so
# the value there is "include:spf.brevo.com".
RELAY_SPF_INCLUDE=
# --- Webmail ---
WEBMAIL_HOST=localhost:3000
ADMIN_HOST=
# --- Storage ----------------------------------------------------------------
STORAGE_DRIVER=local
S3_ENDPOINT=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
-104
View File
@@ -1,104 +0,0 @@
# LambdaMail - production environment
#
# Copy this into Coolify's environment editor, or to .env on the host, and
# replace every value marked CHANGE. The stack refuses to start without the
# required ones rather than falling back to a default, because a default
# secret in a published repository is not a secret.
#
# Generate each secret with: openssl rand -base64 36
# except POSTGRES_PASSWORD and REDIS_PASSWORD, which go inside connection URIs
# and must be URL-safe - see the note above them.
# --- Container images -------------------------------------------------------
# Published by .gitea/workflows/release.yml for linux/amd64 and linux/arm64.
# Pin IMAGE_TAG to a released version or a sha- tag to roll back.
IMAGE_NAMESPACE=git.lucasrguerra.dev.br/lucasrguerra/lambdamail
IMAGE_TAG=latest
# --- Identity ---------------------------------------------------------------
# MAIL_DOMAIN is the domain you send and receive as.
# PRIMARY_MAIL_HOST is the host the MX record points at; its PTR must match.
MAIL_DOMAIN=example.com # CHANGE
PRIMARY_MAIL_HOST=mail.example.com # CHANGE
WEBMAIL_HOST=mail.example.com # CHANGE - where the web UI is served
ADMIN_HOST= # optional separate host for /admin
PUBLIC_IPV4=203.0.113.10 # CHANGE - the VPS public IPv4
# Leave empty unless IPv6 has a matching PTR and is covered by SPF. Half
# configured IPv6 is a common cause of rejection (PLAN.md premises).
PUBLIC_IPV6=
# --- Secrets ----------------------------------------------------------------
# These two are substituted into connection URIs - postgres://user:PASS@db/... and
# redis://:PASS@redis:6379 - so they must contain no character that means
# something in a URL. Use hex:
#
# openssl rand -hex 24
#
# NOT `openssl rand -base64`, whose alphabet includes "/" and "+". A "/" ends
# the authority component, so the host is parsed as part of the password and
# every connection fails authentication - which surfaces as the migrations
# container exiting 1 while Postgres itself reports healthy.
POSTGRES_DB=lambdamail
POSTGRES_USER=lambdamail
POSTGRES_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24
REDIS_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24
# Signs webmail and admin sessions. Minimum 32 characters; the auth service
# refuses to start below that.
JWT_SECRET=CHANGE-generate-with-openssl-rand-at-least-32-chars
# Encrypts DKIM private keys, TOTP secrets and provider tokens at rest.
# LOSING THIS MAKES EVERY STORED SECRET UNRECOVERABLE - back it up separately
# from the database (PLAN.md risk R9).
LAMBDAMAIL_MASTER_KEY=CHANGE-generate-with-openssl-rand-at-least-32-chars
# --- DNS automation ---------------------------------------------------------
# Token scope: Zone.DNS:Edit plus Zone.Zone:Read on the zone. Leave empty to
# manage the 13 records by hand; the service then logs that sync is off.
CLOUDFLARE_API_TOKEN=
DNS_SYNC_INTERVAL_SECONDS=21600
# --- TLS --------------------------------------------------------------------
# traefik: read the certificate Coolify's proxy already manages (default).
# acme: obtain it here via DNS-01. Required for DANE, and only then.
TLS_MODE=traefik
COOLIFY_PROXY_DIR=/data/coolify/proxy
COOLIFY_PROXY_NETWORK=coolify
TRAEFIK_ACME_FILENAME=acme.json
CERT_POLL_INTERVAL_SECONDS=60
PROTOCOLS_SPOOL_DIR=/var/lambdamail/spool
# Used only when TLS_MODE=acme.
ACME_EMAIL=
ACME_DIRECTORY_URL=
# --- Transport security -----------------------------------------------------
# Start at testing. Promote to enforce only once TLS-RPT reports come back
# clean: enforce with a broken certificate silently blackholes inbound mail.
MTA_STS_MODE=testing
OUTBOUND_MTA_STS_ENABLED=true
# DANE stays off under TLS_MODE=traefik. The key changes at every renewal and
# the published TLSA record stops matching, which permanently rejects mail at
# every validating MTA (PLAN.md section 5.1).
OUTBOUND_DANE_ENABLED=false
DNSSEC_RESOLVER=1.1.1.1:53
MAX_MESSAGE_BYTES=52428800
ARC_SEAL_ENABLED=false
# --- Smarthost --------------------------------------------------------------
# Most VPS providers block outbound port 25. If the preflight reports it
# blocked, set a relay here; it is added to the published SPF automatically.
RELAY_HOST=
RELAY_PORT=587
RELAY_USER=
RELAY_PASS=
# The SPF term that authorises the relay, exactly as your provider documents
# it. Set this: it cannot be derived from RELAY_HOST, and getting it wrong is
# worse than leaving it out. Brevo's relay is smtp-relay.brevo.com, but
# brevo.com publishes the SPF of their corporate mail - the sending ranges are
# at spf.brevo.com, so the value here is "include:spf.brevo.com".
RELAY_SPF_INCLUDE=
# --- Storage ----------------------------------------------------------------
STORAGE_DRIVER=local
S3_ENDPOINT=
S3_BUCKET=
S3_ACCESS_KEY=
S3_SECRET_KEY=
+12 -3
View File
@@ -269,14 +269,23 @@ jobs:
needs: [go-protocols, ts-auth, ts-storage, webmail-build]
steps:
- uses: actions/checkout@v7
- run: cp .env.example .env
- run: scripts/init-env.sh
# The proxy network is declared external because Coolify owns it on the
# deploy host. Nothing creates it here, and without it compose refuses to
# start the stack at all.
#
# Idempotent on purpose: the runner's Docker daemon outlives the job, so
# the network may already exist from an earlier run.
- name: Cria a rede do proxy
run: docker network create "${COOLIFY_PROXY_NETWORK:-coolify}"
run: |
net="${COOLIFY_PROXY_NETWORK:-coolify}"
docker network inspect "$net" >/dev/null 2>&1 || docker network create "$net"
# The build overlay is explicit here: CI has to exercise the code in the
# branch, not the images already published from main.
- run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml --env-file .env up --build --wait
- run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml ps
- run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml down -v
# Always, or a failed `up` leaves containers and volumes behind in the
# daemon the next run starts from.
- name: Derruba a stack
if: always()
run: docker compose -f docker-compose.yaml -f docker-compose.build.yaml down -v
+105
View File
@@ -0,0 +1,105 @@
# Changelog
All notable changes to LambdaMail are listed here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows
[Semantic Versioning](https://semver.org/).
[Português](CHANGELOG.pt-BR.md)
## [Unreleased]
## [1.0.0] - 2026-10-30
The first tagged release. Earlier work was never tagged, so this entry sums up
everything from the first commit on 2026-07-23 to the move to Gitea. Gaps that
remain are listed in the README under
[What is not finished](README.md#what-is-not-finished).
### Mail server
- SMTP MX and Submission (587 STARTTLS, 465 implicit TLS), IMAP with UID, flags,
IDLE, CONDSTORE, `SEARCH`, `EXPUNGE`, `COPY` and `MOVE`, POP3 and ManageSieve.
- SPF, DKIM (Ed25519 and RSA dual signature), DMARC, ARC sealing, MTA-STS and
DANE on delivery, and TLS-RPT report ingestion.
- Rspamd scoring with greylisting, RBLs and rate limiting, and ClamAV.
- A durable outbound queue in Postgres with the retry ladder and RFC 3464
bounces, a smarthost fallback for hosts with port 25 blocked, and a
configurable SPF term for the relay (`RELAY_SPF_INCLUDE`).
- Mail addressed to this server is delivered locally instead of going out to the
internet and back.
- Every submitted message is kept in the sender's Sent folder; app passwords
sign in over SMTP, IMAP and POP3.
- Sieve rules evaluated at delivery, custom folders, and an RFC 5230 vacation
responder that replies in thread and once per sender per week.
### Webmail and console
- A complete redesign of both surfaces (the "Nocturne" interface): dark and light
themes, a command menu on `Ctrl K` / `Cmd K`, `c` and `/` shortcuts, an
installable app with its own icon, and a drawn logo.
- Conversations in cascade, with who wrote, how many, and whether you have
answered; reply, reply all, important mail, undo send, and drafts that replace
the previous autosave.
- A reader that suits both themes: plain messages take the app's colours,
designed ones keep their white sheet, quoted history folds away, remote images
are blocked until allowed.
- Trusted senders, kept on the server per mailbox: their mail skips spam and
always shows images.
- Webmail sessions of 30 days that renew while used, several accounts signed in
at once and switchable from the sidebar; the console stays at 8 hours.
- Profile photos with a circular crop editor, and per-domain BIMI logo hosting
and validation, published to DNS and kept in step with the logo.
- Admin console with server-side paging and filtering, user editing (role,
quota, language, password, second factor), aliases, a diagnostics page, and
isolation between the user and admin surfaces. Domain admins are confined to
their own domain.
- English, Portuguese and Spanish, with a test that every string exists
in all of them.
### DNS and TLS
- Cloudflare reconciliation of the expected records for every active domain,
idempotent, with per-record verification against public resolvers; a DKIM
record is never published with an empty key.
- A TLS panel that states the digest the TLSA record must carry, and documented
steps for turning DANE on. `TLS_MODE=traefik` (default) or `acme`.
### Security
- Two-factor sign-in with TOTP and recovery codes; enrolment can no longer
replace a secret the authenticator already has, enable MFA without recovery
codes, or kill the auth service. `make reset-mfa` recovers a broken enrolment.
- Argon2 concurrency is bounded, so a login flood cannot take the auth service
down.
- All service containers run as non-root; images are scanned with Trivy and the
build fails on HIGH or CRITICAL findings.
- Dependencies updated for two published advisories: `next` 15.5.28
(unauthenticated remote code execution in image optimisation,
GHSA-2464-fhr4-w9xw) and `go-jose` 4.1.6 (denial of service).
### Deployment and CI
- The deploy host pulls images that CI built and tested; nothing is compiled on
the server. The compose file pins Traefik to the proxy network by name.
- **Moved to Gitea.** The source, the workflows (`.gitea/workflows`) and the
container images now live on `git.lucasrguerra.dev.br`; the images are
published for `linux/amd64` and `linux/arm64` under
`git.lucasrguerra.dev.br/lucasrguerra/lambdamail/<service>` and the `v*` tag
creates a release with notes. See [docs/RELEASING.md](docs/RELEASING.md).
- CI caches npm, Go modules, the embedded Postgres, Trivy's database and image
layers.
- One `.env.example` for development and production, and `scripts/init-env.sh`
to create `.env` with random secrets (`make env`). It replaces
`.env.production.example`.
### Fixed
- Webmail healthcheck: Next binds IPv4 only and `wget` resolved `localhost` to
`::1`.
- The admin lockout, a draft that did not discard, folder counters, a vacation
reply that was not a reply, DNS panels that reported published records as
missing, and the reconcile button that created nothing.
- Migrations: a default partition for `email_messages`.
[Unreleased]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/compare/v1.0.0...HEAD
[1.0.0]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/releases/tag/v1.0.0
+106
View File
@@ -0,0 +1,106 @@
# Registro de mudanças
Tudo o que muda de relevante no LambdaMail está aqui. O formato segue o
[Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/) e o projeto segue o
[Versionamento Semântico](https://semver.org/lang/pt-BR/).
[English](CHANGELOG.md)
## [Não lançado]
## [1.0.0] - 2026-10-30
A primeira versão com tag. O trabalho anterior nunca foi tagueado, então esta
entrada resume tudo desde o primeiro commit, em 2026-07-23, até a mudança para o
Gitea. O que ainda falta está no README, em
[O que não está pronto](README.pt-BR.md#o-que-não-está-pronto).
### Servidor de e-mail
- SMTP MX e Submission (587 STARTTLS, 465 TLS implícito), IMAP com UID, flags,
IDLE, CONDSTORE, `SEARCH`, `EXPUNGE`, `COPY` e `MOVE`, POP3 e ManageSieve.
- SPF, DKIM (assinatura dupla Ed25519 e RSA), DMARC, selo ARC, MTA-STS e DANE na
entrega, e ingestão de relatórios TLS-RPT.
- Pontuação do Rspamd com greylisting, RBLs e limite de taxa, e ClamAV.
- Fila de saída durável no Postgres, com a escada de tentativas e devoluções RFC
3464, retransmissão por smarthost para hosts com a porta 25 bloqueada, e termo
SPF do relay configurável (`RELAY_SPF_INCLUDE`).
- O e-mail endereçado a este servidor é entregue localmente, sem sair para a
internet e voltar.
- Toda mensagem enviada fica na pasta Enviados de quem enviou; as senhas de
aplicativo funcionam em SMTP, IMAP e POP3.
- Regras Sieve avaliadas na entrega, pastas próprias e resposta de ausência RFC
5230, que responde dentro da conversa e uma vez por remetente por semana.
### Webmail e console
- Redesenho completo das duas interfaces ("Nocturne"): temas escuro e claro,
menu de comandos em `Ctrl K` / `Cmd K`, atalhos `c` e `/`, app instalável com
ícone próprio e um logo desenhado.
- Conversas em cascata, com quem escreveu, quantas mensagens e se você já
respondeu; responder, responder a todos, importantes, desfazer o envio, e
rascunhos que substituem o salvamento automático anterior.
- Um leitor que combina com os dois temas: mensagens simples usam as cores do
app, as desenhadas mantêm a folha branca, o histórico citado se recolhe e as
imagens remotas ficam bloqueadas até serem liberadas.
- Remetentes confiáveis, guardados no servidor por caixa: o e-mail deles não vai
para o spam e sempre mostra as imagens.
- Sessões do webmail de 30 dias que se renovam com o uso, várias contas abertas
ao mesmo tempo e trocáveis pela barra lateral; o console continua em 8 horas.
- Foto de perfil com editor de recorte circular, e hospedagem e validação do
logotipo BIMI por domínio, publicado no DNS e mantido em dia com o logo.
- Console de administração com paginação e filtros no servidor, edição de
usuários (papel, cota, idioma, senha, segundo fator), apelidos, uma página de
diagnóstico e isolamento entre as superfícies de usuário e de administração.
Administradores de domínio ficam restritos ao próprio domínio.
- Inglês, português e espanhol, com um teste de que toda string existe nos três.
### DNS e TLS
- Reconciliação com a Cloudflare dos registros esperados de cada domínio ativo,
idempotente, com verificação de cada registro em resolvedores públicos; um
registro DKIM nunca é publicado com a chave vazia.
- Um painel de TLS que mostra o digest que o registro TLSA precisa carregar, e
passos documentados para ligar o DANE. `TLS_MODE=traefik` (padrão) ou `acme`.
### Segurança
- Login em duas etapas com TOTP e códigos de recuperação; o cadastro não
substitui mais um segredo que o autenticador já tem, não ativa o MFA sem
códigos de recuperação e não derruba o serviço de autenticação. O
`make reset-mfa` recupera um cadastro quebrado.
- A concorrência do Argon2 é limitada, então uma enxurrada de logins não derruba
o serviço de autenticação.
- Todos os containers de serviço rodam sem root; as imagens passam pelo Trivy e
o build falha em achados HIGH ou CRITICAL.
- Dependências atualizadas por dois avisos publicados: `next` 15.5.28 (execução
remota de código sem autenticação na otimização de imagens,
GHSA-2464-fhr4-w9xw) e `go-jose` 4.1.6 (negação de serviço).
### Implantação e CI
- O servidor de produção só puxa imagens que o CI construiu e testou; nada é
compilado nele. O compose fixa o Traefik na rede do proxy pelo nome.
- **Mudança para o Gitea.** O código, os workflows (`.gitea/workflows`) e as
imagens de container agora ficam em `git.lucasrguerra.dev.br`; as imagens são
publicadas para `linux/amd64` e `linux/arm64` em
`git.lucasrguerra.dev.br/lucasrguerra/lambdamail/<serviço>`, e a tag `v*` cria
uma release com notas. Veja [docs/RELEASING.md](docs/RELEASING.md).
- O CI guarda em cache o npm, os módulos Go, o Postgres embutido, o banco do
Trivy e as camadas das imagens.
- Um único `.env.example` para desenvolvimento e produção, e o
`scripts/init-env.sh` para criar o `.env` com segredos aleatórios
(`make env`). Ele substitui o `.env.production.example`.
### Corrigido
- Healthcheck do webmail: o Next escuta só em IPv4 e o `wget` resolvia
`localhost` para `::1`.
- O bloqueio do administrador, o rascunho que não descartava, os contadores de
pastas, uma resposta de ausência que não era resposta, painéis de DNS que
diziam faltar registros já publicados e o botão de reconciliar que não criava
nada.
- Migrações: partição padrão para `email_messages`.
[Não lançado]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/compare/v1.0.0...HEAD
[1.0.0]: https://git.lucasrguerra.dev.br/lucasrguerra/LambdaMail/releases/tag/v1.0.0
+5 -1
View File
@@ -1,4 +1,8 @@
.PHONY: up down logs test test-db-up test-db-down lint migrate-up migrate-down gen-dev-cert seed preflight create-admin reset-password reset-mfa
.PHONY: env up down logs test test-db-up test-db-down lint migrate-up migrate-down gen-dev-cert seed preflight create-admin reset-password reset-mfa
# .env from .env.example with fresh secrets; refuses to overwrite an existing one.
env:
scripts/init-env.sh
# Local development builds from source; production pulls what CI published.
up:
+8 -6
View File
@@ -2,7 +2,7 @@
<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="LICENSE">License</a></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>
@@ -18,7 +18,7 @@ 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
> **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.
@@ -107,7 +107,7 @@ 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
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='...'
@@ -166,7 +166,7 @@ more thing to keep patched.
## Running locally
```bash
cp .env.example .env
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
@@ -204,8 +204,8 @@ checked in a real browser; see
## Configuration
Every variable is documented inline in
[`.env.production.example`](.env.production.example). The ones without a safe
default:
[`.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 | |
| :--- | :--- |
@@ -383,6 +383,8 @@ Stated plainly, because a mail server that half works loses mail:
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
+8 -5
View File
@@ -2,7 +2,7 @@
<h1 align="center">LambdaMail</h1>
<p align="center">Seu próprio servidor de e-mail, com um webmail que dá gosto de usar.<br>SMTP, IMAP e POP3 · SPF, DKIM, DMARC e ARC · Webmail e console de administração · Um único <code>docker-compose.yaml</code>, feito para o Coolify.</p>
<p align="center"><a href="docs/WEBMAIL.md">Webmail</a> · <a href="docs/DEPLOYMENT.md">Implantação</a> · <a href="docs/OPERATIONS.md">Operação</a> · <a href="docs/MAILBOX-CORE.md">Núcleo da caixa</a> · <a href="LICENSE">Licença</a></p>
<p align="center"><a href="docs/WEBMAIL.md">Webmail</a> · <a href="docs/DEPLOYMENT.md">Implantação</a> · <a href="docs/OPERATIONS.md">Operação</a> · <a href="docs/MAILBOX-CORE.md">Núcleo da caixa</a> · <a href="docs/RELEASING.md">Lançamentos</a> · <a href="CHANGELOG.pt-BR.md">Mudanças</a> · <a href="LICENSE">Licença</a></p>
<p align="center"><a href="README.md">English</a> · <b>Português</b></p>
@@ -18,7 +18,7 @@ SMTP, IMAP, POP3 e ManageSieve, com SPF, DKIM, DMARC, ARC, MTA-STS e TLS-RPT,
automação de DNS pela Cloudflare e filtro de spam e de vírus. Sem planos pagos e
sem limites artificiais de domínios ou caixas.
> **Estado: pré-lançamento.** O caminho do e-mail, a postura de segurança e as
> **Versão 1.0.0** ([mudanças](CHANGELOG.pt-BR.md)). O caminho do e-mail, a postura de segurança e as
> interfaces web funcionam de ponta a ponta, e há uma suíte de testes
> automatizados por trás. Leia [O que não está pronto](#o-que-não-está-pronto)
> antes de colocar e-mail de verdade nele.
@@ -109,7 +109,7 @@ precisa acontecer.
A versão curta:
```bash
cp .env.production.example .env # depois troque cada CHANGE
scripts/init-env.sh --production # .env com segredos aleatórios; lista o que definir
make preflight # confere porta 25, PTR, RBLs, escopo do token
docker compose up -d
make create-admin EMAIL=voce@exemplo.com PASSWORD='...'
@@ -166,7 +166,7 @@ coisa para manter atualizada.
## Rodando localmente
```bash
cp .env.example .env
make env # .env a partir do .env.example, com segredos aleatórios
make gen-dev-cert # certificado autoassinado para os listeners
make up # compila a partir do código pelo overlay de build
make seed # uma caixa postmaster@example.test de teste
@@ -204,7 +204,8 @@ navegador de verdade; veja [docs/WEBMAIL.md](docs/WEBMAIL.md#testing-it).
## Configuração
Cada variável está documentada em linha em
[`.env.production.example`](.env.production.example). As que não têm padrão seguro:
[`.env.example`](.env.example), o arquivo único para desenvolvimento e produção.
As que não têm padrão seguro (o `scripts/init-env.sh` gera os segredos):
| Variável | |
| :--- | :--- |
@@ -385,6 +386,8 @@ e-mail:
- Os testes acompanham a mudança que precisa deles; as suítes acima precisam
continuar verdes.
- Os commits citam a issue: `#N: o que mudou e por quê`.
- Uma mudança que alguém vai notar entra em *Não lançado* no [CHANGELOG.pt-BR.md](CHANGELOG.pt-BR.md)
(e no equivalente em inglês). Como se lança uma versão: [docs/RELEASING.md](docs/RELEASING.md).
## Licença
+23 -10
View File
@@ -70,17 +70,28 @@ Cloudflare token, or you can add them by hand later.
## Step 2 — Create the application in Coolify
1. **New Resource → Docker Compose**, pointed at this repository.
1. **New Resource → Docker Compose**, pointed at this repository. It lives on
Gitea: choose **Private Repository (with Deploy Key)** and use
`git@git.lucasrguerra.dev.br:lucasrguerra/LambdaMail.git` with a key that is
registered in Gitea. An application that was created from a GitHub source
keeps cloning from GitHub until its source is changed in the Coolify UI; the
API cannot change it.
2. Coolify will read `docker-compose.yaml`. Leave the compose file alone; all
configuration is environment.
3. Under **Environment Variables**, paste the contents of
`.env.production.example` and replace every value marked `CHANGE`.
3. Create the environment on your own machine, then paste it into
**Environment Variables**:
Generate each secret separately:
```bash
scripts/init-env.sh --production # writes .env with random secrets
```
```bash
openssl rand -base64 36
```
The script lists every value that is still at a development default
(`MAIL_DOMAIN`, `PRIMARY_MAIL_HOST`, `WEBMAIL_HOST`, `PUBLIC_IPV4`,
`IMAGE_TAG`); set those in `.env`, then paste. Every variable is explained
inline in [`.env.example`](../.env.example).
`POSTGRES_PASSWORD` and `REDIS_PASSWORD` are generated as hex on purpose: they
go inside connection URIs, and the `/` and `+` of base64 break them.
`LAMBDAMAIL_MASTER_KEY` deserves particular care. It encrypts every DKIM
private key, TOTP secret and provider token in the database. **Store a copy
@@ -176,8 +187,9 @@ have propagated and the IP has some sending history.
## Upgrading
Images are published to the Gitea container registry for `linux/amd64` and `linux/arm64` on every merge
to `main`.
Images are published to the Gitea container registry for `linux/amd64` and
`linux/arm64` on every merge to `main`, and again under the version number when
a `v*` tag is pushed (see [RELEASING.md](RELEASING.md)).
```bash
docker compose pull
@@ -189,7 +201,8 @@ Deploys pull the images CI published rather than building on the server;
source instead.
Migrations run automatically on start, before the services that depend on them.
**Pin `IMAGE_TAG` to a `sha-` tag rather than leaving it on `latest`.** Not
**Pin `IMAGE_TAG` to a release (`1.0.0`) or a `sha-` tag rather than leaving it
on `latest`.** Not
only for rollback: `docker compose up` does not re-pull a tag it already has
locally, so a deployment left on `latest` will happily keep running the image
it pulled the first time while reporting success. An immutable tag changes on
+88
View File
@@ -0,0 +1,88 @@
# Releasing LambdaMail
How a version gets from a commit to a server, and what runs where. The history
of what changed is in the [CHANGELOG](../CHANGELOG.md).
## The pipeline
Everything runs on **Gitea Actions** (`.gitea/workflows/`), on runners hosted on
the Oracle server.
| Workflow | Runs on | Does |
| :--- | :--- | :--- |
| `ci.yml` | every push and pull request | language lint, Go and TypeScript suites against a real Postgres, webmail tests, a Trivy scan of the repository and of each image, and the whole stack brought up with compose |
| `release.yml` | a push to `main`, a `v*` tag, or by hand | runs `ci.yml` again, builds the six images for `linux/amd64` and `linux/arm64`, publishes them, and on a tag creates the release |
Nothing is published unless the full suite passes first.
Images go to the Gitea registry, one package per service:
```
git.lucasrguerra.dev.br/lucasrguerra/lambdamail/{protocols,auth,storage,webmail,migrations,rspamd}
```
Tags on every image: `latest`, `sha-<full commit>`, and on a version tag also
`1.2.3`, `1.2` and `1`. The packages are public, so a server pulls them without
logging in.
## Cutting a release
1. Update [CHANGELOG.md](../CHANGELOG.md) and [CHANGELOG.pt-BR.md](../CHANGELOG.pt-BR.md):
move what is under *Unreleased* to a new `## [X.Y.Z] - date` section.
2. Set `"version"` in `services/{auth,storage,webmail}/package.json` and their
lockfiles to the same number.
3. Merge to `main` and wait for the `Release` run of that merge to go green.
4. Tag the merge commit and push the tag:
```bash
git tag -a v1.2.3 -m "LambdaMail 1.2.3"
git push gitea v1.2.3
```
The tag triggers `release.yml` once more. It republishes the images under
`1.2.3`, `1.2` and `1` (layers are cached, so this is quick) and creates the
entry under **Releases** with the image list and the commit log since the
previous tag. A tag with a hyphen (`v1.3.0-rc.1`) is marked as a pre-release.
Versions follow [Semantic Versioning](https://semver.org/): a change to a
migration, a required environment variable, or a published port is at least a
minor release, and anything that needs manual steps on upgrade is called out in
the changelog.
## Deploying a version
Coolify pulls; it does not build. See the *Upgrading* section of
[DEPLOYMENT.md](DEPLOYMENT.md) for the two-step deploy (set `IMAGE_TAG`, then
deploy). In short:
1. `IMAGE_TAG` = the release (`1.2.3`) or `sha-<commit>`. Never `latest`.
2. Deploy. Migrations run before the services that need them.
3. Check `GET /health` on `protocols` and sign in to the webmail.
To roll back, set `IMAGE_TAG` back to the previous release and deploy again.
Migrations are not reverted: write them so the previous version still runs
against the new schema.
## Runners
Two `act_runner` instances run on the Oracle server (Ampere ARM, 4 cores), each
with its own Docker-in-Docker daemon, so two jobs never share a network or a
port. The jobs run unprivileged.
- `linux/arm64` images build natively; `linux/amd64` images build under QEMU
emulation, which is slower. Layers are cached in the registry.
- The Docker daemon of each runner keeps its layer cache between runs, and the
toolcache volume keeps downloaded toolchains.
- The workspace of a job is not visible to the Docker daemon, so workflows copy
files into containers with `docker cp` instead of mounting them with `-v`.
- Windows and macOS jobs cannot run here.
## Why some actions are not used
- `aquasecurity/trivy-action` clones Trivy from the workflow's own server,
which is Gitea; Trivy runs from its official image instead.
- `actions/upload-artifact` v4+ and the `gha` build cache are GitHub services;
per-architecture images are told apart by tag, and the cache lives in the
registry.
- Workflows read `GITHUB_OUTPUT` and `GITHUB_STEP_SUMMARY` under those names:
the `GITEA_*` equivalents are not set by the runner.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Creates .env from .env.example and fills the empty secrets with random values.
#
# scripts/init-env.sh local development
# scripts/init-env.sh --production also lists what is still at a dev default
# scripts/init-env.sh --force overwrite an existing .env
#
# An existing .env is never touched without --force: regenerating
# LAMBDAMAIL_MASTER_KEY makes every stored secret unrecoverable.
set -euo pipefail
cd "$(dirname "$0")/.."
production=0
force=0
for arg in "$@"; do
case "$arg" in
--production) production=1 ;;
--force) force=1 ;;
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown option: $arg" >&2; exit 2 ;;
esac
done
if [ -e .env ] && [ "$force" -ne 1 ]; then
echo ".env already exists; leaving it alone (use --force to overwrite)." >&2
exit 1
fi
command -v openssl >/dev/null || { echo "openssl is required to generate secrets" >&2; exit 1; }
cp .env.example .env
chmod 600 .env
# set_var KEY VALUE: replaces the line KEY=... in .env. The value is hex, so it
# needs no escaping for sed.
set_var() {
sed -i.bak "s|^$1=.*|$1=$2|" .env && rm -f .env.bak
}
set_var POSTGRES_PASSWORD "$(openssl rand -hex 24)"
set_var REDIS_PASSWORD "$(openssl rand -hex 24)"
set_var JWT_SECRET "$(openssl rand -hex 32)"
set_var LAMBDAMAIL_MASTER_KEY "$(openssl rand -hex 32)"
echo ".env written (mode 600) with fresh secrets."
if [ "$production" -eq 1 ]; then
set_var COOLIFY_PROXY_DIR /data/coolify/proxy
echo
echo "Production: still at their development defaults, set these in .env:"
pending=0
check() {
if grep -qE "^$1=$2\$" .env; then echo " - $1 (now $2)"; pending=1; fi
}
check MAIL_DOMAIN localhost
check PRIMARY_MAIL_HOST mail.localhost
check WEBMAIL_HOST 'localhost:3000'
check PUBLIC_IPV4 127.0.0.1
check IMAGE_TAG latest
[ "$pending" -eq 0 ] && echo " (nothing)"
echo
echo "Back up LAMBDAMAIL_MASTER_KEY separately from the database."
echo "Next: make preflight, then docker compose up -d (docs/DEPLOYMENT.md)."
fi
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@lambdamail/auth",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lambdamail/auth",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"hash-wasm": "^4.12.0",
"ioredis": "^5.4.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lambdamail/auth",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@lambdamail/storage",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lambdamail/storage",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"ioredis": "^5.4.1",
"pg": "^8.12.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lambdamail/storage",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@lambdamail/webmail",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@lambdamail/webmail",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"clsx": "^2.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lambdamail/webmail",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",