···33import (
44 "bytes"
55 "fmt"
66+ "regexp"
77+ "strings"
6879 callout "github.com/sspaeti/goldmark-obsidian-callout-for-neomd"
810 "github.com/yuin/goldmark"
···6668 }
6769 return fmt.Sprintf(htmlTemplate, fragment.String()), nil
6870}
7171+7272+// calloutIconMap maps callout types to their emoji icons (same as in the fork's ast.go).
7373+var calloutIconMap = map[string]string{
7474+ "note": "📘",
7575+ "info": "ℹ️",
7676+ "abstract": "📋",
7777+ "summary": "📋",
7878+ "tldr": "📋",
7979+ "todo": "☑️",
8080+ "tip": "💡",
8181+ "hint": "💡",
8282+ "important": "💡", // tip alias
8383+ "success": "✅",
8484+ "check": "✅",
8585+ "done": "✅",
8686+ "question": "❓",
8787+ "help": "❓",
8888+ "faq": "❓",
8989+ "warning": "⚠️",
9090+ "caution": "⚠️",
9191+ "attention": "⚠️",
9292+ "failure": "❌",
9393+ "fail": "❌",
9494+ "missing": "❌",
9595+ "danger": "🚨",
9696+ "error": "🚨",
9797+ "bug": "🐛",
9898+ "example": "📝",
9999+ "quote": "💬",
100100+ "cite": "💬",
101101+}
102102+103103+// calloutRegex matches callout syntax: > [!type] optional title
104104+// Captures: (optional space after >)(type)(optional: + or -)(optional title)
105105+var calloutRegex = regexp.MustCompile(`(?m)^(>\s*)\[!(\w+)\]([+-])?\s*(.*)?$`)
106106+107107+// 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.
110110+func FormatCalloutsForPlainText(markdown string) string {
111111+ lines := strings.Split(markdown, "\n")
112112+ for i, line := range lines {
113113+ if calloutRegex.MatchString(line) {
114114+ submatches := calloutRegex.FindStringSubmatch(line)
115115+ if len(submatches) >= 5 {
116116+ prefix := submatches[1] // "> " or ">"
117117+ calloutType := submatches[2] // "note", "tip", etc.
118118+ customTitle := submatches[4] // optional custom title
119119+120120+ // Get emoji for this type (default to note if unknown)
121121+ calloutTypeLower := strings.ToLower(calloutType)
122122+ emoji, ok := calloutIconMap[calloutTypeLower]
123123+ if !ok {
124124+ emoji = "📘" // default to note icon
125125+ }
126126+127127+ // If there's a custom title, use it; otherwise use capitalized type name
128128+ title := customTitle
129129+ if strings.TrimSpace(title) == "" {
130130+ title = strings.ToUpper(calloutTypeLower[:1]) + calloutTypeLower[1:]
131131+ }
132132+133133+ lines[i] = prefix + emoji + " " + title
134134+ }
135135+ }
136136+ }
137137+ return strings.Join(lines, "\n")
138138+}
+80
internal/render/html_test.go
···168168 t.Errorf(">[!note] without space did not render as callout. Use '> [!note]' (with space) instead. Got:\n%s", out)
169169 }
170170}
171171+172172+func 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"
175175+ got := FormatCalloutsForPlainText(input)
176176+ if got != expected {
177177+ t.Errorf("FormatCalloutsForPlainText with title:\nwant: %q\ngot: %q", expected, got)
178178+ }
179179+}
180180+181181+func TestFormatCalloutsForPlainText_NoTitle(t *testing.T) {
182182+ input := "> [!note]\n> This is a note\n"
183183+ expected := "> 📘 Note\n> This is a note\n"
184184+ got := FormatCalloutsForPlainText(input)
185185+ if got != expected {
186186+ t.Errorf("FormatCalloutsForPlainText without title:\nwant: %q\ngot: %q", expected, got)
187187+ }
188188+}
189189+190190+func 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"
193193+ got := FormatCalloutsForPlainText(input)
194194+ if got != expected {
195195+ t.Errorf("FormatCalloutsForPlainText with multiple callouts:\nwant: %q\ngot: %q", expected, got)
196196+ }
197197+}
198198+199199+func TestFormatCalloutsForPlainText_NoSpaceAfterArrow(t *testing.T) {
200200+ input := ">[!tip] Title\n>Content here\n"
201201+ // Should still match because regex handles both "> " and ">"
202202+ got := FormatCalloutsForPlainText(input)
203203+ if !strings.Contains(got, "💡 Title") {
204204+ t.Errorf("FormatCalloutsForPlainText should handle >[!type] without space:\ngot: %q", got)
205205+ }
206206+}
207207+208208+func TestFormatCalloutsForPlainText_AllTypes(t *testing.T) {
209209+ tests := []struct {
210210+ callType string
211211+ wantIcon string
212212+ }{
213213+ {"note", "📘"},
214214+ {"tip", "💡"},
215215+ {"warning", "⚠️"},
216216+ {"danger", "🚨"},
217217+ {"success", "✅"},
218218+ {"info", "ℹ️"},
219219+ {"question", "❓"},
220220+ {"bug", "🐛"},
221221+ {"example", "📝"},
222222+ }
223223+224224+ for _, tt := range tests {
225225+ t.Run(tt.callType, func(t *testing.T) {
226226+ input := "> [!" + tt.callType + "] Title\n> Content\n"
227227+ got := FormatCalloutsForPlainText(input)
228228+ if !strings.Contains(got, tt.wantIcon) {
229229+ t.Errorf("expected icon %s for type %s, got: %q", tt.wantIcon, tt.callType, got)
230230+ }
231231+ })
232232+ }
233233+}
234234+235235+func TestFormatCalloutsForPlainText_PreservesNonCallouts(t *testing.T) {
236236+ input := "Regular text\n\n> Regular blockquote\n> without callout\n\n> [!note] Callout\n> With content\n"
237237+ got := FormatCalloutsForPlainText(input)
238238+239239+ // Should preserve regular text and blockquotes
240240+ if !strings.Contains(got, "Regular text") {
241241+ t.Error("should preserve regular text")
242242+ }
243243+ if !strings.Contains(got, "> Regular blockquote") {
244244+ t.Error("should preserve regular blockquotes")
245245+ }
246246+ // Should format the callout
247247+ if !strings.Contains(got, "📘 Callout") {
248248+ t.Error("should format callout syntax")
249249+ }
250250+}
+36-13
internal/smtp/sender.go
···11// Package smtp handles outgoing email via SMTP.
22// Sends multipart/alternative (text/plain + text/html) so recipients
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)
77+// - HTML: Full goldmark rendering with styled callout boxes
88+// These formats never mix - each is derived independently from the markdown source.
49package smtp
510611import (
···6873 return nil, nil
6974}
70757676+// prepareEmailBodies converts markdown to both plain text and HTML for multipart/alternative emails.
7777+// This separation ensures we never mix the two formats - plain text gets readable callout formatting,
7878+// HTML gets full goldmark rendering with styled callout boxes.
7979+func prepareEmailBodies(markdownBody string) (plainText, htmlBody string, err error) {
8080+ // Plain text part: Format callouts as emoji-prefixed blockquotes (> [!note] → > 📘 Note)
8181+ plainText = render.FormatCalloutsForPlainText(markdownBody)
8282+8383+ // HTML part: Full goldmark rendering with styled callout boxes
8484+ htmlBody, err = render.ToHTML(markdownBody)
8585+ if err != nil {
8686+ return "", "", fmt.Errorf("markdown to html: %w", err)
8787+ }
8888+8989+ return plainText, htmlBody, nil
9090+}
9191+7192// Send composes and sends an email.
7272-// markdownBody is sent as text/plain (raw) and text/html (goldmark-rendered).
9393+// markdownBody is converted to both plain text and HTML (multipart/alternative).
7394// cc and bcc may be empty. BCC recipients receive the email but are not visible
7495// in the message headers (standard BCC privacy behaviour).
7596// attachments is a list of local file paths (may be nil).
7697func Send(cfg Config, to, cc, bcc, subject, markdownBody string, attachments []string) error {
7777- htmlBody, err := render.ToHTML(markdownBody)
9898+ // Convert markdown to both formats (plain text with formatted callouts, HTML with styled boxes)
9999+ plainText, htmlBody, err := prepareEmailBodies(markdownBody)
78100 if err != nil {
7979- return fmt.Errorf("markdown to html: %w", err)
101101+ return err
80102 }
8110382104 // BCC is intentionally NOT passed to buildMessage — it must not appear in headers.
8383- raw, err := buildMessage(cfg.From, to, cc, subject, markdownBody, htmlBody, attachments)
105105+ raw, err := buildMessage(cfg.From, to, cc, subject, plainText, htmlBody, attachments)
84106 if err != nil {
85107 return fmt.Errorf("build message: %w", err)
86108 }
···255277// BuildMessageWithThreading builds a MIME message with optional threading headers (In-Reply-To, References).
256278// Used for replies and forwards to maintain proper email conversation threading.
257279func BuildMessageWithThreading(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature, inReplyTo, references string) ([]byte, error) {
258258- htmlBody, err := render.ToHTML(markdownBody)
280280+ // Convert markdown to both formats (plain text with formatted callouts, HTML with styled boxes)
281281+ plainText, htmlBody, err := prepareEmailBodies(markdownBody)
259282 if err != nil {
260260- return nil, fmt.Errorf("markdown to html: %w", err)
283283+ return nil, err
261284 }
262285 // Inject HTML signature before </body> tag if provided
263286 if htmlSignature != "" {
···277300 refChain = inReplyTo
278301 }
279302 }
280280- return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, attachments, inReplyTo, refChain)
303303+ return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, inReplyTo, refChain)
281304}
282305283306// BuildDraftMessage constructs a raw MIME draft for IMAP APPEND.
···292315293316// BuildReactionMessage constructs a minimal reaction email with threading headers.
294317// Used for emoji reactions sent as replies to emails.
295295-// markdownBody is used for both text/plain and text/html parts (same as BuildMessageWithThreading).
318318+// markdownBody is converted to both plain text and HTML (multipart/alternative).
296319// inReplyTo is the Message-ID of the original email.
297320// references is the References chain from the original email (may be empty).
298321func BuildReactionMessage(from, to, cc, subject, markdownBody, inReplyTo, references string) ([]byte, error) {
299299- // Convert markdown to HTML (same as regular replies)
300300- htmlBody, err := render.ToHTML(markdownBody)
322322+ // Convert markdown to both formats (plain text with formatted callouts, HTML with styled boxes)
323323+ plainText, htmlBody, err := prepareEmailBodies(markdownBody)
301324 if err != nil {
302302- return nil, fmt.Errorf("markdown to html: %w", err)
325325+ return nil, err
303326 }
304327305328 // Build References chain: append inReplyTo to existing references
···312335 }
313336 }
314337 // No attachments for reactions
315315- // Use markdown for text/plain part, rendered HTML for text/html part (same as regular replies)
316316- return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, nil, inReplyTo, refChain)
338338+ // Use formatted plain text for text/plain part, rendered HTML for text/html part (same as regular replies)
339339+ return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, nil, inReplyTo, refChain)
317340}
318341319342// inlineImage holds a local image path and its assigned Content-ID.