From 5068bae37eb1f00bb12a64f001fa9f95db7cd24c Mon Sep 17 00:00:00 2001 From: Jordan Neufeld Date: Tue, 2 Dec 2025 21:47:19 -0600 Subject: [PATCH 1/4] basic email handling working reorder functions --- cloudflare/email/email.go | 243 ++++++++++++++++++ .../assets/common/worker.mjs | 8 + .../assets/runtime/cloudflare.mjs | 2 + go.sum | 0 internal/jsmail/header.go | 39 +++ 5 files changed, 292 insertions(+) create mode 100644 cloudflare/email/email.go create mode 100644 go.sum create mode 100644 internal/jsmail/header.go diff --git a/cloudflare/email/email.go b/cloudflare/email/email.go new file mode 100644 index 00000000..cab8d1b5 --- /dev/null +++ b/cloudflare/email/email.go @@ -0,0 +1,243 @@ +package email + +import ( + "context" + "errors" + "fmt" + "io" + "net/mail" + "syscall/js" + + "github.com/syumai/workers" + "github.com/syumai/workers/internal/jsmail" + "github.com/syumai/workers/internal/jsutil" + "github.com/syumai/workers/internal/runtimecontext" +) + +var ( + emailHandler Handler + doneCh = make(chan struct{}) +) + +func init() { + emailHandlerFunc := js.FuncOf(func(this js.Value, args []js.Value) any { + message := args[0] + var cb js.Func + cb = js.FuncOf(func(_ js.Value, pArgs []js.Value) any { + defer cb.Release() + resolve := pArgs[0] + reject := pArgs[1] + go func() { + if len(args) > 1 { + reject.Invoke(jsutil.Errorf("too many args given to handleEmail: %d", len(args))) + return + } + err := invokeEmailHandler(message) + if err != nil { + reject.Invoke(jsutil.Error(err.Error())) + return + } + resolve.Invoke(js.Undefined()) + }() + return js.Undefined() + }) + return jsutil.NewPromise(cb) + }) + jsutil.Binding.Set("handleEmail", emailHandlerFunc) +} + +// Type definitions + +type Handler func(m ForwardableEmailMessage) error + +// Email is the base interface for all email messages +type Email interface { + From() string + To() string + Raw() io.ReadCloser +} + +// SendableEmailMessage represents an email that can be sent outbound +type SendableEmailMessage interface { + From() string + To() string + Raw() io.ReadCloser +} + +// ForwardableEmailMessage is an inbound email that can be forwarded +type ForwardableEmailMessage interface { + SendableEmailMessage + Headers() mail.Header + Forward(rcptTo string, headers mail.Header) error + Reply(message SendableEmailMessage) error + SetReject(reason string) error +} + +// forwardableEmailMessage represents an incoming email message +type forwardableEmailMessage struct { + obj js.Value + from string + to string + raw js.Value + rawSize int +} + +// EmailMessage is an outbound email that can be sent +type EmailMessage struct { + from string + to string + raw io.ReadCloser +} + +// EmailClient is used to send outbound emails +type EmailClient struct { + bind js.Value +} + +// Constructor functions + +// NewEmailMessage creates a new outbound email message +func NewEmailMessage(from string, to string, raw io.ReadCloser) *EmailMessage { + return &EmailMessage{ + from: from, + to: to, + raw: raw, + } +} + +// NewClient creates a new EmailClient with the given binding +func NewClient(bind js.Value) *EmailClient { + return &EmailClient{ + bind: bind, + } +} + +// newForwardableEmailMessage creates a forwardableEmailMessage from the given context +func newForwardableEmailMessage(ctx context.Context) (*forwardableEmailMessage, error) { + obj := runtimecontext.MustExtractTriggerObj(ctx) + if obj.IsUndefined() { + return nil, errors.New("email event is null") + } + + return &forwardableEmailMessage{ + obj: obj, + from: obj.Get("from").String(), + to: obj.Get("to").String(), + raw: obj.Get("raw"), + rawSize: obj.Get("rawSize").Int(), + }, nil +} + +// forwardableEmailMessage methods + +func (f *forwardableEmailMessage) From() string { + return f.from +} + +func (f *forwardableEmailMessage) To() string { + return f.to +} + +func (f *forwardableEmailMessage) Raw() io.ReadCloser { + return jsutil.ConvertReadableStreamToReadCloser(f.raw) +} + +func (f *forwardableEmailMessage) Headers() mail.Header { + return jsmail.ToHeader(f.obj.Get("headers")) +} + +func (f *forwardableEmailMessage) Forward(rcpTo string, headers mail.Header) error { + var jsHeaders js.Value + + if headers != nil { + jsHeaders = jsmail.ToJSHeader(headers) + } + + prom := f.obj.Call("forward", rcpTo, jsHeaders) + _, err := jsutil.AwaitPromise(prom) + return err +} + +func (f *forwardableEmailMessage) Reply(message SendableEmailMessage) error { + msg := SendableEmailMessageToJSEmailMessage(message) + prom := f.obj.Call("reply", msg) + _, err := jsutil.AwaitPromise(prom) + return err +} + +func (f *forwardableEmailMessage) SetReject(reason string) error { + prom := f.obj.Call("setReject", reason) + _, err := jsutil.AwaitPromise(prom) + return err +} + +// EmailMessage methods + +func (e *EmailMessage) From() string { + return e.from +} + +func (e *EmailMessage) To() string { + return e.to +} + +func (e *EmailMessage) Raw() io.ReadCloser { + return e.raw +} + +// EmailClient methods + +func (c *EmailClient) Send(m SendableEmailMessage) error { + if c.bind.IsUndefined() || c.bind.Get("send").IsUndefined() { + return errors.New("provided email binding not found. Make sure you have [[send_email]] configured in your wrangler.toml or wrangler.jsonc") + } + emailMsg := SendableEmailMessageToJSEmailMessage(m) + // Call .send on the message + _, err := jsutil.AwaitPromise(c.bind.Call("send", emailMsg)) + if err != nil { + return fmt.Errorf("failed to send email: %v", err) + } + + return nil +} + +// Public API functions + +// Handle registers the email handler and blocks until the worker terminates +func Handle(handler Handler) { + HandleNonBlock(handler) + workers.Ready() + <-Done() +} + +// HandleNonBlock registers the email handler without blocking +func HandleNonBlock(handler Handler) { + emailHandler = handler +} + +// Done returns a channel that blocks indefinitely, preventing the worker from terminating +// Just like the cron package, doneCh is never actually closed, +// it's used for blocking/waiting so that worker does not terminate +func Done() <-chan struct{} { + return doneCh +} + +// Internal/helper functions + +// invokeEmailHandler is called by the JavaScript runtime when an email is received +func invokeEmailHandler(eventObj js.Value) error { + ctx := runtimecontext.New(context.Background(), eventObj) + message, err := newForwardableEmailMessage(ctx) + if err != nil { + return err + } + return emailHandler(message) +} + +// SendableEmailMessageToJSEmailMessage converts a SendableEmailMessage to a JavaScript EmailMessage +func SendableEmailMessageToJSEmailMessage(message SendableEmailMessage) js.Value { + runtimeCtx := jsutil.RuntimeContext + emailMessageCtor := runtimeCtx.Get("EmailMessage") + rawReadableStream := jsutil.ConvertReaderToReadableStream(io.NopCloser(message.Raw())) + return emailMessageCtor.New(message.From(), message.To(), rawReadableStream) +} diff --git a/cmd/workers-assets-gen/assets/common/worker.mjs b/cmd/workers-assets-gen/assets/common/worker.mjs index 638b323f..ab323422 100644 --- a/cmd/workers-assets-gen/assets/common/worker.mjs +++ b/cmd/workers-assets-gen/assets/common/worker.mjs @@ -49,6 +49,7 @@ async function scheduled(event, env, ctx) { return binding.runScheduler(event); } + async function queue(batch, env, ctx) { const binding = {}; await run(createRuntimeContext({ env, ctx, binding })); @@ -63,9 +64,16 @@ async function onRequest(ctx) { return binding.handleRequest(request); } +async function email(message, env, ctx) { + const binding = {}; + await run(createRuntimeContext({ env, ctx, binding })); + return binding.handleEmail(message); +} + export default { fetch, scheduled, queue, onRequest, + email, }; diff --git a/cmd/workers-assets-gen/assets/runtime/cloudflare.mjs b/cmd/workers-assets-gen/assets/runtime/cloudflare.mjs index 1fb0623a..a43cb5c1 100644 --- a/cmd/workers-assets-gen/assets/runtime/cloudflare.mjs +++ b/cmd/workers-assets-gen/assets/runtime/cloudflare.mjs @@ -1,4 +1,5 @@ import { connect } from "cloudflare:sockets"; +import { EmailMessage } from "cloudflare:email"; import mod from "./app.wasm"; export async function loadModule() { @@ -11,5 +12,6 @@ export function createRuntimeContext({ env, ctx, binding }) { ctx, connect, binding, + EmailMessage }; } diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..e69de29b diff --git a/internal/jsmail/header.go b/internal/jsmail/header.go new file mode 100644 index 00000000..0a5b5b66 --- /dev/null +++ b/internal/jsmail/header.go @@ -0,0 +1,39 @@ +package jsmail + +import ( + "net/mail" + "net/textproto" + "strings" + "syscall/js" + + "github.com/syumai/workers/internal/jsutil" +) + +// ToHeader converts JavaScript sides Headers to mail.Header. +// - Headers: https://developer.mozilla.org/docs/Web/API/Headers +func ToHeader(headers js.Value) mail.Header { + entries := jsutil.ArrayFrom(headers.Call("entries")) + headerLen := entries.Length() + h := make(map[string][]string) + for i := 0; i < headerLen; i++ { + entry := entries.Index(i) + key := textproto.CanonicalMIMEHeaderKey(entry.Index(0).String()) + values := entry.Index(1).String() + h[key] = strings.Split(values, ",") + + } + return mail.Header(h) + +} + +// ToJSHeader converts mail.Header to JavaScript sides Headers. +// - Headers: https://developer.mozilla.org/docs/Web/API/Headers +func ToJSHeader(header mail.Header) js.Value { + h := jsutil.HeadersClass.New() + for key, values := range header { + for _, value := range values { + h.Call("append", key, value) + } + } + return h +} From 838926a0475fd0f8c753cc262fe3392143891303 Mon Sep 17 00:00:00 2001 From: Jordan Neufeld Date: Mon, 8 Dec 2025 21:43:33 -0600 Subject: [PATCH 2/4] Add comprehensive example and some cleanup --- _examples/email/.gitignore | 5 ++ _examples/email/Makefile | 12 ++++ _examples/email/README.md | 52 +++++++++++++++ _examples/email/go.mod | 7 ++ _examples/email/go.sum | 0 _examples/email/main.go | 120 ++++++++++++++++++++++++++++++++++ _examples/email/wrangler.toml | 17 +++++ cloudflare/email/email.go | 10 +-- internal/jsmail/header.go | 2 - 9 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 _examples/email/.gitignore create mode 100644 _examples/email/Makefile create mode 100644 _examples/email/README.md create mode 100644 _examples/email/go.mod create mode 100644 _examples/email/go.sum create mode 100644 _examples/email/main.go create mode 100644 _examples/email/wrangler.toml diff --git a/_examples/email/.gitignore b/_examples/email/.gitignore new file mode 100644 index 00000000..83e4a9c8 --- /dev/null +++ b/_examples/email/.gitignore @@ -0,0 +1,5 @@ +build +node_modules +.wrangler +.env +*.backup diff --git a/_examples/email/Makefile b/_examples/email/Makefile new file mode 100644 index 00000000..3140ac9c --- /dev/null +++ b/_examples/email/Makefile @@ -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 diff --git a/_examples/email/README.md b/_examples/email/README.md new file mode 100644 index 00000000..6ab61749 --- /dev/null +++ b/_examples/email/README.md @@ -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 +``` + diff --git a/_examples/email/go.mod b/_examples/email/go.mod new file mode 100644 index 00000000..016b4427 --- /dev/null +++ b/_examples/email/go.mod @@ -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 => ../../ diff --git a/_examples/email/go.sum b/_examples/email/go.sum new file mode 100644 index 00000000..e69de29b diff --git a/_examples/email/main.go b/_examples/email/main.go new file mode 100644 index 00000000..815ed6a5 --- /dev/null +++ b/_examples/email/main.go @@ -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: %v", 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: %v", 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 %v", err) + } + return nil +} + +func main() { + email.Handle(HandleEmail) +} diff --git a/_examples/email/wrangler.toml b/_examples/email/wrangler.toml new file mode 100644 index 00000000..3af75d48 --- /dev/null +++ b/_examples/email/wrangler.toml @@ -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" diff --git a/cloudflare/email/email.go b/cloudflare/email/email.go index cab8d1b5..795b03dd 100644 --- a/cloudflare/email/email.go +++ b/cloudflare/email/email.go @@ -50,13 +50,6 @@ func init() { type Handler func(m ForwardableEmailMessage) error -// Email is the base interface for all email messages -type Email interface { - From() string - To() string - Raw() io.ReadCloser -} - // SendableEmailMessage represents an email that can be sent outbound type SendableEmailMessage interface { From() string @@ -69,7 +62,7 @@ type ForwardableEmailMessage interface { SendableEmailMessage Headers() mail.Header Forward(rcptTo string, headers mail.Header) error - Reply(message SendableEmailMessage) error + Reply(SendableEmailMessage) error SetReject(reason string) error } @@ -197,7 +190,6 @@ func (c *EmailClient) Send(m SendableEmailMessage) error { if err != nil { return fmt.Errorf("failed to send email: %v", err) } - return nil } diff --git a/internal/jsmail/header.go b/internal/jsmail/header.go index 0a5b5b66..dd395d87 100644 --- a/internal/jsmail/header.go +++ b/internal/jsmail/header.go @@ -20,10 +20,8 @@ func ToHeader(headers js.Value) mail.Header { key := textproto.CanonicalMIMEHeaderKey(entry.Index(0).String()) values := entry.Index(1).String() h[key] = strings.Split(values, ",") - } return mail.Header(h) - } // ToJSHeader converts mail.Header to JavaScript sides Headers. From b0663be0461b1a44d247f99d635653734eed38bc Mon Sep 17 00:00:00 2001 From: Jordan Neufeld Date: Mon, 8 Dec 2025 22:24:24 -0600 Subject: [PATCH 3/4] add a couple basic tests --- cloudflare/email/email_test.go | 150 +++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 cloudflare/email/email_test.go diff --git a/cloudflare/email/email_test.go b/cloudflare/email/email_test.go new file mode 100644 index 00000000..d152f757 --- /dev/null +++ b/cloudflare/email/email_test.go @@ -0,0 +1,150 @@ +package email + +import ( + "io" + "strings" + "syscall/js" + "testing" +) + +// TestNewEmailMessage tests the EmailMessage constructor +func TestNewEmailMessage(t *testing.T) { + tests := []struct { + name string + from string + to string + raw io.ReadCloser + }{ + { + name: "basic email message", + from: "sender@example.com", + to: "recipient@example.com", + raw: io.NopCloser(strings.NewReader("test email body")), + }, + { + name: "nil reader", + from: "sender@example.com", + to: "recipient@example.com", + raw: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg := NewEmailMessage(tt.from, tt.to, tt.raw) + + if msg.From() != tt.from { + t.Errorf("From() = %q, want %q", msg.From(), tt.from) + } + + if msg.To() != tt.to { + t.Errorf("To() = %q, want %q", msg.To(), tt.to) + } + + if msg.Raw() != tt.raw { + t.Errorf("Raw() = %v, want %v", msg.Raw(), tt.raw) + } + }) + } +} + +// TestEmailClientSend tests the EmailClient Send method +func TestEmailClientSend(t *testing.T) { + tests := []struct { + name string + binding js.Value + sendMethod js.Value + expectError bool + errorMsg string + }{ + { + name: "undefined binding", + binding: js.Undefined(), + expectError: true, + errorMsg: "binding not found", + }, + { + name: "binding without send method", + binding: js.ValueOf(map[string]interface{}{ + "other": "value", + }), + expectError: true, + errorMsg: "binding not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(tt.binding) + msg := NewEmailMessage("from@test.com", "to@test.com", io.NopCloser(strings.NewReader("test"))) + + err := client.Send(msg) + + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing %q, got nil", tt.errorMsg) + } else if !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error containing %q, got %q", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +// TestForwardableEmailMessageMethods tests the basic methods of ForwardableEmailMessage +func TestForwardableEmailMessageMethods(t *testing.T) { + // Create a mock js.Value that looks like an email message + mockJSValue := js.ValueOf(map[string]interface{}{ + "from": "sender@example.com", + "to": "recipient@example.com", + "raw": js.Undefined(), // Will be mocked in actual implementation + "rawSize": 100, + "headers": js.ValueOf(map[string]interface{}{ + "Subject": "Test Email", + "From": "Sender ", + }), + }) + + // Create forwardableEmailMessage directly + f := &forwardableEmailMessage{ + obj: mockJSValue, + from: "sender@example.com", + to: "recipient@example.com", + raw: js.Undefined(), + rawSize: 100, + } + + tests := []struct { + name string + testFunc func() interface{} + expected interface{} + }{ + { + name: "From method", + testFunc: func() interface{} { + return f.From() + }, + expected: "sender@example.com", + }, + { + name: "To method", + testFunc: func() interface{} { + return f.To() + }, + expected: "recipient@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.testFunc() + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} From c4e89aca70d411132b009c2458648d0f8517e1d5 Mon Sep 17 00:00:00 2001 From: Jordan Neufeld Date: Tue, 9 Dec 2025 13:59:22 -0600 Subject: [PATCH 4/4] address comments from code review --- _examples/email/main.go | 6 +++--- cloudflare/email/email.go | 4 ++-- internal/jsmail/header.go | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/_examples/email/main.go b/_examples/email/main.go index 815ed6a5..63f03e8c 100644 --- a/_examples/email/main.go +++ b/_examples/email/main.go @@ -93,7 +93,7 @@ func reply(msg email.ForwardableEmailMessage, body []byte) error { buf, err := buildSimpleEmail(from, to, subject, headers, body) if err != nil { - return fmt.Errorf("error building reply: %v", err) + return fmt.Errorf("error building reply: %w", err) } reply := email.NewEmailMessage(from, to, io.NopCloser(buf)) @@ -105,12 +105,12 @@ func reply(msg email.ForwardableEmailMessage, body []byte) error { 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: %v", err) + 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 %v", err) + return fmt.Errorf("error sending email %w", err) } return nil } diff --git a/cloudflare/email/email.go b/cloudflare/email/email.go index 795b03dd..6351adb7 100644 --- a/cloudflare/email/email.go +++ b/cloudflare/email/email.go @@ -182,13 +182,13 @@ func (e *EmailMessage) Raw() io.ReadCloser { func (c *EmailClient) Send(m SendableEmailMessage) error { if c.bind.IsUndefined() || c.bind.Get("send").IsUndefined() { - return errors.New("provided email binding not found. Make sure you have [[send_email]] configured in your wrangler.toml or wrangler.jsonc") + return errors.New("provided email binding not found") } emailMsg := SendableEmailMessageToJSEmailMessage(m) // Call .send on the message _, err := jsutil.AwaitPromise(c.bind.Call("send", emailMsg)) if err != nil { - return fmt.Errorf("failed to send email: %v", err) + return fmt.Errorf("failed to send email: %w", err) } return nil } diff --git a/internal/jsmail/header.go b/internal/jsmail/header.go index dd395d87..e442b790 100644 --- a/internal/jsmail/header.go +++ b/internal/jsmail/header.go @@ -14,7 +14,7 @@ import ( func ToHeader(headers js.Value) mail.Header { entries := jsutil.ArrayFrom(headers.Call("entries")) headerLen := entries.Length() - h := make(map[string][]string) + h := make(map[string][]string, headerLen) for i := 0; i < headerLen; i++ { entry := entries.Index(i) key := textproto.CanonicalMIMEHeaderKey(entry.Index(0).String())