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