1package view
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "image"
8 "image/png"
9 "io"
10 "mime/quotedprintable"
11 "net/http"
12 "os"
13 "regexp"
14 "strings"
15 "time"
16
17 _ "image/gif"
18 _ "image/jpeg"
19
20 "github.com/PuerkitoBio/goquery"
21 "github.com/charmbracelet/lipgloss"
22 "github.com/yuin/goldmark"
23 "github.com/yuin/goldmark/renderer/html"
24 "golang.org/x/sys/unix"
25)
26
27// getTerminalCellSize returns the height of a terminal cell in pixels.
28// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
29// Falls back to a default of 18 pixels if the query fails.
30func getTerminalCellSize() int {
31 const defaultCellHeight = 18
32
33 // Try stdout, stdin, stderr, then /dev/tty as last resort
34 fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
35
36 for _, fd := range fds {
37 if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
38 return cellHeight
39 }
40 }
41
42 // Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
43 if tty, err := os.Open("/dev/tty"); err == nil {
44 defer tty.Close()
45 if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
46 return cellHeight
47 }
48 }
49
50 debugKitty("using default cell height: %d pixels", defaultCellHeight)
51 return defaultCellHeight
52}
53
54// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor.
55// Returns 0 if it fails or if pixel dimensions are not available.
56func getCellHeightFromFd(fd int) int {
57 ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
58 if err != nil {
59 return 0
60 }
61
62 // ws.Row = number of character rows
63 // ws.Ypixel = height in pixels
64 // Some terminals don't report pixel dimensions (return 0)
65 if ws.Row > 0 && ws.Ypixel > 0 {
66 cellHeight := int(ws.Ypixel) / int(ws.Row)
67 if cellHeight > 0 {
68 debugKitty("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
69 return cellHeight
70 }
71 }
72
73 // Terminal reported dimensions but no pixel info - this is common
74 if ws.Row > 0 && ws.Ypixel == 0 {
75 debugKitty("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
76 }
77
78 return 0
79}
80
81// hyperlink formats a string as a terminal-clickable hyperlink.
82func hyperlink(url, text string) string {
83 if text == "" {
84 text = url
85 }
86 return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, text)
87}
88
89func decodeQuotedPrintable(s string) (string, error) {
90 reader := quotedprintable.NewReader(strings.NewReader(s))
91 body, err := io.ReadAll(reader)
92 if err != nil {
93 return "", err
94 }
95 return string(body), nil
96}
97
98// markdownToHTML converts a Markdown string to an HTML string.
99func markdownToHTML(md []byte) []byte {
100 var buf bytes.Buffer
101 p := goldmark.New(
102 goldmark.WithRendererOptions(
103 html.WithUnsafe(), // Allow raw HTML in email.
104 ),
105 )
106 if err := p.Convert(md, &buf); err != nil {
107 return md // Fallback to original markdown.
108 }
109 return buf.Bytes()
110}
111
112func kittySupported() bool {
113 term := strings.ToLower(os.Getenv("TERM"))
114 if strings.Contains(term, "kitty") {
115 return true
116 }
117 return os.Getenv("KITTY_WINDOW_ID") != ""
118}
119
120func debugKitty(format string, args ...interface{}) {
121 if os.Getenv("DEBUG_KITTY_IMAGES") == "" {
122 return
123 }
124 msg := fmt.Sprintf("[kitty-img] "+format+"\n", args...)
125 fmt.Print(msg)
126 if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
127 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
128 _, _ = f.WriteString(msg)
129 _ = f.Close()
130 }
131 }
132}
133
134func fetchRemoteBase64(url string) string {
135 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
136 return ""
137 }
138 client := &http.Client{Timeout: 5 * time.Second}
139 resp, err := client.Get(url)
140 if err != nil {
141 debugKitty("remote fetch failed url=%s err=%v", url, err)
142 return ""
143 }
144 defer resp.Body.Close()
145 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
146 debugKitty("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
147 return ""
148 }
149 data, err := io.ReadAll(resp.Body)
150 if err != nil {
151 debugKitty("remote fetch read error url=%s err=%v", url, err)
152 return ""
153 }
154
155 img, _, err := image.Decode(bytes.NewReader(data))
156 if err != nil {
157 debugKitty("remote decode failed url=%s err=%v", url, err)
158 return ""
159 }
160
161 var buf bytes.Buffer
162 if err := png.Encode(&buf, img); err != nil {
163 debugKitty("remote png encode failed url=%s err=%v", url, err)
164 return ""
165 }
166
167 encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
168 debugKitty("remote fetch ok url=%s len=%d", url, len(encoded))
169 return encoded
170}
171
172func dataURIBase64(uri string) string {
173 if !strings.HasPrefix(uri, "data:") {
174 return ""
175 }
176 comma := strings.Index(uri, ",")
177 if comma == -1 || comma+1 >= len(uri) {
178 return ""
179 }
180 return uri[comma+1:]
181}
182
183// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
184// This prevents the newline-collapsing regex from removing intentional spacing.
185// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
186const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
187const imageRowPlaceholderSuffix = "]]"
188
189func kittyInlineImage(payload string) string {
190 if payload == "" {
191 return ""
192 }
193
194 const chunkSize = 4096
195 var b strings.Builder
196
197 // Calculate how many terminal rows the image occupies to advance text after it.
198 rows := 1
199 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
200 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
201 cellHeight := getTerminalCellSize()
202 h := img.Bounds().Dy()
203 rows = (h + cellHeight - 1) / cellHeight
204 if rows < 1 {
205 rows = 1
206 }
207 debugKitty("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
208 }
209 }
210
211 for offset := 0; offset < len(payload); offset += chunkSize {
212 end := offset + chunkSize
213 if end > len(payload) {
214 end = len(payload)
215 }
216 more := "0"
217 if end < len(payload) {
218 more = "1"
219 }
220
221 chunk := payload[offset:end]
222 if offset == 0 {
223 // C=1 means cursor does NOT move after image render (stays at top-left of image position)
224 // This is needed for proper TUI rendering, but we must add newlines to push text below
225 b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
226 } else {
227 b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
228 }
229 }
230
231 // Add newlines to push cursor below the image.
232 // Use a placeholder that won't be collapsed by the newline regex.
233 b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
234
235 return b.String()
236}
237
238// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
239func expandImageRowPlaceholders(text string) string {
240 re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
241 return re.ReplaceAllStringFunc(text, func(match string) string {
242 // Extract the number of rows from the placeholder
243 numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
244 numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
245 rows := 1
246 if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
247 rows = 1
248 }
249 // Return the newlines needed to push content below the image
250 return strings.Repeat("\n", rows)
251 })
252}
253
254type InlineImage struct {
255 CID string
256 Base64 string
257}
258
259// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
260func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
261 inlineMap := make(map[string]string, len(inline))
262 for _, img := range inline {
263 cid := strings.TrimSpace(img.CID)
264 cid = strings.TrimPrefix(cid, "<")
265 cid = strings.TrimSuffix(cid, ">")
266 cid = strings.TrimPrefix(cid, "cid:")
267 if cid == "" || img.Base64 == "" {
268 continue
269 }
270 inlineMap[cid] = img.Base64
271 }
272 return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle)
273}
274
275// ProcessBody takes a raw email body, decodes it, and formats it as plain
276// text with terminal hyperlinks.
277func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
278 return processBody(rawBody, nil, h1Style, h2Style, bodyStyle)
279}
280
281func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
282 decodedBody, err := decodeQuotedPrintable(rawBody)
283 if err != nil {
284 decodedBody = rawBody
285 }
286
287 htmlBody := markdownToHTML([]byte(decodedBody))
288
289 doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
290 if err != nil {
291 return "", fmt.Errorf("could not parse email body: %w", err)
292 }
293
294 doc.Find("style, script").Remove()
295
296 // Style headers by setting their text content.
297 // We use SetText so the h1/h2 tags remain in the document for spacing logic.
298 doc.Find("h1").Each(func(i int, s *goquery.Selection) {
299 s.SetText(h1Style.Render(s.Text()))
300 })
301
302 doc.Find("h2").Each(func(i int, s *goquery.Selection) {
303 s.SetText(h2Style.Render(s.Text()))
304 })
305
306 // Add newlines after block elements for better spacing.
307 doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
308 s.After("\n\n")
309 })
310
311 // Replace <br> tags with newlines
312 doc.Find("br").Each(func(i int, s *goquery.Selection) {
313 s.ReplaceWithHtml("\n")
314 })
315
316 // Format links and images
317 doc.Find("a").Each(func(i int, s *goquery.Selection) {
318 href, exists := s.Attr("href")
319 if !exists {
320 return
321 }
322 s.ReplaceWithHtml(hyperlink(href, s.Text()))
323 })
324
325 doc.Find("img").Each(func(i int, s *goquery.Selection) {
326 src, exists := s.Attr("src")
327 if !exists {
328 return
329 }
330 alt, _ := s.Attr("alt")
331 if alt == "" {
332 alt = "Does not contain alt text"
333 }
334
335 if kittySupported() {
336 var payload string
337 if strings.HasPrefix(src, "data:image/") {
338 payload = dataURIBase64(src)
339 } else if strings.HasPrefix(src, "cid:") {
340 cid := strings.TrimPrefix(src, "cid:")
341 cid = strings.Trim(cid, "<>")
342 if inline != nil {
343 payload = inline[cid]
344 debugKitty("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
345 } else {
346 debugKitty("cid lookup skipped inline map nil for %s", cid)
347 }
348 } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
349 payload = fetchRemoteBase64(src)
350 }
351
352 if payload != "" {
353 if rendered := kittyInlineImage(payload); rendered != "" {
354 debugKitty("rendered inline image src=%s len=%d dataURI=%t cid=%t", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
355 s.ReplaceWithHtml("\n" + rendered + "\n")
356 return
357 }
358 debugKitty("payload present but renderer returned empty src=%s len=%d", src, len(payload))
359 } else {
360 debugKitty("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
361 }
362 } else {
363 debugKitty("kitty not detected for src=%s", src)
364 }
365
366 s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
367 })
368
369 text := doc.Text()
370
371 // Collapse excessive newlines, but not the image row placeholders
372 re := regexp.MustCompile(`\n{3,}`)
373 text = re.ReplaceAllString(text, "\n\n")
374
375 // Now expand the image row placeholders to actual newlines
376 text = expandImageRowPlaceholders(text)
377
378 return bodyStyle.Render(text), nil
379}