0032 — Templates de e-mail no design system¶
| Status | CONCLUÍDA |
| Branch | spec/0032-templates-de-email-no-design-system |
| Criada em | 2026-08-09 |
| Apps afetados | api · web sem impacto · e2e sem impacto |
Status:
RASCUNHO→APROVADA→EM IMPLEMENTAÇÃO→CONCLUÍDA
1. Problema¶
Os três e-mails que a plataforma envia — código de verificação, convite de usuário e recuperação de senha — não se parecem com a Garantora. São o template genérico que veio junto com o esqueleto do projeto: azul #1a56db, cinzas do Tailwind, Arial.
A paleta da marca não aparece em lugar nenhum. Conferido nos três arquivos:
| Cor no e-mail | Onde é usada | Existe no design system? |
|---|---|---|
#1a56db (9×) | cabeçalho, botão, código OTP, link | não — não é token nenhum |
#9ca3af (10×) | textos de apoio, rodapé | não — cinza do Tailwind |
#6b7280, #374151, #e5e7eb | corpo, bordas | não |
#005577 navy · #008B8B teal · #FFA94D âmbar | — | sim, e nenhuma é usada |
O efeito é concreto, não estético. O e-mail é o primeiro contato da marca com quem ainda não entrou no produto: o convidado de uma garantidora nova (spec 0031) recebe o link por WhatsApp, abre o e-mail de verificação, e o que vê não tem relação visual com a tela em que vai cair em seguida. Num fluxo que já pede confiança — clicar num link, digitar um código, criar senha —, a inconsistência trabalha contra.
Some-se que o texto também nunca foi revisado: "Olá, {{ name }}!", "Aceitar Convite" em Title Case (o app usa sentence case), e nenhum e-mail diz o que fazer quando a pessoa não reconhece a ação além de "ignore este e-mail".
2. Objetivo¶
Os três e-mails passam a usar a paleta, a hierarquia e o tom da Garantora, a partir de um layout base compartilhado — para que o próximo e-mail nasça no padrão em vez de copiar o do lado.
3. Fora de escopo¶
- Criar e-mails novos. Decisão do usuário: o escopo são os três que existem. Cobrança, sinistro e afins ganham o layout quando forem implementados — a base fica pronta para eles, mas nenhum template novo entra aqui.
- Logo em imagem. O
logo.svgdo app não serve: Gmail e Outlook bloqueiam SVG. PNG hospedado depende de o cliente carregar imagem remota (a maioria não carrega por padrão) e PNG embutido exigiriamultipart/relatedno envio. Decisão do usuário: wordmark em HTML/CSS (RN2). - Webfont. Onest e Inter não existem em cliente de e-mail, e
@importé ignorado pelo Gmail. Decisão do usuário: fontes de sistema (RN3). - Trocar o transporte. O envio segue
aiosmtplibcom STARTTLS pelo SES Mail Manager, e as três funçõessend_*mantêm assinatura pública. O que muda noemail_service.pyé apenas anexar a segunda parte da mensagem (RN11). - Internacionalização. Os e-mails são em português, como o resto do produto (a spec 0009 removeu o i18n).
4. Regras de negócio¶
- RN1. A paleta é a do
docs/design-tokens.md, sem exceção. Nenhuma cor nova, nenhum cinza do Tailwind. O mapeamento:
| Elemento | Token | Valor |
|---|---|---|
| Cabeçalho (fundo) | brand.structure | #005577 |
| Wordmark "GARANTORA" | branco sobre navy | #FFFFFF |
| Botão de ação primária | brand.structure | #005577 |
| Link e acento | brand.accent-hover | #007A7A |
| Destaque do código OTP | brand.structure | #005577 |
| Fundo da mensagem | bg.default | #EDF1F6 |
| Painel | bg.surface | #FFFFFF |
| Texto principal | text.primary | #1A1A2E |
| Texto de apoio | text.secondary | #465264 |
| Filete e borda | border.subtle | #ADB8C8 |
| Alerta de prazo | warning-ink | #AE5700 |
| Fundo do rodapé | bg.default | #EDF1F6 |
No dark mode (RN7) valem os neutros do tema escuro do mesmo documento: bg.default #0B1220 · bg.surface #1E293B · text.primary #E8EAF0 · text.secondary #ACB4C0 · border.subtle #49586C.
Correção de 2026-08-09, ao implementar. A versão original desta RN mandava usar
brand.accent(#008B8B) em link e no código OTP. Mede 4,15:1 sobre painel branco — reprova em AA para texto normal, e o própriodocs/design-tokens.mddiz a regra que eu estava quebrando: "Nenhuma cor viva serve como texto no tema claro… Texto e glifo coloridos usam a tinta correspondente, sempre." Trocado poraccent-hover(#007A7A, 5,17:1) no link estructure(#005577, 8,18:1) no código, que é o elemento que a pessoa precisa ler e transcrever.O subtítulo do cabeçalho, pelo mesmo motivo, é branco sobre o navy: nenhum tom da paleta passa de 3,3:1 ali, e a hierarquia com o wordmark vem de tamanho e peso, não de cor.
O âmbar #FFA94D (highlight) não entra. Ele é o token da ação mais desejada da tela — no produto, o CTA de conversão. Num e-mail transacional a ação já é única e esperada; usar o âmbar aqui gastaria o sinal que o distingue.
-
RN2. O logo é wordmark em HTML, não imagem. "GARANTORA" em texto, branco sobre o navy do cabeçalho, com
letter-spacingque lembre o logotipo. Sempre renderiza — nunca é bloqueado, não depende de rede, sobrevive ao dark mode e não exige hospedar asset. Perde-se o desenho do "G"; ganha-se que o topo do e-mail nunca aparece vazio. -
RN3. Fontes de sistema apenas. Pilha
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, eui-monospace, SFMono-Regular, Menlo, monospacepara o código OTP. A identidade vem de cor, espaço e hierarquia — que é o que de fato carrega marca em e-mail, onde a tipografia não é controlável. -
RN4. HTML de e-mail, não HTML de web. Tabelas para estrutura, estilo inline, nada de flexbox, grid,
positionou classes externas. É a restrição que o Outlook (motor Word) impõe, e vale mesmo parecendo retrógrada. Largura fixa de 600px com degradação para 100% em telas estreitas. -
RN5. Layout base compartilhado.
base.htmlcom cabeçalho, painel, rodapé e os tokens; os três templates herdam com{% extends %}e preenchem blocos (title,preheader,content). Uma mudança de marca passa a ser um arquivo, não três.
Suposição declarada. O usuário marcou "Layout base reutilizável" e "Só os 3 templates atuais", que se excluem como estavam escritas. Li como: criar a base, mas não adicionar e-mails novos ao produto — a leitura que preserva as duas intenções. Se o desejado era não criar abstração nenhuma, esta RN cai.
-
RN6. Preheader em todo e-mail. O trecho que a caixa de entrada mostra ao lado do assunto, hoje inexistente — sem ele, o cliente exibe o começo do HTML, que é lixo de marcação. Fica oculto no corpo e diz a ação em uma linha ("Seu código expira em 10 minutos", "Convite para acessar a Garantora").
-
RN7. Dark mode que não quebra. Vários clientes invertem cores sozinhos. As regras:
color-scheme: light darkesupported-color-schemesdeclarados; bloco@media (prefers-color-scheme: dark)para quem respeita; e nenhuma informação depende só de cor de fundo — o que precisa ser legível tem contraste próprio. O cabeçalho navy e o botão navy mantêm texto branco nos dois modos. -
RN8. Texto revisado, em sentence case. Como o resto do produto:
| Hoje | Vira |
|---|---|
| "Aceitar Convite" | "Aceitar convite" |
| "Olá, {{ name }}!" | "Olá, {{ name }}" — sem exclamação, o tom do produto é sóbrio |
| "⏱ Este código expira em 10 minutos." | "O código expira em 10 minutos." (sem emoji — renderiza mal e alguns filtros penalizam) |
| "ignore este e-mail. Sua conta permanece segura." | mantém o sentido, mas diz o que fazer se a pessoa não reconhece a ação |
- RN9. Nada muda no que o e-mail faz. Mesmas oito variáveis de contexto (
name,code,year,invite_url,inviter_name,tenant_name,reset_url,expire_minutes), mesmos destinatários, mesmos assuntos, mesmos prazos.
Corrigido em 2026-08-09, ao ler os templates para implementar: a lista original desta RN tinha sete e esquecia
expire_minutes— que vem dePASSWORD_RESET_TOKEN_EXPIRE_MINUTESe é o prazo mostrado no e-mail de recuperação. Perdê-la na reescrita produziria "Este link expira em minutos". Se algum e-mail deixar de chegar ou de conter o que continha, é defeito desta spec.
-
RN10. O link continua visível em texto. O
user_invitee opassword_resetjá mostram a URL para copiar quando o botão não funciona. Isso fica — é o que salva o cliente que não renderiza o botão. -
RN11. Todo e-mail vai em duas partes: texto e HTML. Decisão do usuário em 2026-08-09, resolvendo a decisão que estava em aberto. Cada e-mail passa a ter também uma versão
text/plain, para entregabilidade (filtros penalizam mensagem só-HTML) e para quem lê em cliente sem HTML.
A ordem das partes não é detalhe: é a regra do formato. Em multipart/alternative, o cliente exibe a última parte que sabe renderizar. Texto anexado depois do HTML faria todo mundo ver texto puro — o e-mail "funcionaria", e ninguém veria o design que esta spec entrega. Ordem obrigatória: text/plain primeiro, text/html depois.
A mensagem já é MIMEMultipart("alternative") hoje, com uma parte só; a mudança é anexar a outra na posição certa.
A versão texto é template próprio (.txt), não HTML sem tags: quebra de linha, ordem e o link em linha isolada são decisões de legibilidade que não sobrevivem a uma conversão automática.
5. Impacto por app¶
apps/api¶
Arquivos:
app/templates/email/
├── base.html ← novo: cabeçalho, painel, rodapé, tokens, dark mode
├── verification_code.html ← herda a base
├── verification_code.txt ← novo: versão texto (RN11)
├── user_invite.html ← herda a base
├── user_invite.txt ← novo
├── password_reset.html ← herda a base
└── password_reset.txt ← novo
email_service.py — mudança contida em _send, que passa a receber o corpo em texto e anexá-lo antes do HTML:
async def _send(to_email: str, subject: str, html_body: str, text_body: str) -> None:
msg = MIMEMultipart("alternative")
...
msg.attach(MIMEText(text_body, "plain", "utf-8")) # primeiro
msg.attach(MIMEText(html_body, "html", "utf-8")) # último = preferido
As três funções send_* mantêm assinatura pública inalterada — passam a renderizar os dois templates e repassar ambos. O Environment do Jinja resolve {% extends %} e os .txt sem configuração adicional.
Testes — tests/test_email_templates.py, novo. Não há teste de e-mail hoje; o que vale a pena fixar não é aparência, é o que quebra em silêncio:
- os três templates renderizam com o contexto real de cada um;
- as variáveis aparecem no HTML final (
code,invite_url,reset_url,name); - nenhuma cor fora da paleta — varredura por
#rrggbbno HTML renderizado, comparando com a lista de RN1. É o teste que impede a próxima cor solta de entrar; <title>e preheader presentes;- o
invite_urlaparece duas vezes — no botão e em texto (RN10); - os três
.txtrenderizam e contêm o dado essencial (código, link); - a ordem das partes — na mensagem montada,
text/plainvem antes detext/html. É o teste que protege contra a inversão silenciosa da RN11: sem ele, trocar a ordem passa despercebido até alguém reclamar que o e-mail "está sem formatação".
Sem migration, sem rota, sem model. Nenhuma mudança de contrato de API.
apps/web¶
Sem impacto. Nenhum e-mail é renderizado pelo front. O design system do web é a fonte dos tokens, mas nada nele muda — os valores são copiados para os templates porque HTML de e-mail não importa CSS.
Isso cria uma segunda cópia dos tokens, e o
CLAUDE.mdconta a história de quando duas fontes da mesma verdade divergiram (o pacote do design system, spec 0017). Aqui a duplicação é inevitável — e-mail não tem como importar o tema —, e a defesa é o teste nº 3: uma cor que não esteja na paleta falha a suíte.
apps/e2e¶
Sem impacto. A suíte já lê e-mail pelo Mailpit (fixtures/mailpit.ts) nos fluxos de recuperação de senha e convite, e continua funcionando: os testes casam por conteúdo (OTP, link), não por marcação. Se algum quebrar, é porque o HTML mudou de forma que afetaria também um cliente real — e aí o vermelho é justo.
6. Contrato de API¶
Sem mudança. Nenhum endpoint, schema ou payload é tocado. As três funções de envio mantêm assinatura:
send_verification_code(email, name, code)
send_user_invite(email, name, invite_url, inviter_name, tenant_name)
send_password_reset(email, name, reset_url)
O _send interno ganha o parâmetro text_body (RN11), mas é privado ao módulo — nenhum chamador fora do email_service o usa.
7. Critérios de aceite¶
- [x] CA1. Dado qualquer um dos três e-mails renderizado, então toda cor
#rrggbbpresente está na tabela da RN1 — verificável por varredura automatizada. - [x] CA2. Dado o e-mail aberto no Mailpit, então o topo mostra o wordmark GARANTORA em branco sobre navy
#005577, sem imagem e sem espaço vazio. - [x] CA3. Dado o e-mail de verificação, então o código aparece em destaque, legível, e continua sendo extraível pelo
extractOtpdo e2e. - [x] CA4. Dado o convite, então o botão diz "Aceitar convite" (sentence case) e a URL continua visível em texto logo abaixo (RN10).
- [x] CA5. Dados os três templates, então todos herdam de
base.html— mudar a cor do cabeçalho em um arquivo muda nos três. - [x] CA6. Dado um cliente em dark mode, então o texto do cabeçalho e do botão permanece legível (branco sobre navy), sem inversão que apague conteúdo.
- [x] CA7. Dado o preheader, então a caixa de entrada mostra a frase da RN6 — e não o começo da marcação HTML.
- [x] CA8. Dado o fluxo de recuperação de senha do e2e, então ele continua passando sem alteração no spec — o
extractLinkacha o link como antes. - [x] CA9.
make testpassa com os testes novos de template; nenhum teste anterior quebra. - [x] CA11. Dado qualquer e-mail recebido, então ele tem duas partes —
text/plainetext/html—, nesta ordem, e o cliente exibe a versão HTML (RN11). - [x] CA12. Dado o e-mail lido como texto puro (cliente sem HTML), então o código de verificação e os links continuam legíveis e utilizáveis.
- [x] CA10. Renderizado em largura de 375px, o e-mail não exige rolagem horizontal e o botão continua clicável.
8. Plano de implementação¶
Ordem: api apenas (web e e2e sem impacto).
- [x]
apps/api—base.htmlcom tokens, cabeçalho, painel, rodapé, preheader e dark mode - [x]
apps/api—verification_code.htmlherdando a base - [x]
apps/api—user_invite.htmlherdando a base - [x]
apps/api—password_reset.htmlherdando a base - [x]
apps/api— os três.txt(RN11) - [x]
apps/api—_sendcomtext_body, anexado antes do HTML - [x]
apps/api— revisão dos textos (RN8), nas duas versões - [x]
apps/api—tests/test_email_templates.pycom os cinco testes - [x]
make testeruff(comparar antes/depois nos arquivos tocados) - [x] Envio real para o Mailpit e inspeção visual dos três, incluindo 375px
- [x] Conferir que o
extractOtp/extractLinkdo e2e seguem funcionando
9. Decisões em aberto¶
Nenhuma. A única que havia foi decidida pelo usuário em 2026-08-09:
-
[x] Versão
text/plain: entra (RN11). Aproveita que os templates estão sendo reescritos, melhora entregabilidade e atende cliente sem HTML. Descartado deixar para outra spec — o custo agora é anexar uma parte a umMIMEMultipartque já éalternative, e adiar significaria reescrever os mesmos textos duas vezes.Efeito colateral: o transporte sai do fora de escopo, mas apenas em
_send; a assinatura pública das três funções de envio não muda.
10. Registro¶
Implementada em 2026-08-09.
Dois erros da própria spec, achados ao implementar¶
| O que a spec dizia | O que estava errado |
|---|---|
RN1: link e código OTP em brand.accent (#008B8B) | Mede 4,15:1 sobre painel branco — reprova AA para texto normal. E o docs/design-tokens.md já dizia a regra que eu estava quebrando: "Nenhuma cor viva serve como texto no tema claro… Texto e glifo coloridos usam a tinta correspondente, sempre." Trocado por accent-hover (#007A7A, 5,17:1) no link e structure (#005577, 8,18:1) no código — que é justamente o que a pessoa precisa ler e transcrever. |
| RN9: sete variáveis de contexto | São oito. Faltava expire_minutes, que vem de PASSWORD_RESET_TOKEN_EXPIRE_MINUTES e é o prazo do e-mail de recuperação. Perdê-la na reescrita produziria "Este link expira em minutos" — e o teste test_variaveis_chegam_ao_html_e_ao_texto existe para que isso não passe de novo. |
Nos dois casos a spec foi corrigida antes do código, com o registro do que dizia antes.
O que a inspeção visual pegou e nenhum teste pegaria¶
O rodapé estava dentro do card, pintado com bg.default — a mesma cor do fundo da mensagem. Resultado: o painel branco parecia cortado, terminando sem borda, e o copyright flutuava numa faixa que se fundia com a página. Todos os testes passavam, overflow era 0, e a paleta estava correta.
Corrigido movendo o rodapé para fora do painel, que é o padrão em e-mail: o card fecha com os cantos arredondados e o copyright fica solto abaixo.
O dark mode quebrou duas coisas, e uma era grave¶
Renderizado com prefers-color-scheme: dark, o e-mail tinha dois defeitos que os 19 testes não pegavam — todos passavam, a paleta estava correta, o overflow era zero:
- O cabeçalho perdia o navy. Ele carregava a classe
.g-panel, e a regra do dark (background:#1E293B !important) ganhava do estilo inline. Resultado: topo cinza, igual ao corpo, sem distinção e sem marca. - O código de verificação ficava ilegível. O
<span>tinha.g-text, que no dark o pintava de#E8EAF0— mas a caixa em volta continuava#EDF1F6, porque a classe dela só mudava a borda. Texto branco sobre fundo claro, no e-mail cujo único propósito é transmitir aquele código.
O segundo é o erro que a própria RN7 mandava evitar ("nenhuma informação depende só de cor de fundo") e que eu cometi mesmo assim. A lição, agora escrita no base.html: superfície e texto andam juntos — quem escurece o fundo tem de clarear o texto no mesmo elemento.
Correções: o cabeçalho e o botão saem do dark mode (são a marca, valem nos dois modos com texto branco), e a caixa do código virou ilha clara deliberada, com fundo, borda e tinta fixos — legível inclusive nos clientes que inventam a própria inversão.
Sobre o subtítulo do cabeçalho¶
Nenhum tom da paleta passa de 3,3:1 sobre o navy #005577 (medido: structure dark 2,98 · info 3,18 · accent dark 3,21). O subtítulo é branco, e a hierarquia com o wordmark vem de tamanho e peso — não de cor. É a mesma lição da RN1, aplicada no único lugar onde ainda dava para escorregar.
Verificação¶
- Templates: 7 arquivos —
base.html+ 3 HTML herdando + 3.txt. - Testes:
tests/test_email_templates.py, 19 passam. Cobrem renderização, variáveis, paleta, preheader, link em dobro, ordem das partes e a extração do OTP. - Envio real: os três e-mails enviados de verdade pelo
email_servicee recebidos no Mailpit, cada um com HTML e texto presentes, o texto começando emGARANTORA. - Renderização: os três abertos em 700px e 375px a partir do HTML que o Mailpit recebeu —
scrollWidth - clientWidth == 0nos seis casos. - Dark mode: os três renderizados com
colorScheme: 'dark'— cabeçalho medido emrgb(0,85,119)(=#005577, o navy da marca) nos três, e o código legível. - Suíte completa: 262 passam, 1 falha — o flake conhecido de
test_partner_agency_search.py. Total 263 (244 anteriores + 19 novos). - Ruff: 251, igual ao baseline — zero apontamento novo. O único
I001ememail_service.pyé pré-existente (confirmado rodando o ruff sobre a versão doHEAD); o arquivo de teste novo passa limpo. _send: a assinatura pública das três funçõessend_*não mudou.- e2e (CA8): executada a pedido do usuário — 159 passam, 0 falham (8,9 min). O
password-reset.spec.ts, rodado também isolado para evidência direta, passa 6/6, incluindo "redefine a senha pelo link do e-mail e loga com a nova": oextractLinkacha o link na marcação nova sem nenhuma alteração no spec, que era o risco real de reescrever o HTML por baixo de um teste que casa porhref.
Jornada visual dos e-mails¶
A pedido do usuário, os três e-mails ganharam uma jornada em journeys/emails.json, com capturador próprio (journeys/capture-emails.mjs) — 3 e-mails, 9 capturas, 0 defeitos.
Duas decisões de desenho:
- Os prints vêm do HTML que o servidor SMTP recebeu, lido do Mailpit, e não de um preview renderizado do template. Renderizar o template direto pularia a etapa em que as coisas somem: codificação, quoted-printable, o que o transporte reescreve.
- O capturador produz o mesmo
resultado.jsondocapture.mjsgenérico, então orender.mjsmonta a página sem saber que aqueles passos não são telas. A fronteira genérico × projeto da skill fica intacta: nada de e-mail entrou nos scripts da skill.
A jornada tem um viewport que as de tela não têm — dark. É exatamente onde os dois piores defeitos desta spec apareceram, e tê-lo na página é o que impede que voltem sem ninguém ver.