diff --git a/internal/cmd/execute_gmail_forward_test.go b/internal/cmd/execute_gmail_forward_test.go
new file mode 100644
index 00000000..9ce62a19
--- /dev/null
+++ b/internal/cmd/execute_gmail_forward_test.go
@@ -0,0 +1,763 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "google.golang.org/api/gmail/v1"
+ "google.golang.org/api/option"
+)
+
+// TestExecute_GmailForward_DefaultSubjectAndAttachments verifies that forwarding
+// a message with a text/plain body and one attachment produces the correct
+// default "Fwd: " subject, includes the forwarded-message header block, and
+// carries the original attachment through to the sent MIME.
+func TestExecute_GmailForward_DefaultSubjectAndAttachments(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ attachmentData := []byte("binary-payload")
+ attachmentEncoded := base64.RawURLEncoding.EncodeToString(attachmentData)
+ plainBody := base64.RawURLEncoding.EncodeToString([]byte("Original body text."))
+
+ var attachmentFetched int32
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1/attachments/a1"):
+ atomic.AddInt32(&attachmentFetched, 1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": attachmentEncoded})
+ return
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m1",
+ "threadId": "t1",
+ "payload": map[string]any{
+ "mimeType": "multipart/mixed",
+ "headers": []map[string]any{
+ {"name": "Subject", "value": "Hello"},
+ {"name": "From", "value": "sender@example.com"},
+ {"name": "Date", "value": "Mon, 1 Jan 2024 00:00:00 +0000"},
+ },
+ "parts": []map[string]any{
+ {
+ "mimeType": "text/plain",
+ "body": map[string]any{"data": plainBody},
+ },
+ {
+ "filename": "report.pdf",
+ "mimeType": "application/pdf",
+ "body": map[string]any{
+ "attachmentId": "a1",
+ "size": len(attachmentData),
+ },
+ },
+ },
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/send"):
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ var msg gmail.Message
+ if unmarshalErr := json.Unmarshal(body, &msg); unmarshalErr != nil {
+ t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ s := string(raw)
+ if !strings.Contains(s, "Subject: Fwd: Hello\r\n") {
+ t.Fatalf("missing or wrong Subject in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "---------- Forwarded message ----------") {
+ t.Fatalf("missing forwarded message header in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "Forwarding note.") {
+ t.Fatalf("missing preface body in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "Original body text.") {
+ t.Fatalf("missing original body text in raw:\n%s", s)
+ }
+ // Verify attachment is present
+ if !strings.Contains(s, "report.pdf") {
+ t.Fatalf("missing attachment filename in raw:\n%s", s)
+ }
+ attachB64 := base64.StdEncoding.EncodeToString(attachmentData)
+ if !strings.Contains(s, attachB64) {
+ t.Fatalf("missing attachment data in raw:\n%s", s)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "s1", "threadId": "t2"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ out := captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "gmail", "forward", "m1",
+ "--to", "to@example.com",
+ "--body", "Forwarding note.",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ if atomic.LoadInt32(&attachmentFetched) != 1 {
+ t.Fatalf("expected 1 attachment fetch, got %d", atomic.LoadInt32(&attachmentFetched))
+ }
+
+ var parsed map[string]any
+ if unmarshalErr := json.Unmarshal([]byte(out), &parsed); unmarshalErr != nil {
+ t.Fatalf("json parse: %v\nout=%q", unmarshalErr, out)
+ }
+ if parsed["messageId"] != "s1" {
+ t.Fatalf("unexpected messageId: %v", parsed["messageId"])
+ }
+}
+
+// TestExecute_GmailForward_HTMLOnlyMessage verifies that when the original
+// message has only an HTML body (no text/plain), the forward generates a
+// plain-text fallback by stripping HTML tags and preserves the HTML in
+// the HTML part.
+func TestExecute_GmailForward_HTMLOnlyMessage(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ htmlContent := "
Hello world
"
+ htmlEncoded := base64.RawURLEncoding.EncodeToString([]byte(htmlContent))
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m2"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m2",
+ "threadId": "t2",
+ "payload": map[string]any{
+ "mimeType": "text/html",
+ "headers": []map[string]any{
+ {"name": "Subject", "value": "HTML Only"},
+ {"name": "From", "value": "html@example.com"},
+ {"name": "Date", "value": "Mon, 1 Jan 2024 00:00:00 +0000"},
+ },
+ "body": map[string]any{"data": htmlEncoded},
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/send"):
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ var msg gmail.Message
+ if unmarshalErr := json.Unmarshal(body, &msg); unmarshalErr != nil {
+ t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ s := string(raw)
+ // Plain-text fallback should have HTML tags stripped
+ if !strings.Contains(s, "Hello world") {
+ t.Fatalf("missing plain-text fallback (HTML stripped) in raw:\n%s", s)
+ }
+ // HTML part should preserve the original
+ if !strings.Contains(s, htmlContent) {
+ t.Fatalf("missing original HTML in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "---------- Forwarded message ----------") {
+ t.Fatalf("missing forwarded message header in raw:\n%s", s)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "s2", "threadId": "t3"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ _ = captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "gmail", "forward", "m2",
+ "--to", "to@example.com",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+}
+
+// TestExecute_GmailForward_CustomSubject verifies that --subject overrides
+// the default "Fwd: " subject.
+func TestExecute_GmailForward_CustomSubject(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ plainBody := base64.RawURLEncoding.EncodeToString([]byte("Some text."))
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m3"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m3",
+ "threadId": "t3",
+ "payload": map[string]any{
+ "mimeType": "text/plain",
+ "headers": []map[string]any{
+ {"name": "Subject", "value": "Original Subject"},
+ {"name": "From", "value": "orig@example.com"},
+ {"name": "Date", "value": "Mon, 1 Jan 2024 00:00:00 +0000"},
+ },
+ "body": map[string]any{"data": plainBody},
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/send"):
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ var msg gmail.Message
+ if unmarshalErr := json.Unmarshal(body, &msg); unmarshalErr != nil {
+ t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ s := string(raw)
+ if !strings.Contains(s, "Subject: Custom Subject\r\n") {
+ t.Fatalf("expected custom subject in raw:\n%s", s)
+ }
+ if strings.Contains(s, "Fwd: Original Subject") {
+ t.Fatalf("should not have default Fwd: subject in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "FYI") {
+ t.Fatalf("missing body preface in raw:\n%s", s)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "s3", "threadId": "t4"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ _ = captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "gmail", "forward", "m3",
+ "--to", "to@example.com",
+ "--subject", "Custom Subject",
+ "--body", "FYI",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+}
+
+// TestExecute_GmailForward_CcAndBcc verifies that --cc and --bcc recipients
+// appear in the sent MIME headers.
+func TestExecute_GmailForward_CcAndBcc(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ plainBody := base64.RawURLEncoding.EncodeToString([]byte("Body content."))
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m4"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m4",
+ "threadId": "t4",
+ "payload": map[string]any{
+ "mimeType": "text/plain",
+ "headers": []map[string]any{
+ {"name": "Subject", "value": "CC Test"},
+ {"name": "From", "value": "orig@example.com"},
+ {"name": "Date", "value": "Mon, 1 Jan 2024 00:00:00 +0000"},
+ },
+ "body": map[string]any{"data": plainBody},
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/send"):
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ var msg gmail.Message
+ if unmarshalErr := json.Unmarshal(body, &msg); unmarshalErr != nil {
+ t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ s := string(raw)
+ if !strings.Contains(s, "Cc: cc@example.com\r\n") {
+ t.Fatalf("missing Cc header in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "Bcc: bcc@example.com\r\n") {
+ t.Fatalf("missing Bcc header in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "To: to@example.com\r\n") {
+ t.Fatalf("missing To header in raw:\n%s", s)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "s4", "threadId": "t5"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ _ = captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "gmail", "forward", "m4",
+ "--to", "to@example.com",
+ "--cc", "cc@example.com",
+ "--bcc", "bcc@example.com",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+}
+
+// TestExecute_GmailForward_NoAttachments verifies that --no-attachments
+// strips the original attachment and does NOT call the attachment fetch API.
+func TestExecute_GmailForward_NoAttachments(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ plainBody := base64.RawURLEncoding.EncodeToString([]byte("Text body."))
+ attachmentData := []byte("should-not-appear")
+ attachmentEncoded := base64.RawURLEncoding.EncodeToString(attachmentData)
+
+ var attachmentFetched int32
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m5/attachments/a1"):
+ atomic.AddInt32(&attachmentFetched, 1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": attachmentEncoded})
+ return
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m5"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m5",
+ "threadId": "t5",
+ "payload": map[string]any{
+ "mimeType": "multipart/mixed",
+ "headers": []map[string]any{
+ {"name": "Subject", "value": "With Attachment"},
+ {"name": "From", "value": "orig@example.com"},
+ {"name": "Date", "value": "Mon, 1 Jan 2024 00:00:00 +0000"},
+ },
+ "parts": []map[string]any{
+ {
+ "mimeType": "text/plain",
+ "body": map[string]any{"data": plainBody},
+ },
+ {
+ "filename": "secret.pdf",
+ "mimeType": "application/pdf",
+ "body": map[string]any{
+ "attachmentId": "a1",
+ "size": len(attachmentData),
+ },
+ },
+ },
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/send"):
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ var msg gmail.Message
+ if unmarshalErr := json.Unmarshal(body, &msg); unmarshalErr != nil {
+ t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ s := string(raw)
+ // Should NOT contain the attachment
+ if strings.Contains(s, "secret.pdf") {
+ t.Fatalf("attachment should have been stripped but found filename in raw:\n%s", s)
+ }
+ attachB64 := base64.StdEncoding.EncodeToString(attachmentData)
+ if strings.Contains(s, attachB64) {
+ t.Fatalf("attachment data should have been stripped but found in raw:\n%s", s)
+ }
+ // Should still have the forwarded body
+ if !strings.Contains(s, "---------- Forwarded message ----------") {
+ t.Fatalf("missing forwarded message header in raw:\n%s", s)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "s5", "threadId": "t6"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ _ = captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "gmail", "forward", "m5",
+ "--to", "to@example.com",
+ "--no-attachments",
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ if atomic.LoadInt32(&attachmentFetched) != 0 {
+ t.Fatalf("expected 0 attachment fetch calls with --no-attachments, got %d", atomic.LoadInt32(&attachmentFetched))
+ }
+}
+
+// TestExecute_GmailForward_ExtraLocalAttachments verifies that --attach adds
+// a local file alongside the original attachment in the forwarded MIME.
+func TestExecute_GmailForward_ExtraLocalAttachments(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ origAttachData := []byte("original-attachment")
+ origAttachEncoded := base64.RawURLEncoding.EncodeToString(origAttachData)
+ plainBody := base64.RawURLEncoding.EncodeToString([]byte("Message body."))
+
+ // Create a local temp file to attach.
+ tmpFile, tmpErr := os.CreateTemp(t.TempDir(), "local-*.txt")
+ if tmpErr != nil {
+ t.Fatalf("CreateTemp: %v", tmpErr)
+ }
+ localContent := []byte("local-file-content")
+ if _, writeErr := tmpFile.Write(localContent); writeErr != nil {
+ t.Fatalf("Write: %v", writeErr)
+ }
+ _ = tmpFile.Close()
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m6/attachments/a1"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": origAttachEncoded})
+ return
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m6"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m6",
+ "threadId": "t6",
+ "payload": map[string]any{
+ "mimeType": "multipart/mixed",
+ "headers": []map[string]any{
+ {"name": "Subject", "value": "With Attach"},
+ {"name": "From", "value": "orig@example.com"},
+ {"name": "Date", "value": "Mon, 1 Jan 2024 00:00:00 +0000"},
+ },
+ "parts": []map[string]any{
+ {
+ "mimeType": "text/plain",
+ "body": map[string]any{"data": plainBody},
+ },
+ {
+ "filename": "orig.bin",
+ "mimeType": "application/octet-stream",
+ "body": map[string]any{
+ "attachmentId": "a1",
+ "size": len(origAttachData),
+ },
+ },
+ },
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/send"):
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ var msg gmail.Message
+ if unmarshalErr := json.Unmarshal(body, &msg); unmarshalErr != nil {
+ t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ s := string(raw)
+ // Both the original attachment and the local file should be present
+ origB64 := base64.StdEncoding.EncodeToString(origAttachData)
+ if !strings.Contains(s, origB64) {
+ t.Fatalf("missing original attachment data in raw:\n%s", s)
+ }
+ if !strings.Contains(s, "orig.bin") {
+ t.Fatalf("missing original attachment filename in raw:\n%s", s)
+ }
+ localB64 := base64.StdEncoding.EncodeToString(localContent)
+ if !strings.Contains(s, localB64) {
+ t.Fatalf("missing local attachment data in raw:\n%s", s)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "s6", "threadId": "t7"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ _ = captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "gmail", "forward", "m6",
+ "--to", "to@example.com",
+ "--attach", tmpFile.Name(),
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+}
+
+// TestExecute_GmailForward_NoAttachmentsWithLocalAttach verifies that
+// --no-attachments strips the original attachment while --attach still
+// includes the local file.
+func TestExecute_GmailForward_NoAttachmentsWithLocalAttach(t *testing.T) {
+ origNew := newGmailService
+ t.Cleanup(func() { newGmailService = origNew })
+
+ origAttachData := []byte("original-should-not-appear")
+ origAttachEncoded := base64.RawURLEncoding.EncodeToString(origAttachData)
+ plainBody := base64.RawURLEncoding.EncodeToString([]byte("Some text."))
+
+ // Create a local temp file to attach.
+ tmpFile, tmpErr := os.CreateTemp(t.TempDir(), "local-*.txt")
+ if tmpErr != nil {
+ t.Fatalf("CreateTemp: %v", tmpErr)
+ }
+ localContent := []byte("local-only-content")
+ if _, writeErr := tmpFile.Write(localContent); writeErr != nil {
+ t.Fatalf("Write: %v", writeErr)
+ }
+ _ = tmpFile.Close()
+
+ var attachmentFetched int32
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m7/attachments/a1"):
+ atomic.AddInt32(&attachmentFetched, 1)
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": origAttachEncoded})
+ return
+ case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m7"):
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "m7",
+ "threadId": "t7",
+ "payload": map[string]any{
+ "mimeType": "multipart/mixed",
+ "headers": []map[string]any{
+ {"name": "Subject", "value": "Mixed"},
+ {"name": "From", "value": "orig@example.com"},
+ {"name": "Date", "value": "Mon, 1 Jan 2024 00:00:00 +0000"},
+ },
+ "parts": []map[string]any{
+ {
+ "mimeType": "text/plain",
+ "body": map[string]any{"data": plainBody},
+ },
+ {
+ "filename": "orig-secret.bin",
+ "mimeType": "application/octet-stream",
+ "body": map[string]any{
+ "attachmentId": "a1",
+ "size": len(origAttachData),
+ },
+ },
+ },
+ },
+ })
+ return
+ case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/send"):
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ t.Fatalf("ReadAll: %v", err)
+ }
+ var msg gmail.Message
+ if unmarshalErr := json.Unmarshal(body, &msg); unmarshalErr != nil {
+ t.Fatalf("unmarshal: %v body=%q", unmarshalErr, string(body))
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(msg.Raw)
+ if err != nil {
+ t.Fatalf("decode raw: %v", err)
+ }
+ s := string(raw)
+ // Original attachment should NOT be present
+ origB64 := base64.StdEncoding.EncodeToString(origAttachData)
+ if strings.Contains(s, origB64) {
+ t.Fatalf("original attachment data should be stripped but found in raw:\n%s", s)
+ }
+ if strings.Contains(s, "orig-secret.bin") {
+ t.Fatalf("original attachment filename should be stripped but found in raw:\n%s", s)
+ }
+ // Local file SHOULD be present
+ localB64 := base64.StdEncoding.EncodeToString(localContent)
+ if !strings.Contains(s, localB64) {
+ t.Fatalf("missing local attachment data in raw:\n%s", s)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]any{"id": "s7", "threadId": "t8"})
+ return
+ default:
+ http.NotFound(w, r)
+ return
+ }
+ }))
+ defer srv.Close()
+
+ svc, err := gmail.NewService(context.Background(),
+ option.WithoutAuthentication(),
+ option.WithHTTPClient(srv.Client()),
+ option.WithEndpoint(srv.URL+"/"),
+ )
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
+
+ _ = captureStdout(t, func() {
+ _ = captureStderr(t, func() {
+ if err := Execute([]string{
+ "--json",
+ "--account", "a@b.com",
+ "gmail", "forward", "m7",
+ "--to", "to@example.com",
+ "--no-attachments",
+ "--attach", tmpFile.Name(),
+ }); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ })
+ })
+
+ if atomic.LoadInt32(&attachmentFetched) != 0 {
+ t.Fatalf("expected 0 attachment fetch calls with --no-attachments, got %d", atomic.LoadInt32(&attachmentFetched))
+ }
+}
diff --git a/internal/cmd/gmail.go b/internal/cmd/gmail.go
index 23d8187a..ba6a60f0 100644
--- a/internal/cmd/gmail.go
+++ b/internal/cmd/gmail.go
@@ -21,6 +21,7 @@ type GmailCmd struct {
Trash GmailTrashMsgCmd `cmd:"" name:"trash" group:"Organize" help:"Move messages to trash"`
Send GmailSendCmd `cmd:"" name:"send" group:"Write" help:"Send an email"`
+ Forward GmailForwardCmd `cmd:"" name:"forward" aliases:"fwd" group:"Write" help:"Forward a message"`
AutoReply GmailAutoReplyCmd `cmd:"" name:"autoreply" group:"Write" help:"Reply once to matching messages"`
Track GmailTrackCmd `cmd:"" name:"track" group:"Write" help:"Email open tracking"`
Drafts GmailDraftsCmd `cmd:"" name:"drafts" aliases:"draft" group:"Write" help:"Draft operations"`
diff --git a/internal/cmd/gmail_forward.go b/internal/cmd/gmail_forward.go
new file mode 100644
index 00000000..3622d306
--- /dev/null
+++ b/internal/cmd/gmail_forward.go
@@ -0,0 +1,264 @@
+package cmd
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "html"
+ "os"
+ "strings"
+
+ "google.golang.org/api/gmail/v1"
+
+ "github.com/steipete/gogcli/internal/outfmt"
+ "github.com/steipete/gogcli/internal/ui"
+)
+
+type GmailForwardCmd struct {
+ MessageID string `arg:"" name:"messageId" help:"Message ID to forward"`
+ To string `name:"to" help:"Recipients (comma-separated; required)"`
+ Cc string `name:"cc" help:"CC recipients (comma-separated)"`
+ Bcc string `name:"bcc" help:"BCC recipients (comma-separated)"`
+ Subject string `name:"subject" help:"Override subject (default: Fwd: )"`
+ Body string `name:"body" help:"Body preface (plain text)"`
+ BodyFile string `name:"body-file" help:"Body preface file ('-' for stdin)"`
+ From string `name:"from" help:"Send from verified send-as alias"`
+ Attach []string `name:"attach" help:"Additional local file (repeatable)"`
+ NoAttachments bool `name:"no-attachments" help:"Strip original attachments"`
+}
+
+func (c *GmailForwardCmd) Run(ctx context.Context, flags *RootFlags) error {
+ u := ui.FromContext(ctx)
+
+ messageID := normalizeGmailMessageID(strings.TrimSpace(c.MessageID))
+ if messageID == "" {
+ return usage("messageId required")
+ }
+
+ toArg := strings.TrimSpace(c.To)
+ if toArg == "" {
+ return usage("required: --to")
+ }
+ toRecipients := splitCSV(toArg)
+ if len(toRecipients) == 0 {
+ return usage("required: --to")
+ }
+ ccRecipients := splitCSV(c.Cc)
+ bccRecipients := splitCSV(c.Bcc)
+
+ prefacePlain, err := resolveBodyInput(c.Body, c.BodyFile)
+ if err != nil {
+ return err
+ }
+
+ // Expand local attachment paths early so we fail fast on bad paths.
+ var localAttachPaths []string
+ if len(c.Attach) > 0 {
+ localAttachPaths, err = expandComposeAttachmentPaths(c.Attach)
+ if err != nil {
+ return err
+ }
+ }
+
+ if dryRunErr := dryRunExit(ctx, flags, "gmail.forward", map[string]any{
+ "message_id": messageID,
+ "to": toRecipients,
+ "cc": ccRecipients,
+ "bcc": bccRecipients,
+ "subject": strings.TrimSpace(c.Subject),
+ "from": strings.TrimSpace(c.From),
+ "body_len": len(strings.TrimSpace(prefacePlain)),
+ "attachments": localAttachPaths,
+ "no_attachments": c.NoAttachments,
+ }); dryRunErr != nil {
+ return dryRunErr
+ }
+
+ account, svc, err := requireGmailService(ctx, flags)
+ if err != nil {
+ return err
+ }
+
+ msg, err := svc.Users.Messages.Get("me", messageID).Format("full").Context(ctx).Do()
+ if err != nil {
+ return err
+ }
+ if msg == nil || msg.Payload == nil {
+ return fmt.Errorf("message %s has no payload", messageID)
+ }
+
+ sendAsList, sendAsListErr := listSendAs(ctx, svc)
+ from, err := resolveComposeFrom(ctx, svc, account, c.From, sendAsList, sendAsListErr)
+ if err != nil {
+ return err
+ }
+
+ subject := strings.TrimSpace(c.Subject)
+ if subject == "" {
+ subject = forwardSubject(headerValue(msg.Payload, "Subject"))
+ }
+
+ plainBody, htmlBody := buildForwardBodies(msg.Payload, prefacePlain)
+
+ var attachments []mailAttachment
+
+ if !c.NoAttachments {
+ attachments, err = collectForwardAttachments(ctx, svc, messageID, msg.Payload)
+ if err != nil {
+ return err
+ }
+ }
+
+ if len(localAttachPaths) > 0 {
+ attachments = append(attachments, attachmentsFromPaths(localAttachPaths)...)
+ }
+
+ raw, err := buildRFC822(mailOptions{
+ From: from.header,
+ To: toRecipients,
+ Cc: ccRecipients,
+ Bcc: bccRecipients,
+ Subject: subject,
+ Body: plainBody,
+ BodyHTML: htmlBody,
+ Attachments: attachments,
+ }, nil)
+ if err != nil {
+ return err
+ }
+
+ sent, err := svc.Users.Messages.Send("me", &gmail.Message{
+ Raw: base64.RawURLEncoding.EncodeToString(raw),
+ ThreadId: msg.ThreadId,
+ }).Context(ctx).Do()
+ if err != nil {
+ return err
+ }
+
+ if outfmt.IsJSON(ctx) {
+ return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
+ "messageId": sent.Id,
+ "threadId": sent.ThreadId,
+ "from": from.header,
+ })
+ }
+
+ u.Out().Printf("message_id\t%s", sent.Id)
+ if sent.ThreadId != "" {
+ u.Out().Printf("thread_id\t%s", sent.ThreadId)
+ }
+ return nil
+}
+
+// forwardSubject prepends "Fwd: " to a subject unless it already has a forward prefix.
+func forwardSubject(original string) string {
+ subject := strings.TrimSpace(original)
+ if subject == "" {
+ return "Fwd: (no subject)"
+ }
+ lower := strings.ToLower(subject)
+ if strings.HasPrefix(lower, "fwd:") || strings.HasPrefix(lower, "fw:") {
+ return subject
+ }
+ return "Fwd: " + subject
+}
+
+func forwardHeaderPlain(p *gmail.MessagePart) string {
+ fields := []string{"From", "Date", "Subject", "To", "Cc"}
+ lines := make([]string, 0, len(fields)+1)
+ lines = append(lines, "---------- Forwarded message ----------")
+ for _, name := range fields {
+ value := strings.TrimSpace(headerValue(p, name))
+ if value != "" {
+ lines = append(lines, fmt.Sprintf("%s: %s", name, value))
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+func forwardHeaderHTML(p *gmail.MessagePart) string {
+ fields := []string{"From", "Date", "Subject", "To", "Cc"}
+ lines := make([]string, 0, len(fields)+1)
+ lines = append(lines, html.EscapeString("---------- Forwarded message ----------"))
+ for _, name := range fields {
+ value := strings.TrimSpace(headerValue(p, name))
+ if value != "" {
+ lines = append(lines, fmt.Sprintf("%s: %s", html.EscapeString(name), html.EscapeString(value)))
+ }
+ }
+ return strings.Join(lines, "
")
+}
+
+func buildForwardBodies(payload *gmail.MessagePart, prefacePlain string) (string, string) {
+ plainOriginal := findPartBody(payload, "text/plain")
+ htmlOriginal := findPartBody(payload, "text/html")
+
+ // Generate plain text fallback from HTML when no text/plain part exists.
+ if plainOriginal == "" && htmlOriginal != "" {
+ plainOriginal = stripHTMLTags(htmlOriginal)
+ }
+
+ plainHeader := forwardHeaderPlain(payload)
+ plainBody := joinForwardSections("\n\n", prefacePlain, plainHeader, plainOriginal)
+
+ // Only produce an HTML body if the original has HTML or the result would benefit from it.
+ useHTML := strings.TrimSpace(htmlOriginal) != ""
+ if !useHTML {
+ return plainBody, ""
+ }
+
+ htmlPreface := ""
+ if strings.TrimSpace(prefacePlain) != "" {
+ htmlPreface = plainToHTML(prefacePlain)
+ }
+ if strings.TrimSpace(htmlOriginal) == "" && strings.TrimSpace(plainOriginal) != "" {
+ htmlOriginal = plainToHTML(plainOriginal)
+ }
+
+ htmlHeader := forwardHeaderHTML(payload)
+ htmlBody := joinForwardSections("
", htmlPreface, htmlHeader, htmlOriginal)
+ return plainBody, htmlBody
+}
+
+func plainToHTML(s string) string {
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ s = strings.ReplaceAll(s, "\r", "\n")
+ escaped := html.EscapeString(s)
+ return strings.ReplaceAll(escaped, "\n", "
")
+}
+
+// joinForwardSections joins non-empty parts with the given separator.
+func joinForwardSections(sep string, parts ...string) string {
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ if strings.TrimSpace(part) == "" {
+ continue
+ }
+ out = append(out, part)
+ }
+ if len(out) == 0 {
+ return ""
+ }
+ return strings.Join(out, sep)
+}
+
+func collectForwardAttachments(ctx context.Context, svc *gmail.Service, messageID string, payload *gmail.MessagePart) ([]mailAttachment, error) {
+ infos := collectAttachments(payload)
+ if len(infos) == 0 {
+ return nil, nil
+ }
+
+ attachments := make([]mailAttachment, 0, len(infos))
+ for _, info := range infos {
+ data, err := fetchAttachmentBytes(ctx, svc, messageID, info.AttachmentID)
+ if err != nil {
+ return nil, fmt.Errorf("fetching attachment %q: %w", info.Filename, err)
+ }
+ attachments = append(attachments, mailAttachment{
+ Filename: info.Filename,
+ MIMEType: info.MimeType,
+ Data: data,
+ })
+ }
+ return attachments, nil
+}
diff --git a/internal/cmd/gmail_forward_test.go b/internal/cmd/gmail_forward_test.go
new file mode 100644
index 00000000..9f277fc4
--- /dev/null
+++ b/internal/cmd/gmail_forward_test.go
@@ -0,0 +1,424 @@
+package cmd
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+
+ "google.golang.org/api/gmail/v1"
+)
+
+// ---------------------------------------------------------------------------
+// forwardSubject
+// ---------------------------------------------------------------------------
+
+func TestForwardSubject(t *testing.T) {
+ tests := []struct {
+ name string
+ original string
+ want string
+ }{
+ {name: "empty string", original: "", want: "Fwd: (no subject)"},
+ {name: "whitespace only", original: " ", want: "Fwd: (no subject)"},
+ {name: "plain subject", original: "Hello", want: "Fwd: Hello"},
+ {name: "already Fwd:", original: "Fwd: Hello", want: "Fwd: Hello"},
+ {name: "already Fw:", original: "Fw: Hello", want: "Fw: Hello"},
+ {name: "already FWD: uppercase", original: "FWD: Hello", want: "FWD: Hello"},
+ {name: "already fwd: lowercase", original: "fwd: Hello", want: "fwd: Hello"},
+ {name: "already fw: lowercase", original: "fw: Hello", want: "fw: Hello"},
+ {name: "Re: subject gets Fwd:", original: "Re: Something", want: "Fwd: Re: Something"},
+ {name: "leading whitespace trimmed", original: " Hello ", want: "Fwd: Hello"},
+ {name: "Fwd: with extra spaces", original: " Fwd: Hello ", want: "Fwd: Hello"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := forwardSubject(tt.original)
+ if got != tt.want {
+ t.Fatalf("forwardSubject(%q) = %q, want %q", tt.original, got, tt.want)
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// helpers to build gmail.MessagePart fixtures
+// ---------------------------------------------------------------------------
+
+func makePart(mimeType string, body string, headers map[string]string) *gmail.MessagePart {
+ p := &gmail.MessagePart{
+ MimeType: mimeType,
+ Headers: make([]*gmail.MessagePartHeader, 0, len(headers)),
+ }
+ for k, v := range headers {
+ p.Headers = append(p.Headers, &gmail.MessagePartHeader{Name: k, Value: v})
+ }
+ if body != "" {
+ p.Body = &gmail.MessagePartBody{
+ Data: base64.RawURLEncoding.EncodeToString([]byte(body)),
+ }
+ }
+ return p
+}
+
+func makeMultipartPayload(headers map[string]string, parts ...*gmail.MessagePart) *gmail.MessagePart {
+ p := &gmail.MessagePart{
+ MimeType: "multipart/alternative",
+ Headers: make([]*gmail.MessagePartHeader, 0, len(headers)),
+ Parts: parts,
+ }
+ for k, v := range headers {
+ p.Headers = append(p.Headers, &gmail.MessagePartHeader{Name: k, Value: v})
+ }
+ return p
+}
+
+// ---------------------------------------------------------------------------
+// forwardHeaderPlain
+// ---------------------------------------------------------------------------
+
+func TestForwardHeaderPlain(t *testing.T) {
+ t.Run("all headers present", func(t *testing.T) {
+ p := makePart("text/plain", "", map[string]string{
+ "From": "alice@example.com",
+ "Date": "Mon, 1 Jan 2024 10:00:00 +0000",
+ "Subject": "Test",
+ "To": "bob@example.com",
+ "Cc": "carol@example.com",
+ })
+ got := forwardHeaderPlain(p)
+
+ if !strings.HasPrefix(got, "---------- Forwarded message ----------") {
+ t.Fatalf("missing forwarded message banner:\n%s", got)
+ }
+ for _, want := range []string{
+ "From: alice@example.com",
+ "Date: Mon, 1 Jan 2024 10:00:00 +0000",
+ "Subject: Test",
+ "To: bob@example.com",
+ "Cc: carol@example.com",
+ } {
+ if !strings.Contains(got, want) {
+ t.Fatalf("missing %q in:\n%s", want, got)
+ }
+ }
+ })
+
+ t.Run("missing Cc header omitted", func(t *testing.T) {
+ p := makePart("text/plain", "", map[string]string{
+ "From": "alice@example.com",
+ "Date": "Mon, 1 Jan 2024 10:00:00 +0000",
+ "Subject": "Test",
+ "To": "bob@example.com",
+ })
+ got := forwardHeaderPlain(p)
+
+ if strings.Contains(got, "Cc:") {
+ t.Fatalf("expected no Cc line, got:\n%s", got)
+ }
+ })
+
+ t.Run("no headers at all", func(t *testing.T) {
+ p := makePart("text/plain", "", map[string]string{})
+ got := forwardHeaderPlain(p)
+
+ if got != "---------- Forwarded message ----------" {
+ t.Fatalf("expected only banner, got:\n%s", got)
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// forwardHeaderHTML
+// ---------------------------------------------------------------------------
+
+func TestForwardHeaderHTML(t *testing.T) {
+ t.Run("all headers present", func(t *testing.T) {
+ p := makePart("text/html", "", map[string]string{
+ "From": "alice@example.com",
+ "Date": "Mon, 1 Jan 2024 10:00:00 +0000",
+ "Subject": "Test",
+ "To": "bob@example.com",
+ "Cc": "carol@example.com",
+ })
+ got := forwardHeaderHTML(p)
+
+ if !strings.Contains(got, "---------- Forwarded message ----------") {
+ t.Fatalf("missing forwarded message banner:\n%s", got)
+ }
+ for _, want := range []string{
+ "From: alice@example.com",
+ "Date: Mon, 1 Jan 2024 10:00:00 +0000",
+ "Subject: Test",
+ "To: bob@example.com",
+ "Cc: carol@example.com",
+ } {
+ if !strings.Contains(got, want) {
+ t.Fatalf("missing %q in:\n%s", want, got)
+ }
+ }
+ })
+
+ t.Run("uses br separator", func(t *testing.T) {
+ p := makePart("text/html", "", map[string]string{
+ "From": "alice@example.com",
+ "To": "bob@example.com",
+ })
+ got := forwardHeaderHTML(p)
+
+ // Lines are joined by
, not newlines.
+ if strings.Contains(got, "\n") {
+ t.Fatalf("expected
separators, not newlines:\n%s", got)
+ }
+ if !strings.Contains(got, "
") {
+ t.Fatalf("expected
separators:\n%s", got)
+ }
+ })
+
+ t.Run("HTML-escapes special characters", func(t *testing.T) {
+ p := makePart("text/html", "", map[string]string{
+ "From": "Alice ",
+ })
+ got := forwardHeaderHTML(p)
+
+ if !strings.Contains(got, "<alice@example.com>") {
+ t.Fatalf("expected HTML-escaped angle brackets in:\n%s", got)
+ }
+ })
+
+ t.Run("missing Cc header omitted", func(t *testing.T) {
+ p := makePart("text/html", "", map[string]string{
+ "From": "alice@example.com",
+ "Subject": "Test",
+ "To": "bob@example.com",
+ })
+ got := forwardHeaderHTML(p)
+
+ if strings.Contains(got, "Cc:") {
+ t.Fatalf("expected no Cc line, got:\n%s", got)
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// buildForwardBodies
+// ---------------------------------------------------------------------------
+
+func TestBuildForwardBodies(t *testing.T) {
+ t.Run("plain only original", func(t *testing.T) {
+ payload := makeMultipartPayload(
+ map[string]string{
+ "From": "alice@example.com",
+ "Subject": "Test",
+ },
+ makePart("text/plain", "Hello, world!", map[string]string{}),
+ )
+ plain, htmlBody := buildForwardBodies(payload, "")
+
+ if !strings.Contains(plain, "---------- Forwarded message ----------") {
+ t.Fatalf("plain missing forwarded header:\n%s", plain)
+ }
+ if !strings.Contains(plain, "Hello, world!") {
+ t.Fatalf("plain missing original body:\n%s", plain)
+ }
+ if htmlBody != "" {
+ t.Fatalf("expected empty HTML body for plain-only original, got:\n%s", htmlBody)
+ }
+ })
+
+ t.Run("HTML only original generates plain fallback", func(t *testing.T) {
+ payload := makeMultipartPayload(
+ map[string]string{
+ "From": "alice@example.com",
+ "Subject": "Test",
+ },
+ makePart("text/html", "Hello
", map[string]string{}),
+ )
+ plain, htmlBody := buildForwardBodies(payload, "")
+
+ // Plain body should exist (stripped from HTML).
+ if !strings.Contains(plain, "Hello") {
+ t.Fatalf("plain missing stripped HTML content:\n%s", plain)
+ }
+ if !strings.Contains(plain, "---------- Forwarded message ----------") {
+ t.Fatalf("plain missing forwarded header:\n%s", plain)
+ }
+ // HTML body should exist.
+ if htmlBody == "" {
+ t.Fatalf("expected non-empty HTML body for HTML original")
+ }
+ if !strings.Contains(htmlBody, "Hello
") {
+ t.Fatalf("HTML body missing original content:\n%s", htmlBody)
+ }
+ })
+
+ t.Run("both plain and HTML original", func(t *testing.T) {
+ payload := makeMultipartPayload(
+ map[string]string{
+ "From": "alice@example.com",
+ "Subject": "Test",
+ },
+ makePart("text/plain", "Plain text", map[string]string{}),
+ makePart("text/html", "Rich text", map[string]string{}),
+ )
+ plain, htmlBody := buildForwardBodies(payload, "")
+
+ if !strings.Contains(plain, "Plain text") {
+ t.Fatalf("plain missing original text:\n%s", plain)
+ }
+ if !strings.Contains(htmlBody, "Rich text") {
+ t.Fatalf("HTML missing original HTML:\n%s", htmlBody)
+ }
+ })
+
+ t.Run("with preface text", func(t *testing.T) {
+ payload := makeMultipartPayload(
+ map[string]string{
+ "From": "alice@example.com",
+ "Subject": "Test",
+ },
+ makePart("text/plain", "Original body", map[string]string{}),
+ makePart("text/html", "Original body
", map[string]string{}),
+ )
+ plain, htmlBody := buildForwardBodies(payload, "FYI see below")
+
+ // Preface should appear before the forwarded header.
+ prefaceIdx := strings.Index(plain, "FYI see below")
+ headerIdx := strings.Index(plain, "---------- Forwarded message ----------")
+ if prefaceIdx < 0 || headerIdx < 0 || prefaceIdx >= headerIdx {
+ t.Fatalf("preface should appear before forwarded header in plain:\n%s", plain)
+ }
+
+ // HTML body should also include the preface.
+ if !strings.Contains(htmlBody, "FYI see below") {
+ t.Fatalf("HTML body missing preface:\n%s", htmlBody)
+ }
+ })
+
+ t.Run("without preface", func(t *testing.T) {
+ payload := makeMultipartPayload(
+ map[string]string{
+ "From": "alice@example.com",
+ "Subject": "Test",
+ },
+ makePart("text/plain", "Body text", map[string]string{}),
+ )
+ plain, _ := buildForwardBodies(payload, "")
+
+ // Should start with the forwarded header (no preface).
+ if !strings.HasPrefix(strings.TrimSpace(plain), "---------- Forwarded message ----------") {
+ t.Fatalf("expected plain to start with forwarded header:\n%s", plain)
+ }
+ })
+
+ t.Run("empty original body", func(t *testing.T) {
+ payload := makeMultipartPayload(
+ map[string]string{
+ "From": "alice@example.com",
+ "Subject": "Test",
+ },
+ )
+ plain, htmlBody := buildForwardBodies(payload, "")
+
+ if !strings.Contains(plain, "---------- Forwarded message ----------") {
+ t.Fatalf("plain missing forwarded header:\n%s", plain)
+ }
+ if htmlBody != "" {
+ t.Fatalf("expected empty HTML body when original has no content, got:\n%s", htmlBody)
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// plainToHTML
+// ---------------------------------------------------------------------------
+
+func TestPlainToHTML(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {name: "simple text", input: "Hello", want: "Hello"},
+ {name: "newlines to br", input: "Line 1\nLine 2", want: "Line 1
Line 2"},
+ {name: "CRLF to br", input: "Line 1\r\nLine 2", want: "Line 1
Line 2"},
+ {name: "bare CR to br", input: "Line 1\rLine 2", want: "Line 1
Line 2"},
+ {name: "escapes ampersand", input: "A & B", want: "A & B"},
+ {name: "escapes angle brackets", input: "", want: "<script>alert(1)</script>"},
+ {name: "escapes quotes", input: `She said "hello"`, want: "She said "hello""},
+ {name: "empty string", input: "", want: ""},
+ {name: "multiple newlines", input: "A\n\nB", want: "A
B"},
+ {name: "mixed line endings", input: "A\r\nB\rC\nD", want: "A
B
C
D"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := plainToHTML(tt.input)
+ if got != tt.want {
+ t.Fatalf("plainToHTML(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// joinForwardSections
+// ---------------------------------------------------------------------------
+
+func TestJoinForwardSections(t *testing.T) {
+ tests := []struct {
+ name string
+ sep string
+ parts []string
+ want string
+ }{
+ {
+ name: "all non-empty",
+ sep: "\n\n",
+ parts: []string{"A", "B", "C"},
+ want: "A\n\nB\n\nC",
+ },
+ {
+ name: "skips empty",
+ sep: "\n\n",
+ parts: []string{"A", "", "C"},
+ want: "A\n\nC",
+ },
+ {
+ name: "skips whitespace-only",
+ sep: "\n\n",
+ parts: []string{"A", " ", "C"},
+ want: "A\n\nC",
+ },
+ {
+ name: "all empty returns empty",
+ sep: "\n\n",
+ parts: []string{"", "", ""},
+ want: "",
+ },
+ {
+ name: "single part",
+ sep: "\n\n",
+ parts: []string{"A"},
+ want: "A",
+ },
+ {
+ name: "no parts",
+ sep: "\n\n",
+ parts: []string{},
+ want: "",
+ },
+ {
+ name: "HTML separator",
+ sep: "
",
+ parts: []string{"Hello", "World"},
+ want: "Hello
World",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := joinForwardSections(tt.sep, tt.parts...)
+ if got != tt.want {
+ t.Fatalf("joinForwardSections(%q, %v) = %q, want %q", tt.sep, tt.parts, got, tt.want)
+ }
+ })
+ }
+}