fix: harden imap backend decoding
This commit is contained in:
parent
c688137ed3
commit
656d0d7f91
2 changed files with 233 additions and 8 deletions
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
Loading…
Reference in a new issue