fix(gmail): use people profile for primary sender name

This commit is contained in:
Peter Steinberger 2026-04-27 20:55:03 +01:00
parent e97cad62f6
commit 3eb8330159
No known key found for this signature in database
3 changed files with 212 additions and 0 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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 <a@b.com>") {
t.Fatalf("expected from with People display name, got: %q", out)
}
if !strings.Contains(rawSent, `From: "People User" <a@b.com>`) {
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 })