From d2ae2a5b9d575fe266ea5d89770f685db039c6c7 Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 31 Jul 2025 15:39:56 +0400 Subject: [PATCH 1/5] 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 From 65b01341502f0fd5530d84ed713d7071971178e0 Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 31 Jul 2025 15:51:05 +0400 Subject: [PATCH 2/5] fix: load uid and name separately (#45) --- fetcher/fetcher.go | 59 +++++++++++++++++++++++++++++----------------- main.go | 32 +++++++++++++++++++++++++ tui/messages.go | 2 +- 3 files changed, 71 insertions(+), 22 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 449e30c19306b38ac16287843252003d3df090fd..ac4db994deb7f59b740c06491693027eef5a649c 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "io/ioutil" + "log" "mime" "strings" "time" @@ -146,13 +147,28 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { done <- c.Fetch(seqset, fetchItems, messages) }() - var emails []Email + // Collect messages from the channel into a slice + var msgs []*imap.Message for msg := range messages { - if msg == nil { + msgs = append(msgs, msg) + } + + // Wait for the fetch to complete and check for errors + if err := <-done; err != nil { + return nil, err + } + + var emails []Email + for _, msg := range msgs { + if msg == nil || msg.Envelope == nil { continue } - fromAddrs := msg.Envelope.From[0].Address() + var fromAddr string + if len(msg.Envelope.From) > 0 { + fromAddr = msg.Envelope.From[0].Address() + } + var toAddrList []string for _, addr := range msg.Envelope.To { toAddrList = append(toAddrList, addr.Address()) @@ -160,17 +176,13 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { emails = append(emails, Email{ UID: msg.Uid, - From: fromAddrs, + From: fromAddr, To: toAddrList, Subject: decodeHeader(msg.Envelope.Subject), Date: msg.Envelope.Date, }) } - if err := <-done; err != nil { - return nil, err - } - // 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] @@ -179,8 +191,6 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { 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 { @@ -199,12 +209,23 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error done := make(chan error, 1) fetchItems := []imap.FetchItem{imap.FetchItem("BODY[]")} go func() { - done <- c.Fetch(seqset, fetchItems, messages) + done <- c.UidFetch(seqset, fetchItems, messages) }() - msg := <-messages - if msg == nil { - return "", nil, fmt.Errorf("could not fetch email body") + // Wait for the fetch operation to complete first. + if err := <-done; err != nil { + return "", nil, err + } + + // Now that the fetch is complete, check for a message. + var msg *imap.Message + select { + case msg = <-messages: + if msg == nil { + return "", nil, fmt.Errorf("fetched a nil message") + } + default: + return "", nil, fmt.Errorf("no message found with UID %d", uid) } bodyLiteral := msg.GetBody(&imap.BodySectionName{}) @@ -224,7 +245,8 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error if err == io.EOF { break } else if err != nil { - return "", nil, fmt.Errorf("error getting next part: %v", err) + log.Printf("Error getting next part: %v", err) + break } cdHeader := p.Header.Get("Content-Disposition") @@ -256,14 +278,9 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error } } - if err := <-done; err != nil { - return "", nil, err - } - return body, attachments, nil } - func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { c, err := connect(cfg) if err != nil { @@ -314,4 +331,4 @@ func ArchiveEmail(cfg *config.Config, uid uint32) error { archiveMailbox = "Archive" } return moveEmail(cfg, uid, archiveMailbox) -} +} \ No newline at end of file diff --git a/main.go b/main.go index 48bd81d061375d7aa031ea8c9eef2a8ae2b8f302..5581998663b4fb75ebada9c7f53e45ce4d6b9324 100644 --- a/main.go +++ b/main.go @@ -152,6 +152,21 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() case tui.ViewEmailMsg: + // Show a status message while fetching the email body + m.current = tui.NewStatus("Fetching email content...") + // Pass the index directly to the command + return m, tea.Batch(m.current.Init(), fetchEmailBodyCmd(m.config, m.emails[msg.Index], msg.Index)) + + case tui.EmailBodyFetchedMsg: + if msg.Err != nil { + log.Printf("could not fetch email body: %v", msg.Err) + m.current = m.inbox + return m, nil + } + // Use the index from the message to update the correct email + m.emails[msg.Index].Body = msg.Body + m.emails[msg.Index].Attachments = msg.Attachments + emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height) m.current = emailView return m, m.current.Init() @@ -248,6 +263,23 @@ func (m *mainModel) View() string { return m.current.View() } +func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, index int) tea.Cmd { + return func() tea.Msg { + body, attachments, err := fetcher.FetchEmailBody(cfg, email.UID) + if err != nil { + return tui.EmailBodyFetchedMsg{Index: index, Err: err} + } + + // Return the fetched data along with the original index + return tui.EmailBodyFetchedMsg{ + Index: index, + Body: body, + Attachments: attachments, + } + } +} + + func markdownToHTML(md []byte) []byte { var buf bytes.Buffer p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe())) diff --git a/tui/messages.go b/tui/messages.go index 4f14b69848bc0a4734c0f7b0f96742ac5d2a0623..b6fb3014c6f326e13eb15c43f25f7c38bb4f03b6 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -116,4 +116,4 @@ type EmailBodyFetchedMsg struct { Body string Attachments []fetcher.Attachment Err error -} \ No newline at end of file +} From 276f6aa40f9ea2f34631b94d81c7a35ce0229580 Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 31 Jul 2025 16:03:41 +0400 Subject: [PATCH 3/5] fix: performance issues (#45) --- fetcher/fetcher.go | 148 ++++++++++++++++++++++++++++----------------- main.go | 12 +++- tui/messages.go | 4 +- 3 files changed, 105 insertions(+), 59 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index ac4db994deb7f59b740c06491693027eef5a649c..60ef46e0ae592b0a122c9838f55fef26ac21dfab 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -1,11 +1,9 @@ package fetcher import ( - "encoding/base64" "fmt" "io" "io/ioutil" - "log" "mime" "strings" "time" @@ -21,6 +19,7 @@ import ( // Attachment holds data for an email attachment. type Attachment struct { Filename string + PartID string // Keep PartID to fetch on demand Data []byte } @@ -141,19 +140,16 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { messages := make(chan *imap.Message, limit) done := make(chan error, 1) - // Only fetch Envelope and UID for the inbox view fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid} go func() { done <- c.Fetch(seqset, fetchItems, messages) }() - // Collect messages from the channel into a slice var msgs []*imap.Message for msg := range messages { msgs = append(msgs, msg) } - // Wait for the fetch to complete and check for errors if err := <-done; err != nil { return nil, err } @@ -183,7 +179,6 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { }) } - // 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] } @@ -207,73 +202,73 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error messages := make(chan *imap.Message, 1) done := make(chan error, 1) - fetchItems := []imap.FetchItem{imap.FetchItem("BODY[]")} + fetchItems := []imap.FetchItem{imap.FetchBodyStructure} go func() { done <- c.UidFetch(seqset, fetchItems, messages) }() - // Wait for the fetch operation to complete first. if err := <-done; err != nil { return "", nil, err } - // Now that the fetch is complete, check for a message. - var msg *imap.Message - select { - case msg = <-messages: - if msg == nil { - return "", nil, fmt.Errorf("fetched a nil message") - } - default: - return "", nil, fmt.Errorf("no message found with UID %d", uid) + msg := <-messages + if msg == nil || msg.BodyStructure == nil { + return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid) } - bodyLiteral := msg.GetBody(&imap.BodySectionName{}) - if bodyLiteral == nil { - return "", nil, fmt.Errorf("could not get message body") - } + var textPartID string + var attachments []Attachment + var findParts func(*imap.BodyStructure, string) + findParts = func(bs *imap.BodyStructure, prefix string) { + for i, part := range bs.Parts { + partID := fmt.Sprintf("%d", i+1) + if prefix != "" { + partID = fmt.Sprintf("%s.%d", prefix, i+1) + } - mr, err := mail.CreateReader(bodyLiteral) - if err != nil { - return "", nil, fmt.Errorf("error creating mail reader: %v", err) + if part.MIMEType == "text" && (part.MIMESubType == "plain" || part.MIMESubType == "html") && textPartID == "" { + textPartID = partID + } + if part.Disposition == "attachment" || part.Disposition == "inline" { + if filename, ok := part.Params["filename"]; ok { + attachments = append(attachments, Attachment{Filename: filename, PartID: partID}) + } + } + if len(part.Parts) > 0 { + findParts(part, partID) + } + } } + findParts(msg.BodyStructure, "") 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 + if textPartID != "" { + partMessages := make(chan *imap.Message, 1) + partDone := make(chan error, 1) + + // Correctly create the fetch item as a string for your library version + fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID)) + section, err := imap.ParseBodySectionName(fetchItem) + if err != nil { + return "", nil, 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 - } - } + go func() { + // Pass the string-based fetch item + partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages) + }() + + if err := <-partDone; err != nil { + return "", nil, err } - 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 + partMsg := <-partMessages + if partMsg != nil { + // Use the parsed section to get the body from the response + literal := partMsg.GetBody(section) + if literal != nil { + bodyBytes, _ := ioutil.ReadAll(literal) + body = string(bodyBytes) } } } @@ -281,6 +276,49 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error return body, attachments, nil } +func FetchAttachment(cfg *config.Config, uid uint32, partID string) ([]byte, 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 + } + + seqset := new(imap.SeqSet) + seqset.AddNum(uid) + + fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", partID)) + section, err := imap.ParseBodySectionName(fetchItem) + if err != nil { + return nil, err + } + + messages := make(chan *imap.Message, 1) + done := make(chan error, 1) + go func() { + done <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, messages) + }() + + if err := <-done; err != nil { + return nil, err + } + + msg := <-messages + if msg == nil { + return nil, fmt.Errorf("could not fetch attachment") + } + + literal := msg.GetBody(section) + if literal == nil { + return nil, fmt.Errorf("could not get attachment body") + } + + return ioutil.ReadAll(literal) +} + func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { c, err := connect(cfg) if err != nil { diff --git a/main.go b/main.go index 5581998663b4fb75ebada9c7f53e45ce4d6b9324..a8d247debf6ed614077295bc4471229b39e55ca2 100644 --- a/main.go +++ b/main.go @@ -234,7 +234,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tui.DownloadAttachmentMsg: m.previousModel = m.current m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename)) - return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(msg)) + // Use the new FetchAttachment function + return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(m.config, m.emails[msg.Index].UID, msg)) case tui.AttachmentDownloadedMsg: var statusMsg string @@ -359,8 +360,13 @@ func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { } } -func downloadAttachmentCmd(msg tui.DownloadAttachmentMsg) tea.Cmd { +func downloadAttachmentCmd(cfg *config.Config, uid uint32, msg tui.DownloadAttachmentMsg) tea.Cmd { return func() tea.Msg { + data, err := fetcher.FetchAttachment(cfg, uid, msg.PartID) + if err != nil { + return tui.AttachmentDownloadedMsg{Err: err} + } + homeDir, err := os.UserHomeDir() if err != nil { return tui.AttachmentDownloadedMsg{Err: err} @@ -372,7 +378,7 @@ func downloadAttachmentCmd(msg tui.DownloadAttachmentMsg) tea.Cmd { } } filePath := filepath.Join(downloadsPath, msg.Filename) - err = os.WriteFile(filePath, msg.Data, 0644) + err = os.WriteFile(filePath, data, 0644) return tui.AttachmentDownloadedMsg{Path: filePath, Err: err} } } diff --git a/tui/messages.go b/tui/messages.go index b6fb3014c6f326e13eb15c43f25f7c38bb4f03b6..66c14af92f387f0f52a40ddaae8e1b202f5064cb 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -88,8 +88,10 @@ type EmailActionDoneMsg struct { type GoToChoiceMenuMsg struct{} type DownloadAttachmentMsg struct { + Index int Filename string - Data []byte + PartID string + Data []byte } type AttachmentDownloadedMsg struct { From a9e792a661dfca4cfb17706cf4b005816af36fa4 Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 31 Jul 2025 19:51:13 +0400 Subject: [PATCH 4/5] fix: decoding emails --- fetcher/fetcher.go | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 60ef46e0ae592b0a122c9838f55fef26ac21dfab..fa3693a07e37a26ffc13d39a5e9ddf97ccce7341 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -1,10 +1,13 @@ package fetcher import ( + "bytes" + "encoding/base64" "fmt" "io" "io/ioutil" "mime" + "mime/quotedprintable" "strings" "time" @@ -246,7 +249,6 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error partMessages := make(chan *imap.Message, 1) partDone := make(chan error, 1) - // Correctly create the fetch item as a string for your library version fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID)) section, err := imap.ParseBodySectionName(fetchItem) if err != nil { @@ -254,7 +256,6 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error } go func() { - // Pass the string-based fetch item partDone <- c.UidFetch(seqset, []imap.FetchItem{fetchItem}, partMessages) }() @@ -264,11 +265,41 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error partMsg := <-partMessages if partMsg != nil { - // Use the parsed section to get the body from the response literal := partMsg.GetBody(section) if literal != nil { - bodyBytes, _ := ioutil.ReadAll(literal) - body = string(bodyBytes) + // The new decoding logic starts here + buf, _ := ioutil.ReadAll(literal) + mr, err := mail.CreateReader(bytes.NewReader(buf)) + if err != nil { + body = string(buf) + } else { + p, err := mr.NextPart() + if err != nil { + body = string(buf) + } else { + encoding := p.Header.Get("Content-Transfer-Encoding") + bodyBytes, _ := ioutil.ReadAll(p.Body) + + switch strings.ToLower(encoding) { + case "base64": + decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes)) + if err == nil { + body = string(decoded) + } else { + body = string(bodyBytes) + } + case "quoted-printable": + decoded, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(string(bodyBytes)))) + if err == nil { + body = string(decoded) + } else { + body = string(bodyBytes) + } + default: + body = string(bodyBytes) + } + } + } } } } From 5b3eb174b2b0f206bc0cf96a0ecb5960301da67e Mon Sep 17 00:00:00 2001 From: drew Date: Thu, 31 Jul 2025 19:54:05 +0400 Subject: [PATCH 5/5] fix: format everything --- fetcher/fetcher.go | 4 ++-- main.go | 3 +-- tui/messages.go | 2 +- tui/styles.go | 2 +- view/html.go | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index fa3693a07e37a26ffc13d39a5e9ddf97ccce7341..047c5ec30f7b1a683c19acf36c0b64ed5f68399d 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -248,7 +248,7 @@ func FetchEmailBody(cfg *config.Config, uid uint32) (string, []Attachment, error if textPartID != "" { partMessages := make(chan *imap.Message, 1) partDone := make(chan error, 1) - + fetchItem := imap.FetchItem(fmt.Sprintf("BODY.PEEK[%s]", textPartID)) section, err := imap.ParseBodySectionName(fetchItem) if err != nil { @@ -400,4 +400,4 @@ func ArchiveEmail(cfg *config.Config, uid uint32) error { archiveMailbox = "Archive" } return moveEmail(cfg, uid, archiveMailbox) -} \ No newline at end of file +} diff --git a/main.go b/main.go index a8d247debf6ed614077295bc4471229b39e55ca2..a5079d01f4bb0669d2e3acc272faae4c7b2f7133 100644 --- a/main.go +++ b/main.go @@ -166,7 +166,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Use the index from the message to update the correct email m.emails[msg.Index].Body = msg.Body m.emails[msg.Index].Attachments = msg.Attachments - + emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height) m.current = emailView return m, m.current.Init() @@ -280,7 +280,6 @@ func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, index int) tea.C } } - func markdownToHTML(md []byte) []byte { var buf bytes.Buffer p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe())) diff --git a/tui/messages.go b/tui/messages.go index 66c14af92f387f0f52a40ddaae8e1b202f5064cb..d34659a6ecb5145923b97ac274515a633fc89fe9 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -91,7 +91,7 @@ type DownloadAttachmentMsg struct { Index int Filename string PartID string - Data []byte + Data []byte } type AttachmentDownloadedMsg struct { diff --git a/tui/styles.go b/tui/styles.go index 88980dbe6826f536e76da6b59fdc57a465d5cc3c..253e7f5aa5f31302d412683aca05c27042874e8e 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -33,7 +33,7 @@ var ( Align(lipgloss.Center) BodyStyle = lipgloss.NewStyle(). - Bold(true) // A bit bold + Bold(true) // A bit bold ) var DocStyle = lipgloss.NewStyle().Margin(1, 2) diff --git a/view/html.go b/view/html.go index c42f4f6a0cbefa81ad94af3a81f014f4d218bc59..1cdf4afb572916be422f6132d5959d5a8f67625f 100644 --- a/view/html.go +++ b/view/html.go @@ -107,4 +107,4 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st re := regexp.MustCompile(`\n{3,}`) text = re.ReplaceAllString(text, "\n\n") return bodyStyle.Render(text), nil -} \ No newline at end of file +}