diff --git a/src/internal/connector/exchange/mail_folder_cache.go b/src/internal/connector/exchange/mail_folder_cache.go new file mode 100644 index 000000000..58a733c45 --- /dev/null +++ b/src/internal/connector/exchange/mail_folder_cache.go @@ -0,0 +1,179 @@ +package exchange + +import ( + "context" + + multierror "github.com/hashicorp/go-multierror" + "github.com/microsoftgraph/msgraph-sdk-go/models" + msfolderdelta "github.com/microsoftgraph/msgraph-sdk-go/users/item/mailfolders/delta" + "github.com/pkg/errors" + + "github.com/alcionai/corso/internal/connector/graph" + "github.com/alcionai/corso/internal/path" +) + +const ( + // rootFolderAlias is the per-user root container alias for the exchange email + // hierarchy. + rootFolderAlias = "msgfolderroot" + // nextDataLink is a random map key so we can iterate through delta results. + nextDataLink = "@odata.nextLink" +) + +type container interface { + descendable + displayable +} + +// cachedContainer is used for local unit tests but also makes it so that this +// code can be broken into generic- and service-specific chunks later on to +// reuse logic in IDToPath. +type cachedContainer interface { + container + Path() *path.Builder + SetPath(*path.Builder) +} + +type mailFolder struct { + models.MailFolderable + p *path.Builder +} + +func (mf mailFolder) Path() *path.Builder { + return mf.p +} + +func (mf *mailFolder) SetPath(newPath *path.Builder) { + mf.p = newPath +} + +type mailFolderCache struct { + cache map[string]cachedContainer + gs graph.Service + userID string +} + +// populateRoot fetches and populates the root folder in the cache so the cache +// knows when to stop resolving the path. +func (mc *mailFolderCache) populateRoot(context.Context) error { + wantedOpts := []string{"displayName", "parentFolderId"} + + opts, err := optionsForMailFoldersItem(wantedOpts) + if err != nil { + return errors.Wrapf(err, "getting options for mail folders %v", wantedOpts) + } + + f, err := mc. + gs. + Client(). + UsersById(mc.userID). + MailFoldersById(rootFolderAlias). + GetWithRequestConfigurationAndResponseHandler(opts, nil) + if err != nil { + return errors.Wrapf(err, "fetching root folder") + } + + // Root only needs the ID because we hide it's name for Mail. + idPtr := f.GetId() + if idPtr == nil || len(*idPtr) == 0 { + return errors.New("root folder has no ID") + } + + mc.cache[*idPtr] = &mailFolder{ + MailFolderable: f, + p: &path.Builder{}, + } + + return nil +} + +func checkRequiredValues(c container) error { + idPtr := c.GetId() + if idPtr == nil || len(*idPtr) == 0 { + return errors.New("folder without ID") + } + + ptr := c.GetDisplayName() + if ptr == nil || len(*ptr) == 0 { + return errors.Errorf("folder %s without display name", *idPtr) + } + + ptr = c.GetParentFolderId() + if ptr == nil || len(*ptr) == 0 { + return errors.Errorf("folder %s without parent ID", *idPtr) + } + + return nil +} + +func (mc *mailFolderCache) Populate(ctx context.Context) error { + if mc.cache == nil { + mc.cache = map[string]cachedContainer{} + } + + if err := mc.populateRoot(ctx); err != nil { + return err + } + + builder := mc. + gs. + Client(). + UsersById(mc.userID). + MailFolders(). + Delta() + + var errs *multierror.Error + + for { + resp, err := builder.Get() + if err != nil { + return err + } + + for _, f := range resp.GetValue() { + if err := checkRequiredValues(f); err != nil { + errs = multierror.Append(errs, err) + continue + } + + mc.cache[*f.GetId()] = &mailFolder{MailFolderable: f} + } + + r := resp.GetAdditionalData() + + n, ok := r[nextDataLink] + if !ok || n == nil { + break + } + + link := *(n.(*string)) + builder = msfolderdelta.NewDeltaRequestBuilder(link, mc.gs.Adapter()) + } + + return errs.ErrorOrNil() +} + +func (mc *mailFolderCache) IDToPath( + ctx context.Context, + folderID string, +) (*path.Builder, error) { + c, ok := mc.cache[folderID] + if !ok { + return nil, errors.Errorf("folder %s not cached", folderID) + } + + p := c.Path() + if p != nil { + return p, nil + } + + parentPath, err := mc.IDToPath(ctx, *c.GetParentFolderId()) + if err != nil { + return nil, errors.Wrap(err, "retrieving parent folder") + } + + fullPath := parentPath.Append(*c.GetDisplayName()) + c.SetPath(fullPath) + + return fullPath, nil +} diff --git a/src/internal/connector/exchange/mail_folder_cache_test.go b/src/internal/connector/exchange/mail_folder_cache_test.go new file mode 100644 index 000000000..2a58236eb --- /dev/null +++ b/src/internal/connector/exchange/mail_folder_cache_test.go @@ -0,0 +1,321 @@ +package exchange + +import ( + "context" + stdpath "path" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/alcionai/corso/internal/connector/graph" + "github.com/alcionai/corso/internal/path" + "github.com/alcionai/corso/internal/tester" +) + +const ( + // Need to use a hard-coded ID because GetAllFolderNamesForUser only gets + // top-level folders right now. + //nolint:lll + testFolderID = "AAMkAGZmNjNlYjI3LWJlZWYtNGI4Mi04YjMyLTIxYThkNGQ4NmY1MwAuAAAAAADCNgjhM9QmQYWNcI7hCpPrAQDSEBNbUIB9RL6ePDeF3FIYAABl7AqpAAA=" + + // Full folder path for the folder above. + expectedFolderPath = "toplevel/subFolder/subsubfolder" +) + +type mockContainer struct { + id *string + name *string + parentID *string +} + +//nolint:revive +func (m mockContainer) GetId() *string { + return m.id +} + +func (m mockContainer) GetDisplayName() *string { + return m.name +} + +//nolint:revive +func (m mockContainer) GetParentFolderId() *string { + return m.parentID +} + +type MailFolderCacheUnitSuite struct { + suite.Suite +} + +func TestMailFolderCacheUnitSuite(t *testing.T) { + suite.Run(t, new(MailFolderCacheUnitSuite)) +} + +func (suite *MailFolderCacheUnitSuite) TestCheckRequiredValues() { + id := uuid.NewString() + name := "foo" + parentID := uuid.NewString() + emptyString := "" + + table := []struct { + name string + c mockContainer + check assert.ErrorAssertionFunc + }{ + { + name: "NilID", + c: mockContainer{ + id: nil, + name: &name, + parentID: &parentID, + }, + check: assert.Error, + }, + { + name: "NilDisplayName", + c: mockContainer{ + id: &id, + name: nil, + parentID: &parentID, + }, + check: assert.Error, + }, + { + name: "NilParentFolderID", + c: mockContainer{ + id: &id, + name: &name, + parentID: nil, + }, + check: assert.Error, + }, + { + name: "EmptyID", + c: mockContainer{ + id: &emptyString, + name: &name, + parentID: &parentID, + }, + check: assert.Error, + }, + { + name: "EmptyDisplayName", + c: mockContainer{ + id: &id, + name: &emptyString, + parentID: &parentID, + }, + check: assert.Error, + }, + { + name: "EmptyParentFolderID", + c: mockContainer{ + id: &id, + name: &name, + parentID: &emptyString, + }, + check: assert.Error, + }, + { + name: "AllValues", + c: mockContainer{ + id: &id, + name: &name, + parentID: &parentID, + }, + check: assert.NoError, + }, + } + + for _, test := range table { + suite.T().Run(test.name, func(t *testing.T) { + test.check(t, checkRequiredValues(test.c)) + }) + } +} + +func newMockCachedContainer(name string) *mockCachedContainer { + return &mockCachedContainer{ + id: uuid.NewString(), + parentID: uuid.NewString(), + displayName: name, + } +} + +type mockCachedContainer struct { + id string + parentID string + displayName string + p *path.Builder + expectedPath string +} + +//nolint:revive +func (m mockCachedContainer) GetId() *string { + return &m.id +} + +//nolint:revive +func (m mockCachedContainer) GetParentFolderId() *string { + return &m.parentID +} + +func (m mockCachedContainer) GetDisplayName() *string { + return &m.displayName +} + +func (m mockCachedContainer) Path() *path.Builder { + return m.p +} + +func (m *mockCachedContainer) SetPath(newPath *path.Builder) { + m.p = newPath +} + +// TestConfiguredMailFolderCacheUnitSuite cannot run its tests in parallel. +type ConfiguredMailFolderCacheUnitSuite struct { + suite.Suite + + mc mailFolderCache + + allContainers []*mockCachedContainer +} + +func (suite *ConfiguredMailFolderCacheUnitSuite) SetupTest() { + suite.allContainers = []*mockCachedContainer{} + + for i := 0; i < 4; i++ { + suite.allContainers = append( + suite.allContainers, + newMockCachedContainer(strings.Repeat("sub", i)+"folder"), + ) + } + + // Base case for the recursive lookup. + suite.allContainers[0].p = path.Builder{}.Append(suite.allContainers[0].displayName) + suite.allContainers[0].expectedPath = suite.allContainers[0].displayName + + for i := 1; i < len(suite.allContainers); i++ { + suite.allContainers[i].parentID = suite.allContainers[i-1].id + suite.allContainers[i].expectedPath = stdpath.Join( + suite.allContainers[i-1].expectedPath, + suite.allContainers[i].displayName, + ) + } + + suite.mc = mailFolderCache{cache: map[string]cachedContainer{}} + + for _, c := range suite.allContainers { + suite.mc.cache[c.id] = c + } +} + +func TestConfiguredMailFolderCacheUnitSuite(t *testing.T) { + suite.Run(t, new(ConfiguredMailFolderCacheUnitSuite)) +} + +func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderNoPathsCached() { + ctx := context.Background() + + for _, c := range suite.allContainers { + suite.T().Run(*c.GetDisplayName(), func(t *testing.T) { + p, err := suite.mc.IDToPath(ctx, c.id) + require.NoError(t, err) + + assert.Equal(t, c.expectedPath, p.String()) + }) + } +} + +func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderCachesPaths() { + t := suite.T() + ctx := context.Background() + c := suite.allContainers[len(suite.allContainers)-1] + + p, err := suite.mc.IDToPath(ctx, c.id) + require.NoError(t, err) + + assert.Equal(t, c.expectedPath, p.String()) + + c.parentID = "foo" + + p, err = suite.mc.IDToPath(ctx, c.id) + require.NoError(t, err) + + assert.Equal(t, c.expectedPath, p.String()) +} + +func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderErrorsParentNotFound() { + t := suite.T() + ctx := context.Background() + last := suite.allContainers[len(suite.allContainers)-1] + almostLast := suite.allContainers[len(suite.allContainers)-2] + + delete(suite.mc.cache, almostLast.id) + + _, err := suite.mc.IDToPath(ctx, last.id) + assert.Error(t, err) +} + +func (suite *ConfiguredMailFolderCacheUnitSuite) TestLookupCachedFolderErrorsNotFound() { + t := suite.T() + ctx := context.Background() + + _, err := suite.mc.IDToPath(ctx, "foo") + assert.Error(t, err) +} + +type MailFolderCacheIntegrationSuite struct { + suite.Suite + gs graph.Service +} + +func (suite *MailFolderCacheIntegrationSuite) SetupSuite() { + t := suite.T() + + _, err := tester.GetRequiredEnvVars(tester.M365AcctCredEnvs...) + require.NoError(t, err) + + a := tester.NewM365Account(t) + require.NoError(t, err) + + m365, err := a.M365Config() + require.NoError(t, err) + + service, err := createService(m365, false) + require.NoError(t, err) + + suite.gs = service +} + +func TestMailFolderCacheIntegrationSuite(t *testing.T) { + if err := tester.RunOnAny( + tester.CorsoCITests, + tester.CorsoGraphConnectorTests, + ); err != nil { + t.Skip() + } + + suite.Run(t, new(MailFolderCacheIntegrationSuite)) +} + +func (suite *MailFolderCacheIntegrationSuite) TestDeltaFetch() { + ctx := context.Background() + t := suite.T() + userID := tester.M365UserID(t) + + mfc := mailFolderCache{ + userID: userID, + gs: suite.gs, + } + + require.NoError(t, mfc.Populate(ctx)) + + p, err := mfc.IDToPath(ctx, testFolderID) + require.NoError(t, err) + + assert.Equal(t, expectedFolderPath, p.String()) +} diff --git a/src/internal/connector/exchange/query_options.go b/src/internal/connector/exchange/query_options.go index 93177b29a..126618392 100644 --- a/src/internal/connector/exchange/query_options.go +++ b/src/internal/connector/exchange/query_options.go @@ -9,6 +9,7 @@ import ( mscontacts "github.com/microsoftgraph/msgraph-sdk-go/users/item/contacts" msevents "github.com/microsoftgraph/msgraph-sdk-go/users/item/events" msfolder "github.com/microsoftgraph/msgraph-sdk-go/users/item/mailfolders" + msfolderitem "github.com/microsoftgraph/msgraph-sdk-go/users/item/mailfolders/item" msmessage "github.com/microsoftgraph/msgraph-sdk-go/users/item/messages" msitem "github.com/microsoftgraph/msgraph-sdk-go/users/item/messages/item" "github.com/pkg/errors" @@ -224,6 +225,27 @@ func optionsForMailFolders(moreOps []string) (*msfolder.MailFoldersRequestBuilde return options, nil } +// optionsForMailFoldersItem transforms the options into a more dynamic call for MailFoldersById. +// moreOps is a []string of options(e.g. "displayName", "isHidden") +// Returns first call in MailFoldersById().GetWithRequestConfigurationAndResponseHandler(options, handler) +func optionsForMailFoldersItem( + moreOps []string, +) (*msfolderitem.MailFolderItemRequestBuilderGetRequestConfiguration, error) { + selecting, err := buildOptions(moreOps, folders) + if err != nil { + return nil, err + } + + requestParameters := &msfolderitem.MailFolderItemRequestBuilderGetQueryParameters{ + Select: selecting, + } + options := &msfolderitem.MailFolderItemRequestBuilderGetRequestConfiguration{ + QueryParameters: requestParameters, + } + + return options, nil +} + // optionsForEvents ensures valid option inputs for exchange.Events // @return is first call in Events().GetWithRequestConfigurationAndResponseHandler(options, handler) func optionsForEvents(moreOps []string) (*msevents.EventsRequestBuilderGetRequestConfiguration, error) {