跳到內容

ch32-email

取得並執行

這個範例可以獨立 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

說明

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.

Terminal window
npm install
npx wrangler types --env-interface CloudflareBindings
npx wrangler dev --port 9041
RouteWhat it shows
/shapeThe 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-manyto/cc/bcc each accept string, EmailAddress, or arrays.
/send-attachmentInline attachment with contentId.
/send-lockedA binding with allowed_destination_addresses, allowed and denied.
/send-legacyHand-assembled RFC 5322 via cloudflare:email.
Terminal window
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>.txt
HTML: .wrangler/tmp/email/miniflare-<hash>/email-html/<uuid>.html

Open the .html in a browser — that is the fastest way to iterate on email layout without sending anything.

Terminal window
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”
Terminal window
curl -s localhost:9041/send-legacy | jq

The 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.

Terminal window
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 email

The 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.

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.

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).

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.ts

tsconfig.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"]
}