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