html.go

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