Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type Email struct {
UID uint32
From string
To []string
ReplyTo []string
Subject string
Body string
Date time.Time
Expand Down
1 change: 1 addition & 0 deletions backend/imap/imap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 26 additions & 17 deletions backend/jmap/jmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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{}
Expand Down
142 changes: 142 additions & 0 deletions backend/jmap/jmap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
16 changes: 14 additions & 2 deletions backend/pop3/pop3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions backend/pop3/pop3_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
})
}
}
7 changes: 7 additions & 0 deletions fetcher/fetcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ type Email struct {
UID uint32
From string
To []string
ReplyTo []string
Subject string
Body string
Date time.Time
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading