Skip to content

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

ArquivoClasseResponsabilidade
email-templates/email-templates.service.tsEmailTemplatesServiceCRUD de templates de email (MJML, designJson, thumbnail, contagem de uso)
email-tracking/email-tracking.service.tsEmailTrackingServiceRegistro de aberturas, cliques e descadastros; detecção de provedor de email
email-tracking/email-tracking.controller.tsEmailTrackingControllerEndpoints 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.tsEmailSendProcessorWorker 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étodoPathDescrição
GET/email-templatesLista templates com paginação e filtros
GET/email-templates/merge-fieldsLista campos de personalização disponíveis
GET/email-templates/:idDetalhes de um template
POST/email-templatesCria template
PATCH/email-templates/:idAtualiza template
DELETE/email-templates/:idRemove template
POST/email-templates/:id/duplicateDuplica como novo template
POST/email-templates/:id/thumbnailUpload 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 1
  • limit?: number — padrão 20, máximo 100
  • category?: string — filtro exato por categoria
  • search?: string — busca insensível a maiúsculas no campo name

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ório
  • category: string — padrão 'general'
  • mjml: string — código MJML (pode ser HTML puro se não contiver <mjml ou <mj-)
  • designJson?: object — JSON do editor visual (Unlayer/similar), armazenado como JSONB
  • thumbnailUrl?: 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:

ChaveLabelExemplo
\{\{customer.name\}\}Nome completo do clienteJoão Silva
\{\{customer.firstName\}\}Primeiro nome do clienteJoão
\{\{customer.email\}\}E-mail do clientejoao@email.com
\{\{customer.phone\}\}Telefone do cliente(11) 99999-9999
\{\{store.name\}\}Nome da lojaLoja Centro
\{\{store.city\}\}Cidade da lojaSão Paulo
\{\{seller.name\}\}Nome do vendedorMaria Souza
\{\{organization.name\}\}Nome da organizaçãoEmpresa 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 puro
  • trackingId: string — UUID único do EmailMessage, usado para construir todas as URLs de tracking
  • baseUrl: 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:

  1. Chama sanitizeMjml() para garantir width="600px" no mj-body.
  2. Compila com mjml2html(input, { validationLevel: 'skip' }).
  3. Se houver warnings → loga contagem e primeira mensagem (não falha).
  4. 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:

  1. Extrai tipo MIME e dados base64.
  2. Converte para Buffer.
  3. Chama uploader(buffer, mimeType) → obtém URL pública (MinIO).
  4. Substitui o src pela URL.

Imagens que falham no upload são mantidas como base64 (log de warning, não falha o processamento).

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: e tel:

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:

  1. Busca EmailMessage pelo trackingId (select: { id, campaignId, openedAt }).
  2. Retorna false se não encontrado.
  3. Se openedAt já preenchido → retorna true sem atualizar (idempotente).
  4. Atualiza emailMessage.openedAt = new Date().
  5. Se campaignId presente → incrementa CampaignMetric.opens += 1.
  6. Retorna true.

recordClick(trackingId): Promise<boolean>

Registra o primeiro clique em link do email. Idempotente: mesmo padrão de recordOpen.

Fluxo:

  1. Busca EmailMessage pelo trackingId (select: { id, campaignId, clickedAt }).
  2. Retorna false se não encontrado.
  3. Se clickedAt já preenchido → retorna true (idempotente).
  4. Atualiza emailMessage.clickedAt = new Date().
  5. Se campaignId presente → incrementa CampaignMetric.clicks += 1.
  6. Retorna true.

recordUnsubscribe(trackingId): Promise<{ success, alreadyUnsubscribed }>

Registra descadastro de email e marca o cliente como optado para fora.

Fluxo:

  1. Busca EmailMessage pelo trackingId com { id, campaignId, customerId, unsubscribedAt, organizationId }.
  2. Retorna { success: false, alreadyUnsubscribed: false } se não encontrado.
  3. Se unsubscribedAt já preenchido → retorna { success: true, alreadyUnsubscribed: true }.
  4. Atualiza emailMessage.unsubscribedAt = new Date().
  5. Atualiza customer.emailOptOut = true.
  6. Se campaignId presente → incrementa CampaignMetric.unsubscribes += 1.
  7. 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ínioRetorno
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///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7

GET /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 url com decodeURIComponent.
  • Valida que a URL começa com http:// ou https:// (retorna 400 se inválida).
  • Redireciona com 302 Found para 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 OK com 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:

  1. Busca configuração SMTP da organização (OrganizationSmtpSettings). Lança erro se não configurado.
  2. Decripta a senha SMTP via EncryptionService.decrypt().
  3. Cria transporter Nodemailer com timeouts explícitos:
    • connectionTimeout: 15000ms
    • greetingTimeout: 15000ms
    • socketTimeout: 30000ms
  4. Monta headers de unsubscribe (se presentes no job).
  5. Envia email: from: "{senderName} <{senderEmail}>", to, subject, html, headers.
  6. Atualiza EmailMessage.status = 'SENT', sentAt = now().
  7. Incrementa CampaignMetric.sent += 1 e CampaignMetric.delivered += 1.
  8. Chama checkCampaignCompletion().

Fluxo de erro — classificação de bounce:

CondiçãoStatusBounceType
responseCode entre 550–559BOUNCEDHARD
responseCode entre 450–459BOUNCEDSOFT
Mensagem contém "mailbox not found", "user unknown", "does not exist"BOUNCEDHARD
Mensagem contém "mailbox full", "over quota", "try again"BOUNCEDSOFT
Outros errosFAILEDnull

Após classificar:

  • Atualiza EmailMessage.status, bounceType, bounceMessage, errorMessage.
  • Incrementa a métrica correspondente: hardBounces, softBounces ou failed.
  • 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âmetroValor
Rate limit200ms entre jobs ≈ 300 emails/minuto
Retries3 tentativas
BackoffExponencial com base de 60s
Tipos de erro que não fazem retryErros 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" retornado

Tabelas do Banco

TabelaCampos relevantes
EmailTemplateid, organizationId, name, category, mjml, designJson (JSONB), thumbnailUrl, usedCount, createdAt, updatedAt
EmailMessageid, trackingId (UUID único por mensagem), campaignId, customerId, organizationId, status (QUEUED/SENT/FAILED/BOUNCED), sentAt, openedAt, clickedAt, unsubscribedAt, bounceType (HARD/SOFT), bounceMessage, errorMessage
OrganizationSmtpSettingsorganizationId, host, port, secure, user, pass (AES-256-GCM)
CampaignMetriccampaignId, sent, delivered, opens, clicks, unsubscribes, failed, hardBounces, softBounces
Campaignstatus — atualizado para 'sent' quando todos os EmailMessage saem de QUEUED

Integração com Outros Módulos

MóduloTipo de Integração
EncryptionServiceDecripta senha SMTP no EmailSendProcessor; decripta accessToken WABA em outros módulos
PrismaServiceBanco principal — todas as operações de leitura e escrita de EmailMessage, CampaignMetric, Campaign
MinIO / StorageServiceUpload de imagens base64 durante processEmailHtml (via callback imageUploader)
CampaignsServiceOrquestra o envio: cria EmailMessages, processa HTML e enfileira jobs
BullMQFila email-send — rate limit de 200ms entre jobs, retry exponencial

Tratamento de Erros

SituaçãoComportamento
SMTP não configuradoJob lança erro, sem retry (mensagem contém "SMTP config")
Erro de autenticação SMTPJob 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 / socketFAILED, aciona retry (até 3 tentativas)
Erro de rede transienteFAILED, aciona retry com backoff exponencial
compileMjml lança exceçãoPropaga para cima — job não é enfileirado, campanha registra erro
Upload de imagem base64 falhaWarning logado, imagem mantida como base64 (não falha o job)
recordOpen/recordClick falhaWarning logado, resposta HTTP já entregue (fire-and-forget)
trackingId não encontradoRetorna false — resposta HTTP ainda entregue normalmente

Documentação interna — Galdix CRM