Skip to content

Commit 7544450

Browse files
committed
fix(mail): address Codex PR review — lazy reply derivation + restored local guards
- deriveReplyDefaults now takes a replyNeeds struct and only parses source headers for slots the caller actually consumes. An explicit --to override no longer fails on a malformed source From; an explicit --cc override no longer fails on malformed source To/Cc. - Restore the local 'len(toAddrs) == 0' guard in non-reply mode so empty --to fails before any Gmail API call (regression introduced by reply mode). - Treat --reply-to "" as non-reply mode (don't call GetMessage with empty ID, don't skip --to/--subject requirements). --reply-all --reply-to "" still errors with the existing requires-reply-to message. - New tests pin exact alias-filter expectations and cover the three new edge cases above.
1 parent de7e470 commit 7544450

2 files changed

Lines changed: 128 additions & 23 deletions

File tree

internal/cmd/mail/draft.go

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ Examples:
5656
gro mail draft --reply-to <message-id> --body "thanks, will review"
5757
gro mail draft --reply-to <message-id> --reply-all --body "..."`,
5858
RunE: func(cmd *cobra.Command, _ []string) error {
59-
isReply := cmd.Flags().Changed("reply-to")
59+
// Reply mode is opt-in via a non-empty --reply-to. Empty string
60+
// (e.g. --reply-to "") is treated the same as omitting the flag,
61+
// since an empty message ID can't be fetched.
62+
isReply := strings.TrimSpace(replyTo) != ""
6063

6164
// 1. Required-flag checks. In reply mode, --to and --subject are
6265
// derived from the source message and only required if not derivable.
@@ -108,6 +111,12 @@ Examples:
108111
fromAddr = parsed.Address
109112
}
110113

114+
// 3b. Non-reply mode: require at least one --to address now (no I/O yet).
115+
// Reply mode defers this check until after source derivation.
116+
if !isReply && len(toAddrs) == 0 {
117+
return fmt.Errorf("--to must contain at least one address")
118+
}
119+
111120
// 4. Body source: exactly one of --body, --stdin, --file.
112121
bodySources := 0
113122
if cmd.Flags().Changed("body") {
@@ -195,8 +204,13 @@ Examples:
195204
if err != nil {
196205
return fmt.Errorf("fetching reply source %s: %w", replyTo, err)
197206
}
207+
needs := replyNeeds{
208+
To: !cmd.Flags().Changed("to"),
209+
Cc: replyAll && !cmd.Flags().Changed("cc"),
210+
Subject: !cmd.Flags().Changed("subject"),
211+
}
198212
selfSet := map[string]bool{}
199-
if replyAll {
213+
if needs.Cc {
200214
profile, err := client.GetProfile(cmd.Context())
201215
if err != nil {
202216
return fmt.Errorf("fetching profile for --reply-all: %w", err)
@@ -208,20 +222,20 @@ Examples:
208222
selfSet[strings.ToLower(fromAddr)] = true
209223
}
210224
}
211-
derived, err := deriveReplyDefaults(src, replyAll, selfSet)
225+
derived, err := deriveReplyDefaults(src, needs, selfSet)
212226
if err != nil {
213227
return err
214228
}
215229
threadID = derived.ThreadID
216230
inReplyTo = derived.InReplyTo
217231
references = derived.References
218-
if !cmd.Flags().Changed("to") {
232+
if needs.To {
219233
toAddrs = derived.To
220234
}
221-
if !cmd.Flags().Changed("cc") {
235+
if needs.Cc {
222236
ccAddrs = derived.Cc
223237
}
224-
if !cmd.Flags().Changed("subject") {
238+
if needs.Subject {
225239
subject = derived.Subject
226240
}
227241
}
@@ -302,28 +316,44 @@ type replyDerivation struct {
302316
Subject string
303317
}
304318

305-
func deriveReplyDefaults(src *gmailapi.Message, replyAll bool, selfSet map[string]bool) (replyDerivation, error) {
319+
// replyNeeds tells deriveReplyDefaults which slots the caller will actually
320+
// consume. Source headers are only parsed for needed slots, so an explicit
321+
// --to/--cc/--subject override is unaffected by a malformed/missing source
322+
// header for that slot.
323+
type replyNeeds struct {
324+
To bool
325+
Cc bool
326+
Subject bool
327+
}
328+
329+
func deriveReplyDefaults(src *gmailapi.Message, needs replyNeeds, selfSet map[string]bool) (replyDerivation, error) {
306330
if src == nil {
307331
return replyDerivation{}, fmt.Errorf("source message is nil")
308332
}
309-
if strings.TrimSpace(src.From) == "" {
310-
return replyDerivation{}, fmt.Errorf("source message %s has no From header", src.ID)
311-
}
333+
// Message-Id is always required: it feeds In-Reply-To and References,
334+
// which are emitted regardless of override flags.
312335
if strings.TrimSpace(src.RFCMessageID) == "" {
313336
return replyDerivation{}, fmt.Errorf("source message %s has no Message-Id header", src.ID)
314337
}
315-
fromAddr, err := mail.ParseAddress(src.From)
316-
if err != nil {
317-
return replyDerivation{}, fmt.Errorf("parsing source From header %q: %w", src.From, err)
318-
}
319338
out := replyDerivation{
320339
ThreadID: src.ThreadID,
321340
InReplyTo: src.RFCMessageID,
322341
References: buildReferences(src.References, src.RFCMessageID),
323-
To: []string{fromAddr.Address},
324-
Subject: addRePrefix(src.Subject),
325342
}
326-
if replyAll {
343+
if needs.To {
344+
if strings.TrimSpace(src.From) == "" {
345+
return replyDerivation{}, fmt.Errorf("source message %s has no From header", src.ID)
346+
}
347+
fromAddr, err := mail.ParseAddress(src.From)
348+
if err != nil {
349+
return replyDerivation{}, fmt.Errorf("parsing source From header %q: %w", src.From, err)
350+
}
351+
out.To = []string{fromAddr.Address}
352+
}
353+
if needs.Subject {
354+
out.Subject = addRePrefix(src.Subject)
355+
}
356+
if needs.Cc {
327357
ccAddrs, err := splitAddressHeaders(src.To, src.Cc)
328358
if err != nil {
329359
return replyDerivation{}, fmt.Errorf("parsing source To/Cc headers: %w", err)

internal/cmd/mail/draft_test.go

Lines changed: 81 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -606,12 +606,87 @@ func TestDraftCommand_ReplyAll_FiltersFromAlias(t *testing.T) {
606606
withMockClient(mock, func() {
607607
err := cmd.Execute()
608608
testutil.NoError(t, err)
609-
// alias@example.com filtered (via --from), me@example.com would have been (not present here), bob+carol remain.
610-
for _, a := range seen.Cc {
611-
if a == "alias@example.com" {
612-
t.Errorf("alias not filtered from Cc: %v", seen.Cc)
613-
}
614-
}
609+
// Source To = alias + bob; source Cc = carol. alias filtered via --from.
610+
// Pin the exact list so the test fails if filtering accidentally drops bob/carol too.
611+
testutil.LenSlice(t, len(seen.Cc), 2)
612+
testutil.Equal(t, seen.Cc[0], "bob@example.com")
613+
testutil.Equal(t, seen.Cc[1], "carol@example.com")
614+
})
615+
}
616+
617+
func TestDraftCommand_ReplyTo_OverrideToWithMalformedSourceFrom(t *testing.T) {
618+
// --to override means the source From is never parsed, so a malformed
619+
// source From header must not fail the command.
620+
var seen gmailapi.DraftMessage
621+
src := srcReply()
622+
src.From = "not-an-email"
623+
mock := &MockGmailClient{
624+
GetMessageFunc: func(_ context.Context, _ string, _ bool) (*gmailapi.Message, error) { return src, nil },
625+
CreateDraftFunc: func(_ context.Context, msg gmailapi.DraftMessage) (*gmailapi.DraftResult, error) {
626+
seen = msg
627+
return &gmailapi.DraftResult{ID: "d1"}, nil
628+
},
629+
}
630+
cmd := newDraftCommand()
631+
cmd.SetArgs([]string{"--reply-to", "msg-src", "--to", "override@x.com", "--body", "x", "--plain"})
632+
withMockClient(mock, func() {
633+
err := cmd.Execute()
634+
testutil.NoError(t, err)
635+
testutil.LenSlice(t, len(seen.To), 1)
636+
testutil.Equal(t, seen.To[0], "override@x.com")
637+
// Threading headers still derive (Message-Id is unaffected).
638+
testutil.Equal(t, seen.InReplyTo, "<orig@example.com>")
639+
})
640+
}
641+
642+
func TestDraftCommand_ReplyAll_OverrideCcWithMalformedSourceToCc(t *testing.T) {
643+
// --cc override means source To/Cc are never parsed, so malformed values must not fail.
644+
var seen gmailapi.DraftMessage
645+
src := srcReply()
646+
src.To = "garbage @@@ not parseable"
647+
mock := &MockGmailClient{
648+
GetMessageFunc: func(_ context.Context, _ string, _ bool) (*gmailapi.Message, error) { return src, nil },
649+
CreateDraftFunc: func(_ context.Context, msg gmailapi.DraftMessage) (*gmailapi.DraftResult, error) {
650+
seen = msg
651+
return &gmailapi.DraftResult{ID: "d1"}, nil
652+
},
653+
}
654+
cmd := newDraftCommand()
655+
cmd.SetArgs([]string{"--reply-to", "msg-src", "--reply-all", "--cc", "explicit@x.com", "--body", "x", "--plain"})
656+
withMockClient(mock, func() {
657+
err := cmd.Execute()
658+
testutil.NoError(t, err)
659+
testutil.LenSlice(t, len(seen.Cc), 1)
660+
testutil.Equal(t, seen.Cc[0], "explicit@x.com")
661+
})
662+
}
663+
664+
func TestDraftCommand_EmptyReplyToTreatedAsNonReply(t *testing.T) {
665+
mock := &MockGmailClient{
666+
GetMessageFunc: func(_ context.Context, _ string, _ bool) (*gmailapi.Message, error) {
667+
t.Fatal("GetMessage must not be called with empty --reply-to")
668+
return nil, nil
669+
},
670+
}
671+
cmd := newDraftCommand()
672+
cmd.SetArgs([]string{"--reply-to", "", "--body", "x", "--plain"})
673+
withMockClient(mock, func() {
674+
err := cmd.Execute()
675+
testutil.Error(t, err)
676+
// Empty --reply-to falls back to non-reply mode, so --to is required.
677+
testutil.Contains(t, err.Error(), "--to")
678+
})
679+
}
680+
681+
func TestDraftCommand_EmptyReplyToWithReplyAll(t *testing.T) {
682+
mock := &MockGmailClient{}
683+
cmd := newDraftCommand()
684+
cmd.SetArgs([]string{"--reply-to", "", "--reply-all", "--body", "x", "--plain"})
685+
withMockClient(mock, func() {
686+
err := cmd.Execute()
687+
testutil.Error(t, err)
688+
testutil.Contains(t, err.Error(), "--reply-all")
689+
testutil.Contains(t, err.Error(), "--reply-to")
615690
})
616691
}
617692

0 commit comments

Comments
 (0)