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