From 3eb833015943808f7bedf38bd6cc99ca6800ea80 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 20:55:03 +0100 Subject: [PATCH] fix(gmail): use people profile for primary sender name --- CHANGELOG.md | 2 + internal/cmd/gmail_compose.go | 30 ++++++ internal/cmd/gmail_send_test.go | 180 ++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3383a51..87eb35a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Gmail: build outbound `Date` headers with the configured timezone so replies do not inherit a wrong host-local offset. (#514, #472) — thanks @dinakars777. - Gmail: preserve renewed watch expiration fields when a long-running `gmail watch serve` process records push delivery state after `gmail watch renew` runs separately. (#526) - Gmail: auto-fill draft reply subjects from the original message when `gmail drafts create --reply-to-message-id` omits `--subject`. (#488) — thanks @jbowerbir. +- Gmail: fall back to the People profile name for primary-account `From` headers when Gmail send-as settings omit a display name. (#431) — thanks @moeedahmed. - Gmail: reuse the shared paginated list runner for thread and message search so `--all`, `--page`, text, and JSON output stay consistent. - Drive: print large upload progress to stderr while keeping JSON output parseable. (#529) - Drive: include `hasThumbnail` and `thumbnailLink` in `drive ls`, `drive search`, and `drive get` JSON responses. (#486) — thanks @gtapps. @@ -25,6 +26,7 @@ - Drive: include `driveId` in `drive ls`, `drive search`, and `drive get` field masks so Shared Drive files can be identified in JSON output. (#524) — thanks @LeanSheng. - Gmail: expose reply threading headers in default `gmail get --format metadata` output and fail explicit reply targets that cannot provide a `Message-ID`. (#528, #512) — thanks @solomonneas. - Docs: include available tab names when `docs cat --tab` / structure lookup cannot find the requested tab. (#532) — thanks @johnbenjaminlewis. +- Docs: size Markdown images consistently for `docs write --replace --markdown` by reusing the Docs image insertion path after Drive conversion. (#518) — thanks @vinothd-oai. ## 0.13.0 - 2026-04-20 diff --git a/internal/cmd/gmail_compose.go b/internal/cmd/gmail_compose.go index 7bac8d3..755f761 100644 --- a/internal/cmd/gmail_compose.go +++ b/internal/cmd/gmail_compose.go @@ -8,6 +8,7 @@ import ( "strings" "google.golang.org/api/gmail/v1" + "google.golang.org/api/people/v1" "github.com/steipete/gogcli/internal/config" "github.com/steipete/gogcli/internal/outfmt" @@ -99,11 +100,40 @@ func resolveComposeFrom(ctx context.Context, svc *gmail.Service, account, from s if sendAsListErr == nil { if displayName := primaryDisplayNameFromSendAsList(sendAsList, account); displayName != "" { result.header = displayName + " <" + account + ">" + } else if displayName := primaryDisplayNameFromPeople(ctx, account); displayName != "" { + result.header = displayName + " <" + account + ">" } } return result, nil } +func primaryDisplayNameFromPeople(ctx context.Context, account string) string { + svc, err := newPeopleContactsService(ctx, account) + if err != nil { + return "" + } + person, err := svc.People.Get(peopleMeResource).PersonFields("names").Context(ctx).Do() + if err != nil { + return "" + } + return primaryDisplayNameFromPerson(person) +} + +func primaryDisplayNameFromPerson(person *people.Person) string { + if person == nil { + return "" + } + for _, name := range person.Names { + if name == nil { + continue + } + if displayName := strings.TrimSpace(name.DisplayName); displayName != "" { + return displayName + } + } + return "" +} + func prepareComposeReply(ctx context.Context, svc *gmail.Service, replyToMessageID, threadID string, quote bool, plainBody, htmlBody string) (*replyInfo, string, string, error) { info, err := fetchReplyInfo(ctx, svc, replyToMessageID, threadID, quote) if err != nil { diff --git a/internal/cmd/gmail_send_test.go b/internal/cmd/gmail_send_test.go index 3569d43..372e30a 100644 --- a/internal/cmd/gmail_send_test.go +++ b/internal/cmd/gmail_send_test.go @@ -14,6 +14,7 @@ import ( "google.golang.org/api/gmail/v1" "google.golang.org/api/option" + "google.golang.org/api/people/v1" "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" @@ -704,6 +705,185 @@ func TestGmailSendCmd_RunJSON_PrimaryAccountDisplayNameFallbackToList(t *testing } } +func TestGmailSendCmd_RunJSON_PrimaryAccountDisplayNameFallbackToPeople(t *testing.T) { + origGmail := newGmailService + origPeople := newPeopleContactsService + t.Cleanup(func() { + newGmailService = origGmail + newPeopleContactsService = origPeople + }) + + var rawSent string + 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": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAs": []map[string]any{ + { + "sendAsEmail": "a@b.com", + "verificationStatus": "accepted", + "isPrimary": true, + }, + }, + }) + return + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/people/me"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/me", + "names": []map[string]any{{"displayName": "People User"}}, + }) + return + case r.Method == http.MethodPost && path == "/users/me/messages/send": + var msg gmail.Message + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + t.Fatalf("decode sent message: %v", err) + } + raw, err := base64.RawURLEncoding.DecodeString(msg.Raw) + if err != nil { + t.Fatalf("decode raw message: %v", err) + } + rawSent = string(raw) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "m3c", + "threadId": "t3c", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + gmailSvc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewGmailService: %v", err) + } + peopleSvc, err := people.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewPeopleService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return gmailSvc, nil } + newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return peopleSvc, 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: "recipient@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, "People User ") { + t.Fatalf("expected from with People display name, got: %q", out) + } + if !strings.Contains(rawSent, `From: "People User" `) { + t.Fatalf("expected raw From header to use People display name, got: %q", rawSent) + } +} + +func TestGmailSendCmd_RunJSON_PrimaryAccountPeopleFallbackFailureIgnored(t *testing.T) { + origGmail := newGmailService + origPeople := newPeopleContactsService + t.Cleanup(func() { + newGmailService = origGmail + newPeopleContactsService = origPeople + }) + + 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": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAs": []map[string]any{ + { + "sendAsEmail": "a@b.com", + "verificationStatus": "accepted", + "isPrimary": true, + }, + }, + }) + return + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/people/me"): + http.Error(w, "People API unavailable", http.StatusForbidden) + 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": "m3d", + "threadId": "t3d", + }) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + gmailSvc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewGmailService: %v", err) + } + peopleSvc, err := people.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewPeopleService: %v", err) + } + newGmailService = func(context.Context, string) (*gmail.Service, error) { return gmailSvc, nil } + newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return peopleSvc, 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: "recipient@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\": \"a@b.com\"") { + t.Fatalf("expected bare from when People fallback fails, got: %q", out) + } +} + func TestGmailSendCmd_RunJSON_PrimaryAccountNoDisplayName(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew })