package cmd import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "reflect" "sort" "strings" "testing" "google.golang.org/api/gmail/v1" "google.golang.org/api/option" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) func TestReplyHeaders(t *testing.T) { type hdr struct { Name string Value string } type msg struct { ThreadID string Headers []hdr } messages := map[string]msg{ "m1": {ThreadID: "t1", Headers: []hdr{{Name: "Message-ID", Value: ""}}}, "m2": {ThreadID: "t2", Headers: []hdr{ {Name: "Message-Id", Value: ""}, {Name: "References", Value: ""}, }}, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/gmail/v1/users/me/messages/") { http.NotFound(w, r) return } id := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/messages/") m, ok := messages[id] if !ok { http.NotFound(w, r) return } hs := make([]map[string]any, 0, len(m.Headers)) for _, h := range m.Headers { hs = append(hs, map[string]any{"name": h.Name, "value": h.Value}) } resp := map[string]any{ "id": id, "threadId": m.ThreadID, "payload": map[string]any{ "headers": hs, }, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) 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) } ctx := context.Background() inReplyTo, refs, threadID, err := replyHeaders(ctx, svc, "m1") if err != nil { t.Fatalf("replyHeaders: %v", err) } if inReplyTo != "" || refs != "" || threadID != "t1" { t.Fatalf("unexpected: inReplyTo=%q refs=%q thread=%q", inReplyTo, refs, threadID) } inReplyTo, refs, threadID, err = replyHeaders(ctx, svc, "m2") if err != nil { t.Fatalf("replyHeaders: %v", err) } if inReplyTo != "" { t.Fatalf("unexpected inReplyTo: %q", inReplyTo) } if !strings.Contains(refs, "") || !strings.Contains(refs, "") { t.Fatalf("unexpected refs: %q", refs) } if threadID != "t2" { t.Fatalf("unexpected thread: %q", threadID) } } func TestFetchReplyInfo_ThreadID(t *testing.T) { type hdr struct { Name string Value string } type msg struct { ID string ThreadID string InternalDate string Headers []hdr } thread := struct { ID string Messages []msg }{ ID: "t1", Messages: []msg{ { ID: "m1", ThreadID: "t1", InternalDate: "1000", Headers: []hdr{ {Name: "Message-ID", Value: ""}, {Name: "From", Value: "sender@example.com"}, }, }, { ID: "m2", ThreadID: "t1", InternalDate: "2000", Headers: []hdr{ {Name: "Message-ID", Value: ""}, {Name: "From", Value: "sender2@example.com"}, }, }, }, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/gmail/v1/users/me/threads/") { http.NotFound(w, r) return } id := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/threads/") if id != thread.ID { http.NotFound(w, r) return } msgs := make([]map[string]any, 0, len(thread.Messages)) for _, m := range thread.Messages { hs := make([]map[string]any, 0, len(m.Headers)) for _, h := range m.Headers { hs = append(hs, map[string]any{"name": h.Name, "value": h.Value}) } msgs = append(msgs, map[string]any{ "id": m.ID, "threadId": m.ThreadID, "internalDate": m.InternalDate, "payload": map[string]any{ "headers": hs, }, }) } resp := map[string]any{ "id": thread.ID, "messages": msgs, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) 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) } info, err := fetchReplyInfo(context.Background(), svc, "", "t1") if err != nil { t.Fatalf("fetchReplyInfo: %v", err) } if info.ThreadID != "t1" { t.Fatalf("unexpected thread: %q", info.ThreadID) } if info.InReplyTo != "" { t.Fatalf("unexpected inReplyTo: %q", info.InReplyTo) } } func TestSelectLatestThreadMessage(t *testing.T) { m1 := &gmail.Message{Id: "m1"} m2 := &gmail.Message{Id: "m2", InternalDate: 10} m3 := &gmail.Message{Id: "m3", InternalDate: 20} if got := selectLatestThreadMessage([]*gmail.Message{m1, m2, m3}); got == nil || got.Id != "m3" { t.Fatalf("expected m3, got %#v", got) } if got := selectLatestThreadMessage([]*gmail.Message{nil, m1}); got == nil || got.Id != "m1" { t.Fatalf("expected m1 fallback, got %#v", got) } } func TestGmailSendCmd_RunJSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") if r.Method == http.MethodPost && path == "/users/me/messages/send" { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "m1", "threadId": "t1", }) return } http.NotFound(w, r) })) 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 } u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) if err != nil { t.Fatalf("ui.New: %v", err) } ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) cmd := &GmailSendCmd{ To: "a@example.com", Subject: "Hello", Body: "Body", } out := captureStdout(t, func() { if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { t.Fatalf("Run: %v", err) } }) if !strings.Contains(out, "\"messageId\"") || !strings.Contains(out, "\"threadId\"") { t.Fatalf("unexpected output: %q", out) } } func TestGmailSendCmd_RunJSON_WithFrom(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") switch { case r.Method == http.MethodGet && path == "/users/me/settings/sendAs/alias@example.com": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "sendAsEmail": "alias@example.com", "displayName": "Alias", "verificationStatus": "accepted", }) return case r.Method == http.MethodPost && path == "/users/me/messages/send": w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "m2", "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 } u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) if err != nil { t.Fatalf("ui.New: %v", err) } ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) cmd := &GmailSendCmd{ To: "a@example.com", From: "alias@example.com", Subject: "Hello", Body: "Body", } out := captureStdout(t, func() { if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil { t.Fatalf("Run: %v", err) } }) if !strings.Contains(out, "\"from\"") || !strings.Contains(out, "Alias ") { t.Fatalf("unexpected output: %q", out) } } func TestGmailSendCmd_Run_FromUnverified(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/gmail/v1") if r.Method == http.MethodGet && path == "/users/me/settings/sendAs/alias@example.com" { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "sendAsEmail": "alias@example.com", "verificationStatus": "pending", }) return } http.NotFound(w, r) })) 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 } cmd := &GmailSendCmd{ To: "a@example.com", From: "alias@example.com", Subject: "Hello", Body: "Body", } if err := cmd.Run(context.Background(), &RootFlags{Account: "a@b.com"}); err == nil { t.Fatalf("expected error for unverified send-as") } } func TestParseEmailAddresses(t *testing.T) { tests := []struct { name string input string expect []string }{ { name: "empty", input: "", expect: nil, }, { name: "single plain email", input: "alice@example.com", expect: []string{"alice@example.com"}, }, { name: "single with display name", input: "Alice Smith ", expect: []string{"alice@example.com"}, }, { name: "single with quoted display name", input: `"Alice Smith" `, expect: []string{"alice@example.com"}, }, { name: "multiple addresses", input: "alice@example.com, bob@example.com", expect: []string{"alice@example.com", "bob@example.com"}, }, { name: "multiple with display names", input: "Alice , Bob ", expect: []string{"alice@example.com", "bob@example.com"}, }, { name: "mixed formats", input: `"Alice Smith" , bob@example.com, Charlie `, expect: []string{"alice@example.com", "bob@example.com", "charlie@example.com"}, }, { name: "uppercase email", input: "Alice@EXAMPLE.COM", expect: []string{"alice@example.com"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := parseEmailAddresses(tc.input) if !reflect.DeepEqual(got, tc.expect) { t.Errorf("parseEmailAddresses(%q) = %v, want %v", tc.input, got, tc.expect) } }) } } func TestFilterOutSelf(t *testing.T) { tests := []struct { name string addresses []string selfEmail string expect []string }{ { name: "empty list", addresses: nil, selfEmail: "me@example.com", expect: []string{}, }, { name: "no self present", addresses: []string{"alice@example.com", "bob@example.com"}, selfEmail: "me@example.com", expect: []string{"alice@example.com", "bob@example.com"}, }, { name: "self present exact case", addresses: []string{"alice@example.com", "me@example.com", "bob@example.com"}, selfEmail: "me@example.com", expect: []string{"alice@example.com", "bob@example.com"}, }, { name: "self present different case", addresses: []string{"alice@example.com", "ME@EXAMPLE.COM"}, selfEmail: "me@example.com", expect: []string{"alice@example.com"}, }, { name: "only self", addresses: []string{"me@example.com"}, selfEmail: "me@example.com", expect: []string{}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := filterOutSelf(tc.addresses, tc.selfEmail) if !reflect.DeepEqual(got, tc.expect) { t.Errorf("filterOutSelf(%v, %q) = %v, want %v", tc.addresses, tc.selfEmail, got, tc.expect) } }) } } func TestDeduplicateAddresses(t *testing.T) { tests := []struct { name string addresses []string expect []string }{ { name: "empty", addresses: nil, expect: []string{}, }, { name: "no duplicates", addresses: []string{"alice@example.com", "bob@example.com"}, expect: []string{"alice@example.com", "bob@example.com"}, }, { name: "exact duplicates", addresses: []string{"alice@example.com", "alice@example.com", "bob@example.com"}, expect: []string{"alice@example.com", "bob@example.com"}, }, { name: "case-insensitive duplicates", addresses: []string{"alice@example.com", "ALICE@EXAMPLE.COM", "bob@example.com"}, expect: []string{"alice@example.com", "bob@example.com"}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := deduplicateAddresses(tc.addresses) if !reflect.DeepEqual(got, tc.expect) { t.Errorf("deduplicateAddresses(%v) = %v, want %v", tc.addresses, got, tc.expect) } }) } } func TestBuildReplyAllRecipients(t *testing.T) { tests := []struct { name string info *replyInfo selfEmail string expectTo []string expectCc []string }{ { name: "simple reply-all", info: &replyInfo{ FromAddr: "sender@example.com", ToAddrs: []string{"me@example.com", "alice@example.com"}, CcAddrs: []string{"bob@example.com"}, }, selfEmail: "me@example.com", expectTo: []string{"sender@example.com", "alice@example.com"}, expectCc: []string{"bob@example.com"}, }, { name: "sender with display name", info: &replyInfo{ FromAddr: "Sender Name ", ToAddrs: []string{"me@example.com"}, CcAddrs: nil, }, selfEmail: "me@example.com", expectTo: []string{"sender@example.com"}, expectCc: []string{}, }, { name: "deduplication across To", info: &replyInfo{ FromAddr: "sender@example.com", ToAddrs: []string{"sender@example.com", "alice@example.com"}, CcAddrs: nil, }, selfEmail: "me@example.com", expectTo: []string{"sender@example.com", "alice@example.com"}, expectCc: []string{}, }, { name: "Cc address already in To is excluded from Cc", info: &replyInfo{ FromAddr: "sender@example.com", ToAddrs: []string{"alice@example.com"}, CcAddrs: []string{"alice@example.com", "bob@example.com"}, }, selfEmail: "me@example.com", expectTo: []string{"sender@example.com", "alice@example.com"}, expectCc: []string{"bob@example.com"}, }, { name: "self in Cc is filtered", info: &replyInfo{ FromAddr: "sender@example.com", ToAddrs: []string{"alice@example.com"}, CcAddrs: []string{"me@example.com", "bob@example.com"}, }, selfEmail: "me@example.com", expectTo: []string{"sender@example.com", "alice@example.com"}, expectCc: []string{"bob@example.com"}, }, { name: "case insensitive self filtering", info: &replyInfo{ FromAddr: "sender@example.com", ToAddrs: []string{"ME@EXAMPLE.COM", "alice@example.com"}, CcAddrs: nil, }, selfEmail: "me@example.com", expectTo: []string{"sender@example.com", "alice@example.com"}, expectCc: []string{}, }, { name: "empty recipients", info: &replyInfo{ FromAddr: "", ToAddrs: nil, CcAddrs: nil, }, selfEmail: "me@example.com", expectTo: []string{}, expectCc: []string{}, }, { name: "Reply-To header takes precedence over From (RFC 5322)", info: &replyInfo{ FromAddr: "original-sender@example.com", ReplyToAddr: "reply-here@example.com", ToAddrs: []string{"me@example.com", "alice@example.com"}, CcAddrs: nil, }, selfEmail: "me@example.com", expectTo: []string{"reply-here@example.com", "alice@example.com"}, expectCc: []string{}, }, { name: "Reply-To with display name", info: &replyInfo{ FromAddr: "sender@example.com", ReplyToAddr: "Mailing List ", ToAddrs: []string{"alice@example.com"}, CcAddrs: nil, }, selfEmail: "me@example.com", expectTo: []string{"list@example.com", "alice@example.com"}, expectCc: []string{}, }, { name: "Empty Reply-To falls back to From", info: &replyInfo{ FromAddr: "sender@example.com", ReplyToAddr: "", ToAddrs: []string{"alice@example.com"}, CcAddrs: nil, }, selfEmail: "me@example.com", expectTo: []string{"sender@example.com", "alice@example.com"}, expectCc: []string{}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gotTo, gotCc := buildReplyAllRecipients(tc.info, tc.selfEmail) // Sort for comparison since order may vary sort.Strings(gotTo) sort.Strings(tc.expectTo) sort.Strings(gotCc) sort.Strings(tc.expectCc) if !reflect.DeepEqual(gotTo, tc.expectTo) { t.Errorf("To: got %v, want %v", gotTo, tc.expectTo) } if !reflect.DeepEqual(gotCc, tc.expectCc) { t.Errorf("Cc: got %v, want %v", gotCc, tc.expectCc) } }) } } func TestFetchReplyInfo(t *testing.T) { type hdr struct { Name string Value string } type msg struct { ThreadID string Headers []hdr } messages := map[string]msg{ "m1": { ThreadID: "t1", Headers: []hdr{ {Name: "Message-ID", Value: ""}, {Name: "From", Value: "sender@example.com"}, {Name: "To", Value: "alice@example.com, bob@example.com"}, {Name: "Cc", Value: "charlie@example.com"}, }, }, "m2": { ThreadID: "t2", Headers: []hdr{ {Name: "Message-ID", Value: ""}, {Name: "From", Value: `"Sender Name" `}, {Name: "To", Value: "recipient@example.com"}, }, }, "m3": { ThreadID: "t3", Headers: []hdr{ {Name: "Message-ID", Value: ""}, {Name: "From", Value: "original-sender@example.com"}, {Name: "Reply-To", Value: "Mailing List "}, {Name: "To", Value: "recipient@example.com"}, }, }, } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.URL.Path, "/gmail/v1/users/me/messages/") { http.NotFound(w, r) return } id := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/messages/") m, ok := messages[id] if !ok { http.NotFound(w, r) return } hs := make([]map[string]any, 0, len(m.Headers)) for _, h := range m.Headers { hs = append(hs, map[string]any{"name": h.Name, "value": h.Value}) } resp := map[string]any{ "id": id, "threadId": m.ThreadID, "payload": map[string]any{ "headers": hs, }, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) 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) } ctx := context.Background() // Test m1: multiple recipients info, err := fetchReplyInfo(ctx, svc, "m1", "") if err != nil { t.Fatalf("fetchReplyInfo(m1): %v", err) } if info.ThreadID != "t1" { t.Errorf("ThreadID = %q, want %q", info.ThreadID, "t1") } if info.FromAddr != "sender@example.com" { t.Errorf("FromAddr = %q, want %q", info.FromAddr, "sender@example.com") } expectedTo := []string{"alice@example.com", "bob@example.com"} if !reflect.DeepEqual(info.ToAddrs, expectedTo) { t.Errorf("ToAddrs = %v, want %v", info.ToAddrs, expectedTo) } expectedCc := []string{"charlie@example.com"} if !reflect.DeepEqual(info.CcAddrs, expectedCc) { t.Errorf("CcAddrs = %v, want %v", info.CcAddrs, expectedCc) } // Test m2: sender with display name info, err = fetchReplyInfo(ctx, svc, "m2", "") if err != nil { t.Fatalf("fetchReplyInfo(m2): %v", err) } if info.FromAddr != `"Sender Name" ` { t.Errorf("FromAddr = %q, want %q", info.FromAddr, `"Sender Name" `) } // Test empty message ID info, err = fetchReplyInfo(ctx, svc, "", "") if err != nil { t.Fatalf("fetchReplyInfo(''): %v", err) } if info.ThreadID != "" || info.FromAddr != "" { t.Errorf("Expected empty replyInfo for empty message ID") } // Test m3: message with Reply-To header info, err = fetchReplyInfo(ctx, svc, "m3", "") if err != nil { t.Fatalf("fetchReplyInfo(m3): %v", err) } if info.FromAddr != "original-sender@example.com" { t.Errorf("FromAddr = %q, want %q", info.FromAddr, "original-sender@example.com") } if info.ReplyToAddr != "Mailing List " { t.Errorf("ReplyToAddr = %q, want %q", info.ReplyToAddr, "Mailing List ") } } func TestReplyAllValidation(t *testing.T) { // Test that --reply-all requires --reply-to-message-id cmd := &GmailSendCmd{ ReplyAll: true, } // This would normally go through Run(), but we can test the validation logic if cmd.ReplyAll && strings.TrimSpace(cmd.ReplyToMessageID) == "" && strings.TrimSpace(cmd.ThreadID) == "" { // Expected: should require --reply-to-message-id } else { t.Error("Expected validation to require --reply-to-message-id when --reply-all is set") } // Test with --reply-to-message-id set cmd.ReplyToMessageID = "msg123" if cmd.ReplyAll && strings.TrimSpace(cmd.ReplyToMessageID) == "" { t.Error("Should not require --reply-to-message-id when it's already set") } cmd.ReplyToMessageID = "" cmd.ThreadID = "thread123" if cmd.ReplyAll && strings.TrimSpace(cmd.ThreadID) == "" { t.Error("Should not require --reply-to-message-id when --thread-id is set") } // Test --to is optional when --reply-all is used if strings.TrimSpace(cmd.To) == "" && !cmd.ReplyAll { t.Error("--to should be optional when --reply-all is used") } }