Skip to content

feat(cloudflare/workers): add Email event source for inbound mail#401

Open
sam-goodwin wants to merge 1 commit into
mainfrom
claude/fervent-mcnulty-aa0fc1
Open

feat(cloudflare/workers): add Email event source for inbound mail#401
sam-goodwin wants to merge 1 commit into
mainfrom
claude/fervent-mcnulty-aa0fc1

Conversation

@sam-goodwin

@sam-goodwin sam-goodwin commented May 21, 2026

Copy link
Copy Markdown
Contributor

Adds Cloudflare.email({ zone }).subscribe(handler) — one call wires both halves of an Email Worker consumer:

  • Runtime: registers the email event listener on the Worker.
  • Deploy-time: the EmailEventSourcePolicy yields a sibling EmailRouting toggle on the zone and an EmailRule whose actions: [{ type: "worker", … }] targets this Worker — mirroring the QueueEventSource policy pattern.
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";

export default Cloudflare.Worker(
  "Inbox",
  { main: import.meta.path },
  Effect.gen(function* () {
    yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) =>
      message.forward("ops@example.com"),
    );
    return {};
  }).pipe(Effect.provide(Cloudflare.EmailEventSourceLive)),
);

alchemy.run.ts no longer needs hand-rolled EmailRouting + EmailRule calls.

Filtering and dispatching in the handler

matchers scopes which envelopes Cloudflare delivers to this Worker (the auto-created EmailRule); inside the handler you branch on the message and use the Effect-wrapped actions. A real-world inbox might look like this:

Effect.gen(function* () {
  const tickets = yield* Cloudflare.R2Bucket.bind(Tickets);
  const ops = yield* Cloudflare.EmailAddress.bind(OpsEmail);

  yield* Cloudflare.email({
    zone: "example.com",
    // Only route mail addressed to the inboxes we care about.
    matchers: [
      { type: "literal", field: "to", value: "support@example.com" },
      { type: "literal", field: "to", value: "billing@example.com" },
    ],
    priority: 10,
  }).subscribe((message) => {
    // Reject anything Cloudflare's spam pipeline flagged.
    if (message.headers.get("x-spam-score") === "high") {
      return message.setReject("Rejected: spam");
    }

    // support@ — archive the raw RFC 5322 bytes in R2 and ack.
    if (message.to === "support@example.com") {
      return tickets.put(`/inbox/${crypto.randomUUID()}`, message.body, {
        httpMetadata: { contentType: "message/rfc822" },
      });
    }

    // billing@ — forward, fall back to a bounce if the destination
    // is unverified or Cloudflare otherwise rejects the forward.
    return message.forward(ops.email).pipe(
      Effect.catchTag("EmailError", () =>
        message.setReject("Billing inbox unavailable"),
      ),
    );
  });
}).pipe(
  Effect.provide(Cloudflare.EmailEventSourceLive),
  Effect.provide(Cloudflare.R2BucketBindingLive),
);

The catch-all ({ type: "all" }) is the default if you omit matchers. To run with no EmailRule at all (manage routing yourself), call email() with no zone — the policy becomes a no-op.

Message wrapper

Matches the R2 / KV binding convention — message.raw is the underlying cf.ForwardableEmailMessage (escape hatch); ergonomic fields and Effect-wrapped actions sit on top:

interface ForwardableEmailMessage {
  readonly raw: cf.ForwardableEmailMessage;
  readonly from: string;
  readonly to: string;
  readonly headers: cf.Headers;
  readonly body: cf.ReadableStream<Uint8Array>;
  readonly bodySize: number;
  setReject(reason: string): Effect.Effect<void>;
  forward(rcptTo: string, headers?: cf.Headers): Effect.Effect<void, EmailError>;
  reply(message: cf.EmailMessage): Effect.Effect<void, EmailError>;
}

The cf names raw / rawSize (the message body and its size) are exposed as body / bodySize to avoid clashing with the raw escape hatch.

Notes

  • email is already in ExportedHandlerMethods on Worker.ts, so the runtime dispatch table wires this up automatically.
  • EmailEventSourcePolicy registered in Cloudflare.providers() alongside QueueEventSourcePolicy / CronEventSourcePolicy.
  • One subscribe per Worker (the runtime fans every email event out to every registered listener, so per-rule dispatch is handled inside the single handler via message.to / headers — exactly like the example above).
  • Tutorial added at website/src/content/docs/tutorial/cloudflare/email-worker.mdx.
  • JSDoc annotated with @binding / @section / @example so bun generate:api-reference emits providers/Cloudflare/EmailEventSource.md.

@alchemy-version-bot

alchemy-version-bot Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Install the packages built from this commit:

alchemy

bun add alchemy@https://pkg.ing/alchemy/c3ac23a

@alchemy.run/better-auth

bun add @alchemy.run/better-auth@https://pkg.ing/@alchemy.run/better-auth/c3ac23a

@alchemy.run/pr-package

bun add @alchemy.run/pr-package@https://pkg.ing/@alchemy.run/pr-package/c3ac23a

@sam-goodwin

Copy link
Copy Markdown
Contributor Author

@Mkassabov i don't have an account with email testing capability. Can you set up our test account and drive this home?

@Mkassabov Mkassabov force-pushed the claude/fervent-mcnulty-aa0fc1 branch from 972af75 to 7bd1edb Compare June 2, 2026 18:05
Port of the original Binding.Policy implementation to the post-#690
event-source convention (Layer resolves the Worker host directly;
sibling Email.Routing + Email.Rule are yielded under Namespace.push
guarded by __ALCHEMY_RUNTIME__, mirroring Queues.EventSourceLive).

- Cloudflare.email({ zone, matchers }).subscribe(handler) wires the
  runtime email listener and auto-creates Email.Routing (retained on
  destroy) + Email.Rule targeting the host Worker
- Effect-native ForwardableEmailMessage wrapper (forward/reply/
  setReject as Effects, raw escape hatch)
- e2e test + fixture gated on CLOUDFLARE_TEST_ZONE /
  CLOUDFLARE_TEST_EMAIL_INBOX / CLOUDFLARE_TEST_EMAIL_FROM
- doc relocated to cloudflare/email/email-worker.mdx (tutorial/ dir
  no longer exists)
- example updated to the Email.Send / Email.SendBinding API

Co-authored-by: Michael (Pear) <michael@alchemy.run>
@Mkassabov Mkassabov force-pushed the claude/fervent-mcnulty-aa0fc1 branch from fe991d9 to c3ac23a Compare July 8, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants