Merge docs/readme-style: a README with a face, and complete entity decoding in previews
CI / Lint de idioma (R9) (push) Successful in 43s
CI / Go (protocols) (push) Failing after 42s
CI / TypeScript (auth) (push) Failing after 42s
CI / TypeScript (storage) (push) Successful in 12s
CI / Build (webmail) (push) Successful in 1m37s
CI / Stack completa (push) Skipped
CI / Imagem (webmail) (push) Canceled after 0s
CI / Imagem (storage) (push) Canceled after 0s
CI / Imagem (rspamd) (push) Canceled after 0s
CI / Imagem (protocols) (push) Canceled after 0s
CI / Imagem (migrations) (push) Canceled after 3m38s
CI / Imagem (auth) (push) Canceled after 5m39s
Release / ci (push) Canceled after 12m51s
Release / Namespace das imagens (push) Canceled after 0s
Release / Build auth (linux/amd64) (push) Canceled after 0s
Release / Build auth (linux/arm64) (push) Canceled after 0s
Release / Build migrations (linux/amd64) (push) Canceled after 0s
Release / Build migrations (linux/arm64) (push) Canceled after 0s
Release / Build protocols (linux/amd64) (push) Canceled after 0s
Release / Build protocols (linux/arm64) (push) Canceled after 0s
Release / Build rspamd (linux/amd64) (push) Canceled after 0s
Release / Build rspamd (linux/arm64) (push) Canceled after 0s
Release / Build storage (linux/amd64) (push) Canceled after 0s
Release / Build storage (linux/arm64) (push) Canceled after 0s
Release / Build webmail (linux/amd64) (push) Canceled after 0s
Release / Build webmail (linux/arm64) (push) Canceled after 0s
Release / Publica auth (push) Canceled after 0s
Release / Publica migrations (push) Canceled after 0s
Release / Publica protocols (push) Canceled after 0s
Release / Publica rspamd (push) Canceled after 0s
Release / Publica storage (push) Canceled after 0s
CI / Trivy (dependências, segredos e Dockerfiles) (push) Failing after 3m16s
Release / Publica webmail (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -1,7 +1,18 @@
|
||||
# LambdaMail
|
||||
<p align="center"><img src="docs/img/lambdamail-logo.svg" width="120" alt="LambdaMail"></p>
|
||||
<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>
|
||||
|
||||
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`.
|
||||
<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"><b>English</b> · <a href="README.pt-BR.md">Português</a></p>
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-dark-en.png" width="900" alt="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"><br><sub>The real interface running on an invented mailbox: names, addresses and messages are made up.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
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 |
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-light-en.png" width="440" alt="The same conversation in the light theme"> <img src="docs/img/designed-light-en.png" width="440" alt="A designed newsletter keeping its own white sheet, in the light theme"></p>
|
||||
<p align="center"><sub>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.</sub></p>
|
||||
|
||||
<p align="center"><img src="docs/img/menu-dark-en.png" width="620" alt="The command menu open over a conversation, listing actions, folders and appearance"><br><sub>The command menu, on <code>Ctrl K</code> or <code>Cmd K</code>.</sub></p>
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<p align="center"><img src="docs/img/lambdamail-logo.svg" width="120" alt="LambdaMail"></p>
|
||||
<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="README.md">English</a> · <b>Português</b></p>
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-dark-pt.png" width="900" alt="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"><br><sub>A interface real rodando numa caixa inventada: nomes, endereços e mensagens são fictícios.</sub></p>
|
||||
|
||||
---
|
||||
|
||||
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 |
|
||||
|
||||
<p align="center"><img src="docs/img/webmail-light-pt.png" width="440" alt="A mesma conversa no tema claro"> <img src="docs/img/designed-light-pt.png" width="440" alt="Uma newsletter desenhada mantendo a própria folha branca, no tema claro"></p>
|
||||
<p align="center"><sub>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.</sub></p>
|
||||
|
||||
<p align="center"><img src="docs/img/menu-dark-pt.png" width="620" alt="O menu de comandos aberto sobre uma conversa, listando ações, pastas e aparência"><br><sub>O menu de comandos, em <code>Ctrl K</code> ou <code>Cmd K</code>.</sub></p>
|
||||
|
||||
## 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.<domínio>` e `autoconfig.<domínio>` 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://<host de e-mail>/.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://<host de e-mail>/.well-known/bimi/<domínio>/default.svg`
|
||||
e o certificado em `.../<domínio>/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.<domínio>` 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).
|
||||
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->
|
||||
<path d="M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z" fill="#1B2BD0"/>
|
||||
<path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 224 KiB |
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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 = `<html><head><style>
|
||||
.wrap{max-width:560px;margin:0 auto;font-family:Georgia,serif;color:#1d1d1f}
|
||||
.band{background:#1B2BD0;color:#fff;padding:26px 30px}
|
||||
.band h1{margin:0;font:700 24px/1.2 Arial,sans-serif}
|
||||
.body{padding:22px 30px;background:#fff}
|
||||
.story{border-top:1px solid #e3e5ea;padding:14px 0}
|
||||
.story b{font:700 16px Arial,sans-serif}
|
||||
</style></head><body><div class="wrap">
|
||||
<div class="band"><h1>Field Notes</h1><div style="opacity:.8;font:13px Arial">The weekly digest</div></div>
|
||||
<div class="body">
|
||||
<p>Three short reads for the week, picked by hand.</p>
|
||||
<div class="story"><b>What a slow build taught us about caching</b><p style="margin:.3em 0 0">A look at why the fastest fix was deleting code.</p></div>
|
||||
<div class="story"><b>Small mail servers, big promises</b><p style="margin:.3em 0 0">Running your own, a year in.</p></div>
|
||||
<div class="story"><b>The case for boring dashboards</b><p style="margin:.3em 0 0">Fewer charts, better decisions.</p></div>
|
||||
</div></div></body></html>`;
|
||||
|
||||
const flightHtml = `<table width="100%" cellpadding="0" cellspacing="0" style="font-family:Arial,sans-serif"><tr><td bgcolor="#0b3d91" style="color:#fff;padding:18px 24px;font-size:18px"><b>Booking confirmed</b></td></tr>
|
||||
<tr><td bgcolor="#f4f6fb" style="padding:20px 24px;color:#1d1d1f"><p style="margin:0 0 6px"><b>LIS → GRU</b> · Fri 14 Nov, 22:40</p><p style="margin:0;color:#555">Seat 14A · Reference <b>QX7M2P</b></p></td></tr></table>`;
|
||||
|
||||
const quote = (who, when, text) =>
|
||||
`<div class="gmail_quote"><div class="gmail_attr">On ${when}, ${who} wrote:</div><blockquote class="gmail_quote">${text}</blockquote></div>`;
|
||||
|
||||
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 <helena@studio.example>", date: "2026-10-20T09:10:00Z", mid: "l1@x", html: "<p>Hi Mara,</p><p>Here is the checklist for Thursday. Could you take the copy review and the release notes? Marcos has the infra items.</p><p>Helena</p>", 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: "<p>On it. I will send the notes by Wednesday noon.</p>" }),
|
||||
m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Helena Vargas <helena@studio.example>", date: "2026-10-21T08:30:00Z", mid: "l3@x", html: "<p>Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.</p><p>Helena</p>" }),
|
||||
m({ folder: "INBOX", thread: T.launch, subject: "Re: Launch checklist", from: "Marcos Oliveira <marcos@studio.example>", date: "2026-10-22T07:55:00Z", mid: "l4@x", seen: false,
|
||||
html: `<p>Morning both. The staging run finished green, so I think we are fine to go ahead on Thursday as planned.</p><p>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.</p><p>Marcos</p>${quote("Helena Vargas <helena@studio.example>", "Wed, Oct 21, 2026 at 8:30 AM", "<p>Perfect. One more thing: the status page should say \"scheduled maintenance\" until we flip the switch.</p><p>Helena</p>")}` }),
|
||||
m({ folder: "INBOX", thread: T.invoice, subject: "Invoice 2041 for October", from: "Northwind Billing <billing@northwind.example>", date: "2026-10-22T06:20:00Z", mid: "i1@x", seen: false, attach: true, html: "<p>Hello Mara,</p><p>Your invoice for October is ready. The total is due on 5 November.</p><p>Northwind Billing</p>" }),
|
||||
m({ folder: "INBOX", thread: T.digest, subject: "Your weekly digest", from: "Field Notes <hello@fieldnotes.example>", 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 <no-reply@atlasair.example>", date: "2026-10-21T13:12:00Z", mid: "f1@x", html: flightHtml }),
|
||||
m({ folder: "INBOX", thread: T.review, subject: "Design review notes", from: "Priya Nair <priya@studio.example>", date: "2026-10-20T16:45:00Z", mid: "r1@x", attach: true, flagged: true, html: "<p>Notes from today's review are attached. The short version: keep the sidebar, simplify the toolbar, and let the reading pane breathe.</p><p>Priya</p>" }),
|
||||
m({ folder: "INBOX", thread: T.contract, subject: "Contract draft v3", from: "Tomas Reyes <tomas@legal.example>", date: "2026-10-19T14:05:00Z", mid: "c1@x", html: "<p>Mara, v3 of the contract is in the shared folder with the changes we agreed on. Tell me if anything looks off before Friday.</p><p>Tomas</p>" }),
|
||||
m({ folder: "INBOX", thread: T.standup, subject: "Standup moves to 10:15", from: "Marcos Oliveira <marcos@studio.example>", date: "2026-10-18T09:00:00Z", mid: "s1@x", html: "<p>Heads up: from Monday the standup is at 10:15.</p>" }),
|
||||
m({ folder: "INBOX", thread: T.welcome, subject: "Welcome to the team workspace", from: "Workspace <team@workspace.example>", date: "2026-10-15T08:00:00Z", mid: "w1@x", html: "<p>Welcome aboard. Your workspace is ready.</p>" }),
|
||||
];
|
||||
|
||||
// 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*</)?.[1] || f).trim();
|
||||
const addrOf = (f) => (f.match(/<([^>]+)>/)?.[1] || f).toLowerCase();
|
||||
const plain = (h) => h.replace(/<style[\s\S]*?<\/style>/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));
|
||||
@@ -16,7 +16,21 @@ const NAMED: Record<string, string> = {
|
||||
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);
|
||||
|
||||
@@ -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("<img src=x onerror=alert(1)> safe");
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
});
|
||||