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 " { 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 " { 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") } }