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