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 callout support

sspaeti 248876c6 d6e7d5c6

+192 -2
+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 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 5 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 6 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
+67
docs/content/docs/sending.md
··· 14 14 - **Images** → `multipart/related` with `Content-ID` — displayed inline in the email body 15 15 - **Other files** (PDF, zip, …) → `multipart/mixed` — shown as downloadable attachments 16 16 17 + ## Callouts (Admonition) 18 + 19 + neomd supports GitHub/Obsidian-style [callouts](https://www.ssp.sh/brain/admonition-call-outs) through the [this extension (with my fork)](https://github.com/sspaeti/goldmark-obsidian-callout-for-neomd) for highlighted information boxes in your emails. Use the `> [!TYPE]` syntax to create styled alert boxes: 20 + 21 + This is how it looks at the recievers end: 22 + ![neomd](images/callouts.png) 23 + 24 + ```markdown 25 + > [!note] 26 + > This is a note callout with default styling 27 + 28 + > [!tip] Pro Tip 29 + > Use custom titles by adding text after the type 30 + 31 + > [!warning] Important 32 + > Callouts can have multiple paragraphs 33 + > 34 + > Just add blank blockquote lines between them 35 + 36 + > [!important] 37 + > Recipients see colored boxes with icons in HTML email clients 38 + > while plain text clients show it as a blockquote 39 + ``` 40 + 41 + **Available callout types:** 42 + - `[!note]` — Blue info box 43 + - `[!tip]` — Green success/tip box 44 + - `[!important]` — Purple important box 45 + - `[!warning]` — Yellow warning box 46 + - `[!caution]` — Red caution/danger box 47 + 48 + **Features:** 49 + - Custom titles — add text after the type: `> [!warning] Security Alert` 50 + - Multiple paragraphs — use `> ` (blockquote with space) for blank lines 51 + - Works in both syntaxes: `> [!note]` (with space) or `>[!note]` (without space) 52 + 53 + **What recipients see:** 54 + 55 + HTML email clients (Gmail, Outlook, Apple Mail) display callouts as colored boxes with: 56 + - Colored left border (4px solid) 57 + - Colored background 58 + - Bold title with icon 59 + - Proper spacing and padding 60 + 61 + >[!NOTE] 62 + > Plain text email clients show callouts as regular blockquotes (graceful degradation). 63 + 64 + **Example in composed email:** 65 + 66 + ```markdown 67 + Hi team, 68 + 69 + Here's the update on the project: 70 + 71 + > [!tip] Good News 72 + > We're ahead of schedule! The new feature shipped yesterday. 73 + 74 + > [!warning] Action Required 75 + > Please review the security audit by Friday. 76 + > 77 + > Contact @security if you have questions. 78 + 79 + Thanks, 80 + Simon 81 + ``` 82 + 83 + 17 84 ## Multiple From Addresses 18 85 19 86 Add `[[senders]]` blocks to config to define extra identities that share an existing account's SMTP credentials:
docs/static/images/callouts.png

This is a binary file and will not be displayed.

+3
go.mod
··· 5 5 require ( 6 6 github.com/BurntSushi/toml v1.6.0 7 7 github.com/JohannesKaufmann/html-to-markdown v1.6.0 8 + github.com/VojtaStruhar/goldmark-obsidian-callout v0.1.0 8 9 github.com/charmbracelet/bubbles v1.0.0 9 10 github.com/charmbracelet/bubbletea v1.3.10 10 11 github.com/charmbracelet/glamour v0.9.1 ··· 51 52 golang.org/x/term v0.30.0 // indirect 52 53 golang.org/x/text v0.23.0 // indirect 53 54 ) 55 + 56 + replace github.com/VojtaStruhar/goldmark-obsidian-callout => /home/sspaeti/git/email/goldmark-obsidian-callout-for-neomd
+19 -2
internal/render/html.go
··· 4 4 "bytes" 5 5 "fmt" 6 6 7 + callout "github.com/VojtaStruhar/goldmark-obsidian-callout" 7 8 "github.com/yuin/goldmark" 8 9 "github.com/yuin/goldmark/extension" 9 10 "github.com/yuin/goldmark/renderer/html" ··· 28 29 blockquote{border-left:3px solid #ddd;color:#666;margin:0 0 1em;padding-left:1em} 29 30 hr{border:0;border-bottom:1px solid #eee;margin:20px 0} 30 31 img{max-width:100%%;height:auto} 32 + .callout{border-left:3px solid;padding:8px 12px;margin:0.8em 0;border-radius:3px;background:#f6f8fa} 33 + .callout-title{font-weight:600;margin-bottom:4px;display:flex;align-items:center;font-size:15px} 34 + .callout-icon{font-size:15px;margin-right:6px} 35 + .callout-title-inner{line-height:1.3} 36 + .callout>:last-child{margin-bottom:0} 37 + .callout-note{border-left-color:#7E9CD8;background:#f0f3fc} 38 + .callout-tip{border-left-color:#98BB6C;background:#f2f7f0} 39 + .callout-important{border-left-color:#957FB8;background:#f4f2f7} 40 + .callout-warning{border-left-color:#E6C384;background:#fdf9f0} 41 + .callout-caution{border-left-color:#C34043;background:#fcf0f0} 42 + .callout-info{border-left-color:#7FB4CA;background:#f0f6f8} 43 + .callout-danger{border-left-color:#E82424;background:#fef0f0} 44 + .callout-success{border-left-color:#76946A;background:#f1f6f0} 31 45 </style> 32 46 </head> 33 47 <body> ··· 35 49 </body> 36 50 </html>` 37 51 38 - // md is the goldmark renderer with GFM extensions. 52 + // md is the goldmark renderer with GFM extensions and callout support. 39 53 var md = goldmark.New( 40 - goldmark.WithExtensions(extension.GFM), 54 + goldmark.WithExtensions( 55 + extension.GFM, 56 + callout.ObsidianCallout, 57 + ), 41 58 goldmark.WithRendererOptions(html.WithHardWraps()), 42 59 ) 43 60
+102
internal/render/html_test.go
··· 66 66 t.Fatalf("ToANSI returned error: %v", err) 67 67 } 68 68 } 69 + 70 + func TestToHTML_Callout_Note(t *testing.T) { 71 + md := "> [!note]\n> This is a note callout\n" 72 + out, err := ToHTML(md) 73 + if err != nil { 74 + t.Fatalf("ToHTML returned error: %v", err) 75 + } 76 + // Print actual HTML for debugging 77 + t.Logf("Actual HTML output:\n%s", out) 78 + if !strings.Contains(out, "callout") { 79 + t.Errorf("expected 'callout' class in output, got:\n%s", out) 80 + } 81 + if !strings.Contains(out, "callout-note") { 82 + t.Errorf("expected 'callout-note' class in output, got:\n%s", out) 83 + } 84 + if !strings.Contains(out, "This is a note callout") { 85 + t.Errorf("expected callout content in output, got:\n%s", out) 86 + } 87 + } 88 + 89 + func TestToHTML_Callout_WithTitle(t *testing.T) { 90 + md := "> [!warning] Custom Warning Title\n> This is a warning\n" 91 + out, err := ToHTML(md) 92 + if err != nil { 93 + t.Fatalf("ToHTML returned error: %v", err) 94 + } 95 + if !strings.Contains(out, "callout-warning") { 96 + t.Errorf("expected 'callout-warning' class in output, got:\n%s", out) 97 + } 98 + if !strings.Contains(out, "Custom Warning Title") { 99 + t.Errorf("expected custom title in output, got:\n%s", out) 100 + } 101 + if !strings.Contains(out, "This is a warning") { 102 + t.Errorf("expected callout content in output, got:\n%s", out) 103 + } 104 + } 105 + 106 + func TestToHTML_Callout_MultiParagraph(t *testing.T) { 107 + md := "> [!tip]\n> First paragraph\n> \n> Second paragraph\n" 108 + out, err := ToHTML(md) 109 + if err != nil { 110 + t.Fatalf("ToHTML returned error: %v", err) 111 + } 112 + if !strings.Contains(out, "callout-tip") { 113 + t.Errorf("expected 'callout-tip' class in output, got:\n%s", out) 114 + } 115 + if !strings.Contains(out, "First paragraph") { 116 + t.Errorf("expected first paragraph in output, got:\n%s", out) 117 + } 118 + if !strings.Contains(out, "Second paragraph") { 119 + t.Errorf("expected second paragraph in output, got:\n%s", out) 120 + } 121 + } 122 + 123 + func TestToHTML_Callout_Types(t *testing.T) { 124 + tests := []struct { 125 + name string 126 + callType string 127 + wantClass string 128 + }{ 129 + {"note", "note", "callout-note"}, 130 + {"tip", "tip", "callout-tip"}, 131 + {"important", "important", "callout-important"}, 132 + {"warning", "warning", "callout-warning"}, 133 + {"caution", "caution", "callout-caution"}, 134 + } 135 + 136 + for _, tt := range tests { 137 + t.Run(tt.name, func(t *testing.T) { 138 + md := "> [!" + tt.callType + "]\n> Test content\n" 139 + out, err := ToHTML(md) 140 + if err != nil { 141 + t.Fatalf("ToHTML returned error: %v", err) 142 + } 143 + if !strings.Contains(out, tt.wantClass) { 144 + t.Errorf("expected '%s' class in output, got:\n%s", tt.wantClass, out) 145 + } 146 + }) 147 + } 148 + } 149 + 150 + func TestToHTML_Callout_NoSpaceSyntax(t *testing.T) { 151 + // Test if >[!note] works without space after > 152 + md := ">[!note] No Space Test\n>This tests the syntax without space\n" 153 + out, err := ToHTML(md) 154 + if err != nil { 155 + t.Fatalf("ToHTML returned error: %v", err) 156 + } 157 + // Check if it rendered as callout or as regular blockquote 158 + if strings.Contains(out, "callout-note") { 159 + // Success: >[!note] (no space) DOES work as callout 160 + if !strings.Contains(out, "No Space Test") { 161 + t.Errorf("expected title in callout output, got:\n%s", out) 162 + } 163 + if !strings.Contains(out, "This tests the syntax without space") { 164 + t.Errorf("expected content in callout output, got:\n%s", out) 165 + } 166 + } else { 167 + // Failure: rendered as blockquote instead 168 + t.Errorf(">[!note] without space did not render as callout. Use '> [!note]' (with space) instead. Got:\n%s", out) 169 + } 170 + }