From 7056d8f19c2d4c4dfa8e90db1cbaaa75050ae868 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:50:46 -0700 Subject: [PATCH 1/4] feat(threading): client-side JWZ threading with collapse/expand Adds an internal/threading package implementing the Jamie Zawinski threading algorithm (Message-ID + In-Reply-To + References) with subject-fallback grouping for orphans. The inbox renders one row per thread root with a count and last sender; pressing Enter toggles expand/collapse; the per-folder flat-vs-threaded mode persists via folder_cache. The MessageID/InReplyTo/References metadata is now carried through fetcher and the IMAP/JMAP/POP3 backends, the on-disk email cache, the daemon RPC types, and the inbox model so threading works against cached headers without server round-trips. Per the maintainer's spec in #509 and #1130: client-side, provider-agnostic, JWZ rather than X-GM-THRID, deterministic ordering. - internal/threading/jwz.go: ThreadNode, Thread, Build() - internal/threading/subject.go: canonicalSubject() - internal/threading/jwz_test.go: chains, forks, missing parents, subject-fallback grouping, deterministic ordering - tui/inbox.go: threaded mode rendering + 'T' toggle + expand/collapse - config/folder_cache.go: persist threaded toggle per folder - backend/{imap,jmap,pop3}: emit MessageID/InReplyTo/References - screenshots/cmd/threading_demo: VHS helper Closes #509. Addresses #1130. --- backend/backend.go | 1 + backend/imap/imap.go | 1 + backend/jmap/jmap.go | 4 + backend/pop3/pop3.go | 41 ++- config/cache.go | 18 +- config/default_keybinds.json | 1 + config/folder_cache.go | 60 +++- config/keybinds.go | 38 +-- daemon/daemon.go | 36 +-- fetcher/fetcher.go | 78 ++++-- internal/threading/jwz.go | 365 +++++++++++++++++++++++++ internal/threading/jwz_test.go | 131 +++++++++ internal/threading/subject.go | 20 ++ main.go | 54 ++-- screenshots/cmd/threading_demo/main.go | 107 ++++++++ screenshots/threading_demo.tape | 27 ++ tui/inbox.go | 280 +++++++++++++++++-- 17 files changed, 1147 insertions(+), 115 deletions(-) create mode 100644 internal/threading/jwz.go create mode 100644 internal/threading/jwz_test.go create mode 100644 internal/threading/subject.go create mode 100644 screenshots/cmd/threading_demo/main.go create mode 100644 screenshots/threading_demo.tape diff --git a/backend/backend.go b/backend/backend.go index 2e7e63b4..580ca511 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -80,6 +80,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 bb6141eb..11d311d0 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -144,6 +144,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 5485ba5c..0462f193 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -695,6 +695,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 ffec6c97..daf5efb7 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) @@ -298,6 +301,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 != "" { @@ -339,16 +344,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 13a941fb..24f1542c 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 8d72b19a..6a325ef6 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 d4f916c5..71036304 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 034a8d81..a9053f56 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -33,15 +33,16 @@ type GlobalKeys struct { } type InboxKeys struct { - VisualMode string `json:"visual_mode"` - Delete string `json:"delete"` - Archive string `json:"archive"` - Refresh string `json:"refresh"` - Search string `json:"search"` - Filter string `json:"filter"` - 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"` + Search string `json:"search"` + Filter string `json:"filter"` + Open string `json:"open"` + NextTab string `json:"next_tab"` + PrevTab string `json:"prev_tab"` } type EmailKeys struct { @@ -140,15 +141,16 @@ 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, - "search": kb.Inbox.Search, - "filter": kb.Inbox.Filter, - "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, + "search": kb.Inbox.Search, + "filter": kb.Inbox.Filter, + "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 f4833ea8..ac194ee1 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -360,14 +360,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 { @@ -474,14 +476,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 b36345a0..6ec145d1 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 @@ -167,6 +171,38 @@ func deliveryHeadersMatch(data []byte, fetchEmail string, account *config.Accoun 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) { contentType := header.Get("Content-Type") mediaType, params, parseErr := mime.ParseMediaType(contentType) @@ -456,7 +492,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, } @@ -542,15 +578,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, }) } @@ -1494,7 +1534,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, } @@ -1563,14 +1603,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 00000000..bd6c418c --- /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 00000000..6446051b --- /dev/null +++ b/internal/threading/jwz_test.go @@ -0,0 +1,131 @@ +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 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", + " 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 00000000..318097b6 --- /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)\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 87a83b09..cb4f212a 100644 --- a/main.go +++ b/main.go @@ -2277,14 +2277,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 @@ -2294,14 +2296,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 @@ -2329,14 +2333,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/screenshots/cmd/threading_demo/main.go b/screenshots/cmd/threading_demo/main.go new file mode 100644 index 00000000..37d17fe5 --- /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 00000000..af18d0d5 --- /dev/null +++ b/screenshots/threading_demo.tape @@ -0,0 +1,27 @@ +# Screenshot: Threaded Conversation View +# Shows flat inbox, threaded collapsed root, then expanded thread tree + +Output screenshots/threading_demo.gif + +Set FontSize 14 +Set FontFamily "JetBrainsMono Nerd Font" +Set Width 1400 +Set Height 800 +Set Theme "Catppuccin Mocha" +Set Padding 20 +Set WindowBar Colorful +Set WindowBarSize 40 +Set BorderRadius 10 + +Hide +Type "go run ./screenshots/cmd/threading_demo" +Enter +Show + +Sleep 1s +Type "T" +Sleep 1s +Enter +Sleep 1s + +Screenshot screenshots/threading_demo.png diff --git a/tui/inbox.go b/tui/inbox.go index 9faa01c4..5d87aed6 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -13,6 +13,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" ) @@ -39,6 +40,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 } @@ -80,6 +87,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 := " · " @@ -139,6 +153,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 } @@ -300,6 +320,8 @@ type Inbox struct { searchActive bool searchQuery string searchResults []fetcher.Email + threaded map[string]bool + expanded map[string]bool // Visual mode state (Vim-style multi-select) visualMode bool // Whether visual mode is active @@ -370,6 +392,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{}, @@ -402,24 +426,7 @@ func (m *Inbox) updateList() { showAccountLabel = true } - items := make([]list.Item, len(displayEmails)) - for i, email := range displayEmails { - accountEmail := "" - if showAccountLabel { - accountEmail = m.accountLabelForEmail(email) - } - - 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() @@ -432,6 +439,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"))), @@ -600,6 +608,95 @@ func extractEmailAddress(value string) string { return strings.Trim(value, "<>") } +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 { + accountEmail = m.accountLabelForEmail(email) + } + + 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.searchActive { @@ -625,6 +722,9 @@ func (m *Inbox) getTitle() string { if m.isFetching { title += " (loading more...)" } + if m.isThreaded() { + title += " (threaded)" + } if m.pluginStatus != "" { title += " (" + m.pluginStatus + ")" } @@ -647,6 +747,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 } @@ -680,6 +821,10 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case searchBinding: m.searchOverlay = NewSearchOverlay(m.width, m.height) return m, m.searchOverlay.Init() + case m.toggleThreadedKey(): + m.toggleThreaded() + m.updateList() + return m, nil case kb.Inbox.VisualMode: if !m.visualMode { // Enter visual mode @@ -777,7 +922,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} } @@ -806,7 +951,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} } @@ -826,6 +971,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 @@ -1134,6 +1287,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 @@ -1229,7 +1385,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. @@ -1276,3 +1432,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 "" +} From b41d7909b6ce73666af957466877670de51173fe Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:04:06 -0700 Subject: [PATCH 2/4] fix(threading): address self-review findings - backend/jmap: Email/get now requests inReplyTo and references alongside messageId so JMAP-backed accounts thread by real References/In-Reply-To rather than falling through to subject grouping - internal/threading/subject: add Swedish/Norwegian/Danish (SV), Finnish (VS), Spanish (RV), Portuguese (ENC), Dutch (Antw), Polish (Odp), and Italian (R/I) reply/forward prefixes - internal/threading/jwz_test: regression coverage for SV/RV/Antw subject-fallback grouping --- backend/jmap/jmap.go | 6 +++++- internal/threading/jwz_test.go | 23 +++++++++++++++++++++++ internal/threading/subject.go | 2 +- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 0462f193..69240eaf 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -165,7 +165,11 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u Name: "Email/query", Path: "/ids", }, - Properties: []string{"id", "subject", "from", "to", "replyTo", "receivedAt", "preview", "keywords", "mailboxIds", "hasAttachment", "messageId"}, + Properties: []string{ + "id", "subject", "from", "to", "replyTo", "receivedAt", + "preview", "keywords", "mailboxIds", "hasAttachment", + "messageId", "inReplyTo", "references", + }, }) resp, err := p.client.Do(req) diff --git a/internal/threading/jwz_test.go b/internal/threading/jwz_test.go index 6446051b..0b62ec8a 100644 --- a/internal/threading/jwz_test.go +++ b/internal/threading/jwz_test.go @@ -86,6 +86,26 @@ func TestBuildSubjectFallbackGroupingForOrphans(t *testing.T) { } } +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"}, @@ -120,6 +140,9 @@ func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) { "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", } diff --git a/internal/threading/subject.go b/internal/threading/subject.go index 318097b6..1a36dd55 100644 --- a/internal/threading/subject.go +++ b/internal/threading/subject.go @@ -5,7 +5,7 @@ import ( "strings" ) -var subjectPrefixRE = regexp.MustCompile(`(?i)^(Re|Fwd|Fw|AW|WG|Tr|Reé|Resp)\s*:\s*`) +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) From f99d64957bc8f6951a9f2918ca1240018e2d150f Mon Sep 17 00:00:00 2001 From: Andriy Chernov Date: Thu, 7 May 2026 13:05:14 +0400 Subject: [PATCH 3/4] fix: option in the settings, documentation Signed-off-by: drew --- config/config.go | 1 + config/folder_cache.go | 20 ++++--- docs/docs/Features/Keybinds.md | 3 ++ docs/docs/Features/THREADED_VIEW.md | 82 +++++++++++++++++++++++++++++ i18n/locales/ar.json | 1 + i18n/locales/de.json | 1 + i18n/locales/en.json | 1 + i18n/locales/es.json | 1 + i18n/locales/fr.json | 1 + i18n/locales/ja.json | 1 + i18n/locales/pl.json | 1 + i18n/locales/pt.json | 1 + i18n/locales/ru.json | 1 + i18n/locales/uk.json | 1 + i18n/locales/zh.json | 1 + main.go | 4 ++ tui/folder_inbox.go | 7 +++ tui/inbox.go | 13 ++++- tui/settings_general.go | 11 ++-- 19 files changed, 140 insertions(+), 12 deletions(-) create mode 100644 docs/docs/Features/THREADED_VIEW.md diff --git a/config/config.go b/config/config.go index 0e6bb581..622f6e7b 100644 --- a/config/config.go +++ b/config/config.go @@ -91,6 +91,7 @@ type Config struct { HideTips bool `json:"hide_tips,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"` EnableSplitPane bool `json:"enable_split_pane,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` diff --git a/config/folder_cache.go b/config/folder_cache.go index 71036304..222cc1ec 100644 --- a/config/folder_cache.go +++ b/config/folder_cache.go @@ -204,14 +204,22 @@ func LoadFolderEmailHeaders(folderName string) ([]threading.EmailHeader, error) return headers, nil } -func IsFolderThreaded(folderName string) bool { +// IsFolderThreaded returns the threading state for a folder. If the user has +// explicitly toggled threading for this folder, that override is returned. +// Otherwise defaultEnabled (from Config.EnableThreaded) is used. +func IsFolderThreaded(folderName string, defaultEnabled bool) bool { cache, err := LoadFolderCache() if err != nil || cache.ThreadedFolders == nil { - return false + return defaultEnabled } - return cache.ThreadedFolders[folderName] + v, ok := cache.ThreadedFolders[folderName] + if !ok { + return defaultEnabled + } + return v } +// SetFolderThreaded stores an explicit per-folder threading override. func SetFolderThreaded(folderName string, threaded bool) error { cache, err := LoadFolderCache() if err != nil { @@ -220,11 +228,7 @@ func SetFolderThreaded(folderName string, threaded bool) error { if cache.ThreadedFolders == nil { cache.ThreadedFolders = make(map[string]bool) } - if threaded { - cache.ThreadedFolders[folderName] = true - } else { - delete(cache.ThreadedFolders, folderName) - } + cache.ThreadedFolders[folderName] = threaded return SaveFolderCache(cache) } diff --git a/docs/docs/Features/Keybinds.md b/docs/docs/Features/Keybinds.md index e4b6d11f..544697af 100644 --- a/docs/docs/Features/Keybinds.md +++ b/docs/docs/Features/Keybinds.md @@ -22,9 +22,12 @@ Plain text, not encrypted. Edit with any text editor. Restart matcha to apply ch }, "inbox": { "visual_mode": "v", + "toggle_threaded": "T", "delete": "d", "archive": "a", "refresh": "r", + "search": "/", + "filter": "f", "open": "enter", "next_tab": "l", "prev_tab": "h" diff --git a/docs/docs/Features/THREADED_VIEW.md b/docs/docs/Features/THREADED_VIEW.md new file mode 100644 index 00000000..5316d84b --- /dev/null +++ b/docs/docs/Features/THREADED_VIEW.md @@ -0,0 +1,82 @@ +--- +title: Threaded View +sidebar_position: 13 +--- + +# Threaded Conversation View + +Matcha can group related emails into conversations using the JWZ threading +algorithm (the same approach used by mutt and other classic mail clients). +Replies, forwards, and quoted threads collapse under their root message so an +inbox of 200 individual messages can render as 30 conversations. + +## Enabling threaded view + +There are three ways to control threading: + +### 1. Settings menu (global default) + +- Press `Esc` from the inbox to open the main menu. +- Open **Settings** → **General**. +- Toggle **Threaded Conversation View** to ON. + +This sets the default for every folder. New folders without an explicit +override inherit this default immediately. + +### 2. Configuration file + +Edit `~/.config/matcha/config.json` and add: + +```json +{ + "enable_threaded": true +} +``` + +### 3. Keybind (per-folder override) + +Press `T` (configurable as `inbox.toggle_threaded` in `keybinds.json`) from any +inbox view to toggle threading **for the current folder only**. The override is +persisted in the folder cache and survives restarts. + +A per-folder override always wins over the global default. To return a folder +to the default, toggle it back to match the default value. + +## Using threaded view + +When threading is enabled the email list shows the root message of each +conversation with a count of replies. The default state is collapsed. + +| Key | Action | +| -------- | --------------------------------------- | +| `T` | Toggle threaded view for the folder | +| `enter` | Open the focused message | +| `space` | Expand or collapse the focused thread | +| `j`/`k` | Navigate threads or messages within | + +Visual mode (`v`), delete (`d`), archive (`a`), and the other inbox keybinds +behave the same as in flat view — operations applied to a collapsed thread +target the root message; expand the thread first to act on a single reply. + +## How threading works + +Matcha threads emails entirely on the client. Threading uses: + +1. `Message-ID`, `In-Reply-To`, and `References` headers (RFC 5322). +2. A subject-based fallback that strips `Re:`, `Fwd:`, and locale-specific + prefixes when reply headers are missing. + +Threading is recomputed whenever the email cache changes for a folder, so new +mail slots into existing conversations without a manual refresh. + +## Per-folder overrides + +The setting is split into two layers: + +- **Global default** — `Config.EnableThreaded` in `config.json`. +- **Per-folder override** — stored in `folder_cache.json` under + `threaded_folders`. Only folders the user has explicitly toggled appear here. + +If you change the global default in settings, every folder without an override +flips to the new default on the next render. Folders with an override keep +their explicit value until toggled again. diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index c1de1c1b..7322919b 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -151,6 +151,7 @@ "hide_tips": "إخفاء النصائح السياقية", "disable_notifications": "تعطيل الإشعارات", "enable_split_pane": "عرض مقسم", + "enable_threaded": "عرض المحادثات", "date_format": "تنسيق التاريخ", "language": "اللغة", "signature": "تعديل التوقيع", diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 2d5f9012..0ef214d9 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -147,6 +147,7 @@ "hide_tips": "Kontextuelle Tipps Ausblenden", "disable_notifications": "Benachrichtigungen Deaktivieren", "enable_split_pane": "Geteilte Ansicht", + "enable_threaded": "Konversations-Threads", "date_format": "Datumsformat", "language": "Sprache", "signature": "Signatur Bearbeiten", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index f101d960..7d669eca 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -147,6 +147,7 @@ "hide_tips": "Hide Contextual Tips", "disable_notifications": "Disable Notifications", "enable_split_pane": "Split Pane View", + "enable_threaded": "Threaded Conversation View", "date_format": "Date Format", "language": "Language", "signature": "Edit Signature", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 0fef00a1..ce21590d 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -147,6 +147,7 @@ "hide_tips": "Ocultar Consejos Contextuales", "disable_notifications": "Deshabilitar Notificaciones", "enable_split_pane": "Vista dividida", + "enable_threaded": "Vista de conversación", "date_format": "Formato de Fecha", "language": "Idioma", "signature": "Editar Firma", diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index d75d3fd5..ae1c10c1 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -147,6 +147,7 @@ "hide_tips": "Masquer les Conseils Contextuels", "disable_notifications": "Désactiver les Notifications", "enable_split_pane": "Vue divisée", + "enable_threaded": "Vue par conversation", "date_format": "Format de Date", "language": "Langue", "signature": "Modifier la Signature", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index c9b720b9..b4d2a7d8 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -145,6 +145,7 @@ "hide_tips": "コンテキストヒントを非表示", "disable_notifications": "通知を無効化", "enable_split_pane": "分割ビュー", + "enable_threaded": "スレッド表示", "date_format": "日付形式", "language": "言語", "signature": "署名を編集", diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index 2113edc5..e88fe43e 100644 --- a/i18n/locales/pl.json +++ b/i18n/locales/pl.json @@ -151,6 +151,7 @@ "hide_tips": "Ukryj Wskazówki Kontekstowe", "disable_notifications": "Wyłącz Powiadomienia", "enable_split_pane": "Widok podzielony", + "enable_threaded": "Widok wątków", "date_format": "Format Daty", "language": "Język", "signature": "Edytuj Podpis", diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index 97d24e7d..4587a052 100644 --- a/i18n/locales/pt.json +++ b/i18n/locales/pt.json @@ -147,6 +147,7 @@ "hide_tips": "Ocultar Dicas Contextuais", "disable_notifications": "Desativar Notificações", "enable_split_pane": "Vista dividida", + "enable_threaded": "Vista de conversação", "date_format": "Formato de Data", "language": "Idioma", "signature": "Editar Assinatura", diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index da64ae1e..307a5815 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -151,6 +151,7 @@ "hide_tips": "Скрыть Контекстные Подсказки", "disable_notifications": "Отключить Уведомления", "enable_split_pane": "Разделённый вид", + "enable_threaded": "Просмотр беседами", "date_format": "Формат Даты", "language": "Язык", "signature": "Редактировать Подпись", diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index 7ad0961e..e10b3b5e 100644 --- a/i18n/locales/uk.json +++ b/i18n/locales/uk.json @@ -149,6 +149,7 @@ "hide_tips": "Приховати контекстні підказки", "disable_notifications": "Вимкнути сповіщення", "enable_split_pane": "Розділений вигляд", + "enable_threaded": "Перегляд розмов", "date_format": "Формат дати", "language": "Мова", "signature": "Редагувати підпис", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 1fcaa050..a144819f 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -145,6 +145,7 @@ "hide_tips": "隐藏上下文提示", "disable_notifications": "禁用通知", "enable_split_pane": "分屏视图", + "enable_threaded": "会话视图", "date_format": "日期格式", "language": "语言", "signature": "编辑签名", diff --git a/main.go b/main.go index cb4f212a..7aaca7e9 100644 --- a/main.go +++ b/main.go @@ -465,6 +465,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.folderInbox = tui.NewFolderInbox(cachedFolders, m.config.Accounts) m.folderInbox.SetDateFormat(m.config.GetDateFormat()) + m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded) // Use cached INBOX emails for instant display (memory first, then disk) if cached, ok := m.folderEmails["INBOX"]; ok && len(cached) > 0 { m.folderInbox.SetEmails(cached, m.config.Accounts) @@ -1018,6 +1019,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { log.Printf("config reload: %v", err) } } + if m.folderInbox != nil { + m.folderInbox.SetDefaultThreaded(m.config.EnableThreaded) + } return m, nil case tui.LanguageChangedMsg: diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 7e0aa99c..2969fba3 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -133,6 +133,13 @@ func (m *FolderInbox) SetDateFormat(layout string) { } } +// SetDefaultThreaded propagates the global default threading toggle. +func (m *FolderInbox) SetDefaultThreaded(v bool) { + if m.inbox != nil { + m.inbox.SetDefaultThreaded(v) + } +} + // NewFolderInbox creates a new FolderInbox with the given folders and accounts. func NewFolderInbox(folders []string, accounts []config.Account) *FolderInbox { folders = sortFolders(folders) diff --git a/tui/inbox.go b/tui/inbox.go index 5d87aed6..0477271f 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -322,6 +322,7 @@ type Inbox struct { searchResults []fetcher.Email threaded map[string]bool expanded map[string]bool + defaultThreaded bool // Visual mode state (Vim-style multi-select) visualMode bool // Whether visual mode is active @@ -754,6 +755,16 @@ func (m *Inbox) folderKey() string { return string(m.mailbox) } +// SetDefaultThreaded sets the global default threading state used when no +// per-folder override exists. Pass Config.EnableThreaded. +func (m *Inbox) SetDefaultThreaded(v bool) { + m.defaultThreaded = v + // Drop the in-memory cache so the new default takes effect for folders + // without an explicit override on the next render. + m.threaded = nil + m.expanded = nil +} + func (m *Inbox) isThreaded() bool { if m.threaded == nil { m.threaded = make(map[string]bool) @@ -763,7 +774,7 @@ func (m *Inbox) isThreaded() bool { } key := m.folderKey() if _, ok := m.threaded[key]; !ok { - m.threaded[key] = config.IsFolderThreaded(key) + m.threaded[key] = config.IsFolderThreaded(key, m.defaultThreaded) } return m.threaded[key] } diff --git a/tui/settings_general.go b/tui/settings_general.go index 9a066511..b6618956 100644 --- a/tui/settings_general.go +++ b/tui/settings_general.go @@ -23,6 +23,7 @@ func (m *Settings) buildGeneralOptions() []generalOption { {"settings_general.hide_tips", onOff(m.cfg.HideTips), "Hide helpful hints displayed at the bottom of the screen.", false, ""}, {"settings_general.disable_notifications", onOff(m.cfg.DisableNotifications), "Turn off desktop notifications for new mail.", false, ""}, {"settings_general.enable_split_pane", onOff(m.cfg.EnableSplitPane), "View inbox and email side-by-side.", false, ""}, + {"settings_general.enable_threaded", onOff(m.cfg.EnableThreaded), "Group emails into conversations by reply chain. Per-folder overrides are kept.", false, ""}, {"settings_general.date_format", getDateFormatLabel(m.cfg.DateFormat), "Change how dates and times are displayed.", false, ""}, {"settings_general.language", getLanguageLabel(m.cfg.GetLanguage()), "Change the interface language. Changes apply instantly.", false, ""}, {"settings_general.signature", getSignatureStatus(), "Configure the global signature appended to your outgoing emails.", false, ""}, @@ -86,7 +87,11 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cfg.EnableSplitPane = !m.cfg.EnableSplitPane _ = config.SaveConfig(m.cfg) saved = true - case 4: // Date Format + case 4: // Threaded Conversation View + m.cfg.EnableThreaded = !m.cfg.EnableThreaded + _ = config.SaveConfig(m.cfg) + saved = true + case 5: // Date Format switch m.cfg.DateFormat { case config.DateFormatEU: m.cfg.DateFormat = config.DateFormatUS @@ -97,7 +102,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } _ = config.SaveConfig(m.cfg) saved = true - case 5: // Language + case 6: // Language // Cycle through available languages langs := i18n.LanguageCodes() currentLang := m.cfg.GetLanguage() @@ -118,7 +123,7 @@ func (m *Settings) updateGeneral(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func() tea.Msg { return ConfigSavedMsg{} }, func() tea.Msg { return LanguageChangedMsg{} }, ) - case 6: // Edit Signature + case 7: // Edit Signature if msg.String() == "enter" || msg.String() == "right" || msg.String() == "l" { return m, func() tea.Msg { return GoToSignatureEditorMsg{} } } From fdf1ca4a83b095e52ad9bb600a0696dfa5385aed Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 7 May 2026 13:16:26 +0400 Subject: [PATCH 4/4] save config Signed-off-by: drew --- config/config.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/config.go b/config/config.go index 7db45434..d3c860df 100644 --- a/config/config.go +++ b/config/config.go @@ -399,9 +399,11 @@ type secureDiskConfig struct { HideTips bool `json:"hide_tips,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"` EnableSplitPane bool `json:"enable_split_pane,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` + Language string `json:"language,omitempty"` } // SaveConfig saves the given configuration to the config file and passwords to the keyring. @@ -544,6 +546,7 @@ func LoadConfig() (*Config, error) { HideTips bool `json:"hide_tips,omitempty"` DisableNotifications bool `json:"disable_notifications,omitempty"` EnableSplitPane bool `json:"enable_split_pane,omitempty"` + EnableThreaded bool `json:"enable_threaded,omitempty"` Theme string `json:"theme,omitempty"` MailingLists []MailingList `json:"mailing_lists,omitempty"` DateFormat string `json:"date_format,omitempty"` @@ -580,6 +583,7 @@ func LoadConfig() (*Config, error) { config.HideTips = raw.HideTips config.DisableNotifications = raw.DisableNotifications config.EnableSplitPane = raw.EnableSplitPane + config.EnableThreaded = raw.EnableThreaded config.Theme = raw.Theme config.MailingLists = raw.MailingLists config.DateFormat = raw.DateFormat