diff --git a/CHANGELOG.md b/CHANGELOG.md index 7850328..6bf1663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ - Auth: OAuth browser flow now finishes immediately after callback; manual OAuth paste accepts EOF; verify requested account matches authorized email; store tokens under the real account email (Google userinfo). - Auth: `gog auth tokens list` filters non-token keyring entries. - Gmail: watch push dedupe/historyId sync improvements; List-Unsubscribe extraction; MIME normalization + padded base64url support (#52) — thanks @antons. +- Gmail: drafts update preserves thread/reply headers when updating existing drafts (#55) — thanks @antons. ### Changed diff --git a/internal/cmd/execute_gmail_more_commands_test.go b/internal/cmd/execute_gmail_more_commands_test.go index 68a6c77..3922060 100644 --- a/internal/cmd/execute_gmail_more_commands_test.go +++ b/internal/cmd/execute_gmail_more_commands_test.go @@ -98,6 +98,23 @@ func TestExecute_GmailThreadDraftsSend_JSON(t *testing.T) { }, }) return + case strings.Contains(path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "messages": []map[string]any{ + { + "id": "m1", + "threadId": "t1", + "payload": map[string]any{ + "headers": []map[string]any{ + {"name": "Message-ID", "value": ""}, + }, + }, + }, + }, + }) + return case strings.Contains(path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodPut: w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index 3c2815c..4db9f61 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -289,6 +289,7 @@ type draftComposeInput struct { Body string BodyHTML string ReplyToMessageID string + ReplyToThreadID string ReplyTo string Attach []string From string @@ -320,10 +321,13 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, } } - inReplyTo, references, threadID, err := replyHeaders(ctx, svc, input.ReplyToMessageID) + info, err := fetchReplyInfo(ctx, svc, input.ReplyToMessageID, input.ReplyToThreadID) if err != nil { return nil, "", err } + inReplyTo := info.InReplyTo + references := info.References + threadID := info.ThreadID atts := make([]mailAttachment, 0, len(input.Attach)) for _, p := range input.Attach { @@ -358,6 +362,9 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, } func writeDraftResult(ctx context.Context, u *ui.UI, draft *gmail.Draft, threadID string) error { + if threadID == "" && draft != nil && draft.Message != nil { + threadID = draft.Message.ThreadId + } if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{ "draftId": draft.Id, @@ -390,6 +397,7 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error Body: c.Body, BodyHTML: c.BodyHTML, ReplyToMessageID: c.ReplyToMessageID, + ReplyToThreadID: "", ReplyTo: c.ReplyTo, Attach: c.Attach, From: c.From, @@ -440,6 +448,22 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error return usage("empty draftId") } + svc, err := newGmailService(ctx, account) + if err != nil { + return err + } + + threadID := "" + if strings.TrimSpace(c.ReplyToMessageID) == "" { + existing, err := svc.Users.Drafts.Get("me", draftID).Format("metadata").Do() + if err != nil { + return err + } + if existing != nil && existing.Message != nil { + threadID = strings.TrimSpace(existing.Message.ThreadId) + } + } + input := draftComposeInput{ To: c.To, Cc: c.Cc, @@ -448,6 +472,7 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error Body: c.Body, BodyHTML: c.BodyHTML, ReplyToMessageID: c.ReplyToMessageID, + ReplyToThreadID: threadID, ReplyTo: c.ReplyTo, Attach: c.Attach, From: c.From, @@ -456,11 +481,6 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error return validateErr } - svc, err := newGmailService(ctx, account) - if err != nil { - return err - } - msg, threadID, err := buildDraftMessage(ctx, svc, account, input) if err != nil { return err diff --git a/internal/cmd/gmail_drafts_cmd_test.go b/internal/cmd/gmail_drafts_cmd_test.go index 033e40c..06102d2 100644 --- a/internal/cmd/gmail_drafts_cmd_test.go +++ b/internal/cmd/gmail_drafts_cmd_test.go @@ -416,7 +416,29 @@ func TestGmailDraftsUpdateCmd_JSON(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodGet: - t.Fatalf("unexpected drafts get: %s", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "d1", + "message": map[string]any{"id": "m1", "threadId": "t1"}, + }) + return + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "messages": []map[string]any{ + { + "id": "m1", + "threadId": "t1", + "payload": map[string]any{ + "headers": []map[string]any{ + {"name": "Message-ID", "value": ""}, + }, + }, + }, + }, + }) + return case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodPut: body, err := io.ReadAll(r.Body) if err != nil { @@ -515,7 +537,7 @@ func TestGmailDraftsUpdateCmd_JSON(t *testing.T) { if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { t.Fatalf("json parse: %v", err) } - if parsed.DraftID != "d1" || parsed.ThreadID != "" { + if parsed.DraftID != "d1" || parsed.ThreadID != "t1" { t.Fatalf("unexpected json: %#v", parsed) } }