Skip to content

Commit 248876c

Browse files
committed
adding callout support
1 parent d6e7d5c commit 248876c

File tree

6 files changed

+192
-2
lines changed

6 files changed

+192
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
# 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
45
- **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
56
- **`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
67
- **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

docs/content/docs/sending.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,73 @@ When attachments are present the MIME structure is upgraded automatically:
1414
- **Images**`multipart/related` with `Content-ID` — displayed inline in the email body
1515
- **Other files** (PDF, zip, …) → `multipart/mixed` — shown as downloadable attachments
1616

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+
1784
## Multiple From Addresses
1885

1986
Add `[[senders]]` blocks to config to define extra identities that share an existing account's SMTP credentials:

docs/static/images/callouts.png

184 KB
Loading

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.24.2
55
require (
66
github.com/BurntSushi/toml v1.6.0
77
github.com/JohannesKaufmann/html-to-markdown v1.6.0
8+
github.com/VojtaStruhar/goldmark-obsidian-callout v0.1.0
89
github.com/charmbracelet/bubbles v1.0.0
910
github.com/charmbracelet/bubbletea v1.3.10
1011
github.com/charmbracelet/glamour v0.9.1
@@ -51,3 +52,5 @@ require (
5152
golang.org/x/term v0.30.0 // indirect
5253
golang.org/x/text v0.23.0 // indirect
5354
)
55+
56+
replace github.com/VojtaStruhar/goldmark-obsidian-callout => /home/sspaeti/git/email/goldmark-obsidian-callout-for-neomd

internal/render/html.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"fmt"
66

7+
callout "github.com/VojtaStruhar/goldmark-obsidian-callout"
78
"github.com/yuin/goldmark"
89
"github.com/yuin/goldmark/extension"
910
"github.com/yuin/goldmark/renderer/html"
@@ -28,16 +29,32 @@ pre{background:#f6f8fa;padding:12px;border-radius:4px;overflow:auto;font-family:
2829
blockquote{border-left:3px solid #ddd;color:#666;margin:0 0 1em;padding-left:1em}
2930
hr{border:0;border-bottom:1px solid #eee;margin:20px 0}
3031
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}
3145
</style>
3246
</head>
3347
<body>
3448
%s
3549
</body>
3650
</html>`
3751

38-
// md is the goldmark renderer with GFM extensions.
52+
// md is the goldmark renderer with GFM extensions and callout support.
3953
var md = goldmark.New(
40-
goldmark.WithExtensions(extension.GFM),
54+
goldmark.WithExtensions(
55+
extension.GFM,
56+
callout.ObsidianCallout,
57+
),
4158
goldmark.WithRendererOptions(html.WithHardWraps()),
4259
)
4360

internal/render/html_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,105 @@ func TestToANSI_Smoke(t *testing.T) {
6666
t.Fatalf("ToANSI returned error: %v", err)
6767
}
6868
}
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+
}

0 commit comments

Comments
 (0)