Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
8 changes: 4 additions & 4 deletions backend/imap/imap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 8 additions & 6 deletions backend/jmap/jmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -297,25 +297,27 @@ 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 {
if r, ok := inv.Args.(*email.GetResponse); ok && len(r.List) > 0 {
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
}
}
if body == "" {
for _, part := range eml.TextBody {
if val, ok := eml.BodyValues[part.PartID]; ok {
body = val.Value
mimeType = "text/plain"
break
}
}
Expand All @@ -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) {
Expand Down
22 changes: 12 additions & 10 deletions backend/pop3/pop3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions config/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
7 changes: 4 additions & 3 deletions daemon/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
})
}

Expand Down
14 changes: 8 additions & 6 deletions daemonclient/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,15 +104,15 @@ 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,
Folder: folder,
UID: uid,
}, &result)
if err != nil {
return "", nil, err
return "", "", nil, err
}

var attachments []backend.Attachment
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions daemonrpc/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading