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