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}