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	"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}