feat: implement imap backend

This commit is contained in:
thibaud-leclere 2026-04-10 11:39:40 +02:00
parent e360f9bffa
commit c688137ed3
5 changed files with 700 additions and 2 deletions

11
go.mod
View file

@ -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
View file

@ -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=

View 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{}

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

View file

@ -11,6 +11,9 @@ type Service struct {
}
func NewService(backend Backend) Service {
if backend == nil {
backend = NewDefaultBackend()
}
return Service{backend: backend}
}