diff --git a/backend/backend.go b/backend/backend.go index 56a7d425..ca051efc 100644 --- a/backend/backend.go +++ b/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 diff --git a/backend/imap/imap.go b/backend/imap/imap.go index 978ec1e0..f0f1d8cd 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 b67137e9..ec6d5ed9 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) @@ -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 } diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index 1c84e88b..b9c47544 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 18109f54..0774516f 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/config.go b/config/config.go index edcf41db..d3c860df 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"` @@ -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 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..222cc1ec 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,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) +} 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/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/fetcher/fetcher.go b/fetcher/fetcher.go index b9a3fca5..74f7cb31 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -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, }) } 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/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..0b62ec8a --- /dev/null +++ b/internal/threading/jwz_test.go @@ -0,0 +1,154 @@ +package threading + +import ( + "reflect" + "testing" + "time" +) + +func TestBuildThreeMessageChain(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1", Sender: "a"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2", Sender: "b"}, + {ID: "", References: []string{"", ""}, Subject: "Re: Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3", Sender: "c"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Count != 3 { + t.Fatalf("got count %d, want 3", threads[0].Count) + } + if got := threads[0].Root.Children[0].Children[0].EmailID; got != "3" { + t.Fatalf("got chain leaf %q, want 3", got) + } +} + +func TestBuildForkedThread(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "2"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + children := threads[0].Root.Children + if len(children) != 2 { + t.Fatalf("got %d children, want 2", len(children)) + } + if children[0].EmailID != "2" || children[1].EmailID != "3" { + t.Fatalf("got child order %q, %q; want 2, 3", children[0].EmailID, children[1].EmailID) + } +} + +func TestBuildMissingParentPlaceholderRoot(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base, EmailID: "child"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base.Add(time.Minute), EmailID: "other"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Root.EmailID != "" { + t.Fatalf("got root EmailID %q, want placeholder", threads[0].Root.EmailID) + } + if len(threads[0].Root.Children) != 2 { + t.Fatalf("got %d placeholder children, want 2", len(threads[0].Root.Children)) + } +} + +func TestBuildSubjectFallbackGroupingForOrphans(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Re: Foo", Date: base, EmailID: "1"}, + {ID: "", Subject: "Fwd: foo", Date: base.Add(time.Minute), EmailID: "2"}, + {ID: "", Subject: "Bar", Date: base.Add(2 * time.Minute), EmailID: "3"}, + }) + + if len(threads) != 2 { + t.Fatalf("got %d threads, want 2", len(threads)) + } + var grouped Thread + for _, thread := range threads { + if thread.Subject == "foo" { + grouped = thread + break + } + } + if grouped.Count != 2 { + t.Fatalf("got grouped count %d, want 2", grouped.Count) + } +} + +func TestBuildSubjectFallbackGroupsLocalePrefixes(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + threads := Build([]EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1"}, + {ID: "", Subject: "SV: Foo", Date: base.Add(time.Minute), EmailID: "2"}, + {ID: "", Subject: "RV: Foo", Date: base.Add(2 * time.Minute), EmailID: "3"}, + {ID: "", Subject: "Antw: Foo", Date: base.Add(3 * time.Minute), EmailID: "4"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Subject != "foo" { + t.Fatalf("got subject %q, want foo", threads[0].Subject) + } + if threads[0].Count != 4 { + t.Fatalf("got grouped count %d, want 4", threads[0].Count) + } +} + +func TestBuildEmptyReferencesList(t *testing.T) { + threads := Build([]EmailHeader{ + {ID: "", References: nil, Subject: "Foo", Date: time.Now(), EmailID: "1"}, + }) + + if len(threads) != 1 { + t.Fatalf("got %d threads, want 1", len(threads)) + } + if threads[0].Root.EmailID != "1" { + t.Fatalf("got root %q, want 1", threads[0].Root.EmailID) + } +} + +func TestBuildStableOrderingAcrossCalls(t *testing.T) { + base := time.Date(2026, 4, 28, 10, 0, 0, 0, time.UTC) + headers := []EmailHeader{ + {ID: "", Subject: "Foo", Date: base, EmailID: "1"}, + {ID: "", Subject: "Bar", Date: base, EmailID: "2"}, + {ID: "", References: []string{""}, Subject: "Re: Foo", Date: base, EmailID: "3"}, + } + + first := Build(headers) + second := Build(headers) + if !reflect.DeepEqual(first, second) { + t.Fatalf("Build output differed across calls:\n%#v\n%#v", first, second) + } +} + +func TestCanonicalSubjectNormalizesReplyAndForwardPrefixes(t *testing.T) { + tests := map[string]string{ + "Re: Re: Foo": "foo", + "Fwd: FW: Foo": "foo", + "AW: WG: Tr: Foo": "foo", + "Reé: Resp: Foo": "foo", + "SV: VS: RV: Foo": "foo", + "ENC: Antw: Foo": "foo", + "Odp: R: I: Foo": "foo", + " Foo ": "foo", + } + + for in, want := range tests { + if got := canonicalSubject(in); got != want { + t.Fatalf("canonicalSubject(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/threading/subject.go b/internal/threading/subject.go new file mode 100644 index 00000000..1a36dd55 --- /dev/null +++ b/internal/threading/subject.go @@ -0,0 +1,20 @@ +package threading + +import ( + "regexp" + "strings" +) + +var subjectPrefixRE = regexp.MustCompile(`(?i)^(Re|Fwd|Fw|AW|WG|Tr|Reé|Resp|SV|VS|RV|ENC|Antw|Odp|R|I)\s*:\s*`) + +func canonicalSubject(s string) string { + s = strings.TrimSpace(s) + for { + next := subjectPrefixRE.ReplaceAllString(s, "") + if next == s { + break + } + s = strings.TrimSpace(next) + } + return strings.ToLower(strings.TrimSpace(s)) +} diff --git a/main.go b/main.go index 31efff8c..6e683d11 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) @@ -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 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/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 9faa01c4..0477271f 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,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 "" +} diff --git a/tui/settings_general.go b/tui/settings_general.go index d4c89ead..e242022d 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, ""}, @@ -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{} } }