From 884bf47e10f759239e613e34f9da6229a022ba4d Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 15 Apr 2026 09:26:54 -0500 Subject: [PATCH 01/14] 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. --- Cargo.lock | 28 +++---- examples/send-email/Cargo.toml | 18 +++++ examples/send-email/README.md | 33 +++++++++ examples/send-email/package.json | 12 +++ examples/send-email/src/lib.rs | 29 ++++++++ examples/send-email/wrangler.toml | 11 +++ test/src/lib.rs | 1 + test/src/router.rs | 6 +- test/src/send_email.rs | 95 ++++++++++++++++++++++++ test/tests/mf.ts | 9 +++ test/tests/send_email.spec.ts | 28 +++++++ test/wrangler.toml | 5 ++ worker-build/src/main.rs | 1 + worker-build/src/main_legacy.rs | 1 + worker-sys/src/types.rs | 2 + worker-sys/src/types/send_email.rs | 21 ++++++ worker/src/env.rs | 9 +++ worker/src/lib.rs | 2 + worker/src/send_email.rs | 113 +++++++++++++++++++++++++++++ 19 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 examples/send-email/Cargo.toml create mode 100644 examples/send-email/README.md create mode 100644 examples/send-email/package.json create mode 100644 examples/send-email/src/lib.rs create mode 100644 examples/send-email/wrangler.toml create mode 100644 test/src/send_email.rs create mode 100644 test/tests/send_email.spec.ts create mode 100644 worker-sys/src/types/send_email.rs create mode 100644 worker/src/send_email.rs diff --git a/Cargo.lock b/Cargo.lock index 227642578..813fc06a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,20 +200,6 @@ dependencies = [ "syn", ] -[[package]] -name = "axum-on-workers" -version = "0.1.0" -dependencies = [ - "axum", - "axum-macros", - "serde", - "tower-service", - "wasm-bindgen", - "wasm-bindgen-futures", - "worker", - "worker-macros", -] - [[package]] name = "base64" version = "0.22.1" @@ -1559,6 +1545,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" @@ -2323,6 +2315,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/examples/send-email/Cargo.toml b/examples/send-email/Cargo.toml new file mode 100644 index 000000000..65bfc70dc --- /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 000000000..97ee0301a --- /dev/null +++ b/examples/send-email/README.md @@ -0,0 +1,33 @@ +# Sending Email from Cloudflare Workers + +Demonstration of using `worker::SendEmail` to dispatch an outbound message +through a `[[send_email]]` binding. + +The MIME body is built with +[`mail-builder`](https://crates.io/crates/mail-builder), wrapped in an +[`EmailMessage`](https://docs.rs/worker/latest/worker/struct.EmailMessage.html), +and handed to `env.send_email("EMAIL")`. + +## Local development + +Running `wrangler dev --local` does **not** actually deliver the email. Per +the [Cloudflare docs](https://developers.cloudflare.com/email-routing/email-workers/local-development/), +outbound messages are simulated by writing each one to a local `.eml` file — +the path is printed in the terminal so you can inspect the raw message. + +```bash +npm install +npm run dev +# then, in another shell: +curl http://localhost:8787/ +``` + +## Deploying + +Before deploying, verify the sender and recipient addresses as documented at +, +then: + +```bash +npm run deploy +``` diff --git a/examples/send-email/package.json b/examples/send-email/package.json new file mode 100644 index 000000000..50fe00331 --- /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" + } +} diff --git a/examples/send-email/src/lib.rs b/examples/send-email/src/lib.rs new file mode 100644 index 000000000..e189ee673 --- /dev/null +++ b/examples/send-email/src/lib.rs @@ -0,0 +1,29 @@ +use mail_builder::MessageBuilder; +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 { + // 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 = MessageBuilder::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 email = EmailMessage::new(SENDER, RECIPIENT, &raw)?; + env.send_email("EMAIL")?.send(&email).await?; + + Response::ok("sent") +} diff --git a/examples/send-email/wrangler.toml b/examples/send-email/wrangler.toml new file mode 100644 index 000000000..6591b41df --- /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/test/src/lib.rs b/test/src/lib.rs index 6fb94a33c..b05027a75 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 socket; mod sql_counter; diff --git a/test/src/router.rs b/test/src/router.rs index b6f348717..5203164fb 100644 --- a/test/src/router.rs +++ b/test/src/router.rs @@ -1,7 +1,8 @@ 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; @@ -239,6 +240,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); }); #[cfg(feature = "http")] diff --git a/test/src/send_email.rs b/test/src/send_email.rs new file mode 100644 index 000000000..8ffeeba1d --- /dev/null +++ b/test/src/send_email.rs @@ -0,0 +1,95 @@ +use crate::SomeSharedData; +use worker::{Date, EmailMessage, Env, Request, Response, Result}; + +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 Scenario { + envelope_from: &'static str, + envelope_to: &'static str, + header_from: &'static str, + include_message_id: bool, +} + +impl Scenario { + fn for_name(name: &str) -> Option { + Some(match name { + "ok" => Self { + envelope_from: SENDER, + envelope_to: RECIPIENT, + header_from: SENDER, + include_message_id: true, + }, + "missing-message-id" => Self { + envelope_from: SENDER, + envelope_to: RECIPIENT, + header_from: SENDER, + include_message_id: false, + }, + "disallowed-sender" => Self { + envelope_from: BAD_SENDER, + envelope_to: RECIPIENT, + header_from: BAD_SENDER, + include_message_id: true, + }, + "disallowed-recipient" => Self { + envelope_from: SENDER, + envelope_to: BAD_RECIPIENT, + header_from: SENDER, + include_message_id: true, + }, + "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 + } +} + +#[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 Some(scenario) = Scenario::for_name(&name) else { + return Response::error(format!("unknown scenario: {name}"), 400); + }; + + let message = EmailMessage::new( + scenario.envelope_from, + scenario.envelope_to, + &scenario.raw(), + )?; + let result = env.send_email("EMAIL")?.send(&message).await; + Response::from_json(&serde_json::json!({ + "ok": result.is_ok(), + "error": result.err().map(|e| e.to_string()), + })) +} diff --git a/test/tests/mf.ts b/test/tests/mf.ts index 3c881b187..8d8003e93 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 000000000..a46af1af7 --- /dev/null +++ b/test/tests/send_email.spec.ts @@ -0,0 +1,28 @@ +import { describe, test, expect } from "vitest"; +import { mf, mfUrl } from "./mf"; + +type SendResult = { ok: boolean; 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; +} + +describe("send email", () => { + test("sends a valid email through the binding", async () => { + const result = await runScenario("ok"); + expect(result).toEqual({ ok: true, error: null }); + }); + + test.each([ + ["missing-message-id", /message-id/i], + ["disallowed-sender", /email from .* not allowed/i], + ["disallowed-recipient", /email to .* not allowed/i], + ["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); + }); +}); diff --git a/test/wrangler.toml b/test/wrangler.toml index 16b49d87e..654208df2 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/worker-build/src/main.rs b/worker-build/src/main.rs index 595592e09..be25e7f15 100644 --- a/worker-build/src/main.rs +++ b/worker-build/src/main.rs @@ -370,6 +370,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 845473421..80566e685 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-sys/src/types.rs b/worker-sys/src/types.rs index afef5bc11..15fe212d3 100644 --- a/worker-sys/src/types.rs +++ b/worker-sys/src/types.rs @@ -17,6 +17,7 @@ mod r2; mod rate_limit; mod schedule; mod secret_store; +mod send_email; mod socket; mod tls_client_auth; mod version; @@ -42,6 +43,7 @@ pub use r2::*; pub use rate_limit::*; pub use schedule::*; pub use secret_store::*; +pub use send_email::*; pub use socket::*; pub use tls_client_auth::*; pub use version::*; diff --git a/worker-sys/src/types/send_email.rs b/worker-sys/src/types/send_email.rs new file mode 100644 index 000000000..97344312f --- /dev/null +++ b/worker-sys/src/types/send_email.rs @@ -0,0 +1,21 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(module = "cloudflare:email")] +extern "C" { + #[wasm_bindgen(extends=js_sys::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] +extern "C" { + #[wasm_bindgen(extends=js_sys::Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type SendEmail; + + #[wasm_bindgen(method, catch)] + pub fn send(this: &SendEmail, message: &EmailMessage) -> Result; +} diff --git a/worker/src/env.rs b/worker/src/env.rs index 780c2585b..ba3a95850 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,14 @@ impl Env { pub fn rate_limiter(&self, binding: &str) -> Result { self.get_binding(binding) } + + /// Access a [send_email binding](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/) + /// configured under `[[send_email]]` in your `wrangler.toml`. Use the + /// returned [`SendEmail`] to dispatch an + /// [`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 bbaead37c..24fa95185 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -193,6 +193,7 @@ 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::{EmailMessage, SendEmail}; pub use crate::socket::*; pub use crate::streams::*; pub use crate::version::*; @@ -236,6 +237,7 @@ mod router; mod schedule; mod secret_store; pub mod send; +mod send_email; mod socket; mod sql; mod streams; diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs new file mode 100644 index 000000000..0bc0e6c9c --- /dev/null +++ b/worker/src/send_email.rs @@ -0,0 +1,113 @@ +use wasm_bindgen::{JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use worker_sys::{EmailMessage as EmailMessageSys, SendEmail as SendEmailSys}; + +use crate::{send::SendFuture, EnvBinding, Result}; + +/// A binding to the [Cloudflare Email Sending] service, declared under +/// `[[send_email]]` in `wrangler.toml` and retrieved via +/// [`Env::send_email`](crate::Env::send_email). +/// +/// Build an [`EmailMessage`] (optionally with a MIME builder such as +/// [`mail-builder`]) and hand it to [`SendEmail::send`]. +/// +/// [Cloudflare Email Sending]: https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/ +/// [`mail-builder`]: https://crates.io/crates/mail-builder +/// +/// ```ignore +/// use worker::*; +/// +/// #[event(fetch)] +/// async fn fetch(_req: Request, env: Env, _ctx: Context) -> Result { +/// let raw = "From: sender@example.com\r\n\ +/// To: recipient@example.com\r\n\ +/// Subject: Hello\r\n\ +/// \r\n\ +/// Hi there!"; +/// let message = EmailMessage::new( +/// "sender@example.com", +/// "recipient@example.com", +/// raw, +/// )?; +/// env.send_email("SEND_EMAIL")?.send(&message).await?; +/// Response::ok("sent") +/// } +/// ``` +#[derive(Debug)] +pub struct SendEmail(SendEmailSys); + +unsafe impl Send for SendEmail {} +unsafe impl Sync for SendEmail {} + +impl EnvBinding for SendEmail { + const TYPE_NAME: &'static str = "SendEmail"; + + // `SendEmail` is a TypeScript interface (not a class) in + // @cloudflare/workers-types, so the runtime doesn't expose a `SendEmail` + // global for the default `constructor.name` check to match against. Skip + // the check and accept whatever object the runtime hands us. + fn get(val: JsValue) -> Result { + Ok(val.unchecked_into()) + } +} + +impl JsCast for SendEmail { + // `SendEmail` has no JS class at runtime (see `EnvBinding::get` above), so + // the wasm-bindgen-generated `val instanceof SendEmail` shim would throw a + // `ReferenceError` if we ever reached it. Fall back to a plain object + // check — good enough for the binding and can't blow up. + fn instanceof(val: &JsValue) -> bool { + val.is_object() + } + + fn unchecked_from_js(val: JsValue) -> Self { + Self(val.into()) + } + + fn unchecked_from_js_ref(val: &JsValue) -> &Self { + unsafe { &*(val as *const JsValue as *const Self) } + } +} + +impl AsRef for SendEmail { + fn as_ref(&self) -> &JsValue { + &self.0 + } +} + +impl From for JsValue { + fn from(sender: SendEmail) -> Self { + JsValue::from(sender.0) + } +} + +impl From for SendEmail { + fn from(inner: SendEmailSys) -> Self { + Self(inner) + } +} + +impl SendEmail { + /// Dispatch a prebuilt [`EmailMessage`] through this binding. + pub async fn send(&self, message: &EmailMessage) -> Result<()> { + let promise = self.0.send(&message.0)?; + SendFuture::new(JsFuture::from(promise)).await?; + Ok(()) + } +} + +/// An RFC 5322 MIME message ready to be handed to [`SendEmail::send`]. +/// +/// The envelope `from`/`to` addresses drive the SMTP `MAIL FROM` and `RCPT TO` +/// commands and may legitimately differ from the `From:`/`To:` headers inside +/// `raw` — for example when implementing bounces, VERP, or BCC. +#[derive(Debug)] +pub struct EmailMessage(EmailMessageSys); + +impl EmailMessage { + /// Build a message from envelope addresses and a fully-formed RFC 5322 + /// MIME body. + pub fn new(from: &str, to: &str, raw: &str) -> Result { + Ok(Self(EmailMessageSys::new(from, to, raw)?)) + } +} From c3bae338ca173aa4d4ddffcf9d7e26c5537fe53d Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Thu, 16 Apr 2026 21:42:13 -0500 Subject: [PATCH 02/14] 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)`. --- examples/send-email/README.md | 21 +- examples/send-email/package.json | 2 +- examples/send-email/src/lib.rs | 38 ++- test/src/send_email.rs | 76 ++++-- test/tests/send_email.spec.ts | 47 +++- worker-sys/src/types/send_email.rs | 5 +- worker/src/env.rs | 5 +- worker/src/lib.rs | 5 +- worker/src/send_email.rs | 418 +++++++++++++++++++++++++++-- 9 files changed, 550 insertions(+), 67 deletions(-) diff --git a/examples/send-email/README.md b/examples/send-email/README.md index 97ee0301a..47faddd04 100644 --- a/examples/send-email/README.md +++ b/examples/send-email/README.md @@ -3,10 +3,18 @@ Demonstration of using `worker::SendEmail` to dispatch an outbound message through a `[[send_email]]` binding. -The MIME body is built with -[`mail-builder`](https://crates.io/crates/mail-builder), wrapped in an -[`EmailMessage`](https://docs.rs/worker/latest/worker/struct.EmailMessage.html), -and handed to `env.send_email("EMAIL")`. +Two paths are shown: + +* `GET /` — the **structured** path, using + [`Message::builder`](https://docs.rs/worker/latest/worker/struct.MessageBuilder.html). + The runtime composes the MIME body from the fields you set (`from`, `to`, + `subject`, `text`/`html`, attachments, etc.). +* `GET /raw` — the **raw MIME** path, using + [`EmailMessage`](https://docs.rs/worker/latest/worker/struct.EmailMessage.html). + The MIME body is built locally with + [`mail-builder`](https://crates.io/crates/mail-builder) and handed verbatim + to the binding. Use this when you need precise control over the MIME + structure (custom headers, DKIM passthrough, VERP bounces, etc.). ## Local development @@ -19,13 +27,14 @@ the path is printed in the terminal so you can inspect the raw message. npm install npm run dev # then, in another shell: -curl http://localhost:8787/ +curl http://localhost:8787/ # structured +curl http://localhost:8787/raw # raw MIME ``` ## Deploying Before deploying, verify the sender and recipient addresses as documented at -, +, then: ```bash diff --git a/examples/send-email/package.json b/examples/send-email/package.json index 50fe00331..f589b6957 100644 --- a/examples/send-email/package.json +++ b/examples/send-email/package.json @@ -7,6 +7,6 @@ "dev": "cargo install worker-build ; wrangler dev --local" }, "devDependencies": { - "wrangler": "^4" + "wrangler": "^4.83.0" } } diff --git a/examples/send-email/src/lib.rs b/examples/send-email/src/lib.rs index e189ee673..3085d4f31 100644 --- a/examples/send-email/src/lib.rs +++ b/examples/send-email/src/lib.rs @@ -1,18 +1,44 @@ -use mail_builder::MessageBuilder; +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 { +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 email = Email::builder() + .from(("Sending email test", SENDER)) + .to(RECIPIENT) + .subject("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()?; + + sender.send(&email).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 = MessageBuilder::new() + let raw = MimeBuilder::new() .from(("Sending email test", SENDER)) .to(RECIPIENT) .subject("An email generated in a Worker") @@ -22,8 +48,6 @@ async fn fetch(_req: Request, env: Env, _ctx: Context) -> Result { .write_to_string() .map_err(|e| Error::RustError(e.to_string()))?; - let email = EmailMessage::new(SENDER, RECIPIENT, &raw)?; - env.send_email("EMAIL")?.send(&email).await?; - - Response::ok("sent") + let message = EmailMessage::new(SENDER, RECIPIENT, &raw)?; + sender.send_mime(&message).await } diff --git a/test/src/send_email.rs b/test/src/send_email.rs index 8ffeeba1d..69c7f4f52 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -1,5 +1,5 @@ use crate::SomeSharedData; -use worker::{Date, EmailMessage, Env, Request, Response, Result}; +use worker::{Date, Email, EmailAddress, EmailMessage, Env, Request, Response, Result, SendEmail}; const SENDER: &str = "allowed-sender@example.com"; const RECIPIENT: &str = "allowed-recipient@example.com"; @@ -7,41 +7,41 @@ const BAD_SENDER: &str = "evil@example.com"; const BAD_RECIPIENT: &str = "nope@example.com"; const MISMATCHED_FROM: &str = "mismatched@example.com"; -struct Scenario { +struct MimeScenario { envelope_from: &'static str, envelope_to: &'static str, header_from: &'static str, include_message_id: bool, } -impl Scenario { +impl MimeScenario { fn for_name(name: &str) -> Option { Some(match name { - "ok" => Self { + "mime-ok" => Self { envelope_from: SENDER, envelope_to: RECIPIENT, header_from: SENDER, include_message_id: true, }, - "missing-message-id" => Self { + "mime-missing-message-id" => Self { envelope_from: SENDER, envelope_to: RECIPIENT, header_from: SENDER, include_message_id: false, }, - "disallowed-sender" => Self { + "mime-disallowed-sender" => Self { envelope_from: BAD_SENDER, envelope_to: RECIPIENT, header_from: BAD_SENDER, include_message_id: true, }, - "disallowed-recipient" => Self { + "mime-disallowed-recipient" => Self { envelope_from: SENDER, envelope_to: BAD_RECIPIENT, header_from: SENDER, include_message_id: true, }, - "from-mismatch" => Self { + "mime-from-mismatch" => Self { envelope_from: SENDER, envelope_to: RECIPIENT, header_from: MISMATCHED_FROM, @@ -70,6 +70,33 @@ impl Scenario { } } +fn build_structured(name: &str) -> Option> { + let builder = match name { + "structured-ok" => Email::builder() + .from(SENDER) + .to(RECIPIENT) + .subject("structured integration test") + .text("hello from the structured path"), + "structured-with-name" => Email::builder() + .from(EmailAddress::with_name("Integration", SENDER)) + .to(RECIPIENT) + .subject("structured integration test") + .html("

hello from the structured path

"), + "structured-disallowed-sender" => Email::builder() + .from(BAD_SENDER) + .to(RECIPIENT) + .subject("structured integration test") + .text("hello"), + "structured-disallowed-recipient" => Email::builder() + .from(SENDER) + .to(BAD_RECIPIENT) + .subject("structured integration test") + .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()?; @@ -78,18 +105,33 @@ pub async fn handle_send_email(req: Request, env: Env, _data: SomeSharedData) -> .find_map(|(k, v)| (k == "scenario").then(|| v.into_owned())) .unwrap_or_default(); - let Some(scenario) = Scenario::for_name(&name) else { - return Response::error(format!("unknown scenario: {name}"), 400); - }; + let sender = env.send_email("EMAIL")?; + + if let Some(scenario) = MimeScenario::for_name(&name) { + return respond(dispatch_mime(&sender, &scenario).await); + } + + if let Some(email_result) = build_structured(&name) { + return respond(dispatch_structured(&sender, email_result).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())?; + sender.send_mime(&message).await.map(|r| r.message_id) +} + +async fn dispatch_structured(sender: &SendEmail, email: Result) -> Result { + let email = email?; + sender.send(&email).await.map(|r| r.message_id) +} - let message = EmailMessage::new( - scenario.envelope_from, - scenario.envelope_to, - &scenario.raw(), - )?; - let result = env.send_email("EMAIL")?.send(&message).await; +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/send_email.spec.ts b/test/tests/send_email.spec.ts index a46af1af7..5dea93ace 100644 --- a/test/tests/send_email.spec.ts +++ b/test/tests/send_email.spec.ts @@ -1,7 +1,11 @@ import { describe, test, expect } from "vitest"; import { mf, mfUrl } from "./mf"; -type SendResult = { ok: boolean; error: string | null }; +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}`); @@ -9,17 +13,44 @@ async function runScenario(name: string): Promise { return (await resp.json()) as SendResult; } -describe("send email", () => { +// Miniflare's send_email binding resolves to `undefined` on success rather +// than `{ messageId }` like real workerd, so `messageId` is expected to be an +// empty string here. We still assert the type/presence. +function expectSuccess(result: SendResult) { + expect(result.error).toBeNull(); + expect(result.ok).toBe(true); + expect(typeof result.messageId).toBe("string"); +} + +describe("send email (raw MIME)", () => { test("sends a valid email through the binding", async () => { - const result = await runScenario("ok"); - expect(result).toEqual({ ok: true, error: null }); + expectSuccess(await runScenario("mime-ok")); + }); + + 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([ - ["missing-message-id", /message-id/i], - ["disallowed-sender", /email from .* not allowed/i], - ["disallowed-recipient", /email to .* not allowed/i], - ["from-mismatch", /from.*does not match/i], + ["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); diff --git a/worker-sys/src/types/send_email.rs b/worker-sys/src/types/send_email.rs index 97344312f..1d5f69f3a 100644 --- a/worker-sys/src/types/send_email.rs +++ b/worker-sys/src/types/send_email.rs @@ -16,6 +16,9 @@ extern "C" { #[derive(Debug, Clone, PartialEq, Eq)] pub type SendEmail; + // The runtime's `send` overload accepts either an `EmailMessage` instance + // or a plain builder object, so the arg is declared as `JsValue` and the + // caller is responsible for constructing the right shape. #[wasm_bindgen(method, catch)] - pub fn send(this: &SendEmail, message: &EmailMessage) -> Result; + pub fn send(this: &SendEmail, message: &JsValue) -> Result; } diff --git a/worker/src/env.rs b/worker/src/env.rs index ba3a95850..72502d96b 100644 --- a/worker/src/env.rs +++ b/worker/src/env.rs @@ -130,9 +130,10 @@ impl Env { self.get_binding(binding) } - /// Access a [send_email binding](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/) + /// 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 an + /// 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) diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 24fa95185..f8f516079 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -193,7 +193,10 @@ 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::{EmailMessage, SendEmail}; +pub use crate::send_email::{ + AttachmentContent, Email, EmailAddress, EmailAttachment, EmailBuilder, EmailMessage, + EmailSendResult, SendEmail, +}; pub use crate::socket::*; pub use crate::streams::*; pub use crate::version::*; diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs index 0bc0e6c9c..fe6acb006 100644 --- a/worker/src/send_email.rs +++ b/worker/src/send_email.rs @@ -1,36 +1,43 @@ +use serde::{ + ser::{SerializeMap, SerializeStruct}, + Deserialize, Serialize, Serializer, +}; use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use worker_sys::{EmailMessage as EmailMessageSys, SendEmail as SendEmailSys}; -use crate::{send::SendFuture, EnvBinding, Result}; +use crate::{error::Error, send::SendFuture, EnvBinding, Result}; /// A binding to the [Cloudflare Email Sending] service, declared under /// `[[send_email]]` in `wrangler.toml` and retrieved via /// [`Env::send_email`](crate::Env::send_email). /// -/// Build an [`EmailMessage`] (optionally with a MIME builder such as -/// [`mail-builder`]) and hand it to [`SendEmail::send`]. +/// Two send paths are supported, mirroring the JS `send()` overloads: /// -/// [Cloudflare Email Sending]: https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/ -/// [`mail-builder`]: https://crates.io/crates/mail-builder +/// * [`SendEmail::send`] — hand it a structured [`Email`]; the runtime builds +/// the MIME body and dispatches. This is the common path. +/// * [`SendEmail::send_mime`] — hand it a prebuilt [`EmailMessage`] (a +/// fully-formed RFC 5322 MIME blob plus envelope addresses) for cases that +/// need precise MIME control. +/// +/// Both return an [`EmailSendResult`] containing the runtime-assigned message +/// id (useful for logging and correlation). +/// +/// [Cloudflare Email Sending]: https://developers.cloudflare.com/email-service/api/send-emails/workers-api/ /// /// ```ignore /// use worker::*; /// /// #[event(fetch)] /// async fn fetch(_req: Request, env: Env, _ctx: Context) -> Result { -/// let raw = "From: sender@example.com\r\n\ -/// To: recipient@example.com\r\n\ -/// Subject: Hello\r\n\ -/// \r\n\ -/// Hi there!"; -/// let message = EmailMessage::new( -/// "sender@example.com", -/// "recipient@example.com", -/// raw, -/// )?; -/// env.send_email("SEND_EMAIL")?.send(&message).await?; -/// Response::ok("sent") +/// let email = Email::builder() +/// .from(("Acme", "noreply@acme.test")) +/// .to("user@example.com") +/// .subject("Welcome") +/// .text("Thanks for signing up.") +/// .build()?; +/// let result = env.send_email("EMAIL")?.send(&email).await?; +/// Response::ok(result.message_id) /// } /// ``` #[derive(Debug)] @@ -88,19 +95,56 @@ impl From for SendEmail { } impl SendEmail { - /// Dispatch a prebuilt [`EmailMessage`] through this binding. - pub async fn send(&self, message: &EmailMessage) -> Result<()> { - let promise = self.0.send(&message.0)?; - SendFuture::new(JsFuture::from(promise)).await?; - Ok(()) + /// Dispatch a structured [`Email`]; the runtime composes the MIME body. + pub async fn send(&self, email: &Email) -> Result { + // `serialize_maps_as_objects(true)` gives plain JS objects for the + // `headers` map; default `serialize_bytes` behavior produces a + // `Uint8Array` for binary attachment content. + let ser = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + let payload = email.serialize(&ser).map_err(JsValue::from)?; + self.send_js(&payload).await + } + + /// Dispatch a prebuilt [`EmailMessage`] containing a fully-formed RFC 5322 + /// MIME body. Prefer [`send`](Self::send) for typical use — this path is + /// for cases where you need precise control over the MIME structure + /// (custom headers, DKIM passthrough, VERP bounces, etc.). + pub async fn send_mime(&self, message: &EmailMessage) -> Result { + self.send_js(message.0.as_ref()).await + } + + async fn send_js(&self, payload: &JsValue) -> Result { + let promise = self.0.send(payload)?; + let value = SendFuture::new(JsFuture::from(promise)).await?; + // Miniflare's `send_email` binding resolves to `undefined`; real + // workerd resolves to `{ messageId }`. Tolerate both so local dev + // with `wrangler dev` doesn't throw on deserialize. + if value.is_undefined() || value.is_null() { + return Ok(EmailSendResult::default()); + } + Ok(serde_wasm_bindgen::from_value(value)?) } } -/// An RFC 5322 MIME message ready to be handed to [`SendEmail::send`]. +/// Return value of a successful send. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EmailSendResult { + /// The runtime-assigned message id (also exposed as the `Message-ID` + /// header on the delivered message). + /// + /// Empty under `miniflare` (used by `wrangler dev`), which resolves the + /// binding with no value; real workerd populates it. + pub message_id: String, +} + +/// An RFC 5322 MIME message ready to be handed to [`SendEmail::send_mime`]. /// /// The envelope `from`/`to` addresses drive the SMTP `MAIL FROM` and `RCPT TO` /// commands and may legitimately differ from the `From:`/`To:` headers inside -/// `raw` — for example when implementing bounces, VERP, or BCC. +/// `raw` — for example when implementing bounces, VERP, or BCC. For everyday +/// use where you don't care about that distinction, prefer [`Email`] and +/// [`SendEmail::send`]. #[derive(Debug)] pub struct EmailMessage(EmailMessageSys); @@ -111,3 +155,329 @@ impl EmailMessage { Ok(Self(EmailMessageSys::new(from, to, raw)?)) } } + +/// An email address, optionally annotated with a display name. +/// +/// Accepts several convenient forms via [`From`]: `"a@b.com"`, +/// `("Name", "a@b.com")`, or construct explicitly via +/// [`EmailAddress::new`]/[`EmailAddress::with_name`]. +#[derive(Debug, Clone)] +pub struct EmailAddress { + pub email: String, + pub name: Option, +} + +impl EmailAddress { + pub fn new(email: impl Into) -> Self { + Self { + email: email.into(), + name: None, + } + } + + pub fn with_name(name: impl Into, email: impl Into) -> Self { + Self { + email: email.into(), + name: Some(name.into()), + } + } +} + +// Emits a bare string when there's no display name, and `{ email, name }` +// otherwise — matches the shape the runtime expects. +impl Serialize for EmailAddress { + fn serialize(&self, serializer: S) -> std::result::Result { + match &self.name { + None => serializer.serialize_str(&self.email), + Some(name) => { + let mut st = serializer.serialize_struct("EmailAddress", 2)?; + st.serialize_field("email", &self.email)?; + st.serialize_field("name", name)?; + st.end() + } + } + } +} + +impl From<&str> for EmailAddress { + fn from(email: &str) -> Self { + Self::new(email) + } +} + +impl From for EmailAddress { + fn from(email: String) -> Self { + Self::new(email) + } +} + +// (name, email) — matches the "Display Name " reading order. +impl, E: Into> From<(N, E)> for EmailAddress { + fn from((name, email): (N, E)) -> Self { + Self::with_name(name, email) + } +} + +/// Content for an [`EmailAttachment`] — either a pre-encoded base64 string or +/// raw bytes (serialized as a `Uint8Array`). +#[derive(Debug, Clone)] +pub enum AttachmentContent { + Base64(String), + Binary(Vec), +} + +impl Serialize for AttachmentContent { + fn serialize(&self, serializer: S) -> std::result::Result { + match self { + AttachmentContent::Base64(s) => serializer.serialize_str(s), + // With the non-`json_compatible` serde_wasm_bindgen serializer this + // produces a `Uint8Array`, which is what the runtime expects. + AttachmentContent::Binary(bytes) => serializer.serialize_bytes(bytes), + } + } +} + +impl From for AttachmentContent { + fn from(s: String) -> Self { + AttachmentContent::Base64(s) + } +} + +impl From<&str> for AttachmentContent { + fn from(s: &str) -> Self { + AttachmentContent::Base64(s.to_owned()) + } +} + +impl From> for AttachmentContent { + fn from(bytes: Vec) -> Self { + AttachmentContent::Binary(bytes) + } +} + +impl From<&[u8]> for AttachmentContent { + fn from(bytes: &[u8]) -> Self { + AttachmentContent::Binary(bytes.to_vec()) + } +} + +/// A file attachment for an [`Email`]. +/// +/// Use [`EmailAttachment::attachment`] for a regular downloadable attachment, +/// or [`EmailAttachment::inline`] for an image referenced by `cid:` in the +/// HTML body (requires a `content_id`). +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "disposition", rename_all = "lowercase")] +pub enum EmailAttachment { + Attachment { + filename: String, + #[serde(rename = "type")] + content_type: String, + content: AttachmentContent, + }, + Inline { + #[serde(rename = "contentId")] + content_id: String, + filename: String, + #[serde(rename = "type")] + content_type: String, + content: AttachmentContent, + }, +} + +impl EmailAttachment { + pub fn attachment( + filename: impl Into, + content_type: impl Into, + content: impl Into, + ) -> Self { + EmailAttachment::Attachment { + filename: filename.into(), + content_type: content_type.into(), + content: content.into(), + } + } + + pub fn inline( + content_id: impl Into, + filename: impl Into, + content_type: impl Into, + content: impl Into, + ) -> Self { + EmailAttachment::Inline { + content_id: content_id.into(), + filename: filename.into(), + content_type: content_type.into(), + content: content.into(), + } + } +} + +/// A structured email message, dispatched via [`SendEmail::send`]. +/// +/// Build with [`Email::builder`]. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Email { + from: EmailAddress, + to: Vec, + subject: String, + #[serde(skip_serializing_if = "Option::is_none")] + html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + text: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + cc: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + bcc: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + reply_to: Option, + // `Vec<(String, String)>` preserves insertion order (duplicate header + // names are meaningful in RFC 5322) but serializes as a plain JS object. + #[serde( + skip_serializing_if = "Vec::is_empty", + serialize_with = "serialize_headers" + )] + headers: Vec<(String, String)>, + #[serde(skip_serializing_if = "Vec::is_empty")] + attachments: Vec, +} + +impl Email { + #[must_use] + pub fn builder() -> EmailBuilder { + EmailBuilder::default() + } +} + +fn serialize_headers( + headers: &[(String, String)], + serializer: S, +) -> std::result::Result { + let mut map = serializer.serialize_map(Some(headers.len()))?; + for (k, v) in headers { + map.serialize_entry(k, v)?; + } + map.end() +} + +/// Fluent builder for [`Email`]. See [`Email::builder`]. +#[derive(Debug, Clone, Default)] +pub struct EmailBuilder { + from: Option, + to: Vec, + subject: Option, + html: Option, + text: Option, + cc: Vec, + bcc: Vec, + reply_to: Option, + headers: Vec<(String, String)>, + attachments: Vec, +} + +impl EmailBuilder { + #[must_use] + pub fn from(mut self, from: impl Into) -> Self { + self.from = Some(from.into()); + self + } + + /// Add a single recipient. Can be called multiple times (the runtime + /// accepts up to 50 recipients per send). + #[must_use] + pub fn to(mut self, recipient: impl Into) -> Self { + self.to.push(recipient.into()); + self + } + + /// Replace the recipient list with the given iterator. + #[must_use] + pub fn to_all(mut self, recipients: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.to = recipients.into_iter().map(Into::into).collect(); + self + } + + #[must_use] + pub fn subject(mut self, subject: impl Into) -> Self { + self.subject = Some(subject.into()); + self + } + + #[must_use] + pub fn html(mut self, html: impl Into) -> Self { + self.html = Some(html.into()); + self + } + + #[must_use] + pub fn text(mut self, text: impl Into) -> Self { + self.text = Some(text.into()); + self + } + + #[must_use] + pub fn cc(mut self, recipient: impl Into) -> Self { + self.cc.push(recipient.into()); + self + } + + #[must_use] + pub fn bcc(mut self, recipient: impl Into) -> Self { + self.bcc.push(recipient.into()); + self + } + + #[must_use] + pub fn reply_to(mut self, reply_to: impl Into) -> Self { + self.reply_to = Some(reply_to.into()); + self + } + + #[must_use] + pub fn header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } + + #[must_use] + pub fn attachment(mut self, attachment: EmailAttachment) -> Self { + self.attachments.push(attachment); + self + } + + /// Finalize the builder. + /// + /// Returns an error if `from`, `to`, or `subject` are missing. Body + /// validation (need at least one of `html` / `text`) is deferred to the + /// runtime, which produces a more specific error. + pub fn build(self) -> Result { + let from = self + .from + .ok_or_else(|| Error::RustError("EmailBuilder::build: missing `from`".into()))?; + if self.to.is_empty() { + return Err(Error::RustError( + "EmailBuilder::build: missing `to`".into(), + )); + } + let subject = self + .subject + .ok_or_else(|| Error::RustError("EmailBuilder::build: missing `subject`".into()))?; + Ok(Email { + from, + to: self.to, + subject, + html: self.html, + text: self.text, + cc: self.cc, + bcc: self.bcc, + reply_to: self.reply_to, + headers: self.headers, + attachments: self.attachments, + }) + } +} From 72e85685875e76fac125a75fca1a9cd47409f881 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Fri, 17 Apr 2026 13:16:11 -0500 Subject: [PATCH 03/14] address pr comments --- test/src/send_email.rs | 6 +++- worker/src/send_email.rs | 64 +++++++++++++++++----------------------- 2 files changed, 32 insertions(+), 38 deletions(-) diff --git a/test/src/send_email.rs b/test/src/send_email.rs index 69c7f4f52..01050a8ff 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -119,7 +119,11 @@ pub async fn handle_send_email(req: Request, env: Env, _data: SomeSharedData) -> } async fn dispatch_mime(sender: &SendEmail, scenario: &MimeScenario) -> Result { - let message = EmailMessage::new(scenario.envelope_from, scenario.envelope_to, &scenario.raw())?; + let message = EmailMessage::new( + scenario.envelope_from, + scenario.envelope_to, + &scenario.raw(), + )?; sender.send_mime(&message).await.map(|r| r.message_id) } diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs index fe6acb006..840477b9a 100644 --- a/worker/src/send_email.rs +++ b/worker/src/send_email.rs @@ -1,7 +1,6 @@ -use serde::{ - ser::{SerializeMap, SerializeStruct}, - Deserialize, Serialize, Serializer, -}; +use std::collections::BTreeMap; + +use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; use worker_sys::{EmailMessage as EmailMessageSys, SendEmail as SendEmailSys}; @@ -218,46 +217,50 @@ impl, E: Into> From<(N, E)> for EmailAddress { } } -/// Content for an [`EmailAttachment`] — either a pre-encoded base64 string or -/// raw bytes (serialized as a `Uint8Array`). +/// Content for an [`EmailAttachment`] — either text (sent as UTF-8 bytes on +/// the wire) or raw bytes (serialized as a `Uint8Array`). +/// +/// If your source is a base64 string, decode it to bytes first and use +/// [`AttachmentContent::Bytes`]. The runtime does not base64-decode string +/// content — it treats it as UTF-8 and would send the literal base64 text. #[derive(Debug, Clone)] pub enum AttachmentContent { - Base64(String), - Binary(Vec), + Text(String), + Bytes(Vec), } impl Serialize for AttachmentContent { fn serialize(&self, serializer: S) -> std::result::Result { match self { - AttachmentContent::Base64(s) => serializer.serialize_str(s), + AttachmentContent::Text(s) => serializer.serialize_str(s), // With the non-`json_compatible` serde_wasm_bindgen serializer this // produces a `Uint8Array`, which is what the runtime expects. - AttachmentContent::Binary(bytes) => serializer.serialize_bytes(bytes), + AttachmentContent::Bytes(bytes) => serializer.serialize_bytes(bytes), } } } impl From for AttachmentContent { fn from(s: String) -> Self { - AttachmentContent::Base64(s) + AttachmentContent::Text(s) } } impl From<&str> for AttachmentContent { fn from(s: &str) -> Self { - AttachmentContent::Base64(s.to_owned()) + AttachmentContent::Text(s.to_owned()) } } impl From> for AttachmentContent { fn from(bytes: Vec) -> Self { - AttachmentContent::Binary(bytes) + AttachmentContent::Bytes(bytes) } } impl From<&[u8]> for AttachmentContent { fn from(bytes: &[u8]) -> Self { - AttachmentContent::Binary(bytes.to_vec()) + AttachmentContent::Bytes(bytes.to_vec()) } } @@ -332,13 +335,10 @@ pub struct Email { bcc: Vec, #[serde(skip_serializing_if = "Option::is_none")] reply_to: Option, - // `Vec<(String, String)>` preserves insertion order (duplicate header - // names are meaningful in RFC 5322) but serializes as a plain JS object. - #[serde( - skip_serializing_if = "Vec::is_empty", - serialize_with = "serialize_headers" - )] - headers: Vec<(String, String)>, + // Runtime side is `jsg::Dict` — one value per header name + // survives the wire, so a map up front makes the constraint explicit. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + headers: BTreeMap, #[serde(skip_serializing_if = "Vec::is_empty")] attachments: Vec, } @@ -350,17 +350,6 @@ impl Email { } } -fn serialize_headers( - headers: &[(String, String)], - serializer: S, -) -> std::result::Result { - let mut map = serializer.serialize_map(Some(headers.len()))?; - for (k, v) in headers { - map.serialize_entry(k, v)?; - } - map.end() -} - /// Fluent builder for [`Email`]. See [`Email::builder`]. #[derive(Debug, Clone, Default)] pub struct EmailBuilder { @@ -372,7 +361,7 @@ pub struct EmailBuilder { cc: Vec, bcc: Vec, reply_to: Option, - headers: Vec<(String, String)>, + headers: BTreeMap, attachments: Vec, } @@ -438,9 +427,12 @@ impl EmailBuilder { self } + /// Set a custom header. Calling with the same `name` twice overwrites the + /// previous value — the runtime's header map only preserves one value per + /// name. #[must_use] pub fn header(mut self, name: impl Into, value: impl Into) -> Self { - self.headers.push((name.into(), value.into())); + self.headers.insert(name.into(), value.into()); self } @@ -460,9 +452,7 @@ impl EmailBuilder { .from .ok_or_else(|| Error::RustError("EmailBuilder::build: missing `from`".into()))?; if self.to.is_empty() { - return Err(Error::RustError( - "EmailBuilder::build: missing `to`".into(), - )); + return Err(Error::RustError("EmailBuilder::build: missing `to`".into())); } let subject = self .subject From 73c632b8c06f6494c6861bb42160dca5280f53a9 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Mon, 20 Apr 2026 09:58:24 -0500 Subject: [PATCH 04/14] remove miniflare workaround https://github.com/cloudflare/workers-sdk/pull/13577 landed --- package-lock.json | 64 +++++++++++++++++------------------ package.json | 2 +- test/tests/send_email.spec.ts | 5 +-- worker/src/send_email.rs | 11 +----- 4 files changed, 35 insertions(+), 47 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f1ade3a3..60f2a5dbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@types/node": "^24.0.1", - "miniflare": "^4.20260329.0", + "miniflare": "^4.20260420.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.20260329.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260329.1.tgz", - "integrity": "sha512-oyDXYlPBuGXKkZ85+M3jFz0/qYmvA4AEURN8USIGPDCR5q+HFSRwywSd9neTx3Wi7jhey2wuYaEpD3fEFWyWUA==", + "version": "1.20260420.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260420.1.tgz", + "integrity": "sha512-Y6HtAY+pS5INiD9HyO1JvvujZO24mD3eqRwPZlLXBkcT+wW8bTOve/8mVKErEzEtZ5LkuT3tJqG9py8TxQEBgw==", "cpu": [ "x64" ], @@ -367,9 +367,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260329.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260329.1.tgz", - "integrity": "sha512-++ZxVa3ovzYeDLEG6zMqql9gzZAG8vak6ZSBQgprGKZp7akr+GKTpw9f3RrMP552NSi3gTisroLobrrkPBtYLQ==", + "version": "1.20260420.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260420.1.tgz", + "integrity": "sha512-7aiRtZTc5S4aKcL6uIx+B3tCzb/bULjQmE67/03k0HtaDNzP20GnYmYpFCqleFqsdmIb4Tx8PkKPmsXI3AJLvQ==", "cpu": [ "arm64" ], @@ -384,9 +384,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260329.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260329.1.tgz", - "integrity": "sha512-kkeywAgIHwbqHkVILqbj/YkfbrA6ARbmutjiYzZA2MwMSfNXlw6/kedAKOY8YwcymZIgepx3YTIPnBP50pOotw==", + "version": "1.20260420.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260420.1.tgz", + "integrity": "sha512-J/DW149FPmug1wSM32zBF7My14xg+inIYwzS4bSAxyXR6tBiTxbhgFWQQz99nt08ZMstdKHRD6f6C/KQaleQcA==", "cpu": [ "x64" ], @@ -401,9 +401,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260329.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260329.1.tgz", - "integrity": "sha512-eYBN20+B7XOUSWEe0mlqkMUbfLoIKjKZnpqQiSxnLbL72JKY0D/KlfN/b7RVGLpewB7i8rTrwTNr0szCKnZzSQ==", + "version": "1.20260420.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260420.1.tgz", + "integrity": "sha512-a5I147McRM/L4YHu9EwOsoAyIExZndPRQoLx/33dbw/yUEnO825gvn5QZkCGXBVL2JwsPAyowB0Xliqrj+71Sw==", "cpu": [ "arm64" ], @@ -418,9 +418,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260329.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260329.1.tgz", - "integrity": "sha512-5R+/oxrDhS9nL3oA3ZWtD6ndMOqm7RfKknDNxLcmYW5DkUu7UH3J/s1t/Dz66iFePzr5BJmE7/8gbmve6TjtZQ==", + "version": "1.20260420.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260420.1.tgz", + "integrity": "sha512-ZrHqlHbJNU8P24EAOBaZ6B44G9P+po2z0DBwbAr8965aWR+vohy3cfmgE9uzNPAQfKNmvq7fmc4VwsRpERkg0w==", "cpu": [ "x64" ], @@ -2736,16 +2736,16 @@ } }, "node_modules/miniflare": { - "version": "4.20260329.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260329.0.tgz", - "integrity": "sha512-+G+1YFVeuEpw/gZZmUHQR7IfzJV+DDGvnSl0yXzhgvHh8Nbr8Go5uiWIwl17EyZ1Uors3FKUMDUyU6+ejeKZOw==", + "version": "4.20260420.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260420.0.tgz", + "integrity": "sha512-w8s3eh2W7EEsFh2uGdddZLkbTwiPI8MCSMXKtuLSA9btW8xmQsVVSkrFuLXFyTKcX0QkstS5dhcWjQPQRJ2WKg==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", - "undici": "7.24.4", - "workerd": "1.20260329.1", + "undici": "7.24.8", + "workerd": "1.20260420.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, @@ -3480,9 +3480,9 @@ } }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", "dev": true, "license": "MIT", "engines": { @@ -3783,9 +3783,9 @@ } }, "node_modules/workerd": { - "version": "1.20260329.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260329.1.tgz", - "integrity": "sha512-+ifMv3uBuD33ee7pan5n8+sgVxm2u5HnbgfXzHKwMNTKw86znqBJSnJoBqtP88+2T5U2Lu11xXUt+khPYioXwQ==", + "version": "1.20260420.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260420.1.tgz", + "integrity": "sha512-1AOJgng169u4fiFrEd5WjrAGpdwd3A4ZJtP8PMvf+RF9NUKy+mdwrKdz4qPZ6Tt/Bya99vsLn6UX33fjAEVoaA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -3796,11 +3796,11 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260329.1", - "@cloudflare/workerd-darwin-arm64": "1.20260329.1", - "@cloudflare/workerd-linux-64": "1.20260329.1", - "@cloudflare/workerd-linux-arm64": "1.20260329.1", - "@cloudflare/workerd-windows-64": "1.20260329.1" + "@cloudflare/workerd-darwin-64": "1.20260420.1", + "@cloudflare/workerd-darwin-arm64": "1.20260420.1", + "@cloudflare/workerd-linux-64": "1.20260420.1", + "@cloudflare/workerd-linux-arm64": "1.20260420.1", + "@cloudflare/workerd-windows-64": "1.20260420.1" } }, "node_modules/ws": { diff --git a/package.json b/package.json index 2a586a07e..2a371f4e9 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.20260329.0", + "miniflare": "^4.20260420.0", "typescript": "^5.8.3", "uuid": "^11.1.0", "vitest": "^3.2.4" diff --git a/test/tests/send_email.spec.ts b/test/tests/send_email.spec.ts index 5dea93ace..141a9fe9f 100644 --- a/test/tests/send_email.spec.ts +++ b/test/tests/send_email.spec.ts @@ -13,13 +13,10 @@ async function runScenario(name: string): Promise { return (await resp.json()) as SendResult; } -// Miniflare's send_email binding resolves to `undefined` on success rather -// than `{ messageId }` like real workerd, so `messageId` is expected to be an -// empty string here. We still assert the type/presence. function expectSuccess(result: SendResult) { expect(result.error).toBeNull(); expect(result.ok).toBe(true); - expect(typeof result.messageId).toBe("string"); + expect(result.messageId).toBeTruthy(); } describe("send email (raw MIME)", () => { diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs index 840477b9a..c97a99b3b 100644 --- a/worker/src/send_email.rs +++ b/worker/src/send_email.rs @@ -115,25 +115,16 @@ impl SendEmail { async fn send_js(&self, payload: &JsValue) -> Result { let promise = self.0.send(payload)?; let value = SendFuture::new(JsFuture::from(promise)).await?; - // Miniflare's `send_email` binding resolves to `undefined`; real - // workerd resolves to `{ messageId }`. Tolerate both so local dev - // with `wrangler dev` doesn't throw on deserialize. - if value.is_undefined() || value.is_null() { - return Ok(EmailSendResult::default()); - } Ok(serde_wasm_bindgen::from_value(value)?) } } /// Return value of a successful send. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EmailSendResult { /// The runtime-assigned message id (also exposed as the `Message-ID` /// header on the delivered message). - /// - /// Empty under `miniflare` (used by `wrangler dev`), which resolves the - /// binding with no value; real workerd populates it. pub message_id: String, } From 7f07e367908e2e40dc651b466c4fc4fdef313fbf Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Sun, 26 Apr 2026 21:05:30 -0500 Subject: [PATCH 05/14] cleanup comments & example readme --- examples/send-email/README.md | 28 +++++++--------------------- worker-sys/src/types/send_email.rs | 5 ++--- worker/src/send_email.rs | 28 ++++++++++++++-------------- 3 files changed, 23 insertions(+), 38 deletions(-) diff --git a/examples/send-email/README.md b/examples/send-email/README.md index 47faddd04..ff3ef9783 100644 --- a/examples/send-email/README.md +++ b/examples/send-email/README.md @@ -1,27 +1,15 @@ -# Sending Email from Cloudflare Workers +# Sending email from Cloudflare Workers -Demonstration of using `worker::SendEmail` to dispatch an outbound message -through a `[[send_email]]` binding. +Example of using `worker::SendEmail` to send a message through a `[[send_email]]` binding. -Two paths are shown: +Two routes: -* `GET /` — the **structured** path, using - [`Message::builder`](https://docs.rs/worker/latest/worker/struct.MessageBuilder.html). - The runtime composes the MIME body from the fields you set (`from`, `to`, - `subject`, `text`/`html`, attachments, etc.). -* `GET /raw` — the **raw MIME** path, using - [`EmailMessage`](https://docs.rs/worker/latest/worker/struct.EmailMessage.html). - The MIME body is built locally with - [`mail-builder`](https://crates.io/crates/mail-builder) and handed verbatim - to the binding. Use this when you need precise control over the MIME - structure (custom headers, DKIM passthrough, VERP bounces, etc.). +* `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 -Running `wrangler dev --local` does **not** actually deliver the email. Per -the [Cloudflare docs](https://developers.cloudflare.com/email-routing/email-workers/local-development/), -outbound messages are simulated by writing each one to a local `.eml` file — -the path is printed in the terminal so you can inspect the raw message. +`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 @@ -33,9 +21,7 @@ curl http://localhost:8787/raw # raw MIME ## Deploying -Before deploying, verify the sender and recipient addresses as documented at -, -then: +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/worker-sys/src/types/send_email.rs b/worker-sys/src/types/send_email.rs index 1d5f69f3a..6fa564373 100644 --- a/worker-sys/src/types/send_email.rs +++ b/worker-sys/src/types/send_email.rs @@ -16,9 +16,8 @@ extern "C" { #[derive(Debug, Clone, PartialEq, Eq)] pub type SendEmail; - // The runtime's `send` overload accepts either an `EmailMessage` instance - // or a plain builder object, so the arg is declared as `JsValue` and the - // caller is responsible for constructing the right shape. + // `send` takes either an `EmailMessage` or a builder object, so this + // is `JsValue` — the caller picks which. #[wasm_bindgen(method, catch)] pub fn send(this: &SendEmail, message: &JsValue) -> Result; } diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs index c97a99b3b..3304ddebf 100644 --- a/worker/src/send_email.rs +++ b/worker/src/send_email.rs @@ -11,16 +11,16 @@ use crate::{error::Error, send::SendFuture, EnvBinding, Result}; /// `[[send_email]]` in `wrangler.toml` and retrieved via /// [`Env::send_email`](crate::Env::send_email). /// -/// Two send paths are supported, mirroring the JS `send()` overloads: +/// There are two send paths, mirroring the JS `send()` overloads: /// -/// * [`SendEmail::send`] — hand it a structured [`Email`]; the runtime builds +/// * [`SendEmail::send`] takes a structured [`Email`]; the runtime builds /// the MIME body and dispatches. This is the common path. -/// * [`SendEmail::send_mime`] — hand it a prebuilt [`EmailMessage`] (a +/// * [`SendEmail::send_mime`] takes a prebuilt [`EmailMessage`] (a /// fully-formed RFC 5322 MIME blob plus envelope addresses) for cases that /// need precise MIME control. /// /// Both return an [`EmailSendResult`] containing the runtime-assigned message -/// id (useful for logging and correlation). +/// id, which is useful for logging and correlation. /// /// [Cloudflare Email Sending]: https://developers.cloudflare.com/email-service/api/send-emails/workers-api/ /// @@ -61,7 +61,7 @@ impl JsCast for SendEmail { // `SendEmail` has no JS class at runtime (see `EnvBinding::get` above), so // the wasm-bindgen-generated `val instanceof SendEmail` shim would throw a // `ReferenceError` if we ever reached it. Fall back to a plain object - // check — good enough for the binding and can't blow up. + // check, which is good enough for the binding and can't blow up. fn instanceof(val: &JsValue) -> bool { val.is_object() } @@ -105,7 +105,7 @@ impl SendEmail { } /// Dispatch a prebuilt [`EmailMessage`] containing a fully-formed RFC 5322 - /// MIME body. Prefer [`send`](Self::send) for typical use — this path is + /// MIME body. Prefer [`send`](Self::send) for typical use; this path is /// for cases where you need precise control over the MIME structure /// (custom headers, DKIM passthrough, VERP bounces, etc.). pub async fn send_mime(&self, message: &EmailMessage) -> Result { @@ -132,8 +132,8 @@ pub struct EmailSendResult { /// /// The envelope `from`/`to` addresses drive the SMTP `MAIL FROM` and `RCPT TO` /// commands and may legitimately differ from the `From:`/`To:` headers inside -/// `raw` — for example when implementing bounces, VERP, or BCC. For everyday -/// use where you don't care about that distinction, prefer [`Email`] and +/// `raw`. That matters for bounces, VERP, or BCC. For everyday use where you +/// don't care about the distinction, prefer [`Email`] and /// [`SendEmail::send`]. #[derive(Debug)] pub struct EmailMessage(EmailMessageSys); @@ -208,12 +208,12 @@ impl, E: Into> From<(N, E)> for EmailAddress { } } -/// Content for an [`EmailAttachment`] — either text (sent as UTF-8 bytes on +/// Content for an [`EmailAttachment`]: either text (sent as UTF-8 bytes on /// the wire) or raw bytes (serialized as a `Uint8Array`). /// /// If your source is a base64 string, decode it to bytes first and use /// [`AttachmentContent::Bytes`]. The runtime does not base64-decode string -/// content — it treats it as UTF-8 and would send the literal base64 text. +/// content; it treats it as UTF-8 and would send the literal base64 text. #[derive(Debug, Clone)] pub enum AttachmentContent { Text(String), @@ -326,8 +326,8 @@ pub struct Email { bcc: Vec, #[serde(skip_serializing_if = "Option::is_none")] reply_to: Option, - // Runtime side is `jsg::Dict` — one value per header name - // survives the wire, so a map up front makes the constraint explicit. + // Runtime side is `jsg::Dict`, so only one value per header + // name survives the wire. A map up front makes that constraint explicit. #[serde(skip_serializing_if = "BTreeMap::is_empty")] headers: BTreeMap, #[serde(skip_serializing_if = "Vec::is_empty")] @@ -419,8 +419,8 @@ impl EmailBuilder { } /// Set a custom header. Calling with the same `name` twice overwrites the - /// previous value — the runtime's header map only preserves one value per - /// name. + /// previous value, since the runtime's header map only preserves one value + /// per name. #[must_use] pub fn header(mut self, name: impl Into, value: impl Into) -> Self { self.headers.insert(name.into(), value.into()); From 7aa8b24c7935441f4ee54e2b6ddc4c6cfc0f4e51 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 28 Apr 2026 20:55:48 -0700 Subject: [PATCH 06/14] use ts-gen for email --- .gitmodules | 3 + chompfile.toml | 19 ++ examples/send-email/src/lib.rs | 2 +- test/src/send_email.rs | 4 +- ts-gen | 1 + types/email.d.ts | 133 +++++++++++++ worker-sys/src/types.rs | 2 - worker-sys/src/types/send_email.rs | 23 --- worker/src/email.rs | 292 +++++++++++++++++++++++++++++ worker/src/lib.rs | 13 ++ worker/src/send_email.rs | 73 +++----- 11 files changed, 487 insertions(+), 78 deletions(-) create mode 160000 ts-gen create mode 100644 types/email.d.ts delete mode 100644 worker-sys/src/types/send_email.rs create mode 100644 worker/src/email.rs diff --git a/.gitmodules b/.gitmodules index c0cb169f1..d6bcf086c 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/chompfile.toml b/chompfile.toml index 01f062301..623c65bda 100644 --- a/chompfile.toml +++ b/chompfile.toml @@ -1,5 +1,24 @@ version = 0.1 +[[task]] +name = 'build:types' +deps = ['install:ts-gen'] +# Externals point unresolved types at the high-level `worker` crate's re-exports +# (or the underlying web-sys / js-sys types). The generated file lives inside +# `worker/` and is included via `mod email;` in worker/src/lib.rs. +run = '''ts-gen --input types/email.d.ts --output worker/src/email.rs \ + --external "ReadableStream=::web_sys::ReadableStream" \ + --external "Headers=::web_sys::Headers" \ + --external "Event=::web_sys::Event" \ + --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/src/lib.rs b/examples/send-email/src/lib.rs index 3085d4f31..89e6e99df 100644 --- a/examples/send-email/src/lib.rs +++ b/examples/send-email/src/lib.rs @@ -16,7 +16,7 @@ async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { _ => return Response::error("not found", 404), }; - Response::ok(format!("sent: {}", result.message_id)) + Response::ok(format!("sent: {}", result.message_id())) } async fn send_structured(sender: &SendEmail) -> Result { diff --git a/test/src/send_email.rs b/test/src/send_email.rs index 01050a8ff..61bd5b2b8 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -124,12 +124,12 @@ async fn dispatch_mime(sender: &SendEmail, scenario: &MimeScenario) -> Result) -> Result { let email = email?; - sender.send(&email).await.map(|r| r.message_id) + sender.send(&email).await.map(|r| r.message_id()) } fn respond(result: Result) -> Result { diff --git a/ts-gen b/ts-gen new file mode 160000 index 000000000..cf8ae5b8a --- /dev/null +++ b/ts-gen @@ -0,0 +1 @@ +Subproject commit cf8ae5b8a3e20978365532c22eaf12e8e31c2596 diff --git a/types/email.d.ts b/types/email.d.ts new file mode 100644 index 000000000..232051cc0 --- /dev/null +++ b/types/email.d.ts @@ -0,0 +1,133 @@ +/* + * 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; +} +/** + * An email message that can be sent from a Worker. + */ +interface EmailMessage { + /** + * Envelope From attribute of the email message. + */ + readonly from: string; + /** + * Envelope To attribute of the email message. + */ + readonly to: string; +} +/** + * 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; +declare module "cloudflare:email" { + let _EmailMessage: { + prototype: EmailMessage; + // EDIT: + new (from: string, to: string, raw: string | ReadableStream): EmailMessage; + }; + export { _EmailMessage as EmailMessage }; +} diff --git a/worker-sys/src/types.rs b/worker-sys/src/types.rs index 15fe212d3..afef5bc11 100644 --- a/worker-sys/src/types.rs +++ b/worker-sys/src/types.rs @@ -17,7 +17,6 @@ mod r2; mod rate_limit; mod schedule; mod secret_store; -mod send_email; mod socket; mod tls_client_auth; mod version; @@ -43,7 +42,6 @@ pub use r2::*; pub use rate_limit::*; pub use schedule::*; pub use secret_store::*; -pub use send_email::*; pub use socket::*; pub use tls_client_auth::*; pub use version::*; diff --git a/worker-sys/src/types/send_email.rs b/worker-sys/src/types/send_email.rs deleted file mode 100644 index 6fa564373..000000000 --- a/worker-sys/src/types/send_email.rs +++ /dev/null @@ -1,23 +0,0 @@ -use wasm_bindgen::prelude::*; - -#[wasm_bindgen(module = "cloudflare:email")] -extern "C" { - #[wasm_bindgen(extends=js_sys::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] -extern "C" { - #[wasm_bindgen(extends=js_sys::Object)] - #[derive(Debug, Clone, PartialEq, Eq)] - pub type SendEmail; - - // `send` takes either an `EmailMessage` or a builder object, so this - // is `JsValue` — the caller picks which. - #[wasm_bindgen(method, catch)] - pub fn send(this: &SendEmail, message: &JsValue) -> Result; -} diff --git a/worker/src/email.rs b/worker/src/email.rs new file mode 100644 index 000000000..afb5a6f0a --- /dev/null +++ b/worker/src/email.rs @@ -0,0 +1,292 @@ +#[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 { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + #[allow(unused_imports)] + use wasm_bindgen::JsCast; + JsCast::unchecked_into(js_sys::Object::new()) + } + pub fn builder() -> EmailSendResultBuilder { + EmailSendResultBuilder { + inner: Self::new(), + required: 1u64, + } + } +} +pub struct EmailSendResultBuilder { + inner: EmailSendResult, + required: u64, +} +#[allow(unused_mut)] +impl EmailSendResultBuilder { + pub fn message_id(mut self, val: &str) -> Self { + self.inner.set_message_id(val); + self.required &= 18446744073709551614u64; + self + } + pub fn build(self) -> Result { + if self.required != 0 { + let mut missing = Vec::new(); + if self.required & 1u64 != 0 { + missing.push("missing required property `messageId`"); + } + return Err(JsValue::from_str(&format!( + "{}: {}", + stringify!(EmailSendResult), + missing.join(", ") + ))); + } + Ok(self.inner) + } +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = Object)] + #[derive(Debug, Clone, PartialEq, Eq)] + pub type EmailMessage; + #[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; +} +impl EmailMessage { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + #[allow(unused_unsafe)] + unsafe { + JsValue::from(js_sys::Object::new()).unchecked_into() + } + } +} +#[wasm_bindgen] +extern "C" { + # [wasm_bindgen (extends = 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: &EmailMessage, + ) -> Result; +} +#[allow(dead_code)] +pub type EmailAttachment = JsValue; +#[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 { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + #[allow(unused_imports)] + use wasm_bindgen::JsCast; + JsCast::unchecked_into(js_sys::Object::new()) + } + pub fn builder() -> EmailAddressBuilder { + EmailAddressBuilder { + inner: Self::new(), + required: 3u64, + } + } +} +pub struct EmailAddressBuilder { + inner: EmailAddress, + required: u64, +} +#[allow(unused_mut)] +impl EmailAddressBuilder { + pub fn name(mut self, val: &str) -> Self { + self.inner.set_name(val); + self.required &= 18446744073709551614u64; + self + } + pub fn email(mut self, val: &str) -> Self { + self.inner.set_email(val); + self.required &= 18446744073709551613u64; + self + } + pub fn build(self) -> Result { + if self.required != 0 { + let mut missing = Vec::new(); + if self.required & 1u64 != 0 { + missing.push("missing required property `name`"); + } + if self.required & 2u64 != 0 { + missing.push("missing required property `email`"); + } + return Err(JsValue::from_str(&format!( + "{}: {}", + stringify!(EmailAddress), + missing.join(", ") + ))); + } + Ok(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: &EmailMessage) + -> Result; + #[wasm_bindgen(method, catch, js_name = "send")] + pub async fn send_with_builder( + this: &SendEmail, + builder: &Object, + ) -> Result; +} +#[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; + } +} diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 9766a1f9d..560fb578d 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -220,6 +220,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; diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs index 3304ddebf..2f88f7587 100644 --- a/worker/src/send_email.rs +++ b/worker/src/send_email.rs @@ -1,11 +1,19 @@ use std::collections::BTreeMap; -use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; +use serde::{ser::SerializeStruct, Serialize, Serializer}; use wasm_bindgen::{JsCast, JsValue}; -use wasm_bindgen_futures::JsFuture; -use worker_sys::{EmailMessage as EmailMessageSys, SendEmail as SendEmailSys}; -use crate::{error::Error, send::SendFuture, EnvBinding, Result}; +use crate::{error::Error, EnvBinding, Result}; + +// JS-side bindings come from the auto-generated `crate::email` module +// (`chomp build:types` from `types/email.d.ts`). We re-export the slice +// users actually need, keeping this file focused on the high-level +// ergonomics — builders, address typing, attachment shaping — that don't +// have a 1:1 with the JS API. +pub use crate::email::email::EmailMessage; +pub use crate::email::EmailSendResult; + +use crate::email::SendEmail as SendEmailSys; /// A binding to the [Cloudflare Email Sending] service, declared under /// `[[send_email]]` in `wrangler.toml` and retrieved via @@ -36,7 +44,7 @@ use crate::{error::Error, send::SendFuture, EnvBinding, Result}; /// .text("Thanks for signing up.") /// .build()?; /// let result = env.send_email("EMAIL")?.send(&email).await?; -/// Response::ok(result.message_id) +/// Response::ok(result.message_id()) /// } /// ``` #[derive(Debug)] @@ -67,7 +75,7 @@ impl JsCast for SendEmail { } fn unchecked_from_js(val: JsValue) -> Self { - Self(val.into()) + Self(val.unchecked_into()) } fn unchecked_from_js_ref(val: &JsValue) -> &Self { @@ -77,19 +85,13 @@ impl JsCast for SendEmail { impl AsRef for SendEmail { fn as_ref(&self) -> &JsValue { - &self.0 + self.0.as_ref() } } impl From for JsValue { fn from(sender: SendEmail) -> Self { - JsValue::from(sender.0) - } -} - -impl From for SendEmail { - fn from(inner: SendEmailSys) -> Self { - Self(inner) + sender.0.into() } } @@ -100,8 +102,8 @@ impl SendEmail { // `headers` map; default `serialize_bytes` behavior produces a // `Uint8Array` for binary attachment content. let ser = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); - let payload = email.serialize(&ser).map_err(JsValue::from)?; - self.send_js(&payload).await + let payload = email.serialize(&ser)?; + Ok(self.0.send_with_builder(payload.unchecked_ref()).await?) } /// Dispatch a prebuilt [`EmailMessage`] containing a fully-formed RFC 5322 @@ -109,40 +111,11 @@ impl SendEmail { /// for cases where you need precise control over the MIME structure /// (custom headers, DKIM passthrough, VERP bounces, etc.). pub async fn send_mime(&self, message: &EmailMessage) -> Result { - self.send_js(message.0.as_ref()).await - } - - async fn send_js(&self, payload: &JsValue) -> Result { - let promise = self.0.send(payload)?; - let value = SendFuture::new(JsFuture::from(promise)).await?; - Ok(serde_wasm_bindgen::from_value(value)?) - } -} - -/// Return value of a successful send. -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EmailSendResult { - /// The runtime-assigned message id (also exposed as the `Message-ID` - /// header on the delivered message). - pub message_id: String, -} - -/// An RFC 5322 MIME message ready to be handed to [`SendEmail::send_mime`]. -/// -/// The envelope `from`/`to` addresses drive the SMTP `MAIL FROM` and `RCPT TO` -/// commands and may legitimately differ from the `From:`/`To:` headers inside -/// `raw`. That matters for bounces, VERP, or BCC. For everyday use where you -/// don't care about the distinction, prefer [`Email`] and -/// [`SendEmail::send`]. -#[derive(Debug)] -pub struct EmailMessage(EmailMessageSys); - -impl EmailMessage { - /// Build a message from envelope addresses and a fully-formed RFC 5322 - /// MIME body. - pub fn new(from: &str, to: &str, raw: &str) -> Result { - Ok(Self(EmailMessageSys::new(from, to, raw)?)) + // The TS spec types `SendEmail.send` against the global `EmailMessage` + // interface, but the constructor lives on the `cloudflare:email` + // module-scoped subtype. Cast through `JsValue` to feed it to the + // interface-typed binding. + Ok(self.0.send(message.unchecked_ref()).await?) } } From 737fabd63487dfa4248e95705e3581cb401173f8 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 28 Apr 2026 22:49:56 -0700 Subject: [PATCH 07/14] Auto-gen email bindings via ts-gen anonymous interface synthesis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chompfile.toml | 8 +- examples/send-email/src/lib.rs | 12 +- test/src/send_email.rs | 56 ++-- ts-gen | 2 +- types/email.d.ts | 51 +++- worker/src/email.rs | 304 +++++++++++++++++++++- worker/src/lib.rs | 4 +- worker/src/send_email.rs | 455 ++------------------------------- 8 files changed, 413 insertions(+), 479 deletions(-) diff --git a/chompfile.toml b/chompfile.toml index 623c65bda..7c4858b2d 100644 --- a/chompfile.toml +++ b/chompfile.toml @@ -11,7 +11,13 @@ run = '''ts-gen --input types/email.d.ts --output worker/src/email.rs \ --external "Headers=::web_sys::Headers" \ --external "Event=::web_sys::Event" \ --external "Env=crate::Env" \ - --external "ExecutionContext=crate::Context"''' + --external "ExecutionContext=crate::Context" +# Top-level `SendEmail.send(message: &EmailMessage)` references +# `email::EmailMessage` (the constructor class inside `pub mod email`), +# but ts-gen does not yet emit a `use email::EmailMessage` for that +# cross-module reference. Prepend it ourselves; removable once ts-gen +# resolves the same-file module imports natively. +node -e "const f=require('fs');const p='worker/src/email.rs';f.writeFileSync(p,'#[allow(unused_imports)] use email::EmailMessage;\n'+f.readFileSync(p,'utf8'))"''' [[task]] name = 'install:ts-gen' diff --git a/examples/send-email/src/lib.rs b/examples/send-email/src/lib.rs index 89e6e99df..6e90d6f21 100644 --- a/examples/send-email/src/lib.rs +++ b/examples/send-email/src/lib.rs @@ -20,15 +20,19 @@ async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { } async fn send_structured(sender: &SendEmail) -> Result { - let email = Email::builder() - .from(("Sending email test", SENDER)) + let from = EmailAddress::builder() + .name("Sending email test") + .email(SENDER) + .build()?; + let builder = SendEmailBuilder::builder() + .from_with_email_address(&from) .to(RECIPIENT) .subject("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()?; - sender.send(&email).await + Ok(sender.send_with_builder(&builder).await?) } async fn send_raw_mime(sender: &SendEmail) -> Result { @@ -49,5 +53,5 @@ async fn send_raw_mime(sender: &SendEmail) -> Result { .map_err(|e| Error::RustError(e.to_string()))?; let message = EmailMessage::new(SENDER, RECIPIENT, &raw)?; - sender.send_mime(&message).await + Ok(sender.send(&message).await?) } diff --git a/test/src/send_email.rs b/test/src/send_email.rs index 61bd5b2b8..b08529083 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -1,5 +1,7 @@ use crate::SomeSharedData; -use worker::{Date, Email, EmailAddress, EmailMessage, Env, Request, Response, Result, SendEmail}; +use worker::{ + Date, EmailAddress, EmailMessage, Env, Request, Response, Result, SendEmail, SendEmailBuilder, +}; const SENDER: &str = "allowed-sender@example.com"; const RECIPIENT: &str = "allowed-recipient@example.com"; @@ -70,31 +72,39 @@ impl MimeScenario { } } -fn build_structured(name: &str) -> Option> { +fn build_structured(name: &str) -> Option> { + let builder = SendEmailBuilder::builder(); let builder = match name { - "structured-ok" => Email::builder() + "structured-ok" => builder .from(SENDER) .to(RECIPIENT) .subject("structured integration test") .text("hello from the structured path"), - "structured-with-name" => Email::builder() - .from(EmailAddress::with_name("Integration", SENDER)) - .to(RECIPIENT) - .subject("structured integration test") - .html("

hello from the structured path

"), - "structured-disallowed-sender" => Email::builder() + "structured-with-name" => { + let address = EmailAddress::builder() + .name("Integration") + .email(SENDER) + .build() + .ok()?; + builder + .from_with_email_address(&address) + .to(RECIPIENT) + .subject("structured integration test") + .html("

hello from the structured path

") + } + "structured-disallowed-sender" => builder .from(BAD_SENDER) .to(RECIPIENT) .subject("structured integration test") .text("hello"), - "structured-disallowed-recipient" => Email::builder() + "structured-disallowed-recipient" => builder .from(SENDER) .to(BAD_RECIPIENT) .subject("structured integration test") .text("hello"), _ => return None, }; - Some(builder.build()) + Some(builder.build().map_err(Into::into)) } #[worker::send] @@ -111,25 +121,27 @@ pub async fn handle_send_email(req: Request, env: Env, _data: SomeSharedData) -> return respond(dispatch_mime(&sender, &scenario).await); } - if let Some(email_result) = build_structured(&name) { - return respond(dispatch_structured(&sender, email_result).await); + if let Some(builder_result) = build_structured(&name) { + return respond(dispatch_structured(&sender, builder_result).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(), - )?; - sender.send_mime(&message).await.map(|r| r.message_id()) + let message = + EmailMessage::new(scenario.envelope_from, scenario.envelope_to, &scenario.raw())?; + let result = sender.send(&message).await?; + Ok(result.message_id()) } -async fn dispatch_structured(sender: &SendEmail, email: Result) -> Result { - let email = email?; - sender.send(&email).await.map(|r| r.message_id()) +async fn dispatch_structured( + sender: &SendEmail, + builder: Result, +) -> Result { + let builder = builder?; + let result = sender.send_with_builder(&builder).await?; + Ok(result.message_id()) } fn respond(result: Result) -> Result { diff --git a/ts-gen b/ts-gen index cf8ae5b8a..70964e9c5 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit cf8ae5b8a3e20978365532c22eaf12e8e31c2596 +Subproject commit 70964e9c5b9aeac33b4ffc91ff824934df23fff3 diff --git a/types/email.d.ts b/types/email.d.ts index 232051cc0..5f66c6806 100644 --- a/types/email.d.ts +++ b/types/email.d.ts @@ -27,10 +27,44 @@ interface EmailSendResult { */ messageId: string; } +// EDIT: upstream declares the legacy email constructor inside +// `declare module "cloudflare:email" { let _EmailMessage: { new(...) }; +// export { _EmailMessage as EmailMessage } }` paired with a global +// `interface EmailMessage` of the same name. ts-gen then emits two +// distinct Rust types, which forces hand-rolled `unchecked_ref` casts at +// every call site. +// +// Until ts-gen learns to fully unify the same-named module export with +// the global interface, we sidestep by giving the *interface* a distinct +// name (`StructuredEmailMessage`) and letting the module-scoped class +// keep the runtime name `EmailMessage`. So: +// +// * `EmailMessage` — module-scoped class imported from +// `cloudflare:email`, used to construct raw-MIME messages and pass +// to `SendEmail.send(message)`. +// * `StructuredEmailMessage` — global interface, the envelope-getters +// view exposed on `ForwardableEmailMessage` and elsewhere. +// +// Two names, two types — but the wasm-bindgen import `EmailMessage` +// matches the runtime export, so no shim renaming is required. +declare module "cloudflare:email" { + class EmailMessage { + constructor(from: string, to: string, raw: string | ReadableStream); + readonly from: string; + readonly to: string; + } + export { EmailMessage }; +} +import { EmailMessage } from "cloudflare:email"; + /** * An email message that can be sent from a Worker. + * + * EDIT: renamed from upstream's `EmailMessage` to avoid the same-name + * collision with the `cloudflare:email` constructor class. See the + * EDIT note on the module declaration below for the rationale. */ -interface EmailMessage { +interface StructuredEmailMessage { /** * Envelope From attribute of the email message. */ @@ -43,7 +77,7 @@ interface EmailMessage { /** * An email message that is sent to a consumer Worker and can be rejected/forwarded. */ -interface ForwardableEmailMessage extends EmailMessage { +interface ForwardableEmailMessage extends StructuredEmailMessage { /** * Stream of the email message content. */ @@ -74,7 +108,7 @@ interface ForwardableEmailMessage extends EmailMessage { * @param message The reply message. * @returns A promise that resolves when the email message is replied. */ - reply(message: EmailMessage): Promise; + reply(message: StructuredEmailMessage): Promise; } /** A file attachment for an email message */ type EmailAttachment = @@ -101,6 +135,9 @@ interface EmailAddress { * A binding that allows a Worker to send email messages. */ interface SendEmail { + // EDIT: this overload takes the `cloudflare:email`-imported + // `EmailMessage` class directly — see the EDIT note on that module + // declaration above for the naming rationale. send(message: EmailMessage): Promise; send(builder: { from: string | EmailAddress; @@ -123,11 +160,3 @@ declare type EmailExportedHandler = ( env: Env, ctx: ExecutionContext, ) => void | Promise; -declare module "cloudflare:email" { - let _EmailMessage: { - prototype: EmailMessage; - // EDIT: - new (from: string, to: string, raw: string | ReadableStream): EmailMessage; - }; - export { _EmailMessage as EmailMessage }; -} diff --git a/worker/src/email.rs b/worker/src/email.rs index afb5a6f0a..4226d5e02 100644 --- a/worker/src/email.rs +++ b/worker/src/email.rs @@ -1,3 +1,4 @@ +#[allow(unused_imports)] use email::EmailMessage; #[allow(dead_code)] use crate::Context as ExecutionContext; #[allow(dead_code)] @@ -83,15 +84,15 @@ impl EmailSendResultBuilder { extern "C" { # [wasm_bindgen (extends = Object)] #[derive(Debug, Clone, PartialEq, Eq)] - pub type EmailMessage; + pub type StructuredEmailMessage; #[doc = " Envelope From attribute of the email message."] #[wasm_bindgen(method, getter)] - pub fn from(this: &EmailMessage) -> String; + pub fn from(this: &StructuredEmailMessage) -> String; #[doc = " Envelope To attribute of the email message."] #[wasm_bindgen(method, getter)] - pub fn to(this: &EmailMessage) -> String; + pub fn to(this: &StructuredEmailMessage) -> String; } -impl EmailMessage { +impl StructuredEmailMessage { #[allow(clippy::new_without_default)] pub fn new() -> Self { #[allow(unused_unsafe)] @@ -102,7 +103,7 @@ impl EmailMessage { } #[wasm_bindgen] extern "C" { - # [wasm_bindgen (extends = EmailMessage , extends = Object)] + # [wasm_bindgen (extends = StructuredEmailMessage , extends = Object)] #[derive(Debug, Clone, PartialEq, Eq)] pub type ForwardableEmailMessage; #[doc = " Stream of the email message content."] @@ -179,11 +180,130 @@ extern "C" { #[wasm_bindgen(method, catch)] pub async fn reply( this: &ForwardableEmailMessage, - message: &EmailMessage, + message: &StructuredEmailMessage, ) -> Result; } -#[allow(dead_code)] -pub type EmailAttachment = JsValue; +#[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 { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + #[allow(unused_imports)] + use wasm_bindgen::JsCast; + JsCast::unchecked_into(js_sys::Object::new()) + } + pub fn builder() -> EmailAttachmentBuilder { + EmailAttachmentBuilder { + inner: Self::new(), + required: 15u64, + } + } +} +pub struct EmailAttachmentBuilder { + inner: EmailAttachment, + required: u64, +} +#[allow(unused_mut)] +impl EmailAttachmentBuilder { + pub fn content(mut self, val: &str) -> Self { + self.inner.set_content(val); + self.required &= 18446744073709551614u64; + self + } + pub fn content_with_array_buffer(mut self, val: &ArrayBuffer) -> Self { + self.inner.set_content_with_array_buffer(val); + self.required &= 18446744073709551614u64; + self + } + pub fn content_with_js_value(mut self, val: &Object) -> Self { + self.inner.set_content_with_js_value(val); + self.required &= 18446744073709551614u64; + self + } + pub fn content_id(mut self, val: &str) -> Self { + self.inner.set_content_id(val); + self + } + pub fn content_id_with_undefined(mut self, val: &Undefined) -> Self { + self.inner.set_content_id_with_undefined(val); + self + } + pub fn disposition(mut self, val: &str) -> Self { + self.inner.set_disposition(val); + self.required &= 18446744073709551613u64; + self + } + pub fn disposition_with_js_value(mut self, val: &str) -> Self { + self.inner.set_disposition_with_js_value(val); + self.required &= 18446744073709551613u64; + self + } + pub fn filename(mut self, val: &str) -> Self { + self.inner.set_filename(val); + self.required &= 18446744073709551611u64; + self + } + pub fn r#type(mut self, val: &str) -> Self { + self.inner.set_type(val); + self.required &= 18446744073709551607u64; + self + } + pub fn build(self) -> Result { + if self.required != 0 { + let mut missing = Vec::new(); + if self.required & 1u64 != 0 { + missing.push("missing required property `content`"); + } + if self.required & 2u64 != 0 { + missing.push("missing required property `disposition`"); + } + if self.required & 4u64 != 0 { + missing.push("missing required property `filename`"); + } + if self.required & 8u64 != 0 { + missing.push("missing required property `type`"); + } + return Err(JsValue::from_str(&format!( + "{}: {}", + stringify!(EmailAttachment), + missing.join(", ") + ))); + } + Ok(self.inner) + } +} #[wasm_bindgen] extern "C" { # [wasm_bindgen (extends = Object)] @@ -257,10 +377,172 @@ extern "C" { #[wasm_bindgen(method, catch, js_name = "send")] pub async fn send_with_builder( this: &SendEmail, - builder: &Object, + 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 { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + #[allow(unused_imports)] + use wasm_bindgen::JsCast; + JsCast::unchecked_into(js_sys::Object::new()) + } + pub fn builder() -> SendEmailBuilderBuilder { + SendEmailBuilderBuilder { + inner: Self::new(), + required: 7u64, + } + } +} +pub struct SendEmailBuilderBuilder { + inner: SendEmailBuilder, + required: u64, +} +#[allow(unused_mut)] +impl SendEmailBuilderBuilder { + pub fn from(mut self, val: &str) -> Self { + self.inner.set_from(val); + self.required &= 18446744073709551614u64; + self + } + pub fn from_with_email_address(mut self, val: &EmailAddress) -> Self { + self.inner.set_from_with_email_address(val); + self.required &= 18446744073709551614u64; + self + } + pub fn to(mut self, val: &str) -> Self { + self.inner.set_to(val); + self.required &= 18446744073709551613u64; + self + } + pub fn to_with_array(mut self, val: &Array) -> Self { + self.inner.set_to_with_array(val); + self.required &= 18446744073709551613u64; + self + } + pub fn subject(mut self, val: &str) -> Self { + self.inner.set_subject(val); + self.required &= 18446744073709551611u64; + self + } + pub fn reply_to(mut self, val: &str) -> Self { + self.inner.set_reply_to(val); + self + } + pub fn reply_to_with_email_address(mut self, val: &EmailAddress) -> Self { + self.inner.set_reply_to_with_email_address(val); + self + } + pub fn cc(mut self, val: &str) -> Self { + self.inner.set_cc(val); + self + } + pub fn cc_with_array(mut self, val: &Array) -> Self { + self.inner.set_cc_with_array(val); + self + } + pub fn bcc(mut self, val: &str) -> Self { + self.inner.set_bcc(val); + self + } + pub fn bcc_with_array(mut self, val: &Array) -> Self { + self.inner.set_bcc_with_array(val); + self + } + pub fn headers(mut self, val: &Object) -> Self { + self.inner.set_headers(val); + self + } + pub fn text(mut self, val: &str) -> Self { + self.inner.set_text(val); + self + } + pub fn html(mut self, val: &str) -> Self { + self.inner.set_html(val); + self + } + pub fn attachments(mut self, val: &Array) -> Self { + self.inner.set_attachments(val); + self + } + pub fn build(self) -> Result { + if self.required != 0 { + let mut missing = Vec::new(); + if self.required & 1u64 != 0 { + missing.push("missing required property `from`"); + } + if self.required & 2u64 != 0 { + missing.push("missing required property `to`"); + } + if self.required & 4u64 != 0 { + missing.push("missing required property `subject`"); + } + return Err(JsValue::from_str(&format!( + "{}: {}", + stringify!(SendEmailBuilder), + missing.join(", ") + ))); + } + Ok(self.inner) + } +} +#[wasm_bindgen] extern "C" { # [wasm_bindgen (extends = ExtendableEvent , extends = Object)] #[derive(Debug, Clone, PartialEq, Eq)] @@ -288,5 +570,9 @@ pub mod email { to: &str, raw: &ReadableStream, ) -> Result; + #[wasm_bindgen(method, getter)] + pub fn from(this: &EmailMessage) -> String; + #[wasm_bindgen(method, getter)] + pub fn to(this: &EmailMessage) -> String; } } diff --git a/worker/src/lib.rs b/worker/src/lib.rs index 560fb578d..2e7ea66e0 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -194,8 +194,8 @@ pub use crate::router::{RouteContext, RouteParams, Router}; pub use crate::schedule::*; pub use crate::secret_store::SecretStore; pub use crate::send_email::{ - AttachmentContent, Email, EmailAddress, EmailAttachment, EmailBuilder, EmailMessage, - EmailSendResult, SendEmail, + EmailAddress, EmailAttachment, EmailMessage, EmailSendResult, SendEmail, SendEmailBuilder, + StructuredEmailMessage, }; pub use crate::socket::*; pub use crate::streams::*; diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs index 2f88f7587..819ecda23 100644 --- a/worker/src/send_email.rs +++ b/worker/src/send_email.rs @@ -1,437 +1,34 @@ -use std::collections::BTreeMap; - -use serde::{ser::SerializeStruct, Serialize, Serializer}; -use wasm_bindgen::{JsCast, JsValue}; - -use crate::{error::Error, EnvBinding, Result}; - -// JS-side bindings come from the auto-generated `crate::email` module -// (`chomp build:types` from `types/email.d.ts`). We re-export the slice -// users actually need, keeping this file focused on the high-level -// ergonomics — builders, address typing, attachment shaping — that don't -// have a 1:1 with the JS API. +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)`. `StructuredEmailMessage` is the global +// envelope-getter interface exposed on `ForwardableEmailMessage` and +// `reply`. Same JS object, two distinct Rust types — see the d.ts EDIT +// note for the naming rationale. pub use crate::email::email::EmailMessage; -pub use crate::email::EmailSendResult; - -use crate::email::SendEmail as SendEmailSys; - -/// A binding to the [Cloudflare Email Sending] service, declared under -/// `[[send_email]]` in `wrangler.toml` and retrieved via -/// [`Env::send_email`](crate::Env::send_email). -/// -/// There are two send paths, mirroring the JS `send()` overloads: -/// -/// * [`SendEmail::send`] takes a structured [`Email`]; the runtime builds -/// the MIME body and dispatches. This is the common path. -/// * [`SendEmail::send_mime`] takes a prebuilt [`EmailMessage`] (a -/// fully-formed RFC 5322 MIME blob plus envelope addresses) for cases that -/// need precise MIME control. -/// -/// Both return an [`EmailSendResult`] containing the runtime-assigned message -/// id, which is useful for logging and correlation. -/// -/// [Cloudflare Email Sending]: https://developers.cloudflare.com/email-service/api/send-emails/workers-api/ -/// -/// ```ignore -/// use worker::*; -/// -/// #[event(fetch)] -/// async fn fetch(_req: Request, env: Env, _ctx: Context) -> Result { -/// let email = Email::builder() -/// .from(("Acme", "noreply@acme.test")) -/// .to("user@example.com") -/// .subject("Welcome") -/// .text("Thanks for signing up.") -/// .build()?; -/// let result = env.send_email("EMAIL")?.send(&email).await?; -/// Response::ok(result.message_id()) -/// } -/// ``` -#[derive(Debug)] -pub struct SendEmail(SendEmailSys); - -unsafe impl Send for SendEmail {} -unsafe impl Sync for SendEmail {} +pub use crate::email::{ + EmailAddress, EmailAttachment, EmailSendResult, SendEmail, SendEmailBuilder, + StructuredEmailMessage, +}; impl EnvBinding for SendEmail { const TYPE_NAME: &'static str = "SendEmail"; - // `SendEmail` is a TypeScript interface (not a class) in - // @cloudflare/workers-types, so the runtime doesn't expose a `SendEmail` - // global for the default `constructor.name` check to match against. Skip - // the check and accept whatever object the runtime hands us. - fn get(val: JsValue) -> Result { + // `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()) } } - -impl JsCast for SendEmail { - // `SendEmail` has no JS class at runtime (see `EnvBinding::get` above), so - // the wasm-bindgen-generated `val instanceof SendEmail` shim would throw a - // `ReferenceError` if we ever reached it. Fall back to a plain object - // check, which is good enough for the binding and can't blow up. - fn instanceof(val: &JsValue) -> bool { - val.is_object() - } - - fn unchecked_from_js(val: JsValue) -> Self { - Self(val.unchecked_into()) - } - - fn unchecked_from_js_ref(val: &JsValue) -> &Self { - unsafe { &*(val as *const JsValue as *const Self) } - } -} - -impl AsRef for SendEmail { - fn as_ref(&self) -> &JsValue { - self.0.as_ref() - } -} - -impl From for JsValue { - fn from(sender: SendEmail) -> Self { - sender.0.into() - } -} - -impl SendEmail { - /// Dispatch a structured [`Email`]; the runtime composes the MIME body. - pub async fn send(&self, email: &Email) -> Result { - // `serialize_maps_as_objects(true)` gives plain JS objects for the - // `headers` map; default `serialize_bytes` behavior produces a - // `Uint8Array` for binary attachment content. - let ser = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); - let payload = email.serialize(&ser)?; - Ok(self.0.send_with_builder(payload.unchecked_ref()).await?) - } - - /// Dispatch a prebuilt [`EmailMessage`] containing a fully-formed RFC 5322 - /// MIME body. Prefer [`send`](Self::send) for typical use; this path is - /// for cases where you need precise control over the MIME structure - /// (custom headers, DKIM passthrough, VERP bounces, etc.). - pub async fn send_mime(&self, message: &EmailMessage) -> Result { - // The TS spec types `SendEmail.send` against the global `EmailMessage` - // interface, but the constructor lives on the `cloudflare:email` - // module-scoped subtype. Cast through `JsValue` to feed it to the - // interface-typed binding. - Ok(self.0.send(message.unchecked_ref()).await?) - } -} - -/// An email address, optionally annotated with a display name. -/// -/// Accepts several convenient forms via [`From`]: `"a@b.com"`, -/// `("Name", "a@b.com")`, or construct explicitly via -/// [`EmailAddress::new`]/[`EmailAddress::with_name`]. -#[derive(Debug, Clone)] -pub struct EmailAddress { - pub email: String, - pub name: Option, -} - -impl EmailAddress { - pub fn new(email: impl Into) -> Self { - Self { - email: email.into(), - name: None, - } - } - - pub fn with_name(name: impl Into, email: impl Into) -> Self { - Self { - email: email.into(), - name: Some(name.into()), - } - } -} - -// Emits a bare string when there's no display name, and `{ email, name }` -// otherwise — matches the shape the runtime expects. -impl Serialize for EmailAddress { - fn serialize(&self, serializer: S) -> std::result::Result { - match &self.name { - None => serializer.serialize_str(&self.email), - Some(name) => { - let mut st = serializer.serialize_struct("EmailAddress", 2)?; - st.serialize_field("email", &self.email)?; - st.serialize_field("name", name)?; - st.end() - } - } - } -} - -impl From<&str> for EmailAddress { - fn from(email: &str) -> Self { - Self::new(email) - } -} - -impl From for EmailAddress { - fn from(email: String) -> Self { - Self::new(email) - } -} - -// (name, email) — matches the "Display Name " reading order. -impl, E: Into> From<(N, E)> for EmailAddress { - fn from((name, email): (N, E)) -> Self { - Self::with_name(name, email) - } -} - -/// Content for an [`EmailAttachment`]: either text (sent as UTF-8 bytes on -/// the wire) or raw bytes (serialized as a `Uint8Array`). -/// -/// If your source is a base64 string, decode it to bytes first and use -/// [`AttachmentContent::Bytes`]. The runtime does not base64-decode string -/// content; it treats it as UTF-8 and would send the literal base64 text. -#[derive(Debug, Clone)] -pub enum AttachmentContent { - Text(String), - Bytes(Vec), -} - -impl Serialize for AttachmentContent { - fn serialize(&self, serializer: S) -> std::result::Result { - match self { - AttachmentContent::Text(s) => serializer.serialize_str(s), - // With the non-`json_compatible` serde_wasm_bindgen serializer this - // produces a `Uint8Array`, which is what the runtime expects. - AttachmentContent::Bytes(bytes) => serializer.serialize_bytes(bytes), - } - } -} - -impl From for AttachmentContent { - fn from(s: String) -> Self { - AttachmentContent::Text(s) - } -} - -impl From<&str> for AttachmentContent { - fn from(s: &str) -> Self { - AttachmentContent::Text(s.to_owned()) - } -} - -impl From> for AttachmentContent { - fn from(bytes: Vec) -> Self { - AttachmentContent::Bytes(bytes) - } -} - -impl From<&[u8]> for AttachmentContent { - fn from(bytes: &[u8]) -> Self { - AttachmentContent::Bytes(bytes.to_vec()) - } -} - -/// A file attachment for an [`Email`]. -/// -/// Use [`EmailAttachment::attachment`] for a regular downloadable attachment, -/// or [`EmailAttachment::inline`] for an image referenced by `cid:` in the -/// HTML body (requires a `content_id`). -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "disposition", rename_all = "lowercase")] -pub enum EmailAttachment { - Attachment { - filename: String, - #[serde(rename = "type")] - content_type: String, - content: AttachmentContent, - }, - Inline { - #[serde(rename = "contentId")] - content_id: String, - filename: String, - #[serde(rename = "type")] - content_type: String, - content: AttachmentContent, - }, -} - -impl EmailAttachment { - pub fn attachment( - filename: impl Into, - content_type: impl Into, - content: impl Into, - ) -> Self { - EmailAttachment::Attachment { - filename: filename.into(), - content_type: content_type.into(), - content: content.into(), - } - } - - pub fn inline( - content_id: impl Into, - filename: impl Into, - content_type: impl Into, - content: impl Into, - ) -> Self { - EmailAttachment::Inline { - content_id: content_id.into(), - filename: filename.into(), - content_type: content_type.into(), - content: content.into(), - } - } -} - -/// A structured email message, dispatched via [`SendEmail::send`]. -/// -/// Build with [`Email::builder`]. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Email { - from: EmailAddress, - to: Vec, - subject: String, - #[serde(skip_serializing_if = "Option::is_none")] - html: Option, - #[serde(skip_serializing_if = "Option::is_none")] - text: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - cc: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - bcc: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - reply_to: Option, - // Runtime side is `jsg::Dict`, so only one value per header - // name survives the wire. A map up front makes that constraint explicit. - #[serde(skip_serializing_if = "BTreeMap::is_empty")] - headers: BTreeMap, - #[serde(skip_serializing_if = "Vec::is_empty")] - attachments: Vec, -} - -impl Email { - #[must_use] - pub fn builder() -> EmailBuilder { - EmailBuilder::default() - } -} - -/// Fluent builder for [`Email`]. See [`Email::builder`]. -#[derive(Debug, Clone, Default)] -pub struct EmailBuilder { - from: Option, - to: Vec, - subject: Option, - html: Option, - text: Option, - cc: Vec, - bcc: Vec, - reply_to: Option, - headers: BTreeMap, - attachments: Vec, -} - -impl EmailBuilder { - #[must_use] - pub fn from(mut self, from: impl Into) -> Self { - self.from = Some(from.into()); - self - } - - /// Add a single recipient. Can be called multiple times (the runtime - /// accepts up to 50 recipients per send). - #[must_use] - pub fn to(mut self, recipient: impl Into) -> Self { - self.to.push(recipient.into()); - self - } - - /// Replace the recipient list with the given iterator. - #[must_use] - pub fn to_all(mut self, recipients: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.to = recipients.into_iter().map(Into::into).collect(); - self - } - - #[must_use] - pub fn subject(mut self, subject: impl Into) -> Self { - self.subject = Some(subject.into()); - self - } - - #[must_use] - pub fn html(mut self, html: impl Into) -> Self { - self.html = Some(html.into()); - self - } - - #[must_use] - pub fn text(mut self, text: impl Into) -> Self { - self.text = Some(text.into()); - self - } - - #[must_use] - pub fn cc(mut self, recipient: impl Into) -> Self { - self.cc.push(recipient.into()); - self - } - - #[must_use] - pub fn bcc(mut self, recipient: impl Into) -> Self { - self.bcc.push(recipient.into()); - self - } - - #[must_use] - pub fn reply_to(mut self, reply_to: impl Into) -> Self { - self.reply_to = Some(reply_to.into()); - self - } - - /// Set a custom header. Calling with the same `name` twice overwrites the - /// previous value, since the runtime's header map only preserves one value - /// per name. - #[must_use] - pub fn header(mut self, name: impl Into, value: impl Into) -> Self { - self.headers.insert(name.into(), value.into()); - self - } - - #[must_use] - pub fn attachment(mut self, attachment: EmailAttachment) -> Self { - self.attachments.push(attachment); - self - } - - /// Finalize the builder. - /// - /// Returns an error if `from`, `to`, or `subject` are missing. Body - /// validation (need at least one of `html` / `text`) is deferred to the - /// runtime, which produces a more specific error. - pub fn build(self) -> Result { - let from = self - .from - .ok_or_else(|| Error::RustError("EmailBuilder::build: missing `from`".into()))?; - if self.to.is_empty() { - return Err(Error::RustError("EmailBuilder::build: missing `to`".into())); - } - let subject = self - .subject - .ok_or_else(|| Error::RustError("EmailBuilder::build: missing `subject`".into()))?; - Ok(Email { - from, - to: self.to, - subject, - html: self.html, - text: self.text, - cc: self.cc, - bcc: self.bcc, - reply_to: self.reply_to, - headers: self.headers, - attachments: self.attachments, - }) - } -} From 1998f926d72334b32bf957f4b24eb6b2d3538e37 Mon Sep 17 00:00:00 2001 From: Connor Hindley Date: Wed, 29 Apr 2026 08:56:00 -0500 Subject: [PATCH 08/14] add new_with_readable_stream test --- test/src/send_email.rs | 38 ++++++++++++++++++++++++++++++++--- test/tests/send_email.spec.ts | 4 ++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/test/src/send_email.rs b/test/src/send_email.rs index b08529083..065d18c34 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -1,6 +1,9 @@ use crate::SomeSharedData; +use futures_util::stream::once; +use wasm_bindgen::JsCast; use worker::{ - Date, EmailAddress, EmailMessage, Env, Request, Response, Result, SendEmail, SendEmailBuilder, + web_sys, worker_sys, Date, EmailAddress, EmailMessage, Env, FixedLengthStream, Request, + Response, Result, SendEmail, SendEmailBuilder, }; const SENDER: &str = "allowed-sender@example.com"; @@ -117,6 +120,10 @@ pub async fn handle_send_email(req: Request, env: Env, _data: SomeSharedData) -> 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); } @@ -129,8 +136,33 @@ pub async fn handle_send_email(req: Request, env: Env, _data: SomeSharedData) -> } async fn dispatch_mime(sender: &SendEmail, scenario: &MimeScenario) -> Result { - let message = - EmailMessage::new(scenario.envelope_from, scenario.envelope_to, &scenario.raw())?; + 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. +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 + .unchecked_into::() + .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()) } diff --git a/test/tests/send_email.spec.ts b/test/tests/send_email.spec.ts index 141a9fe9f..7e15b4238 100644 --- a/test/tests/send_email.spec.ts +++ b/test/tests/send_email.spec.ts @@ -24,6 +24,10 @@ describe("send email (raw MIME)", () => { 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], From 5096560ef58fdecb40981c4bae610b75da5ca9b5 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 29 Apr 2026 11:49:30 -0700 Subject: [PATCH 09/14] Pull cross-module qualification + new builder ergonomics from ts-gen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- chompfile.toml | 17 +- examples/send-email/src/lib.rs | 20 +- test/src/send_email.rs | 45 +-- ts-gen | 2 +- types/email.d.ts | 67 ++-- worker/src/email.rs | 580 ++++++++++++++++++++------------- worker/src/lib.rs | 1 - worker/src/send_email.rs | 23 +- 8 files changed, 416 insertions(+), 339 deletions(-) diff --git a/chompfile.toml b/chompfile.toml index 7c4858b2d..2d1d315ac 100644 --- a/chompfile.toml +++ b/chompfile.toml @@ -3,21 +3,12 @@ version = 0.1 [[task]] name = 'build:types' deps = ['install:ts-gen'] -# Externals point unresolved types at the high-level `worker` crate's re-exports -# (or the underlying web-sys / js-sys types). The generated file lives inside -# `worker/` and is included via `mod email;` in worker/src/lib.rs. +# `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 "ReadableStream=::web_sys::ReadableStream" \ - --external "Headers=::web_sys::Headers" \ - --external "Event=::web_sys::Event" \ --external "Env=crate::Env" \ - --external "ExecutionContext=crate::Context" -# Top-level `SendEmail.send(message: &EmailMessage)` references -# `email::EmailMessage` (the constructor class inside `pub mod email`), -# but ts-gen does not yet emit a `use email::EmailMessage` for that -# cross-module reference. Prepend it ourselves; removable once ts-gen -# resolves the same-file module imports natively. -node -e "const f=require('fs');const p='worker/src/email.rs';f.writeFileSync(p,'#[allow(unused_imports)] use email::EmailMessage;\n'+f.readFileSync(p,'utf8'))"''' + --external "ExecutionContext=crate::Context"''' [[task]] name = 'install:ts-gen' diff --git a/examples/send-email/src/lib.rs b/examples/send-email/src/lib.rs index 6e90d6f21..d28f131ba 100644 --- a/examples/send-email/src/lib.rs +++ b/examples/send-email/src/lib.rs @@ -20,17 +20,15 @@ async fn fetch(req: Request, env: Env, _ctx: Context) -> Result { } async fn send_structured(sender: &SendEmail) -> Result { - let from = EmailAddress::builder() - .name("Sending email test") - .email(SENDER) - .build()?; - let builder = SendEmailBuilder::builder() - .from_with_email_address(&from) - .to(RECIPIENT) - .subject("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()?; + 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?) } diff --git a/test/src/send_email.rs b/test/src/send_email.rs index 065d18c34..ae5066e2e 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -75,39 +75,25 @@ impl MimeScenario { } } -fn build_structured(name: &str) -> Option> { - let builder = SendEmailBuilder::builder(); +fn build_structured(name: &str) -> Option { + let subject = "structured integration test"; let builder = match name { - "structured-ok" => builder - .from(SENDER) - .to(RECIPIENT) - .subject("structured integration test") + "structured-ok" => SendEmailBuilder::builder(SENDER, RECIPIENT, subject) .text("hello from the structured path"), "structured-with-name" => { - let address = EmailAddress::builder() - .name("Integration") - .email(SENDER) - .build() - .ok()?; - builder - .from_with_email_address(&address) - .to(RECIPIENT) - .subject("structured integration test") + 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" => builder - .from(BAD_SENDER) - .to(RECIPIENT) - .subject("structured integration test") - .text("hello"), - "structured-disallowed-recipient" => builder - .from(SENDER) - .to(BAD_RECIPIENT) - .subject("structured integration test") - .text("hello"), + "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().map_err(Into::into)) + Some(builder.build()) } #[worker::send] @@ -128,8 +114,8 @@ pub async fn handle_send_email(req: Request, env: Env, _data: SomeSharedData) -> return respond(dispatch_mime(&sender, &scenario).await); } - if let Some(builder_result) = build_structured(&name) { - return respond(dispatch_structured(&sender, builder_result).await); + if let Some(builder) = build_structured(&name) { + return respond(dispatch_structured(&sender, builder).await); } Response::error(format!("unknown scenario: {name}"), 400) @@ -169,9 +155,8 @@ async fn dispatch_mime_stream(sender: &SendEmail) -> Result { async fn dispatch_structured( sender: &SendEmail, - builder: Result, + builder: SendEmailBuilder, ) -> Result { - let builder = builder?; let result = sender.send_with_builder(&builder).await?; Ok(result.message_id()) } diff --git a/ts-gen b/ts-gen index 70964e9c5..4c40247b3 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit 70964e9c5b9aeac33b4ffc91ff824934df23fff3 +Subproject commit 4c40247b350b883a1905d03fefb00e3956e2dff2 diff --git a/types/email.d.ts b/types/email.d.ts index 5f66c6806..f22c708ac 100644 --- a/types/email.d.ts +++ b/types/email.d.ts @@ -1,9 +1,9 @@ /* * 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. @@ -27,57 +27,41 @@ interface EmailSendResult { */ messageId: string; } -// EDIT: upstream declares the legacy email constructor inside -// `declare module "cloudflare:email" { let _EmailMessage: { new(...) }; -// export { _EmailMessage as EmailMessage } }` paired with a global -// `interface EmailMessage` of the same name. ts-gen then emits two -// distinct Rust types, which forces hand-rolled `unchecked_ref` casts at -// every call site. -// -// Until ts-gen learns to fully unify the same-named module export with -// the global interface, we sidestep by giving the *interface* a distinct -// name (`StructuredEmailMessage`) and letting the module-scoped class -// keep the runtime name `EmailMessage`. So: -// -// * `EmailMessage` — module-scoped class imported from -// `cloudflare:email`, used to construct raw-MIME messages and pass -// to `SendEmail.send(message)`. -// * `StructuredEmailMessage` — global interface, the envelope-getters -// view exposed on `ForwardableEmailMessage` and elsewhere. +// 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. // -// Two names, two types — but the wasm-bindgen import `EmailMessage` -// matches the runtime export, so no shim renaming is required. +// 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 can be sent from a Worker. - * - * EDIT: renamed from upstream's `EmailMessage` to avoid the same-name - * collision with the `cloudflare:email` constructor class. See the - * EDIT note on the module declaration below for the rationale. - */ -interface StructuredEmailMessage { - /** - * Envelope From attribute of the email message. - */ - readonly from: string; - /** - * Envelope To attribute of the email message. - */ - readonly to: string; -} /** * An email message that is sent to a consumer Worker and can be rejected/forwarded. */ -interface ForwardableEmailMessage extends StructuredEmailMessage { +interface ForwardableEmailMessage extends EmailMessage { /** * Stream of the email message content. */ @@ -108,7 +92,7 @@ interface ForwardableEmailMessage extends StructuredEmailMessage { * @param message The reply message. * @returns A promise that resolves when the email message is replied. */ - reply(message: StructuredEmailMessage): Promise; + reply(message: EmailMessage): Promise; } /** A file attachment for an email message */ type EmailAttachment = @@ -135,9 +119,6 @@ interface EmailAddress { * A binding that allows a Worker to send email messages. */ interface SendEmail { - // EDIT: this overload takes the `cloudflare:email`-imported - // `EmailMessage` class directly — see the EDIT note on that module - // declaration above for the naming rationale. send(message: EmailMessage): Promise; send(builder: { from: string | EmailAddress; diff --git a/worker/src/email.rs b/worker/src/email.rs index 4226d5e02..a886b416a 100644 --- a/worker/src/email.rs +++ b/worker/src/email.rs @@ -1,4 +1,3 @@ -#[allow(unused_imports)] use email::EmailMessage; #[allow(dead_code)] use crate::Context as ExecutionContext; #[allow(dead_code)] @@ -41,69 +40,32 @@ extern "C" { pub fn set_message_id(this: &EmailSendResult, val: &str); } impl EmailSendResult { - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - #[allow(unused_imports)] - use wasm_bindgen::JsCast; - JsCast::unchecked_into(js_sys::Object::new()) - } - pub fn builder() -> EmailSendResultBuilder { - EmailSendResultBuilder { - inner: Self::new(), - required: 1u64, - } + #[doc = " # Provided fields"] + #[doc = " "] + #[doc = " * `message_id`: The Email Message ID"] + pub fn new(message_id: &str) -> EmailSendResult { + Self::builder(message_id).build() + } + #[doc = " # Provided fields"] + #[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, - required: u64, } -#[allow(unused_mut)] impl EmailSendResultBuilder { - pub fn message_id(mut self, val: &str) -> Self { - self.inner.set_message_id(val); - self.required &= 18446744073709551614u64; - self - } - pub fn build(self) -> Result { - if self.required != 0 { - let mut missing = Vec::new(); - if self.required & 1u64 != 0 { - missing.push("missing required property `messageId`"); - } - return Err(JsValue::from_str(&format!( - "{}: {}", - stringify!(EmailSendResult), - missing.join(", ") - ))); - } - Ok(self.inner) + pub fn build(self) -> EmailSendResult { + self.inner } } #[wasm_bindgen] extern "C" { - # [wasm_bindgen (extends = Object)] - #[derive(Debug, Clone, PartialEq, Eq)] - pub type StructuredEmailMessage; - #[doc = " Envelope From attribute of the email message."] - #[wasm_bindgen(method, getter)] - pub fn from(this: &StructuredEmailMessage) -> String; - #[doc = " Envelope To attribute of the email message."] - #[wasm_bindgen(method, getter)] - pub fn to(this: &StructuredEmailMessage) -> String; -} -impl StructuredEmailMessage { - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - #[allow(unused_unsafe)] - unsafe { - JsValue::from(js_sys::Object::new()).unchecked_into() - } - } -} -#[wasm_bindgen] -extern "C" { - # [wasm_bindgen (extends = StructuredEmailMessage , extends = Object)] + # [wasm_bindgen (extends = email :: EmailMessage , extends = Object)] #[derive(Debug, Clone, PartialEq, Eq)] pub type ForwardableEmailMessage; #[doc = " Stream of the email message content."] @@ -180,7 +142,7 @@ extern "C" { #[wasm_bindgen(method, catch)] pub async fn reply( this: &ForwardableEmailMessage, - message: &StructuredEmailMessage, + message: &email::EmailMessage, ) -> Result; } #[wasm_bindgen] @@ -218,90 +180,207 @@ extern "C" { pub fn set_type(this: &EmailAttachment, val: &str); } impl EmailAttachment { - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - #[allow(unused_imports)] - use wasm_bindgen::JsCast; - JsCast::unchecked_into(js_sys::Object::new()) - } - pub fn builder() -> EmailAttachmentBuilder { - EmailAttachmentBuilder { - inner: Self::new(), - required: 15u64, - } + #[doc = " * `disposition: \"inline\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"attachment\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"inline\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"attachment\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"inline\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"attachment\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"inline\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"attachment\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"inline\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"attachment\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"inline\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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 = " * `disposition: \"attachment\"`"] + #[doc = " "] + #[doc = " # Provided fields"] + #[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, - required: u64, } -#[allow(unused_mut)] impl EmailAttachmentBuilder { - pub fn content(mut self, val: &str) -> Self { - self.inner.set_content(val); - self.required &= 18446744073709551614u64; - self - } - pub fn content_with_array_buffer(mut self, val: &ArrayBuffer) -> Self { - self.inner.set_content_with_array_buffer(val); - self.required &= 18446744073709551614u64; - self - } - pub fn content_with_js_value(mut self, val: &Object) -> Self { - self.inner.set_content_with_js_value(val); - self.required &= 18446744073709551614u64; - self - } - pub fn content_id(mut self, val: &str) -> Self { + pub fn content_id(self, val: &str) -> Self { self.inner.set_content_id(val); self } - pub fn content_id_with_undefined(mut self, val: &Undefined) -> Self { + pub fn content_id_with_undefined(self, val: &Undefined) -> Self { self.inner.set_content_id_with_undefined(val); self } - pub fn disposition(mut self, val: &str) -> Self { - self.inner.set_disposition(val); - self.required &= 18446744073709551613u64; - self - } - pub fn disposition_with_js_value(mut self, val: &str) -> Self { - self.inner.set_disposition_with_js_value(val); - self.required &= 18446744073709551613u64; - self - } - pub fn filename(mut self, val: &str) -> Self { - self.inner.set_filename(val); - self.required &= 18446744073709551611u64; - self - } - pub fn r#type(mut self, val: &str) -> Self { - self.inner.set_type(val); - self.required &= 18446744073709551607u64; - self - } - pub fn build(self) -> Result { - if self.required != 0 { - let mut missing = Vec::new(); - if self.required & 1u64 != 0 { - missing.push("missing required property `content`"); - } - if self.required & 2u64 != 0 { - missing.push("missing required property `disposition`"); - } - if self.required & 4u64 != 0 { - missing.push("missing required property `filename`"); - } - if self.required & 8u64 != 0 { - missing.push("missing required property `type`"); - } - return Err(JsValue::from_str(&format!( - "{}: {}", - stringify!(EmailAttachment), - missing.join(", ") - ))); - } - Ok(self.inner) + pub fn build(self) -> EmailAttachment { + self.inner } } #[wasm_bindgen] @@ -319,51 +398,30 @@ extern "C" { pub fn set_email(this: &EmailAddress, val: &str); } impl EmailAddress { - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - #[allow(unused_imports)] - use wasm_bindgen::JsCast; - JsCast::unchecked_into(js_sys::Object::new()) - } - pub fn builder() -> EmailAddressBuilder { - EmailAddressBuilder { - inner: Self::new(), - required: 3u64, - } + #[doc = " # Provided fields"] + #[doc = " "] + #[doc = " * `name`"] + #[doc = " * `email`"] + pub fn new(name: &str, email: &str) -> EmailAddress { + Self::builder(name, email).build() + } + #[doc = " # Provided fields"] + #[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, - required: u64, } -#[allow(unused_mut)] impl EmailAddressBuilder { - pub fn name(mut self, val: &str) -> Self { - self.inner.set_name(val); - self.required &= 18446744073709551614u64; - self - } - pub fn email(mut self, val: &str) -> Self { - self.inner.set_email(val); - self.required &= 18446744073709551613u64; - self - } - pub fn build(self) -> Result { - if self.required != 0 { - let mut missing = Vec::new(); - if self.required & 1u64 != 0 { - missing.push("missing required property `name`"); - } - if self.required & 2u64 != 0 { - missing.push("missing required property `email`"); - } - return Err(JsValue::from_str(&format!( - "{}: {}", - stringify!(EmailAddress), - missing.join(", ") - ))); - } - Ok(self.inner) + pub fn build(self) -> EmailAddress { + self.inner } } #[wasm_bindgen] @@ -372,8 +430,10 @@ extern "C" { #[derive(Debug, Clone, PartialEq, Eq)] pub type SendEmail; #[wasm_bindgen(method, catch)] - pub async fn send(this: &SendEmail, message: &EmailMessage) - -> Result; + 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, @@ -437,109 +497,157 @@ extern "C" { pub fn set_attachments(this: &SendEmailBuilder, val: &Array); } impl SendEmailBuilder { - #[allow(clippy::new_without_default)] - pub fn new() -> Self { - #[allow(unused_imports)] - use wasm_bindgen::JsCast; - JsCast::unchecked_into(js_sys::Object::new()) - } - pub fn builder() -> SendEmailBuilderBuilder { - SendEmailBuilderBuilder { - inner: Self::new(), - required: 7u64, - } + #[doc = " # Provided fields"] + #[doc = " "] + #[doc = " * `from`"] + #[doc = " * `to`"] + #[doc = " * `subject`"] + pub fn new(from: &str, to: &str, subject: &str) -> SendEmailBuilder { + Self::builder(from, to, subject).build() + } + #[doc = " # Provided fields"] + #[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 = " # Provided fields"] + #[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 = " # Provided fields"] + #[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 = " # Provided fields"] + #[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 = " # Provided fields"] + #[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 = " # Provided fields"] + #[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 = " # Provided fields"] + #[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, - required: u64, } -#[allow(unused_mut)] impl SendEmailBuilderBuilder { - pub fn from(mut self, val: &str) -> Self { - self.inner.set_from(val); - self.required &= 18446744073709551614u64; - self - } - pub fn from_with_email_address(mut self, val: &EmailAddress) -> Self { - self.inner.set_from_with_email_address(val); - self.required &= 18446744073709551614u64; - self - } - pub fn to(mut self, val: &str) -> Self { - self.inner.set_to(val); - self.required &= 18446744073709551613u64; - self - } - pub fn to_with_array(mut self, val: &Array) -> Self { - self.inner.set_to_with_array(val); - self.required &= 18446744073709551613u64; - self - } - pub fn subject(mut self, val: &str) -> Self { - self.inner.set_subject(val); - self.required &= 18446744073709551611u64; - self - } - pub fn reply_to(mut self, val: &str) -> Self { + pub fn reply_to(self, val: &str) -> Self { self.inner.set_reply_to(val); self } - pub fn reply_to_with_email_address(mut self, val: &EmailAddress) -> 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(mut self, val: &str) -> Self { + pub fn cc(self, val: &str) -> Self { self.inner.set_cc(val); self } - pub fn cc_with_array(mut self, val: &Array) -> Self { + pub fn cc_with_array(self, val: &Array) -> Self { self.inner.set_cc_with_array(val); self } - pub fn bcc(mut self, val: &str) -> Self { + pub fn bcc(self, val: &str) -> Self { self.inner.set_bcc(val); self } - pub fn bcc_with_array(mut self, val: &Array) -> Self { + pub fn bcc_with_array(self, val: &Array) -> Self { self.inner.set_bcc_with_array(val); self } - pub fn headers(mut self, val: &Object) -> Self { + pub fn headers(self, val: &Object) -> Self { self.inner.set_headers(val); self } - pub fn text(mut self, val: &str) -> Self { + pub fn text(self, val: &str) -> Self { self.inner.set_text(val); self } - pub fn html(mut self, val: &str) -> Self { + pub fn html(self, val: &str) -> Self { self.inner.set_html(val); self } - pub fn attachments(mut self, val: &Array) -> Self { + pub fn attachments(self, val: &Array) -> Self { self.inner.set_attachments(val); self } - pub fn build(self) -> Result { - if self.required != 0 { - let mut missing = Vec::new(); - if self.required & 1u64 != 0 { - missing.push("missing required property `from`"); - } - if self.required & 2u64 != 0 { - missing.push("missing required property `to`"); - } - if self.required & 4u64 != 0 { - missing.push("missing required property `subject`"); - } - return Err(JsValue::from_str(&format!( - "{}: {}", - stringify!(SendEmailBuilder), - missing.join(", ") - ))); - } - Ok(self.inner) + pub fn build(self) -> SendEmailBuilder { + self.inner } } #[wasm_bindgen] @@ -570,8 +678,10 @@ pub mod email { 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/lib.rs b/worker/src/lib.rs index 2e7ea66e0..c54129ca8 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -195,7 +195,6 @@ pub use crate::schedule::*; pub use crate::secret_store::SecretStore; pub use crate::send_email::{ EmailAddress, EmailAttachment, EmailMessage, EmailSendResult, SendEmail, SendEmailBuilder, - StructuredEmailMessage, }; pub use crate::socket::*; pub use crate::streams::*; diff --git a/worker/src/send_email.rs b/worker/src/send_email.rs index 819ecda23..7d1862c23 100644 --- a/worker/src/send_email.rs +++ b/worker/src/send_email.rs @@ -9,14 +9,13 @@ use crate::{EnvBinding, Result}; // // `EmailMessage` is the constructor class imported from // `cloudflare:email`, used to build raw-MIME messages and pass to -// `SendEmail.send(message)`. `StructuredEmailMessage` is the global -// envelope-getter interface exposed on `ForwardableEmailMessage` and -// `reply`. Same JS object, two distinct Rust types — see the d.ts EDIT -// note for the naming rationale. +// `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, - StructuredEmailMessage, }; impl EnvBinding for SendEmail { @@ -32,3 +31,17 @@ impl EnvBinding for SendEmail { 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::(); + } +} From c091fe441be8b62c6e5bfae0c2b90aa0da53c463 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 29 Apr 2026 12:02:34 -0700 Subject: [PATCH 10/14] Use FixedLengthStream Deref instead of unchecked_into in mime-stream test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FixedLengthStream` already has `extends = web_sys::TransformStream` in `worker-sys`, so wasm-bindgen auto-generates `Deref` and `fixed.readable()` resolves through it. The previous `fixed.unchecked_into::().readable()` was unnecessarily defensive — drop the cast plus the now-unused `JsCast` and `web_sys` imports. --- test/src/send_email.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/test/src/send_email.rs b/test/src/send_email.rs index ae5066e2e..05c91b282 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -1,9 +1,8 @@ use crate::SomeSharedData; use futures_util::stream::once; -use wasm_bindgen::JsCast; use worker::{ - web_sys, worker_sys, Date, EmailAddress, EmailMessage, Env, FixedLengthStream, Request, - Response, Result, SendEmail, SendEmailBuilder, + worker_sys, Date, EmailAddress, EmailMessage, Env, FixedLengthStream, Request, Response, + Result, SendEmail, SendEmailBuilder, }; const SENDER: &str = "allowed-sender@example.com"; @@ -134,16 +133,15 @@ async fn dispatch_mime(sender: &SendEmail, scenario: &MimeScenario) -> Result 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 - .unchecked_into::() - .readable(); + let stream = fixed.readable(); let message = EmailMessage::new_with_readable_stream( scenario.envelope_from, scenario.envelope_to, From 773c88dcbf9721d95affbfa9097c1c9296ca9ef7 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 29 Apr 2026 12:16:39 -0700 Subject: [PATCH 11/14] 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 #8 commit (CONVENTIONS.md rationale + emit cleanup). --- test/src/send_email.rs | 5 +---- ts-gen | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/test/src/send_email.rs b/test/src/send_email.rs index 05c91b282..8d0eed13a 100644 --- a/test/src/send_email.rs +++ b/test/src/send_email.rs @@ -151,10 +151,7 @@ async fn dispatch_mime_stream(sender: &SendEmail) -> Result { Ok(result.message_id()) } -async fn dispatch_structured( - sender: &SendEmail, - builder: SendEmailBuilder, -) -> Result { +async fn dispatch_structured(sender: &SendEmail, builder: SendEmailBuilder) -> Result { let result = sender.send_with_builder(&builder).await?; Ok(result.message_id()) } diff --git a/ts-gen b/ts-gen index 4c40247b3..16258d96a 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit 4c40247b350b883a1905d03fefb00e3956e2dff2 +Subproject commit 16258d96a1d3f25314a2033dc1894fb6a9cda11b From f553c32edf710c4ca9b4eb93649870f8f107aded Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 29 Apr 2026 12:40:11 -0700 Subject: [PATCH 12/14] Pull doc-comment generation from ts-gen PR #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. --- ts-gen | 2 +- worker/src/email.rs | 188 +++++++++++++++++++++++++------------------- 2 files changed, 107 insertions(+), 83 deletions(-) diff --git a/ts-gen b/ts-gen index 16258d96a..dd2827b14 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit 16258d96a1d3f25314a2033dc1894fb6a9cda11b +Subproject commit dd2827b14e38345e88f07a246786e37afad92083 diff --git a/worker/src/email.rs b/worker/src/email.rs index a886b416a..53bfa07ac 100644 --- a/worker/src/email.rs +++ b/worker/src/email.rs @@ -18,12 +18,12 @@ extern "C" { #[derive(Debug, Clone, PartialEq, Eq)] pub type ExtendableEvent; #[doc = " The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing."] - #[doc = " "] + #[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 = ""] #[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>; @@ -40,14 +40,14 @@ extern "C" { pub fn set_message_id(this: &EmailSendResult, val: &str); } impl EmailSendResult { - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `message_id`: The Email Message ID"] pub fn new(message_id: &str) -> EmailSendResult { Self::builder(message_id).build() } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[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()); @@ -78,36 +78,36 @@ extern "C" { #[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 = ""] #[doc = " ## Arguments"] - #[doc = " "] + #[doc = ""] #[doc = " * `reason` - The reject reason."] - #[doc = " "] + #[doc = ""] #[doc = " ## Returns"] - #[doc = " "] + #[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 = ""] #[doc = " ## Arguments"] - #[doc = " "] + #[doc = ""] #[doc = " * `reason` - The reject reason."] - #[doc = " "] + #[doc = ""] #[doc = " ## Returns"] - #[doc = " "] + #[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 = ""] #[doc = " ## Arguments"] - #[doc = " "] + #[doc = ""] #[doc = " * `rcptTo` - Verified destination address."] #[doc = " * `headers` - A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)."] - #[doc = " "] + #[doc = ""] #[doc = " ## Returns"] - #[doc = " "] + #[doc = ""] #[doc = " A promise that resolves when the email message is forwarded."] #[wasm_bindgen(method, catch)] pub async fn forward( @@ -115,14 +115,14 @@ extern "C" { rcpt_to: &str, ) -> Result; #[doc = " Forward this email message to a verified destination address of the account."] - #[doc = " "] + #[doc = ""] #[doc = " ## Arguments"] - #[doc = " "] + #[doc = ""] #[doc = " * `rcptTo` - Verified destination address."] #[doc = " * `headers` - A [Headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)."] - #[doc = " "] + #[doc = ""] #[doc = " ## Returns"] - #[doc = " "] + #[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( @@ -131,13 +131,13 @@ extern "C" { headers: &Headers, ) -> Result; #[doc = " Reply to the sender of this email message with a new EmailMessage object."] - #[doc = " "] + #[doc = ""] #[doc = " ## Arguments"] - #[doc = " "] + #[doc = ""] #[doc = " * `message` - The reply message."] - #[doc = " "] + #[doc = ""] #[doc = " ## Returns"] - #[doc = " "] + #[doc = ""] #[doc = " A promise that resolves when the email message is replied."] #[wasm_bindgen(method, catch)] pub async fn reply( @@ -180,30 +180,36 @@ extern "C" { pub fn set_type(this: &EmailAttachment, val: &str); } impl EmailAttachment { + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"inline\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[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 = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[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 = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -214,10 +220,12 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_inline_with_array_buffer(content, filename, r#type).build() } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -228,10 +236,12 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_attachment_with_array_buffer(content, filename, r#type).build() } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"inline\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -242,10 +252,12 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_inline_with_js_value(content, filename, r#type).build() } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -256,10 +268,12 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_attachment_with_js_value(content, filename, r#type).build() } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"inline\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -271,10 +285,12 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -290,10 +306,12 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"inline\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -309,10 +327,12 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -328,10 +348,12 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"inline\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -347,10 +369,12 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } + #[doc = " # Inlined fields"] + #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] - #[doc = " "] - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = ""] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] #[doc = " * `type`"] @@ -398,15 +422,15 @@ extern "C" { pub fn set_email(this: &EmailAddress, val: &str); } impl EmailAddress { - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `name`"] #[doc = " * `email`"] pub fn new(name: &str, email: &str) -> EmailAddress { Self::builder(name, email).build() } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `name`"] #[doc = " * `email`"] pub fn builder(name: &str, email: &str) -> EmailAddressBuilder { @@ -497,16 +521,16 @@ extern "C" { pub fn set_attachments(this: &SendEmailBuilder, val: &Array); } impl SendEmailBuilder { - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] pub fn new(from: &str, to: &str, subject: &str) -> SendEmailBuilder { Self::builder(from, to, subject).build() } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] @@ -517,8 +541,8 @@ impl SendEmailBuilder { ) -> SendEmailBuilder { Self::builder_with_str_and_array(from, to, subject).build() } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] @@ -529,8 +553,8 @@ impl SendEmailBuilder { ) -> SendEmailBuilder { Self::builder_with_email_address_and_str(from, to, subject).build() } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] @@ -541,8 +565,8 @@ impl SendEmailBuilder { ) -> SendEmailBuilder { Self::builder_with_email_address_and_array(from, to, subject).build() } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] @@ -553,8 +577,8 @@ impl SendEmailBuilder { inner.set_subject(subject); SendEmailBuilderBuilder { inner } } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] @@ -569,8 +593,8 @@ impl SendEmailBuilder { inner.set_subject(subject); SendEmailBuilderBuilder { inner } } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] @@ -585,8 +609,8 @@ impl SendEmailBuilder { inner.set_subject(subject); SendEmailBuilderBuilder { inner } } - #[doc = " # Provided fields"] - #[doc = " "] + #[doc = " # Parameters"] + #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] #[doc = " * `subject`"] From 2d79e31b19e84c14670a35224869af4a4413671b Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 29 Apr 2026 12:41:25 -0700 Subject: [PATCH 13/14] Advance ts-gen submodule to merged main ts-gen PR #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. --- ts-gen | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ts-gen b/ts-gen index dd2827b14..f14be7303 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit dd2827b14e38345e88f07a246786e37afad92083 +Subproject commit f14be7303f51963f8038bfed6186701aee55eebe From 4b72d0b88b6514e3145fc0d5c71bc3565c462575 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Wed, 29 Apr 2026 12:44:40 -0700 Subject: [PATCH 14/14] Advance ts-gen submodule to merged main ts-gen PR #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. --- ts-gen | 2 +- worker/src/email.rs | 76 ++++++++++++++++++++++----------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/ts-gen b/ts-gen index f14be7303..1f3ca2dea 160000 --- a/ts-gen +++ b/ts-gen @@ -1 +1 @@ -Subproject commit f14be7303f51963f8038bfed6186701aee55eebe +Subproject commit 1f3ca2dea011b5a82a9a353f22c855ef52b511d8 diff --git a/worker/src/email.rs b/worker/src/email.rs index 53bfa07ac..9360fea0e 100644 --- a/worker/src/email.rs +++ b/worker/src/email.rs @@ -40,15 +40,15 @@ extern "C" { pub fn set_message_id(this: &EmailSendResult, val: &str); } impl EmailSendResult { - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] - #[doc = " * `message_id`: The Email Message ID"] + #[doc = " * `message_id` - The Email Message ID"] pub fn new(message_id: &str) -> EmailSendResult { Self::builder(message_id).build() } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] - #[doc = " * `message_id`: The Email Message ID"] + #[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); @@ -180,11 +180,11 @@ extern "C" { pub fn set_type(this: &EmailAttachment, val: &str); } impl EmailAttachment { - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"inline\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -192,11 +192,11 @@ impl EmailAttachment { pub fn new_inline(content: &str, filename: &str, r#type: &str) -> EmailAttachment { Self::builder_inline(content, filename, r#type).build() } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -204,11 +204,11 @@ impl EmailAttachment { pub fn new_attachment(content: &str, filename: &str, r#type: &str) -> EmailAttachment { Self::builder_attachment(content, filename, r#type).build() } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"inline\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -220,11 +220,11 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_inline_with_array_buffer(content, filename, r#type).build() } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -236,11 +236,11 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_attachment_with_array_buffer(content, filename, r#type).build() } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"inline\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -252,11 +252,11 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_inline_with_js_value(content, filename, r#type).build() } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -268,11 +268,11 @@ impl EmailAttachment { ) -> EmailAttachment { Self::builder_attachment_with_js_value(content, filename, r#type).build() } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"inline\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -285,11 +285,11 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -306,11 +306,11 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"inline\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -327,11 +327,11 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -348,11 +348,11 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"inline\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -369,11 +369,11 @@ impl EmailAttachment { inner.set_type(r#type); EmailAttachmentBuilder { inner } } - #[doc = " # Inlined fields"] + #[doc = " ## Inlined fields"] #[doc = ""] #[doc = " * `disposition: \"attachment\"`"] #[doc = ""] - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `content`"] #[doc = " * `filename`"] @@ -422,14 +422,14 @@ extern "C" { pub fn set_email(this: &EmailAddress, val: &str); } impl EmailAddress { - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `name`"] #[doc = " * `email`"] pub fn new(name: &str, email: &str) -> EmailAddress { Self::builder(name, email).build() } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `name`"] #[doc = " * `email`"] @@ -521,7 +521,7 @@ extern "C" { pub fn set_attachments(this: &SendEmailBuilder, val: &Array); } impl SendEmailBuilder { - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] @@ -529,7 +529,7 @@ impl SendEmailBuilder { pub fn new(from: &str, to: &str, subject: &str) -> SendEmailBuilder { Self::builder(from, to, subject).build() } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] @@ -541,7 +541,7 @@ impl SendEmailBuilder { ) -> SendEmailBuilder { Self::builder_with_str_and_array(from, to, subject).build() } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] @@ -553,7 +553,7 @@ impl SendEmailBuilder { ) -> SendEmailBuilder { Self::builder_with_email_address_and_str(from, to, subject).build() } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] @@ -565,7 +565,7 @@ impl SendEmailBuilder { ) -> SendEmailBuilder { Self::builder_with_email_address_and_array(from, to, subject).build() } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] @@ -577,7 +577,7 @@ impl SendEmailBuilder { inner.set_subject(subject); SendEmailBuilderBuilder { inner } } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] @@ -593,7 +593,7 @@ impl SendEmailBuilder { inner.set_subject(subject); SendEmailBuilderBuilder { inner } } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"] @@ -609,7 +609,7 @@ impl SendEmailBuilder { inner.set_subject(subject); SendEmailBuilderBuilder { inner } } - #[doc = " # Parameters"] + #[doc = " ## Arguments"] #[doc = ""] #[doc = " * `from`"] #[doc = " * `to`"]