backend/backend.go 🔗
@@ -82,6 +82,7 @@ type Email struct {
Date time.Time
IsRead bool
MessageID string
+ InReplyTo string
References []string
Attachments []Attachment
AccountID string
Matt Van Horn , Matt Van Horn , Andriy Chernov , and drew created
## 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>
backend/backend.go | 1
backend/imap/imap.go | 1
backend/jmap/jmap.go | 10
backend/pop3/pop3.go | 41 ++
config/cache.go | 18
config/config.go | 5
config/default_keybinds.json | 1
config/folder_cache.go | 64 ++++
config/keybinds.go | 38 +-
daemon/daemon.go | 36 +-
docs/docs/Features/Keybinds.md | 3
docs/docs/Features/THREADED_VIEW.md | 82 ++++++
fetcher/fetcher.go | 78 ++++-
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
internal/threading/jwz.go | 365 ++++++++++++++++++++++++++++
internal/threading/jwz_test.go | 154 +++++++++++
internal/threading/subject.go | 20 +
main.go | 58 ++-
screenshots/cmd/threading_demo/main.go | 107 ++++++++
screenshots/threading_demo.tape | 27 ++
tui/folder_inbox.go | 7
tui/inbox.go | 291 ++++++++++++++++++++-
tui/settings_general.go | 11
33 files changed, 1,310 insertions(+), 119 deletions(-)
@@ -82,6 +82,7 @@ type Email struct {
Date time.Time
IsRead bool
MessageID string
+ InReplyTo string
References []string
Attachments []Attachment
AccountID string
@@ -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,
@@ -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)
@@ -697,6 +701,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
}
@@ -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.
@@ -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.
@@ -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"`
@@ -398,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.
@@ -543,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"`
@@ -579,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
@@ -7,6 +7,7 @@
},
"inbox": {
"visual_mode": "v",
+ "toggle_threaded": "T",
"delete": "d",
"archive": "a",
"refresh": "r",
@@ -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,59 @@ 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
+}
+
+// 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 defaultEnabled
+ }
+ 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 {
+ cache = &FolderCache{}
+ }
+ if cache.ThreadedFolders == nil {
+ cache.ThreadedFolders = make(map[string]bool)
+ }
+ cache.ThreadedFolders[folderName] = threaded
+ 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)
+}
@@ -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,
@@ -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,
})
}
@@ -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"
@@ -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.
@@ -16,6 +16,7 @@ import (
"mime/quotedprintable"
"net/textproto"
"os"
+ "regexp"
"slices"
"sort"
"strings"
@@ -85,11 +86,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
@@ -168,6 +172,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)
@@ -457,7 +493,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,
}
@@ -543,15 +579,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,
})
}
@@ -1516,7 +1556,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,
}
@@ -1585,14 +1625,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,
})
}
@@ -151,6 +151,7 @@
"hide_tips": "إخفاء النصائح السياقية",
"disable_notifications": "تعطيل الإشعارات",
"enable_split_pane": "عرض مقسم",
+ "enable_threaded": "عرض المحادثات",
"date_format": "تنسيق التاريخ",
"language": "اللغة",
"signature": "تعديل التوقيع",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -145,6 +145,7 @@
"hide_tips": "コンテキストヒントを非表示",
"disable_notifications": "通知を無効化",
"enable_split_pane": "分割ビュー",
+ "enable_threaded": "スレッド表示",
"date_format": "日付形式",
"language": "言語",
"signature": "署名を編集",
@@ -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",
@@ -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",
@@ -151,6 +151,7 @@
"hide_tips": "Скрыть Контекстные Подсказки",
"disable_notifications": "Отключить Уведомления",
"enable_split_pane": "Разделённый вид",
+ "enable_threaded": "Просмотр беседами",
"date_format": "Формат Даты",
"language": "Язык",
"signature": "Редактировать Подпись",
@@ -149,6 +149,7 @@
"hide_tips": "Приховати контекстні підказки",
"disable_notifications": "Вимкнути сповіщення",
"enable_split_pane": "Розділений вигляд",
+ "enable_threaded": "Перегляд розмов",
"date_format": "Формат дати",
"language": "Мова",
"signature": "Редагувати підпис",
@@ -145,6 +145,7 @@
"hide_tips": "隐藏上下文提示",
"disable_notifications": "禁用通知",
"enable_split_pane": "分屏视图",
+ "enable_threaded": "会话视图",
"date_format": "日期格式",
"language": "语言",
"signature": "编辑签名",
@@ -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 ""
+}
@@ -0,0 +1,154 @@
+package threading
+
+import (
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestBuildThreeMessageChain(t *testing.T) {
+ base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
+ threads := Build([]EmailHeader{
+ {ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"},
+ {ID: "<b@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"},
+ {ID: "<c@example>", References: []string{"<a@example>", "<b@example>"}, 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: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
+ {ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
+ {ID: "<b@example>", References: []string{"<a@example>"}, 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: "<child@example>", References: []string{"<missing@example>"}, Subject: "Re: Foo", Date: base, EmailID: "child"},
+ {ID: "<other@example>", References: []string{"<missing@example>"}, 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: "<a@example>", Subject: "Re: Foo", Date: base, EmailID: "1"},
+ {ID: "<b@example>", Subject: "Fwd: foo", Date: base.Add(time.Minute), EmailID: "2"},
+ {ID: "<c@example>", Subject: "Bar", Date: base.Add(2 * time.Minute), EmailID: "3"},
+ })
+
+ if len(threads) != 2 {
+ t.Fatalf("got %d threads, want 2", len(threads))
+ }
+ var grouped Thread
+ for _, thread := range threads {
+ if thread.Subject == "foo" {
+ grouped = thread
+ break
+ }
+ }
+ if grouped.Count != 2 {
+ t.Fatalf("got grouped count %d, want 2", grouped.Count)
+ }
+}
+
+func TestBuildSubjectFallbackGroupsLocalePrefixes(t *testing.T) {
+ base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC)
+ threads := Build([]EmailHeader{
+ {ID: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
+ {ID: "<b@example>", Subject: "SV: Foo", Date: base.Add(time.Minute), EmailID: "2"},
+ {ID: "<c@example>", Subject: "RV: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"},
+ {ID: "<d@example>", 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: "<a@example>", 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: "<a@example>", Subject: "Foo", Date: base, EmailID: "1"},
+ {ID: "<b@example>", Subject: "Bar", Date: base, EmailID: "2"},
+ {ID: "<c@example>", References: []string{"<a@example>"}, Subject: "Re: Foo", Date: base, EmailID: "3"},
+ }
+
+ first := Build(headers)
+ second := Build(headers)
+ if !reflect.DeepEqual(first, second) {
+ t.Fatalf("Build output differed across calls:\n%#v\n%#v", first, second)
+ }
+}
+
+func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) {
+ tests := map[string]string{
+ "Re: Re: Foo": "foo",
+ "Fwd: FW: Foo": "foo",
+ "AW: WG: Tr: Foo": "foo",
+ "Reé: Resp: Foo": "foo",
+ "SV: VS: RV: Foo": "foo",
+ "ENC: Antw: Foo": "foo",
+ "Odp: R: I: Foo": "foo",
+ " Foo ": "foo",
+ }
+
+ for in, want := range tests {
+ if got := canonicalSubject(in); got != want {
+ t.Fatalf("canonicalSubject(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
@@ -0,0 +1,20 @@
+package threading
+
+import (
+ "regexp"
+ "strings"
+)
+
+var subjectPrefixRE = regexp.MustCompile(`(?i)^(Re|Fwd|Fw|AW|WG|Tr|Reé|Resp|SV|VS|RV|ENC|Antw|Odp|R|I)\s*:\s*`)
+
+func canonicalSubject(s string) string {
+ s = strings.TrimSpace(s)
+ for {
+ next := subjectPrefixRE.ReplaceAllString(s, "")
+ if next == s {
+ break
+ }
+ s = strings.TrimSpace(next)
+ }
+ return strings.ToLower(strings.TrimSpace(s))
+}
@@ -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)
@@ -1020,6 +1021,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:
@@ -2283,14 +2287,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
@@ -2300,14 +2306,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
@@ -2335,14 +2343,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
@@ -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 <priya@example.com>",
+ To: []string{"demo@floatpane.com"},
+ Subject: "Re: Release checklist for 1.8",
+ Date: now.Add(-8 * time.Minute),
+ MessageID: "<release-304@example.com>",
+ References: []string{"<release-301@example.com>", "<release-302@example.com>"},
+ AccountID: account.ID,
+ },
+ {
+ UID: 303,
+ From: "Buildkite <buildkite@example.com>",
+ To: []string{"demo@floatpane.com"},
+ Subject: "main passed",
+ Date: now.Add(-20 * time.Minute),
+ MessageID: "<build-303@example.com>",
+ AccountID: account.ID,
+ IsRead: true,
+ },
+ {
+ UID: 302,
+ From: "Noah Reed <noah@example.com>",
+ To: []string{"demo@floatpane.com"},
+ Subject: "Re: Release checklist for 1.8",
+ Date: now.Add(-33 * time.Minute),
+ MessageID: "<release-302@example.com>",
+ References: []string{"<release-301@example.com>"},
+ AccountID: account.ID,
+ IsRead: true,
+ },
+ {
+ UID: 301,
+ From: "Avery Stone <avery@example.com>",
+ To: []string{"demo@floatpane.com"},
+ Subject: "Release checklist for 1.8",
+ Date: now.Add(-52 * time.Minute),
+ MessageID: "<release-301@example.com>",
+ AccountID: account.ID,
+ IsRead: true,
+ },
+ {
+ UID: 300,
+ From: "Finance <finance@example.com>",
+ To: []string{"demo@floatpane.com"},
+ Subject: "Invoice approvals",
+ Date: now.Add(-2 * time.Hour),
+ MessageID: "<invoice-300@example.com>",
+ 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)
+ }
+}
@@ -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
@@ -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)
@@ -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,9 @@ type Inbox struct {
searchActive bool
searchQuery string
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
@@ -370,6 +393,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 +427,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 +440,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 +609,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 +723,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 +748,57 @@ func (m *Inbox) getBaseTitle() string {
}
}
+func (m *Inbox) folderKey() string {
+ if m.folderName != "" {
+ return m.folderName
+ }
+ 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)
+ }
+ 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, m.defaultThreaded)
+ }
+ 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 +832,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 +933,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 +962,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 +982,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 +1298,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 +1396,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 +1443,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 ""
+}
@@ -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, ""},
@@ -82,7 +83,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
@@ -93,7 +98,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()
@@ -114,7 +119,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{} }
}