feat: header display (#43)

drew created

Change summary

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(-)

Detailed changes

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 {

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)

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

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)

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