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.
┌──────────────────────────────────────────────────────────────┐
│ 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
Comparativo: SES puro vs. plataforma de marketing vs. SES Engine
| Capacidade | AWS SES puro | Mautic / SaaS | SES Engine (CL) |
|---|---|---|---|
| Entrega em alto volume | Sim (motor) | Depende de gateway | Sim (via SES) |
| Editor visual de e-mail | Não | Sim | Sim (próprio) |
| Listas & segmentação | Não | Sim | Sim + IA (AHRI) |
| Tracking abertura/clique | Não | Sim | Sim (pixel + redirect) |
| Automações / fluxos | Não | Sim | Sim (BullMQ) |
| Custo | US$ 0,10/1k | Por contato (caro) | US$ 0,10/1k + infra |
| Controle do roadmap | N/A | Do fornecedor | 100% 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).
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.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.
| KPI | Definição | Meta | Limite crítico (AWS) |
|---|---|---|---|
| Taxa de entrega | delivered / (sent − hardBounce) | > 98% | < 95% aciona alerta interno |
| Bounce rate | bounces / sent (janela móvel) | < 2% | < 5% (revisão) / < 10% (suspensão) |
| Complaint rate | complaints / delivered | < 0,05% | < 0,1% (revisão) / < 0,5% (suspensão) |
| Taxa de abertura | uniqueOpens / delivered (pixel) | > 22% | < 10% indica problema de reputação |
| CTR | uniqueClicks / delivered (redirect) | > 3% | — |
| CTOR | uniqueClicks / uniqueOpens | > 12% | — |
| Throughput | e-mails/s respeitando send rate SES | ≈ rate da conta | Estourar o rate = Throttling (erro 454) |
| Tempo de setup de domínio | Do add-domain a DKIM/SPF verde | < 30 min | SES não envia sem verificação |
KPIs de negócio
| KPI | Definição | Meta MVP | Observação |
|---|---|---|---|
| Custo por 1.000 e-mails | Custo SES + rateio de infra por milhar enviado | < US$ 0,20 | SES cobra US$ 0,10/1k; infra é o delta |
| MRR por cliente | Receita recorrente mensal média por conta ativa | > R$ 490 | Modelo 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 h | Gargalo = verificação de domínio |
| Churn mensal | Contas 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.
| Componente | Preço AWS | Exemplo (1M e-mails/mês) |
|---|---|---|
| Envio (API/SMTP) | US$ 0,10 / 1.000 | US$ 100,00 |
| Anexos / dados | US$ 0,12 / GB | Variável (≈ US$ 6 p/ 50 GB) |
| Recebimento (inbound) | US$ 0,10 / 1.000 | Só se usar inbound |
| Dedicated IP (opcional) | US$ 24,95 / IP / mês | Recomendado > 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
- Setup de domínio. Adicionar um domínio
*.clsolucoes.com, gerar registros DKIM/SPF e verificar em < 30 min. - Lista & supressão. Importar CSV, deduplicar, validar sintaxe e aplicar lista de supressão global antes do disparo.
- Envio real. Disparar uma campanha via
SendBulkTemplatedEmailrespeitando o rate limit, semThrottling. - Feedback loop. Consumir bounce/complaint/delivery via SNS→SQS e atualizar status e supressão em tempo real.
- Tracking. Registrar abertura (pixel) e clique (redirect) e refletir nos dashboards.
- Guardrails. Bloquear automaticamente novos disparos se bounce > 5% ou complaint > 0,1% na janela móvel.
- AHRI. Gerar assunto + corpo + 3 variantes de teste A/B a partir de um briefing, prontos para envio.
*.clsolucoes.com.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
| Persona | Dor principal | Objetivo (métrica) | Volume típico | Config AHRI padrão |
|---|---|---|---|---|
| PME | Não sabe escrever assunto/copy; medo de "cair no spam"; sem tempo | Taxa de abertura > 25%; agendamento de retorno | 1–3 mil/mês | Templates prontos, tom institucional, sugestão de assunto automática |
| Infoprodutor | Reputação queima em lançamento; bounce de lista importada; deliverability instável | Faturamento do carrinho; entrega > 97% no pico | 10–50 mil em rajada | Warm-up obrigatório, throttling por hora, supressão de risco pré-envio, copy persuasiva |
| Assessoria | Gerir 20+ clientes sem misturar reputação; provar ROI por cliente | Retenção de clientes; relatório white-label; margem | 100 mil+ agregado/mês | Multi-tenant, IP/subdomínio por cliente, voz configurável por conta, API-first |
| E-commerce | Carrinho abandonado sem recuperação; pós-venda inexistente | Receita recuperada; LTV; taxa de recompra | Transacional contínuo | Gatilhos por evento, régua sempre ativa, personalização por produto/pedido |
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 uso | Personas primárias | Módulo backend | Trigger | SLA de entrega |
|---|---|---|---|---|
| Campanha pontual | PME, Infoprodutor | campaigns | Agendamento manual | Lote em fila < 30 min |
| Newsletter recorrente | PME, Assessoria | campaigns + cron | Cron (RRULE) | Janela definida pelo usuário |
| Boas-vindas | Infoprodutor, E-commerce | automations | contact.created | < 60 s do gatilho |
| Recuperação de carrinho | E-commerce | automations | cart.abandoned | Delay configurável (1h/24h/72h) |
| Régua de cobrança | E-commerce, Assessoria | automations | order.status_changed | < 60 s do gatilho |
3.5 Jornada de uma conta (do trial ao volume)
- 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. - 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.
- 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.
- 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.
- Escala. Assessoria adiciona contas-filhas; e-commerce cruza eventos transacionais; monitoramento de bounce/complaint por tenant impede contaminação de reputação.
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
| Componente | Responsabilidade | NÃO faz |
|---|---|---|
| Frontend (Next.js) | UI, editor de templates, preview, agendamento, dashboards; consome API via JWT | Nunca fala direto com SES nem com a fila |
campaigns | CRUD de campanha, resolve o segmento em snapshot de destinatários, cria o lote e enfileira jobs de envio | Não renderiza nem envia — só materializa e enfileira |
templates | Armazena MJML/HTML + variáveis; versiona; valida merge tags; render de preview | Não persiste dados de contato |
lists / contacts | Contatos, campos custom, status (subscribed/unsub/bounced), import com validação | Não decide quem recebe (isso é segmentação) |
segmentação | Traduz regras (AND/OR sobre campos e eventos) em query; gera snapshot congelado no disparo | Não envia; entrega lista de IDs ao campaigns |
automations | Máquina de estados por contato: gatilho → delay → passo → condição; agenda jobs futuros | Não gera copy (delega à AHRI) |
tracking | Endpoints de pixel de abertura e redirect de clique; assina/valida tokens de rastreio | Não agrega — só registra evento cru |
statistics | Agrega eventos em contadores por campanha/automação/tenant; alimenta dashboards | Não é fonte da verdade de evento (é derivado) |
webhooks | Ingest 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-gen | Nã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 prioridade | Não guarda estado de negócio (só o job) |
| Worker | Render final, merge de variáveis, respeita cota/hora do SES, chama SES, trata retryable vs. permanente | Não recalcula segmento |
| AWS SES | Entrega SMTP, assinatura DKIM, publicação de eventos via configuration set | — |
| suppression list | Registro 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.
| Fila | Job | Concorrência | Retry / backoff | Prioridade |
|---|---|---|---|---|
send | Enviar 1 e-mail (ou micro-lote) via SES | Alta, limitada pela cota SES/s | 3× exponencial (2s→8s→32s) só em erro retryável | Média |
automation | Executar passo de régua para 1 contato | Alta | 3× exponencial | Alta (transacional) |
webhook-ingest | Processar notificação SNS/SES | Alta | 5× (evento não pode ser perdido) | Alta |
ahri-gen | Chamada ao Claude para gerar conteúdo | Baixa (custo/token) | 2× + fallback de modelo | Baixa |
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]
);
}
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)
- SES publica. Configuration set envia o evento ao tópico SNS assim que há hard bounce ou reclamação (botão "marcar como spam").
- 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. - Worker deduplica. Insert em
email_eventscomON CONFLICT DO NOTHING; evento repetido é ignorado. - Supressão. Se
Bounce.bounceType = Permanentou forComplaint, insere o e-mail nasuppression_listdo tenant e marca o contato comobounced/complained. Envios futuros checam essa lista antes de enfileirar. - Contadores.
statisticsincrementa os agregados da campanha; dashboard reflete em segundos.
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.
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.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
}
}
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>
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 é:
- Upload & parse. Detecção de encoding (UTF-8/Latin-1) e delimitador; preview das 20 primeiras linhas.
- Mapeamento. Cada coluna do arquivo é vinculada a um campo (padrão ou customizado) ou marcada como ignorada.
- Validação. E-mails malformados vão para um relatório de rejeição; duplicatas internas do arquivo são coalescidas.
- 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. - 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
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.
Matriz de funcionalidades por módulo
| Módulo | Funcionalidades-chave | API | Webhook | Papel mínimo |
|---|---|---|---|---|
| Campanhas | Draft, agendamento por timezone, throttling, A/B, pausa/retomada | ✓ | campaign.sent | Editor |
| Templates | Editor MJML, merge tags, versão imutável, preview por contato | ✓ | — | Editor |
| Contatos & Listas | Import CSV/XLSX, dedup, campos customizados, merge não-destrutivo | ✓ | contact.created | Editor |
| Segmentação | Filtros AND/OR aninhados, dinâmico/estático, prévia de tamanho | ✓ | — | Editor |
| Automações | Gatilho→condição→ação, esperas, ramificação, limite de reentrada | ✓ | journey.entered | Admin |
| Estatísticas | Funil, mapa de cliques, receita por UTM, export CSV | ✓ (read) | — | Viewer |
| Supressão | Unsubscribe, bounce, complaint, bloqueio manual, auditoria | ✓ | contact.suppressed | Admin |
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.
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.
| Host | Tipo | Valor | Proxy |
|---|---|---|---|
bounce.mkt.clsolucoes.com | MX | 10 feedback-smtp.us-east-1.amazonses.com | DNS only |
bounce.mkt.clsolucoes.com | TXT | "v=spf1 include:amazonses.com ~all" | DNS only |
mkt.clsolucoes.com | TXT | "v=spf1 include:amazonses.com ~all" | DNS only |
_dmarc.mkt.clsolucoes.com | TXT | "v=DMARC1; p=quarantine; rua=mailto:[email protected]; adkim=s; aspf=s; pct=100" | DNS only |
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:
- Use case URL.
https://app.clsolucoes.come descrição do produto (plataforma de e-mail marketing AHRI). - Tipo de e-mail. Marketing/transacional, com opt-in explícito e link de unsubscribe em todo envio.
- Gestão de bounces/complaints. Descreva o pipeline SNS → supressão automática (este é o item que mais aprova ou reprova o pedido).
- Volume estimado. Diário e pico (ex.: 50k/dia, pico 200k em campanhas).
- 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: 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
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" }
]
}
}
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" }
]
}
}
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
p=quarantine→reject) · 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%.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.
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"
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
| Dia | Volume máx./dia | Público prioritário | Meta de abertura | Ação de controle |
|---|---|---|---|---|
| 1–2 | 50 | Equipe interna + clientes VIP | > 60% | Verificar DKIM/DMARC nos cabeçalhos recebidos |
| 3–4 | 500 | Top 10% mais engajados (30 d) | > 45% | Bounce < 2%, complaint = 0 |
| 5–7 | 1.500 | Engajados 90 d | > 35% | Conferir Postmaster: IP reputation "High" |
| 8–10 | 5.000 | Engajados 180 d | > 25% | Bounce < 3%, complaint < 0,05% |
| 11–14 | 10.000 | Ativos gerais | > 20% | Endurecer DMARC para quarantine |
| 15–20 | 20.000 | Base ativa + reengajamento cauteloso | > 15% | Sunset policy ativa (ver abaixo) |
| 21–30 | 35.000 | Base completa segmentada | > 12% | reject no DMARC; regime pleno |
| 30+ | 50.000+ | Operação normal | > 10% | Monitoramento contínuo |
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 CAPSno 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-Unsubscribecomone-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
| Fonte | O que observar | Cadência |
|---|---|---|
| SES Reputation Dashboard | Bounce & Complaint agregados da conta; status "Healthy / Under Review / Paused" | Diária + alarme CloudWatch |
| Google Postmaster Tools | IP/Domain reputation, spam rate, autenticação, encryption | Diária durante warm-up |
| Microsoft SNDS / JMRP | Reputação junto ao Outlook/Hotmail; feedback loop | Semanal |
| Relatórios DMARC (RUA) | % de mensagens alinhadas, fontes não autorizadas | Diária (agregada) |
| CloudWatch Alarms | Corte automático de fila se complaint > 0,08% | Tempo real |
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.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.
| Tag | Origem | Fallback recomendado | Exemplo renderizado |
|---|---|---|---|
{{nome}} | contato.first_name | cliente | Olá, Marina |
{{nome_completo}} | contato.full_name | {{nome}} | Marina Costa |
{{empresa}} | contato.company | sua empresa | Ótica Vista Clara |
{{cargo}} | contato.title | — | Gerente Comercial |
{{primeiro_nome_vendedor}} | owner.first_name | equipe CL | Rafael |
{{proposta_id}} | deal.public_id | bloqueia envio se vazio | a1B2c3 |
{{cidade}} | contato.city | sua região | Curitiba |
{{unsub_url}} | sistema | — | https://mkt…/u/xyz |
{{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
- Preview responsivo. Alterna desktop (600px) e mobile (375px) lado a lado, com dados reais de um contato-amostra do segmento — não lorem ipsum.
- 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.
- 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.
- Checklist de conformidade. Trava o botão "Agendar" até: remetente verificado (DKIM ok), rodapé com endereço físico presente,
List-Unsubscribeinjetado, versão texto gerada.
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 │
└──────────────┘ └───────────┘ └─────────────┘
- Lista / segmento. Escolhe público, aplica supressão global (cap. 7) e mostra o tamanho final estimado após remover suprimidos e inativos.
- Template. Seleciona ou cria; herda tema da marca.
- 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).
- Remetente verificado. Só lista identidades com DKIM validado no SES. Nome amigável +
fromalinhado ao domínio autenticado. O remetente é sempre a empresa — a AHRI nunca personifica o cliente no campo From. - Agendamento. Imediato, agendado (com fuso do destinatário) ou por gatilho. Respeita o teto de volume do warm-up vigente.
- Confirmação. Checklist de conformidade + resumo: público N, remetente, assunto, horário, custo estimado.
- 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)
);
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.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 }
]
}
| Operador | Aplica a | Semântica |
|---|---|---|
eq / neq | string, número, bool | Igualdade estrita |
in / not_in | listas | Pertence ao conjunto |
contains | arrays (tags) | Array possui o elemento |
gt / lt / gte / lte | número, data | Comparação ordinal |
within_days | event.* | Ocorreu nos últimos N dias |
never | event.* | Nunca ocorreu (0 registros) |
is_empty / is_set | qualquer atributo | Campo nulo / preenchido |
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
| Gatilho | Dispara quando | Payload disponível |
|---|---|---|
contact.created | Novo opt-in confirmado | contato completo |
segment.entered | Contato passa a satisfazer um segmento | contato + segmentId |
date.reached | Campo de data == hoje (cron diário) | contato + campo |
cart.abandoned | Carrinho sem checkout há 1h+ | contato + itens + valor |
purchase.completed | Pedido pago | contato + pedido |
webhook.received | POST externo com contactId | corpo 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.
- +1h. E-mail lembrete simples com os itens e um botão "finalizar compra". Sem desconto ainda.
- +24h. Se não comprou: prova social + escassez ("últimas unidades"). Ainda sem desconto.
- +48h. Se não comprou: cupom de 10% com validade de 24h. Último toque.
- A qualquer momento.
purchase.completed→ encerra a instância e remove o contato da jornada.
{ "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).
| Semana | Tema | CTA | Condição de saída |
|---|---|---|---|
| 1 | Dor + diagnóstico | Ler artigo | — |
| 2 | Estudo de caso | Ver case | clicou 2x → lead-quente |
| 3 | Comparativo / objeções | Baixar guia | — |
| 4 | Oferta + agenda | Falar com vendas | agendou → 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;
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.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
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ção | Subtipo | Ação automática |
|---|---|---|
| Bounce | Permanent (hard) | Suprimir para sempre + evento bounced |
| Bounce | Transient (soft) | Registrar; suprimir só após 3 soft bounces seguidos |
| Complaint | qualquer | Suprimir para sempre + evento complained |
| Delivery | — | Evento 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)
| Quando | Evento | Detalhe |
|---|---|---|
| 10/07 09:00:02 | sent | Campanha "Julho — Planos" |
| 10/07 09:00:05 | delivered | 250 OK |
| 10/07 09:14:31 | opened | iOS Mail · pt-BR |
| 10/07 09:15:10 | clicked | /planos?ref=news |
| 18/07 08:00:00 | unsubscribed | via 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)
);
| Motivo | Origem | Reversível? |
|---|---|---|
hard_bounce | SNS Bounce Permanent | Não (endereço inexistente) |
complaint | SNS Complaint | Não (marcou como spam) |
unsubscribe | Link do rodapé / List-Unsubscribe | Só por novo opt-in explícito do próprio contato |
manual | Admin / solicitação LGPD | Manual, com registro |
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
- Clique. POST/GET em
/u/<token>insere emsuppressionscomreason='unsubscribe'e grava eventounsubscribed. - Efeito imediato. Qualquer
journey_runativa do contato é cancelada; ele some de todos os segmentos de envio na próxima leitura. - Confirmação. Página simples "Você foi descadastrado" — sem tentativa de retenção que force novo consentimento.
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étrica | Fórmula | Benchmark saudável |
|---|---|---|
| Taxa de entrega | delivered / sent | > 98% |
| Taxa de abertura | opened_únicos / delivered | 25–45% |
| Taxa de clique (CTR) | clicked_únicos / delivered | 2–6% |
| CTOR (clique/abertura) | clicked_únicos / opened_únicos | 10–20% |
| Taxa de bounce | bounced / sent | < 2% |
| Taxa de complaint | complained / delivered | < 0,1% |
| Taxa de descadastro | unsubscribed / 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';
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.
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 SES | Classe | Ação do worker |
|---|---|---|
Throttling / 454 | Transitó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 / ECONNRESET | Transitória (rede) | Retry com backoff, até QUEUE_MAX_ATTEMPTS. |
MessageRejected (endereço inválido) | Permanente | Falha imediata, sem retry. Vai para DLQ e marca o destinatário na suppression list. |
AccountSuspended | Permanente crítica | Pausa 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.
- Pausar.
queue.pause()interrompe o consumo; jobsactiveterminam graciosamente (graceful shutdown comSIGTERMaguardando o job em curso). - Diagnosticar. Query em
message_logporcampaign_idagrupando porstatusmostra onde parou. - Retomar. Um resume-job varre
queued|failede reenfileira com a mesmajobIddeterminí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 });
}
});
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.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.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.
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 │ │
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.
[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
| Etapa | Quem executa | Dado sensível? |
|---|---|---|
| Recomendação, frete, objeção, upsell, cupom | AHRI (Claude) + tools do backend | Não |
| Criação do pedido e da cobrança | Backend NestJS/Laravel + gateway | Não (só valores e IDs) |
| Geração do QR PIX | Gateway; AHRI apenas exibe | Não |
| Digitação do cartão / CVV | Checkout do gateway (iframe/hosted, tokenização no browser) | Sim — isolado no gateway |
| Autorização e confirmação | Adquirente → webhook assinado → backend | Não (só status) |
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
uuidgeradas porgen_random_uuid()(extensãopgcrypto). IDs de negócio nunca são sequenciais expostos. - Multi-tenant por coluna
conta_idem toda tabela de topo, comRow Level Securityativado — nenhuma query cruza contas. - Timestamps sempre
timestamptzem UTC; o front converte para America/Sao_Paulo. - Soft-delete via
deletado_em timestamptz NULLnas entidades que precisam de auditoria (contatos, campanhas, templates). - Enums em tipos nativos
CREATE TYPE— mais baratos quetext+CHECKe autodocumentados.
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)
);
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);
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;
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
| Tabela | Cardinalidade | Chave de partição/índice quente | Retenção |
|---|---|---|---|
contatos | Alta | (conta_id, status), GIN atributos | Enquanto ativo (soft-delete) |
mensagens | Muito alta | (campanha_id, status) | 18 meses |
eventos | Máxima | RANGE ocorrido_em (mês) | 13 meses on-line, resto no S3 |
supressao | Média | (conta_id, email) | Permanente |
automacao_execucoes | Alta | proximo_passo_em parcial | 90 dias após concluída |
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 prefixosk_live_/sk_test_. - Rate limit: 600 req/min por chave (padrão), 60 req/min em
/campanhas/*/enviar. Retorna429comRetry-Aftere headersX-RateLimit-Remaining. - Idempotência:
POSTaceita headerIdempotency-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étodo | Rota | Escopo | Descrição |
|---|---|---|---|
| GET | /v1/contatos | read | Lista/filtra contatos (query em atributos) |
| POST | /v1/contatos | write | Cria ou faz upsert por e-mail |
| POST | /v1/contatos/import | write | Importa CSV (assíncrono, retorna job_id) |
| GET | /v1/listas | read | Lista as listas e contadores |
| POST | /v1/listas | write | Cria lista (opt-in simples/duplo) |
| POST | /v1/segmentos | write | Cria segmento salvo (regras jsonb) |
| POST | /v1/segmentos/preview | read | Conta destinatários sem persistir |
| GET | /v1/templates | read | Lista templates |
| POST | /v1/templates | write | Cria template (pode ser gerado pela AHRI) |
| POST | /v1/campanhas | write | Cria campanha em rascunho |
| POST | /v1/campanhas/{id}/enviar | send | Dispara imediatamente |
| POST | /v1/campanhas/{id}/agendar | send | Agenda para agendada_para |
| POST | /v1/automacoes | write | Cria automação (gatilho + fluxo) |
| GET | /v1/eventos | read | Consulta eventos por mensagem/tipo/período |
| GET | /v1/supressao | read | Lista supressões |
| POST | /v1/supressao | write | Adiciona supressão manual |
| DELETE | /v1/supressao/{email} | write | Remove (reabilita envio) |
| POST | /v1/dominios | write | Registra domínio, retorna DKIM CNAMEs |
| POST | /v1/dominios/{id}/verificar | write | Dispara checagem de DNS (DKIM/SPF/DMARC) |
| POST | /webhooks/sns | — | Ingest 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"
}
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.
- SubscriptionConfirmation. No primeiro handshake, faça
GETnaSubscribeURLpara confirmar a assinatura do tópico. Só aceite URLs emsns.*.amazonaws.com. - Validar assinatura. Baixe o certificado de
SigningCertURL(valide o host*.amazonaws.com), monte a string canônica dos campos na ordem exigida e verifiqueSignaturecomSHA256withRSA(SignatureVersion1). Rejeite com403se falhar. - Notification. O
Messageé uma string JSON — faça parse e roteie poreventType. - Responder rápido. Devolva
200em < 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
eventType | Ação em eventos | Ação em mensagens | Ação em supressao |
|---|---|---|---|
Delivery | insere delivery | entregue, seta entregue_em | — |
Open | insere open (ua/ip em dados) | aberta, seta primeira_abertura_em | — |
Click | insere click (url em dados) | clicada, seta primeiro_clique_em | — |
Bounce (Permanent) | insere bounce, bounce_tipo=hard | bounce | insere hard_bounce |
Bounce (Transient) | insere bounce, bounce_tipo=soft | bounce | só após N reincidências |
Complaint | insere complaint | reclamou | insere complaint |
/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 }
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.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.
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ário | Base legal | Evidência exigida |
|---|---|---|
| Newsletter / promoções B2C | Consentimento (art. 7º, I) | Timestamp, IP, origem do formulário, texto do checkbox exibido |
| Prospecção B2B fria | Legí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ção | Cumprimento de obrigação / exercício de direito | Contrato inadimplido, base de crédito, finalidade compatível |
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 (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.
| Direito | Ação no sistema | SLA |
|---|---|---|
| Confirmação / acesso | Export JSON de todos os registros do titular | 15 dias |
| Correção | Update de contato + log de auditoria | 15 dias |
| Eliminação | Purge + inserção em supressão (evita recaptura) | 15 dias |
| Revogação de consentimento | Preenche revoked_at, cessa disparos | Imediato |
| Oposição | Opt-out honrado, mesmo em legítimo interesse | Imediato |
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 PCI | Como cumprimos |
|---|---|
| Não armazenar PAN/CVV | Backend recebe só payment_token e status; validação por schema rejeita qualquer campo tipo cartão |
| Transmissão criptografada | TLS 1.2+ obrigatório; iframe/redirect do gateway sob HTTPS; HSTS ativo |
| Webhook autêntico | Verificação de assinatura do gateway (HMAC) antes de processar; rejeita replays por idempotency key |
| Segregação de escopo | Página de pagamento servida pelo gateway, não pelo nosso domínio de app |
| Sem log de cartão | Filtro de logging redige qualquer sequência 13-19 dígitos que passe Luhn |
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.
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
| # | Controle | Domínio | Evidência de conformidade |
|---|---|---|---|
| C-01 | Opt-in registrado com snapshot | LGPD | consent_log com IP, texto e versão de política |
| C-02 | Double opt-in em base própria | LGPD | confirmed_at preenchido |
| C-03 | Unsubscribe 1-clique em todo e-mail | LGPD | Cabeçalho List-Unsubscribe-Post |
| C-04 | Lista de supressão pré-envio | LGPD | Guarda antes do enfileiramento |
| C-05 | Atendimento a DSAR | LGPD | Endpoint export/erase + SLA 15 dias |
| C-06 | Encarregado (DPO) publicado | LGPD | [email protected] no rodapé |
| C-07 | Cartão só no gateway (SAQ-A) | PCI | Iframe/redirect; sem form no nosso back |
| C-08 | Webhook de pagamento assinado | PCI | Verificação HMAC + idempotência |
| C-09 | Redação de PAN em logs | PCI | Filtro Luhn no logger |
| C-10 | Segredos em cofre + IAM mínimo | Segurança | Secrets Manager, Role por ambiente |
| C-11 | TLS 1.2+ e HSTS | Segurança | Config Cloudflare/edge |
| C-12 | Isolamento multi-tenant (RLS) | Segurança | Policy por tenant_id |
| C-13 | Trilha de auditoria imutável | Segurança | audit_log append-only, 12 meses |
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).
Requisitos Funcionais
Contatos e listas
| ID | Requisito | Prio. | Critério de aceite |
|---|---|---|---|
| RF-01 | Importar contatos via CSV com mapeamento de colunas | Must | Arquivo de 100k linhas importa em < 60s; linhas inválidas viram relatório de erro sem abortar o lote |
| RF-02 | Deduplicar contatos por e-mail dentro do tenant | Must | Importar o mesmo e-mail duas vezes resulta em 1 registro; conflito de campos segue regra de precedência configurada |
| RF-03 | Campos personalizados (custom fields) por tenant | Should | Tenant cria campo empresa; valor fica disponível em merge tags e segmentação |
| RF-04 | Gerenciar múltiplas listas e associação N:N a contatos | Must | Um contato pertence a ≥2 listas; remover de uma lista não o remove das demais |
| RF-05 | Validação sintática e MX do e-mail na entrada | Should | E-mail sem MX válido é marcado risky e excluído de disparos por padrão |
Templates e campanhas
| ID | Requisito | Prio. | Critério de aceite |
|---|---|---|---|
| RF-06 | Editor de template com merge tags e pré-visualização | Must | {{primeiro_nome}} renderiza no preview e no envio; tag sem valor usa fallback definido |
| RF-07 | Geração de copy assistida pela AHRI (Claude) | Should | Dado brief + tom, AHRI retorna assunto + corpo em < 10s; usuário edita antes de salvar |
| RF-08 | Criar campanha vinculando template + segmento + remetente | Must | Campanha só entra em "pronta" se template, segmento e remetente verificado estiverem definidos |
| RF-09 | Agendar disparo (imediato, data/hora, fuso do destinatário) | Should | Agendar para 09:00 no fuso do contato dispara na hora local correta ±5 min |
| RF-10 | Teste A/B de assunto | Could | Divide amostra %, mede taxa de abertura, envia vencedor ao restante |
| RF-11 | Integridade do remetente: sempre a empresa, nunca personificar o contato | Must | Campo From resolve para domínio verificado da CL/tenant; impossível usar e-mail do próprio destinatário como remetente |
Segmentação e automação
| ID | Requisito | Prio. | Critério de aceite |
|---|---|---|---|
| RF-12 | Segmentar por campos, tags, engajamento e eventos | Must | Segmento "abriu últimos 30d E não comprou" retorna contagem correta e é reutilizável |
| RF-13 | Segmento dinâmico (recalculado no disparo) | Should | Contato que satisfaz o filtro após a criação entra automaticamente no próximo envio |
| RF-14 | Automação por gatilho (welcome, abandono, aniversário) | Should | Novo contato recebe e-mail de boas-vindas em < 5 min do opt-in confirmado |
| RF-15 | Fluxos multi-etapa com espera e condição | Could | Fluxo: e-mail 1 → espera 2d → se não abriu, e-mail 2; ramificação avaliada corretamente |
Tracking, entrega e supressão
| ID | Requisito | Prio. | Critério de aceite |
|---|---|---|---|
| RF-16 | Rastrear aberturas (pixel) e cliques (link wrap) | Must | Abertura e clique aparecem no relatório em < 2 min; cada clique preserva URL original |
| RF-17 | Ingestão de eventos SES via SNS (delivery, bounce, complaint) | Must | Bounce permanente e complaint inserem em supressão automaticamente e em tempo real |
| RF-18 | Lista de supressão aplicada antes de enfileirar | Must | Contato suprimido nunca recebe disparo, mesmo se estiver no segmento |
| RF-19 | Unsubscribe 1-clique (RFC 8058) e página de preferências | Must | Clique no link marca supressão e cessa envios; header List-Unsubscribe-Post presente |
| RF-20 | Dashboard de deliverability (taxa de entrega, bounce, complaint) | Should | Métricas por campanha e agregadas; alerta quando complaint > 0,1% |
Filas, envio e módulo conversacional
| ID | Requisito | Prio. | Critério de aceite |
|---|---|---|---|
| RF-21 | Fila de disparo com rate limit por identidade SES | Must | Respeita o sending quota e max send rate; não excede cota nem em pico |
| RF-22 | Retentativa com backoff em falha transitória | Must | Throttling/erro 4xx transitório reenfileira com backoff exponencial; erro permanente não |
| RF-23 | Idempotência de envio (sem duplicado por retry) | Must | Reprocessar o mesmo job não gera segundo e-mail ao destinatário |
| RF-24 | Módulo conversacional AHRI responde a replies | Should | Resposta do lead é classificada (interesse/objeção/opt-out) e sugere réplica; opt-out suprime na hora |
| RF-25 | Handoff humano no conversacional | Could | Quando confiança < limiar, conversa é encaminhada a atendente com contexto |
Integrações
| ID | Requisito | Prio. | Critério de aceite |
|---|---|---|---|
| RF-26 | Integração AWS SES (envio) e SNS (eventos) | Must | Identidade verificada com DKIM/SPF/DMARC; webhook SNS validado por assinatura |
| RF-27 | Motor de IA Claude para geração e classificação | Must | Chamadas com timeout e fallback; prompt versionado; custo por token registrado |
| RF-28 | Webhook de saída para CRM do cliente | Could | Eventos (abertura, clique, resposta) entregues com retry e assinatura HMAC |
| RF-29 | API REST autenticada para gestão de contatos/campanhas | Should | Endpoints documentados (OpenAPI); auth por token de tenant com escopo |
Requisitos Não-Funcionais
| ID | Categoria | Requisito | Prio. | Critério de aceite / meta |
|---|---|---|---|---|
| RNF-01 | Throughput | Vazão de envio sustentada | Must | ≥ 50 e-mails/s por tenant sem estourar cota SES; escalável por aumento de cota |
| RNF-02 | Latência | Resposta da API de gestão | Must | p95 < 300 ms; p99 < 800 ms em carga nominal |
| RNF-03 | Latência IA | Geração de copy pela AHRI | Should | p95 < 10 s para assunto+corpo; timeout de 20 s com fallback |
| RNF-04 | Disponibilidade | Uptime do serviço de envio e API | Must | ≥ 99,9% mensal; fila resiliente a reinício sem perda de job |
| RNF-05 | Segurança | Segredos em cofre, TLS 1.2+, IAM mínimo | Must | Nenhum segredo em repositório; TLS obrigatório; scan sem chave exposta |
| RNF-06 | LGPD | Consentimento comprovável e DSAR | Must | Conforme controles C-01..C-06 do cap. de Segurança; export/erase ≤ 15 dias |
| RNF-07 | PCI-DSS | Escopo SAQ-A | Must | Nenhum dado de cartão no backend; pagamento só no gateway |
| RNF-08 | Escalabilidade | Crescer horizontalmente workers de fila | Should | Dobrar workers dobra vazão de forma ~linear até o limite de cota SES |
| RNF-09 | Multi-tenant | Isolamento de dados por tenant | Must | RLS ativa; teste de vazamento cross-tenant retorna 0 registros alheios |
| RNF-10 | Observabilidade | Logs estruturados, métricas e tracing | Should | Toda campanha rastreável ponta a ponta; dashboards de fila, entrega e erro |
| RNF-11 | Deliverability | Autenticação e reputação de domínio | Must | SPF, DKIM e DMARC válidos; complaint < 0,1%; bounce < 2% |
| RNF-12 | Custo | Controle de custo por disparo e por token IA | Should | Custo/1k e-mails e custo/campanha visíveis; alerta ao exceder orçamento |
| RNF-13 | Manutenibilidade | Cobertura de testes e CI | Should | Cobertura ≥ 70% no core; pipeline bloqueia merge com teste vermelho |
| RNF-14 | Portabilidade | Deploy reprodutível | Could | Ambiente sobe via IaC; sem passo manual não documentado |
| RNF-15 | Acessibilidade | Front conforme WCAG 2.1 AA | Should | Editor 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
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)
Comparativo por critério
| Critério | Plataforma própria (Next.js + SES) | Mautic |
|---|---|---|
| Controle | Total. 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. |
| Flexibilidade | Ilimitada — 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 infra | Baixo 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ção | Você 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. |
| Escalabilidade | Elá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-market | Semanas para o MVP (contatos + template + envio + tracking). Curva inicial maior. | Dias para colocar de pé. Ganha o sprint 1, perde o ano. |
| Lock-in | Nenhum 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. |
| Deliverability | Sob 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 produto | Má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-label | Projetável desde o schema (tenant_id em tudo). Vira SaaS vendável. | Não nativo. Multi-tenant no Mautic é gambiarra ou instâncias separadas. |
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, ajustandomax_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.
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:
- 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.
- 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.
- 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.
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.
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ão | Objetivo | Escopo | Crité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. |
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
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.
- SPF. Registro TXT do domínio de envio inclui
include:amazonses.come resolve sem múltiplosv=spf1. - 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.
- DMARC. Registro
_dmarc.clsolucoes.comexiste com política ao menosp=nonee endereçorua=recebendo relatórios. - Domínio verificado no SES. Identidade de domínio (não só e-mail) verificada; conta fora do sandbox com quota adequada ao volume.
- Warm-up. Se IP dedicado novo, respeitar a rampa (ex.: 50 → 500 → 5.000/dia) antes do volume cheio.
- Supressão sincronizada. Lista de supressão (bounce/complaint/unsubscribe) aplicada; nenhum endereço suprimido entra no batch.
- Link de descadastro. Cada e-mail tem unsubscribe de um clique e header
List-Unsubscribe+List-Unsubscribe-Post. - Higiene da lista. Endereços validados sintaticamente; role-based (
contato@,vendas@) e domínios inexistentes removidos. - Teste de spam. Envio-teste para caixas Gmail/Outlook/Yahoo próprias; conferir pasta de entrada e pontuação (SpamAssassin/mail-tester > 8/10).
- Métricas de guarda. Bounce esperado < 5% e complaint < 0,1%; alarme configurado para pausar campanha se estourar.
*.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
| Termo | Definição |
|---|---|
| SES | Amazon Simple Email Service — motor de envio de e-mail transacional e em massa, cobrado por volume. |
| SNS | Amazon Simple Notification Service — pub/sub que entrega eventos do SES (bounce, complaint, delivery) ao seu webhook. |
| Bounce | Falha de entrega. Hard bounce = permanente (endereço inexistente) → suprimir sempre. Soft bounce = temporário (caixa cheia) → repetir com limite. |
| Complaint | Destinatário marcou como spam. Deve suprimir imediatamente; taxa acima de 0,1% ameaça a conta SES. |
| Supressão | Lista de endereços que nunca devem receber envio (bounce, complaint, descadastro). Aplicada antes de todo batch. |
| SPF | Sender Policy Framework — registro DNS que autoriza servidores a enviar em nome do domínio. |
| DKIM | DomainKeys Identified Mail — assinatura criptográfica que prova que o e-mail não foi adulterado e vem do domínio. |
| DMARC | Política que diz aos provedores o que fazer quando SPF/DKIM falham, mais relatórios de abuso. |
| Warm-up | Rampa gradual de volume em IP/domínio novo para construir reputação sem cair em spam. |
| Deliverability | Capacidade de chegar à caixa de entrada (não spam). Função de reputação, autenticação e higiene de lista. |
| Configuration Set | Agrupamento de regras no SES que define para onde publicar os eventos de envio (o tópico SNS). |
| Multi-tenant | Arquitetura em que uma instância serve múltiplos clientes isolados por tenant_id, base do white-label. |
| AHRI | IA comercial da CL Soluções (motor Claude) que gera copy, roda A/B e conduz a conversa de venda até o checkout. |