diff --git a/backend/backend.go b/backend/backend.go index 6c80804..229117f 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -71,6 +71,7 @@ type Email struct { Date time.Time IsRead bool MessageID string + InReplyTo string References []string Attachments []Attachment AccountID string diff --git a/backend/imap/imap.go b/backend/imap/imap.go index f795dab..660e7e2 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -136,6 +136,7 @@ func toBackendEmails(emails []fetcher.Email) []backend.Email { Date: e.Date, IsRead: e.IsRead, MessageID: e.MessageID, + InReplyTo: e.InReplyTo, References: e.References, Attachments: toBackendAttachments(e.Attachments), AccountID: e.AccountID, diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 7241bd4..7fa5598 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -168,7 +168,7 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u Properties: []string{ "id", "subject", "from", "to", "replyTo", "receivedAt", "preview", "keywords", "mailboxIds", "hasAttachment", - "messageId", + "messageId", "inReplyTo", "references", }, }) @@ -614,6 +614,10 @@ func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend. if len(eml.MessageID) > 0 { e.MessageID = eml.MessageID[0] } + if len(eml.InReplyTo) > 0 { + e.InReplyTo = eml.InReplyTo[0] + } + e.References = append(e.References, eml.References...) return e } diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index 364bbcd..7aef951 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -15,6 +15,7 @@ import ( "io" "mime" "net/mail" + "regexp" "strings" "time" @@ -27,6 +28,8 @@ import ( "github.com/floatpane/matcha/sender" ) +var pop3MessageIDRE = regexp.MustCompile(`<[^>]+>`) + func init() { backend.RegisterBackend("pop3", func(account *config.Account) (backend.Provider, error) { return New(account) @@ -294,6 +297,8 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account subject := header.Get("Subject") dateStr := header.Get("Date") messageID := header.Get("Message-ID") + inReplyTo := firstMessageID(header.Get("In-Reply-To")) + references := messageIDList(header.Get("References")) var to []string if toHeader := header.Get("To"); toHeader != "" { @@ -335,16 +340,34 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account } return backend.Email{ - UID: hashUID(uidStr), - From: from, - To: to, - ReplyTo: replyTo, - Subject: subject, - Date: date, - IsRead: false, - MessageID: messageID, - AccountID: accountID, + UID: hashUID(uidStr), + From: from, + To: to, + ReplyTo: replyTo, + Subject: subject, + Date: date, + IsRead: false, + MessageID: messageID, + InReplyTo: inReplyTo, + References: references, + AccountID: accountID, + } +} + +func firstMessageID(value string) string { + ids := messageIDList(value) + if len(ids) == 0 { + return "" + } + return ids[0] +} + +func messageIDList(value string) []string { + matches := pop3MessageIDRE.FindAllString(value, -1) + if len(matches) == 0 { + return strings.Fields(value) } + return matches } // parseMessageBody extracts the body text and attachments from a raw message. diff --git a/config/cache.go b/config/cache.go index 90feffe..dae47ed 100644 --- a/config/cache.go +++ b/config/cache.go @@ -11,14 +11,16 @@ import ( // CachedEmail stores essential email data for caching. type CachedEmail struct { - UID uint32 `json:"uid"` - From string `json:"from"` - To []string `json:"to"` - Subject string `json:"subject"` - Date time.Time `json:"date"` - MessageID string `json:"message_id"` - AccountID string `json:"account_id"` - IsRead bool `json:"is_read"` + UID uint32 `json:"uid"` + From string `json:"from"` + To []string `json:"to"` + Subject string `json:"subject"` + Date time.Time `json:"date"` + MessageID string `json:"message_id"` + InReplyTo string `json:"in_reply_to,omitempty"` + References []string `json:"references,omitempty"` + AccountID string `json:"account_id"` + IsRead bool `json:"is_read"` } // EmailCache stores cached emails for all accounts. diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 54c1f41..9be3f6d 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -7,6 +7,7 @@ }, "inbox": { "visual_mode": "v", + "toggle_threaded": "T", "delete": "d", "archive": "a", "refresh": "r", diff --git a/config/folder_cache.go b/config/folder_cache.go index d4f916c..7103630 100644 --- a/config/folder_cache.go +++ b/config/folder_cache.go @@ -4,8 +4,11 @@ import ( "encoding/json" "os" "path/filepath" + "strconv" "strings" "time" + + "github.com/floatpane/matcha/internal/threading" ) // CachedFolders stores folder names for a single account. @@ -17,8 +20,9 @@ type CachedFolders struct { // FolderCache stores cached folders for all accounts. type FolderCache struct { - Accounts []CachedFolders `json:"accounts"` - UpdatedAt time.Time `json:"updated_at"` + Accounts []CachedFolders `json:"accounts"` + ThreadedFolders map[string]bool `json:"threaded_folders,omitempty"` + UpdatedAt time.Time `json:"updated_at"` } // folderCacheFile returns the full path to the folder cache file. @@ -179,3 +183,55 @@ func LoadFolderEmailCache(folderName string) ([]CachedEmail, error) { } return cache.Emails, nil } + +func LoadFolderEmailHeaders(folderName string) ([]threading.EmailHeader, error) { + emails, err := LoadFolderEmailCache(folderName) + if err != nil { + return nil, err + } + headers := make([]threading.EmailHeader, 0, len(emails)) + for _, email := range emails { + headers = append(headers, threading.EmailHeader{ + ID: email.MessageID, + InReplyTo: email.InReplyTo, + References: email.References, + Subject: email.Subject, + Date: email.Date, + EmailID: cachedEmailID(email), + Sender: email.From, + }) + } + return headers, nil +} + +func IsFolderThreaded(folderName string) bool { + cache, err := LoadFolderCache() + if err != nil || cache.ThreadedFolders == nil { + return false + } + return cache.ThreadedFolders[folderName] +} + +func SetFolderThreaded(folderName string, threaded bool) error { + cache, err := LoadFolderCache() + if err != nil { + cache = &FolderCache{} + } + if cache.ThreadedFolders == nil { + cache.ThreadedFolders = make(map[string]bool) + } + if threaded { + cache.ThreadedFolders[folderName] = true + } else { + delete(cache.ThreadedFolders, folderName) + } + return SaveFolderCache(cache) +} + +func cachedEmailID(email CachedEmail) string { + return email.AccountID + ":" + formatUID(email.UID) +} + +func formatUID(uid uint32) string { + return strconv.FormatUint(uint64(uid), 10) +} diff --git a/config/keybinds.go b/config/keybinds.go index f01d60e..fed741d 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -33,13 +33,14 @@ type GlobalKeys struct { } type InboxKeys struct { - VisualMode string `json:"visual_mode"` - Delete string `json:"delete"` - Archive string `json:"archive"` - Refresh string `json:"refresh"` - Open string `json:"open"` - NextTab string `json:"next_tab"` - PrevTab string `json:"prev_tab"` + VisualMode string `json:"visual_mode"` + ToggleThreaded string `json:"toggle_threaded"` + Delete string `json:"delete"` + Archive string `json:"archive"` + Refresh string `json:"refresh"` + Open string `json:"open"` + NextTab string `json:"next_tab"` + PrevTab string `json:"prev_tab"` } type EmailKeys struct { @@ -138,13 +139,14 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "nav_down": kb.Global.NavDown, }) check("inbox", map[string]string{ - "visual_mode": kb.Inbox.VisualMode, - "delete": kb.Inbox.Delete, - "archive": kb.Inbox.Archive, - "refresh": kb.Inbox.Refresh, - "open": kb.Inbox.Open, - "next_tab": kb.Inbox.NextTab, - "prev_tab": kb.Inbox.PrevTab, + "visual_mode": kb.Inbox.VisualMode, + "toggle_threaded": kb.Inbox.ToggleThreaded, + "delete": kb.Inbox.Delete, + "archive": kb.Inbox.Archive, + "refresh": kb.Inbox.Refresh, + "open": kb.Inbox.Open, + "next_tab": kb.Inbox.NextTab, + "prev_tab": kb.Inbox.PrevTab, }) check("email", map[string]string{ "reply": kb.Email.Reply, diff --git a/daemon/daemon.go b/daemon/daemon.go index 1963df4..60000c1 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -352,14 +352,16 @@ func (d *Daemon) syncAllAccounts(ctx context.Context) { var cached []config.CachedEmail for _, e := range emails { cached = append(cached, config.CachedEmail{ - UID: e.UID, - From: e.From, - To: e.To, - Subject: e.Subject, - Date: e.Date, - MessageID: e.MessageID, - AccountID: e.AccountID, - IsRead: e.IsRead, + UID: e.UID, + From: e.From, + To: e.To, + Subject: e.Subject, + Date: e.Date, + MessageID: e.MessageID, + InReplyTo: e.InReplyTo, + References: e.References, + AccountID: e.AccountID, + IsRead: e.IsRead, }) } if err := d.updateFolderCache("INBOX", acct.ID, cached); err != nil { @@ -459,14 +461,16 @@ func (d *Daemon) fetchAndCache(accountID, folder string) { var cached []config.CachedEmail for _, e := range emails { cached = append(cached, config.CachedEmail{ - UID: e.UID, - From: e.From, - To: e.To, - Subject: e.Subject, - Date: e.Date, - MessageID: e.MessageID, - AccountID: e.AccountID, - IsRead: e.IsRead, + UID: e.UID, + From: e.From, + To: e.To, + Subject: e.Subject, + Date: e.Date, + MessageID: e.MessageID, + InReplyTo: e.InReplyTo, + References: e.References, + AccountID: e.AccountID, + IsRead: e.IsRead, }) } diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 01e0950..18fa2c9 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -16,6 +16,7 @@ import ( "mime/quotedprintable" "net/textproto" "os" + "regexp" "slices" "sort" "strings" @@ -84,11 +85,14 @@ type Email struct { Date time.Time IsRead bool MessageID string + InReplyTo string References []string Attachments []Attachment AccountID string // ID of the account this email belongs to } +var headerMessageIDRE = regexp.MustCompile(`<[^>]+>`) + // Folder represents an IMAP mailbox/folder. type Folder struct { Name string @@ -133,6 +137,38 @@ func deliveryHeadersMatch(data []byte, fetchEmail string) bool { return false } +func headerMessageIDs(data []byte, key string) []string { + if len(data) == 0 { + return nil + } + reader := textproto.NewReader(bufio.NewReader(bytes.NewReader(data))) + headers, err := reader.ReadMIMEHeader() + if err != nil && len(headers) == 0 { + return nil + } + var ids []string + for _, value := range headers.Values(key) { + matches := headerMessageIDRE.FindAllString(value, -1) + if len(matches) == 0 { + for _, field := range strings.Fields(value) { + ids = append(ids, strings.TrimSpace(field)) + } + continue + } + for _, match := range matches { + ids = append(ids, strings.TrimSpace(match)) + } + } + return ids +} + +func firstEnvelopeInReplyTo(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + func decodePart(reader io.Reader, header mail.PartHeader) (string, error) { mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type")) if err != nil { @@ -406,7 +442,7 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u // Delivery header section for matching auto-forwarded emails deliveryHeaderSection := &imap.FetchItemBodySection{ Specifier: imap.PartSpecifierHeader, - HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"}, + HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To", "References"}, Peek: true, } @@ -490,15 +526,19 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u continue } + headerData := msg.FindBodySection(deliveryHeaderSection) batchEmails = append(batchEmails, Email{ - UID: uint32(msg.UID), - From: fromAddr, - To: toAddrList, - ReplyTo: replyToAddrList, - Subject: decodeHeader(msg.Envelope.Subject), - Date: msg.Envelope.Date, - IsRead: hasSeenFlag(msg.Flags), - AccountID: account.ID, + UID: uint32(msg.UID), + From: fromAddr, + To: toAddrList, + ReplyTo: replyToAddrList, + Subject: decodeHeader(msg.Envelope.Subject), + Date: msg.Envelope.Date, + IsRead: hasSeenFlag(msg.Flags), + MessageID: msg.Envelope.MessageID, + InReplyTo: firstEnvelopeInReplyTo(msg.Envelope.InReplyTo), + References: headerMessageIDs(headerData, "References"), + AccountID: account.ID, }) } @@ -1442,7 +1482,7 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, // Delivery header section for matching auto-forwarded emails deliveryHeaderSection := &imap.FetchItemBodySection{ Specifier: imap.PartSpecifierHeader, - HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To"}, + HeaderFields: []string{"Delivered-To", "X-Forwarded-To", "X-Original-To", "References"}, Peek: true, } @@ -1507,14 +1547,18 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, continue } + headerData := msg.FindBodySection(deliveryHeaderSection) emails = append(emails, Email{ - UID: uint32(msg.UID), - From: fromAddr, - To: toAddrList, - Subject: decodeHeader(msg.Envelope.Subject), - Date: msg.Envelope.Date, - IsRead: hasSeenFlag(msg.Flags), - AccountID: account.ID, + UID: uint32(msg.UID), + From: fromAddr, + To: toAddrList, + Subject: decodeHeader(msg.Envelope.Subject), + Date: msg.Envelope.Date, + IsRead: hasSeenFlag(msg.Flags), + MessageID: msg.Envelope.MessageID, + InReplyTo: firstEnvelopeInReplyTo(msg.Envelope.InReplyTo), + References: headerMessageIDs(headerData, "References"), + AccountID: account.ID, }) } diff --git a/internal/threading/jwz.go b/internal/threading/jwz.go new file mode 100644 index 0000000..bd6c418 --- /dev/null +++ b/internal/threading/jwz.go @@ -0,0 +1,365 @@ +package threading + +import ( + "regexp" + "sort" + "strings" + "time" +) + +type EmailHeader struct { + ID string + InReplyTo string + References []string + Subject string + Date time.Time + EmailID string + Sender string +} + +type Thread struct { + Root *ThreadNode + LatestAt time.Time + Count int + Subject string + Senders []string +} + +type ThreadNode struct { + EmailID string + Children []*ThreadNode + Date time.Time + Sender string + Subject string +} + +type container struct { + id string + node *ThreadNode + parent *container + children []*container +} + +var messageIDRE = regexp.MustCompile(`<[^>]+>`) + +func Build(headers []EmailHeader) []Thread { + containers := make(map[string]*container) + ordered := make([]*container, 0, len(headers)) + + get := func(id string) *container { + if c := containers[id]; c != nil { + return c + } + c := &container{id: id} + containers[id] = c + ordered = append(ordered, c) + return c + } + + for _, h := range headers { + msgID := normalizeMessageID(h.ID) + if msgID == "" { + msgID = "email:" + h.EmailID + } + c := get(msgID) + if c.node != nil { + msgID = msgID + "#email:" + h.EmailID + c = get(msgID) + } + c.node = &ThreadNode{ + EmailID: h.EmailID, + Date: h.Date, + Sender: h.Sender, + Subject: h.Subject, + } + + var prev *container + refs := normalizeReferences(h.References) + for _, ref := range refs { + refc := get(ref) + if prev != nil { + link(prev, refc) + } + prev = refc + } + + parentID := normalizeMessageID(h.InReplyTo) + if parentID == "" && len(refs) > 0 { + parentID = refs[len(refs)-1] + } + if parentID != "" { + link(get(parentID), c) + } + } + + var roots []*container + for _, c := range ordered { + if c.parent == nil { + if root := prune(c); root != nil { + roots = append(roots, root) + } + } + } + roots = groupBySubject(roots) + + threads := make([]Thread, 0, len(roots)) + for _, root := range roots { + sortContainer(root) + thread := buildThread(root) + if thread.Count > 0 { + threads = append(threads, thread) + } + } + + sort.SliceStable(threads, func(i, j int) bool { + if !threads[i].LatestAt.Equal(threads[j].LatestAt) { + return threads[i].LatestAt.After(threads[j].LatestAt) + } + return threadKey(threads[i].Root) < threadKey(threads[j].Root) + }) + + return threads +} + +func normalizeReferences(refs []string) []string { + seen := make(map[string]bool) + var out []string + for _, ref := range refs { + for _, id := range extractMessageIDs(ref) { + if !seen[id] { + out = append(out, id) + seen[id] = true + } + } + } + return out +} + +func extractMessageIDs(s string) []string { + matches := messageIDRE.FindAllString(s, -1) + if len(matches) == 0 { + if id := normalizeMessageID(s); id != "" { + return []string{id} + } + return nil + } + ids := make([]string, 0, len(matches)) + for _, match := range matches { + if id := normalizeMessageID(match); id != "" { + ids = append(ids, id) + } + } + return ids +} + +func normalizeMessageID(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return "" + } + if matches := messageIDRE.FindAllString(id, -1); len(matches) > 0 { + id = matches[len(matches)-1] + } + id = strings.TrimSpace(id) + id = strings.TrimPrefix(id, "<") + id = strings.TrimSuffix(id, ">") + id = strings.TrimSpace(id) + return strings.ToLower(id) +} + +func link(parent, child *container) { + if parent == nil || child == nil || parent == child { + return + } + if child.parent != nil || child.hasDescendant(parent) { + return + } + child.parent = parent + for _, existing := range parent.children { + if existing == child { + return + } + } + parent.children = append(parent.children, child) +} + +func (c *container) hasDescendant(target *container) bool { + for _, child := range c.children { + if child == target || child.hasDescendant(target) { + return true + } + } + return false +} + +func prune(c *container) *container { + if c == nil { + return nil + } + var children []*container + for _, child := range c.children { + if pruned := prune(child); pruned != nil { + pruned.parent = c + children = append(children, pruned) + } + } + c.children = children + + if c.node != nil { + return c + } + switch len(c.children) { + case 0: + return nil + case 1: + child := c.children[0] + child.parent = c.parent + return child + default: + return c + } +} + +func groupBySubject(roots []*container) []*container { + subjects := make(map[string]*container) + var grouped []*container + for _, root := range roots { + subject := firstSubject(root) + if subject == "" { + grouped = append(grouped, root) + continue + } + if existing := subjects[subject]; existing != nil { + link(existing, root) + continue + } + subjects[subject] = root + grouped = append(grouped, root) + } + return grouped +} + +func firstSubject(c *container) string { + if c == nil { + return "" + } + if c.node != nil { + return canonicalSubject(c.node.Subject) + } + for _, child := range c.children { + if subject := firstSubject(child); subject != "" { + return subject + } + } + return "" +} + +func sortContainer(c *container) { + for _, child := range c.children { + sortContainer(child) + } + sort.SliceStable(c.children, func(i, j int) bool { + a, b := c.children[i], c.children[j] + ad, bd := containerDate(a), containerDate(b) + if !ad.Equal(bd) { + return ad.Before(bd) + } + return containerKey(a) < containerKey(b) + }) +} + +func buildThread(root *container) Thread { + node := toThreadNode(root) + thread := Thread{Root: node, Subject: canonicalSubject(firstDisplaySubject(node))} + seenSenders := make(map[string]bool) + walkThread(node, &thread, seenSenders) + return thread +} + +func toThreadNode(c *container) *ThreadNode { + node := &ThreadNode{} + if c.node != nil { + *node = *c.node + node.Children = nil + } + for _, child := range c.children { + node.Children = append(node.Children, toThreadNode(child)) + } + return node +} + +func walkThread(node *ThreadNode, thread *Thread, seenSenders map[string]bool) { + if node == nil { + return + } + if node.EmailID != "" { + thread.Count++ + if node.Date.After(thread.LatestAt) { + thread.LatestAt = node.Date + } + if node.Sender != "" && !seenSenders[node.Sender] { + thread.Senders = append(thread.Senders, node.Sender) + seenSenders[node.Sender] = true + } + } + for _, child := range node.Children { + walkThread(child, thread, seenSenders) + } +} + +func containerDate(c *container) time.Time { + if c == nil { + return time.Time{} + } + if c.node != nil { + return c.node.Date + } + var earliest time.Time + for _, child := range c.children { + date := containerDate(child) + if earliest.IsZero() || (!date.IsZero() && date.Before(earliest)) { + earliest = date + } + } + return earliest +} + +func containerKey(c *container) string { + if c == nil { + return "" + } + if c.node != nil && c.node.EmailID != "" { + return c.node.EmailID + } + return c.id +} + +func threadKey(n *ThreadNode) string { + if n == nil { + return "" + } + if n.EmailID != "" { + return n.EmailID + } + for _, child := range n.Children { + if key := threadKey(child); key != "" { + return key + } + } + return "" +} + +func firstDisplaySubject(node *ThreadNode) string { + if node == nil { + return "" + } + if node.Subject != "" { + return node.Subject + } + for _, child := range node.Children { + if subject := firstDisplaySubject(child); subject != "" { + return subject + } + } + return "" +} diff --git a/internal/threading/jwz_test.go b/internal/threading/jwz_test.go new file mode 100644 index 0000000..0b62ec8 --- /dev/null +++ b/internal/threading/jwz_test.go @@ -0,0 +1,154 @@ +package threading + +import ( + "reflect" + "testing" + "time" +) + +func TestBuildThreeMessageChain(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"}, + {ID: "", References: []string{"", ""}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Count != 3 { + t.Fatalf("got count %d, want 3", threads[0].Count) + } + if got := threads[0].Root.Children[0].Children[0].EmailID; got != "3" { + t.Fatalf("got chain leaf %q, want 3", got) + } +} + +func TestBuildForkedThread(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + children := threads[0].Root.Children + if len(children) != 2 { + t.Fatalf("got %d children, want 2", len(children)) + } + if children[0].EmailID != "2" || children[1].EmailID != "3" { + t.Fatalf("got child order %q, %q; want 2, 3", children[0].EmailID, children[1].EmailID) + } +} + +func TestBuildMissingParentPlaceholderRoot(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base, EmailID: "child"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "other"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Root.EmailID != "" { + t.Fatalf("got root EmailID %q, want placeholder", threads[0].Root.EmailID) + } + if len(threads[0].Root.Children) != 2 { + t.Fatalf("got %d placeholder children, want 2", len(threads[0].Root.Children)) + } +} + +func TestBuildSubjectFallbackGroupingForOrphans(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Re: Foo", Date: base, EmailID: "1"}, + {ID: "", Subject: "Fwd: foo", Date: base.Add(time.Minute), EmailID: "2"}, + {ID: "", Subject: "Bar", Date: base.Add(2 * time.Minute), EmailID: "3"}, + }) + + if len(threads) != 2 { + t.Fatalf("got %d threads, want 2", len(threads)) + } + var grouped Thread + for _, thread := range threads { + if thread.Subject == "foo" { + grouped = thread + break + } + } + if grouped.Count != 2 { + t.Fatalf("got grouped count %d, want 2", grouped.Count) + } +} + +func TestBuildSubjectFallbackGroupsLocalePrefixes(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1"}, + {ID: "", Subject: "SV: Foo", Date: base.Add(time.Minute), EmailID: "2"}, + {ID: "", Subject: "RV: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"}, + {ID: "", Subject: "Antw: Foo", Date: base.Add(3 * time.Minute), EmailID: "4"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Subject != "foo" { + t.Fatalf("got subject %q, want foo", threads[0].Subject) + } + if threads[0].Count != 4 { + t.Fatalf("got grouped count %d, want 4", threads[0].Count) + } +} + +func TestBuildEmptyReferencesList(t *testing.T) { + threads := Build([]EmailHeader{ + {ID: "", References: nil, Subject: "Foo", Date: time.Now(), EmailID: "1"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Root.EmailID != "1" { + t.Fatalf("got root %q, want 1", threads[0].Root.EmailID) + } +} + +func TestBuildStableOrderingAcrossCalls(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + headers := []EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1"}, + {ID: "", Subject: "Bar", Date: base, EmailID: "2"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base, EmailID: "3"}, + } + + first := Build(headers) + second := Build(headers) + if !reflect.DeepEqual(first, second) { + t.Fatalf("Build output differed across calls:\n%#v\n%#v", first, second) + } +} + +func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) { + tests := map[string]string{ + "Re: Re: Foo": "foo", + "Fwd: FW: Foo": "foo", + "AW: WG: Tr: Foo": "foo", + "Reé: Resp: Foo": "foo", + "SV: VS: RV: Foo": "foo", + "ENC: Antw: Foo": "foo", + "Odp: R: I: Foo": "foo", + " Foo ": "foo", + } + + for in, want := range tests { + if got := canonicalSubject(in); got != want { + t.Fatalf("canonicalSubject(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/threading/subject.go b/internal/threading/subject.go new file mode 100644 index 0000000..1a36dd5 --- /dev/null +++ b/internal/threading/subject.go @@ -0,0 +1,20 @@ +package threading + +import ( + "regexp" + "strings" +) + +var subjectPrefixRE = regexp.MustCompile(`(?i)^(Re|Fwd|Fw|AW|WG|Tr|Reé|Resp|SV|VS|RV|ENC|Antw|Odp|R|I)\s*:\s*`) + +func canonicalSubject(s string) string { + s = strings.TrimSpace(s) + for { + next := subjectPrefixRE.ReplaceAllString(s, "") + if next == s { + break + } + s = strings.TrimSpace(next) + } + return strings.ToLower(strings.TrimSpace(s)) +} diff --git a/main.go b/main.go index ad0d853..f84b368 100644 --- a/main.go +++ b/main.go @@ -2138,14 +2138,16 @@ func emailsToCache(emails []fetcher.Email) []config.CachedEmail { var cached []config.CachedEmail for _, email := range emails { cached = append(cached, config.CachedEmail{ - UID: email.UID, - From: email.From, - To: email.To, - Subject: email.Subject, - Date: email.Date, - MessageID: email.MessageID, - AccountID: email.AccountID, - IsRead: email.IsRead, + UID: email.UID, + From: email.From, + To: email.To, + Subject: email.Subject, + Date: email.Date, + MessageID: email.MessageID, + InReplyTo: email.InReplyTo, + References: email.References, + AccountID: email.AccountID, + IsRead: email.IsRead, }) } return cached @@ -2155,14 +2157,16 @@ func cacheToEmails(cached []config.CachedEmail) []fetcher.Email { var emails []fetcher.Email for _, c := range cached { emails = append(emails, fetcher.Email{ - UID: c.UID, - From: c.From, - To: c.To, - Subject: c.Subject, - Date: c.Date, - MessageID: c.MessageID, - AccountID: c.AccountID, - IsRead: c.IsRead, + UID: c.UID, + From: c.From, + To: c.To, + Subject: c.Subject, + Date: c.Date, + MessageID: c.MessageID, + InReplyTo: c.InReplyTo, + References: c.References, + AccountID: c.AccountID, + IsRead: c.IsRead, }) } return emails @@ -2190,14 +2194,16 @@ func saveEmailsToCache(emails []fetcher.Email) { var cachedEmails []config.CachedEmail for _, email := range emails { cachedEmails = append(cachedEmails, config.CachedEmail{ - UID: email.UID, - From: email.From, - To: email.To, - Subject: email.Subject, - Date: email.Date, - MessageID: email.MessageID, - AccountID: email.AccountID, - IsRead: email.IsRead, + UID: email.UID, + From: email.From, + To: email.To, + Subject: email.Subject, + Date: email.Date, + MessageID: email.MessageID, + InReplyTo: email.InReplyTo, + References: email.References, + AccountID: email.AccountID, + IsRead: email.IsRead, }) // Save sender as a contact diff --git a/public/assets/threading-remotion-demo.gif b/public/assets/threading-remotion-demo.gif new file mode 100644 index 0000000..92edf24 Binary files /dev/null and b/public/assets/threading-remotion-demo.gif differ diff --git a/public/assets/threading_demo.gif b/public/assets/threading_demo.gif new file mode 100644 index 0000000..6d719c7 Binary files /dev/null and b/public/assets/threading_demo.gif differ diff --git a/screenshots/cmd/threading_demo/main.go b/screenshots/cmd/threading_demo/main.go new file mode 100644 index 0000000..37d17fe --- /dev/null +++ b/screenshots/cmd/threading_demo/main.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + "os" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/tui" +) + +type wrapper struct { + inbox *tui.Inbox +} + +func (w wrapper) Init() tea.Cmd { + return w.inbox.Init() +} + +func (w wrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m, cmd := w.inbox.Update(msg) + if inbox, ok := m.(*tui.Inbox); ok { + w.inbox = inbox + } + return w, cmd +} + +func (w wrapper) View() tea.View { + v := w.inbox.View() + v.AltScreen = true + return v +} + +func main() { + now := time.Now() + account := config.Account{ + ID: "demo-user", + Name: "Matcha Demo", + Email: "demo@floatpane.com", + FetchEmail: "demo@floatpane.com", + } + + emails := []fetcher.Email{ + { + UID: 304, + From: "Priya Shah ", + To: []string{"demo@floatpane.com"}, + Subject: "Re: Release checklist for 1.8", + Date: now.Add(-8 * time.Minute), + MessageID: "", + References: []string{"", ""}, + AccountID: account.ID, + }, + { + UID: 303, + From: "Buildkite ", + To: []string{"demo@floatpane.com"}, + Subject: "main passed", + Date: now.Add(-20 * time.Minute), + MessageID: "", + AccountID: account.ID, + IsRead: true, + }, + { + UID: 302, + From: "Noah Reed ", + To: []string{"demo@floatpane.com"}, + Subject: "Re: Release checklist for 1.8", + Date: now.Add(-33 * time.Minute), + MessageID: "", + References: []string{""}, + AccountID: account.ID, + IsRead: true, + }, + { + UID: 301, + From: "Avery Stone ", + To: []string{"demo@floatpane.com"}, + Subject: "Release checklist for 1.8", + Date: now.Add(-52 * time.Minute), + MessageID: "", + AccountID: account.ID, + IsRead: true, + }, + { + UID: 300, + From: "Finance ", + To: []string{"demo@floatpane.com"}, + Subject: "Invoice approvals", + Date: now.Add(-2 * time.Hour), + MessageID: "", + AccountID: account.ID, + IsRead: true, + }, + } + + inbox := tui.NewInbox(emails, []config.Account{account}) + inbox.SetFolderName("INBOX") + + p := tea.NewProgram(wrapper{inbox: inbox}) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/screenshots/threading_demo.tape b/screenshots/threading_demo.tape new file mode 100644 index 0000000..e1b55b5 --- /dev/null +++ b/screenshots/threading_demo.tape @@ -0,0 +1,44 @@ +# VHS demo for threaded conversation view (#509) +# Shows: flat inbox (5 messages) -> threaded view (3 rows, thread root with count) -> expanded thread tree + +Output threading_demo.gif + +Set FontSize 14 +Set FontFamily "JetBrainsMono Nerd Font" +Set Width 1400 +Set Height 900 +Set Theme "Catppuccin Mocha" +Set Padding 20 +Set Framerate 30 +Set PlaybackSpeed 0.7 + +Set WindowBar Colorful +Set WindowBarSize 40 +Set BorderRadius 10 + +Sleep 500ms +Type "./bin/threading_demo" +Sleep 300ms +Enter + +# Boot - flat view, 5 emails listed +Sleep 4s + +# Toggle to threaded - now 3 rows (thread root collapses 3 messages) +Type "T" +Sleep 2500ms + +# Cursor is on row 1 which is the thread root +# Enter expands it +Enter +Sleep 3000ms + +# Enter again to collapse +Enter +Sleep 2000ms + +# Toggle back to flat +Type "T" +Sleep 2000ms + +Ctrl+c diff --git a/tui/inbox.go b/tui/inbox.go index 731150e..038d8d0 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -12,6 +12,7 @@ import ( "charm.land/lipgloss/v2" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/internal/threading" "github.com/floatpane/matcha/theme" ) @@ -38,6 +39,12 @@ type item struct { accountEmail string date time.Time isRead bool + threadKey string + threadCount int + threadRoot bool + threadChild bool + threadDepth int + expanded bool } func (i item) Title() string { return i.title } @@ -65,6 +72,13 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list statusStyle = readEmailStyle statusIcon = "\uf2b6" } + if i.threadRoot && i.threadCount > 1 { + if i.expanded { + statusIcon = "▾" + } else { + statusIcon = "▸" + } + } styledIcon := statusStyle.Render(statusIcon) styledSender := statusStyle.Render(sender) separator := " · " @@ -124,6 +138,12 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list subjectBudget := maxLeft - prefixWidth - iconWidth - senderWidth - sepWidth subject := i.title + if i.threadChild { + subject = strings.Repeat(" ", i.threadDepth) + "↳ " + subject + } + if i.threadRoot && i.threadCount > 1 { + subject = fmt.Sprintf("%s (%d)", subject, i.threadCount) + } if subjectBudget < 4 { subjectBudget = 4 } @@ -281,6 +301,8 @@ type Inbox struct { extraShortHelpKeys []key.Binding pluginStatus string // Persistent status text set by plugins pluginKeyBindings []PluginKeyBinding + threaded map[string]bool + expanded map[string]bool // Visual mode state (Vim-style multi-select) visualMode bool // Whether visual mode is active @@ -354,6 +376,8 @@ func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mail currentAccountID: "", emailCountByAcct: emailCountByAcct, mailbox: mailbox, + threaded: make(map[string]bool), + expanded: make(map[string]bool), visualMode: false, selectedUIDs: make(map[uint32]string), selectionOrder: []uint32{}, @@ -387,30 +411,7 @@ func (m *Inbox) updateList() { m.emailsCount = len(displayEmails) - items := make([]list.Item, len(displayEmails)) - for i, email := range displayEmails { - accountEmail := "" - if showAccountLabel { - // Find the account email for display - for _, acc := range m.accounts { - if acc.ID == email.AccountID { - accountEmail = acc.FetchEmail - break - } - } - } - - items[i] = item{ - title: email.Subject, - desc: email.From, - originalIndex: i, - uid: email.UID, - accountID: email.AccountID, - accountEmail: accountEmail, - date: email.Date, - isRead: email.IsRead, - } - } + items := m.itemsForEmails(displayEmails, showAccountLabel) l := list.New(items, itemDelegate{inbox: m}, 20, 14) l.Title = m.getTitle() @@ -423,6 +424,7 @@ func (m *Inbox) updateList() { l.AdditionalShortHelpKeys = func() []key.Binding { bindings := []key.Binding{ key.NewBinding(key.WithKeys("v"), key.WithHelp("v", t("inbox.visual_mode"))), + key.NewBinding(key.WithKeys(m.toggleThreadedKey()), key.WithHelp(m.toggleThreadedKey(), "threaded")), key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))), key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))), key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))), @@ -465,6 +467,100 @@ func (m *Inbox) updateList() { m.list = l } +func (m *Inbox) itemsForEmails(displayEmails []fetcher.Email, showAccountLabel bool) []list.Item { + if !m.isThreaded() { + items := make([]list.Item, len(displayEmails)) + for i, email := range displayEmails { + items[i] = m.itemForEmail(email, i, showAccountLabel) + } + return items + } + + emailIndex := make(map[string]int, len(displayEmails)) + headers := make([]threading.EmailHeader, 0, len(displayEmails)) + for i, email := range displayEmails { + id := inboxEmailID(email) + emailIndex[id] = i + headers = append(headers, threading.EmailHeader{ + ID: email.MessageID, + InReplyTo: email.InReplyTo, + References: email.References, + Subject: email.Subject, + Date: email.Date, + EmailID: id, + Sender: email.From, + }) + } + + var items []list.Item + for _, thread := range threading.Build(headers) { + key := threadItemKey(thread.Root) + root := firstEmailNode(thread.Root) + if root == nil { + continue + } + idx := emailIndex[root.EmailID] + rootEmail := displayEmails[idx] + latest := latestEmailNode(thread.Root) + if latest == nil { + latest = root + } + + rootItem := m.itemForEmail(rootEmail, idx, showAccountLabel) + rootItem.title = firstNonEmpty(root.Subject, thread.Subject) + rootItem.desc = latest.Sender + rootItem.date = thread.LatestAt + rootItem.isRead = threadRead(displayEmails, emailIndex, thread.Root) + rootItem.threadKey = key + rootItem.threadCount = thread.Count + rootItem.threadRoot = true + rootItem.expanded = m.expanded[key] + items = append(items, rootItem) + + if m.expanded[key] { + items = appendThreadChildren(items, m, displayEmails, emailIndex, showAccountLabel, thread.Root.Children, 1) + } + } + return items +} + +func appendThreadChildren(items []list.Item, m *Inbox, emails []fetcher.Email, emailIndex map[string]int, showAccountLabel bool, nodes []*threading.ThreadNode, depth int) []list.Item { + for _, node := range nodes { + if node.EmailID != "" { + idx := emailIndex[node.EmailID] + child := m.itemForEmail(emails[idx], idx, showAccountLabel) + child.threadChild = true + child.threadDepth = depth + items = append(items, child) + } + items = appendThreadChildren(items, m, emails, emailIndex, showAccountLabel, node.Children, depth+1) + } + return items +} + +func (m *Inbox) itemForEmail(email fetcher.Email, index int, showAccountLabel bool) item { + accountEmail := "" + if showAccountLabel { + for _, acc := range m.accounts { + if acc.ID == email.AccountID { + accountEmail = acc.FetchEmail + break + } + } + } + + return item{ + title: email.Subject, + desc: email.From, + originalIndex: index, + uid: email.UID, + accountID: email.AccountID, + accountEmail: accountEmail, + date: email.Date, + isRead: email.IsRead, + } +} + func (m *Inbox) getTitle() string { var title string if m.currentAccountID == "" { @@ -488,6 +584,9 @@ func (m *Inbox) getTitle() string { if m.isFetching { title += " (loading more...)" } + if m.isThreaded() { + title += " (threaded)" + } if m.pluginStatus != "" { title += " (" + m.pluginStatus + ")" } @@ -510,6 +609,47 @@ func (m *Inbox) getBaseTitle() string { } } +func (m *Inbox) folderKey() string { + if m.folderName != "" { + return m.folderName + } + return string(m.mailbox) +} + +func (m *Inbox) isThreaded() bool { + if m.threaded == nil { + m.threaded = make(map[string]bool) + } + if m.expanded == nil { + m.expanded = make(map[string]bool) + } + key := m.folderKey() + if _, ok := m.threaded[key]; !ok { + m.threaded[key] = config.IsFolderThreaded(key) + } + return m.threaded[key] +} + +func (m *Inbox) toggleThreaded() { + if m.threaded == nil { + m.threaded = make(map[string]bool) + } + key := m.folderKey() + next := !m.isThreaded() + m.threaded[key] = next + if !next { + m.expanded = make(map[string]bool) + } + _ = config.SetFolderThreaded(key, next) +} + +func (m *Inbox) toggleThreadedKey() string { + if config.Keybinds.Inbox.ToggleThreaded != "" { + return config.Keybinds.Inbox.ToggleThreaded + } + return "T" +} + func (m *Inbox) Init() tea.Cmd { return nil } @@ -531,6 +671,10 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } kb := config.Keybinds switch keypress := msg.String(); keypress { + case m.toggleThreadedKey(): + m.toggleThreaded() + m.updateList() + return m, nil case kb.Inbox.VisualMode: if !m.visualMode { // Enter visual mode @@ -621,7 +765,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { // Single delete selectedItem, ok := m.list.SelectedItem().(item) - if ok { + if ok && selectedItem.uid != 0 { return m, func() tea.Msg { return DeleteEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} } @@ -650,7 +794,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { // Single archive selectedItem, ok := m.list.SelectedItem().(item) - if ok { + if ok && selectedItem.uid != 0 { return m, func() tea.Msg { return ArchiveEmailMsg{UID: selectedItem.uid, AccountID: selectedItem.accountID, Mailbox: m.mailbox} } @@ -670,6 +814,14 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case kb.Inbox.Open: selectedItem, ok := m.list.SelectedItem().(item) if ok { + if selectedItem.threadRoot && selectedItem.threadCount > 1 { + m.expanded[selectedItem.threadKey] = !m.expanded[selectedItem.threadKey] + m.updateList() + return m, nil + } + if selectedItem.uid == 0 { + return m, nil + } idx := selectedItem.originalIndex uid := selectedItem.uid accountID := selectedItem.accountID @@ -943,6 +1095,9 @@ func (m *Inbox) updateVisualSelection() { firstAccountID := "" for i := start; i <= end && i < len(items); i++ { if itm, ok := items[i].(item); ok { + if itm.uid == 0 { + continue + } // Ensure all selected emails are from the same account (prevent cross-account batch ops) if firstAccountID == "" { firstAccountID = itm.accountID @@ -1038,7 +1193,7 @@ func (m *Inbox) SetSize(width, height int) { // SetFolderName sets a custom folder name for the inbox title. func (m *Inbox) SetFolderName(name string) { m.folderName = name - m.list.Title = m.getTitle() + m.updateList() } // SetPluginStatus sets a persistent status string from plugins, shown in the title. @@ -1084,3 +1239,85 @@ func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) { m.updateList() } + +func inboxEmailID(email fetcher.Email) string { + return fmt.Sprintf("%s:%d", email.AccountID, email.UID) +} + +func threadItemKey(node *threading.ThreadNode) string { + if node == nil { + return "" + } + if node.EmailID != "" { + return node.EmailID + } + for _, child := range node.Children { + if key := threadItemKey(child); key != "" { + return key + } + } + return "" +} + +func firstEmailNode(node *threading.ThreadNode) *threading.ThreadNode { + if node == nil { + return nil + } + if node.EmailID != "" { + return node + } + for _, child := range node.Children { + if first := firstEmailNode(child); first != nil { + return first + } + } + return nil +} + +func latestEmailNode(node *threading.ThreadNode) *threading.ThreadNode { + if node == nil { + return nil + } + var latest *threading.ThreadNode + if node.EmailID != "" { + latest = node + } + for _, child := range node.Children { + candidate := latestEmailNode(child) + if candidate == nil { + continue + } + if latest == nil || candidate.Date.After(latest.Date) || + (candidate.Date.Equal(latest.Date) && candidate.EmailID < latest.EmailID) { + latest = candidate + } + } + return latest +} + +func threadRead(emails []fetcher.Email, emailIndex map[string]int, node *threading.ThreadNode) bool { + if node == nil { + return true + } + read := true + if node.EmailID != "" { + if idx, ok := emailIndex[node.EmailID]; ok && !emails[idx].IsRead { + read = false + } + } + for _, child := range node.Children { + if !threadRead(emails, emailIndex, child) { + read = false + } + } + return read +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +}