Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Auth: fix Gmail search example in auth success template. (#89) — thanks @rvben.
- Chat: normalize thread IDs and show a clearer error for consumer accounts. (#84)
- CLI: remove redundant newlines in text output for calendar, chat, Gmail, and groups commands. (#91) — thanks @salmonumbrella.
- Gmail: include primary account display name in send From header when available. (#93) — thanks @salmonumbrella.

## 0.7.0 - 2026-01-17

Expand Down
8 changes: 8 additions & 0 deletions internal/cmd/gmail_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
if sa.DisplayName != "" {
fromAddr = sa.DisplayName + " <" + c.From + ">"
}
} else {
// No --from specified: look up the primary account's send-as settings
// to get the display name
sa, saErr := svc.Users.Settings.SendAs.Get("me", account).Context(ctx).Do()
if saErr == nil && sa.DisplayName != "" {
fromAddr = sa.DisplayName + " <" + account + ">"
}
// If lookup fails, we just use the plain email address (no error)
}

// Fetch reply info (includes recipient headers for reply-all)
Expand Down
188 changes: 188 additions & 0 deletions internal/cmd/gmail_send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,194 @@ func TestGmailSendCmd_RunJSON_WithFrom(t *testing.T) {
}
}

func TestGmailSendCmd_RunJSON_PrimaryAccountDisplayName(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/a@b.com":
// Return send-as settings with display name for primary account
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "a@b.com",
"displayName": "Primary User",
"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": "m3",
"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 }

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",
// Note: No From field set - testing primary account display name lookup
}

out := captureStdout(t, func() {
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("Run: %v", err)
}
})
// Verify the From field in output includes display name
if !strings.Contains(out, "\"from\"") || !strings.Contains(out, "Primary User <a@b.com>") {
t.Fatalf("expected from with display name, got: %q", out)
}
}

func TestGmailSendCmd_RunJSON_PrimaryAccountNoDisplayName(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/a@b.com":
// Return send-as settings WITHOUT display name
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "a@b.com",
"displayName": "", // No display name
"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": "m4",
"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 }

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)
}
})
// Verify the From field in output is just the email (no angle brackets)
// JSON output has space after colon, e.g., "from": "a@b.com"
if !strings.Contains(out, "\"from\": \"a@b.com\"") {
t.Fatalf("expected from without display name, got: %q", out)
}
}

func TestGmailSendCmd_RunJSON_PrimaryAccountLookupFails(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/a@b.com":
// Simulate send-as lookup failure (404)
http.NotFound(w, r)
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": "m5",
"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 }

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",
}

// Should NOT fail even if send-as lookup fails - should gracefully fall back to plain email
out := captureStdout(t, func() {
if err := cmd.Run(ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("Run: %v (should not fail when send-as lookup fails for primary account)", err)
}
})
// Verify the From field in output is just the email
// JSON output has space after colon, e.g., "from": "a@b.com"
if !strings.Contains(out, "\"from\": \"a@b.com\"") {
t.Fatalf("expected from with plain email on lookup failure, got: %q", out)
}
}

func TestGmailSendCmd_Run_FromUnverified(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
Expand Down