Guias30 de julho de 20267 min de leitura

Qualquer e-mail vira webhook com o Cloudflare Email Routing

Um endereço catch-all, um handler de e-mail no Worker e umas sessenta linhas: todo e-mail para seu domínio vira um POST JSON no seu pipeline HTTP atual.

UM ENDEREÇO É SÓ MAIS UMA URL DE ENTRADAqualquer pessoaenvia um e-mailMXroteamentoparse + verificaçãoPOSTseu handlerou seu agentelogmesmas novas tentativas, mesmo log de eventos, mesmo botão Replay de qualquer outra origem de webhook
Entra um registro MX, sai um POST HTTP. O parse e a verificação acontecem no meio do caminho.

O Cloudflare Email Routing consegue entregar a um Worker todo e-mail enviado para seu domínio. É gratuito, não há servidor de e-mail para manter e não há polling de IMAP. Combine uma regra catch-all com um handler de e-mail e você tem algo realmente útil: endereços de entrada ilimitados no seu domínio, cada um transformando e-mail num POST JSON para o pipeline HTTP que você já roda.

Lançamos exatamente isso no AnyHook (todo endpoint agora também é um endereço de e-mail), e a maior parte da hora que levou foi para três decisões de design, e não para código.

A configuração

  1. Ative o Email Routing em sua zona. A Cloudflare adiciona os registros MX, e as regras nomeadas que já existem, como hello@, continuam funcionando, porque as regras nomeadas têm precedência sobre o catch-all
  2. Crie uma regra catch-all com a ação Send to a Worker (enviar para um Worker)
  3. Exporte um handler email ao lado do seu handler fetch:
export default {
  async fetch(request, env, ctx) { /* your existing worker */ },
  async email(message, env, ctx)  { /* new */ },
};

O handler recebe um objeto ForwardableEmailMessage: from, to, headers, rawSize, um stream raw com a mensagem MIME completa e três verbos, forward(), reply() e setReject().

Decisão 1: o endereço é seu roteador

Um catch-all recebe e-mail para qualquer parte local, então o próprio endereço pode carregar informação de roteamento. Os nossos codificam um cliente e um app:

{user-slug}.{app-slug}@anyhook.net

A regra de parse que torna isso seguro é dividir no último ponto, e ela só é segura porque os slugs são sanitizados para [a-z0-9-] na criação. Eles nunca podem conter ponto, então a divisão é inequívoca por construção, e não por convenção. Se seus identificadores podem conter pontos, escolha um separador que eles não possam conter, ou esse roteador vai rotear errado.

export function parseInboxAddress(to: string) {
  const local = (to.split("@")[0] ?? "").toLowerCase();
  const dot = local.lastIndexOf(".");
  if (dot <= 0 || dot === local.length - 1) return null;

  const userSlug = local.slice(0, dot);
  const appSlug  = local.slice(dot + 1);
  if (!/^[a-z0-9-]+$/.test(userSlug) || !/^[a-z0-9-]+$/.test(appSlug)) return null;
  return { userSlug, appSlug };
}

Converta para minúsculas primeiro; os remetentes não preservam maiúsculas e minúsculas na parte local. E mantenha isso como uma função pura: é a parte cheia de ramificações, o que faz dela a parte testável. A nossa tem oito testes (maiúsculas e minúsculas, hífens, pontos no início e no fim, +tag, underscores, path traversal, parte local vazia). A cola em volta dela não tem nenhum, porque a cola não tem ramificações.

Decisão 2: recuse no SMTP, nunca engula

Um catch-all atrai toda sondagem apontada para seu domínio. A tentação é descartar em silêncio tudo o que não passa no parse. Não faça isso, porque um remetente que não recebe erro supõe que houve entrega. Para um endereço digitado errado, isso é um humano esperando uma resposta que nunca chega.

const parsed = parseInboxAddress(message.to);
if (!parsed) {
  message.setReject("Address must be {user}.{app}@example.com");
  return;
}

setReject devolve a mensagem já na conversa SMTP, então o servidor remetente gera um aviso de falha de verdade. O mesmo vale para e-mails grandes demais, e confira rawSize antes de carregar o stream no buffer, não depois:

if (message.rawSize > MAX_BYTES) {
  message.setReject(`Message exceeds ${MAX_BYTES} bytes`);
  return;
}

As mensagens recebidas podem ter até 25 MiB. Descobrir que uma mensagem é grande demais carregando-a na memória do Worker é a ordem errada.

UM CATCH-ALL ATRAI SONDAGENSe-mail recebidoparse do endereçocheca rawSize antesokum webhooklognão parseável, ou acima de 25 MiBsetReject()recusa no SMTPo servidor remetente gera um aviso de falha realdescartar em silêncio diz a uma pessoa que o e-mail dela foi entregue quando não foi
Duas saídas e nenhuma terceira. Tudo o que não pode ser roteado volta para onde o remetente consegue ver.

Decisão 3: não construa um segundo pipeline

O handler de e-mail poderia fazer o parse do MIME, aplicar cotas, gravar no banco de dados e enfileirar a entrega, duplicando tudo o que seu caminho HTTP já faz. São dois pipelines que se afastam para sempre.

É a mesma armadilha em que as plataformas de automação caem quando o motor de workflows também é a porta de entrada, e é por isso que uma execução com falha ali deixa o evento só no registro daquela execução: Webhook do n8n não funciona trata desse modo de falha em detalhe.

Em vez disso, reempacote a mensagem como a requisição HTTP que ela teria sido e chame seu próprio handler fetch:

export async function handleEmail(message, env, ctx) {
  const parsed = parseInboxAddress(message.to);
  if (!parsed) { message.setReject("…"); return; }

  const raw = await new Response(message.raw).text();
  const req = new Request(
    `https://in.example.com/${parsed.userSlug}/${parsed.appSlug}`,
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        "x-transport": "email",
      },
      body: JSON.stringify({
        type: "email.received",
        from: message.from,
        to: message.to,
        subject: message.headers.get("subject"),
        message_id: message.headers.get("message-id"),
        size: message.rawSize,
        raw,
      }),
    }
  );

  const res = await handler.fetch(req, env, ctx);
  if (!res.ok && res.status !== 202) {
    message.setReject(`Rejected (${res.status})`);
  }
}

Tudo o que vem depois segue o mesmo caminho de código dos seus webhooks: autenticação da rota, rate limits, cotas, limites de payload, fila, novas tentativas (retries), logs. Um pipeline, dois transportes. Quando o caminho HTTP ganha um recurso, o e-mail também ganha, automaticamente. O cabeçalho x-transport: email é a única coisa que diferencia os dois, para interfaces que queiram mostrar um envelope em vez de um payload.

Repare nas últimas quatro linhas: se seu próprio pipeline recusar o evento (app desconhecido, limite diário atingido), propague isso também como uma recusa SMTP. A mensagem devolvida é seu canal para relatar erros ao remetente.

O que saber antes de depender disso

  • Mande a string raw inteira adiante e faça o parse lá, com uma biblioteca MIME de verdade. Os campos de conveniência (subject, from) servem para roteamento e exibição; anexos, partes HTML e cabeçalhos DKIM ficam em raw
  • As regras nomeadas continuam valendo. Um catch-all só vê o e-mail que nenhuma regra nomeada pega, então um encaminhamento hello@ existente fica intacto
  • Os limites de CPU do plano Workers Free se aplicam a handlers de e-mail como a qualquer Worker. O padrão de reempacotamento mantém o handler enxuto, o que ajuda

Se você prefere não rodar isso por conta própria, este é exatamente o padrão que o AnyHook hospeda: uma chamada de API sem chave devolve um endpoint que é, ao mesmo tempo, uma URL de webhook e um endereço de e-mail. Os dois alimentam um único log de eventos que você pode ler por HTTP ou MCP.

Todos os posts30 de julho de 2026 · 7 min

Pare de perder webhooks.

Troque uma URL. Tenha novas tentativas, um log de eventos e replay com um clique. Ou veja antes: crie uma conta, rode a simulação de queda de 30 segundos e veja um evento sobreviver ao seu servidor fora do ar. Não precisa de servidor.