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	"sync"
 16	"time"
 17
 18	_ "image/gif"
 19	_ "image/jpeg"
 20
 21	"charm.land/lipgloss/v2"
 22	"github.com/PuerkitoBio/goquery"
 23	"github.com/yuin/goldmark"
 24	"github.com/yuin/goldmark/renderer/html"
 25	"golang.org/x/sys/unix"
 26)
 27
 28var linkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#9BC4FF")) // Cyan
 29
 30// getTerminalCellSize returns the height of a terminal cell in pixels.
 31// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
 32// Falls back to a default of 18 pixels if the query fails.
 33func getTerminalCellSize() int {
 34	const defaultCellHeight = 18
 35
 36	// Try stdout, stdin, stderr, then /dev/tty as last resort
 37	fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
 38
 39	for _, fd := range fds {
 40		if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
 41			return cellHeight
 42		}
 43	}
 44
 45	// Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
 46	if tty, err := os.Open("/dev/tty"); err == nil {
 47		defer tty.Close()
 48		if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
 49			return cellHeight
 50		}
 51	}
 52
 53	debugImageProtocol("using default cell height: %d pixels", defaultCellHeight)
 54	return defaultCellHeight
 55}
 56
 57// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor.
 58// Returns 0 if it fails or if pixel dimensions are not available.
 59func getCellHeightFromFd(fd int) int {
 60	ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
 61	if err != nil {
 62		return 0
 63	}
 64
 65	// ws.Row = number of character rows
 66	// ws.Ypixel = height in pixels
 67	// Some terminals don't report pixel dimensions (return 0)
 68	if ws.Row > 0 && ws.Ypixel > 0 {
 69		cellHeight := int(ws.Ypixel) / int(ws.Row)
 70		if cellHeight > 0 {
 71			debugImageProtocol("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
 72			return cellHeight
 73		}
 74	}
 75
 76	// Terminal reported dimensions but no pixel info - this is common
 77	if ws.Row > 0 && ws.Ypixel == 0 {
 78		debugImageProtocol("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
 79	}
 80
 81	return 0
 82}
 83
 84// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
 85func hyperlinkSupported() bool {
 86	term := strings.ToLower(os.Getenv("TERM"))
 87
 88	// Terminals known to support OSC 8 hyperlinks
 89	supportedTerms := []string{
 90		"kitty",
 91		"ghostty",
 92		"wezterm",
 93		"alacritty",
 94		"foot",
 95		"tmux",
 96		"screen",
 97	}
 98
 99	for _, supported := range supportedTerms {
100		if strings.Contains(term, supported) {
101			return true
102		}
103	}
104
105	// Check for specific terminal programs
106	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
107	supportedPrograms := []string{
108		"iterm.app",
109		"hyper",
110		"vscode",
111		"ghostty",
112		"wezterm",
113	}
114
115	for _, supported := range supportedPrograms {
116		if strings.Contains(termProgram, supported) {
117			return true
118		}
119	}
120
121	// Check for VTE-based terminals (GNOME Terminal, etc.)
122	if os.Getenv("VTE_VERSION") != "" {
123		return true
124	}
125
126	// Check for specific environment variables that indicate hyperlink support
127	if os.Getenv("KITTY_WINDOW_ID") != "" ||
128		os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
129		os.Getenv("WEZTERM_EXECUTABLE") != "" {
130		return true
131	}
132
133	return false
134}
135
136// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
137func hyperlink(url, text string) string {
138	if text == "" {
139		text = url
140	}
141
142	supported := hyperlinkSupported()
143
144	if supported {
145		// Use OSC 8 hyperlink sequence for supported terminals
146		return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle.Render(text))
147	} else {
148		// Fallback to plain text format for unsupported terminals
149		// Use HTML-encoded angle brackets to prevent HTML parser from treating them as tags
150		if text == url {
151			return fmt.Sprintf("<%s>", linkStyle.Render(url))
152		}
153		return fmt.Sprintf("%s <%s>", linkStyle.Render(text), linkStyle.Render(url))
154	}
155}
156
157func decodeQuotedPrintable(s string) (string, error) {
158	reader := quotedprintable.NewReader(strings.NewReader(s))
159	body, err := io.ReadAll(reader)
160	if err != nil {
161		return "", err
162	}
163	return string(body), nil
164}
165
166// markdownToHTML converts a Markdown string to an HTML string.
167func markdownToHTML(md []byte) []byte {
168	var buf bytes.Buffer
169	p := goldmark.New(
170		goldmark.WithRendererOptions(
171			html.WithUnsafe(), // Allow raw HTML in email.
172		),
173	)
174	if err := p.Convert(md, &buf); err != nil {
175		return md // Fallback to original markdown.
176	}
177	return buf.Bytes()
178}
179
180func kittySupported() bool {
181	term := strings.ToLower(os.Getenv("TERM"))
182	if strings.Contains(term, "kitty") {
183		return true
184	}
185	return os.Getenv("KITTY_WINDOW_ID") != ""
186}
187
188func ghosttySupported() bool {
189	// Check for TERM containing ghostty
190	term := strings.ToLower(os.Getenv("TERM"))
191	if strings.Contains(term, "ghostty") {
192		return true
193	}
194
195	// Check for Ghostty-specific environment variables
196	if os.Getenv("TERM_PROGRAM") == "ghostty" {
197		return true
198	}
199
200	// Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
201	return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
202}
203
204func iterm2Supported() bool {
205	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
206	if termProgram == "iterm.app" {
207		return true
208	}
209
210	// Check for iTerm2-specific environment variables
211	if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
212		return true
213	}
214
215	return false
216}
217
218func weztermSupported() bool {
219	// Check for WezTerm-specific environment variables
220	if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
221		return true
222	}
223
224	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
225	if termProgram == "wezterm" {
226		return true
227	}
228
229	term := strings.ToLower(os.Getenv("TERM"))
230	if strings.Contains(term, "wezterm") {
231		return true
232	}
233
234	return false
235}
236
237func waystSupported() bool {
238	term := strings.ToLower(os.Getenv("TERM"))
239	if strings.Contains(term, "wayst") {
240		return true
241	}
242
243	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
244	if termProgram == "wayst" {
245		return true
246	}
247
248	return false
249}
250
251func warpSupported() bool {
252	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
253	if termProgram == "warp" {
254		return true
255	}
256
257	// Check for Warp-specific environment variables
258	if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
259		return true
260	}
261
262	return false
263}
264
265func konsoleSupported() bool {
266	// Check for Konsole-specific environment variables
267	if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
268		return true
269	}
270
271	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
272	if termProgram == "konsole" {
273		return true
274	}
275
276	return false
277}
278
279// ImageProtocolSupported checks if any supported image protocol terminal is detected.
280func ImageProtocolSupported() bool {
281	return imageProtocolSupported()
282}
283
284// imageProtocolSupported checks if any supported image protocol terminal is detected.
285func imageProtocolSupported() bool {
286	return kittySupported() || ghosttySupported() || iterm2Supported() ||
287		weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
288}
289
290func debugImageProtocol(format string, args ...interface{}) {
291	if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
292		return
293	}
294	msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
295	fmt.Print(msg)
296	if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
297		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
298			_, _ = f.WriteString(msg)
299			_ = f.Close()
300		}
301	} else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
302		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
303			_, _ = f.WriteString(msg)
304			_ = f.Close()
305		}
306	}
307}
308
309// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
310var remoteImageCache sync.Map
311
312// nextImageID is an auto-incrementing counter for Kitty image IDs.
313var nextImageID uint32 = 1000
314
315// allocImageID returns a unique Kitty image ID.
316func allocImageID() uint32 {
317	id := nextImageID
318	nextImageID++
319	return id
320}
321
322func fetchRemoteBase64(url string) string {
323	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
324		return ""
325	}
326
327	// Check cache first
328	if cached, ok := remoteImageCache.Load(url); ok {
329		debugImageProtocol("remote cache hit url=%s", url)
330		return cached.(string)
331	}
332
333	client := &http.Client{Timeout: 5 * time.Second}
334	resp, err := client.Get(url)
335	if err != nil {
336		debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
337		return ""
338	}
339	defer resp.Body.Close()
340	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
341		debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
342		return ""
343	}
344	data, err := io.ReadAll(resp.Body)
345	if err != nil {
346		debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
347		return ""
348	}
349
350	img, _, err := image.Decode(bytes.NewReader(data))
351	if err != nil {
352		debugImageProtocol("remote decode failed url=%s err=%v", url, err)
353		return ""
354	}
355
356	var buf bytes.Buffer
357	if err := png.Encode(&buf, img); err != nil {
358		debugImageProtocol("remote png encode failed url=%s err=%v", url, err)
359		return ""
360	}
361
362	encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
363	debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
364	remoteImageCache.Store(url, encoded)
365	return encoded
366}
367
368func dataURIBase64(uri string) string {
369	if !strings.HasPrefix(uri, "data:") {
370		return ""
371	}
372	comma := strings.Index(uri, ",")
373	if comma == -1 || comma+1 >= len(uri) {
374		return ""
375	}
376	return uri[comma+1:]
377}
378
379// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
380// This prevents the newline-collapsing regex from removing intentional spacing.
381// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
382const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
383const imageRowPlaceholderSuffix = "]]"
384
385func kittyInlineImage(payload string) string {
386	if payload == "" {
387		return ""
388	}
389
390	const chunkSize = 4096
391	var b strings.Builder
392
393	// Calculate how many terminal rows the image occupies to advance text after it.
394	rows := 1
395	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
396		if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
397			cellHeight := getTerminalCellSize()
398			h := img.Bounds().Dy()
399			rows = (h + cellHeight - 1) / cellHeight
400			if rows < 1 {
401				rows = 1
402			}
403			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
404		}
405	}
406
407	for offset := 0; offset < len(payload); offset += chunkSize {
408		end := offset + chunkSize
409		if end > len(payload) {
410			end = len(payload)
411		}
412		more := "0"
413		if end < len(payload) {
414			more = "1"
415		}
416
417		chunk := payload[offset:end]
418		if offset == 0 {
419			// C=1 means cursor does NOT move after image render (stays at top-left of image position)
420			// This is needed for proper TUI rendering, but we must add newlines to push text below
421			b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
422		} else {
423			b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
424		}
425	}
426
427	// Add newlines to push cursor below the image.
428	// Use a placeholder that won't be collapsed by the newline regex.
429	b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
430
431	return b.String()
432}
433
434// iterm2InlineImage renders an image using iTerm2's image protocol
435func iterm2InlineImage(payload string) string {
436	if payload == "" {
437		return ""
438	}
439
440	// Calculate rows for cursor positioning
441	rows := 1
442	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
443		if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
444			cellHeight := getTerminalCellSize()
445			h := img.Bounds().Dy()
446			rows = (h + cellHeight - 1) / cellHeight
447			if rows < 1 {
448				rows = 1
449			}
450			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
451		}
452	}
453
454	// iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
455	result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
456
457	// Add placeholder for row spacing
458	result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
459
460	return result
461}
462
463// renderInlineImage renders an image using the appropriate protocol for the detected terminal
464func renderInlineImage(payload string) string {
465	if payload == "" {
466		return ""
467	}
468
469	if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
470		// These terminals use the Kitty graphics protocol
471		return kittyInlineImage(payload)
472	} else if iterm2Supported() || warpSupported() {
473		// iTerm2 and Warp use the iTerm2 image protocol
474		return iterm2InlineImage(payload)
475	}
476
477	return ""
478}
479
480// imageRows calculates the number of terminal rows an image occupies.
481func imageRows(payload string) int {
482	rows := 1
483	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
484		if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
485			cellHeight := getTerminalCellSize()
486			h := img.Bounds().Dy()
487			rows = (h + cellHeight - 1) / cellHeight
488			if rows < 1 {
489				rows = 1
490			}
491			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
492		}
493	}
494	return rows
495}
496
497// kittyUploadImage uploads image data to the terminal with a unique ID using
498// the Kitty graphics protocol transmit action (a=t). The image is stored in
499// the terminal's memory and can be displayed later by ID without re-sending data.
500func kittyUploadImage(payload string, id uint32) {
501	if payload == "" {
502		return
503	}
504
505	const chunkSize = 4096
506	for offset := 0; offset < len(payload); offset += chunkSize {
507		end := offset + chunkSize
508		if end > len(payload) {
509			end = len(payload)
510		}
511		more := "0"
512		if end < len(payload) {
513			more = "1"
514		}
515
516		chunk := payload[offset:end]
517		if offset == 0 {
518			// a=t: transmit (upload) only, don't display yet
519			// i=ID: assign this image ID
520			fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
521		} else {
522			fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
523		}
524	}
525	os.Stdout.Sync()
526}
527
528// kittyDisplayImage displays a previously uploaded image by its ID at the
529// current cursor position. This is very fast since no image data is transmitted.
530func kittyDisplayImage(id uint32) string {
531	// a=p: put (display) an already-uploaded image by ID
532	// C=1: cursor does not move
533	return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
534}
535
536// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
537// without any row placeholders. Used for out-of-band rendering to stdout.
538func iterm2ImageEscapeOnly(payload string) string {
539	if payload == "" {
540		return ""
541	}
542	return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
543}
544
545// RenderImageToStdout writes an image directly to stdout at the given screen
546// row using cursor positioning. This bypasses bubbletea's cell-based renderer
547// which cannot handle graphics protocol escape sequences.
548//
549// For Kitty-protocol terminals, images are uploaded once and then displayed by
550// ID on subsequent calls, making scroll rendering nearly instant.
551func RenderImageToStdout(placement *ImagePlacement, screenRow int) {
552	if placement.Base64 == "" {
553		return
554	}
555
556	useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
557	useIterm2 := iterm2Supported() || warpSupported()
558
559	if useKitty {
560		// Upload once, display by ID on subsequent renders
561		if !placement.Uploaded {
562			placement.ID = allocImageID()
563			kittyUploadImage(placement.Base64, placement.ID)
564			placement.Uploaded = true
565		}
566		seq := kittyDisplayImage(placement.ID)
567		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
568		os.Stdout.Sync()
569	} else if useIterm2 {
570		seq := iterm2ImageEscapeOnly(placement.Base64)
571		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
572		os.Stdout.Sync()
573	}
574}
575
576// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
577func expandImageRowPlaceholders(text string) string {
578	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
579	return re.ReplaceAllStringFunc(text, func(match string) string {
580		// Extract the number of rows from the placeholder
581		numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
582		numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
583		rows := 1
584		if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
585			rows = 1
586		}
587		// Return the newlines needed to push content below the image
588		return strings.Repeat("\n", rows)
589	})
590}
591
592type InlineImage struct {
593	CID    string
594	Base64 string
595}
596
597// ImagePlacement holds the data needed to render an image at a specific
598// line in the email body. Images are rendered directly to stdout (bypassing
599// bubbletea's cell-based renderer which cannot handle graphics protocols).
600type ImagePlacement struct {
601	Line     int    // Line number in the processed body text where the image starts
602	Base64   string // Base64-encoded image data (PNG)
603	Rows     int    // Number of terminal rows the image occupies
604	Uploaded bool   // Whether the image has been uploaded to the terminal via Kitty ID
605	ID       uint32 // Kitty image ID for display-by-reference
606}
607
608// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
609// Returns the rendered body text, image placements for out-of-band rendering, and any error.
610func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
611	inlineMap := make(map[string]string, len(inline))
612	for _, img := range inline {
613		cid := strings.TrimSpace(img.CID)
614		cid = strings.TrimPrefix(cid, "<")
615		cid = strings.TrimSuffix(cid, ">")
616		cid = strings.TrimPrefix(cid, "cid:")
617		if cid == "" || img.Base64 == "" {
618			continue
619		}
620		inlineMap[cid] = img.Base64
621	}
622	return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
623}
624
625// ProcessBody takes a raw email body, decodes it, and formats it as plain
626// text with terminal hyperlinks.
627func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
628	return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages)
629}
630
631func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
632	decodedBody, err := decodeQuotedPrintable(rawBody)
633	if err != nil {
634		decodedBody = rawBody
635	}
636
637	htmlBody := markdownToHTML([]byte(decodedBody))
638
639	doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
640	if err != nil {
641		return "", nil, fmt.Errorf("could not parse email body: %w", err)
642	}
643
644	doc.Find("style, script").Remove()
645
646	// Style headers by setting their text content.
647	// We use SetText so the h1/h2 tags remain in the document for spacing logic.
648	doc.Find("h1").Each(func(i int, s *goquery.Selection) {
649		s.SetText(h1Style.Render(s.Text()))
650	})
651
652	doc.Find("h2").Each(func(i int, s *goquery.Selection) {
653		s.SetText(h2Style.Render(s.Text()))
654	})
655
656	// Add newlines after block elements for better spacing.
657	doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
658		s.After("\n\n")
659	})
660
661	// Replace <br> tags with newlines
662	doc.Find("br").Each(func(i int, s *goquery.Selection) {
663		s.ReplaceWithHtml("\n")
664	})
665
666	// Handle blockquote elements (quoted replies)
667	// We collect quote data and use placeholders, then render after doc.Text()
668	var quoteData []struct {
669		from, date string
670		content    string
671	}
672	doc.Find("blockquote").Each(func(i int, s *goquery.Selection) {
673		// Try to extract sender info from cite attribute or preceding text
674		cite, _ := s.Attr("cite")
675		quoteText := strings.TrimSpace(s.Text())
676
677		// Look for "On DATE, EMAIL wrote:" pattern in previous sibling or cite
678		var from, date string
679		prevText := ""
680		if prev := s.Prev(); prev.Length() > 0 {
681			prevText = strings.TrimSpace(prev.Text())
682		}
683
684		onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
685		if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
686			date = parseDateForDisplay(matches[1])
687			from = matches[2]
688			// Remove the "On ... wrote:" from the previous element
689			s.Prev().Remove()
690		} else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
691			date = parseDateForDisplay(matches[1])
692			from = matches[2]
693		}
694
695		// Store quote data and use placeholder
696		quoteData = append(quoteData, struct {
697			from, date string
698			content    string
699		}{from, date, quoteText})
700		placeholder := fmt.Sprintf("\n[[MATCHA_QUOTE:%d]]\n", len(quoteData)-1)
701		s.ReplaceWithHtml(placeholder)
702	})
703
704	// Format links and images.
705	// Collect image placements for out-of-band rendering (bubbletea v2's
706	// ultraviolet renderer cannot pass through graphics protocol sequences).
707	var imgIndex int
708	var pendingImages []struct {
709		index   int
710		payload string
711		rows    int
712	}
713
714	doc.Find("a").Each(func(i int, s *goquery.Selection) {
715		href, exists := s.Attr("href")
716		if !exists {
717			return
718		}
719		s.ReplaceWithHtml(hyperlink(href, s.Text()))
720	})
721
722	doc.Find("img").Each(func(i int, s *goquery.Selection) {
723		src, exists := s.Attr("src")
724		if !exists {
725			return
726		}
727		alt, _ := s.Attr("alt")
728		if alt == "" {
729			alt = "Does not contain alt text"
730		}
731
732		if !disableImages && imageProtocolSupported() {
733			var payload string
734			if strings.HasPrefix(src, "data:image/") {
735				payload = dataURIBase64(src)
736			} else if strings.HasPrefix(src, "cid:") {
737				cid := strings.TrimPrefix(src, "cid:")
738				cid = strings.Trim(cid, "<>")
739				if inline != nil {
740					payload = inline[cid]
741					debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
742				} else {
743					debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
744				}
745			} else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
746				payload = fetchRemoteBase64(src)
747			}
748
749			if payload != "" {
750				rows := imageRows(payload)
751				debugImageProtocol("collected image placement src=%s rows=%d (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, rows, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
752
753				idx := imgIndex
754				imgIndex++
755				pendingImages = append(pendingImages, struct {
756					index   int
757					payload string
758					rows    int
759				}{idx, payload, rows})
760
761				// Insert a placeholder with blank lines for spacing.
762				// The image placement marker lets us find the line number later.
763				placeholder := fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
764				s.ReplaceWithHtml(fmt.Sprintf("\n[[MATCHA_IMG:%d]]%s", idx, placeholder))
765				return
766			}
767			debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
768		} else {
769			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())
770		}
771		if hyperlinkSupported() {
772			s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
773		} else {
774			s.ReplaceWithHtml(fmt.Sprintf("\n %s \n", linkStyle.Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
775		}
776	})
777
778	text := doc.Text()
779
780	// Collapse excessive newlines, but not the image row placeholders
781	re := regexp.MustCompile(`\n{3,}`)
782	text = re.ReplaceAllString(text, "\n\n")
783
784	// Now expand the image row placeholders to actual newlines
785	text = expandImageRowPlaceholders(text)
786
787	// Build image placements by finding the line numbers of image markers.
788	var placements []ImagePlacement
789	if len(pendingImages) > 0 {
790		lines := strings.Split(text, "\n")
791		imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
792		for lineNum, line := range lines {
793			if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
794				var idx int
795				fmt.Sscanf(matches[1], "%d", &idx)
796				for _, pi := range pendingImages {
797					if pi.index == idx {
798						placements = append(placements, ImagePlacement{
799							Line:   lineNum,
800							Base64: pi.payload,
801							Rows:   pi.rows,
802						})
803						break
804					}
805				}
806			}
807		}
808
809		// Remove the image markers from the text (leave the spacing)
810		text = imgMarkerRegex.ReplaceAllString(text, "")
811	}
812
813	// Replace quote placeholders with styled quote boxes
814	quoteRegex := regexp.MustCompile(`\[\[MATCHA_QUOTE:(\d+)\]\]`)
815	text = quoteRegex.ReplaceAllStringFunc(text, func(match string) string {
816		idxStr := quoteRegex.FindStringSubmatch(match)[1]
817		var idx int
818		fmt.Sscanf(idxStr, "%d", &idx)
819		if idx >= 0 && idx < len(quoteData) {
820			q := quoteData[idx]
821			return renderQuoteBox(q.from, q.date, strings.Split(q.content, "\n"))
822		}
823		return match
824	})
825
826	// Style quoted reply sections (for plain text > quotes)
827	text = styleQuotedReplies(text)
828
829	return bodyStyle.Render(text), placements, nil
830}
831
832// quoteBoxStyle is the style for the quoted reply box border
833var quoteBoxStyle = lipgloss.NewStyle().
834	Border(lipgloss.RoundedBorder()).
835	BorderForeground(lipgloss.Color("240")).
836	Padding(0, 1).
837	Foreground(lipgloss.Color("240"))
838
839// quoteHeaderStyle is the style for the header line in the quote box
840var quoteHeaderStyle = lipgloss.NewStyle().
841	Foreground(lipgloss.Color("240"))
842
843// styleQuotedReplies detects quoted reply sections and styles them in a box
844func styleQuotedReplies(text string) string {
845	lines := strings.Split(text, "\n")
846	var result []string
847	var quoteBlock []string
848	var quoteFrom, quoteDate string
849	inQuote := false
850
851	// Regex to match "On DATE, EMAIL wrote:" pattern
852	// Matches various date formats
853	onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
854
855	for i := 0; i < len(lines); i++ {
856		line := lines[i]
857		trimmedLine := strings.TrimSpace(line)
858
859		// Check for "On DATE, EMAIL wrote:" header
860		if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
861			// If we were already in a quote block, render it first
862			if inQuote && len(quoteBlock) > 0 {
863				result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
864				quoteBlock = nil
865			}
866
867			// Parse the date and email from the match
868			dateStr := matches[1]
869			quoteFrom = matches[2]
870			quoteDate = parseDateForDisplay(dateStr)
871			inQuote = true
872			continue
873		}
874
875		// Check if line starts with ">" (quoted text)
876		if strings.HasPrefix(trimmedLine, ">") {
877			if !inQuote {
878				// Start a new quote block without header info
879				inQuote = true
880				quoteFrom = ""
881				quoteDate = ""
882			}
883			// Remove the leading "> " and add to quote block
884			quotedContent := strings.TrimPrefix(trimmedLine, ">")
885			quotedContent = strings.TrimPrefix(quotedContent, " ")
886			quoteBlock = append(quoteBlock, quotedContent)
887		} else if inQuote {
888			// End of quote block - check if it's just whitespace
889			if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") {
890				// Empty line within quote block, keep it
891				quoteBlock = append(quoteBlock, "")
892			} else if trimmedLine == "" && len(quoteBlock) == 0 {
893				// Empty line before any quoted content, skip
894				continue
895			} else {
896				// End of quote block
897				if len(quoteBlock) > 0 {
898					result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
899					quoteBlock = nil
900				}
901				inQuote = false
902				quoteFrom = ""
903				quoteDate = ""
904				result = append(result, line)
905			}
906		} else {
907			result = append(result, line)
908		}
909	}
910
911	// Handle any remaining quote block
912	if inQuote && len(quoteBlock) > 0 {
913		result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
914	}
915
916	return strings.Join(result, "\n")
917}
918
919// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
920func parseDateForDisplay(dateStr string) string {
921	// Common date formats to try
922	formats := []string{
923		"Jan 2, 2006 at 3:04 PM",
924		"02:01:06 15:04",
925		"2006-01-02 15:04:05",
926		"Mon, 02 Jan 2006 15:04:05 -0700",
927		"Mon, 2 Jan 2006 15:04:05 -0700",
928		"2 Jan 2006 15:04:05",
929		"January 2, 2006 at 3:04 PM",
930		"Jan 2, 2006 3:04 PM",
931		time.RFC1123Z,
932		time.RFC1123,
933		time.RFC822Z,
934		time.RFC822,
935	}
936
937	for _, format := range formats {
938		if t, err := time.Parse(format, dateStr); err == nil {
939			return t.Format("02:01:06 15:04")
940		}
941	}
942
943	// Return original if parsing fails
944	return dateStr
945}
946
947// renderQuoteBox renders a quoted section in a styled box
948func renderQuoteBox(from, date string, lines []string) string {
949	// Build header with email on left and date on right
950	var header string
951	if from != "" || date != "" {
952		if from != "" && date != "" {
953			header = quoteHeaderStyle.Render(from + "  " + date)
954		} else if from != "" {
955			header = quoteHeaderStyle.Render(from)
956		} else {
957			header = quoteHeaderStyle.Render(date)
958		}
959	}
960
961	// Join the quoted content
962	content := strings.Join(lines, "\n")
963
964	// Build the box content
965	var boxContent string
966	if header != "" {
967		boxContent = header + "\n\n" + content
968	} else {
969		boxContent = content
970	}
971
972	return quoteBoxStyle.Render(boxContent)
973}