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
 66	doc.Find("h1").Each(func(i int, s *goquery.Selection) {
 67		s.SetText(h1Style.Render(s.Text()))
 68	})
 69
 70	doc.Find("h2").Each(func(i int, s *goquery.Selection) {
 71		s.SetText(h2Style.Render(s.Text()))
 72	})
 73
 74	// Add newlines after block elements for better spacing
 75	doc.Find("p, div").Each(func(i int, s *goquery.Selection) {
 76		s.After("\n\n")
 77	})
 78
 79	// Replace <br> tags with newlines
 80	doc.Find("br").Each(func(i int, s *goquery.Selection) {
 81		s.ReplaceWithHtml("\n")
 82	})
 83
 84	// Format links and images
 85	doc.Find("a").Each(func(i int, s *goquery.Selection) {
 86		href, exists := s.Attr("href")
 87		if !exists {
 88			return
 89		}
 90		s.ReplaceWithHtml(hyperlink(href, s.Text()))
 91	})
 92
 93	doc.Find("img").Each(func(i int, s *goquery.Selection) {
 94		src, exists := s.Attr("src")
 95		if !exists {
 96			return
 97		}
 98		alt, _ := s.Attr("alt")
 99		if alt == "" {
100			alt = "Does not contain alt text"
101		}
102		s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
103	})
104
105	text := doc.Text()
106
107	re := regexp.MustCompile(`\n{3,}`)
108	text = re.ReplaceAllString(text, "\n\n")
109	return bodyStyle.Render(text), nil
110}