Skip to content

Commit 2ffa955

Browse files
committed
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.
1 parent bcbf045 commit 2ffa955

17 files changed

Lines changed: 1148 additions & 117 deletions

File tree

backend/backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ type Email struct {
7171
Date time.Time
7272
IsRead bool
7373
MessageID string
74+
InReplyTo string
7475
References []string
7576
Attachments []Attachment
7677
AccountID string

backend/imap/imap.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ func toBackendEmails(emails []fetcher.Email) []backend.Email {
136136
Date: e.Date,
137137
IsRead: e.IsRead,
138138
MessageID: e.MessageID,
139+
InReplyTo: e.InReplyTo,
139140
References: e.References,
140141
Attachments: toBackendAttachments(e.Attachments),
141142
AccountID: e.AccountID,

backend/jmap/jmap.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,10 @@ func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend.
614614
if len(eml.MessageID) > 0 {
615615
e.MessageID = eml.MessageID[0]
616616
}
617+
if len(eml.InReplyTo) > 0 {
618+
e.InReplyTo = eml.InReplyTo[0]
619+
}
620+
e.References = append(e.References, eml.References...)
617621
return e
618622
}
619623

backend/pop3/pop3.go

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"io"
1616
"mime"
1717
"net/mail"
18+
"regexp"
1819
"strings"
1920
"time"
2021

@@ -27,6 +28,8 @@ import (
2728
"github.com/floatpane/matcha/sender"
2829
)
2930

31+
var pop3MessageIDRE = regexp.MustCompile(`<[^>]+>`)
32+
3033
func init() {
3134
backend.RegisterBackend("pop3", func(account *config.Account) (backend.Provider, error) {
3235
return New(account)
@@ -294,6 +297,8 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account
294297
subject := header.Get("Subject")
295298
dateStr := header.Get("Date")
296299
messageID := header.Get("Message-ID")
300+
inReplyTo := firstMessageID(header.Get("In-Reply-To"))
301+
references := messageIDList(header.Get("References"))
297302

298303
var to []string
299304
if toHeader := header.Get("To"); toHeader != "" {
@@ -335,16 +340,34 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account
335340
}
336341

337342
return backend.Email{
338-
UID: hashUID(uidStr),
339-
From: from,
340-
To: to,
341-
ReplyTo: replyTo,
342-
Subject: subject,
343-
Date: date,
344-
IsRead: false,
345-
MessageID: messageID,
346-
AccountID: accountID,
343+
UID: hashUID(uidStr),
344+
From: from,
345+
To: to,
346+
ReplyTo: replyTo,
347+
Subject: subject,
348+
Date: date,
349+
IsRead: false,
350+
MessageID: messageID,
351+
InReplyTo: inReplyTo,
352+
References: references,
353+
AccountID: accountID,
354+
}
355+
}
356+
357+
func firstMessageID(value string) string {
358+
ids := messageIDList(value)
359+
if len(ids) == 0 {
360+
return ""
361+
}
362+
return ids[0]
363+
}
364+
365+
func messageIDList(value string) []string {
366+
matches := pop3MessageIDRE.FindAllString(value, -1)
367+
if len(matches) == 0 {
368+
return strings.Fields(value)
347369
}
370+
return matches
348371
}
349372

350373
// parseMessageBody extracts the body text and attachments from a raw message.

config/cache.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@ import (
1111

1212
// CachedEmail stores essential email data for caching.
1313
type CachedEmail struct {
14-
UID uint32 `json:"uid"`
15-
From string `json:"from"`
16-
To []string `json:"to"`
17-
Subject string `json:"subject"`
18-
Date time.Time `json:"date"`
19-
MessageID string `json:"message_id"`
20-
AccountID string `json:"account_id"`
21-
IsRead bool `json:"is_read"`
14+
UID uint32 `json:"uid"`
15+
From string `json:"from"`
16+
To []string `json:"to"`
17+
Subject string `json:"subject"`
18+
Date time.Time `json:"date"`
19+
MessageID string `json:"message_id"`
20+
InReplyTo string `json:"in_reply_to,omitempty"`
21+
References []string `json:"references,omitempty"`
22+
AccountID string `json:"account_id"`
23+
IsRead bool `json:"is_read"`
2224
}
2325

2426
// EmailCache stores cached emails for all accounts.

config/default_keybinds.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
},
88
"inbox": {
99
"visual_mode": "v",
10+
"toggle_threaded": "T",
1011
"delete": "d",
1112
"archive": "a",
1213
"refresh": "r",

config/folder_cache.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import (
44
"encoding/json"
55
"os"
66
"path/filepath"
7+
"strconv"
78
"strings"
89
"time"
10+
11+
"github.com/floatpane/matcha/internal/threading"
912
)
1013

1114
// CachedFolders stores folder names for a single account.
@@ -17,8 +20,9 @@ type CachedFolders struct {
1720

1821
// FolderCache stores cached folders for all accounts.
1922
type FolderCache struct {
20-
Accounts []CachedFolders `json:"accounts"`
21-
UpdatedAt time.Time `json:"updated_at"`
23+
Accounts []CachedFolders `json:"accounts"`
24+
ThreadedFolders map[string]bool `json:"threaded_folders,omitempty"`
25+
UpdatedAt time.Time `json:"updated_at"`
2226
}
2327

2428
// folderCacheFile returns the full path to the folder cache file.
@@ -179,3 +183,55 @@ func LoadFolderEmailCache(folderName string) ([]CachedEmail, error) {
179183
}
180184
return cache.Emails, nil
181185
}
186+
187+
func LoadFolderEmailHeaders(folderName string) ([]threading.EmailHeader, error) {
188+
emails, err := LoadFolderEmailCache(folderName)
189+
if err != nil {
190+
return nil, err
191+
}
192+
headers := make([]threading.EmailHeader, 0, len(emails))
193+
for _, email := range emails {
194+
headers = append(headers, threading.EmailHeader{
195+
ID: email.MessageID,
196+
InReplyTo: email.InReplyTo,
197+
References: email.References,
198+
Subject: email.Subject,
199+
Date: email.Date,
200+
EmailID: cachedEmailID(email),
201+
Sender: email.From,
202+
})
203+
}
204+
return headers, nil
205+
}
206+
207+
func IsFolderThreaded(folderName string) bool {
208+
cache, err := LoadFolderCache()
209+
if err != nil || cache.ThreadedFolders == nil {
210+
return false
211+
}
212+
return cache.ThreadedFolders[folderName]
213+
}
214+
215+
func SetFolderThreaded(folderName string, threaded bool) error {
216+
cache, err := LoadFolderCache()
217+
if err != nil {
218+
cache = &FolderCache{}
219+
}
220+
if cache.ThreadedFolders == nil {
221+
cache.ThreadedFolders = make(map[string]bool)
222+
}
223+
if threaded {
224+
cache.ThreadedFolders[folderName] = true
225+
} else {
226+
delete(cache.ThreadedFolders, folderName)
227+
}
228+
return SaveFolderCache(cache)
229+
}
230+
231+
func cachedEmailID(email CachedEmail) string {
232+
return email.AccountID + ":" + formatUID(email.UID)
233+
}
234+
235+
func formatUID(uid uint32) string {
236+
return strconv.FormatUint(uint64(uid), 10)
237+
}

config/keybinds.go

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,14 @@ type GlobalKeys struct {
3333
}
3434

3535
type InboxKeys struct {
36-
VisualMode string `json:"visual_mode"`
37-
Delete string `json:"delete"`
38-
Archive string `json:"archive"`
39-
Refresh string `json:"refresh"`
40-
Open string `json:"open"`
41-
NextTab string `json:"next_tab"`
42-
PrevTab string `json:"prev_tab"`
36+
VisualMode string `json:"visual_mode"`
37+
ToggleThreaded string `json:"toggle_threaded"`
38+
Delete string `json:"delete"`
39+
Archive string `json:"archive"`
40+
Refresh string `json:"refresh"`
41+
Open string `json:"open"`
42+
NextTab string `json:"next_tab"`
43+
PrevTab string `json:"prev_tab"`
4344
}
4445

4546
type EmailKeys struct {
@@ -138,13 +139,14 @@ func ValidateKeybinds(kb KeybindsConfig) []string {
138139
"nav_down": kb.Global.NavDown,
139140
})
140141
check("inbox", map[string]string{
141-
"visual_mode": kb.Inbox.VisualMode,
142-
"delete": kb.Inbox.Delete,
143-
"archive": kb.Inbox.Archive,
144-
"refresh": kb.Inbox.Refresh,
145-
"open": kb.Inbox.Open,
146-
"next_tab": kb.Inbox.NextTab,
147-
"prev_tab": kb.Inbox.PrevTab,
142+
"visual_mode": kb.Inbox.VisualMode,
143+
"toggle_threaded": kb.Inbox.ToggleThreaded,
144+
"delete": kb.Inbox.Delete,
145+
"archive": kb.Inbox.Archive,
146+
"refresh": kb.Inbox.Refresh,
147+
"open": kb.Inbox.Open,
148+
"next_tab": kb.Inbox.NextTab,
149+
"prev_tab": kb.Inbox.PrevTab,
148150
})
149151
check("email", map[string]string{
150152
"reply": kb.Email.Reply,

daemon/daemon.go

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -352,14 +352,16 @@ func (d *Daemon) syncAllAccounts(ctx context.Context) {
352352
var cached []config.CachedEmail
353353
for _, e := range emails {
354354
cached = append(cached, config.CachedEmail{
355-
UID: e.UID,
356-
From: e.From,
357-
To: e.To,
358-
Subject: e.Subject,
359-
Date: e.Date,
360-
MessageID: e.MessageID,
361-
AccountID: e.AccountID,
362-
IsRead: e.IsRead,
355+
UID: e.UID,
356+
From: e.From,
357+
To: e.To,
358+
Subject: e.Subject,
359+
Date: e.Date,
360+
MessageID: e.MessageID,
361+
InReplyTo: e.InReplyTo,
362+
References: e.References,
363+
AccountID: e.AccountID,
364+
IsRead: e.IsRead,
363365
})
364366
}
365367
if err := d.updateFolderCache("INBOX", acct.ID, cached); err != nil {
@@ -459,14 +461,16 @@ func (d *Daemon) fetchAndCache(accountID, folder string) {
459461
var cached []config.CachedEmail
460462
for _, e := range emails {
461463
cached = append(cached, config.CachedEmail{
462-
UID: e.UID,
463-
From: e.From,
464-
To: e.To,
465-
Subject: e.Subject,
466-
Date: e.Date,
467-
MessageID: e.MessageID,
468-
AccountID: e.AccountID,
469-
IsRead: e.IsRead,
464+
UID: e.UID,
465+
From: e.From,
466+
To: e.To,
467+
Subject: e.Subject,
468+
Date: e.Date,
469+
MessageID: e.MessageID,
470+
InReplyTo: e.InReplyTo,
471+
References: e.References,
472+
AccountID: e.AccountID,
473+
IsRead: e.IsRead,
470474
})
471475
}
472476

0 commit comments

Comments
 (0)