#### Does this PR need a docs update or release note? - [x] ⛔ No #### Type of change - [x] 🐛 Bugfix #### Test Plan - [x] ⚡ Unit test - [x] 💚 E2E
504 lines
16 KiB
Go
504 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/alcionai/clues"
|
|
msgraphgocore "github.com/microsoftgraph/msgraph-sdk-go-core"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/models"
|
|
"github.com/microsoftgraph/msgraph-sdk-go/users"
|
|
|
|
"github.com/alcionai/corso/src/internal/common/idname"
|
|
"github.com/alcionai/corso/src/internal/common/ptr"
|
|
"github.com/alcionai/corso/src/internal/common/str"
|
|
"github.com/alcionai/corso/src/internal/common/tform"
|
|
"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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func (c Client) Users() Users {
|
|
return Users{c}
|
|
}
|
|
|
|
// Users is an interface-compliant provider of the client.
|
|
type Users struct {
|
|
Client
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// structs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type UserInfo struct {
|
|
ServicesEnabled map[path.ServiceType]struct{}
|
|
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
|
|
QuotaExceeded bool
|
|
}
|
|
|
|
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 {
|
|
return &UserInfo{
|
|
ServicesEnabled: map[path.ServiceType]struct{}{
|
|
path.ExchangeService: {},
|
|
path.OneDriveService: {},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ServiceEnabled returns true if the UserInfo has an entry for the
|
|
// service. If no entry exists, the service is assumed to not be enabled.
|
|
func (ui *UserInfo) ServiceEnabled(service path.ServiceType) bool {
|
|
if ui == nil || len(ui.ServicesEnabled) == 0 {
|
|
return false
|
|
}
|
|
|
|
_, ok := ui.ServicesEnabled[service]
|
|
|
|
return ok
|
|
}
|
|
|
|
// Returns if we can run delta queries on a mailbox. We cannot run
|
|
// them if the mailbox is full which is indicated by QuotaExceeded.
|
|
func (ui *UserInfo) CanMakeDeltaQueries() bool {
|
|
return !ui.Mailbox.QuotaExceeded
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// methods
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Filter out both guest users, and (for on-prem installations) non-synced users.
|
|
// The latter filter makes an assumption that no on-prem users are guests; this might
|
|
// require more fine-tuned controls in the future.
|
|
// https://stackoverflow.com/questions/64044266/error-message-unsupported-or-invalid-query-filter-clause-specified-for-property
|
|
//
|
|
// ne 'Guest' ensures we don't filter out users where userType = null, which can happen
|
|
// for user accounts created prior to 2014. In order to use the `ne` comparator, we
|
|
// MUST include $count=true and the ConsistencyLevel: eventual header.
|
|
// https://stackoverflow.com/questions/49340485/how-to-filter-users-by-usertype-null
|
|
//
|
|
//nolint:lll
|
|
var userFilterNoGuests = "onPremisesSyncEnabled eq true OR userType ne 'Guest'"
|
|
|
|
// I can't believe I have to do this.
|
|
var t = true
|
|
|
|
func userOptions(fs *string) *users.UsersRequestBuilderGetRequestConfiguration {
|
|
return &users.UsersRequestBuilderGetRequestConfiguration{
|
|
Headers: newEventualConsistencyHeaders(),
|
|
QueryParameters: &users.UsersRequestBuilderGetQueryParameters{
|
|
Select: idAnd(userPrincipalName, displayName),
|
|
Filter: fs,
|
|
Count: &t,
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetAll retrieves all users.
|
|
func (c Users) GetAll(ctx context.Context, errs *fault.Bus) ([]models.Userable, error) {
|
|
service, err := c.Service()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var resp models.UserCollectionResponseable
|
|
|
|
resp, err = service.Client().Users().Get(ctx, userOptions(&userFilterNoGuests))
|
|
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting all users")
|
|
}
|
|
|
|
iter, err := msgraphgocore.NewPageIterator[models.Userable](
|
|
resp,
|
|
service.Adapter(),
|
|
models.CreateUserCollectionResponseFromDiscriminatorValue)
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "creating users iterator")
|
|
}
|
|
|
|
var (
|
|
us = make([]models.Userable, 0)
|
|
el = errs.Local()
|
|
)
|
|
|
|
iterator := func(item models.Userable) bool {
|
|
if el.Failure() != nil {
|
|
return false
|
|
}
|
|
|
|
err := validateUser(item)
|
|
if err != nil {
|
|
el.AddRecoverable(graph.Wrap(ctx, err, "validating user"))
|
|
} else {
|
|
us = append(us, item)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
if err := iter.Iterate(ctx, iterator); err != nil {
|
|
return nil, graph.Wrap(ctx, err, "iterating all users")
|
|
}
|
|
|
|
return us, el.Failure()
|
|
}
|
|
|
|
// GetByID looks up the user matching the given identifier. The identifier can be either a
|
|
// canonical user id or a princpalName.
|
|
func (c Users) GetByID(ctx context.Context, identifier string) (models.Userable, error) {
|
|
var (
|
|
resp models.Userable
|
|
err error
|
|
)
|
|
|
|
resp, err = c.Stable.Client().Users().ByUserId(identifier).Get(ctx, nil)
|
|
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting user")
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
// GetIDAndName looks up the user matching the given ID, and returns
|
|
// its canonical ID and the PrincipalName as the name.
|
|
func (c Users) GetIDAndName(ctx context.Context, userID string) (string, string, error) {
|
|
u, err := c.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
return ptr.Val(u.GetId()), ptr.Val(u.GetUserPrincipalName()), nil
|
|
}
|
|
|
|
// GetAllIDsAndNames retrieves all users in the tenant and returns them in an idname.Cacher
|
|
func (c Users) GetAllIDsAndNames(ctx context.Context, errs *fault.Bus) (idname.Cacher, error) {
|
|
all, err := c.GetAll(ctx, errs)
|
|
if err != nil {
|
|
return nil, clues.Wrap(err, "getting all users")
|
|
}
|
|
|
|
idToName := make(map[string]string, len(all))
|
|
|
|
for _, u := range all {
|
|
id := strings.ToLower(ptr.Val(u.GetId()))
|
|
name := strings.ToLower(ptr.Val(u.GetUserPrincipalName()))
|
|
|
|
idToName[id] = name
|
|
}
|
|
|
|
return idname.NewCache(idToName), nil
|
|
}
|
|
|
|
func (c Users) GetInfo(ctx context.Context, userID string) (*UserInfo, error) {
|
|
// Assume all services are enabled
|
|
// then filter down to only services the user has enabled
|
|
userInfo := newUserInfo()
|
|
|
|
requestParameters := users.ItemMailFoldersRequestBuilderGetQueryParameters{
|
|
Select: idAnd(),
|
|
Top: ptr.To[int32](1), // if we get any folders, then we have access.
|
|
}
|
|
|
|
options := users.ItemMailFoldersRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &requestParameters,
|
|
}
|
|
|
|
mfs, err := c.GetMailFolders(ctx, userID, options)
|
|
if err != nil {
|
|
logger.CtxErr(ctx, err).Error("getting user's mail folders")
|
|
|
|
if graph.IsErrUserNotFound(err) {
|
|
return nil, clues.Stack(graph.ErrResourceOwnerNotFound, err)
|
|
}
|
|
|
|
if !graph.IsErrExchangeMailFolderNotFound(err) {
|
|
return nil, clues.Stack(err)
|
|
}
|
|
|
|
delete(userInfo.ServicesEnabled, path.ExchangeService)
|
|
}
|
|
|
|
if _, err := c.GetDrives(ctx, userID); err != nil {
|
|
logger.CtxErr(ctx, err).Error("getting user's drives")
|
|
|
|
if graph.IsErrUserNotFound(err) {
|
|
return nil, clues.Stack(graph.ErrResourceOwnerNotFound, err)
|
|
}
|
|
|
|
if !clues.HasLabel(err, graph.LabelsMysiteNotFound) {
|
|
return nil, clues.Stack(err)
|
|
}
|
|
|
|
delete(userInfo.ServicesEnabled, path.OneDriveService)
|
|
}
|
|
|
|
mbxInfo, err := c.getMailboxSettings(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
userInfo.Mailbox = mbxInfo
|
|
|
|
// TODO: This tries to determine if the user has hit their mailbox
|
|
// limit by trying to fetch an item and seeing if we get the quota
|
|
// exceeded error. Ideally(if available) we should convert this to
|
|
// pull the user's usage via an api and compare if they have used
|
|
// up their quota.
|
|
if mfs != nil {
|
|
mf := mfs.GetValue()[0] // we will always have one
|
|
options := &users.ItemMailFoldersItemMessagesDeltaRequestBuilderGetRequestConfiguration{
|
|
QueryParameters: &users.ItemMailFoldersItemMessagesDeltaRequestBuilderGetQueryParameters{
|
|
Top: ptr.To[int32](1), // just one item is enough
|
|
},
|
|
}
|
|
_, err = c.Stable.Client().
|
|
Users().
|
|
ByUserId(userID).
|
|
MailFolders().
|
|
ByMailFolderId(ptr.Val(mf.GetId())).
|
|
Messages().
|
|
Delta().
|
|
Get(ctx, options)
|
|
|
|
if err != nil && !graph.IsErrQuotaExceeded(err) {
|
|
return nil, err
|
|
}
|
|
|
|
userInfo.Mailbox.QuotaExceeded = graph.IsErrQuotaExceeded(err)
|
|
}
|
|
|
|
return userInfo, nil
|
|
}
|
|
|
|
// TODO: remove when exchange api goes into this package
|
|
func (c Users) GetMailFolders(
|
|
ctx context.Context,
|
|
userID string,
|
|
options users.ItemMailFoldersRequestBuilderGetRequestConfiguration,
|
|
) (models.MailFolderCollectionResponseable, error) {
|
|
mailFolders, err := c.Stable.Client().Users().ByUserId(userID).MailFolders().Get(ctx, &options)
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting MailFolders")
|
|
}
|
|
|
|
return mailFolders, nil
|
|
}
|
|
|
|
// TODO: remove when drive api goes into this package
|
|
func (c Users) GetDrives(ctx context.Context, userID string) (models.DriveCollectionResponseable, error) {
|
|
drives, err := c.Stable.Client().Users().ByUserId(userID).Drives().Get(ctx, nil)
|
|
if err != nil {
|
|
return nil, graph.Wrap(ctx, err, "getting drives")
|
|
}
|
|
|
|
return drives, nil
|
|
}
|
|
|
|
func (c Users) getMailboxSettings(
|
|
ctx context.Context,
|
|
userID string,
|
|
) (MailboxInfo, error) {
|
|
var (
|
|
rawURL = fmt.Sprintf("https://graph.microsoft.com/v1.0/users/%s/mailboxSettings", userID)
|
|
adapter = c.Stable.Adapter()
|
|
mi = MailboxInfo{
|
|
ErrGetMailBoxSetting: []error{},
|
|
}
|
|
)
|
|
|
|
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 mi, graph.Wrap(ctx, err, "getting additional data")
|
|
}
|
|
|
|
if graph.IsErrAccessDenied(err) {
|
|
logger.Ctx(ctx).Info("err getting additional data: access denied")
|
|
|
|
mi.ErrGetMailBoxSetting = append(mi.ErrGetMailBoxSetting, clues.New("access denied"))
|
|
|
|
return mi, nil
|
|
}
|
|
|
|
if graph.IsErrExchangeMailFolderNotFound(err) {
|
|
logger.Ctx(ctx).Info("mailfolders not found")
|
|
|
|
mi.ErrGetMailBoxSetting = append(mi.ErrGetMailBoxSetting, ErrMailBoxSettingsNotFound)
|
|
|
|
return mi, nil
|
|
}
|
|
|
|
additionalData := settings.GetAdditionalData()
|
|
|
|
mi.ArchiveFolder, err = str.AnyValueToString("archiveFolder", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.Timezone, err = str.AnyValueToString("timeZone", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.DateFormat, err = str.AnyValueToString("dateFormat", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.TimeFormat, err = str.AnyValueToString("timeFormat", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.Purpose, err = str.AnyValueToString("userPurpose", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.DelegateMeetMsgDeliveryOpt, err = str.AnyValueToString("delegateMeetingMessageDeliveryOptions", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
// decode automatic replies settings
|
|
replySetting, err := tform.AnyValueToT[map[string]any]("automaticRepliesSetting", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.Status, err = str.AnyValueToString("status", replySetting)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.ExternalAudience, err = str.AnyValueToString("externalAudience", replySetting)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.ExternalReplyMessage, err = str.AnyValueToString("externalReplyMessage", replySetting)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.InternalReplyMessage, err = str.AnyValueToString("internalReplyMessage", replySetting)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
// decode scheduledStartDateTime
|
|
startDateTime, err := tform.AnyValueToT[map[string]any]("scheduledStartDateTime", replySetting)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.ScheduledStartDateTime.DateTime, err = str.AnyValueToString("dateTime", startDateTime)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.ScheduledStartDateTime.Timezone, err = str.AnyValueToString("timeZone", startDateTime)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
endDateTime, err := tform.AnyValueToT[map[string]any]("scheduledEndDateTime", replySetting)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.ScheduledEndDateTime.DateTime, err = str.AnyValueToString("dateTime", endDateTime)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.AutomaticRepliesSetting.ScheduledEndDateTime.Timezone, err = str.AnyValueToString("timeZone", endDateTime)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
// Language decode
|
|
language, err := tform.AnyValueToT[map[string]any]("language", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.Language.DisplayName, err = str.AnyValueToString("displayName", language)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.Language.Locale, err = str.AnyValueToString("locale", language)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
// working hours
|
|
workingHours, err := tform.AnyValueToT[map[string]any]("workingHours", additionalData)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.WorkingHours.StartTime, err = str.AnyValueToString("startTime", workingHours)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.WorkingHours.EndTime, err = str.AnyValueToString("endTime", workingHours)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
timeZone, err := tform.AnyValueToT[map[string]any]("timeZone", workingHours)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
mi.WorkingHours.TimeZone.Name, err = str.AnyValueToString("name", timeZone)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
days, err := tform.AnyValueToT[[]any]("daysOfWeek", workingHours)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
|
|
for _, day := range days {
|
|
s, err := str.AnyToString(day)
|
|
mi.ErrGetMailBoxSetting = appendIfErr(mi.ErrGetMailBoxSetting, err)
|
|
mi.WorkingHours.DaysOfWeek = append(mi.WorkingHours.DaysOfWeek, s)
|
|
}
|
|
|
|
return mi, nil
|
|
}
|
|
|
|
func appendIfErr(errs []error, err error) []error {
|
|
if err == nil {
|
|
return errs
|
|
}
|
|
|
|
return append(errs, err)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// validateUser ensures the item is a Userable, and contains the necessary
|
|
// identifiers that we handle with all users.
|
|
func validateUser(item models.Userable) error {
|
|
if item.GetId() == nil {
|
|
return clues.New("missing ID")
|
|
}
|
|
|
|
if item.GetUserPrincipalName() == nil {
|
|
return clues.New("missing principalName")
|
|
}
|
|
|
|
return nil
|
|
}
|