···11# Changelog
2233# 2026-04-17
44-- **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
44+- **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
55- **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
66- **`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
77- **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
···104104// Captures: (optional space after >)(type)(optional: + or -)(optional title)
105105var calloutRegex = regexp.MustCompile(`(?m)^(>\s*)\[!(\w+)\]([+-])?\s*(.*)?$`)
106106107107-// FormatCalloutsForPlainText converts callout markdown syntax to emoji-prefixed blockquotes.
108108-// Converts `> [!note] Title` to `> 📘 Title` (or `> 📘 Note` if no title).
109109-// This makes callouts readable in plain text email clients while preserving the blockquote structure.
107107+// FormatCalloutsForPlainText converts callout markdown syntax to emoji-prefixed text.
108108+// Converts `> [!note] Title` to `📘 Note` (or custom title if provided).
109109+// Removes blockquote markers since markdown renderers (glamour) would strip them anyway.
110110+// Content lines following the callout header are also unquoted for clean display.
110111func FormatCalloutsForPlainText(markdown string) string {
111112 lines := strings.Split(markdown, "\n")
113113+ inCallout := false
114114+112115 for i, line := range lines {
116116+ // Check if this line starts a new callout
113117 if calloutRegex.MatchString(line) {
114118 submatches := calloutRegex.FindStringSubmatch(line)
115119 if len(submatches) >= 5 {
116116- prefix := submatches[1] // "> " or ">"
117120 calloutType := submatches[2] // "note", "tip", etc.
118121 customTitle := submatches[4] // optional custom title
119122···130133 title = strings.ToUpper(calloutTypeLower[:1]) + calloutTypeLower[1:]
131134 }
132135133133- lines[i] = prefix + emoji + " " + title
136136+ // Replace with emoji title (no blockquote marker)
137137+ lines[i] = emoji + " " + title
138138+ inCallout = true
134139 }
140140+ } else if inCallout && strings.HasPrefix(line, ">") {
141141+ // This is a content line of the callout - remove the blockquote marker
142142+ lines[i] = strings.TrimPrefix(line, ">")
143143+ lines[i] = strings.TrimPrefix(lines[i], " ") // Remove leading space after >
144144+ } else if inCallout && strings.TrimSpace(line) == "" {
145145+ // Empty line ends the callout
146146+ inCallout = false
147147+ } else if inCallout {
148148+ // Non-blockquote line also ends the callout
149149+ inCallout = false
135150 }
136151 }
137152 return strings.Join(lines, "\n")
+37-6
internal/render/html_test.go
···171171172172func TestFormatCalloutsForPlainText_WithTitle(t *testing.T) {
173173 input := "> [!tip] Good News\n> We're ahead of schedule!\n"
174174- expected := "> 💡 Good News\n> We're ahead of schedule!\n"
174174+ expected := "💡 Good News\nWe're ahead of schedule!\n"
175175 got := FormatCalloutsForPlainText(input)
176176 if got != expected {
177177 t.Errorf("FormatCalloutsForPlainText with title:\nwant: %q\ngot: %q", expected, got)
···180180181181func TestFormatCalloutsForPlainText_NoTitle(t *testing.T) {
182182 input := "> [!note]\n> This is a note\n"
183183- expected := "> 📘 Note\n> This is a note\n"
183183+ expected := "📘 Note\nThis is a note\n"
184184 got := FormatCalloutsForPlainText(input)
185185 if got != expected {
186186 t.Errorf("FormatCalloutsForPlainText without title:\nwant: %q\ngot: %q", expected, got)
···189189190190func TestFormatCalloutsForPlainText_MultipleCallouts(t *testing.T) {
191191 input := "> [!warning] Action Required\n> Please review by Friday.\n\n> [!note]\n> Please read\n"
192192- expected := "> ⚠️ Action Required\n> Please review by Friday.\n\n> 📘 Note\n> Please read\n"
192192+ expected := "⚠️ Action Required\nPlease review by Friday.\n\n📘 Note\nPlease read\n"
193193 got := FormatCalloutsForPlainText(input)
194194 if got != expected {
195195 t.Errorf("FormatCalloutsForPlainText with multiple callouts:\nwant: %q\ngot: %q", expected, got)
···198198199199func TestFormatCalloutsForPlainText_NoSpaceAfterArrow(t *testing.T) {
200200 input := ">[!tip] Title\n>Content here\n"
201201- // Should still match because regex handles both "> " and ">"
202201 got := FormatCalloutsForPlainText(input)
203202 if !strings.Contains(got, "💡 Title") {
204203 t.Errorf("FormatCalloutsForPlainText should handle >[!type] without space:\ngot: %q", got)
205204 }
205205+ if !strings.Contains(got, "Content here") {
206206+ t.Errorf("FormatCalloutsForPlainText should unquote content:\ngot: %q", got)
207207+ }
208208+ // Should NOT contain > markers
209209+ if strings.Contains(got, ">") {
210210+ t.Errorf("FormatCalloutsForPlainText should remove blockquote markers:\ngot: %q", got)
211211+ }
206212}
207213208214func TestFormatCalloutsForPlainText_AllTypes(t *testing.T) {
···236242 input := "Regular text\n\n> Regular blockquote\n> without callout\n\n> [!note] Callout\n> With content\n"
237243 got := FormatCalloutsForPlainText(input)
238244239239- // Should preserve regular text and blockquotes
245245+ // Should preserve regular text and non-callout blockquotes
240246 if !strings.Contains(got, "Regular text") {
241247 t.Error("should preserve regular text")
242248 }
243249 if !strings.Contains(got, "> Regular blockquote") {
244250 t.Error("should preserve regular blockquotes")
245251 }
246246- // Should format the callout
252252+ // Should format the callout (no blockquote marker)
247253 if !strings.Contains(got, "📘 Callout") {
248254 t.Error("should format callout syntax")
255255+ }
256256+ if !strings.Contains(got, "With content") {
257257+ t.Error("should include callout content")
258258+ }
259259+}
260260+261261+func TestFormatCalloutsForPlainText_MultiParagraphCallout(t *testing.T) {
262262+ input := "> [!tip] Title\n> First paragraph\n> \n> Second paragraph\n"
263263+ got := FormatCalloutsForPlainText(input)
264264+265265+ if !strings.Contains(got, "💡 Title") {
266266+ t.Errorf("should have emoji title, got: %q", got)
267267+ }
268268+ if !strings.Contains(got, "First paragraph") {
269269+ t.Errorf("should have first paragraph, got: %q", got)
270270+ }
271271+ if !strings.Contains(got, "Second paragraph") {
272272+ t.Errorf("should have second paragraph, got: %q", got)
273273+ }
274274+ // Should not have > markers
275275+ lines := strings.Split(got, "\n")
276276+ for _, line := range lines {
277277+ if strings.TrimSpace(line) != "" && strings.HasPrefix(strings.TrimSpace(line), ">") {
278278+ t.Errorf("should not have > markers in callout content, got line: %q", line)
279279+ }
249280 }
250281}
+4-2
internal/smtp/sender.go
···33// get clickable links and formatted output while you write pure Markdown.
44//
55// Email format separation: Markdown input is converted to TWO independent formats:
66-// - Plain text: Callouts formatted as emoji blockquotes (> [!note] → > 📘 Note)
66+// - Plain text: Callouts formatted as emoji text without blockquotes (> [!note] → 📘 Note)
77// - HTML: Full goldmark rendering with styled callout boxes
88// These formats never mix - each is derived independently from the markdown source.
99+// Plain text removes blockquote markers because terminal renderers would strip them anyway.
910package smtp
10111112import (
···7778// This separation ensures we never mix the two formats - plain text gets readable callout formatting,
7879// HTML gets full goldmark rendering with styled callout boxes.
7980func prepareEmailBodies(markdownBody string) (plainText, htmlBody string, err error) {
8080- // Plain text part: Format callouts as emoji-prefixed blockquotes (> [!note] → > 📘 Note)
8181+ // Plain text part: Format callouts as emoji text without blockquotes (> [!note] → 📘 Note)
8282+ // Blockquote markers are removed because terminal renderers strip them during display anyway.
8183 plainText = render.FormatCalloutsForPlainText(markdownBody)
82848385 // HTML part: Full goldmark rendering with styled callout boxes