Tema
Módulo Email
Localizações:
- Templates:
backend/src/modules/email-templates/ - Rastreamento e processamento HTML:
backend/src/modules/marketing/email-tracking/ - Processador de envio:
backend/src/modules/marketing/campaigns/services/email-send.processor.ts
O módulo de email abrange três responsabilidades distintas: gestão dos templates MJML/HTML usados em campanhas, processamento do HTML antes do envio (compilação MJML, rastreamento, unsubscribe), e envio assíncrono via fila BullMQ com registro de bounces e métricas.
Arquivos e Responsabilidades
| Arquivo | Classe | Responsabilidade |
|---|---|---|
email-templates/email-templates.service.ts | EmailTemplatesService | CRUD de templates de email (MJML, designJson, thumbnail, contagem de uso) |
email-tracking/email-tracking.service.ts | EmailTrackingService | Registro de aberturas, cliques e descadastros; detecção de provedor de email |
email-tracking/email-tracking.controller.ts | EmailTrackingController | Endpoints públicos: pixel GIF, redirect de clique, página de unsubscribe |
email-tracking/html-processor.ts | — (funções exportadas) | Pipeline de processamento HTML: compilação MJML, upload de imagens base64, wrap de links, footer e pixel |
campaigns/services/email-send.processor.ts | EmailSendProcessor | Worker BullMQ da fila email-send: envia email via SMTP Nodemailer, classifica bounces, atualiza métricas |
EmailTemplatesService
Classe: EmailTemplatesServiceLocalização: backend/src/modules/email-templates/email-templates.service.ts
CRUD completo de templates de email por organização. Todos os métodos recebem orgId como primeiro parâmetro e aplicam escopo de organização na query.
Endpoints
Permissão requerida: app:campaigns
| Método | Path | Descrição |
|---|---|---|
GET | /email-templates | Lista templates com paginação e filtros |
GET | /email-templates/merge-fields | Lista campos de personalização disponíveis |
GET | /email-templates/:id | Detalhes de um template |
POST | /email-templates | Cria template |
PATCH | /email-templates/:id | Atualiza template |
DELETE | /email-templates/:id | Remove template |
POST | /email-templates/:id/duplicate | Duplica como novo template |
POST | /email-templates/:id/thumbnail | Upload de thumbnail (MinIO) |
findAll(orgId, filter): Promise<{ data, total, page, limit }>
Lista templates com paginação e filtros opcionais.
Parâmetros de EmailTemplateFilterDto:
page?: number— padrão 1limit?: number— padrão 20, máximo 100category?: string— filtro exato por categoriasearch?: string— busca insensível a maiúsculas no camponame
Executa em transação ($transaction) para obter os dados e o total em uma única roundtrip. Ordena por updatedAt DESC.
findOne(orgId, id): Promise<EmailTemplate>
Busca template por ID com escopo de organização. Lança NotFoundException se não encontrado.
create(orgId, dto): Promise<EmailTemplate>
Cria novo template. Campos:
name: string— obrigatóriocategory: string— padrão'general'mjml: string— código MJML (pode ser HTML puro se não contiver<mjmlou<mj-)designJson?: object— JSON do editor visual (Unlayer/similar), armazenado como JSONBthumbnailUrl?: string— URL do preview (preenchida após upload separado)
update(orgId, id, dto): Promise<EmailTemplate>
Atualiza campos fornecidos (partial update). Primeiro valida existência via findOne(). Apenas campos presentes no DTO são atualizados (spreads condicionais).
remove(orgId, id): Promise<{ deleted: true }>
Remove template. Primeiro valida existência. Retorna { deleted: true }.
duplicate(orgId, id): Promise<EmailTemplate>
Cria cópia do template com nome "{nome} (Cópia)". A cópia tem thumbnailUrl = null e usedCount = 0. O designJson e mjml são copiados integralmente.
incrementUsage(orgId, id): Promise<EmailTemplate>
Incrementa usedCount em 1. Chamado automaticamente quando o template é usado para enviar uma campanha.
getMergeFields(): Array<{ key, label, sample }>
Retorna a lista de campos de personalização disponíveis para uso nos templates. Os campos são substituídos no momento do envio com os dados reais do destinatário.
Campos disponíveis:
| Chave | Label | Exemplo |
|---|---|---|
\{\{customer.name\}\} | Nome completo do cliente | João Silva |
\{\{customer.firstName\}\} | Primeiro nome do cliente | João |
\{\{customer.email\}\} | E-mail do cliente | joao@email.com |
\{\{customer.phone\}\} | Telefone do cliente | (11) 99999-9999 |
\{\{store.name\}\} | Nome da loja | Loja Centro |
\{\{store.city\}\} | Cidade da loja | São Paulo |
\{\{seller.name\}\} | Nome do vendedor | Maria Souza |
\{\{organization.name\}\} | Nome da organização | Empresa XYZ |
HTML Processor
Localização: backend/src/modules/marketing/email-tracking/html-processor.ts
Conjunto de funções puras exportadas que compõem o pipeline de processamento de HTML antes do envio de um email de campanha. Cada função recebe HTML (ou MJML) e retorna HTML modificado.
processEmailHtml(mjmlOrHtml, trackingId, baseUrl, imageUploader?): Promise<string>
Função principal que orquestra o pipeline completo na ordem correta:
1. compileMjml() — MJML → HTML (ou pass-through se já for HTML)
2. uploadBase64Images() — imagens embutidas → URLs MinIO (se imageUploader fornecido)
3. wrapLinks() — href → URL de rastreamento de clique
4. addUnsubscribeFooter() — footer com link de descadastro
5. injectTrackingPixel() — <img> 1x1 antes de </body>Parâmetros:
mjmlOrHtml: string— conteúdo MJML ou HTML purotrackingId: string— UUID único doEmailMessage, usado para construir todas as URLs de trackingbaseUrl: string— base da API (ex:https://apicrm.galdix.com.br)imageUploader?: (buffer: Buffer, mimeType: string) => Promise<string>— callback para upload de imagens base64
compileMjml(input): string
Detecta MJML pela presença de <mjml ou <mj- no input. Se detectado:
- Chama
sanitizeMjml()para garantirwidth="600px"nomj-body. - Compila com
mjml2html(input, { validationLevel: 'skip' }). - Se houver warnings → loga contagem e primeira mensagem (não falha).
- Se a compilação lançar exceção → propaga o erro (falha o processamento).
Se não for MJML → retorna o input sem modificação.
sanitizeMjml(input): string
Garante que <mj-body> tenha width="600px" se o atributo estiver ausente. Necessário para compatibilidade com clientes de email que não respeitam larguras implícitas.
uploadBase64Images(html, uploader): Promise<string>
Detecta todos os src="data:image/(png|jpeg|jpg|gif|webp);base64,..." com regex global. Para cada imagem:
- Extrai tipo MIME e dados base64.
- Converte para
Buffer. - Chama
uploader(buffer, mimeType)→ obtém URL pública (MinIO). - Substitui o
srcpela URL.
Imagens que falham no upload são mantidas como base64 (log de warning, não falha o processamento).
wrapLinks(html, trackingId, baseUrl): string
Substitui todos os href="https://..." e href="http://..." por URLs de rastreamento de clique. Regex global, case-insensitive.
href="https://exemplo.com/pagina"
→ href="{baseUrl}/email-tracking/c/{trackingId}?url=https%3A%2F%2Fexemplo.com%2Fpagina"Links ignorados (não são rastreados):
- URLs que já contêm
/email-tracking/(evita double-wrap) - Links
mailto:etel:
addUnsubscribeFooter(html, trackingId, baseUrl): string
Injeta div de rodapé antes de </body> (ou ao final se não houver </body>):
html
<div style="text-align:center;padding:20px 0 10px;font-size:11px;color:#999;">
<a href="{baseUrl}/email-tracking/u/{trackingId}" style="color:#999;text-decoration:underline;">
Descadastrar
</a>
· Você recebeu este e-mail porque está cadastrado em nossa lista.
</div>injectTrackingPixel(html, trackingId, baseUrl): string
Injeta pixel de rastreamento de abertura imediatamente antes de </body>:
html
<img src="{baseUrl}/email-tracking/t/{trackingId}.png"
width="1" height="1"
style="display:block;width:1px;height:1px;"
alt="" />getListUnsubscribeHeaders(trackingId, baseUrl): object
Retorna os headers HTTP de unsubscribe conformes com RFC 8058:
typescript
{
'List-Unsubscribe': '<{baseUrl}/email-tracking/u/{trackingId}>',
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click'
}Esses headers são passados ao EmailSendProcessor no payload do job e adicionados ao email enviado via Nodemailer. Permitem que clientes de email como Gmail exibam o botão "Cancelar inscrição" nativamente.
EmailTrackingService
Classe: EmailTrackingServiceLocalização: backend/src/modules/marketing/email-tracking/email-tracking.service.ts
Serviço de registro de eventos de rastreamento. Opera sempre sobre trackingId — o UUID único por EmailMessage.
recordOpen(trackingId): Promise<boolean>
Registra a primeira abertura de um email. Idempotente: só conta uma vez (verifica openedAt === null antes de atualizar).
Fluxo:
- Busca
EmailMessagepelotrackingId(select: { id, campaignId, openedAt }). - Retorna
falsese não encontrado. - Se
openedAtjá preenchido → retornatruesem atualizar (idempotente). - Atualiza
emailMessage.openedAt = new Date(). - Se
campaignIdpresente → incrementaCampaignMetric.opens += 1. - Retorna
true.
recordClick(trackingId): Promise<boolean>
Registra o primeiro clique em link do email. Idempotente: mesmo padrão de recordOpen.
Fluxo:
- Busca
EmailMessagepelotrackingId(select: { id, campaignId, clickedAt }). - Retorna
falsese não encontrado. - Se
clickedAtjá preenchido → retornatrue(idempotente). - Atualiza
emailMessage.clickedAt = new Date(). - Se
campaignIdpresente → incrementaCampaignMetric.clicks += 1. - Retorna
true.
recordUnsubscribe(trackingId): Promise<{ success, alreadyUnsubscribed }>
Registra descadastro de email e marca o cliente como optado para fora.
Fluxo:
- Busca
EmailMessagepelotrackingIdcom{ id, campaignId, customerId, unsubscribedAt, organizationId }. - Retorna
{ success: false, alreadyUnsubscribed: false }se não encontrado. - Se
unsubscribedAtjá preenchido → retorna{ success: true, alreadyUnsubscribed: true }. - Atualiza
emailMessage.unsubscribedAt = new Date(). - Atualiza
customer.emailOptOut = true. - Se
campaignIdpresente → incrementaCampaignMetric.unsubscribes += 1. - Retorna
{ success: true, alreadyUnsubscribed: false }.
detectProvider(email): 'gmail' | 'outlook' | 'yahoo' | 'other'
Detecta o provedor de email a partir do domínio. Utilitário — não persiste nada.
| Domínio | Retorno |
|---|---|
| gmail.com, googlemail.com | 'gmail' |
| outlook.com, hotmail.com, live.com, msn.com | 'outlook' |
| yahoo.com, yahoo.com.br, ymail.com | 'yahoo' |
| qualquer outro | 'other' |
EmailTrackingController
Classe: EmailTrackingControllerDecorador: @Public() — todos os endpoints não requerem autenticação Clerk.
Os endpoints são acessados diretamente pelo cliente de email do destinatário, sem token de sessão.
GET /email-tracking/t/:trackingId.png — Pixel de abertura
Registra abertura e retorna GIF transparente 1x1.
Comportamento:
- Chama
tracking.recordOpen(trackingId)em fire-and-forget (.catch()com log de warning) — a resposta não espera o registro para não atrasar o carregamento do email. - Responde imediatamente com o GIF (
Content-Type: image/gif). - Headers de cache:
Cache-Control: no-store, no-cache, must-revalidate,Pragma: no-cache,Expires: 0— evita que clientes de email façam cache do pixel e disparem a abertura múltiplas vezes ao reabrir o email.
GIF: Buffer estático de 43 bytes (GIF 1x1 transparente em base64):
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7GET /email-tracking/c/:trackingId?url=... — Rastreamento de clique
Registra clique e redireciona para a URL original.
Comportamento:
- Chama
tracking.recordClick(trackingId)em fire-and-forget. - Decodifica
urlcomdecodeURIComponent. - Valida que a URL começa com
http://ouhttps://(retorna 400 se inválida). - Redireciona com
302 Foundpara a URL original.
GET /email-tracking/u/:trackingId — Página de unsubscribe (GET)
Processa descadastro via link clicado pelo usuário e exibe página HTML de confirmação.
Comportamento:
- Chama
tracking.recordUnsubscribe(trackingId)e aguarda o resultado (síncrono — necessário para exibir o estado correto). - Retorna HTML com design responsivo baseado em
result.alreadyUnsubscribed:false→ "Descadastro confirmado" com ícone ✅true→ "Já descadastrado" com ícone 📬
POST /email-tracking/u/:trackingId — Unsubscribe RFC 8058 (One-Click)
Processa descadastro acionado pelo cliente de email via botão nativo de cancelar inscrição (List-Unsubscribe-Post).
Comportamento:
- Chama
tracking.recordUnsubscribe(trackingId)de forma síncrona. - Retorna
200 OKcom body'OK'(padrão exigido pela RFC 8058).
EmailSendProcessor
Classe: EmailSendProcessorExtends: WorkerHost (BullMQ) Fila: email-send
Worker que processa jobs de envio de email. Cada job corresponde a uma mensagem individual de campanha.
Interface EmailSendJobData
typescript
interface EmailSendJobData {
campaignId: string;
customerId: string;
recipientEmail: string;
subject: string;
htmlBody: string; // HTML já processado (compilado MJML + tracking)
organizationId: string;
emailMessageId: string;
senderName: string;
senderEmail: string;
listUnsubscribeHeader?: string; // <{url}> — RFC 2369
listUnsubscribePostHeader?: string; // 'List-Unsubscribe=One-Click' — RFC 8058
}process(job): Promise<void>
Processamento principal de um job de envio.
Fluxo de sucesso:
- Busca configuração SMTP da organização (
OrganizationSmtpSettings). Lança erro se não configurado. - Decripta a senha SMTP via
EncryptionService.decrypt(). - Cria transporter Nodemailer com timeouts explícitos:
connectionTimeout: 15000msgreetingTimeout: 15000mssocketTimeout: 30000ms
- Monta headers de unsubscribe (se presentes no job).
- Envia email:
from: "{senderName} <{senderEmail}>",to,subject,html,headers. - Atualiza
EmailMessage.status = 'SENT',sentAt = now(). - Incrementa
CampaignMetric.sent += 1eCampaignMetric.delivered += 1. - Chama
checkCampaignCompletion().
Fluxo de erro — classificação de bounce:
| Condição | Status | BounceType |
|---|---|---|
responseCode entre 550–559 | BOUNCED | HARD |
responseCode entre 450–459 | BOUNCED | SOFT |
| Mensagem contém "mailbox not found", "user unknown", "does not exist" | BOUNCED | HARD |
| Mensagem contém "mailbox full", "over quota", "try again" | BOUNCED | SOFT |
| Outros erros | FAILED | null |
Após classificar:
- Atualiza
EmailMessage.status,bounceType,bounceMessage,errorMessage. - Incrementa a métrica correspondente:
hardBounces,softBouncesoufailed. - Chama
checkCampaignCompletion(). - Relança o erro se
status === 'FAILED'e não for erro de autenticação/configuração SMTP (para acionar retry da fila).
checkCampaignCompletion(campaignId): Promise<void>
Verifica se todos os emails da campanha foram processados (nenhum EmailMessage com status = 'QUEUED' restante). Se sim → atualiza Campaign.status = 'sent'.
Chamado tanto no sucesso quanto no erro para garantir que a campanha seja marcada como concluída independentemente de falhas individuais.
Configuração SMTP
Armazenada em OrganizationSmtpSettings:
typescript
{
organizationId: string;
host: string;
port: number;
secure: boolean; // true = TLS, false = STARTTLS
user: string;
pass: string; // criptografado AES-256-GCM
}A senha é sempre decriptada em memória (EncryptionService.decrypt()) antes de criar o transporter. Nunca armazenada em texto claro.
Configurações da Fila
| Parâmetro | Valor |
|---|---|
| Rate limit | 200ms entre jobs ≈ 300 emails/minuto |
| Retries | 3 tentativas |
| Backoff | Exponencial com base de 60s |
| Tipos de erro que não fazem retry | Erros de autenticação SMTP, config ausente, bounces classificados |
Pipeline Completo de Envio de Campanha Email
CampaignsService.sendCampaign()
│
├─ Para cada destinatário:
│ ├─ Cria EmailMessage (status: QUEUED, trackingId: UUID)
│ ├─ processEmailHtml(template.mjml, trackingId, baseUrl, imageUploader)
│ │ ├─ compileMjml() → MJML → HTML
│ │ ├─ uploadBase64Images() → base64 → MinIO URLs
│ │ ├─ wrapLinks() → hrefs → tracking URLs
│ │ ├─ addUnsubscribeFooter() → rodapé de descadastro
│ │ └─ injectTrackingPixel() → pixel 1x1
│ ├─ getListUnsubscribeHeaders(trackingId, baseUrl)
│ └─ emailSendQueue.add('send', EmailSendJobData, { delay: 200ms * index })
│
└─ EmailSendProcessor.process(job)
├─ Nodemailer.sendMail()
├─ EmailMessage.status = 'SENT' | 'BOUNCED' | 'FAILED'
├─ CampaignMetric += { sent, delivered } ou { hardBounces, softBounces, failed }
└─ checkCampaignCompletion() → Campaign.status = 'sent'Rastreamento em Runtime
Quando o destinatário interage com o email:
Cliente abre o email
→ <img src="/email-tracking/t/{trackingId}.png"> carregada pelo cliente de email
→ GET /email-tracking/t/{trackingId}.png
→ EmailTrackingController.trackOpen()
→ GIF retornado imediatamente (fire-and-forget)
→ EmailTrackingService.recordOpen() → EmailMessage.openedAt, CampaignMetric.opens++
Cliente clica em link
→ href="/email-tracking/c/{trackingId}?url={encoded}" acionado
→ GET /email-tracking/c/{trackingId}?url={encoded}
→ EmailTrackingController.trackClick()
→ recordClick() em fire-and-forget
→ 302 redirect para URL original
Cliente clica em "Descadastrar"
→ href="/email-tracking/u/{trackingId}" acionado
→ GET /email-tracking/u/{trackingId}
→ EmailTrackingController.unsubscribeGet()
→ recordUnsubscribe() — síncrono
→ HTML de confirmação retornado
Cliente de email usa botão nativo "Cancelar inscrição"
→ List-Unsubscribe-Post acionado
→ POST /email-tracking/u/{trackingId}
→ recordUnsubscribe()
→ "OK" retornadoTabelas do Banco
| Tabela | Campos relevantes |
|---|---|
EmailTemplate | id, organizationId, name, category, mjml, designJson (JSONB), thumbnailUrl, usedCount, createdAt, updatedAt |
EmailMessage | id, trackingId (UUID único por mensagem), campaignId, customerId, organizationId, status (QUEUED/SENT/FAILED/BOUNCED), sentAt, openedAt, clickedAt, unsubscribedAt, bounceType (HARD/SOFT), bounceMessage, errorMessage |
OrganizationSmtpSettings | organizationId, host, port, secure, user, pass (AES-256-GCM) |
CampaignMetric | campaignId, sent, delivered, opens, clicks, unsubscribes, failed, hardBounces, softBounces |
Campaign | status — atualizado para 'sent' quando todos os EmailMessage saem de QUEUED |
Integração com Outros Módulos
| Módulo | Tipo de Integração |
|---|---|
EncryptionService | Decripta senha SMTP no EmailSendProcessor; decripta accessToken WABA em outros módulos |
PrismaService | Banco principal — todas as operações de leitura e escrita de EmailMessage, CampaignMetric, Campaign |
MinIO / StorageService | Upload de imagens base64 durante processEmailHtml (via callback imageUploader) |
CampaignsService | Orquestra o envio: cria EmailMessages, processa HTML e enfileira jobs |
BullMQ | Fila email-send — rate limit de 200ms entre jobs, retry exponencial |
Tratamento de Erros
| Situação | Comportamento |
|---|---|
| SMTP não configurado | Job lança erro, sem retry (mensagem contém "SMTP config") |
| Erro de autenticação SMTP | Job falha, sem retry (/auth/i) |
| Hard bounce (5xx / "not found") | BOUNCED/HARD, sem retry |
| Soft bounce (4xx / "over quota") | BOUNCED/SOFT, sem retry |
| Timeout de conexão / socket | FAILED, aciona retry (até 3 tentativas) |
| Erro de rede transiente | FAILED, aciona retry com backoff exponencial |
| compileMjml lança exceção | Propaga para cima — job não é enfileirado, campanha registra erro |
| Upload de imagem base64 falha | Warning logado, imagem mantida como base64 (não falha o job) |
| recordOpen/recordClick falha | Warning logado, resposta HTTP já entregue (fire-and-forget) |
| trackingId não encontrado | Retorna false — resposta HTTP ainda entregue normalmente |