From c11de45c3aee05f7734344d162f604573389e124 Mon Sep 17 00:00:00 2001 From: Ryan Hartman Date: Wed, 6 May 2026 07:43:09 -0600 Subject: [PATCH] feat: markdown for html bodies (#1213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What? Threads MIME type detection from the fetch layer through to rendering. FetchEmailBody and the IMAP/JMAP/POP3 providers now return BodyMIMEType. The Backend interface, daemon RPC protocol, and local cache schema all carry the MIME type through. view.ProcessBody takes the MIME type and skips the markdownToHTML pre-pass when it's text/html. The TUI message fetcher and view call sites are updated for the new field. ## Why? Right now every email body gets run through md4c before we render it as HTML. The assumption was that md4c would pass raw HTML through untouched, but that falls apart on real HTML email — Datadog digests, marketing stuff, anything with heavy attributes or indentation trips md4c's html_block rules. When that happens, md4c either escapes the tags or treats them as code blocks, and the TUI ends up showing literal ``, ``, `
` instead of the rendered content. Detecting text/html and skipping the markdown pass fixes that, while text/plain senders like GitHub notifications and mailing lists keep their markdown formatting. ### Trade-offs & compatibility If a text/html sender writes `**bold**` inside their HTML, it won't render as bold anymore. That's spec-correct behavior, and it's worth losing to get tables back. On the compat side, MIME type defaults to an empty string in the wire format and cache, so old cache entries fall back to the legacy markdown path. No migration needed. --------- Co-authored-by: Drew Smirnoff --- backend/backend.go | 4 +- backend/imap/imap.go | 8 ++-- backend/jmap/jmap.go | 14 +++--- backend/pop3/pop3.go | 22 +++++----- config/cache.go | 1 + daemon/handler.go | 7 +-- daemonclient/service.go | 14 +++--- daemonrpc/protocol.go | 5 ++- fetcher/fetcher.go | 78 ++++++++++++++++++++++------------ main.go | 94 +++++++++++++++++++++++------------------ tui/email_view.go | 6 +-- tui/messages.go | 24 ++++++----- view/html.go | 31 +++++++++++--- view/html_test.go | 75 ++++++++++++++++++++++++++++++-- 14 files changed, 258 insertions(+), 125 deletions(-) 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 +// "