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 time in reading view (with locale), auto-bcc

sspaeti 8886a601 95d27862

+81 -4
+5
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-05 4 + - **`auto_bcc` config** — root-level `auto_bcc = "addr@example.com"` appends an address to every outgoing email's Bcc so you keep a copy in an external mailbox (e.g. a hey.com archive); visible in the composer and pre-send review (no silent BCC), deduped against any manual Bcc entry 5 + - **`shift+tab` in compose** — navigate back through To/Cc/Bcc/Subject fields (previously could only move forward with tab/enter) 6 + - **Reader shows local time** — email dates in the reader header now convert to your system's local timezone and include the clock time (e.g. `Apr 05, 00:51`); previously showed the sender's timezone date without time 7 + 3 8 # 2026-04-02 4 9 - **Auto From on reply** — replying auto-selects the From address that matches the email's To/CC field (e.g. email sent to `simon@domain.com` replies from `simon@domain.com`); `r` now works from inbox list view; `# [neomd: from: ...]` shown in editor; `x` in pre-send discards the email 5 10 - **Email safety hardening** — bulk operations show live progress counter ("Screening: 42/1000…") for batches >10; screener now moves emails before updating list files (no inconsistent state on failure); SaveSent failure shown as warning instead of silently swallowed; batch failures report exact moved/total counts; partial batch undo info preserved on error; undo stack capped at 20
+6
internal/config/config.go
··· 139 139 Screener ScreenerConfig `toml:"screener"` 140 140 Folders FoldersConfig `toml:"folders"` 141 141 UI UIConfig `toml:"ui"` 142 + 143 + // AutoBCC, if set, is added to every outgoing email's Bcc field so the 144 + // user keeps a copy in an external mailbox (e.g. their hey.com archive). 145 + // Format: "addr@example.com" or "Name <addr@example.com>". Shown in the 146 + // composer and pre-send review so it's never a silent BCC. 147 + AutoBCC string `toml:"auto_bcc"` 142 148 } 143 149 144 150 // ActiveAccounts returns the list of configured accounts.
+33
internal/ui/compose.go
··· 188 188 // Tab without suggestions → next field (same as enter) 189 189 return c.advanceField() 190 190 191 + case "shift+tab": 192 + // Move to the previous field (To ← Cc ← Bcc ← Subject). 193 + return c.retreatField() 194 + 191 195 case "enter": 192 196 // Enter always advances to next field 193 197 if len(c.suggestions) > 0 && c.suggestI >= 0 { ··· 256 260 return c, nil, false 257 261 case stepSubject: 258 262 return c, nil, true 263 + } 264 + return c, nil, false 265 + } 266 + 267 + // retreatField moves to the previous compose field. Skips Cc/Bcc when hidden. 268 + func (c composeModel) retreatField() (composeModel, tea.Cmd, bool) { 269 + c.suggestions = nil 270 + c.suggestI = -1 271 + switch c.step { 272 + case stepSubject: 273 + if c.extraVisible { 274 + c.step = stepBCC 275 + c.subject.Blur() 276 + c.bcc.Focus() 277 + } else { 278 + c.step = stepTo 279 + c.subject.Blur() 280 + c.to.Focus() 281 + } 282 + case stepBCC: 283 + c.step = stepCC 284 + c.bcc.Blur() 285 + c.cc.Focus() 286 + case stepCC: 287 + c.step = stepTo 288 + c.cc.Blur() 289 + c.to.Focus() 290 + case stepTo: 291 + // already at the first field — no-op 259 292 } 260 293 return c, nil, false 261 294 }
+14
internal/ui/inbox.go
··· 162 162 } 163 163 } 164 164 165 + // fmtDateFull returns a date+time string for the reader header (always 166 + // includes the clock time, unlike fmtDate which is compact for list rows). 167 + func fmtDateFull(t time.Time) string { 168 + if t.IsZero() { 169 + return "" 170 + } 171 + t = t.Local() 172 + if t.Year() == time.Now().Year() { 173 + return t.Format("Jan 02, 15:04") 174 + } 175 + return t.Format("Jan 02 2006, 15:04") 176 + } 177 + 165 178 func fmtDate(t time.Time) string { 166 179 if t.IsZero() { 167 180 return " " 168 181 } 182 + t = t.Local() 169 183 now := time.Now() 170 184 if t.Year() == now.Year() && t.YearDay() == now.YearDay() { 171 185 return t.Format("15:04 ")
+20 -1
internal/ui/model.go
··· 1444 1444 1445 1445 // Go to pre-send review instead of sending immediately. 1446 1446 m.pendingSend = &pendingSendData{ 1447 - to: msg.to, cc: msg.cc, bcc: msg.bcc, 1447 + to: msg.to, cc: msg.cc, bcc: mergeAutoBCC(msg.bcc, m.cfg.AutoBCC), 1448 1448 subject: msg.subject, body: cleanBody, 1449 1449 } 1450 1450 m.state = statePresend ··· 2966 2966 } 2967 2967 2968 2968 // extractEmailAddr returns the bare email address from "Name <addr>" or "addr". 2969 + // mergeAutoBCC appends autoBCC to the existing bcc field, deduped by email 2970 + // address. Returns bcc unchanged when autoBCC is empty or already present. 2971 + func mergeAutoBCC(bcc, autoBCC string) string { 2972 + autoBCC = strings.TrimSpace(autoBCC) 2973 + if autoBCC == "" { 2974 + return bcc 2975 + } 2976 + autoAddr := strings.ToLower(extractEmailAddr(autoBCC)) 2977 + for _, p := range strings.Split(bcc, ",") { 2978 + if strings.ToLower(extractEmailAddr(strings.TrimSpace(p))) == autoAddr { 2979 + return bcc 2980 + } 2981 + } 2982 + if strings.TrimSpace(bcc) == "" { 2983 + return autoBCC 2984 + } 2985 + return bcc + ", " + autoBCC 2986 + } 2987 + 2969 2988 func extractEmailAddr(s string) string { 2970 2989 if i := strings.IndexByte(s, '<'); i >= 0 { 2971 2990 if j := strings.IndexByte(s, '>'); j > i {
+3 -3
internal/ui/reader.go
··· 106 106 styleFrom.Render("From: ") + e.From, 107 107 styleDate.Render("To: ") + e.To, 108 108 styleSubject.Render("Subject: ") + e.Subject, 109 - styleDate.Render("Date: ") + fmtDate(e.Date), 109 + styleDate.Render("Date: ") + fmtDateFull(e.Date), 110 110 } 111 111 112 112 if len(attachments) > 0 { ··· 158 158 case 0: // stepTo 159 159 return styleHelp.Render(" tab/enter next · ctrl+b toggle Cc/Bcc · ctrl+t attach" + fromHint + " · esc cancel") 160 160 case 1, 2: // stepCC, stepBCC 161 - return styleHelp.Render(" tab/enter next (optional) · ctrl+b hide Cc/Bcc · ctrl+t attach" + fromHint + " · esc cancel") 161 + return styleHelp.Render(" tab next · shift+tab prev · ctrl+b hide Cc/Bcc · ctrl+t attach" + fromHint + " · esc cancel") 162 162 default: // stepSubject 163 - return styleHelp.Render(" enter open editor · ctrl+t attach · D remove last" + fromHint + " · esc cancel") 163 + return styleHelp.Render(" enter open editor · shift+tab prev · ctrl+t attach · D remove last" + fromHint + " · esc cancel") 164 164 } 165 165 } 166 166