feat: markdown for html bodies (#1213)

Ryan Hartman and Drew Smirnoff created

## 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
`<table>`, `<tr>`, `<td>` 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 <drew@floatpane.com>

Change summary

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

Detailed changes

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

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

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

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.

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"`

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,
 	})
 }
 

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

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 {

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

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

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

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

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 <td style="...">).
+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))

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 <table> 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 "<table cellpadding=..." tag as visible body text.
+const datadogShapeHTML = `    <table cellpadding="0" cellspacing="0" border="0" width="710" style="border:1px solid #E7E7E7;">
+      <tr>
+        <td style="background-color: #632ca6; color: white;">
+          <h1>The Daily Digest</h1>
+        </td>
+      </tr>
+    </table>`
+
+// 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
+// "<table cellpadding=..." text, which is what users see leaked into the
+// inbox viewer. This test will pass on master too — it documents the bug,
+// not the fix.
+func TestProcessBody_LegacyPathManglesIndentedHTML(t *testing.T) {
+	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+	processed, _, err := ProcessBody(datadogShapeHTML, "", lipgloss.NewStyle(), lipgloss.NewStyle(), lipgloss.NewStyle(), false)
+	if err != nil {
+		t.Fatalf("ProcessBody(legacy) failed: %v", err)
+	}
+	clean := ansiEscapeRegex.ReplaceAllString(processed, "")
+	if !strings.Contains(clean, "<table") {
+		t.Errorf("legacy path should leak literal '<table' tag for indented attribute-heavy HTML — if this assertion stops firing, md4c's html_block handling has improved and this PR's premise needs re-evaluation. Got:\n%s", clean)
+	}
+}
+
+// TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass is the fix counterpart to
+// the legacy-mangling test above. Same input, but tagged "text/html", goes
+// straight to htmlconv without the broken markdown pre-pass.
+func TestProcessBody_HTMLMIMETypeSkipsMarkdownPrepass(t *testing.T) {
+	ansiEscapeRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
+	bodyStyle := lipgloss.NewStyle()
+	h1Style := lipgloss.NewStyle()
+	h2Style := lipgloss.NewStyle()
+
+	// Same input as TestProcessBody_LegacyPathManglesIndentedHTML — the
+	// differential is purely the MIME-type argument.
+	processed, _, err := ProcessBody(datadogShapeHTML, BodyMIMETypeHTML, h1Style, h2Style, bodyStyle, false)
+	if err != nil {
+		t.Fatalf("ProcessBody(text/html) failed: %v", err)
+	}
+	clean := ansiEscapeRegex.ReplaceAllString(processed, "")
+	if strings.Contains(clean, "<table") {
+		t.Errorf("text/html body should not leak literal '<table' tag. Got:\n%s", clean)
+	}
+	if !strings.Contains(clean, "The Daily Digest") {
+		t.Errorf("expected text content 'The Daily Digest' in output. Got:\n%s", clean)
+	}
+
+	// Sanity: a body labeled as plain text falls through markdownToHTML and
+	// preserves markdown semantics (heading rendering through the pipeline).
+	mdBody := "# Heading One\n\nSome **bold** text."
+	plainProcessed, _, err := ProcessBody(mdBody, BodyMIMETypePlain, h1Style, h2Style, bodyStyle, false)
+	if err != nil {
+		t.Fatalf("ProcessBody(text/plain) failed: %v", err)
+	}
+	plainClean := ansiEscapeRegex.ReplaceAllString(plainProcessed, "")
+	if !strings.Contains(plainClean, "Heading One") {
+		t.Errorf("text/plain body should still render markdown. Got:\n%s", plainClean)
+	}
+}
+
 func TestRemoteImageCache_EvictsOldestWhenFull(t *testing.T) {
 	// Start with a clean cache so prior tests don't interfere.
 	remoteImageCache.Purge()