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 kittySupported() || ghosttySupported() || iterm2Supported() ||
279 weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
280}
281
282func debugImageProtocol(format string, args ...interface{}) {
283 if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
284 return
285 }
286 msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
287 fmt.Print(msg)
288 if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
289 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
290 _, _ = f.WriteString(msg)
291 _ = f.Close()
292 }
293 } else if path := os.Getenv("DEBUG_KITTY_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 }
299}
300
301func fetchRemoteBase64(url string) string {
302 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
303 return ""
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 img, _, err := image.Decode(bytes.NewReader(data))
323 if err != nil {
324 debugImageProtocol("remote decode failed url=%s err=%v", url, err)
325 return ""
326 }
327
328 var buf bytes.Buffer
329 if err := png.Encode(&buf, img); err != nil {
330 debugImageProtocol("remote png encode failed url=%s err=%v", url, err)
331 return ""
332 }
333
334 encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
335 debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
336 return encoded
337}
338
339func dataURIBase64(uri string) string {
340 if !strings.HasPrefix(uri, "data:") {
341 return ""
342 }
343 comma := strings.Index(uri, ",")
344 if comma == -1 || comma+1 >= len(uri) {
345 return ""
346 }
347 return uri[comma+1:]
348}
349
350// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
351// This prevents the newline-collapsing regex from removing intentional spacing.
352// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
353const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
354const imageRowPlaceholderSuffix = "]]"
355
356func kittyInlineImage(payload string) string {
357 if payload == "" {
358 return ""
359 }
360
361 const chunkSize = 4096
362 var b strings.Builder
363
364 // Calculate how many terminal rows the image occupies to advance text after it.
365 rows := 1
366 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
367 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
368 cellHeight := getTerminalCellSize()
369 h := img.Bounds().Dy()
370 rows = (h + cellHeight - 1) / cellHeight
371 if rows < 1 {
372 rows = 1
373 }
374 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
375 }
376 }
377
378 for offset := 0; offset < len(payload); offset += chunkSize {
379 end := offset + chunkSize
380 if end > len(payload) {
381 end = len(payload)
382 }
383 more := "0"
384 if end < len(payload) {
385 more = "1"
386 }
387
388 chunk := payload[offset:end]
389 if offset == 0 {
390 // C=1 means cursor does NOT move after image render (stays at top-left of image position)
391 // This is needed for proper TUI rendering, but we must add newlines to push text below
392 b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
393 } else {
394 b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
395 }
396 }
397
398 // Add newlines to push cursor below the image.
399 // Use a placeholder that won't be collapsed by the newline regex.
400 b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
401
402 return b.String()
403}
404
405// iterm2InlineImage renders an image using iTerm2's image protocol
406func iterm2InlineImage(payload string) string {
407 if payload == "" {
408 return ""
409 }
410
411 // Calculate rows for cursor positioning
412 rows := 1
413 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
414 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
415 cellHeight := getTerminalCellSize()
416 h := img.Bounds().Dy()
417 rows = (h + cellHeight - 1) / cellHeight
418 if rows < 1 {
419 rows = 1
420 }
421 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
422 }
423 }
424
425 // iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
426 result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
427
428 // Add placeholder for row spacing
429 result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
430
431 return result
432}
433
434// renderInlineImage renders an image using the appropriate protocol for the detected terminal
435func renderInlineImage(payload string) string {
436 if payload == "" {
437 return ""
438 }
439
440 if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
441 // These terminals use the Kitty graphics protocol
442 return kittyInlineImage(payload)
443 } else if iterm2Supported() || warpSupported() {
444 // iTerm2 and Warp use the iTerm2 image protocol
445 return iterm2InlineImage(payload)
446 }
447
448 return ""
449}
450
451// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
452func expandImageRowPlaceholders(text string) string {
453 re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
454 return re.ReplaceAllStringFunc(text, func(match string) string {
455 // Extract the number of rows from the placeholder
456 numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
457 numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
458 rows := 1
459 if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
460 rows = 1
461 }
462 // Return the newlines needed to push content below the image
463 return strings.Repeat("\n", rows)
464 })
465}
466
467type InlineImage struct {
468 CID string
469 Base64 string
470}
471
472// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
473func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
474 inlineMap := make(map[string]string, len(inline))
475 for _, img := range inline {
476 cid := strings.TrimSpace(img.CID)
477 cid = strings.TrimPrefix(cid, "<")
478 cid = strings.TrimSuffix(cid, ">")
479 cid = strings.TrimPrefix(cid, "cid:")
480 if cid == "" || img.Base64 == "" {
481 continue
482 }
483 inlineMap[cid] = img.Base64
484 }
485 return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle)
486}
487
488// ProcessBody takes a raw email body, decodes it, and formats it as plain
489// text with terminal hyperlinks.
490func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
491 return processBody(rawBody, nil, h1Style, h2Style, bodyStyle)
492}
493
494func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) {
495 decodedBody, err := decodeQuotedPrintable(rawBody)
496 if err != nil {
497 decodedBody = rawBody
498 }
499
500 htmlBody := markdownToHTML([]byte(decodedBody))
501
502 doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
503 if err != nil {
504 return "", fmt.Errorf("could not parse email body: %w", err)
505 }
506
507 doc.Find("style, script").Remove()
508
509 // Style headers by setting their text content.
510 // We use SetText so the h1/h2 tags remain in the document for spacing logic.
511 doc.Find("h1").Each(func(i int, s *goquery.Selection) {
512 s.SetText(h1Style.Render(s.Text()))
513 })
514
515 doc.Find("h2").Each(func(i int, s *goquery.Selection) {
516 s.SetText(h2Style.Render(s.Text()))
517 })
518
519 // Add newlines after block elements for better spacing.
520 doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
521 s.After("\n\n")
522 })
523
524 // Replace <br> tags with newlines
525 doc.Find("br").Each(func(i int, s *goquery.Selection) {
526 s.ReplaceWithHtml("\n")
527 })
528
529 // Handle blockquote elements (quoted replies)
530 // We collect quote data and use placeholders, then render after doc.Text()
531 var quoteData []struct {
532 from, date string
533 content string
534 }
535 doc.Find("blockquote").Each(func(i int, s *goquery.Selection) {
536 // Try to extract sender info from cite attribute or preceding text
537 cite, _ := s.Attr("cite")
538 quoteText := strings.TrimSpace(s.Text())
539
540 // Look for "On DATE, EMAIL wrote:" pattern in previous sibling or cite
541 var from, date string
542 prevText := ""
543 if prev := s.Prev(); prev.Length() > 0 {
544 prevText = strings.TrimSpace(prev.Text())
545 }
546
547 onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
548 if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
549 date = parseDateForDisplay(matches[1])
550 from = matches[2]
551 // Remove the "On ... wrote:" from the previous element
552 s.Prev().Remove()
553 } else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
554 date = parseDateForDisplay(matches[1])
555 from = matches[2]
556 }
557
558 // Store quote data and use placeholder
559 quoteData = append(quoteData, struct {
560 from, date string
561 content string
562 }{from, date, quoteText})
563 placeholder := fmt.Sprintf("\n[[MATCHA_QUOTE:%d]]\n", len(quoteData)-1)
564 s.ReplaceWithHtml(placeholder)
565 })
566
567 // Format links and images
568 doc.Find("a").Each(func(i int, s *goquery.Selection) {
569 href, exists := s.Attr("href")
570 if !exists {
571 return
572 }
573 s.ReplaceWithHtml(hyperlink(href, s.Text()))
574 })
575
576 doc.Find("img").Each(func(i int, s *goquery.Selection) {
577 src, exists := s.Attr("src")
578 if !exists {
579 return
580 }
581 alt, _ := s.Attr("alt")
582 if alt == "" {
583 alt = "Does not contain alt text"
584 }
585
586 if imageProtocolSupported() {
587 var payload string
588 if strings.HasPrefix(src, "data:image/") {
589 payload = dataURIBase64(src)
590 } else if strings.HasPrefix(src, "cid:") {
591 cid := strings.TrimPrefix(src, "cid:")
592 cid = strings.Trim(cid, "<>")
593 if inline != nil {
594 payload = inline[cid]
595 debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
596 } else {
597 debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
598 }
599 } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
600 payload = fetchRemoteBase64(src)
601 }
602
603 if payload != "" {
604 if rendered := renderInlineImage(payload); rendered != "" {
605 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())
606 s.ReplaceWithHtml("\n" + rendered + "\n")
607 return
608 }
609 debugImageProtocol("payload present but renderer returned empty src=%s len=%d", src, len(payload))
610 } else {
611 debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
612 }
613 } else {
614 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())
615 }
616 if hyperlinkSupported() {
617 s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
618 } else {
619 s.ReplaceWithHtml(fmt.Sprintf("\n [Image: %s, %s] \n", alt, src))
620 }
621 })
622
623 text := doc.Text()
624
625 // Collapse excessive newlines, but not the image row placeholders
626 re := regexp.MustCompile(`\n{3,}`)
627 text = re.ReplaceAllString(text, "\n\n")
628
629 // Now expand the image row placeholders to actual newlines
630 text = expandImageRowPlaceholders(text)
631
632 // Replace quote placeholders with styled quote boxes
633 quoteRegex := regexp.MustCompile(`\[\[MATCHA_QUOTE:(\d+)\]\]`)
634 text = quoteRegex.ReplaceAllStringFunc(text, func(match string) string {
635 idxStr := quoteRegex.FindStringSubmatch(match)[1]
636 var idx int
637 fmt.Sscanf(idxStr, "%d", &idx)
638 if idx >= 0 && idx < len(quoteData) {
639 q := quoteData[idx]
640 return renderQuoteBox(q.from, q.date, strings.Split(q.content, "\n"))
641 }
642 return match
643 })
644
645 // Style quoted reply sections (for plain text > quotes)
646 text = styleQuotedReplies(text)
647
648 return bodyStyle.Render(text), nil
649}
650
651// quoteBoxStyle is the style for the quoted reply box border
652var quoteBoxStyle = lipgloss.NewStyle().
653 Border(lipgloss.RoundedBorder()).
654 BorderForeground(lipgloss.Color("240")).
655 Padding(0, 1).
656 Foreground(lipgloss.Color("240"))
657
658// quoteHeaderStyle is the style for the header line in the quote box
659var quoteHeaderStyle = lipgloss.NewStyle().
660 Foreground(lipgloss.Color("240"))
661
662// styleQuotedReplies detects quoted reply sections and styles them in a box
663func styleQuotedReplies(text string) string {
664 lines := strings.Split(text, "\n")
665 var result []string
666 var quoteBlock []string
667 var quoteFrom, quoteDate string
668 inQuote := false
669
670 // Regex to match "On DATE, EMAIL wrote:" pattern
671 // Matches various date formats
672 onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
673
674 for i := 0; i < len(lines); i++ {
675 line := lines[i]
676 trimmedLine := strings.TrimSpace(line)
677
678 // Check for "On DATE, EMAIL wrote:" header
679 if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
680 // If we were already in a quote block, render it first
681 if inQuote && len(quoteBlock) > 0 {
682 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
683 quoteBlock = nil
684 }
685
686 // Parse the date and email from the match
687 dateStr := matches[1]
688 quoteFrom = matches[2]
689 quoteDate = parseDateForDisplay(dateStr)
690 inQuote = true
691 continue
692 }
693
694 // Check if line starts with ">" (quoted text)
695 if strings.HasPrefix(trimmedLine, ">") {
696 if !inQuote {
697 // Start a new quote block without header info
698 inQuote = true
699 quoteFrom = ""
700 quoteDate = ""
701 }
702 // Remove the leading "> " and add to quote block
703 quotedContent := strings.TrimPrefix(trimmedLine, ">")
704 quotedContent = strings.TrimPrefix(quotedContent, " ")
705 quoteBlock = append(quoteBlock, quotedContent)
706 } else if inQuote {
707 // End of quote block - check if it's just whitespace
708 if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") {
709 // Empty line within quote block, keep it
710 quoteBlock = append(quoteBlock, "")
711 } else if trimmedLine == "" && len(quoteBlock) == 0 {
712 // Empty line before any quoted content, skip
713 continue
714 } else {
715 // End of quote block
716 if len(quoteBlock) > 0 {
717 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
718 quoteBlock = nil
719 }
720 inQuote = false
721 quoteFrom = ""
722 quoteDate = ""
723 result = append(result, line)
724 }
725 } else {
726 result = append(result, line)
727 }
728 }
729
730 // Handle any remaining quote block
731 if inQuote && len(quoteBlock) > 0 {
732 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
733 }
734
735 return strings.Join(result, "\n")
736}
737
738// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
739func parseDateForDisplay(dateStr string) string {
740 // Common date formats to try
741 formats := []string{
742 "Jan 2, 2006 at 3:04 PM",
743 "02:01:06 15:04",
744 "2006-01-02 15:04:05",
745 "Mon, 02 Jan 2006 15:04:05 -0700",
746 "Mon, 2 Jan 2006 15:04:05 -0700",
747 "2 Jan 2006 15:04:05",
748 "January 2, 2006 at 3:04 PM",
749 "Jan 2, 2006 3:04 PM",
750 time.RFC1123Z,
751 time.RFC1123,
752 time.RFC822Z,
753 time.RFC822,
754 }
755
756 for _, format := range formats {
757 if t, err := time.Parse(format, dateStr); err == nil {
758 return t.Format("02:01:06 15:04")
759 }
760 }
761
762 // Return original if parsing fails
763 return dateStr
764}
765
766// renderQuoteBox renders a quoted section in a styled box
767func renderQuoteBox(from, date string, lines []string) string {
768 // Build header with email on left and date on right
769 var header string
770 if from != "" || date != "" {
771 if from != "" && date != "" {
772 header = quoteHeaderStyle.Render(from + " " + date)
773 } else if from != "" {
774 header = quoteHeaderStyle.Render(from)
775 } else {
776 header = quoteHeaderStyle.Render(date)
777 }
778 }
779
780 // Join the quoted content
781 content := strings.Join(lines, "\n")
782
783 // Build the box content
784 var boxContent string
785 if header != "" {
786 boxContent = header + "\n\n" + content
787 } else {
788 boxContent = content
789 }
790
791 return quoteBoxStyle.Render(boxContent)
792}