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