Pular para conteúdo

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.svg do 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 exigiria multipart/related no 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 aiosmtplib com STARTTLS pelo SES Mail Manager, e as três funções send_* mantêm assinatura pública. O que muda no email_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óprio docs/design-tokens.md diz 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 é 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-spacing que 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, e ui-monospace, SFMono-Regular, Menlo, monospace para 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, position ou 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.html com 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 dark e supported-color-schemes declarados; 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 de PASSWORD_RESET_TOKEN_EXPIRE_MINUTES e é 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_invite e o password_reset já 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:

  1. os três templates renderizam com o contexto real de cada um;
  2. as variáveis aparecem no HTML final (code, invite_url, reset_url, name);
  3. nenhuma cor fora da paleta — varredura por #rrggbb no HTML renderizado, comparando com a lista de RN1. É o teste que impede a próxima cor solta de entrar;
  4. <title> e preheader presentes;
  5. o invite_url aparece duas vezes — no botão e em texto (RN10);
  6. os três .txt renderizam e contêm o dado essencial (código, link);
  7. a ordem das partes — na mensagem montada, text/plain vem antes de text/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.md conta 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 #rrggbb presente 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 extractOtp do 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 extractLink acha o link como antes.
  • [x] CA9. make test passa com os testes novos de template; nenhum teste anterior quebra.
  • [x] CA11. Dado qualquer e-mail recebido, então ele tem duas partes — text/plain e text/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.html com tokens, cabeçalho, painel, rodapé, preheader e dark mode
  • [x] apps/api — verification_code.html herdando a base
  • [x] apps/api — user_invite.html herdando a base
  • [x] apps/api — password_reset.html herdando a base
  • [x] apps/api — os três .txt (RN11)
  • [x] apps/api — _send com text_body, anexado antes do HTML
  • [x] apps/api — revisão dos textos (RN8), nas duas versões
  • [x] apps/api — tests/test_email_templates.py com os cinco testes
  • [x] make test e ruff (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/extractLink do 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 um MIMEMultipart que 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:

  1. 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.
  2. 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_service e recebidos no Mailpit, cada um com HTML e texto presentes, o texto começando em GARANTORA.
  • Renderização: os três abertos em 700px e 375px a partir do HTML que o Mailpit recebeu — scrollWidth - clientWidth == 0 nos seis casos.
  • Dark mode: os três renderizados com colorScheme: 'dark' — cabeçalho medido em rgb(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 I001 em email_service.py é pré-existente (confirmado rodando o ruff sobre a versão do HEAD); o arquivo de teste novo passa limpo.
  • _send: a assinatura pública das três funções send_* 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": o extractLink acha 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 por href.

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.json do capture.mjs genérico, então o render.mjs monta 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.