diff --git a/.gitmodules b/.gitmodules index c0cb169f..d6bcf086 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "wasm-streams"] path = wasm-streams url = git@github.com:guybedford/wasm-streams.git +[submodule "ts-gen"] + path = ts-gen + url = git@github.com:wasm-bindgen/ts-gen diff --git a/Cargo.lock b/Cargo.lock index 180ee8fd..25ccd3cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1543,6 +1543,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mail-builder" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" + [[package]] name = "matchit" version = "0.7.3" @@ -2326,6 +2332,14 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send-email-on-workers" +version = "0.1.0" +dependencies = [ + "mail-builder", + "worker", +] + [[package]] name = "serde" version = "1.0.228" diff --git a/chompfile.toml b/chompfile.toml index 01f06230..2d1d315a 100644 --- a/chompfile.toml +++ b/chompfile.toml @@ -1,5 +1,21 @@ version = 0.1 +[[task]] +name = 'build:types' +deps = ['install:ts-gen'] +# `Env` / `ExecutionContext` are project-specific re-exports that ts-gen +# can't infer; everything else (`ReadableStream`, `Headers`, `Event`, …) +# resolves through ts-gen's built-in web_sys defaults. +run = '''ts-gen --input types/email.d.ts --output worker/src/email.rs \ + --external "Env=crate::Env" \ + --external "ExecutionContext=crate::Context"''' + +[[task]] +name = 'install:ts-gen' +# ts-gen pulls in oxc which needs a newer rustc than the workspace's pinned 1.88; +# build with the user's stable toolchain instead so it ignores rust-toolchain.toml. +run = 'cargo +stable install --path ts-gen' + [[task]] name = 'build:wasm-bindgen' cwd = 'wasm-bindgen' diff --git a/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml new file mode 100644 index 00000000..65bfc70d --- /dev/null +++ b/examples/send-email/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "send-email-on-workers" +version = "0.1.0" +edition = "2021" + +[package.metadata.release] +release = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Default feature `gethostname` pulls in a crate that doesn't build for +# `wasm32-unknown-unknown`, so disable it. The remaining core API is enough +# to assemble a message as long as we set `date` and `message_id` ourselves +# (the auto-generated ones rely on `SystemTime::now()` / `gethostname`). +mail-builder = { version = "0.4", default-features = false } +worker.workspace = true diff --git a/examples/send-email/README.md b/examples/send-email/README.md new file mode 100644 index 00000000..ff3ef978 --- /dev/null +++ b/examples/send-email/README.md @@ -0,0 +1,28 @@ +# Sending email from Cloudflare Workers + +Example of using `worker::SendEmail` to send a message through a `[[send_email]]` binding. + +Two routes: + +* `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. +* `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. + +## Local development + +`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. + +```bash +npm install +npm run dev +# then, in another shell: +curl http://localhost:8787/ # structured +curl http://localhost:8787/raw # raw MIME +``` + +## Deploying + +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: + +```bash +npm run deploy +``` diff --git a/examples/send-email/package.json b/examples/send-email/package.json new file mode 100644 index 00000000..f589b695 --- /dev/null +++ b/examples/send-email/package.json @@ -0,0 +1,12 @@ +{ + "name": "send-email-on-workers", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "cargo install worker-build ; wrangler deploy", + "dev": "cargo install worker-build ; wrangler dev --local" + }, + "devDependencies": { + "wrangler": "^4.83.0" + } +} diff --git a/examples/send-email/src/lib.rs b/examples/send-email/src/lib.rs new file mode 100644 index 00000000..d28f131b --- /dev/null +++ b/examples/send-email/src/lib.rs @@ -0,0 +1,55 @@ +use mail_builder::MessageBuilder as MimeBuilder; +use worker::*; + +const SENDER: &str = "sender@example.com"; +const RECIPIENT: &str = "recipient@example.com"; + +#[event(fetch)] +async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { + let sender = env.send_email("EMAIL")?; + + let result = match req.path().as_str() { + "/" => send_structured(&sender).await?, + "/raw" => send_raw_mime(&sender).await?, + // Don't dispatch on favicon / unknown paths — otherwise every browser + // tab to localhost sends a real email in `wrangler dev`. + _ => return Response::error("not found", 404), + }; + + Response::ok(format!("sent: {}", result.message_id())) +} + +async fn send_structured(sender: &SendEmail) -> Result { + let from = EmailAddress::new("Sending email test", SENDER); + let builder = SendEmailBuilder::builder_with_email_address_and_str( + &from, + RECIPIENT, + "An email generated in a Worker", + ) + .text("Congratulations, you just sent an email from a Worker.") + .html("

Congratulations, you just sent an email from a Worker.

") + .build(); + + Ok(sender.send_with_builder(&builder).await?) +} + +async fn send_raw_mime(sender: &SendEmail) -> Result { + // mail-builder's auto-generated `Date:` and `Message-ID:` headers rely on + // `SystemTime::now()` and `gethostname`, neither of which work on + // `wasm32-unknown-unknown`. https://github.com/stalwartlabs/mail-builder/pull/26 + let now_ms = Date::now().as_millis(); + let message_id = format!("{now_ms}@example.com"); + + let raw = MimeBuilder::new() + .from(("Sending email test", SENDER)) + .to(RECIPIENT) + .subject("An email generated in a Worker") + .date((now_ms / 1000) as i64) + .message_id(message_id) + .text_body("Congratulations, you just sent an email from a Worker.") + .write_to_string() + .map_err(|e| Error::RustError(e.to_string()))?; + + let message = EmailMessage::new(SENDER, RECIPIENT, &raw)?; + Ok(sender.send(&message).await?) +} diff --git a/examples/send-email/wrangler.toml b/examples/send-email/wrangler.toml new file mode 100644 index 00000000..6591b41d --- /dev/null +++ b/examples/send-email/wrangler.toml @@ -0,0 +1,11 @@ +name = "send-email-on-workers" +main = "build/index.js" +compatibility_date = "2024-10-01" + +[build] +command = "cargo install \"worker-build@^0.8\" && worker-build --release" +# For development: use local worker-build binary +# command = "../../target/release/worker-build --release" + +[[send_email]] +name = "EMAIL" diff --git a/package-lock.json b/package-lock.json index b1b25337..63913399 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@types/node": "^24.0.1", - "miniflare": "^4.20260421.0", + "miniflare": "^4.20260424.0", "typescript": "^5.8.3", "uuid": "^11.1.0", "vitest": "^3.2.4" @@ -350,9 +350,9 @@ "license": "MIT" }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260421.1.tgz", - "integrity": "sha512-DLU5ZTZ1VHeZZnj0PuVJEMHKGisfLe2XShyImP5P/PPj/m/t7CLEJmPiI7FMxvT7ynArkckJl7m+Z5x7u4Kkdw==", + "version": "1.20260424.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260424.1.tgz", + "integrity": "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw==", "cpu": [ "x64" ], @@ -367,9 +367,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260421.1.tgz", - "integrity": "sha512-Trotq3xRAkIcpC505WoxM8+kIH4JIvOJCNuRatyHcz9uF5S+ukgiVUFUlM+GIjw1uCM/Bda2St+vSniX1RZdpw==", + "version": "1.20260424.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260424.1.tgz", + "integrity": "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w==", "cpu": [ "arm64" ], @@ -384,9 +384,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260421.1.tgz", - "integrity": "sha512-938QjUv0z+QqK6BAvgwX/lCIZ2b224ZXoXtGTbhyNVMhB+mt4Dj24cj9qca4ekNXjVM7uTKp1yOHZO97fVSacw==", + "version": "1.20260424.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260424.1.tgz", + "integrity": "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA==", "cpu": [ "x64" ], @@ -401,9 +401,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260421.1.tgz", - "integrity": "sha512-YI4+mLfwnJcKJ+iPyxzx+tp2Jy4o29BxBPSQGZxl/AZyvZ9eTKsmNZmtjEiT4i3O/M0tdO/B/d9ESDHbRCs2rQ==", + "version": "1.20260424.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260424.1.tgz", + "integrity": "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g==", "cpu": [ "arm64" ], @@ -418,9 +418,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260421.1.tgz", - "integrity": "sha512-q1SFgwlNH9lFmw74vh7EJbJtduo92Nx51mNOfd3/u6pux6AldcwRviYzKEEv3FEbtv6OBB7J8D5f8vtZj7Z6Sg==", + "version": "1.20260424.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260424.1.tgz", + "integrity": "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg==", "cpu": [ "x64" ], @@ -1004,9 +1004,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1024,9 +1021,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1044,9 +1038,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1064,9 +1055,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1084,9 +1072,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1104,9 +1089,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1124,9 +1106,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1144,9 +1123,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1164,9 +1140,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1190,9 +1163,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1216,9 +1186,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1242,9 +1209,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1268,9 +1232,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1294,9 +1255,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1320,9 +1278,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1346,9 +1301,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1715,9 +1667,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1732,9 +1681,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1749,9 +1695,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1766,9 +1709,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1783,9 +1723,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1800,9 +1737,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1817,9 +1751,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1834,9 +1765,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1851,9 +1779,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1868,9 +1793,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1885,9 +1807,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1902,9 +1821,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1919,9 +1835,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2823,16 +2736,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260421.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260421.0.tgz", - "integrity": "sha512-7ZkNQ7brgQ2hh5ha9iQCDUjxBkLvuiG2VdDns9esRL8O8lXg+MoP6E0dO1rtp+ZY2I+vV1tPWr6td5IojkewLw==", + "version": "4.20260424.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260424.0.tgz", + "integrity": "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.8", - "workerd": "1.20260421.1", + "workerd": "1.20260424.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, @@ -3870,9 +3783,9 @@ } }, "node_modules/workerd": { - "version": "1.20260421.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260421.1.tgz", - "integrity": "sha512-zTYD+xFR4d7TUCxsyl7FTPth9a8CDgk8pM7xUWbJxo0SGUx+2e5C7Q5LrramBZwmuAErtzXmOjlQ15PtkPAhZA==", + "version": "1.20260424.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260424.1.tgz", + "integrity": "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -3883,11 +3796,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260421.1", - "@cloudflare/workerd-darwin-arm64": "1.20260421.1", - "@cloudflare/workerd-linux-64": "1.20260421.1", - "@cloudflare/workerd-linux-arm64": "1.20260421.1", - "@cloudflare/workerd-windows-64": "1.20260421.1" + "@cloudflare/workerd-darwin-64": "1.20260424.1", + "@cloudflare/workerd-darwin-arm64": "1.20260424.1", + "@cloudflare/workerd-linux-64": "1.20260424.1", + "@cloudflare/workerd-linux-arm64": "1.20260424.1", + "@cloudflare/workerd-windows-64": "1.20260424.1" } }, "node_modules/ws": { diff --git a/package.json b/package.json index 6f0b338e..783b1cee 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "homepage": "https://github.com/cloudflare/workers-rs#readme", "devDependencies": { "@types/node": "^24.0.1", - "miniflare": "^4.20260421.0", + "miniflare": "^4.20260424.0", "typescript": "^5.8.3", "uuid": "^11.1.0", "vitest": "^3.2.4" diff --git a/test/src/lib.rs b/test/src/lib.rs index a5c401e8..37f718fd 100644 --- a/test/src/lib.rs +++ b/test/src/lib.rs @@ -32,6 +32,7 @@ mod rate_limit; mod request; mod router; mod secret_store; +mod send_email; mod service; mod signal; mod socket; diff --git a/test/src/router.rs b/test/src/router.rs index 147c5cd4..b5a4b56b 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -1,8 +1,9 @@ use crate::signal; use crate::{ alarm, analytics_engine, assets, auto_response, cache, container, counter, d1, durable, fetch, - form, js_snippets, kv, put_raw, queue, r2, rate_limit, request, secret_store, service, socket, - sql_counter, sql_iterator, user, ws, SomeSharedData, GLOBAL_SECOND_START, GLOBAL_STATE, + form, js_snippets, kv, put_raw, queue, r2, rate_limit, request, secret_store, send_email, + service, socket, sql_counter, sql_iterator, user, ws, SomeSharedData, GLOBAL_SECOND_START, + GLOBAL_STATE, }; #[cfg(feature = "http")] use std::convert::TryInto; @@ -240,6 +241,7 @@ macro_rules! add_routes ( add_route!($obj, get, format_route!("/rate-limit/key/{}", "key"), rate_limit::handle_rate_limit_with_key); add_route!($obj, get, "/rate-limit/bulk-test", rate_limit::handle_rate_limit_bulk_test); add_route!($obj, get, "/rate-limit/reset", rate_limit::handle_rate_limit_reset); + add_route!($obj, get, "/send-email", send_email::handle_send_email); add_route!($obj, get, "/signal/poll", signal::handle_signal_poll); }); diff --git a/test/src/send_email.rs b/test/src/send_email.rs new file mode 100644 index 00000000..8d0eed13 --- /dev/null +++ b/test/src/send_email.rs @@ -0,0 +1,165 @@ +use crate::SomeSharedData; +use futures_util::stream::once; +use worker::{ + worker_sys, Date, EmailAddress, EmailMessage, Env, FixedLengthStream, Request, Response, + Result, SendEmail, SendEmailBuilder, +}; + +const SENDER: &str = "allowed-sender@example.com"; +const RECIPIENT: &str = "allowed-recipient@example.com"; +const BAD_SENDER: &str = "evil@example.com"; +const BAD_RECIPIENT: &str = "nope@example.com"; +const MISMATCHED_FROM: &str = "mismatched@example.com"; + +struct MimeScenario { + envelope_from: &'static str, + envelope_to: &'static str, + header_from: &'static str, + include_message_id: bool, +} + +impl MimeScenario { + fn for_name(name: &str) -> Option { + Some(match name { + "mime-ok" => Self { + envelope_from: SENDER, + envelope_to: RECIPIENT, + header_from: SENDER, + include_message_id: true, + }, + "mime-missing-message-id" => Self { + envelope_from: SENDER, + envelope_to: RECIPIENT, + header_from: SENDER, + include_message_id: false, + }, + "mime-disallowed-sender" => Self { + envelope_from: BAD_SENDER, + envelope_to: RECIPIENT, + header_from: BAD_SENDER, + include_message_id: true, + }, + "mime-disallowed-recipient" => Self { + envelope_from: SENDER, + envelope_to: BAD_RECIPIENT, + header_from: SENDER, + include_message_id: true, + }, + "mime-from-mismatch" => Self { + envelope_from: SENDER, + envelope_to: RECIPIENT, + header_from: MISMATCHED_FROM, + include_message_id: true, + }, + _ => return None, + }) + } + + fn raw(&self) -> String { + let mut raw = format!( + "From: {}\r\n\ + To: {}\r\n\ + Subject: Integration test\r\n\ + Date: Thu, 01 Jan 1970 00:00:00 +0000\r\n", + self.header_from, self.envelope_to + ); + if self.include_message_id { + raw.push_str(&format!( + "Message-ID: <{}@example.com>\r\n", + Date::now().as_millis() + )); + } + raw.push_str("\r\nhello from an integration test\r\n"); + raw + } +} + +fn build_structured(name: &str) -> Option { + let subject = "structured integration test"; + let builder = match name { + "structured-ok" => SendEmailBuilder::builder(SENDER, RECIPIENT, subject) + .text("hello from the structured path"), + "structured-with-name" => { + let address = EmailAddress::new("Integration", SENDER); + SendEmailBuilder::builder_with_email_address_and_str(&address, RECIPIENT, subject) + .html("

hello from the structured path

") + } + "structured-disallowed-sender" => { + SendEmailBuilder::builder(BAD_SENDER, RECIPIENT, subject).text("hello") + } + "structured-disallowed-recipient" => { + SendEmailBuilder::builder(SENDER, BAD_RECIPIENT, subject).text("hello") + } + _ => return None, + }; + Some(builder.build()) +} + +#[worker::send] +pub async fn handle_send_email(req: Request, env: Env, _data: SomeSharedData) -> Result { + let url = req.url()?; + let name = url + .query_pairs() + .find_map(|(k, v)| (k == "scenario").then(|| v.into_owned())) + .unwrap_or_default(); + + let sender = env.send_email("EMAIL")?; + + if name == "mime-stream" { + return respond(dispatch_mime_stream(&sender).await); + } + + if let Some(scenario) = MimeScenario::for_name(&name) { + return respond(dispatch_mime(&sender, &scenario).await); + } + + if let Some(builder) = build_structured(&name) { + return respond(dispatch_structured(&sender, builder).await); + } + + Response::error(format!("unknown scenario: {name}"), 400) +} + +async fn dispatch_mime(sender: &SendEmail, scenario: &MimeScenario) -> Result { + let message = EmailMessage::new( + scenario.envelope_from, + scenario.envelope_to, + &scenario.raw(), + )?; + let result = sender.send(&message).await?; + Ok(result.message_id()) +} + +// Exercises the `EmailMessage::new_with_readable_stream` constructor — the +// `&str` raw path is covered by `dispatch_mime`. Builds a one-chunk +// `FixedLengthStream` and pulls the readable side off the underlying +// `TransformStream` via Deref (`FixedLengthStream` extends +// `web_sys::TransformStream`, so `.readable()` resolves through it). +async fn dispatch_mime_stream(sender: &SendEmail) -> Result { + let scenario = MimeScenario::for_name("mime-ok").expect("mime-ok scenario must exist"); + let raw = scenario.raw().into_bytes(); + let len = raw.len() as u64; + let fixed: worker_sys::FixedLengthStream = + FixedLengthStream::wrap(once(async move { Ok(raw) }), len).into(); + let stream = fixed.readable(); + let message = EmailMessage::new_with_readable_stream( + scenario.envelope_from, + scenario.envelope_to, + &stream, + )?; + let result = sender.send(&message).await?; + Ok(result.message_id()) +} + +async fn dispatch_structured(sender: &SendEmail, builder: SendEmailBuilder) -> Result { + let result = sender.send_with_builder(&builder).await?; + Ok(result.message_id()) +} + +fn respond(result: Result) -> Result { + Response::from_json(&serde_json::json!({ + "ok": result.is_ok(), + "messageId": result.as_ref().ok(), + "error": result.err().map(|e| e.to_string()), + })) +} diff --git a/test/tests/mf.ts b/test/tests/mf.ts index 3c881b18..8d8003e9 100644 --- a/test/tests/mf.ts +++ b/test/tests/mf.ts @@ -119,6 +119,15 @@ const mf_instance = new Miniflare({ period: 60, } } + }, + email: { + send_email: [ + { + name: "EMAIL", + allowed_sender_addresses: ["allowed-sender@example.com"], + allowed_destination_addresses: ["allowed-recipient@example.com"], + } + ] } }, { diff --git a/test/tests/send_email.spec.ts b/test/tests/send_email.spec.ts new file mode 100644 index 00000000..7e15b423 --- /dev/null +++ b/test/tests/send_email.spec.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from "vitest"; +import { mf, mfUrl } from "./mf"; + +type SendResult = { + ok: boolean; + messageId: string | null; + error: string | null; +}; + +async function runScenario(name: string): Promise { + const resp = await mf.dispatchFetch(`${mfUrl}send-email?scenario=${name}`); + expect(resp.status).toBe(200); + return (await resp.json()) as SendResult; +} + +function expectSuccess(result: SendResult) { + expect(result.error).toBeNull(); + expect(result.ok).toBe(true); + expect(result.messageId).toBeTruthy(); +} + +describe("send email (raw MIME)", () => { + test("sends a valid email through the binding", async () => { + expectSuccess(await runScenario("mime-ok")); + }); + + test("sends a valid email constructed from a ReadableStream", async () => { + expectSuccess(await runScenario("mime-stream")); + }); + + test.each([ + ["mime-missing-message-id", /message-id/i], + ["mime-disallowed-sender", /email from .* not allowed/i], + ["mime-disallowed-recipient", /email to .* not allowed/i], + ["mime-from-mismatch", /from.*does not match/i], + ])("rejects scenario %s", async (scenario, errorPattern) => { + const result = await runScenario(scenario); + expect(result.ok).toBe(false); + expect(result.error).toMatch(errorPattern); + }); +}); + +describe("send email (structured builder)", () => { + test("sends a plain-text email", async () => { + expectSuccess(await runScenario("structured-ok")); + }); + + test("sends an HTML email with a display-name sender", async () => { + expectSuccess(await runScenario("structured-with-name")); + }); + + test.each([ + ["structured-disallowed-sender", /email from .* not allowed/i], + ["structured-disallowed-recipient", /email to .* not allowed/i], + ])("rejects scenario %s", async (scenario, errorPattern) => { + const result = await runScenario(scenario); + expect(result.ok).toBe(false); + expect(result.error).toMatch(errorPattern); + }); +}); diff --git a/test/wrangler.toml b/test/wrangler.toml index 16b49d87..654208df 100644 --- a/test/wrangler.toml +++ b/test/wrangler.toml @@ -90,3 +90,8 @@ max_instances = 1 name = "TEST_RATE_LIMITER" namespace_id = "1" simple = { limit = 10, period = 60 } + +[[send_email]] +name = "EMAIL" +allowed_sender_addresses = ["allowed-sender@example.com"] +allowed_destination_addresses = ["allowed-recipient@example.com"] diff --git a/ts-gen b/ts-gen new file mode 160000 index 00000000..1f3ca2de --- /dev/null +++ b/ts-gen @@ -0,0 +1 @@ +Subproject commit 1f3ca2dea011b5a82a9a353f22c855ef52b511d8 diff --git a/types/email.d.ts b/types/email.d.ts new file mode 100644 index 00000000..f22c708a --- /dev/null +++ b/types/email.d.ts @@ -0,0 +1,143 @@ +/* + * Email only types from @cloudflare/worker-types. Valid as of 28/04/2026. + * This file builds src/email.rs as auto-generated bindings using ts-gen. + * + * NOTE: All hand edits to the @cloudflare/worker-types are marked with an "EDIT:" comment. + */ + +/** + * The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) + */ +declare abstract class ExtendableEvent extends Event { + /** + * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) + */ + waitUntil(promise: Promise): void; +} +/** + * The returned data after sending an email + */ +interface EmailSendResult { + /** + * The Email Message ID + */ + messageId: string; +} +// EDIT: upstream splits `EmailMessage` into a global `interface +// EmailMessage` (the instance shape) and a `let _EmailMessage: { new(): EmailMessage }` +// inside `declare module "cloudflare:email"`, exported as `EmailMessage`. +// At runtime they are the same type — TS only splits them because module +// declarations cannot directly declare a class with both a constructor +// and an instance shape readable from the outer scope. +// +// In Rust + wasm-bindgen, a single `pub type EmailMessage` with a +// `#[wasm_bindgen(constructor)]` fn naturally covers both roles, so we +// collapse the upstream pattern into one `class EmailMessage` inside the +// module declaration. Everything that referenced upstream's global +// `EmailMessage` now imports from `cloudflare:email`. +declare module "cloudflare:email" { + /** + * An email message that can be sent from a Worker. + */ + class EmailMessage { + constructor(from: string, to: string, raw: string | ReadableStream); + /** + * Envelope From attribute of the email message. + */ + readonly from: string; + /** + * Envelope To attribute of the email message. + */ + readonly to: string; + } + export { EmailMessage }; +} +import { EmailMessage } from "cloudflare:email"; + +/** + * An email message that is sent to a consumer Worker and can be rejected/forwarded. + */ +interface ForwardableEmailMessage extends EmailMessage { + /** + * Stream of the email message content. + */ + readonly raw: ReadableStream; + /** + * An [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). + */ + readonly headers: Headers; + /** + * Size of the email message content. + */ + readonly rawSize: number; + /** + * Reject this email message by returning a permanent SMTP error back to the connecting client including the given reason. + * @param reason The reject reason. + * @returns void + */ + setReject(reason: string): void; + /** + * Forward this email message to a verified destination address of the account. + * @param rcptTo Verified destination address. + * @param headers A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers). + * @returns A promise that resolves when the email message is forwarded. + */ + forward(rcptTo: string, headers?: Headers): Promise; + /** + * Reply to the sender of this email message with a new EmailMessage object. + * @param message The reply message. + * @returns A promise that resolves when the email message is replied. + */ + reply(message: EmailMessage): Promise; +} +/** A file attachment for an email message */ +type EmailAttachment = + | { + disposition: "inline"; + contentId: string; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; + } + | { + disposition: "attachment"; + contentId?: undefined; + filename: string; + type: string; + content: string | ArrayBuffer | ArrayBufferView; + }; +/** An Email Address */ +interface EmailAddress { + name: string; + email: string; +} +/** + * A binding that allows a Worker to send email messages. + */ +interface SendEmail { + send(message: EmailMessage): Promise; + send(builder: { + from: string | EmailAddress; + to: string | string[]; + subject: string; + replyTo?: string | EmailAddress; + cc?: string | string[]; + bcc?: string | string[]; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; + }): Promise; +} +declare abstract class EmailEvent extends ExtendableEvent { + readonly message: ForwardableEmailMessage; +} +declare type EmailExportedHandler = ( + message: ForwardableEmailMessage, + env: Env, + ctx: ExecutionContext, +) => void | Promise; diff --git a/worker-build/src/main.rs b/worker-build/src/main.rs index 0710d660..2050de1e 100644 --- a/worker-build/src/main.rs +++ b/worker-build/src/main.rs @@ -373,6 +373,7 @@ fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> { let mut command = Command::new(esbuild_path); command.args([ "--external:./index_bg.wasm", + "--external:cloudflare:email", "--external:cloudflare:sockets", "--external:cloudflare:workers", "--format=esm", diff --git a/worker-build/src/main_legacy.rs b/worker-build/src/main_legacy.rs index 84547342..80566e68 100644 --- a/worker-build/src/main_legacy.rs +++ b/worker-build/src/main_legacy.rs @@ -167,6 +167,7 @@ fn bundle(out_dir: &Path, esbuild_path: &Path) -> Result<()> { let mut command = Command::new(esbuild_path); command.args([ "--external:./index.wasm", + "--external:cloudflare:email", "--external:cloudflare:sockets", "--external:cloudflare:workers", "--format=esm", diff --git a/worker/src/email.rs b/worker/src/email.rs new file mode 100644 index 00000000..9360fea0 --- /dev/null +++ b/worker/src/email.rs @@ -0,0 +1,712 @@ +#[allow(dead_code)] +use crate::Context as ExecutionContext; +#[allow(dead_code)] +use crate::Env; +#[allow(dead_code)] +use ::web_sys::Event; +#[allow(dead_code)] +use ::web_sys::Headers; +#[allow(dead_code)] +use ::web_sys::ReadableStream; +#[allow(unused_imports)] +use js_sys::*; +#[allow(unused_imports)] +use wasm_bindgen::prelude::*; +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Event , extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type ExtendableEvent; + #[doc = " The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing."] + #[doc = ""] + #[doc = " [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil)"] + #[wasm_bindgen(method, js_name = "waitUntil")] + pub fn wait_until(this: &ExtendableEvent, promise: &Promise); + #[doc = " The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing."] + #[doc = ""] + #[doc = " [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil)"] + #[wasm_bindgen(method, catch, js_name = "waitUntil")] + pub fn try_wait_until(this: &ExtendableEvent, promise: &Promise) -> Result<(), JsValue>; +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type EmailSendResult; + #[doc = " The Email Message ID"] + #[wasm_bindgen(method, getter, js_name = "messageId")] + pub fn message_id(this: &EmailSendResult) -> String; + #[wasm_bindgen(method, setter, js_name = "messageId")] + pub fn set_message_id(this: &EmailSendResult, val: &str); +} +impl EmailSendResult { + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `message_id` - The Email Message ID"] + pub fn new(message_id: &str) -> EmailSendResult { + Self::builder(message_id).build() + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `message_id` - The Email Message ID"] + pub fn builder(message_id: &str) -> EmailSendResultBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_message_id(message_id); + EmailSendResultBuilder { inner } + } +} +pub struct EmailSendResultBuilder { + inner: EmailSendResult, +} +impl EmailSendResultBuilder { + pub fn build(self) -> EmailSendResult { + self.inner + } +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = email :: EmailMessage , extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type ForwardableEmailMessage; + #[doc = " Stream of the email message content."] + #[wasm_bindgen(method, getter)] + pub fn raw(this: &ForwardableEmailMessage) -> ReadableStream; + #[doc = " An [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)."] + #[wasm_bindgen(method, getter)] + pub fn headers(this: &ForwardableEmailMessage) -> Headers; + #[doc = " Size of the email message content."] + #[wasm_bindgen(method, getter, js_name = "rawSize")] + pub fn raw_size(this: &ForwardableEmailMessage) -> f64; + #[doc = " Reject this email message by returning a permanent SMTP error back to the connecting client including the given reason."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `reason` - The reject reason."] + #[doc = ""] + #[doc = " ## Returns"] + #[doc = ""] + #[doc = " void"] + #[wasm_bindgen(method, js_name = "setReject")] + pub fn set_reject(this: &ForwardableEmailMessage, reason: &str); + #[doc = " Reject this email message by returning a permanent SMTP error back to the connecting client including the given reason."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `reason` - The reject reason."] + #[doc = ""] + #[doc = " ## Returns"] + #[doc = ""] + #[doc = " void"] + #[wasm_bindgen(method, catch, js_name = "setReject")] + pub fn try_set_reject(this: &ForwardableEmailMessage, reason: &str) -> Result<(), JsValue>; + #[doc = " Forward this email message to a verified destination address of the account."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `rcptTo` - Verified destination address."] + #[doc = " * `headers` - A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)."] + #[doc = ""] + #[doc = " ## Returns"] + #[doc = ""] + #[doc = " A promise that resolves when the email message is forwarded."] + #[wasm_bindgen(method, catch)] + pub async fn forward( + this: &ForwardableEmailMessage, + rcpt_to: &str, + ) -> Result; + #[doc = " Forward this email message to a verified destination address of the account."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `rcptTo` - Verified destination address."] + #[doc = " * `headers` - A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)."] + #[doc = ""] + #[doc = " ## Returns"] + #[doc = ""] + #[doc = " A promise that resolves when the email message is forwarded."] + #[wasm_bindgen(method, catch, js_name = "forward")] + pub async fn forward_with_headers( + this: &ForwardableEmailMessage, + rcpt_to: &str, + headers: &Headers, + ) -> Result; + #[doc = " Reply to the sender of this email message with a new EmailMessage object."] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `message` - The reply message."] + #[doc = ""] + #[doc = " ## Returns"] + #[doc = ""] + #[doc = " A promise that resolves when the email message is replied."] + #[wasm_bindgen(method, catch)] + pub async fn reply( + this: &ForwardableEmailMessage, + message: &email::EmailMessage, + ) -> Result; +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type EmailAttachment; + #[wasm_bindgen(method, getter)] + pub fn content(this: &EmailAttachment) -> JsValue; + #[wasm_bindgen(method, getter, js_name = "contentId")] + pub fn content_id(this: &EmailAttachment) -> Option; + #[wasm_bindgen(method, getter)] + pub fn disposition(this: &EmailAttachment) -> JsValue; + #[wasm_bindgen(method, getter)] + pub fn filename(this: &EmailAttachment) -> String; + #[wasm_bindgen(method, getter)] + pub fn r#type(this: &EmailAttachment) -> String; + #[wasm_bindgen(method, setter)] + pub fn set_content(this: &EmailAttachment, val: &str); + #[wasm_bindgen(method, setter, js_name = "content")] + pub fn set_content_with_array_buffer(this: &EmailAttachment, val: &ArrayBuffer); + #[wasm_bindgen(method, setter, js_name = "content")] + pub fn set_content_with_js_value(this: &EmailAttachment, val: &Object); + #[wasm_bindgen(method, setter, js_name = "contentId")] + pub fn set_content_id(this: &EmailAttachment, val: &str); + #[wasm_bindgen(method, setter, js_name = "contentId")] + pub fn set_content_id_with_undefined(this: &EmailAttachment, val: &Undefined); + #[wasm_bindgen(method, setter)] + pub fn set_disposition(this: &EmailAttachment, val: &str); + #[wasm_bindgen(method, setter, js_name = "disposition")] + pub fn set_disposition_with_js_value(this: &EmailAttachment, val: &str); + #[wasm_bindgen(method, setter)] + pub fn set_filename(this: &EmailAttachment, val: &str); + #[wasm_bindgen(method, setter)] + pub fn set_type(this: &EmailAttachment, val: &str); +} +impl EmailAttachment { + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"inline\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn new_inline(content: &str, filename: &str, r#type: &str) -> EmailAttachment { + Self::builder_inline(content, filename, r#type).build() + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"attachment\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn new_attachment(content: &str, filename: &str, r#type: &str) -> EmailAttachment { + Self::builder_attachment(content, filename, r#type).build() + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"inline\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn new_inline_with_array_buffer( + content: &ArrayBuffer, + filename: &str, + r#type: &str, + ) -> EmailAttachment { + Self::builder_inline_with_array_buffer(content, filename, r#type).build() + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"attachment\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn new_attachment_with_array_buffer( + content: &ArrayBuffer, + filename: &str, + r#type: &str, + ) -> EmailAttachment { + Self::builder_attachment_with_array_buffer(content, filename, r#type).build() + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"inline\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn new_inline_with_js_value( + content: &Object, + filename: &str, + r#type: &str, + ) -> EmailAttachment { + Self::builder_inline_with_js_value(content, filename, r#type).build() + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"attachment\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn new_attachment_with_js_value( + content: &Object, + filename: &str, + r#type: &str, + ) -> EmailAttachment { + Self::builder_attachment_with_js_value(content, filename, r#type).build() + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"inline\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn builder_inline(content: &str, filename: &str, r#type: &str) -> EmailAttachmentBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_content(content); + inner.set_disposition("inline"); + inner.set_filename(filename); + inner.set_type(r#type); + EmailAttachmentBuilder { inner } + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"attachment\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn builder_attachment( + content: &str, + filename: &str, + r#type: &str, + ) -> EmailAttachmentBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_content(content); + inner.set_disposition_with_js_value("attachment"); + inner.set_filename(filename); + inner.set_type(r#type); + EmailAttachmentBuilder { inner } + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"inline\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn builder_inline_with_array_buffer( + content: &ArrayBuffer, + filename: &str, + r#type: &str, + ) -> EmailAttachmentBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_content_with_array_buffer(content); + inner.set_disposition("inline"); + inner.set_filename(filename); + inner.set_type(r#type); + EmailAttachmentBuilder { inner } + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"attachment\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn builder_attachment_with_array_buffer( + content: &ArrayBuffer, + filename: &str, + r#type: &str, + ) -> EmailAttachmentBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_content_with_array_buffer(content); + inner.set_disposition_with_js_value("attachment"); + inner.set_filename(filename); + inner.set_type(r#type); + EmailAttachmentBuilder { inner } + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"inline\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn builder_inline_with_js_value( + content: &Object, + filename: &str, + r#type: &str, + ) -> EmailAttachmentBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_content_with_js_value(content); + inner.set_disposition("inline"); + inner.set_filename(filename); + inner.set_type(r#type); + EmailAttachmentBuilder { inner } + } + #[doc = " ## Inlined fields"] + #[doc = ""] + #[doc = " * `disposition: \"attachment\"`"] + #[doc = ""] + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `content`"] + #[doc = " * `filename`"] + #[doc = " * `type`"] + pub fn builder_attachment_with_js_value( + content: &Object, + filename: &str, + r#type: &str, + ) -> EmailAttachmentBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_content_with_js_value(content); + inner.set_disposition_with_js_value("attachment"); + inner.set_filename(filename); + inner.set_type(r#type); + EmailAttachmentBuilder { inner } + } +} +pub struct EmailAttachmentBuilder { + inner: EmailAttachment, +} +impl EmailAttachmentBuilder { + pub fn content_id(self, val: &str) -> Self { + self.inner.set_content_id(val); + self + } + pub fn content_id_with_undefined(self, val: &Undefined) -> Self { + self.inner.set_content_id_with_undefined(val); + self + } + pub fn build(self) -> EmailAttachment { + self.inner + } +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type EmailAddress; + #[wasm_bindgen(method, getter)] + pub fn name(this: &EmailAddress) -> String; + #[wasm_bindgen(method, setter)] + pub fn set_name(this: &EmailAddress, val: &str); + #[wasm_bindgen(method, getter)] + pub fn email(this: &EmailAddress) -> String; + #[wasm_bindgen(method, setter)] + pub fn set_email(this: &EmailAddress, val: &str); +} +impl EmailAddress { + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `name`"] + #[doc = " * `email`"] + pub fn new(name: &str, email: &str) -> EmailAddress { + Self::builder(name, email).build() + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `name`"] + #[doc = " * `email`"] + pub fn builder(name: &str, email: &str) -> EmailAddressBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_name(name); + inner.set_email(email); + EmailAddressBuilder { inner } + } +} +pub struct EmailAddressBuilder { + inner: EmailAddress, +} +impl EmailAddressBuilder { + pub fn build(self) -> EmailAddress { + self.inner + } +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type SendEmail; + #[wasm_bindgen(method, catch)] + pub async fn send( + this: &SendEmail, + message: &email::EmailMessage, + ) -> Result; + #[wasm_bindgen(method, catch, js_name = "send")] + pub async fn send_with_builder( + this: &SendEmail, + builder: &SendEmailBuilder, + ) -> Result; +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type SendEmailBuilder; + #[wasm_bindgen(method, getter)] + pub fn from(this: &SendEmailBuilder) -> JsValue; + #[wasm_bindgen(method, setter)] + pub fn set_from(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, setter, js_name = "from")] + pub fn set_from_with_email_address(this: &SendEmailBuilder, val: &EmailAddress); + #[wasm_bindgen(method, getter)] + pub fn to(this: &SendEmailBuilder) -> JsValue; + #[wasm_bindgen(method, setter)] + pub fn set_to(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, setter, js_name = "to")] + pub fn set_to_with_array(this: &SendEmailBuilder, val: &Array); + #[wasm_bindgen(method, getter)] + pub fn subject(this: &SendEmailBuilder) -> String; + #[wasm_bindgen(method, setter)] + pub fn set_subject(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, getter, js_name = "replyTo")] + pub fn reply_to(this: &SendEmailBuilder) -> Option; + #[wasm_bindgen(method, setter, js_name = "replyTo")] + pub fn set_reply_to(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, setter, js_name = "replyTo")] + pub fn set_reply_to_with_email_address(this: &SendEmailBuilder, val: &EmailAddress); + #[wasm_bindgen(method, getter)] + pub fn cc(this: &SendEmailBuilder) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_cc(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, setter, js_name = "cc")] + pub fn set_cc_with_array(this: &SendEmailBuilder, val: &Array); + #[wasm_bindgen(method, getter)] + pub fn bcc(this: &SendEmailBuilder) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_bcc(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, setter, js_name = "bcc")] + pub fn set_bcc_with_array(this: &SendEmailBuilder, val: &Array); + #[wasm_bindgen(method, getter)] + pub fn headers(this: &SendEmailBuilder) -> Option>; + #[wasm_bindgen(method, setter)] + pub fn set_headers(this: &SendEmailBuilder, val: &Object); + #[wasm_bindgen(method, getter)] + pub fn text(this: &SendEmailBuilder) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_text(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, getter)] + pub fn html(this: &SendEmailBuilder) -> Option; + #[wasm_bindgen(method, setter)] + pub fn set_html(this: &SendEmailBuilder, val: &str); + #[wasm_bindgen(method, getter)] + pub fn attachments(this: &SendEmailBuilder) -> Option>; + #[wasm_bindgen(method, setter)] + pub fn set_attachments(this: &SendEmailBuilder, val: &Array); +} +impl SendEmailBuilder { + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn new(from: &str, to: &str, subject: &str) -> SendEmailBuilder { + Self::builder(from, to, subject).build() + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn new_with_str_and_array( + from: &str, + to: &Array, + subject: &str, + ) -> SendEmailBuilder { + Self::builder_with_str_and_array(from, to, subject).build() + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn new_with_email_address_and_str( + from: &EmailAddress, + to: &str, + subject: &str, + ) -> SendEmailBuilder { + Self::builder_with_email_address_and_str(from, to, subject).build() + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn new_with_email_address_and_array( + from: &EmailAddress, + to: &Array, + subject: &str, + ) -> SendEmailBuilder { + Self::builder_with_email_address_and_array(from, to, subject).build() + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn builder(from: &str, to: &str, subject: &str) -> SendEmailBuilderBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_from(from); + inner.set_to(to); + inner.set_subject(subject); + SendEmailBuilderBuilder { inner } + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn builder_with_str_and_array( + from: &str, + to: &Array, + subject: &str, + ) -> SendEmailBuilderBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_from(from); + inner.set_to_with_array(to); + inner.set_subject(subject); + SendEmailBuilderBuilder { inner } + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn builder_with_email_address_and_str( + from: &EmailAddress, + to: &str, + subject: &str, + ) -> SendEmailBuilderBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_from_with_email_address(from); + inner.set_to(to); + inner.set_subject(subject); + SendEmailBuilderBuilder { inner } + } + #[doc = " ## Arguments"] + #[doc = ""] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn builder_with_email_address_and_array( + from: &EmailAddress, + to: &Array, + subject: &str, + ) -> SendEmailBuilderBuilder { + let inner: Self = JsCast::unchecked_into(js_sys::Object::new()); + inner.set_from_with_email_address(from); + inner.set_to_with_array(to); + inner.set_subject(subject); + SendEmailBuilderBuilder { inner } + } +} +pub struct SendEmailBuilderBuilder { + inner: SendEmailBuilder, +} +impl SendEmailBuilderBuilder { + pub fn reply_to(self, val: &str) -> Self { + self.inner.set_reply_to(val); + self + } + pub fn reply_to_with_email_address(self, val: &EmailAddress) -> Self { + self.inner.set_reply_to_with_email_address(val); + self + } + pub fn cc(self, val: &str) -> Self { + self.inner.set_cc(val); + self + } + pub fn cc_with_array(self, val: &Array) -> Self { + self.inner.set_cc_with_array(val); + self + } + pub fn bcc(self, val: &str) -> Self { + self.inner.set_bcc(val); + self + } + pub fn bcc_with_array(self, val: &Array) -> Self { + self.inner.set_bcc_with_array(val); + self + } + pub fn headers(self, val: &Object) -> Self { + self.inner.set_headers(val); + self + } + pub fn text(self, val: &str) -> Self { + self.inner.set_text(val); + self + } + pub fn html(self, val: &str) -> Self { + self.inner.set_html(val); + self + } + pub fn attachments(self, val: &Array) -> Self { + self.inner.set_attachments(val); + self + } + pub fn build(self) -> SendEmailBuilder { + self.inner + } +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = ExtendableEvent , extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type EmailEvent; + #[wasm_bindgen(method, getter)] + pub fn message(this: &EmailEvent) -> ForwardableEmailMessage; +} +#[allow(dead_code)] +pub type EmailExportedHandler = + Function JsOption>>; +pub mod email { + use super::*; + use js_sys::*; + use wasm_bindgen::prelude::*; + #[wasm_bindgen(module = "cloudflare:email")] + extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type EmailMessage; + #[wasm_bindgen(constructor, catch)] + pub fn new(from: &str, to: &str, raw: &str) -> Result; + #[wasm_bindgen(constructor, catch, js_name = "EmailMessage")] + pub fn new_with_readable_stream( + from: &str, + to: &str, + raw: &ReadableStream, + ) -> Result; + #[doc = " Envelope From attribute of the email message."] + #[wasm_bindgen(method, getter)] + pub fn from(this: &EmailMessage) -> String; + #[doc = " Envelope To attribute of the email message."] + #[wasm_bindgen(method, getter)] + pub fn to(this: &EmailMessage) -> String; + } +} diff --git a/worker/src/env.rs b/worker/src/env.rs index 780c2585..72502d96 100644 --- a/worker/src/env.rs +++ b/worker/src/env.rs @@ -5,6 +5,7 @@ use crate::analytics_engine::AnalyticsEngineDataset; use crate::d1::D1Database; use crate::kv::KvStore; use crate::rate_limit::RateLimiter; +use crate::send_email::SendEmail; use crate::Ai; #[cfg(feature = "queue")] use crate::Queue; @@ -128,6 +129,15 @@ impl Env { pub fn rate_limiter(&self, binding: &str) -> Result { self.get_binding(binding) } + + /// Access a [send_email binding](https://developers.cloudflare.com/email-service/api/send-emails/workers-api/) + /// configured under `[[send_email]]` in your `wrangler.toml`. Use the + /// returned [`SendEmail`] to dispatch either a structured + /// [`Email`](crate::Email) or a prebuilt + /// [`EmailMessage`](crate::EmailMessage). + pub fn send_email(&self, binding: &str) -> Result { + self.get_binding(binding) + } } pub trait EnvBinding: Sized + JsCast { diff --git a/worker/src/lib.rs b/worker/src/lib.rs index cc90c9f1..c54129ca 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -193,6 +193,9 @@ pub use crate::response::{EncodeBody, IntoResponse, Response, ResponseBody, Resp pub use crate::router::{RouteContext, RouteParams, Router}; pub use crate::schedule::*; pub use crate::secret_store::SecretStore; +pub use crate::send_email::{ + EmailAddress, EmailAttachment, EmailMessage, EmailSendResult, SendEmail, SendEmailBuilder, +}; pub use crate::socket::*; pub use crate::streams::*; pub use crate::version::*; @@ -216,6 +219,19 @@ mod date; mod delay; pub mod durable; mod dynamic_dispatch; +// Generated by `chomp build:types` from types/email.d.ts. Keep it scoped here +// and let the high-level `send_email` module re-export the slice consumers +// need. The codegen produces builder types without `Debug` and an inner +// `email` module that triggers `clippy::module_inception`; allow both since +// they're owned by ts-gen, not us. +#[allow( + unused_imports, + dead_code, + missing_debug_implementations, + clippy::module_inception, + clippy::new_without_default +)] +mod email; mod env; mod error; mod fetcher; @@ -236,6 +252,7 @@ mod router; mod schedule; mod secret_store; pub mod send; +mod send_email; pub mod signal; mod socket; mod sql; diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs new file mode 100644 index 00000000..7d1862c2 --- /dev/null +++ b/worker/src/send_email.rs @@ -0,0 +1,47 @@ +use wasm_bindgen::JsCast; + +use crate::{EnvBinding, Result}; + +// The full email surface comes from the auto-generated `crate::email` +// module (`chomp build:types` from `types/email.d.ts`). This file only +// adds the [`EnvBinding`] trait impl on top of the auto-gen `SendEmail` +// extern type so `Env::send_email("EMAIL")` resolves cleanly. +// +// `EmailMessage` is the constructor class imported from +// `cloudflare:email`, used to build raw-MIME messages and pass to +// `SendEmail.send(message)`. It also doubles as the instance-shape +// type referenced by `ForwardableEmailMessage` and `reply()` — same JS +// object collapsed to a single Rust type, see the d.ts EDIT note for +// the naming rationale. +pub use crate::email::email::EmailMessage; +pub use crate::email::{ + EmailAddress, EmailAttachment, EmailSendResult, SendEmail, SendEmailBuilder, +}; + +impl EnvBinding for SendEmail { + const TYPE_NAME: &'static str = "SendEmail"; + + // `SendEmail` is a TypeScript interface, not a class — the runtime + // doesn't expose a `SendEmail` global for the default + // `constructor.name` check to match against. The TS types are + // authoritative: if `env.EMAIL` is bound to a SendEmail per + // `wrangler.toml`, the runtime hands us the right shape, so we + // skip the check and `unchecked_into`. + fn get(val: wasm_bindgen::JsValue) -> Result { + Ok(val.unchecked_into()) + } +} + +#[cfg(test)] +mod send_check { + // `SendEmail` is `Send` automatically — wasm-bindgen makes `JsValue` + // `Send + Sync` and every extern `pub type` in `email.rs` carries that + // through. This compile-time check guards against an upstream + // regression. + use super::SendEmail; + fn _assert_send() {} + #[allow(dead_code)] + fn _check() { + _assert_send::(); + } +}