Skip to content
Closed
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
57 changes: 57 additions & 0 deletions internal/cmd/execute_gmail_get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,63 @@ func TestExecute_GmailGet_Metadata_JSON(t *testing.T) {
}
}

func TestExecute_GmailGet_Metadata_DefaultHeadersIncludeThreading(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1") {
http.NotFound(w, r)
return
}
if got := r.URL.Query().Get("format"); got != "metadata" {
t.Errorf("format=%q", got)
http.Error(w, "bad format", http.StatusBadRequest)
return
}
gotHeaders := r.URL.Query()["metadataHeaders"]
want := []string{
"From", "To", "Cc", "Bcc", "Subject", "Date",
"Message-ID", "In-Reply-To", "References", "List-Unsubscribe",
}
if !containsAll(gotHeaders, want) {
t.Errorf("metadataHeaders=%#v missing one of %v", gotHeaders, want)
http.Error(w, "bad metadataHeaders", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m1",
"threadId": "t1",
"payload": map[string]any{"headers": []map[string]any{}},
})
}))
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 }

_ = captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"--account", "a@b.com",
"gmail", "get", "m1",
"--format", "metadata",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
}

func containsAll(got []string, want []string) bool {
set := map[string]bool{}
for _, g := range got {
Expand Down
5 changes: 4 additions & 1 deletion internal/cmd/gmail_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error {
if format == gmailFormatMetadata {
headerList := splitCSV(c.Headers)
if len(headerList) == 0 {
headerList = []string{"From", "To", "Cc", "Bcc", "Subject", "Date"}
headerList = []string{
"From", "To", "Cc", "Bcc", "Subject", "Date",
"Message-ID", "In-Reply-To", "References",
}
}
if !hasHeaderName(headerList, "List-Unsubscribe") {
headerList = append(headerList, "List-Unsubscribe")
Expand Down
9 changes: 8 additions & 1 deletion internal/cmd/gmail_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,13 @@ func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID st
if err != nil {
return nil, err
}
return replyInfoFromMessage(msg, includeQuoteBodies), nil

info := replyInfoFromMessage(msg, includeQuoteBodies)
if info.InReplyTo == "" {
return nil, fmt.Errorf("reply target message %s has no Message-ID header; cannot set In-Reply-To/References", replyToMessageID)
}

return info, nil
}

// For thread replies, we always need just headers to select the latest message.
Expand Down Expand Up @@ -481,6 +487,7 @@ func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID st
if info.ThreadID == "" {
info.ThreadID = thread.Id
}

return info, nil
}

Expand Down
96 changes: 96 additions & 0 deletions internal/cmd/gmail_send_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,102 @@ func TestFetchReplyInfoFromThread(t *testing.T) {
}
}

func TestFetchReplyInfo_NoMessageIDFails(t *testing.T) {
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
if r.Method != http.MethodGet || path != "/users/me/messages/m0" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m0",
"threadId": "t0",
"payload": map[string]any{
"headers": []map[string]any{
{"name": "From", "value": "a@example.com"},
{"name": "Subject", "value": "no message id here"},
},
},
})
})
defer cleanup()

_, err := fetchReplyInfo(context.Background(), svc, "m0", "", false)
if err == nil {
t.Fatalf("expected error when reply target lacks Message-ID, got nil")
}
if !strings.Contains(err.Error(), "Message-ID") {
t.Fatalf("expected error to mention Message-ID, got: %v", err)
}
}

func TestFetchReplyInfo_ParentWithoutReferences(t *testing.T) {
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
if r.Method != http.MethodGet || path != "/users/me/messages/m0" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m0",
"threadId": "t0",
"payload": map[string]any{
"headers": []map[string]any{
{"name": "Message-ID", "value": "<orig@id>"},
},
},
})
})
defer cleanup()

info, err := fetchReplyInfo(context.Background(), svc, "m0", "", false)
if err != nil {
t.Fatalf("fetchReplyInfo: %v", err)
}
if info.InReplyTo != "<orig@id>" {
t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo)
}
if info.References != "<orig@id>" {
t.Fatalf("expected References to equal parent Message-ID, got %q", info.References)
}
}

func TestFetchReplyInfo_ParentWithReferences(t *testing.T) {
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/gmail/v1")
if r.Method != http.MethodGet || path != "/users/me/messages/m0" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "m0",
"threadId": "t0",
"payload": map[string]any{
"headers": []map[string]any{
{"name": "Message-ID", "value": "<orig@id>"},
{"name": "References", "value": "<grandparent@id> <parent@id>"},
},
},
})
})
defer cleanup()

info, err := fetchReplyInfo(context.Background(), svc, "m0", "", false)
if err != nil {
t.Fatalf("fetchReplyInfo: %v", err)
}
if info.InReplyTo != "<orig@id>" {
t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo)
}
expected := "<grandparent@id> <parent@id> <orig@id>"
if info.References != expected {
t.Fatalf("expected References %q, got %q", expected, info.References)
}
}

func TestWriteSendResults_JSON(t *testing.T) {
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if err != nil {
Expand Down