Skip to content

Commit 726899a

Browse files
mvanhornandriy-chernovandrinoff
authored
feat(threading): JWZ conversation view (#1188)
## What? - Add `internal/threading` package implementing the Jamie Zawinski threading algorithm against `Message-ID` / `In-Reply-To` / `References` headers, with subject-fallback grouping for orphans - Carry `MessageID`, `InReplyTo`, and `References` through fetcher, 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 - Inbox renders threaded mode with one row per thread root, showing the count and last-sender; `Enter` toggles expand/collapse; expanded children render indented with `↪` markers - `T` keybind toggles flat vs threaded for the current folder; the per-folder mode persists via `folder_cache.go` - Subject canonicalization handles `Re:`, `Fwd:`, `Fw:`, `AW:`, `WG:`, `Tr:` (lowercased, stripped repeatedly so `Re: Re: Foo` -> `foo`) - Tests cover: 3-message chains, forks, missing-parent placeholders, subject-fallback grouping, empty References, deterministic ordering across repeated `Build()` calls - VHS demo (`screenshots/cmd/threading_demo` + `screenshots/threading_demo.tape`): flat (5 emails) → threaded (3 rows with `(3)` count on the root) → expanded (5 rows with `↪` on children) → collapsed → flat ## Why? This is the maintainer's spec from issue #509 and the more detailed #1130: > "Group emails into conversation threads using `In-Reply-To` and `References` headers (RFC 5322). Display threads as collapsible groups in the inbox, showing the latest message and a count of messages in the thread." > "Build threads with the Jamie Zawinski algorithm (the one Thunderbird uses) so we don't have to rely on `X-GM-THRID`. Threading should be done client-side from the cached header set so it works across providers." The framing in #1130 is the user-visible argument: "Showing each reply as a separate inbox row is how Mutt looked in 1999. Modern terminal clients (aerc, himalaya) all thread." The launch threads on r/coolgithubprojects + r/CLI + r/selfhosted (cumulative 161 upvotes, 32 comments) consistently flagged conversation grouping as the gap users notice first when comparing matcha to gmail/superhuman/aerc. ## Notes - Touches `main.go` (alongside in-flight #845 and #686). Conflicts should be mechanical - the threading wiring in `main.go` is small (cache-conversion paths to carry References/InReplyTo). Happy to rebase or stack PRs. - Ordering ties in JWZ are broken on `EmailID` so `Build()` is deterministic across runs. - The implementation deliberately avoids `X-GM-THRID` and IMAP THREAD (RFC 5256) per the spec - threading is purely client-side over cached envelope data. - Out of scope: per-thread mark-as-read propagation rules (kept current behavior); thread-aware archive/delete (uses single-message semantics for now). Closes #509. Addresses #1130. This contribution was developed with AI assistance. --------- Signed-off-by: drew <me@andrinoff.com> Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Andriy Chernov <andriy@floatpane.com> Co-authored-by: drew <me@andrinoff.com>
1 parent c94e714 commit 726899a

33 files changed

Lines changed: 1310 additions & 119 deletions

backend/backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type Email struct {
8282
Date time.Time
8383
IsRead bool
8484
MessageID string
85+
InReplyTo string
8586
References []string
8687
Attachments []Attachment
8788
AccountID string

backend/imap/imap.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ func toBackendEmails(emails []fetcher.Email) []backend.Email {
144144
Date: e.Date,
145145
IsRead: e.IsRead,
146146
MessageID: e.MessageID,
147+
InReplyTo: e.InReplyTo,
147148
References: e.References,
148149
Attachments: toBackendAttachments(e.Attachments),
149150
AccountID: e.AccountID,

backend/jmap/jmap.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,11 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u
165165
Name: "Email/query",
166166
Path: "/ids",
167167
},
168-
Properties: []string{"id", "subject", "from", "to", "replyTo", "receivedAt", "preview", "keywords", "mailboxIds", "hasAttachment", "messageId"},
168+
Properties: []string{
169+
"id", "subject", "from", "to", "replyTo", "receivedAt",
170+
"preview", "keywords", "mailboxIds", "hasAttachment",
171+
"messageId", "inReplyTo", "references",
172+
},
169173
})
170174

171175
resp, err := p.client.Do(req)
@@ -697,6 +701,10 @@ func jmapEmailToBackend(eml *email.Email, uid uint32, accountID string) backend.
697701
if len(eml.MessageID) > 0 {
698702
e.MessageID = eml.MessageID[0]
699703
}
704+
if len(eml.InReplyTo) > 0 {
705+
e.InReplyTo = eml.InReplyTo[0]
706+
}
707+
e.References = append(e.References, eml.References...)
700708
return e
701709
}
702710

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)
@@ -298,6 +301,8 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account
298301
subject := header.Get("Subject")
299302
dateStr := header.Get("Date")
300303
messageID := header.Get("Message-ID")
304+
inReplyTo := firstMessageID(header.Get("In-Reply-To"))
305+
references := messageIDList(header.Get("References"))
301306

302307
var to []string
303308
if toHeader := header.Get("To"); toHeader != "" {
@@ -339,16 +344,34 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account
339344
}
340345

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

354377
// 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/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ type Config struct {
9191
HideTips bool `json:"hide_tips,omitempty"`
9292
DisableNotifications bool `json:"disable_notifications,omitempty"`
9393
EnableSplitPane bool `json:"enable_split_pane,omitempty"`
94+
EnableThreaded bool `json:"enable_threaded,omitempty"`
9495
Theme string `json:"theme,omitempty"`
9596
MailingLists []MailingList `json:"mailing_lists,omitempty"`
9697
DateFormat string `json:"date_format,omitempty"`
@@ -398,9 +399,11 @@ type secureDiskConfig struct {
398399
HideTips bool `json:"hide_tips,omitempty"`
399400
DisableNotifications bool `json:"disable_notifications,omitempty"`
400401
EnableSplitPane bool `json:"enable_split_pane,omitempty"`
402+
EnableThreaded bool `json:"enable_threaded,omitempty"`
401403
Theme string `json:"theme,omitempty"`
402404
MailingLists []MailingList `json:"mailing_lists,omitempty"`
403405
DateFormat string `json:"date_format,omitempty"`
406+
Language string `json:"language,omitempty"`
404407
}
405408

406409
// SaveConfig saves the given configuration to the config file and passwords to the keyring.
@@ -543,6 +546,7 @@ func LoadConfig() (*Config, error) {
543546
HideTips bool `json:"hide_tips,omitempty"`
544547
DisableNotifications bool `json:"disable_notifications,omitempty"`
545548
EnableSplitPane bool `json:"enable_split_pane,omitempty"`
549+
EnableThreaded bool `json:"enable_threaded,omitempty"`
546550
Theme string `json:"theme,omitempty"`
547551
MailingLists []MailingList `json:"mailing_lists,omitempty"`
548552
DateFormat string `json:"date_format,omitempty"`
@@ -579,6 +583,7 @@ func LoadConfig() (*Config, error) {
579583
config.HideTips = raw.HideTips
580584
config.DisableNotifications = raw.DisableNotifications
581585
config.EnableSplitPane = raw.EnableSplitPane
586+
config.EnableThreaded = raw.EnableThreaded
582587
config.Theme = raw.Theme
583588
config.MailingLists = raw.MailingLists
584589
config.DateFormat = raw.DateFormat

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: 62 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,59 @@ 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+
// IsFolderThreaded returns the threading state for a folder. If the user has
208+
// explicitly toggled threading for this folder, that override is returned.
209+
// Otherwise defaultEnabled (from Config.EnableThreaded) is used.
210+
func IsFolderThreaded(folderName string, defaultEnabled bool) bool {
211+
cache, err := LoadFolderCache()
212+
if err != nil || cache.ThreadedFolders == nil {
213+
return defaultEnabled
214+
}
215+
v, ok := cache.ThreadedFolders[folderName]
216+
if !ok {
217+
return defaultEnabled
218+
}
219+
return v
220+
}
221+
222+
// SetFolderThreaded stores an explicit per-folder threading override.
223+
func SetFolderThreaded(folderName string, threaded bool) error {
224+
cache, err := LoadFolderCache()
225+
if err != nil {
226+
cache = &FolderCache{}
227+
}
228+
if cache.ThreadedFolders == nil {
229+
cache.ThreadedFolders = make(map[string]bool)
230+
}
231+
cache.ThreadedFolders[folderName] = threaded
232+
return SaveFolderCache(cache)
233+
}
234+
235+
func cachedEmailID(email CachedEmail) string {
236+
return email.AccountID + ":" + formatUID(email.UID)
237+
}
238+
239+
func formatUID(uid uint32) string {
240+
return strconv.FormatUint(uint64(uid), 10)
241+
}

config/keybinds.go

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,16 @@ 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-
Search string `json:"search"`
41-
Filter string `json:"filter"`
42-
Open string `json:"open"`
43-
NextTab string `json:"next_tab"`
44-
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+
Search string `json:"search"`
42+
Filter string `json:"filter"`
43+
Open string `json:"open"`
44+
NextTab string `json:"next_tab"`
45+
PrevTab string `json:"prev_tab"`
4546
}
4647

4748
type EmailKeys struct {
@@ -140,15 +141,16 @@ func ValidateKeybinds(kb KeybindsConfig) []string {
140141
"nav_down": kb.Global.NavDown,
141142
})
142143
check("inbox", map[string]string{
143-
"visual_mode": kb.Inbox.VisualMode,
144-
"delete": kb.Inbox.Delete,
145-
"archive": kb.Inbox.Archive,
146-
"refresh": kb.Inbox.Refresh,
147-
"search": kb.Inbox.Search,
148-
"filter": kb.Inbox.Filter,
149-
"open": kb.Inbox.Open,
150-
"next_tab": kb.Inbox.NextTab,
151-
"prev_tab": kb.Inbox.PrevTab,
144+
"visual_mode": kb.Inbox.VisualMode,
145+
"toggle_threaded": kb.Inbox.ToggleThreaded,
146+
"delete": kb.Inbox.Delete,
147+
"archive": kb.Inbox.Archive,
148+
"refresh": kb.Inbox.Refresh,
149+
"search": kb.Inbox.Search,
150+
"filter": kb.Inbox.Filter,
151+
"open": kb.Inbox.Open,
152+
"next_tab": kb.Inbox.NextTab,
153+
"prev_tab": kb.Inbox.PrevTab,
152154
})
153155
check("email", map[string]string{
154156
"reply": kb.Email.Reply,

daemon/daemon.go

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -360,14 +360,16 @@ func (d *Daemon) syncAllAccounts(ctx context.Context) {
360360
var cached []config.CachedEmail
361361
for _, e := range emails {
362362
cached = append(cached, config.CachedEmail{
363-
UID: e.UID,
364-
From: e.From,
365-
To: e.To,
366-
Subject: e.Subject,
367-
Date: e.Date,
368-
MessageID: e.MessageID,
369-
AccountID: e.AccountID,
370-
IsRead: e.IsRead,
363+
UID: e.UID,
364+
From: e.From,
365+
To: e.To,
366+
Subject: e.Subject,
367+
Date: e.Date,
368+
MessageID: e.MessageID,
369+
InReplyTo: e.InReplyTo,
370+
References: e.References,
371+
AccountID: e.AccountID,
372+
IsRead: e.IsRead,
371373
})
372374
}
373375
if err := d.updateFolderCache("INBOX", acct.ID, cached); err != nil {
@@ -474,14 +476,16 @@ func (d *Daemon) fetchAndCache(accountID, folder string) {
474476
var cached []config.CachedEmail
475477
for _, e := range emails {
476478
cached = append(cached, config.CachedEmail{
477-
UID: e.UID,
478-
From: e.From,
479-
To: e.To,
480-
Subject: e.Subject,
481-
Date: e.Date,
482-
MessageID: e.MessageID,
483-
AccountID: e.AccountID,
484-
IsRead: e.IsRead,
479+
UID: e.UID,
480+
From: e.From,
481+
To: e.To,
482+
Subject: e.Subject,
483+
Date: e.Date,
484+
MessageID: e.MessageID,
485+
InReplyTo: e.InReplyTo,
486+
References: e.References,
487+
AccountID: e.AccountID,
488+
IsRead: e.IsRead,
485489
})
486490
}
487491

0 commit comments

Comments
 (0)