email-mcp/internal/imapclient/backend_test.go
2026-04-10 11:49:52 +02:00

447 lines
12 KiB
Go

package imapclient
import (
"context"
"errors"
"strings"
"testing"
goimap "github.com/emersion/go-imap/v2"
goimapclient "github.com/emersion/go-imap/v2/imapclient"
"email-mcp/internal/secretstore"
)
type fakeCommand struct {
err error
waited bool
}
func (c *fakeCommand) Wait() error {
c.waited = true
return c.err
}
type fakeListCommand struct {
data []*goimap.ListData
err error
}
func (c *fakeListCommand) Collect() ([]*goimap.ListData, error) {
return c.data, c.err
}
type fakeSelectCommand struct {
data *goimap.SelectData
err error
}
func (c *fakeSelectCommand) Wait() (*goimap.SelectData, error) {
return c.data, c.err
}
type fakeFetchCommand struct {
data []*goimapclient.FetchMessageBuffer
err error
}
func (c *fakeFetchCommand) Collect() ([]*goimapclient.FetchMessageBuffer, error) {
return c.data, c.err
}
type fakeSession struct {
loginUsername string
loginPassword string
loginCmd *fakeCommand
logoutCmd *fakeCommand
closed bool
listRef string
listPattern string
listOptions *goimap.ListOptions
listCmd *fakeListCommand
selectMailbox string
selectOptions *goimap.SelectOptions
selectCmd *fakeSelectCommand
fetchNumSet goimap.NumSet
fetchOptions *goimap.FetchOptions
fetchCmd *fakeFetchCommand
}
func (s *fakeSession) Login(username, password string) waitCommand {
s.loginUsername = username
s.loginPassword = password
return s.loginCmd
}
func (s *fakeSession) Logout() waitCommand {
return s.logoutCmd
}
func (s *fakeSession) Close() error {
s.closed = true
return nil
}
func (s *fakeSession) List(ref, pattern string, options *goimap.ListOptions) listCommand {
s.listRef = ref
s.listPattern = pattern
s.listOptions = options
return s.listCmd
}
func (s *fakeSession) Select(mailbox string, options *goimap.SelectOptions) selectCommand {
s.selectMailbox = mailbox
s.selectOptions = options
return s.selectCmd
}
func (s *fakeSession) Fetch(numSet goimap.NumSet, options *goimap.FetchOptions) fetchCommand {
s.fetchNumSet = numSet
s.fetchOptions = options
return s.fetchCmd
}
func TestBackendListMailboxesUsesTLSAndFiltersNoselect(t *testing.T) {
session := &fakeSession{
loginCmd: &fakeCommand{},
logoutCmd: &fakeCommand{},
listCmd: &fakeListCommand{data: []*goimap.ListData{
{Mailbox: "Archive"},
{Mailbox: "[Gmail]", Attrs: []goimap.MailboxAttr{goimap.MailboxAttrNoSelect}},
{Mailbox: "INBOX"},
}},
}
var gotAddress string
backend := imapBackend{
dialTLS: func(address string, _ *goimapclient.Options) (imapSession, error) {
gotAddress = address
return session, nil
},
}
mailboxes, err := backend.ListMailboxes(context.Background(), secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
})
if err != nil {
t.Fatalf("ListMailboxes returned error: %v", err)
}
if gotAddress != "imap.example.com:993" {
t.Fatalf("expected dial address imap.example.com:993, got %q", gotAddress)
}
if session.loginUsername != "alice" || session.loginPassword != "secret" {
t.Fatalf("unexpected login credentials: %q %q", session.loginUsername, session.loginPassword)
}
if session.listRef != "" || session.listPattern != "*" {
t.Fatalf("unexpected list arguments: ref=%q pattern=%q", session.listRef, session.listPattern)
}
if !session.logoutCmd.waited {
t.Fatal("expected backend to logout after listing mailboxes")
}
if !session.closed {
t.Fatal("expected backend to close the IMAP session")
}
if len(mailboxes) != 2 {
t.Fatalf("expected 2 selectable mailboxes, got %#v", mailboxes)
}
if mailboxes[0].Name != "Archive" || mailboxes[1].Name != "INBOX" {
t.Fatalf("unexpected mailboxes: %#v", mailboxes)
}
}
func TestBackendListMessagesUsesReadOnlySelectAndUIDSummaries(t *testing.T) {
session := &fakeSession{
loginCmd: &fakeCommand{},
logoutCmd: &fakeCommand{},
selectCmd: &fakeSelectCommand{data: &goimap.SelectData{NumMessages: 200}},
fetchCmd: &fakeFetchCommand{data: []*goimapclient.FetchMessageBuffer{
{
SeqNum: 151,
UID: goimap.UID(501),
Envelope: &goimap.Envelope{
Subject: "older",
From: []goimap.Address{
{Mailbox: "alice", Host: "example.com"},
},
},
},
{
SeqNum: 200,
UID: goimap.UID(900),
Envelope: &goimap.Envelope{
Subject: "newer",
From: []goimap.Address{
{Name: "Bob", Mailbox: "bob", 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", 80)
if err != nil {
t.Fatalf("ListMessages returned error: %v", err)
}
if session.selectMailbox != "INBOX" {
t.Fatalf("expected INBOX to be selected, got %q", session.selectMailbox)
}
if session.selectOptions == nil || !session.selectOptions.ReadOnly {
t.Fatalf("expected read-only select options, got %#v", session.selectOptions)
}
if session.fetchNumSet == nil || session.fetchNumSet.String() != "151:200" {
t.Fatalf("expected fetch sequence range 151:200, got %v", session.fetchNumSet)
}
if session.fetchOptions == nil || !session.fetchOptions.UID || !session.fetchOptions.Envelope {
t.Fatalf("expected UID+Envelope fetch options, got %#v", session.fetchOptions)
}
if len(messages) != 2 {
t.Fatalf("expected 2 message summaries, got %#v", messages)
}
if messages[0].UID != 900 || messages[0].Subject != "newer" || messages[0].From != "Bob <bob@example.com>" {
t.Fatalf("unexpected newest summary: %#v", messages[0])
}
if messages[1].UID != 501 || messages[1].Subject != "older" || messages[1].From != "alice@example.com" {
t.Fatalf("unexpected older summary: %#v", messages[1])
}
}
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}
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("Received: first\r\nReceived: second\r\nSubject: hello\r\n folded\r\n\r\n"),
},
{
Section: textSection,
Bytes: []byte("message body"),
},
},
},
}},
}
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 session.fetchNumSet == nil || session.fetchNumSet.String() != "42" {
t.Fatalf("expected UID fetch for 42, got %v", session.fetchNumSet)
}
if session.fetchOptions == nil || !session.fetchOptions.UID || len(session.fetchOptions.BodySection) != 2 {
t.Fatalf("expected UID fetch with header and text sections, got %#v", session.fetchOptions)
}
if message.UID != 42 || message.Mailbox != "INBOX" {
t.Fatalf("unexpected message identity: %#v", message)
}
if len(message.Headers) != 3 {
t.Fatalf("expected 3 parsed headers, got %#v", message.Headers)
}
if message.Headers[0].Name != "Received" || message.Headers[0].Value != "first" {
t.Fatalf("unexpected first header: %#v", message.Headers[0])
}
if message.Headers[1].Name != "Received" || message.Headers[1].Value != "second" {
t.Fatalf("unexpected second header: %#v", message.Headers[1])
}
if message.Headers[2].Name != "Subject" || message.Headers[2].Value != "hello folded" {
t.Fatalf("unexpected subject header: %#v", message.Headers[2])
}
if message.Body != "message body" {
t.Fatalf("unexpected body: %q", message.Body)
}
}
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{},
logoutCmd: &fakeCommand{},
selectCmd: &fakeSelectCommand{data: &goimap.SelectData{NumMessages: 3}},
fetchCmd: &fakeFetchCommand{},
}
backend := imapBackend{
dialTLS: func(string, *goimapclient.Options) (imapSession, error) {
return session, nil
},
}
_, err := backend.GetMessage(context.Background(), secretstore.Credential{
Host: "imap.example.com",
Username: "alice",
Password: "secret",
}, "INBOX", 42)
if !errors.Is(err, ErrMessageNotFound) {
t.Fatalf("expected ErrMessageNotFound, got %v", err)
}
}
func TestNewServiceUsesDefaultBackendWhenNil(t *testing.T) {
svc := NewService(nil)
if svc.backend == nil {
t.Fatal("expected NewService to install a default backend")
}
}