diff --git a/internal/imapclient/backend.go b/internal/imapclient/backend.go index 19968dc..4d0a39f 100644 --- a/internal/imapclient/backend.go +++ b/internal/imapclient/backend.go @@ -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{} diff --git a/internal/imapclient/backend_test.go b/internal/imapclient/backend_test.go index 347cdcb..79bf822 100644 --- a/internal/imapclient/backend_test.go +++ b/internal/imapclient/backend_test.go @@ -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 " { + 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{},