447 lines
12 KiB
Go
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")
|
|
}
|
|
}
|