html.go

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