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}