Detailed changes
@@ -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)
}
@@ -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) {
@@ -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) {
@@ -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.
@@ -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"`
@@ -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,
})
}
@@ -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)
}
@@ -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 {
@@ -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)
}
@@ -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,
}
}
}
@@ -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)
}
@@ -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 ---
@@ -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))
@@ -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()