html.go

  1package view
  2
  3import (
  4	"bytes"
  5	"encoding/base64"
  6	"fmt"
  7	"io"
  8	"mime/quotedprintable"
  9	"os"
 10	"regexp"
 11	"strings"
 12	"time"
 13
 14	"charm.land/lipgloss/v2"
 15	"github.com/floatpane/matcha/clib"
 16	"github.com/floatpane/matcha/internal/htmlsanitizer"
 17	"github.com/floatpane/matcha/internal/httpclient"
 18	"github.com/floatpane/matcha/internal/loglevel"
 19	"github.com/floatpane/matcha/theme"
 20	"github.com/floatpane/termimage"
 21	lru "github.com/hashicorp/golang-lru/v2"
 22)
 23
 24var htmlSanitizer htmlsanitizer.Sanitizer = htmlsanitizer.NewLibSanitizer()
 25
 26const termGhostty = "ghostty"
 27
 28func linkStyle() lipgloss.Style {
 29	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
 30}
 31
 32// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
 33func hyperlinkSupported() bool {
 34	term := strings.ToLower(os.Getenv("TERM"))
 35
 36	// Terminals known to support OSC 8 hyperlinks
 37	supportedTerms := []string{
 38		"kitty",
 39		termGhostty,
 40		"wezterm",
 41		"alacritty",
 42		"foot",
 43		"tmux",
 44		"screen",
 45	}
 46
 47	for _, supported := range supportedTerms {
 48		if strings.Contains(term, supported) {
 49			return true
 50		}
 51	}
 52
 53	// Check for specific terminal programs
 54	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 55	supportedPrograms := []string{
 56		"iterm.app",
 57		"hyper",
 58		"vscode",
 59		termGhostty,
 60		"wezterm",
 61	}
 62
 63	for _, supported := range supportedPrograms {
 64		if strings.Contains(termProgram, supported) {
 65			return true
 66		}
 67	}
 68
 69	// Check for VTE-based terminals (GNOME Terminal, etc.)
 70	if os.Getenv("VTE_VERSION") != "" {
 71		return true
 72	}
 73
 74	// Check for specific environment variables that indicate hyperlink support
 75	if os.Getenv("KITTY_WINDOW_ID") != "" ||
 76		os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
 77		os.Getenv("WEZTERM_EXECUTABLE") != "" ||
 78		os.Getenv("WT_SESSION") != "" {
 79		return true
 80	}
 81
 82	return false
 83}
 84
 85// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
 86func hyperlink(url, text string) string {
 87	url = strings.TrimSpace(url)
 88	text = stripTerminalControls(text)
 89	if text == "" {
 90		text = url
 91	}
 92
 93	supported := hyperlinkSupported()
 94
 95	if supported {
 96		// Use OSC 8 hyperlink sequence for supported terminals
 97		return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
 98	}
 99	// Fallback to plain text format for unsupported terminals
100	if text == url {
101		return fmt.Sprintf("<%s>", linkStyle().Render(url))
102	}
103	return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url))
104}
105
106func stripTerminalControls(s string) string {
107	return strings.Map(func(r rune) rune {
108		if r == '\n' || r == '\t' {
109			return r
110		}
111		if r < 0x20 || r == 0x7f || r == 0x9c {
112			return -1
113		}
114		return r
115	}, s)
116}
117
118func hasTerminalControls(s string) bool {
119	return strings.IndexFunc(s, func(r rune) bool {
120		return r < 0x20 || r == 0x7f || r == 0x9c
121	}) != -1
122}
123
124func decodeQuotedPrintable(s string) (string, error) {
125	reader := quotedprintable.NewReader(strings.NewReader(s))
126	body, err := io.ReadAll(reader)
127	if err != nil {
128		return "", err
129	}
130	return string(body), nil
131}
132
133// markdownToHTML converts a Markdown string to an HTML string using md4c (C).
134func markdownToHTML(md []byte) []byte {
135	return clib.MarkdownToHTML(md)
136}
137
138func kittySupported() bool {
139	term := strings.ToLower(os.Getenv("TERM"))
140	if strings.Contains(term, "kitty") {
141		return true
142	}
143	return os.Getenv("KITTY_WINDOW_ID") != ""
144}
145
146func ghosttySupported() bool {
147	// Check for TERM containing ghostty
148	term := strings.ToLower(os.Getenv("TERM"))
149	if strings.Contains(term, termGhostty) {
150		return true
151	}
152
153	// Check for Ghostty-specific environment variables
154	if os.Getenv("TERM_PROGRAM") == termGhostty {
155		return true
156	}
157
158	// Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
159	return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
160}
161
162func iterm2Supported() bool {
163	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
164	if termProgram == "iterm.app" {
165		return true
166	}
167
168	// Check for iTerm2-specific environment variables
169	if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
170		return true
171	}
172
173	return false
174}
175
176func weztermSupported() bool {
177	// Check for WezTerm-specific environment variables
178	if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
179		return true
180	}
181
182	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
183	if termProgram == "wezterm" {
184		return true
185	}
186
187	term := strings.ToLower(os.Getenv("TERM"))
188	return strings.Contains(term, "wezterm")
189}
190
191func waystSupported() bool {
192	term := strings.ToLower(os.Getenv("TERM"))
193	if strings.Contains(term, "wayst") {
194		return true
195	}
196
197	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
198	return termProgram == "wayst"
199}
200
201func warpSupported() bool {
202	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
203	if termProgram == "warp" {
204		return true
205	}
206
207	// Check for Warp-specific environment variables
208	if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
209		return true
210	}
211
212	return false
213}
214
215func konsoleSupported() bool {
216	// Check for Konsole-specific environment variables
217	if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
218		return true
219	}
220
221	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
222	return termProgram == "konsole"
223}
224
225func zellijSupported() bool {
226	return os.Getenv("ZELLIJ") != "" || os.Getenv("ZELLIJ_SESSION_NAME") != ""
227}
228
229func sixelSupported() bool {
230	// Zellij always supports Sixel
231	if zellijSupported() {
232		return true
233	}
234
235	// Native Sixel terminals
236	term := strings.ToLower(os.Getenv("TERM"))
237	return strings.Contains(term, "mlterm") ||
238		strings.Contains(term, "foot") ||
239		(strings.Contains(term, "xterm") && os.Getenv("SIXEL") == "1")
240}
241
242// ImageProtocolSupported checks if any supported image protocol terminal is detected.
243func ImageProtocolSupported() bool {
244	return imageProtocolSupported()
245}
246
247// SixelSupported returns true if the terminal uses the Sixel graphics protocol.
248func SixelSupported() bool {
249	return sixelSupported()
250}
251
252// imageProtocolSupported checks if any supported image protocol terminal is detected.
253func imageProtocolSupported() bool {
254	return sixelSupported() || kittySupported() || ghosttySupported() || iterm2Supported() ||
255		weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
256}
257
258func debugImageProtocol(format string, args ...interface{}) {
259	if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
260		return
261	}
262	msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
263	loglevel.Infof("%s", strings.TrimSuffix(msg, "\n"))
264	if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
265		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil { //nolint:gosec
266			if _, err := f.WriteString(msg); err != nil {
267				loglevel.Debugf("image protocol write error: %v", err)
268			}
269			if err := f.Close(); err != nil {
270				loglevel.Debugf("image protocol close error: %v", err)
271			}
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 { //nolint:gosec
275			if _, err := f.WriteString(msg); err != nil {
276				loglevel.Debugf("image protocol write error: %v", err)
277			}
278			if err := f.Close(); err != nil {
279				loglevel.Debugf("image protocol close error: %v", err)
280			}
281		}
282	}
283}
284
285const remoteImageCacheSize = 20
286
287// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
288var remoteImageCache *lru.Cache[string, string]
289
290func init() {
291	c, err := lru.New[string, string](remoteImageCacheSize)
292	if err != nil {
293		panic(err) // only fails on size <= 0
294	}
295	remoteImageCache = c
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.Get(url); ok {
305		debugImageProtocol("remote cache hit url=%s", url)
306		return cached
307	}
308
309	client := httpclient.New(httpclient.RemoteImageTimeout)
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() //nolint:errcheck
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	// Limit response body to 10 MB to prevent memory exhaustion from
321	// malicious or very large images.
322	const maxImageSize = 10 << 20 // 10 MB
323	data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize))
324	if err != nil {
325		debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
326		return ""
327	}
328
329	result, ok := clib.DecodeToPNG(data)
330	if !ok {
331		debugImageProtocol("remote decode failed url=%s", url)
332		return ""
333	}
334
335	encoded := base64.StdEncoding.EncodeToString(result.PNGData)
336	debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
337	remoteImageCache.Add(url, encoded)
338	return encoded
339}
340
341func dataURIBase64(uri string) string {
342	if !strings.HasPrefix(uri, "data:") {
343		return ""
344	}
345	comma := strings.Index(uri, ",")
346	if comma == -1 || comma+1 >= len(uri) {
347		return ""
348	}
349	return uri[comma+1:]
350}
351
352// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
353// This prevents the newline-collapsing regex from removing intentional spacing.
354// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
355const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
356const imageRowPlaceholderSuffix = "]]"
357
358// prerenderImage decodes and renders an image via termimage at layout time,
359// returning the cached escape sequence and the exact number of terminal rows
360// the rendered image will occupy. Both are stored on the ImagePlacement so
361// (a) text below the image is offset by the correct row count and (b) the
362// paint stage in RenderImageToStdout is a plain stdout write with no decode.
363func prerenderImage(payload string) (string, int) {
364	src := "data:image/png;base64," + payload
365	var buf bytes.Buffer
366	_, rows, err := termimage.DisplayWithSize(&buf, src, termimage.Options{
367		Protocol:  termimage.Auto,
368		Sandboxed: true,
369	})
370	if err != nil {
371		debugImageProtocol("termimage.DisplayWithSize error: %v", err)
372		return "", 1
373	}
374	if rows < 1 {
375		rows = 1
376	}
377	debugImageProtocol("termimage: prerendered rows=%d bytes=%d", rows, buf.Len())
378	return buf.String(), rows
379}
380
381// RenderImageToStdout writes an image directly to stdout at the given screen
382// row using cursor positioning. This bypasses bubbletea's cell-based renderer
383// which cannot handle graphics protocol escape sequences.
384//
385// The escape sequence and row count were captured at HTML processing time by
386// prerenderImage, so this call is a plain stdout write with no decode.
387func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ...int) {
388	if placement.Encoded == "" {
389		return
390	}
391
392	col := 1
393	if len(screenCol) > 0 && screenCol[0] > 0 {
394		col = screenCol[0]
395	}
396
397	debugImageProtocol("termimage: rendering %d bytes at row=%d col=%d", len(placement.Encoded), screenRow+1, col)
398	fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", //nolint:errcheck
399		screenRow+1, col, placement.Encoded)
400	os.Stdout.Sync() //nolint:errcheck,gosec
401}
402
403// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
404func expandImageRowPlaceholders(text string) string {
405	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
406	return re.ReplaceAllStringFunc(text, func(match string) string {
407		// Extract the number of rows from the placeholder
408		numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
409		numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
410		rows := 1
411		if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
412			rows = 1
413		}
414		// Return the newlines needed to push content below the image
415		return strings.Repeat("\n", rows)
416	})
417}
418
419type InlineImage struct {
420	CID    string
421	Base64 string
422}
423
424// ImagePlacement holds the data needed to render an image at a specific
425// line in the email body. Images are rendered directly to stdout (bypassing
426// bubbletea's cell-based renderer which cannot handle graphics protocols).
427//
428// Encoded and Rows are populated at HTML processing time by prerenderImage
429// using termimage.DisplayWithSize, so paint-stage rendering is a plain
430// stdout write and layout-stage row reservation matches the rendered output
431// exactly.
432type ImagePlacement struct {
433	Line    int    // Line number in the processed body text where the image starts
434	Rows    int    // Number of terminal rows the rendered image occupies (from termimage)
435	Encoded string // Cached terminal escape sequence from termimage (rendered once at layout time)
436}
437
438// BodyMIMEType values understood by ProcessBody/ProcessBodyWithInline. Empty
439// string means "unknown" — the renderer falls back to running markdownToHTML
440// before HTML parsing, which is correct for plaintext-with-markdown bodies but
441// can mangle complex HTML (e.g. tables with attribute-heavy <td style="...">).
442const (
443	BodyMIMETypeHTML  = "text/html"
444	BodyMIMETypePlain = "text/plain"
445)
446
447// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
448// Returns the rendered body text, image placements for out-of-band rendering, and any error.
449// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass).
450func ProcessBodyWithInline(rawBody, mimeType string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
451	inlineMap := make(map[string]string, len(inline))
452	for _, img := range inline {
453		cid := strings.TrimSpace(img.CID)
454		cid = strings.TrimPrefix(cid, "<")
455		cid = strings.TrimSuffix(cid, ">")
456		cid = strings.TrimPrefix(cid, "cid:")
457		if cid == "" || img.Base64 == "" {
458			continue
459		}
460		inlineMap[cid] = img.Base64
461	}
462	return processBody(rawBody, mimeType, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
463}
464
465// ProcessBody takes a raw email body, decodes it, and formats it as plain
466// text with terminal hyperlinks.
467// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass).
468func ProcessBody(rawBody, mimeType string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
469	return processBody(rawBody, mimeType, nil, h1Style, h2Style, bodyStyle, disableImages)
470}
471
472func processBody(rawBody, mimeType string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
473	decodedBody, err := decodeQuotedPrintable(rawBody)
474	if err != nil {
475		decodedBody = rawBody
476	}
477
478	// HTML bodies skip the markdown pre-pass — md4c can mangle attribute-heavy
479	// or indented HTML (#602-style raw-tag bleed-through). Empty mimeType keeps
480	// legacy behavior for cached/legacy callers that don't supply one.
481	directHTML := mimeType == BodyMIMETypeHTML
482	var htmlBody []byte
483	if directHTML {
484		htmlBody = []byte(decodedBody)
485	} else {
486		htmlBody = markdownToHTML([]byte(decodedBody))
487	}
488	htmlBody = htmlSanitizer.SanitizeBytes(htmlBody)
489
490	result, placements, err := renderHTMLToText(htmlBody, inline, h1Style, h2Style, disableImages)
491	if err != nil {
492		return "", nil, err
493	}
494
495	// Some real-world HTML emails (newsletters with table-only layouts and no
496	// <th>, AWeber-shape bodies) emit no visible content from htmlconv. Pre-
497	// c11de45, every body went through markdownToHTML first, which happened to
498	// keep these alive. Retry through the markdown pre-pass when the direct
499	// HTML path produces nothing.
500	if directHTML && strings.TrimSpace(result) == "" {
501		fallbackHTML := htmlSanitizer.SanitizeBytes(markdownToHTML([]byte(decodedBody)))
502		result, placements, err = renderHTMLToText(fallbackHTML, inline, h1Style, h2Style, disableImages)
503		if err != nil {
504			return "", nil, err
505		}
506	}
507
508	result = styleQuotedReplies(result)
509	return bodyStyle.Render(result), placements, nil
510}
511
512func renderHTMLToText(htmlBody []byte, inline map[string]string, h1Style, h2Style lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
513	// Parse HTML into structured elements using C parser.
514	elements, ok := clib.HTMLToElements(string(htmlBody))
515	if !ok {
516		return "", nil, fmt.Errorf("could not parse email body")
517	}
518
519	// Process elements: apply styles and collect image placements.
520	var text strings.Builder
521	var imgIndex int
522	var pendingImages []struct {
523		index   int
524		encoded string
525		rows    int
526	}
527
528	onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
529
530	for _, elem := range elements {
531		switch elem.Type {
532		case clib.HElemText:
533			text.WriteString(elem.Text)
534
535		case clib.HElemH1:
536			text.WriteString(h1Style.Render(elem.Text))
537			text.WriteString("\n\n")
538
539		case clib.HElemH2:
540			text.WriteString(h2Style.Render(elem.Text))
541			text.WriteString("\n\n")
542
543		case clib.HElemLink:
544			if hasTerminalControls(elem.Attr1) {
545				text.WriteString(stripTerminalControls(elem.Text))
546			} else {
547				text.WriteString(hyperlink(elem.Attr1, elem.Text))
548			}
549
550		case clib.HElemImage:
551			src := strings.TrimSpace(elem.Attr1)
552			alt := stripTerminalControls(elem.Attr2)
553			if hasTerminalControls(src) {
554				continue
555			}
556
557			if !disableImages && imageProtocolSupported() {
558				payload := resolveImagePayload(src, inline)
559
560				if payload != "" {
561					encoded, rows := prerenderImage(payload)
562					if encoded == "" {
563						debugImageProtocol("prerender failed for src=%s", src)
564					} else {
565						debugImageProtocol("collected image placement src=%s rows=%d", src, rows)
566
567						idx := imgIndex
568						imgIndex++
569						pendingImages = append(pendingImages, struct {
570							index   int
571							encoded string
572							rows    int
573						}{idx, encoded, rows})
574
575						fmt.Fprintf(&text, "\n[[MATCHA_IMG:%d]]", idx)
576						fmt.Fprintf(&text, "\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
577						continue
578					}
579				}
580				debugImageProtocol("no payload for src=%s", src)
581			}
582			if isRemoteImageURL(src) && hyperlinkSupported() {
583				fmt.Fprintf(&text, "\n %s \n", hyperlink(src, fmt.Sprintf("[Click here to view image: %s]", alt)))
584			} else {
585				fmt.Fprintf(&text, "\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src)))
586			}
587
588		case clib.HElemTable:
589			headerRows := 0
590			if elem.Attr1 != "" {
591				fmt.Sscanf(elem.Attr1, "%d", &headerRows) //nolint:errcheck,gosec
592			}
593			text.WriteString("\n")
594			text.WriteString(renderTable(elem.Text, headerRows))
595			text.WriteString("\n")
596
597		case clib.HElemBlockquote:
598			var from, date string
599			prevText := elem.Attr2
600			cite := elem.Attr1
601
602			if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
603				date = parseDateForDisplay(matches[1])
604				from = matches[2]
605			} else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
606				date = parseDateForDisplay(matches[1])
607				from = matches[2]
608			}
609
610			text.WriteString(renderQuoteBox(from, date, strings.Split(elem.Text, "\n")))
611		}
612	}
613
614	result := text.String()
615
616	// Collapse excessive newlines, but not the image row placeholders
617	re := regexp.MustCompile(`\n{3,}`)
618	result = re.ReplaceAllString(result, "\n\n")
619
620	// Now expand the image row placeholders to actual newlines
621	result = expandImageRowPlaceholders(result)
622
623	// Build image placements by finding the line numbers of image markers.
624	var placements []ImagePlacement
625	if len(pendingImages) > 0 {
626		lines := strings.Split(result, "\n")
627		imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
628		for lineNum, line := range lines {
629			if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
630				var idx int
631				fmt.Sscanf(matches[1], "%d", &idx) //nolint:errcheck,gosec
632				for _, pi := range pendingImages {
633					if pi.index == idx {
634						placements = append(placements, ImagePlacement{
635							Line:    lineNum,
636							Encoded: pi.encoded,
637							Rows:    pi.rows,
638						})
639						break
640					}
641				}
642			}
643		}
644
645		// Remove the image markers from the text (leave the spacing)
646		result = imgMarkerRegex.ReplaceAllString(result, "")
647	}
648
649	return result, placements, nil
650}
651
652func resolveImagePayload(src string, inline map[string]string) string {
653	switch {
654	case strings.HasPrefix(src, "data:image/"):
655		return dataURIBase64(src)
656	case strings.HasPrefix(src, "cid:"):
657		cid := strings.TrimPrefix(src, "cid:")
658		cid = strings.Trim(cid, "<>")
659		if inline != nil {
660			payload := inline[cid]
661			debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
662			return payload
663		}
664		debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
665		return ""
666	case strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://"):
667		return fetchRemoteBase64(src)
668	}
669	return ""
670}
671
672func isRemoteImageURL(src string) bool {
673	src = strings.ToLower(src)
674	return strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://")
675}
676
677func tableHeaderStyle() lipgloss.Style {
678	return lipgloss.NewStyle().Bold(true).Foreground(theme.ActiveTheme.Accent)
679}
680
681func tableBorderStyle() lipgloss.Style {
682	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Secondary)
683}
684
685// renderTable renders table data as a Unicode box-drawing table.
686// data is tab-separated cells, newline-separated rows.
687// headerRows is the number of header rows.
688func renderTable(data string, headerRows int) string {
689	rows := strings.Split(data, "\n")
690	if len(rows) == 0 {
691		return ""
692	}
693
694	// Parse into 2D grid and trim cell whitespace
695	var grid [][]string
696	maxCols := 0
697	for _, row := range rows {
698		cells := strings.Split(row, "\t")
699		trimmed := make([]string, len(cells))
700		for i, c := range cells {
701			trimmed[i] = strings.TrimSpace(c)
702		}
703		grid = append(grid, trimmed)
704		if len(trimmed) > maxCols {
705			maxCols = len(trimmed)
706		}
707	}
708
709	// Normalize: ensure all rows have the same number of columns
710	for i := range grid {
711		for len(grid[i]) < maxCols {
712			grid[i] = append(grid[i], "")
713		}
714	}
715
716	// Calculate column widths
717	colWidths := make([]int, maxCols)
718	for _, row := range grid {
719		for j, cell := range row {
720			if len(cell) > colWidths[j] {
721				colWidths[j] = len(cell)
722			}
723		}
724	}
725
726	// Minimum width per column
727	for i := range colWidths {
728		if colWidths[i] < 3 {
729			colWidths[i] = 3
730		}
731	}
732
733	bs := tableBorderStyle()
734	hs := tableHeaderStyle()
735
736	// Build horizontal borders
737	buildBorder := func(left, mid, right, fill string) string {
738		var b strings.Builder
739		b.WriteString(bs.Render(left))
740		for j, w := range colWidths {
741			b.WriteString(bs.Render(strings.Repeat(fill, w+2)))
742			if j < len(colWidths)-1 {
743				b.WriteString(bs.Render(mid))
744			}
745		}
746		b.WriteString(bs.Render(right))
747		return b.String()
748	}
749
750	topBorder := buildBorder("┌", "┬", "┐", "─")
751	midBorder := buildBorder("├", "┼", "┤", "─")
752	botBorder := buildBorder("└", "┴", "┘", "─")
753
754	var out strings.Builder
755	out.WriteString(topBorder)
756	out.WriteString("\n")
757
758	for i, row := range grid {
759		out.WriteString(bs.Render("│"))
760		for j, cell := range row {
761			padded := cell + strings.Repeat(" ", colWidths[j]-len(cell))
762			if i < headerRows {
763				out.WriteString(" " + hs.Render(padded) + " ")
764			} else {
765				out.WriteString(" " + padded + " ")
766			}
767			out.WriteString(bs.Render("│"))
768		}
769		out.WriteString("\n")
770
771		if i < headerRows && (i+1 == headerRows || i+1 == len(grid)) {
772			out.WriteString(midBorder)
773			out.WriteString("\n")
774		}
775	}
776
777	out.WriteString(botBorder)
778	return out.String()
779}
780
781func quoteBoxStyle() lipgloss.Style {
782	return lipgloss.NewStyle().
783		Border(lipgloss.RoundedBorder()).
784		BorderForeground(theme.ActiveTheme.Secondary).
785		Padding(0, 1).
786		Foreground(theme.ActiveTheme.Secondary)
787}
788
789func quoteHeaderStyle() lipgloss.Style {
790	return lipgloss.NewStyle().
791		Foreground(theme.ActiveTheme.Secondary)
792}
793
794// styleQuotedReplies detects quoted reply sections and styles them in a box
795func styleQuotedReplies(text string) string {
796	lines := strings.Split(text, "\n")
797	var result []string
798	var quoteBlock []string
799	var quoteFrom, quoteDate string
800	inQuote := false
801
802	// Regex to match "On DATE, EMAIL wrote:" pattern
803	// Matches various date formats
804	onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
805
806	for i := 0; i < len(lines); i++ {
807		line := lines[i]
808		trimmedLine := strings.TrimSpace(line)
809
810		// Check for "On DATE, EMAIL wrote:" header
811		if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
812			// If we were already in a quote block, render it first
813			if inQuote && len(quoteBlock) > 0 {
814				result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
815				quoteBlock = nil
816			}
817
818			// Parse the date and email from the match
819			dateStr := matches[1]
820			quoteFrom = matches[2]
821			quoteDate = parseDateForDisplay(dateStr)
822			inQuote = true
823			continue
824		}
825
826		// Check if line starts with ">" (quoted text)
827		if strings.HasPrefix(trimmedLine, ">") { //nolint:gocritic
828			if !inQuote {
829				// Start a new quote block without header info
830				inQuote = true
831				quoteFrom = ""
832				quoteDate = ""
833			}
834			// Remove the leading "> " and add to quote block
835			quotedContent := strings.TrimPrefix(trimmedLine, ">")
836			quotedContent = strings.TrimPrefix(quotedContent, " ")
837			quoteBlock = append(quoteBlock, quotedContent)
838		} else if inQuote {
839			// End of quote block - check if it's just whitespace
840			if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") { //nolint:gocritic
841				// Empty line within quote block, keep it
842				quoteBlock = append(quoteBlock, "")
843			} else if trimmedLine == "" && len(quoteBlock) == 0 {
844				// Empty line before any quoted content, skip
845				continue
846			} else {
847				// End of quote block
848				if len(quoteBlock) > 0 {
849					result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
850					quoteBlock = nil
851				}
852				inQuote = false
853				quoteFrom = ""
854				quoteDate = ""
855				result = append(result, line)
856			}
857		} else {
858			result = append(result, line)
859		}
860	}
861
862	// Handle any remaining quote block
863	if inQuote && len(quoteBlock) > 0 {
864		result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
865	}
866
867	return strings.Join(result, "\n")
868}
869
870// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
871func parseDateForDisplay(dateStr string) string {
872	// Common date formats to try
873	formats := []string{
874		"Jan 2, 2006 at 3:04 PM",
875		"02:01:06 15:04",
876		"2006-01-02 15:04:05",
877		"Mon, 02 Jan 2006 15:04:05 -0700",
878		"Mon, 2 Jan 2006 15:04:05 -0700",
879		"2 Jan 2006 15:04:05",
880		"January 2, 2006 at 3:04 PM",
881		"Jan 2, 2006 3:04 PM",
882		time.RFC1123Z,
883		time.RFC1123,
884		time.RFC822Z,
885		time.RFC822,
886	}
887
888	for _, format := range formats {
889		if t, err := time.Parse(format, dateStr); err == nil {
890			return t.Format("02:01:06 15:04")
891		}
892	}
893
894	// Return original if parsing fails
895	return dateStr
896}
897
898// renderQuoteBox renders a quoted section in a styled box
899func renderQuoteBox(from, date string, lines []string) string {
900	// Build header with email on left and date on right
901	var header string
902	if from != "" || date != "" {
903		switch {
904		case from != "" && date != "":
905			header = quoteHeaderStyle().Render(from + "  " + date)
906		case from != "":
907			header = quoteHeaderStyle().Render(from)
908		default:
909			header = quoteHeaderStyle().Render(date)
910		}
911	}
912
913	// Join the quoted content
914	content := strings.Join(lines, "\n")
915
916	// Build the box content
917	var boxContent string
918	if header != "" {
919		boxContent = header + "\n\n" + content
920	} else {
921		boxContent = content
922	}
923
924	return quoteBoxStyle().Render(boxContent)
925}