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.

adding link opener in reading mode

sspaeti 96d76f60 723961f5

+201 -14
+1
CHANGELOG.md
··· 5 5 - **Filter preserves across actions** — the local `/` filter no longer clears when pressing `n` (toggle read), `m` (mark), `U` (clear marks), or sorting; filter stays active until `esc` 6 6 - **Address autocomplete in compose** — To, Cc, and Bcc fields show autocomplete suggestions from screener lists (`screened_in.txt`, `feed.txt`, `papertrail.txt`); navigate with `ctrl+n`/`ctrl+p`/arrows, accept with `tab`; supports multi-address fields (autocomplete applies after the last comma) 7 7 - **Everything view (`ge` or `:everything`)** — shows the 50 most recent emails across all folders in a temporary "Everything" tab, sorted by date descending; each subject prefixed with `[Folder]`; useful for finding emails that were screened out or moved to spam 8 + - **Link opener (`space+1-9` in reader)** — links are extracted from the email body, numbered `[1]`-`[0]` in the header; press `space` then a digit to open in `$BROWSER`; up to 10 links per email, deduplicated by URL 8 9 - **Draft signature fix** — re-opening a draft (`E`) no longer appends a duplicate signature; the draft body already contains it from the first compose 9 10 - **Draft reader footer** — `E draft` now appears in the reader footer when viewing an email from the Drafts folder 10 11 - **Android support (`make android`)** — cross-compile for Android ARM64; runs in Termux; documented in `docs/android.md` with install instructions and useful shortcuts
+3 -1
README.md
··· 66 66 - **Write in Markdown, send beautifully** — compose in `$EDITOR` (defaults to `nvim`), send as `multipart/alternative`: raw Markdown as plain text + goldmark-rendered HTML so recipients get clickable links, bold, headers, inline code, and code blocks 67 67 - **Pre-send review** — after closing the editor, review To/Subject/body before sending; attach files, save to Drafts, or re-open the editor — no accidental sends 68 68 - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within neovim via `<leader>a`; the reader lists all attachments (including inline images) and `1`–`9` downloads and opens them 69 + - **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER` 69 70 - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients 70 71 - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose 71 72 - **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder ··· 147 148 148 149 Compose in Markdown, send as `multipart/alternative` (plain text + HTML). Attachments, CC/BCC, multiple From addresses, drafts, and pre-send review are all supported. 149 150 150 - See [docs/sending.md](docs/sending.md) for details on MIME structure, attachments, pre-send review, drafts, and how images are handled in the reader. 151 + - See [docs/sending.md](docs/sending.md) for details on MIME structure, attachments, pre-send review, and drafts. 152 + - See [docs/reading.md](docs/reading.md) for the reader: images, inline links, attachments, and navigation. 151 153 152 154 ## Make Targets 153 155
+1
docs/keybindings.md
··· 120 120 | `e (pre-send)` | re-open editor to edit body | 121 121 | `enter (pre-send)` | confirm and send | 122 122 | `1-9 (reader)` | download attachment N to ~/Downloads and open with xdg-open | 123 + | `space+1-9 (reader)` | open link N in $BROWSER (0 = 10th link) | 123 124 | `e (reader)` | open in $EDITOR read-only — search, copy, vim motions | 124 125 | `E (reader)` | continue draft — re-open as editable compose (Drafts folder) | 125 126 | `o (reader)` | open in w3m (terminal browser) |
+57
docs/reading.md
··· 1 + # Reading Emails 2 + 3 + Emails are rendered as styled Markdown in the terminal using [glamour](https://github.com/charmbracelet/glamour). The reader supports vim-style navigation. 4 + 5 + ## Navigation 6 + 7 + | Key | Action | 8 + |-----|--------| 9 + | `j` / `k` | scroll line by line | 10 + | `space` / `d` | page down / up | 11 + | `gg` | jump to top of email | 12 + | `G` | jump to bottom of email | 13 + | `h` / `q` / `esc` | back to inbox | 14 + 15 + ## Opening Emails Externally 16 + 17 + | Key | Action | 18 + |-----|--------| 19 + | `e` | open in `$EDITOR` (read-only) — search, copy, vim motions | 20 + | `o` | open in w3m (terminal browser, clickable links) | 21 + | `O` | open in `$BROWSER` (GUI browser, images rendered) | 22 + | `ctrl+o` | open newsletter web version in `$BROWSER` (from `List-Post` header) | 23 + 24 + ## Images 25 + 26 + Remote images appear as `[Image: alt]` placeholders, keeping the reading experience clean and fast. To see images, press `O` to open in your browser. 27 + 28 + **Inline / attached images** (e.g. screenshots pasted into an email) are listed in the reader header: `Attach: [1] screenshot.png [2] report.pdf`. Press `1`–`9` to download to `~/Downloads/` and open with `xdg-open`. Inline images also show `[Image: filename.png]` placeholders at their position in the body text. 29 + 30 + ## Links 31 + 32 + Links in emails are automatically numbered inline where they appear in the body. A link like `Check out our blog` renders as `Check out our blog [1]` in the terminal. 33 + 34 + Press `space` then a digit (`1`–`9`, `0` for 10th) to open the link in `$BROWSER`. 35 + 36 + - Up to 10 links per email, deduplicated by URL 37 + - Numbers appear inline so you can see them while reading without scrolling 38 + - If an email has no links, `space` works as page-down as usual 39 + 40 + ## Attachments 41 + 42 + Attachments are listed in the reader header: 43 + 44 + ``` 45 + Attach: [1] report.pdf [2] photo.png 46 + ``` 47 + 48 + Press `1`–`9` to download attachment N to `~/Downloads/` and open it with `xdg-open`. Filenames are deduplicated automatically if a file already exists. 49 + 50 + ## Replying, Forwarding, and Drafts 51 + 52 + | Key | Action | 53 + |-----|--------| 54 + | `r` | reply to sender | 55 + | `R` | reply-all (sender + all CC recipients) | 56 + | `f` | forward email | 57 + | `E` | continue draft (only in Drafts folder) — re-opens as editable compose |
+1 -5
docs/sending.md
··· 76 76 77 77 Press `d` in the pre-send screen to save to Drafts instead of sending. Navigate to Drafts with `gd`. To resume a saved draft, open it and press `E` — it re-opens in the editor with all fields pre-filled, and saving goes through the normal pre-send review. 78 78 79 - ## Images in the Reader 80 - 81 - The TUI reader shows emails as plain Markdown — remote images appear as `[Image: alt]` placeholders, keeping the reading experience clean and fast. To see images, press `O` to open the email as HTML in your `$BROWSER` (images load from remote URLs as normal). For newsletters, `ctrl+o` opens the canonical web version directly (extracted from the `List-Post` header or the plain-text preamble), which is usually the better reading experience anyway. `o` opens in `w3m` for a quick terminal preview without leaving the keyboard. 82 - 83 - **Inline / attached images** (e.g. screenshots pasted into an email) are listed in the reader header alongside other attachments: `Attach: [1] screenshot.png [2] report.pdf`. Press `1`–`9` to download the file to `~/Downloads/` and open it with `xdg-open`. Inline images also show `[Image: filename.png]` placeholders at their position in the body text. 79 + For reading emails — images, links, attachments, and navigation — see [reading.md](reading.md).
+1
internal/ui/keys.go
··· 92 92 {"e (pre-send)", "re-open editor to edit body"}, 93 93 {"enter (pre-send)", "confirm and send"}, 94 94 {"1-9 (reader)", "download attachment N to ~/Downloads and open with xdg-open"}, 95 + {"space+1-9 (reader)", "open link N in $BROWSER (0 = 10th link)"}, 95 96 {"e (reader)", "open in $EDITOR read-only — search, copy, vim motions"}, 96 97 {"E (reader)", "continue draft — re-open as editable compose (Drafts folder)"}, 97 98 {"o (reader)", "open in w3m (terminal browser)"},
+62 -3
internal/ui/model.go
··· 143 143 openHTMLBody string // original HTML part; used by openInExternalViewer when available 144 144 openWebURL string // canonical "view online" URL for ctrl+o (may be empty) 145 145 openAttachments []imap.Attachment // attachments of the currently open email 146 + openLinks []emailLink // extracted links from the email body 147 + readerPending string // chord prefix in reader (space for link open) 146 148 147 149 // Compose / pre-send 148 150 compose composeModel ··· 920 922 m.pendingForward = false 921 923 return m.launchForwardCmd() 922 924 } 923 - _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.cfg.UI.Theme, m.width) 925 + m.openLinks = extractLinks(msg.body) 926 + _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width) 924 927 m.state = stateReading 925 928 return m, nil 926 929 ··· 1822 1825 } 1823 1826 1824 1827 func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 1825 - switch msg.String() { 1828 + key := msg.String() 1829 + 1830 + // Handle reader chords 1831 + if m.readerPending != "" { 1832 + pending := m.readerPending 1833 + m.readerPending = "" 1834 + switch pending { 1835 + case " ": // space + digit opens link 1836 + if len(key) == 1 && key >= "0" && key <= "9" { 1837 + var idx int 1838 + if key == "0" { 1839 + idx = 9 1840 + } else { 1841 + idx = int(key[0] - '1') 1842 + } 1843 + if idx < len(m.openLinks) { 1844 + return m, m.openLinkCmd(m.openLinks[idx].URL) 1845 + } 1846 + m.status = fmt.Sprintf("No link [%s].", key) 1847 + return m, nil 1848 + } 1849 + // Not a digit — fall through 1850 + case "g": // gg = top of email 1851 + if key == "g" { 1852 + m.reader.GotoTop() 1853 + return m, nil 1854 + } 1855 + } 1856 + } 1857 + 1858 + switch key { 1826 1859 case "q", "esc", "h": 1827 1860 m.state = stateInbox 1861 + m.readerPending = "" 1828 1862 return m, nil 1829 1863 case "e": 1830 1864 return m.openInNeovim() ··· 1853 1887 if idx < len(m.openAttachments) { 1854 1888 return m, m.downloadOpenAttachmentCmd(m.openAttachments[idx]) 1855 1889 } 1890 + case " ": 1891 + if len(m.openLinks) > 0 { 1892 + m.readerPending = " " 1893 + m.status = "open link: space+1-9 (0=10th)" 1894 + return m, nil 1895 + } 1896 + case "g": 1897 + m.readerPending = "g" 1898 + return m, nil 1899 + case "G": 1900 + m.reader.GotoBottom() 1901 + return m, nil 1856 1902 } 1857 1903 var cmd tea.Cmd 1858 1904 m.reader, cmd = m.reader.Update(msg) 1859 1905 return m, cmd 1906 + } 1907 + 1908 + // openLinkCmd opens a URL in $BROWSER (or xdg-open). 1909 + func (m Model) openLinkCmd(url string) tea.Cmd { 1910 + browser := os.Getenv("BROWSER") 1911 + if browser == "" { 1912 + browser = "xdg-open" 1913 + } 1914 + return func() tea.Msg { 1915 + cmd := exec.Command(browser, url) 1916 + _ = cmd.Start() 1917 + return nil 1918 + } 1860 1919 } 1861 1920 1862 1921 // openInBrowser writes the email as HTML to a temp file and opens it in ··· 2730 2789 b.WriteString(m.reader.View()) 2731 2790 } 2732 2791 isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts 2733 - b.WriteString("\n" + readerHelp(isDraft)) 2792 + b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0)) 2734 2793 return b.String() 2735 2794 } 2736 2795
+75 -5
internal/ui/reader.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "regexp" 5 6 "strings" 6 7 7 8 "github.com/charmbracelet/bubbles/viewport" ··· 9 10 "github.com/sspaeti/neomd/internal/render" 10 11 ) 11 12 13 + // emailLink holds an extracted link from the email body. 14 + type emailLink struct { 15 + Text string 16 + URL string 17 + } 18 + 19 + // mdLinkRe matches [text](url) in markdown. 20 + var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)]+)\)`) 21 + 22 + // extractLinks pulls all [text](url) links from markdown, deduplicating by URL. 23 + func extractLinks(markdown string) []emailLink { 24 + matches := mdLinkRe.FindAllStringSubmatch(markdown, -1) 25 + seen := make(map[string]bool) 26 + var links []emailLink 27 + for _, m := range matches { 28 + if len(m) < 3 { 29 + continue 30 + } 31 + url := m[2] 32 + if seen[url] { 33 + continue 34 + } 35 + seen[url] = true 36 + text := m[1] 37 + if len(text) > 40 { 38 + text = text[:37] + "..." 39 + } 40 + links = append(links, emailLink{Text: text, URL: url}) 41 + } 42 + if len(links) > 10 { 43 + links = links[:10] 44 + } 45 + return links 46 + } 12 47 13 48 // newReader creates a viewport for reading emails. 14 49 func newReader(width, height int) viewport.Model { ··· 17 52 return vp 18 53 } 19 54 55 + // numberLinks replaces [text](url) in markdown with [text [N]](url) so glamour 56 + // renders the link number inline where the link appears in the body. 57 + func numberLinks(body string, links []emailLink) string { 58 + if len(links) == 0 { 59 + return body 60 + } 61 + // Build URL → number map 62 + urlToNum := make(map[string]int, len(links)) 63 + for i, l := range links { 64 + n := i + 1 65 + if n == 10 { 66 + n = 0 67 + } 68 + urlToNum[l.URL] = n 69 + } 70 + return mdLinkRe.ReplaceAllStringFunc(body, func(m string) string { 71 + parts := mdLinkRe.FindStringSubmatch(m) 72 + if len(parts) < 3 { 73 + return m 74 + } 75 + text, url := parts[1], parts[2] 76 + if n, ok := urlToNum[url]; ok { 77 + return fmt.Sprintf("[%s [%d]](%s)", text, n, url) 78 + } 79 + return m 80 + }) 81 + } 82 + 20 83 // loadEmailIntoReader renders the email and sets the viewport content. 21 - func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body string, attachments []imap.Attachment, theme string, width int) error { 84 + func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body string, attachments []imap.Attachment, links []emailLink, theme string, width int) error { 22 85 header := renderEmailHeader(email, attachments, width) 23 86 24 - rendered, err := render.ToANSI(body, theme, width) 87 + // Inject link numbers inline before glamour rendering 88 + numbered := numberLinks(body, links) 89 + 90 + rendered, err := render.ToANSI(numbered, theme, width) 25 91 if err != nil { 26 92 rendered = body // fall back to raw markdown 27 93 } ··· 59 125 60 126 // readerHelp returns the one-line help string for the reader view. 61 127 // When isDraft is true, "E draft" is shown so the user knows they can re-open in compose. 62 - func readerHelp(isDraft bool) string { 63 - keys := []string{"j/k scroll", "space/d page", "h/q back", "r reply", "f fwd", "e nvim"} 128 + func readerHelp(isDraft bool, hasLinks bool) string { 129 + keys := []string{"j/k scroll", "h/q back", "r reply", "f fwd", "e nvim"} 64 130 if isDraft { 65 131 keys = append(keys, "E draft") 66 132 } 67 - keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attachment", "? help") 133 + keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach") 134 + if hasLinks { 135 + keys = append(keys, "space+1-9 links") 136 + } 137 + keys = append(keys, "? help") 68 138 return styleHelp.Render(" " + strings.Join(keys, " · ")) 69 139 } 70 140