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