A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

link opening up to 100 links

sspaeti 0342d959 75e9d311

+46 -7
+5
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-14 4 + - **CC/BCC display in reader** — the email reader now shows CC and BCC headers when viewing sent emails; both fields appear conditionally (only when present) between To and Subject in the header display; particularly useful for confirming auto_bcc was applied when replying; note that BCC visibility depends on IMAP provider behavior (some providers strip BCC headers from Sent folder, others preserve them) 5 + - **Extended link support (99 links)** — link opener now supports up to 99 links per email (previously limited to 10); `space+1-0` opens links 1-10, `space+l11-99` opens links 11-99 using intuitive numeric shortcuts (e.g. `space+l26` for link [26]); status line provides progressive feedback during multi-key input; footer help and `?` overlay updated 6 + - **Fix: link extraction with brackets in text** — markdown link regex now correctly matches links with brackets inside the link text (e.g. `[[Watch the studio tour here]](url)`); changed from `[^\]]+` (anything except `]`) to non-greedy `.+?` to handle nested brackets; fixes newsletter links from Beehiiv and similar services 7 + 3 8 # 2026-04-13 4 9 - **Emoji reactions (`ctrl+e`)** — fast, keyboard-driven emoji reactions from inbox or reader; press `ctrl+e` to open emoji picker overlay, select with `1`-`8` for instant send or navigate with `j`/`k` and press `enter`; sends minimal reaction email (emoji + italic footer + quoted original message) with proper threading headers; available reactions: 👍 ❤️ 😂 🎉 🙏 💯 👀 ✅; original email marked with `\Answered` flag; reaction saved to Sent folder; auto-selects From address matching recipient (same logic as regular replies) 5 10 - **Email threading headers** — all replies (regular `r`/`R` and emoji reactions `ctrl+e`) now include proper `In-Reply-To` and `References` headers for conversation threading; ensures replies appear correctly grouped in Gmail, Outlook, and Apple Mail conversation views; `References` header extracted from IMAP message body and preserved in reply chain
+2 -1
docs/content/docs/keybindings.md
··· 132 132 | `e (pre-send)` | re-open editor to edit body | 133 133 | `enter (pre-send)` | confirm and send | 134 134 | `1-9 (reader)` | download attachment N to ~/Downloads and open with xdg-open | 135 - | `space+1-9 (reader)` | open link N in $BROWSER (0 = 10th link) | 135 + | `space+1-0 (reader)` | open link 1-10 in $BROWSER (0 = 10th link) | 136 + | `space+l11-99 (reader)` | open link 11-99 in $BROWSER (e.g. space+l26 for [26]) | 136 137 | `e (reader)` | open in $EDITOR read-only — search, copy, vim motions | 137 138 | `E (reader)` | continue draft — re-open as editable compose (Drafts folder) | 138 139 | `o (reader)` | open in w3m (terminal browser) |
+2 -1
internal/ui/keys.go
··· 99 99 {"e (pre-send)", "re-open editor to edit body"}, 100 100 {"enter (pre-send)", "confirm and send"}, 101 101 {"1-9 (reader)", "download attachment N to ~/Downloads and open with xdg-open"}, 102 - {"space+1-9 (reader)", "open link N in $BROWSER (0 = 10th link)"}, 102 + {"space+1-0 (reader)", "open link 1-10 in $BROWSER (0 = 10th link)"}, 103 + {"space+l11-99 (reader)", "open link 11-99 in $BROWSER (e.g. space+l26 for [26])"}, 103 104 {"e (reader)", "open in $EDITOR read-only — search, copy, vim motions"}, 104 105 {"E (reader)", "continue draft — re-open as editable compose (Drafts folder)"}, 105 106 {"o (reader)", "open in w3m (terminal browser)"},
+34 -2
internal/ui/model.go
··· 2749 2749 pending := m.readerPending 2750 2750 m.readerPending = "" 2751 2751 switch pending { 2752 - case " ": // space + digit opens link 2752 + case " ": // space + digit opens link (1-10) 2753 2753 if len(key) == 1 && key >= "0" && key <= "9" { 2754 2754 var idx int 2755 2755 if key == "0" { ··· 2761 2761 return m, m.openLinkCmd(m.openLinks[idx].URL) 2762 2762 } 2763 2763 m.status = fmt.Sprintf("No link [%s].", key) 2764 + return m, nil 2765 + } 2766 + // space + l = prefix for links 11-99 (two digits) 2767 + if key == "l" { 2768 + m.readerPending = "l" 2769 + m.status = "link number (11-99): l__" 2770 + return m, nil 2771 + } 2772 + // Not a digit or 'l' — fall through 2773 + case "l": // l + first digit (waiting for second digit) 2774 + if len(key) == 1 && key >= "0" && key <= "9" { 2775 + m.readerPending = "l" + key 2776 + m.status = fmt.Sprintf("link number: l%s_", key) 2764 2777 return m, nil 2765 2778 } 2766 2779 // Not a digit — fall through ··· 2768 2781 if key == "g" { 2769 2782 m.reader.GotoTop() 2770 2783 return m, nil 2784 + } 2785 + default: 2786 + // Handle "l[0-9]" pattern (first digit entered, waiting for second) 2787 + if len(pending) == 2 && pending[0] == 'l' && pending[1] >= '0' && pending[1] <= '9' { 2788 + if len(key) == 1 && key >= "0" && key <= "9" { 2789 + // Parse two-digit number 2790 + numStr := string(pending[1]) + key 2791 + num, _ := strconv.Atoi(numStr) 2792 + idx := num - 1 // convert to 0-based index 2793 + if idx >= 0 && idx < len(m.openLinks) { 2794 + return m, m.openLinkCmd(m.openLinks[idx].URL) 2795 + } 2796 + m.status = fmt.Sprintf("No link [%d].", num) 2797 + return m, nil 2798 + } 2771 2799 } 2772 2800 } 2773 2801 } ··· 2821 2849 case " ": 2822 2850 if len(m.openLinks) > 0 { 2823 2851 m.readerPending = " " 2824 - m.status = "open link: space+1-9 (0=10th)" 2852 + if len(m.openLinks) > 10 { 2853 + m.status = "open link: 1-0 (links 1-10), l11-99 (links 11+)" 2854 + } else { 2855 + m.status = "open link: 1-0" 2856 + } 2825 2857 return m, nil 2826 2858 } 2827 2859 case "g":
+3 -3
internal/ui/reader.go
··· 40 40 } 41 41 links = append(links, emailLink{Text: text, URL: url}) 42 42 } 43 - if len(links) > 10 { 44 - links = links[:10] 43 + if len(links) > 99 { 44 + links = links[:99] 45 45 } 46 46 return links 47 47 } ··· 141 141 } 142 142 keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach") 143 143 if hasLinks { 144 - keys = append(keys, "space+1-9 links") 144 + keys = append(keys, "space+1-0 links", "space+l11-99 links 11+") 145 145 } 146 146 keys = append(keys, "? help") 147 147 return styleHelp.Render(" " + strings.Join(keys, " · "))