html.go

  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}