fix(gmail): preserve draft thread headers on update (#55) (thanks @antons)

This commit is contained in:
Peter Steinberger 2026-01-09 22:47:52 +01:00
parent a528467dcd
commit 95e883dc8f
4 changed files with 68 additions and 8 deletions

View File

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

View File

@ -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": "<m1@example.com>"},
},
},
},
},
})
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{

View File

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

View File

@ -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": "<m1@example.com>"},
},
},
},
},
})
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)
}
}