Skip to content

Commit 7ad7139

Browse files
connyayguybedford
andauthored
feat(worker): add send_email binding support (cloudflare#975)
* feat(worker): add send_email binding support Adds a `SendEmail` binding and `EmailMessage` type so workers can dispatch email via the Cloudflare Email Sending service configured under `[[send_email]]` in wrangler.toml. Includes worker-sys bindings for `cloudflare:email`, a runnable example under `examples/send-email`, and integration tests. * feat(worker): add structured Email builder for send_email binding Extend the SendEmail binding to cover the public-beta builder overload in addition to the raw MIME path. Adds `Email`/`EmailBuilder`, `EmailAddress`, `EmailAttachment` (with `AttachmentContent::{Base64, Binary}`), and `EmailSendResult { message_id }`. `SendEmail::send` now takes `&Email`; the raw MIME path moves to `SendEmail::send_mime(&EmailMessage)`. * address pr comments * remove miniflare workaround cloudflare/workers-sdk#13577 landed * cleanup comments & example readme * use ts-gen for email * Auto-gen email bindings via ts-gen anonymous interface synthesis Drops the hand-written Email/EmailBuilder/EmailAddress/EmailAttachment types in worker/src/send_email.rs in favour of the auto-generated SendEmailBuilder, EmailAddress, EmailAttachment, etc. that ts-gen now synthesises from the d.ts. send_email.rs is reduced to the EnvBinding trait impl on the auto-gen SendEmail extern type and re-exports. types/email.d.ts renames the global EmailMessage interface to StructuredEmailMessage to keep it unambiguously distinct from the cloudflare:email-imported EmailMessage constructor class. The chompfile prepends a `use email::EmailMessage` to the generated file so the top-level send(message) signature resolves cross-module — removable once ts-gen handles same-file module imports natively. All 133 npm tests pass; both legacy raw-MIME and modern structured send paths work end-to-end. * add new_with_readable_stream test * Pull cross-module qualification + new builder ergonomics from ts-gen ts-gen learned three things since the last sync that simplify the email surface here: * Cross-module type references emit qualified Rust paths (`&email::EmailMessage` from a `Global` extern block referencing the `cloudflare:email` class). Drops the `chompfile.toml` postprocess that was prepending `use email::EmailMessage;` to the generated file. * Built-in `web_sys` defaults — `Headers`, `Event`, `ReadableStream`, etc. resolve to `::web_sys::*` automatically, so those `--external` flags are redundant. Only the project-specific `Env` and `ExecutionContext` mappings remain in the chompfile. * New dictionary builder shape: required fields go through the constructor, `build()` is infallible, literal discriminators collapse into the function name. Call sites update from `SendEmailBuilder::builder().from(x).build()?` to `SendEmailBuilder::builder(from, to, subject).build()` (or `::new(from, to, subject)` when no optionals are needed). `types/email.d.ts` collapses to a single `class EmailMessage` inside `declare module "cloudflare:email"`. The previous global-interface + module-class split (mirroring upstream `@cloudflare/workers-types`) was producing two distinct Rust types that both lowered to the same JS object, which forced an `unchecked_ref` at the `reply()` call site. Collapsed to one type they're indistinguishable in Rust. `worker/src/send_email.rs` keeps the [`EnvBinding`] impl on top of the auto-gen `SendEmail` extern type, plus a `#[cfg(test)]` compile check that `SendEmail: Send` (which it is already, via the upstream `JsValue: Send + Sync` change — no `unsafe impl Send` needed). * Use FixedLengthStream Deref instead of unchecked_into in mime-stream test `FixedLengthStream` already has `extends = web_sys::TransformStream` in `worker-sys`, so wasm-bindgen auto-generates `Deref<Target = TransformStream>` and `fixed.readable()` resolves through it. The previous `fixed.unchecked_into::<web_sys::TransformStream>().readable()` was unnecessarily defensive — drop the cast plus the now-unused `JsCast` and `web_sys` imports. * Fmt + advance ts-gen submodule to PR branch HEAD CI's rustfmt --check flagged the dispatch_structured signature. Apply fmt and bump the ts-gen submodule pointer to the latest PR cloudflare#8 commit (CONVENTIONS.md rationale + emit cleanup). * Pull doc-comment generation from ts-gen PR cloudflare#9 Each `new*` and `builder*` variant now ships with a doc block listing its inlined literal discriminants under `# Inlined fields` and the caller-supplied parameters under `# Parameters`, sourced from the original getter JSDoc. * Advance ts-gen submodule to merged main ts-gen PR cloudflare#9 (doc comments on dictionary builder variants) merged. Bump the submodule pointer to the merge commit on main; the generated `worker/src/email.rs` is unchanged from the PR-branch output. * Advance ts-gen submodule to merged main ts-gen PR cloudflare#10 (h2 headings + dash-separated bullets in builder docs) merged. Bump the submodule pointer and regenerate `worker/src/email.rs` with the updated doc format. --------- Co-authored-by: Guy Bedford <gbedford@cloudflare.com>
1 parent fd18681 commit 7ad7139

24 files changed

Lines changed: 1362 additions & 31 deletions

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
[submodule "wasm-streams"]
55
path = wasm-streams
66
url = git@github.com:guybedford/wasm-streams.git
7+
[submodule "ts-gen"]
8+
path = ts-gen
9+
url = git@github.com:wasm-bindgen/ts-gen

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

chompfile.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
version = 0.1
22

3+
[[task]]
4+
name = 'build:types'
5+
deps = ['install:ts-gen']
6+
# `Env` / `ExecutionContext` are project-specific re-exports that ts-gen
7+
# can't infer; everything else (`ReadableStream`, `Headers`, `Event`, …)
8+
# resolves through ts-gen's built-in web_sys defaults.
9+
run = '''ts-gen --input types/email.d.ts --output worker/src/email.rs \
10+
--external "Env=crate::Env" \
11+
--external "ExecutionContext=crate::Context"'''
12+
13+
[[task]]
14+
name = 'install:ts-gen'
15+
# ts-gen pulls in oxc which needs a newer rustc than the workspace's pinned 1.88;
16+
# build with the user's stable toolchain instead so it ignores rust-toolchain.toml.
17+
run = 'cargo +stable install --path ts-gen'
18+
319
[[task]]
420
name = 'build:wasm-bindgen'
521
cwd = 'wasm-bindgen'

examples/send-email/Cargo.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "send-email-on-workers"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[package.metadata.release]
7+
release = false
8+
9+
[lib]
10+
crate-type = ["cdylib"]
11+
12+
[dependencies]
13+
# Default feature `gethostname` pulls in a crate that doesn't build for
14+
# `wasm32-unknown-unknown`, so disable it. The remaining core API is enough
15+
# to assemble a message as long as we set `date` and `message_id` ourselves
16+
# (the auto-generated ones rely on `SystemTime::now()` / `gethostname`).
17+
mail-builder = { version = "0.4", default-features = false }
18+
worker.workspace = true

examples/send-email/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Sending email from Cloudflare Workers
2+
3+
Example of using `worker::SendEmail` to send a message through a `[[send_email]]` binding.
4+
5+
Two routes:
6+
7+
* `GET /` — the structured path. Set fields like `from`, `to`, `subject`, and `text`/`html` on [`Message::builder`](https://docs.rs/worker/latest/worker/struct.MessageBuilder.html), and the runtime assembles the MIME body for you.
8+
* `GET /raw` — the raw MIME path. Build the body yourself with [`mail-builder`](https://crates.io/crates/mail-builder) and hand it to [`EmailMessage`](https://docs.rs/worker/latest/worker/struct.EmailMessage.html) as-is. Reach for this when you need control over the MIME — custom headers, DKIM passthrough, VERP bounces, that sort of thing.
9+
10+
## Local development
11+
12+
`wrangler dev --local` won't actually send anything. As the [Cloudflare docs](https://developers.cloudflare.com/email-routing/email-workers/local-development/) explain, outbound messages get written to a local `.eml` file. Wrangler prints the path so you can open it and check the raw message.
13+
14+
```bash
15+
npm install
16+
npm run dev
17+
# then, in another shell:
18+
curl http://localhost:8787/ # structured
19+
curl http://localhost:8787/raw # raw MIME
20+
```
21+
22+
## Deploying
23+
24+
Verify the sender and recipient addresses first (see the [Cloudflare email API docs](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/)), then:
25+
26+
```bash
27+
npm run deploy
28+
```

examples/send-email/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "send-email-on-workers",
3+
"version": "0.0.0",
4+
"private": true,
5+
"scripts": {
6+
"deploy": "cargo install worker-build ; wrangler deploy",
7+
"dev": "cargo install worker-build ; wrangler dev --local"
8+
},
9+
"devDependencies": {
10+
"wrangler": "^4.83.0"
11+
}
12+
}

examples/send-email/src/lib.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
use mail_builder::MessageBuilder as MimeBuilder;
2+
use worker::*;
3+
4+
const SENDER: &str = "sender@example.com";
5+
const RECIPIENT: &str = "recipient@example.com";
6+
7+
#[event(fetch)]
8+
async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
9+
let sender = env.send_email("EMAIL")?;
10+
11+
let result = match req.path().as_str() {
12+
"/" => send_structured(&sender).await?,
13+
"/raw" => send_raw_mime(&sender).await?,
14+
// Don't dispatch on favicon / unknown paths — otherwise every browser
15+
// tab to localhost sends a real email in `wrangler dev`.
16+
_ => return Response::error("not found", 404),
17+
};
18+
19+
Response::ok(format!("sent: {}", result.message_id()))
20+
}
21+
22+
async fn send_structured(sender: &SendEmail) -> Result<EmailSendResult> {
23+
let from = EmailAddress::new("Sending email test", SENDER);
24+
let builder = SendEmailBuilder::builder_with_email_address_and_str(
25+
&from,
26+
RECIPIENT,
27+
"An email generated in a Worker",
28+
)
29+
.text("Congratulations, you just sent an email from a Worker.")
30+
.html("<p>Congratulations, you just sent an email from a Worker.</p>")
31+
.build();
32+
33+
Ok(sender.send_with_builder(&builder).await?)
34+
}
35+
36+
async fn send_raw_mime(sender: &SendEmail) -> Result<EmailSendResult> {
37+
// mail-builder's auto-generated `Date:` and `Message-ID:` headers rely on
38+
// `SystemTime::now()` and `gethostname`, neither of which work on
39+
// `wasm32-unknown-unknown`. https://github.com/stalwartlabs/mail-builder/pull/26
40+
let now_ms = Date::now().as_millis();
41+
let message_id = format!("{now_ms}@example.com");
42+
43+
let raw = MimeBuilder::new()
44+
.from(("Sending email test", SENDER))
45+
.to(RECIPIENT)
46+
.subject("An email generated in a Worker")
47+
.date((now_ms / 1000) as i64)
48+
.message_id(message_id)
49+
.text_body("Congratulations, you just sent an email from a Worker.")
50+
.write_to_string()
51+
.map_err(|e| Error::RustError(e.to_string()))?;
52+
53+
let message = EmailMessage::new(SENDER, RECIPIENT, &raw)?;
54+
Ok(sender.send(&message).await?)
55+
}

examples/send-email/wrangler.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
name = "send-email-on-workers"
2+
main = "build/index.js"
3+
compatibility_date = "2024-10-01"
4+
5+
[build]
6+
command = "cargo install \"worker-build@^0.8\" && worker-build --release"
7+
# For development: use local worker-build binary
8+
# command = "../../target/release/worker-build --release"
9+
10+
[[send_email]]
11+
name = "EMAIL"

package-lock.json

Lines changed: 28 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"homepage": "https://github.com/cloudflare/workers-rs#readme",
1616
"devDependencies": {
1717
"@types/node": "^24.0.1",
18-
"miniflare": "^4.20260421.0",
18+
"miniflare": "^4.20260424.0",
1919
"typescript": "^5.8.3",
2020
"uuid": "^14.0.0",
2121
"vitest": "^3.2.4"

0 commit comments

Comments
 (0)