Skip to content
Open

Email #185

Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions _examples/email/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
build
node_modules
.wrangler
.env
*.backup
12 changes: 12 additions & 0 deletions _examples/email/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.PHONY: dev
dev:
wrangler dev

.PHONY: build
build:
go run ../../cmd/workers-assets-gen -mode=go
GOOS=js GOARCH=wasm go build -o ./build/app.wasm .

.PHONY: deploy
deploy:
wrangler deploy
52 changes: 52 additions & 0 deletions _examples/email/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# email

* This is an example of an email handler showing how to forward, reply to, and send emails

## Demo

- Update `EMAIL_DOMAIN` and `VERIFIED_DESTINATION_ADDRESS` variables in `wrangler.toml` according to your domain and email addresses you'll be using to test
- Deploy worker to cloudflare
- [Enable Email routing](https://developers.cloudflare.com/email-routing/get-started/enable-email-routing/)
- In routing rules, either set the catch-all address action to send to your worker, or create custom address and set action to send to your worker
- Ensure you add the address you used for `VERIFIED_DESTINATION_ADDRESS` as a verified destination if you want to send emails to the given destination address. Complete the verification per the prompts in Cloudflare UI.
- ⚠️ IMPORTANT - When performing the testing below, ensure to send emails from a completely separate address that is not involved
in any forwarding related to this domain. Some email clients (like Gmail) detect sending-and-forwarding-to-yourself type of behaviour and you'll likely never get expected results.
- Send an email to your address that routes to your new worker with one of the following:
- If `Subject` contains `please reply` -> Worker will reply to your email
- If `Subject` contains `important` -> Worker will forward message to the addressed configured in `VERIFIED_DESTINATION_ADDRESS`
- If none of the cases above match -> Worker will send a net new message to `VERIFIED_DESTINATION_ADDRESS`

## Tips

- Consider using 3p packages like https://github.com/jhillyerd/enmime for easier construction of emails, particularly outbound ones
- Email Hygiene:
- The From/To values in the headers must always match the From/To values of the raw email, otherwise Cloudflare will throw an error
- Always include a Message-ID, otherwise cloudflare will throw an error
- Keep these in mind when using `.Reply()` - all of these are necessary in order for mail clients like Gmail to properly thread the messages
- Always include `In-Reply-To` Header referencing the original `Message-ID`
- Always include `References` Header referencing the original `Message-ID`
- Always ensure the Subject starts with `Re: `
- If you are building a `mail.Header` map manually, you MUST ensure to store the header with capitalized first character. The stdlib does this via the textproto package, i.e. `textproto.CanonicalMIMEHeaderKey("message-id")`

## Known issues
### [Cannot reply to emails received on a subdomain](https://community.cloudflare.com/t/email-worker-cannot-reply-to-emails-received-on-a-subdomain/719852/6)

Can't do much about this one, as the bug exists even at the javascript layer. Workarounds are to use your TLD for emails, or use `.Send()` explicitly rather than `.Reply()` if the recipient is a verified destination address.

## Development

### Requirements

This project requires these tools to be installed globally.

* wrangler
* Go 1.24.0 or later

### Commands

```
make dev # run dev server
make build # build Go Wasm binary
make deploy # deploy worker
```

7 changes: 7 additions & 0 deletions _examples/email/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module github.com/syumai/workers/_examples/email

go 1.21.3

require github.com/syumai/workers v0.0.0

replace github.com/syumai/workers => ../../
Empty file added _examples/email/go.sum
Empty file.
120 changes: 120 additions & 0 deletions _examples/email/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"bytes"
"fmt"
"io"
"log/slog"
"net/textproto"
"os"
"strings"
"time"

"github.com/syumai/workers/cloudflare"
"github.com/syumai/workers/cloudflare/email"
)

var emailDomain string = cloudflare.Getenv("EMAIL_DOMAIN")
var verifiedDesinationAddress string = cloudflare.Getenv("VERIFIED_DESTINATION_ADDRESS")
var logger = slog.New(slog.NewJSONHandler(os.Stdout, nil))

func HandleEmail(emailMessage email.ForwardableEmailMessage) error {
var err error

rawEmail := emailMessage.Raw()
msg, err := io.ReadAll(emailMessage.Raw())
if err != nil {
return err
}
defer rawEmail.Close()

logger.Info("received email",
"from", emailMessage.Headers().Get("From"),
"to", emailMessage.Headers().Get("To"),
"raw", string(msg),
)

subject := emailMessage.Headers().Get("Subject")

switch {
case strings.Contains(subject, "please reply"):
err = reply(emailMessage, []byte("I got your message"))
if err != nil {
return err
}
case strings.Contains(subject, "important"):
err = emailMessage.Forward(verifiedDesinationAddress, nil)
if err != nil {
return err
}
default:
from := fmt.Sprintf("no-reply%s", emailDomain)
send(from, verifiedDesinationAddress, "Someone sent us an email", []byte("Someone sent us a message!"))
}

return nil
}

func buildSimpleEmail(from, to, subject string, headers map[string]string, body []byte) (*bytes.Buffer, error) {
buf := new(bytes.Buffer)

// Write headers
fmt.Fprintf(buf, "From: <%s>\r\n", from)
fmt.Fprintf(buf, "To: <%s>\r\n", to)
fmt.Fprintf(buf, "Subject: %s\r\n", subject)
fmt.Fprintf(buf, "Date: %s\r\n", time.Now().Format(time.RFC1123Z))
fmt.Fprintf(buf, "Message-ID: <%d@%s>\r\n", time.Now().UnixNano(), emailDomain)

// Write custom headers
for k, v := range headers {
// Headers must start with Capital Letters
fmt.Fprintf(buf, "%s: %s\r\n", textproto.CanonicalMIMEHeaderKey(k), v)
}

// Empty line separates headers from body
fmt.Fprintf(buf, "\r\n")

// Write body
buf.Write(body)

return buf, nil
}

func reply(msg email.ForwardableEmailMessage, body []byte) error {
from := msg.To()
to := msg.From()
subject := fmt.Sprintf("Re: %s", msg.Headers().Get("Subject"))

// Build reply headers
headers := map[string]string{
"In-Reply-To": msg.Headers().Get("Message-ID"),
"References": msg.Headers().Get("Message-ID"),
}

buf, err := buildSimpleEmail(from, to, subject, headers, body)
if err != nil {
return fmt.Errorf("error building reply: %w", err)
}

reply := email.NewEmailMessage(from, to, io.NopCloser(buf))

err = msg.Reply(reply)
return err
}

func send(from string, to string, subject string, body []byte) error {
buf, err := buildSimpleEmail(from, to, subject, nil, body)
if err != nil {
return fmt.Errorf("error building email: %w", err)
}
mailClient := email.NewClient(cloudflare.GetBinding("EMAIL"))
err = mailClient.Send(email.NewEmailMessage(from, to, io.NopCloser(buf)))
if err != nil {
return fmt.Errorf("error sending email %w", err)
}
return nil
}

func main() {
email.Handle(HandleEmail)
}
17 changes: 17 additions & 0 deletions _examples/email/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name = "email"
main = "./build/worker.mjs"
compatibility_date = "2025-12-02"

[build]
command = "make build"

[[send_email]]
name = "EMAIL"

[vars]
# This is the email address that is allowed to receive emails sent as net-new or forwarded emails from Cloudflare
VERIFIED_DESTINATION_ADDRESS = "my-verified-personal-address@gmail.com"

# Used for generating Message-ID headers and 'from' address. This should be the domain you have
# configured in Cloudflare for email forwarding, ideally a TLD and not a subdomain
EMAIL_DOMAIN = "@mydomain.com"
Loading