From a586512b4214d4823514016e37273eb9966dcbe4 Mon Sep 17 00:00:00 2001 From: Keepers Date: Thu, 4 Aug 2022 15:28:28 -0600 Subject: [PATCH] add command to purge user's folders by prefix/date (#480) --- src/cli/print/print.go | 2 + src/cmd/purge/purge.go | 122 ++++++++++++++++++ src/internal/common/time.go | 22 ++-- .../connector/exchange/service_functions.go | 55 ++++++++ src/internal/connector/graph_connector.go | 13 +- .../connector/graph_connector_test.go | 2 +- 6 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 src/cmd/purge/purge.go diff --git a/src/cli/print/print.go b/src/cli/print/print.go index 9bef35b5f..4193e176d 100644 --- a/src/cli/print/print.go +++ b/src/cli/print/print.go @@ -71,6 +71,7 @@ func info(w io.Writer, s ...any) { return } fmt.Fprint(w, s...) + fmt.Fprintf(w, "\n") } // Info prints the formatted strings to cobra's error writer (stdErr by default) @@ -85,6 +86,7 @@ func infof(w io.Writer, t string, s ...any) { return } fmt.Fprintf(w, t, s...) + fmt.Fprintf(w, "\n") } // --------------------------------------------------------------------------------------------------------- diff --git a/src/cmd/purge/purge.go b/src/cmd/purge/purge.go new file mode 100644 index 000000000..5b0156829 --- /dev/null +++ b/src/cmd/purge/purge.go @@ -0,0 +1,122 @@ +package main + +import ( + "os" + "time" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + . "github.com/alcionai/corso/cli/print" + "github.com/alcionai/corso/cli/utils" + "github.com/alcionai/corso/internal/common" + "github.com/alcionai/corso/internal/connector" + "github.com/alcionai/corso/internal/connector/exchange" + "github.com/alcionai/corso/pkg/account" + "github.com/alcionai/corso/pkg/credentials" +) + +var purgeCmd = &cobra.Command{ + Use: "purge", + Short: "Purge m365 data", + RunE: doFolderPurge, +} + +var ( + before string + user string + tenant string + prefix string +) + +func doFolderPurge(cmd *cobra.Command, args []string) error { + if utils.HasNoFlagsAndShownHelp(cmd) { + return nil + } + + // get account info + m365Cfg := account.M365Config{ + M365: credentials.GetM365(), + TenantID: first(tenant, os.Getenv(account.TenantID)), + } + acct, err := account.NewAccount(account.ProviderM365, m365Cfg) + if err != nil { + return Only(errors.Wrap(err, "finding m365 account details")) + } + + // build a graph connector + gc, err := connector.NewGraphConnector(acct) + if err != nil { + return Only(errors.Wrap(err, "connecting to graph api")) + } + + // get them folders + mfs, err := exchange.GetAllMailFolders(gc.Service(), user, prefix) + if err != nil { + return Only(errors.Wrap(err, "retrieving mail folders")) + } + + // format the time input + beforeTime := time.Now().UTC() + if len(before) > 0 { + beforeTime, err = common.ParseTime(before) + if err != nil { + return Only(errors.Wrap(err, "parsing before flag to time")) + } + } + stLen := len(common.SimpleDateTimeFormat) + + // delete files + for _, mf := range mfs { + + // compare the folder time to the deletion boundary time first + var delete bool + dnLen := len(mf.DisplayName) + if dnLen > stLen { + dnSuff := mf.DisplayName[dnLen-stLen:] + dnTime, err := common.ParseTime(dnSuff) + if err != nil { + Info(errors.Wrapf(err, "Error: deleting folder [%s]", mf.DisplayName)) + continue + } + delete = dnTime.Before(beforeTime) + } + + if !delete { + continue + } + + Info("Deleting folder: ", mf.DisplayName) + err = exchange.DeleteMailFolder(gc.Service(), user, mf.ID) + if err != nil { + Info(errors.Wrapf(err, "Error: deleting folder [%s]", mf.DisplayName)) + } + } + + return nil +} + +func main() { + fs := purgeCmd.Flags() + fs.StringVar(&before, "before", "", "folders older than this date are deleted. (default: now in UTC)") + fs.StringVar(&user, "user", "", "m365 user id whose folders will be deleted") + cobra.CheckErr(purgeCmd.MarkFlagRequired("user")) + fs.StringVar(&tenant, "tenant", "", "m365 tenant containing the user") + fs.StringVar(&prefix, "prefix", "", "filters mail folders by displayName prefix") + cobra.CheckErr(purgeCmd.MarkFlagRequired("prefix")) + + if err := purgeCmd.Execute(); err != nil { + Info("Error: ", err.Error()) + os.Exit(1) + } +} + +// returns the first non-zero valued string +func first(vs ...string) string { + for _, v := range vs { + if len(v) > 0 { + return v + } + } + return "" +} diff --git a/src/internal/common/time.go b/src/internal/common/time.go index 0c14de171..091e9fd63 100644 --- a/src/internal/common/time.go +++ b/src/internal/common/time.go @@ -5,18 +5,20 @@ import ( "time" ) +const ( + SimpleDateTimeFormat = "02-Jan-2006_15:04:05" +) + // FormatTime produces the standard format for corso time values. // Always formats into the UTC timezone. func FormatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) } -// FormatSimpleDateTime produces standard format for -// GraphConnector. Format used on CI testing and default folder -// creation during the restore process +// FormatSimpleDateTime produces a simple datetime of the format +// "02-Jan-2006_15:04:05" func FormatSimpleDateTime(t time.Time) string { - timeFolderFormat := "02-Jan-2006_15:04:05" - return t.UTC().Format(timeFolderFormat) + return t.UTC().Format(SimpleDateTimeFormat) } // ParseTime makes a best attempt to produce a time value from @@ -26,8 +28,12 @@ func ParseTime(s string) (time.Time, error) { return time.Time{}, errors.New("cannot interpret an empty string as time.Time") } t, err := time.Parse(time.RFC3339Nano, s) - if err != nil { - return time.Time{}, err + if err == nil { + return t.UTC(), nil } - return t.UTC(), nil + t, err = time.Parse(SimpleDateTimeFormat, s) + if err == nil { + return t.UTC(), nil + } + return time.Time{}, errors.New("unable to format time string: " + s) } diff --git a/src/internal/connector/exchange/service_functions.go b/src/internal/connector/exchange/service_functions.go index c2a15f739..2c57510a7 100644 --- a/src/internal/connector/exchange/service_functions.go +++ b/src/internal/connector/exchange/service_functions.go @@ -3,6 +3,7 @@ package exchange import ( "context" "fmt" + "strings" "time" absser "github.com/microsoft/kiota-abstractions-go/serialization" @@ -76,6 +77,60 @@ func DeleteMailFolder(gs graph.Service, user, folderID string) error { return gs.Client().UsersById(user).MailFoldersById(folderID).Delete() } +type MailFolder struct { + ID string + DisplayName string +} + +// 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. +func GetAllMailFolders(gs graph.Service, user, nameContains string) ([]MailFolder, error) { + var ( + mfs = []MailFolder{} + err error + ) + + opts, err := optionsForMailFolders([]string{"id", "displayName"}) + if err != nil { + return nil, err + } + + resp, err := gs.Client().UsersById(user).MailFolders().GetWithRequestConfigurationAndResponseHandler(opts, nil) + if err != nil { + return nil, err + } + + iter, err := msgraphgocore.NewPageIterator(resp, gs.Adapter(), models.CreateMailFolderCollectionResponseFromDiscriminatorValue) + if err != nil { + return nil, err + } + + cb := func(folderItem any) bool { + folder, ok := folderItem.(models.MailFolderable) + if !ok { + err = errors.New("HasFolder() iteration failure") + return false + } + + include := len(nameContains) == 0 || + (len(nameContains) > 0 && strings.Contains(*folder.GetDisplayName(), nameContains)) + if include { + mfs = append(mfs, MailFolder{ + ID: *folder.GetId(), + DisplayName: *folder.GetDisplayName(), + }) + } + + return true + } + + if err := iter.Iterate(cb); err != nil { + return nil, err + } + return mfs, err +} + // GetMailFolderID query function to retrieve the M365 ID based on the folder's displayName. // @param folderName the target folder's display name. Case sensitive // @returns a *string if the folder exists. If the folder does not exist returns nil, error-> folder not found diff --git a/src/internal/connector/graph_connector.go b/src/internal/connector/graph_connector.go index eaaef036c..bb7f502fa 100644 --- a/src/internal/connector/graph_connector.go +++ b/src/internal/connector/graph_connector.go @@ -42,21 +42,28 @@ type GraphConnector struct { credentials account.M365Config } +// Service returns the GC's embedded graph.Service +func (gc GraphConnector) Service() graph.Service { + return gc.graphService +} + +var _ graph.Service = &graphService{} + type graphService struct { client msgraphsdk.GraphServiceClient adapter msgraphsdk.GraphRequestAdapter failFast bool // if true service will exit sequence upon encountering an error } -func (gs *graphService) Client() *msgraphsdk.GraphServiceClient { +func (gs graphService) Client() *msgraphsdk.GraphServiceClient { return &gs.client } -func (gs *graphService) Adapter() *msgraphsdk.GraphRequestAdapter { +func (gs graphService) Adapter() *msgraphsdk.GraphRequestAdapter { return &gs.adapter } -func (gs *graphService) ErrPolicy() bool { +func (gs graphService) ErrPolicy() bool { return gs.failFast } diff --git a/src/internal/connector/graph_connector_test.go b/src/internal/connector/graph_connector_test.go index 0a6da7e85..310497083 100644 --- a/src/internal/connector/graph_connector_test.go +++ b/src/internal/connector/graph_connector_test.go @@ -153,7 +153,7 @@ func (suite *GraphConnectorIntegrationSuite) TestGraphConnector_CreateAndDeleteF aFolder, err := exchange.CreateMailFolder(&suite.connector.graphService, userID, folderName) assert.NoError(suite.T(), err, support.ConnectorStackErrorTrace(err)) if aFolder != nil { - err = exchange.DeleteMailFolder(&suite.connector.graphService, userID, *aFolder.GetId()) + err = exchange.DeleteMailFolder(suite.connector.Service(), userID, *aFolder.GetId()) assert.NoError(suite.T(), err) } }