fix(clib): escape markdown fallback (#1290)

FromSi created

## What?

Adds a safe HTML fallback for Markdown conversion failures. When
`MarkdownToHTML` fails in either the cgo `md4c` path or the no-cgo
`goldmark` path, it now logs the failure and returns escaped plain text
wrapped in `<pre>` instead of returning raw Markdown bytes.

Also adds a focused test for the fallback escaping behavior.

## Why?

Closes #571

Previously, failed Markdown conversion returned the original Markdown
bytes. The email viewer then tried to parse those raw bytes as HTML,
which could render poorly and treat HTML-looking plain text as markup.

The new fallback keeps failed conversions readable in the HTML viewer
while escaping unsafe HTML-like input.

Change summary

clib/markdown.go               |  8 ++++++--
clib/markdown_fallback.go      |  7 +++++++
clib/markdown_fallback_test.go | 25 +++++++++++++++++++++++++
clib/markdown_nocgo.go         |  4 +++-
4 files changed, 41 insertions(+), 3 deletions(-)

Detailed changes

clib/markdown.go 🔗

@@ -56,7 +56,10 @@ static char* md4c_to_html(const char* input, size_t input_len, size_t* out_len)
 }
 */
 import "C"
-import "unsafe"
+import (
+	"log"
+	"unsafe"
+)
 
 // MarkdownToHTML converts Markdown bytes to HTML using md4c (C).
 // This is significantly faster than goldmark for large documents.
@@ -71,7 +74,8 @@ func MarkdownToHTML(md []byte) []byte {
 	var outLen C.size_t
 	result := C.md4c_to_html((*C.char)(cInput), C.size_t(len(md)), &outLen)
 	if result == nil {
-		return md // fallback to original on failure
+		log.Printf("markdown: md4c_to_html failed, falling back to escaped plain-text HTML")
+		return markdownPlainTextHTML(md)
 	}
 	defer C.free(unsafe.Pointer(result))
 

clib/markdown_fallback.go 🔗

@@ -0,0 +1,7 @@
+package clib
+
+import "html"
+
+func markdownPlainTextHTML(md []byte) []byte {
+	return []byte("<pre>" + html.EscapeString(string(md)) + "</pre>")
+}

clib/markdown_fallback_test.go 🔗

@@ -0,0 +1,25 @@
+package clib
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestMarkdownPlainTextHTMLFallbackEscapesInput(t *testing.T) {
+	input := []byte("# Heading\n\n<script>alert(\"x\")</script>\n<b>literal</b>")
+
+	got := string(markdownPlainTextHTML(input))
+
+	if !strings.HasPrefix(got, "<pre>") || !strings.HasSuffix(got, "</pre>") {
+		t.Fatalf("fallback should wrap content in pre tag, got %q", got)
+	}
+	if strings.Contains(got, "<script>") || strings.Contains(got, "<b>") {
+		t.Fatalf("fallback should escape HTML-looking input, got %q", got)
+	}
+	if !strings.Contains(got, "&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;") {
+		t.Fatalf("fallback missing escaped script text, got %q", got)
+	}
+	if !strings.Contains(got, "# Heading") {
+		t.Fatalf("fallback should preserve markdown text, got %q", got)
+	}
+}

clib/markdown_nocgo.go 🔗

@@ -4,6 +4,7 @@ package clib
 
 import (
 	"bytes"
+	"log"
 
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/renderer/html"
@@ -18,7 +19,8 @@ func MarkdownToHTML(md []byte) []byte {
 		),
 	)
 	if err := p.Convert(md, &buf); err != nil {
-		return md
+		log.Printf("markdown: goldmark conversion failed, falling back to escaped plain-text HTML: %v", err)
+		return markdownPlainTextHTML(md)
 	}
 	return buf.Bytes()
 }