From c688137ed3896f154c404c063e55087cf0a815eb Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Fri, 10 Apr 2026 11:39:40 +0200 Subject: [PATCH] feat: implement imap backend --- go.mod | 11 +- go.sum | 37 ++++ internal/imapclient/backend.go | 332 ++++++++++++++++++++++++++++ internal/imapclient/backend_test.go | 319 ++++++++++++++++++++++++++ internal/imapclient/service.go | 3 + 5 files changed, 700 insertions(+), 2 deletions(-) create mode 100644 internal/imapclient/backend.go create mode 100644 internal/imapclient/backend_test.go diff --git a/go.mod b/go.mod index 4115660..3511edc 100644 --- a/go.mod +++ b/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 +) diff --git a/go.sum b/go.sum index 5334971..3e15389 100644 --- a/go.sum +++ b/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= diff --git a/internal/imapclient/backend.go b/internal/imapclient/backend.go new file mode 100644 index 0000000..19968dc --- /dev/null +++ b/internal/imapclient/backend.go @@ -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{} diff --git a/internal/imapclient/backend_test.go b/internal/imapclient/backend_test.go new file mode 100644 index 0000000..347cdcb --- /dev/null +++ b/internal/imapclient/backend_test.go @@ -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 " { + 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") + } +} diff --git a/internal/imapclient/service.go b/internal/imapclient/service.go index 7a1a856..7d01171 100644 --- a/internal/imapclient/service.go +++ b/internal/imapclient/service.go @@ -11,6 +11,9 @@ type Service struct { } func NewService(backend Backend) Service { + if backend == nil { + backend = NewDefaultBackend() + } return Service{backend: backend} }