Skip to content

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).

text
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

TabelaFunção
email_remetentesIdentidades (slug → from). CHECK @olp.digital. Seeds: financeiro, suporte, noreply, sistema.
email_templatesTemplates por slug (fixo_billing protegido por trigger fn_email_templates_protect_fixos; manual editável). Aponta remetente_slug_padrao.
email_outboxFila 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−5
  • billing.cobranca_atraso — régua D+1/D+7/D+15
  • billing.fatura_emitida — gerada
  • billing.recibo_pagamento — webhook MP payment.approved

Resolução de remetente

Precedência (em enqueueEmail):

  1. remetenteSlug (override explícito do caller — usado por disparo manual)
  2. email_templates.remetente_slug_padrao
  3. 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 em EdgeRuntime.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 sendViaResend com Idempotency-Key = outbox.id.
  • Retentativas com backoff exponencial: 1m, 5m, 15m, 1h, 6h (cap).
  • Após N falhas retryable → status dlq + email.dlq em _shared/incident-actions.ts (banner Admin).

Latência de envio

CenárioLatência típica
Caminho feliz (trigger direto)<2s (Resend ~500ms + overhead)
Trigger direto falhou (rede/throttle)≤60s (cron 1 min pega)
Retry após falha1m → 5m → 15m → 1h → 6h

Knobs operacionais

Chave (configuracoes_plataforma)DefaultFaixaOnde editar
email_outbox_batch_size201–100Admin 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

CallerAçãoSlug
faturamento-cronFatura geradabilling.fatura_emitida
faturamento-cronD−5 lembretebilling.cobranca_lembrete
faturamento-cronD+1/7/15 atrasobilling.cobranca_atraso
mercadopago-webhookpayment.approvedbilling.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:

  1. escolas.email_financeiro (preferido — preenchido pelo admin no cadastro/edição)
  2. escolas.email_contato (fallback se financeiro nulo)
  3. 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 ~all final 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 com domain not verifiedemail.dlq aciona o banner de incidente.

Observabilidade

Actions registradas em src/constants/log-actions.ts:

  • email.enviado / email.falhou / email.dlq
  • email.retry
  • email.template_create / email.template_update / email.template_delete
  • email.remetente_create / email.remetente_update
  • email.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 é:

  1. Admin abre o EnviarEmailFaturaDialog da fatura.
  2. Seleciona um template categoria='fixo_billing' (ex.: billing.fatura_emitida, billing.fatura_atrasada, billing.fatura_paga) e o remetente.
  3. Preview ao vivo via template_preview com payload de amostra.
  4. Submete — backend (admin-emailsdisparar_billing_fatura) carrega fatura+escola, monta payload real, valida que o template é fixo_billing, aplica idempotência (referencia: { tipo: 'fatura_manual', id }) e enfileira em email_outbox. Cron process-email-outbox envia em poucos segundos.
  5. Log email.disparo_manual é registrado com fatura_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 em beforeinput + keydown + sanitização de paste). Para inserir o 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.
  • Catálogo SSOT vive em src/components/admin-financeiro/emails/template-builder/available-variables.ts e espelha VARIAVEIS_BILLING em supabase/functions/admin-emails/index.ts. Mudanças exigem alterar os dois.
  • Preview lateral colapsável: renderiza via template_preview com debounce de 400 ms. Mostra assunto resolvido + HTML em iframe sandbox="" (sem scripts).
  • Blocos suportados: saudacao, paragrafo, cta (com URL — também aceita ), rodape. Reordenáveis e removíveis.
  • : aponta para https://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 em corpo_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:
    1. Coluna tipo_comunicacao em email_templates (hoje derivado).
    2. Tabela email_unsubscribes com token único por endereço.
    3. Bloco de unsubscribe automático no footer — exigência legal e de deliverability (separa reputação de domínio).
  • 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 slug manual_*, rejeição de tipos de bloco inválidos, UUID em disparar_billing_fatura, schema de template_preview, dedup do detector de variáveis e classificação de variáveis inválidas.