Skip to content

Commit c11de45

Browse files
YeeP79andrinoff
andauthored
feat: markdown for html bodies (#1213)
## 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>
1 parent efdebf1 commit c11de45

14 files changed

Lines changed: 258 additions & 125 deletions

File tree

backend/backend.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ type Provider interface {
2727
// EmailReader fetches emails and their content.
2828
type EmailReader interface {
2929
FetchEmails(ctx context.Context, folder string, limit, offset uint32) ([]Email, error)
30-
FetchEmailBody(ctx context.Context, folder string, uid uint32) (string, []Attachment, error)
30+
// FetchEmailBody returns the chosen body, its MIME type ("text/html" or
31+
// "text/plain"; empty when unknown), parsed attachments, and any error.
32+
FetchEmailBody(ctx context.Context, folder string, uid uint32) (string, string, []Attachment, error)
3133
FetchAttachment(ctx context.Context, folder string, uid uint32, partID, encoding string) ([]byte, error)
3234
}
3335

backend/imap/imap.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u
3636
return toBackendEmails(emails), nil
3737
}
3838

39-
func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, []backend.Attachment, error) {
40-
body, atts, err := fetcher.FetchEmailBodyFromMailbox(p.account, folder, uid)
39+
func (p *Provider) FetchEmailBody(_ context.Context, folder string, uid uint32) (string, string, []backend.Attachment, error) {
40+
body, mimeType, atts, err := fetcher.FetchEmailBodyFromMailbox(p.account, folder, uid)
4141
if err != nil {
42-
return "", nil, err
42+
return "", "", nil, err
4343
}
44-
return body, toBackendAttachments(atts), nil
44+
return body, mimeType, toBackendAttachments(atts), nil
4545
}
4646

4747
func (p *Provider) FetchAttachment(_ context.Context, folder string, uid uint32, partID, encoding string) ([]byte, error) {

backend/jmap/jmap.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,10 +276,10 @@ func searchLimit(query backend.SearchQuery) uint32 {
276276
return 100
277277
}
278278

279-
func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) {
279+
func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, string, []backend.Attachment, error) {
280280
jmapID, err := p.lookupJMAPID(uid)
281281
if err != nil {
282-
return "", nil, err
282+
return "", "", nil, err
283283
}
284284

285285
req := &jmapclient.Request{}
@@ -297,25 +297,27 @@ func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (stri
297297

298298
resp, err := p.client.Do(req)
299299
if err != nil {
300-
return "", nil, fmt.Errorf("jmap body: %w", err)
300+
return "", "", nil, fmt.Errorf("jmap body: %w", err)
301301
}
302302

303303
for _, inv := range resp.Responses {
304304
if r, ok := inv.Args.(*email.GetResponse); ok && len(r.List) > 0 {
305305
eml := r.List[0]
306306

307307
// Get body text (prefer HTML)
308-
var body string
308+
var body, mimeType string
309309
for _, part := range eml.HTMLBody {
310310
if val, ok := eml.BodyValues[part.PartID]; ok {
311311
body = val.Value
312+
mimeType = "text/html"
312313
break
313314
}
314315
}
315316
if body == "" {
316317
for _, part := range eml.TextBody {
317318
if val, ok := eml.BodyValues[part.PartID]; ok {
318319
body = val.Value
320+
mimeType = "text/plain"
319321
break
320322
}
321323
}
@@ -336,11 +338,11 @@ func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (stri
336338
atts = append(atts, a)
337339
}
338340

339-
return body, atts, nil
341+
return body, mimeType, atts, nil
340342
}
341343
}
342344

343-
return "", nil, fmt.Errorf("jmap: email not found")
345+
return "", "", nil, fmt.Errorf("jmap: email not found")
344346
}
345347

346348
func (p *Provider) FetchAttachment(_ context.Context, _ string, _ uint32, partID, _ string) ([]byte, error) {

backend/pop3/pop3.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,21 +131,21 @@ func (p *Provider) FetchEmails(_ context.Context, _ string, limit, offset uint32
131131
return emails, nil
132132
}
133133

134-
func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) {
134+
func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, string, []backend.Attachment, error) {
135135
conn, err := p.connect()
136136
if err != nil {
137-
return "", nil, err
137+
return "", "", nil, err
138138
}
139139
defer conn.Quit()
140140

141141
msgID, err := p.findMessageByUID(conn, uid)
142142
if err != nil {
143-
return "", nil, err
143+
return "", "", nil, err
144144
}
145145

146146
raw, err := conn.RetrRaw(msgID)
147147
if err != nil {
148-
return "", nil, fmt.Errorf("pop3 retr: %w", err)
148+
return "", "", nil, fmt.Errorf("pop3 retr: %w", err)
149149
}
150150

151151
return parseMessageBody(raw)
@@ -352,15 +352,17 @@ func entityToEmail(header *message.Header, msgInfo pop3client.MessageID, account
352352
}
353353

354354
// parseMessageBody extracts the body text and attachments from a raw message.
355-
func parseMessageBody(r io.Reader) (string, []backend.Attachment, error) {
355+
func parseMessageBody(r io.Reader) (string, string, []backend.Attachment, error) {
356356
mr, err := gomail.CreateReader(r)
357357
if err != nil {
358-
// Not a multipart message — read body directly
358+
// Not a multipart message — read body directly. We don't know the
359+
// content type at this layer; surface empty so the renderer falls
360+
// back to its legacy markdown→HTML path.
359361
body, err := io.ReadAll(r)
360362
if err != nil {
361-
return "", nil, err
363+
return "", "", nil, err
362364
}
363-
return string(body), nil, nil
365+
return string(body), "", nil, nil
364366
}
365367

366368
var bodyText string
@@ -411,9 +413,9 @@ func parseMessageBody(r io.Reader) (string, []backend.Attachment, error) {
411413
}
412414

413415
if htmlBody != "" {
414-
return htmlBody, attachments, nil
416+
return htmlBody, "text/html", attachments, nil
415417
}
416-
return bodyText, attachments, nil
418+
return bodyText, "text/plain", attachments, nil
417419
}
418420

419421
// findAttachmentData walks a raw message to find attachment data by partID.

config/cache.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ type CachedEmailBody struct {
425425
UID uint32 `json:"uid"`
426426
AccountID string `json:"account_id"`
427427
Body string `json:"body"`
428+
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
428429
Attachments []CachedAttachment `json:"attachments,omitempty"`
429430
CachedAt time.Time `json:"cached_at"`
430431
LastAccessedAt time.Time `json:"last_accessed_at"`

daemon/handler.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Reque
154154
ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout)
155155
defer cancel()
156156

157-
body, attachments, err := p.FetchEmailBody(ctx, params.Folder, params.UID)
157+
body, mimeType, attachments, err := p.FetchEmailBody(ctx, params.Folder, params.UID)
158158
if err != nil {
159159
conn.SendError(req.ID, daemonrpc.ErrCodeInternal, err.Error())
160160
return
@@ -172,8 +172,9 @@ func (d *Daemon) handleFetchEmailBody(conn *daemonrpc.Conn, req *daemonrpc.Reque
172172
}
173173

174174
conn.SendResponse(req.ID, daemonrpc.FetchEmailBodyResult{
175-
Body: body,
176-
Attachments: attInfos,
175+
Body: body,
176+
BodyMIMEType: mimeType,
177+
Attachments: attInfos,
177178
})
178179
}
179180

daemonclient/service.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import (
1616
// TUI and CLI use this interface — they don't care which mode is active.
1717
type Service interface {
1818
FetchEmails(accountID, folder string, limit, offset uint32) ([]backend.Email, error)
19-
FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error)
19+
// FetchEmailBody returns body, MIME type ("text/html"|"text/plain"|""),
20+
// attachments, and any error.
21+
FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error)
2022
DeleteEmails(accountID, folder string, uids []uint32) error
2123
ArchiveEmails(accountID, folder string, uids []uint32) error
2224
MoveEmails(accountID string, uids []uint32, src, dst string) error
@@ -102,15 +104,15 @@ func (s *daemonService) FetchEmails(accountID, folder string, limit, offset uint
102104
return emails, err
103105
}
104106

105-
func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error) {
107+
func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error) {
106108
var result daemonrpc.FetchEmailBodyResult
107109
err := s.client.Call(daemonrpc.MethodFetchEmailBody, daemonrpc.FetchEmailBodyParams{
108110
AccountID: accountID,
109111
Folder: folder,
110112
UID: uid,
111113
}, &result)
112114
if err != nil {
113-
return "", nil, err
115+
return "", "", nil, err
114116
}
115117

116118
var attachments []backend.Attachment
@@ -122,7 +124,7 @@ func (s *daemonService) FetchEmailBody(accountID, folder string, uid uint32) (st
122124
MIMEType: a.MIMEType,
123125
})
124126
}
125-
return result.Body, attachments, nil
127+
return result.Body, result.BodyMIMEType, attachments, nil
126128
}
127129

128130
func (s *daemonService) DeleteEmails(accountID, folder string, uids []uint32) error {
@@ -251,10 +253,10 @@ func (s *directService) FetchEmails(accountID, folder string, limit, offset uint
251253
return p.FetchEmails(context.Background(), folder, limit, offset)
252254
}
253255

254-
func (s *directService) FetchEmailBody(accountID, folder string, uid uint32) (string, []backend.Attachment, error) {
256+
func (s *directService) FetchEmailBody(accountID, folder string, uid uint32) (string, string, []backend.Attachment, error) {
255257
p, err := s.getProvider(accountID)
256258
if err != nil {
257-
return "", nil, err
259+
return "", "", nil, err
258260
}
259261
return p.FetchEmailBody(context.Background(), folder, uid)
260262
}

daemonrpc/protocol.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,9 @@ type FetchEmailBodyParams struct {
147147
}
148148

149149
type FetchEmailBodyResult struct {
150-
Body string `json:"body"`
151-
Attachments []AttachmentInfo `json:"attachments"`
150+
Body string `json:"body"`
151+
BodyMIMEType string `json:"body_mime_type,omitempty"`
152+
Attachments []AttachmentInfo `json:"attachments"`
152153
}
153154

154155
type AttachmentInfo struct {

0 commit comments

Comments
 (0)