html.go

  1package view
  2
  3import (
  4	"bytes"
  5	"fmt"
  6	"io"
  7	"mime/quotedprintable"
  8	"regexp"
  9	"strings"
 10
 11	"github.com/PuerkitoBio/goquery"
 12	"github.com/charmbracelet/lipgloss"
 13	"github.com/yuin/goldmark"
 14	"github.com/yuin/goldmark/renderer/html"
 15)
 16
 17// hyperlink formats a string as a terminal-clickable hyperlink.
 18func hyperlink(url, text string) string {
 19	if text == "" {
 20		text = url
 21	}
 22	return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, text)
 23}
 24
 25func decodeQuotedPrintable(s string) (string, error) {
 26	reader := quotedprintable.NewReader(strings.NewReader(s))
 27	body, err := io.ReadAll(reader)
 28	if err != nil {
 29		return "", err
 30	}
 31	return string(body), nil
 32}
 33
 34// markdownToHTML converts a Markdown string to an HTML string.
 35func markdownToHTML(md []byte) []byte {
 36	var buf bytes.Buffer
 37	p := goldmark.New(
 38		goldmark.WithRendererOptions(
 39			html.WithUnsafe(), // Allow raw HTML in email.
 40		),
 41	)
 42	if err := p.Convert(md, &buf); err != nil {
 43		return md // Fallback to original markdown.
 44	}
 45	return buf.Bytes()
 46}
 47
 48// ProcessBody takes a raw email body, decodes it, and formats it as plain
 49// text with terminal hyperlinks.
 50func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
 51	decodedBody, err := decodeQuotedPrintable(rawBody)
 52	if err != nil {
 53		decodedBody = rawBody
 54	}
 55
 56	htmlBody := markdownToHTML([]byte(decodedBody))
 57
 58	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
 59	if err != nil {
 60		return "", fmt.Errorf("could not parse email body: %w", err)
 61	}
 62
 63	doc.Find("style, script").Remove()
 64
 65	// Style headers by setting their text content.
 66	// We use SetText so the h1/h2 tags remain in the document for spacing logic.
 67	doc.Find("h1").Each(func(i int, s *goquery.Selection) {
 68		s.SetText(h1Style.Render(s.Text()))
 69	})
 70
 71	doc.Find("h2").Each(func(i int, s *goquery.Selection) {
 72		s.SetText(h2Style.Render(s.Text()))
 73	})
 74
 75	// Add newlines after block elements for better spacing.
 76	// THIS IS THE KEY FIX: Include h1 and h2 in the selector.
 77	doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
 78		s.After("\n\n")
 79	})
 80
 81	// Replace <br> tags with newlines
 82	doc.Find("br").Each(func(i int, s *goquery.Selection) {
 83		s.ReplaceWithHtml("\n")
 84	})
 85
 86	// Format links and images
 87	doc.Find("a").Each(func(i int, s *goquery.Selection) {
 88		href, exists := s.Attr("href")
 89		if !exists {
 90			return
 91		}
 92		s.ReplaceWithHtml(hyperlink(href, s.Text()))
 93	})
 94
 95	doc.Find("img").Each(func(i int, s *goquery.Selection) {
 96		src, exists := s.Attr("src")
 97		if !exists {
 98			return
 99		}
100		alt, _ := s.Attr("alt")
101		if alt == "" {
102			alt = "Does not contain alt text"
103		}
104		s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
105	})
106
107	text := doc.Text()
108
109	re := regexp.MustCompile(`\n{3,}`)
110	text = re.ReplaceAllString(text, "\n\n")
111
112	text = strings.TrimSpace(text)
113
114	return bodyStyle.Render(text), nil
115}