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