···11# Changelog
2233+# 2026-04-27
44+- **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
55+36# 2026-04-24
47- **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
58
+39-1
cmd/neomd/main.go
···55 "context"
66 "flag"
77 "fmt"
88+ "net/url"
89 "os"
910 "os/signal"
1011 "strings"
···2526 cfgPath := flag.String("config", "", "path to config.toml (default: ~/.config/neomd/config.toml)")
2627 showVersion := flag.Bool("version", false, "print version and exit")
2728 headless := flag.Bool("headless", false, "run in headless daemon mode (no TUI)")
2929+ mailtoFlag := flag.String("mailto", "", "open compose with a mailto: URI (e.g. mailto:user@example.com?subject=Hello)")
2830 flag.Parse()
3131+3232+ // Also accept mailto: URI as a positional argument (for xdg-open / .desktop handler).
3333+ mailtoURI := *mailtoFlag
3434+ if mailtoURI == "" && flag.NArg() > 0 && strings.HasPrefix(flag.Arg(0), "mailto:") {
3535+ mailtoURI = flag.Arg(0)
3636+ }
29373038 if *showVersion {
3139 fmt.Println("neomd", version)
···132140 } else {
133141 // TUI mode: run interactive interface
134142 ui.Version = version
135135- model := ui.New(cfg, imapClients, sc)
143143+ var mailto *ui.MailtoParams
144144+ if mailtoURI != "" {
145145+ mailto = parseMailto(mailtoURI)
146146+ }
147147+ model := ui.New(cfg, imapClients, sc, mailto)
136148137149 p := tea.NewProgram(
138150 model,
···161173// - If userSTARTTLS is true: always use STARTTLS (user explicitly enabled it)
162174// - Standard ports: 993 → TLS, 143 → STARTTLS
163175// - Non-standard ports: default to TLS (e.g., Proton Mail Bridge on 1143)
176176+// parseMailto parses a mailto: URI into MailtoParams.
177177+// Format: mailto:addr?subject=S&cc=C&bcc=B&body=B
178178+func parseMailto(raw string) *ui.MailtoParams {
179179+ // url.Parse chokes on mailto: without //, so fix up.
180180+ u, err := url.Parse(raw)
181181+ if err != nil {
182182+ return &ui.MailtoParams{To: raw}
183183+ }
184184+ to := u.Opaque // everything before ?
185185+ if to == "" {
186186+ to = u.Path
187187+ }
188188+ // Percent-decode the "to" field (some mailers encode spaces/commas).
189189+ if decoded, err := url.PathUnescape(to); err == nil {
190190+ to = decoded
191191+ }
192192+ q := u.Query()
193193+ return &ui.MailtoParams{
194194+ To: to,
195195+ CC: q.Get("cc"),
196196+ BCC: q.Get("bcc"),
197197+ Subject: q.Get("subject"),
198198+ Body: q.Get("body"),
199199+ }
200200+}
201201+164202func inferIMAPSecurity(port string, userSTARTTLS bool) (useTLS, useSTARTTLS bool) {
165203 if userSTARTTLS {
166204 // User explicitly set starttls=true in config — honor it.
+83
docs/content/docs/sending.md
···248248```
249249250250251251+252252+## Mailto Handler
253253+254254+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.
255255+256256+**Setup on Linux:**
257257+258258+```bash
259259+# Register neomd as the default mailto handler
260260+xdg-mime default neomd-mailto.desktop x-scheme-handler/mailto
261261+```
262262+263263+The `.desktop` file at `~/.local/share/applications/neomd-mailto.desktop`:
264264+265265+```ini
266266+[Desktop Entry]
267267+Type=Application
268268+Name=neomd (mailto)
269269+Comment=Compose email in neomd terminal email client
270270+Exec=foot -e /path/to/neomd-mailto.sh %u
271271+Icon=mail-send
272272+Terminal=false
273273+NoDisplay=true
274274+MimeType=x-scheme-handler/mailto;
275275+```
276276+277277+Replace `foot` with your terminal emulator (`alacritty`, `kitty`, `ghostty`, etc.). See the [wrapper script](#wrapper-script) section below for why a wrapper is needed.
278278+279279+**Usage:**
280280+281281+```bash
282282+# From the CLI (flag or positional argument)
283283+neomd --mailto "mailto:user@example.com?subject=Hello&body=Check%20this%20out"
284284+neomd "mailto:user@example.com?subject=Hello"
285285+286286+# Test the xdg handler
287287+xdg-open "mailto:user@example.com?subject=Test&body=Hello%20world"
288288+```
289289+290290+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).
291291+292292+293293+### Browser setup (Brave/Chrome)
294294+295295+Chromium-based browsers maintain their own protocol handler list that can override the system default. To use neomd for mailto links in Brave:
296296+297297+1. Go to `brave://settings/handlers` (or `chrome://settings/handlers` for Chrome)
298298+2. Remove any existing mailto handler (e.g. `office.hostpoint.ch`, `mail.google.com`)
299299+3. Next time you click a mailto link, Brave will show a dialog asking to open neomd
300300+301301+Check "Always allow" in the dialog to skip the prompt in the future.
302302+303303+### Wrapper script
304304+305305+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:
306306+307307+`~/.local/bin/neomd-mailto.sh`:
308308+309309+```bash
310310+#!/bin/zsh
311311+source ~/.zshrc 2>/dev/null
312312+/home/sspaeti/.local/bin/neomd "$1" 2>/tmp/neomd-mailto.log
313313+if [ $? -ne 0 ]; then
314314+ echo "neomd failed. Log:"
315315+ cat /tmp/neomd-mailto.log
316316+ read -p "Press enter to close."
317317+fi
318318+```
319319+320320+Make it executable: `chmod +x ~/.local/bin/neomd-mailto.sh`
321321+322322+Then reference it in the `.desktop` file:
323323+324324+```ini
325325+Exec=foot -e /home/sspaeti/.local/bin/neomd-mailto.sh %u
326326+```
327327+328328+Replace `zsh`/`.zshrc` with `bash`/`.bashrc` if you use bash. Replace `foot` with your terminal emulator.
329329+330330+### How it looks
331331+332332+
333333+
docs/static/images/mailto-open.png
This is a binary file and will not be displayed.
+55-2
internal/ui/model.go
···174174// Version is set by main.go at startup (from build-time ldflags).
175175var Version = "dev"
176176177177+// MailtoParams holds pre-filled compose fields from a mailto: URI.
178178+// When non-nil, the TUI starts directly in compose mode.
179179+type MailtoParams struct {
180180+ To string
181181+ CC string
182182+ BCC string
183183+ Subject string
184184+ Body string
185185+}
186186+177187// neomdTempDir returns /tmp/neomd/, creating it if needed.
178188// Using a dedicated subdirectory keeps temp files discoverable (e.g. recovering
179189// a draft after a crash) and avoids cluttering /tmp/.
···551561 // Default: date descending (newest first).
552562 sortField string
553563 sortReverse bool
564564+565565+ // mailto holds pre-filled compose fields from a mailto: URI.
566566+ // When set, the TUI opens compose immediately after init.
567567+ mailto *MailtoParams
568568+ mailtoBody string // body from mailto URI, consumed by launchEditorCmd
554569}
555570556571// New creates and initialises the TUI model.
557557-func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener) Model {
572572+func New(cfg *config.Config, clients []*imap.Client, sc *screener.Screener, mailto ...*MailtoParams) Model {
558573 sp := spinner.New()
559574 sp.Spinner = spinner.Dot
560575561576 compose := newComposeModel()
562577 compose.knownAddrs = sc.AllAddresses()
563578579579+ var mp *MailtoParams
580580+ if len(mailto) > 0 {
581581+ mp = mailto[0]
582582+ }
583583+564584 return Model{
565585 cfg: cfg,
566586 accounts: cfg.ActiveAccounts(),
···578598 startupNotice: detectStartupNotice(),
579599 sortField: "date",
580600 sortReverse: true, // newest first
601601+ mailto: mp,
581602 }
582603}
583604···16111632 m.startupNotice = ""
16121633 }
16131634 sortCmd := m.sortEmails() // applies sort and sets list items
16351635+16361636+ // mailto: open compose with pre-filled fields on first inbox load.
16371637+ if m.mailto != nil {
16381638+ mp := m.mailto
16391639+ m.mailto = nil // consume once
16401640+ m.attachments = nil
16411641+ m.compose.reset()
16421642+ m.presendFromI = 0
16431643+ if mp.To != "" {
16441644+ m.compose.to.SetValue(mp.To)
16451645+ }
16461646+ if mp.CC != "" {
16471647+ m.compose.cc.SetValue(mp.CC)
16481648+ m.compose.extraVisible = true
16491649+ }
16501650+ if mp.BCC != "" {
16511651+ m.compose.bcc.SetValue(mp.BCC)
16521652+ m.compose.extraVisible = true
16531653+ }
16541654+ if mp.Subject != "" {
16551655+ m.compose.subject.SetValue(mp.Subject)
16561656+ }
16571657+ m.state = stateCompose
16581658+ m.status = ""
16591659+ m.isError = false
16601660+ m.mailtoBody = mp.Body
16611661+ return m, tea.Batch(sortCmd, m.fetchFolderCountsCmd())
16621662+ }
1614166316151664 // First-run welcome: show a brief intro popup.
16161665 if config.IsFirstRun() {
···37743823 subject := m.compose.subject.Value()
37753824 prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.TextSignature())
3776382538263826+ // Consume any mailto body (pre-filled from --mailto flag).
38273827+ body := m.mailtoBody
38283828+ m.mailtoBody = ""
38293829+37773830 // Write temp file
37783831 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md")
37793832 if err != nil {
···37833836 return m, nil
37843837 }
37853838 tmpPath := f.Name()
37863786- f.WriteString(prelude) //nolint
38393839+ f.WriteString(prelude + body) //nolint
37873840 f.Close()
3788384137893842 editorBin := os.Getenv("EDITOR")