From 205b59554cfcdbea61b94b93700763381578432b Mon Sep 17 00:00:00 2001 From: drew Date: Fri, 10 Jul 2026 21:10:04 +0400 Subject: [PATCH 1/2] feat: reply-all Signed-off-by: drew --- config/default_keybinds.json | 1 + config/keybinds.go | 2 + docs/docs/Features/Keybinds.md | 1 + main.go | 109 +++++++++++++++++++++++++++++++++ tui/email_view.go | 6 +- tui/messages.go | 4 ++ 6 files changed, 122 insertions(+), 1 deletion(-) diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 4c13dcee..b328b13d 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -19,6 +19,7 @@ }, "email": { "reply": "r", + "reply_all": "shift+r", "forward": "f", "delete": "d", "archive": "a", diff --git a/config/keybinds.go b/config/keybinds.go index 7853c6ac..f7856522 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -48,6 +48,7 @@ type InboxKeys struct { type EmailKeys struct { Reply string `json:"reply"` + ReplyAll string `json:"reply_all"` Forward string `json:"forward"` Delete string `json:"delete"` Archive string `json:"archive"` @@ -127,6 +128,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { }, "email": { "reply": kb.Email.Reply, + "reply_all": kb.Email.ReplyAll, "forward": kb.Email.Forward, keyDelete: kb.Email.Delete, "archive": kb.Email.Archive, diff --git a/docs/docs/Features/Keybinds.md b/docs/docs/Features/Keybinds.md index a4cbfff8..50979665 100644 --- a/docs/docs/Features/Keybinds.md +++ b/docs/docs/Features/Keybinds.md @@ -34,6 +34,7 @@ Plain text, not encrypted. Edit with any text editor. Restart matcha to apply ch }, "email": { "reply": "r", + "reply_all": "shift+r", "forward": "f", "delete": "d", "archive": "a", diff --git a/main.go b/main.go index ebe0569e..9ca9217a 100644 --- a/main.go +++ b/main.go @@ -1667,6 +1667,115 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo m.syncPluginKeyBindings() return m, m.current.Init() + case tui.ReplyAllEmailMsg: + // Determine the original sender (Reply-To if present, else From) + var sender string + if len(msg.Email.ReplyTo) > 0 { + sender = strings.Join(msg.Email.ReplyTo, ", ") + } else { + sender = msg.Email.From + } + subject := msg.Email.Subject + normalizedSubject := strings.ToLower(strings.TrimSpace(subject)) + if !strings.HasPrefix(normalizedSubject, "re:") { + subject = "Re: " + subject + } + quotedText := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Local().Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> ")) + + // Build set of the user's own addresses to exclude from reply-all + selfAddrs := make(map[string]bool) + if m.config != nil { + for _, acc := range m.config.Accounts { + selfAddrs[strings.ToLower(acc.Email)] = true + selfAddrs[strings.ToLower(acc.GetFetchEmail())] = true + selfAddrs[strings.ToLower(acc.GetSendAsEmail())] = true + } + // For catch-all accounts, also exclude the delivery address + accountID := msg.Email.AccountID + if accountID == "" && len(m.config.Accounts) > 0 { + accountID = m.config.GetFirstAccount().ID + } + for _, acc := range m.config.Accounts { + if acc.ID == accountID && acc.CatchAll && len(msg.Email.To) > 0 { + if addr, err := mail.ParseAddress(msg.Email.To[0]); err == nil { + selfAddrs[strings.ToLower(addr.Address)] = true + } else { + selfAddrs[strings.ToLower(strings.TrimSpace(msg.Email.To[0]))] = true + } + break + } + } + } + + // Collect Cc recipients from original To list, excluding self and the sender + seen := make(map[string]bool) + var ccRecipients []string + for _, addr := range msg.Email.To { + parsed, err := mail.ParseAddress(addr) + addrLower := "" + displayAddr := addr + if err == nil { + addrLower = strings.ToLower(parsed.Address) + displayAddr = parsed.Address + } else { + addrLower = strings.ToLower(strings.TrimSpace(addr)) + } + if selfAddrs[addrLower] || seen[addrLower] { + continue + } + seen[addrLower] = true + ccRecipients = append(ccRecipients, displayAddr) + } + cc := strings.Join(ccRecipients, ", ") + + var composer *tui.Composer + hideTips := false + if m.config != nil { + hideTips = m.config.HideTips + } + if m.config != nil && len(m.config.Accounts) > 0 { + accountID := msg.Email.AccountID + if accountID == "" { + accountID = m.config.GetFirstAccount().ID + } + composer = tui.NewComposerWithAccounts(m.config.Accounts, accountID, sender, subject, "", hideTips) + // For catch-all accounts, pre-fill From with the specific address the email was delivered to. + if len(msg.Email.To) > 0 { + for i := range m.config.Accounts { + if m.config.Accounts[i].ID == accountID && m.config.Accounts[i].CatchAll { + acc := &m.config.Accounts[i] + deliveryAddr := msg.Email.To[0] + if addr, err := mail.ParseAddress(deliveryAddr); err == nil { + deliveryAddr = addr.Address + } + fromVal := deliveryAddr + if acc.Name != "" { + fromVal = fmt.Sprintf("%s <%s>", acc.Name, deliveryAddr) + } + composer.SetFromOverride(fromVal) + break + } + } + } + } else { + composer = tui.NewComposer("", sender, subject, "", hideTips) + } + if cc != "" { + composer.SetCc(cc) + } + composer.SetQuotedText(quotedText) + + // Set reply headers + inReplyTo := msg.Email.MessageID + references := append(msg.Email.References, msg.Email.MessageID) //nolint:gocritic + composer.SetReplyContext(inReplyTo, references) + + m.applySpellcheckOptions(composer) + m.current = composer + m.current, _ = m.current.Update(m.currentWindowSize()) + m.syncPluginKeyBindings() + return m, m.current.Init() + case tui.ForwardEmailMsg: subject := msg.Email.Subject if !strings.HasPrefix(strings.ToLower(subject), "fwd:") { diff --git a/tui/email_view.go b/tui/email_view.go index e764d6aa..86609a19 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -265,6 +265,10 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Clear Kitty graphics before opening composer ClearKittyGraphics() return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} } + case kb.Email.ReplyAll: + // Clear Kitty graphics before opening composer + ClearKittyGraphics() + return m, func() tea.Msg { return ReplyAllEmailMsg{Email: m.email} } case kb.Email.Forward: // Clear Kitty graphics before opening composer ClearKittyGraphics() @@ -385,7 +389,7 @@ func (m *EmailView) View() tea.View { help = helpStyle.Render(helpText) } else { var shortcuts strings.Builder - shortcuts.WriteString("\uf112 r: reply • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox") + shortcuts.WriteString("\uf112 r: reply • \uf064 shift+r: reply all • \uf064 f: forward • \uea81 d: delete • \uea98 a: archive • \uf435 tab: focus attachments • \ueb06 esc: back to inbox") if view.ImageProtocolSupported() { shortcuts.WriteString("• \uf03e i: toggle images") } diff --git a/tui/messages.go b/tui/messages.go index 26bc5090..3f097ad8 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -180,6 +180,10 @@ type ReplyToEmailMsg struct { Email fetcher.Email } +type ReplyAllEmailMsg struct { + Email fetcher.Email +} + type ForwardEmailMsg struct { Email fetcher.Email } From f61aefbbd21c07db390f1ec942af480ee6d2a89e Mon Sep 17 00:00:00 2001 From: drew Date: Fri, 10 Jul 2026 21:13:53 +0400 Subject: [PATCH 2/2] fix: lint errors Signed-off-by: drew --- main.go | 2 +- tui/email_view.go | 34 ++++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/main.go b/main.go index 9ca9217a..18a191b3 100644 --- a/main.go +++ b/main.go @@ -1712,7 +1712,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo var ccRecipients []string for _, addr := range msg.Email.To { parsed, err := mail.ParseAddress(addr) - addrLower := "" + var addrLower string displayAddr := addr if err == nil { addrLower = strings.ToLower(parsed.Address) diff --git a/tui/email_view.go b/tui/email_view.go index 86609a19..87127b93 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -194,6 +194,24 @@ func (m *EmailView) Init() tea.Cmd { return nil } +// handleComposeAction checks if the key matches reply, reply-all, or forward +// and returns the corresponding message. Returns false if no match. +func (m *EmailView) handleComposeAction(key string) (tea.Msg, bool) { + kb := config.Keybinds + switch key { + case kb.Email.Reply: + ClearKittyGraphics() + return ReplyToEmailMsg{Email: m.email}, true + case kb.Email.ReplyAll: + ClearKittyGraphics() + return ReplyAllEmailMsg{Email: m.email}, true + case kb.Email.Forward: + ClearKittyGraphics() + return ForwardEmailMsg{Email: m.email}, true + } + return nil, false +} + func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd cmds := make([]tea.Cmd, 0, 1) @@ -261,18 +279,10 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.viewport.SetContent(wrapped + "\n") return m, nil } - case kb.Email.Reply: - // Clear Kitty graphics before opening composer - ClearKittyGraphics() - return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} } - case kb.Email.ReplyAll: - // Clear Kitty graphics before opening composer - ClearKittyGraphics() - return m, func() tea.Msg { return ReplyAllEmailMsg{Email: m.email} } - case kb.Email.Forward: - // Clear Kitty graphics before opening composer - ClearKittyGraphics() - return m, func() tea.Msg { return ForwardEmailMsg{Email: m.email} } + case kb.Email.Reply, kb.Email.ReplyAll, kb.Email.Forward: + if composeMsg, ok := m.handleComposeAction(msg.String()); ok { + return m, func() tea.Msg { return composeMsg } + } case kb.Email.Delete: accountID := m.accountID uid := m.email.UID