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.

at main 174 lines 6.6 kB view raw
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}