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.

add > notation for plain text

sspaeti 5dbc19e7 b6e69a18

+186 -13
+70
internal/render/html.go
··· 3 3 import ( 4 4 "bytes" 5 5 "fmt" 6 + "regexp" 7 + "strings" 6 8 7 9 callout "github.com/sspaeti/goldmark-obsidian-callout-for-neomd" 8 10 "github.com/yuin/goldmark" ··· 66 68 } 67 69 return fmt.Sprintf(htmlTemplate, fragment.String()), nil 68 70 } 71 + 72 + // calloutIconMap maps callout types to their emoji icons (same as in the fork's ast.go). 73 + var calloutIconMap = map[string]string{ 74 + "note": "📘", 75 + "info": "ℹ️", 76 + "abstract": "📋", 77 + "summary": "📋", 78 + "tldr": "📋", 79 + "todo": "☑️", 80 + "tip": "💡", 81 + "hint": "💡", 82 + "important": "💡", // tip alias 83 + "success": "✅", 84 + "check": "✅", 85 + "done": "✅", 86 + "question": "❓", 87 + "help": "❓", 88 + "faq": "❓", 89 + "warning": "⚠️", 90 + "caution": "⚠️", 91 + "attention": "⚠️", 92 + "failure": "❌", 93 + "fail": "❌", 94 + "missing": "❌", 95 + "danger": "🚨", 96 + "error": "🚨", 97 + "bug": "🐛", 98 + "example": "📝", 99 + "quote": "💬", 100 + "cite": "💬", 101 + } 102 + 103 + // calloutRegex matches callout syntax: > [!type] optional title 104 + // Captures: (optional space after >)(type)(optional: + or -)(optional title) 105 + var calloutRegex = regexp.MustCompile(`(?m)^(>\s*)\[!(\w+)\]([+-])?\s*(.*)?$`) 106 + 107 + // FormatCalloutsForPlainText converts callout markdown syntax to emoji-prefixed blockquotes. 108 + // Converts `> [!note] Title` to `> 📘 Title` (or `> 📘 Note` if no title). 109 + // This makes callouts readable in plain text email clients while preserving the blockquote structure. 110 + func FormatCalloutsForPlainText(markdown string) string { 111 + lines := strings.Split(markdown, "\n") 112 + for i, line := range lines { 113 + if calloutRegex.MatchString(line) { 114 + submatches := calloutRegex.FindStringSubmatch(line) 115 + if len(submatches) >= 5 { 116 + prefix := submatches[1] // "> " or ">" 117 + calloutType := submatches[2] // "note", "tip", etc. 118 + customTitle := submatches[4] // optional custom title 119 + 120 + // Get emoji for this type (default to note if unknown) 121 + calloutTypeLower := strings.ToLower(calloutType) 122 + emoji, ok := calloutIconMap[calloutTypeLower] 123 + if !ok { 124 + emoji = "📘" // default to note icon 125 + } 126 + 127 + // If there's a custom title, use it; otherwise use capitalized type name 128 + title := customTitle 129 + if strings.TrimSpace(title) == "" { 130 + title = strings.ToUpper(calloutTypeLower[:1]) + calloutTypeLower[1:] 131 + } 132 + 133 + lines[i] = prefix + emoji + " " + title 134 + } 135 + } 136 + } 137 + return strings.Join(lines, "\n") 138 + }
+80
internal/render/html_test.go
··· 168 168 t.Errorf(">[!note] without space did not render as callout. Use '> [!note]' (with space) instead. Got:\n%s", out) 169 169 } 170 170 } 171 + 172 + func TestFormatCalloutsForPlainText_WithTitle(t *testing.T) { 173 + input := "> [!tip] Good News\n> We're ahead of schedule!\n" 174 + expected := "> 💡 Good News\n> We're ahead of schedule!\n" 175 + got := FormatCalloutsForPlainText(input) 176 + if got != expected { 177 + t.Errorf("FormatCalloutsForPlainText with title:\nwant: %q\ngot: %q", expected, got) 178 + } 179 + } 180 + 181 + func TestFormatCalloutsForPlainText_NoTitle(t *testing.T) { 182 + input := "> [!note]\n> This is a note\n" 183 + expected := "> 📘 Note\n> This is a note\n" 184 + got := FormatCalloutsForPlainText(input) 185 + if got != expected { 186 + t.Errorf("FormatCalloutsForPlainText without title:\nwant: %q\ngot: %q", expected, got) 187 + } 188 + } 189 + 190 + func TestFormatCalloutsForPlainText_MultipleCallouts(t *testing.T) { 191 + input := "> [!warning] Action Required\n> Please review by Friday.\n\n> [!note]\n> Please read\n" 192 + expected := "> ⚠️ Action Required\n> Please review by Friday.\n\n> 📘 Note\n> Please read\n" 193 + got := FormatCalloutsForPlainText(input) 194 + if got != expected { 195 + t.Errorf("FormatCalloutsForPlainText with multiple callouts:\nwant: %q\ngot: %q", expected, got) 196 + } 197 + } 198 + 199 + func TestFormatCalloutsForPlainText_NoSpaceAfterArrow(t *testing.T) { 200 + input := ">[!tip] Title\n>Content here\n" 201 + // Should still match because regex handles both "> " and ">" 202 + got := FormatCalloutsForPlainText(input) 203 + if !strings.Contains(got, "💡 Title") { 204 + t.Errorf("FormatCalloutsForPlainText should handle >[!type] without space:\ngot: %q", got) 205 + } 206 + } 207 + 208 + func TestFormatCalloutsForPlainText_AllTypes(t *testing.T) { 209 + tests := []struct { 210 + callType string 211 + wantIcon string 212 + }{ 213 + {"note", "📘"}, 214 + {"tip", "💡"}, 215 + {"warning", "⚠️"}, 216 + {"danger", "🚨"}, 217 + {"success", "✅"}, 218 + {"info", "ℹ️"}, 219 + {"question", "❓"}, 220 + {"bug", "🐛"}, 221 + {"example", "📝"}, 222 + } 223 + 224 + for _, tt := range tests { 225 + t.Run(tt.callType, func(t *testing.T) { 226 + input := "> [!" + tt.callType + "] Title\n> Content\n" 227 + got := FormatCalloutsForPlainText(input) 228 + if !strings.Contains(got, tt.wantIcon) { 229 + t.Errorf("expected icon %s for type %s, got: %q", tt.wantIcon, tt.callType, got) 230 + } 231 + }) 232 + } 233 + } 234 + 235 + func TestFormatCalloutsForPlainText_PreservesNonCallouts(t *testing.T) { 236 + input := "Regular text\n\n> Regular blockquote\n> without callout\n\n> [!note] Callout\n> With content\n" 237 + got := FormatCalloutsForPlainText(input) 238 + 239 + // Should preserve regular text and blockquotes 240 + if !strings.Contains(got, "Regular text") { 241 + t.Error("should preserve regular text") 242 + } 243 + if !strings.Contains(got, "> Regular blockquote") { 244 + t.Error("should preserve regular blockquotes") 245 + } 246 + // Should format the callout 247 + if !strings.Contains(got, "📘 Callout") { 248 + t.Error("should format callout syntax") 249 + } 250 + }
+36 -13
internal/smtp/sender.go
··· 1 1 // Package smtp handles outgoing email via SMTP. 2 2 // Sends multipart/alternative (text/plain + text/html) so recipients 3 3 // get clickable links and formatted output while you write pure Markdown. 4 + // 5 + // Email format separation: Markdown input is converted to TWO independent formats: 6 + // - Plain text: Callouts formatted as emoji blockquotes (> [!note] → > 📘 Note) 7 + // - HTML: Full goldmark rendering with styled callout boxes 8 + // These formats never mix - each is derived independently from the markdown source. 4 9 package smtp 5 10 6 11 import ( ··· 68 73 return nil, nil 69 74 } 70 75 76 + // prepareEmailBodies converts markdown to both plain text and HTML for multipart/alternative emails. 77 + // This separation ensures we never mix the two formats - plain text gets readable callout formatting, 78 + // HTML gets full goldmark rendering with styled callout boxes. 79 + func prepareEmailBodies(markdownBody string) (plainText, htmlBody string, err error) { 80 + // Plain text part: Format callouts as emoji-prefixed blockquotes (> [!note] → > 📘 Note) 81 + plainText = render.FormatCalloutsForPlainText(markdownBody) 82 + 83 + // HTML part: Full goldmark rendering with styled callout boxes 84 + htmlBody, err = render.ToHTML(markdownBody) 85 + if err != nil { 86 + return "", "", fmt.Errorf("markdown to html: %w", err) 87 + } 88 + 89 + return plainText, htmlBody, nil 90 + } 91 + 71 92 // Send composes and sends an email. 72 - // markdownBody is sent as text/plain (raw) and text/html (goldmark-rendered). 93 + // markdownBody is converted to both plain text and HTML (multipart/alternative). 73 94 // cc and bcc may be empty. BCC recipients receive the email but are not visible 74 95 // in the message headers (standard BCC privacy behaviour). 75 96 // attachments is a list of local file paths (may be nil). 76 97 func Send(cfg Config, to, cc, bcc, subject, markdownBody string, attachments []string) error { 77 - htmlBody, err := render.ToHTML(markdownBody) 98 + // Convert markdown to both formats (plain text with formatted callouts, HTML with styled boxes) 99 + plainText, htmlBody, err := prepareEmailBodies(markdownBody) 78 100 if err != nil { 79 - return fmt.Errorf("markdown to html: %w", err) 101 + return err 80 102 } 81 103 82 104 // BCC is intentionally NOT passed to buildMessage — it must not appear in headers. 83 - raw, err := buildMessage(cfg.From, to, cc, subject, markdownBody, htmlBody, attachments) 105 + raw, err := buildMessage(cfg.From, to, cc, subject, plainText, htmlBody, attachments) 84 106 if err != nil { 85 107 return fmt.Errorf("build message: %w", err) 86 108 } ··· 255 277 // BuildMessageWithThreading builds a MIME message with optional threading headers (In-Reply-To, References). 256 278 // Used for replies and forwards to maintain proper email conversation threading. 257 279 func BuildMessageWithThreading(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature, inReplyTo, references string) ([]byte, error) { 258 - htmlBody, err := render.ToHTML(markdownBody) 280 + // Convert markdown to both formats (plain text with formatted callouts, HTML with styled boxes) 281 + plainText, htmlBody, err := prepareEmailBodies(markdownBody) 259 282 if err != nil { 260 - return nil, fmt.Errorf("markdown to html: %w", err) 283 + return nil, err 261 284 } 262 285 // Inject HTML signature before </body> tag if provided 263 286 if htmlSignature != "" { ··· 277 300 refChain = inReplyTo 278 301 } 279 302 } 280 - return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, attachments, inReplyTo, refChain) 303 + return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, inReplyTo, refChain) 281 304 } 282 305 283 306 // BuildDraftMessage constructs a raw MIME draft for IMAP APPEND. ··· 292 315 293 316 // BuildReactionMessage constructs a minimal reaction email with threading headers. 294 317 // Used for emoji reactions sent as replies to emails. 295 - // markdownBody is used for both text/plain and text/html parts (same as BuildMessageWithThreading). 318 + // markdownBody is converted to both plain text and HTML (multipart/alternative). 296 319 // inReplyTo is the Message-ID of the original email. 297 320 // references is the References chain from the original email (may be empty). 298 321 func BuildReactionMessage(from, to, cc, subject, markdownBody, inReplyTo, references string) ([]byte, error) { 299 - // Convert markdown to HTML (same as regular replies) 300 - htmlBody, err := render.ToHTML(markdownBody) 322 + // Convert markdown to both formats (plain text with formatted callouts, HTML with styled boxes) 323 + plainText, htmlBody, err := prepareEmailBodies(markdownBody) 301 324 if err != nil { 302 - return nil, fmt.Errorf("markdown to html: %w", err) 325 + return nil, err 303 326 } 304 327 305 328 // Build References chain: append inReplyTo to existing references ··· 312 335 } 313 336 } 314 337 // No attachments for reactions 315 - // Use markdown for text/plain part, rendered HTML for text/html part (same as regular replies) 316 - return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, nil, inReplyTo, refChain) 338 + // Use formatted plain text for text/plain part, rendered HTML for text/html part (same as regular replies) 339 + return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, nil, inReplyTo, refChain) 317 340 } 318 341 319 342 // inlineImage holds a local image path and its assigned Content-ID.