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