Skip to content

Commit 4ed1dce

Browse files
fix(gmail): use Reply-To header for reply-all per RFC 5322
When the original message has a Reply-To header, use that address instead of From when auto-populating recipients for --reply-all. This follows RFC 5322 email conventions for reply handling. - Add ReplyToAddr field to replyInfo struct - Fetch Reply-To header in fetchReplyInfo() - Update buildReplyAllRecipients to prioritize Reply-To over From - Add tests for Reply-To header behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 81d281f commit 4ed1dce

2 files changed

Lines changed: 81 additions & 15 deletions

File tree

internal/cmd/gmail_send.go

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,22 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
151151
}
152152

153153
// buildReplyAllRecipients constructs To and Cc lists for a reply-all.
154-
// Original sender (From) -> To
154+
// Per RFC 5322: if Reply-To header is present, use it instead of From.
155+
// Reply-To (or From if no Reply-To) -> To
155156
// Original To recipients -> To
156157
// Original Cc recipients -> Cc
157158
// Filters out self and deduplicates.
158159
func buildReplyAllRecipients(info *replyInfo, selfEmail string) (to, cc []string) {
159-
// Collect To recipients: original sender + original To recipients
160+
// Collect To recipients: reply address (Reply-To if present, else From) + original To recipients
160161
toAddrs := make([]string, 0, 1+len(info.ToAddrs))
161-
if fromAddrs := parseEmailAddresses(info.FromAddr); len(fromAddrs) > 0 {
162-
toAddrs = append(toAddrs, fromAddrs...)
162+
163+
// Per RFC 5322, Reply-To takes precedence over From for replies
164+
replyAddress := info.ReplyToAddr
165+
if replyAddress == "" {
166+
replyAddress = info.FromAddr
167+
}
168+
if replyAddrs := parseEmailAddresses(replyAddress); len(replyAddrs) > 0 {
169+
toAddrs = append(toAddrs, replyAddrs...)
163170
}
164171
toAddrs = append(toAddrs, info.ToAddrs...)
165172

@@ -188,12 +195,13 @@ func buildReplyAllRecipients(info *replyInfo, selfEmail string) (to, cc []string
188195

189196
// replyInfo contains all information extracted from the original message for replying
190197
type replyInfo struct {
191-
InReplyTo string
192-
References string
193-
ThreadID string
194-
FromAddr string // Original sender
195-
ToAddrs []string // Original To recipients
196-
CcAddrs []string // Original Cc recipients
198+
InReplyTo string
199+
References string
200+
ThreadID string
201+
FromAddr string // Original sender
202+
ReplyToAddr string // Original Reply-To header (per RFC 5322, use this instead of From if present)
203+
ToAddrs []string // Original To recipients
204+
CcAddrs []string // Original Cc recipients
197205
}
198206

199207
func replyHeaders(ctx context.Context, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) {
@@ -211,18 +219,19 @@ func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID st
211219
}
212220
msg, err := svc.Users.Messages.Get("me", replyToMessageID).
213221
Format("metadata").
214-
MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "To", "Cc").
222+
MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc").
215223
Context(ctx).
216224
Do()
217225
if err != nil {
218226
return nil, err
219227
}
220228

221229
info := &replyInfo{
222-
ThreadID: msg.ThreadId,
223-
FromAddr: headerValue(msg.Payload, "From"),
224-
ToAddrs: parseEmailAddresses(headerValue(msg.Payload, "To")),
225-
CcAddrs: parseEmailAddresses(headerValue(msg.Payload, "Cc")),
230+
ThreadID: msg.ThreadId,
231+
FromAddr: headerValue(msg.Payload, "From"),
232+
ReplyToAddr: headerValue(msg.Payload, "Reply-To"),
233+
ToAddrs: parseEmailAddresses(headerValue(msg.Payload, "To")),
234+
CcAddrs: parseEmailAddresses(headerValue(msg.Payload, "Cc")),
226235
}
227236

228237
// Prefer Message-ID and References from the original message.

internal/cmd/gmail_send_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,42 @@ func TestBuildReplyAllRecipients(t *testing.T) {
323323
expectTo: []string{},
324324
expectCc: []string{},
325325
},
326+
{
327+
name: "Reply-To header takes precedence over From (RFC 5322)",
328+
info: &replyInfo{
329+
FromAddr: "original-sender@example.com",
330+
ReplyToAddr: "reply-here@example.com",
331+
ToAddrs: []string{"me@example.com", "alice@example.com"},
332+
CcAddrs: nil,
333+
},
334+
selfEmail: "me@example.com",
335+
expectTo: []string{"reply-here@example.com", "alice@example.com"},
336+
expectCc: []string{},
337+
},
338+
{
339+
name: "Reply-To with display name",
340+
info: &replyInfo{
341+
FromAddr: "sender@example.com",
342+
ReplyToAddr: "Mailing List <list@example.com>",
343+
ToAddrs: []string{"alice@example.com"},
344+
CcAddrs: nil,
345+
},
346+
selfEmail: "me@example.com",
347+
expectTo: []string{"list@example.com", "alice@example.com"},
348+
expectCc: []string{},
349+
},
350+
{
351+
name: "Empty Reply-To falls back to From",
352+
info: &replyInfo{
353+
FromAddr: "sender@example.com",
354+
ReplyToAddr: "",
355+
ToAddrs: []string{"alice@example.com"},
356+
CcAddrs: nil,
357+
},
358+
selfEmail: "me@example.com",
359+
expectTo: []string{"sender@example.com", "alice@example.com"},
360+
expectCc: []string{},
361+
},
326362
}
327363

328364
for _, tc := range tests {
@@ -373,6 +409,15 @@ func TestFetchReplyInfo(t *testing.T) {
373409
{Name: "To", Value: "recipient@example.com"},
374410
},
375411
},
412+
"m3": {
413+
ThreadID: "t3",
414+
Headers: []hdr{
415+
{Name: "Message-ID", Value: "<id3@example.com>"},
416+
{Name: "From", Value: "original-sender@example.com"},
417+
{Name: "Reply-To", Value: "Mailing List <list@example.com>"},
418+
{Name: "To", Value: "recipient@example.com"},
419+
},
420+
},
376421
}
377422

378423
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -450,6 +495,18 @@ func TestFetchReplyInfo(t *testing.T) {
450495
if info.ThreadID != "" || info.FromAddr != "" {
451496
t.Errorf("Expected empty replyInfo for empty message ID")
452497
}
498+
499+
// Test m3: message with Reply-To header
500+
info, err = fetchReplyInfo(ctx, svc, "m3")
501+
if err != nil {
502+
t.Fatalf("fetchReplyInfo(m3): %v", err)
503+
}
504+
if info.FromAddr != "original-sender@example.com" {
505+
t.Errorf("FromAddr = %q, want %q", info.FromAddr, "original-sender@example.com")
506+
}
507+
if info.ReplyToAddr != "Mailing List <list@example.com>" {
508+
t.Errorf("ReplyToAddr = %q, want %q", info.ReplyToAddr, "Mailing List <list@example.com>")
509+
}
453510
}
454511

455512
func TestReplyAllValidation(t *testing.T) {

0 commit comments

Comments
 (0)