feat: implement imap backend
This commit is contained in:
parent
e360f9bffa
commit
c688137ed3
5 changed files with 700 additions and 2 deletions
11
go.mod
11
go.mod
|
|
@ -2,6 +2,13 @@ module email-mcp
|
|||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/godbus/dbus/v5 v5.2.2
|
||||
require (
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.27.0 // indirect
|
||||
require (
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
golang.org/x/sys v0.27.0 // indirect
|
||||
)
|
||||
|
|
|
|||
37
go.sum
37
go.sum
|
|
@ -1,4 +1,41 @@
|
|||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
|
||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
|||
332
internal/imapclient/backend.go
Normal file
332
internal/imapclient/backend.go
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
package imapclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goimap "github.com/emersion/go-imap/v2"
|
||||
goimapclient "github.com/emersion/go-imap/v2/imapclient"
|
||||
|
||||
"email-mcp/internal/secretstore"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultListMessagesLimit = 20
|
||||
maxListMessagesLimit = 50
|
||||
imapImplicitTLSPort = "993"
|
||||
)
|
||||
|
||||
var ErrMessageNotFound = errors.New("message not found")
|
||||
|
||||
type waitCommand interface {
|
||||
Wait() error
|
||||
}
|
||||
|
||||
type listCommand interface {
|
||||
Collect() ([]*goimap.ListData, error)
|
||||
}
|
||||
|
||||
type selectCommand interface {
|
||||
Wait() (*goimap.SelectData, error)
|
||||
}
|
||||
|
||||
type fetchCommand interface {
|
||||
Collect() ([]*goimapclient.FetchMessageBuffer, error)
|
||||
}
|
||||
|
||||
type imapSession interface {
|
||||
Login(username, password string) waitCommand
|
||||
Logout() waitCommand
|
||||
Close() error
|
||||
List(ref, pattern string, options *goimap.ListOptions) listCommand
|
||||
Select(mailbox string, options *goimap.SelectOptions) selectCommand
|
||||
Fetch(numSet goimap.NumSet, options *goimap.FetchOptions) fetchCommand
|
||||
}
|
||||
|
||||
type imapBackend struct {
|
||||
dialTLS func(address string, options *goimapclient.Options) (imapSession, error)
|
||||
}
|
||||
|
||||
type goIMAPSession struct {
|
||||
client *goimapclient.Client
|
||||
}
|
||||
|
||||
func NewDefaultBackend() Backend {
|
||||
return imapBackend{
|
||||
dialTLS: func(address string, options *goimapclient.Options) (imapSession, error) {
|
||||
client, err := goimapclient.DialTLS(address, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return goIMAPSession{client: client}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s goIMAPSession) Login(username, password string) waitCommand {
|
||||
return s.client.Login(username, password)
|
||||
}
|
||||
|
||||
func (s goIMAPSession) Logout() waitCommand {
|
||||
return s.client.Logout()
|
||||
}
|
||||
|
||||
func (s goIMAPSession) Close() error {
|
||||
return s.client.Close()
|
||||
}
|
||||
|
||||
func (s goIMAPSession) List(ref, pattern string, options *goimap.ListOptions) listCommand {
|
||||
return s.client.List(ref, pattern, options)
|
||||
}
|
||||
|
||||
func (s goIMAPSession) Select(mailbox string, options *goimap.SelectOptions) selectCommand {
|
||||
return s.client.Select(mailbox, options)
|
||||
}
|
||||
|
||||
func (s goIMAPSession) Fetch(numSet goimap.NumSet, options *goimap.FetchOptions) fetchCommand {
|
||||
return s.client.Fetch(numSet, options)
|
||||
}
|
||||
|
||||
func (b imapBackend) ListMailboxes(ctx context.Context, cred secretstore.Credential) ([]Mailbox, error) {
|
||||
var mailboxes []Mailbox
|
||||
err := b.withSession(ctx, cred, func(session imapSession) error {
|
||||
listed, err := session.List("", "*", nil).Collect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mailboxes = make([]Mailbox, 0, len(listed))
|
||||
for _, item := range listed {
|
||||
if item == nil || hasMailboxAttr(item.Attrs, goimap.MailboxAttrNoSelect) {
|
||||
continue
|
||||
}
|
||||
mailboxes = append(mailboxes, Mailbox{Name: item.Mailbox})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mailboxes, nil
|
||||
}
|
||||
|
||||
func (b imapBackend) ListMessages(ctx context.Context, cred secretstore.Credential, mailbox string, limit int) ([]MessageSummary, error) {
|
||||
var summaries []MessageSummary
|
||||
err := b.withSession(ctx, cred, func(session imapSession) error {
|
||||
selected, err := session.Select(mailbox, &goimap.SelectOptions{ReadOnly: true}).Wait()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if selected == nil || selected.NumMessages == 0 {
|
||||
summaries = []MessageSummary{}
|
||||
return nil
|
||||
}
|
||||
|
||||
effectiveLimit := clampListLimit(limit)
|
||||
start := uint32(1)
|
||||
if selected.NumMessages > uint32(effectiveLimit) {
|
||||
start = selected.NumMessages - uint32(effectiveLimit) + 1
|
||||
}
|
||||
|
||||
var seqSet goimap.SeqSet
|
||||
seqSet.AddRange(start, selected.NumMessages)
|
||||
|
||||
fetched, err := session.Fetch(seqSet, &goimap.FetchOptions{
|
||||
UID: true,
|
||||
Envelope: true,
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
summaries = make([]MessageSummary, 0, len(fetched))
|
||||
for i := len(fetched) - 1; i >= 0; i-- {
|
||||
msg := fetched[i]
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, MessageSummary{
|
||||
UID: uint32(msg.UID),
|
||||
Subject: envelopeSubject(msg.Envelope),
|
||||
From: envelopeFrom(msg.Envelope),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (b imapBackend) GetMessage(ctx context.Context, cred secretstore.Credential, mailbox string, uid uint32) (Message, error) {
|
||||
var message Message
|
||||
err := b.withSession(ctx, cred, func(session imapSession) error {
|
||||
if _, err := session.Select(mailbox, &goimap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headerSection := &goimap.FetchItemBodySection{
|
||||
Specifier: goimap.PartSpecifierHeader,
|
||||
Peek: true,
|
||||
}
|
||||
textSection := &goimap.FetchItemBodySection{
|
||||
Specifier: goimap.PartSpecifierText,
|
||||
Peek: true,
|
||||
}
|
||||
|
||||
fetched, err := session.Fetch(goimap.UIDSetNum(goimap.UID(uid)), &goimap.FetchOptions{
|
||||
UID: true,
|
||||
BodySection: []*goimap.FetchItemBodySection{headerSection, textSection},
|
||||
}).Collect()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range fetched {
|
||||
if item == nil || uint32(item.UID) != uid {
|
||||
continue
|
||||
}
|
||||
message = Message{
|
||||
UID: uid,
|
||||
Mailbox: mailbox,
|
||||
Headers: parseHeaderBlock(item.FindBodySection(headerSection)),
|
||||
Body: string(item.FindBodySection(textSection)),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return ErrMessageNotFound
|
||||
})
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
|
||||
func (b imapBackend) withSession(ctx context.Context, cred secretstore.Credential, fn func(imapSession) error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cred.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dialTLS := b.dialTLS
|
||||
if dialTLS == nil {
|
||||
dialTLS = func(address string, options *goimapclient.Options) (imapSession, error) {
|
||||
client, err := goimapclient.DialTLS(address, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return goIMAPSession{client: client}, nil
|
||||
}
|
||||
}
|
||||
|
||||
session, err := dialTLS(imapAddress(cred.Host), &goimapclient.Options{
|
||||
Dialer: &net.Dialer{Timeout: 30 * time.Second},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
if err := session.Login(cred.Username, cred.Password).Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runErr := fn(session)
|
||||
logoutErr := session.Logout().Wait()
|
||||
if runErr != nil {
|
||||
return runErr
|
||||
}
|
||||
return logoutErr
|
||||
}
|
||||
|
||||
func imapAddress(host string) string {
|
||||
host = strings.TrimSpace(host)
|
||||
if _, _, err := net.SplitHostPort(host); err == nil {
|
||||
return host
|
||||
}
|
||||
return net.JoinHostPort(host, imapImplicitTLSPort)
|
||||
}
|
||||
|
||||
func clampListLimit(limit int) int {
|
||||
if limit <= 0 {
|
||||
return defaultListMessagesLimit
|
||||
}
|
||||
if limit > maxListMessagesLimit {
|
||||
return maxListMessagesLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func hasMailboxAttr(attrs []goimap.MailboxAttr, want goimap.MailboxAttr) bool {
|
||||
for _, attr := range attrs {
|
||||
if attr == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func envelopeSubject(envelope *goimap.Envelope) string {
|
||||
if envelope == nil {
|
||||
return ""
|
||||
}
|
||||
return envelope.Subject
|
||||
}
|
||||
|
||||
func envelopeFrom(envelope *goimap.Envelope) string {
|
||||
if envelope == nil || len(envelope.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
first := envelope.From[0]
|
||||
address := first.Addr()
|
||||
if address == "" {
|
||||
return first.Name
|
||||
}
|
||||
if strings.TrimSpace(first.Name) == "" {
|
||||
return address
|
||||
}
|
||||
return first.Name + " <" + address + ">"
|
||||
}
|
||||
|
||||
func parseHeaderBlock(raw []byte) []Header {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
|
||||
headers := make([]Header, 0, len(lines))
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if len(headers) == 0 {
|
||||
continue
|
||||
}
|
||||
headers[len(headers)-1].Value += " " + strings.TrimSpace(line)
|
||||
continue
|
||||
}
|
||||
|
||||
name, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
headers = append(headers, Header{
|
||||
Name: strings.TrimSpace(name),
|
||||
Value: strings.TrimSpace(value),
|
||||
})
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
var _ Backend = imapBackend{}
|
||||
319
internal/imapclient/backend_test.go
Normal file
319
internal/imapclient/backend_test.go
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
package imapclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 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 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,9 @@ type Service struct {
|
|||
}
|
||||
|
||||
func NewService(backend Backend) Service {
|
||||
if backend == nil {
|
||||
backend = NewDefaultBackend()
|
||||
}
|
||||
return Service{backend: backend}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue