feat(threading): JWZ conversation view (#1188)

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>

Change summary

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(-)

Detailed changes

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

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,

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
 }
 

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.

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.

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

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)
+}

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,

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,
 		})
 	}
 

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"

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.

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,
 		})
 	}
 

i18n/locales/ar.json 🔗

@@ -151,6 +151,7 @@
       "hide_tips": "إخفاء النصائح السياقية",
       "disable_notifications": "تعطيل الإشعارات",
       "enable_split_pane": "عرض مقسم",
+      "enable_threaded": "عرض المحادثات",
       "date_format": "تنسيق التاريخ",
       "language": "اللغة",
       "signature": "تعديل التوقيع",

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",

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",

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",

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",

i18n/locales/ja.json 🔗

@@ -145,6 +145,7 @@
       "hide_tips": "コンテキストヒントを非表示",
       "disable_notifications": "通知を無効化",
       "enable_split_pane": "分割ビュー",
+      "enable_threaded": "スレッド表示",
       "date_format": "日付形式",
       "language": "言語",
       "signature": "署名を編集",

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",

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",

i18n/locales/ru.json 🔗

@@ -151,6 +151,7 @@
       "hide_tips": "Скрыть Контекстные Подсказки",
       "disable_notifications": "Отключить Уведомления",
       "enable_split_pane": "Разделённый вид",
+      "enable_threaded": "Просмотр беседами",
       "date_format": "Формат Даты",
       "language": "Язык",
       "signature": "Редактировать Подпись",

i18n/locales/uk.json 🔗

@@ -149,6 +149,7 @@
       "hide_tips": "Приховати контекстні підказки",
       "disable_notifications": "Вимкнути сповіщення",
       "enable_split_pane": "Розділений вигляд",
+      "enable_threaded": "Перегляд розмов",
       "date_format": "Формат дати",
       "language": "Мова",
       "signature": "Редагувати підпис",

i18n/locales/zh.json 🔗

@@ -145,6 +145,7 @@
       "hide_tips": "隐藏上下文提示",
       "disable_notifications": "禁用通知",
       "enable_split_pane": "分屏视图",
+      "enable_threaded": "会话视图",
       "date_format": "日期格式",
       "language": "语言",
       "signature": "编辑签名",

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 ""
+}

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: "<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)
+		}
+	}
+}

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))
+}

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

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 <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)
+	}
+}

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

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)

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 ""
+}

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{} }
 				}