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
201// imageProtocolSupported checks if any supported image protocol terminal is detected.
202func imageProtocolSupported() bool {
203 return kittySupported() || ghosttySupported()
204}
205
206func debugImageProtocol(format string, args ...interface{}) {
207 if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
208 return
209 }
210 msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
211 fmt.Print(msg)
212 if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
213 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
214 _, _ = f.WriteString(msg)
215 _ = f.Close()
216 }
217 } else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
218 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
219 _, _ = f.WriteString(msg)
220 _ = f.Close()
221 }
222 }
223}
224
225func fetchRemoteBase64(url string) string {
226 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
227 return ""
228 }
229 client := &http.Client{Timeout: 5 * time.Second}
230 resp, err := client.Get(url)
231 if err != nil {
232 debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
233 return ""
234 }
235 defer resp.Body.Close()
236 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
237 debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
238 return ""
239 }
240 data, err := io.ReadAll(resp.Body)
241 if err != nil {
242 debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
243 return ""
244 }
245
246 img, _, err := image.Decode(bytes.NewReader(data))
247 if err != nil {
248 debugImageProtocol("remote decode failed url=%s err=%v", url, err)
249 return ""
250 }
251
252 var buf bytes.Buffer
253 if err := png.Encode(&buf, img); err != nil {
254 debugImageProtocol("remote png encode failed url=%s err=%v", url, err)
255 return ""
256 }
257
258 encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
259 debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
260 return encoded
261}
262
263func dataURIBase64(uri string) string {
264 if !strings.HasPrefix(uri, "data:") {
265 return ""
266 }
267 comma := strings.Index(uri, ",")
268 if comma == -1 || comma+1 >= len(uri) {
269 return ""
270 }
271 return uri[comma+1:]
272}
273
274// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
275// This prevents the newline-collapsing regex from removing intentional spacing.
276// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
277const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
278const imageRowPlaceholderSuffix = "]]"
279
280func kittyInlineImage(payload string) string {
281 if payload == "" {
282 return ""
283 }
284
285 const chunkSize = 4096
286 var b strings.Builder
287
288 // Calculate how many terminal rows the image occupies to advance text after it.
289 rows := 1
290 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
291 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
292 cellHeight := getTerminalCellSize()
293 h := img.Bounds().Dy()
294 rows = (h + cellHeight - 1) / cellHeight
295 if rows < 1 {
296 rows = 1
297 }
298 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
299 }
300 }
301
302 for offset := 0; offset < len(payload); offset += chunkSize {
303 end := offset + chunkSize
304 if end > len(payload) {
305 end = len(payload)
306 }
307 more := "0"
308 if end < len(payload) {
309 more = "1"
310 }
311
312 chunk := payload[offset:end]
313 if offset == 0 {
314 // C=1 means cursor does NOT move after image render (stays at top-left of image position)
315 // This is needed for proper TUI rendering, but we must add newlines to push text below
316 b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
317 } else {
318 b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
319 }
320 }
321
322 // Add newlines to push cursor below the image.
323 // Use a placeholder that won't be collapsed by the newline regex.
324 b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
325
326 return b.String()
327}
328
329// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
330func expandImageRowPlaceholders(text string) string {
331 re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
332 return re.ReplaceAllStringFunc(text, func(match string) string {
333 // Extract the number of rows from the placeholder
334 numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
335 numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
336 rows := 1
337 if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
338 rows = 1
339 }
340 // Return the newlines needed to push content below the image
341 return strings.Repeat("\n", rows)
342 })
343}
344
345type InlineImage struct {
346 CID string
347 Base64 string
348}
349
350// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
351func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
352 inlineMap := make(map[string]string, len(inline))
353 for _, img := range inline {
354 cid := strings.TrimSpace(img.CID)
355 cid = strings.TrimPrefix(cid, "<")
356 cid = strings.TrimSuffix(cid, ">")
357 cid = strings.TrimPrefix(cid, "cid:")
358 if cid == "" || img.Base64 == "" {
359 continue
360 }
361 inlineMap[cid] = img.Base64
362 }
363 return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle)
364}
365
366// ProcessBody takes a raw email body, decodes it, and formats it as plain
367// text with terminal hyperlinks.
368func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
369 return processBody(rawBody, nil, h1Style, h2Style, bodyStyle)
370}
371
372func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
373 decodedBody, err := decodeQuotedPrintable(rawBody)
374 if err != nil {
375 decodedBody = rawBody
376 }
377
378 htmlBody := markdownToHTML([]byte(decodedBody))
379
380 doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
381 if err != nil {
382 return "", fmt.Errorf("could not parse email body: %w", err)
383 }
384
385 doc.Find("style, script").Remove()
386
387 // Style headers by setting their text content.
388 // We use SetText so the h1/h2 tags remain in the document for spacing logic.
389 doc.Find("h1").Each(func(i int, s *goquery.Selection) {
390 s.SetText(h1Style.Render(s.Text()))
391 })
392
393 doc.Find("h2").Each(func(i int, s *goquery.Selection) {
394 s.SetText(h2Style.Render(s.Text()))
395 })
396
397 // Add newlines after block elements for better spacing.
398 doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
399 s.After("\n\n")
400 })
401
402 // Replace <br> tags with newlines
403 doc.Find("br").Each(func(i int, s *goquery.Selection) {
404 s.ReplaceWithHtml("\n")
405 })
406
407 // Format links and images
408 doc.Find("a").Each(func(i int, s *goquery.Selection) {
409 href, exists := s.Attr("href")
410 if !exists {
411 return
412 }
413 s.ReplaceWithHtml(hyperlink(href, s.Text()))
414 })
415
416 doc.Find("img").Each(func(i int, s *goquery.Selection) {
417 src, exists := s.Attr("src")
418 if !exists {
419 return
420 }
421 alt, _ := s.Attr("alt")
422 if alt == "" {
423 alt = "Does not contain alt text"
424 }
425
426 if imageProtocolSupported() {
427 var payload string
428 if strings.HasPrefix(src, "data:image/") {
429 payload = dataURIBase64(src)
430 } else if strings.HasPrefix(src, "cid:") {
431 cid := strings.TrimPrefix(src, "cid:")
432 cid = strings.Trim(cid, "<>")
433 if inline != nil {
434 payload = inline[cid]
435 debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
436 } else {
437 debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
438 }
439 } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
440 payload = fetchRemoteBase64(src)
441 }
442
443 if payload != "" {
444 if rendered := kittyInlineImage(payload); rendered != "" {
445 debugImageProtocol("rendered inline image src=%s len=%d dataURI=%t cid=%t (kitty=%t ghostty=%t)", src, len(payload), strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"), kittySupported(), ghosttySupported())
446 s.ReplaceWithHtml("\n" + rendered + "\n")
447 return
448 }
449 debugImageProtocol("payload present but renderer returned empty src=%s len=%d", src, len(payload))
450 } else {
451 debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
452 }
453 } else {
454 debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t)", src, kittySupported(), ghosttySupported())
455 }
456 if hyperlinkSupported() {
457 s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
458 } else {
459 s.ReplaceWithHtml(fmt.Sprintf("\n [Image: %s, %s] \n", alt, src))
460 }
461 })
462
463 text := doc.Text()
464
465 // Collapse excessive newlines, but not the image row placeholders
466 re := regexp.MustCompile(`\n{3,}`)
467 text = re.ReplaceAllString(text, "\n\n")
468
469 // Now expand the image row placeholders to actual newlines
470 text = expandImageRowPlaceholders(text)
471
472 return bodyStyle.Render(text), nil
473}