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
- 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 - Crie uma regra catch-all com a ação Send to a Worker (enviar para um Worker)
- Exporte um handler
emailao lado do seu handlerfetch:
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.
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
rawinteira 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 emraw - 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.