Skip to content

feat(worker): add send_email binding support#975

Merged
guybedford merged 15 commits intocloudflare:mainfrom
connyay:cjh-sending-email
Apr 29, 2026
Merged

feat(worker): add send_email binding support#975
guybedford merged 15 commits intocloudflare:mainfrom
connyay:cjh-sending-email

Conversation

@connyay
Copy link
Copy Markdown
Contributor

@connyay connyay commented Apr 15, 2026

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.

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.
@connyay
Copy link
Copy Markdown
Contributor Author

connyay commented Apr 15, 2026

There are currently two open PRs around workers + email: #624 #715

Sorry for further muddying the waters here. I opened this with the narrow focus on sending + tests + example. If this lands I am happy to work on email recieving in a follow up.

@connyay connyay marked this pull request as draft April 16, 2026 13:44
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)`.
@connyay connyay marked this pull request as ready for review April 17, 2026 02:46
Comment thread worker/src/send_email.rs Outdated
@edevil
Copy link
Copy Markdown
Contributor

edevil commented Apr 17, 2026

Duplicate headers silently collapseworker/src/send_email.rs:335-341, 353-362

The field comment on Email::headers says Vec<(String, String)> is used because "duplicate header names are meaningful in RFC 5322," but the serialization path makes duplicates unreachable:

  • serialize_headers calls SerializeMap::serialize_entry for each pair.
  • The encoder (line 103) is Serializer::new().serialize_maps_as_objects(true), so the map becomes a plain JS object — duplicate keys overwrite.
  • The runtime-side type is jsg::Dict<kj::String> (also a map; no duplicates).

So duplicates silently collapse to the last value instead of being preserved. Two ways to tighten:

  1. Drop the RFC 5322 claim and back the field with a BTreeMap<String, String> (or HashMap) so the type reflects what actually gets sent.
  2. Error on duplicates in EmailBuilder::build() so callers don't lose data without noticing.

(1) is simpler and fine given the runtime can't represent duplicates anyway.

@edevil
Copy link
Copy Markdown
Contributor

edevil commented Apr 17, 2026

AttachmentContent::Base64 is misnamed — the runtime does not base64-decode string contentworker/src/send_email.rs:221-250

The runtime treats attachment string content as UTF-8 bytes, not base64. The function in edgeworker that feeds the mail stream does this:

kj::ArrayPtr<kj::byte> getArrayPtrFromContent(kj::OneOf<kj::String, jsg::BufferSource>& content) {
  KJ_SWITCH_ONEOF(content) {
    KJ_CASE_ONEOF(string, kj::String)        { return string.asBytes(); }
    KJ_CASE_ONEOF(buffer, jsg::BufferSource) { return buffer.asArrayPtr(); }
  }
}

kj::String::asBytes() returns raw UTF-8 — no base64 decoding happens. So:

  • AttachmentContent::Base64("Hello, World!") → recipient gets the text Hello, World!. Works, despite the name.
  • AttachmentContent::Base64("JVBERi0xLjQK…") (a user who followed the public docs and pre-base64-encoded a PDF) → recipient gets the literal ASCII of the base64 string, not the decoded PDF. Broken, silently.

(Sidebar: the public docs at email-service/api/send-emails/workers-api/ claim content: string | ArrayBuffer; // Base64 string or binary content — that contradicts the runtime and should be filed as a docs bug.)

Suggested fix — rename to reflect actual semantics

pub enum AttachmentContent {
    Text(String),      // sent as UTF-8 bytes on the wire
    Bytes(Vec<u8>),    // sent as Uint8Array
}

with the obvious From impls (&str/StringText, &[u8]/Vec<u8>Bytes) and a doc note: "If your source is base64, decode it to bytes first." This matches the runtime's OneOf<String, BufferSource> faithfully and removes the footgun.

@guybedford
Copy link
Copy Markdown
Collaborator

@connyay I'd be interested to get your feedback here - I was able to update this API to use a new automated bindgen from ts-gen as an additional commit, which basically just automates the src/email.rs file entirely. This would be a nice self-contained subsystem to test the output on. Please take a look and let me know what you think of the differences.

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.
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 29, 2026

Merging this PR will not alter performance

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

✅ 2 untouched benchmarks


Comparing connyay:cjh-sending-email (4b72d0b) with main (fd18681)

Open in CodSpeed

@connyay
Copy link
Copy Markdown
Contributor Author

connyay commented Apr 29, 2026

@guybedford that looks great! ts-gen should be a slam dunk for flagship #979 👀 - I can rework that after this lands or you are welcome to use that as a testbed as it should be a pretty straight forward generation.

few notes on email:

  1. ForwardableEmailMessage::reply() is awkward to call from Rust. The generated signature is reply(this, message: &StructuredEmailMessage) (email.rs:181), but StructuredEmailMessage only has readonly from/to getters — no setters, no builder, no way to populate fields from Rust. At runtime you want to pass an EmailMessage class instance from cloudflare:email, which is now a distinct Rust type with no extends = StructuredEmailMessage, so the call site ends up doing email_msg.unchecked_ref::<StructuredEmailMessage>(). That's the same unchecked_ref shape the d.ts EDIT note was trying to eliminate — reply() is the one place it still shows up. There aren't any tests on reply() yet, which is probably why this hasn't come up.

A couple of options: have ts-gen emit extends = StructuredEmailMessage on the class form, or retype reply's parameter as the class — that second one may also better match the runtime contract, since reply() likely expects a cloudflare:email EmailMessage instance (with body) rather than a bare envelope object, which would mean the d.ts itself is mistyped here.

2.unsafe impl Send for SendEmail {} is missing.
3. maybe this is my go tendencies showing but SendEmailBuilder::builder().from(...).build()? is awkward.

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

`FixedLengthStream` already has `extends = web_sys::TransformStream` in
`worker-sys`, so wasm-bindgen auto-generates `Deref<Target = TransformStream>`
and `fixed.readable()` resolves through it. The previous
`fixed.unchecked_into::<web_sys::TransformStream>().readable()` was
unnecessarily defensive — drop the cast plus the now-unused `JsCast`
and `web_sys` imports.
@guybedford
Copy link
Copy Markdown
Collaborator

@connyay I've fixed up the EmailMessage interface now, with some work on ts-gen to make it compile. Please take a look and let me know if this seems correct now.

unsafe impl Send is no longer necessary since JsValue became send in wasm bindgen. I added a test to verify.

For (3), builders are important for providing optimized bindgen (this is a future optimization we want to add, not just relying on serde for our own sledgehammer style bindings).

I've added a few fixes for the ergonomics here, please take another look:

  1. builder() now takes required arguments instead of having setters for them
  2. As a result, builder() expands to the union and overload variants like all functions
  3. String literal args are now treated as overload variations so we get builder_inline(..) and builder_attachment(...) as separate builder constructors.
  4. I then added a new(required) function that mirrors the builder(required).build() as a short-hand. While not as useful for the email builder, it is a low cost wrapper with little bloat and enables EmailAddress::new("name", "addr") and EmailAttachment::new_inline(content, filename, type_) to now work directly.

CI's rustfmt --check flagged the dispatch_structured signature.
Apply fmt and bump the ts-gen submodule pointer to the latest
PR cloudflare#8 commit (CONVENTIONS.md rationale + emit cleanup).
@connyay
Copy link
Copy Markdown
Contributor Author

connyay commented Apr 29, 2026

This is delightful - ship it!

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 PR cloudflare#9 (doc comments on dictionary builder variants) merged.
Bump the submodule pointer to the merge commit on main; the
generated `worker/src/email.rs` is unchanged from the PR-branch
output.
ts-gen PR cloudflare#10 (h2 headings + dash-separated bullets in builder docs)
merged. Bump the submodule pointer and regenerate
`worker/src/email.rs` with the updated doc format.
@guybedford guybedford merged commit 7ad7139 into cloudflare:main Apr 29, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants