Catalisa.Building Blocks
Catálogo/Comunicação/Email

Email

Beta

A caixa de entrada do seu usuário, Gmail ou Outlook, atrás de uma única API

32
Endpoints
5
Entidades
2
Provedores
Tenant
Escopo
3026
Porta
2026-05
Desde

O seu produto passa a ler, organizar e responder e-mail dentro da conta do próprio cliente — Gmail ou Outlook — sem que o seu time precise aprender OAuth do Google, Microsoft Graph, Pub/Sub e renovação de assinatura de push.

Para quem é
  • CRMs e plataformas de vendas que precisam registrar a conversa por e-mail junto do negócio
  • Operações de crédito e cobrança que recebem documento e resposta de cliente por e-mail
  • Times de atendimento que querem responder do endereço do próprio atendente, não de um no-reply
Substitui
  • Assinatura de uma API unificada de e-mail (Nylas, Unipile, Aurinko)
  • Integração própria e direta com Gmail API e Microsoft Graph, com a manutenção que vem junto
  • Rotina caseira de renovação de token OAuth e de assinatura de push notification
O que não é
  • Serviço de disparo transacional em massa (isso é SendGrid, Amazon SES, Resend — ver §5)
  • Servidor de e-mail, caixa postal hospedada ou domínio de envio
  • Ferramenta de marketing com métrica de abertura, clique e descadastro
O que dá para fazer

34 endpoints em 8 recursos.

Explorar a API →
01

Resumo executivo

O Email conecta a conta de e-mail do usuário final — a dele mesmo, no Gmail ou no Outlook — ao seu produto. Depois de conectada, o seu sistema lê a caixa, procura mensagem, abre a conversa inteira, baixa anexo, marca como lida, move de pasta e responde do endereço da pessoa, com uma única API. Não é um serviço de disparo: é acesso à caixa de entrada que já existe.

Na prática, é a diferença entre o vendedor ter que copiar e colar o e-mail do cliente no CRM e o CRM já mostrar a conversa completa ao lado da proposta — sem que ninguém do seu time precise aprender que o watch do Gmail morre em sete dias e que o Microsoft Graph limita quatro requisições concorrentes por caixa.

Está em beta desde maio de 2026. Roda em monolito e em standalone, tem cobertura de testes unitários e de integração, e ainda não tem cliente em produção. A restrição relevante para planejamento é de escopo do Google, não de código: por padrão a conexão Gmail nasce somente com permissão de envio, porque leitura de caixa no Gmail é escopo restrito e exige avaliação de segurança anual — o detalhe completo está em §5 e §15. No Outlook a leitura funciona por padrão.

AtributoValor
Identificadoremail
CategoriaComunicação
EscopoTenant (exige organizationId no token)
Porta (standalone)3026 no host, 3000 dentro do contêiner
Path alias@email
Prefixo HTTP/email
StatusBeta desde 2026-05
Depende dePostgreSQL (schema email), IAM, Google e/ou Microsoft

02

O problema

negócio

O cenário. Um produto B2B quer mostrar, dentro da própria tela, a conversa por e-mail que já acontece fora dele. O vendedor negocia por e-mail. O analista de crédito recebe o comprovante por e-mail. O cobrador combina o pagamento por e-mail. Nada disso entra no sistema, e o sistema fica sabendo da operação sempre por último.

flowchart LR
  V["Vendedor negocia a proposta"] --> FORA["A conversa vive na caixa de e-mail"]
  A["Analista recebe o comprovante"] --> FORA
  C["Cobrador combina o pagamento"] --> FORA
  FORA --> N["Ninguém registra, ou alguém copia e cola"]
  FORA --> S["O produto fica sabendo por último"]
  N --> P["Histórico é da pessoa, não da empresa"]
  S --> P

O que trava hoje

  • São dois mundos, não um. O Gmail organiza por label e conversa; o Outlook organiza por pasta e conversationId. Threading, paginação, busca e anexo funcionam de formas diferentes. Você escreve a integração duas vezes e normaliza os dois modelos por conta própria.
  • O push expira, e em ritmos diferentes. O users.watch do Gmail precisa ser chamado pelo menos a cada sete dias, e a própria Google recomenda chamá-lo uma vez por dia para não perder notificação (Gmail API — Push Notifications). No Microsoft Graph, a assinatura de mensagem de caixa expira em no máximo 10.080 minutos, e cai para 1.440 minutos se você pedir os dados do recurso junto (Graph — subscription). São dois cron jobs, com dois relógios, e reconciliação por conta quando um deles falha.
  • Push não dispensa polling. A documentação do Gmail admite que, em situações extremas, notificações podem atrasar ou ser descartadas, e recomenda um history.list periódico como rede de segurança. Ou seja: você constrói os dois caminhos, não um.
  • Os limites de quota moldam a arquitetura. O Gmail dá 6.000 unidades de quota por minuto por usuário, e um messages.get custa 20 unidades (Gmail API — Usage limits). São 300 mensagens por minuto por caixa: carregar um histórico de 50 mil mensagens leva quase três horas, só de limite. O Graph impõe 10.000 requisições por 10 minutos e apenas 4 concorrentes por combinação de aplicativo e caixa (Graph — throttling limits), o que impede paralelizar a carga dentro da mesma caixa.
  • Ler caixa no Gmail é um processo, não uma configuração. Os escopos de leitura do Gmail são restritos e exigem uma avaliação de segurança independente, renovada a cada doze meses, feita por um laboratório credenciado do CASA (Google — Restricted scope verification, App Defense Alliance — CASA). Google e App Defense Alliance não publicam o custo dessa avaliação.
  • Do lado da Microsoft, sem publisher verification a adoção corporativa quebra. Aplicativos multitenant registrados depois de 08/11/2020 que pedem mais que login e leitura de perfil aparecem como editor não verificado, e o usuário corporativo simplesmente não consegue consentir (Entra — Publisher verification). A verificação em si não é cobrada pela Microsoft.

Em uma tabela, é isto que "escrever a integração duas vezes" significa na prática — cada linha é um comportamento que diverge e que o seu código precisa reconciliar:

Onde os dois divergemGmailOutlook (Microsoft Graph)
Organização da caixaRótulos (label) e threadIdPastas e conversationId
Mecanismo de pushusers.watch publicando no Google Cloud Pub/Subsubscription com notificationUrl HTTPS pública
Expiração do push7 dias, com renovação diária recomendadaMáximo de 10.080 minutos; 1.440 se pedir os dados do recurso
Regime de quota6.000 unidades/minuto por usuário; messages.get custa 2010.000 requisições por 10 minutos e 4 concorrentes por caixa
Custo de uma listagemN+1: uma chamada para os identificadores, uma por mensagemUma chamada devolve as mensagens completas
Barreira de entradaAvaliação de segurança CASA anual para escopo restrito de leituraPublisher verification para o consentimento corporativo funcionar

O custo de não resolver

Os cinco fatos acima são oficiais e verificáveis, e já bastam: dois relógios de renovação, dois modelos de dados, dois regimes de quota, uma avaliação anual paga e um processo de verificação de editor. Estimativas de esforço para construir Gmail e Outlook do zero circulam na faixa de 12 a 18 dev-months e US$ 240 mil a US$ 480 mil de custo inicial, com 0,5 a 1 FTE permanente de manutenção — números publicados pela Unipile, que vende a alternativa, e por isso citados aqui como material de fornecedor, não como referência neutra (Unipile — Build vs Buy, consultado em 2026-08-16).


03

Proposta de valor

negócio
AntesDepois
Uma integração para o Gmail e outra para o OutlookUm contrato canônico de mensagem, conversa e pasta para os dois
Cron de renovação de watch e outro de subscription, com relógios distintosPOST /provider-subscriptions e um job que renova o que está para vencer
Refresh token expirando em produção às três da tardeO serviço renova sozinho cinco minutos antes de vencer, na própria requisição
Copiar e colar e-mail dentro do CRMO produto lê, responde e arquiva na caixa do usuário
Corpo de e-mail replicado no seu banco, com o problema de LGPD juntoNenhum corpo de mensagem é gravado — a API é passagem

Um modelo canônico, dois provedores

CanonicalEmailMessage traz remetente, destinatários, assunto, corpo em texto e HTML, sinalizadores de lida e favorita, rótulos e anexos com o mesmo formato, venha de onde vier.

flowchart LR
  G["Gmail API"] --> MG["GoogleEmailProvider + mapper"]
  O["Microsoft Graph v1.0"] --> MO["OutlookEmailProvider + mapper"]
  MG --> C["CanonicalEmailMessage<br/>CanonicalEmailThread<br/>CanonicalEmailFolder"]
  MO --> C
  C --> APP["Seu código — um só dialeto"]

Até a triagem nativa é normalizada: as abas do Gmail (CATEGORY_PROMOTIONS, CATEGORY_SOCIAL, ...) e o Focused Inbox do Outlook viram um único campo category, então você exibe "Principal" e "Outros" sem inventar classificador.

category canônicaDe onde vem no GmailDe onde vem no Outlook
primarySem rótulo CATEGORY_*, ou CATEGORY_PERSONALinferenceClassification: focused
other— (o Gmail não tem esse balde)inferenceClassification: other
promotionsCATEGORY_PROMOTIONS—
socialCATEGORY_SOCIAL—
updatesCATEGORY_UPDATES—
forumsCATEGORY_FORUMS—
Ausente—Mensagem sem inferenceClassification

O token se renova sozinho

Toda operação passa por getProviderForConnection, que checa a validade do access token e o renova se faltarem menos de cinco minutos para expirar. Não existe caminho em que o integrador precise pensar em refresh.

As credenciais OAuth são do cliente

Cada organização cadastra o próprio client_id e client_secret do Google ou da Microsoft. O consentimento do usuário final é dado ao aplicativo do cliente, não a um intermediário — e a tela de consentimento mostra o nome dele.

Nada do conteúdo fica no banco

Assunto, corpo e anexo trafegam do provedor para o chamador e não são persistidos. O que existe no PostgreSQL são credenciais cifradas, o endereço da conta conectada e metadados de assinatura.

flowchart LR
  PROV["Gmail · Microsoft Graph"] -->|"assunto, corpo, anexo"| BB["Building block Email"]
  BB -->|"assunto, corpo, anexo"| APP["Seu sistema"]
  BB -.->|"grava apenas"| DB[("PostgreSQL schema email<br/>credenciais cifradas<br/>endereço da conta<br/>metadados de assinatura")]
  APP -->|"você decide o que reter"| SEU[("Seu banco, sua política de retenção")]

04

Casos de uso reais

negócio

Caso 1 — Um CRM passa a mostrar a negociação inteira, e não só o que o vendedor lembrou de anotar Cenário ilustrativo

Contexto

Plataforma de CRM para times comerciais de médio porte, entre 20 e 200 vendedores por cliente. Metade dos clientes usa Google Workspace, metade usa Microsoft 365.

A dor

A negociação acontece no e-mail e o CRM só sabe o que o vendedor anota. Quando o vendedor sai da empresa, o histórico sai junto — está na caixa pessoal dele. O gestor não consegue auditar o que foi prometido ao cliente, e o time de produto ouve o mesmo pedido em toda pesquisa: "puxe meu e-mail para cá".

A solução com o BB

O cliente cadastra as credenciais OAuth em POST /email/api/v1/email/provider-configs. Cada vendedor conecta a própria conta por POST /email/api/v1/email/connections/connect e volta autorizado. O CRM busca a conversa com o contato usando POST /email/api/v1/email/messages/search, com a sintaxe nativa do provedor no campo query, e abre a conversa completa em GET /email/api/v1/email/threads/:id. Responder é POST /email/api/v1/email/messages/:id/reply — a resposta sai do endereço do vendedor, com o In-Reply-To correto, e aparece na caixa "Enviados" dele.

sequenceDiagram
  participant ADM as Admin do cliente
  participant VEN as Vendedor
  participant CRM as CRM
  participant BB as BB Email
  participant PR as Gmail ou Outlook

  ADM->>BB: POST /provider-configs — credenciais OAuth da organização
  VEN->>BB: POST /connections/connect
  BB-->>VEN: authorizationUrl — consentimento na conta dele
  Note over CRM: O vendedor abre a proposta 4471
  CRM->>BB: POST /messages/search — query nativa do provedor
  BB->>PR: busca na caixa do vendedor
  PR-->>BB: mensagens
  BB-->>CRM: CanonicalEmailMessage
  CRM->>BB: GET /threads/:id — a conversa inteira
  CRM->>BB: POST /messages/:id/reply
  BB->>PR: envia pelo endereço do vendedor
  PR-->>VEN: a resposta aparece em Enviados
O resultado

O histórico deixa de ser propriedade da caixa de correio individual. E como o corpo não é persistido pelo building block, o CRM decide o que guardar do lado dele — o que muda a conversa com o jurídico do cliente.

Caso 2 — Uma financeira para de perder documento anexado em resposta de cliente Cenário ilustrativo

Contexto

Financeira de crédito com esteira digital. O cliente recebe a solicitação de documento por e-mail e responde anexando o comprovante de renda.

A dor

O anexo chega numa caixa compartilhada e alguém precisa baixar, renomear e subir no sistema. A esteira fica parada esperando um humano fazer download e upload, e o SLA de análise estoura por um motivo que não tem nada a ver com análise.

A solução com o BB

Uma conexão Outlook para a caixa compartilhada. POST /email/api/v1/email/provider-subscriptions cria a assinatura de push no Graph apontando para /me/messages. Quando chega mensagem, o Graph avisa o endpoint público de notificação, o serviço valida o clientState, traduz o evento e entrega MESSAGE_RECEIVED no webhook do cliente. A esteira busca a mensagem, lê os metadados de anexo e baixa o conteúdo em GET /email/api/v1/email/messages/:id/attachments/:attId, que devolve base64 pronto para gravar no File Storage. Depois marca como lida com PATCH /email/api/v1/email/messages/:id.

sequenceDiagram
  participant CLI as Cliente da financeira
  participant MS as Microsoft Graph
  participant BB as BB Email
  participant EST as Esteira de crédito
  participant FS as File Storage

  CLI->>MS: responde o e-mail com o comprovante anexado
  MS->>BB: POST /providers/notifications — clientState do lote
  BB->>BB: valida o clientState e traduz o evento
  BB->>EST: webhook MESSAGE_RECEIVED assinado em HMAC
  EST->>BB: GET /messages/:id — metadados do anexo
  EST->>BB: GET /messages/:id/attachments/:attId
  BB-->>EST: conteúdo em base64
  EST->>FS: grava o comprovante no cofre
  EST->>BB: PATCH /messages/:id — marca como lida
O resultado

O caminho do anexo até o cofre de documentos vira código. O tempo entre "cliente respondeu" e "documento na esteira" deixa de depender de alguém abrir o Outlook.

Caso 3 — Um time de atendimento responde do endereço da empresa sem sair do sistema Cenário ilustrativo

Contexto

Operação de atendimento de uma seguradora, com uma caixa atendimento@ e doze atendentes.

A dor

Os doze abrem a mesma caixa no navegador. Dois respondem o mesmo cliente. Um marca como lida e some do radar dos outros. Não há como saber quem respondeu o quê, e a auditoria da seguradora pede exatamente isso.

A solução com o BB

O sistema de atendimento passa a ser a única interface: lista com GET /email/api/v1/email/messages, filtra por pasta e por category, atribui internamente, e responde por POST /email/api/v1/email/messages/:id/reply. Cada operação chega ao provedor autenticada pela conexão da caixa, e cada chamada carrega o token do atendente que a disparou — então o registro de quem fez o quê está no Audit Trail, não na memória do time.

flowchart LR
  CX["Caixa atendimento@ — uma EmailConnection"] --> SIS["Sistema de atendimento — única interface"]
  SIS --> L["GET /messages — filtra por pasta e por category"]
  L --> F["Fila com atribuição interna por atendente"]
  F --> R["POST /messages/:id/reply — sai do endereço da caixa"]
  R --> PR["Outlook"]
  F --> T["Token do atendente em cada chamada"]
  T --> AUD["Audit Trail — quem respondeu o quê"]
O resultado

Uma caixa compartilhada com fila, atribuição e trilha, sem trocar o Outlook por outro produto e sem migrar endereço.

Caso 4 — O mercado já decidiu que essa integração se compra, não se constrói Referência de mercado

Contexto

Existe uma categoria inteira de fornecedores vivendo exclusivamente de intermediar Gmail e Outlook: Nylas, Unipile e Aurinko, com preço público entre US$ 1,00 e € 5,00 por conta conectada por mês (consulta em 2026-08-16). Uma categoria só se sustenta quando o problema é caro o bastante para valer um fornecedor.

A dor

As restrições de mercado que criam essa categoria são documentadas pelos próprios provedores: watch do Gmail expirando em sete dias com recomendação de renovação diária, assinatura do Graph limitada a 10.080 minutos, quota de 6.000 unidades por minuto por usuário no Gmail, teto de 4 requisições concorrentes por caixa no Graph e a avaliação de segurança CASA anual obrigatória para escopos restritos do Gmail. Todas com fonte oficial, listadas em §2.

A solução com o BB

A Catalisa resolve o mesmo problema com uma diferença de posição: as credenciais OAuth são do cliente, o dado não passa a residir no fornecedor, e a peça encaixa nos outros 31 building blocks em vez de virar mais um contrato para gerenciar.

flowchart LR
  R["Restrições oficiais de Google e Microsoft<br/>push que expira · quota · CASA · publisher verification"] --> CAT["Uma categoria de fornecedores vive disso<br/>Nylas · Unipile · Aurinko"]
  CAT --> P1["Preço por conta conectada, todo mês"]
  CAT --> P2["O corpo da mensagem passa a residir no fornecedor"]
  CAT --> P3["Mais um contrato para gerenciar"]
  R --> BB["Building block Email da Catalisa"]
  BB --> D1["Credencial OAuth é do cliente"]
  BB --> D2["Conteúdo não é gravado — a API é passagem"]
  BB --> D3["Encaixa nos outros 31 building blocks"]
O resultado

O mesmo trabalho que a categoria vende, sem o intermediário no caminho do dado do seu cliente e sem uma fatura por conta que cresce junto com o seu time de vendas.


05

Mercado e diferenciais

negócio

Panorama

Há duas coisas muito diferentes que as pessoas chamam de "API de e-mail", e confundir as duas é o erro comercial mais caro desta categoria.

flowchart TD
  Q{"O que você chama de API de e-mail?"}
  Q -->|"disparar do domínio da sua aplicação"| T["Envio transacional"]
  Q -->|"ler e responder na conta do usuário"| I["Acesso à caixa de entrada"]
  T --> TF["SendGrid · Amazon SES · Postmark<br/>Resend · Mailgun · Brevo"]
  TF --> TU["Recuperação de senha, confirmação de pedido, nota fiscal<br/>cobrança por milheiro enviado"]
  I --> IF["Nylas · Unipile · Aurinko<br/>ou Gmail API e Graph direto"]
  IF --> IU["Conversa com o cliente, no endereço da pessoa<br/>cobrança por conta conectada"]
  IU --> CAT["Building block Email da Catalisa está aqui"]
  TU --> NAO["Este building block não faz isso<br/>e não compete com eles"]

A primeira é envio transacional: SendGrid, Amazon SES, Postmark, Resend, Mailgun e Brevo pegam a sua mensagem e a entregam a partir do domínio da sua aplicação, cobrando por milheiro enviado. Servem para recuperação de senha, confirmação de pedido e nota fiscal. Este building block não faz isso e não compete com eles.

A segunda é acesso à caixa de entrada: ler, organizar e responder dentro da conta que o usuário já tem, no Gmail ou no Outlook, com o endereço dele. É onde vivem Nylas, Unipile e Aurinko — e é aqui que o Email da Catalisa está. A alternativa é integrar Gmail API e Microsoft Graph direto, o que não custa licença nenhuma e custa tudo o mais.

Tabela comparativa

CritérioCatalisa EmailNylasUnipileAurinkoGmail + Graph direto
Preço (consulta 2026-08-16)Precificação em definiçãoUS$ 15/mês + US$ 2,00/conta€ 49/mês mínimo, € 5,00/conta (11–50)US$ 1,00–2,00/conta, por volumeSem custo de licença
ProvedoresGmail, Outlook25+, com IMAP e ExchangeGmail, Outlook, IMAP + LinkedIn, WhatsAppGmail, Outlook, Exchange on-prem, ZohoSó os dois, um por vez
Leitura de caixa no GmailRequer escopo restrito e CASA (ver §15)Incluída, CASA é do fornecedorIncluída, CASA é do fornecedorIncluída, CASA é do fornecedorRequer escopo restrito e CASA seus
Corpo da mensagem no banco do fornecedorNão é gravadoSincronizado e armazenadoSincronizado e armazenadoSincronizado e armazenadoNão se aplica
De quem é o client_id OAuthDo cliente, por organizaçãoDo fornecedor ou do clienteDo fornecedorDo clienteDo cliente
Push gerenciadoSim, com job de renovaçãoSimSimSimVocê constrói
Canais além de e-mailCalendar é building block irmãoCalendário, contatos, notetakerLinkedIn, WhatsApp, Instagram, TelegramCalendário, contatos, tarefas, sync de CRM—
Multi-tenant com isolamento por organizaçãoNativo, do tokenVocê modelaVocê modelaVocê modelaVocê modela
MaturidadeBeta, sem cliente em produçãoAnos de produção, BAA e HIPAAProdução, empresa mais novaProdução—

Nossos diferenciais

  1. O conteúdo não vira cópia. Nenhum corpo, assunto ou anexo é gravado no nosso PostgreSQL — as rotas de mensagem chamam o provedor e devolvem. É difícil de copiar porque não é uma funcionalidade e sim uma decisão de arquitetura: quem já construiu um índice de busca próprio sobre a caixa sincronizada não consegue voltar atrás.
  2. A credencial OAuth é do cliente, por organização. O usuário final consente ao aplicativo do cliente. Isso muda quem responde pela avaliação de segurança do Google, quem aparece na tela de consentimento e de quem é o relacionamento com o provedor.
  3. A mesma anatomia vale para o Calendar. provider-config, connection, provider-subscription, provider-notification e webhook são a mesma estrutura nos dois building blocks. Quem integrou um integra o outro no mesmo dia — e é isso que torna "e-mail e agenda do seu vendedor" uma entrega, não dois projetos.
  4. O tenant vem do token. Nenhuma rota lê organizationId do corpo. Numa plataforma que atende dezenas de empresas, isso é a diferença entre isolamento garantido e isolamento combinado.

Quando escolher o concorrente

Esta é a tabela que um comprador técnico procura primeiro. Cada linha é um caso em que a resposta honesta não é "use a Catalisa".

Se o seu requisito éEscolhaPorque
IMAP genérico, Yahoo, iCloud ou Exchange on-premisesNylas ou AurinkoEste building block não atende: são só Gmail e Outlook, e as duas cobrem esse terreno hoje
E-mail, WhatsApp e LinkedIn no mesmo lugar e na mesma faturaUnipileA Unipile entrega isso e nós não
BAA, HIPAA ou um histórico longo de produção auditávelNylasEla tem o programa de compliance montado; nós estamos em beta
Time dedicado, volume que justifica e paciência para a avaliação CASA anualGmail API e Microsoft Graph diretoÉ mais barato em licença — custa zero — e dá acesso a cem por cento da superfície de cada provedor
Disparar cem mil e-mails transacionais por mês do domínio da sua aplicaçãoAmazon SES, Postmark ou ResendNenhum dos citados acima serve: é a outra categoria do panorama

O Email da Catalisa ganha quando o problema é acessar a caixa do usuário dentro de uma plataforma B2B multi-tenant que já usa outros building blocks — e perde em quase todo o resto.


06

Modelo de cobrança e ROI

negócio

Unidade de cobrança

A conta de e-mail conectada por mês — a mesma unidade que Nylas, Unipile e Aurinko usam. É justa porque cresce com o valor entregue: uma conta conectada é um vendedor, um analista ou uma caixa compartilhada que passou a operar dentro do seu produto. Quem conectou zero conta não paga por nada.

Atenção. Precificação em definição. Não há preço fechado para o Email. O que está definido são os drivers.

O que dispara custo

DriverPor quê
Contas conectadas ativasCada uma consome quota do provedor e ocupa uma assinatura de push
Chamadas de API por contaListagem no Gmail hidrata cada mensagem individualmente — é a chamada mais cara do BB
Assinaturas de push ativasCada renovação é uma chamada ao provedor; no Gmail custa 100 unidades de quota
Entregas de webhookCada notificação recebida vira uma ou mais entregas HTTP com registro em log

Comparação de custo

Cenário: operação com 200 contas conectadas, uso contínuo.

Catalisa EmailNylas (Full Platform)UnipileAurinkoGmail + Graph direto
BasePrecificação em definiçãoUS$ 15/mês, 5 contas inclusas€ 49/mês mínimoSem base publicadaUS$ 0
Conta adicional—US$ 2,00/conta/mês€ 5,00/conta/mês (faixa 11–50)US$ 1,00 a US$ 2,00/conta/mêsUS$ 0
Mensal em 200 contas—≈ US$ 405Faixa acima de 50 contas não publicada≈ US$ 200 a US$ 400US$ 0 de licença
Avaliação CASA anualDo cliente, se usar leitura no GmailDo fornecedorDo fornecedorDo fornecedorSua, anual e obrigatória
Engenharia de integraçãoUma APIUma APIUma APIUma APIDois provedores, permanente

Estimativa a partir dos preços públicos consultados em 2026-08-16 nas páginas oficiais de Nylas, Unipile e Aurinko. Preço de fornecedor muda sem aviso e admite negociação por volume — confirme na data da sua análise. Nada aqui é proposta comercial.

ROI

A conta não fecha na linha de licença, porque o caminho direto custa zero de licença. Ela fecha em três lugares:

flowchart LR
  DIR["Integrar Gmail e Graph direto<br/>US$ 0 de licença"] --> C1["1. Avaliação CASA anual<br/>obrigatória para ler caixa no Gmail"]
  DIR --> C2["2. Esforço de construção<br/>12 a 18 dev-months"]
  DIR --> C3["3. Manutenção permanente<br/>0,5 a 1 FTE, para sempre"]
  C1 --> ROI["É aqui que a conta do building block fecha"]
  C2 --> ROI
  C3 --> ROI
  1. A avaliação de segurança CASA anual, obrigatória para ler caixa no Gmail, cujo custo Google e App Defense Alliance não publicam — a faixa de US$ 15 mil a US$ 75 mil que circula é estimativa da Unipile e deve ser tratada como tal.
  2. O esforço de construção, estimado pelo mesmo material de fornecedor em 12 a 18 dev-months para Gmail e Outlook em produção.
  3. O custo permanente de manutenção, que não some depois do lançamento — são dois relógios de expiração de push, dois regimes de quota e dois processos de verificação que continuam existindo no ano dois.

Se a sua plataforma já usa o IAM e o Webhooks Engine, o Email entra como mais um building block, não como um sistema novo para operar.


07

Arquitetura

As cinco camadas

Da requisição HTTP até a API do provedor, o caminho atravessa cinco camadas. Nenhuma delas grava conteúdo de mensagem.

flowchart TD
  HTTP["Requisição HTTP"] --> APP

  subgraph APP["1. Hono app — basePath /email"]
    R1["/api/v1/email/provider-configs — providerConfigRouter"]
    R2["/api/v1/email/connections — connectionRouter"]
    R3["/api/v1/email/messages — messagesRouter"]
    R4["/api/v1/email/threads — threadsRouter"]
    R5["/api/v1/email/folders — foldersRouter"]
    R6["/api/v1/email/webhooks — webhookRouter"]
    R7["/api/v1/email/provider-subscriptions — providerSubscriptionRouter"]
    R8["/api/v1/email/providers/notifications — providerNotificationRouter (público)"]
    R9["/health · /connected"]
  end

  APP -->|"Zod parse → ResultAsync&lt;T, AppError&gt;"| SVC

  subgraph SVC["2. services/"]
    S1["EmailProviderConfigService — credenciais OAuth por organização"]
    S2["EmailConnectionService — OAuth, refresh, fábrica de provider"]
    S3["EmailService — mensagens, conversas, pastas"]
    S4["EmailProviderSubscriptionService — push do provedor"]
    S5["EmailWebhookService — assinatura e entrega ao cliente"]
  end

  SVC --> REPO
  SVC --> PROV

  subgraph REPO["3. repositories/ (Prisma)"]
    D1[("PostgreSQL schema email<br/>credenciais e tokens CIFRADOS (AES-256-GCM)<br/>NENHUM corpo de mensagem")]
  end

  subgraph PROV["4. providers/ — interface EmailProvider"]
    P1["GoogleEmailProvider"]
    P2["OutlookEmailProvider"]
  end

  PROV -->|"HTTPS"| EXT

  subgraph EXT["5. APIs externas"]
    E1["Gmail API"]
    E2["Microsoft Graph"]
  end

O fluxo de conexão OAuth

É o caminho que todo integrador percorre primeiro, e o único que exige um humano no meio.

sequenceDiagram
  participant U as Usuário
  participant APP as Seu app
  participant BB as Email BB
  participant PR as Google / Microsoft

  U->>APP: conectar
  APP->>BB: POST /connections/connect {configId, redirectUrl, scopes?}
  BB->>BB: 1. lê as credenciais da organização e as decifra
  BB->>BB: 2. cifra o state: org + user + config + redirectUrl
  BB-->>APP: {authorizationUrl}
  APP-->>U: redirect
  U->>PR: consentimento na tela do provedor
  PR-->>U: GET /connections/callback?code=...&state=...
  U->>BB: entrega code e state
  BB->>BB: 3. decifra o state
  BB->>PR: 4. troca o code por tokens
  BB->>PR: 5. lê o e-mail da conta
  BB->>BB: 6. grava EmailConnection com tokens cifrados
  BB-->>U: 302 para o redirectUrl do passo 1

Decisões não óbvias

São oito, cada uma com um trade-off que vale conhecer antes de integrar. Documentar o porquê aqui evita que a próxima pessoa "conserte" de propósito o que está assim por um motivo.

O state do OAuth é um blob cifrado, não um identificador

Em vez de guardar o contexto da autorização numa tabela e mandar uma chave, o serviço cifra {organizationId, userId, configId, redirectUrl} com AES-256-GCM e manda tudo no state. O callback decifra e sabe quem começou o fluxo. A vantagem é não precisar de estado compartilhado entre réplicas — em standalone com várias instâncias, qualquer uma atende o callback. O custo é que o state não caduca sozinho, e é por isso que ele não carrega nada além do necessário para reconstruir o contexto.

O callback é rota pública, e tem que ser

O provedor redireciona o navegador do usuário, sem Authorization. A autenticação é o próprio state cifrado: quem não tem a EMAIL_CREDENTIAL_MASTER_KEY não produz um state válido.

Renovação de token dentro da requisição, não em job

getProviderForConnection checa tokenExpiresAt e renova se faltarem menos de 5 minutos. A alternativa — um job varrendo tokens — precisaria acordar mais rápido que o menor tempo de vida de token de qualquer provedor, e ainda assim perderia a corrida. Renovar no caminho quente custa uma chamada extra ocasional e elimina a classe inteira de bug "token venceu entre o job e o uso".

flowchart TD
  OP["Qualquer operação de mensagem"] --> CHK{"tokenExpiresAt está a mais de 5 min de vencer?"}
  CHK -->|"sim"| GO["Segue direto, sem chamada ao provedor"]
  CHK -->|"não"| RT{"A conexão tem refresh token?"}
  RT -->|"sim"| REN["Renova no provedor, recifra o envelope,<br/>regrava tokenExpiresAt e status ACTIVE"]
  RT -->|"não"| EXP["Marca a conexão como TOKEN_EXPIRED<br/>e falha com 400 Token expired and no refresh token available"]
  REN --> GO

Uma listagem no Gmail é N+1, e isso é do provedor

O messages.list do Gmail devolve só identificadores; cada mensagem precisa de um messages.get. Uma página de 25 mensagens são 26 chamadas — 505 unidades de quota. O Graph devolve as mensagens completas na própria listagem. Essa assimetria é a razão de o pageSize ser limitado a 100.

Página de 25 mensagensGmailOutlook (Graph)
Chamadas ao provedor26 (1 messages.list + 25 messages.get)1
Custo em quota≈ 505 unidades das 6.000/minuto por usuário1 das 10.000/10 minutos, limitada a 4 concorrentes

Uma mensagem que some não derruba a página inteira

No Gmail, entre listar identificadores e hidratá-los, uma mensagem pode ser apagada. O código captura a falha individual e devolve a página sem ela, registrando um aviso. A alternativa — falhar tudo — travava permanentemente quem paginava histórico antigo, porque o identificador morto continuava lá.

O pageToken do Outlook é uma URL inteira

O Graph devolve @odata.nextLink completo, e o provider detecta que o token começa com http e o usa direto. Não tente construir esse cursor à mão.

A verificação da notificação do Gmail só liga quando configurada

O Pub/Sub anexa um token OIDC às entregas autenticadas, e o serviço o valida contra o JWKS do Google somente se EMAIL_GOOGLE_PUBSUB_AUDIENCE estiver definida. Sem a variável, a notificação é aceita — modo de desenvolvimento. Em produção, defina a variável.

A chave de externalId da assinatura difere por provedor

O Graph devolve um identificador de assinatura próprio. O Gmail não devolve nenhum — cada usuário tem no máximo um watch ativo —, então o serviço usa o endereço de e-mail da conta como chave, que é o que a notificação do Pub/Sub carrega de volta.

ProvedorO que vai para externalIdComo a notificação é reconciliada
OUTLOOKsubscriptionId devolvido pelo Graphbody.value[0].subscriptionId
GOOGLEEndereço de e-mail da conta conectadaemailAddress decodificado de body.message.data

Monolito e standalone

Em monolito o app do Email é montado no app principal e responde em localhost:3000/email. Em standalone ele sobe pelo src/email/main.ts, escuta a porta de PORT (3000 dentro do contêiner, publicada em 3026 no host pelo docker-compose.yaml) e alcança o IAM por MODULE_IAM_URL. Nenhuma rota muda de caminho entre os dois modos.


08

Conceitos e modelo de dados

Glossário

TermoSignifica
Provider configAs credenciais OAuth de uma organização para um provedor: client_id, client_secret, redirect_uri e, no Outlook, tenant_id. Guardadas cifradas. Uma organização pode ter várias, com uma marcada como padrão.
ConnectionUma conta de e-mail de uma pessoa, já autorizada. Guarda os tokens cifrados e o endereço. É o objeto que toda operação de mensagem exige, pelo connectionId.
Provider subscriptionA assinatura de push no provedor: subscription do Microsoft Graph ou watch do Gmail. Expira e precisa ser renovada.
Webhook subscriptionA assinatura de push do seu lado: para onde o BB entrega o evento já traduzido, com assinatura HMAC. Não confunda com a de cima — são as duas pontas da mesma corrente.
Canonical messageO formato único de mensagem, independente de provedor. Traz corpo em texto e HTML, sinalizadores, rótulos e metadados de anexo.
CategoryTriagem nativa normalizada: primary, other, promotions, social, updates, forums. Vem das abas do Gmail ou do Focused Inbox do Outlook. Ausente quando o provedor não dá sinal.
FolderPasta ou rótulo, normalizado em INBOX, SENT, DRAFTS, TRASH, SPAM, ARCHIVE ou CUSTOM.
ThreadConversa. No Gmail é o threadId nativo; no Outlook é o conversationId, e o provider monta a conversa consultando as mensagens que o compartilham.

Modelo de dados

Schema email no PostgreSQL. Cinco modelos, em duas correntes independentes: a de acesso à caixa (configuração → conexão → assinatura de push) e a de entrega ao cliente (assinatura de webhook → log de entrega).

erDiagram
  EmailProviderConfig ||--o{ EmailConnection : "autoriza"
  EmailConnection ||--o{ EmailProviderSubscription : "observa"
  EmailWebhookSubscription ||--o{ EmailWebhookDeliveryLog : "registra"

  EmailProviderConfig {
    uuid id PK
    uuid organizationId
    enum providerType "GOOGLE ou OUTLOOK"
    text credentials "cifrado AES-256-GCM"
    bool isDefault
    bool isActive
    ts deletedAt "exclusao logica"
  }
  EmailConnection {
    uuid id PK
    uuid organizationId
    uuid userId "quem iniciou o fluxo"
    uuid configId FK
    string externalEmail
    text accessToken "cifrado"
    text refreshToken "cifrado"
    ts tokenExpiresAt
    enum status "ACTIVE TOKEN_EXPIRED REVOKED ERROR"
  }
  EmailProviderSubscription {
    uuid id PK
    uuid connectionId FK
    string externalId UK "subscriptionId ou e-mail"
    string resource "/me/messages"
    string clientState "UUID v4, so Outlook"
    ts expiresAt
    bool isActive
  }
  EmailWebhookSubscription {
    uuid id PK
    uuid organizationId
    text callbackUrl
    string_array events
    text secret "HMAC-SHA256"
    bool isActive
    ts deletedAt "exclusao logica"
  }
  EmailWebhookDeliveryLog {
    uuid id PK
    uuid subscriptionId FK
    string eventType
    json payload "metadados, nunca o conteudo"
    enum status "PENDING SUCCESS FAILED RETRYING"
    int statusCode
    int retryCount
    ts nextRetryAt
  }
Modelo PrismaTabelaPropósitoCampos-chave
EmailProviderConfigemail.email_provider_configsCredenciais OAuth da organizaçãocredentials (cifrado), providerType, isDefault, isActive, único (organizationId, name)
EmailConnectionemail.email_connectionsConta autorizada de um usuárioexternalEmail, accessToken e refreshToken (cifrados), tokenExpiresAt, status
EmailProviderSubscriptionemail.email_provider_subscriptionsAssinatura de push no provedorexternalId (único), resource, clientState, expiresAt, isActive
EmailWebhookSubscriptionemail.email_webhook_subscriptionsPara onde entregar o eventocallbackUrl, events[], secret, isActive
EmailWebhookDeliveryLogemail.email_webhook_delivery_logsResultado de cada entregastatus, statusCode, payload, retryCount, nextRetryAt

Enumerações

EnumValores
EmailProviderTypeGOOGLE · OUTLOOK
EmailConnectionStatusACTIVE · TOKEN_EXPIRED · REVOKED · ERROR
EmailWebhookEventTypeMESSAGE_RECEIVED · MESSAGE_SENT · MESSAGE_UPDATED · MESSAGE_DELETED
EmailDeliveryStatusPENDING · SUCCESS · FAILED · RETRYING
EmailFolderTypeINBOX · SENT · DRAFTS · TRASH · SPAM · ARCHIVE · CUSTOM

Ciclo de vida de uma conexão

Os estados são os do enum EmailConnectionStatus, e as transições são as que existem hoje em connection.service.ts e connection.repository.ts — nada além disso muda o status de uma conexão.

stateDiagram-v2
  [*] --> Aguardando: POST /connections/connect
  state "Aguardando consentimento — nada é gravado ainda" as Aguardando
  state "ACTIVE" as ACTIVE
  state "TOKEN_EXPIRED" as EXPIRED
  state "REVOKED" as REVOKED
  state "ERROR — no enum, nunca atribuído" as ERRO

  Aguardando --> ACTIVE: GET /connections/callback com code válido
  ACTIVE --> ACTIVE: refresh bem-sucedido a menos de 5 min do vencimento
  ACTIVE --> EXPIRED: token vence e não há refresh token
  ACTIVE --> REVOKED: DELETE /connections/:id — deletedAt preenchido, a linha permanece
  EXPIRED --> ACTIVE: refresh volta a funcionar
  EXPIRED --> REVOKED: DELETE /connections/:id
  REVOKED --> [*]
EstadoO que significa na prática
ACTIVEOperação normal. Toda chamada renova o token quando faltam menos de 5 minutos.
TOKEN_EXPIREDO access token venceu e não havia refresh token para renovar. O usuário precisa reconectar.
REVOKEDA conexão foi desconectada. Qualquer operação sobre ela devolve 400.
ERRORExiste no enum e nenhum caminho de código o atribui. Não trate esse valor como sinal (§15).

Ciclo de vida de uma assinatura de push

A assinatura no provedor não tem coluna de status: o que a governa é isActive mais o expiresAt que o provedor devolveu.

stateDiagram-v2
  [*] --> Ativa: POST /provider-subscriptions
  state "isActive = true, com expiresAt do provedor" as Ativa
  state "Removida no provedor e apagada do banco (hard delete)" as Removida

  Ativa --> Ativa: POST /provider-subscriptions/:id/renew
  Ativa --> Ativa: job renewExpiringSubscriptions() — varre o que vence nas próximas 6 horas
  Ativa --> Vencida: ninguém renovou até expiresAt
  state "Vencida — o provedor para de notificar, sem erro" as Vencida
  Ativa --> Removida: DELETE /provider-subscriptions/:id
  Vencida --> Removida: DELETE /provider-subscriptions/:id
  Removida --> [*]
ProvedorDe onde vem o expiresAt
GOOGLEO watch do Gmail decide: cerca de 7 dias
OUTLOOKO que você pediu em expirationMinutes, limitado a 10.080 minutos para mensagem

Ciclo de vida de uma entrega de webhook

O status do EmailWebhookDeliveryLog é gravado uma única vez, no momento da entrega, a partir da resposta HTTP do seu endpoint. Não há segunda tentativa hoje — RETRYING e nextRetryAt são gravados, e nenhum processo os consome (§15).

stateDiagram-v2
  [*] --> Entrega: POST no seu callbackUrl, tempo limite de 30s
  state "Entrega em andamento" as Entrega
  state "SUCCESS — resposta 2xx" as SUCCESS
  state "FAILED — resposta HTTP fora do 2xx, nextRetryAt em 60s" as FAILED
  state "RETRYING — a requisição nem completou, nextRetryAt em 60s" as RETRYING
  state "PENDING — padrão do schema, nenhum código o grava" as PENDING

  Entrega --> SUCCESS: response.ok
  Entrega --> FAILED: resposta com status de erro
  Entrega --> RETRYING: falha de rede ou estouro do tempo limite
  SUCCESS --> [*]
  FAILED --> [*]: nenhum processo relê nextRetryAt
  RETRYING --> [*]: nenhum processo relê nextRetryAt

09

Referência da API

Prefixo: /email. Atenção ao caminho: o basePath é /email e os routers ficam sob /api/v1/email, então a rota completa repete o segmento — /email/api/v1/email/messages. Está correto; confira contra src/email/app.ts.

Pedaço do caminhoDe onde vemExemplo
/emailbasePath do app Hono/email
/api/v1/emailPrefixo com que os routers são montados/email/api/v1/email
/messagesRecurso do router/email/api/v1/email/messages

Todas as rotas autenticadas exigem Authorization: Bearer <token> e passam por requireOrganization, exceto onde indicado. As três permissões do vocabulário são EMAIL_READ, EMAIL_WRITE e EMAIL_ADMIN.

flowchart LR
  T["Bearer token do IAM"] --> AUTH["authMiddleware"]
  AUTH --> ORG["requireOrganization"]
  ORG --> PERM{"Qual permissão a rota exige?"}
  PERM -->|"EMAIL_READ"| L["Listar, buscar, ler mensagem, conversa, pasta e anexo"]
  PERM -->|"EMAIL_WRITE"| W["Conectar conta, enviar, responder, encaminhar, marcar, mover, apagar"]
  PERM -->|"EMAIL_ADMIN"| A["Credenciais de provedor, assinaturas de push e webhooks"]
  PUB["Rotas públicas — sem authMiddleware"] --> CB["GET /connections/callback — autenticada pelo state cifrado"]
  PUB --> NT["POST /providers/notifications — autenticada por clientState ou token OIDC"]

Configurações de provedor — /email/api/v1/email/provider-configs

MétodoRotaDescriçãoPermissão
POST/email/api/v1/email/provider-configsCadastra credenciais OAuth da organizaçãoEMAIL_ADMIN
GET/email/api/v1/email/provider-configsLista as configuraçõesEMAIL_READ
GET/email/api/v1/email/provider-configs/:idBusca uma configuraçãoEMAIL_READ
PATCH/email/api/v1/email/provider-configs/:idAtualiza nome, credenciais, padrão ou ativaçãoEMAIL_ADMIN
DELETE/email/api/v1/email/provider-configs/:idExclusão lógicaEMAIL_ADMIN
POST/email/api/v1/email/provider-configs/:id/testValida o formato da configuraçãoEMAIL_ADMIN

Conexões — /email/api/v1/email/connections

MétodoRotaDescriçãoPermissão
POST/email/api/v1/email/connections/connectDevolve a URL de autorização do provedorEMAIL_WRITE
GET/email/api/v1/email/connections/callbackCallback do OAuth; redireciona ao redirectUrlPública — sem authMiddleware
GET/email/api/v1/email/connectionsLista as conexões do usuário do tokenEMAIL_READ
DELETE/email/api/v1/email/connections/:idDesconecta (marca REVOKED, exclusão lógica)EMAIL_WRITE

Mensagens — /email/api/v1/email/messages

MétodoRotaDescriçãoPermissão
GET/email/api/v1/email/messagesLista mensagens; filtros por query stringEMAIL_READ
POST/email/api/v1/email/messages/searchBusca com query longa no corpo da requisiçãoEMAIL_READ
POST/email/api/v1/email/messagesEnvia mensagem ou salva rascunhoEMAIL_WRITE
GET/email/api/v1/email/messages/:idBusca uma mensagem; exige ?connectionId=EMAIL_READ
POST/email/api/v1/email/messages/:id/replyResponde, com replyAll opcionalEMAIL_WRITE
POST/email/api/v1/email/messages/:id/forwardEncaminhaEMAIL_WRITE
PATCH/email/api/v1/email/messages/:idMarca lida ou favorita, muda rótulos, move de pastaEMAIL_WRITE
DELETE/email/api/v1/email/messages/:idMove para a lixeira; ?permanent=true apaga de vezEMAIL_WRITE
GET/email/api/v1/email/messages/:id/attachments/:attIdBaixa o anexo em base64; exige ?connectionId=EMAIL_READ

Conversas — /email/api/v1/email/threads

MétodoRotaDescriçãoPermissão
GET/email/api/v1/email/threadsLista conversasEMAIL_READ
GET/email/api/v1/email/threads/:idConversa com todas as mensagens; exige ?connectionId=EMAIL_READ
PATCH/email/api/v1/email/threads/:idAplica a alteração a todas as mensagens da conversaEMAIL_WRITE
DELETE/email/api/v1/email/threads/:idApaga todas as mensagens da conversa; exige ?connectionId=EMAIL_WRITE

Pastas — /email/api/v1/email/folders

MétodoRotaDescriçãoPermissão
GET/email/api/v1/email/foldersLista pastas e rótulos; exige ?connectionId=EMAIL_READ

Webhooks do cliente — /email/api/v1/email/webhooks

MétodoRotaDescriçãoPermissão
POST/email/api/v1/email/webhooksCria a assinatura de entregaEMAIL_ADMIN
GET/email/api/v1/email/webhooksLista as assinaturasEMAIL_READ
PATCH/email/api/v1/email/webhooks/:idAtualiza URL, eventos, segredo ou ativaçãoEMAIL_ADMIN
DELETE/email/api/v1/email/webhooks/:idExclusão lógicaEMAIL_ADMIN

Assinaturas de push no provedor — /email/api/v1/email/provider-subscriptions

MétodoRotaDescriçãoPermissão
POST/email/api/v1/email/provider-subscriptionsCria subscription no Graph ou watch no GmailEMAIL_ADMIN
POST/email/api/v1/email/provider-subscriptions/:id/renewEstende a validadeEMAIL_ADMIN
DELETE/email/api/v1/email/provider-subscriptions/:idCancela no provedor e apaga a linhaEMAIL_ADMIN

Notificação do provedor e utilitárias

MétodoRotaDescriçãoPermissão
POST/email/api/v1/email/providers/notificationsRecebe o push do Graph e do Pub/SubPública — validação é por provedor
GET/email/healthSonda de saúde do serviçoPública
GET/email/connectedPágina de destino padrão pós-OAuth, para desenvolvimentoPública

/health e /connected não entram na contagem de 32 endpoints, que cobre apenas as rotas de negócio dos oito routers.


POST /email/api/v1/email/provider-configs

Cadastra as credenciais OAuth da organização. Aceita o corpo direto ou no envelope {"data":{"attributes":{...}}}.

Request

json
{
  "name": "Google Workspace corporativo",
  "providerType": "GOOGLE",
  "credentials": {
    "client_id": "1234567890-abc.apps.googleusercontent.com",
    "client_secret": "GOCSPX-...",
    "redirect_uri": "https://email.example.com/email/api/v1/email/connections/callback"
  },
  "isDefault": true
}
{
  "name": "Google Workspace corporativo",
  "providerType": "GOOGLE",
  "credentials": {
    "client_id": "1234567890-abc.apps.googleusercontent.com",
    "client_secret": "GOCSPX-...",
    "redirect_uri": "https://email.example.com/email/api/v1/email/connections/callback"
  },
  "isDefault": true
}
CampoTipoObrigatórioDescrição
namestring (1–100)SimÚnico dentro da organização
providerTypeGOOGLE | OUTLOOKSimProvedor
credentialsobject<string,string>Simclient_id, client_secret, redirect_uri; no Outlook aceita tenant_id (padrão common)
isDefaultbooleanNãoSe omitido, a primeira configuração da organização vira padrão
isActivebooleanNãoPadrão true
settingsobjectNãoAjustes específicos do provedor

Resposta 201 — o campo credentials nunca é devolvido.

json
{
  "data": {
    "type": "email-provider-config",
    "id": "0f5a...",
    "links": { "self": "/api/v1/email/provider-configs/0f5a..." },
    "attributes": {
      "name": "Google Workspace corporativo",
      "providerType": "GOOGLE",
      "isDefault": true,
      "isActive": true,
      "createdAt": "2026-08-16T12:00:00.000Z",
      "updatedAt": "2026-08-16T12:00:00.000Z"
    }
  }
}
{
  "data": {
    "type": "email-provider-config",
    "id": "0f5a...",
    "links": { "self": "/api/v1/email/provider-configs/0f5a..." },
    "attributes": {
      "name": "Google Workspace corporativo",
      "providerType": "GOOGLE",
      "isDefault": true,
      "isActive": true,
      "createdAt": "2026-08-16T12:00:00.000Z",
      "updatedAt": "2026-08-16T12:00:00.000Z"
    }
  }
}

Erros

StatusQuando
400Corpo reprovado no Zod, ou EMAIL_CREDENTIAL_MASTER_KEY ausente/malformada
403Token sem organizationId, ou sem EMAIL_ADMIN
409Já existe configuração com esse name na organização

POST /email/api/v1/email/connections/connect

Devolve a URL para onde você redireciona o usuário. Nada é gravado neste passo.

Request

json
{
  "configId": "0f5a...",
  "redirectUrl": "https://app.seucliente.com.br/integracoes/email/ok",
  "scopes": ["https://www.googleapis.com/auth/gmail.send", "openid"]
}
{
  "configId": "0f5a...",
  "redirectUrl": "https://app.seucliente.com.br/integracoes/email/ok",
  "scopes": ["https://www.googleapis.com/auth/gmail.send", "openid"]
}
CampoTipoObrigatórioDescrição
configIdstring (uuid)SimConfiguração de provedor a usar
redirectUrlstring (url)SimPara onde o BB devolve o navegador depois do callback
scopesstring[]NãoSobrepõe os escopos padrão. Leia §15 antes de mexer aqui.

Escopos padrão

ProvedorEscopos padrãoConsequência
GOOGLEgmail.send, userinfo.email, openidEnvio apenas. Listar, ler, modificar e criar watch no Gmail falham por falta de escopo.
OUTLOOKUser.Read, Mail.ReadWrite, Mail.Send, offline_accessLeitura e escrita completas na caixa.

Resposta 200 — objeto simples, sem envelope data.

json
{ "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=..." }
{ "authorizationUrl": "https://accounts.google.com/o/oauth2/v2/auth?client_id=..." }

Erros

StatusQuando
400configId inexistente na organização, ou credenciais sem client_id/redirect_uri
403Sem organizationId no token, ou sem EMAIL_WRITE

GET /email/api/v1/email/connections/callback

Rota pública, chamada pelo navegador do usuário. Recebe code e state, troca o código por tokens, cria a EmailConnection e responde 302 para o redirectUrl informado no connect.

SituaçãoResposta
Sucesso302 para o redirectUrl
Provedor devolveu error na query400 em text/plain com o erro e a descrição
code ou state ausente400 Missing code or state parameter
state não decifra ou está incompleto400 OAuth callback failed: Invalid callback state

O redirect_uri cadastrado nas credenciais precisa apontar para esta rota, e ser idêntico ao registrado no console do Google ou no portal do Entra. Divergência de um caractere resulta em redirect_uri_mismatch na tela do provedor, antes de chegar aqui.


GET /email/api/v1/email/messages

Lista mensagens de uma conexão. Todos os filtros vêm por query string.

ParâmetroTipoObrigatórioDescrição
connectionIdstring (uuid)SimConexão a consultar
folderstringNãoGmail: identificador de rótulo (INBOX). Outlook: nome bem conhecido de pasta (inbox, sentitems).
labelIdsstring ou string[]NãoAceita lista separada por vírgula. Só faz efeito no Gmail.
querystringNãoSintaxe nativa do provedor — q do Gmail, $search do Graph. Não é traduzida.
threadIdstringNãoRestringe à conversa
pageSizenumber (1–100)NãoPadrão 25
pageTokenstringNãoCursor da página anterior. No Outlook é a URL @odata.nextLink inteira.
includeBodybooleanNãoPadrão false. Sem ele o Gmail retorna só metadados.

Resposta 200 — sem envelope data.

json
{
  "messages": [
    {
      "id": "19f2b0c1a2d3e4f5",
      "threadId": "19f2b0c1a2d3e400",
      "providerMessageId": "19f2b0c1a2d3e4f5",
      "rfc822MessageId": "<CADk...@mail.gmail.com>",
      "from": { "email": "cliente@empresa.com.br", "name": "Maria Silva" },
      "to": [{ "email": "vendedor@financeira.com.br" }],
      "cc": [], "bcc": [], "replyTo": [],
      "subject": "Proposta 4471",
      "snippet": "Segue o comprovante solicitado...",
      "isRead": false, "isStarred": false, "isDraft": false, "isSent": false,
      "hasAttachments": true,
      "labels": ["INBOX", "UNREAD", "CATEGORY_PERSONAL"],
      "category": "primary",
      "folder": "INBOX",
      "attachments": [
        { "id": "ANGjdJ...", "filename": "comprovante.pdf",
          "mimeType": "application/pdf", "size": 184320 }
      ],
      "receivedAt": "2026-08-16T11:42:00.000Z"
    }
  ],
  "hasMore": true,
  "nextPageToken": "08...="
}
{
  "messages": [
    {
      "id": "19f2b0c1a2d3e4f5",
      "threadId": "19f2b0c1a2d3e400",
      "providerMessageId": "19f2b0c1a2d3e4f5",
      "rfc822MessageId": "<CADk...@mail.gmail.com>",
      "from": { "email": "cliente@empresa.com.br", "name": "Maria Silva" },
      "to": [{ "email": "vendedor@financeira.com.br" }],
      "cc": [], "bcc": [], "replyTo": [],
      "subject": "Proposta 4471",
      "snippet": "Segue o comprovante solicitado...",
      "isRead": false, "isStarred": false, "isDraft": false, "isSent": false,
      "hasAttachments": true,
      "labels": ["INBOX", "UNREAD", "CATEGORY_PERSONAL"],
      "category": "primary",
      "folder": "INBOX",
      "attachments": [
        { "id": "ANGjdJ...", "filename": "comprovante.pdf",
          "mimeType": "application/pdf", "size": 184320 }
      ],
      "receivedAt": "2026-08-16T11:42:00.000Z"
    }
  ],
  "hasMore": true,
  "nextPageToken": "08...="
}

Erros

StatusQuando
400Query reprovada no Zod, conexão REVOKED, ou token expirado sem refresh token
403Sem organizationId no token, ou sem EMAIL_READ
404connectionId inexistente na organização do token
500Falha do provedor. A mensagem do Gmail é repassada — costuma dizer se é escopo, quota ou token morto.

POST /email/api/v1/email/messages

Envia uma mensagem ou salva um rascunho.

Request

json
{
  "connectionId": "7c1e...",
  "to": [{ "email": "cliente@empresa.com.br", "name": "Maria Silva" }],
  "cc": [{ "email": "gerente@financeira.com.br" }],
  "subject": "Proposta 4471 — documentação pendente",
  "bodyText": "Bom dia, Maria. Falta o comprovante de renda.",
  "bodyHtml": "<p>Bom dia, Maria. Falta o comprovante de renda.</p>",
  "attachments": [
    { "filename": "checklist.pdf", "mimeType": "application/pdf", "data": "JVBERi0x..." }
  ],
  "asDraft": false
}
{
  "connectionId": "7c1e...",
  "to": [{ "email": "cliente@empresa.com.br", "name": "Maria Silva" }],
  "cc": [{ "email": "gerente@financeira.com.br" }],
  "subject": "Proposta 4471 — documentação pendente",
  "bodyText": "Bom dia, Maria. Falta o comprovante de renda.",
  "bodyHtml": "<p>Bom dia, Maria. Falta o comprovante de renda.</p>",
  "attachments": [
    { "filename": "checklist.pdf", "mimeType": "application/pdf", "data": "JVBERi0x..." }
  ],
  "asDraft": false
}
CampoTipoObrigatórioDescrição
connectionIdstring (uuid)SimDe qual conta sai a mensagem
toarray (mín. 1)SimCada item com email e name opcional
cc, bcc, replyToarrayNãoMesmo formato
subjectstringSimAceita string vazia
bodyText, bodyHtmlstringNãoInformar os dois gera multipart/alternative
attachmentsarrayNãodata em base64; aceita isInline e contentId
inReplyToMessageIdstringNãoIdentificador do provedor para encadear a conversa
asDraftbooleanNãoPadrão false. true cria rascunho sem enviar.

Resposta 201 — { "data": <CanonicalEmailMessage> }.

Erros

StatusQuando
400Corpo reprovado no Zod, ou conexão REVOKED
403Sem EMAIL_WRITE
500Provedor recusou o envio — anexo grande, destinatário inválido, quota estourada

POST /email/api/v1/email/provider-subscriptions

Cria a assinatura de push no provedor. Os campos são mutuamente relevantes: o Outlook usa notificationUrl e resource, o Gmail usa topicName e labelIds.

CampoTipoObrigatórioDescrição
connectionIdstring (uuid)SimConta a observar
resourcestringNãoPadrão /me/messages. Só o Outlook usa.
notificationUrlstring (url)OutlookURL HTTPS pública para onde o Graph envia
topicNamestringGmailprojects/<projeto>/topics/<topico>; cai para EMAIL_GOOGLE_PUBSUB_TOPIC
labelIdsstring[]NãoGmail: restringe o watch. Padrão ["INBOX"].
expirationMinutesnumber (10–10080)NãoOutlook: padrão 4.320 minutos (3 dias)

O schema exige pelo menos um entre notificationUrl, topicName e a variável de ambiente do tópico — sem nenhum deles, 400.

Resposta 201

json
{
  "data": {
    "type": "email-provider-subscription",
    "id": "b9e0...",
    "attributes": {
      "connectionId": "7c1e...",
      "providerType": "OUTLOOK",
      "externalId": "8f2c-...",
      "resource": "/me/messages",
      "expiresAt": "2026-08-19T12:00:00.000Z",
      "isActive": true,
      "createdAt": "2026-08-16T12:00:00.000Z"
    }
  }
}
{
  "data": {
    "type": "email-provider-subscription",
    "id": "b9e0...",
    "attributes": {
      "connectionId": "7c1e...",
      "providerType": "OUTLOOK",
      "externalId": "8f2c-...",
      "resource": "/me/messages",
      "expiresAt": "2026-08-19T12:00:00.000Z",
      "isActive": true,
      "createdAt": "2026-08-16T12:00:00.000Z"
    }
  }
}

No Gmail, o externalId é o endereço de e-mail da conta, não um identificador opaco — é assim que a notificação do Pub/Sub é reconciliada com a assinatura.


POST /email/api/v1/email/webhooks

Assina os eventos que o BB entrega ao seu sistema.

CampoTipoObrigatórioDescrição
callbackUrlstring (url)SimOnde você recebe
eventsarraySimMESSAGE_RECEIVED, MESSAGE_SENT, MESSAGE_UPDATED, MESSAGE_DELETED
secretstring (mín. 16)NãoSe omitido, o serviço gera 32 bytes aleatórios
descriptionstring (máx. 500)NãoTexto livre

A entrega é um POST com estes cabeçalhos:

CabeçalhoConteúdo
X-Webhook-Signaturesha256=<HMAC-SHA256 do corpo bruto, com o segredo da assinatura>
X-Webhook-EventO tipo do evento
X-Webhook-IdIdentificador da entrega, o mesmo do registro em email_webhook_delivery_logs

Corpo entregue: { "eventType": "...", "data": { ... }, "timestamp": "..." }. Tempo limite de 30 segundos.


10

Início rápido

Do zero à primeira mensagem enviada pela conta do usuário. Ambiente local, modo monolito na porta 3000 — em standalone troque por http://localhost:3026.

Atenção. Comandos não executados. O building block ainda não está publicado em staging (não consta em AMBIENTES.md), então os comandos abaixo foram escritos a partir do código e não foram executados contra um ambiente. Os passos 2 e 3 exigem consentimento humano no navegador e não são automatizáveis.

São seis passos, e só um deles precisa de um humano no navegador:

sequenceDiagram
  participant VC as Você
  participant IAM as IAM
  participant BB as BB Email
  participant PR as Google

  VC->>IAM: 1. POST /users/login
  IAM-->>VC: accessToken com organizationId e permissões
  VC->>BB: 2. POST /provider-configs — credenciais OAuth
  BB-->>VC: 201 com o configId
  VC->>BB: 3. POST /connections/connect
  BB-->>VC: authorizationUrl
  VC->>PR: abre a URL no navegador e consente
  PR->>BB: GET /connections/callback?code=...&state=...
  BB-->>VC: 302 para /email/connected
  VC->>BB: 4. GET /connections
  BB-->>VC: connectionId e externalEmail
  VC->>BB: 5. POST /messages
  BB->>PR: envia pela conta conectada
  VC->>BB: 6. GET /messages?folder=INBOX

1. Autenticar no IAM

bash
TOKEN=$(curl -s -X POST http://localhost:3000/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)
TOKEN=$(curl -s -X POST http://localhost:3000/iam/api/v1/users/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@catalisa.app",
    "password": "root123456",
    "organizationId": "b0000000-0000-0000-0000-000000000001"
  }' | jq -r .accessToken)

Resposta esperada: $TOKEN com um JWT. Se vier null, o login falhou — confira e-mail, senha e organização. O token precisa carregar EMAIL_ADMIN, EMAIL_READ e EMAIL_WRITE, além do organizationId.

2. Cadastrar as credenciais OAuth da organização

Registre antes um aplicativo no Google Cloud Console ou no portal do Entra, e aponte o redirect_uri para a rota de callback do building block.

bash
CONFIG=$(curl -s -X POST http://localhost:3000/email/api/v1/email/provider-configs \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "Workspace de teste",
    "providerType": "GOOGLE",
    "credentials": {
      "client_id": "SEU_CLIENT_ID.apps.googleusercontent.com",
      "client_secret": "SEU_CLIENT_SECRET",
      "redirect_uri": "http://localhost:3000/email/api/v1/email/connections/callback"
    },
    "isDefault": true
  }')
CONFIG_ID=$(echo "$CONFIG" | jq -r '.data.id')
CONFIG=$(curl -s -X POST http://localhost:3000/email/api/v1/email/provider-configs \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "name": "Workspace de teste",
    "providerType": "GOOGLE",
    "credentials": {
      "client_id": "SEU_CLIENT_ID.apps.googleusercontent.com",
      "client_secret": "SEU_CLIENT_SECRET",
      "redirect_uri": "http://localhost:3000/email/api/v1/email/connections/callback"
    },
    "isDefault": true
  }')
CONFIG_ID=$(echo "$CONFIG" | jq -r '.data.id')

Resposta esperada: 201, com attributes.providerType igual a GOOGLE e sem o campo credentials.

3. Conectar a conta

bash
curl -s -X POST http://localhost:3000/email/api/v1/email/connections/connect \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"configId\": \"$CONFIG_ID\",
    \"redirectUrl\": \"http://localhost:3000/email/connected\"
  }" | jq -r .authorizationUrl
curl -s -X POST http://localhost:3000/email/api/v1/email/connections/connect \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"configId\": \"$CONFIG_ID\",
    \"redirectUrl\": \"http://localhost:3000/email/connected\"
  }" | jq -r .authorizationUrl

Resposta esperada: uma linha única começando com https://accounts.google.com/o/oauth2/v2/auth?client_id=.... Nada foi gravado ainda — este passo só monta a URL.

Abra a URL no navegador, consinta, e o provedor devolve para a rota de callback, que grava a conexão e redireciona para /email/connected — a página "Email connected". Não há como pular esta etapa: OAuth exige um humano.

4. Descobrir o connectionId

bash
CONN_ID=$(curl -s http://localhost:3000/email/api/v1/email/connections \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')

curl -s http://localhost:3000/email/api/v1/email/connections \
  -H "Authorization: Bearer $TOKEN" | jq '.data[0].attributes'
CONN_ID=$(curl -s http://localhost:3000/email/api/v1/email/connections \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data[0].id')

curl -s http://localhost:3000/email/api/v1/email/connections \
  -H "Authorization: Bearer $TOKEN" | jq '.data[0].attributes'

Resposta esperada:

json
{
  "providerType": "GOOGLE",
  "externalEmail": "usuario@seudominio.com.br",
  "status": "ACTIVE",
  "configId": "0f5a..."
}
{
  "providerType": "GOOGLE",
  "externalEmail": "usuario@seudominio.com.br",
  "status": "ACTIVE",
  "configId": "0f5a..."
}

5. Enviar a primeira mensagem

bash
curl -s -X POST http://localhost:3000/email/api/v1/email/messages \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"connectionId\": \"$CONN_ID\",
    \"to\": [{\"email\": \"voce@exemplo.com.br\"}],
    \"subject\": \"Teste do building block Email\",
    \"bodyText\": \"Se você está lendo isto, a conexão funciona.\"
  }" | jq '.data.id, .data.isSent'
curl -s -X POST http://localhost:3000/email/api/v1/email/messages \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"connectionId\": \"$CONN_ID\",
    \"to\": [{\"email\": \"voce@exemplo.com.br\"}],
    \"subject\": \"Teste do building block Email\",
    \"bodyText\": \"Se você está lendo isto, a conexão funciona.\"
  }" | jq '.data.id, .data.isSent'

Resposta esperada: 201. A mensagem aparece na caixa "Enviados" da conta conectada — não de um remetente da Catalisa.

6. Listar a caixa de entrada

bash
curl -s "http://localhost:3000/email/api/v1/email/messages?connectionId=$CONN_ID&folder=INBOX&pageSize=5" \
  -H "Authorization: Bearer $TOKEN" | jq '.messages[] | {subject, from, isRead}'
curl -s "http://localhost:3000/email/api/v1/email/messages?connectionId=$CONN_ID&folder=INBOX&pageSize=5" \
  -H "Authorization: Bearer $TOKEN" | jq '.messages[] | {subject, from, isRead}'

Com uma conexão Outlook, isto lista. Com uma conexão Gmail nos escopos padrão, isto falha com 500 e a mensagem de erro do Gmail sobre escopo insuficiente — comportamento esperado, explicado em §15.

Credenciais de desenvolvimento local. Nunca cole segredo de produção em documentação ou script — veja AMBIENTES.md.


11

Receitas

Receber notificação de mensagem nova no Outlook

Objetivo. Fazer o seu sistema ser avisado em menos de um minuto quando chega e-mail na caixa conectada.

São duas assinaturas, uma em cada ponta da corrente. Este diagrama é o que mais evita chamado de suporte:

sequenceDiagram
  participant PR as Microsoft Graph
  participant BB as BB Email
  participant SEU as Seu sistema

  Note over BB,SEU: Montagem, uma vez
  SEU->>BB: POST /webhooks — assinatura 2, para onde o BB entrega
  SEU->>BB: POST /provider-subscriptions — assinatura 1, no provedor
  BB->>PR: cria a subscription com clientState aleatório
  PR->>BB: GET notificationUrl?validationToken=...
  BB-->>PR: devolve o token em text/plain
  PR-->>BB: subscription criada, com expiresAt

  Note over PR,SEU: Em operação, a cada mensagem
  PR->>BB: POST /providers/notifications
  BB->>BB: valida o clientState do lote
  BB->>SEU: POST no callbackUrl com X-Webhook-Signature

Passo 1 — assinar o evento do seu lado, que é para onde o building block entrega.

bash
WEBHOOK=$(curl -s -X POST http://localhost:3000/email/api/v1/email/webhooks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "callbackUrl": "https://api.seusistema.com.br/hooks/email",
    "events": ["MESSAGE_RECEIVED", "MESSAGE_UPDATED"],
    "description": "Esteira de documentos"
  }')
echo "$WEBHOOK" | jq '.data.id'
WEBHOOK=$(curl -s -X POST http://localhost:3000/email/api/v1/email/webhooks \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "callbackUrl": "https://api.seusistema.com.br/hooks/email",
    "events": ["MESSAGE_RECEIVED", "MESSAGE_UPDATED"],
    "description": "Esteira de documentos"
  }')
echo "$WEBHOOK" | jq '.data.id'

Resposta esperada: 201, com o identificador da assinatura. O secret vem nesta resposta e em nenhuma outra — guarde-o agora.

Passo 2 — criar a assinatura de push no Microsoft Graph, que é o que faz o provedor avisar o building block.

bash
curl -s -X POST http://localhost:3000/email/api/v1/email/provider-subscriptions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"connectionId\": \"$CONN_ID\",
    \"resource\": \"/me/messages\",
    \"notificationUrl\": \"https://email.suaempresa.com.br/email/api/v1/email/providers/notifications\",
    \"expirationMinutes\": 4320
  }" | jq '.data.attributes'
curl -s -X POST http://localhost:3000/email/api/v1/email/provider-subscriptions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"connectionId\": \"$CONN_ID\",
    \"resource\": \"/me/messages\",
    \"notificationUrl\": \"https://email.suaempresa.com.br/email/api/v1/email/providers/notifications\",
    \"expirationMinutes\": 4320
  }" | jq '.data.attributes'

Resposta esperada: 201, com externalId preenchido pelo Graph, isActive: true e expiresAt cerca de três dias à frente.

Ao criar a assinatura, o Graph chama a notificationUrl com ?validationToken= e espera o valor de volta em text/plain — o router já faz isso. Se essa etapa falhar, a assinatura não nasce.

Armadilhas.

  • A notificationUrl tem que ser HTTPS e alcançável pela internet. localhost não funciona: use um túnel em desenvolvimento.
  • Você precisa das duas assinaturas. Só a do provedor faz o BB receber, mas não faz ele entregar; só a sua faz ele querer entregar, mas nada chega.
  • O segredo do webhook aparece na criação e não é devolvido nas listagens. Guarde-o na hora.
  • A validação da entrega é HMAC-SHA256 sobre o corpo bruto. Se o seu framework reserializar o JSON antes de você calcular o hash, a assinatura não bate.
  • A latência oficial do Graph para message é média abaixo de 1 minuto, máxima 3 (Graph — throttling e latência). Não prometa segundos ao usuário final.

Manter o push vivo

Objetivo. Evitar que a assinatura expire e o produto emudeça sem ninguém perceber.

flowchart TD
  CRON["Cron externo — a plataforma agenda, o BB não"] --> JOB["renewExpiringSubscriptions()"]
  JOB --> Q["findExpiringBefore — o que vence nas próximas 6 horas"]
  Q --> LOOP{"Para cada assinatura"}
  LOOP -->|"Outlook"| RO["renew para 3 dias — respeita minutes"]
  LOOP -->|"Gmail"| RG["chama watch de novo — ignora minutes,<br/>exige EMAIL_GOOGLE_PUBSUB_TOPIC"]
  RO --> OK["expiresAt novo gravado"]
  RG --> OK
  LOOP -->|"token vencido"| WARN["logger.warn Failed to renew email provider subscription"]
  WARN --> MUDO["Sem monitoramento, o push morre em silêncio"]

Passo 1 — renovar uma assinatura específica, à mão.

bash
curl -s -X POST \
  "http://localhost:3000/email/api/v1/email/provider-subscriptions/$SUB_ID/renew" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"minutes": 4320}' | jq '.data.attributes.expiresAt'
curl -s -X POST \
  "http://localhost:3000/email/api/v1/email/provider-subscriptions/$SUB_ID/renew" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"minutes": 4320}' | jq '.data.attributes.expiresAt'

Resposta esperada: um expiresAt mais distante que o anterior. Se a data não mudou, a renovação não aconteceu — leia o log.

Passo 2 — automatizar. Para a renovação automática, agende renewExpiringSubscriptions() de src/email/jobs/subscription-renewal.job.ts. Ele varre o que vence nas próximas 6 horas e renova para 3 dias.

Armadilhas.

  • O job não é agendado por este building block. Ele existe como função exportada e precisa de um cron ou de uma fila do lado de fora. Sem isso, toda assinatura morre no prazo do provedor.
  • No Gmail, a renovação ignora o parâmetro minutes e depende de EMAIL_GOOGLE_PUBSUB_TOPIC estar configurado — renovar é chamar watch de novo, e o Google decide a nova expiração (cerca de 7 dias).
  • A Google recomenda chamar watch uma vez por dia, não a cada sete. Uma janela de 6 horas atende folgado, desde que o cron rode.
  • Renovação com token vencido falha silenciosamente no log do job, com logger.warn. Monitore esse aviso.

Baixar o anexo que o cliente respondeu

Objetivo. Levar o PDF que chegou por e-mail até o File Storage, sem intervenção humana.

flowchart LR
  S["POST /messages/search<br/>sintaxe nativa do provedor"] --> M["GET /messages/:id<br/>metadados do anexo"]
  M --> D["GET /messages/:id/attachments/:attId<br/>conteúdo em base64"]
  D --> B["base64 -d"]
  B --> FS["File Storage"]

Passo 1 — localizar a mensagem, com a sintaxe nativa do provedor no campo query.

bash
MSG_ID=$(curl -s -X POST http://localhost:3000/email/api/v1/email/messages/search \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"connectionId\": \"$CONN_ID\",
    \"query\": \"has:attachment from:cliente@empresa.com.br\",
    \"pageSize\": 5
  }" | jq -r '.messages[0].id')
MSG_ID=$(curl -s -X POST http://localhost:3000/email/api/v1/email/messages/search \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{
    \"connectionId\": \"$CONN_ID\",
    \"query\": \"has:attachment from:cliente@empresa.com.br\",
    \"pageSize\": 5
  }" | jq -r '.messages[0].id')

Resposta esperada: um identificador de mensagem em $MSG_ID. null significa que a busca não achou nada — ou que a conexão Gmail não tem escopo de leitura (§15).

Passo 2 — descobrir os anexos da mensagem.

bash
ATT_ID=$(curl -s "http://localhost:3000/email/api/v1/email/messages/$MSG_ID?connectionId=$CONN_ID" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.attachments[0].id')
ATT_ID=$(curl -s "http://localhost:3000/email/api/v1/email/messages/$MSG_ID?connectionId=$CONN_ID" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.attachments[0].id')

Resposta esperada: o identificador do primeiro anexo. Aqui vêm só metadados — filename, mimeType e size.

Passo 3 — baixar e gravar em disco.

bash
curl -s "http://localhost:3000/email/api/v1/email/messages/$MSG_ID/attachments/$ATT_ID?connectionId=$CONN_ID" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.data' | base64 -d > comprovante.pdf
curl -s "http://localhost:3000/email/api/v1/email/messages/$MSG_ID/attachments/$ATT_ID?connectionId=$CONN_ID" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.data.data' | base64 -d > comprovante.pdf

Resposta esperada: um comprovante.pdf legível no disco. Confira com file comprovante.pdf.

Armadilhas.

  • O conteúdo vem inteiro em base64 dentro do JSON. Um anexo de 20 MB vira quase 27 MB de resposta na memória. Não use este endpoint para arquivo grande sem pensar no consumo.
  • A busca usa a sintaxe do provedor: has:attachment from:... no Gmail, $search do Graph no Outlook. Não há tradução entre as duas.
  • O attachments de uma listagem traz só metadados; o conteúdo só vem por este endpoint.
  • No Gmail, a busca por has:attachment exige escopo de leitura — ver §15.

Marcar a conversa inteira como lida

Objetivo. Encerrar o atendimento sem percorrer mensagem por mensagem.

bash
curl -s -X PATCH "http://localhost:3000/email/api/v1/email/threads/$THREAD_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"connectionId\": \"$CONN_ID\", \"markRead\": true, \"addLabels\": [\"Resolvido\"]}" \
  | jq '.data | {id, unreadCount}'
curl -s -X PATCH "http://localhost:3000/email/api/v1/email/threads/$THREAD_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"connectionId\": \"$CONN_ID\", \"markRead\": true, \"addLabels\": [\"Resolvido\"]}" \
  | jq '.data | {id, unreadCount}'

Armadilhas.

  • No Outlook a operação não é atômica. O provider busca as mensagens da conversa e aplica a alteração uma a uma. Com o teto de 4 requisições concorrentes por caixa do Graph, uma conversa de 40 mensagens leva tempo e pode falhar no meio, deixando parte alterada.
  • PATCH de conversa aceita markRead, addLabels, removeLabels e moveToFolder. Não aceita markStarred — favoritar é operação de mensagem.
  • No Gmail, addLabels espera identificadores de rótulo, não os nomes exibidos. Descubra-os em GET /folders.

Diagnosticar por que uma chamada ao provedor falhou

Objetivo. Separar erro de escopo, de quota e de token morto — que se parecem muito.

flowchart TD
  E["Chamada ao provedor falhou"] --> S{"Qual o status da conexão?"}
  S -->|"REVOKED"| R["400 em qualquer operação — refaça o fluxo OAuth"]
  S -->|"TOKEN_EXPIRED"| T["O refresh token sumiu — o usuário precisa reconectar"]
  S -->|"ACTIVE"| M{"O que diz o texto do erro 500?"}
  M -->|"insufficient scope"| SC["Conexão Gmail nos escopos padrão — só envio (§15)"]
  M -->|"rate limit ou quota"| Q["Quota do provedor estourada — espere a janela"]
  M -->|"invalid credentials"| C["Credencial revogada no provedor"]
  M -->|"nada disso"| CF["POST /provider-configs/:id/test — valida só a forma"]

Passo 1 — a conexão está viva?

bash
curl -s http://localhost:3000/email/api/v1/email/connections \
  -H "Authorization: Bearer $TOKEN" | jq '.data[] | {id, externalEmail, status: .attributes.status}'
curl -s http://localhost:3000/email/api/v1/email/connections \
  -H "Authorization: Bearer $TOKEN" | jq '.data[] | {id, externalEmail, status: .attributes.status}'

Resposta esperada: uma linha por conexão, com status igual a ACTIVE. Qualquer outro valor já é a resposta do diagnóstico.

Passo 2 — a configuração do provedor é válida em forma?

bash
curl -s -X POST "http://localhost:3000/email/api/v1/email/provider-configs/$CONFIG_ID/test" \
  -H "Authorization: Bearer $TOKEN" | jq
curl -s -X POST "http://localhost:3000/email/api/v1/email/provider-configs/$CONFIG_ID/test" \
  -H "Authorization: Bearer $TOKEN" | jq

Resposta esperada: 200. Lembre que isto não chama o Google nem a Microsoft — passar aqui não prova que o client_secret está certo.

Ordem de diagnóstico:

  1. status da conexão. REVOKED devolve 400 em qualquer operação. TOKEN_EXPIRED significa que o refresh token sumiu — o usuário precisa reconectar.
  2. A mensagem do erro 500 nas rotas do Gmail. O provider repassa o texto do Gmail, que costuma dizer explicitamente se é escopo insuficiente, quota estourada ou credencial revogada.
  3. Escopo. Se a conexão Gmail foi criada com os escopos padrão, tudo que não é envio falha. Não há conserto sem reconectar com escopos maiores — ver §15.
  4. Quota. Gmail: 6.000 unidades por minuto por usuário, e um messages.get custa 20. Graph: 10.000 requisições por 10 minutos e 4 concorrentes por caixa. Uma listagem de 100 mensagens no Gmail consome cerca de 2.005 unidades.
  5. POST /provider-configs/:id/test valida apenas a forma da configuração — que o tipo de provedor existe e que a credencial decifra. Não chama o provedor nem confirma que o client_secret está certo.

12

Integração com outros building blocks

Building blockComo se relacionaObrigatório
IAMEmite o token, define a organização e concede EMAIL_READ, EMAIL_WRITE e EMAIL_ADMINSim
CalendarBuilding block irmão: mesma anatomia de provider-config, connection, provider-subscription e provider-notification, com as mesmas credenciais Google e MicrosoftNão
File StorageDestino natural dos anexos baixados; o Email entrega base64, o File Storage guardaNão
CustomersLiga o endereço de e-mail da conversa à pessoa ou empresa cadastradaNão
Webhooks EngineDistribuição de eventos em escala; o Email tem entrega própria e simples, sem repetição automática (§15)Não
Audit TrailRegistra quem conectou conta, quem leu caixa e quem enviou em nome de quemNão
Data ExtractionExtrai dados estruturados do PDF que chegou anexadoNão

A simetria com o Calendar é o argumento, não a coincidência. Os dois building blocks compartilham a mesma anatomia de integração OAuth:

flowchart TD
  subgraph EM["EMAIL"]
    E1["provider-configs"]
    E2["connections/connect"]
    E3["connections/callback"]
    E4["provider-subscriptions"]
    E5["providers/notifications"]
    E6["webhooks"]
    E7["messages · threads · folders"]
  end

  subgraph CA["CALENDAR"]
    C1["provider-configs"]
    C2["connections/connect"]
    C3["connections/callback"]
    C4["provider-notifications"]
    C5["webhooks"]
    C6["calendars · events · slots"]
  end

  EM <-->|"mesma estrutura"| CA
  EM --> BASE
  CA --> BASE
  BASE["Mesmas credenciais OAuth do cliente no Google e na Microsoft<br/>Mesmo padrão de cifra AES-256-GCM<br/>Mesmo modelo de conexão por usuário"]

Na prática: quem já conectou a agenda do vendedor conecta o e-mail dele com o mesmo consentimento, o mesmo aplicativo registrado e o mesmo código de front-end. Vender "a agenda e a caixa do seu time dentro do seu produto" deixa de ser dois projetos e vira uma entrega.

E a cadeia completa, que é o que nenhum fornecedor isolado de API de e-mail entrega:

flowchart LR
  IAM["IAM<br/>token, organização e permissões"] --> EMAIL
  EMAIL["Email"] -->|"MESSAGE_RECEIVED"| SEU["Seu sistema"]
  EMAIL -->|"remetente"| CUS["Customers<br/>quem é"]
  SEU -->|"anexo em base64"| FS["File Storage"]
  SEU --> DP["Decision Platform<br/>segue a esteira"]
  FS --> DE["Data Extraction<br/>lê o PDF"]
  DE --> DP
  CUS --> DP
  SEU --> AUD["Audit Trail<br/>quem leu e quem respondeu"]

Cada seta dessa cadeia é um contrato que já existe. É a diferença entre comprar uma API de e-mail e comprar a peça de e-mail de uma plataforma.


13

Configuração e operação

Variáveis de ambiente

VariávelDescriçãoObrigatóriaPadrão
EMAIL_CREDENTIAL_MASTER_KEYChave AES-256-GCM. Exatamente 64 caracteres hexadecimais (32 bytes). Gere com openssl rand -hex 32.Sim, para usar o BB—
EMAIL_GOOGLE_PUBSUB_TOPICTópico do Pub/Sub no formato projects/<projeto>/topics/<topico>. Necessária para push do Gmail e obrigatória para renovar o watch.Só com push do Gmail—
EMAIL_GOOGLE_PUBSUB_AUDIENCEaud esperado no token OIDC que o Pub/Sub anexa. Sem ela a verificação da notificação é pulada — defina em produção.Recomendada—
DATABASE_URLPostgreSQL com o schema emailSim—
JWT_SECRETVerificação do token do IAM. Mínimo 44 caracteres.Sim—
MODULE_IAM_URLEndereço do IAM em standaloneEm standalone—
MODULE_EMAIL_URLEndereço do Email para os outros building blocksNão''
DEPLOYMENT_MODEmonolith ou standaloneNãostandalone no main.ts
PORTPorta de escuta em standaloneNão3000

Dependências de infraestrutura

DependênciaPara quê
PostgreSQLSchema email — configurações, conexões, assinaturas e logs de entrega
IAMEmissão e verificação do token com organização e permissões
Google Cloud Pub/SubSó para push do Gmail: tópico, subscription e permissão de publicação para o Gmail
Endpoint HTTPS públicoSó para push do Outlook: o Graph precisa alcançar a rota de notificação
flowchart LR
  BB["Building block Email"] --> PG[("PostgreSQL — schema email")]
  BB --> IAM["IAM — token com organização e permissões"]
  BB -.->|"só com push do Gmail"| PS["Google Cloud Pub/Sub<br/>tópico, subscription e permissão de publicação"]
  BB -.->|"só com push do Outlook"| HT["Endpoint HTTPS público<br/>alcançável pelo Microsoft Graph"]
  BB -.->|"não usa"| NO["Redis · S3"]

O Email não usa Redis e não usa S3.

Limites e quotas

LimiteValorOrigem
pageSize de mensagens e conversas1 a 100, padrão 25Schema Zod deste BB
expirationMinutes de assinatura10 a 10.080Schema Zod deste BB
Padrão de expiração no Outlook4.320 minutos (3 dias)Padrão deste BB
Máximo de assinatura para mensagem no Graph10.080 minutos; 1.440 com dados do recursoMicrosoft
Validade do watch do Gmail7 dias; renovação diária recomendadaGoogle
Quota do Gmail6.000 unidades/minuto por usuário; messages.get = 20, messages.send = 100, users.watch = 100Google
Throttling do Outlook via Graph10.000 requisições/10 min e 4 concorrentes por aplicativo e caixaMicrosoft
Tempo limite de entrega de webhook30 segundosCódigo deste BB
Comprimento do segredo de webhookMínimo 16 caracteresSchema Zod deste BB

Catálogo de erros

StatusCódigoSignificaO que fazer
400VALIDATIONCorpo ou query reprovados no ZodConfira campos e tipos contra §9
400VALIDATIONThis email connection has been revokedA conexão foi desconectada; refaça o fluxo OAuth
400VALIDATIONToken expired and no refresh token availableO provedor não devolveu refresh token; reconecte com access_type=offline
400VALIDATIONInvalid callback statestate corrompido, ou a chave mestra mudou desde o início do fluxo
400VALIDATIONCannot delete the only email provider configCrie outra configuração antes de apagar a única
400VALIDATIONInvalid notification signatureclientState da notificação não confere; verifique a origem
403—Organization context requiredAutentique com token que carrega organizationId
403FORBIDDENFalta EMAIL_READ, EMAIL_WRITE ou EMAIL_ADMINAjuste o papel no IAM e emita token novo
404NOT_FOUNDEmailConnection, EmailProviderConfig ou assinatura inexistente na organização do tokenConfira o identificador e a organização
409CONFLICTNome de configuração de provedor já usadoEscolha outro nome
500INTERNALFailed to list Gmail messages: <texto do Gmail>Leia o texto repassado: costuma nomear escopo, quota ou token
500INTERNALFailed to decrypt connection tokensEMAIL_CREDENTIAL_MASTER_KEY mudou; os dados cifrados com a chave antiga são irrecuperáveis

Observabilidade

  • GET /email/health devolve nome do serviço e versão do build. É uma sonda de processo vivo: não testa banco nem provedor.
  • POST /provider-configs/:id/test valida a forma da configuração — que o provedor existe e que a credencial decifra. Não confirma que o segredo está correto no Google ou na Microsoft.
  • O job de renovação registra Failed to renew email provider subscription com logger.warn por assinatura que falha. É o sinal mais importante de monitorar: sem ele, o push morre em silêncio.
  • Listagens do Gmail que perdem mensagem no meio registram [gmail] listMessages: skipped N/M unfetchable message(s) — é o único rastro de página que voltou incompleta.
  • Cada entrega de webhook grava uma linha em email_webhook_delivery_logs com status, código HTTP e mensagem de erro. É por ali que se responde "o evento saiu daqui?".

14

Segurança e compliance

Isolamento entre tenants

O organizationId vem do claim assinado do JWT. Todos os oito routers de negócio aplicam requireOrganization, que devolve 403 quando o claim falta. Nenhuma rota lê organizationId do corpo ou da query.

flowchart TD
  T["JWT assinado pelo IAM<br/>claim organizationId"] --> RQ["requireOrganization<br/>403 se o claim faltar"]
  RQ --> SVC["Serviço recebe organizationId do token, nunca do corpo"]
  SVC --> REPO["findById(id, organizationId)<br/>filtro na cláusula SQL, junto com deletedAt: null"]
  REPO --> OK["Conexão da organização certa"]
  REPO --> NF["404 — connectionId de outra organização"]
  OK --> GP["getProviderForConnection — passagem obrigatória"]
  GP --> CAIXA["Chamada ao Gmail ou ao Graph"]
  NF --> STOP["Nenhum dado sai"]

No acesso ao dado, EmailConnectionRepository.findById(id, organizationId) e EmailProviderConfigRepository.findById(id, organizationId) filtram por organização na cláusula SQL, junto com deletedAt: null — um connectionId de outra organização resulta em 404, não em dado. Como toda operação de mensagem passa obrigatoriamente por getProviderForConnection, que resolve a conexão com esse filtro, não existe caminho de leitura de caixa que escape do escopo da organização.

Credenciais OAuth e tokens

EmailProviderConfig.credentials, EmailConnection.accessToken e EmailConnection.refreshToken são cifrados com AES-256-GCM — chave de 32 bytes, IV aleatório de 12 bytes por operação e auth tag verificada na decifra. O formato gravado é iv:authTag:ciphertext, tudo em hexadecimal:

A chave vem de EMAIL_CREDENTIAL_MASTER_KEY e é validada no uso: 64 caracteres hexadecimais, ou a operação falha. O client_secret do provedor nunca volta em nenhuma resposta da API — as rotas de configuração serializam apenas nome, tipo, sinalizadores e datas.

O state do OAuth

É cifrado com a mesma chave mestra e a mesma cifra autenticada. Quem não tem a chave não forja um state válido, e um state adulterado falha na verificação do auth tag, produzindo Invalid callback state.

Autenticação das notificações do provedor

No Outlook, cada assinatura nasce com um clientState aleatório (UUID v4) que o Graph devolve em toda notificação; o serviço exige que todos os itens do lote tragam o valor correto, e recusa lote vazio. No Gmail, o Pub/Sub anexa um token OIDC assinado pelo Google, verificado contra o JWKS oficial quando EMAIL_GOOGLE_PUBSUB_AUDIENCE está configurada — configure-a em produção, porque sem ela o endpoint aceita a notificação sem verificar.

Integridade da entrega ao cliente

Cada webhook sai com X-Webhook-Signature: sha256=<HMAC> calculado sobre o corpo exato enviado. Valide a assinatura antes de confiar no conteúdo.

Dado pessoal e LGPD

E-mail é dado pessoal, e o corpo de uma mensagem pode conter dado pessoal sensível. A decisão de arquitetura que mais importa aqui é esta: o building block não persiste conteúdo de mensagem. Assunto, corpo, destinatários e anexos passam do provedor para o chamador e não tocam o PostgreSQL. Concretamente, o que fica gravado é:

Dado pessoal gravadoOndePor quê
Endereço de e-mail da conta conectadaemail_connections.external_emailIdentificar a conexão para o usuário
Endereço de e-mail da conta observadaemail_provider_subscriptions.external_id, no GmailÉ a chave que reconcilia a notificação com a assinatura
Identificadores da notificação do provedoremail_webhook_delivery_logs.payloadAuditar a entrega. No Gmail contém o endereço e o historyId; no Outlook, o identificador da mensagem e o tipo de alteração — nunca o conteúdo.

Ou seja: o BB registra que houve mensagem e em qual caixa, nunca o que estava escrito. Quem decide o que reter do conteúdo é o sistema que consome a API — e é lá que a política de retenção precisa existir.

Retenção e exclusão

Configurações de provedor, conexões e assinaturas de webhook usam exclusão lógica (deletedAt), preservando a linha para auditoria. Assinaturas de push são removidas de fato, no provedor e no banco. Não há expurgo automatizado de logs de entrega — ver §15.

Consentimento

O acesso à caixa depende de consentimento explícito do titular na tela do próprio provedor, com os escopos listados. A credencial OAuth é da organização cliente, então o consentimento é dado ao aplicativo dela — o que também significa que a responsabilidade pela verificação junto a Google e Microsoft é dela.

Autenticação e permissões

Duas rotas são públicas por necessidade de protocolo: o callback do OAuth, autenticado pelo state cifrado, e o endpoint de notificação, autenticado pelo mecanismo de cada provedor. Todas as demais exigem token do IAM, organizationId e uma das três permissões. Operações destrutivas ou de configuração exigem EMAIL_ADMIN.


15

Limitações conhecidas

LimitaçãoImpactoSituação
Gmail nasce somente com permissão de envioOs escopos padrão da conexão Google são gmail.send, userinfo.email e openid. Listar, ler, modificar mensagem e criar watch no Gmail falham por falta de escopo. Ler caixa no Gmail exige escopos restritos, que exigem avaliação de segurança CASA anual.Por design, até que haja avaliação aprovada. Dá para passar scopes maiores no connect, mas o aplicativo do cliente precisa estar verificado — não anuncie leitura de Gmail sem confirmar isso.
Sem cliente em produçãoO BB é beta: tem testes unitários e de integração, roda em monolito e em standalone, mas não tem histórico de carga real.Beta
Só Gmail e OutlookNão há IMAP genérico, Exchange on-premises, Yahoo, iCloud nem Zoho.Por design; a arquitetura de providers/ aceita novos, o registro é em auth-helpers.ts
O job de renovação não se agenda sozinhorenewExpiringSubscriptions() existe como função e nada a chama. Sem um cron externo, toda assinatura de push expira no prazo do provedor e o produto emudece.Precisa de agendamento na plataforma
Webhook sem repetição automáticaEntrega que falha grava FAILED ou RETRYING e preenche nextRetryAt, mas nenhum processo consome esse campo. Não há segunda tentativa.Roadmap; para entrega crítica, use o Webhooks Engine
Logs de entrega crescem sem limiteemail_webhook_delivery_logs não tem expurgo nem particionamento.Roadmap
Reconectar cria uma conexão novaO callback sempre insere uma EmailConnection. Reconectar a mesma conta gera outra linha, e GET /connections passa a listar as duas. O repositório tem findByExternalEmail, que o fluxo de callback não usa.Roadmap
Desconectar não revoga no provedorDELETE /connections/:id marca REVOKED e apaga logicamente, mas não chama a revogação de token no Google ou na Microsoft, nem cancela as assinaturas de push da conexão.Roadmap — cancele a assinatura antes de desconectar
Acesso é por organização, não por usuárioGET /connections lista só as conexões do usuário do token, mas as rotas de mensagem resolvem a conexão apenas pela organização. Qualquer usuário da mesma organização com EMAIL_READ e o connectionId em mãos acessa aquela caixa.Por design atual; trate connectionId como informação restrita
Operação de conversa não é atômica no OutlookPATCH e DELETE de conversa iteram mensagem a mensagem. Falha no meio deixa a conversa parcialmente alterada, e o teto de 4 concorrentes por caixa do Graph torna conversa longa lenta.Limitação do provedor
Listagem no Gmail é N+1messages.list devolve identificadores e cada mensagem exige um messages.get. Uma página de 25 são 26 chamadas, cerca de 505 unidades de quota.Limitação do provedor
Busca não é traduzida entre provedoresO query vai cru para o q do Gmail ou o $search do Graph. A mesma string produz resultados diferentes.Por design — traduzir sintaxe de busca esconde comportamento e piora o resultado
Sem sincronização incrementalNão há history.list do Gmail nem delta query do Graph. A notificação avisa que algo mudou; descobrir o que mudou é trabalho do consumidor.Roadmap
ERROR no enum de status nunca é atribuídoEmailConnectionStatus.ERROR existe no schema e nenhum caminho de código o define. Não trate esse valor como sinal.Schema à frente do código
PENDING no enum de status de entrega nunca é gravadoEmailDeliveryStatus.PENDING é o padrão da coluna no schema, mas o serviço sempre grava o log depois da tentativa, com SUCCESS, FAILED ou RETRYING. Não espere ver PENDING em email_webhook_delivery_logs.Schema à frente do código
MESSAGE_SENT não é emitido pelo provedorO tipo de evento existe e pode ser assinado, mas as notificações do Gmail viram MESSAGE_UPDATED e as do Graph seguem o changeType (created, updated, deleted).Schema à frente do código
Sem paginação para conexões e configuraçõesGET /connections e GET /provider-configs devolvem tudo, sem cursor.Aceitável no volume atual
Anexo trafega inteiro em base64Sem streaming nem URL assinada; um anexo de 20 MB vira quase 27 MB de JSON na memória.Roadmap
/health não sonda dependênciaDevolve serviço e versão. Não testa PostgreSQL nem provedor.Roadmap

16

Perguntas frequentes

Isso serve para disparar e-mail transacional, tipo confirmação de pedido?

Não. Para disparo em volume a partir do domínio da sua aplicação, use Amazon SES, Postmark, SendGrid ou Resend — é outra categoria de produto, com outra infraestrutura de entregabilidade. O Email da Catalisa envia pela conta do usuário, uma mensagem por vez, com o endereço dele no remetente. Serve para a resposta que uma pessoa daria; não para a notificação que um sistema dispara.

O conteúdo dos e-mails do meu cliente fica guardado na Catalisa?

Não. Assunto, corpo e anexo passam pela API e não são gravados. O banco guarda credenciais cifradas, o endereço da conta conectada e metadados de assinatura e de entrega — os detalhes estão em §14. Quem decide o que reter do conteúdo é o seu sistema, e é lá que a política de retenção precisa existir.

Por que a leitura funciona no Outlook e não funciona no Gmail?

Porque os escopos padrão da conexão Google são de envio apenas. Ler caixa no Gmail exige escopos restritos, e o Google condiciona escopo restrito a uma avaliação de segurança independente, renovada a cada doze meses. A conexão nasce sem esses escopos justamente para não prometer o que a verificação ainda não autoriza. Dá para pedir escopos maiores no connect, mas o aplicativo do cliente precisa estar aprovado — veja §15.

Quem é o dono da relação com o Google e a Microsoft?

O cliente. Cada organização cadastra o próprio client_id e client_secret, e é o nome do aplicativo dela que aparece na tela de consentimento do usuário final. Isso é uma vantagem — a marca certa na tela, o dado sem intermediário — e uma responsabilidade: a verificação de editor na Microsoft e a avaliação de escopo restrito no Google são dela.

Preciso mesmo de duas assinaturas para receber uma notificação?

Precisa. A provider-subscription faz o Google ou a Microsoft avisarem o building block. A webhook faz o building block avisar você. Só a primeira e nada chega ao seu sistema; só a segunda e nada chega ao building block. É o erro de integração mais comum aqui.

O que acontece se a assinatura de push expirar?

O produto emudece sem erro. Não há exceção, não há alerta — as notificações simplesmente param. Por isso o item mais importante de operação deste BB é agendar renewExpiringSubscriptions() em um cron e monitorar o aviso Failed to renew email provider subscription. O job renova o que vence nas próximas 6 horas; ele existe, mas ninguém o chama por padrão (§15).

O token OAuth do usuário expira. Preciso tratar isso?

Não. Toda operação verifica a validade antes de chamar o provedor e renova se faltarem menos de cinco minutos. O único caso que exige ação humana é quando o provedor não devolveu refresh token ou o usuário revogou o acesso: a conexão vai para TOKEN_EXPIRED e a pessoa precisa reconectar.

Dá para conectar uma caixa compartilhada, e não a de uma pessoa?

Dá, desde que a caixa tenha credenciais próprias de acesso ao Outlook e alguém consinta por ela. A EmailConnection guarda o userId de quem iniciou o fluxo, mas as operações de mensagem resolvem a conexão pela organização — então outros usuários da mesma organização operam a mesma caixa com o connectionId. Leia a linha sobre escopo de acesso em §15 antes de desenhar isso.

Como isso se relaciona com o building block Calendar?

São irmãos por construção: mesma estrutura de configuração de provedor, conexão, assinatura de push e notificação, e as mesmas credenciais OAuth no Google e na Microsoft. Quem integrou um integra o outro no mesmo dia, e o usuário consente uma vez pelo mesmo aplicativo. É o diagrama de §12.

Por que a rota tem email duas vezes no caminho?

Porque o basePath do app é /email e os routers ficam sob /api/v1/email. O caminho real é /email/api/v1/email/messages. Está correto e é assim que precisa ser chamado — confira em src/email/app.ts se tiver dúvida.


Padrão: PADRAO-DOCUMENTACAO.md · Índice: INDEX.md