Your own mail server, with a webmail that is a pleasure to use. SMTP, IMAP and POP3 · SPF, DKIM, DMARC and ARC · Webmail and admin console · One docker-compose.yaml, made for Coolify.
-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`.
+
The real interface running on an invented mailbox: names, addresses and messages are made up.
+
+---
+
+LambdaMail is 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
@@ -12,8 +23,6 @@ paid tiers and no artificial limits on domains or mailboxes.
> See [What is not finished](#what-is-not-finished) before putting real mail
> through it.
----
-
## What it does
| Area | |
@@ -84,6 +93,11 @@ The reference is **[docs/WEBMAIL.md](docs/WEBMAIL.md)**. The short version:
| `c` | New message |
| `/` | Search the list |
+
+
The light theme, and a designed message (a newsletter, a receipt) that keeps the sheet it was drawn for. A message written plainly takes the app's own colours instead.
+
+
The command menu, on Ctrl K or Cmd K.
+
## Deploying
For a real VPS, follow **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)**. It covers the
@@ -161,6 +175,10 @@ 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.
+To see or work on the interface without any mail services, run the invented
+mailbox in [`scripts/screenshots/`](scripts/screenshots/README.md) and point the
+webmail at it; the same stub produced the pictures above.
+
## Testing
```bash
diff --git a/README.pt-BR.md b/README.pt-BR.md
new file mode 100644
index 0000000..6500717
--- /dev/null
+++ b/README.pt-BR.md
@@ -0,0 +1,391 @@
+
+
LambdaMail
+
Seu próprio servidor de e-mail, com um webmail que dá gosto de usar. SMTP, IMAP e POP3 · SPF, DKIM, DMARC e ARC · Webmail e console de administração · Um único docker-compose.yaml, feito para o Coolify.
A interface real rodando numa caixa inventada: nomes, endereços e mensagens são fictícios.
+
+---
+
+O LambdaMail é um servidor de e-mail próprio, com webmail e console de
+administração integrados, feito para rodar no [Coolify](https://coolify.io) a
+partir de um único `docker-compose.yaml`.
+
+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
+> 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.
+
+## O que ele faz
+
+| Área | |
+| :--- | :--- |
+| **Protocolos** | SMTP MX (25), Submission (587 STARTTLS, 465 TLS implícito), IMAP (143/993) com UID, flags, IDLE e CONDSTORE, POP3 (110/995), ManageSieve (4190) |
+| **Autenticação** | SPF, DKIM (assinatura dupla Ed25519 + RSA), DMARC, selo ARC, DANE/TLSA e MTA-STS na entrega, ingestão de TLS-RPT |
+| **Filtragem** | Pontuação do Rspamd com greylisting, RBLs e limite de taxa; ClamAV; Sieve |
+| **Entrega** | Fila de saída durável no Postgres, com a escada de tentativas e os retornos da RFC 3464; relay (smarthost) para hospedagens com a porta 25 bloqueada |
+| **DNS** | Reconciliação na Cloudflare dos registros esperados de cada domínio ativo, idempotente, com verificação de cada registro em resolvedores públicos |
+| **Organização** | Regras Sieve criadas na interface, pastas próprias e um respondedor de férias (RFC 5230) que responde dentro da conversa e uma vez por remetente por semana |
+| **Identidade** | Foto de perfil por usuário, com editor de recorte circular, exibida no webmail; hospedagem e validação do logo BIMI, a marca que os clientes externos exibem |
+| **Web** | Webmail em `/user/*` e console de administração em `/admin/*`, isolados pela audiência da sessão, com dois fatores TOTP, códigos de recuperação, senhas de aplicativo, em inglês, português e espanhol |
+| **Sessões** | As sessões do webmail duram 30 dias e se renovam enquanto são usadas; o console continua em 8 horas. Várias contas podem estar abertas ao mesmo tempo e se alternam pela barra lateral, sem sair |
+| **Remetentes confiáveis** | Confiar num remetente tira o e-mail dele do spam e mostra sempre as imagens, guardado no servidor por caixa. "Não é spam" na pasta Spam faz as duas coisas num clique. Um From forjado não é resgatado: a entrega exige DMARC, ou SPF do domínio do From |
+| **Conversas** | O e-mail é listado em uma linha por conversa, com quem escreveu, quantas mensagens e se você já respondeu. As mensagens se empilham no leitor e a resposta é escrita embaixo delas. Uma chave desliga |
+| **Leitura** | Uma mensagem escrita à mão usa as cores do próprio app; uma desenhada (newsletter, recibo) mantém a folha branca. O histórico citado se recolhe, imagens remotas ficam bloqueadas até você permitir, e dá para ver uma mensagem simples como foi enviada |
+| **Aparência** | Temas escuro (padrão) e claro, menu de comandos em `Ctrl K` / `Cmd K`, atalhos `c` e `/`, e um aplicativo instalável com ícone próprio. As fontes são servidas pelo próprio app |
+| **Console** | Paginação e filtros no servidor, edição de usuário (papel, cota, idioma, senha, segundo fator), aliases por usuário e uma página de diagnóstico em `/admin/tests` |
+
+## Arquitetura
+
+Dois ambientes de execução, divididos pelo que cada um faz melhor:
+
+- **`protocols`** (Go) — todos os listeners de protocolo, o worker de entrega, o
+ tratamento dos certificados TLS, a reconciliação de DNS e a API de e-mail do
+ webmail (mensagens, conversas, rascunhos). É dono da semântica de pastas, UIDs,
+ flags e blobs, e decide, na entrega, a qual conversa uma mensagem pertence e
+ se o e-mail de um remetente confiável pula o spam.
+- **`auth`** (TypeScript) — logins, sessões e as contas abertas num navegador,
+ dois fatores, remetentes confiáveis, preferências e a API do console de
+ administração.
+- **`webmail`** (Next.js) — as duas superfícies web e o proxy que dá ao navegador
+ uma origem única.
+- **`storage`** (TypeScript) — só a verificação de saúde, por enquanto.
+
+O Postgres é a fonte da verdade de tudo, inclusive da fila de saída. O Redis é
+cache e índice de despacho; perdê-lo não perde nenhum e-mail.
+
+```
+ ┌──────────┐
+ navegador ───────▶│ webmail │──┬─▶ auth (sessões, contas, remetentes confiáveis, admin)
+ └──────────┘ └─▶ protocols (ler/enviar e-mail, conversas, DKIM, TLS)
+ │
+ internet ──25/587/465/143/993──────────┘
+ │
+ Postgres ─┴─ Redis ─ Rspamd ─ ClamAV
+```
+
+## Usando o webmail
+
+A referência é o **[docs/WEBMAIL.md](docs/WEBMAIL.md)** (em inglês). Em resumo:
+
+- **Continue conectado.** Uma sessão dura 30 dias e se renova a cada dia de uso.
+ Adicione outra conta pelo menu no pé da barra lateral e alterne entre elas;
+ sair deixa as outras abertas.
+- **Confie num remetente.** O escudo ao lado do nome do remetente confia nele: o
+ e-mail dele deixa de ir para o spam e as imagens carregam. Na pasta Spam,
+ **Não é spam** move a mensagem para a caixa de entrada e confia no remetente.
+- **Leia em conversas.** Uma linha por conversa; abra e responda embaixo das
+ mensagens. Em Configurações → Idioma dá para desligar.
+- **Aperte `Ctrl K` (ou `Cmd K`).** Pastas, Escrever, Configurações, contas e
+ aparência, digitando. `c` escreve uma mensagem e `/` busca.
+- **Escolha um tema** (escuro, claro ou do sistema) no mesmo menu do pé da barra
+ lateral. O escuro é o padrão.
+
+| Tecla | |
+| :--- | :--- |
+| `Ctrl K` / `Cmd K` | Menu de comandos |
+| `c` | Nova mensagem |
+| `/` | Buscar na lista |
+
+
+
O tema claro, e uma mensagem desenhada (uma newsletter, um recibo) que mantém a folha para a qual foi feita. Uma mensagem escrita à mão usa as cores do próprio app.
+
+
O menu de comandos, em Ctrl K ou Cmd K.
+
+## Implantação
+
+Para uma VPS de verdade, siga o **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)** (em
+inglês). Ele cobre as três coisas que nenhum código faz por você — DNS reverso,
+a delegação DNSSEC e conseguir a porta 25 liberada — e a ordem em que o resto
+precisa acontecer.
+
+A versão curta:
+
+```bash
+cp .env.production.example .env # depois troque cada CHANGE
+make preflight # confere porta 25, PTR, RBLs, escopo do token
+docker compose up -d
+make create-admin EMAIL=voce@exemplo.com PASSWORD='...'
+```
+
+`make create-admin` não é opcional: uma instalação nova não tem nenhuma caixa, e
+todo caminho para o console precisa de uma.
+
+### Portas
+
+Tudo abaixo é TCP. "Entrada" é o que o firewall do host e o grupo de segurança do
+provedor precisam permitir; "saída" é o que o host precisa conseguir alcançar.
+
+| Porta | Direção | Quem conecta | Obrigatória? | Observações |
+| ---: | :--- | :--- | :--- | :--- |
+| 25 | entrada | Qualquer MTA remetente | **Sim**, para receber | Precisa estar aberta ao mundo. Tem limite de taxa e não é autenticada — é assim que o SMTP funciona. |
+| 25 | **saída** | Este host → MTAs remotos | **Sim**, para enviar | A que a maioria dos provedores bloqueia. Sem ela, configure um relay (`RELAY_HOST`); veja [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). |
+| 80 | entrada | Let's Encrypt, navegadores | **Sim** | Desafio ACME HTTP-01 e o redirecionamento para HTTPS. Fechá-la significa não ter certificado. |
+| 443 | entrada | Navegadores | **Sim** | Webmail, console, política MTA-STS, autodiscover. |
+| 587 | entrada | Clientes de e-mail | Recomendada | Submission com STARTTLS. A porta que os clientes devem usar. |
+| 465 | entrada | Clientes de e-mail | Opcional | Submission com TLS implícito, para clientes que insistem. |
+| 143 | entrada | Clientes de e-mail | Opcional | IMAP com STARTTLS. Prefira a 993. |
+| 993 | entrada | Clientes de e-mail | Recomendada | IMAPS. |
+| 110 / 995 | entrada | Clientes de e-mail | Opcional | POP3 / POP3S. Deixe fechada, a menos que algo precise de POP3. |
+| 4190 | entrada | Clientes de e-mail | Opcional | ManageSieve, para editar filtros num cliente de desktop. |
+| 53 | saída | Este host → resolvedor | **Sim** | Consultas de MX, SPF, DKIM, DANE e MTA-STS. UDP **e** TCP — respostas DNSSEC estouram o UDP. |
+| 443 | saída | Este host → APIs | Se usado | Automação de DNS da Cloudflare, busca da política MTA-STS, atualização de assinaturas do ClamAV. |
+
+As portas 110, 995 e 4190 são publicadas pelo compose, mas vale fechá-las no
+firewall a menos que algum cliente precise delas — cada porta aberta é mais uma
+coisa para manter atualizada.
+
+### O que o host precisa permitir
+
+- **DNS reverso (PTR)** do IPv4 público resolvendo para `PRIMARY_MAIL_HOST`, e
+ esse nome resolvendo de volta para o mesmo endereço. Quem configura é o dono do
+ bloco de IPs — o painel do seu provedor, nunca a Cloudflare. Gmail e Outlook
+ mandam para o spam ou recusam e-mail sem isso. Se o host tem IPv6 público e
+ publica um registro AAAA, ele também precisa de PTR: remetentes que conectam
+ por IPv6 são julgados pelo reverso do IPv6.
+- **Porta 25 de saída liberada.** Peça ao provedor; um timeout, e não uma recusa,
+ ao conectar num MX remoto é a assinatura de um bloqueio.
+- **Docker** com o plugin compose, e um usuário no grupo `docker` ou root.
+- **Portas abaixo de 1024** são abertas pelo proxy do Docker no host, então o
+ daemon precisa do privilégio — o padrão numa instalação normal. Os contêineres
+ em si rodam sem privilégios.
+- **O diretório de certificados do proxy**, só com `TLS_MODE=traefik` — veja
+ [Certificados](#certificados) abaixo, e prefira `TLS_MODE=acme` se puder.
+- **Disco** para o spool de e-mail (`protocols_spool`) e o Postgres. O e-mail é
+ guardado uma vez, com contagem de referências, mas planeje o crescimento.
+- **Um firewall que não limite a porta 25 até a inutilidade.** Alguns provedores
+ aplicam limites de SYN agressivos que parecem falhas de entrega intermitentes.
+
+## Rodando localmente
+
+```bash
+cp .env.example .env
+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
+```
+
+O webmail fica em `http://localhost:3000`. A senha do seed é `dev-password-only`
+— é um dado de teste, não uma credencial.
+
+Para ver ou trabalhar na interface sem nenhum serviço de e-mail, rode a caixa
+inventada de [`scripts/screenshots/`](scripts/screenshots/README.md) e aponte o
+webmail para ela; o mesmo servidor de demonstração gerou as imagens acima.
+
+## Testes
+
+```bash
+make test-db-up # um Postgres descartável na :55432; imprime as duas variáveis a exportar
+make test # suíte do Go, do auth e do webmail
+make lint # go vet, tsc e o lint de idioma
+```
+
+As suítes do Go e do auth incluem testes de integração que precisam de Postgres.
+Eles são pulados sem `TEST_DATABASE_URL` e rodam contra um banco de verdade no
+CI, que é onde as regressões de segurança são garantidas — senha errada, código
+TOTP errado, revogação de sessão, isolamento entre superfícies, leitura entre
+caixas e, agora, sessões que se renovam, contas que se alternam, remetentes
+confiáveis e a qual conversa uma resposta pertence. Uma execução verde **sem** a
+variável pula tudo isso, por isso o `make test` avisa em voz alta; rode com o
+banco antes de confiar nela.
+
+A suíte do webmail é de arquivos e lógica (`vitest`): ela protege o sistema de
+cores, o sanitizador, os modos do leitor, o desfazer e o descartar do editor, e
+que toda frase exista nos três idiomas. As telas em si são conferidas num
+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:
+
+| Variável | |
+| :--- | :--- |
+| `JWT_SECRET` | Assina as sessões. No mínimo 32 caracteres; o serviço se recusa a iniciar abaixo disso, em vez de cair numa constante. |
+| `LAMBDAMAIL_MASTER_KEY` | Cifra as chaves DKIM, os segredos TOTP e os tokens de provedores. **Faça backup dela separado do banco** — perdê-la torna irrecuperável todo segredo guardado. |
+| `POSTGRES_PASSWORD`, `REDIS_PASSWORD` | Sem padrão; o compose falha sem elas. |
+| `MAIL_DOMAIN`, `PRIMARY_MAIL_HOST`, `PUBLIC_IPV4` | Identidade. `PRIMARY_MAIL_HOST` precisa bater com o PTR de `PUBLIC_IPV4`. |
+
+### Certificados
+
+Dois modos, e a escolha importa mais do que parece.
+
+**`TLS_MODE=acme` (recomendado).** O LambdaMail obtém o próprio certificado para
+`PRIMARY_MAIL_HOST`, `mta-sts.` e `autoconfig.` por DNS-01,
+usando o token da Cloudflare já configurado para o DNS. Precisa de
+`LAMBDAMAIL_MASTER_KEY` para selar as chaves da conta e do certificado, e de
+`ACME_EMAIL`. É o único modo em que o DANE funciona, porque é o único em que a
+chave do próximo certificado existe antes do próprio certificado.
+
+**`TLS_MODE=traefik`.** Os listeners de e-mail leem o certificado que o proxy
+reverso já obteve, em `COOLIFY_PROXY_DIR`, montado só para leitura. Duas coisas
+precisam ser verdade, e nenhuma é óbvia quando não são:
+
+- o proxy precisa ter uma rota para `PRIMARY_MAIL_HOST`, ou esse nome nunca
+ aparece no `acme.json` e não existe certificado para ler;
+- o `acme.json` precisa ser legível pelo usuário não root do contêiner. Deixado
+ no modo `600` de propriedade do root — como o proxy o grava — ele não é.
+
+As duas falhas são silenciosas do mesmo jeito: o HTTPS continua funcionando,
+porque o proxy o serve, enquanto os listeners de e-mail caem para um **certificado
+autoassinado** e todo cliente que verifica recusa SMTP, IMAP e POP3. Parece
+problema de certificado e quase sempre é problema de permissão.
+
+`/admin/security` informa o emissor e diz de forma direta quando o certificado é
+autoassinado, que é o jeito mais rápido de distinguir os dois casos.
+
+### Fotos de perfil e BIMI
+
+São duas coisas diferentes, e a diferença é a história toda.
+
+Uma **foto de perfil** é por usuário e aparece dentro do LambdaMail. Nenhum
+cliente de e-mail externo a lê, e não existe protocolo que permita isso: o avatar
+que o Gmail ou o Outlook mostra ao lado de uma mensagem vem dos contatos do
+*destinatário*, ou da conta Google ou Microsoft do remetente. Um servidor próprio
+não consegue pôr uma imagem ali.
+
+O **BIMI** é o que os clientes externos leem, e é por domínio: uma marca para
+todos que enviam dele. Publicar uma exige três coisas, e o console
+(`/admin/domains`, dentro do domínio) cuida de cada uma:
+
+1. **O logo.** Envie um PNG ou um SVG. O estúdio o converte para o que o BIMI
+ aceita, SVG Tiny Portable/Secure numa tela quadrada de no máximo 32 KB, sem
+ nada ativo ou externo dentro, e deixa você posicioná-lo como no editor da foto
+ de perfil: arrastar, ampliar, cor de fundo, com uma prévia dentro do círculo
+ ou do quadrado arredondado que os clientes desenham. De um SVG é removido o que
+ o formato proíbe (scripts, links, bitmaps embutidos, metadados de editor) e
+ avisa o que foi; um PNG é vetorizado, o que perde algum detalhe, então envie o
+ SVG quando tiver. O servidor valida o resultado de novo contra todas as regras
+ de uma vez, porque um receptor que rejeita a marca simplesmente não mostra nada.
+2. **DMARC em `p=quarantine` ou `p=reject`** com `pct=100`, o que este servidor
+ já configura.
+3. **Um certificado, para o Gmail e o Apple Mail exibirem.** Um Verified Mark
+ Certificate (VMC, para marca registrada) ou um Common Mark Certificate (CMC,
+ para marca em uso) é **emitido por uma autoridade certificadora** (DigiCert,
+ Entrust, SSL.com e outras) depois que ela verifica a organização. Ele não pode
+ ser gerado aqui a partir de uma imagem, e nada assinado aqui seria confiável.
+ O que o console faz: entrega o SVG exato para enviar à autoridade, recebe o
+ `.pem` que ela devolver e recusa o que não funcionaria (o logo dentro dele
+ precisa ser, byte a byte, o publicado; precisa cobrir o domínio, estar na
+ validade e nunca conter uma chave privada). Depois o serve em
+ `https:///.well-known/bimi/vmc.pem` e aponta a tag `a=` para
+ lá. Sem certificado o registro continua válido e alguns clientes o usam; o
+ Gmail e o Apple Mail, não.
+
+"Verificar configuração" no painel olha o que um receptor olharia: o arquivo do
+logo, o DMARC, o registro BIMI no DNS, a URL pública do logo, o certificado e a
+URL pública dele, e aponta o que está errado.
+
+O logo é servido em `https:///.well-known/bimi//default.svg`
+e o certificado em `...//vmc.pem`: um host de e-mail, um caminho por
+domínio, para que cada domínio hospedado possa publicar o seu. (O antigo
+`/.well-known/bimi/default.svg` escolhia o domínio pelo nome do host, o que só
+conseguia nomear o domínio principal; o registro de todo outro domínio apontava
+para um 404. Ele ainda responde, para os registros já publicados.)
+
+**O registro de DNS acompanha a imagem.** Com um token da Cloudflare configurado,
+o registro TXT `default._bimi.` faz parte do que o reconciliador de DNS
+publica, e salvar ou remover o logo ou o certificado no console dispara essa
+reconciliação sozinho ("Publicar no DNS agora" faz o mesmo sob demanda). O
+registro é criado quando há um logo, atualizado quando o certificado é
+adicionado e removido quando o logo é removido, mas só um registro que este
+reconciliador publicou: um feito à mão é deixado em paz.
+
+Um certificado guardado só é anunciado (`a=`) enquanto for válido para o logo
+servido. O certificado atesta um SVG exato, então trocar o logo o encerra, e um
+registro que continuasse apontando para ele faria todo receptor rejeitar o BIMI,
+inclusive os que mostrariam o logo sem certificado. O registro então volta a só
+`l=`, e o console diz por que o certificado foi retido. O painel, a verificação e
+o reconciliador tomam essa decisão da mesma função (`bimi.Publish`), então não
+podem discordar sobre ela.
+
+Uma foto de perfil alterada aparece na hora em todo o webmail: cada foto carrega
+uma versão que é incrementada ao salvar, nesta aba e nas outras. O servidor deixa
+o navegador reaproveitar uma por um minuto e depois revalida.
+
+### DANE
+
+Desligado por padrão, e vale entender antes de ligar: um registro TLSA que não
+bate com a chave em uso não degrada a entrega, ele a **interrompe** em todo
+remetente que valida. Duas coisas precisam ser verdade primeiro.
+
+- **DNSSEC na zona, com o registro DS publicado no registrador.** O DANE confia
+ no registro TLSA porque o DNSSEC o assina; sem a cadeia não há o que confiar, e
+ os validadores tratam o registro como ausente.
+- **`TLS_MODE=acme`.** O LambdaMail precisa ser dono da chave para publicar a
+ associação da *próxima* antes de trocar para ela. Com `TLS_MODE=traefik` o
+ proxy renova no próprio ritmo e o registro deixaria de bater em silêncio.
+
+Então defina `OUTBOUND_DANE_ENABLED=true`. O gerenciador publica `3 1 1` —
+DANE-EE sobre o SubjectPublicKeyInfo, o único uso que um MTA receptor deveria
+oferecer — e cuida da troca publicando a associação atual e a próxima juntas,
+esperando a propagação antes de emitir contra a chave nova.
+
+`/admin/security` mostra o digest do certificado que está de fato em uso. Esse
+número e o registro TLSA no DNS precisam ser iguais; quando divergem, o e-mail é
+recusado e mais nada informa o motivo:
+
+```bash
+dig +short TLSA _25._tcp.mail.exemplo.com
+```
+
+## Operação
+
+| | |
+| :--- | :--- |
+| `make preflight` | Saída pela porta 25, PTR e FCrDNS, listagens em RBLs, escopo do token da Cloudflare, presença do certificado |
+| `make create-admin EMAIL=… PASSWORD='…'` | Primeiro administrador numa instalação nova |
+| `make reset-password EMAIL=… PASSWORD='…'` | Recupera uma conta bloqueada e encerra as sessões dela |
+| `docker compose logs -f protocols` | Entrega, TLS e reconciliação de DNS |
+| `GET /health` no `protocols` | `200` saudável, `200` com `X-LambdaMail-Health: degraded` quando um certificado precisa de atenção, `503` quando uma dependência caiu |
+
+O [docs/OPERATIONS.md](docs/OPERATIONS.md) cobre backups, rotação de
+certificados, o que significam os alertas e como ler uma falha de entrega.
+
+## O que não está pronto
+
+Dito com franqueza, porque um servidor de e-mail que funciona pela metade perde
+e-mail:
+
+- **Painéis do console somente de leitura.** O checklist guiado de entrada de
+ domínio relata o estado em vez de conduzi-lo.
+- **Anexos do editor.** O seletor lista os arquivos, mas nada é enviado, e o app
+ avisa isso na tela. Ler e baixar os anexos recebidos funciona.
+- **Aprendizado do filtro de spam.** "Não é spam" para de condenar aquele
+ remetente, mas o Rspamd não aprende com isso, então e-mails parecidos de
+ desconhecidos são julgados como antes.
+- **As conversas são agrupadas na entrega.** E-mails antigos são agrupados por
+ assunto, e só onde existe uma resposta; uma conversa cujas respostas perderam o
+ `References` e o assunto pode aparecer em linhas separadas.
+- **Não implementado.** IMAP `APPEND`, JMAP, CalDAV/CardDAV, multi-nó. Os backups
+ estão documentados, mas não automatizados.
+- **DANE** vem desligado. Veja [DANE](#dane) para ligá-lo; com
+ `TLS_MODE=traefik` ele fica desligado de propósito, porque a chave do proxy muda
+ a cada renovação e o registro TLSA publicado deixa de bater.
+
+## Contribuindo
+
+- Todo o código, os comentários e os identificadores são em inglês. O
+ `make lint` garante isso (e, no código, só ASCII: escreva `é`, não uma
+ letra acentuada).
+- As cores vêm das variáveis do tema em `globals.css`, nunca de um valor literal,
+ e uma nova é definida nos dois temas; veja
+ [docs/WEBMAIL.md](docs/WEBMAIL.md#the-colour-system). O logo é gerado por
+ [`scripts/brand/`](scripts/brand/README.md).
+- Migrations novas são numeradas em `migrations/` e rodam no deploy. O código de
+ entrega não pode depender de uma que talvez não tenha rodado: os testes de ponta
+ a ponta aplicam só a primeira.
+- 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ê`.
+
+## Licença
+
+MIT — veja [LICENSE](LICENSE).
diff --git a/docs/img/designed-light-en.png b/docs/img/designed-light-en.png
new file mode 100644
index 0000000..525b279
Binary files /dev/null and b/docs/img/designed-light-en.png differ
diff --git a/docs/img/designed-light-pt.png b/docs/img/designed-light-pt.png
new file mode 100644
index 0000000..cec85ef
Binary files /dev/null and b/docs/img/designed-light-pt.png differ
diff --git a/docs/img/lambdamail-logo.svg b/docs/img/lambdamail-logo.svg
new file mode 100644
index 0000000..7b2fa2c
--- /dev/null
+++ b/docs/img/lambdamail-logo.svg
@@ -0,0 +1,5 @@
+
diff --git a/docs/img/menu-dark-en.png b/docs/img/menu-dark-en.png
new file mode 100644
index 0000000..9ce7cae
Binary files /dev/null and b/docs/img/menu-dark-en.png differ
diff --git a/docs/img/menu-dark-pt.png b/docs/img/menu-dark-pt.png
new file mode 100644
index 0000000..7e9479b
Binary files /dev/null and b/docs/img/menu-dark-pt.png differ
diff --git a/docs/img/webmail-dark-en.png b/docs/img/webmail-dark-en.png
new file mode 100644
index 0000000..ad883be
Binary files /dev/null and b/docs/img/webmail-dark-en.png differ
diff --git a/docs/img/webmail-dark-pt.png b/docs/img/webmail-dark-pt.png
new file mode 100644
index 0000000..41fbdf2
Binary files /dev/null and b/docs/img/webmail-dark-pt.png differ
diff --git a/docs/img/webmail-light-en.png b/docs/img/webmail-light-en.png
new file mode 100644
index 0000000..0c60d6a
Binary files /dev/null and b/docs/img/webmail-light-en.png differ
diff --git a/docs/img/webmail-light-pt.png b/docs/img/webmail-light-pt.png
new file mode 100644
index 0000000..24ae2a1
Binary files /dev/null and b/docs/img/webmail-light-pt.png differ
diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md
new file mode 100644
index 0000000..cf8487b
--- /dev/null
+++ b/scripts/screenshots/README.md
@@ -0,0 +1,25 @@
+# Photographing the interface
+
+`stub-server.mjs` stands in for the auth and protocols services and serves a
+small mailbox that is entirely invented (names, addresses on reserved example
+domains, messages). It exists so the webmail can be run, worked on and
+photographed without a mail server or anyone's real mail.
+
+ node scripts/screenshots/stub-server.mjs # listens on :4001
+ cd services/webmail
+ AUTH_SERVICE_URL=http://127.0.0.1:4001 \
+ PROTOCOLS_SERVICE_URL=http://127.0.0.1:4001 npx next dev -p 3100
+
+Open `http://localhost:3100/user/mail/inbox` with a cookie named
+`lm_user_session` holding any token with the claims the middleware reads (the
+capture script builds one).
+
+`capture.mjs` writes the pictures the README uses into `docs/img/`, in English
+and Portuguese, dark and light:
+
+ cd scripts/screenshots && npm install --no-save puppeteer-core
+ CHROME_PATH=/path/to/chrome node capture.mjs
+
+It needs a Chrome, and the webmail running as above. The stub keeps its state in
+memory (opening a message marks it read); the script resets it before every
+picture through `/__reset`, so each one starts from the same mailbox.
diff --git a/scripts/screenshots/capture.mjs b/scripts/screenshots/capture.mjs
new file mode 100644
index 0000000..e6ee8c9
--- /dev/null
+++ b/scripts/screenshots/capture.mjs
@@ -0,0 +1,66 @@
+// Photographs the webmail running against the invented mailbox of
+// stub-server.mjs, for the README. Needs puppeteer-core and a Chrome:
+//
+// CHROME_PATH=/usr/bin/google-chrome node scripts/screenshots/capture.mjs
+//
+// BASE is where the webmail is listening (default http://localhost:3100).
+import puppeteer from "puppeteer-core";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const out = path.join(here, "..", "..", "docs", "img");
+const base = process.env.BASE ?? "http://localhost:3100";
+const stub = process.env.STUB ?? "http://127.0.0.1:4001";
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// The webmail only checks that a session cookie looks like one; the stub
+// accepts anything, so a token with the right claims is enough.
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
+const session = `${b64({ alg: "HS256" })}.${b64({ aud: "lambdamail:user", purpose: "session", surface: "user", exp: Math.floor(Date.now() / 1000) + 3600 })}.stub`;
+
+const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH, headless: true, args: ["--no-sandbox"] });
+
+async function shoot({ name, theme, locale, width = 1440, height = 900, steps }) {
+ // Every picture starts from the mailbox as it began: opening a message marks it read.
+ await fetch(`${stub}/__reset`);
+ const page = await browser.newPage();
+ await page.setViewport({ width, height, deviceScaleFactor: 1 });
+ const host = new URL(base).hostname;
+ await page.setCookie(
+ { name: "lm_user_session", value: session, domain: host, path: "/" },
+ { name: "lm_locale", value: locale, domain: host, path: "/" },
+ );
+ await page.evaluateOnNewDocument((t) => localStorage.setItem("lm_theme", t), theme);
+ await page.goto(`${base}/user/mail/inbox`, { waitUntil: "domcontentloaded", timeout: 120000 });
+ await page.waitForSelector("button.lm-row", { timeout: 120000 });
+ // Next's development badge is not part of the product.
+ await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" });
+ await sleep(1500);
+ const open = async (re) => {
+ for (const row of await page.$$("button.lm-row")) {
+ if (re.test(await row.evaluate((e) => e.textContent))) { await row.click(); return; }
+ }
+ throw new Error(`no row matches ${re}`);
+ };
+ await steps(page, open);
+ await page.screenshot({ path: path.join(out, `${name}.png`) });
+ await page.close();
+ console.log("wrote", name);
+}
+
+const launch = async (page, open) => { await open(/Launch checklist/); await sleep(3500); };
+const digest = async (page, open) => { await open(/weekly digest/); await sleep(3500); };
+const menu = async (page, open) => {
+ await open(/Launch checklist/); await sleep(3000);
+ await page.keyboard.down("Control"); await page.keyboard.press("k"); await page.keyboard.up("Control");
+ await sleep(700);
+};
+
+for (const [locale, tag] of [["en", "en"], ["pt-BR", "pt"]]) {
+ await shoot({ name: `webmail-dark-${tag}`, theme: "dark", locale, steps: launch });
+ await shoot({ name: `webmail-light-${tag}`, theme: "light", locale, steps: launch });
+ await shoot({ name: `menu-dark-${tag}`, theme: "dark", locale, steps: menu });
+ await shoot({ name: `designed-light-${tag}`, theme: "light", locale, steps: digest });
+}
+await browser.close();
diff --git a/scripts/screenshots/stub-server.mjs b/scripts/screenshots/stub-server.mjs
new file mode 100644
index 0000000..fb0c897
--- /dev/null
+++ b/scripts/screenshots/stub-server.mjs
@@ -0,0 +1,143 @@
+// A stand-in for the auth and protocols services that serves a small, entirely
+// invented mailbox in the JSON shapes the webmail expects. It exists so the
+// interface can be run and photographed without a mail server or real mail:
+//
+// node scripts/screenshots/stub-server.mjs # listens on :4001
+// cd services/webmail && AUTH_SERVICE_URL=http://127.0.0.1:4001 \
+// PROTOCOLS_SERVICE_URL=http://127.0.0.1:4001 npx next dev -p 3100
+//
+// Nothing here is real: the names, the addresses (all on reserved example
+// domains) and the messages are made up.
+import http from "node:http";
+
+const ME = "mara@lambdamail.example";
+const prefs = { signature: "", auto_save_drafts: true, conversation_view: true };
+const trusted = new Set(["helena@studio.example"]);
+
+const T = {
+ launch: "10000000-0000-4000-8000-000000000001",
+ invoice: "10000000-0000-4000-8000-000000000002",
+ digest: "10000000-0000-4000-8000-000000000003",
+ flight: "10000000-0000-4000-8000-000000000004",
+ review: "10000000-0000-4000-8000-000000000005",
+ contract: "10000000-0000-4000-8000-000000000006",
+ welcome: "10000000-0000-4000-8000-000000000007",
+ standup: "10000000-0000-4000-8000-000000000008",
+};
+
+const digestHtml = `
Morning both. The staging run finished green, so I think we are fine to go ahead on Thursday as planned.
I did notice the backup job takes a little longer than it used to (about 11 minutes now). Not a blocker, but I will keep an eye on it and report back after the launch.
Marcos
${quote("Helena Vargas <helena@studio.example>", "Wed, Oct 21, 2026 at 8:30 AM", "
Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.