···55- **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`
66- **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)
77- **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
88+- **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
89- **Draft signature fix** — re-opening a draft (`E`) no longer appends a duplicate signature; the draft body already contains it from the first compose
910- **Draft reader footer** — `E draft` now appears in the reader footer when viewing an email from the Drafts folder
1011- **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
···6666- **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
6767- **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
6868- **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
6969+- **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER`
6970- **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients
7071- **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose
7172- **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
···147148148149Compose in Markdown, send as `multipart/alternative` (plain text + HTML). Attachments, CC/BCC, multiple From addresses, drafts, and pre-send review are all supported.
149150150150-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.
151151+- See [docs/sending.md](docs/sending.md) for details on MIME structure, attachments, pre-send review, and drafts.
152152+- See [docs/reading.md](docs/reading.md) for the reader: images, inline links, attachments, and navigation.
151153152154## Make Targets
153155
+1
docs/keybindings.md
···120120| `e (pre-send)` | re-open editor to edit body |
121121| `enter (pre-send)` | confirm and send |
122122| `1-9 (reader)` | download attachment N to ~/Downloads and open with xdg-open |
123123+| `space+1-9 (reader)` | open link N in $BROWSER (0 = 10th link) |
123124| `e (reader)` | open in $EDITOR read-only — search, copy, vim motions |
124125| `E (reader)` | continue draft — re-open as editable compose (Drafts folder) |
125126| `o (reader)` | open in w3m (terminal browser) |
+57
docs/reading.md
···11+# Reading Emails
22+33+Emails are rendered as styled Markdown in the terminal using [glamour](https://github.com/charmbracelet/glamour). The reader supports vim-style navigation.
44+55+## Navigation
66+77+| Key | Action |
88+|-----|--------|
99+| `j` / `k` | scroll line by line |
1010+| `space` / `d` | page down / up |
1111+| `gg` | jump to top of email |
1212+| `G` | jump to bottom of email |
1313+| `h` / `q` / `esc` | back to inbox |
1414+1515+## Opening Emails Externally
1616+1717+| Key | Action |
1818+|-----|--------|
1919+| `e` | open in `$EDITOR` (read-only) — search, copy, vim motions |
2020+| `o` | open in w3m (terminal browser, clickable links) |
2121+| `O` | open in `$BROWSER` (GUI browser, images rendered) |
2222+| `ctrl+o` | open newsletter web version in `$BROWSER` (from `List-Post` header) |
2323+2424+## Images
2525+2626+Remote images appear as `[Image: alt]` placeholders, keeping the reading experience clean and fast. To see images, press `O` to open in your browser.
2727+2828+**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.
2929+3030+## Links
3131+3232+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.
3333+3434+Press `space` then a digit (`1`–`9`, `0` for 10th) to open the link in `$BROWSER`.
3535+3636+- Up to 10 links per email, deduplicated by URL
3737+- Numbers appear inline so you can see them while reading without scrolling
3838+- If an email has no links, `space` works as page-down as usual
3939+4040+## Attachments
4141+4242+Attachments are listed in the reader header:
4343+4444+```
4545+Attach: [1] report.pdf [2] photo.png
4646+```
4747+4848+Press `1`–`9` to download attachment N to `~/Downloads/` and open it with `xdg-open`. Filenames are deduplicated automatically if a file already exists.
4949+5050+## Replying, Forwarding, and Drafts
5151+5252+| Key | Action |
5353+|-----|--------|
5454+| `r` | reply to sender |
5555+| `R` | reply-all (sender + all CC recipients) |
5656+| `f` | forward email |
5757+| `E` | continue draft (only in Drafts folder) — re-opens as editable compose |
+1-5
docs/sending.md
···76767777Press `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.
78787979-## Images in the Reader
8080-8181-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.
8282-8383-**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.
7979+For reading emails — images, links, attachments, and navigation — see [reading.md](reading.md).
+1
internal/ui/keys.go
···9292 {"e (pre-send)", "re-open editor to edit body"},
9393 {"enter (pre-send)", "confirm and send"},
9494 {"1-9 (reader)", "download attachment N to ~/Downloads and open with xdg-open"},
9595+ {"space+1-9 (reader)", "open link N in $BROWSER (0 = 10th link)"},
9596 {"e (reader)", "open in $EDITOR read-only — search, copy, vim motions"},
9697 {"E (reader)", "continue draft — re-open as editable compose (Drafts folder)"},
9798 {"o (reader)", "open in w3m (terminal browser)"},
+62-3
internal/ui/model.go
···143143 openHTMLBody string // original HTML part; used by openInExternalViewer when available
144144 openWebURL string // canonical "view online" URL for ctrl+o (may be empty)
145145 openAttachments []imap.Attachment // attachments of the currently open email
146146+ openLinks []emailLink // extracted links from the email body
147147+ readerPending string // chord prefix in reader (space for link open)
146148147149 // Compose / pre-send
148150 compose composeModel
···920922 m.pendingForward = false
921923 return m.launchForwardCmd()
922924 }
923923- _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.cfg.UI.Theme, m.width)
925925+ m.openLinks = extractLinks(msg.body)
926926+ _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width)
924927 m.state = stateReading
925928 return m, nil
926929···18221825}
1823182618241827func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
18251825- switch msg.String() {
18281828+ key := msg.String()
18291829+18301830+ // Handle reader chords
18311831+ if m.readerPending != "" {
18321832+ pending := m.readerPending
18331833+ m.readerPending = ""
18341834+ switch pending {
18351835+ case " ": // space + digit opens link
18361836+ if len(key) == 1 && key >= "0" && key <= "9" {
18371837+ var idx int
18381838+ if key == "0" {
18391839+ idx = 9
18401840+ } else {
18411841+ idx = int(key[0] - '1')
18421842+ }
18431843+ if idx < len(m.openLinks) {
18441844+ return m, m.openLinkCmd(m.openLinks[idx].URL)
18451845+ }
18461846+ m.status = fmt.Sprintf("No link [%s].", key)
18471847+ return m, nil
18481848+ }
18491849+ // Not a digit — fall through
18501850+ case "g": // gg = top of email
18511851+ if key == "g" {
18521852+ m.reader.GotoTop()
18531853+ return m, nil
18541854+ }
18551855+ }
18561856+ }
18571857+18581858+ switch key {
18261859 case "q", "esc", "h":
18271860 m.state = stateInbox
18611861+ m.readerPending = ""
18281862 return m, nil
18291863 case "e":
18301864 return m.openInNeovim()
···18531887 if idx < len(m.openAttachments) {
18541888 return m, m.downloadOpenAttachmentCmd(m.openAttachments[idx])
18551889 }
18901890+ case " ":
18911891+ if len(m.openLinks) > 0 {
18921892+ m.readerPending = " "
18931893+ m.status = "open link: space+1-9 (0=10th)"
18941894+ return m, nil
18951895+ }
18961896+ case "g":
18971897+ m.readerPending = "g"
18981898+ return m, nil
18991899+ case "G":
19001900+ m.reader.GotoBottom()
19011901+ return m, nil
18561902 }
18571903 var cmd tea.Cmd
18581904 m.reader, cmd = m.reader.Update(msg)
18591905 return m, cmd
19061906+}
19071907+19081908+// openLinkCmd opens a URL in $BROWSER (or xdg-open).
19091909+func (m Model) openLinkCmd(url string) tea.Cmd {
19101910+ browser := os.Getenv("BROWSER")
19111911+ if browser == "" {
19121912+ browser = "xdg-open"
19131913+ }
19141914+ return func() tea.Msg {
19151915+ cmd := exec.Command(browser, url)
19161916+ _ = cmd.Start()
19171917+ return nil
19181918+ }
18601919}
1861192018621921// openInBrowser writes the email as HTML to a temp file and opens it in
···27302789 b.WriteString(m.reader.View())
27312790 }
27322791 isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts
27332733- b.WriteString("\n" + readerHelp(isDraft))
27922792+ b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0))
27342793 return b.String()
27352794}
27362795
+75-5
internal/ui/reader.go
···2233import (
44 "fmt"
55+ "regexp"
56 "strings"
6778 "github.com/charmbracelet/bubbles/viewport"
···910 "github.com/sspaeti/neomd/internal/render"
1011)
11121313+// emailLink holds an extracted link from the email body.
1414+type emailLink struct {
1515+ Text string
1616+ URL string
1717+}
1818+1919+// mdLinkRe matches [text](url) in markdown.
2020+var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)]+)\)`)
2121+2222+// extractLinks pulls all [text](url) links from markdown, deduplicating by URL.
2323+func extractLinks(markdown string) []emailLink {
2424+ matches := mdLinkRe.FindAllStringSubmatch(markdown, -1)
2525+ seen := make(map[string]bool)
2626+ var links []emailLink
2727+ for _, m := range matches {
2828+ if len(m) < 3 {
2929+ continue
3030+ }
3131+ url := m[2]
3232+ if seen[url] {
3333+ continue
3434+ }
3535+ seen[url] = true
3636+ text := m[1]
3737+ if len(text) > 40 {
3838+ text = text[:37] + "..."
3939+ }
4040+ links = append(links, emailLink{Text: text, URL: url})
4141+ }
4242+ if len(links) > 10 {
4343+ links = links[:10]
4444+ }
4545+ return links
4646+}
12471348// newReader creates a viewport for reading emails.
1449func newReader(width, height int) viewport.Model {
···1752 return vp
1853}
19545555+// numberLinks replaces [text](url) in markdown with [text [N]](url) so glamour
5656+// renders the link number inline where the link appears in the body.
5757+func numberLinks(body string, links []emailLink) string {
5858+ if len(links) == 0 {
5959+ return body
6060+ }
6161+ // Build URL → number map
6262+ urlToNum := make(map[string]int, len(links))
6363+ for i, l := range links {
6464+ n := i + 1
6565+ if n == 10 {
6666+ n = 0
6767+ }
6868+ urlToNum[l.URL] = n
6969+ }
7070+ return mdLinkRe.ReplaceAllStringFunc(body, func(m string) string {
7171+ parts := mdLinkRe.FindStringSubmatch(m)
7272+ if len(parts) < 3 {
7373+ return m
7474+ }
7575+ text, url := parts[1], parts[2]
7676+ if n, ok := urlToNum[url]; ok {
7777+ return fmt.Sprintf("[%s [%d]](%s)", text, n, url)
7878+ }
7979+ return m
8080+ })
8181+}
8282+2083// loadEmailIntoReader renders the email and sets the viewport content.
2121-func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body string, attachments []imap.Attachment, theme string, width int) error {
8484+func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body string, attachments []imap.Attachment, links []emailLink, theme string, width int) error {
2285 header := renderEmailHeader(email, attachments, width)
23862424- rendered, err := render.ToANSI(body, theme, width)
8787+ // Inject link numbers inline before glamour rendering
8888+ numbered := numberLinks(body, links)
8989+9090+ rendered, err := render.ToANSI(numbered, theme, width)
2591 if err != nil {
2692 rendered = body // fall back to raw markdown
2793 }
···5912560126// readerHelp returns the one-line help string for the reader view.
61127// When isDraft is true, "E draft" is shown so the user knows they can re-open in compose.
6262-func readerHelp(isDraft bool) string {
6363- keys := []string{"j/k scroll", "space/d page", "h/q back", "r reply", "f fwd", "e nvim"}
128128+func readerHelp(isDraft bool, hasLinks bool) string {
129129+ keys := []string{"j/k scroll", "h/q back", "r reply", "f fwd", "e nvim"}
64130 if isDraft {
65131 keys = append(keys, "E draft")
66132 }
6767- keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attachment", "? help")
133133+ keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach")
134134+ if hasLinks {
135135+ keys = append(keys, "space+1-9 links")
136136+ }
137137+ keys = append(keys, "? help")
68138 return styleHelp.Render(" " + strings.Join(keys, " · "))
69139}
70140