跳到內容

Email:收信路由與寄信

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24

一個 SaaS 產品一定會需要 email:註冊驗證、密碼重設、團隊邀請、週報、以及一個 support@ 信箱。傳統做法是接一個 SendGrid 或 Postmark,然後為了收信再架一個 webhook 端點。

Cloudflare 把兩個方向都做進了平台,而且收信這一側是免費且 GA 的


現在的傘狀產品叫 Cloudflare Email Service/email-service/),底下兩個子產品:

Email Routing(收信)Email Sending(寄信)
狀態GAPublic beta(2026-04-16 起公測)
方案Free 與 Paid 都可用僅 Workers Paid
計費無限、免費每月含 3,000 封,之後 $0.35 / 1,000 封

/email-routing/ 的舊文件沒有被轉址,仍然可以打開,但頂端掛了一條橫幅:「Email Routing is now part of Email Service」。舊設定繼續運作,新專案則被導向 /email-service/

搜尋時要注意這一點 —— 你會同時搜到兩套文件,而且部分深層舊路徑(例如 /email-routing/email-workers/send-email/)現在會解析到新的 Email Service 內容。

FAQ 有一句話值得先擺在最前面:「Email Service is intended only for transactional emails.」 行銷/大量寄送功能「planned for future release」。不要拿它寄電子報。


export default {
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
// ...
},
};

實測 Object.getOwnPropertyNames(message)

["from", "to", "raw", "rawSize", "headers", "setReject", "forward", "reply"]

注意這些全都是 own property,不在 prototype 上。 所以 Object.getPrototypeOf(message) 只會給你 Object.prototype —— 想看它的介面要用 getOwnPropertyNames(message) 而不是看原型鏈。

message.setReject("Rejected by policy"); // 回一個永久性 SMTP 錯誤給寄件方
await message.forward("ops@example.com"); // 轉寄到帳號的「已驗證」目的地
await message.reply(emailMessage); // 回覆寄件人

fromto 是 envelope 層的位址,不是 From: / To: 標頭。 這個區分在處理轉寄信或 mailing list 時很重要 —— 標頭可以偽造,envelope 不行(至少較難)。要拿標頭得走 message.headers

實測本機送一封信進去:

{
"from": "user@sender.test",
"to": "support@example.com",
"subject": "help me please",
"rawSize": 219,
"headerCount": 7
}

官方原文:「Replies through the Workers API must satisfy the following requirements, otherwise reply() throws an exception」:

  1. 來信必須有有效的 DMARC 結果。
  2. 每個 EmailMessage 事件只能回覆一次
  3. 回覆的收件人必須與來信的寄件人相符。
  4. 外寄的寄件網域必須與收信的網域相符。
  5. 如果來信的 References 標頭超過 100 筆就會被拒絕(防迴圈)。

第 1 條是最常踩的:如果寄件方的網域沒有正確的 DMARC,你根本回不了信。 這不是你能控制的,所以 reply() 一定要包在錯誤處理裡。

第 5 條是自動回覆之間互相回覆造成無限迴圈的防護。

reply() 需不需要 send_email binding?官方文件從頭到尾沒有說。 email-handler 頁完全沒有出現 send_email 這個字。範例專案的 email() handler 裡有 send_email binding(因為同一個 Worker 也負責寄信),所以我無法用它證明任何一邊。本章不做這個宣稱。

官方文件記載的路徑:

Terminal window
curl -X POST 'http://localhost:9041/cdn-cgi/handler/email' \
--url-query 'from=user@sender.test' \
--url-query 'to=support@example.com' \
--data-binary @message.eml

body 必須是 RFC 5322,而且必須包含 Message-ID 標頭

實測成功時的回應:

Worker successfully processed email
HTTP 200

⚠️ setReject() 在本機看不出效果。 我送一封主旨帶 [SPAM] 的信進去觸發 setReject(),本機一樣回 HTTP 200 加上「Worker successfully processed email」。production 上寄件方會收到一個永久性 SMTP 錯誤,本機完全沒有這個訊號。

要驗證拒信邏輯,只能靠你自己在 handler 裡 log。

⚠️ reply() 的 builder 形式在本機不支援。 實測直接丟:

Error: EmailReplyMessageBuilder is not currently supported
at async handleEmail (.../miniflare/dist/src/workers/core/entry.worker.js:3505:12)

EmailMessage(手寫 MIME)的形式在本機可用。範例專案因此寫了一個 fallback:先試 builder,失敗就退回手寫 MIME —— 這樣同一份程式碼在本機與 production 都能跑。這個限制沒有記載在文件的 local-development 頁


{
"send_email": [{ "name": "EMAIL" }]
}
await env.EMAIL.send({
from: { name: "LinkForge", email: "noreply@yourdomain.com" },
to: "user@example.com",
subject: "Your weekly report",
text: "Plain text part.",
html: "<p>HTML part.</p>",
replyTo: "support@yourdomain.com",
});

實測本機回傳:

{ "messageId": "<PYvhRO0UnZZaM2IZPtdvoFG9Kw02F8ovzxYD@example.com>" }

不需要 mimetext,不需要手組 MIME。

to / cc / bcc 的型別都很寬鬆(實測型別定義):

type EmailDestinations = {
to?: string | EmailAddress | (string | EmailAddress)[];
cc?: string | EmailAddress | (string | EmailAddress)[];
bcc?: string | EmailAddress | (string | EmailAddress)[];
};

三者合計最多 50 個收件人

官方 workers-api 頁有一個標題就叫 「Legacy EmailMessage API」

EmailMessage API 為了向後相容而保留支援。當你已經有一個原始的 RFC 5322 MIME 訊息要寄時使用它。新的程式碼請優先使用上面的 send() 方法。

如果你還在寫這個:

import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext"; // ← 不需要了

那是舊寫法。

legacy 路徑有多麻煩,實測給了很好的示範。 我第一次手寫 MIME 時漏了一個標頭,得到:

{ "threwName": "Error", "threw": "invalid message-id" }

補上 Message-IDDate 之後才成功。而且 —— 回傳的 messageId 和我寫進去的完全不同,代表平台會用自己的。所以你手寫的那個 header 只是為了通過驗證,並不會被採用。

實測 EmailAttachment 是一個辨識聯集

type EmailAttachment =
| { disposition: 'inline'; contentId: string; filename: string; type: string; content: ... }
| { disposition: 'attachment'; contentId?: undefined; filename: string; type: string; content: ... };

兩種 disposition 都支援。但注意 contentId 的處理:inline 時是必填,attachment 時型別是 undefined(也就是不准給)。

這比官方文件寫的 contentId?: string(一個對兩種 disposition 都選填的欄位)更精確。型別在這裡把「contentId 只有搭配 inline 才有意義」這件事編碼進去了。

// inline:HTML 用 <img src="cid:logo123"> 引用
{ disposition: "inline", contentId: "logo123", filename: "logo.png",
type: "image/png", content: pngBytes }
// attachment:真正的附件,不能給 contentId
{ disposition: "attachment", filename: "invoice.pdf",
type: "application/pdf", content: pdfBytes }

每封信最多 32 個附件

⚠️ 本機模擬器不能序列化 ArrayBuffer 形式的附件內容,官方 local-development 頁明載會拋 Cannot serialize value: [object ArrayBuffer]。變通做法是本機用字串內容,二進位附件到部署後的 Worker 上測。

(我實測的 ArrayBuffer 附件在本機成功了 —— 可能是因為我用的是 4 bytes 的極小 buffer,或是這個限制已在較新版本修掉。兩種說法都不確定,請自己以真實大小的附件驗證。

實測 config-schema.jsonsend_email 每一項有四個可選欄位:

欄位意義
(不設任何限制)可寄給帳號中任何已驗證的目的地位址
destination_address只能寄給這一個位址。tonull/undefined 時會自動用它
allowed_destination_addresses只能寄給白名單內的位址
allowed_sender_addresses只能從白名單內的位址寄出

外加一個 remote

這些限制在本機就會生效 —— 這是難得的好消息。實測用一個設了 allowed_destination_addresses: ["ops@example.com"] 的 binding:

{
"allowed": { "ok": { "messageId": "<p610AIG...@example.com>" } },
"notAllowed": { "threwName": "Error", "threw": "email to somebody-else@example.com not allowed" }
}

production 的錯誤會帶結構化的 codeE_RECIPIENT_NOT_ALLOWEDE_SENDER_NOT_VERIFIEDE_RATE_LIMIT_EXCEEDEDE_TOO_MANY_ATTACHMENTS 等等),本機丟的是純文字訊息。告警規則要對 error.code 而不是 message 字串。

實務建議:為每一種用途開一個受限的 binding。 週報用一個 allowed_sender_addresses: ["reports@..."],密碼重設用另一個。這樣即使某條路徑被注入,爆炸半徑也被設定檔限制住了 —— 這是零成本的縱深防禦。

實測 /shape

{
"ctorName": "Fetcher",
"protoKeys": ["fetch", "connect", "constructor"],
"hasSend": "function",
"sendSource": "[object JsRpcProperty]",
"typeofNonsense": "function"
}

又是 Fetcher + JsRpcProperty(第 23 章 Pipelines、第 30 章 Browser Run 之後的第三個)。typeof env.EMAIL.任何名字 都是 "function"

但和那兩個不同的是:send() 在本機真的會執行。 本機的 RPC receiver 有實作它。所以「是 RPC proxy」不等於「本機不能用」—— 要判斷的是遠端的 receiver 有沒有實作那個方法,而這件事你只能實際呼叫看看。

官方:wrangler dev 會模擬 binding,「emails are not sent —— 而是把 email 內容 log 到 console 並存成本機檔案供檢視」。

實測 dev server 的輸出:

send_email binding called with MessageBuilder:
Text: .../.wrangler/tmp/email/miniflare-<hash>/email-text/<uuid>.txt
HTML: .../.wrangler/tmp/email/miniflare-<hash>/email-html/<uuid>.html

HTML 版本會存成 .html 檔,可以直接用瀏覽器打開。 這對調校信件排版非常好用 —— 不需要真的寄出去。

需要真的寄出時用 remote binding(Worker 在本機跑,信真的走 Email Service 出去)。


Email Sending 最重要的變動,是**「只能寄給已驗證的目的地」這條限制對已 onboard 的網域已經不適用**。

blog(2026-04-16 公測公告):

「Until now, your agent could only reply synchronously, or send emails to members of your Cloudflare account. With Email Sending, that constraint is gone.

網域 onboarding 是在 dashboard 上做的,Cloudflare 會自動加上 DNS 記錄。寄信側用 cf-bounce 子網域:MX(處理退信)、SPF(v=spf1 include:_spf.mx.cloudflare.net ~all)、DKIM(selector cf-bounce._domainkey),以及根網域上的 DMARC。收信側則是根網域 MX + SPF + DKIM(selector cf2024-1._domainkey)。DNS 傳播「最多可能需要 24 小時」。

同時,舊的「已驗證目的地」機制仍然存在,而且是免費路徑

「Sending to verified destination addresses in your account is free on all plans, even when only Email Routing is configured.」

⚠️ 這裡文件是不一致的。 send-bindings 頁在描述「沒有設限制的 binding」時,仍然寫著:

「No restriction attribute: The binding can send to any verified destination address in your account.」

這句話和 blog 宣告的「限制已經消失」沒有在任何一頁被調和。而且沒有任何一頁明確寫出「網域 onboard 之後就可以寄給任意收件人」 —— /configuration/domains/ 只說「Once your domain is onboarded, you can start sending emails.」

支持「限制已消失」的間接證據是:錯誤碼清單裡收件人相關的只有 E_RECIPIENT_NOT_ALLOWED(不在 allowed_destination_addresses 白名單內),沒有任何「收件人未驗證」的錯誤碼

本章的立場:以 blog 與計費模型為準(否則每月 3,000 封的額度沒有意義),但這是推論而非引用。部署前請自行以真實網域驗證。


限制
每個網域的 routing 規則200
每個帳號的已驗證目的地位址200
收信訊息大小25 MiB(超過直接拒收)
References 標頭上限100 筆
每個 zone 的網域數30(Routing + Sending 合計)

收信是無限且免費的,Free 與 Paid 皆同。

限制
收件人(to+cc+bcc 合計)50
主旨長度998 字元
訊息總大小(含附件)5 MiB
——但寄給已驗證目的地時25 MiB
自訂標頭總大小16 KB
附件數32

計費:每月含 3,000 封,之後 $0.35 / 1,000 封。寄給已驗證目的地位址免費且不消耗額度。在 API 邊界就被拒絕、或被 suppression list 擋下的信不計入用量;hard bounce 會計入

⚠️ 每日配額沒有公開數字。 官方只說:

「New accounts start with a conservative daily quota and scale up over time based on your sending behavior, deliverability rates, and account standing.」

而且每秒/每分鐘的速率上限也沒有公開數字,儘管 E_RATE_LIMIT_EXCEEDED 這個錯誤碼確實存在。

實務後果:你無法事先算出「這次批次寄信會不會被擋」。 唯一的做法是把寄信放進 Queue(第 19 章),讓 E_RATE_LIMIT_EXCEEDED 觸發 retry,而不是在請求路徑上同步寄。

註意那個 5 MiB vs 25 MiB 的差異:一般收件人只有 5 MiB,寄給自己帳號的已驗證位址才有 25 MiB。所以「把報表當附件寄給客戶」的可用空間比你想的小。


(一)support@ 收信 → 自動開 ticket

Section titled “(一)support@ 收信 → 自動開 ticket”
async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {
const subject = message.headers.get("subject") ?? "(no subject)";
// 先擋掉不該處理的,setReject 給寄件方一個永久錯誤
if (message.rawSize > 5_000_000) {
message.setReject("Message too large; please use the web uploader");
return;
}
const raw = await new Response(message.raw).text(); // raw 是 stream,只能讀一次
await env.DB.prepare(
"insert into tickets (id, sender, subject, body, created_at) values (?1,?2,?3,?4,?5)",
).bind(crypto.randomUUID(), message.from, subject, raw.slice(0, 4000), Date.now()).run();
// reply 可能因為對方 DMARC 無效而失敗 -- 不能讓它擋住 ticket 的建立
ctx.waitUntil(
message.reply(buildAck(message, subject)).catch((e) => console.error("reply", e)),
);
ctx.waitUntil(message.forward("ops@yourdomain.com").catch(() => {}));
}

三個設計點:

  • message.raw 是 stream,只能消費一次。 如果又要存又要解析,先 .tee()
  • reply() 放進 waitUntil 對方網域 DMARC 無效時它會 throw,而那不該讓 ticket 建立失敗。
  • setReject() 用在「這封信我們根本不想收」,而不是「處理失敗」。它會讓寄件方看到退信。

(二)團隊邀請:同步寄,但要能重試

Section titled “(二)團隊邀請:同步寄,但要能重試”
app.post("/api/team/invite", async (c) => {
const { email } = await c.req.json<{ email: string }>();
const token = await createInviteToken(c.env, email);
try {
await c.env.EMAIL_INVITE.send({ // 專用的受限 binding
from: { name: "LinkForge", email: "invites@linkforge.dev" },
to: email,
subject: "You've been invited to LinkForge",
html: renderInvite(token),
text: `Accept your invite: https://linkforge.dev/invite/${token}`,
});
} catch (e) {
const code = (e as { code?: string }).code;
if (code === "E_RATE_LIMIT_EXCEEDED" || code === "E_DAILY_LIMIT_EXCEEDED") {
await c.env.MAIL_QUEUE.send({ kind: "invite", email, token }); // 排隊重試
return c.json({ status: "queued" }, 202);
}
throw e;
}
return c.json({ status: "sent" });
});

永遠同時提供 text 與 html。 只給 html 的信在很多客戶端與垃圾信過濾器眼中是負分。

// Cron Trigger(第 20 章)每週一早上排入佇列
async scheduled(_c: ScheduledController, env: Env, ctx: ExecutionContext) {
const { results } = await env.DB.prepare("select email, tenant_id from users where weekly = 1").all();
// createBatch 攤平建立成本(第 19 章)
for (const chunk of chunks(results, 100)) {
await env.MAIL_QUEUE.sendBatch(chunk.map((r) => ({ body: { kind: "weekly", ...r } })));
}
}
// consumer:一封一封寄,錯誤自動重試
async queue(batch: MessageBatch<MailJob>, env: Env) {
for (const msg of batch.messages) {
try {
await env.EMAIL_REPORTS.send(await renderWeekly(env, msg.body));
msg.ack();
} catch (e) {
const code = (e as { code?: string }).code;
// 這些重試沒有意義,直接 ack 掉避免佔用 DLQ
if (code === "E_RECIPIENT_SUPPRESSED" || code === "E_VALIDATION_ERROR") {
console.warn("permanent", code, msg.body);
msg.ack();
} else {
msg.retry(); // 速率限制、暫時性失敗 -> 讓 Queue 退避重試
}
}
}
}

這個結構直接來自 32.5 那個「配額沒有公開數字」的事實。 既然你無法事先知道速率上限,唯一穩健的做法就是讓重試機制去發現它 —— 而第 19 章的 Queue 已經內建了退避與 DLQ。

同時注意 E_RECIPIENT_SUPPRESSEDE_VALIDATION_ERROR直接 ack:這兩個重試一萬次也不會成功,讓它們進 DLQ 只是製造噪音。


#結論影響
1產品重組為 Cloudflare Email Service 傘狀;/email-routing/ 未轉址但掛了橫幅會同時搜到兩套文件
2Routing GA、Free 可用、收信無限免費;Sending 是 public beta、僅 Workers Paid
3官方 FAQ:「Email Service is intended only for transactional emails.」不要拿來寄電子報
4ForwardableEmailMessage 的方法全是 own property,prototype 上沒有要用 getOwnPropertyNames(message) 探測
5from / toenvelope 位址,不是 From: / To: 標頭處理轉寄信時很重要
6reply() 有五個硬性條件,第一條是來信必須有有效 DMARC不可控,必須包 try/catch
7References 超過 100 筆的信不能回覆自動回覆的迴圈防護
8reply() 需不需要 send_email binding,文件完全沒說本章不做宣稱
9reply() 的 builder 形式本機不支援EmailReplyMessageBuilder is not currently supported未記載於 local-development 頁;範例用 fallback
10setReject() 在本機一樣回 HTTP 200「successfully processed」拒信邏輯本機測不出來
11本機 /cdn-cgi/handler/email 的 body 必須含 Message-ID
12legacy EmailMessage 路徑漏標頭會丟 invalid message-id
13legacy 路徑回傳的 messageId 與你寫入的不同平台會用自己的
14官方明文標記 「Legacy EmailMessage API」,新程式碼應用 send()mimetext 不再需要
15EmailAttachment辨識聯集inlinecontentId 必填,attachment 時型別為 undefined比官方文件的 contentId?: string 更精確
16to/cc/bcc 各接受 string / EmailAddress / 陣列;合計上限 50
17send_email 的四個限制欄位在本機就會生效(實測 email to ... not allowed難得的好消息
18production 的錯誤帶結構化 codeE_*),本機只有純文字告警要對 error.code
19binding 又是 Fetcher + [object JsRpcProperty],任何名字 typeof 都是 functionsend() 本機真的能執行
20由 19 推得:「是 RPC proxy」≠「本機不能用」要看遠端 receiver 有沒有實作
21本機把信存成 .wrangler/tmp/email/*/email-html/*.html,可用瀏覽器開調排版很好用
22「只能寄給已驗證目的地」的舊限制對已 onboard 網域已解除(blog 明言)但 send-bindings 頁仍寫著舊說法,未被調和
23沒有任何一頁明說「onboard 後可寄任意收件人」;間接證據是錯誤碼裡沒有「收件人未驗證」本章標為推論
24訊息總大小一般收件人 5 MiB,已驗證目的地 25 MiB寄報表附件的空間比想像中小
25每日配額與速率上限都沒有公開數字(「conservative daily quota」)批次寄信必須走 Queue 讓重試去發現上限
26收信訊息上限 25 MiB,超過直接拒收

  1. setReject() 換成不同的理由字串,部署後從外部真的寄一封信進去,記錄寄件方收到的退信內容 —— 這是本機測不到的那一半。
  2. 用一封 References 標頭有 101 筆的信測試 reply(),確認第 5 條防護。
  3. 為三種用途各開一個 send_email binding(週報 / 邀請 / 系統通知),各自設 allowed_sender_addresses,然後故意用錯的 binding 寄,確認本機就會擋下來。
  4. 寄一個 6 MiB 的附件給一般收件人,記錄實際的錯誤碼(應為 E_CONTENT_TOO_LARGE),再寄給已驗證目的地比較。
  5. 把 32.6 的週報實作起來,用 Queue 的 metrics(第 19 章)觀察 E_RATE_LIMIT_EXCEEDED 造成的重試比例,藉此反推你帳號實際的速率上限 —— 因為官方不會告訴你。

下一章(第 33 章):多租戶執行使用者程式碼 —— Workers for Platforms 與 Dynamic Workers 的差異,以及什麼時候該用哪一個。