fix(compose): prioritize reply-to header over from address (#529)

Ron Kizilov and drew created

Co-authored-by: drew <me@andrinoff.com>

Change summary

backend/backend.go        |   1 
backend/imap/imap.go      |   1 
backend/jmap/jmap.go      |  43 +++++++----
backend/jmap/jmap_test.go | 142 +++++++++++++++++++++++++++++++++++++++++
backend/pop3/pop3.go      |  16 ++++
backend/pop3/pop3_test.go | 109 +++++++++++++++++++++++++++++++
fetcher/fetcher.go        |   7 ++
main.go                   |   7 +
8 files changed, 306 insertions(+), 20 deletions(-)

Detailed changes

backend/backend.go 🔗

@@ -65,6 +65,7 @@ type Email struct {
 	UID         uint32
 	From        string
 	To          []string
+	ReplyTo     []string
 	Subject     string
 	Body        string
 	Date        time.Time

backend/imap/imap.go 🔗

@@ -130,6 +130,7 @@ func toBackendEmails(emails []fetcher.Email) []backend.Email {
 			UID:         e.UID,
 			From:        e.From,
 			To:          e.To,
+			ReplyTo:     e.ReplyTo,
 			Subject:     e.Subject,
 			Body:        e.Body,
 			Date:        e.Date,

backend/jmap/jmap.go 🔗

@@ -166,7 +166,7 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u
 			Path:     "/ids",
 		},
 		Properties: []string{
-			"id", "subject", "from", "to", "receivedAt",
+			"id", "subject", "from", "to", "replyTo", "receivedAt",
 			"preview", "keywords", "mailboxIds", "hasAttachment",
 			"messageId",
 		},
@@ -186,22 +186,7 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u
 				p.idToJMAPID[uid] = eml.ID
 				p.mu.Unlock()
 
-				e := backend.Email{
-					UID:       uid,
-					Subject:   eml.Subject,
-					Date:      safeTime(eml.ReceivedAt),
-					IsRead:    eml.Keywords["$seen"],
-					AccountID: p.account.ID,
-				}
-				if len(eml.From) > 0 {
-					e.From = eml.From[0].String()
-				}
-				for _, addr := range eml.To {
-					e.To = append(e.To, addr.String())
-				}
-				if len(eml.MessageID) > 0 {
-					e.MessageID = eml.MessageID[0]
-				}
+				e := jmapEmailToBackend(eml, uid, p.account.ID)
 				emails = append(emails, e)
 			}
 		}
@@ -608,6 +593,30 @@ func jmapIDToUID(id jmapclient.ID) uint32 {
 	return v
 }
 
+// jmapEmailToBackend converts a JMAP email to a backend.Email.
+func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend.Email {
+	e := backend.Email{
+		UID:       uid,
+		Subject:   eml.Subject,
+		Date:      safeTime(eml.ReceivedAt),
+		IsRead:    eml.Keywords["$seen"],
+		AccountID: accountID,
+	}
+	if len(eml.From) > 0 {
+		e.From = eml.From[0].String()
+	}
+	for _, addr := range eml.To {
+		e.To = append(e.To, addr.Email)
+	}
+	for _, addr := range eml.ReplyTo {
+		e.ReplyTo = append(e.ReplyTo, addr.Email)
+	}
+	if len(eml.MessageID) > 0 {
+		e.MessageID = eml.MessageID[0]
+	}
+	return e
+}
+
 func safeTime(t *time.Time) time.Time {
 	if t == nil {
 		return time.Time{}

backend/jmap/jmap_test.go 🔗

@@ -0,0 +1,142 @@
+package jmap
+
+import (
+	"testing"
+
+	"git.sr.ht/~rockorager/go-jmap/mail"
+	"git.sr.ht/~rockorager/go-jmap/mail/email"
+)
+
+func TestJmapEmailToBackend_ReplyTo(t *testing.T) {
+	tests := []struct {
+		name        string
+		replyTo     []*mail.Address
+		wantReplyTo []string
+	}{
+		{
+			name:        "single bare address",
+			replyTo:     []*mail.Address{{Email: "alice@example.com"}},
+			wantReplyTo: []string{"alice@example.com"},
+		},
+		{
+			name:        "address with display name returns only email",
+			replyTo:     []*mail.Address{{Name: "Alice Smith", Email: "alice@example.com"}},
+			wantReplyTo: []string{"alice@example.com"},
+		},
+		{
+			name: "display name with comma returns only email",
+			replyTo: []*mail.Address{
+				{Name: "Doe, John", Email: "john@example.com"},
+			},
+			wantReplyTo: []string{"john@example.com"},
+		},
+		{
+			name: "multiple addresses with display names",
+			replyTo: []*mail.Address{
+				{Name: "Doe, John", Email: "john@example.com"},
+				{Name: "Smith, Jane", Email: "jane@example.com"},
+			},
+			wantReplyTo: []string{"john@example.com", "jane@example.com"},
+		},
+		{
+			name:        "empty reply-to",
+			replyTo:     nil,
+			wantReplyTo: nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			eml := &email.Email{
+				ReplyTo: tt.replyTo,
+			}
+			result := jmapEmailToBackend(eml, 1, "test-account")
+
+			if len(result.ReplyTo) != len(tt.wantReplyTo) {
+				t.Fatalf("ReplyTo length = %d, want %d; got %v", len(result.ReplyTo), len(tt.wantReplyTo), result.ReplyTo)
+			}
+			for i, want := range tt.wantReplyTo {
+				if result.ReplyTo[i] != want {
+					t.Errorf("ReplyTo[%d] = %q, want %q", i, result.ReplyTo[i], want)
+				}
+			}
+		})
+	}
+}
+
+func TestJmapEmailToBackend_To(t *testing.T) {
+	tests := []struct {
+		name   string
+		to     []*mail.Address
+		wantTo []string
+	}{
+		{
+			name:   "address with display name returns only email",
+			to:     []*mail.Address{{Name: "Alice Smith", Email: "alice@example.com"}},
+			wantTo: []string{"alice@example.com"},
+		},
+		{
+			name: "multiple addresses return only emails",
+			to: []*mail.Address{
+				{Name: "Doe, John", Email: "john@example.com"},
+				{Name: "Alice", Email: "alice@example.com"},
+			},
+			wantTo: []string{"john@example.com", "alice@example.com"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			eml := &email.Email{
+				To: tt.to,
+			}
+			result := jmapEmailToBackend(eml, 1, "test-account")
+
+			if len(result.To) != len(tt.wantTo) {
+				t.Fatalf("To length = %d, want %d; got %v", len(result.To), len(tt.wantTo), result.To)
+			}
+			for i, want := range tt.wantTo {
+				if result.To[i] != want {
+					t.Errorf("To[%d] = %q, want %q", i, result.To[i], want)
+				}
+			}
+		})
+	}
+}
+
+func TestJmapEmailToBackend_From(t *testing.T) {
+	tests := []struct {
+		name     string
+		from     []*mail.Address
+		wantFrom string
+	}{
+		{
+			name:     "from with display name includes name",
+			from:     []*mail.Address{{Name: "Alice Smith", Email: "alice@example.com"}},
+			wantFrom: "Alice Smith <alice@example.com>",
+		},
+		{
+			name:     "from without display name returns bare email",
+			from:     []*mail.Address{{Email: "alice@example.com"}},
+			wantFrom: "alice@example.com",
+		},
+		{
+			name:     "empty from",
+			from:     nil,
+			wantFrom: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			eml := &email.Email{
+				From: tt.from,
+			}
+			result := jmapEmailToBackend(eml, 1, "test-account")
+
+			if result.From != tt.wantFrom {
+				t.Errorf("From = %q, want %q", result.From, tt.wantFrom)
+			}
+		})
+	}
+}

backend/pop3/pop3.go 🔗

@@ -297,8 +297,19 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account
 
 	var to []string
 	if toHeader := header.Get("To"); toHeader != "" {
-		for _, addr := range strings.Split(toHeader, ",") {
-			to = append(to, strings.TrimSpace(addr))
+		if addrs, err := mail.ParseAddressList(toHeader); err == nil {
+			for _, addr := range addrs {
+				to = append(to, addr.Address)
+			}
+		}
+	}
+
+	var replyTo []string
+	if replyToHeader := header.Get("Reply-To"); replyToHeader != "" {
+		if addrs, err := mail.ParseAddressList(replyToHeader); err == nil {
+			for _, addr := range addrs {
+				replyTo = append(replyTo, addr.Address)
+			}
 		}
 	}
 
@@ -327,6 +338,7 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account
 		UID:       hashUID(uidStr),
 		From:      from,
 		To:        to,
+		ReplyTo:   replyTo,
 		Subject:   subject,
 		Date:      date,
 		IsRead:    false,

backend/pop3/pop3_test.go 🔗

@@ -0,0 +1,109 @@
+package pop3
+
+import (
+	"testing"
+
+	"github.com/emersion/go-message"
+	pop3client "github.com/knadh/go-pop3"
+)
+
+func TestEntityToEmail_ReplyTo(t *testing.T) {
+	tests := []struct {
+		name          string
+		replyToHeader string
+		wantReplyTo   []string
+	}{
+		{
+			name:          "single bare address",
+			replyToHeader: "alice@example.com",
+			wantReplyTo:   []string{"alice@example.com"},
+		},
+		{
+			name:          "single address with display name",
+			replyToHeader: "Alice Smith <alice@example.com>",
+			wantReplyTo:   []string{"alice@example.com"},
+		},
+		{
+			name:          "display name with comma",
+			replyToHeader: `"Doe, John" <john@example.com>`,
+			wantReplyTo:   []string{"john@example.com"},
+		},
+		{
+			name:          "multiple addresses",
+			replyToHeader: "alice@example.com, bob@example.com",
+			wantReplyTo:   []string{"alice@example.com", "bob@example.com"},
+		},
+		{
+			name:          "multiple addresses with display names containing commas",
+			replyToHeader: `"Doe, John" <john@example.com>, "Smith, Jane" <jane@example.com>`,
+			wantReplyTo:   []string{"john@example.com", "jane@example.com"},
+		},
+		{
+			name:          "empty reply-to",
+			replyToHeader: "",
+			wantReplyTo:   nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var header message.Header
+			header.Set("From", "sender@example.com")
+			header.Set("Subject", "Test")
+			if tt.replyToHeader != "" {
+				header.Set("Reply-To", tt.replyToHeader)
+			}
+
+			msgInfo := pop3client.MessageID{ID: 1, UID: "test-uid"}
+			email := entityToEmail(&header, msgInfo, "test-account")
+
+			if len(email.ReplyTo) != len(tt.wantReplyTo) {
+				t.Fatalf("ReplyTo length = %d, want %d; got %v", len(email.ReplyTo), len(tt.wantReplyTo), email.ReplyTo)
+			}
+			for i, want := range tt.wantReplyTo {
+				if email.ReplyTo[i] != want {
+					t.Errorf("ReplyTo[%d] = %q, want %q", i, email.ReplyTo[i], want)
+				}
+			}
+		})
+	}
+}
+
+func TestEntityToEmail_To(t *testing.T) {
+	tests := []struct {
+		name     string
+		toHeader string
+		wantTo   []string
+	}{
+		{
+			name:     "display name with comma",
+			toHeader: `"Doe, John" <john@example.com>`,
+			wantTo:   []string{"john@example.com"},
+		},
+		{
+			name:     "multiple addresses with display names",
+			toHeader: `"Doe, John" <john@example.com>, Alice <alice@example.com>`,
+			wantTo:   []string{"john@example.com", "alice@example.com"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var header message.Header
+			header.Set("From", "sender@example.com")
+			header.Set("To", tt.toHeader)
+
+			msgInfo := pop3client.MessageID{ID: 1, UID: "test-uid"}
+			email := entityToEmail(&header, msgInfo, "test-account")
+
+			if len(email.To) != len(tt.wantTo) {
+				t.Fatalf("To length = %d, want %d; got %v", len(email.To), len(tt.wantTo), email.To)
+			}
+			for i, want := range tt.wantTo {
+				if email.To[i] != want {
+					t.Errorf("To[%d] = %q, want %q", i, email.To[i], want)
+				}
+			}
+		})
+	}
+}

fetcher/fetcher.go 🔗

@@ -74,6 +74,7 @@ type Email struct {
 	UID         uint32
 	From        string
 	To          []string
+	ReplyTo     []string
 	Subject     string
 	Body        string
 	Date        time.Time
@@ -441,6 +442,11 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 				toAddrList = append(toAddrList, addr.Addr())
 			}
 
+			var replyToAddrList []string
+			for _, addr := range msg.Envelope.ReplyTo {
+				replyToAddrList = append(replyToAddrList, addr.Addr())
+			}
+
 			matched := false
 			if isSentMailbox {
 				var senderEmail string
@@ -472,6 +478,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u
 				UID:       uint32(msg.UID),
 				From:      fromAddr,
 				To:        toAddrList,
+				ReplyTo:   replyToAddrList,
 				Subject:   decodeHeader(msg.Envelope.Subject),
 				Date:      msg.Envelope.Date,
 				IsRead:    hasSeenFlag(msg.Flags),

main.go 🔗

@@ -1031,7 +1031,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		return m, tea.Batch(cmds...)
 
 	case tui.ReplyToEmailMsg:
-		to := msg.Email.From
+		var to string
+		if len(msg.Email.ReplyTo) > 0 {
+			to = strings.Join(msg.Email.ReplyTo, ", ")
+		} else {
+			to = msg.Email.From
+		}
 		subject := msg.Email.Subject
 		normalizedSubject := strings.ToLower(strings.TrimSpace(subject))
 		if !strings.HasPrefix(normalizedSubject, "re:") {