diff --git a/src/internal/connector/discovery/discovery.go b/src/internal/connector/discovery/discovery.go index 82a9b916b..069eeec9f 100644 --- a/src/internal/connector/discovery/discovery.go +++ b/src/internal/connector/discovery/discovery.go @@ -69,6 +69,22 @@ func Users( return users, nil } +// UserDetails fetches detailed info like - userPurpose for all users in the tenant. +func GetUserInfo( + ctx context.Context, + acct account.Account, + userID string, + errs *fault.Bus, +) (*api.UserInfo, error) { + client, err := apiClient(ctx, acct) + if err != nil { + return nil, err + } + + return client.Users().GetInfo(ctx, userID) +} + +// User fetches a single user's data. func User( ctx context.Context, gwi getWithInfoer, diff --git a/src/internal/connector/discovery/discovery_test.go b/src/internal/connector/discovery/discovery_test.go index dd9971b08..4c80ba2c6 100644 --- a/src/internal/connector/discovery/discovery_test.go +++ b/src/internal/connector/discovery/discovery_test.go @@ -197,14 +197,23 @@ func (suite *DiscoveryIntegrationSuite) TestUserInfo() { path.ExchangeService: {}, path.OneDriveService: {}, }, + HasMailBox: true, + HasOneDrive: true, + Mailbox: api.MailboxInfo{ + Purpose: "user", + ErrGetMailBoxSetting: nil, + }, }, }, { name: "user does not exist", user: uuid.NewString(), expect: &api.UserInfo{ - DiscoveredServices: map[path.ServiceType]struct{}{ - path.OneDriveService: {}, // currently statically populated + DiscoveredServices: map[path.ServiceType]struct{}{}, + HasMailBox: false, + HasOneDrive: false, + Mailbox: api.MailboxInfo{ + ErrGetMailBoxSetting: api.ErrMailBoxSettingsNotFound, }, }, }, @@ -218,7 +227,66 @@ func (suite *DiscoveryIntegrationSuite) TestUserInfo() { result, err := discovery.UserInfo(ctx, uapi, test.user) require.NoError(t, err, clues.ToCore(err)) - assert.Equal(t, test.expect, result) + assert.Equal(t, test.expect.HasMailBox, result.HasMailBox) + assert.Equal(t, test.expect.HasOneDrive, result.HasOneDrive) + assert.Equal(t, test.expect.DiscoveredServices, result.DiscoveredServices) + }) + } +} + +func (suite *DiscoveryIntegrationSuite) TestUserWithoutDrive() { + t := suite.T() + acct := tester.NewM365Account(t) + userID := tester.M365UserID(t) + + table := []struct { + name string + user string + expect *api.UserInfo + }{ + { + name: "user without drive and exchange", + user: "a53c26f7-5100-4acb-a910-4d20960b2c19", // User: testevents@10rqc2.onmicrosoft.com + expect: &api.UserInfo{ + DiscoveredServices: map[path.ServiceType]struct{}{}, + HasOneDrive: false, + HasMailBox: false, + Mailbox: api.MailboxInfo{ + ErrGetMailBoxSetting: api.ErrMailBoxSettingsNotFound, + }, + }, + }, + { + name: "user with drive and exchange", + user: userID, + expect: &api.UserInfo{ + DiscoveredServices: map[path.ServiceType]struct{}{ + path.ExchangeService: {}, + path.OneDriveService: {}, + }, + HasOneDrive: true, + HasMailBox: true, + Mailbox: api.MailboxInfo{ + Purpose: "user", + ErrGetMailBoxSetting: nil, + }, + }, + }, + } + for _, test := range table { + suite.Run(test.name, func() { + ctx, flush := tester.NewContext() + defer flush() + + t := suite.T() + + result, err := discovery.GetUserInfo(ctx, acct, test.user, fault.New(true)) + require.NoError(t, err, clues.ToCore(err)) + assert.Equal(t, test.expect.DiscoveredServices, result.DiscoveredServices) + assert.Equal(t, test.expect.HasOneDrive, result.HasOneDrive) + assert.Equal(t, test.expect.HasMailBox, result.HasMailBox) + assert.Equal(t, test.expect.Mailbox.ErrGetMailBoxSetting, result.Mailbox.ErrGetMailBoxSetting) + assert.Equal(t, test.expect.Mailbox.Purpose, result.Mailbox.Purpose) }) } } diff --git a/src/internal/connector/graph/errors.go b/src/internal/connector/graph/errors.go index f3f47da4b..70348762d 100644 --- a/src/internal/connector/graph/errors.go +++ b/src/internal/connector/graph/errors.go @@ -38,6 +38,7 @@ const ( errCodeResourceNotFound = "ResourceNotFound" errCodeRequestResourceNotFound = "Request_ResourceNotFound" errCodeMailboxNotEnabledForRESTAPI = "MailboxNotEnabledForRESTAPI" + errCodeErrorAccessDenied = "ErrorAccessDenied" ) const ( @@ -106,6 +107,10 @@ func IsErrUserNotFound(err error) bool { return hasErrorCode(err, errCodeRequestResourceNotFound) } +func IsErrAccessDenied(err error) bool { + return hasErrorCode(err, errCodeErrorAccessDenied) +} + func IsErrTimeout(err error) bool { switch err := err.(type) { case *url.Error: diff --git a/src/pkg/services/m365/api/users.go b/src/pkg/services/m365/api/users.go index 9fa76421f..b2916f4fd 100644 --- a/src/pkg/services/m365/api/users.go +++ b/src/pkg/services/m365/api/users.go @@ -13,9 +13,15 @@ import ( "github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/connector/graph" "github.com/alcionai/corso/src/pkg/fault" + "github.com/alcionai/corso/src/pkg/logger" "github.com/alcionai/corso/src/pkg/path" ) +// Variables +var ( + ErrMailBoxSettingsNotFound = clues.New("mailbox settings not found") +) + // --------------------------------------------------------------------------- // controller // --------------------------------------------------------------------------- @@ -35,6 +41,50 @@ type Users struct { type UserInfo struct { DiscoveredServices map[path.ServiceType]struct{} + HasMailBox bool + HasOneDrive bool + Mailbox MailboxInfo +} + +type MailboxInfo struct { + Purpose string + ArchiveFolder string + DateFormat string + TimeFormat string + DelegateMeetMsgDeliveryOpt string + Timezone string + AutomaticRepliesSetting AutomaticRepliesSettings + Language Language + WorkingHours WorkingHours + ErrGetMailBoxSetting error +} + +type AutomaticRepliesSettings struct { + ExternalAudience string + ExternalReplyMessage string + InternalReplyMessage string + ScheduledEndDateTime timeInfo + ScheduledStartDateTime timeInfo + Status string +} + +type timeInfo struct { + DateTime string + Timezone string +} + +type Language struct { + Locale string + DisplayName string +} + +type WorkingHours struct { + DaysOfWeek []string + StartTime string + EndTime string + TimeZone struct { + Name string + } } func newUserInfo() *UserInfo { @@ -193,19 +243,192 @@ func (c Users) GetInfo(ctx context.Context, userID string) (*UserInfo, error) { } ) - // TODO: OneDrive - _, err = c.stable.Client().UsersById(userID).MailFolders().Get(ctx, &options) + userInfo.HasMailBox = true + + err = c.GetExchange(ctx, userID, options) if err != nil { if !graph.IsErrExchangeMailFolderNotFound(err) { + logger.Ctx(ctx).Errorf("err getting user's mail folder: %s", err) + return nil, graph.Wrap(ctx, err, "getting user's mail folder") } + logger.Ctx(ctx).Infof("resource owner does not have a mailbox enabled") delete(userInfo.DiscoveredServices, path.ExchangeService) + + userInfo.HasMailBox = false + } + + userInfo.HasOneDrive = true + + err = c.GetOnedrive(ctx, userID) + if err != nil { + err = graph.Stack(ctx, err) + + if !clues.HasLabel(err, graph.LabelsMysiteNotFound) { + logger.Ctx(ctx).Errorf("err getting user's onedrive's data: %s", err) + + return nil, graph.Wrap(ctx, err, "getting user's onedrive's data") + } + + logger.Ctx(ctx).Infof("resource owner does not have a drive") + + delete(userInfo.DiscoveredServices, path.OneDriveService) + userInfo.HasOneDrive = false + } + + err = c.getAdditionalData(ctx, userID, &userInfo.Mailbox) + if err != nil { + return nil, err } return userInfo, nil } +// verify mailbox enabled for user +func (c Users) GetExchange( + ctx context.Context, + userID string, + options users.ItemMailFoldersRequestBuilderGetRequestConfiguration, +) error { + _, err := c.stable.Client().UsersById(userID).MailFolders().Get(ctx, &options) + if err != nil { + return err + } + + return nil +} + +// verify onedrive enabled for user +func (c Users) GetOnedrive(ctx context.Context, userID string) error { + _, err := c.stable.Client().UsersById(userID).Drives().Get(ctx, nil) + if err != nil { + return err + } + + return nil +} + +func (c Users) getAdditionalData(ctx context.Context, userID string, mailbox *MailboxInfo) error { + var ( + rawURL = fmt.Sprintf("https://graph.microsoft.com/v1.0/users/%s/mailboxSettings", userID) + adapter = c.stable.Adapter() + mailBoundErr clues.Err + ) + + settings, err := users.NewUserItemRequestBuilder(rawURL, adapter).Get(ctx, nil) + if err != nil && !(graph.IsErrAccessDenied(err) || graph.IsErrExchangeMailFolderNotFound(err)) { + logger.CtxErr(ctx, err).Error("getting mailbox settings") + + return graph.Wrap(ctx, err, "getting additional data") + } + + if graph.IsErrAccessDenied(err) { + logger.Ctx(ctx).Info("err getting additional data: access denied") + + mailbox.ErrGetMailBoxSetting = clues.New("access denied") + + return nil + } + + if graph.IsErrExchangeMailFolderNotFound(err) { + logger.Ctx(ctx).Info("err exchange mail folder not found") + + mailbox.ErrGetMailBoxSetting = ErrMailBoxSettingsNotFound + + return nil + } + + additionalData := settings.GetAdditionalData() + + mailbox.ArchiveFolder = toString(ctx, additionalData["archiveFolder"], &mailBoundErr) + mailbox.Timezone = toString(ctx, additionalData["timeZone"], &mailBoundErr) + mailbox.DateFormat = toString(ctx, additionalData["dateFormat"], &mailBoundErr) + mailbox.TimeFormat = toString(ctx, additionalData["timeFormat"], &mailBoundErr) + mailbox.Purpose = toString(ctx, additionalData["userPurpose"], &mailBoundErr) + mailbox.DelegateMeetMsgDeliveryOpt = toString( + ctx, + additionalData["delegateMeetingMessageDeliveryOptions"], + &mailBoundErr) + + // decode automatic replies settings + replySetting := toMap(ctx, additionalData["automaticRepliesSetting"], &mailBoundErr) + mailbox.AutomaticRepliesSetting.Status = toString( + ctx, + replySetting["status"], + &mailBoundErr) + mailbox.AutomaticRepliesSetting.ExternalAudience = toString( + ctx, + replySetting["externalAudience"], + &mailBoundErr) + mailbox.AutomaticRepliesSetting.ExternalReplyMessage = toString( + ctx, + replySetting["externalReplyMessage"], + &mailBoundErr) + mailbox.AutomaticRepliesSetting.InternalReplyMessage = toString( + ctx, + replySetting["internalReplyMessage"], + &mailBoundErr) + + // decode scheduledStartDateTime + startDateTime := toMap(ctx, replySetting["scheduledStartDateTime"], &mailBoundErr) + mailbox.AutomaticRepliesSetting.ScheduledStartDateTime.DateTime = toString( + ctx, + startDateTime["dateTime"], + &mailBoundErr) + mailbox.AutomaticRepliesSetting.ScheduledStartDateTime.Timezone = toString( + ctx, + startDateTime["timeZone"], + &mailBoundErr) + + endDateTime := toMap(ctx, replySetting["scheduledEndDateTime"], &mailBoundErr) + mailbox.AutomaticRepliesSetting.ScheduledEndDateTime.DateTime = toString( + ctx, + endDateTime["dateTime"], + &mailBoundErr) + mailbox.AutomaticRepliesSetting.ScheduledEndDateTime.Timezone = toString( + ctx, + endDateTime["timeZone"], + &mailBoundErr) + + // Language decode + language := toMap(ctx, additionalData["language"], &mailBoundErr) + mailbox.Language.DisplayName = toString( + ctx, + language["displayName"], + &mailBoundErr) + mailbox.Language.Locale = toString(ctx, language["locale"], &mailBoundErr) + + // working hours + workingHours := toMap(ctx, additionalData["workingHours"], &mailBoundErr) + mailbox.WorkingHours.StartTime = toString( + ctx, + workingHours["startTime"], + &mailBoundErr) + mailbox.WorkingHours.EndTime = toString( + ctx, + workingHours["endTime"], + &mailBoundErr) + + timeZone := toMap(ctx, workingHours["timeZone"], &mailBoundErr) + mailbox.WorkingHours.TimeZone.Name = toString( + ctx, + timeZone["name"], + &mailBoundErr) + + days := toArray(ctx, workingHours["daysOfWeek"], &mailBoundErr) + for _, day := range days { + mailbox.WorkingHours.DaysOfWeek = append(mailbox.WorkingHours.DaysOfWeek, + toString(ctx, day, &mailBoundErr)) + } + + if mailBoundErr.Core().Msg != "" { + mailbox.ErrGetMailBoxSetting = &mailBoundErr + } + + return nil +} + // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- @@ -229,3 +452,51 @@ func validateUser(item any) (models.Userable, error) { return m, nil } + +func toString(ctx context.Context, data any, mailBoxErr *clues.Err) string { + dataPointer, ok := data.(*string) + if !ok { + logger.Ctx(ctx).Info("error getting data from mailboxSettings") + + *mailBoxErr = *ErrMailBoxSettingsNotFound + + return "" + } + + value, ok := ptr.ValOK(dataPointer) + if !ok { + logger.Ctx(ctx).Info("error getting value from pointer for mailboxSettings") + + *mailBoxErr = *ErrMailBoxSettingsNotFound + + return "" + } + + return value +} + +func toMap(ctx context.Context, data any, mailBoxErr *clues.Err) map[string]interface{} { + value, ok := data.(map[string]interface{}) + if !ok { + logger.Ctx(ctx).Info("error getting mailboxSettings") + + *mailBoxErr = *clues.New("mailbox settings not found") + + return value + } + + return value +} + +func toArray(ctx context.Context, data any, mailBoxErr *clues.Err) []interface{} { + value, ok := data.([]interface{}) + if !ok { + logger.Ctx(ctx).Info("error getting mailboxSettings") + + *mailBoxErr = *clues.New("mailbox settings not found") + + return value + } + + return value +} diff --git a/src/pkg/services/m365/m365.go b/src/pkg/services/m365/m365.go index 97f724f76..b3db55d13 100644 --- a/src/pkg/services/m365/m365.go +++ b/src/pkg/services/m365/m365.go @@ -32,6 +32,7 @@ type User struct { PrincipalName string ID string Name string + Info api.UserInfo } type UserInfo struct { @@ -72,6 +73,13 @@ func Users(ctx context.Context, acct account.Account, errs *fault.Bus) ([]*User, return nil, clues.Wrap(err, "formatting user data") } + userInfo, err := discovery.GetUserInfo(ctx, acct, pu.ID, errs) + if err != nil { + return nil, clues.Wrap(err, "getting user details") + } + + pu.Info = *userInfo + ret = append(ret, pu) } diff --git a/src/pkg/services/m365/m365_test.go b/src/pkg/services/m365/m365_test.go index b62b52206..94bdfdd34 100644 --- a/src/pkg/services/m365/m365_test.go +++ b/src/pkg/services/m365/m365_test.go @@ -46,6 +46,7 @@ func (suite *M365IntegrationSuite) TestUsers() { assert.NotEmpty(t, u.ID) assert.NotEmpty(t, u.PrincipalName) assert.NotEmpty(t, u.Name) + assert.NotEmpty(t, u.Info) }) } }