fix(gmail): expose reply threading metadata

Co-authored-by: Solomon Neas <srneas@gmail.com>
This commit is contained in:
Peter Steinberger 2026-04-27 10:12:21 +01:00
parent 3f46ddf14a
commit 63cfefe144
No known key found for this signature in database
4 changed files with 117 additions and 8 deletions

View File

@ -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": "<orig@id>"},
{"name": "In-Reply-To", "value": "<parent@id>"},
{"name": "References", "value": "<parent@id> <orig@id>"},
},
},
})
}))
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, "<orig@id>") || !strings.Contains(out, "<parent@id>") {
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 })

View File

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

View File

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

View File

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