Email Billing (Resend)
SSOT da infraestrutura de e-mail transacional/operacional via Resend. Categoria: billing. Outros casos de uso (ex.: comunicação/campanhas) são fora deste documento.
Visão geral
Adapter próprio para a API Resend + outbox em PostgreSQL + worker via cron de 1 min. Não usa Lovable Emails nem SMTP do Supabase Auth. Resend é o gateway de envio transacional de olp.digital; a caixa humana (suporte@olp.digital) roda em Titan sem conflito de DNS (seletores DKIM e includes SPF distintos, mesma zona raiz).
caller (faturamento-cron, mercadopago-webhook)
│ enqueueEmail({templateSlug, to, payload, referencia, remetenteSlug?})
▼
email_outbox ──(pg_cron 1min)──▶ process-email-outbox
│ │ resolve template + render HTML
│ │ sendViaResend (Idempotency-Key)
│ ▼
└───────────── status: sent | retry | dlq ───────────────┘Tabelas
| Tabela | Função |
|---|---|
email_remetentes | Identidades (slug → from). CHECK @olp.digital. Seeds: financeiro, suporte, noreply, sistema. |
email_templates | Templates por slug (fixo_billing protegido por trigger fn_email_templates_protect_fixos; manual editável). Aponta remetente_slug_padrao. |
email_outbox | Fila com snapshot imutável de from_email/from_nome/reply_to. Idempotência via índice único uniq_email_outbox_idem(template_slug, referencia_tipo, referencia_id) WHERE status IN ('pending','sending','sent'). |
Slugs em produção (categoria fixo_billing)
billing.cobranca_lembrete— D−5billing.cobranca_atraso— régua D+1/D+7/D+15billing.fatura_emitida— geradabilling.recibo_pagamento— webhook MPpayment.approved
Resolução de remetente
Precedência (em enqueueEmail):
remetenteSlug(override explícito do caller — usado por disparo manual)email_templates.remetente_slug_padrao- Fallback global
noreply
O resolvido é snapshotado em email_outbox (from_email, from_nome, reply_to, remetente_slug). Mudanças posteriores em email_remetentes não afetam linhas já enfileiradas.
Worker (process-email-outbox)
- Cron
process-email-outbox-1min(1 min,pg_cron+pg_net) — rede de segurança. - Disparo direto pós-enqueue (caminho rápido):
enqueueEmail()invoca o worker emEdgeRuntime.waitUntil(fetch)logo após o INSERT — latência típica <2s. Ver ADR-013. - Lock otimista
pending → sending(evita concorrência entre cron e trigger direto). - Render via blocos (
_shared/email-templates/billing-default.ts). - Envio via
sendViaResendcomIdempotency-Key = outbox.id. - Retentativas com backoff exponencial: 1m, 5m, 15m, 1h, 6h (cap).
- Após N falhas retryable → status
dlq+email.dlqem_shared/incident-actions.ts(banner Admin).
Latência de envio
| Cenário | Latência típica |
|---|---|
| Caminho feliz (trigger direto) | <2s (Resend ~500ms + overhead) |
| Trigger direto falhou (rede/throttle) | ≤60s (cron 1 min pega) |
| Retry após falha | 1m → 5m → 15m → 1h → 6h |
Knobs operacionais
Chave (configuracoes_plataforma) | Default | Faixa | Onde editar |
|---|---|---|---|
email_outbox_batch_size | 20 | 1–100 | Admin Financeiro › Emails › Configurações |
O worker cacheia o batch_size por 30s — alteração propaga em ≤30s. Intervalo do cron está fixo em migration; alterar exige cron.alter_job (não há UI).
Integrações
| Caller | Ação | Slug |
|---|---|---|
faturamento-cron | Fatura gerada | billing.fatura_emitida |
faturamento-cron | D−5 lembrete | billing.cobranca_lembrete |
faturamento-cron | D+1/7/15 atraso | billing.cobranca_atraso |
mercadopago-webhook | payment.approved | billing.recibo_pagamento |
Todos enqueueEmail são fire-and-forget (.catch(log)), nunca bloqueiam o caller.
Resolução do destinatário (Mai/2026)
Faturas e cobranças usam o seguinte fallback ao resolver to/payerEmail:
escolas.email_financeiro(preferido — preenchido pelo admin no cadastro/edição)escolas.email_contato(fallback se financeiro nulo)- Constante
financeiro@escola.com.br(último recurso — só para checkout MP)
Aplicado em faturamento-cron/index.ts (enqueue de billing.*) e mercadopago-preference/index.ts (criação da preferência MP). Validação Zod em _shared/cadastro-handlers/admin-escola-schemas.ts exige email_financeiro no complete_signup e nas ações create/update do admin-escolas.
Pré-requisitos operacionais (Resend)
Status atual em produção: domínio olp.digital verified no Resend (painel: Domains → olp.digital → DNS verified em May 07).
DNS na zona raiz (Cloudflare) — coexistência Titan (mailbox humana) + Resend (transacional):
- SPF (TXT
@): inclui_spf.resend.com. Titan publica seu próprio registro de envio; ambos convivem em~allfinal na mesma string. - DKIM: Resend usa seletor
resend._domainkey; Titan usa seletores próprios (titan1._domainkey/titan2._domainkey). Sem conflito. - DMARC:
v=DMARC1; p=reject; rua=mailto:...já vigente.
Se o status do domínio sair de
verified(rotação DNS, alteração de registro), todos os sends entram em DLQ silenciosamente comdomain not verified—email.dlqaciona o banner de incidente.
Observabilidade
Actions registradas em src/constants/log-actions.ts:
email.enviado/email.falhou/email.dlqemail.retryemail.template_create/email.template_update/email.template_deleteemail.remetente_create/email.remetente_updateemail.reenvio_manual
email.dlq está em _shared/incident-actions.ts (incidente crítico).
API admin (admin-emails)
Restrito a papel administrador. Endpoints: CRUD de email_templates (manual) e email_remetentes, listagem do email_outbox com filtros, e reenvio (DLQ → pending). Templates fixo_billing são read-only (trigger DB).
Testes
_shared/__tests__/resend-adapter.test.ts— 10 testes (sanitização, retry, backoff, idempotency-key)._shared/__tests__/email-template-render.test.ts— 5 testes (substituição de variáveis).
FAQ
Send entrou em DLQ — o que faço? Verificar log email.dlq no admin dashboard. Causas comuns: domínio não verificado no Resend, recipient inválido. Após corrigir, usar reenvio em admin-emails para mover DLQ → pending.
Como adiciono novo slug? INSERT em email_templates com categoria='manual' e slug iniciando com manual_. Templates fixo_billing só por migração.
Referências
- ADR-002 third-party adapter pattern
- ADR-006 fire-and-forget logging
- Memory:
mem://features/billing/email-infrastructure-resend
Disparo manual de cobrança (admin)
Em Admin › Financeiro › Cobranças (e dentro do drawer de cada escola) existe um botão "Enviar e-mail" ao lado de cada fatura. O fluxo é:
- Admin abre o
EnviarEmailFaturaDialogda fatura. - Seleciona um template
categoria='fixo_billing'(ex.:billing.fatura_emitida,billing.fatura_atrasada,billing.fatura_paga) e o remetente. - Preview ao vivo via
template_previewcom payload de amostra. - Submete — backend (
admin-emails→disparar_billing_fatura) carrega fatura+escola, monta payload real, valida que o template éfixo_billing, aplica idempotência (referencia: { tipo: 'fatura_manual', id }) e enfileira ememail_outbox. Cronprocess-email-outboxenvia em poucos segundos. - Log
email.disparo_manualé registrado comfatura_id,template_slug,escola_id,outbox_id,motivo(em caso de duplicado retorna 409).
Não é possível disparar templates manual por essa via — apenas fixo_billing. Templates manuais são pensados para comunicação ad-hoc (envio futuro via tela de Comunicação, fora do escopo desta entrega).
Templates manuais — Builder visual
Aba Admin › Financeiro › Emails › Templates tem CRUD completo para templates categoria='manual'. Templates fixo_billing permitem editar apenas metadados (assunto, descrição, remetente padrão).
Regras do builder
- Slug deve começar com
manual_e usa apenas[a-z0-9_](validado por Zod no backend e por trigger no banco). - Variáveis nunca são digitadas — usuário não consegue digitar
{no editor (intercepto embeforeinput+keydown+ sanitização de paste). Para inseriro usuário arrasta o chip da paleta ou clica nele. - Variáveis viram chips com
data-var="<slug>"no DOM:- chip verde = variável conhecida (existe em
VARIAVEIS_DISPONIVEIS); - chip vermelho = variável desconhecida (provavelmente vinda de template antigo). Salvar é bloqueado nesse caso.
- chip verde = variável conhecida (existe em
- Catálogo SSOT vive em
src/components/admin-financeiro/emails/template-builder/available-variables.tse espelhaVARIAVEIS_BILLINGemsupabase/functions/admin-emails/index.ts. Mudanças exigem alterar os dois. - Preview lateral colapsável: renderiza via
template_previewcom debounce de 400 ms. Mostra assunto resolvido + HTML em iframesandbox=""(sem scripts). - Blocos suportados:
saudacao,paragrafo,cta(com URL — também aceita),rodape. Reordenáveis e removíveis. : aponta parahttps://olp.digital/?section=pagamentos(seção Pagamentos da escola, deep-link via?section=). NÃO usar/escola/financeiro—/escola/:slugé a rota pública do Mural Olímpico e cairia em 404. "Financeiro" é seção exclusiva do Admin.- Serialização: chips ↔
(texto plano salvo emcorpo_blocos.texto). Compatível 100% com o renderer existente (renderEmail).
Posição LGPD — comunicação transacional
Toda comunicação enviada hoje pela OLP é transacional (cobrança, recibo, alerta de segurança, mudança de senha, suporte operacional).
- Base legal: Art. 7, V (execução de contrato) e Art. 7, IX (legítimo interesse) da LGPD. Equivale ao tratamento dado por CAN-SPAM (EUA) que isenta transactional emails da obrigação de unsubscribe.
- Não há opt-out porque o e-mail é parte indissociável da prestação do serviço — escola que se cadastrou aceitou receber comunicação operacional. Oferecer "descadastrar" criaria falsa expectativa.
- Quando criarmos categoria
marketing(newsletter / promocional), passa a ser obrigatório:- Coluna
tipo_comunicacaoememail_templates(hoje derivado). - Tabela
email_unsubscribescom token único por endereço. - Bloco de unsubscribe automático no footer — exigência legal e de deliverability (separa reputação de domínio).
- Coluna
- O footer dos templates fixos explicita a natureza transacional: "Você está recebendo este e-mail porque é responsável pela conta na plataforma OLP. Comunicações operacionais não podem ser desinscritas."
Testes adicionais
admin-emails/__tests__/manual-actions.test.ts— 8 testes: validação de slugmanual_*, rejeição de tipos de bloco inválidos, UUID emdisparar_billing_fatura, schema detemplate_preview, dedup do detector de variáveis e classificação de variáveis inválidas.