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