diff --git a/README.md b/README.md index fbf331a..beab4cb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,18 @@ -# LambdaMail +

LambdaMail

+

LambdaMail

+

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`. +

Webmail · Deploying · Operating · Mailbox core · License

+ +

English · Português

+ +

The LambdaMail webmail in the dark theme: the inbox as conversations on the left, and the Launch checklist conversation open on the right with the newest message expanded and the older ones as a line each
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 same conversation in the light theme A designed newsletter keeping its own white sheet, in the light theme

+

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 open over a conversation, listing actions, folders and appearance
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

+

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.

+ +

Webmail · Implantação · Operação · Núcleo da caixa · Licença

+ +

English · Português

+ +

O webmail do LambdaMail no tema escuro: a caixa de entrada em conversas à esquerda, e a conversa Launch checklist aberta à direita, com a mensagem mais recente expandida e as anteriores em uma linha cada
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 | + +

A mesma conversa no tema claro Uma newsletter desenhada mantendo a própria folha branca, no tema claro

+

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 aberto sobre uma conversa, listando ações, pastas e aparência
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 = `
+

Field Notes

The weekly digest
+
+

Three short reads for the week, picked by hand.

+
What a slow build taught us about caching

A look at why the fastest fix was deleting code.

+
Small mail servers, big promises

Running your own, a year in.

+
The case for boring dashboards

Fewer charts, better decisions.

+
`; + +const flightHtml = ` +
Booking confirmed

LIS → GRU · Fri 14 Nov, 22:40

Seat 14A · Reference QX7M2P

`; + +const quote = (who, when, text) => + `
On ${when}, ${who} wrote:
${text}
`; + +let uidSeq = 100; +const m = (o) => ({ uid: ++uidSeq, seen: true, answered: false, flagged: false, attach: false, ...o }); +const msgs = [ + m({ folder: "INBOX", thread: T.launch, subject: "Launch checklist", from: "Helena Vargas ", date: "2026-10-20T09:10:00Z", mid: "l1@x", html: "

Hi Mara,

Here is the checklist for Thursday. Could you take the copy review and the release notes? Marcos has the infra items.

Helena

", answered: true }), + m({ folder: "Sent", thread: T.launch, subject: "Re: Launch checklist", from: `Mara Lima <${ME}>`, date: "2026-10-20T11:42:00Z", mid: "l2@x", own: true, html: "

On it. I will send the notes by Wednesday noon.

" }), + m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Helena Vargas ", date: "2026-10-21T08:30:00Z", mid: "l3@x", html: "

Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.

Helena

" }), + m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Marcos Oliveira ", date: "2026-10-22T07:55:00Z", mid: "l4@x", seen: false, + html: `

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.

Helena

")}` }), + m({ folder: "INBOX", thread: T.invoice, subject: "Invoice 2041 for October", from: "Northwind Billing ", date: "2026-10-22T06:20:00Z", mid: "i1@x", seen: false, attach: true, html: "

Hello Mara,

Your invoice for October is ready. The total is due on 5 November.

Northwind Billing

" }), + m({ folder: "INBOX", thread: T.digest, subject: "Your weekly digest", from: "Field Notes ", date: "2026-10-21T18:00:00Z", mid: "d1@x", html: digestHtml }), + m({ folder: "INBOX", thread: T.flight, subject: "Your booking LIS to GRU is confirmed", from: "Atlas Air ", date: "2026-10-21T13:12:00Z", mid: "f1@x", html: flightHtml }), + m({ folder: "INBOX", thread: T.review, subject: "Design review notes", from: "Priya Nair ", date: "2026-10-20T16:45:00Z", mid: "r1@x", attach: true, flagged: true, html: "

Notes from today's review are attached. The short version: keep the sidebar, simplify the toolbar, and let the reading pane breathe.

Priya

" }), + m({ folder: "INBOX", thread: T.contract, subject: "Contract draft v3", from: "Tomas Reyes ", date: "2026-10-19T14:05:00Z", mid: "c1@x", html: "

Mara, v3 of the contract is in the shared folder with the changes we agreed on. Tell me if anything looks off before Friday.

Tomas

" }), + m({ folder: "INBOX", thread: T.standup, subject: "Standup moves to 10:15", from: "Marcos Oliveira ", date: "2026-10-18T09:00:00Z", mid: "s1@x", html: "

Heads up: from Monday the standup is at 10:15.

" }), + m({ folder: "INBOX", thread: T.welcome, subject: "Welcome to the team workspace", from: "Workspace ", date: "2026-10-15T08:00:00Z", mid: "w1@x", html: "

Welcome aboard. Your workspace is ready.

" }), +]; + +// Opening a message marks it read, so a run can put the mailbox back as it began. +const pristine = structuredClone(msgs); +const reset = () => { msgs.splice(0, msgs.length, ...structuredClone(pristine)); }; + +const key = (x) => `${x.folder.toLowerCase()}:${x.uid}`; +const nameOf = (f) => (f.match(/^"?([^"<]*?)"?\s* (f.match(/<([^>]+)>/)?.[1] || f).toLowerCase(); +const plain = (h) => h.replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); +const thread = (id) => msgs.filter((x) => x.thread === id).sort((a, b) => a.date.localeCompare(b.date)); + +function summary(id, folder) { + const all = thread(id); + const here = all.filter((x) => x.folder.toLowerCase() === folder.toLowerCase()); + if (!here.length) return null; + const latest = here[here.length - 1]; + const incoming = all.filter((x) => !x.own); + const last = incoming[incoming.length - 1]; + const answered = last ? last.answered || all.slice(all.indexOf(last) + 1).some((x) => x.own) : false; + const seen = new Set(); + const participants = []; + for (const x of all) { + const a = addrOf(x.from); + if (seen.has(a) || participants.length === 4) continue; + seen.add(a); + participants.push({ name: nameOf(x.from), address: a, me: Boolean(x.own) }); + } + return { + thread_id: id, uid: latest.uid, subject: latest.subject, sender_address: addrOf(latest.from), from_display_name: nameOf(latest.from), + snippet: plain(latest.html).slice(0, 140), received_at: latest.date, has_attachments: all.some((x) => x.attach), + message_count: all.length, unseen_count: all.filter((x) => !x.seen).length, seen: all.every((x) => x.seen), + flagged: all.some((x) => x.flagged), answered, participants, + }; +} + +const json = (res, code, body) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; +const readBody = (req) => new Promise((r) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => r(b ? JSON.parse(b) : {})); }); + +http.createServer(async (req, res) => { + const url = new URL(req.url, "http://x"); + const p = url.pathname; + const q = url.searchParams; + const body = ["POST", "PUT", "DELETE"].includes(req.method) ? await readBody(req) : {}; + if (p === "/__reset") { reset(); return json(res, 200, { ok: true }); } + if (p === "/api/v1/user/me" || p === "/api/v1/admin/me") return json(res, 200, { id: "u1", email: ME, role: "USER", locale: "en", mfa_enrolled: true, recovery_codes_left: 8 }); + if (p === "/api/v1/user/preferences") { if (req.method === "POST") Object.assign(prefs, body); return json(res, 200, prefs); } + if (p === "/api/v1/user/trusted-senders") { if (req.method === "PUT") trusted.add(body.address); if (req.method === "DELETE") trusted.delete(q.get("address")); return json(res, 200, { senders: [...trusted] }); } + if (p === "/api/v1/auth/accounts") return json(res, 200, { accounts: [{ id: "u1", email: ME, active: true }] }); + if (p === "/api/v1/mail/folders") { + const count = (f) => msgs.filter((x) => x.folder === f).length; + const unread = (f) => msgs.filter((x) => x.folder === f && !x.seen).length; + return json(res, 200, [["INBOX", "inbox"], ["Sent", "sent"], ["Drafts", "drafts"], ["Archive", "archive"], ["Junk", "junk"], ["Trash", "trash"]] + .map(([name, use], i) => ({ id: `f${i}`, name, special_use: use, unread_count: unread(name), total_count: count(name) }))); + } + if (p === "/api/v1/mail/threads") { + const f = q.get("folder") || "inbox"; + const ids = [...new Set(msgs.filter((x) => x.folder.toLowerCase() === f.toLowerCase()).map((x) => x.thread))]; + return json(res, 200, ids.map((id) => summary(id, f)).filter(Boolean).sort((a, b) => b.received_at.localeCompare(a.received_at))); + } + if (p.startsWith("/api/v1/mail/thread/")) { + const all = thread(p.split("/").pop()); + if (!all.length) return json(res, 404, { error: "NOT_FOUND" }); + return json(res, 200, all.map((x, i) => ({ + uid: x.uid, folder: x.folder, subject: x.subject, sender_address: addrOf(x.from), from_display_name: nameOf(x.from), snippet: plain(x.html).slice(0, 100), + received_at: x.date, has_attachments: x.attach, seen: x.seen, flagged: x.flagged, own: Boolean(x.own), answered: !x.own && (x.answered || all.slice(i + 1).some((y) => y.own)), + }))); + } + if (p.startsWith("/api/v1/mail/message/")) { + const uid = Number(p.split("/").pop()); + const x = msgs.find((y) => y.uid === uid && y.folder.toLowerCase() === (q.get("folder") || "").toLowerCase()); + if (!x) return json(res, 404, {}); + x.seen = true; + return json(res, 200, { uid, subject: x.subject, from: x.from, to: x.own ? ["helena@studio.example"] : [ME], cc: [], date: x.date, text: plain(x.html), html: x.html, attachments: x.attach ? ["document.pdf"] : [], message_id: `<${x.mid}>`, in_reply_to: "", important: false, inline_images: {} }); + } + if (p === "/api/v1/mail/events") return json(res, 404, {}); + if (p.startsWith("/api/v1/mail/")) return json(res, 200, { ok: true, uid: 1 }); + return json(res, 404, { error: "NOT_FOUND", path: p }); +}).listen(Number(process.env.PORT ?? 4001), () => console.log("demo mailbox on", process.env.PORT ?? 4001)); diff --git a/services/webmail/src/lib/snippet.ts b/services/webmail/src/lib/snippet.ts index b998c5a..bb3deb2 100644 --- a/services/webmail/src/lib/snippet.ts +++ b/services/webmail/src/lib/snippet.ts @@ -16,7 +16,21 @@ const NAMED: Record = { nbsp: " ", }; +/** + * Turns `á`, `—`, `'` and the rest into the characters they + * stand for. The browser already knows all of them (HTML defines over two + * thousand), so it is asked rather than given a short list: a list stops at + * `&` and leaves `ç` and `·` printed in a Portuguese or a + * marketing preview. A textarea holds text only, so nothing in it runs. + */ function decodeEntities(text: string): string { + if (!text.includes("&")) return text; + if (typeof document !== "undefined") { + const holder = document.createElement("textarea"); + holder.innerHTML = text; + return holder.value; + } + // No DOM (rendered on the server): the few that matter most. return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body: string) => { if (body[0] === "#") { const code = body[1].toLowerCase() === "x" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); diff --git a/services/webmail/src/snippet.test.ts b/services/webmail/src/snippet.test.ts index 57f2dda..f737f30 100644 --- a/services/webmail/src/snippet.test.ts +++ b/services/webmail/src/snippet.test.ts @@ -26,4 +26,16 @@ describe("plainSnippet", () => { it("keeps an entity it does not know", () => { expect(plainSnippet("a &madeup; b")).toBe("a &madeup; b"); }); + + // The preview of a Portuguese or a marketing message is full of these, and a + // short list of entities printed them as text. + it("decodes any named entity, not only the common few", () => { + expect(plainSnippet("Atualização — número 3 → ok · fim")).toBe("Atualiza\u00e7\u00e3o \u2014 n\u00famero 3 \u2192 ok \u00b7 fim"); + expect(plainSnippet("Café & crème …")).toBe("Caf\u00e9 & cr\u00e8me \u2026"); + }); + + it("does not run markup that an entity decodes into", () => { + expect(plainSnippet("<img src=x onerror=alert(1)> safe")).toBe(" safe"); + expect(document.querySelector("img")).toBeNull(); + }); });