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.

add mailto option for using it as default email client

+180 -3
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-27 4 + - **Mailto handler (`--mailto` / positional URI)** — neomd can now be used as the system default `mailto:` handler; clicking a `mailto:` link in any browser opens a foot terminal with neomd in compose mode, pre-filled with To, CC, BCC, Subject, and Body from the URI; supports both `neomd --mailto "mailto:user@example.com?subject=Hello"` and `neomd "mailto:..."` (positional, for `.desktop` integration); registered via `xdg-mime` with a `neomd-mailto.desktop` file; after sending or cancelling, neomd continues as normal 5 + 3 6 # 2026-04-24 4 7 - **Disable threading in Sent folder** — the Sent tab now shows each email individually without thread grouping, ordered by date; threading remains active in all other folders; useful because Sent emails are your own outgoing messages and don't benefit from conversation grouping 5 8
+39 -1
cmd/neomd/main.go
··· 5 5 "context" 6 6 "flag" 7 7 "fmt" 8 + "net/url" 8 9 "os" 9 10 "os/signal" 10 11 "strings" ··· 25 26 cfgPath := flag.String("config", "", "path to config.toml (default: ~/.config/neomd/config.toml)") 26 27 showVersion := flag.Bool("version", false, "print version and exit") 27 28 headless := flag.Bool("headless", false, "run in headless daemon mode (no TUI)") 29 + mailtoFlag := flag.String("mailto", "", "open compose with a mailto: URI (e.g. mailto:user@example.com?subject=Hello)") 28 30 flag.Parse() 31 + 32 + // Also accept mailto: URI as a positional argument (for xdg-open / .desktop handler). 33 + mailtoURI := *mailtoFlag 34 + if mailtoURI == "" && flag.NArg() > 0 && strings.HasPrefix(flag.Arg(0), "mailto:") { 35 + mailtoURI = flag.Arg(0) 36 + } 29 37 30 38 if *showVersion { 31 39 fmt.Println("neomd", version) ··· 132 140 } else { 133 141 // TUI mode: run interactive interface 134 142 ui.Version = version 135 - model := ui.New(cfg, imapClients, sc) 143 + var mailto *ui.MailtoParams 144 + if mailtoURI != "" { 145 + mailto = parseMailto(mailtoURI) 146 + } 147 + model := ui.New(cfg, imapClients, sc, mailto) 136 148 137 149 p := tea.NewProgram( 138 150 model, ··· 161 173 // - If userSTARTTLS is true: always use STARTTLS (user explicitly enabled it) 162 174 // - Standard ports: 993 → TLS, 143 → STARTTLS 163 175 // - Non-standard ports: default to TLS (e.g., Proton Mail Bridge on 1143) 176 + // parseMailto parses a mailto: URI into MailtoParams. 177 + // Format: mailto:addr?subject=S&cc=C&bcc=B&body=B 178 + func parseMailto(raw string) *ui.MailtoParams { 179 + // url.Parse chokes on mailto: without //, so fix up. 180 + u, err := url.Parse(raw) 181 + if err != nil { 182 + return &ui.MailtoParams{To: raw} 183 + } 184 + to := u.Opaque // everything before ? 185 + if to == "" { 186 + to = u.Path 187 + } 188 + // Percent-decode the "to" field (some mailers encode spaces/commas). 189 + if decoded, err := url.PathUnescape(to); err == nil { 190 + to = decoded 191 + } 192 + q := u.Query() 193 + return &ui.MailtoParams{ 194 + To: to, 195 + CC: q.Get("cc"), 196 + BCC: q.Get("bcc"), 197 + Subject: q.Get("subject"), 198 + Body: q.Get("body"), 199 + } 200 + } 201 + 164 202 func inferIMAPSecurity(port string, userSTARTTLS bool) (useTLS, useSTARTTLS bool) { 165 203 if userSTARTTLS { 166 204 // User explicitly set starttls=true in config — honor it.
+83
docs/content/docs/sending.md
··· 248 248 ``` 249 249 250 250 251 + 252 + ## Mailto Handler 253 + 254 + neomd can be your system's default `mailto:` handler. When you click a `mailto:` link in a browser, neomd opens in a terminal with the compose form pre-filled. 255 + 256 + **Setup on Linux:** 257 + 258 + ```bash 259 + # Register neomd as the default mailto handler 260 + xdg-mime default neomd-mailto.desktop x-scheme-handler/mailto 261 + ``` 262 + 263 + The `.desktop` file at `~/.local/share/applications/neomd-mailto.desktop`: 264 + 265 + ```ini 266 + [Desktop Entry] 267 + Type=Application 268 + Name=neomd (mailto) 269 + Comment=Compose email in neomd terminal email client 270 + Exec=foot -e /path/to/neomd-mailto.sh %u 271 + Icon=mail-send 272 + Terminal=false 273 + NoDisplay=true 274 + MimeType=x-scheme-handler/mailto; 275 + ``` 276 + 277 + Replace `foot` with your terminal emulator (`alacritty`, `kitty`, `ghostty`, etc.). See the [wrapper script](#wrapper-script) section below for why a wrapper is needed. 278 + 279 + **Usage:** 280 + 281 + ```bash 282 + # From the CLI (flag or positional argument) 283 + neomd --mailto "mailto:user@example.com?subject=Hello&body=Check%20this%20out" 284 + neomd "mailto:user@example.com?subject=Hello" 285 + 286 + # Test the xdg handler 287 + xdg-open "mailto:user@example.com?subject=Test&body=Hello%20world" 288 + ``` 289 + 290 + Supported mailto fields: `to` (path), `cc`, `bcc`, `subject`, `body`. neomd opens the compose form with all fields pre-filled — proceed through the normal compose flow (To → Subject → editor → pre-send → send). 291 + 292 + 293 + ### Browser setup (Brave/Chrome) 294 + 295 + Chromium-based browsers maintain their own protocol handler list that can override the system default. To use neomd for mailto links in Brave: 296 + 297 + 1. Go to `brave://settings/handlers` (or `chrome://settings/handlers` for Chrome) 298 + 2. Remove any existing mailto handler (e.g. `office.hostpoint.ch`, `mail.google.com`) 299 + 3. Next time you click a mailto link, Brave will show a dialog asking to open neomd 300 + 301 + Check "Always allow" in the dialog to skip the prompt in the future. 302 + 303 + ### Wrapper script 304 + 305 + Since neomd is a TUI app, it needs a login shell to access environment variables (e.g. IMAP passwords). The `.desktop` file uses a wrapper script: 306 + 307 + `~/.local/bin/neomd-mailto.sh`: 308 + 309 + ```bash 310 + #!/bin/zsh 311 + source ~/.zshrc 2>/dev/null 312 + /home/sspaeti/.local/bin/neomd "$1" 2>/tmp/neomd-mailto.log 313 + if [ $? -ne 0 ]; then 314 + echo "neomd failed. Log:" 315 + cat /tmp/neomd-mailto.log 316 + read -p "Press enter to close." 317 + fi 318 + ``` 319 + 320 + Make it executable: `chmod +x ~/.local/bin/neomd-mailto.sh` 321 + 322 + Then reference it in the `.desktop` file: 323 + 324 + ```ini 325 + Exec=foot -e /home/sspaeti/.local/bin/neomd-mailto.sh %u 326 + ``` 327 + 328 + Replace `zsh`/`.zshrc` with `bash`/`.bashrc` if you use bash. Replace `foot` with your terminal emulator. 329 + 330 + ### How it looks 331 + 332 + ![mailto](/images/mailto-open.png) 333 +
docs/static/images/mailto-open.png

This is a binary file and will not be displayed.

+55 -2
internal/ui/model.go
··· 174 174 // Version is set by main.go at startup (from build-time ldflags). 175 175 var Version = "dev" 176 176 177 + // MailtoParams holds pre-filled compose fields from a mailto: URI. 178 + // When non-nil, the TUI starts directly in compose mode. 179 + type MailtoParams struct { 180 + To string 181 + CC string 182 + BCC string 183 + Subject string 184 + Body string 185 + } 186 + 177 187 // neomdTempDir returns /tmp/neomd/, creating it if needed. 178 188 // Using a dedicated subdirectory keeps temp files discoverable (e.g. recovering 179 189 // a draft after a crash) and avoids cluttering /tmp/. ··· 551 561 // Default: date descending (newest first). 552 562 sortField string 553 563 sortReverse bool 564 + 565 + // mailto holds pre-filled compose fields from a mailto: URI. 566 + // When set, the TUI opens compose immediately after init. 567 + mailto *MailtoParams 568 + mailtoBody string // body from mailto URI, consumed by launchEditorCmd 554 569 } 555 570 556 571 // New creates and initialises the TUI model. 557 - func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener) Model { 572 + func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener, mailto ...*MailtoParams) Model { 558 573 sp := spinner.New() 559 574 sp.Spinner = spinner.Dot 560 575 561 576 compose := newComposeModel() 562 577 compose.knownAddrs = sc.AllAddresses() 563 578 579 + var mp *MailtoParams 580 + if len(mailto) > 0 { 581 + mp = mailto[0] 582 + } 583 + 564 584 return Model{ 565 585 cfg: cfg, 566 586 accounts: cfg.ActiveAccounts(), ··· 578 598 startupNotice: detectStartupNotice(), 579 599 sortField: "date", 580 600 sortReverse: true, // newest first 601 + mailto: mp, 581 602 } 582 603 } 583 604 ··· 1611 1632 m.startupNotice = "" 1612 1633 } 1613 1634 sortCmd := m.sortEmails() // applies sort and sets list items 1635 + 1636 + // mailto: open compose with pre-filled fields on first inbox load. 1637 + if m.mailto != nil { 1638 + mp := m.mailto 1639 + m.mailto = nil // consume once 1640 + m.attachments = nil 1641 + m.compose.reset() 1642 + m.presendFromI = 0 1643 + if mp.To != "" { 1644 + m.compose.to.SetValue(mp.To) 1645 + } 1646 + if mp.CC != "" { 1647 + m.compose.cc.SetValue(mp.CC) 1648 + m.compose.extraVisible = true 1649 + } 1650 + if mp.BCC != "" { 1651 + m.compose.bcc.SetValue(mp.BCC) 1652 + m.compose.extraVisible = true 1653 + } 1654 + if mp.Subject != "" { 1655 + m.compose.subject.SetValue(mp.Subject) 1656 + } 1657 + m.state = stateCompose 1658 + m.status = "" 1659 + m.isError = false 1660 + m.mailtoBody = mp.Body 1661 + return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd()) 1662 + } 1614 1663 1615 1664 // First-run welcome: show a brief intro popup. 1616 1665 if config.IsFirstRun() { ··· 3774 3823 subject := m.compose.subject.Value() 3775 3824 prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.TextSignature()) 3776 3825 3826 + // Consume any mailto body (pre-filled from --mailto flag). 3827 + body := m.mailtoBody 3828 + m.mailtoBody = "" 3829 + 3777 3830 // Write temp file 3778 3831 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 3779 3832 if err != nil { ··· 3783 3836 return m, nil 3784 3837 } 3785 3838 tmpPath := f.Name() 3786 - f.WriteString(prelude) //nolint 3839 + f.WriteString(prelude + body) //nolint 3787 3840 f.Close() 3788 3841 3789 3842 editorBin := os.Getenv("EDITOR")