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.

remove > in plan text

sspaeti 4a68ab5d 5dbc19e7

+62 -14
+1 -1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 # 2026-04-17 4 - - **GitHub/Obsidian-style callouts in emails** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons using Kanagawa theme colors (crystalBlue, springGreen, carpYellow, oniViolet, autumnRed); compact spacing with emoji and title matching body text size (15px) for minimal visual intrusion; supports custom titles (`> [!note] Custom Title`), multiple paragraphs, and nested callouts; always expanded (no collapsible behavior), no JavaScript required; works in both syntaxes: `> [!note]` (with space) or `>[!note]` (without space); uses local fork of goldmark-obsidian-callout with email-optimized rendering; same syntax used in neomd's README now works in your composed emails 4 + - **GitHub/Obsidian-style callouts in emails** — compose emails with callout syntax `> [!note]`, `> [!tip]`, `> [!warning]` for styled alert boxes in HTML emails; rendered with colored left borders, subtle backgrounds, and emoji icons using Kanagawa theme colors (crystalBlue, springGreen, carpYellow, oniViolet, autumnRed); compact spacing with emoji and title matching body text size (15px) for minimal visual intrusion; supports custom titles (`> [!note] Custom Title`), multiple paragraphs, and nested callouts; always expanded (no collapsible behavior), no JavaScript required; works in both syntaxes: `> [!note]` (with space) or `>[!note]` (without space); plain text emails format callouts as emoji text without blockquote markers (readable in neomd reader and plain text clients); uses local fork of goldmark-obsidian-callout with email-optimized rendering; same syntax used in neomd's README now works in your composed emails 5 5 - **Timer-based mark-as-read** — emails are no longer marked as read immediately when opened; instead, a configurable timer (default 7 seconds) starts when you enter the reader; if you stay for the full duration, the email is marked as `\Seen`; if you exit early (quick peek), it stays unread; prevents accidental marking when browsing through emails 6 6 - **`mark_as_read_after_secs` config** — new `[ui]` option to control mark-as-read delay in seconds (default 7); set to `0` for immediate marking (old behavior); set to any value to customize the delay 7 7 - **Fix: local UI state sync on mark-as-read** — inbox list now updates immediately when an email is marked as read, either via timer or manual toggle (`n`); previously the server was updated but the local UI showed stale unread indicators until manual refresh
+20 -5
internal/render/html.go
··· 104 104 // Captures: (optional space after >)(type)(optional: + or -)(optional title) 105 105 var calloutRegex = regexp.MustCompile(`(?m)^(>\s*)\[!(\w+)\]([+-])?\s*(.*)?$`) 106 106 107 - // FormatCalloutsForPlainText converts callout markdown syntax to emoji-prefixed blockquotes. 108 - // Converts `> [!note] Title` to `> 📘 Title` (or `> 📘 Note` if no title). 109 - // This makes callouts readable in plain text email clients while preserving the blockquote structure. 107 + // FormatCalloutsForPlainText converts callout markdown syntax to emoji-prefixed text. 108 + // Converts `> [!note] Title` to `📘 Note` (or custom title if provided). 109 + // Removes blockquote markers since markdown renderers (glamour) would strip them anyway. 110 + // Content lines following the callout header are also unquoted for clean display. 110 111 func FormatCalloutsForPlainText(markdown string) string { 111 112 lines := strings.Split(markdown, "\n") 113 + inCallout := false 114 + 112 115 for i, line := range lines { 116 + // Check if this line starts a new callout 113 117 if calloutRegex.MatchString(line) { 114 118 submatches := calloutRegex.FindStringSubmatch(line) 115 119 if len(submatches) >= 5 { 116 - prefix := submatches[1] // "> " or ">" 117 120 calloutType := submatches[2] // "note", "tip", etc. 118 121 customTitle := submatches[4] // optional custom title 119 122 ··· 130 133 title = strings.ToUpper(calloutTypeLower[:1]) + calloutTypeLower[1:] 131 134 } 132 135 133 - lines[i] = prefix + emoji + " " + title 136 + // Replace with emoji title (no blockquote marker) 137 + lines[i] = emoji + " " + title 138 + inCallout = true 134 139 } 140 + } else if inCallout && strings.HasPrefix(line, ">") { 141 + // This is a content line of the callout - remove the blockquote marker 142 + lines[i] = strings.TrimPrefix(line, ">") 143 + lines[i] = strings.TrimPrefix(lines[i], " ") // Remove leading space after > 144 + } else if inCallout && strings.TrimSpace(line) == "" { 145 + // Empty line ends the callout 146 + inCallout = false 147 + } else if inCallout { 148 + // Non-blockquote line also ends the callout 149 + inCallout = false 135 150 } 136 151 } 137 152 return strings.Join(lines, "\n")
+37 -6
internal/render/html_test.go
··· 171 171 172 172 func TestFormatCalloutsForPlainText_WithTitle(t *testing.T) { 173 173 input := "> [!tip] Good News\n> We're ahead of schedule!\n" 174 - expected := "> 💡 Good News\n> We're ahead of schedule!\n" 174 + expected := "💡 Good News\nWe're ahead of schedule!\n" 175 175 got := FormatCalloutsForPlainText(input) 176 176 if got != expected { 177 177 t.Errorf("FormatCalloutsForPlainText with title:\nwant: %q\ngot: %q", expected, got) ··· 180 180 181 181 func TestFormatCalloutsForPlainText_NoTitle(t *testing.T) { 182 182 input := "> [!note]\n> This is a note\n" 183 - expected := "> 📘 Note\n> This is a note\n" 183 + expected := "📘 Note\nThis is a note\n" 184 184 got := FormatCalloutsForPlainText(input) 185 185 if got != expected { 186 186 t.Errorf("FormatCalloutsForPlainText without title:\nwant: %q\ngot: %q", expected, got) ··· 189 189 190 190 func TestFormatCalloutsForPlainText_MultipleCallouts(t *testing.T) { 191 191 input := "> [!warning] Action Required\n> Please review by Friday.\n\n> [!note]\n> Please read\n" 192 - expected := "> ⚠️ Action Required\n> Please review by Friday.\n\n> 📘 Note\n> Please read\n" 192 + expected := "⚠️ Action Required\nPlease review by Friday.\n\n📘 Note\nPlease read\n" 193 193 got := FormatCalloutsForPlainText(input) 194 194 if got != expected { 195 195 t.Errorf("FormatCalloutsForPlainText with multiple callouts:\nwant: %q\ngot: %q", expected, got) ··· 198 198 199 199 func TestFormatCalloutsForPlainText_NoSpaceAfterArrow(t *testing.T) { 200 200 input := ">[!tip] Title\n>Content here\n" 201 - // Should still match because regex handles both "> " and ">" 202 201 got := FormatCalloutsForPlainText(input) 203 202 if !strings.Contains(got, "💡 Title") { 204 203 t.Errorf("FormatCalloutsForPlainText should handle >[!type] without space:\ngot: %q", got) 205 204 } 205 + if !strings.Contains(got, "Content here") { 206 + t.Errorf("FormatCalloutsForPlainText should unquote content:\ngot: %q", got) 207 + } 208 + // Should NOT contain > markers 209 + if strings.Contains(got, ">") { 210 + t.Errorf("FormatCalloutsForPlainText should remove blockquote markers:\ngot: %q", got) 211 + } 206 212 } 207 213 208 214 func TestFormatCalloutsForPlainText_AllTypes(t *testing.T) { ··· 236 242 input := "Regular text\n\n> Regular blockquote\n> without callout\n\n> [!note] Callout\n> With content\n" 237 243 got := FormatCalloutsForPlainText(input) 238 244 239 - // Should preserve regular text and blockquotes 245 + // Should preserve regular text and non-callout blockquotes 240 246 if !strings.Contains(got, "Regular text") { 241 247 t.Error("should preserve regular text") 242 248 } 243 249 if !strings.Contains(got, "> Regular blockquote") { 244 250 t.Error("should preserve regular blockquotes") 245 251 } 246 - // Should format the callout 252 + // Should format the callout (no blockquote marker) 247 253 if !strings.Contains(got, "📘 Callout") { 248 254 t.Error("should format callout syntax") 255 + } 256 + if !strings.Contains(got, "With content") { 257 + t.Error("should include callout content") 258 + } 259 + } 260 + 261 + func TestFormatCalloutsForPlainText_MultiParagraphCallout(t *testing.T) { 262 + input := "> [!tip] Title\n> First paragraph\n> \n> Second paragraph\n" 263 + got := FormatCalloutsForPlainText(input) 264 + 265 + if !strings.Contains(got, "💡 Title") { 266 + t.Errorf("should have emoji title, got: %q", got) 267 + } 268 + if !strings.Contains(got, "First paragraph") { 269 + t.Errorf("should have first paragraph, got: %q", got) 270 + } 271 + if !strings.Contains(got, "Second paragraph") { 272 + t.Errorf("should have second paragraph, got: %q", got) 273 + } 274 + // Should not have > markers 275 + lines := strings.Split(got, "\n") 276 + for _, line := range lines { 277 + if strings.TrimSpace(line) != "" && strings.HasPrefix(strings.TrimSpace(line), ">") { 278 + t.Errorf("should not have > markers in callout content, got line: %q", line) 279 + } 249 280 } 250 281 }
+4 -2
internal/smtp/sender.go
··· 3 3 // get clickable links and formatted output while you write pure Markdown. 4 4 // 5 5 // Email format separation: Markdown input is converted to TWO independent formats: 6 - // - Plain text: Callouts formatted as emoji blockquotes (> [!note] → > 📘 Note) 6 + // - Plain text: Callouts formatted as emoji text without blockquotes (> [!note] → 📘 Note) 7 7 // - HTML: Full goldmark rendering with styled callout boxes 8 8 // These formats never mix - each is derived independently from the markdown source. 9 + // Plain text removes blockquote markers because terminal renderers would strip them anyway. 9 10 package smtp 10 11 11 12 import ( ··· 77 78 // This separation ensures we never mix the two formats - plain text gets readable callout formatting, 78 79 // HTML gets full goldmark rendering with styled callout boxes. 79 80 func prepareEmailBodies(markdownBody string) (plainText, htmlBody string, err error) { 80 - // Plain text part: Format callouts as emoji-prefixed blockquotes (> [!note] → > 📘 Note) 81 + // Plain text part: Format callouts as emoji text without blockquotes (> [!note] → 📘 Note) 82 + // Blockquote markers are removed because terminal renderers strip them during display anyway. 81 83 plainText = render.FormatCalloutsForPlainText(markdownBody) 82 84 83 85 // HTML part: Full goldmark rendering with styled callout boxes