Projeto SES EngineStatus ComplementarVersão 1.0
Landing Factory ↗
CL Soluções · Documento complementar · E-mail marketing

SES Engine

Bônus gerado da discussão sobre AWS SES + Mautic: plataforma própria de e-mail marketing sobre AWS SES — campanhas, automações, deliverability, filas e módulo conversacional.

PRDSRSArquiteturaRegras de negócio
01

Sumário executivo

A SES Engine é a plataforma própria de e-mail marketing da CL Soluções construída sobre o AWS Simple Email Service. A tese é simples e deliberada: o SES é o motor de entrega — barato, escalável, com reputação de IP gerida pela AWS — mas não é uma plataforma de marketing. Ele não tem editor de e-mail, listas, segmentação, automações, templates versionados nem tracking de abertura e clique. Esse é exatamente o espaço onde a CL constrói valor: a interface, as regras de negócio e a inteligência (a IA AHRI) formam o cérebro; o SES é só o braço que empurra o SMTP.

O problema que a SES Engine resolve

Depender de plataformas fechadas (Mautic, RD Station, Mailchimp) significa herdar o roadmap, a precificação por contato e as limitações de customização de terceiros. Ao acoplar um front próprio ao SES, a CL controla 100% da experiência do cliente, precifica por volume real de envio (não por contato armazenado) e transforma automações e IA em diferencial competitivo do produto, não em feature comprada de fornecedor.

Princípio de arquitetura. O SES nunca decide o quê, para quem ou quando enviar. Ele só executa o envio de um MIME já montado. Toda decisão de negócio — segmento, horário, deduplicação, opt-out, personalização por AHRI — vive na plataforma da CL.

┌──────────────────────────────────────────────────────────────┐
│                    SES ENGINE (o cérebro)                    │
│                                                              │
│  Next.js + React + Tailwind (editor, listas, dashboards)     │
│         │                                                    │
│         ▼                                                    │
│  API NestJS ── Regras de negócio ── AHRI (Claude)            │
│    • listas & segmentação        • copy & assunto           │
│    • opt-out / supressão         • otimização de horário     │
│    • templates versionados       • variantes A/B            │
│    • fila de disparo (BullMQ)    • classificação de bounce   │
│         │                                                    │
│         ▼                                                    │
│  Postgres  ·  Redis (fila/rate)  ·  S3 (assets/MIME)         │
└─────────┬────────────────────────────────────────────────────┘
          │  SendRawEmail / SendBulkTemplatedEmail (API v2)
          ▼
┌──────────────────────────────────────────────────────────────┐
│                    AWS SES (o motor)                          │
│   Entrega SMTP · reputação de IP · DKIM/SPF · rate limiting   │
│   Feedback: bounce · complaint · delivery  ──►  SNS → SQS     │
└─────────┬────────────────────────────────────────────────────┘
          ▼
     Caixas de entrada dos destinatários (Gmail, Outlook, …)

          SNS → SQS ──► volta para o cérebro (webhook consumer)
          atualiza status, supressão e métricas em tempo real

Onde a AHRI entra

A AHRI é a camada de IA comercial que diferencia a SES Engine de qualquer wrapper genérico do SES. Ela opera em quatro frentes concretas, todas alimentadas pelo Claude como motor de geração:

Geração de copy

Assunto, pré-header e corpo a partir de um briefing curto, respeitando tom da marca e limite de spam-words. Gera 3 variantes de assunto para teste A/B.

Segmentação assistida

Traduz linguagem natural ("quem abriu mas não clicou nos últimos 30 dias") em filtros SQL sobre o schema de eventos.

Otimização de envio

Sugere janela de disparo por engajamento histórico do contato, respeitando o rate limit do SES.

Higiene de reputação

Classifica bounces (hard/soft) e complaints, recomendando supressão automática antes de bater os limites da AWS.

Metas de plataforma no lançamento

US$ 0,10Custo / 1.000 e-mails (SES)
> 98%Taxa de entrega
< 5%Bounce rate
< 0,1%Complaint rate (limite SES)

Comparativo: SES puro vs. plataforma de marketing vs. SES Engine

CapacidadeAWS SES puroMautic / SaaSSES Engine (CL)
Entrega em alto volumeSim (motor)Depende de gatewaySim (via SES)
Editor visual de e-mailNãoSimSim (próprio)
Listas & segmentaçãoNãoSimSim + IA (AHRI)
Tracking abertura/cliqueNãoSimSim (pixel + redirect)
Automações / fluxosNãoSimSim (BullMQ)
CustoUS$ 0,10/1kPor contato (caro)US$ 0,10/1k + infra
Controle do roadmapN/ADo fornecedor100% da CL

Proposta de valor e público

Proposta de valor. Entregar o poder de entrega da infraestrutura da AWS com a experiência de uma plataforma premium de marketing, a um custo por envio até 10x menor que os SaaS que cobram por contato armazenado — com IA integrada de fábrica.

Público. Agências, e-commerces de médio porte e SaaS B2B que disparam de 100 mil a vários milhões de e-mails/mês e sofrem com o modelo de precificação por contato dos concorrentes. Domínios de teste sob *.clsolucoes.com (IP 147.93.15.29, atrás do proxy Cloudflare).

Restrição operacional central. O complaint rate acima de 0,1% e o bounce rate acima de 5% colocam a conta SES em revisão e podem levar à suspensão de envio. A higiene de lista e a supressão automática não são opcionais — são o que mantém o motor ligado.
02

Objetivos & KPIs

Os objetivos da SES Engine se dividem em duas famílias que se retroalimentam: deliverability (o e-mail chega e é lido) e negócio (a plataforma gera receita previsível). A deliverability protege a conta SES dos limites da AWS; o negócio justifica a plataforma existir. Ambos são medidos com metas e limites críticos explícitos — nenhum KPI é "acompanhar por acompanhar".

KPIs de deliverability

Estes são os números que mantêm o motor SES funcionando. Os limites críticos da coluna final são impostos pela AWS: cruzá-los coloca a conta em revisão manual e, na reincidência, suspende o envio.

KPIDefiniçãoMetaLimite crítico (AWS)
Taxa de entregadelivered / (sent − hardBounce)> 98%< 95% aciona alerta interno
Bounce ratebounces / sent (janela móvel)< 2%< 5% (revisão) / < 10% (suspensão)
Complaint ratecomplaints / delivered< 0,05%< 0,1% (revisão) / < 0,5% (suspensão)
Taxa de aberturauniqueOpens / delivered (pixel)> 22%< 10% indica problema de reputação
CTRuniqueClicks / delivered (redirect)> 3%
CTORuniqueClicks / uniqueOpens> 12%
Throughpute-mails/s respeitando send rate SES≈ rate da contaEstourar o rate = Throttling (erro 454)
Tempo de setup de domínioDo add-domain a DKIM/SPF verde< 30 minSES não envia sem verificação
A conta é solidária. Bounce e complaint são calculados sobre toda a conta SES, não por campanha. Um único cliente com lista suja contamina a reputação de todos. Por isso a supressão e a validação de lista são aplicadas globalmente, antes de qualquer disparo entrar na fila.

KPIs de negócio

KPIDefiniçãoMeta MVPObservação
Custo por 1.000 e-mailsCusto SES + rateio de infra por milhar enviado< US$ 0,20SES cobra US$ 0,10/1k; infra é o delta
MRR por clienteReceita recorrente mensal média por conta ativa> R$ 490Modelo por faixa de volume, não por contato
Margem bruta por envio(preço cobrado − custo) / preço> 80%Alavanca do modelo por volume
Ativação (time-to-first-send)Do cadastro ao 1º disparo real< 24 hGargalo = verificação de domínio
Churn mensalContas canceladas / contas ativas< 3%Correlaciona com deliverability

Custo do motor: tabela de referência SES

Preço público do AWS SES (us-east-1) usado como base de custo. A plataforma repassa com margem por faixa de volume.

ComponentePreço AWSExemplo (1M e-mails/mês)
Envio (API/SMTP)US$ 0,10 / 1.000US$ 100,00
Anexos / dadosUS$ 0,12 / GBVariável (≈ US$ 6 p/ 50 GB)
Recebimento (inbound)US$ 0,10 / 1.000Só se usar inbound
Dedicated IP (opcional)US$ 24,95 / IP / mêsRecomendado > 100k/dia

Cálculo de throughput seguro

O send rate da conta (ex.: 14, 50 ou 200 msg/s conforme sandbox/produção) define o teto do worker. A fila de disparo (BullMQ) usa um limiter alinhado a esse rate para nunca receber Throttling:

// worker de disparo — rate alinhado ao send rate da conta SES
new Worker('dispatch', processor, {
  connection: redis,
  limiter: {
    max: 50,        // = SES send rate (msg/s) da conta em produção
    duration: 1000, // janela de 1s
  },
});

// 1M e-mails a 50 msg/s ≈ 5h33min de disparo contínuo
// tempo(s) = totalEmails / sendRate = 1_000_000 / 50 = 20.000s

Critérios de aceite do MVP

  1. Setup de domínio. Adicionar um domínio *.clsolucoes.com, gerar registros DKIM/SPF e verificar em < 30 min.
  2. Lista & supressão. Importar CSV, deduplicar, validar sintaxe e aplicar lista de supressão global antes do disparo.
  3. Envio real. Disparar uma campanha via SendBulkTemplatedEmail respeitando o rate limit, sem Throttling.
  4. Feedback loop. Consumir bounce/complaint/delivery via SNS→SQS e atualizar status e supressão em tempo real.
  5. Tracking. Registrar abertura (pixel) e clique (redirect) e refletir nos dashboards.
  6. Guardrails. Bloquear automaticamente novos disparos se bounce > 5% ou complaint > 0,1% na janela móvel.
  7. AHRI. Gerar assunto + corpo + 3 variantes de teste A/B a partir de um briefing, prontos para envio.
Definição de pronto. O MVP está pronto quando uma campanha real percorre briefing → geração AHRI → segmento → fila → SES → feedback → dashboard, mantendo bounce < 5% e complaint < 0,1%, com todos os sete critérios acima verificados em ambiente de teste sob *.clsolucoes.com.
03

Personas & casos de uso

A AHRI não existe para "mandar e-mail". Ela existe para quatro perfis de conta que compartilham a mesma métrica de sucesso — receita atribuída por disparo — e divergem em volume, ritmo e tolerância a risco de reputação. Antes de desenhar qualquer template ou automação, identifique em qual quadrante a conta se encaixa: isso define warm-up, cadência de envio, agressividade da IA e SLA de suporte.

3.1 As quatro personas de conta

Toda conta em *.clsolucoes.com é classificada em um destes perfis no onboarding. A classificação não é cosmética: ela pré-configura limites de envio no worker, o warm-up plan do SES e o tom padrão dos prompts da AHRI.

PME / SMB tradicional

Clínica, escritório de advocacia, imobiliária, prestador local. Lista de 500 a 8.000 contatos crescida organicamente. Dispara 1 a 3 campanhas por mês. Sensível a preço, avessa a complexidade. Quer "apertar um botão" e ver quem abriu.

Infoprodutor / creator

Lançamentos com picos brutais (30k+ e-mails em 6 horas na abertura de carrinho), depois silêncio. Lista comprada/importada em parte — risco alto de bounce e complaint. Vive de sequência de nutrição e de "a porta fecha à meia-noite". Precisa de proteção de reputação agressiva.

Assessoria / agência (ex.: Corvo/CL)

Opera N contas de clientes sob uma conta-mãe (multi-tenant). Precisa de isolamento de reputação por cliente, relatórios white-label e a AHRI escrevendo com voz de terceiros. Volume agregado alto e constante. É quem mais usa a API e os webhooks.

E-commerce

Loja Shopify/Nuvemshop/Woo com eventos transacionais em tempo real. Menos "campanha", mais "gatilho": carrinho abandonado, pós-venda, review request, back-in-stock. Depende de integração de eventos e da régua rodar sozinha 24/7.

3.2 Tabela de personas: dor, objetivo e configuração padrão

PersonaDor principalObjetivo (métrica)Volume típicoConfig AHRI padrão
PMENão sabe escrever assunto/copy; medo de "cair no spam"; sem tempoTaxa de abertura > 25%; agendamento de retorno1–3 mil/mêsTemplates prontos, tom institucional, sugestão de assunto automática
InfoprodutorReputação queima em lançamento; bounce de lista importada; deliverability instávelFaturamento do carrinho; entrega > 97% no pico10–50 mil em rajadaWarm-up obrigatório, throttling por hora, supressão de risco pré-envio, copy persuasiva
AssessoriaGerir 20+ clientes sem misturar reputação; provar ROI por clienteRetenção de clientes; relatório white-label; margem100 mil+ agregado/mêsMulti-tenant, IP/subdomínio por cliente, voz configurável por conta, API-first
E-commerceCarrinho abandonado sem recuperação; pós-venda inexistenteReceita recuperada; LTV; taxa de recompraTransacional contínuoGatilhos por evento, régua sempre ativa, personalização por produto/pedido
Regra de reputação. Infoprodutor e assessoria NUNCA compartilham pool de envio com PME. Lista importada tem bounce rate imprevisível; um único cliente descuidado pode derrubar a taxa de reclamação da AWS SES acima de 0,1% e suspender o envio de todos. Isolamento por subdomínio/IP dedicado é requisito, não opção.

3.3 Casos de uso canônicos

Cinco fluxos cobrem >90% do que as contas fazem. Cada um mapeia para um módulo específico do backend (ver capítulo de Arquitetura) e para um preset de prompt da AHRI.

1. Campanha pontual

Envio único para um segmento. "Promoção de julho", "convite para o webinar". A AHRI gera 3 variações de assunto e a copy; usuário escolhe, agenda, dispara. Métrica: abertura, clique, conversão. É o caso de entrada da PME.

2. Newsletter recorrente

Cadência fixa (semanal/quinzenal). A AHRI ajuda a montar seções a partir de um briefing ou de URLs. Foco em engajamento sustentado e higiene de lista (remover inativos após N envios sem abrir).

3. Automação de boas-vindas

Gatilho: novo contato entra na lista. Sequência de 2–4 e-mails ao longo de 7 dias (apresentação, prova social, oferta). Roda sozinha. Base para nutrição de infoprodutor.

4. Recuperação de carrinho

Gatilho: evento cart.abandoned do e-commerce. Régua de 3 toques (1h, 24h, 72h) com o produto abandonado renderizado dinamicamente. Cupom escalonado. Maior ROI unitário da plataforma.

5. Régua de cobrança / pós-venda

Gatilho por status de pedido/fatura. Lembrete de pagamento, confirmação, pedido de avaliação, cross-sell. Tom firme mas institucional — a AHRI escreve sempre como a empresa, jamais personificando o cliente final.

3.4 Mapa caso de uso × persona × módulo

Caso de usoPersonas primáriasMódulo backendTriggerSLA de entrega
Campanha pontualPME, InfoprodutorcampaignsAgendamento manualLote em fila < 30 min
Newsletter recorrentePME, Assessoriacampaigns + cronCron (RRULE)Janela definida pelo usuário
Boas-vindasInfoprodutor, E-commerceautomationscontact.created< 60 s do gatilho
Recuperação de carrinhoE-commerceautomationscart.abandonedDelay configurável (1h/24h/72h)
Régua de cobrançaE-commerce, Assessoriaautomationsorder.status_changed< 60 s do gatilho

3.5 Jornada de uma conta (do trial ao volume)

  1. Onboarding. Classificação de persona, verificação de domínio (SPF/DKIM/DMARC no subdomínio em *.clsolucoes.com), import da primeira lista com validação de sintaxe e supressão de descartáveis.
  2. Warm-up. Para infoprodutor/assessoria, plano de aquecimento de 2–4 semanas: SES começa com cota reduzida e escala conforme reputação. Envios acima da cota do dia ficam em fila.
  3. Primeira campanha. PME dispara pontual assistida pela AHRI; e-commerce liga a régua de carrinho. Feedback loop de abertura/clique alimenta as sugestões futuras.
  4. Recorrência. Newsletter e automações passam a rodar; a AHRI aprende o tom da conta e melhora as variações de assunto com base no histórico de performance.
  5. Escala. Assessoria adiciona contas-filhas; e-commerce cruza eventos transacionais; monitoramento de bounce/complaint por tenant impede contaminação de reputação.
Como escolher o preset da AHRI. Persona define o tom (institucional para PME, persuasivo para infoprodutor, white-label para assessoria, transacional para e-commerce). Caso de uso define a estrutura (assunto único vs. sequência vs. gatilho). A combinação dos dois seleciona o prompt mestre correto — detalhado no capítulo de prompts.
04

Arquitetura geral

A plataforma é um pipeline assíncrono desenhado em torno de uma verdade incômoda: enviar e-mail é a parte fácil; garantir entrega, idempotência e reputação é a parte que define se a AHRI sobrevive ao volume. Nada que toca o SES roda no ciclo request/response — tudo passa por fila. O request do usuário apenas materializa um lote e o entrega ao worker; o worker é quem conversa com a AWS.

4.1 Visão em camadas

┌──────────────────────────────────────────────────────────────────────┐
│  CLIENTE / BROWSER                                                     │
│  Next.js + React + Tailwind + TypeScript  (app.clsolucoes.com)         │
│  SSR/ISR · editor de template · dashboards · agendador                │
└───────────────┬──────────────────────────────────────────────────────┘
                │  HTTPS (Cloudflare proxy → 147.93.15.29)
                ▼
┌──────────────────────────────────────────────────────────────────────┐
│  API GATEWAY / EDGE                                                    │
│  Cloudflare: WAF · rate-limit · TLS · cache estático                  │
└───────────────┬──────────────────────────────────────────────────────┘
                │  REST/JSON + JWT (Bearer)
                ▼
┌──────────────────────────────────────────────────────────────────────┐
│  API DE APLICAÇÃO   (NestJS  ou  Laravel)                              │
│  ┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐  │
│  │campaigns │templates │  lists   │segmenta- │automa-   │ tracking │  │
│  │          │          │ /contacts│  ção     │ ções     │          │  │
│  ├──────────┼──────────┼──────────┼──────────┼──────────┴──────────┤  │
│  │statistics│ webhooks │ AHRI     │  auth /  │  suppression list   │  │
│  │          │ (in/out) │ (Claude) │  tenants │  (bounce/complaint) │  │
│  └──────────┴──────────┴──────────┴──────────┴─────────────────────┘  │
└───────┬───────────────────────────────────────────────┬──────────────┘
        │  enqueue (job)                                 │  read/write
        ▼                                                ▼
┌───────────────────────────┐                 ┌──────────────────────────┐
│  FILA                     │                 │  PERSISTÊNCIA            │
│  BullMQ(Redis)  ou  SQS   │                 │  PostgreSQL (dados)      │
│  queues: send · webhook · │                 │  Redis (fila/cache)      │
│  ahri-gen · automation    │                 │  S3 (assets/imagens)     │
└───────────┬───────────────┘                 └──────────────────────────┘
            │  consume
            ▼
┌───────────────────────────┐   SendEmail /   ┌──────────────────────────┐
│  WORKER(S)                 │   SendBulk      │  AWS SES                 │
│  render MJML→HTML · merge  │────────────────▶│  (configuration set +    │
│  vars · throttle · retry   │                 │   dedicated IP pool)     │
└───────────────────────────┘                 └───────────┬──────────────┘
                                                           │  entrega
                                                           ▼
                                                   ┌───────────────┐
                                                   │ DESTINATÁRIOS │
                                                   └───────┬───────┘
                                                           │  eventos
                    delivery/bounce/complaint/open/click   ▼
┌──────────────────────────────────────────────────────────────────────┐
│  SES event publishing → SNS topic → HTTPS webhook (ingest)            │
│  webhook valida assinatura → enfileira → worker atualiza eventos,     │
│  contadores e SUPPRESSION LIST (hard bounce / complaint = supressão)  │
└──────────────────────────────────────────────────────────────────────┘

4.2 Responsabilidade de cada componente

ComponenteResponsabilidadeNÃO faz
Frontend (Next.js)UI, editor de templates, preview, agendamento, dashboards; consome API via JWTNunca fala direto com SES nem com a fila
campaignsCRUD de campanha, resolve o segmento em snapshot de destinatários, cria o lote e enfileira jobs de envioNão renderiza nem envia — só materializa e enfileira
templatesArmazena MJML/HTML + variáveis; versiona; valida merge tags; render de previewNão persiste dados de contato
lists / contactsContatos, campos custom, status (subscribed/unsub/bounced), import com validaçãoNão decide quem recebe (isso é segmentação)
segmentaçãoTraduz regras (AND/OR sobre campos e eventos) em query; gera snapshot congelado no disparoNão envia; entrega lista de IDs ao campaigns
automationsMáquina de estados por contato: gatilho → delay → passo → condição; agenda jobs futurosNão gera copy (delega à AHRI)
trackingEndpoints de pixel de abertura e redirect de clique; assina/valida tokens de rastreioNão agrega — só registra evento cru
statisticsAgrega eventos em contadores por campanha/automação/tenant; alimenta dashboardsNão é fonte da verdade de evento (é derivado)
webhooksIngest de SNS/SES (entrada) e disparo de webhooks para o cliente (saída)Não processa na thread HTTP — enfileira
AHRI (Claude)Geração de assunto/copy/variações via prompt mestre; roda em fila ahri-genNão envia e-mail; devolve conteúdo para revisão/uso
Fila (BullMQ/SQS)Desacopla API do envio; garante retry, backoff, throttling e ordenação por prioridadeNão guarda estado de negócio (só o job)
WorkerRender final, merge de variáveis, respeita cota/hora do SES, chama SES, trata retryable vs. permanenteNão recalcula segmento
AWS SESEntrega SMTP, assinatura DKIM, publicação de eventos via configuration set
suppression listRegistro global (por tenant) de e-mails bloqueados por hard bounce/complaint

4.3 As quatro filas

Separar por fila permite dar prioridade e concorrência independentes. Transacional (automação) não pode ficar preso atrás de uma newsletter de 50 mil.

FilaJobConcorrênciaRetry / backoffPrioridade
sendEnviar 1 e-mail (ou micro-lote) via SESAlta, limitada pela cota SES/s3× exponencial (2s→8s→32s) só em erro retryávelMédia
automationExecutar passo de régua para 1 contatoAlta3× exponencialAlta (transacional)
webhook-ingestProcessar notificação SNS/SESAlta5× (evento não pode ser perdido)Alta
ahri-genChamada ao Claude para gerar conteúdoBaixa (custo/token)2× + fallback de modeloBaixa
Throttling é do worker, não da fila. A fila entrega jobs o mais rápido possível; o worker é quem segura o ritmo para não estourar a cota de Max send rate do SES (ex.: 14/s na cota inicial). Estourar gera Throttling e, pior, degrada reputação. Use um rate-limiter (BullMQ limiter ou token bucket em Redis) por tenant e por IP pool.

4.4 Idempotência — o coração da confiabilidade

Filas garantem entrega at-least-once: o mesmo job pode rodar duas vezes (crash entre o envio e o ack, retry após timeout de rede). Sem idempotência, um retry vira e-mail duplicado para o destinatário — e supressão duplicada corrompe contadores. A defesa é uma chave idempotente por unidade de trabalho.

-- Cada destinatário de cada campanha é uma linha única.
-- A UNIQUE constraint impede duplo envio mesmo sob retry concorrente.
CREATE TABLE campaign_recipients (
  id            BIGSERIAL PRIMARY KEY,
  campaign_id   BIGINT      NOT NULL REFERENCES campaigns(id),
  contact_id    BIGINT      NOT NULL REFERENCES contacts(id),
  tenant_id     BIGINT      NOT NULL,
  -- chave idempotente determinística: mesmo (campanha,contato) => mesma chave
  idem_key      TEXT        NOT NULL,
  status        TEXT        NOT NULL DEFAULT 'queued',  -- queued|sent|delivered|bounced|complained|failed
  ses_message_id TEXT,
  sent_at       TIMESTAMPTZ,
  attempts      SMALLINT    NOT NULL DEFAULT 0,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uq_recipient UNIQUE (campaign_id, contact_id),
  CONSTRAINT uq_idem      UNIQUE (idem_key)
);

CREATE INDEX idx_recip_status ON campaign_recipients (tenant_id, status);
CREATE INDEX idx_recip_msgid  ON campaign_recipients (ses_message_id);
// Worker: o job só envia se conseguir "reservar" a linha (queued -> sending).
// Um segundo processamento do mesmo job encontra status != 'queued' e sai.
async function processSendJob(job) {
  const { recipientId, idemKey } = job.data;

  // reserva atômica: só 1 execução vence
  const reserved = await db.query(
    `UPDATE campaign_recipients
        SET status = 'sending', attempts = attempts + 1
      WHERE id = $1 AND status = 'queued'
      RETURNING id`,
    [recipientId]
  );
  if (reserved.rowCount === 0) return; // já processado ou em outro worker -> idempotente

  const res = await ses.sendEmail({
    // idemKey vira o header X-Idem e é logado; SES não deduplica, nós deduplicamos
    ...buildMessage(job.data),
    Tags: [{ Name: 'idem', Value: idemKey }],
  });

  await db.query(
    `UPDATE campaign_recipients
        SET status = 'sent', ses_message_id = $2, sent_at = now()
      WHERE id = $1`,
    [recipientId, res.MessageId]
  );
}
Regra de ouro. A chave idempotente é determinística: idem_key = sha256(tenant_id : campaign_id : contact_id). Assim, mesmo que a API reenfileire o lote inteiro após um deploy interrompido, a UNIQUE constraint no insert e a reserva atômica no update garantem exatamente um envio por destinatário.

4.5 Idempotência na ingestão de eventos

O SNS também entrega at-least-once — a mesma notificação de bounce pode chegar duas vezes. Cada evento SES carrega um mail.messageId + eventType + timestamp; a combinação é a chave de deduplicação.

CREATE TABLE email_events (
  id           BIGSERIAL PRIMARY KEY,
  message_id   TEXT NOT NULL,           -- ses mail.messageId
  event_type   TEXT NOT NULL,           -- Delivery|Bounce|Complaint|Open|Click
  event_ts     TIMESTAMPTZ NOT NULL,
  dedup_key    TEXT NOT NULL,           -- sha256(message_id:event_type:event_ts)
  payload      JSONB NOT NULL,
  received_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uq_event_dedup UNIQUE (dedup_key)
);

-- INSERT ... ON CONFLICT (dedup_key) DO NOTHING;
-- Segunda entrega do mesmo evento é descartada silenciosamente.

4.6 Fluxo de supressão (bounce / complaint)

  1. SES publica. Configuration set envia o evento ao tópico SNS assim que há hard bounce ou reclamação (botão "marcar como spam").
  2. Webhook valida. O endpoint de ingest confirma a assinatura SNS (chave pública x509 da AWS) e responde 200 rápido, apenas enfileirando em webhook-ingest.
  3. Worker deduplica. Insert em email_events com ON CONFLICT DO NOTHING; evento repetido é ignorado.
  4. Supressão. Se Bounce.bounceType = Permanent ou for Complaint, insere o e-mail na suppression_list do tenant e marca o contato como bounced/complained. Envios futuros checam essa lista antes de enfileirar.
  5. Contadores. statistics incrementa os agregados da campanha; dashboard reflete em segundos.
Supressão é pré-envio, não pós. A checagem contra a suppression list acontece no momento de materializar o lote (módulo campaigns) e novamente na reserva do worker. Confiar só no SES para bloquear supressos custa quota e reputação — a AWS suspende contas cujo bounce rate passa de 5% ou complaint rate passa de 0,1%.

4.7 Multi-tenant e isolamento de reputação

Para a assessoria (Corvo/CL), cada cliente é um tenant com tenant_id propagado em toda linha e todo job. O isolamento vai além do banco: cada tenant de alto volume recebe um subdomínio verificado próprio em *.clsolucoes.com e, quando o volume justifica, um IP pool dedicado no SES. Assim, um cliente com lista ruim não arrasta a reputação dos demais.

Remetente sempre a empresa. O From é sempre um endereço verificado do domínio da conta (ex.: [email protected]), nunca o e-mail pessoal do cliente final personificado. Isso preserva alinhamento DKIM/DMARC e evita spoofing — princípio de integridade do remetente que atravessa toda a arquitetura.
< 0,1%Complaint rate (teto AWS)
< 5%Bounce rate (teto AWS)
at-least-onceGarantia da fila
exactly-onceEfeito (via idempotência)
05

Módulos funcionais

A plataforma AHRI da CL Soluções é composta por sete módulos que se encaixam num único pipeline: você importa e higieniza contatos, monta o público por segmentação, cria o conteúdo em Templates, dispara via Campanhas (manual ou automatizado por Automações), mede em Estatísticas e protege a reputação de envio com Supressão. Cada módulo expõe API REST, webhook e UI própria; todos compartilham o mesmo modelo de dados (workspace_id como fronteira de multi-tenant).

Campanhas

Unidade de envio. Estados: draft → scheduled → sending → paused → sent (e canceled). Suporta agendamento com timezone do destinatário, envio escalonado (throttling por max send rate do SES) e A/B de assunto com corte automático pelo melhor open rate.

Templates

Editor visual em blocos (MJML compilado para HTML) + variáveis de merge com fallback. Versionamento imutável: cada publicação gera um template_version referenciado pela campanha, garantindo que edições futuras não reescrevam o que já foi enviado.

Contatos & Listas

Import CSV/XLSX com mapeamento de colunas, deduplicação por e-mail normalizado, campos customizados tipados (string, number, date, bool, enum) e merge não-destrutivo de registros duplicados.

Segmentação

Filtros compostos (AND/OR aninhados) por atributo, evento (aberto, clicou, comprou) e engajamento (recência/frequência). Segmentos dinâmicos são recalculados no momento do envio; estáticos congelam o snapshot.

Automações

Jornadas visuais gatilho → condição → ação com esperas temporais, ramificações e limites de reentrada. Gatilhos: entrou na lista, disparou evento, data relativa (aniversário, +7 dias após compra).

Estatísticas

Dashboard por campanha e agregado por workspace: entregues, abertos, cliques, bounces, complaints, unsubscribes, receita atribuída. Funil e mapa de cliques por link.

Supressão

Lista mestra de bloqueio: unsubscribe, hard bounce, complaint (spam) e supressão manual. Toda campanha filtra contra ela antes de entregar ao SES — nenhum e-mail suprimido é reenviado.

Módulo: Campanhas

Uma campanha referencia exatamente um template_version e um público (lista, segmento ou combinação com exclusões). O ciclo de vida é uma máquina de estados explícita — transições inválidas são rejeitadas na API (HTTP 409).

  draft ─publish→ scheduled ─(hora agendada)→ sending ─┬─ done →  sent
    ↑                  │                        │       │
    └──── edit ────────┘                   pause│       └─ error → failed
                                                ▼
                                             paused ─resume→ sending

O envio é escalonado: a AHRI enfileira mensagens em lotes respeitando o max send rate negociado no SES (ex.: 50 msg/s). Cada mensagem carrega um X-CLS-Campaign-Id e um X-CLS-Contact-Id nos headers para reconciliação dos eventos SNS.

POST /v1/campaigns
{
  "name": "Black Friday 2026 - Aquecimento",
  "template_version_id": "tplv_9f3a",
  "audience": {
    "include": [{ "segment_id": "seg_engajados_30d" }],
    "exclude": [{ "list_id": "lst_ja_compraram_bf" }]
  },
  "schedule": {
    "mode": "send_at",
    "send_at": "2026-11-20T09:00:00",
    "timezone": "recipient",           // usa tz do contato; fallback America/Sao_Paulo
    "throttle_per_second": 50
  },
  "ab_test": {
    "variable": "subject",
    "variants": [
      { "key": "A", "subject": "Sua vaga na Black Friday acabou de abrir" },
      { "key": "B", "subject": "-40% começa agora (só para você)" }
    ],
    "sample_pct": 20,                   // 10% A + 10% B
    "winner_metric": "open_rate",
    "decide_after_minutes": 120
  }
}
Pausa não é cancelamento. pause interrompe a fila mas preserva o progresso — ao dar resume, apenas os contatos ainda não processados recebem. cancel é terminal e descarta a fila restante.

Módulo: Templates

O editor visual trabalha com blocos (texto, imagem, botão, colunas, divisor, HTML livre) serializados em MJML e compilados para HTML responsivo com inlining de CSS — requisito para compatibilidade com Outlook e Gmail. As merge tags usam sintaxe de duplo colchete com fallback obrigatório para campos opcionais.

Olá {{ contact.first_name | default: "tudo bem" }},

vimos que você se interessou por {{ contact.ultimo_produto_visto | default: "nossas soluções" }}.
Seu sou a AHRI, assistente comercial da CL Soluções.

{% if contact.plano == "trial" %}
  Seu teste termina em {{ contact.trial_expira | date: "DD/MM" }}.
{% endif %}

<a href="https://app.clsolucoes.com/checkout?utm_campaign={{ campaign.slug }}">Ativar agora</a>
Versionamento imutável. Publicar um template cria uma template_version nova; a versão anterior permanece intacta. Campanhas apontam para a version, não para o template — assim relatórios históricos sempre renderizam o conteúdo exato que foi enviado, mesmo após dezenas de edições.

Módulo: Contatos & Listas

O contato é a entidade central. E-mail é normalizado (lowercase, trim, remoção de plus-addressing opcional) e usado como chave de deduplicação dentro do workspace. Campos customizados são tipados no schema do workspace.

-- DDL simplificado (PostgreSQL)
CREATE TABLE contacts (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id  UUID NOT NULL REFERENCES workspaces(id),
  email         CITEXT NOT NULL,
  email_norm    CITEXT NOT NULL,          -- chave de dedup
  first_name    TEXT,
  last_name     TEXT,
  status        TEXT NOT NULL DEFAULT 'subscribed'
                CHECK (status IN ('subscribed','unsubscribed','bounced','complained')),
  attributes    JSONB NOT NULL DEFAULT '{}',   -- campos customizados
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (workspace_id, email_norm)
);
CREATE INDEX idx_contacts_attrs ON contacts USING GIN (attributes);

Na importação de CSV, o fluxo é:

  1. Upload & parse. Detecção de encoding (UTF-8/Latin-1) e delimitador; preview das 20 primeiras linhas.
  2. Mapeamento. Cada coluna do arquivo é vinculada a um campo (padrão ou customizado) ou marcada como ignorada.
  3. Validação. E-mails malformados vão para um relatório de rejeição; duplicatas internas do arquivo são coalescidas.
  4. Dedup & merge. Contatos existentes recebem merge não-destrutivo: campos vazios são preenchidos, campos preenchidos só são sobrescritos se a política de import for overwrite.
  5. Supressão. Registros presentes na lista de supressão são importados como unsubscribed, nunca reativados.

Módulo: Segmentação

Segmentos são construídos por regras compostas com aninhamento AND/OR. Dinâmicos recalculam no envio; estáticos congelam o conjunto de contact_id no momento da criação.

{
  "name": "Engajados últimos 30 dias, não compradores",
  "type": "dynamic",
  "match": "all",
  "rules": [
    { "field": "event.opened",  "op": "within_days", "value": 30 },
    { "field": "attributes.plano", "op": "in", "value": ["trial", "free"] },
    { "match": "any", "rules": [
        { "field": "attributes.uf", "op": "eq", "value": "SP" },
        { "field": "attributes.uf", "op": "eq", "value": "RJ" }
    ]},
    { "field": "event.purchased", "op": "not_within_days", "value": 90 }
  ]
}

Módulo: Automações

Jornadas são grafos direcionados: um gatilho insere o contato, que percorre nós de espera, condição (ramifica) e ação (enviar campanha, adicionar tag, chamar webhook, notificar time). O limite de reentrada evita que o mesmo contato seja processado em loop.

[Gatilho: entrou em "trial"]
        │
     (espera 1 dia)
        │
  <abriu e-mail de boas-vindas?>
     sim ┴ não
      │       │
 [tag: quente]  (espera 2 dias)
      │            │
 [envia oferta] [reenvia boas-vindas]

Módulo: Estatísticas

98,7%Taxa de entrega alvo
<0,1%Complaint rate (limite SES)
<5%Bounce rate (limite SES)
100msLatência de ingestão de evento

As métricas derivam dos eventos SNS (ver capítulo AWS SES) reconciliados pelo X-CLS-Campaign-Id. O dashboard mostra funil (enviado → entregue → aberto → clicado), série temporal de aberturas, mapa de calor de cliques por link e receita atribuída via UTM.

Módulo: Supressão

A lista de supressão é a última barreira antes do SES. Ela é consultada em toda montagem de público e alimentada automaticamente por hard bounces e complaints, além de unsubscribes e entradas manuais.

Regra de ouro. Um e-mail que entrou na supressão por complaint ou hard bounce nunca é reenviado, mesmo que seja reimportado via CSV. Reativação exige ação manual explícita e auditada.

Matriz de funcionalidades por módulo

MóduloFuncionalidades-chaveAPIWebhookPapel mínimo
CampanhasDraft, agendamento por timezone, throttling, A/B, pausa/retomadacampaign.sentEditor
TemplatesEditor MJML, merge tags, versão imutável, preview por contatoEditor
Contatos & ListasImport CSV/XLSX, dedup, campos customizados, merge não-destrutivocontact.createdEditor
SegmentaçãoFiltros AND/OR aninhados, dinâmico/estático, prévia de tamanhoEditor
AutomaçõesGatilho→condição→ação, esperas, ramificação, limite de reentradajourney.enteredAdmin
EstatísticasFunil, mapa de cliques, receita por UTM, export CSV✓ (read)Viewer
SupressãoUnsubscribe, bounce, complaint, bloqueio manual, auditoriacontact.suppressedAdmin
Integração com a AHRI. Em Templates e Automações, a AHRI (motor Claude) gera variações de assunto, reescreve blocos de copy por tom e sugere o próximo passo da jornada com base no engajamento — sempre como rascunho revisável, nunca disparando sem publicação humana.
06

AWS SES — configuração

O AWS SES é o motor de entrega da AHRI. Esta configuração cobre o caminho completo até produção: verificar o domínio com Easy DKIM, publicar SPF e DMARC, definir um MAIL FROM personalizado, sair do sandbox, respeitar quota e taxa de envio, e capturar cada evento (bounce, complaint, delivery, open, click, reject) via Configuration Set + SNS. Todos os domínios de teste são subdomínios de clsolucoes.com apontando para 147.93.15.29 atrás do proxy Cloudflare — atenção: os registros de e-mail (DKIM/SPF/DMARC/MX) NÃO podem ficar proxied, apenas DNS-only.

Cloudflare proxy quebra e-mail. No painel Cloudflare, todos os CNAMEs de DKIM, o MX do MAIL FROM e os TXT de SPF/DMARC devem ficar com a nuvem cinza (DNS only). Registros de e-mail em modo proxied (nuvem laranja) fazem a verificação do SES falhar silenciosamente.

Região e credenciais IAM

Padronize a região us-east-1 (N. Virginia) — maior cota inicial e disponibilidade de todos os recursos de SES v2. A aplicação usa um usuário/role IAM com permissão mínima de envio e leitura de estatística; a publicação de eventos é feita pelo próprio SES para o SNS, não pela aplicação.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SesSendOnly",
      "Effect": "Allow",
      "Action": [
        "ses:SendEmail",
        "ses:SendRawEmail",
        "ses:SendBulkEmail"
      ],
      "Resource": "arn:aws:ses:us-east-1:123456789012:identity/clsolucoes.com",
      "Condition": {
        "StringEquals": {
          "ses:FromAddress": "[email protected]"
        }
      }
    },
    {
      "Sid": "SesReadStats",
      "Effect": "Allow",
      "Action": [
        "ses:GetSendQuota",
        "ses:GetAccountSendingEnabled",
        "ses:ListConfigurationSets"
      ],
      "Resource": "*"
    }
  ]
}

Passo 1 — Verificação de domínio e Easy DKIM

Verifique o subdomínio de marketing mkt.clsolucoes.com (não o apex — isolar o domínio de envio protege a reputação do domínio corporativo). O Easy DKIM gera três CNAMEs; publique-os no Cloudflare como DNS only.

# CNAMEs do Easy DKIM (exemplo — os tokens reais vêm do console SES)
vv_a1b2c3d4e5._domainkey.mkt.clsolucoes.com  CNAME  vv_a1b2c3d4e5.dkim.amazonses.com
ww_f6g7h8i9j0._domainkey.mkt.clsolucoes.com  CNAME  ww_f6g7h8i9j0.dkim.amazonses.com
xx_k1l2m3n4o5._domainkey.mkt.clsolucoes.com  CNAME  xx_k1l2m3n4o5.dkim.amazonses.com

Passo 2 — SPF, MAIL FROM personalizado e DMARC

O SPF do envelope é validado no domínio MAIL FROM, não no From visível. Configure um MAIL FROM personalizado (bounce.mkt.clsolucoes.com) para alinhar SPF e habilitar DMARC por SPF, além do DKIM.

HostTipoValorProxy
bounce.mkt.clsolucoes.comMX10 feedback-smtp.us-east-1.amazonses.comDNS only
bounce.mkt.clsolucoes.comTXT"v=spf1 include:amazonses.com ~all"DNS only
mkt.clsolucoes.comTXT"v=spf1 include:amazonses.com ~all"DNS only
_dmarc.mkt.clsolucoes.comTXT"v=DMARC1; p=quarantine; rua=mailto:[email protected]; adkim=s; aspf=s; pct=100"DNS only
Endereço regional. O MX do MAIL FROM usa feedback-smtp.<região>.amazonses.com. Em us-east-1, é feedback-smtp.us-east-1.amazonses.com. Comece com p=quarantine e migre para p=reject após 2–4 semanas de relatórios DMARC limpos.

Passo 3 — Sair do sandbox (acesso a produção)

Em sandbox, o SES só envia para endereços/domínios verificados, com quota de 200 msg/dia e 1 msg/s. Solicite produção pelo console (Account dashboard → Request production access) ou via support case. Preencha com precisão:

  1. Use case URL. https://app.clsolucoes.com e descrição do produto (plataforma de e-mail marketing AHRI).
  2. Tipo de e-mail. Marketing/transacional, com opt-in explícito e link de unsubscribe em todo envio.
  3. Gestão de bounces/complaints. Descreva o pipeline SNS → supressão automática (este é o item que mais aprova ou reprova o pedido).
  4. Volume estimado. Diário e pico (ex.: 50k/dia, pico 200k em campanhas).
  5. Como obtém as listas. Cadastro com double opt-in; nunca listas compradas.

Passo 4 — Quota e taxa de envio

Dois limites simultâneos: sending quota (mensagens por 24h corridas) e max send rate (mensagens por segundo). A AHRI lê ambos via GetSendQuota e configura o throttling da fila de campanha para nunca ultrapassar o Max24HourSend nem o MaxSendRate.

// Resposta de ses.getSendQuota()
{
  "Max24HourSend": 50000,     // quota diária (rolling 24h)
  "MaxSendRate": 50,          // mensagens por segundo
  "SentLast24Hours": 12840    // já enviadas na janela
}
Throttling não é opcional. Estourar o MaxSendRate retorna Throttling: Maximum sending rate exceeded (HTTP 454). A fila da AHRI usa um token bucket dimensionado em MaxSendRate e recua com backoff exponencial se receber 454, evitando queima de reputação.

Passo 5 — Configuration Set + Event Publishing via SNS

Crie um Configuration Set (ex.: cls-mkt-events) e associe-o a todo envio via header X-SES-CONFIGURATION-SET. Configure um event destination do tipo SNS publicando: bounce, complaint, delivery, open, click e reject. A AHRI expõe um endpoint HTTPS (https://api.clsolucoes.com/webhooks/ses) inscrito no tópico SNS.

AHRI ──SendEmail(ConfigSet)──▶ SES ──entrega──▶ Caixa do destinatário
                                │
              eventos (bounce/complaint/delivery/open/click/reject)
                                ▼
                            SNS Topic ──HTTPS──▶ /webhooks/ses ──▶ Supressão + Estatísticas
Confirmação de assinatura. O primeiro POST do SNS ao endpoint é do tipo SubscriptionConfirmation. O webhook deve fazer GET no SubscribeURL para ativar a entrega. Valide sempre a assinatura da mensagem SNS antes de processar.

Payload SNS — Bounce (hard)

{
  "notificationType": "Bounce",
  "bounce": {
    "bounceType": "Permanent",
    "bounceSubType": "General",
    "bouncedRecipients": [
      {
        "emailAddress": "[email protected]",
        "action": "failed",
        "status": "5.1.1",
        "diagnosticCode": "smtp; 550 5.1.1 user unknown"
      }
    ],
    "timestamp": "2026-07-10T13:22:41.000Z",
    "feedbackId": "01000190ab-bounce-0001"
  },
  "mail": {
    "source": "[email protected]",
    "sourceIp": "147.93.15.29",
    "sendingAccountId": "123456789012",
    "messageId": "01000190ab00cd",
    "destination": ["[email protected]"],
    "headers": [
      { "name": "X-CLS-Campaign-Id", "value": "cmp_7h2k" },
      { "name": "X-CLS-Contact-Id",  "value": "ct_9a1f" }
    ]
  }
}
Permanent → supressão imediata. bounceType: "Permanent" (hard bounce) adiciona o contato à supressão na hora. Transient (soft) só suprime após N tentativas configuráveis — retry para caixas cheias/temporárias, supressão para falhas persistentes.

Payload SNS — Complaint

{
  "notificationType": "Complaint",
  "complaint": {
    "complainedRecipients": [
      { "emailAddress": "[email protected]" }
    ],
    "complaintFeedbackType": "abuse",
    "userAgent": "Amazon SES Mailbox Simulator",
    "timestamp": "2026-07-10T13:25:10.000Z",
    "feedbackId": "01000190ab-complaint-0002"
  },
  "mail": {
    "source": "[email protected]",
    "messageId": "01000190ab00ef",
    "destination": ["[email protected]"],
    "headers": [
      { "name": "X-CLS-Campaign-Id", "value": "cmp_7h2k" },
      { "name": "X-CLS-Contact-Id",  "value": "ct_3b8e" }
    ]
  }
}
Complaint é intocável. Quem marca como spam vai para supressão permanente, sem exceção. A AWS monitora a complaint rate da conta; acima de 0,1% há alerta, acima de 0,5% pode haver pausa da conta. Manter esse número baixo é o objetivo número um do pipeline.

Suppression list da conta (AWS)

Além da supressão interna da AHRI, ative a account-level suppression list do SES, que bloqueia automaticamente endereços com bounce/complaint no nível da conta AWS — uma segunda rede de segurança independente do banco da aplicação.

aws sesv2 put-account-suppression-attributes \
  --suppressed-reasons BOUNCE COMPLAINT \
  --region us-east-1

# Consultar se um endereço está suprimido na conta
aws sesv2 get-suppressed-destination \
  --email-address [email protected] \
  --region us-east-1

.env de exemplo

# AWS SES — CL Soluções / AHRI
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

[email protected]
SES_FROM_NAME="AHRI - CL Soluções"
SES_MAIL_FROM_DOMAIN=bounce.mkt.clsolucoes.com
SES_CONFIGURATION_SET=cls-mkt-events
SES_MAX_SEND_RATE=50           # sincronizado com MaxSendRate real

# SNS — recebimento de eventos
SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:cls-ses-events
SES_WEBHOOK_URL=https://api.clsolucoes.com/webhooks/ses
SES_WEBHOOK_VERIFY_SIGNATURE=true

# Motor de IA (geração de copy pela AHRI)
ANTHROPIC_API_KEY=sk-ant-...
CLAUDE_MODEL=claude-opus-4-8
Checklist de entregabilidade. DKIM verde nos 3 CNAMEs · SPF alinhado no MAIL FROM · DMARC publicado (p=quarantinereject) · produção liberada · Configuration Set em todo envio · webhook SNS confirmado e validando assinatura · supressão de conta ativa · complaint rate <0,1% e bounce rate <5%.
07

Deliverability & warm-up

Entregar e-mail não é enviar e-mail. Entre o botão "Enviar campanha" da AHRI e a caixa de entrada do lead existe uma cadeia de reputação — IP, domínio, autenticação, histórico de engajamento — que decide se a mensagem cai em Inbox, em Promoções, em Spam ou se simplesmente é descartada em silêncio. Este capítulo define os limiares operacionais da CL Soluções para o Amazon SES e o processo de aquecimento (warm-up) que evita a suspensão da conta.

Limiar crítico. A AWS pausa a conta de envio automaticamente quando a taxa de reclamação (Complaint rate) ultrapassa 0,1% ou a taxa de retorno (Bounce rate) ultrapassa 5%. A pausa é da conta inteira, não da campanha — todas as marcas atendidas pela CL Soluções param de enviar até a revisão manual pelo AWS Trust & Safety, que leva de 24 h a 72 h. Trate estes dois números como SLO de produção.

Os quatro pilares da entregabilidade

Reputação

Confiança que os provedores (Gmail, Outlook, Yahoo) depositam no seu par IP + domínio. Construída ao longo de semanas, destruída em uma campanha ruim. Medida pela SES Reputation Dashboard e por sinais externos (Google Postmaster, SNDS).

Autenticação

SPF, DKIM e DMARC alinhados provam que a AHRI tem autorização para enviar em nome do domínio da marca. Sem os três, Gmail e Yahoo (desde fev/2024) rejeitam ou marcam como spam envios em massa.

Higiene de lista

Endereços válidos, engajados e coletados com consentimento. Hard bounces e spamtraps são veneno de reputação — um único spamtrap pode derrubar a taxa de inbox por semanas.

Conteúdo & engajamento

Estrutura HTML limpa, versão texto, ausência de gatilhos de spam, e — o sinal moderno mais forte — pessoas abrindo, respondendo e não deletando sem ler.

Autenticação: SPF, DKIM e DMARC alinhados

A AHRI envia pelos subdomínios *.clsolucoes.com apontados ao IP 147.93.15.29 atrás do proxy Cloudflare. Use um subdomínio dedicado por finalidade (ex.: mkt.clsolucoes.com para marketing, news.clsolucoes.com para newsletter) para isolar a reputação transacional da promocional. Os registros DNS ficam no Cloudflare com proxy desativado (nuvem cinza) — registros MX, SPF, DKIM e DMARC nunca passam pelo proxy laranja.

; --- SPF (TXT no envelope-from / MAIL FROM customizado) ---
mkt.clsolucoes.com.  TXT  "v=spf1 include:amazonses.com -all"

; --- DKIM (SES Easy DKIM gera 3 CNAMEs de 2048 bits) ---
abc123._domainkey.clsolucoes.com.  CNAME  abc123.dkim.amazonses.com.
def456._domainkey.clsolucoes.com.  CNAME  def456.dkim.amazonses.com.
ghi789._domainkey.clsolucoes.com.  CNAME  ghi789.dkim.amazonses.com.

; --- MAIL FROM customizado (alinha o SPF ao domínio visível) ---
mkt.clsolucoes.com.        MX   10 feedback-smtp.sa-east-1.amazonses.com.
mkt.clsolucoes.com.        TXT  "v=spf1 include:amazonses.com ~all"

; --- DMARC (comece em p=none para observar, depois endureça) ---
_dmarc.clsolucoes.com.  TXT  "v=DMARC1; p=none; rua=mailto:[email protected]; \
  ruf=mailto:[email protected]; fo=1; adkim=s; aspf=s; pct=100"
Alinhamento é diferente de "passar". Um e-mail pode passar no SPF via amazonses.com e ainda assim desalinhar no DMARC se o domínio do MAIL FROM não bater com o From: visível. Por isso o MAIL FROM customizado (mkt.clsolucoes.com) é obrigatório: sem ele o SPF alinha com o domínio da Amazon, não com o seu, e o DMARC com aspf=s (strict) falha.

Progressão do DMARC ao longo do warm-up: comece em p=none (dias 1–14, só coleta relatórios), avance para p=quarantine; pct=25 quando os relatórios RUA mostrarem 100% de alinhamento por 7 dias seguidos, e chegue a p=reject; pct=100 apenas depois de ~30 dias limpos. Reject prematuro descarta e-mails legítimos de fluxos que você esqueceu (faturamento, suporte).

Plano de warm-up de IP e domínio

Um IP novo não tem reputação — para os provedores, um IP que ontem enviava zero e hoje despeja 50 mil e-mails é, por definição, um spammer. O aquecimento é uma rampa: volume crescente e consistente, priorizando os contatos mais engajados nos primeiros dias para gerar sinais positivos (aberturas, cliques, respostas) que ancoram a reputação.

  Volume/dia
  50k ┤                                          ┌──────  regime
      │                                     ┌────┘        pleno
  20k ┤                              ┌──────┘
      │                       ┌──────┘
   5k ┤                ┌──────┘   ← rampa (x~1,7/dia)
      │          ┌─────┘
  500 ┤    ┌─────┘
   50 ┤────┘
      └────┼─────┼─────┼─────┼─────┼─────┼──────►  dia
           1     5     10    15    20    30
  Engajamento alto  →→→  segmentos frios entram só após dia ~15
DiaVolume máx./diaPúblico prioritárioMeta de aberturaAção de controle
1–250Equipe interna + clientes VIP> 60%Verificar DKIM/DMARC nos cabeçalhos recebidos
3–4500Top 10% mais engajados (30 d)> 45%Bounce < 2%, complaint = 0
5–71.500Engajados 90 d> 35%Conferir Postmaster: IP reputation "High"
8–105.000Engajados 180 d> 25%Bounce < 3%, complaint < 0,05%
11–1410.000Ativos gerais> 20%Endurecer DMARC para quarantine
15–2020.000Base ativa + reengajamento cauteloso> 15%Sunset policy ativa (ver abaixo)
21–3035.000Base completa segmentada> 12%reject no DMARC; regime pleno
30+50.000+Operação normal> 10%Monitoramento contínuo
Regra do dobro. Nunca mais que dobre o volume de um dia para o outro durante a rampa, e nunca pule dias — um IP aquecido que fica 3–5 dias sem enviar "esfria" e precisa de re-rampa. Se um dia bater no limite de reclamação, recue ao volume do dia anterior por 48 h antes de retomar.

Higiene de lista e sunset policy

Nunca importe listas compradas ou raspadas — elas contêm spamtraps (endereços plantados por provedores exatamente para pegar quem envia sem consentimento). A AHRI só dispara para contatos com opt-in registrado. Regras operacionais:

  • Hard bounce (endereço inexistente, código SMTP 5xx): remover permanentemente na primeira ocorrência. A AHRI marca status='suppressed' e nunca mais reenvia.
  • Soft bounce (caixa cheia, servidor temporariamente fora, 4xx): tolerar até 3 ocorrências em 30 dias; na 4ª, suprimir.
  • Reclamação (spam): supressão imediata e definitiva, sem exceção.
  • Sunset policy: contato sem nenhuma abertura ou clique em 180 dias sai do público padrão e entra em fluxo de reengajamento único; se não reagir, é suprimido. Enviar para inativos derruba a taxa de engajamento média, que é o principal sinal do Gmail.
  • Validação prévia: rodar verificação de sintaxe + MX + descarte de e-mails descartáveis (mailinator etc.) antes de qualquer import.
-- Supressão global: uma linha por contato problemático, consultada antes de cada envio
CREATE TABLE email_suppression (
  id           BIGSERIAL PRIMARY KEY,
  email        CITEXT      NOT NULL UNIQUE,
  reason       TEXT        NOT NULL CHECK (reason IN
               ('hard_bounce','complaint','unsubscribe','sunset','manual')),
  bounce_type  TEXT,                      -- 'Permanent' | 'Transient' (SES)
  source_msg   TEXT,                      -- SES messageId que originou
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_suppression_email ON email_suppression (email);

-- Contador de soft bounces em janela móvel de 30 dias
CREATE TABLE email_soft_bounce (
  email      CITEXT      NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Antes de enfileirar: LEFT JOIN suppression; descartar se houver match.

Feedback loops e processamento de eventos SES

O SES publica bounces e complaints em um tópico SNS que a AHRI consome para atualizar a supressão em tempo real. Sem esse loop fechado, você continua enviando para quem reclamou — e a AWS enxerga isso como reincidência agravante.

// Handler NestJS do webhook SNS (SES notifications)
@Post('ses/notifications')
async handleSes(@Body() msg: SnsMessage) {
  const n = JSON.parse(msg.Message);
  if (n.notificationType === 'Bounce' && n.bounce.bounceType === 'Permanent') {
    for (const r of n.bounce.bouncedRecipients)
      await this.suppress(r.emailAddress, 'hard_bounce', n.mail.messageId);
  }
  if (n.notificationType === 'Complaint') {
    for (const r of n.complaint.complainedRecipients)
      await this.suppress(r.emailAddress, 'complaint', n.mail.messageId);
  }
  return { ok: true }; // SNS exige 200 rápido, senão reentrega
}

Conteúdo: evitar gatilhos de spam

Filtros modernos são bayesianos e olham engajamento, mas heurísticas de conteúdo ainda pesam. Diretrizes que a AHRI aplica na geração via Claude:

  • Proporção texto/imagem saudável — nunca um e-mail de imagem única com pouco texto (padrão clássico de spam que burla filtros de palavra).
  • Sempre gerar versão texto plano (multipart/alternative). E-mail só-HTML tem score de spam maior.
  • Evitar ALL CAPS no assunto, excesso de !!!, "GRÁTIS", "CLIQUE AQUI AGORA", "$$$", "100% garantido".
  • Nada de encurtadores genéricos (bit.ly) — usar domínio próprio de rastreamento (link.clsolucoes.com) com o mesmo alinhamento.
  • Um único domínio de link por e-mail; misturar muitos domínios cheira a phishing.
  • Link de descadastro visível + cabeçalho List-Unsubscribe com one-click (RFC 8058), exigido por Gmail/Yahoo para remetentes em massa.
List-Unsubscribe: <https://mkt.clsolucoes.com/u/{{token}}>, <mailto:[email protected]>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Monitoramento de reputação

<0,1%Complaint rate (teto AWS)
<5%Bounce rate (teto AWS)
<2%Bounce alvo interno CL
<0,05%Complaint alvo interno CL
FonteO que observarCadência
SES Reputation DashboardBounce & Complaint agregados da conta; status "Healthy / Under Review / Paused"Diária + alarme CloudWatch
Google Postmaster ToolsIP/Domain reputation, spam rate, autenticação, encryptionDiária durante warm-up
Microsoft SNDS / JMRPReputação junto ao Outlook/Hotmail; feedback loopSemanal
Relatórios DMARC (RUA)% de mensagens alinhadas, fontes não autorizadasDiária (agregada)
CloudWatch AlarmsCorte automático de fila se complaint > 0,08%Tempo real
Circuit breaker. Configure um alarme CloudWatch na métrica Reputation.ComplaintRate em 0,08% (80% do teto) que dispara uma Lambda para pausar a fila da AHRI antes que a AWS pause a conta inteira. É sempre melhor você mesmo parar do que ser parado — a pausa auto-imposta não gera revisão do Trust & Safety.
08

Templates & campanhas

O editor de templates e o fluxo de campanha são onde a operação de e-mail encontra a experiência do usuário. A CL Soluções entrega um editor visual de blocos, personalização por merge tags com fallback garantido, versão texto automática e um funil de criação com trava de conformidade (remetente verificado + descadastro obrigatório) antes de qualquer mensagem entrar na fila do SES.

Editor visual por blocos

O editor é construído em React + Tailwind + TypeScript e serializa o layout para um JSON canônico — nunca HTML solto. O JSON é a fonte da verdade; o HTML de e-mail (tabelas role="presentation", inline styles, compatível com Outlook) e o texto plano são renderizados a partir dele no backend. Isso garante que preview, teste e envio real usem exatamente a mesma saída.

Texto

Rich text com merge tags, negrito, itálico, link. Herda tipografia do tema da marca.

Imagem

Upload para CDN próprio, alt obrigatório, largura responsiva, link opcional. Bloqueia peso > 1 MB.

Botão

CTA "bulletproof" (VML para Outlook), cor do tema, URL com UTM automático e rastreamento em link.clsolucoes.com.

Colunas

1, 2 ou 3 colunas que colapsam para pilha única no mobile via media query embutida.

Divisor / Espaço

Régua e espaçador controlados em px, para respiro visual sem HTML manual.

Rodapé

Bloco travado: endereço físico do remetente (CAN-SPAM/LGPD) + link de descadastro. Não removível.

// Documento de template — fonte da verdade (validado por JSON Schema)
{
  "version": "1.0",
  "theme": { "brand": "cl-solucoes", "primary": "#0B5FFF", "font": "Inter, Arial, sans-serif" },
  "blocks": [
    { "type": "image", "src": "https://cdn.clsolucoes.com/hdr.png",
      "alt": "CL Soluções", "width": 600, "href": null },
    { "type": "text", "html": "Olá {{nome|cliente}}, a {{empresa}} preparou uma proposta pra você." },
    { "type": "button", "label": "Ver proposta",
      "href": "https://app.clsolucoes.com/p/{{proposta_id}}", "utm_campaign": "jul26" },
    { "type": "columns", "cols": 2, "stackOnMobile": true, "children": [ /* ... */ ] },
    { "type": "footer", "locked": true }
  ]
}
// JSON Schema (trecho) — o backend rejeita templates fora do contrato
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["version", "blocks"],
  "properties": {
    "blocks": {
      "type": "array", "minItems": 1,
      "items": {
        "type": "object", "required": ["type"],
        "properties": {
          "type": { "enum": ["text","image","button","columns","divider","spacer","footer"] },
          "alt":  { "type": "string", "minLength": 1 }
        },
        "allOf": [{
          "if":   { "properties": { "type": { "const": "image" } } },
          "then": { "required": ["src","alt","width"] }
        }]
      }
    }
  }
}

Merge tags, sintaxe e fallback

Personalização usa a sintaxe {{campo|fallback}}. O fallback não é opcional na prática: um {{nome}} vazio produz "Olá , tudo bem?" — sinal amador que reduz engajamento. A AHRI recusa o envio se qualquer merge tag sem fallback tiver taxa de preenchimento < 100% no segmento selecionado.

TagOrigemFallback recomendadoExemplo renderizado
{{nome}}contato.first_nameclienteOlá, Marina
{{nome_completo}}contato.full_name{{nome}}Marina Costa
{{empresa}}contato.companysua empresaÓtica Vista Clara
{{cargo}}contato.titleGerente Comercial
{{primeiro_nome_vendedor}}owner.first_nameequipe CLRafael
{{proposta_id}}deal.public_idbloqueia envio se vazioa1B2c3
{{cidade}}contato.citysua regiãoCuritiba
{{unsub_url}}sistemahttps://mkt…/u/xyz
Fallback encadeado. A sintaxe aceita cadeia: {{nome_completo|nome|"cliente"}} tenta o nome completo, depois o primeiro nome, e só então a string literal. Campos marcados como obrigatórios de negócio (ex.: proposta_id) não têm fallback — a ausência deles é erro de dados, não de cópia, e deve interromper o envio para aquele contato específico.
// Resolução de merge com fallback encadeado (TypeScript)
function resolveTag(expr: string, ctx: Record<string, unknown>): string {
  const parts = expr.split('|').map(s => s.trim());
  for (const p of parts) {
    if (p.startsWith('"') && p.endsWith('"')) return p.slice(1, -1); // literal
    const v = ctx[p];
    if (v !== undefined && v !== null && String(v).trim() !== '') return String(v);
  }
  return ''; // nenhum valor: marca contato como 'skip_missing_data'
}
// render: template.replace(/\{\{([^}]+)\}\}/g, (_, e) => resolveTag(e, ctx))

Versão texto plano automática

Toda campanha vira multipart/alternative. A versão texto é derivada do JSON, não do HTML: cada bloco tem uma projeção textual — botão vira Ver proposta: <url>, imagem vira seu alt, colunas viram parágrafos sequenciais. Isso garante coerência e evita o texto plano "sujo" que resulta de strip de HTML.

Preview, teste e conformidade

  1. Preview responsivo. Alterna desktop (600px) e mobile (375px) lado a lado, com dados reais de um contato-amostra do segmento — não lorem ipsum.
  2. Inspeção de merge. Painel lista cada tag, sua taxa de preenchimento no segmento e o fallback que será usado. Tags abaixo de 100% aparecem em alerta.
  3. Teste de envio. Dispara para até 5 endereços internos passando pelo pipeline real de renderização + SES sandbox, para conferir renderização no Gmail/Outlook/Apple Mail de verdade.
  4. Checklist de conformidade. Trava o botão "Agendar" até: remetente verificado (DKIM ok), rodapé com endereço físico presente, List-Unsubscribe injetado, versão texto gerada.
Descadastro é lei, não feature. O bloco de rodapé com link de descadastro é locked: true e não pode ser removido no editor. Um envio sem {{unsub_url}} resolvido e sem cabeçalho List-Unsubscribe é bloqueado pelo validador de saída — sem exceção, nem para campanhas "internas". Exigido por CAN-SPAM, LGPD e pelas regras de bulk sender do Gmail/Yahoo.

Fluxo de criação de campanha

 ┌──────────┐   ┌──────────┐   ┌───────────┐   ┌─────────────┐
 │ 1. Lista │──▶│2. Template│──▶│3. Assunto │──▶│4. Remetente │
 │/ segmento│   │           │   │+ preheader│   │ verificado  │
 └──────────┘   └──────────┘   └───────────┘   └──────┬──────┘
                                                       │
   ┌──────────────┐   ┌───────────┐   ┌─────────────┐ │
   │ 7. Fila SES  │◀──│6. Confirm.│◀──│5. Agendamento│◀┘
   │ (rate-limit) │   │(checklist)│   │ + timezone   │
   └──────────────┘   └───────────┘   └─────────────┘
  1. Lista / segmento. Escolhe público, aplica supressão global (cap. 7) e mostra o tamanho final estimado após remover suprimidos e inativos.
  2. Template. Seleciona ou cria; herda tema da marca.
  3. Assunto e preheader. Ambos aceitam merge tags. AHRI sugere variações via Claude e alerta sobre gatilhos de spam e comprimento (assunto ideal 30–50 caracteres; preheader 40–100).
  4. Remetente verificado. Só lista identidades com DKIM validado no SES. Nome amigável + from alinhado ao domínio autenticado. O remetente é sempre a empresa — a AHRI nunca personifica o cliente no campo From.
  5. Agendamento. Imediato, agendado (com fuso do destinatário) ou por gatilho. Respeita o teto de volume do warm-up vigente.
  6. Confirmação. Checklist de conformidade + resumo: público N, remetente, assunto, horário, custo estimado.
  7. Fila. Entra na fila do SES respeitando o rate limit da conta e a rampa de warm-up; progresso em tempo real.
-- Estado da campanha (máquina de estados servidor-autoritativa)
CREATE TABLE campaign (
  id           BIGSERIAL PRIMARY KEY,
  brand_id     BIGINT NOT NULL REFERENCES brand(id),
  template_id  BIGINT NOT NULL REFERENCES template(id),
  segment_id   BIGINT NOT NULL REFERENCES segment(id),
  subject      TEXT   NOT NULL,
  preheader    TEXT,
  from_name    TEXT   NOT NULL,
  from_email   CITEXT NOT NULL,                 -- deve ter DKIM verificado
  status       TEXT   NOT NULL DEFAULT 'draft'
               CHECK (status IN ('draft','review','scheduled','queued',
                                 'sending','sent','paused','failed')),
  scheduled_at TIMESTAMPTZ,
  recipient_tz BOOLEAN NOT NULL DEFAULT false,  -- usar fuso do destinatário
  created_by   BIGINT NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT scheduled_needs_time
    CHECK (status <> 'scheduled' OR scheduled_at IS NOT NULL)
);
Uma campanha, uma verdade. Transições de status são exclusivas do backend. O front nunca marca sent; ele reflete o que o worker do SES reportou. Assim, um refresh, dois operadores ou uma aba esquecida aberta jamais disparam a mesma campanha duas vezes — a transição scheduled → queued é idempotente e protegida por lock de linha.
09

Segmentação & automações

Segmentos são consultas vivas sobre a base de contatos; automações são máquinas de estado que reagem a gatilhos e executam ações no tempo. Juntos, tiram a AHRI do modo "disparo manual" e a colocam no modo "jornada contínua": cada contato recebe a mensagem certa no momento certo sem intervenção humana.

Segmentos dinâmicos vs. listas estáticas

Uma lista é um conjunto fixo de contatos — você adiciona e remove manualmente. Um segmento é uma consulta salva: sempre que consultado, retorna os contatos que naquele instante satisfazem os critérios. Contatos entram e saem sozinhos conforme seus atributos, eventos e engajamento mudam. Na CL Soluções, a regra é: use lista para importações e coortes congeladas; use segmento para tudo que depende de comportamento.

Por atributo

Campos do perfil: cidade = "Fortaleza", plano IN ("pro","enterprise"), tags CONTÉM "lead-quente". Estável, avaliado a cada leitura.

Por evento

Ação registrada na timeline: abriu nos últimos 30d, clicou em qualquer campanha, comprou pelo menos 1x. Depende do histórico de tracking (Cap. 10).

Por engajamento

Derivado: ativo = abriu ou clicou nos últimos 90d; inativo = nenhum evento positivo há 90d+; nunca engajou = 0 aberturas desde o opt-in.

Modelo de regra de segmento (JSON)

Todo segmento é serializado como uma árvore booleana de condições. Este é o formato que o front (React) envia e que o back (NestJS) traduz para SQL parametrizado — nunca concatene valores crus na query.

{
  "segmentId": "seg_ativos_fortaleza",
  "name": "Ativos em Fortaleza — plano Pro",
  "match": "all",            // all = AND | any = OR
  "rules": [
    { "field": "attr.cidade",     "op": "eq",       "value": "Fortaleza" },
    { "field": "attr.plano",      "op": "in",       "value": ["pro", "enterprise"] },
    { "field": "attr.tags",       "op": "contains", "value": "lead-quente" },
    {
      "match": "any",
      "rules": [
        { "field": "event.opened", "op": "within_days", "value": 90 },
        { "field": "event.clicked", "op": "within_days", "value": 90 }
      ]
    },
    { "field": "suppressed",      "op": "eq",       "value": false }
  ]
}
OperadorAplica aSemântica
eq / neqstring, número, boolIgualdade estrita
in / not_inlistasPertence ao conjunto
containsarrays (tags)Array possui o elemento
gt / lt / gte / ltenúmero, dataComparação ordinal
within_daysevent.*Ocorreu nos últimos N dias
neverevent.*Nunca ocorreu (0 registros)
is_empty / is_setqualquer atributoCampo nulo / preenchido
Regra de ouro. suppressed = false é implícito e obrigatório em todo segmento usado para envio. O back injeta esse filtro mesmo que o front não o mande — supressão nunca é opcional (ver Cap. 10).

Tradução para SQL (referência)

O exemplo acima compila para algo próximo de:

SELECT c.id, c.email
FROM contacts c
WHERE c.org_id = $1
  AND c.attr->>'cidade' = $2
  AND c.attr->>'plano' = ANY($3)
  AND c.tags @> $4::jsonb          -- contains 'lead-quente'
  AND EXISTS (
    SELECT 1 FROM events e
    WHERE e.contact_id = c.id
      AND e.type IN ('opened','clicked')
      AND e.occurred_at >= now() - interval '90 days'
  )
  AND c.suppressed = false;

Anatomia de uma automação

Uma automação (ou jornada) é um grafo direcionado de nós. Cada contato que entra vira uma instância de execução que caminha pelo grafo mantendo seu próprio estado (nó atual, timestamp do próximo passo, variáveis). Três famílias de nós:

Gatilho entrada

Inicia a jornada. Novo contato criado, entrou em segmento, data/aniversário atingido, evento (carrinho abandonado, comprou), ou webhook externo.

Condição ramificação

Split sim/não ou multi-branch. Avalia atributo, evento ou variável e roteia a instância. Ex.: abriu o e-mail anterior?

Ação efeito

Enviar e-mail, esperar (delay), taguear, mover de lista, chamar webhook, notificar vendedor, ou encerrar a jornada.

Gatilhos suportados

GatilhoDispara quandoPayload disponível
contact.createdNovo opt-in confirmadocontato completo
segment.enteredContato passa a satisfazer um segmentocontato + segmentId
date.reachedCampo de data == hoje (cron diário)contato + campo
cart.abandonedCarrinho sem checkout há 1h+contato + itens + valor
purchase.completedPedido pagocontato + pedido
webhook.receivedPOST externo com contactIdcorpo do webhook

Fluxo 1 — Boas-vindas (onboarding)

Gatilho contact.created. Objetivo: apresentar a marca, qualificar e gerar a primeira conversão. Régua de 3 toques em 5 dias com ramificação por engajamento.

[contact.created]
       |
       v
  (enviar) E-mail 1 · Boas-vindas + cupom
       |
       v
  (esperar 2 dias)
       |
       v
  <abriu E-mail 1?> --- nao ---> (enviar) E-mail 2b · reenvio outro assunto
       | sim                              |
       v                                 v
  (enviar) E-mail 2a · caso de uso   (esperar 3 dias)
       |                                 |
       +----------------+----------------+
                        v
                <clicou algum link?>
                   | sim        | nao
                   v            v
            (taguear         (mover p/ lista
             "lead-quente")   "reengajar")
                   |            |
                   v            v
              (notificar     (encerrar)
               vendedor)

Definição da jornada (JSON)

{
  "journeyId": "jrn_boas_vindas",
  "trigger": { "type": "contact.created" },
  "reentry": "once",             // once | always
  "nodes": [
    { "id": "n1", "type": "action", "action": "send_email",
      "templateId": "tpl_welcome", "next": "n2" },
    { "id": "n2", "type": "delay", "wait": { "days": 2 }, "next": "n3" },
    { "id": "n3", "type": "condition",
      "if": { "field": "event.opened", "op": "eq", "value": "tpl_welcome" },
      "then": "n4a", "else": "n4b" },
    { "id": "n4a", "type": "action", "action": "send_email",
      "templateId": "tpl_usecase", "next": "n5" },
    { "id": "n4b", "type": "action", "action": "send_email",
      "templateId": "tpl_welcome_resend", "next": "n5" },
    { "id": "n5", "type": "condition",
      "if": { "field": "event.clicked", "op": "within_days", "value": 5 },
      "then": "n6a", "else": "n6b" },
    { "id": "n6a", "type": "action", "action": "add_tag", "tag": "lead-quente",
      "next": "n7" },
    { "id": "n7", "type": "action", "action": "notify_owner",
      "channel": "slack", "next": "end" },
    { "id": "n6b", "type": "action", "action": "move_to_list",
      "listId": "lst_reengajar", "next": "end" },
    { "id": "end", "type": "exit" }
  ]
}

Fluxo 2 — Recuperação de carrinho abandonado

Gatilho cart.abandoned. A janela de conversão de carrinho é curta: o primeiro toque em ~1h vale mais que qualquer outro. Encerre a jornada imediatamente se o gatilho purchase.completed chegar para o mesmo contato — ninguém deve receber "esqueceu algo?" depois de comprar.

  1. +1h. E-mail lembrete simples com os itens e um botão "finalizar compra". Sem desconto ainda.
  2. +24h. Se não comprou: prova social + escassez ("últimas unidades"). Ainda sem desconto.
  3. +48h. Se não comprou: cupom de 10% com validade de 24h. Último toque.
  4. A qualquer momento. purchase.completed → encerra a instância e remove o contato da jornada.
Cancelamento por evento. Toda jornada de carrinho precisa de um exit condition global: { "cancelOn": "purchase.completed" }. Sem isso, você desconta 10% de quem já ia comprar sem cupom e queima margem.

Fluxo 3 — Régua de relacionamento (nurturing)

Gatilho segment.entered no segmento lead-morno. Cadência educativa de 4 semanas, 1 e-mail por semana, com early exit se o lead esquentar. A AHRI gera o corpo de cada toque a partir de um briefing e do estágio do funil (ver capítulo de prompts).

SemanaTemaCTACondição de saída
1Dor + diagnósticoLer artigo
2Estudo de casoVer caseclicou 2x → lead-quente
3Comparativo / objeçõesBaixar guia
4Oferta + agendaFalar com vendasagendou → encerra

Modelo de dados de automação (resumido)

Quatro tabelas sustentam o motor. journeys guarda a definição; journey_runs é o estado por contato; journey_steps é o log auditável de cada nó executado; scheduled_jobs materializa os delays para o worker consumir.

CREATE TABLE journeys (
  id           TEXT PRIMARY KEY,
  org_id       TEXT NOT NULL,
  name         TEXT NOT NULL,
  trigger      JSONB NOT NULL,        -- { type, ... }
  definition   JSONB NOT NULL,        -- nodes[]
  status       TEXT NOT NULL DEFAULT 'draft', -- draft|active|paused
  reentry      TEXT NOT NULL DEFAULT 'once',
  cancel_on    TEXT,                  -- evento que aborta a run
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE journey_runs (
  id           BIGSERIAL PRIMARY KEY,
  journey_id   TEXT NOT NULL REFERENCES journeys(id),
  contact_id   TEXT NOT NULL,
  current_node TEXT NOT NULL,
  state        JSONB NOT NULL DEFAULT '{}',
  status       TEXT NOT NULL DEFAULT 'running', -- running|done|canceled
  next_run_at  TIMESTAMPTZ,           -- quando o worker deve retomar
  entered_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (journey_id, contact_id)     -- garante reentry 'once'
);

CREATE TABLE journey_steps (
  id           BIGSERIAL PRIMARY KEY,
  run_id       BIGINT NOT NULL REFERENCES journey_runs(id),
  node_id      TEXT NOT NULL,
  action       TEXT NOT NULL,
  result       JSONB,
  executed_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_runs_due
  ON journey_runs (next_run_at)
  WHERE status = 'running' AND next_run_at IS NOT NULL;
Worker. Um job a cada minuto varre journey_runs onde next_run_at <= now() e status = 'running', executa o nó atual, grava em journey_steps e avança current_node/next_run_at. O índice parcial idx_runs_due mantém essa varredura barata mesmo com milhões de runs concluídas.
<60sLatência do worker
Reentry padrão
100%Steps auditados
10

Tracking & supressão

Sem eventos não há métrica, sem métrica não há segmento comportamental e sem supressão não há reputação de domínio. Tracking e supressão são o sistema nervoso e o sistema imunológico da plataforma: um registra tudo que o destinatário faz, o outro impede que você mande e-mail para quem já disse não — ou para quem o provedor já marcou como spam.

O que é rastreado e como

Abertura pixel

Imagem transparente 1×1 injetada no rodapé do HTML. O GET do pixel registra opened. Sinal fraco (Apple MPP e proxies inflam), mas útil em tendência.

Clique redirect

Todo link do corpo é reescrito para um endpoint de redirect com token assinado. O clique é registrado e o usuário é encaminhado ao destino real (302).

Entrega / bounce / spam SNS

O provedor de envio (SES) publica Delivery, Bounce e Complaint num tópico SNS que o webhook consome de forma assíncrona.

Pixel de abertura

Endpoint atrás do Cloudflare em track.clsolucoes.com (proxied, IP de origem 147.93.15.29). Responde sempre 200 com um GIF de 43 bytes e Cache-Control: no-store para evitar que o CDN sirva a imagem sem contar o hit.

<!-- injetado antes de </body> no HTML do e-mail -->
<img src="https://track.clsolucoes.com/o/eyJtIjoibXNnXzhkMmYiLCJyIjoicmNwdF80YTEwIn0.k3n1c9"
     width="1" height="1" alt="" style="display:none">

O caminho /o/<token> carrega, em JWT curto assinado (HS256), o messageId e o recipientId. Nada de e-mail em claro na URL.

Reescrita de links (clique)

No momento da renderização, cada href vira um link de redirect. O token carrega destino + mensagem + destinatário e é assinado para impedir open-redirect.

Original:  https://clsolucoes.com/planos?ref=news
Reescrito: https://track.clsolucoes.com/c/eyJ1IjoiaHR0cHM6Ly9jbHNvbHVjb2VzLmNvbS9wbGFub3M_cmVmPW5ld3MiLCJtIjoibXNnXzhkMmYiLCJyIjoicmNwdF80YTEwIn0.a7f2e1
// handler do redirect (NestJS) — pseudo
GET /c/:token
  payload = verifyJwt(token, HS256_SECRET)   // { u, m, r }
  if (!payload) return 400
  recordEvent({ type: 'clicked', messageId: payload.m,
                recipientId: payload.r, url: payload.u })
  return redirect(302, payload.u)             // destino ORIGINAL apenas
Open redirect. Nunca redirecione para uma URL vinda da querystring em claro. Só redirecione para o u que estava dentro do token assinado. Assinatura inválida = 400, nunca 302.

Bounces e complaints via SNS

O SES entrega notificações num tópico SNS; o endpoint https://track.clsolucoes.com/sns as recebe. Valide a assinatura da mensagem SNS e confirme a subscription (SubscriptionConfirmation) antes de processar Notification.

{
  "notificationType": "Bounce",
  "bounce": {
    "bounceType": "Permanent",        // Permanent = hard bounce
    "bounceSubType": "General",
    "bouncedRecipients": [
      { "emailAddress": "[email protected]",
        "diagnosticCode": "smtp; 550 5.1.1 user unknown" }
    ],
    "timestamp": "2026-07-10T13:22:41.000Z"
  },
  "mail": { "messageId": "msg_8d2f", "source": "[email protected]" }
}
NotificaçãoSubtipoAção automática
BouncePermanent (hard)Suprimir para sempre + evento bounced
BounceTransient (soft)Registrar; suprimir só após 3 soft bounces seguidos
ComplaintqualquerSuprimir para sempre + evento complained
DeliveryEvento delivered

Timeline do destinatário

Todo evento é gravado numa tabela append-only, chaveada por destinatário e mensagem. Isso alimenta a timeline na UI, os segmentos comportamentais (Cap. 9) e o cálculo de métricas.

CREATE TABLE events (
  id           BIGSERIAL PRIMARY KEY,
  org_id       TEXT NOT NULL,
  contact_id   TEXT NOT NULL,
  message_id   TEXT,
  campaign_id  TEXT,
  type         TEXT NOT NULL,   -- sent|delivered|opened|clicked
                                -- |bounced|complained|unsubscribed
  url          TEXT,            -- só para 'clicked'
  meta         JSONB,           -- user_agent, ip_hash, diagnostic
  occurred_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_events_contact ON events (contact_id, occurred_at DESC);
CREATE INDEX idx_events_campaign ON events (campaign_id, type);
-- dedupe de aberturas repetidas do mesmo cache de imagem:
CREATE UNIQUE INDEX uq_open_once
  ON events (message_id, contact_id)
  WHERE type = 'opened';

Exemplo de timeline (um destinatário)

QuandoEventoDetalhe
10/07 09:00:02sentCampanha "Julho — Planos"
10/07 09:00:05delivered250 OK
10/07 09:14:31openediOS Mail · pt-BR
10/07 09:15:10clicked/planos?ref=news
18/07 08:00:00unsubscribedvia link do rodapé

Supressão — a lista que sempre vence

A lista de supressão é a autoridade final: se um e-mail está nela, nenhum envio o alcança, independentemente de segmento, lista ou automação. Três entradas alimentam a supressão automaticamente e uma manualmente.

CREATE TABLE suppressions (
  org_id     TEXT NOT NULL,
  email      TEXT NOT NULL,
  reason     TEXT NOT NULL,   -- hard_bounce|complaint|unsubscribe|manual
  source     TEXT,            -- messageId ou 'admin'
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (org_id, email)
);
MotivoOrigemReversível?
hard_bounceSNS Bounce PermanentNão (endereço inexistente)
complaintSNS ComplaintNão (marcou como spam)
unsubscribeLink do rodapé / List-UnsubscribeSó por novo opt-in explícito do próprio contato
manualAdmin / solicitação LGPDManual, com registro
Nunca reenvie a suprimidos. A checagem de supressão é a última etapa antes de entregar ao SES, aplicada por e-mail, não por contactId — o mesmo endereço pode existir em vários contatos. Ignorar a supressão gera complaints, derruba a reputação do domínio e pode colocar clsolucoes.com em blocklist. Um único envio a quem marcou spam custa mais caro que a campanha inteira.

Unsubscribe honrado na hora (LGPD / CAN-SPAM)

O descadastro é imediato e sem fricção: um clique, sem login, sem "tem certeza?" obrigatório. Além do link no rodapé, envie o header List-Unsubscribe com one-click (RFC 8058), que o Gmail/Outlook renderizam como botão nativo.

List-Unsubscribe: <https://track.clsolucoes.com/u/eyJyIjoicmNwdF80YTEwIn0.9f2a>,
                  <mailto:[email protected]?subject=rcpt_4a10>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
  1. Clique. POST/GET em /u/<token> insere em suppressions com reason='unsubscribe' e grava evento unsubscribed.
  2. Efeito imediato. Qualquer journey_run ativa do contato é cancelada; ele some de todos os segmentos de envio na próxima leitura.
  3. Confirmação. Página simples "Você foi descadastrado" — sem tentativa de retenção que force novo consentimento.
LGPD. Descadastro e supressão manual atendem ao direito de oposição (art. 18). Guarde reason, source e created_at como prova de atendimento. A supressão persiste mesmo se o contato for reimportado por engano.

Métricas a partir dos eventos

Nada é pré-agregado: as taxas são calculadas por consulta sobre events, o que mantém tudo auditável e reprocessável. Base do denominador é sempre delivered (não sent) para abertura e clique.

MétricaFórmulaBenchmark saudável
Taxa de entregadelivered / sent> 98%
Taxa de aberturaopened_únicos / delivered25–45%
Taxa de clique (CTR)clicked_únicos / delivered2–6%
CTOR (clique/abertura)clicked_únicos / opened_únicos10–20%
Taxa de bouncebounced / sent< 2%
Taxa de complaintcomplained / delivered< 0,1%
Taxa de descadastrounsubscribed / delivered< 0,5%
-- Painel de uma campanha, tudo derivado de events
SELECT
  count(*) FILTER (WHERE type='sent')                         AS sent,
  count(*) FILTER (WHERE type='delivered')                    AS delivered,
  count(DISTINCT contact_id) FILTER (WHERE type='opened')     AS opens,
  count(DISTINCT contact_id) FILTER (WHERE type='clicked')    AS clicks,
  count(*) FILTER (WHERE type='bounced')                      AS bounces,
  count(*) FILTER (WHERE type='complained')                   AS complaints,
  round(100.0 * count(DISTINCT contact_id) FILTER (WHERE type='opened')
        / NULLIF(count(*) FILTER (WHERE type='delivered'),0), 1) AS open_rate
FROM events
WHERE campaign_id = 'cmp_julho_planos';
Complaint como alarme. Se a taxa de complaint de qualquer campanha passar de 0,1%, pause os envios daquele domínio automaticamente e investigue segmentação/consentimento antes de continuar. Provedores punem rápido e a recuperação de reputação é lenta.
<0,1%Complaint alvo
1×1Pixel abertura
0sLatência do opt-out
append-onlyTabela de eventos
11

Filas, workers & throttling

Enviar e-mail transacional ou de campanha direto do request HTTP é o caminho mais curto para queimar a reputação do domínio, estourar a quota do SES e duplicar mensagens quando o cliente aperta F5. Toda saída da AHRI passa por fila: o produtor só valida e enfileira, o worker consome respeitando o max send rate, aplica retry com backoff e garante idempotência.

Por que enfileirar

O Amazon SES impõe dois limites rígidos por conta/região: sending quota (mensagens por período de 24h) e max send rate (mensagens por segundo). Ultrapassar o rate devolve Throttling (HTTP 454/throttled) e ultrapassar a quota devolve LimitExceeded. Sem fila, um pico de tráfego (ex.: disparo de 40 mil e-mails de uma campanha da CL Soluções) tentaria abrir 40 mil conexões SMTP simultâneas — o SES rejeitaria a maioria e a taxa de bounce/complaint subiria, empurrando o domínio para revisão ou suspensão.

Suavização de pico

A fila absorve o burst e o worker drena no ritmo exato que o SES aceita. O produtor responde ao usuário em milissegundos; o envio real acontece em background.

Retry confiável

Falhas transitórias (throttling, timeout de rede, 5xx do SES) são reprocessadas automaticamente com backoff exponencial, sem intervenção humana.

Idempotência

Cada job carrega uma messageKey determinística. Reprocessamentos e cliques duplicados não geram e-mail duplicado.

Observabilidade

Estado por job (waiting, active, completed, failed, delayed) e DLQ dão auditoria completa: o que saiu, o que falhou e por quê.

Arquitetura de referência

Padrão CL Soluções para o back Node/NestJS: BullMQ sobre Redis. É a escolha default por dar controle fino de rate limiting, delayed jobs, prioridade e DLQ nativa sem depender da AWS para a mecânica da fila. Onde a stack já é serverless/AWS-first, o equivalente é SQS + Lambda com maxReceiveCount e uma redrive_policy apontando para uma DLQ.

  API (Next.js / NestJS)                REDIS (BullMQ)                    WORKERS                       SES
 ┌────────────────────┐    enqueue    ┌──────────────────┐   BRPOPLPUSH  ┌───────────────┐  SendEmail  ┌──────────┐
 │ POST /campanhas/:id │──────────────▶│  queue: mail:send │──────────────▶│ worker #1..N  │────────────▶│  Amazon  │
 │  valida + dedup     │              │  ┌────────────┐  │              │ token bucket  │◀────────────│   SES    │
 └────────────────────┘              │  │ waiting     │  │              │ (14 msg/s)    │  200 / 454  └────┬─────┘
          │                          │  │ delayed     │  │              └──────┬────────┘                    │
          │ grava outbox             │  │ active      │  │                     │ falha definitiva          │ SNS
          ▼                          │  │ failed ─────┼──┼──▶ DLQ mail:dead   │                     ▼ (bounce/complaint)
 ┌────────────────────┐              │  └────────────┘  │              ┌──────┴────────┐          ┌──────────────┐
 │ Postgres (outbox)  │◀─────────────┼── status update ─┘              │ retry backoff │          │ webhook SNS  │
 │ message_log        │   completed  └──────────────────┘              │ 2^n * base    │          │ → suppression│
 └────────────────────┘                                                 └───────────────┘          └──────────────┘

O outbox em Postgres é a fonte de verdade. O produtor grava a intenção de envio em message_log na mesma transação que enfileira o job (padrão transactional outbox), garantindo que nenhuma mensagem enfileirada fique sem registro e vice-versa.

Rate limiting: token bucket alinhado à quota SES

O worker não decide sozinho o quanto envia — ele consulta um token bucket global no Redis compartilhado por todos os workers. A taxa de reabastecimento é derivada do Max send rate real da conta SES, com margem de segurança de ~10% para absorver jitter e latência.

Dimensionamento. Descubra a taxa atual com aws sesv2 get-account (campo SendQuota.MaxSendRate). Se o SES informa 14 msg/s, configure o bucket para ~12 msg/s. Nunca fixe o valor em código: leia de variável de ambiente e ajuste conforme a AWS aumenta a cota com o aquecimento do domínio.

# .env do worker de e-mail
SES_MAX_SEND_RATE=14          # o que a AWS reporta hoje
SES_SAFETY_FACTOR=0.85        # usa 85% da capacidade -> ~11.9 msg/s
SES_DAILY_QUOTA=50000
QUEUE_CONCURRENCY=25          # nº de jobs simultâneos por worker
QUEUE_BACKOFF_BASE_MS=2000    # base do backoff exponencial
QUEUE_MAX_ATTEMPTS=5          # tentativas antes da DLQ
REDIS_URL=redis://:[email protected]:6379/2

BullMQ implementa o token bucket nativamente via limiter no Worker: { max, duration } significa "no máximo max jobs a cada duration ms", e o limite é global no cluster porque o estado vive no Redis.

Backoff em throttling

Nem todo erro merece o mesmo tratamento. O worker classifica a falha antes de decidir reprocessar:

Sinal do SESClasseAção do worker
Throttling / 454Transitória (rate)Retry com backoff exponencial + jitter; NÃO conta como tentativa "gasta" agressivamente — reduz temporariamente o bucket.
LimitExceeded (quota diária)Transitória (janela)Re-agenda o job com delay até a virada da janela de 24h; pausa o consumo da fila.
Timeout / 5xx / ECONNRESETTransitória (rede)Retry com backoff, até QUEUE_MAX_ATTEMPTS.
MessageRejected (endereço inválido)PermanenteFalha imediata, sem retry. Vai para DLQ e marca o destinatário na suppression list.
AccountSuspendedPermanente críticaPausa a fila inteira, dispara alerta PagerDuty/Slack. Requer ação humana.

O backoff usado é exponencial com jitter total para evitar o efeito manada (thundering herd) quando muitos jobs são throttled ao mesmo tempo: delay = random(0, base * 2^attempt), com teto (ex.: 5 min).

Dead-letter queue e deduplicação

Após esgotar as tentativas, o job vai para a fila mail:dead. A DLQ não é um cemitério: um job cron (capítulo de agendamento) inspeciona a DLQ, agrupa por causa e permite replay seletivo após correção (ex.: o domínio saiu do sandbox, ou o template foi corrigido).

A deduplicação usa uma jobId determinística derivada de campanha:destinatário:versão-template. BullMQ recusa enfileirar dois jobs com o mesmo jobId enquanto o primeiro não é removido, o que — combinado com a messageKey única no message_log (índice UNIQUE) — dá dupla proteção contra duplicidade.

-- Postgres: outbox + trava de idempotência
CREATE TABLE message_log (
  id            BIGSERIAL PRIMARY KEY,
  message_key   TEXT NOT NULL,            -- ex.: 'camp_931:[email protected]:tmpl_v4'
  campaign_id   BIGINT NOT NULL,
  recipient     TEXT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'queued',  -- queued|sent|failed|suppressed
  ses_message_id TEXT,
  attempts      INT NOT NULL DEFAULT 0,
  last_error    TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  CONSTRAINT uq_message_key UNIQUE (message_key)
);
CREATE INDEX idx_msg_campaign_status ON message_log (campaign_id, status);

-- Lista de supressão (bounces/complaints vindos do webhook SNS)
CREATE TABLE suppression (
  email       TEXT PRIMARY KEY,
  reason      TEXT NOT NULL,             -- hard_bounce|complaint|manual
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

Retomada de campanha

Uma campanha nunca é "tudo ou nada". Como cada destinatário é um job independente e o progresso vive no message_log, retomar significa: reenfileirar apenas os registros em status = 'queued' ou 'failed' daquela campanha. Reinícios do worker, deploys ou queda do Redis não reenviam quem já está sent, porque a checagem de message_key barra o duplicado.

  1. Pausar. queue.pause() interrompe o consumo; jobs active terminam graciosamente (graceful shutdown com SIGTERM aguardando o job em curso).
  2. Diagnosticar. Query em message_log por campaign_id agrupando por status mostra onde parou.
  3. Retomar. Um resume-job varre queued|failed e reenfileira com a mesma jobId determinística — os já enviados são ignorados por idempotência.

Pseudocódigo do worker

// worker/mail.worker.ts  (BullMQ + NestJS)
import { Worker, UnrecoverableError } from 'bullmq';
import { sendViaSes } from './ses.client';
import { db } from './db';

const rate = Number(process.env.SES_MAX_SEND_RATE) * Number(process.env.SES_SAFETY_FACTOR);

const worker = new Worker(
  'mail:send',
  async (job) => {
    const { messageKey, campaignId, recipient, template, params } = job.data;

    // 1) Idempotência: já enviado? aborta sem erro.
    const row = await db.oneOrNone(
      `SELECT status FROM message_log WHERE message_key = $1`, [messageKey]);
    if (row?.status === 'sent') return { skipped: true };

    // 2) Supressão: destinatário em bounce/complaint? não envia.
    const supp = await db.oneOrNone(
      `SELECT 1 FROM suppression WHERE email = $1`, [recipient]);
    if (supp) {
      await db.none(`UPDATE message_log SET status='suppressed', updated_at=now()
                     WHERE message_key=$1`, [messageKey]);
      return { suppressed: true };
    }

    // 3) Token bucket global (Redis) alinhado à cota SES
    await acquireToken('ses:bucket', rate);   // bloqueia até haver token

    try {
      const res = await sendViaSes({ recipient, template, params });
      await db.none(
        `UPDATE message_log SET status='sent', ses_message_id=$2,
         attempts=attempts+1, updated_at=now() WHERE message_key=$1`,
        [messageKey, res.MessageId]);
      return { sent: true, id: res.MessageId };

    } catch (err) {
      // 4) Classificação do erro
      if (err.name === 'MessageRejected') {
        await markFailed(messageKey, err.message);
        throw new UnrecoverableError('endereço inválido'); // vai direto p/ DLQ
      }
      if (err.name === 'Throttling' || err.$metadata?.httpStatusCode === 454) {
        await throttleBucket('ses:bucket', 0.5, 30_000); // reduz taxa 30s
        throw err; // retry com backoff (transitório)
      }
      await db.none(`UPDATE message_log SET attempts=attempts+1, last_error=$2,
                     updated_at=now() WHERE message_key=$1`, [messageKey, err.message]);
      throw err; // retry padrão
    }
  },
  {
    connection: redis,
    concurrency: Number(process.env.QUEUE_CONCURRENCY),
    limiter: { max: Math.floor(rate), duration: 1000 }, // token bucket global
  }
);

// Configuração de retry/backoff no PRODUTOR ao enfileirar:
// queue.add('send', data, {
//   jobId: messageKey,                       // dedup determinística
//   attempts: Number(process.env.QUEUE_MAX_ATTEMPTS),
//   backoff: { type: 'exponential', delay: Number(process.env.QUEUE_BACKOFF_BASE_MS) },
//   removeOnComplete: 1000, removeOnFail: false,
// });

worker.on('failed', (job, err) => {
  if (job.attemptsMade >= job.opts.attempts) {
    // esgotou: BullMQ move para a DLQ configurada / marca failed permanente
    metrics.increment('mail.dead_letter', { reason: err.name });
  }
});
Graceful shutdown obrigatório. No deploy, capture SIGTERM e chame await worker.close() antes de encerrar o processo. Sem isso, um job active é morto no meio do SendEmail — a mensagem pode ter saído no SES mas o message_log ainda estar queued, gerando reenvio no próximo boot. O close() espera o job em curso terminar e atualizar o outbox.
Checklist de produção. Bucket lido de env e não hardcoded; DLQ com replay; UNIQUE(message_key) no banco; webhook SNS de bounce/complaint alimentando suppression; alerta quando profundidade da fila > N ou taxa de failed > 2%; e dashboard com waiting/active/delayed/failed por campanha.
12

Módulo conversacional & checkout IA

A AHRI não é um FAQ com botões. Ela é uma vendedora: conduz a conversa do primeiro "oi" ao pagamento confirmado — apresenta o produto, calcula frete pelo CEP, contorna objeção, oferece upsell e cupom, e no fim abre um checkout transparente e seguro ou gera o QR Code PIX. O que ela nunca faz: tocar em número de cartão, CVV ou foto de cartão.

Regra PCI inegociável. A AHRI NUNCA coleta, pede, aceita ou armazena número de cartão, CVV/CVC, validade ou foto/print de cartão — por chat, voz, imagem ou qualquer canal. Fazer isso é fraude em potencial e viola o PCI-DSS, jogando toda a operação da CL Soluções para o escopo mais caro e arriscado de conformidade. Dados de cartão são digitados EXCLUSIVAMENTE no ambiente certificado do gateway (checkout transparente/hospedado, tokenização no browser). Se o cliente colar o número no chat, a IA descarta a mensagem, não repete o dado, e reconduz para o link seguro.

O que a IA faz — e onde ela para

Descoberta & produto

Entende a necessidade, recomenda SKU, tira dúvida de tamanho/compatibilidade, mostra prova social e estoque.

Frete por CEP

Coleta o CEP, chama a API de frete/transportadora, apresenta prazo e valor, e usa o frete como alavanca ("faltam R$ 40 para frete grátis").

Objeção & upsell

Responde preço, garantia, troca; oferece combo, item complementar (cross-sell) e cupom de recuperação.

Pagamento — mão passa ao gateway

Para PIX gera o QR. Para cartão, abre o checkout seguro. A IA vende no checkout, mas o dado sensível é digitado no ambiente do gateway.

Fluxo PIX

 Cliente            AHRI (Claude)          Backend/Orquestrador       Gateway (PIX)        Banco/SPI
   │  "quero esse"     │                          │                        │                 │
   ├──────────────────▶│  confirma SKU+frete      │                        │                 │
   │                   ├─────────────────────────▶│ cria pedido (pending)  │                 │
   │                   │                          ├───────────────────────▶│ POST /pix/charge│
   │                   │                          │◀───────────────────────┤ qr_code+txid    │
   │  QR + copia&cola  │◀─────────────────────────┤ devolve payload PIX    │                 │
   │◀──────────────────┤ "É só escanear 👇"       │                        │                 │
   │  paga no app banco│                          │                        │                 │
   │═══════════════════╪══════════════════════════╪════════════════════════╪════════════════▶│ liquidação
   │                   │                          │◀── webhook paid ───────┤◀── confirmação ─┤
   │  "Pago! ✅"       │◀── evento pago ──────────┤ pedido = paid          │                 │
   │◀──────────────────┤ confirma + upsell pós    │                        │                 │

Ponto-chave: a AHRI só exibe o payload PIX (QR + copia-e-cola) que o gateway gerou. Ela não movimenta dinheiro nem conhece dados bancários do cliente. A confirmação vem por webhook assinado do gateway — nunca por "o cliente disse que pagou".

Fluxo cartão via gateway (checkout transparente)

 Cliente            AHRI (Claude)        Backend            Checkout do Gateway (iframe/hosted)   Adquirente
   │ "cartão, 3x"       │                    │                         │                             │
   ├───────────────────▶│ confirma valor+parc │                        │                             │
   │                    ├────────────────────▶│ cria sessão checkout   │                             │
   │                    │                     ├───────────────────────▶│ session_id + URL seguro     │
   │  LINK/iframe seguro │◀───────────────────┤ devolve URL            │                             │
   │◀───────────────────┤ "Abri o pagamento🔒"│                        │                             │
   │  digita cartão ─────┼─────────────────────┼───────────────────────▶│ tokeniza no browser         │
   │  (NUNCA passa pela IA nem pelo backend)   │                        ├────────────────────────────▶│ autoriza
   │                    │                     │◀── webhook approved ────┤◀──────── aprovado ──────────┤
   │  "Aprovado! ✅"    │◀── evento aprovado ─┤ pedido = paid          │                             │
   │◀───────────────────┤ NF + rastreio + upsell                       │                             │
Onde o dado sensível vive. O número do cartão viaja do browser do cliente direto para o gateway (campos hospedados/iframe do próprio gateway, com tokenização no cliente). Ele nunca passa pela AHRI, pelo prompt do Claude, pelos logs da CL Soluções nem pelo backend NestJS/Laravel. O backend só vê um token ou session_id e o resultado da autorização. Isso mantém a operação no escopo PCI reduzido (SAQ A / SAQ A-EP).

Contrato da IA: tool calls, não improviso

A AHRI não "inventa" preço, frete ou link de pagamento — ela chama ferramentas do backend. O modelo decide quando chamar; o backend é a fonte de verdade de valores e cria os recursos de pagamento.

{
  "tools": [
    {
      "name": "cotar_frete",
      "description": "Calcula prazo e valor de frete para um CEP e um conjunto de itens. Use sempre que o cliente informar o CEP ou perguntar sobre entrega.",
      "input_schema": {
        "type": "object",
        "properties": {
          "cep": { "type": "string", "pattern": "^[0-9]{5}-?[0-9]{3}$" },
          "itens": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "sku": { "type": "string" },
                "quantidade": { "type": "integer", "minimum": 1 }
              },
              "required": ["sku", "quantidade"]
            }
          }
        },
        "required": ["cep", "itens"]
      }
    },
    {
      "name": "criar_cobranca_pix",
      "description": "Cria uma cobrança PIX no gateway e retorna QR Code e copia-e-cola. Use após o cliente confirmar itens, frete e valor total.",
      "input_schema": {
        "type": "object",
        "properties": {
          "pedido_id": { "type": "string" },
          "valor_centavos": { "type": "integer", "minimum": 100 }
        },
        "required": ["pedido_id", "valor_centavos"]
      }
    },
    {
      "name": "abrir_checkout_cartao",
      "description": "Cria uma sessão de checkout seguro do gateway para pagamento com cartão e retorna a URL/iframe. NUNCA receba dados do cartão; apenas abra a sessão.",
      "input_schema": {
        "type": "object",
        "properties": {
          "pedido_id": { "type": "string" },
          "valor_centavos": { "type": "integer", "minimum": 100 },
          "parcelas": { "type": "integer", "minimum": 1, "maximum": 12 }
        },
        "required": ["pedido_id", "valor_centavos", "parcelas"]
      }
    },
    {
      "name": "aplicar_cupom",
      "description": "Valida e aplica um cupom ao pedido. Retorna o novo total. Use quando o cliente hesitar por preço ou pedir desconto.",
      "input_schema": {
        "type": "object",
        "properties": {
          "pedido_id": { "type": "string" },
          "codigo": { "type": "string" }
        },
        "required": ["pedido_id", "codigo"]
      }
    }
  ]
}

Prompt de sistema da AHRI vendedora

Você é a AHRI, consultora de vendas da CL Soluções. Seu objetivo é conduzir o
cliente até o pagamento confirmado com naturalidade, empatia e agilidade —
como uma boa vendedora de loja, não como um formulário.

## Como você vende
- Faça UMA pergunta por vez. Descubra a necessidade antes de recomendar.
- Recomende o SKU certo; não empurre o mais caro sem motivo.
- Sempre que o cliente citar CEP ou perguntar de entrega, chame `cotar_frete`.
  Use o frete como alavanca: "faltam R$ X para frete grátis".
- Trate objeções com fatos: garantia, prazo de troca, prova social, parcelamento.
- Ofereça upsell/cross-sell UMA vez, de forma relevante ao que ele comprou.
- Se ele hesitar por preço, ofereça `aplicar_cupom` (dentro da política).
- Confirme itens, frete e VALOR TOTAL em voz alta antes de cobrar.

## Pagamento
- PIX: chame `criar_cobranca_pix` e apresente o QR + copia-e-cola. Explique que
  a confirmação é automática assim que ele pagar no app do banco.
- Cartão: chame `abrir_checkout_cartao` e envie o link/checkout seguro. Diga
  que os dados do cartão são digitados no ambiente protegido do pagamento.

## PROIBIÇÕES ABSOLUTAS (segurança / PCI-DSS)
- NUNCA peça, aceite ou repita número de cartão, CVV, validade, senha do cartão
  ou foto/print do cartão. Em NENHUM canal (texto, áudio, imagem).
- Se o cliente enviar esses dados mesmo assim: NÃO os repita, NÃO os registre,
  responda "Por segurança eu não recebo dados de cartão por aqui 🔒 — é só
  finalizar no checkout seguro que abri pra você" e reconduza para o link.
- Nunca prometa desconto fora dos cupons válidos. Nunca confirme pagamento sem
  o evento do gateway; "o cliente disse que pagou" NÃO é confirmação.
- Valores, frete e estoque vêm SEMPRE das ferramentas — nunca invente números.

## Tom
Brasileiro, caloroso, direto, sem jargão. Emojis com parcimônia. Frases curtas.
Defesa em profundidade. O prompt é a primeira barreira, não a única. Antes de persistir/loggar qualquer mensagem do cliente, o backend passa por um redator (PAN scrubber) que detecta sequências de 13–19 dígitos que passam no algoritmo de Luhn e as substitui por [REDACTED_CARD]. Assim, mesmo que o cliente cole o cartão, o dado não entra em log, banco, nem no histórico enviado de volta ao Claude.

Exemplo de conversa (com tool calls)

Cliente: quero o tênis de corrida, número 42
AHRI:    Ótima escolha! O Runner Pro 42 está em estoque 👟 Me passa seu CEP
         que já calculo o frete e o prazo?
Cliente: 01310-100
[tool] cotar_frete({ cep: "01310-100", itens: [{ sku: "RUN-PRO-42", quantidade: 1 }] })
[resultado] { valor_centavos: 0, prazo_dias: 2, frete_gratis: true }
AHRI:    Boa notícia: frete GRÁTIS pra sua região, chega em 2 dias úteis 🎉
         Fica R$ 399 à vista. Quer levar junto a meia de compressão por +R$ 39?
Cliente: só o tênis mesmo. tá caro
AHRI:    Entendo! Tenho um cupom de primeira compra: BEMVINDO10.
[tool] aplicar_cupom({ pedido_id: "ped_88", codigo: "BEMVINDO10" })
[resultado] { total_centavos: 35910 }
AHRI:    Com ele fica R$ 359,10 🙌 Fecha por PIX (à vista) ou cartão (até 3x)?
Cliente: pix
[tool] criar_cobranca_pix({ pedido_id: "ped_88", valor_centavos: 35910 })
[resultado] { qr_code: "data:image/png;base64,...", copia_cola: "00020126..." }
AHRI:    Prontinho! Escaneia o QR ou usa o copia-e-cola 👇 Assim que você pagar
         no app do banco, eu confirmo aqui automaticamente. 🔒

Onde termina a responsabilidade da IA

EtapaQuem executaDado sensível?
Recomendação, frete, objeção, upsell, cupomAHRI (Claude) + tools do backendNão
Criação do pedido e da cobrançaBackend NestJS/Laravel + gatewayNão (só valores e IDs)
Geração do QR PIXGateway; AHRI apenas exibeNão
Digitação do cartão / CVVCheckout do gateway (iframe/hosted, tokenização no browser)Sim — isolado no gateway
Autorização e confirmaçãoAdquirente → webhook assinado → backendNão (só status)
O valor da ideia. A AHRI faz o que um bom vendedor faz no balcão — recomenda, contorna objeção, oferece cupom e frete, faz upsell e acompanha até o "pago" — enquanto o momento sensível do pagamento acontece no ambiente certificado do gateway. Você ganha a conversão da venda assistida por IA sem herdar o risco e o custo de conformidade de tocar em dados de cartão.
13

Modelo de dados

O schema de e-mail marketing da CL Soluções é normalizado até o ponto em que a taxa de escrita exige (contatos, listas, mensagens), e desnormalizado com jsonb onde o modelo precisa evoluir sem migração (atributos de contato, payload de evento, config de automação). Todo o DDL abaixo roda em PostgreSQL 15+ e é o contrato de dados que a AHRI consome para segmentar e gerar campanhas.

Convenções

  • Chaves primárias em uuid geradas por gen_random_uuid() (extensão pgcrypto). IDs de negócio nunca são sequenciais expostos.
  • Multi-tenant por coluna conta_id em toda tabela de topo, com Row Level Security ativado — nenhuma query cruza contas.
  • Timestamps sempre timestamptz em UTC; o front converte para America/Sao_Paulo.
  • Soft-delete via deletado_em timestamptz NULL nas entidades que precisam de auditoria (contatos, campanhas, templates).
  • Enums em tipos nativos CREATE TYPE — mais baratos que text + CHECK e autodocumentados.
Retenção de eventos. A tabela eventos é a de maior volume (opens/clicks dominam). Ela é particionada por mês em ocorrido_em; partições com mais de 13 meses são desanexadas e exportadas para o data lake (Parquet no S3). O agregado por mensagem fica materializado em mensagens para consultas rápidas sem varrer a tabela quente.
  contas
    │
    ├──< contatos >──┬──< contato_lista >──< listas
    │                └──< campos_customizados (definição)
    │
    ├──< templates >──< campanhas >──< mensagens >──< eventos (particionada/mês)
    │                      │
    │                      └── segmento (jsonb: regras)
    │
    ├──< automacoes >──< automacao_execucoes >──(gera)──> mensagens
    │
    ├──< dominios_verificados >──< remetentes
    │
    └──< supressao (bounce/complaint/unsub — bloqueia envio)

Extensões e enums

CREATE EXTENSION IF NOT EXISTS pgcrypto;   -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS citext;     -- e-mail case-insensitive
CREATE EXTENSION IF NOT EXISTS pg_trgm;    -- busca por nome

CREATE TYPE status_contato     AS ENUM ('ativo','inativo','pendente','bounced','reclamou','descadastrado');
CREATE TYPE status_campanha    AS ENUM ('rascunho','agendada','enviando','pausada','enviada','cancelada','falhou');
CREATE TYPE status_mensagem    AS ENUM ('fila','enviada','entregue','aberta','clicada','bounce','reclamou','falhou','suprimida');
CREATE TYPE tipo_evento        AS ENUM ('delivery','open','click','bounce','complaint','reject','send','rendering_failure');
CREATE TYPE tipo_bounce        AS ENUM ('hard','soft','undetermined');
CREATE TYPE motivo_supressao   AS ENUM ('hard_bounce','soft_bounce_recorrente','complaint','descadastro','manual','lista_global');
CREATE TYPE tipo_campo         AS ENUM ('texto','numero','data','booleano','enum','email','telefone');
CREATE TYPE status_automacao    AS ENUM ('ativa','pausada','arquivada');
CREATE TYPE status_execucao     AS ENUM ('agendada','processando','concluida','erro','cancelada');
CREATE TYPE status_dominio      AS ENUM ('pendente','verificando','verificado','falhou');

Contatos, listas e vínculo

CREATE TABLE contatos (
  id              uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id        uuid NOT NULL,
  email           citext NOT NULL,
  nome            text,
  telefone        text,
  status          status_contato NOT NULL DEFAULT 'ativo',
  atributos       jsonb NOT NULL DEFAULT '{}'::jsonb,  -- campos customizados: {"cidade":"Recife","plano":"pro"}
  origem          text,                                 -- 'import_csv','api','formulario','ahri'
  consentimento_em timestamptz,                          -- LGPD: quando aceitou receber
  ip_consentimento inet,
  criado_em       timestamptz NOT NULL DEFAULT now(),
  atualizado_em   timestamptz NOT NULL DEFAULT now(),
  deletado_em     timestamptz,
  CONSTRAINT uq_contato_email UNIQUE (conta_id, email)
);
CREATE INDEX idx_contatos_conta_status ON contatos (conta_id, status) WHERE deletado_em IS NULL;
CREATE INDEX idx_contatos_atributos    ON contatos USING gin (atributos jsonb_path_ops);
CREATE INDEX idx_contatos_nome_trgm    ON contatos USING gin (nome gin_trgm_ops);

CREATE TABLE listas (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id      uuid NOT NULL,
  nome          text NOT NULL,
  descricao     text,
  dupla_confirmacao boolean NOT NULL DEFAULT false,      -- double opt-in
  total_contatos integer NOT NULL DEFAULT 0,             -- contador materializado
  criado_em     timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT uq_lista_nome UNIQUE (conta_id, nome)
);

CREATE TABLE contato_lista (
  contato_id  uuid NOT NULL REFERENCES contatos(id) ON DELETE CASCADE,
  lista_id    uuid NOT NULL REFERENCES listas(id)   ON DELETE CASCADE,
  inscrito_em timestamptz NOT NULL DEFAULT now(),
  confirmado_em timestamptz,                             -- preenchido no double opt-in
  PRIMARY KEY (contato_id, lista_id)
);
CREATE INDEX idx_contato_lista_lista ON contato_lista (lista_id);

CREATE TABLE campos_customizados (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id    uuid NOT NULL,
  chave       text NOT NULL,                             -- casa com a chave em contatos.atributos
  rotulo      text NOT NULL,
  tipo        tipo_campo NOT NULL DEFAULT 'texto',
  opcoes      jsonb,                                     -- para tipo 'enum': ["pro","free","trial"]
  obrigatorio boolean NOT NULL DEFAULT false,
  CONSTRAINT uq_campo_chave UNIQUE (conta_id, chave)
);
Sobre atributos jsonb. Os valores dos campos customizados vivem em contatos.atributos; campos_customizados guarda só a definição (rótulo, tipo, validação). Isso evita uma tabela EAV e permite segmentar com operadores do Postgres: atributos->>'plano' = 'pro'. O índice GIN jsonb_path_ops acelera @> mas não desigualdades — para filtros numéricos frequentes, promova o campo a coluna real.

Templates, campanhas e mensagens

CREATE TABLE templates (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id    uuid NOT NULL,
  nome        text NOT NULL,
  assunto     text NOT NULL,                             -- suporta {{nome}} handlebars
  preheader   text,
  html        text NOT NULL,
  texto       text,                                      -- fallback text/plain
  gerado_por_ahri boolean NOT NULL DEFAULT false,
  criado_em   timestamptz NOT NULL DEFAULT now(),
  deletado_em timestamptz
);

CREATE TABLE campanhas (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id      uuid NOT NULL,
  nome          text NOT NULL,
  template_id   uuid NOT NULL REFERENCES templates(id),
  remetente_id  uuid NOT NULL REFERENCES remetentes(id),
  status        status_campanha NOT NULL DEFAULT 'rascunho',
  segmento      jsonb,                                   -- regras de audiencia (ver cap. Segmentos)
  lista_ids     uuid[] NOT NULL DEFAULT '{}',
  agendada_para timestamptz,
  enviada_em    timestamptz,
  -- agregados materializados (evita varrer eventos)
  total_destinatarios integer NOT NULL DEFAULT 0,
  total_enviadas      integer NOT NULL DEFAULT 0,
  total_entregues     integer NOT NULL DEFAULT 0,
  total_aberturas     integer NOT NULL DEFAULT 0,
  total_cliques       integer NOT NULL DEFAULT 0,
  total_bounces       integer NOT NULL DEFAULT 0,
  total_reclamacoes   integer NOT NULL DEFAULT 0,
  criado_em     timestamptz NOT NULL DEFAULT now(),
  deletado_em   timestamptz
);
CREATE INDEX idx_campanhas_conta_status ON campanhas (conta_id, status);
CREATE INDEX idx_campanhas_agenda ON campanhas (agendada_para) WHERE status = 'agendada';

CREATE TABLE mensagens (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id      uuid NOT NULL,
  campanha_id   uuid REFERENCES campanhas(id) ON DELETE SET NULL,
  automacao_id  uuid REFERENCES automacoes(id) ON DELETE SET NULL,
  contato_id    uuid NOT NULL REFERENCES contatos(id),
  status        status_mensagem NOT NULL DEFAULT 'fila',
  ses_message_id text,                                   -- MessageId retornado pelo SES
  assunto_render text,                                   -- assunto já com merge tags resolvidas
  enviada_em    timestamptz,
  entregue_em   timestamptz,
  primeira_abertura_em timestamptz,
  primeiro_clique_em   timestamptz,
  erro          text,
  CONSTRAINT chk_origem CHECK (campanha_id IS NOT NULL OR automacao_id IS NOT NULL)
);
CREATE UNIQUE INDEX uq_mensagem_ses ON mensagens (ses_message_id) WHERE ses_message_id IS NOT NULL;
CREATE INDEX idx_mensagens_campanha ON mensagens (campanha_id, status);
CREATE INDEX idx_mensagens_contato  ON mensagens (contato_id);
-- evita reenvio duplicado do mesmo contato na mesma campanha
CREATE UNIQUE INDEX uq_msg_campanha_contato ON mensagens (campanha_id, contato_id) WHERE campanha_id IS NOT NULL;

Eventos (particionada) e supressão

CREATE TABLE eventos (
  id          bigint GENERATED ALWAYS AS IDENTITY,
  conta_id    uuid NOT NULL,
  mensagem_id uuid NOT NULL,
  tipo        tipo_evento NOT NULL,
  ocorrido_em timestamptz NOT NULL DEFAULT now(),
  -- payload jsonb preserva o evento SNS bruto: url do click, user-agent, ip, diagnostic bounce
  dados       jsonb NOT NULL DEFAULT '{}'::jsonb,
  bounce_tipo tipo_bounce,
  PRIMARY KEY (id, ocorrido_em)
) PARTITION BY RANGE (ocorrido_em);

-- partições mensais (criadas por job; exemplo)
CREATE TABLE eventos_2026_07 PARTITION OF eventos
  FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE eventos_2026_08 PARTITION OF eventos
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

CREATE INDEX idx_eventos_mensagem ON eventos (mensagem_id, tipo);
CREATE INDEX idx_eventos_conta_tipo ON eventos (conta_id, tipo, ocorrido_em);
CREATE INDEX idx_eventos_dados ON eventos USING gin (dados jsonb_path_ops);

CREATE TABLE supressao (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id    uuid NOT NULL,
  email       citext NOT NULL,
  motivo      motivo_supressao NOT NULL,
  detalhe     text,                                      -- ex.: 'smtp; 550 5.1.1 user unknown'
  origem_mensagem_id uuid REFERENCES mensagens(id),
  criado_em   timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT uq_supressao UNIQUE (conta_id, email)
);
CREATE INDEX idx_supressao_email ON supressao (conta_id, email);
Regra de ouro do envio. Antes de enfileirar qualquer mensagem, o worker faz NOT EXISTS contra supressao por (conta_id, email). Um hard_bounce ou complaint insere ali imediatamente via webhook (cap. 14) — assim a próxima campanha já pula o endereço sem intervenção manual, protegendo a reputação de envio.

Automações

CREATE TABLE automacoes (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id    uuid NOT NULL,
  nome        text NOT NULL,
  status      status_automacao NOT NULL DEFAULT 'ativa',
  gatilho     jsonb NOT NULL,      -- {"tipo":"entrou_lista","lista_id":"..."} | {"tipo":"data_atributo","campo":"aniversario"}
  fluxo       jsonb NOT NULL,      -- array de passos: [{"acao":"esperar","horas":24},{"acao":"enviar","template_id":"..."}]
  criado_em   timestamptz NOT NULL DEFAULT now(),
  atualizado_em timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE automacao_execucoes (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  automacao_id  uuid NOT NULL REFERENCES automacoes(id) ON DELETE CASCADE,
  contato_id    uuid NOT NULL REFERENCES contatos(id) ON DELETE CASCADE,
  status        status_execucao NOT NULL DEFAULT 'agendada',
  passo_atual   integer NOT NULL DEFAULT 0,
  proximo_passo_em timestamptz,   -- quando o worker deve retomar o fluxo
  estado        jsonb NOT NULL DEFAULT '{}'::jsonb,
  iniciado_em   timestamptz NOT NULL DEFAULT now(),
  concluido_em  timestamptz,
  CONSTRAINT uq_execucao UNIQUE (automacao_id, contato_id)  -- 1 contato por automação
);
CREATE INDEX idx_execucoes_agenda ON automacao_execucoes (proximo_passo_em)
  WHERE status IN ('agendada','processando');

Domínios verificados e remetentes

CREATE TABLE dominios_verificados (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id    uuid NOT NULL,
  dominio     text NOT NULL,                             -- ex.: envio.clsolucoes.com
  status      status_dominio NOT NULL DEFAULT 'pendente',
  dkim_tokens text[] NOT NULL DEFAULT '{}',              -- 3 CNAMEs de DKIM do SES
  spf_ok      boolean NOT NULL DEFAULT false,
  dmarc_ok    boolean NOT NULL DEFAULT false,
  verificado_em timestamptz,
  criado_em   timestamptz NOT NULL DEFAULT now(),
  CONSTRAINT uq_dominio UNIQUE (conta_id, dominio)
);

CREATE TABLE remetentes (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  conta_id    uuid NOT NULL,
  dominio_id  uuid NOT NULL REFERENCES dominios_verificados(id) ON DELETE RESTRICT,
  nome_exibicao text NOT NULL,                           -- 'AHRI · CL Soluções'
  email_local text NOT NULL,                             -- 'contato' -> [email protected]
  responder_para text,                                   -- reply-to
  padrao      boolean NOT NULL DEFAULT false,
  CONSTRAINT uq_remetente UNIQUE (conta_id, dominio_id, email_local)
);
CREATE UNIQUE INDEX uq_remetente_padrao ON remetentes (conta_id) WHERE padrao;
Integridade do remetente. Um remetente só pode apontar para um dominio_verificados com status = 'verificado' (validado por trigger). O e-mail de envio é sempre da CL Soluções — nunca se personifica o cliente final no cabeçalho From. O reply-to pode direcionar para o cliente, mas o From permanece em domínio próprio verificado (DKIM + SPF + DMARC alinhados).

Resumo das tabelas

TabelaCardinalidadeChave de partição/índice quenteRetenção
contatosAlta(conta_id, status), GIN atributosEnquanto ativo (soft-delete)
mensagensMuito alta(campanha_id, status)18 meses
eventosMáximaRANGE ocorrido_em (mês)13 meses on-line, resto no S3
supressaoMédia(conta_id, email)Permanente
automacao_execucoesAltaproximo_passo_em parcial90 dias após concluída
14

APIs & webhooks

A API da CL Soluções é REST sobre HTTPS, versionada em /v1, com autenticação por Bearer token (chave de API por conta). Todas as respostas são JSON; erros seguem RFC 7807 (application/problem+json). O tráfego passa pelo proxy Cloudflare à frente de 147.93.15.29, então o cliente sempre lê o IP real via CF-Connecting-IP.

Autenticação e limites

  • Bearer token no header Authorization: Bearer sk_live_…. Chaves têm escopo (read, write, send) e prefixo sk_live_ / sk_test_.
  • Rate limit: 600 req/min por chave (padrão), 60 req/min em /campanhas/*/enviar. Retorna 429 com Retry-After e headers X-RateLimit-Remaining.
  • Idempotência: POST aceita header Idempotency-Key; a resposta é cacheada por 24h por chave+rota.
  • Paginação: cursor-based via ?cursor=…&limit=100 (máx. 200). Resposta traz { "dados": [...], "proximo_cursor": "…" }.

Tabela de endpoints

MétodoRotaEscopoDescrição
GET/v1/contatosreadLista/filtra contatos (query em atributos)
POST/v1/contatoswriteCria ou faz upsert por e-mail
POST/v1/contatos/importwriteImporta CSV (assíncrono, retorna job_id)
GET/v1/listasreadLista as listas e contadores
POST/v1/listaswriteCria lista (opt-in simples/duplo)
POST/v1/segmentoswriteCria segmento salvo (regras jsonb)
POST/v1/segmentos/previewreadConta destinatários sem persistir
GET/v1/templatesreadLista templates
POST/v1/templateswriteCria template (pode ser gerado pela AHRI)
POST/v1/campanhaswriteCria campanha em rascunho
POST/v1/campanhas/{id}/enviarsendDispara imediatamente
POST/v1/campanhas/{id}/agendarsendAgenda para agendada_para
POST/v1/automacoeswriteCria automação (gatilho + fluxo)
GET/v1/eventosreadConsulta eventos por mensagem/tipo/período
GET/v1/supressaoreadLista supressões
POST/v1/supressaowriteAdiciona supressão manual
DELETE/v1/supressao/{email}writeRemove (reabilita envio)
POST/v1/dominioswriteRegistra domínio, retorna DKIM CNAMEs
POST/v1/dominios/{id}/verificarwriteDispara checagem de DNS (DKIM/SPF/DMARC)
POST/webhooks/snsIngest de notificações do Amazon SNS

Criar campanha

POST /v1/campanhas HTTP/1.1
Host: api.clsolucoes.com
Authorization: Bearer sk_live_9fK2…
Content-Type: application/json
Idempotency-Key: camp-2026-07-blackfriday-01

{
  "nome": "Pré-Black Friday · Plano Pro",
  "template_id": "7c1e0b2a-3f44-4d21-9a10-2b8f6e0c1d55",
  "remetente_id": "a2b9…",
  "lista_ids": ["11111111-1111-1111-1111-111111111111"],
  "segmento": {
    "operador": "E",
    "regras": [
      { "campo": "atributos.plano", "op": "igual", "valor": "pro" },
      { "campo": "status", "op": "igual", "valor": "ativo" },
      { "campo": "atributos.cidade", "op": "em", "valor": ["Recife","Olinda"] }
    ]
  },
  "agendada_para": "2026-11-24T09:00:00-03:00"
}

HTTP/1.1 201 Created
{
  "id": "c0ffee00-…",
  "status": "agendada",
  "total_destinatarios": 3820,
  "agendada_para": "2026-11-24T12:00:00Z"
}
Fluxo de envio. POST /campanhas/{id}/enviar apenas transiciona o status para enviando e enfileira o job. A expansão do segmento, a supressão e o merge das tags acontecem no worker — a chamada HTTP retorna em milissegundos. Acompanhe o progresso por GET /campanhas/{id} (campos agregados) ou pelos webhooks.

Webhook de entrada: Amazon SNS

O SES publica notificações de bounce, complaint, delivery, open e click num tópico SNS que entrega em POST /webhooks/sns. O endpoint precisa tratar três tipos de mensagem SNS e validar a assinatura antes de confiar no payload.

  1. SubscriptionConfirmation. No primeiro handshake, faça GET na SubscribeURL para confirmar a assinatura do tópico. Só aceite URLs em sns.*.amazonaws.com.
  2. Validar assinatura. Baixe o certificado de SigningCertURL (valide o host *.amazonaws.com), monte a string canônica dos campos na ordem exigida e verifique Signature com SHA256withRSA (SignatureVersion 1). Rejeite com 403 se falhar.
  3. Notification. O Message é uma string JSON — faça parse e roteie por eventType.
  4. Responder rápido. Devolva 200 em < 15s; processamento pesado vai para fila. SNS reentrega em caso de timeout.
POST /webhooks/sns HTTP/1.1
x-amz-sns-message-type: Notification
Content-Type: text/plain; charset=UTF-8

{
  "Type": "Notification",
  "MessageId": "22b8…",
  "TopicArn": "arn:aws:sns:us-east-1:9012:cl-ses-eventos",
  "Message": "{\"eventType\":\"Bounce\",\"mail\":{\"messageId\":\"010f018c…\",\"destination\":[\"[email protected]\"]},\"bounce\":{\"bounceType\":\"Permanent\",\"bounceSubType\":\"General\",\"bouncedRecipients\":[{\"emailAddress\":\"[email protected]\",\"diagnosticCode\":\"smtp; 550 5.1.1 user unknown\"}],\"timestamp\":\"2026-07-10T14:03:11.000Z\"}}",
  "Timestamp": "2026-07-10T14:03:12.100Z",
  "SignatureVersion": "1",
  "Signature": "Base64RSA…",
  "SigningCertURL": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-abc.pem",
  "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&…"
}

Roteamento por tipo de evento

eventTypeAção em eventosAção em mensagensAção em supressao
Deliveryinsere deliveryentregue, seta entregue_em
Openinsere open (ua/ip em dados)aberta, seta primeira_abertura_em
Clickinsere click (url em dados)clicada, seta primeiro_clique_em
Bounce (Permanent)insere bounce, bounce_tipo=hardbounceinsere hard_bounce
Bounce (Transient)insere bounce, bounce_tipo=softbouncesó após N reincidências
Complaintinsere complaintreclamouinsere complaint
Nunca confie sem validar. O endpoint /webhooks/sns é público. Sem verificação de assinatura RSA + checagem do host do SigningCertURL, qualquer um poderia injetar bounces falsos e derrubar contatos legítimos para a supressão. Valide assinatura, confira que o TopicArn pertence à sua conta e só então processe.
-- upsert idempotente disparado pelo handler de Bounce permanente
WITH msg AS (
  UPDATE mensagens
     SET status = 'bounce'
   WHERE ses_message_id = $1        -- mail.messageId
  RETURNING id, conta_id, contato_id
)
INSERT INTO supressao (conta_id, email, motivo, detalhe, origem_mensagem_id)
SELECT m.conta_id, $2, 'hard_bounce', $3, m.id   -- $2 email, $3 diagnosticCode
  FROM msg m
ON CONFLICT (conta_id, email) DO NOTHING;

Verificação de domínio

POST /v1/dominios
{ "dominio": "envio.clsolucoes.com" }

HTTP/1.1 201 Created
{
  "id": "d1…",
  "status": "pendente",
  "registros_dns": [
    { "tipo": "CNAME", "nome": "abc123._domainkey.envio.clsolucoes.com", "valor": "abc123.dkim.amazonses.com" },
    { "tipo": "CNAME", "nome": "def456._domainkey.envio.clsolucoes.com", "valor": "def456.dkim.amazonses.com" },
    { "tipo": "CNAME", "nome": "ghi789._domainkey.envio.clsolucoes.com", "valor": "ghi789.dkim.amazonses.com" },
    { "tipo": "TXT", "nome": "envio.clsolucoes.com", "valor": "v=spf1 include:amazonses.com ~all" },
    { "tipo": "TXT", "nome": "_dmarc.envio.clsolucoes.com", "valor": "v=DMARC1; p=quarantine; rua=mailto:[email protected]" }
  ]
}

POST /v1/dominios/d1/verificar   -> { "status": "verificado", "spf_ok": true, "dmarc_ok": true }
Proxy Cloudflare. Os CNAMEs de DKIM devem ficar com proxy desligado (nuvem cinza) no painel Cloudflare — DKIM não pode passar pelo proxy HTTP. Somente registros web (A/AAAA para 147.93.15.29) usam nuvem laranja. Após propagar o DNS, o endpoint de verificação resolve os três tokens diretamente na autoridade e transiciona o domínio para verificado.
15

Segurança, PCI & LGPD

Enviar e-mail comercial em nome da CL Soluções é operar dados pessoais em escala. Cada contato na base é um titular protegido pela LGPD; cada clique num link de pagamento pode arrastar dados de cartão para dentro do escopo PCI-DSS. Este capítulo define os controles obrigatórios, o desenho de isolamento multi-tenant e a fronteira que os dados sensíveis NUNCA cruzam.

Regra inegociável. Dado de cartão (PAN, CVV, validade, trilha) jamais transita, é logado ou persistido no backend da AHRI. O pagamento acontece exclusivamente em página hospedada e tokenizada pelo gateway certificado. O nosso papel é SAQ-A: redirecionar/embutir o iframe do gateway e receber apenas o token/status.

Modelo de responsabilidade de dados

A CL Soluções atua como controladora dos dados de campanha e leads, e como operadora quando executa disparos em nome de clientes que trazem sua própria base. Essa distinção governa contratos (DPA), base legal e quem responde ao titular. A AHRI, como motor de IA, é subprocessadora: gera conteúdo, nunca decide sozinha sobre o dado do titular.

Controladora

Base própria de prospecção, contatos captados em landing pages *.clsolucoes.com, formulários e eventos. Responde diretamente aos titulares e à ANPD.

Operadora

Disparos para a base do cliente. Rege-se por contrato de tratamento (DPA) que define finalidade, prazo, retorno/eliminação e vedação de uso próprio.

Subprocessadores

AWS SES/SNS (entrega e eventos), Cloudflare (proxy/WAF), Claude (geração de texto). Todos listados no aviso de privacidade e sob contrato.

LGPD — bases legais e consentimento

Para e-mail marketing B2C predomina o consentimento (art. 7º, I). Para prospecção B2B legítima pode-se apoiar em legítimo interesse (art. 7º, IX), sempre com teste de proporcionalidade documentado e opt-out imediato. Escolha a base antes do disparo e registre-a por contato.

CenárioBase legalEvidência exigida
Newsletter / promoções B2CConsentimento (art. 7º, I)Timestamp, IP, origem do formulário, texto do checkbox exibido
Prospecção B2B friaLegítimo interesse (art. 7º, IX)LIA registrado, opt-out em 1 clique, dado obtido de fonte pública/negocial
Cliente ativo (pós-venda)Execução de contrato (art. 7º, V)Vínculo contratual + relação com o produto contratado
Cobrança / negativaçãoCumprimento de obrigação / exercício de direitoContrato inadimplido, base de crédito, finalidade compatível
Opt-in comprovável. Não basta ter o e-mail. É preciso provar QUANDO, COMO e COM QUAL TEXTO o titular consentiu. Persista o snapshot do consentimento, não uma flag booleana solta.

DDL do registro de consentimento

CREATE TABLE consent_log (
  id            BIGSERIAL PRIMARY KEY,
  tenant_id     UUID NOT NULL,
  contact_id    UUID NOT NULL,
  email         CITEXT NOT NULL,
  legal_basis   TEXT NOT NULL CHECK (legal_basis IN
                  ('consent','legitimate_interest','contract','legal_obligation')),
  purpose       TEXT NOT NULL,              -- ex: 'newsletter','prospeccao_b2b'
  source        TEXT NOT NULL,              -- ex: 'lp:clsolucoes.com/contato'
  checkbox_text TEXT,                       -- texto EXATO exibido ao titular
  policy_version TEXT NOT NULL,             -- versao do aviso de privacidade aceito
  double_opt_in BOOLEAN NOT NULL DEFAULT false,
  confirmed_at  TIMESTAMPTZ,               -- quando clicou no link de confirmacao
  ip_address    INET,
  user_agent    TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  revoked_at    TIMESTAMPTZ,               -- quando pediu descadastro
  CONSTRAINT consent_tenant_email UNIQUE (tenant_id, email, purpose)
);
CREATE INDEX idx_consent_contact ON consent_log (tenant_id, contact_id);
CREATE INDEX idx_consent_active  ON consent_log (tenant_id, email)
  WHERE revoked_at IS NULL;

Double opt-in (confirmação dupla, opcional)

Recomendado para base própria de newsletter: após o cadastro, dispara-se um e-mail com link assinado; o consentimento só vale quando confirmed_at é preenchido. Reduz spam-trap, endereços digitados errado e reclamações. O link deve ser um token HMAC de uso único com expiração.

// token de confirmacao: HMAC-SHA256, valido por 48h, uso unico
const payload = { cid: contactId, t: 'doi', exp: Date.now() + 48*3600e3 };
const token = base64url(JSON.stringify(payload)) + '.' +
  hmacSha256(process.env.DOI_SIGNING_KEY, base64url(JSON.stringify(payload)));
// GET https://go.clsolucoes.com/confirmar?k=<token>  -> grava confirmed_at

Unsubscribe e lista de supressão

Todo e-mail — sem exceção, inclusive transacionais com conteúdo promocional — carrega link de descadastro visível e o cabeçalho List-Unsubscribe com POST em um clique (RFC 8058), exigência dos provedores para caixa de entrada.

List-Unsubscribe: <https://go.clsolucoes.com/u/<token>>, <mailto:[email protected]?subject=unsub-<id>>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

A supressão é definitiva e cross-tenant no nível da conta quando se trata da base própria: um endereço que pediu saída não pode reaparecer em nova importação. A checagem de supressão roda ANTES de qualquer enfileiramento de envio.

CREATE TABLE suppression_list (
  tenant_id  UUID NOT NULL,
  email      CITEXT NOT NULL,
  reason     TEXT NOT NULL CHECK (reason IN
               ('unsubscribe','hard_bounce','complaint','manual','gdpr_erasure')),
  source_msg UUID,                      -- mensagem que originou (bounce/complaint)
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, email)
);
-- guarda de envio: nunca enfileirar destinatario suprimido
SELECT 1 FROM suppression_list WHERE tenant_id = $1 AND email = $2;
Bounce e complaint alimentam supressão automaticamente. Os eventos Bounce (permanente) e Complaint do SES chegam via SNS e devem inserir na suppression_list em tempo real. Ignorar isso destrói a reputação de envio e viola boas práticas de deliverability.

Direitos do titular

A LGPD (art. 18) garante ao titular acesso, correção, anonimização, portabilidade e eliminação. O sistema precisa atender esses pedidos em prazo razoável (referência de 15 dias). Implemente um endpoint de DSAR (Data Subject Access Request) que orquestre a busca do e-mail em todas as tabelas.

DireitoAção no sistemaSLA
Confirmação / acessoExport JSON de todos os registros do titular15 dias
CorreçãoUpdate de contato + log de auditoria15 dias
EliminaçãoPurge + inserção em supressão (evita recaptura)15 dias
Revogação de consentimentoPreenche revoked_at, cessa disparosImediato
OposiçãoOpt-out honrado, mesmo em legítimo interesseImediato
Eliminação ≠ apagar tudo. Ao eliminar, o e-mail deve ir para a supressão em forma de hash (SHA-256) para impedir reimportação futura, sem manter o dado em claro. Registros exigidos por obrigação legal (ex.: comprovação de consentimento sob litígio) podem ser retidos com base no art. 16.

DPO / Encarregado

A CL Soluções designa um Encarregado (DPO) com canal público de contato ([email protected]) publicado no aviso de privacidade e no rodapé de todo e-mail. O DPO recebe DSARs, faz interface com a ANPD e mantém o registro de operações de tratamento (ROPA).

PCI-DSS — escopo mínimo (SAQ-A)

A AHRI e a CL Soluções operam sob SAQ-A: todo processamento, transmissão e armazenamento de dados de cartão é integralmente terceirizado ao gateway certificado (Stripe, Pagar.me, Cielo etc.). Isso só se sustenta se a fronteira for respeitada tecnicamente.

  Cliente final                CL Soluções / AHRI            Gateway PCI-DSS L1
  ------------                 ------------------            ------------------
  [Navegador] --- clica CTA --> [link go.clsolucoes.com]
       |                              |  redireciona / iframe
       |  digita PAN/CVV -------------------------------------> [checkout tokenizado]
       |                              |                              |
       |<-------------- token + status (webhook assinado) ----------|
       |                              v
       |                     [backend AHRI: grava
       |                      apenas token + status]

  X  PAN/CVV nunca cruzam a coluna do meio  X
Controle PCIComo cumprimos
Não armazenar PAN/CVVBackend recebe só payment_token e status; validação por schema rejeita qualquer campo tipo cartão
Transmissão criptografadaTLS 1.2+ obrigatório; iframe/redirect do gateway sob HTTPS; HSTS ativo
Webhook autênticoVerificação de assinatura do gateway (HMAC) antes de processar; rejeita replays por idempotency key
Segregação de escopoPágina de pagamento servida pelo gateway, não pelo nosso domínio de app
Sem log de cartãoFiltro de logging redige qualquer sequência 13-19 dígitos que passe Luhn
Anti-padrão proibido. Nunca crie um formulário de cartão no front *.clsolucoes.com que faça POST para o nosso backend. Isso migra o escopo para SAQ-D e nos torna alvo de auditoria pesada. Sempre iframe/redirect do gateway.

Gestão de segredos

Chaves AWS/IAM, credenciais de gateway, chave de assinatura de tokens e DOI_SIGNING_KEY vivem em cofre (AWS Secrets Manager / Vault), nunca em .env commitado nem em variável de build do front. Rotação programada e credenciais de menor privilégio.

# Política IAM mínima para envio via SES (principio do menor privilegio)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "SendScoped",
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "arn:aws:ses:us-east-1:111122223333:identity/clsolucoes.com",
      "Condition": {
        "StringEquals": { "ses:FromAddress": "[email protected]" }
      }
    }
  ]
}
# Proibido: ses:* ou Resource "*". Sem permissao de leitura de config global.
Boa prática. A aplicação assume um IAM Role por ambiente; nada de chave de acesso de longa duração em disco. O cofre entrega o segredo em runtime, com cache curto e auditoria de acesso.

Isolamento multi-tenant

Toda tabela carrega tenant_id e toda query é filtrada por ele — sem exceção. Reforce no banco com Row-Level Security para que um bug de aplicação não vaze dados entre clientes.

ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON contacts
  USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- a aplicacao seta o tenant no inicio da transacao:
SET LOCAL app.current_tenant = '<uuid-do-tenant>';

Auditoria

Ações sensíveis — export de base, disparo em massa, alteração de consentimento, acesso a token de pagamento, login administrativo — geram trilha imutável (append-only) com ator, ação, alvo, IP e timestamp. Retenção mínima de 12 meses.

CREATE TABLE audit_log (
  id         BIGSERIAL PRIMARY KEY,
  tenant_id  UUID NOT NULL,
  actor_id   UUID,                 -- usuario ou 'system'/'ahri'
  action     TEXT NOT NULL,        -- ex: 'campaign.send','contact.export','consent.revoke'
  target     TEXT,
  metadata   JSONB,
  ip_address INET,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
) PARTITION BY RANGE (created_at);
-- sem UPDATE/DELETE: revogar esses privilegios ao role da aplicacao

Matriz consolidada de controles

#ControleDomínioEvidência de conformidade
C-01Opt-in registrado com snapshotLGPDconsent_log com IP, texto e versão de política
C-02Double opt-in em base própriaLGPDconfirmed_at preenchido
C-03Unsubscribe 1-clique em todo e-mailLGPDCabeçalho List-Unsubscribe-Post
C-04Lista de supressão pré-envioLGPDGuarda antes do enfileiramento
C-05Atendimento a DSARLGPDEndpoint export/erase + SLA 15 dias
C-06Encarregado (DPO) publicadoLGPD[email protected] no rodapé
C-07Cartão só no gateway (SAQ-A)PCIIframe/redirect; sem form no nosso back
C-08Webhook de pagamento assinadoPCIVerificação HMAC + idempotência
C-09Redação de PAN em logsPCIFiltro Luhn no logger
C-10Segredos em cofre + IAM mínimoSegurançaSecrets Manager, Role por ambiente
C-11TLS 1.2+ e HSTSSegurançaConfig Cloudflare/edge
C-12Isolamento multi-tenant (RLS)SegurançaPolicy por tenant_id
C-13Trilha de auditoria imutávelSegurançaaudit_log append-only, 12 meses
SAQ-AEscopo PCI alvo
15 diasSLA de DSAR
0Dados de cartão no backend
12 mRetenção de auditoria
16

SRS — requisitos

Este SRS congela o escopo funcional e não-funcional da plataforma de e-mail marketing da CL Soluções com a AHRI. Cada requisito tem identificador estável, prioridade MoSCoW e critério de aceite verificável — nada entra em produção sem passar pelo critério que está escrito aqui.

Convenções

  • RF = Requisito Funcional; RNF = Requisito Não-Funcional.
  • Prioridade MoSCoW: Must obrigatório no MVP; Should importante mas adiável; Could desejável; Won't fora deste ciclo.
  • Todo critério de aceite é redigido de forma testável (dado/quando/então implícito).
Rastreabilidade. Cada RF/RNF é referenciado nos casos de teste e nas histórias de implementação. Alterar um requisito exige atualizar o critério de aceite e os testes que o cobrem.

Requisitos Funcionais

Contatos e listas

IDRequisitoPrio.Critério de aceite
RF-01Importar contatos via CSV com mapeamento de colunasMustArquivo de 100k linhas importa em < 60s; linhas inválidas viram relatório de erro sem abortar o lote
RF-02Deduplicar contatos por e-mail dentro do tenantMustImportar o mesmo e-mail duas vezes resulta em 1 registro; conflito de campos segue regra de precedência configurada
RF-03Campos personalizados (custom fields) por tenantShouldTenant cria campo empresa; valor fica disponível em merge tags e segmentação
RF-04Gerenciar múltiplas listas e associação N:N a contatosMustUm contato pertence a ≥2 listas; remover de uma lista não o remove das demais
RF-05Validação sintática e MX do e-mail na entradaShouldE-mail sem MX válido é marcado risky e excluído de disparos por padrão

Templates e campanhas

IDRequisitoPrio.Critério de aceite
RF-06Editor de template com merge tags e pré-visualizaçãoMust{{primeiro_nome}} renderiza no preview e no envio; tag sem valor usa fallback definido
RF-07Geração de copy assistida pela AHRI (Claude)ShouldDado brief + tom, AHRI retorna assunto + corpo em < 10s; usuário edita antes de salvar
RF-08Criar campanha vinculando template + segmento + remetenteMustCampanha só entra em "pronta" se template, segmento e remetente verificado estiverem definidos
RF-09Agendar disparo (imediato, data/hora, fuso do destinatário)ShouldAgendar para 09:00 no fuso do contato dispara na hora local correta ±5 min
RF-10Teste A/B de assuntoCouldDivide amostra %, mede taxa de abertura, envia vencedor ao restante
RF-11Integridade do remetente: sempre a empresa, nunca personificar o contatoMustCampo From resolve para domínio verificado da CL/tenant; impossível usar e-mail do próprio destinatário como remetente
RF-11 é regra de negócio crítica. O remetente é sempre a empresa. A plataforma bloqueia qualquer configuração que faça o e-mail parecer vir do próprio cliente/destinatário — isso protege reputação, evita spoofing e mantém DKIM/DMARC válidos.

Segmentação e automação

IDRequisitoPrio.Critério de aceite
RF-12Segmentar por campos, tags, engajamento e eventosMustSegmento "abriu últimos 30d E não comprou" retorna contagem correta e é reutilizável
RF-13Segmento dinâmico (recalculado no disparo)ShouldContato que satisfaz o filtro após a criação entra automaticamente no próximo envio
RF-14Automação por gatilho (welcome, abandono, aniversário)ShouldNovo contato recebe e-mail de boas-vindas em < 5 min do opt-in confirmado
RF-15Fluxos multi-etapa com espera e condiçãoCouldFluxo: e-mail 1 → espera 2d → se não abriu, e-mail 2; ramificação avaliada corretamente

Tracking, entrega e supressão

IDRequisitoPrio.Critério de aceite
RF-16Rastrear aberturas (pixel) e cliques (link wrap)MustAbertura e clique aparecem no relatório em < 2 min; cada clique preserva URL original
RF-17Ingestão de eventos SES via SNS (delivery, bounce, complaint)MustBounce permanente e complaint inserem em supressão automaticamente e em tempo real
RF-18Lista de supressão aplicada antes de enfileirarMustContato suprimido nunca recebe disparo, mesmo se estiver no segmento
RF-19Unsubscribe 1-clique (RFC 8058) e página de preferênciasMustClique no link marca supressão e cessa envios; header List-Unsubscribe-Post presente
RF-20Dashboard de deliverability (taxa de entrega, bounce, complaint)ShouldMétricas por campanha e agregadas; alerta quando complaint > 0,1%

Filas, envio e módulo conversacional

IDRequisitoPrio.Critério de aceite
RF-21Fila de disparo com rate limit por identidade SESMustRespeita o sending quota e max send rate; não excede cota nem em pico
RF-22Retentativa com backoff em falha transitóriaMustThrottling/erro 4xx transitório reenfileira com backoff exponencial; erro permanente não
RF-23Idempotência de envio (sem duplicado por retry)MustReprocessar o mesmo job não gera segundo e-mail ao destinatário
RF-24Módulo conversacional AHRI responde a repliesShouldResposta do lead é classificada (interesse/objeção/opt-out) e sugere réplica; opt-out suprime na hora
RF-25Handoff humano no conversacionalCouldQuando confiança < limiar, conversa é encaminhada a atendente com contexto

Integrações

IDRequisitoPrio.Critério de aceite
RF-26Integração AWS SES (envio) e SNS (eventos)MustIdentidade verificada com DKIM/SPF/DMARC; webhook SNS validado por assinatura
RF-27Motor de IA Claude para geração e classificaçãoMustChamadas com timeout e fallback; prompt versionado; custo por token registrado
RF-28Webhook de saída para CRM do clienteCouldEventos (abertura, clique, resposta) entregues com retry e assinatura HMAC
RF-29API REST autenticada para gestão de contatos/campanhasShouldEndpoints documentados (OpenAPI); auth por token de tenant com escopo

Requisitos Não-Funcionais

IDCategoriaRequisitoPrio.Critério de aceite / meta
RNF-01ThroughputVazão de envio sustentadaMust≥ 50 e-mails/s por tenant sem estourar cota SES; escalável por aumento de cota
RNF-02LatênciaResposta da API de gestãoMustp95 < 300 ms; p99 < 800 ms em carga nominal
RNF-03Latência IAGeração de copy pela AHRIShouldp95 < 10 s para assunto+corpo; timeout de 20 s com fallback
RNF-04DisponibilidadeUptime do serviço de envio e APIMust≥ 99,9% mensal; fila resiliente a reinício sem perda de job
RNF-05SegurançaSegredos em cofre, TLS 1.2+, IAM mínimoMustNenhum segredo em repositório; TLS obrigatório; scan sem chave exposta
RNF-06LGPDConsentimento comprovável e DSARMustConforme controles C-01..C-06 do cap. de Segurança; export/erase ≤ 15 dias
RNF-07PCI-DSSEscopo SAQ-AMustNenhum dado de cartão no backend; pagamento só no gateway
RNF-08EscalabilidadeCrescer horizontalmente workers de filaShouldDobrar workers dobra vazão de forma ~linear até o limite de cota SES
RNF-09Multi-tenantIsolamento de dados por tenantMustRLS ativa; teste de vazamento cross-tenant retorna 0 registros alheios
RNF-10ObservabilidadeLogs estruturados, métricas e tracingShouldToda campanha rastreável ponta a ponta; dashboards de fila, entrega e erro
RNF-11DeliverabilityAutenticação e reputação de domínioMustSPF, DKIM e DMARC válidos; complaint < 0,1%; bounce < 2%
RNF-12CustoControle de custo por disparo e por token IAShouldCusto/1k e-mails e custo/campanha visíveis; alerta ao exceder orçamento
RNF-13ManutenibilidadeCobertura de testes e CIShouldCobertura ≥ 70% no core; pipeline bloqueia merge com teste vermelho
RNF-14PortabilidadeDeploy reprodutívelCouldAmbiente sobe via IaC; sem passo manual não documentado
RNF-15AcessibilidadeFront conforme WCAG 2.1 AAShouldEditor e dashboards navegáveis por teclado; contraste AA

Fronteira do sistema

            +-------------------------------------------+
            |            Plataforma CL / AHRI            |
  Usuario --+--> [Front Next.js/React/Tailwind/TS]      |
            |          |                                |
            |          v                                |
            |    [API NestJS/Laravel] --- [Postgres RLS]|
            |          |         \                      |
            |          v          \--> [Claude / AHRI]  |
            |    [Fila de disparo]                      |
            +---------|---------------------|-----------+
                      v                     ^
                  [AWS SES] --entrega--> Destinatario
                      |  eventos (bounce/complaint/open)
                      v
                  [AWS SNS] --webhook assinado--> API --> supressao
Definição de pronto (por requisito). Um RF/RNF só é considerado entregue quando: (1) o critério de aceite passa em teste automatizado ou verificação manual documentada; (2) não regride os controles de LGPD/PCI do capítulo anterior; (3) está coberto por observabilidade (RNF-10).

29Requisitos funcionais
15Não-funcionais
99,9%Disponibilidade alvo
p95<300msLatência da API
17

Plataforma própria vs Mautic

A pergunta não é "o que envia e-mail mais rápido", é "o que a CL Soluções pode vender daqui a 18 meses". Mautic entrega automação de marketing pronta hoje; uma plataforma própria sobre Amazon SES entrega um produto proprietário com AHRI no núcleo, deliverability sob seu controle e nenhum teto de roadmap imposto por terceiros. Este capítulo mostra onde cada caminho ganha e por que, para o objetivo estratégico da CL, o motor deve ser SES e a interface deve ser sua.

As duas arquiteturas, lado a lado

Antes de comparar critérios, é preciso deixar claro o que cada opção realmente é. Mautic é uma aplicação PHP monolítica (Symfony) que você hospeda, com banco MySQL, cron para processamento de filas e um transporte de e-mail plugável. A plataforma própria é um front Next.js/React/Tailwind falando com uma API Node/NestJS (ou Laravel), tendo o Amazon SES como transporte e a AHRI (Claude) como motor de geração de copy e de conversa.

PLATAFORMA PRÓPRIA (recomendada)                MAUTIC (pacote fechado)

  Next.js + React + Tailwind                      Interface Symfony/Twig
         |  (App Router, RSC)                            |  (temas PHP)
         v                                               v
  API NestJS / Laravel  <--- AHRI (Claude) ---     Core Mautic (PHP)
         |                    copy + conversa            |
         |                                               |  plugin de transporte
         v                                               v
  Amazon SES (envio)                              Amazon SES / SMTP / Sendgrid
         |                                               |
  SNS -> webhooks -> filas                        callbacks -> DoNotContact
  (bounce/complaint/delivery)                     (bounce handling limitado)
         |                                               |
  Postgres + eventos próprios                     MySQL (schema do Mautic)
Ponto-chave. As duas opções podem usar o mesmo motor de envio (SES). A diferença estratégica não está em quem entrega o e-mail, está em quem é dono da interface, do dado e da experiência — e portanto de quem pode transformar isso em produto.

Comparativo por critério

CritérioPlataforma própria (Next.js + SES)Mautic
ControleTotal. Schema, filas, lógica de supressão, tracking e UX são seus. Você decide o que loga e como.Parcial. Você controla config e plugins, mas o núcleo dita o modelo de dados e o ciclo de vida do contato.
FlexibilidadeIlimitada — qualquer fluxo, qualquer integração, qualquer regra de negócio da CL.Alta dentro do paradigma do Mautic; sair dele exige plugins PHP e hacks no core.
Custo de infraBaixo e variável: SES a US$ 0,10/mil e-mails + Vercel/EC2. Sem licença."Grátis" (open-source) mas exige VM robusta, MySQL tunado e manutenção de cron — o custo migra para operação.
ManutençãoVocê mantém seu código, mas ele é pequeno e sob medida. Sem dívida de framework legado.Upgrades de major version são dolorosos; plugins quebram; comunidade lenta em correções.
EscalabilidadeElástica. SES escala para milhões/dia; API stateless atrás de load balancer; filas SQS.Gargalo no cron PHP e no MySQL para bases grandes; requer sharding e tuning agressivo.
Time-to-marketSemanas para o MVP (contatos + template + envio + tracking). Curva inicial maior.Dias para colocar de pé. Ganha o sprint 1, perde o ano.
Lock-inNenhum de fornecedor de app; SES é commodity substituível por outro provedor SMTP.Alto no modelo Mautic: dados, automações e templates ficam no formato dele.
DeliverabilitySob seu comando: DKIM/SPF/DMARC próprios, warm-up, dedicated IP, reputação por domínio.Boa, mas o handling de bounce/complaint é genérico; reputação depende do transporte configurado.
Diferenciação de produtoMáxima. AHRI, checkout conversacional, IA de copy — recursos que nenhum concorrente Mautic tem.Zero. Você tem o mesmo Mautic que qualquer um baixa de graça.
Multi-tenant / white-labelProjetável desde o schema (tenant_id em tudo). Vira SaaS vendável.Não nativo. Multi-tenant no Mautic é gambiarra ou instâncias separadas.
US$ 0,10por 1.000 e-mails no SES
~3–5semanas até MVP próprio
0licença / teto de features
1diferencial não copiável: AHRI

O custo real do Mautic não está na fatura

Mautic é gratuito para baixar, e é aí que a conta engana. O custo aparece em três lugares:

  • Operação. O worker de fila em PHP (mautic:emails:send) roda por cron. Em bases de centenas de milhares, você acaba tunando MySQL, ajustando max_execution_time, isolando o cron em VM dedicada e depurando travamentos de fila. Isso é trabalho de DevOps recorrente.
  • Upgrades. Cada major (3.x → 4.x → 5.x) traz breaking changes. Temas customizados e plugins de terceiros quebram. Já vi migração de Mautic parar campanha por dias.
  • Teto de produto. O mais caro: quando um cliente da CL pedir "quero que o e-mail continue como conversa no WhatsApp com a IA e feche a venda ali", o Mautic não faz. Você vai construir por fora mesmo — e aí já era melhor ter construído tudo.
Armadilha. Adotar Mautic "só para começar" e planejar migrar depois quase sempre vira migração adiada indefinidamente. Os dados nascem no schema do Mautic, as automações nascem no editor do Mautic, e o custo de sair cresce a cada campanha. A hora mais barata de não entrar é agora.

Por que SES como motor e interface própria

A recomendação para a CL Soluções é clara: usar Amazon SES como motor de entrega e construir a interface e a inteligência por cima. O SES resolve a parte comoditizada e difícil (entregar e-mail em escala com boa reputação) por centavos, e deixa livre exatamente a parte que vira produto.

SES é commodity, não lock-in

O SES fala SMTP e API HTTP padrão. Se um dia precisar trocar por SparkPost ou por um SMTP próprio, você troca o adaptador de transporte — o resto da plataforma nem percebe. Isole o SES atrás de uma interface MailTransport.

Deliverability sob seu domínio

Com SES você configura DKIM, SPF e DMARC nos seus domínios *.clsolucoes.com, faz warm-up de IP dedicado e monitora reputação no próprio painel. Bounce e complaint chegam via SNS em tempo real para sua lógica de supressão.

AHRI no núcleo

Com a interface própria, a AHRI (Claude) gera a copy, roda testes A/B de assunto, personaliza por segmento e conduz a conversa pós-clique até o checkout. Isso é o produto — e não existe plugin de Mautic que faça.

Vira SaaS vendável

Com tenant_id no schema desde o dia zero e white-label no front, a mesma base atende dezenas de clientes da CL sob marcas diferentes. O Mautic te dá uma ferramenta interna; a plataforma própria te dá um SKU.

Um recorte de como o transporte fica desacoplado, para deixar concreto que "SES como motor" não amarra nada:

// mail/transport.interface.ts
export interface MailTransport {
  send(msg: OutboundEmail): Promise<SendResult>;
}

// mail/ses.transport.ts
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';

export class SesTransport implements MailTransport {
  constructor(private ses: SESv2Client) {}

  async send(msg: OutboundEmail): Promise<SendResult> {
    const out = await this.ses.send(new SendEmailCommand({
      FromEmailAddress: msg.from,           // ex: [email protected]
      Destination: { ToAddresses: [msg.to] },
      ConfigurationSetName: 'cl-prod',      // liga eventos SNS
      Content: {
        Simple: {
          Subject: { Data: msg.subject },
          Body: { Html: { Data: msg.html } },
        },
      },
    }));
    return { providerMessageId: out.MessageId! };
  }
}

// Trocar de provedor = trocar esta classe. O resto da plataforma não muda.

Quando o Mautic ainda faz sentido

Ser dogmático aqui seria errado. Existem cenários legítimos para o Mautic — todos com prazo de validade:

  1. Validação de mercado em dias. Se a CL precisa provar uma tese de campanha para um cliente esta semana e não há tempo de engenharia, o Mautic sobe rápido e testa a hipótese comercial. É um protótipo descartável, tratado como tal.
  2. Cliente que só quer nutrição clássica. Funil de e-mail linear, sem IA, sem checkout conversacional, sem white-label. Se o requisito nunca vai além do que o Mautic faz, construir do zero é sobre-engenharia.
  3. Equipe zero de dev disponível. Se no momento não há mão de obra para manter código, uma instância Mautic gerida por um parceiro é ponte até haver time.
Decisão recomendada. Para a CL Soluções, cujo objetivo é ter um produto com AHRI como diferencial e potencial de white-label multi-tenant: SES como motor + interface Next.js própria. Use Mautic apenas como MVP descartável para validar uma campanha específica quando o relógio comercial não deixa espaço para engenharia — e com data marcada para sair dele.

Resumo executivo em uma frase

Mautic te faz economizar três semanas agora ao custo de nunca ter um produto proprietário depois; SES + plataforma própria te custa essas três semanas e te devolve controle total de deliverability, dado e experiência — e a única coisa que os concorrentes não conseguem baixar de graça: a AHRI no coração da operação.

18

Roadmap & apêndices

Roadmap não é lista de desejos — é sequência de apostas com dependência técnica. Aqui está a ordem em que a CL Soluções constrói: primeiro o que prova que e-mail sai, chega e é rastreado (V1); depois o que escala relacionamento sem esforço manual (V2); por fim o que transforma a plataforma em produto vendável com AHRI no núcleo (V3). Fecha com apêndices prontos para colar: .env, checklist de deliverability, payload real de complaint do SNS e glossário.

Roadmap por versão

VersãoObjetivoEscopoCritério de "pronto"
V1 — MVP Provar o loop de envio ponta a ponta Cadastro e importação de contatos (CSV); editor de template HTML com variáveis; criação de campanha; envio via Amazon SES; tracking de abertura e clique; lista de supressão automática (bounce/complaint via SNS). Uma campanha real é enviada para 1.000 contatos, com abertura/clique rastreados e hard bounces suprimidos automaticamente sem intervenção manual.
V2 — Escala Reduzir esforço manual e aumentar relevância Automações (gatilho → espera → ação); segmentação avançada por atributo e comportamento; editor drag-and-drop de e-mail; testes A/B de assunto e conteúdo com vencedor automático. Um fluxo de boas-vindas de 3 e-mails roda sozinho por gatilho; um A/B de assunto escolhe o vencedor por taxa de abertura sem operador.
V3 — Produto Diferenciação com IA e monetização como SaaS Módulo conversacional / checkout com IA (AHRI conduz do clique à venda); IA de copy (geração e reescrita de assunto e corpo por Claude); multi-tenant white-label; API pública com chaves e webhooks. Um cliente da CL opera sob sua própria marca; a AHRI gera a copy de uma campanha e fecha ao menos uma venda em conversa; um terceiro dispara envio via API pública.
Ordem importa. Não pule V1. Automação (V2) sem supressão e tracking confiáveis (V1) só acelera o dano à reputação do domínio. E IA de checkout (V3) sem base de eventos (V1) não tem sinal para personalizar. Cada versão é o alicerce da seguinte.
V1  contatos -> template -> campanha -> SES -> tracking -> supressão
         \_______________ fundação de dados e reputação _______________/
                                    |
V2  automações  +  segmentação  +  drag-drop  +  A/B
         \____________ escala sem esforço manual ____________/
                                    |
V3  AHRI checkout  +  IA de copy  +  white-label  +  API pública
         \____________ produto vendável / SaaS ____________/

Apêndice A — .env de exemplo (AWS / SES / SNS)

Valores fictícios; substitua pelos reais no cofre de segredos (nunca commite .env). Domínios de envio sob *.clsolucoes.com.

# ---- Aplicação ----
NODE_ENV=production
APP_URL=https://app.clsolucoes.com
APP_PORT=3000

# ---- Banco ----
DATABASE_URL=postgresql://cl_user:[email protected]:5432/cl_marketing

# ---- AWS / SES ----
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
SES_CONFIGURATION_SET=cl-prod          # config set que publica eventos no SNS
[email protected]
[email protected]

# ---- SNS (notificações de bounce/complaint/delivery) ----
SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:cl-ses-events
SNS_WEBHOOK_PATH=/webhooks/ses         # endpoint que recebe as notificações
SNS_VERIFY_SIGNATURE=true              # validar assinatura da mensagem SNS

# ---- Filas (SQS) ----
SQS_SEND_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/cl-send
SQS_EVENTS_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/cl-events

# ---- AHRI (Claude) ----
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AHRI_MODEL=claude-opus-4-8
AHRI_MAX_TOKENS=2000

# ---- Tracking ----
TRACKING_DOMAIN=t.clsolucoes.com       # domínio dedicado de tracking (CNAME)
TRACKING_SECRET=troque_este_segredo_de_assinatura_de_links
Segurança. Ative SNS_VERIFY_SIGNATURE=true em produção: o endpoint de webhook é público e precisa validar a assinatura da mensagem SNS antes de suprimir contatos — caso contrário qualquer um pode forjar um complaint e envenenar sua lista.

Apêndice B — Checklist de deliverability pré-campanha

Rode esta lista antes de todo envio em massa. Um item vermelho aborta o disparo.

  1. SPF. Registro TXT do domínio de envio inclui include:amazonses.com e resolve sem múltiplos v=spf1.
  2. DKIM. Os 3 CNAMEs de DKIM do SES estão publicados e "verified" no painel. Assinatura DKIM presente no header do e-mail de teste.
  3. DMARC. Registro _dmarc.clsolucoes.com existe com política ao menos p=none e endereço rua= recebendo relatórios.
  4. Domínio verificado no SES. Identidade de domínio (não só e-mail) verificada; conta fora do sandbox com quota adequada ao volume.
  5. Warm-up. Se IP dedicado novo, respeitar a rampa (ex.: 50 → 500 → 5.000/dia) antes do volume cheio.
  6. Supressão sincronizada. Lista de supressão (bounce/complaint/unsubscribe) aplicada; nenhum endereço suprimido entra no batch.
  7. Link de descadastro. Cada e-mail tem unsubscribe de um clique e header List-Unsubscribe + List-Unsubscribe-Post.
  8. Higiene da lista. Endereços validados sintaticamente; role-based (contato@, vendas@) e domínios inexistentes removidos.
  9. Teste de spam. Envio-teste para caixas Gmail/Outlook/Yahoo próprias; conferir pasta de entrada e pontuação (SpamAssassin/mail-tester > 8/10).
  10. Métricas de guarda. Bounce esperado < 5% e complaint < 0,1%; alarme configurado para pausar campanha se estourar.
Regra de ouro. Reputação leva meses para construir e um único disparo sujo para destruir. O checklist não é burocracia — é o que mantém *.clsolucoes.com na caixa de entrada.

Apêndice C — Payload SNS de complaint (exemplo real)

Quando um destinatário marca o e-mail como spam, o SES publica no tópico SNS. Este é o formato que seu webhook em /webhooks/ses precisa processar para suprimir o contato:

{
  "notificationType": "Complaint",
  "complaint": {
    "complainedRecipients": [
      { "emailAddress": "[email protected]" }
    ],
    "timestamp": "2026-07-10T14:32:05.000Z",
    "feedbackId": "0100018f9a2b3c4d-abc12345-6789-0abc-def0-1234567890ab-000000",
    "userAgent": "Amazon SES Mailbox Simulator",
    "complaintFeedbackType": "abuse",
    "arrivalDate": "2026-07-10T14:32:05.000Z"
  },
  "mail": {
    "timestamp": "2026-07-10T14:30:00.000Z",
    "source": "[email protected]",
    "sourceArn": "arn:aws:ses:us-east-1:123456789012:identity/clsolucoes.com",
    "sendingAccountId": "123456789012",
    "messageId": "0100018f9a1b2c3d-11111111-2222-3333-4444-555555555555-000000",
    "destination": [ "[email protected]" ],
    "headers": [
      { "name": "From", "value": "AHRI <[email protected]>" },
      { "name": "To", "value": "[email protected]" },
      { "name": "Subject", "value": "Sua proposta personalizada chegou" }
    ],
    "commonHeaders": {
      "from": [ "AHRI <[email protected]>" ],
      "to": [ "[email protected]" ],
      "subject": "Sua proposta personalizada chegou"
    }
  }
}

O handler extrai complaint.complainedRecipients[].emailAddress, cruza pelo mail.messageId para achar a campanha, insere na tabela de supressão com motivo complaint e nunca mais envia para aquele endereço:

-- DDL da tabela de supressão
CREATE TABLE suppression (
  id           BIGSERIAL PRIMARY KEY,
  tenant_id    BIGINT NOT NULL,
  email        CITEXT NOT NULL,
  reason       TEXT NOT NULL CHECK (reason IN ('bounce','complaint','unsubscribe','manual')),
  message_id   TEXT,
  suppressed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (tenant_id, email)
);
CREATE INDEX idx_suppression_email ON suppression (tenant_id, email);

Apêndice D — Glossário

TermoDefinição
SESAmazon Simple Email Service — motor de envio de e-mail transacional e em massa, cobrado por volume.
SNSAmazon Simple Notification Service — pub/sub que entrega eventos do SES (bounce, complaint, delivery) ao seu webhook.
BounceFalha de entrega. Hard bounce = permanente (endereço inexistente) → suprimir sempre. Soft bounce = temporário (caixa cheia) → repetir com limite.
ComplaintDestinatário marcou como spam. Deve suprimir imediatamente; taxa acima de 0,1% ameaça a conta SES.
SupressãoLista de endereços que nunca devem receber envio (bounce, complaint, descadastro). Aplicada antes de todo batch.
SPFSender Policy Framework — registro DNS que autoriza servidores a enviar em nome do domínio.
DKIMDomainKeys Identified Mail — assinatura criptográfica que prova que o e-mail não foi adulterado e vem do domínio.
DMARCPolítica que diz aos provedores o que fazer quando SPF/DKIM falham, mais relatórios de abuso.
Warm-upRampa gradual de volume em IP/domínio novo para construir reputação sem cair em spam.
DeliverabilityCapacidade de chegar à caixa de entrada (não spam). Função de reputação, autenticação e higiene de lista.
Configuration SetAgrupamento de regras no SES que define para onde publicar os eventos de envio (o tópico SNS).
Multi-tenantArquitetura em que uma instância serve múltiplos clientes isolados por tenant_id, base do white-label.
AHRIIA comercial da CL Soluções (motor Claude) que gera copy, roda A/B e conduz a conversa de venda até o checkout.
Próximo passo. Com V1 no ar e os apêndices como fonte de verdade operacional, a CL tem o alicerce para escalar (V2) e depois empacotar como produto (V3). O roadmap é vivo: revise-o a cada release e mova para cima o que o mercado pedir primeiro.