workers-rs icon indicating copy to clipboard operation
workers-rs copied to clipboard

feat(EmailWorkers): Implement email worker functionality

Open nudded opened this issue 8 months ago • 11 comments

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(())
}

nudded avatar Mar 15 '25 19:03 nudded

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 avatar Mar 15 '25 21:03 DougAnderson444

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 avatar Mar 16 '25 01:03 DougAnderson444

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.

DougAnderson444 avatar Mar 16 '25 12:03 DougAnderson444

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)

nudded avatar Mar 17 '25 16:03 nudded

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

DougAnderson444 avatar Mar 17 '25 18:03 DougAnderson444

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 avatar Mar 18 '25 08:03 nudded

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

nudded avatar Mar 25 '25 18:03 nudded

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 avatar Mar 25 '25 20:03 jeholliday

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.

devnull03 avatar May 22 '25 06:05 devnull03

@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 https://github.com/jeholliday/workers-rs/commit/f05b750e46d63a4f71ff244d29234421dddbc953

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

jeholliday avatar May 24 '25 02:05 jeholliday

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)))

relvacode avatar Jul 27 '25 17:07 relvacode