Skip to content

feat(EmailWorkers): Implement email worker functionality#715

Merged
guybedford merged 4 commits into
cloudflare:mainfrom
nudded:feat-email-workers
May 14, 2026
Merged

feat(EmailWorkers): Implement email worker functionality#715
guybedford merged 4 commits into
cloudflare:mainfrom
nudded:feat-email-workers

Conversation

@nudded

@nudded nudded commented Mar 15, 2025

Copy link
Copy Markdown
Contributor

Closes #274

Looking for initial comments so I can wrap up this implementation. I've successfully validated reply and forward functionality.

example handler:

use uuid::Uuid;
use worker::*;

#[event(email)]
async fn main(message: EmailMessage, _env: Env, _ctx: Context) -> Result<()> {
    let message_id = message.headers().get("Message-ID")?.unwrap();

    let msg = format!(
        "From: Cloudflare bot <{}>
To: Toon <{}>
In-Reply-To: {}
Message-ID: <{}@redacted.com>
Subject: Email well received!

I've parsed the mail!

",
        message.to_email(),
        message.from_email(),
        message_id,
        Uuid::new_v4()
    );

    let new_message = EmailMessage::new(&message.to_email(), &message.from_email(), &msg)?;

    message.reply(new_message).await?;
    Ok(())
}

@DougAnderson444

Copy link
Copy Markdown

Does this part need to be a specific format? Or can it be any string?

let msg = format!(
        "From: Cloudflare bot <{}>
To: Toon <{}>
In-Reply-To: {}
Message-ID: <{}@redacted.com>
Subject: Email well received!

I've parsed the mail!

"

What I am wondering is if it needs a specific format, maybe it should use the type system to enforce it?

If it's just any old string, then disregard

@DougAnderson444

Copy link
Copy Markdown

What about an API like this:

use uuid::Uuid;
use worker::*;

#[event(email)]
async fn main(message: EmailMessage, _env: Env, _ctx: Context) -> Result<()> {

    let new_message = EmailMessage::try_from(
        RawEmailMessage::builder()
            .from_name("From Name")
            .from_email(&message.to_email())
            .to_name("To Name")
            .to_email(&message.from_email())
            .subject("Email well received!")
            .date("Sat, 15 Mar 2025 22:06:02 +0000")
            .message_id(format!("{}@redacted.com", Uuid::new_v4()))
            .in_reply_to(message.headers().get("Message-ID")?.unwrap())
            .message("I've parsed the mail!")
            .build(),
    );

    message.reply(new_message).await?;

    Ok(())
}

With the type system enforcing mandatory fields at compile time and removing any risk of user formatting errors?

@DougAnderson444

Copy link
Copy Markdown

Actually, on second thought, maybe msg should just be left a a string and if the user wants to use a build helper, it can exist in userland as a separate crate.

@nudded

nudded commented Mar 17, 2025

Copy link
Copy Markdown
Contributor Author

Actually, on second thought, maybe msg should just be left a a string and if the user wants to use a build helper, it can exist in userland as a separate crate.

yeah, In my testing not a lot of the existing crates seem to work well, so it might be useful to create a small crate that fills this gap (but should imho not be part of this crate)

@DougAnderson444

Copy link
Copy Markdown

@nudded

nudded commented Mar 18, 2025

Copy link
Copy Markdown
Contributor Author

This looked promising: https://docs.rs/mail-builder/latest/mail_builder/index.html

Tried this, got a CPU limit exceeded error on Cloudflare. (I think it's related to it adding the Date header, but did not want to spend much more time debugging)

@nudded

nudded commented Mar 25, 2025

Copy link
Copy Markdown
Contributor Author

@zebp If you have some time for a review :)

@jeholliday

Copy link
Copy Markdown

I tried out these changes, and I was able to receive and reply to emails just fine. I also used the mail_parser and mail_builder crates, and I found that they worked pretty well in combination with workers. The one thing I noticed is that you have to explicitly set a date and message id when building a message because otherwise the defaults will panic when it tries to get the date or generate a random id.

It would be nice if functionality for the SendEmail binding could also be added. I tried adding it myself I was also able to send emails that way. I just reused the binding for an EmailMessage and it worked to call send().

@devnull03

Copy link
Copy Markdown

I tried out these changes, and I was able to receive and reply to emails just fine. I also used the mail_parser and mail_builder crates, and I found that they worked pretty well in combination with workers. The one thing I noticed is that you have to explicitly set a date and message id when building a message because otherwise the defaults will panic when it tries to get the date or generate a random id.

It would be nice if functionality for the SendEmail binding could also be added. I tried adding it myself I was also able to send emails that way. I just reused the binding for an EmailMessage and it worked to call send().

@jeholliday would you be able to share what you wrote to implement the SendEmail binding? and the other stuff too. It would be really helpful. I am also trying to mainly use the send to other email addresses functionality instead of replying to received email.

@jeholliday

jeholliday commented May 24, 2025

Copy link
Copy Markdown

@jeholliday would you be able to share what you wrote to implement the SendEmail binding? and the other stuff too. It would be really helpful. I am also trying to mainly use the send to other email addresses functionality instead of replying to received email.

@devnull03 Sure, sorry it took a few days. I have committed the changes needed to get the SendEmail binding working in jeholliday@f05b750

This is an excerpt from where I am successfully using it:

use mail_builder::MessageBuilder;
use uuid::Uuid;
use worker::*;

async fn send_email(subject: &str, body: &str, env: &Env) -> Result<()> {
    let from = "<from_email>";
    let to = "<to_email>";

    let msg = MessageBuilder::new()
        .from(from)
        .to(to)
        .subject(subject)
        .date(Date::now().as_millis() / 1000)
        .message_id(format!("{}@<my_domain>", Uuid::new_v4()))
        .text_body(body)
        .write_to_string()
        .unwrap();

    let msg = EmailMessage::new(from, to, &msg)?;
    let seb = env.send_email("SEB")?;
    seb.send(msg).await?;

    Ok(())
}

This uses a binding named SEB from my workers.toml:

send_email = [
    { name = "SEB", destination_address = "<to_email>" },
]

Edit: BTW I remember it was very annoying to get the uuid and rand crates working, but I don't remember the details. I just have the following in my Cargo.toml to force it to use an older version, but there might be a better solution:

rand = "0.8.5"
getrandom = { version = "0.2.15", features = ["js"] }
uuid = { version = "=1.9", features = ["v4", "js"] }

Also if you are trying to use this branch, make sure you use the worker-build from this branch or it will fail to build: cargo install --path worker-build

@relvacode

Copy link
Copy Markdown

I was able to get this to work with MessageBuilder for replying to incoming e-mails but one thing to note is that In-Reply-To must be set and you cannot use .in_reply_to() with the incoming message ID header directly, as MessageBuilder adds surrounding <>.

Instead, you need to set the header manually using a raw header value.

let msg_id = msg.headers().get("Message-ID")?.unwrap();
let reply_msg = MessageBuilder::new()
    .header("In-Reply-To", HeaderType::from(Raw::new(msg_id)))

@nudded nudded force-pushed the feat-email-workers branch from ea7ff18 to 8344747 Compare May 14, 2026 17:01
@codspeed-hq

codspeed-hq Bot commented May 14, 2026

Copy link
Copy Markdown

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 nudded:feat-email-workers (4c16466) with main (fe8acaa)

Open in CodSpeed

guybedford
guybedford previously approved these changes May 14, 2026

@guybedford guybedford left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update on this one @nudded.

@guybedford

Copy link
Copy Markdown
Collaborator

@nudded I simplified the wrapper struct to just implement the main two convenience methods you added as raw_byte_stream() and raw_bytes. PTAL and let me know how that seems to you.

@nudded

nudded commented May 14, 2026

Copy link
Copy Markdown
Contributor Author

@nudded I simplified the wrapper struct to just implement the main two convenience methods you added as raw_byte_stream() and raw_bytes. PTAL and let me know how that seems to you.

Looks good to me! I was adding an example as well, but I could add that in a follow up PR as well.

@guybedford

Copy link
Copy Markdown
Collaborator

I was adding an example as well, but I could add that in a follow up PR as well.

Example would be great, please go ahead - I can wait to land this.

@nudded

nudded commented May 14, 2026

Copy link
Copy Markdown
Contributor Author

@guybedford added example and had to do a slight tweak to the macros. I deployed it and validated it (it's not the prettiest example, but it does work)

(comments about local development can be removed, but perhaps useful for future readers)

guybedford
guybedford previously approved these changes May 14, 2026
@guybedford guybedford merged commit 3d0903a into cloudflare:main May 14, 2026
11 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.

[Feature] Add email worker support

6 participants