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