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