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 debugImageProtocol("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 debugImageProtocol("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 debugImageProtocol("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
76 }
77
78 return 0
79}
80
81// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
82func hyperlinkSupported() bool {
83 term := strings.ToLower(os.Getenv("TERM"))
84
85 // Terminals known to support OSC 8 hyperlinks
86 supportedTerms := []string{
87 "kitty",
88 "ghostty",
89 "wezterm",
90 "alacritty",
91 "foot",
92 "tmux",
93 "screen",
94 }
95
96 for _, supported := range supportedTerms {
97 if strings.Contains(term, supported) {
98 return true
99 }
100 }
101
102 // Check for specific terminal programs
103 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
104 supportedPrograms := []string{
105 "iterm.app",
106 "hyper",
107 "vscode",
108 "ghostty",
109 "wezterm",
110 }
111
112 for _, supported := range supportedPrograms {
113 if strings.Contains(termProgram, supported) {
114 return true
115 }
116 }
117
118 // Check for VTE-based terminals (GNOME Terminal, etc.)
119 if os.Getenv("VTE_VERSION") != "" {
120 return true
121 }
122
123 // Check for specific environment variables that indicate hyperlink support
124 if os.Getenv("KITTY_WINDOW_ID") != "" ||
125 os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
126 os.Getenv("WEZTERM_EXECUTABLE") != "" {
127 return true
128 }
129
130 return false
131}
132
133// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
134func hyperlink(url, text string) string {
135 if text == "" {
136 text = url
137 }
138
139 supported := hyperlinkSupported()
140
141 if supported {
142 // Use OSC 8 hyperlink sequence for supported terminals
143 return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, text)
144 } else {
145 // Fallback to plain text format for unsupported terminals
146 // Use HTML-encoded angle brackets to prevent HTML parser from treating them as tags
147 if text == url {
148 return fmt.Sprintf("<%s>", url)
149 }
150 return fmt.Sprintf("%s <%s>", text, url)
151 }
152}
153
154func decodeQuotedPrintable(s string) (string, error) {
155 reader := quotedprintable.NewReader(strings.NewReader(s))
156 body, err := io.ReadAll(reader)
157 if err != nil {
158 return "", err
159 }
160 return string(body), nil
161}
162
163// markdownToHTML converts a Markdown string to an HTML string.
164func markdownToHTML(md []byte) []byte {
165 var buf bytes.Buffer
166 p := goldmark.New(
167 goldmark.WithRendererOptions(
168 html.WithUnsafe(), // Allow raw HTML in email.
169 ),
170 )
171 if err := p.Convert(md, &buf); err != nil {
172 return md // Fallback to original markdown.
173 }
174 return buf.Bytes()
175}
176
177func kittySupported() bool {
178 term := strings.ToLower(os.Getenv("TERM"))
179 if strings.Contains(term, "kitty") {
180 return true
181 }
182 return os.Getenv("KITTY_WINDOW_ID") != ""
183}
184
185func ghosttySupported() bool {
186 // Check for TERM containing ghostty
187 term := strings.ToLower(os.Getenv("TERM"))
188 if strings.Contains(term, "ghostty") {
189 return true
190 }
191
192 // Check for Ghostty-specific environment variables
193 if os.Getenv("TERM_PROGRAM") == "ghostty" {
194 return true
195 }
196
197 // Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
198 return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
199}
200
201func iterm2Supported() bool {
202 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
203 if termProgram == "iterm.app" {
204 return true
205 }
206
207 // Check for iTerm2-specific environment variables
208 if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
209 return true
210 }
211
212 return false
213}
214
215func weztermSupported() bool {
216 // Check for WezTerm-specific environment variables
217 if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
218 return true
219 }
220
221 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
222 if termProgram == "wezterm" {
223 return true
224 }
225
226 term := strings.ToLower(os.Getenv("TERM"))
227 if strings.Contains(term, "wezterm") {
228 return true
229 }
230
231 return false
232}
233
234func waystSupported() bool {
235 term := strings.ToLower(os.Getenv("TERM"))
236 if strings.Contains(term, "wayst") {
237 return true
238 }
239
240 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
241 if termProgram == "wayst" {
242 return true
243 }
244
245 return false
246}
247
248func warpSupported() bool {
249 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
250 if termProgram == "warp" {
251 return true
252 }
253
254 // Check for Warp-specific environment variables
255 if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
256 return true
257 }
258
259 return false
260}
261
262func konsoleSupported() bool {
263 // Check for Konsole-specific environment variables
264 if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
265 return true
266 }
267
268 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
269 if termProgram == "konsole" {
270 return true
271 }
272
273 return false
274}
275
276// imageProtocolSupported checks if any supported image protocol terminal is detected.
277func imageProtocolSupported() bool {
278 return kittySupported() || ghosttySupported() || iterm2Supported() ||
279 weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
280}
281
282func debugImageProtocol(format string, args ...interface{}) {
283 if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
284 return
285 }
286 msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
287 fmt.Print(msg)
288 if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
289 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
290 _, _ = f.WriteString(msg)
291 _ = f.Close()
292 }
293 } else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
294 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
295 _, _ = f.WriteString(msg)
296 _ = f.Close()
297 }
298 }
299}
300
301func fetchRemoteBase64(url string) string {
302 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
303 return ""
304 }
305 client := &http.Client{Timeout: 5 * time.Second}
306 resp, err := client.Get(url)
307 if err != nil {
308 debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
309 return ""
310 }
311 defer resp.Body.Close()
312 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
313 debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
314 return ""
315 }
316 data, err := io.ReadAll(resp.Body)
317 if err != nil {
318 debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
319 return ""
320 }
321
322 img, _, err := image.Decode(bytes.NewReader(data))
323 if err != nil {
324 debugImageProtocol("remote decode failed url=%s err=%v", url, err)
325 return ""
326 }
327
328 var buf bytes.Buffer
329 if err := png.Encode(&buf, img); err != nil {
330 debugImageProtocol("remote png encode failed url=%s err=%v", url, err)
331 return ""
332 }
333
334 encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
335 debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
336 return encoded
337}
338
339func dataURIBase64(uri string) string {
340 if !strings.HasPrefix(uri, "data:") {
341 return ""
342 }
343 comma := strings.Index(uri, ",")
344 if comma == -1 || comma+1 >= len(uri) {
345 return ""
346 }
347 return uri[comma+1:]
348}
349
350// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
351// This prevents the newline-collapsing regex from removing intentional spacing.
352// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
353const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
354const imageRowPlaceholderSuffix = "]]"
355
356func kittyInlineImage(payload string) string {
357 if payload == "" {
358 return ""
359 }
360
361 const chunkSize = 4096
362 var b strings.Builder
363
364 // Calculate how many terminal rows the image occupies to advance text after it.
365 rows := 1
366 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
367 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
368 cellHeight := getTerminalCellSize()
369 h := img.Bounds().Dy()
370 rows = (h + cellHeight - 1) / cellHeight
371 if rows < 1 {
372 rows = 1
373 }
374 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
375 }
376 }
377
378 for offset := 0; offset < len(payload); offset += chunkSize {
379 end := offset + chunkSize
380 if end > len(payload) {
381 end = len(payload)
382 }
383 more := "0"
384 if end < len(payload) {
385 more = "1"
386 }
387
388 chunk := payload[offset:end]
389 if offset == 0 {
390 // C=1 means cursor does NOT move after image render (stays at top-left of image position)
391 // This is needed for proper TUI rendering, but we must add newlines to push text below
392 b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
393 } else {
394 b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
395 }
396 }
397
398 // Add newlines to push cursor below the image.
399 // Use a placeholder that won't be collapsed by the newline regex.
400 b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
401
402 return b.String()
403}
404
405// iterm2InlineImage renders an image using iTerm2's image protocol
406func iterm2InlineImage(payload string) string {
407 if payload == "" {
408 return ""
409 }
410
411 // Calculate rows for cursor positioning
412 rows := 1
413 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
414 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
415 cellHeight := getTerminalCellSize()
416 h := img.Bounds().Dy()
417 rows = (h + cellHeight - 1) / cellHeight
418 if rows < 1 {
419 rows = 1
420 }
421 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
422 }
423 }
424
425 // iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
426 result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
427
428 // Add placeholder for row spacing
429 result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
430
431 return result
432}
433
434// renderInlineImage renders an image using the appropriate protocol for the detected terminal
435func renderInlineImage(payload string) string {
436 if payload == "" {
437 return ""
438 }
439
440 if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
441 // These terminals use the Kitty graphics protocol
442 return kittyInlineImage(payload)
443 } else if iterm2Supported() || warpSupported() {
444 // iTerm2 and Warp use the iTerm2 image protocol
445 return iterm2InlineImage(payload)
446 }
447
448 return ""
449}
450
451// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
452func expandImageRowPlaceholders(text string) string {
453 re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
454 return re.ReplaceAllStringFunc(text, func(match string) string {
455 // Extract the number of rows from the placeholder
456 numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
457 numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
458 rows := 1
459 if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
460 rows = 1
461 }
462 // Return the newlines needed to push content below the image
463 return strings.Repeat("\n", rows)
464 })
465}
466
467type InlineImage struct {
468 CID string
469 Base64 string
470}
471
472// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
473func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
474 inlineMap := make(map[string]string, len(inline))
475 for _, img := range inline {
476 cid := strings.TrimSpace(img.CID)
477 cid = strings.TrimPrefix(cid, "<")
478 cid = strings.TrimSuffix(cid, ">")
479 cid = strings.TrimPrefix(cid, "cid:")
480 if cid == "" || img.Base64 == "" {
481 continue
482 }
483 inlineMap[cid] = img.Base64
484 }
485 return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle)
486}
487
488// ProcessBody takes a raw email body, decodes it, and formats it as plain
489// text with terminal hyperlinks.
490func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
491 return processBody(rawBody, nil, h1Style, h2Style, bodyStyle)
492}
493
494func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
495 decodedBody, err := decodeQuotedPrintable(rawBody)
496 if err != nil {
497 decodedBody = rawBody
498 }
499
500 htmlBody := markdownToHTML([]byte(decodedBody))
501
502 doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
503 if err != nil {
504 return "", fmt.Errorf("could not parse email body: %w", err)
505 }
506
507 doc.Find("style, script").Remove()
508
509 // Style headers by setting their text content.
510 // We use SetText so the h1/h2 tags remain in the document for spacing logic.
511 doc.Find("h1").Each(func(i int, s *goquery.Selection) {
512 s.SetText(h1Style.Render(s.Text()))
513 })
514
515 doc.Find("h2").Each(func(i int, s *goquery.Selection) {
516 s.SetText(h2Style.Render(s.Text()))
517 })
518
519 // Add newlines after block elements for better spacing.
520 doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
521 s.After("\n\n")
522 })
523
524 // Replace <br> tags with newlines
525 doc.Find("br").Each(func(i int, s *goquery.Selection) {
526 s.ReplaceWithHtml("\n")
527 })
528
529 // Format links and images
530 doc.Find("a").Each(func(i int, s *goquery.Selection) {
531 href, exists := s.Attr("href")
532 if !exists {
533 return
534 }
535 s.ReplaceWithHtml(hyperlink(href, s.Text()))
536 })
537
538 doc.Find("img").Each(func(i int, s *goquery.Selection) {
539 src, exists := s.Attr("src")
540 if !exists {
541 return
542 }
543 alt, _ := s.Attr("alt")
544 if alt == "" {
545 alt = "Does not contain alt text"
546 }
547
548 if imageProtocolSupported() {
549 var payload string
550 if strings.HasPrefix(src, "data:image/") {
551 payload = dataURIBase64(src)
552 } else if strings.HasPrefix(src, "cid:") {
553 cid := strings.TrimPrefix(src, "cid:")
554 cid = strings.Trim(cid, "<>")
555 if inline != nil {
556 payload = inline[cid]
557 debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
558 } else {
559 debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
560 }
561 } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
562 payload = fetchRemoteBase64(src)
563 }
564
565 if payload != "" {
566 if rendered := renderInlineImage(payload); rendered != "" {
567 debugImageProtocol("rendered inline image src=%s len=%d dataURI=%t cid=%t (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"), kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
568 s.ReplaceWithHtml("\n" + rendered + "\n")
569 return
570 }
571 debugImageProtocol("payload present but renderer returned empty src=%s len=%d", src, len(payload))
572 } else {
573 debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
574 }
575 } else {
576 debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
577 }
578 if hyperlinkSupported() {
579 s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
580 } else {
581 s.ReplaceWithHtml(fmt.Sprintf("\n [Image: %s, %s] \n", alt, src))
582 }
583 })
584
585 text := doc.Text()
586
587 // Collapse excessive newlines, but not the image row placeholders
588 re := regexp.MustCompile(`\n{3,}`)
589 text = re.ReplaceAllString(text, "\n\n")
590
591 // Now expand the image row placeholders to actual newlines
592 text = expandImageRowPlaceholders(text)
593
594 return bodyStyle.Render(text), nil
595}