diff --git a/src/internal/m365/collection/exchange/mail_restore.go b/src/internal/m365/collection/exchange/mail_restore.go index ad2ed9e45..806b7eb0c 100644 --- a/src/internal/m365/collection/exchange/mail_restore.go +++ b/src/internal/m365/collection/exchange/mail_restore.go @@ -3,6 +3,7 @@ package exchange import ( "context" "errors" + "regexp" "github.com/alcionai/clues" "github.com/microsoftgraph/msgraph-sdk-go/models" @@ -147,6 +148,8 @@ func restoreMail( msg = setMessageSVEPs(toMessage(msg)) + setReplyTos(msg) + attachments := msg.GetAttachments() // Item.Attachments --> HasAttachments doesn't always have a value populated when deserialized msg.SetAttachments([]models.Attachmentable{}) @@ -229,6 +232,38 @@ func setMessageSVEPs(msg models.Messageable) models.Messageable { return msg } +func setReplyTos(msg models.Messageable) { + var ( + replyTos = msg.GetReplyTo() + emailAddress models.EmailAddressable + name, address string + sanitizedReplyTos = make([]models.Recipientable, 0) + ) + + if len(replyTos) == 0 { + return + } + + for _, replyTo := range replyTos { + emailAddress = replyTo.GetEmailAddress() + address = ptr.Val(emailAddress.GetAddress()) + name = ptr.Val(emailAddress.GetName()) + + if isValidEmail(address) || isValidDN(address) { + newEmailAddress := models.NewEmailAddress() + newEmailAddress.SetAddress(ptr.To(address)) + newEmailAddress.SetName(ptr.To(name)) + + sanitizedReplyTo := models.NewRecipient() + sanitizedReplyTo.SetEmailAddress(newEmailAddress) + + sanitizedReplyTos = append(sanitizedReplyTos, sanitizedReplyTo) + } + } + + msg.SetReplyTo(sanitizedReplyTos) +} + func (h mailRestoreHandler) GetItemsInContainerByCollisionKey( ctx context.Context, userID, containerID string, @@ -240,3 +275,24 @@ func (h mailRestoreHandler) GetItemsInContainerByCollisionKey( return m, nil } + +// [TODO]relocate to a common place +func isValidEmail(email string) bool { + emailRegex := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$` + r := regexp.MustCompile(emailRegex) + + return r.MatchString(email) +} + +// isValidDN check if given string's format matches that of a MSFT Distinguished Name +// This regular expression matches strings that start with /o=, +// followed by any characters except /, +// then /ou=, followed by any characters except /, +// then /cn=, followed by any characters except /, +// then /cn= followed by a 32-character hexadecimal string followed by - and any additional characters. +func isValidDN(dn string) bool { + dnRegex := `^/o=[^/]+/ou=[^/]+/cn=[^/]+/cn=[a-fA-F0-9]{32}-[a-zA-Z0-9-]+$` + r := regexp.MustCompile(dnRegex) + + return r.MatchString(dn) +} diff --git a/src/internal/m365/collection/exchange/mail_restore_test.go b/src/internal/m365/collection/exchange/mail_restore_test.go index 1360b91f7..1e09a3579 100644 --- a/src/internal/m365/collection/exchange/mail_restore_test.go +++ b/src/internal/m365/collection/exchange/mail_restore_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "github.com/alcionai/corso/src/internal/common/ptr" "github.com/alcionai/corso/src/internal/m365/service/exchange/mock" "github.com/alcionai/corso/src/internal/tester" "github.com/alcionai/corso/src/internal/tester/its" @@ -24,6 +25,127 @@ import ( "github.com/alcionai/corso/src/pkg/services/m365/api" ) +//nolint:lll +const TestDN = "/o=ExchangeLabs/ou=Exchange Administrative Group (FYDIBOHF23SPDLT)/cn=Recipients/cn=4eca0d46a2324036b0b326dc58cfc802-user" + +type RestoreMailUnitSuite struct { + tester.Suite +} + +func TestRestoreMailUnitSuite(t *testing.T) { + suite.Run(t, &RestoreMailUnitSuite{Suite: tester.NewUnitSuite(t)}) +} + +func (suite *RestoreMailUnitSuite) TestIsValidEmail() { + table := []struct { + name string + email string + check assert.BoolAssertionFunc + }{ + { + name: "valid email", + email: "foo@bar.com", + check: assert.True, + }, + { + name: "invalid email, missing domain", + email: "foo.com", + check: assert.False, + }, + { + name: "invalid email, random uuid", + email: "12345678-abcd-90ef-88f8-2d95ef12fb66", + check: assert.False, + }, + { + name: "empty email", + email: "", + check: assert.False, + }, + } + + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + result := isValidEmail(test.email) + test.check(t, result) + }) + } +} + +func (suite *RestoreMailUnitSuite) TestIsValidDN() { + table := []struct { + name string + dn string + check assert.BoolAssertionFunc + }{ + { + name: "valid DN", + dn: TestDN, + check: assert.True, + }, + { + name: "invalid DN", + dn: "random string", + check: assert.False, + }, + } + + for _, test := range table { + suite.Run(test.name, func() { + t := suite.T() + + result := isValidDN(test.dn) + test.check(t, result) + }) + } +} + +func (suite *RestoreMailUnitSuite) TestSetReplyTos() { + t := suite.T() + + replyTos := make([]models.Recipientable, 0) + + emailAddresses := map[string]string{ + "foo.bar": "foo@bar.com", + "foo.com": "foo.com", + "empty": "", + "dn": TestDN, + } + + validEmailAddresses := map[string]string{ + "foo.bar": "foo@bar.com", + "dn": TestDN, + } + + for k, v := range emailAddresses { + emailAddress := models.NewEmailAddress() + emailAddress.SetAddress(ptr.To(v)) + emailAddress.SetName(ptr.To(k)) + + replyTo := models.NewRecipient() + replyTo.SetEmailAddress(emailAddress) + + replyTos = append(replyTos, replyTo) + } + + mailMessage := models.NewMessage() + mailMessage.SetReplyTo(replyTos) + + setReplyTos(mailMessage) + + sanitizedReplyTos := mailMessage.GetReplyTo() + require.Len(t, sanitizedReplyTos, len(validEmailAddresses)) + + for _, sanitizedReplyTo := range sanitizedReplyTos { + emailAddress := sanitizedReplyTo.GetEmailAddress() + + assert.Contains(t, validEmailAddresses, ptr.Val(emailAddress.GetName())) + assert.Equal(t, validEmailAddresses[ptr.Val(emailAddress.GetName())], ptr.Val(emailAddress.GetAddress())) + } +} + var _ mailRestorer = &mailRestoreMock{} type mailRestoreMock struct {