From 63cfefe14475978b26603477ed23d0b32d4609a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 10:12:21 +0100 Subject: [PATCH] fix(gmail): expose reply threading metadata Co-authored-by: Solomon Neas --- internal/cmd/execute_gmail_get_test.go | 65 ++++++++++++++++++++++++++ internal/cmd/gmail_get.go | 20 +++++--- internal/cmd/gmail_send.go | 6 ++- internal/cmd/gmail_send_reply_test.go | 34 ++++++++++++++ 4 files changed, 117 insertions(+), 8 deletions(-) diff --git a/internal/cmd/execute_gmail_get_test.go b/internal/cmd/execute_gmail_get_test.go index 31f9b77..5e4b328 100644 --- a/internal/cmd/execute_gmail_get_test.go +++ b/internal/cmd/execute_gmail_get_test.go @@ -100,6 +100,71 @@ func containsAll(got []string, want []string) bool { return true } +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 + } + want := []string{ + "From", "To", "Cc", "Bcc", "Subject", "Date", + "Message-ID", "In-Reply-To", "References", "List-Unsubscribe", + } + if gotHeaders := r.URL.Query()["metadataHeaders"]; !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{ + {"name": "Message-ID", "value": ""}, + {"name": "In-Reply-To", "value": ""}, + {"name": "References", "value": " "}, + }, + }, + }) + })) + 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 } + + out := 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) + } + }) + }) + if !strings.Contains(out, "") || !strings.Contains(out, "") { + t.Fatalf("expected threading headers in metadata JSON, got: %q", out) + } +} + func TestExecute_GmailGet_Raw_JSON(t *testing.T) { origNew := newGmailService t.Cleanup(func() { newGmailService = origNew }) diff --git a/internal/cmd/gmail_get.go b/internal/cmd/gmail_get.go index 6b12c5d..ebd89dc 100644 --- a/internal/cmd/gmail_get.go +++ b/internal/cmd/gmail_get.go @@ -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") @@ -72,12 +75,15 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error { // Include a flattened headers map for easier querying // (e.g., jq '.headers.to' instead of complex nested queries) headers := map[string]string{ - "from": headerValue(msg.Payload, "From"), - "to": headerValue(msg.Payload, "To"), - "cc": headerValue(msg.Payload, "Cc"), - "bcc": headerValue(msg.Payload, "Bcc"), - "subject": headerValue(msg.Payload, "Subject"), - "date": headerValue(msg.Payload, "Date"), + "from": headerValue(msg.Payload, "From"), + "to": headerValue(msg.Payload, "To"), + "cc": headerValue(msg.Payload, "Cc"), + "bcc": headerValue(msg.Payload, "Bcc"), + "subject": headerValue(msg.Payload, "Subject"), + "date": headerValue(msg.Payload, "Date"), + "message_id": headerValue(msg.Payload, "Message-ID"), + "in_reply_to": headerValue(msg.Payload, "In-Reply-To"), + "references": headerValue(msg.Payload, "References"), } payload := map[string]any{ "message": msg, diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 2a201b7..c05afda 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -451,7 +451,11 @@ 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. diff --git a/internal/cmd/gmail_send_reply_test.go b/internal/cmd/gmail_send_reply_test.go index 249db01..6ec4bf5 100644 --- a/internal/cmd/gmail_send_reply_test.go +++ b/internal/cmd/gmail_send_reply_test.go @@ -101,6 +101,40 @@ func TestFetchReplyInfoFromThread(t *testing.T) { } } +func TestFetchReplyInfoNoMessageIDFails(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.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() + newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } + + _, err := fetchReplyInfo(context.Background(), svc, "m0", "", false) + if err == nil { + t.Fatal("expected error when reply target lacks Message-ID") + } + if !strings.Contains(err.Error(), "Message-ID") { + t.Fatalf("expected error to mention Message-ID, got: %v", err) + } +} + func TestWriteSendResults_JSON(t *testing.T) { u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) if err != nil {