diff --git a/src/internal/connector/exchange/exchange_service_test.go b/src/internal/connector/exchange/exchange_service_test.go index 2c138638b..01d545d12 100644 --- a/src/internal/connector/exchange/exchange_service_test.go +++ b/src/internal/connector/exchange/exchange_service_test.go @@ -238,6 +238,14 @@ func (suite *ExchangeServiceSuite) TestGraphQueryFunctions() { name: "GraphQuery: Get All Users", function: GetAllUsersForTenant, }, + { + name: "GraphQuery: Get All ContactFolders", + function: GetAllContactFolderNamesForUser, + }, + { + name: "GraphQuery: Get All Events for User", + function: GetAllEventsForUser, + }, } for _, test := range tests { suite.T().Run(test.name, func(t *testing.T) { @@ -291,6 +299,54 @@ func (suite *ExchangeServiceSuite) TestParseCalendarIDFromEvent() { } } +// TestGetMailFolderID verifies the ability to retrieve folder ID of folders +// at the top level of the file tree +func (suite *ExchangeServiceSuite) TestGetFolderID() { + userID := tester.M365UserID(suite.T()) + tests := []struct { + name string + folderName string + // category references the current optionId :: TODO --> use selector fields + category optionIdentifier + checkError assert.ErrorAssertionFunc + }{ + { + name: "Mail Valid", + folderName: "Inbox", + category: messages, + checkError: assert.NoError, + }, + { + name: "Mail Invalid", + folderName: "FolderThatIsNotHere", + category: messages, + checkError: assert.Error, + }, + { + name: "Contact Invalid", + folderName: "FolderThatIsNotHereContacts", + category: contacts, + checkError: assert.Error, + }, + { + name: "Contact Valid", + folderName: "TrialFolder", + category: contacts, + checkError: assert.NoError, + }, + } + for _, test := range tests { + suite.T().Run(test.name, func(t *testing.T) { + _, err := GetFolderID( + suite.es, + test.folderName, + userID, + test.category) + test.checkError(t, err, "Unable to find folder: "+test.folderName) + }) + } +} + // TestIterativeFunctions verifies that GraphQuery to Iterate // functions are valid for current versioning of msgraph-go-sdk func (suite *ExchangeServiceSuite) TestIterativeFunctions() { diff --git a/src/internal/connector/exchange/optionidentifier_string.go b/src/internal/connector/exchange/optionidentifier_string.go index 88d1c7ac3..e549bd0ec 100644 --- a/src/internal/connector/exchange/optionidentifier_string.go +++ b/src/internal/connector/exchange/optionidentifier_string.go @@ -13,11 +13,12 @@ func _() { _ = x[events-2] _ = x[messages-3] _ = x[users-4] + _ = x[contacts-5] } -const _optionIdentifier_name = "unknownfolderseventsmessagesusers" +const _optionIdentifier_name = "unknownfolderseventsmessagesuserscontacts" -var _optionIdentifier_index = [...]uint8{0, 7, 14, 20, 28, 33} +var _optionIdentifier_index = [...]uint8{0, 7, 14, 20, 28, 33, 41} func (i optionIdentifier) String() string { if i < 0 || i >= optionIdentifier(len(_optionIdentifier_index)-1) { diff --git a/src/internal/connector/exchange/query_options.go b/src/internal/connector/exchange/query_options.go index af8233eca..306c99bb6 100644 --- a/src/internal/connector/exchange/query_options.go +++ b/src/internal/connector/exchange/query_options.go @@ -2,6 +2,7 @@ package exchange import ( msuser "github.com/microsoftgraph/msgraph-sdk-go/users" + mscontactfolder "github.com/microsoftgraph/msgraph-sdk-go/users/item/contactfolders" 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" @@ -84,6 +85,25 @@ const ( contacts ) +const ( + mailCategory = "mail" + contactsCategory = "contacts" + eventsCategory = "events" +) + +func categoryToOptionIdentifier(category string) optionIdentifier { + switch category { + case mailCategory: + return messages + case contactsCategory: + return contacts + case eventsCategory: + return events + default: + return unknown + } +} + //--------------------------------------------------- // exchange.Query Option Section //------------------------------------------------ @@ -122,6 +142,23 @@ func OptionsForSingleMessage(moreOps []string) (*msitem.MessageItemRequestBuilde return options, nil } +func optionsForContactFolders(moreOps []string) ( + *mscontactfolder.ContactFoldersRequestBuilderGetRequestConfiguration, + error, +) { + selecting, err := buildOptions(moreOps, folders) + if err != nil { + return nil, err + } + requestParameters := &mscontactfolder.ContactFoldersRequestBuilderGetQueryParameters{ + Select: selecting, + } + options := &mscontactfolder.ContactFoldersRequestBuilderGetRequestConfiguration{ + QueryParameters: requestParameters, + } + return options, nil +} + // optionsForMailFolders transforms the options into a more dynamic call for MailFolders. // @param moreOps is a []string of options(e.g. "displayName", "isHidden") // @return is first call in MailFolders().GetWithRequestConfigurationAndResponseHandler(options, handler) diff --git a/src/internal/connector/exchange/service_functions.go b/src/internal/connector/exchange/service_functions.go index 51fd9b986..4419578c6 100644 --- a/src/internal/connector/exchange/service_functions.go +++ b/src/internal/connector/exchange/service_functions.go @@ -87,6 +87,21 @@ type MailFolder struct { DisplayName string } +// CreateContactFolder makes a contact folder with the displayName of folderName. +// If successful, returns the created folder object. +func CreateContactFolder(gs graph.Service, user, folderName string) (models.ContactFolderable, error) { + requestBody := models.NewContactFolder() + requestBody.SetDisplayName(&folderName) + + return gs.Client().UsersById(user).ContactFolders().Post(requestBody) +} + +// DeleteContactFolder deletes the ContactFolder associated with the M365 ID if permissions are valid. +// Errors returned if the function call was not successful. +func DeleteContactFolder(gs graph.Service, user, folderID string) error { + return gs.Client().UsersById(user).ContactFoldersById(folderID).Delete() +} + // GetAllMailFolders retrieves all mail folders for the specified user. // If nameContains is populated, only returns mail matching that property. // Returns a slice of {ID, DisplayName} tuples. @@ -131,14 +146,29 @@ func GetAllMailFolders(gs graph.Service, user, nameContains string) ([]MailFolde return mfs, err } -// GetMailFolderID query function to retrieve the M365 ID based on the folder's displayName. +// GetFolderID query function to retrieve the M365 ID based on the folder's displayName. // @param folderName the target folder's display name. Case sensitive +// @param category switches query and iteration to support multiple exchange applications // @returns a *string if the folder exists. If the folder does not exist returns nil, error-> folder not found -func GetMailFolderID(service graph.Service, folderName, user string) (*string, error) { - var errs error - var folderID *string +func GetFolderID(service graph.Service, folderName, user string, category optionIdentifier) (*string, error) { + var ( + errs error + folderID *string + query GraphQuery + transform absser.ParsableFactory + ) + switch category { + case messages: + query = GetAllFolderNamesForUser + transform = models.CreateMailFolderCollectionResponseFromDiscriminatorValue + case contacts: + query = GetAllContactFolderNamesForUser + transform = models.CreateContactFolderFromDiscriminatorValue + default: + return nil, fmt.Errorf("unsupported category %s for GetFolderID()", category) + } - response, err := GetAllFolderNamesForUser(service, user) + response, err := query(service, user) if err != nil { return nil, errors.Wrapf( err, @@ -146,31 +176,26 @@ func GetMailFolderID(service graph.Service, folderName, user string) (*string, e user, support.ConnectorStackErrorTrace(err), ) } - pageIterator, err := msgraphgocore.NewPageIterator( response, service.Adapter(), - models.CreateMailFolderCollectionResponseFromDiscriminatorValue, + transform, ) if err != nil { return nil, err } - callbackFunc := func(folderItem any) bool { - folder, ok := folderItem.(models.MailFolderable) - if !ok { - errs = support.WrapAndAppend(service.Adapter().GetBaseUrl(), errors.New("HasFolder() iteration failure"), errs) - return true - } - if *folder.GetDisplayName() == folderName { - folderID = folder.GetId() - return false - } - return true + callbackFunc := iterateFindFolderID(category, + &folderID, + folderName, + service.Adapter().GetBaseUrl(), + errs, + ) + + if err := pageIterator.Iterate(callbackFunc); err != nil { + return nil, support.WrapAndAppend(service.Adapter().GetBaseUrl(), err, errs) } - iterateError := pageIterator.Iterate(callbackFunc) - if iterateError != nil { - errs = support.WrapAndAppend(service.Adapter().GetBaseUrl(), iterateError, errs) - } else if folderID == nil { + + if folderID == nil { return nil, ErrFolderNotFound } @@ -237,25 +262,52 @@ func SetupExchangeCollectionVars(scope selectors.ExchangeScope) ( return nil, nil, nil, errors.New("exchange scope option not supported") } -// GetCopyRestoreFolder utility function to create an unique folder for the restore process -func GetCopyRestoreFolder(service graph.Service, user string) (*string, error) { +// GetCopyRestoreFolder utility function to create +// an unique folder for the restore process +// @param category: input from fullPath()[2] +// that defines the application the folder is created in. +func GetCopyRestoreFolder( + service graph.Service, + user, category string, +) (string, error) { newFolder := fmt.Sprintf("Corso_Restore_%s", common.FormatNow(common.SimpleDateTimeFormat)) - isFolder, err := GetMailFolderID(service, newFolder, user) - if err != nil { - // Verify unique folder was not found - if errors.Is(err, ErrFolderNotFound) { - - fold, err := CreateMailFolder(service, user, newFolder) - if err != nil { - return nil, support.WrapAndAppend(user, err, err) - } - return fold.GetId(), nil - } - - return nil, err + switch category { + case mailCategory, contactsCategory: + return establishFolder(service, newFolder, user, categoryToOptionIdentifier(category)) + default: + return "", fmt.Errorf("%s category not supported", category) } +} - return isFolder, nil +func establishFolder( + service graph.Service, + folderName, user string, + optID optionIdentifier, +) (string, error) { + folderID, err := GetFolderID(service, folderName, user, optID) + if err == nil { + return *folderID, nil + } + // Experienced error other than folder does not exist + if !errors.Is(err, ErrFolderNotFound) { + return "", support.WrapAndAppend(user, err, err) + } + switch optID { + case messages: + fold, err := CreateMailFolder(service, user, folderName) + if err != nil { + return "", support.WrapAndAppend(user, err, err) + } + return *fold.GetId(), nil + case contacts: + fold, err := CreateContactFolder(service, user, folderName) + if err != nil { + return "", support.WrapAndAppend(user, err, err) + } + return *fold.GetId(), nil + default: + return "", fmt.Errorf("category: %s not supported for folder creation", optID) + } } func RestoreExchangeObject( @@ -268,10 +320,8 @@ func RestoreExchangeObject( ) error { var setting optionIdentifier switch category { - case mailCategory: - setting = messages - case contactsCategory: - setting = contacts + case mailCategory, contactsCategory: + setting = categoryToOptionIdentifier(category) default: return fmt.Errorf("type: %s not supported for exchange restore", category) } diff --git a/src/internal/connector/exchange/service_iterators.go b/src/internal/connector/exchange/service_iterators.go index 596242310..aa7953217 100644 --- a/src/internal/connector/exchange/service_iterators.go +++ b/src/internal/connector/exchange/service_iterators.go @@ -12,12 +12,6 @@ import ( "github.com/alcionai/corso/pkg/selectors" ) -const ( - mailCategory = "mail" - contactsCategory = "contacts" - eventsCategory = "events" -) - // descendable represents objects that implement msgraph-sdk-go/models.entityable // and have the concept of a "parent folder". type descendable interface { @@ -280,3 +274,45 @@ func IterateFilterFolderDirectoriesForCollections( return true } } + +// iterateFindFolderID is a utility function that supports finding +// M365 folders objects that matches the folderName. Iterator callback function +// will work on folderCollection responses whose objects implement +// the displayable interface. If folder exists, the function updates the +// folderID memory address that was passed in. +func iterateFindFolderID( + category optionIdentifier, + folderID **string, + folderName, errorIdentifier string, + errs error, +) func(any) bool { + return func(entry any) bool { + switch category { + case messages, contacts: + folder, ok := entry.(displayable) + if !ok { + errs = support.WrapAndAppend( + errorIdentifier, + errors.New("struct does not implement displayable"), + errs, + ) + return true + } + // Display name not set on folder + if folder.GetDisplayName() == nil { + return true + } + name := *folder.GetDisplayName() + if folderName == name { + if folder.GetId() == nil { + return true // invalid folder + } + *folderID = folder.GetId() + return false + } + return true + default: + return false + } + } +} diff --git a/src/internal/connector/exchange/service_query.go b/src/internal/connector/exchange/service_query.go index 1f27dacdb..80ddc660a 100644 --- a/src/internal/connector/exchange/service_query.go +++ b/src/internal/connector/exchange/service_query.go @@ -34,7 +34,7 @@ func GetAllMessagesForUser(gs graph.Service, user string) (absser.Parsable, erro // GetAllContactsForUser is a GraphQuery function for querying all the contacts in a user's account func GetAllContactsForUser(gs graph.Service, user string) (absser.Parsable, error) { - selecting := []string{"id", "parentFolderId"} + selecting := []string{"parentFolderId"} options, err := optionsForContacts(selecting) if err != nil { return nil, err @@ -46,7 +46,7 @@ func GetAllContactsForUser(gs graph.Service, user string) (absser.Parsable, erro // GetAllFolderDisplayNamesForUser is a GraphQuery function for getting FolderId and display // names for Mail Folder. All other information for the MailFolder object is omitted. func GetAllFolderNamesForUser(gs graph.Service, user string) (absser.Parsable, error) { - options, err := optionsForMailFolders([]string{"id", "displayName"}) + options, err := optionsForMailFolders([]string{"displayName"}) if err != nil { return nil, err } @@ -54,6 +54,17 @@ func GetAllFolderNamesForUser(gs graph.Service, user string) (absser.Parsable, e return gs.Client().UsersById(user).MailFolders().GetWithRequestConfigurationAndResponseHandler(options, nil) } +// GetAllContactFolderNamesForUser is a GraphQuery function for getting ContactFolderId +// and display names for contacts. All other information is omitted. +// Does not return the primary Contact Folder +func GetAllContactFolderNamesForUser(gs graph.Service, user string) (absser.Parsable, error) { + options, err := optionsForContactFolders([]string{"displayName"}) + if err != nil { + return nil, err + } + return gs.Client().UsersById(user).ContactFolders().GetWithRequestConfigurationAndResponseHandler(options, nil) +} + // GetAllUsersForTenant is a GraphQuery for retrieving all the UserCollectionResponse with // that contains the UserID and email for each user. All other information is omitted func GetAllUsersForTenant(gs graph.Service, user string) (absser.Parsable, error) { diff --git a/src/internal/connector/graph_connector.go b/src/internal/connector/graph_connector.go index c1ec5ae35..e5e2b4e56 100644 --- a/src/internal/connector/graph_connector.go +++ b/src/internal/connector/graph_connector.go @@ -229,7 +229,7 @@ func (gc *GraphConnector) RestoreMessages(ctx context.Context, dcs []data.Collec pathCounter = map[string]bool{} attempts, successes int errs error - folderID *string + folderID string ) policy := control.Copy // TODO policy to be updated from external source after completion of refactoring @@ -237,10 +237,11 @@ func (gc *GraphConnector) RestoreMessages(ctx context.Context, dcs []data.Collec directory := strings.Join(dc.FullPath(), "") user := dc.FullPath()[1] items := dc.Items() + category := dc.FullPath()[2] if _, ok := pathCounter[directory]; !ok { pathCounter[directory] = true if policy == control.Copy { - folderID, errs = exchange.GetCopyRestoreFolder(&gc.graphService, user) + folderID, errs = exchange.GetCopyRestoreFolder(&gc.graphService, user, category) if errs != nil { return errs } @@ -265,7 +266,7 @@ func (gc *GraphConnector) RestoreMessages(ctx context.Context, dcs []data.Collec continue } category := dc.FullPath()[2] - err = exchange.RestoreExchangeObject(ctx, buf.Bytes(), category, policy, &gc.graphService, *folderID, user) + err = exchange.RestoreExchangeObject(ctx, buf.Bytes(), category, policy, &gc.graphService, folderID, user) if err != nil { errs = support.WrapAndAppend(itemData.UUID(), err, errs) diff --git a/src/internal/connector/graph_connector_test.go b/src/internal/connector/graph_connector_test.go index 975d3e012..2d1eea276 100644 --- a/src/internal/connector/graph_connector_test.go +++ b/src/internal/connector/graph_connector_test.go @@ -275,26 +275,28 @@ func (suite *GraphConnectorIntegrationSuite) TestAccessOfInboxAllUsers() { // Exchange Functions //------------------------------------------------------- -// TestCreateAndDeleteFolder ensures GraphConnector has the ability +// TestCreateAndDeleteMailFolder ensures GraphConnector has the ability // to create and remove folders within the tenant -func (suite *GraphConnectorIntegrationSuite) TestCreateAndDeleteFolder() { - userID := tester.M365UserID(suite.T()) +func (suite *GraphConnectorIntegrationSuite) TestCreateAndDeleteMailFolder() { now := time.Now() folderName := "TestFolder: " + common.FormatSimpleDateTime(now) - aFolder, err := exchange.CreateMailFolder(&suite.connector.graphService, userID, folderName) + aFolder, err := exchange.CreateMailFolder(&suite.connector.graphService, suite.user, folderName) assert.NoError(suite.T(), err, support.ConnectorStackErrorTrace(err)) if aFolder != nil { - err = exchange.DeleteMailFolder(suite.connector.Service(), userID, *aFolder.GetId()) + err = exchange.DeleteMailFolder(suite.connector.Service(), suite.user, *aFolder.GetId()) assert.NoError(suite.T(), err) } } -// TestGetMailFolderID verifies the ability to retrieve folder ID of folders -// at the top level of the file tree -func (suite *GraphConnectorIntegrationSuite) TestGetMailFolderID() { - userID := tester.M365UserID(suite.T()) - folderName := "Inbox" - folderID, err := exchange.GetMailFolderID(&suite.connector.graphService, folderName, userID) +// TestCreateAndDeleteContactFolder ensures GraphConnector has the ability +// to create and remove contact folders within the tenant +func (suite *GraphConnectorIntegrationSuite) TestCreateAndDeleteContactFolder() { + now := time.Now() + folderName := "TestContactFolder: " + common.FormatSimpleDateTime(now) + aFolder, err := exchange.CreateContactFolder(suite.connector.Service(), suite.user, folderName) assert.NoError(suite.T(), err) - assert.NotNil(suite.T(), folderID) + if aFolder != nil { + err = exchange.DeleteContactFolder(suite.connector.Service(), suite.user, *aFolder.GetId()) + assert.NoError(suite.T(), err) + } }