diff --git a/backend/backend.go b/backend/backend.go index 067a51215f8b323cac0d145e1c5c29155c834b17..6c808045b91733a4ea6a8b87d6643ddc9c2c4c43 100644 --- a/backend/backend.go +++ b/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 diff --git a/backend/imap/imap.go b/backend/imap/imap.go index f0facbedca99094940cd6cf3d303f3d424e4851e..f795dab9f6de9a469515122c01cd945b8f0692ef 100644 --- a/backend/imap/imap.go +++ b/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, diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 2dd45e12dd64370314a9bc0700b06fb6cbe97084..7241bd4a9ef70e92ea7277048384917f8ae40f4f 100644 --- a/backend/jmap/jmap.go +++ b/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{} diff --git a/backend/jmap/jmap_test.go b/backend/jmap/jmap_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9a73e1c2af176a4df5bf192527b2a2a20da9aaed --- /dev/null +++ b/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 ", + }, + { + 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) + } + }) + } +} diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index 818231f7f08d17188731037bcb68b4b1afac7310..364bbcdc2752fb65eec957a57599cfcc53f8c5dc 100644 --- a/backend/pop3/pop3.go +++ b/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, diff --git a/backend/pop3/pop3_test.go b/backend/pop3/pop3_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7b8ee7df9a7bc2cad8403cb86c87681ca7b2c452 --- /dev/null +++ b/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 ", + wantReplyTo: []string{"alice@example.com"}, + }, + { + name: "display name with comma", + replyToHeader: `"Doe, John" `, + 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" , "Smith, Jane" `, + 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" `, + wantTo: []string{"john@example.com"}, + }, + { + name: "multiple addresses with display names", + toHeader: `"Doe, John" , Alice `, + 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) + } + } + }) + } +} diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 75cc0ad4e60f168e8643da37f7056dabf58d5b1b..237987206a79c67175e9cc2870c38b22291a1d7d 100644 --- a/fetcher/fetcher.go +++ b/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), diff --git a/main.go b/main.go index 0161ab2f1191b6049ad5d4fa2eb3f5bb76ffbfb8..91bdbfaefbc7694b1272f4ca2b04600e876828ef 100644 --- a/main.go +++ b/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:") {