gogcli/internal/cmd/gmail_send_test.go
2026-04-27 20:55:03 +01:00

1501 lines
42 KiB
Go

package cmd
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"reflect"
"sort"
"strings"
"testing"
"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"
)
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: "<id1@example.com>"}}},
"m2": {ThreadID: "t2", Headers: []hdr{
{Name: "Message-Id", Value: "<id2@example.com>"},
{Name: "References", Value: "<ref@example.com>"},
}},
}
svc, cleanup := newGmailServiceForTest(t, 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 cleanup()
ctx := context.Background()
inReplyTo, refs, threadID, err := replyHeaders(ctx, svc, "m1")
if err != nil {
t.Fatalf("replyHeaders: %v", err)
}
if inReplyTo != "<id1@example.com>" || refs != "<id1@example.com>" || 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 != "<id2@example.com>" {
t.Fatalf("unexpected inReplyTo: %q", inReplyTo)
}
if !strings.Contains(refs, "<ref@example.com>") || !strings.Contains(refs, "<id2@example.com>") {
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: "<id1@example.com>"},
{Name: "From", Value: "sender@example.com"},
},
},
{
ID: "m2",
ThreadID: "t1",
InternalDate: "2000",
Headers: []hdr{
{Name: "Message-ID", Value: "<id2@example.com>"},
{Name: "From", Value: "sender2@example.com"},
},
},
},
}
svc, cleanup := newGmailServiceForTest(t, 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 cleanup()
info, err := fetchReplyInfo(context.Background(), svc, "", "t1", false)
if err != nil {
t.Fatalf("fetchReplyInfo: %v", err)
}
if info.ThreadID != "t1" {
t.Fatalf("unexpected thread: %q", info.ThreadID)
}
if info.InReplyTo != "<id2@example.com>" {
t.Fatalf("unexpected inReplyTo: %q", info.InReplyTo)
}
}
func TestFetchReplyInfo_ThreadID_IncludeBody_FetchesOnlySelectedMessage(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: "<id1@example.com>"},
{Name: "From", Value: "sender@example.com"},
},
},
{
ID: "m2",
ThreadID: "t1",
InternalDate: "2000",
Headers: []hdr{
{Name: "Message-ID", Value: "<id2@example.com>"},
{Name: "From", Value: "sender2@example.com"},
},
},
},
}
var threadCalls, messageCalls int
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/gmail/v1/users/me/threads/"):
threadCalls++
if r.URL.Query().Get("format") != "metadata" {
t.Fatalf("expected thread format=metadata, got %q", r.URL.RawQuery)
}
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)
return
case strings.HasPrefix(r.URL.Path, "/gmail/v1/users/me/messages/"):
messageCalls++
if r.URL.Query().Get("format") != "full" {
t.Fatalf("expected message format=full, got %q", r.URL.RawQuery)
}
id := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/messages/")
if id != "m2" {
http.NotFound(w, r)
return
}
resp := map[string]any{
"id": "m2",
"threadId": "t1",
"payload": map[string]any{
"mimeType": "multipart/alternative",
"headers": []map[string]any{
{"name": "Message-ID", "value": "<id2@example.com>"},
{"name": "From", "value": "sender2@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": base64.RawURLEncoding.EncodeToString([]byte("plain body")),
},
},
{
"mimeType": "text/html",
"body": map[string]any{
"data": base64.RawURLEncoding.EncodeToString([]byte("<p>html body</p>")),
},
},
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
http.NotFound(w, r)
})
defer cleanup()
info, err := fetchReplyInfo(context.Background(), svc, "", "t1", true)
if err != nil {
t.Fatalf("fetchReplyInfo: %v", err)
}
if info.InReplyTo != "<id2@example.com>" {
t.Fatalf("unexpected inReplyTo: %q", info.InReplyTo)
}
if info.Body != "plain body" {
t.Fatalf("unexpected Body: %q", info.Body)
}
if info.BodyHTML != "<p>html body</p>" {
t.Fatalf("unexpected BodyHTML: %q", info.BodyHTML)
}
if threadCalls != 1 || messageCalls != 1 {
t.Fatalf("expected 1 thread call + 1 message call, got thread=%d message=%d", threadCalls, messageCalls)
}
}
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 })
svc, cleanup := newGmailServiceForTest(t, 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 cleanup()
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":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAs": []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 <alias@example.com>") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestGmailSendCmd_RunJSON_WithFromWorkspaceAliasNoVerificationStatus(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":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAs": []map[string]any{
{
"sendAsEmail": "workspace-alias@example.com",
"displayName": "Workspace Alias",
},
},
})
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": "m2w",
"threadId": "t2w",
})
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: "workspace-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, "Workspace Alias <workspace-alias@example.com>") {
t.Fatalf("unexpected output: %q", out)
}
}
func TestGmailSendCmd_RunJSON_WithFromDisplayNameFallbackToList(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":
// List endpoint provides verification + display name (works for service-account impersonation too).
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAs": []map[string]any{
{
"sendAsEmail": "alias@example.com",
"displayName": "Alias From List",
"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": "m2b",
"threadId": "t2b",
})
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 From List <alias@example.com>") {
t.Fatalf("expected from with display name from list fallback, got: %q", out)
}
}
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":
// List endpoint returns the primary entry with display name.
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAs": []map[string]any{
{
"sendAsEmail": "a@b.com",
"displayName": "Primary User",
"verificationStatus": "accepted",
"isPrimary": true,
},
},
})
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_PrimaryAccountDisplayNameFallbackToList(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":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAs": []map[string]any{
{
"sendAsEmail": "a@b.com",
"displayName": "",
"verificationStatus": "accepted",
},
{
"sendAsEmail": "primary@example.com",
"displayName": "Primary User",
"verificationStatus": "accepted",
"isPrimary": true,
},
},
})
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": "m3b",
"threadId": "t3b",
})
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)
}
})
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_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 })
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 })
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" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAs": []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 <alice@example.com>",
expect: []string{"alice@example.com"},
},
{
name: "single with quoted display name",
input: `"Alice Smith" <alice@example.com>`,
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 <alice@example.com>, Bob <bob@example.com>",
expect: []string{"alice@example.com", "bob@example.com"},
},
{
name: "mixed formats",
input: `"Alice Smith" <alice@example.com>, bob@example.com, Charlie <charlie@example.com>`,
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 <sender@example.com>",
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 <list@example.com>",
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: "<id1@example.com>"},
{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: "<id2@example.com>"},
{Name: "From", Value: `"Sender Name" <sender@example.com>`},
{Name: "To", Value: "recipient@example.com"},
},
},
"m3": {
ThreadID: "t3",
Headers: []hdr{
{Name: "Message-ID", Value: "<id3@example.com>"},
{Name: "From", Value: "original-sender@example.com"},
{Name: "Reply-To", Value: "Mailing List <list@example.com>"},
{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", "", false)
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", "", false)
if err != nil {
t.Fatalf("fetchReplyInfo(m2): %v", err)
}
if info.FromAddr != `"Sender Name" <sender@example.com>` {
t.Errorf("FromAddr = %q, want %q", info.FromAddr, `"Sender Name" <sender@example.com>`)
}
// Test empty message ID
info, err = fetchReplyInfo(ctx, svc, "", "", false)
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", "", false)
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 <list@example.com>" {
t.Errorf("ReplyToAddr = %q, want %q", info.ReplyToAddr, "Mailing List <list@example.com>")
}
}
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")
}
}