Status: 🟡 Em fluxo (mega-módulo, várias features convivendo) Code: backend/app/modules/email UI: frontend/src/app/(admin)/[slug]/admin/email Última revisão deste doc: 2026-05-13 por Felipe + Claude Dependências fortes: crm (resolve recipient + atualiza status), intelligence (engagement_score), tracking (eventos)
1. Identidade
O que faz (uma frase)
Sistema completo de email marketing + transacional com adapter pattern (Brevo default, Resend, SendGrid) — campanhas broadcast, sequências automatizadas, jornadas visuais, A/B tests, signup forms, supressões, validação DKIM/SPF/DMARC e webhooks de provider para tracking de opens/clicks/bounces.
Por que existe (negócio)
Email continua sendo canal-chave de nutrição (jornadas multi-toque) e transacional (confirmação, recuperação senha, notificações). Sem este módulo, Mandir teria que integrar Brevo/Mailchimp externamente — perdendo o cruzamento com CRM + intelligence.
Com Email integrado:
- Mais Consciente envia broadcast pra 250 alunos com 1 clique.
- Sequência "Onboarding 7 dias" automática após signup.
- Jornada visual com nó condicional ("se abriu email A, mandar B; senão, mandar C").
- A/B test de assunto antes de send em massa.
- Engagement score do contato considera open/click rate.
Por que existe (técnico)
- Adapter pattern (Brevo / Resend / SendGrid) — trocar provider sem refactor.
- MJML compilation — templates responsivos sem código manual.
- Worker assíncrono pra dispatch em batch (chunks de 25 + sleep 200ms).
- Webhook unificado — diferentes providers, mesma tabela
email_send. - Suppression list global (per-tenant) — opt-out + bounces + complaints.
- Sender Identity validation — DKIM/SPF/DMARC checked via
dnspython.
Status atual
Em produção mas com features em maturidade desigual:
- Broadcast + transactional: estável.
- Sequences: estável (worker
process_sequence_steps). - Journeys (visual flow): estável estrutural (worker
process_journey_runs); UI de edição em iteração. - A/B tests: schema cravado, lógica de winner_decision manual hoje.
- Signup forms: estável; sem analytics de conversion ainda.
Próxima mudança: unificar dispatch de email + WhatsApp em pipeline de "campaign" cross-canal; adicionar embeddings em templates pra recommendation.
2. Cases de uso reais
Case 1: Broadcast pra lista (Brevo)
Felipe cria campanha "Aula extra sexta", seleciona lista 250 alunos, scheduling immediate. dispatch_campaign_task enfileira → batch 25 + sleep 200ms → BrevoAdapter.send → email_send por destinatário → webhook delivered/opened/clicked atualiza status.
Case 2: Sequência onboarding 7 dias
Form signup cria EmailSequenceEnrollment em EmailSequence("Onboarding") com 5 steps (delay_hours: 0, 24, 72, 120, 168). Beat process_sequence_steps (a cada 30min) calcula due_at = enrolled_at + sum(delays[0..current_step]), dispara quando now >= due_at, incrementa current_step.
Case 3: Jornada visual com branch condicional
Felipe edita journey: trigger (tag.added=lead-quente) → email A → delay 24h → condition (opened_email A?) → true: email B / false: email C → end. Beat process_journey_runs avança nó por nó respeitando wait_until em delays.
Case 4: Webhook Brevo opened
Brevo envia POST /api/email/webhooks/brevo com event=opened, message-id=X. Webhook atualiza EmailSend (matched por provider_message_id) → status=opened, opened_at=now. Emite crm.contact.email_opened event → intelligence atualiza engagement_score.
3. Oportunidades de negócio
- Email-as-a-service standalone: "Mandir Email" sem o resto do Suite (Brevo wrapper + sequences + journeys + A/B). Pricing menor.
- Vertical templates: templates pré-aprovados por vertical (e-commerce abandoned cart, course re-engagement, etc.).
- Deliverability score: scoring proprietário (DKIM ok + bounce rate + complaint rate + open rate vs benchmark) — premium feature.
- Cross-channel orchestration: journey nodes que podem ser email OU WhatsApp OU SMS (já preparado em Hub.1).
- Embeddings em templates: recomendar template baseado em contexto do contato.
Riscos: dependência Brevo (preços podem subir); deliverability degrada se admin não configura DKIM corretamente; LGPD/CAN-SPAM exige opt-out estrito.
4. Arquitetura interna
Arquivos
| Arquivo | Propósito |
|---|---|
models.py | 15 tabelas |
service.py | send_transactional, schedule_dispatch_campaign, compile_campaign_html, render_template |
tasks.py | dispatch_campaign_task |
sequence_worker.py | process_sequence_steps (Beat 30min) |
journey_worker.py | process_journey_runs (Beat 30min) |
*_routes.py | Sub-routers por sub-recurso |
adapters/ | base.py, brevo.py, resend.py, sendgrid.py |
Adapters externos
| Provider | Default? | Endpoint | Auth | Webhook events |
|---|---|---|---|---|
| Brevo | ✅ | https://api.brevo.com/v3/smtp/email | API key | delivered, opened, click, soft/hard_bounce, spam, unsubscribed |
| Resend | https://api.resend.com/emails | API key | delivered, bounced, complained | |
| SendGrid | https://api.sendgrid.com/v3/mail/send | API key | processed, delivered, opened, clicked, bounced, dropped, deferred |
Decisão via settings.email_provider (env EMAIL_PROVIDER).
Tasks Celery
| Task | Schedule | Idempotência |
|---|---|---|
email.dispatch_campaign | On-demand | Loop por recipient com chunks 25 + sleep 200ms |
email.process_sequence_steps | Beat 30min | due_at calculado, status active only, suppression check |
email.process_journey_runs | Beat 30min | wait_until respeitado, status active only, suppression check |
5. Tabelas + relacionamentos (15)
Todas com tenant_id indexed. Sem FK cross-módulo (UUIDs soltos).
Campanhas / Sends (2)
email_campaign— slug, subject, body_html/mjml, status, campaign_type (broadcast/sequence_step/journey_action/transactional), scheduled_for, sent_at, sent_count/open_count/click_count/etc., template_id, sender_identity_id, list_id, segment_id, ab_test_id.email_send— campaign_id (FK CASCADE), to_email, status (queued/sent/delivered/opened/clicked/bounced/failed), provider, provider_message_id (key pra webhook match), sent_at/opened_at/clicked_at, sequence_enrollment_id, ab_variant.
Templates / Senders (2)
email_template— name, subject_default, body_html/mjml/json (Unlayer design), thumbnail_url, is_global.email_sender_identity— from_email, from_name, reply_to, domain, status (pending/verified/failed), is_default, dns_spf_ok/dkim_ok/dmarc_ok, dns_checked_at.
Listas / Membros / Suppressions (3)
email_list— name, double_optin, member_count cache.email_list_member— list_id (FK CASCADE), email, status (subscribed/unsubscribed/bounced/complained/pending_confirm), source. UNIQUE(list_id, email).email_suppression— email, reason (bounce_hard/bounce_soft/complaint/unsubscribed/manual). UNIQUE(tenant_id, email).
Sequences (3)
email_sequence— name, status (draft/active/paused/archived), trigger_event.email_sequence_step— sequence_id (FK CASCADE), step_order, template_id, delay_hours, condition_json.email_sequence_enrollment— sequence_id, email, current_step, status (active/completed/cancelled/paused), enrolled_at.
Journeys (3)
email_journey— name, status, trigger_event, trigger_config.email_journey_node— journey_id (FK CASCADE), node_type (trigger/email/delay/condition/ab_split/end), config (JSONB tipo-específico), next_node_id.email_journey_run— journey_id, email, current_node_id, status, wait_until (pra delays), extra (JSONB com variáveis).
A/B Tests + Forms (2)
email_ab_test— name, metric (open_rate/click_rate), sample_pct, winner_variant, decided_at.email_signup_form— name, list_id, double_optin, fields (JSONB), success_message, redirect_url, submit_count.
Relacionamentos cross-módulo
| Direção | Outro módulo | Como |
|---|---|---|
| ↗ Lê | crm | email_send.crm_contact_id (UUID solto), service call get_contact_by_email |
| ↘ Emite event | intelligence | email.send.opened/clicked → engagement_score |
| ↘ Emite event | crm | crm.contact.email_opened/clicked/bounced/unsubscribed |
| ↗ Lê | tracking | webhook eventos consumidos pra audit |
6. API / Endpoints (~30)
Prefixo /api/email.
Campaigns (7)
| Método | Rota | O que faz |
|---|---|---|
| GET / POST / PATCH / DELETE | /campaigns | CRUD |
| POST | /campaigns/{id}/dispatch | Enfileira dispatch (Celery) |
| POST | /campaigns/{id}/schedule | Agenda hora específica |
Templates / Senders / Lists / Suppressions
- Templates: GET/POST/GET{id}/PATCH/DELETE.
- Senders: CRUD +
POST /senders/{id}/verify-dns+GET /senders/{id}/dns-status. - Lists: CRUD +
GET /lists/{id}/members+POST /members+POST /members/bulk+DELETE /members/{id}. - Suppressions: GET (filter reason) + POST + bulk +
POST /suppressions/check(lookup batch).
Sequences / Journeys / A/B Tests
- Sequences: CRUD +
/{id}/steps+/{id}/enrollments. - Journeys: CRUD +
/{id}/nodes/batch(criar nós em lote) +/{id}/runs+/{id}/enrollments. - A/B Tests: CRUD +
/ab-tests/{id}/decide(escolhe winner).
Signup Forms (1 público)
| Método | Rota | Auth | O que faz |
|---|---|---|---|
| POST | /signup-forms/{id}/submit | Público (sem auth) | Submeter form |
Reports / Overview / Providers / Webhooks
- Reports:
/reports/campaigns/{id}+/recipients+/lists/{id}. - Overview:
/overview(stats agregadas). - Providers: GET status, GET metadata.
- Webhooks:
POST /webhooks/{brevo,resend,sendgrid}(sem auth, valida via X-Webhook-Token).
7. Configuração
Env vars
| Var | Provider |
|---|---|
EMAIL_PROVIDER | brevo (default) / resend / sendgrid |
BREVO_API_KEY / BREVO_SENDER_EMAIL / BREVO_SENDER_NAME / BREVO_WEBHOOK_TOKEN | Brevo |
RESEND_API_KEY / RESEND_SENDER_EMAIL / RESEND_SENDER_NAME | Resend |
SENDGRID_API_KEY / SENDGRID_SENDER_EMAIL / SENDGRID_SENDER_NAME | SendGrid |
Kill switches
- Trocar
EMAIL_PROVIDER→ todas as envios passam pelo novo provider. - Set as keys vazias →
_adapter()raisesRuntimeError("brevo_not_configured"). - Pause sequence/journey:
status='paused'.
DNS por provider (auto-validation)
| Provider | SPF include | DKIM selector |
|---|---|---|
| Brevo | spf.brevo.com | brevo |
| Resend | amazonses.com | resend |
| SendGrid | sendgrid.net | s1 |
8. Operações
Como criar campanha
UI: /admin/email/campaigns → "Nova" → form (subject, body MJML/HTML, list, sender) → save (status=draft) → "Dispatch" (status=sending → sent).
Como configurar sender com DKIM
- UI
/admin/email/senders→ adicionar. - Sistema mostra TXTs (SPF + DKIM + DMARC) pra colar no DNS provider.
- Wait DNS propagar (~10-30min).
- Click "Verify DNS" → backend chama
dnspython.asyncresolver→ atualizadns_*_ok.
Troubleshooting
Sintoma: Campanha em status=sending eternamente
Diagnóstico: verificar docker logs mandir-suite-worker | grep email.dispatch.
Causa comum: suppression ate todos recipients; provider rate limit; auth falhou (provider_not_configured).
Sintoma: Webhook Brevo não chega
Diagnóstico: verificar BREVO_WEBHOOK_TOKEN vazio (sem validação) ou correto. Verificar URL configurada no painel Brevo.
Sintoma: Email entra como spam
Causa: DKIM/SPF/DMARC não validado. Fix: completar DNS records + click "Verify DNS".
9. Métricas e observabilidade
| Logger key | Quando emite |
|---|---|
email.send.sent | Transactional/campaign enviado OK |
email.send.failed | Falhou |
email.campaign.dispatch.start/done | Worker iniciou/terminou |
email.webhook.{provider}.received | Webhook chegou |
email.suppression.created | Add to suppression (auto via webhook ou manual) |
10. Limitações e débitos técnicos conhecidos
| # | Item | Plano |
|---|---|---|
| 1 | condition_json em sequence_step não implementado | Fase 2 |
| 2 | Template vars sem escape (XSS risk se var vem de user input) | Adicionar Jinja2 com autoescape |
| 3 | MJML compile fail = blocker | Fallback pra body_html |
| 4 | Sem deduplicação cross-sequence | Mesmo contato em N sequences = N emails |
| 5 | Suppression irreversível | Manual remove |
| 6 | Provider lock-in (DKIM diferente) | Trocar exige reconfig DNS |
| 7 | Cross-module FKs: crm_contact_id UUID solto | OK (convenção Mandir) |
| 8 | A/B winner manual | Auto-decision via beat task TBD |
11. Histórico relevante
- Estável desde Suite v2. Adapters Brevo/Resend/SendGrid maduros.
- Sequences + Journeys entregues em ondas distintas (sequences primeiro, journeys depois com flow-builder).
- Signup forms públicos: Sprint pós-monolito.