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.

update temp-dir with folder

sspaeti 91644a4f bd5b63af

+81 -11
+10 -2
internal/editor/editor.go
··· 6 6 "fmt" 7 7 "os" 8 8 "os/exec" 9 + "path/filepath" 9 10 "regexp" 10 11 "strings" 11 12 ) 12 13 14 + // tempDir returns /tmp/neomd/, creating it if needed. 15 + func tempDir() string { 16 + dir := filepath.Join(os.TempDir(), "neomd") 17 + os.MkdirAll(dir, 0700) //nolint 18 + return dir 19 + } 20 + 13 21 var neomdHeaderRe = regexp.MustCompile(`^# \[neomd: (\w+): (.*)\]$`) 14 22 15 23 // Compose writes prelude to a temp .md file, opens $EDITOR, waits for it ··· 17 25 // The caller is responsible for suspending/resuming the bubbletea program 18 26 // around this call (via tea.ExecProcess or tea.Suspend/Resume). 19 27 func Compose(prelude string) (string, error) { 20 - f, err := os.CreateTemp("", "neomd-*.md") 28 + f, err := os.CreateTemp(tempDir(), "neomd-*.md") 21 29 if err != nil { 22 30 return "", fmt.Errorf("create temp file: %w", err) 23 31 } ··· 54 62 // The caller is responsible for suspending/resuming the bubbletea program via 55 63 // tea.ExecProcess. Returns the command and the temp file path (caller removes it). 56 64 func View(content string) (*exec.Cmd, string, error) { 57 - f, err := os.CreateTemp("", "neomd-read-*.md") 65 + f, err := os.CreateTemp(tempDir(), "neomd-read-*.md") 58 66 if err != nil { 59 67 return nil, "", fmt.Errorf("create temp file: %w", err) 60 68 }
+71 -9
internal/ui/model.go
··· 95 95 } 96 96 ) 97 97 98 + // neomdTempDir returns /tmp/neomd/, creating it if needed. 99 + // Using a dedicated subdirectory keeps temp files discoverable (e.g. recovering 100 + // a draft after a crash) and avoids cluttering /tmp/. 101 + func neomdTempDir() string { 102 + dir := filepath.Join(os.TempDir(), "neomd") 103 + os.MkdirAll(dir, 0700) //nolint 104 + return dir 105 + } 106 + 98 107 // pendingSendData holds a composed message waiting in the pre-send review screen. 99 108 type pendingSendData struct { 100 109 to, cc, bcc, subject, body string ··· 1961 1970 } 1962 1971 } 1963 1972 1964 - f, err := os.CreateTemp("", "neomd-view-*.html") 1973 + f, err := os.CreateTemp(neomdTempDir(), "neomd-view-*.html") 1965 1974 if err != nil { 1966 1975 m.status = "open: " + err.Error() 1967 1976 m.isError = true ··· 2008 2017 } 2009 2018 } 2010 2019 2011 - f, err := os.CreateTemp("", "neomd-view-*.html") 2020 + f, err := os.CreateTemp(neomdTempDir(), "neomd-view-*.html") 2012 2021 if err != nil { 2013 2022 m.status = "open: " + err.Error() 2014 2023 m.isError = true ··· 2173 2182 prelude := editor.Prelude(to, cc, subject, "") 2174 2183 body := m.openBody 2175 2184 2176 - f, err := os.CreateTemp("", "neomd-*.md") 2185 + f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2177 2186 if err != nil { 2178 2187 m.status = "continueDraft: " + err.Error() 2179 2188 m.isError = true ··· 2293 2302 } 2294 2303 _ = prelude 2295 2304 return m.launchEditorCmd() 2305 + case "s": 2306 + // Open in nvim with spell checking, cursor on first error. 2307 + return m.launchSpellCheckCmd(ps) 2296 2308 case "d": 2297 2309 // Save to Drafts without sending. 2298 2310 return m, m.saveDraftCmd(m.presendFrom(), ps.to, ps.cc, ps.subject, ps.body, m.attachments) ··· 2320 2332 return m, nil 2321 2333 } 2322 2334 2335 + // launchSpellCheckCmd opens the composed email body in nvim with spell checking 2336 + // enabled and the cursor positioned on the first misspelled word. 2337 + // On return, the (possibly corrected) body replaces the pre-send body. 2338 + func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { 2339 + prelude := editor.Prelude(ps.to, ps.cc, ps.subject, "") 2340 + content := prelude + ps.body 2341 + 2342 + f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2343 + if err != nil { 2344 + m.status = "spellcheck: " + err.Error() 2345 + m.isError = true 2346 + return m, nil 2347 + } 2348 + tmpPath := f.Name() 2349 + f.WriteString(content) //nolint 2350 + f.Close() 2351 + 2352 + // Open nvim with spell on and jump to first misspelled word. 2353 + // VimEnter + defer_fn ensures spell activates AFTER all plugins load. 2354 + cmd := exec.Command("nvim", 2355 + "-c", `autocmd VimEnter * ++once lua vim.defer_fn(function() vim.wo.spell = true; vim.bo.spelllang = "en_us,de"; vim.cmd("normal! gg]s") end, 100)`, 2356 + tmpPath, 2357 + ) 2358 + m.state = stateCompose 2359 + return m, tea.ExecProcess(cmd, func(execErr error) tea.Msg { 2360 + defer os.Remove(tmpPath) 2361 + if execErr != nil { 2362 + return editorDoneMsg{err: execErr} 2363 + } 2364 + raw, readErr := os.ReadFile(tmpPath) 2365 + if readErr != nil { 2366 + return editorDoneMsg{err: readErr} 2367 + } 2368 + pto, pcc, pbcc, psubject, _ := editor.ParseHeaders(string(raw)) 2369 + if pto == "" { 2370 + pto = ps.to 2371 + } 2372 + if pcc == "" { 2373 + pcc = ps.cc 2374 + } 2375 + if pbcc == "" { 2376 + pbcc = ps.bcc 2377 + } 2378 + if psubject == "" { 2379 + psubject = ps.subject 2380 + } 2381 + return editorDoneMsg{to: pto, cc: pcc, bcc: pbcc, subject: psubject, body: string(raw)} 2382 + }) 2383 + } 2384 + 2323 2385 // previewInBrowser renders the composed email as HTML (same pipeline as sending) 2324 2386 // and opens it in $BROWSER so the user can verify images and formatting. 2325 2387 func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { ··· 2340 2402 // treat as server-relative; file:///abs/path loads from disk. 2341 2403 htmlBody = strings.ReplaceAll(htmlBody, `src="/`, `src="file:///`) 2342 2404 2343 - f, err := os.CreateTemp("", "neomd-preview-*.html") 2405 + f, err := os.CreateTemp(neomdTempDir(), "neomd-preview-*.html") 2344 2406 if err != nil { 2345 2407 m.status = "preview: " + err.Error() 2346 2408 m.isError = true ··· 2388 2450 prelude := editor.Prelude(to, cc, subject, m.cfg.UI.Signature) 2389 2451 2390 2452 // Write temp file 2391 - f, err := os.CreateTemp("", "neomd-*.md") 2453 + f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2392 2454 if err != nil { 2393 2455 m.status = err.Error() 2394 2456 m.isError = true ··· 2449 2511 return m, nil 2450 2512 } 2451 2513 2452 - chooserFile, err := os.CreateTemp("", "neomd-pick-*.txt") 2514 + chooserFile, err := os.CreateTemp(neomdTempDir(), "neomd-pick-*.txt") 2453 2515 if err != nil { 2454 2516 m.status = "attach: " + err.Error() 2455 2517 return m, nil ··· 2490 2552 subject := e.Subject 2491 2553 prelude := editor.ForwardPrelude(subject, e.From, e.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"), e.To, m.openBody) 2492 2554 2493 - f, err := os.CreateTemp("", "neomd-*.md") 2555 + f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2494 2556 if err != nil { 2495 2557 m.status = err.Error() 2496 2558 m.isError = true ··· 2567 2629 2568 2630 prelude := editor.ReplyPrelude(to, cc, subject, e.From, m.openBody) 2569 2631 2570 - f, err := os.CreateTemp("", "neomd-*.md") 2632 + f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") 2571 2633 if err != nil { 2572 2634 m.status = err.Error() 2573 2635 m.isError = true ··· 2758 2820 b.WriteString("\n") 2759 2821 } 2760 2822 2761 - b.WriteString(styleHelp.Render(" enter send · p preview · ctrl+f from · ctrl+b cc/bcc · a attach · D remove last · d draft · e edit · esc cancel")) 2823 + b.WriteString(styleHelp.Render(" enter send · s spell · p preview · ctrl+f from · ctrl+b cc/bcc · a attach · D remove last · d draft · e edit · esc cancel")) 2762 2824 return b.String() 2763 2825 } 2764 2826