A minimal email TUI where you read with Markdown and write in Neovim.
neomd.ssp.sh/docs
email
markdown
neovim
tui
1package render
2
3import (
4 "bytes"
5 "fmt"
6 "regexp"
7 "strings"
8
9 callout "github.com/sspaeti/goldmark-obsidian-callout-for-neomd"
10 "github.com/yuin/goldmark"
11 "github.com/yuin/goldmark/extension"
12 "github.com/yuin/goldmark/renderer/html"
13)
14
15// htmlTemplate is a minimal, self-contained email wrapper.
16// Derived from the listmonk template at:
17// /home/sspaeti/git/sspaeti.com/listmonk/misc/email-template.html
18const htmlTemplate = `<!DOCTYPE html>
19<html>
20<head>
21<meta charset="UTF-8">
22<meta name="viewport" content="width=device-width,initial-scale=1.0">
23<meta http-equiv="Content-Security-Policy" content="script-src 'none'; frame-src 'none'; object-src 'none';">
24<style>
25body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.6;color:#333;margin:0;padding:8px 16px;text-align:left}
26a{color:#3150AA;text-decoration:underline}
27h1,h2,h3{color:#24292e;margin:1.2em 0 .4em;line-height:1.3}
28h1{font-size:22px}h2{font-size:18px}h3{font-size:16px}
29p,ul,ol{font-size:15px;margin:0 0 1em}
30code{background:#f6f8fa;padding:2px 4px;border-radius:3px;font-family:monospace;font-size:85%%}
31pre{background:#f6f8fa;padding:12px;border-radius:4px;overflow:auto;font-family:monospace;font-size:85%%;line-height:1.4}
32blockquote{border-left:3px solid #ddd;color:#666;margin:0 0 1em;padding-left:1em}
33hr{border:0;border-bottom:1px solid #eee;margin:20px 0}
34img{max-width:100%%;height:auto}
35.callout{border-left:3px solid;padding:8px 12px;margin:0.8em 0;border-radius:3px;background:#f6f8fa}
36.callout-title{font-weight:600;margin-bottom:4px;display:flex;align-items:center;font-size:15px}
37.callout-icon{font-size:15px;margin-right:6px}
38.callout-title-inner{line-height:1.3}
39.callout>:last-child{margin-bottom:0}
40.callout-note{border-left-color:#7E9CD8;background:#f0f3fc}
41.callout-tip{border-left-color:#98BB6C;background:#f2f7f0}
42.callout-important{border-left-color:#957FB8;background:#f4f2f7}
43.callout-warning{border-left-color:#E6C384;background:#fdf9f0}
44.callout-caution{border-left-color:#C34043;background:#fcf0f0}
45.callout-info{border-left-color:#7FB4CA;background:#f0f6f8}
46.callout-danger{border-left-color:#E82424;background:#fef0f0}
47.callout-success{border-left-color:#76946A;background:#f1f6f0}
48</style>
49</head>
50<body>
51%s
52</body>
53</html>`
54
55// md is the goldmark renderer with GFM extensions and callout support.
56var md = goldmark.New(
57 goldmark.WithExtensions(
58 extension.GFM,
59 callout.ObsidianCallout,
60 ),
61 goldmark.WithRendererOptions(html.WithHardWraps()),
62)
63
64// browserCSP is injected into raw HTML emails to block scripts, frames, and objects
65// while still allowing remote images (user explicitly chose to open in browser).
66const browserCSP = `<meta http-equiv="Content-Security-Policy" content="script-src 'none'; frame-src 'none'; object-src 'none';">`
67
68// SanitizeForBrowser injects a restrictive CSP into raw HTML to block scripts
69// and frames while allowing images. For use when opening untrusted email HTML.
70func SanitizeForBrowser(html string) string {
71 // Skip if our exact CSP is already present (e.g. from htmlTemplate via ToHTML).
72 if strings.Contains(html, browserCSP) {
73 return html
74 }
75 // Insert after <head> if present, otherwise prepend.
76 lower := strings.ToLower(html)
77 if idx := strings.Index(lower, "<head>"); idx >= 0 {
78 insert := idx + len("<head>")
79 return html[:insert] + "\n" + browserCSP + "\n" + html[insert:]
80 }
81 return browserCSP + "\n" + html
82}
83
84// ToHTML converts a Markdown string to a complete HTML email document.
85func ToHTML(markdown string) (string, error) {
86 var fragment bytes.Buffer
87 if err := md.Convert([]byte(markdown), &fragment); err != nil {
88 return "", fmt.Errorf("markdown to html: %w", err)
89 }
90 return fmt.Sprintf(htmlTemplate, fragment.String()), nil
91}
92
93// calloutIconMap maps callout types to their emoji icons (same as in the fork's ast.go).
94var calloutIconMap = map[string]string{
95 "note": "📘",
96 "info": "ℹ️",
97 "abstract": "📋",
98 "summary": "📋",
99 "tldr": "📋",
100 "todo": "☑️",
101 "tip": "💡",
102 "hint": "💡",
103 "important": "💡", // tip alias
104 "success": "✅",
105 "check": "✅",
106 "done": "✅",
107 "question": "❓",
108 "help": "❓",
109 "faq": "❓",
110 "warning": "⚠️",
111 "caution": "⚠️",
112 "attention": "⚠️",
113 "failure": "❌",
114 "fail": "❌",
115 "missing": "❌",
116 "danger": "🚨",
117 "error": "🚨",
118 "bug": "🐛",
119 "example": "📝",
120 "quote": "💬",
121 "cite": "💬",
122}
123
124// calloutRegex matches callout syntax: > [!type] optional title
125// Captures: (optional space after >)(type)(optional: + or -)(optional title)
126var calloutRegex = regexp.MustCompile(`(?m)^(>\s*)\[!(\w+)\]([+-])?\s*(.*)?$`)
127
128// FormatCalloutsForPlainText converts callout markdown syntax to emoji-prefixed text.
129// Converts `> [!note] Title` to `📘 Note` (or custom title if provided).
130// Removes blockquote markers since markdown renderers (glamour) would strip them anyway.
131// Content lines following the callout header are also unquoted for clean display.
132func FormatCalloutsForPlainText(markdown string) string {
133 lines := strings.Split(markdown, "\n")
134 inCallout := false
135
136 for i, line := range lines {
137 // Check if this line starts a new callout
138 if calloutRegex.MatchString(line) {
139 submatches := calloutRegex.FindStringSubmatch(line)
140 if len(submatches) >= 5 {
141 calloutType := submatches[2] // "note", "tip", etc.
142 customTitle := submatches[4] // optional custom title
143
144 // Get emoji for this type (default to note if unknown)
145 calloutTypeLower := strings.ToLower(calloutType)
146 emoji, ok := calloutIconMap[calloutTypeLower]
147 if !ok {
148 emoji = "📘" // default to note icon
149 }
150
151 // If there's a custom title, use it; otherwise use capitalized type name
152 title := customTitle
153 if strings.TrimSpace(title) == "" {
154 title = strings.ToUpper(calloutTypeLower[:1]) + calloutTypeLower[1:]
155 }
156
157 // Replace with emoji title (no blockquote marker)
158 lines[i] = emoji + " " + title
159 inCallout = true
160 }
161 } else if inCallout && strings.HasPrefix(line, ">") {
162 // This is a content line of the callout - remove the blockquote marker
163 lines[i] = strings.TrimPrefix(line, ">")
164 lines[i] = strings.TrimPrefix(lines[i], " ") // Remove leading space after >
165 } else if inCallout && strings.TrimSpace(line) == "" {
166 // Empty line ends the callout
167 inCallout = false
168 } else if inCallout {
169 // Non-blockquote line also ends the callout
170 inCallout = false
171 }
172 }
173 return strings.Join(lines, "\n")
174}