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
Ron Kizilov and drew created
Co-authored-by: drew <me@andrinoff.com>
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(-)
@@ -65,6 +65,7 @@ type Email struct {
UID uint32
From string
To []string
+ ReplyTo []string
Subject string
Body string
Date time.Time
@@ -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,
@@ -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{}
@@ -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)
+ }
+ })
+ }
+}
@@ -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,
@@ -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)
+ }
+ }
+ })
+ }
+}
@@ -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),
@@ -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:") {