html.go

  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}