Cloudflare Email Routing có thể chuyển mọi email gửi tới domain của bạn cho một Worker. Nó miễn phí, không cần chạy mail server, và không cần polling IMAP. Kết hợp một rule catch-all với một email handler, bạn có một thứ thật sự hữu ích: không giới hạn số địa chỉ nhận thư trên domain của bạn, mỗi địa chỉ biến thư thành một JSON POST vào bất kỳ pipeline HTTP nào bạn đang chạy.
Chúng tôi đã làm đúng thứ này cho AnyHook (mỗi endpoint giờ cũng là một địa chỉ email), và phần lớn trong một giờ bỏ ra là dành cho ba quyết định thiết kế chứ không phải viết code.
Thiết lập
- Bật Email Routing cho zone của bạn. Cloudflare sẽ thêm các bản ghi MX, và các rule có tên sẵn có như
hello@vẫn chạy, vì rule có tên được ưu tiên hơn catch-all - Tạo một rule catch-all với action Send to a Worker
- Export một handler
emailbên cạnh handlerfetchcủa bạn:
export default {
async fetch(request, env, ctx) { /* your existing worker */ },
async email(message, env, ctx) { /* new */ },
};
Handler nhận một ForwardableEmailMessage: from, to, headers, rawSize, một stream raw chứa toàn bộ thông điệp MIME, và ba động từ: forward(), reply() và setReject().
Quyết định 1: địa chỉ chính là bộ định tuyến
Một catch-all nhận thư cho mọi local part, nên bản thân địa chỉ có thể mang thông tin định tuyến. Địa chỉ của chúng tôi chứa một tenant và một App (một URL nhận cho một nguồn webhook, ví dụ “Stripe production”):
{user-slug}.{app-slug}@anyhook.net
Quy tắc parse giúp cách này an toàn là tách tại dấu chấm cuối cùng, và nó chỉ an toàn vì slug được làm sạch về [a-z0-9-] ngay lúc tạo. Slug không bao giờ chứa dấu chấm, nên phép tách không mơ hồ là nhờ cấu trúc chứ không phải nhờ quy ước. Nếu định danh của bạn có thể chứa dấu chấm, hãy chọn một ký tự phân tách mà chúng không thể chứa, nếu không bộ định tuyến này sẽ định tuyến sai.
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 };
}
Chuyển về chữ thường trước; bên gửi không giữ nguyên chữ hoa chữ thường của local part. Và giữ đây là một pure function: đó là phần nhiều nhánh rẽ, nên cũng là phần đáng test. Hàm của chúng tôi có tám test (chữ hoa/thường, dấu gạch nối, dấu chấm ở đầu/cuối, +tag, dấu gạch dưới, path traversal, local part rỗng). Phần code kết dính xung quanh không có test nào, vì phần đó không có nhánh rẽ.
Quyết định 2: từ chối ở bước SMTP, không bao giờ nuốt im
Một catch-all là thỏi nam châm hút mọi lượt dò quét nhắm vào domain của bạn. Cách làm hấp dẫn là lặng lẽ bỏ đi mọi thứ không parse được. Đừng làm vậy, vì bên gửi không nhận được lỗi sẽ cho rằng thư đã tới nơi. Với một địa chỉ gõ nhầm, đó là một người đang chờ một câu trả lời không bao giờ đến.
const parsed = parseInboxAddress(message.to);
if (!parsed) {
message.setReject("Address must be {user}.{app}@example.com");
return;
}
setReject trả thư về ngay ở bước SMTP, nên server gửi sẽ tạo một thông báo lỗi đúng chuẩn. Thư quá kích thước cũng vậy, và hãy kiểm tra rawSize trước khi buffer stream, không phải sau:
if (message.rawSize > MAX_BYTES) {
message.setReject(`Message exceeds ${MAX_BYTES} bytes`);
return;
}
Thư gửi đến có thể lên tới 25 MiB. Nạp thư vào bộ nhớ của Worker rồi mới phát hiện nó quá lớn là sai thứ tự.
Quyết định 3: đừng dựng pipeline thứ hai
Email handler có thể tự parse MIME, áp hạn mức, ghi vào database và đưa lượt gửi vào hàng đợi, lặp lại mọi thứ mà đường HTTP của bạn đã làm. Đó là hai pipeline sẽ mãi trôi lệch khỏi nhau.
Đây cũng là cái bẫy mà các nền tảng tự động hóa mắc phải khi workflow engine đồng thời là cửa nhận (ingress), và đó là lý do một lần chạy thất bại ở đó chỉ để lại sự kiện trong execution record của chính lần chạy ấy: bài Webhook n8n không chạy nói chi tiết về kiểu lỗi này.
Thay vào đó, đóng gói lại thư thành chính HTTP request mà lẽ ra nó sẽ là, rồi gọi fetch handler của chính bạn:
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})`);
}
}
Mọi thứ phía sau, gồm xác thực route, giới hạn tốc độ (rate limit), hạn mức, trần payload, xếp hàng đợi, thử lại (retry) và ghi log, đều đi đúng code path mà webhook của bạn đi. Một pipeline, hai kênh. Khi đường HTTP có thêm tính năng, email cũng tự động có theo. Header x-transport: email là thứ duy nhất phân biệt hai bên, dành cho những giao diện muốn hiển thị phong bì thư thay vì payload.
Để ý bốn dòng cuối: nếu chính pipeline của bạn từ chối sự kiện (không tìm thấy App, đã đạt giới hạn hằng ngày), hãy truyền điều đó thành một lần từ chối ở bước SMTP. Thư báo trả về (bounce) là kênh báo lỗi của bạn tới bên gửi.
Những điều cần biết trước khi dựa vào nó
- Chuyển nguyên chuỗi
rawxuống phía sau và parse ở đó bằng một thư viện MIME thực thụ. Các trường tiện dụng (subject,from) dùng cho định tuyến và hiển thị; tệp đính kèm, phần HTML và các header DKIM nằm trongraw - Rule có tên vẫn giữ nguyên. Catch-all chỉ thấy thư không thuộc rule có tên nào, nên một forward
hello@sẵn có không bị ảnh hưởng - Giới hạn CPU của gói Workers Free áp dụng cho email handler như với mọi Worker. Pattern đóng gói lại giữ cho handler mỏng nhẹ, và điều đó có ích
Nếu bạn không muốn tự vận hành, đây chính là pattern mà AnyHook host sẵn: một lệnh gọi API không cần key trả về một endpoint vừa là URL webhook vừa là địa chỉ email. Cả hai cùng đổ vào một nhật ký sự kiện mà bạn đọc được qua HTTP hoặc MCP.