ch32-email
在 GitHub 上檢視·5 個檔案·8.9 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch32-email
cd ch32-email
npm install可用指令
npm run dev # wrangler dev
npm run types # wrangler types --env-interface CloudflareBindings說明
ch32 — Email
Section titled “ch32 — Email”Probe project for chapter 32. Both directions — receiving via the email()
handler and sending via the send_email binding — run in wrangler dev.
Verified with wrangler 4.118.0.
npm installnpx wrangler types --env-interface CloudflareBindingsnpx wrangler dev --port 9041Sending
Section titled “Sending”| Route | What it shows |
|---|---|
/shape | The binding is a Fetcher with send as a JsRpcProperty — but unlike ch23/ch30, it actually works locally. |
/send?to=&from= | The modern builder API. |
/send-many | to/cc/bcc each accept string, EmailAddress, or arrays. |
/send-attachment | Inline attachment with contentId. |
/send-locked | A binding with allowed_destination_addresses, allowed and denied. |
/send-legacy | Hand-assembled RFC 5322 via cloudflare:email. |
curl -s localhost:9041/send | jq# { "ok": { "messageId": "<PYvhRO0UnZZaM2IZPtdvoFG9Kw02F8ovzxYD@example.com>" }, "ms": 11 }The dev server prints where the message landed:
send_email binding called with MessageBuilder:Text: .wrangler/tmp/email/miniflare-<hash>/email-text/<uuid>.txtHTML: .wrangler/tmp/email/miniflare-<hash>/email-html/<uuid>.htmlOpen the .html in a browser — that is the fastest way to iterate on email
layout without sending anything.
Binding restrictions are enforced locally
Section titled “Binding restrictions are enforced locally”curl -s localhost:9041/send-locked | jq{ "allowed": { "ok": { "messageId": "<p610AIG...@example.com>" } }, "notAllowed": { "threwName": "Error", "threw": "email to somebody-else@example.com not allowed" }}A rare case where local dev enforces a production constraint. Note production
errors carry a structured code (E_RECIPIENT_NOT_ALLOWED,
E_SENDER_NOT_VERIFIED, E_RATE_LIMIT_EXCEEDED, …) while local throws plain
text — branch on error.code, not on the message.
Open one restricted binding per purpose (reports, invites, system notices). It costs nothing and bounds the blast radius of any injection.
The legacy path shows why the builder exists
Section titled “The legacy path shows why the builder exists”curl -s localhost:9041/send-legacy | jqThe first version of this route omitted Message-ID and got
Error: invalid message-id. Adding Message-ID and Date fixed it — and the
returned messageId is different from the one supplied, so the platform mints
its own anyway. Cloudflare now labels this the “Legacy EmailMessage API” and
says new code should use send(). No mimetext required.
Attachments: the type is stricter than the docs
Section titled “Attachments: the type is stricter than the docs”type EmailAttachment = | { disposition: 'inline'; contentId: string; filename; type; content } | { disposition: 'attachment'; contentId?: undefined; filename; type; content };A discriminated union: contentId is required for inline and forbidden
for attachment. The docs show a flat optional contentId?: string, so the
shipped type encodes the constraint better than the prose does.
Max 32 attachments. Total message size 5 MiB — but 25 MiB when sending to a verified destination address in your own account.
Receiving
Section titled “Receiving”printf 'Message-ID: <probe@example.com>\r\nDate: Fri, 01 Aug 2026 08:00:00 +0000\r\nFrom: user@sender.test\r\nTo: support@example.com\r\nSubject: help me please\r\nMIME-Version: 1.0\r\nContent-Type: text/plain\r\n\r\nMy short link is broken.\r\n' > msg.eml
curl -X POST 'http://localhost:9041/cdn-cgi/handler/email' \ --url-query 'from=user@sender.test' \ --url-query 'to=support@example.com' \ --data-binary @msg.eml# Worker successfully processed emailThe body must be RFC 5322 and must include Message-ID.
Object.getOwnPropertyNames(message) gives the whole interface — these are own
properties, not prototype methods, so walking the prototype chain tells you
nothing:
["from", "to", "raw", "rawSize", "headers", "setReject", "forward", "reply"]from and to are envelope addresses, not the From:/To: headers.
Two local limitations, neither documented
Section titled “Two local limitations, neither documented”reply()’s builder overload does not work locally:
Error: EmailReplyMessageBuilder is not currently supported at async handleEmail (.../miniflare/dist/src/workers/core/entry.worker.js:3505:12)The EmailMessage (hand-rolled MIME) overload does. This example tries the
builder and falls back, so the same code runs locally and in production.
setReject() is invisible locally. Sending a [SPAM]-subject message that
triggers setReject() still returns HTTP 200 and “Worker successfully
processed email”. In production the sender gets a permanent SMTP error. Log it
yourself if you want to verify the branch ran.
reply() has five hard requirements
Section titled “reply() has five hard requirements”The first one is outside your control: the incoming email must have a valid
DMARC result, or reply() throws. Always wrap it, and never let it block the
work that matters (this example does the DB insert first and replies in
waitUntil).
Quotas you cannot look up
Section titled “Quotas you cannot look up”Cloudflare publishes no numeric daily quota and no rate-limit figure — only
“New accounts start with a conservative daily quota and scale up over time”.
E_RATE_LIMIT_EXCEEDED exists but its threshold does not.
The consequence for design: you cannot pre-compute whether a batch will be
throttled. Put bulk sending behind a Queue (ch19) and let retries discover the
ceiling. Ack E_RECIPIENT_SUPPRESSED and E_VALIDATION_ERROR immediately —
those never succeed on retry and only pollute the DLQ.
原始碼
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch32-email",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
// The modern sending binding. `name` is the binding name on env.
"send_email": [
{ "name": "EMAIL" },
// Optionally constrain who this binding may send to / from.
{ "name": "EMAIL_LOCKED", "allowed_destination_addresses": ["ops@example.com"] }
],
"d1_databases": [{ "binding": "DB", "database_name": "ch32", "database_id": "ch32-local" }]
}package.json
{
"name": "ch32-email",
"private": true,
"type": "module",
"scripts": { "dev": "wrangler dev", "types": "wrangler types --env-interface CloudflareBindings" },
"devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2" }
}src/index.ts
import { EmailMessage } from "cloudflare:email";
const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
const started = Date.now();
try {
const v = await fn();
return { ok: v === undefined ? "(undefined)" : v, ms: Date.now() - started };
} catch (e) {
return {
threwName: (e as Error).name,
threw: String((e as Error).message ?? e).slice(0, 300),
ms: Date.now() - started,
};
}
};
export default {
// ------------------------------------------------------------ RECEIVING
// Test locally by POSTing a raw RFC 5322 message to
// /cdn-cgi/handler/email?from=...&to=...
async email(message: ForwardableEmailMessage, env: CloudflareBindings, ctx: ExecutionContext) {
const subject = message.headers.get("subject") ?? "(no subject)";
console.log(
JSON.stringify({
from: message.from, // envelope from -- NOT the From: header
to: message.to, // envelope to
subject,
rawSize: message.rawSize,
headerCount: [...message.headers].length,
// Object.getOwnPropertyNames on the prototype tells you what you can do.
// The methods are OWN properties, not prototype methods.
ownApi: Object.getOwnPropertyNames(message),
}),
);
// Spam gate first: setReject returns a permanent SMTP error to the sender.
if (/^\s*\[?spam\]?/i.test(subject)) {
message.setReject("Rejected by ch32 policy");
return;
}
// Read the body. `raw` is a stream and can only be consumed once.
const raw = await new Response(message.raw).text();
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()
.catch((e: unknown) => console.error("db", String(e)));
// reply() does NOT need a send_email binding -- but the domain needs a
// valid DMARC record in production.
//
// NOTE: the builder overload is NOT supported in local dev. wrangler dev
// throws `EmailReplyMessageBuilder is not currently supported`. The
// EmailMessage overload works in both, at the cost of hand-rolled MIME.
const replied = await t(() =>
message.reply({
from: message.to,
subject: `Re: ${subject}`,
text: "Thanks -- we opened a ticket and will get back to you.",
// Threading headers (In-Reply-To/References) are set automatically.
}),
);
if (replied.threw) {
const raw = [
`Message-ID: <${crypto.randomUUID()}@example.com>`,
`In-Reply-To: ${message.headers.get("message-id") ?? ""}`,
`Date: ${new Date().toUTCString()}`,
`From: ${message.to}`,
`To: ${message.from}`,
`Subject: Re: ${subject}`,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"Thanks -- we opened a ticket and will get back to you.",
].join("\r\n");
await message.reply(new EmailMessage(message.to, message.from, raw));
}
console.log(JSON.stringify({ replyBuilder: replied }));
// forward() targets a VERIFIED destination address on the account.
ctx.waitUntil(
message.forward("ops@example.com").catch((e: unknown) => console.error("forward", String(e))),
);
},
// -------------------------------------------------------------- SENDING
async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
const url = new URL(request.url);
const q = url.searchParams;
switch (url.pathname) {
case "/shape": {
const b = env.EMAIL as unknown as object;
return Response.json({
ctorName: Object.getPrototypeOf(b)?.constructor?.name ?? null,
protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(b)),
hasSend: typeof (b as { send?: unknown }).send,
sendSource: String((b as { send?: unknown }).send).slice(0, 40),
// If this is an RPC proxy, ANY name is a function (chapters 23, 30).
typeofNonsense: typeof (b as unknown as Record<string, unknown>).notARealMethod,
});
}
// The modern builder API. No mimetext, no manual MIME assembly.
case "/send": {
return Response.json(
await t(() =>
env.EMAIL.send({
from: { name: "LinkForge", email: q.get("from") ?? "noreply@example.com" },
to: q.get("to") ?? "someone@example.com",
subject: "Your weekly LinkForge report",
text: "Plain text part.",
html: "<p>HTML part.</p>",
replyTo: "support@example.com",
}),
),
);
}
// to/cc/bcc each accept a string, an EmailAddress, or an array of either.
case "/send-many": {
return Response.json(
await t(() =>
env.EMAIL.send({
from: "noreply@example.com",
to: ["a@example.com", { name: "B", email: "b@example.com" }],
cc: "cc@example.com",
bcc: ["bcc@example.com"],
subject: "Multi-recipient",
text: "hi",
}),
),
);
}
// Attachments: the type only allows disposition "inline".
case "/send-attachment": {
return Response.json(
await t(() =>
env.EMAIL.send({
from: "noreply@example.com",
to: "someone@example.com",
subject: "With an inline image",
html: '<p>Logo: <img src="cid:logo123"></p>',
attachments: [
{
disposition: "inline",
contentId: "logo123",
filename: "logo.png",
type: "image/png",
content: new Uint8Array([137, 80, 78, 71]).buffer as ArrayBuffer,
},
],
}),
),
);
}
// The binding restricted with allowed_destination_addresses.
case "/send-locked": {
return Response.json({
allowed: await t(() =>
env.EMAIL_LOCKED.send({
from: "noreply@example.com",
to: "ops@example.com",
subject: "allowed destination",
text: "hi",
}),
),
notAllowed: await t(() =>
env.EMAIL_LOCKED.send({
from: "noreply@example.com",
to: "somebody-else@example.com",
subject: "disallowed destination",
text: "hi",
}),
),
});
}
// The legacy path: hand-assembled MIME via cloudflare:email.
case "/send-legacy": {
return Response.json(
await t(() => {
// Every header below is mandatory. Omitting Message-ID alone
// yields `Error: invalid message-id` -- which is exactly the kind
// of MIME bookkeeping the builder API removes.
const raw = [
`Message-ID: <${crypto.randomUUID()}@example.com>`,
`Date: ${new Date().toUTCString()}`,
"From: noreply@example.com",
"To: someone@example.com",
"Subject: legacy path",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"Assembled by hand.",
].join("\r\n");
return env.EMAIL.send(
new EmailMessage("noreply@example.com", "someone@example.com", raw),
);
}),
);
}
default:
return new Response(
"/shape /send?to=&from= /send-many /send-attachment /send-locked /send-legacy\n" +
"receiving: POST a raw RFC 5322 message to /cdn-cgi/handler/email?from=&to=\n",
{ status: 404 },
);
}
},
} satisfies ExportedHandler<CloudflareBindings>;.gitignore
node_modules/
.wrangler/
worker-configuration.d.tstsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}