From d2ae2a5b9d575fe266ea5d89770f685db039c6c7 Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 31 Jul 2025 15:39:56 +0400 Subject: [PATCH] feat: header display (#43) --- fetcher/fetcher.go | 172 +++++++++++++++++++++++++-------------------- tui/email_view.go | 6 +- tui/messages.go | 7 ++ tui/styles.go | 13 ++++ view/html.go | 28 ++++---- 5 files changed, 132 insertions(+), 94 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 279c1866b62e3860a38a972ba4628c84c18b2b53..449e30c19306b38ac16287843252003d3df090fd 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -5,7 +5,6 @@ import ( "fmt" "io" "io/ioutil" - "log" "mime" "strings" "time" @@ -141,7 +140,8 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { messages := make(chan *imap.Message, limit) done := make(chan error, 1) - fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid, imap.FetchItem("BODY[]")} + // Only fetch Envelope and UID for the inbox view + fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid} go func() { done <- c.Fetch(seqset, fetchItems, messages) }() @@ -152,102 +152,118 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { continue } - bodyLiteral := msg.GetBody(&imap.BodySectionName{}) - if bodyLiteral == nil { - log.Println("Could not get message body") - continue + fromAddrs := msg.Envelope.From[0].Address() + var toAddrList []string + for _, addr := range msg.Envelope.To { + toAddrList = append(toAddrList, addr.Address()) } - mr, err := mail.CreateReader(bodyLiteral) - if err != nil { - log.Printf("Error creating mail reader: %v", err) - continue - } + emails = append(emails, Email{ + UID: msg.Uid, + From: fromAddrs, + To: toAddrList, + Subject: decodeHeader(msg.Envelope.Subject), + Date: msg.Envelope.Date, + }) + } - header := mr.Header - fromAddrs, _ := header.AddressList("From") - toAddrs, _ := header.AddressList("To") - subject := decodeHeader(header.Get("Subject")) - date, _ := header.Date() - messageID := header.Get("Message-ID") - references := header.Get("References") - - var fromAddr string - if len(fromAddrs) > 0 { - fromAddr = fromAddrs[0].Address - } + if err := <-done; err != nil { + return nil, err + } - var toAddrList []string - for _, addr := range toAddrs { - toAddrList = append(toAddrList, addr.Address) - } + // Reverse the order to show newest first + for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 { + emails[i], emails[j] = emails[j], emails[i] + } - var body string - var attachments []Attachment - for { - p, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - log.Printf("Error getting next part: %v", err) - break - } + return emails, nil +} + + +// New function to fetch the body for a single email +func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error) { + c, err := connect(cfg) + if err != nil { + return "", nil, err + } + defer c.Logout() + + if _, err := c.Select("INBOX", false); err != nil { + return "", nil, err + } - // Correctly parse Content-Disposition - cdHeader := p.Header.Get("Content-Disposition") - if cdHeader != "" { - disposition, params, err := mime.ParseMediaType(cdHeader) - if err == nil && (disposition == "attachment" || disposition == "inline") { - filename := params["filename"] - if filename != "" { - partBody, _ := ioutil.ReadAll(p.Body) - encoding := p.Header.Get("Content-Transfer-Encoding") - if strings.ToLower(encoding) == "base64" { - decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody)) - if decodeErr == nil { - partBody = decoded - } + seqset := new(imap.SeqSet) + seqset.AddNum(uid) + + messages := make(chan *imap.Message, 1) + done := make(chan error, 1) + fetchItems := []imap.FetchItem{imap.FetchItem("BODY[]")} + go func() { + done <- c.Fetch(seqset, fetchItems, messages) + }() + + msg := <-messages + if msg == nil { + return "", nil, fmt.Errorf("could not fetch email body") + } + + bodyLiteral := msg.GetBody(&imap.BodySectionName{}) + if bodyLiteral == nil { + return "", nil, fmt.Errorf("could not get message body") + } + + mr, err := mail.CreateReader(bodyLiteral) + if err != nil { + return "", nil, fmt.Errorf("error creating mail reader: %v", err) + } + + var body string + var attachments []Attachment + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return "", nil, fmt.Errorf("error getting next part: %v", err) + } + + cdHeader := p.Header.Get("Content-Disposition") + if cdHeader != "" { + disposition, params, err := mime.ParseMediaType(cdHeader) + if err == nil && (disposition == "attachment" || disposition == "inline") { + filename := params["filename"] + if filename != "" { + partBody, _ := ioutil.ReadAll(p.Body) + encoding := p.Header.Get("Content-Transfer-Encoding") + if strings.ToLower(encoding) == "base64" { + decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody)) + if decodeErr == nil { + partBody = decoded } - attachments = append(attachments, Attachment{Filename: filename, Data: partBody}) - continue // Skip to next part } + attachments = append(attachments, Attachment{Filename: filename, Data: partBody}) + continue } } + } - // Process body part if not an attachment - mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) - if (mediaType == "text/plain" || mediaType == "text/html") && body == "" { - decodedPart, decodeErr := decodePart(p.Body, p.Header) - if decodeErr == nil { - body = decodedPart - } + mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) + if (mediaType == "text/plain" || mediaType == "text/html") && body == "" { + decodedPart, decodeErr := decodePart(p.Body, p.Header) + if decodeErr == nil { + body = decodedPart } } - - emails = append(emails, Email{ - UID: msg.Uid, - From: fromAddr, - To: toAddrList, - Subject: subject, - Body: body, - Date: date, - MessageID: messageID, - References: strings.Fields(references), - Attachments: attachments, - }) } if err := <-done; err != nil { - return nil, err + return "", nil, err } - for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 { - emails[i], emails[j] = emails[j], emails[i] - } - - return emails, nil + return body, attachments, nil } + func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { c, err := connect(cfg) if err != nil { diff --git a/tui/email_view.go b/tui/email_view.go index c0dbeaa9a33cf053dc1548c6438ab6e43df7233a..d7eeb34c23e888bf2e6782cd4037230e5bfae758 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -24,7 +24,8 @@ type EmailView struct { } func NewEmailView(email fetcher.Email, width, height int) *EmailView { - body, err := view.ProcessBody(email.Body) + // Pass the styles from the tui package to the view package + body, err := view.ProcessBody(email.Body, H1Style, H2Style, BodyStyle) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } @@ -32,10 +33,9 @@ func NewEmailView(email fetcher.Email, width, height int) *EmailView { header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject) headerHeight := lipgloss.Height(header) + 2 - // Calculate height for attachments if they exist attachmentHeight := 0 if len(email.Attachments) > 0 { - attachmentHeight = len(email.Attachments) + 2 // +2 for title and border + attachmentHeight = len(email.Attachments) + 2 } vp := viewport.New(width, height-headerHeight-attachmentHeight) diff --git a/tui/messages.go b/tui/messages.go index 1af8dbbf410211eb2235103d6367542d0d96f066..4f14b69848bc0a4734c0f7b0f96742ac5d2a0623 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -110,3 +110,10 @@ type DiscardDraftMsg struct { // RestoreDraftMsg signals that the cached draft should be restored. type RestoreDraftMsg struct{} + +type EmailBodyFetchedMsg struct { + Index int + Body string + Attachments []fetcher.Attachment + Err error +} \ No newline at end of file diff --git a/tui/styles.go b/tui/styles.go index 238733d8dea332272655f8bce1315157f089e978..88980dbe6826f536e76da6b59fdc57a465d5cc3c 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -21,6 +21,19 @@ var ( HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true) InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) + + H1Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")). + Bold(true). + Align(lipgloss.Center) + + H2Style = lipgloss.NewStyle(). + Foreground(lipgloss.Color("205")). + Bold(false). // Less bold + Align(lipgloss.Center) + + BodyStyle = lipgloss.NewStyle(). + Bold(true) // A bit bold ) var DocStyle = lipgloss.NewStyle().Margin(1, 2) diff --git a/view/html.go b/view/html.go index ceb31c611de94d365ece0e5f4d9ad53011a416d4..c42f4f6a0cbefa81ad94af3a81f014f4d218bc59 100644 --- a/view/html.go +++ b/view/html.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/PuerkitoBio/goquery" + "github.com/charmbracelet/lipgloss" "github.com/yuin/goldmark" "github.com/yuin/goldmark/renderer/html" ) @@ -46,14 +47,12 @@ func markdownToHTML(md []byte) []byte { // ProcessBody takes a raw email body, decodes it, and formats it as plain // text with terminal hyperlinks. -func ProcessBody(rawBody string) (string, error) { +func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (string, error) { decodedBody, err := decodeQuotedPrintable(rawBody) if err != nil { - // If decoding fails, fallback to the raw body decodedBody = rawBody } - // Convert markdown to HTML before processing htmlBody := markdownToHTML([]byte(decodedBody)) doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody)) @@ -61,11 +60,19 @@ func ProcessBody(rawBody string) (string, error) { return "", fmt.Errorf("could not parse email body: %w", err) } - // Remove style and script tags to clean up the view doc.Find("style, script").Remove() + // Style headers + doc.Find("h1").Each(func(i int, s *goquery.Selection) { + s.SetText(h1Style.Render(s.Text())) + }) + + doc.Find("h2").Each(func(i int, s *goquery.Selection) { + s.SetText(h2Style.Render(s.Text())) + }) + // Add newlines after block elements for better spacing - doc.Find("p, div, h1, h2, h3, h4, h5, h6").Each(func(i int, s *goquery.Selection) { + doc.Find("p, div").Each(func(i int, s *goquery.Selection) { s.After("\n\n") }) @@ -74,7 +81,7 @@ func ProcessBody(rawBody string) (string, error) { s.ReplaceWithHtml("\n") }) - // Format links into terminal hyperlinks + // Format links and images doc.Find("a").Each(func(i int, s *goquery.Selection) { href, exists := s.Attr("href") if !exists { @@ -83,26 +90,21 @@ func ProcessBody(rawBody string) (string, error) { s.ReplaceWithHtml(hyperlink(href, s.Text())) }) - // Format images into terminal hyperlinks doc.Find("img").Each(func(i int, s *goquery.Selection) { src, exists := s.Attr("src") if !exists { return } alt, _ := s.Attr("alt") - if alt == "" { alt = "Does not contain alt text" } s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt))) }) - // Get the document's text content, which now includes our formatting text := doc.Text() - // Collapse more than 2 consecutive newlines into 2 re := regexp.MustCompile(`\n{3,}`) text = re.ReplaceAllString(text, "\n\n") - - return text, nil -} + return bodyStyle.Render(text), nil +} \ No newline at end of file