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