1package view
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "io"
8 "mime/quotedprintable"
9 "net/http"
10 "os"
11 "regexp"
12 "strings"
13 "sync"
14 "time"
15
16 "charm.land/lipgloss/v2"
17 "github.com/PuerkitoBio/goquery"
18 "github.com/floatpane/matcha/clib"
19 "github.com/floatpane/matcha/theme"
20 "github.com/yuin/goldmark"
21 "github.com/yuin/goldmark/renderer/html"
22)
23
24func linkStyle() lipgloss.Style {
25 return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
26}
27
28// getTerminalCellSize returns the height of a terminal cell in pixels.
29// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
30// Falls back to a default of 18 pixels if the query fails.
31func getTerminalCellSize() int {
32 const defaultCellHeight = 18
33
34 // Try stdout, stdin, stderr, then /dev/tty as last resort
35 fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
36
37 for _, fd := range fds {
38 if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
39 return cellHeight
40 }
41 }
42
43 // Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
44 if tty, err := os.Open("/dev/tty"); err == nil {
45 defer tty.Close()
46 if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
47 return cellHeight
48 }
49 }
50
51 debugImageProtocol("using default cell height: %d pixels", defaultCellHeight)
52 return defaultCellHeight
53}
54
55// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
56func hyperlinkSupported() bool {
57 term := strings.ToLower(os.Getenv("TERM"))
58
59 // Terminals known to support OSC 8 hyperlinks
60 supportedTerms := []string{
61 "kitty",
62 "ghostty",
63 "wezterm",
64 "alacritty",
65 "foot",
66 "tmux",
67 "screen",
68 }
69
70 for _, supported := range supportedTerms {
71 if strings.Contains(term, supported) {
72 return true
73 }
74 }
75
76 // Check for specific terminal programs
77 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
78 supportedPrograms := []string{
79 "iterm.app",
80 "hyper",
81 "vscode",
82 "ghostty",
83 "wezterm",
84 }
85
86 for _, supported := range supportedPrograms {
87 if strings.Contains(termProgram, supported) {
88 return true
89 }
90 }
91
92 // Check for VTE-based terminals (GNOME Terminal, etc.)
93 if os.Getenv("VTE_VERSION") != "" {
94 return true
95 }
96
97 // Check for specific environment variables that indicate hyperlink support
98 if os.Getenv("KITTY_WINDOW_ID") != "" ||
99 os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
100 os.Getenv("WEZTERM_EXECUTABLE") != "" ||
101 os.Getenv("WT_SESSION") != "" {
102 return true
103 }
104
105 return false
106}
107
108// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
109func hyperlink(url, text string) string {
110 if text == "" {
111 text = url
112 }
113
114 supported := hyperlinkSupported()
115
116 if supported {
117 // Use OSC 8 hyperlink sequence for supported terminals
118 return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
119 } else {
120 // Fallback to plain text format for unsupported terminals
121 // Use HTML-encoded angle brackets to prevent HTML parser from treating them as tags
122 if text == url {
123 return fmt.Sprintf("<%s>", linkStyle().Render(url))
124 }
125 return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url))
126 }
127}
128
129func decodeQuotedPrintable(s string) (string, error) {
130 reader := quotedprintable.NewReader(strings.NewReader(s))
131 body, err := io.ReadAll(reader)
132 if err != nil {
133 return "", err
134 }
135 return string(body), nil
136}
137
138// markdownToHTML converts a Markdown string to an HTML string.
139func markdownToHTML(md []byte) []byte {
140 var buf bytes.Buffer
141 p := goldmark.New(
142 goldmark.WithRendererOptions(
143 html.WithUnsafe(), // Allow raw HTML in email.
144 ),
145 )
146 if err := p.Convert(md, &buf); err != nil {
147 return md // Fallback to original markdown.
148 }
149 return buf.Bytes()
150}
151
152func kittySupported() bool {
153 term := strings.ToLower(os.Getenv("TERM"))
154 if strings.Contains(term, "kitty") {
155 return true
156 }
157 return os.Getenv("KITTY_WINDOW_ID") != ""
158}
159
160func ghosttySupported() bool {
161 // Check for TERM containing ghostty
162 term := strings.ToLower(os.Getenv("TERM"))
163 if strings.Contains(term, "ghostty") {
164 return true
165 }
166
167 // Check for Ghostty-specific environment variables
168 if os.Getenv("TERM_PROGRAM") == "ghostty" {
169 return true
170 }
171
172 // Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
173 return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
174}
175
176func iterm2Supported() bool {
177 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
178 if termProgram == "iterm.app" {
179 return true
180 }
181
182 // Check for iTerm2-specific environment variables
183 if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
184 return true
185 }
186
187 return false
188}
189
190func weztermSupported() bool {
191 // Check for WezTerm-specific environment variables
192 if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
193 return true
194 }
195
196 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
197 if termProgram == "wezterm" {
198 return true
199 }
200
201 term := strings.ToLower(os.Getenv("TERM"))
202 if strings.Contains(term, "wezterm") {
203 return true
204 }
205
206 return false
207}
208
209func waystSupported() bool {
210 term := strings.ToLower(os.Getenv("TERM"))
211 if strings.Contains(term, "wayst") {
212 return true
213 }
214
215 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
216 if termProgram == "wayst" {
217 return true
218 }
219
220 return false
221}
222
223func warpSupported() bool {
224 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
225 if termProgram == "warp" {
226 return true
227 }
228
229 // Check for Warp-specific environment variables
230 if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
231 return true
232 }
233
234 return false
235}
236
237func konsoleSupported() bool {
238 // Check for Konsole-specific environment variables
239 if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
240 return true
241 }
242
243 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
244 if termProgram == "konsole" {
245 return true
246 }
247
248 return false
249}
250
251// ImageProtocolSupported checks if any supported image protocol terminal is detected.
252func ImageProtocolSupported() bool {
253 return imageProtocolSupported()
254}
255
256// imageProtocolSupported checks if any supported image protocol terminal is detected.
257func imageProtocolSupported() bool {
258 return kittySupported() || ghosttySupported() || iterm2Supported() ||
259 weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
260}
261
262func debugImageProtocol(format string, args ...interface{}) {
263 if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
264 return
265 }
266 msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
267 fmt.Print(msg)
268 if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
269 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
270 _, _ = f.WriteString(msg)
271 _ = f.Close()
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 {
275 _, _ = f.WriteString(msg)
276 _ = f.Close()
277 }
278 }
279}
280
281// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
282var remoteImageCache sync.Map
283
284// nextImageID is an auto-incrementing counter for Kitty image IDs.
285var nextImageID uint32 = 1000
286
287// allocImageID returns a unique Kitty image ID.
288func allocImageID() uint32 {
289 id := nextImageID
290 nextImageID++
291 return id
292}
293
294func fetchRemoteBase64(url string) string {
295 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
296 return ""
297 }
298
299 // Check cache first
300 if cached, ok := remoteImageCache.Load(url); ok {
301 debugImageProtocol("remote cache hit url=%s", url)
302 return cached.(string)
303 }
304
305 client := &http.Client{Timeout: 5 * time.Second}
306 resp, err := client.Get(url)
307 if err != nil {
308 debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
309 return ""
310 }
311 defer resp.Body.Close()
312 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
313 debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
314 return ""
315 }
316 data, err := io.ReadAll(resp.Body)
317 if err != nil {
318 debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
319 return ""
320 }
321
322 result, ok := clib.DecodeToPNG(data)
323 if !ok {
324 debugImageProtocol("remote decode failed url=%s", url)
325 return ""
326 }
327
328 encoded := base64.StdEncoding.EncodeToString(result.PNGData)
329 debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
330 remoteImageCache.Store(url, encoded)
331 return encoded
332}
333
334func dataURIBase64(uri string) string {
335 if !strings.HasPrefix(uri, "data:") {
336 return ""
337 }
338 comma := strings.Index(uri, ",")
339 if comma == -1 || comma+1 >= len(uri) {
340 return ""
341 }
342 return uri[comma+1:]
343}
344
345// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
346// This prevents the newline-collapsing regex from removing intentional spacing.
347// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
348const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
349const imageRowPlaceholderSuffix = "]]"
350
351func kittyInlineImage(payload string) string {
352 if payload == "" {
353 return ""
354 }
355
356 const chunkSize = 4096
357 var b strings.Builder
358
359 // Calculate how many terminal rows the image occupies to advance text after it.
360 rows := 1
361 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
362 if _, h, ok := clib.ImageDimensions(data); ok {
363 cellHeight := getTerminalCellSize()
364 rows = (h + cellHeight - 1) / cellHeight
365 if rows < 1 {
366 rows = 1
367 }
368 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
369 }
370 }
371
372 for offset := 0; offset < len(payload); offset += chunkSize {
373 end := offset + chunkSize
374 if end > len(payload) {
375 end = len(payload)
376 }
377 more := "0"
378 if end < len(payload) {
379 more = "1"
380 }
381
382 chunk := payload[offset:end]
383 if offset == 0 {
384 // C=1 means cursor does NOT move after image render (stays at top-left of image position)
385 // This is needed for proper TUI rendering, but we must add newlines to push text below
386 b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
387 } else {
388 b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
389 }
390 }
391
392 // Add newlines to push cursor below the image.
393 // Use a placeholder that won't be collapsed by the newline regex.
394 b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
395
396 return b.String()
397}
398
399// iterm2InlineImage renders an image using iTerm2's image protocol
400func iterm2InlineImage(payload string) string {
401 if payload == "" {
402 return ""
403 }
404
405 // Calculate rows for cursor positioning
406 rows := 1
407 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
408 if _, h, ok := clib.ImageDimensions(data); ok {
409 cellHeight := getTerminalCellSize()
410 rows = (h + cellHeight - 1) / cellHeight
411 if rows < 1 {
412 rows = 1
413 }
414 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
415 }
416 }
417
418 // iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
419 result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
420
421 // Add placeholder for row spacing
422 result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
423
424 return result
425}
426
427// renderInlineImage renders an image using the appropriate protocol for the detected terminal
428func renderInlineImage(payload string) string {
429 if payload == "" {
430 return ""
431 }
432
433 if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
434 // These terminals use the Kitty graphics protocol
435 return kittyInlineImage(payload)
436 } else if iterm2Supported() || warpSupported() {
437 // iTerm2 and Warp use the iTerm2 image protocol
438 return iterm2InlineImage(payload)
439 }
440
441 return ""
442}
443
444// imageRows calculates the number of terminal rows an image occupies.
445func imageRows(payload string) int {
446 rows := 1
447 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
448 if _, h, ok := clib.ImageDimensions(data); ok {
449 cellHeight := getTerminalCellSize()
450 rows = (h + cellHeight - 1) / cellHeight
451 if rows < 1 {
452 rows = 1
453 }
454 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
455 }
456 }
457 return rows
458}
459
460// kittyUploadImage uploads image data to the terminal with a unique ID using
461// the Kitty graphics protocol transmit action (a=t). The image is stored in
462// the terminal's memory and can be displayed later by ID without re-sending data.
463func kittyUploadImage(payload string, id uint32) {
464 if payload == "" {
465 return
466 }
467
468 const chunkSize = 4096
469 for offset := 0; offset < len(payload); offset += chunkSize {
470 end := offset + chunkSize
471 if end > len(payload) {
472 end = len(payload)
473 }
474 more := "0"
475 if end < len(payload) {
476 more = "1"
477 }
478
479 chunk := payload[offset:end]
480 if offset == 0 {
481 // a=t: transmit (upload) only, don't display yet
482 // i=ID: assign this image ID
483 fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
484 } else {
485 fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
486 }
487 }
488 os.Stdout.Sync()
489}
490
491// kittyDisplayImage displays a previously uploaded image by its ID at the
492// current cursor position. This is very fast since no image data is transmitted.
493func kittyDisplayImage(id uint32) string {
494 // a=p: put (display) an already-uploaded image by ID
495 // C=1: cursor does not move
496 return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
497}
498
499// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
500// without any row placeholders. Used for out-of-band rendering to stdout.
501func iterm2ImageEscapeOnly(payload string) string {
502 if payload == "" {
503 return ""
504 }
505 return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
506}
507
508// RenderImageToStdout writes an image directly to stdout at the given screen
509// row using cursor positioning. This bypasses bubbletea's cell-based renderer
510// which cannot handle graphics protocol escape sequences.
511//
512// For Kitty-protocol terminals, images are uploaded once and then displayed by
513// ID on subsequent calls, making scroll rendering nearly instant.
514func RenderImageToStdout(placement *ImagePlacement, screenRow int) {
515 if placement.Base64 == "" {
516 return
517 }
518
519 useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
520 useIterm2 := iterm2Supported() || warpSupported()
521
522 if useKitty {
523 // Upload once, display by ID on subsequent renders
524 if !placement.Uploaded {
525 placement.ID = allocImageID()
526 kittyUploadImage(placement.Base64, placement.ID)
527 placement.Uploaded = true
528 }
529 seq := kittyDisplayImage(placement.ID)
530 fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
531 os.Stdout.Sync()
532 } else if useIterm2 {
533 seq := iterm2ImageEscapeOnly(placement.Base64)
534 fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
535 os.Stdout.Sync()
536 }
537}
538
539// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
540func expandImageRowPlaceholders(text string) string {
541 re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
542 return re.ReplaceAllStringFunc(text, func(match string) string {
543 // Extract the number of rows from the placeholder
544 numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
545 numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
546 rows := 1
547 if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
548 rows = 1
549 }
550 // Return the newlines needed to push content below the image
551 return strings.Repeat("\n", rows)
552 })
553}
554
555type InlineImage struct {
556 CID string
557 Base64 string
558}
559
560// ImagePlacement holds the data needed to render an image at a specific
561// line in the email body. Images are rendered directly to stdout (bypassing
562// bubbletea's cell-based renderer which cannot handle graphics protocols).
563type ImagePlacement struct {
564 Line int // Line number in the processed body text where the image starts
565 Base64 string // Base64-encoded image data (PNG)
566 Rows int // Number of terminal rows the image occupies
567 Uploaded bool // Whether the image has been uploaded to the terminal via Kitty ID
568 ID uint32 // Kitty image ID for display-by-reference
569}
570
571// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
572// Returns the rendered body text, image placements for out-of-band rendering, and any error.
573func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
574 inlineMap := make(map[string]string, len(inline))
575 for _, img := range inline {
576 cid := strings.TrimSpace(img.CID)
577 cid = strings.TrimPrefix(cid, "<")
578 cid = strings.TrimSuffix(cid, ">")
579 cid = strings.TrimPrefix(cid, "cid:")
580 if cid == "" || img.Base64 == "" {
581 continue
582 }
583 inlineMap[cid] = img.Base64
584 }
585 return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
586}
587
588// ProcessBody takes a raw email body, decodes it, and formats it as plain
589// text with terminal hyperlinks.
590func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
591 return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages)
592}
593
594func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
595 decodedBody, err := decodeQuotedPrintable(rawBody)
596 if err != nil {
597 decodedBody = rawBody
598 }
599
600 htmlBody := markdownToHTML([]byte(decodedBody))
601
602 doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
603 if err != nil {
604 return "", nil, fmt.Errorf("could not parse email body: %w", err)
605 }
606
607 doc.Find("style, script").Remove()
608
609 // Style headers by setting their text content.
610 // We use SetText so the h1/h2 tags remain in the document for spacing logic.
611 doc.Find("h1").Each(func(i int, s *goquery.Selection) {
612 s.SetText(h1Style.Render(s.Text()))
613 })
614
615 doc.Find("h2").Each(func(i int, s *goquery.Selection) {
616 s.SetText(h2Style.Render(s.Text()))
617 })
618
619 // Add newlines after block elements for better spacing.
620 doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
621 s.After("\n\n")
622 })
623
624 // Replace <br> tags with newlines
625 doc.Find("br").Each(func(i int, s *goquery.Selection) {
626 s.ReplaceWithHtml("\n")
627 })
628
629 // Handle blockquote elements (quoted replies)
630 // We collect quote data and use placeholders, then render after doc.Text()
631 var quoteData []struct {
632 from, date string
633 content string
634 }
635 doc.Find("blockquote").Each(func(i int, s *goquery.Selection) {
636 // Try to extract sender info from cite attribute or preceding text
637 cite, _ := s.Attr("cite")
638 quoteText := strings.TrimSpace(s.Text())
639
640 // Look for "On DATE, EMAIL wrote:" pattern in previous sibling or cite
641 var from, date string
642 prevText := ""
643 if prev := s.Prev(); prev.Length() > 0 {
644 prevText = strings.TrimSpace(prev.Text())
645 }
646
647 onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
648 if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
649 date = parseDateForDisplay(matches[1])
650 from = matches[2]
651 // Remove the "On ... wrote:" from the previous element
652 s.Prev().Remove()
653 } else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
654 date = parseDateForDisplay(matches[1])
655 from = matches[2]
656 }
657
658 // Store quote data and use placeholder
659 quoteData = append(quoteData, struct {
660 from, date string
661 content string
662 }{from, date, quoteText})
663 placeholder := fmt.Sprintf("\n[[MATCHA_QUOTE:%d]]\n", len(quoteData)-1)
664 s.ReplaceWithHtml(placeholder)
665 })
666
667 // Format links and images.
668 // Collect image placements for out-of-band rendering (bubbletea v2's
669 // ultraviolet renderer cannot pass through graphics protocol sequences).
670 var imgIndex int
671 var pendingImages []struct {
672 index int
673 payload string
674 rows int
675 }
676
677 doc.Find("a").Each(func(i int, s *goquery.Selection) {
678 href, exists := s.Attr("href")
679 if !exists {
680 return
681 }
682 s.ReplaceWithHtml(hyperlink(href, s.Text()))
683 })
684
685 doc.Find("img").Each(func(i int, s *goquery.Selection) {
686 src, exists := s.Attr("src")
687 if !exists {
688 return
689 }
690 alt, _ := s.Attr("alt")
691 if alt == "" {
692 alt = "Does not contain alt text"
693 }
694
695 if !disableImages && imageProtocolSupported() {
696 var payload string
697 if strings.HasPrefix(src, "data:image/") {
698 payload = dataURIBase64(src)
699 } else if strings.HasPrefix(src, "cid:") {
700 cid := strings.TrimPrefix(src, "cid:")
701 cid = strings.Trim(cid, "<>")
702 if inline != nil {
703 payload = inline[cid]
704 debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
705 } else {
706 debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
707 }
708 } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
709 payload = fetchRemoteBase64(src)
710 }
711
712 if payload != "" {
713 rows := imageRows(payload)
714 debugImageProtocol("collected image placement src=%s rows=%d (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, rows, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
715
716 idx := imgIndex
717 imgIndex++
718 pendingImages = append(pendingImages, struct {
719 index int
720 payload string
721 rows int
722 }{idx, payload, rows})
723
724 // Insert a placeholder with blank lines for spacing.
725 // The image placement marker lets us find the line number later.
726 placeholder := fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
727 s.ReplaceWithHtml(fmt.Sprintf("\n[[MATCHA_IMG:%d]]%s", idx, placeholder))
728 return
729 }
730 debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
731 } else {
732 debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
733 }
734 if hyperlinkSupported() {
735 s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
736 } else {
737 s.ReplaceWithHtml(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
738 }
739 })
740
741 text := doc.Text()
742
743 // Collapse excessive newlines, but not the image row placeholders
744 re := regexp.MustCompile(`\n{3,}`)
745 text = re.ReplaceAllString(text, "\n\n")
746
747 // Now expand the image row placeholders to actual newlines
748 text = expandImageRowPlaceholders(text)
749
750 // Build image placements by finding the line numbers of image markers.
751 var placements []ImagePlacement
752 if len(pendingImages) > 0 {
753 lines := strings.Split(text, "\n")
754 imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
755 for lineNum, line := range lines {
756 if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
757 var idx int
758 fmt.Sscanf(matches[1], "%d", &idx)
759 for _, pi := range pendingImages {
760 if pi.index == idx {
761 placements = append(placements, ImagePlacement{
762 Line: lineNum,
763 Base64: pi.payload,
764 Rows: pi.rows,
765 })
766 break
767 }
768 }
769 }
770 }
771
772 // Remove the image markers from the text (leave the spacing)
773 text = imgMarkerRegex.ReplaceAllString(text, "")
774 }
775
776 // Replace quote placeholders with styled quote boxes
777 quoteRegex := regexp.MustCompile(`\[\[MATCHA_QUOTE:(\d+)\]\]`)
778 text = quoteRegex.ReplaceAllStringFunc(text, func(match string) string {
779 idxStr := quoteRegex.FindStringSubmatch(match)[1]
780 var idx int
781 fmt.Sscanf(idxStr, "%d", &idx)
782 if idx >= 0 && idx < len(quoteData) {
783 q := quoteData[idx]
784 return renderQuoteBox(q.from, q.date, strings.Split(q.content, "\n"))
785 }
786 return match
787 })
788
789 // Style quoted reply sections (for plain text > quotes)
790 text = styleQuotedReplies(text)
791
792 return bodyStyle.Render(text), placements, nil
793}
794
795func quoteBoxStyle() lipgloss.Style {
796 return lipgloss.NewStyle().
797 Border(lipgloss.RoundedBorder()).
798 BorderForeground(theme.ActiveTheme.Secondary).
799 Padding(0, 1).
800 Foreground(theme.ActiveTheme.Secondary)
801}
802
803func quoteHeaderStyle() lipgloss.Style {
804 return lipgloss.NewStyle().
805 Foreground(theme.ActiveTheme.Secondary)
806}
807
808// styleQuotedReplies detects quoted reply sections and styles them in a box
809func styleQuotedReplies(text string) string {
810 lines := strings.Split(text, "\n")
811 var result []string
812 var quoteBlock []string
813 var quoteFrom, quoteDate string
814 inQuote := false
815
816 // Regex to match "On DATE, EMAIL wrote:" pattern
817 // Matches various date formats
818 onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
819
820 for i := 0; i < len(lines); i++ {
821 line := lines[i]
822 trimmedLine := strings.TrimSpace(line)
823
824 // Check for "On DATE, EMAIL wrote:" header
825 if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
826 // If we were already in a quote block, render it first
827 if inQuote && len(quoteBlock) > 0 {
828 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
829 quoteBlock = nil
830 }
831
832 // Parse the date and email from the match
833 dateStr := matches[1]
834 quoteFrom = matches[2]
835 quoteDate = parseDateForDisplay(dateStr)
836 inQuote = true
837 continue
838 }
839
840 // Check if line starts with ">" (quoted text)
841 if strings.HasPrefix(trimmedLine, ">") {
842 if !inQuote {
843 // Start a new quote block without header info
844 inQuote = true
845 quoteFrom = ""
846 quoteDate = ""
847 }
848 // Remove the leading "> " and add to quote block
849 quotedContent := strings.TrimPrefix(trimmedLine, ">")
850 quotedContent = strings.TrimPrefix(quotedContent, " ")
851 quoteBlock = append(quoteBlock, quotedContent)
852 } else if inQuote {
853 // End of quote block - check if it's just whitespace
854 if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") {
855 // Empty line within quote block, keep it
856 quoteBlock = append(quoteBlock, "")
857 } else if trimmedLine == "" && len(quoteBlock) == 0 {
858 // Empty line before any quoted content, skip
859 continue
860 } else {
861 // End of quote block
862 if len(quoteBlock) > 0 {
863 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
864 quoteBlock = nil
865 }
866 inQuote = false
867 quoteFrom = ""
868 quoteDate = ""
869 result = append(result, line)
870 }
871 } else {
872 result = append(result, line)
873 }
874 }
875
876 // Handle any remaining quote block
877 if inQuote && len(quoteBlock) > 0 {
878 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
879 }
880
881 return strings.Join(result, "\n")
882}
883
884// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
885func parseDateForDisplay(dateStr string) string {
886 // Common date formats to try
887 formats := []string{
888 "Jan 2, 2006 at 3:04 PM",
889 "02:01:06 15:04",
890 "2006-01-02 15:04:05",
891 "Mon, 02 Jan 2006 15:04:05 -0700",
892 "Mon, 2 Jan 2006 15:04:05 -0700",
893 "2 Jan 2006 15:04:05",
894 "January 2, 2006 at 3:04 PM",
895 "Jan 2, 2006 3:04 PM",
896 time.RFC1123Z,
897 time.RFC1123,
898 time.RFC822Z,
899 time.RFC822,
900 }
901
902 for _, format := range formats {
903 if t, err := time.Parse(format, dateStr); err == nil {
904 return t.Format("02:01:06 15:04")
905 }
906 }
907
908 // Return original if parsing fails
909 return dateStr
910}
911
912// renderQuoteBox renders a quoted section in a styled box
913func renderQuoteBox(from, date string, lines []string) string {
914 // Build header with email on left and date on right
915 var header string
916 if from != "" || date != "" {
917 if from != "" && date != "" {
918 header = quoteHeaderStyle().Render(from + " " + date)
919 } else if from != "" {
920 header = quoteHeaderStyle().Render(from)
921 } else {
922 header = quoteHeaderStyle().Render(date)
923 }
924 }
925
926 // Join the quoted content
927 content := strings.Join(lines, "\n")
928
929 // Build the box content
930 var boxContent string
931 if header != "" {
932 boxContent = header + "\n\n" + content
933 } else {
934 boxContent = content
935 }
936
937 return quoteBoxStyle().Render(boxContent)
938}