fix: harden imap backend decoding

This commit is contained in:
thibaud-leclere 2026-04-10 11:49:52 +02:00
parent c688137ed3
commit 656d0d7f91
2 changed files with 233 additions and 8 deletions

View file

@ -1,14 +1,19 @@
package imapclient
import (
"bytes"
"context"
"errors"
"io"
"mime"
"net"
"sort"
"strings"
"time"
goimap "github.com/emersion/go-imap/v2"
goimapclient "github.com/emersion/go-imap/v2/imapclient"
gomail "github.com/emersion/go-message/mail"
"email-mcp/internal/secretstore"
)
@ -21,6 +26,8 @@ const (
var ErrMessageNotFound = errors.New("message not found")
var headerWordDecoder = mime.WordDecoder{}
type waitCommand interface {
Wait() error
}
@ -142,9 +149,21 @@ func (b imapBackend) ListMessages(ctx context.Context, cred secretstore.Credenti
return err
}
sort.SliceStable(fetched, func(i, j int) bool {
if fetched[i] == nil {
return false
}
if fetched[j] == nil {
return true
}
if fetched[i].UID == fetched[j].UID {
return fetched[i].SeqNum > fetched[j].SeqNum
}
return fetched[i].UID > fetched[j].UID
})
summaries = make([]MessageSummary, 0, len(fetched))
for i := len(fetched) - 1; i >= 0; i-- {
msg := fetched[i]
for _, msg := range fetched {
if msg == nil {
continue
}
@ -190,11 +209,13 @@ func (b imapBackend) GetMessage(ctx context.Context, cred secretstore.Credential
if item == nil || uint32(item.UID) != uid {
continue
}
headerBytes := item.FindBodySection(headerSection)
bodyBytes := item.FindBodySection(textSection)
message = Message{
UID: uid,
Mailbox: mailbox,
Headers: parseHeaderBlock(item.FindBodySection(headerSection)),
Body: string(item.FindBodySection(textSection)),
Headers: parseHeaderBlock(headerBytes),
Body: decodeBodyText(headerBytes, bodyBytes),
}
return nil
}
@ -276,7 +297,7 @@ func envelopeSubject(envelope *goimap.Envelope) string {
if envelope == nil {
return ""
}
return envelope.Subject
return decodeEncodedWords(envelope.Subject)
}
func envelopeFrom(envelope *goimap.Envelope) string {
@ -287,12 +308,13 @@ func envelopeFrom(envelope *goimap.Envelope) string {
first := envelope.From[0]
address := first.Addr()
if address == "" {
return first.Name
return decodeEncodedWords(first.Name)
}
if strings.TrimSpace(first.Name) == "" {
name := decodeEncodedWords(first.Name)
if strings.TrimSpace(name) == "" {
return address
}
return first.Name + " <" + address + ">"
return name + " <" + address + ">"
}
func parseHeaderBlock(raw []byte) []Header {
@ -329,4 +351,79 @@ func parseHeaderBlock(raw []byte) []Header {
return headers
}
func decodeEncodedWords(value string) string {
decoded, err := headerWordDecoder.DecodeHeader(value)
if err != nil {
return value
}
return decoded
}
func decodeBodyText(headerBytes, bodyBytes []byte) string {
if len(bodyBytes) == 0 {
return ""
}
messageBytes := assembleMessage(headerBytes, bodyBytes)
reader, err := gomail.CreateReader(bytes.NewReader(messageBytes))
if err != nil && reader == nil {
return string(bodyBytes)
}
if reader == nil {
return string(bodyBytes)
}
var fallback string
for {
part, partErr := reader.NextPart()
if errors.Is(partErr, io.EOF) {
break
}
if partErr != nil && part == nil {
break
}
if part == nil {
continue
}
inlineHeader, ok := part.Header.(*gomail.InlineHeader)
if !ok {
continue
}
partBytes, readErr := io.ReadAll(part.Body)
if readErr != nil {
continue
}
mediaType, _, _ := inlineHeader.ContentType()
switch strings.ToLower(mediaType) {
case "text/plain":
return string(partBytes)
case "text/html":
if fallback == "" {
fallback = string(partBytes)
}
default:
if fallback == "" && strings.HasPrefix(strings.ToLower(mediaType), "text/") {
fallback = string(partBytes)
}
}
}
if fallback != "" {
return fallback
}
return string(bodyBytes)
}
func assembleMessage(headerBytes, bodyBytes []byte) []byte {
trimmedHeaders := bytes.TrimRight(headerBytes, "\r\n")
assembled := make([]byte, 0, len(trimmedHeaders)+len(bodyBytes)+4)
assembled = append(assembled, trimmedHeaders...)
assembled = append(assembled, '\r', '\n', '\r', '\n')
assembled = append(assembled, bodyBytes...)
return assembled
}
var _ Backend = imapBackend{}

View file

@ -3,6 +3,7 @@ package imapclient
import (
"context"
"errors"
"strings"
"testing"
goimap "github.com/emersion/go-imap/v2"
@ -222,6 +223,71 @@ func TestBackendListMessagesUsesReadOnlySelectAndUIDSummaries(t *testing.T) {
}
}
func TestBackendListMessagesSortsSummariesByUIDAndDecodesEnvelopeFields(t *testing.T) {
session := &fakeSession{
loginCmd: &fakeCommand{},
logoutCmd: &fakeCommand{},
selectCmd: &fakeSelectCommand{data: &goimap.SelectData{NumMessages: 3}},
fetchCmd: &fakeFetchCommand{data: []*goimapclient.FetchMessageBuffer{
{
SeqNum: 3,
UID: goimap.UID(300),
Envelope: &goimap.Envelope{
Subject: "=?UTF-8?Q?Ol=C3=A1?=",
From: []goimap.Address{
{Name: "=?UTF-8?Q?J=C3=B6hn_Doe?=", Mailbox: "john", Host: "example.com"},
},
},
},
{
SeqNum: 1,
UID: goimap.UID(100),
Envelope: &goimap.Envelope{
Subject: "first",
From: []goimap.Address{
{Mailbox: "first", Host: "example.com"},
},
},
},
{
SeqNum: 2,
UID: goimap.UID(200),
Envelope: &goimap.Envelope{
Subject: "second",
From: []goimap.Address{
{Mailbox: "second", Host: "example.com"},
},
},
},
}},
}
backend := imapBackend{
dialTLS: func(string, *goimapclient.Options) (imapSession, error) {
return session, nil
},
}
messages, err := backend.ListMessages(context.Background(), secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
}, "INBOX", 3)
if err != nil {
t.Fatalf("ListMessages returned error: %v", err)
}
if got := []uint32{messages[0].UID, messages[1].UID, messages[2].UID}; got[0] != 300 || got[1] != 200 || got[2] != 100 {
t.Fatalf("expected UID-descending order, got %#v", got)
}
if messages[0].Subject != "Olá" {
t.Fatalf("expected decoded subject, got %#v", messages[0])
}
if messages[0].From != "Jöhn Doe <john@example.com>" {
t.Fatalf("expected decoded display name, got %#v", messages[0])
}
}
func TestBackendGetMessageFetchesByUIDAndParsesHeaders(t *testing.T) {
headerSection := &goimap.FetchItemBodySection{Specifier: goimap.PartSpecifierHeader, Peek: true}
textSection := &goimap.FetchItemBodySection{Specifier: goimap.PartSpecifierText, Peek: true}
@ -287,6 +353,68 @@ func TestBackendGetMessageFetchesByUIDAndParsesHeaders(t *testing.T) {
}
}
func TestBackendGetMessageDecodesMultipartAndTransferEncoding(t *testing.T) {
headerSection := &goimap.FetchItemBodySection{Specifier: goimap.PartSpecifierHeader, Peek: true}
textSection := &goimap.FetchItemBodySection{Specifier: goimap.PartSpecifierText, Peek: true}
rawBody := strings.Join([]string{
"MIME-Version: 1.0",
"Content-Type: multipart/alternative; boundary=frontier",
"",
"--frontier",
"Content-Type: text/plain; charset=utf-8",
"Content-Transfer-Encoding: quoted-printable",
"",
"Hello=2C plain text",
"--frontier",
"Content-Type: text/html; charset=utf-8",
"Content-Transfer-Encoding: base64",
"",
"PHA+SGVsbG88L3A+",
"--frontier--",
"",
}, "\r\n")
session := &fakeSession{
loginCmd: &fakeCommand{},
logoutCmd: &fakeCommand{},
selectCmd: &fakeSelectCommand{data: &goimap.SelectData{NumMessages: 5}},
fetchCmd: &fakeFetchCommand{data: []*goimapclient.FetchMessageBuffer{
{
UID: goimap.UID(42),
BodySection: []goimapclient.FetchBodySectionBuffer{
{
Section: headerSection,
Bytes: []byte("Content-Type: multipart/alternative; boundary=frontier\r\n\r\n"),
},
{
Section: textSection,
Bytes: []byte(rawBody),
},
},
},
}},
}
backend := imapBackend{
dialTLS: func(string, *goimapclient.Options) (imapSession, error) {
return session, nil
},
}
message, err := backend.GetMessage(context.Background(), secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
}, "INBOX", 42)
if err != nil {
t.Fatalf("GetMessage returned error: %v", err)
}
if message.Body != "Hello, plain text" {
t.Fatalf("expected decoded plain-text body, got %q", message.Body)
}
}
func TestBackendGetMessageReturnsMessageNotFoundWhenUIDIsMissing(t *testing.T) {
session := &fakeSession{
loginCmd: &fakeCommand{},