diff --git a/backend/backend.go b/backend/backend.go index 2e7e63b471a68f673907464c1ce68936d3fd2960..56a7d42595abef8080ab958ad9699b21aabefad8 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -27,7 +27,9 @@ type Provider interface { // EmailReader fetches emails and their content. type EmailReader interface { FetchEmails(ctx context.Context, folder string, limit, offset uint32) ([]Email, error) - FetchEmailBody(ctx context.Context, folder string, uid uint32) (string, []Attachment, error) + // FetchEmailBody returns the chosen body, its MIME type ("text/html" or + // "text/plain"; empty when unknown), parsed attachments, and any error. + FetchEmailBody(ctx context.Context, folder string, uid uint32) (string, string, []Attachment, error) FetchAttachment(ctx context.Context, folder string, uid uint32, partID, encoding string) ([]byte, error) } diff --git a/backend/imap/imap.go b/backend/imap/imap.go index bb6141eba9003815a38604198ef7abda0fb6f231..978ec1e019cc2532a948724323da75a4d80e4035 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -36,12 +36,12 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u return toBackendEmails(emails), nil } -func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, []backend.Attachment, error) { - body, atts, err := fetcher.FetchEmailBodyFromMailbox(p.account, folder, uid) +func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, string, []backend.Attachment, error) { + body, mimeType, atts, err := fetcher.FetchEmailBodyFromMailbox(p.account, folder, uid) if err != nil { - return "", nil, err + return "", "", nil, err } - return body, toBackendAttachments(atts), nil + return body, mimeType, toBackendAttachments(atts), nil } func (p *Provider) FetchAttachment(_ context.Context, folder string, uid uint32, partID, encoding string) ([]byte, error) { diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 5485ba5c78be2d47540ab53e8c3b9c5391b9a2d4..b67137e9b09683a1aa170ba24db21e6d96a4d472 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -276,10 +276,10 @@ func searchLimit(query backend.SearchQuery) uint32 { return 100 } -func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) { +func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, string, []backend.Attachment, error) { jmapID, err := p.lookupJMAPID(uid) if err != nil { - return "", nil, err + return "", "", nil, err } req := &jmapclient.Request{} @@ -297,7 +297,7 @@ func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (stri resp, err := p.client.Do(req) if err != nil { - return "", nil, fmt.Errorf("jmap body: %w", err) + return "", "", nil, fmt.Errorf("jmap body: %w", err) } for _, inv := range resp.Responses { @@ -305,10 +305,11 @@ func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (stri eml := r.List[0] // Get body text (prefer HTML) - var body string + var body, mimeType string for _, part := range eml.HTMLBody { if val, ok := eml.BodyValues[part.PartID]; ok { body = val.Value + mimeType = "text/html" break } } @@ -316,6 +317,7 @@ func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (stri for _, part := range eml.TextBody { if val, ok := eml.BodyValues[part.PartID]; ok { body = val.Value + mimeType = "text/plain" break } } @@ -336,11 +338,11 @@ func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (stri atts = append(atts, a) } - return body, atts, nil + return body, mimeType, atts, nil } } - return "", nil, fmt.Errorf("jmap: email not found") + return "", "", nil, fmt.Errorf("jmap: email not found") } func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID, _ string) ([]byte, error) { diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index ffec6c97cba4a690252ef93b6d9c59f10af86d1a..1c84e88be0a651ae39cbca8c4f2f64adb1a10c63 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -131,21 +131,21 @@ func (p *Provider) FetchEmails(_ context.Context, _ string, limit, offset uint32 return emails, nil } -func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) { +func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, string, []backend.Attachment, error) { conn, err := p.connect() if err != nil { - return "", nil, err + return "", "", nil, err } defer conn.Quit() msgID, err := p.findMessageByUID(conn, uid) if err != nil { - return "", nil, err + return "", "", nil, err } raw, err := conn.RetrRaw(msgID) if err != nil { - return "", nil, fmt.Errorf("pop3 retr: %w", err) + return "", "", nil, fmt.Errorf("pop3 retr: %w", err) } return parseMessageBody(raw) @@ -352,15 +352,17 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account } // parseMessageBody extracts the body text and attachments from a raw message. -func parseMessageBody(r io.Reader) (string, []backend.Attachment, error) { +func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) { mr, err := gomail.CreateReader(r) if err != nil { - // Not a multipart message — read body directly + // Not a multipart message — read body directly. We don't know the + // content type at this layer; surface empty so the renderer falls + // back to its legacy markdown→HTML path. body, err := io.ReadAll(r) if err != nil { - return "", nil, err + return "", "", nil, err } - return string(body), nil, nil + return string(body), "", nil, nil } var bodyText string @@ -411,9 +413,9 @@ func parseMessageBody(r io.Reader) (string, []backend.Attachment, error) { } if htmlBody != "" { - return htmlBody, attachments, nil + return htmlBody, "text/html", attachments, nil } - return bodyText, attachments, nil + return bodyText, "text/plain", attachments, nil } // findAttachmentData walks a raw message to find attachment data by partID. diff --git a/config/cache.go b/config/cache.go index 72ee7fe1f8788b17aab24c3027fa269a3de9f5a1..18109f542014f49e6be16cc2734f30aacdfbe1b1 100644 --- a/config/cache.go +++ b/config/cache.go @@ -425,6 +425,7 @@ type CachedEmailBody struct { UID uint32 `json:"uid"` AccountID string `json:"account_id"` Body string `json:"body"` + BodyMIMEType string `json:"body_mime_type,omitempty"` // empty for cache rows written before MIME-type tracking; renderer falls back to legacy markdown→HTML pre-pass Attachments []CachedAttachment `json:"attachments,omitempty"` CachedAt time.Time `json:"cached_at"` LastAccessedAt time.Time `json:"last_accessed_at"` diff --git a/daemon/handler.go b/daemon/handler.go index 2c59fb518b81da4fc5de39b4b64e39eaeb70f2c8..e16597d10155937fd5d8a25cba82e8ec6ebb07cf 100644 --- a/daemon/handler.go +++ b/daemon/handler.go @@ -154,7 +154,7 @@ func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Reque ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - body, attachments, err := p.FetchEmailBody(ctx, params.Folder, params.UID) + body, mimeType, attachments, err := p.FetchEmailBody(ctx, params.Folder, params.UID) if err != nil { conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error()) return @@ -172,8 +172,9 @@ func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Reque } conn.SendResponse(req.ID, daemonrpc.FetchEmailBodyResult{ - Body: body, - Attachments: attInfos, + Body: body, + BodyMIMEType: mimeType, + Attachments: attInfos, }) } diff --git a/daemonclient/service.go b/daemonclient/service.go index 7062b06186acd0baa226adbb1dff78d4e79711ca..d18ed4f7b00f6ab8966fd9ccd184d1d66ea86a2d 100644 --- a/daemonclient/service.go +++ b/daemonclient/service.go @@ -16,7 +16,9 @@ import ( // TUI and CLI use this interface — they don't care which mode is active. type Service interface { FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error) - FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error) + // FetchEmailBody returns body, MIME type ("text/html"|"text/plain"|""), + // attachments, and any error. + FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error) DeleteEmails(accountID, folder string, uids []uint32) error ArchiveEmails(accountID, folder string, uids []uint32) error MoveEmails(accountID string, uids []uint32, src, dst string) error @@ -102,7 +104,7 @@ func (s *daemonService) FetchEmails(accountID, folder string, limit, offset uint return emails, err } -func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error) { +func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error) { var result daemonrpc.FetchEmailBodyResult err := s.client.Call(daemonrpc.MethodFetchEmailBody, daemonrpc.FetchEmailBodyParams{ AccountID: accountID, @@ -110,7 +112,7 @@ func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (st UID: uid, }, &result) if err != nil { - return "", nil, err + return "", "", nil, err } var attachments []backend.Attachment @@ -122,7 +124,7 @@ func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (st MIMEType: a.MIMEType, }) } - return result.Body, attachments, nil + return result.Body, result.BodyMIMEType, attachments, nil } func (s *daemonService) DeleteEmails(accountID, folder string, uids []uint32) error { @@ -251,10 +253,10 @@ func (s *directService) FetchEmails(accountID, folder string, limit, offset uint return p.FetchEmails(context.Background(), folder, limit, offset) } -func (s *directService) FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error) { +func (s *directService) FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error) { p, err := s.getProvider(accountID) if err != nil { - return "", nil, err + return "", "", nil, err } return p.FetchEmailBody(context.Background(), folder, uid) } diff --git a/daemonrpc/protocol.go b/daemonrpc/protocol.go index 6fc8ad3a4b1d6b378a19b0ce854d3d3a875b9de3..d19d4c7a8fafb3ea662eceb51a419cf88d9c2a62 100644 --- a/daemonrpc/protocol.go +++ b/daemonrpc/protocol.go @@ -147,8 +147,9 @@ type FetchEmailBodyParams struct { } type FetchEmailBodyResult struct { - Body string `json:"body"` - Attachments []AttachmentInfo `json:"attachments"` + Body string `json:"body"` + BodyMIMEType string `json:"body_mime_type,omitempty"` + Attachments []AttachmentInfo `json:"attachments"` } type AttachmentInfo struct { diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index b36345a0601c723110fccc77edc862a7244367be..b9a3fca52e81efa04156a0bb07d8859bcec8fb7c 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -75,18 +75,19 @@ type Attachment struct { } type Email struct { - UID uint32 - From string - To []string - ReplyTo []string - Subject string - Body string - Date time.Time - IsRead bool - MessageID string - References []string - Attachments []Attachment - AccountID string // ID of the account this email belongs to + UID uint32 + From string + To []string + ReplyTo []string + Subject string + Body string + BodyMIMEType string // "text/html" or "text/plain"; empty when unknown (legacy cache rows). Lets the renderer skip markdown→HTML for already-HTML bodies. + Date time.Time + IsRead bool + MessageID string + References []string + Attachments []Attachment + AccountID string // ID of the account this email belongs to } // Folder represents an IMAP mailbox/folder. @@ -571,15 +572,19 @@ func FetchMailboxEmails(account *config.Account, mailbox string, limit, offset u return allEmails, nil } -func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, []Attachment, error) { +// FetchEmailBodyFromMailbox returns the chosen body, its MIME type +// ("text/html" or "text/plain"; empty if it could not be resolved), the +// parsed attachments, and any error. The MIME type lets the renderer +// skip the markdown→HTML pre-pass for already-HTML bodies. +func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint32) (string, string, []Attachment, error) { c, err := connect(account) if err != nil { - return "", nil, err + return "", "", nil, err } defer c.Close() if _, err := c.Select(mailbox, nil).Wait(); err != nil { - return "", nil, err + return "", "", nil, err } uidSet := imap.UIDSetNum(imap.UID(uid)) @@ -633,11 +638,11 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint }) bsMsgs, err := fetchCmd.Collect() if err != nil { - return "", nil, err + return "", "", nil, err } if len(bsMsgs) == 0 || bsMsgs[0].BodyStructure == nil { - return "", nil, fmt.Errorf("no message or body structure found with UID %d", uid) + return "", "", nil, fmt.Errorf("no message or body structure found with UID %d", uid) } msg := bsMsgs[0] @@ -646,6 +651,10 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint var htmlPartID, htmlPartEncoding string var attachments []Attachment var extractedBody string // Used if we intercept and decrypt a payload + // MIME type of extractedBody. Set alongside every assignment to extractedBody + // so the renderer can skip the markdown→HTML pre-pass for HTML payloads while + // still letting markdown error messages render formatted. + var extractedBodyMIMEType string var checkPart func(part *imap.BodyStructureSinglePart, partID string) checkPart = func(part *imap.BodyStructureSinglePart, partID string) { @@ -709,6 +718,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if err != nil { extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to fetch encrypted part from IMAP server: %v\n", err) + extractedBodyMIMEType = "text/plain" htmlPartID = "extracted" } else { p7, parseErr := pkcs7.Parse(data) @@ -724,6 +734,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if parseErr != nil { extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to parse PKCS7 payload: %v\n", parseErr) + extractedBodyMIMEType = "text/plain" htmlPartID = "extracted" } else { var innerBytes []byte @@ -817,15 +828,18 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } else { if cType == "text/html" { extractedBody = string(b) + extractedBodyMIMEType = "text/html" htmlPartID = "extracted" // Skip IMAP fetch } else if cType == "text/plain" && extractedBody == "" { extractedBody = string(b) + extractedBodyMIMEType = "text/plain" plainPartID = "extracted" } } } } else { extractedBody = fmt.Sprintf("**S/MIME Error:** Failed to read inner decrypted MIME: %v\n\n```\n%s\n```", err, string(innerBytes)) + extractedBodyMIMEType = "text/plain" htmlPartID = "extracted" } @@ -838,6 +852,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint return // Stop checking IMAP structure, we hijacked it } else { extractedBody = fmt.Sprintf("**S/MIME Decryption Failed:** %s\n", decryptionErr) + extractedBodyMIMEType = "text/plain" htmlPartID = "extracted" } } @@ -950,11 +965,13 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint if strings.HasPrefix(ct, "text/html") { body, _ := io.ReadAll(p.Body) extractedBody = string(body) + extractedBodyMIMEType = "text/html" htmlPartID = "decrypted" } else if strings.HasPrefix(ct, "text/plain") && extractedBody == "" { body, _ := io.ReadAll(p.Body) extractedBody = string(body) - htmlPartID = "decrypted" + extractedBodyMIMEType = "text/plain" + plainPartID = "decrypted" } } } @@ -968,10 +985,12 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } else { extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err) + extractedBodyMIMEType = "text/plain" htmlPartID = "extracted" } } else { extractedBody = "**PGP Encrypted:** Private key not configured\n" + extractedBodyMIMEType = "text/plain" htmlPartID = "extracted" } } @@ -1073,18 +1092,21 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint // If we hijacked and decrypted the body, return it immediately if extractedBody != "" { - return extractedBody, attachments, nil + return extractedBody, extractedBodyMIMEType, attachments, nil } var body string + var bodyMIMEType string textPartID := "" textPartEncoding := "" if htmlPartID != "" { textPartID = htmlPartID textPartEncoding = htmlPartEncoding + bodyMIMEType = "text/html" } else if plainPartID != "" { textPartID = plainPartID textPartEncoding = plainPartEncoding + bodyMIMEType = "text/plain" } if os.Getenv("DEBUG_KITTY_IMAGES") != "" { msg := fmt.Sprintf("[kitty-img] body selection html=%s plain=%s chosen=%s\n", htmlPartID, plainPartID, textPartID) @@ -1114,7 +1136,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint }) msgs, err := fetchCmd.Collect() if err != nil { - return "", nil, err + return "", "", nil, err } if len(msgs) > 0 { @@ -1129,7 +1151,7 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } - return body, attachments, nil + return body, bodyMIMEType, attachments, nil } func FetchAttachmentFromMailbox(account *config.Account, mailbox string, uid uint32, partID string, encoding string) ([]byte, error) { @@ -1354,11 +1376,11 @@ func FetchSentEmails(account *config.Account, limit, offset uint32) ([]Email, er return FetchMailboxEmails(account, getSentMailbox(account), limit, offset) } -func FetchEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { +func FetchEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) { return FetchEmailBodyFromMailbox(account, "INBOX", uid) } -func FetchSentEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { +func FetchSentEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) { return FetchEmailBodyFromMailbox(account, getSentMailbox(account), uid) } @@ -1583,10 +1605,10 @@ func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, } // FetchTrashEmailBody fetches the body of an email from trash -func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { +func FetchTrashEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) { c, err := connect(account) if err != nil { - return "", nil, err + return "", "", nil, err } defer c.Close() @@ -1599,10 +1621,10 @@ func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachm } // FetchArchiveEmailBody fetches the body of an email from archive -func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { +func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, string, []Attachment, error) { c, err := connect(account) if err != nil { - return "", nil, err + return "", "", nil, err } defer c.Close() @@ -1728,7 +1750,7 @@ func FetchFolderEmails(account *config.Account, folder string, limit, offset uin } // FetchFolderEmailBody fetches the body of an email from an arbitrary folder. -func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, []Attachment, error) { +func FetchFolderEmailBody(account *config.Account, folder string, uid uint32) (string, string, []Attachment, error) { return FetchEmailBodyFromMailbox(account, folder, uid) } diff --git a/main.go b/main.go index 682bc16f770c2e8212699224368a6dcb98da6cd8..31efff8c5963a4d5491b1f39e2bcc269c46b2fbc 100644 --- a/main.go +++ b/main.go @@ -732,10 +732,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, func() tea.Msg { return tui.PreviewBodyFetchedMsg{ - UID: msg.UID, - Body: cached.Body, - Attachments: attachments, - AccountID: msg.AccountID, + UID: msg.UID, + Body: cached.Body, + BodyMIMEType: cached.BodyMIMEType, + Attachments: attachments, + AccountID: msg.AccountID, } } } @@ -758,10 +759,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } go func() { err := config.SaveEmailBody(folderName, config.CachedEmailBody{ - UID: msg.UID, - AccountID: msg.AccountID, - Body: msg.Body, - Attachments: cachedAttachments, + UID: msg.UID, + AccountID: msg.AccountID, + Body: msg.Body, + BodyMIMEType: msg.BodyMIMEType, + Attachments: cachedAttachments, }, m.config.GetBodyCacheThreshold()) if err != nil { log.Printf("debug: error caching email body fails (disk full, permission denied) for UID: %d: %v", msg.UID, err) @@ -1261,11 +1263,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, func() tea.Msg { return tui.EmailBodyFetchedMsg{ - UID: msg.UID, - Body: cached.Body, - Attachments: attachments, - AccountID: msg.AccountID, - Mailbox: msg.Mailbox, + UID: msg.UID, + Body: cached.Body, + BodyMIMEType: cached.BodyMIMEType, + Attachments: attachments, + AccountID: msg.AccountID, + Mailbox: msg.Mailbox, } } } @@ -1282,7 +1285,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Update the email in our stores - m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments) + m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.BodyMIMEType, msg.Attachments) // Cache the body to disk folderForCache := "INBOX" @@ -1309,10 +1312,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cachedAttachments = append(cachedAttachments, ca) } err := config.SaveEmailBody(folderForCache, config.CachedEmailBody{ - UID: msg.UID, - AccountID: msg.AccountID, - Body: msg.Body, - Attachments: cachedAttachments, + UID: msg.UID, + AccountID: msg.AccountID, + Body: msg.Body, + BodyMIMEType: msg.BodyMIMEType, + Attachments: cachedAttachments, }, m.config.GetBodyCacheThreshold()) if err != nil { @@ -1840,10 +1844,11 @@ func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.Mail return -1 } -func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body string, attachments []fetcher.Attachment) { +func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox tui.MailboxKind, body, bodyMIMEType string, attachments []fetcher.Attachment) { for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { m.emails[i].Body = body + m.emails[i].BodyMIMEType = bodyMIMEType m.emails[i].Attachments = attachments break } @@ -1852,6 +1857,7 @@ func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox t for i := range emails { if emails[i].UID == uid { emails[i].Body = body + emails[i].BodyMIMEType = bodyMIMEType emails[i].Attachments = attachments break } @@ -2378,30 +2384,32 @@ func fetchEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, mailbox } var ( - body string - attachments []fetcher.Attachment - err error + body string + bodyMIMEType string + attachments []fetcher.Attachment + err error ) switch mailbox { case tui.MailboxSent: - body, attachments, err = fetcher.FetchSentEmailBody(account, uid) + body, bodyMIMEType, attachments, err = fetcher.FetchSentEmailBody(account, uid) case tui.MailboxTrash: - body, attachments, err = fetcher.FetchTrashEmailBody(account, uid) + body, bodyMIMEType, attachments, err = fetcher.FetchTrashEmailBody(account, uid) case tui.MailboxArchive: - body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid) + body, bodyMIMEType, attachments, err = fetcher.FetchArchiveEmailBody(account, uid) default: - body, attachments, err = fetcher.FetchEmailBody(account, uid) + body, bodyMIMEType, attachments, err = fetcher.FetchEmailBody(account, uid) } if err != nil { return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} } return tui.EmailBodyFetchedMsg{ - UID: uid, - Body: body, - Attachments: attachments, - AccountID: accountID, - Mailbox: mailbox, + UID: uid, + Body: body, + BodyMIMEType: bodyMIMEType, + Attachments: attachments, + AccountID: accountID, + Mailbox: mailbox, } } } @@ -2758,17 +2766,18 @@ func fetchFolderEmailBodyCmd(cfg *config.Config, uid uint32, accountID string, f return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: fmt.Errorf("account not found")} } - body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid) + body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid) if err != nil { return tui.EmailBodyFetchedMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} } return tui.EmailBodyFetchedMsg{ - UID: uid, - Body: body, - Attachments: attachments, - AccountID: accountID, - Mailbox: mailbox, + UID: uid, + Body: body, + BodyMIMEType: bodyMIMEType, + Attachments: attachments, + AccountID: accountID, + Mailbox: mailbox, } } } @@ -2780,16 +2789,17 @@ func fetchPreviewBodyCmd(cfg *config.Config, uid uint32, accountID string, folde return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: fmt.Errorf("account not found")} } - body, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid) + body, bodyMIMEType, attachments, err := fetcher.FetchFolderEmailBody(account, folderName, uid) if err != nil { return tui.PreviewBodyFetchedMsg{UID: uid, AccountID: accountID, Err: err} } return tui.PreviewBodyFetchedMsg{ - UID: uid, - Body: body, - Attachments: attachments, - AccountID: accountID, + UID: uid, + Body: body, + BodyMIMEType: bodyMIMEType, + Attachments: attachments, + AccountID: accountID, } } } diff --git a/tui/email_view.go b/tui/email_view.go index 6d2b8b7528d9c51bc01740d61c8e11661418f989..165f4bb73e5b5f886eecb9bfb72bf77bab6575f3 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -123,7 +123,7 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma // Initial state for showImages matches config unless overridden later showImages := !disableImages - body, placements, err := view.ProcessBodyWithInline(email.Body, inlineImages, H1Style, H2Style, BodyStyle, !showImages) + body, placements, err := view.ProcessBodyWithInline(email.Body, email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !showImages) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } @@ -240,7 +240,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ClearKittyGraphics() inlineImages := inlineImagesFromAttachments(m.email.Attachments) - body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) + body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } @@ -317,7 +317,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // When the window size changes, wrap and clear kitty images to keep placement stable ClearKittyGraphics() inlineImages := inlineImagesFromAttachments(m.email.Attachments) - body, placements, err := view.ProcessBodyWithInline(m.email.Body, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) + body, placements, err := view.ProcessBodyWithInline(m.email.Body, m.email.BodyMIMEType, inlineImages, H1Style, H2Style, BodyStyle, !m.showImages) if err != nil { body = fmt.Sprintf("Error rendering body: %v", err) } diff --git a/tui/messages.go b/tui/messages.go index 7457eee5fc140c04ea062eff93635ca151c30907..8a605eb0dd9c3b7a8ea519797c22228fcee711b9 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -96,11 +96,12 @@ type UpdatePreviewMsg struct { } type PreviewBodyFetchedMsg struct { - UID uint32 - AccountID string - Body string - Attachments []fetcher.Attachment - Err error + UID uint32 + AccountID string + Body string + BodyMIMEType string + Attachments []fetcher.Attachment + Err error } type FetchErr error @@ -255,12 +256,13 @@ type DiscardDraftMsg struct { } type EmailBodyFetchedMsg struct { - UID uint32 - Body string - Attachments []fetcher.Attachment - Err error - AccountID string - Mailbox MailboxKind + UID uint32 + Body string + BodyMIMEType string + Attachments []fetcher.Attachment + Err error + AccountID string + Mailbox MailboxKind } // --- Multi-Account Messages --- diff --git a/view/html.go b/view/html.go index f83b31dd86571c198aceea7a48c6cb725a783561..8644980fb48a1aaed6facf7fe4f31769710b7398 100644 --- a/view/html.go +++ b/view/html.go @@ -660,9 +660,19 @@ type ImagePlacement struct { SixelEncoded string // Cached Sixel escape sequence (encode once, reuse on scroll) } +// BodyMIMEType values understood by ProcessBody/ProcessBodyWithInline. Empty +// string means "unknown" — the renderer falls back to running markdownToHTML +// before HTML parsing, which is correct for plaintext-with-markdown bodies but +// can mangle complex HTML (e.g. tables with attribute-heavy ). +const ( + BodyMIMETypeHTML = "text/html" + BodyMIMETypePlain = "text/plain" +) + // ProcessBodyWithInline renders the body and resolves CID inline images when provided. // Returns the rendered body text, image placements for out-of-band rendering, and any error. -func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) { +// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass). +func ProcessBodyWithInline(rawBody, mimeType string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) { inlineMap := make(map[string]string, len(inline)) for _, img := range inline { cid := strings.TrimSpace(img.CID) @@ -674,22 +684,31 @@ func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Styl } inlineMap[cid] = img.Base64 } - return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle, disableImages) + return processBody(rawBody, mimeType, inlineMap, h1Style, h2Style, bodyStyle, disableImages) } // ProcessBody takes a raw email body, decodes it, and formats it as plain // text with terminal hyperlinks. -func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) { - return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages) +// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass). +func ProcessBody(rawBody, mimeType string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) { + return processBody(rawBody, mimeType, nil, h1Style, h2Style, bodyStyle, disableImages) } -func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) { +func processBody(rawBody, mimeType string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) { decodedBody, err := decodeQuotedPrintable(rawBody) if err != nil { decodedBody = rawBody } - htmlBody := markdownToHTML([]byte(decodedBody)) + // HTML bodies skip the markdown pre-pass — md4c can mangle attribute-heavy + // or indented HTML (#602-style raw-tag bleed-through). Empty mimeType keeps + // legacy behavior for cached/legacy callers that don't supply one. + var htmlBody []byte + if mimeType == BodyMIMETypeHTML { + htmlBody = []byte(decodedBody) + } else { + htmlBody = markdownToHTML([]byte(decodedBody)) + } // Parse HTML into structured elements using C parser. elements, ok := clib.HTMLToElements(string(htmlBody)) diff --git a/view/html_test.go b/view/html_test.go index 8b11d89be83ca00c16ad2f89bc8efd740a1b77d8..f9630f035056395154132979f88399926366e2f0 100644 --- a/view/html_test.go +++ b/view/html_test.go @@ -626,7 +626,7 @@ func TestProcessBodyWithHyperlinkSupport(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.setupHyperlinks() - processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false) + processed, _, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false) if err != nil { t.Fatalf("ProcessBody() failed: %v", err) } @@ -753,7 +753,7 @@ func TestProcessBodyWithImageProtocol(t *testing.T) { tc.clearAllImageEnv() tc.setupImageProtocol() - processed, placements, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false) + processed, placements, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false) if err != nil { t.Fatalf("ProcessBody() failed: %v", err) } @@ -820,7 +820,7 @@ func TestProcessBody(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - processed, _, err := ProcessBody(tc.input, h1Style, h2Style, bodyStyle, false) + processed, _, err := ProcessBody(tc.input, "", h1Style, h2Style, bodyStyle, false) if err != nil { t.Fatalf("ProcessBody() failed: %v", err) } @@ -834,6 +834,75 @@ func TestProcessBody(t *testing.T) { } } +// datadogShapeHTML is the indented attribute-heavy table shape commonly +// produced by Datadog Daily Digest, marketing tools, and any sender that +// uses HTML for layout. md4c's html_block rule rejects this shape +// (leading whitespace, attribute-laden opening tag), so the markdown +// pre-pass passes the literal text through, and htmlconv then renders the +// raw "
+ + + +
+

The Daily Digest

+
` + +// TestProcessBody_LegacyPathManglesIndentedHTML pins the bug this PR fixes. +// With an empty MIME type, the renderer falls through to the legacy +// markdown→HTML pre-pass, which is what every body went through before this +// change. For Datadog-shape input the output literally contains the opening +// "