Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions examples/send-email/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions examples/send-email/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Sending Email from Cloudflare Workers

Demonstration of using `worker::SendEmail` to dispatch an outbound message
through a `[[send_email]]` binding.

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

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/ # structured
curl http://localhost:8787/raw # raw MIME
```

## Deploying

Before deploying, verify the sender and recipient addresses as documented at
<https://developers.cloudflare.com/email-service/api/send-emails/workers-api/>,
then:

```bash
npm run deploy
```
12 changes: 12 additions & 0 deletions examples/send-email/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "send-email-on-workers",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "cargo install worker-build ; wrangler deploy",
"dev": "cargo install worker-build ; wrangler dev --local"
},
"devDependencies": {
"wrangler": "^4.83.0"
}
}
53 changes: 53 additions & 0 deletions examples/send-email/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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<Response> {
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<EmailSendResult> {
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("<p>Congratulations, you just sent an email from a Worker.</p>")
.build()?;

sender.send(&email).await
}

async fn send_raw_mime(sender: &SendEmail) -> Result<EmailSendResult> {
// mail-builder's auto-generated `Date:` and `Message-ID:` headers rely on
// `SystemTime::now()` and `gethostname`, neither of which work on
// `wasm32-unknown-unknown`. https://github.com/stalwartlabs/mail-builder/pull/26
let now_ms = Date::now().as_millis();
let message_id = format!("{now_ms}@example.com");

let raw = MimeBuilder::new()
.from(("Sending email test", SENDER))
.to(RECIPIENT)
.subject("An email generated in a Worker")
.date((now_ms / 1000) as i64)
.message_id(message_id)
.text_body("Congratulations, you just sent an email from a Worker.")
.write_to_string()
.map_err(|e| Error::RustError(e.to_string()))?;

let message = EmailMessage::new(SENDER, RECIPIENT, &raw)?;
sender.send_mime(&message).await
}
11 changes: 11 additions & 0 deletions examples/send-email/wrangler.toml
Original file line number Diff line number Diff line change
@@ -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"
64 changes: 32 additions & 32 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions test/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ mod rate_limit;
mod request;
mod router;
mod secret_store;
mod send_email;
mod service;
mod socket;
mod sql_counter;
Expand Down
6 changes: 4 additions & 2 deletions test/src/router.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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")]
Expand Down
Loading