diff --git a/.github/workflows/ReadEmail_build_and_test_on_main.yml b/.github/workflows/ReadEmail_build_and_test_on_main.yml new file mode 100644 index 0000000..492f429 --- /dev/null +++ b/.github/workflows/ReadEmail_build_and_test_on_main.yml @@ -0,0 +1,27 @@ +name: ReadEmail_build_main + +on: + push: + branches: + - main + paths: + - 'Frends.Exchange.ReadEmail/**' + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main + with: + workdir: Frends.Exchange.ReadEmail + env_var_name_1: Exchange_User + env_var_name_2: Exchange_User_Password + env_var_name_3: Exchange_Application_ID + env_var_name_4: Exchange_Tenant_ID + env_var_name_5: Exchange_ClientSecret + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} + env_var_value_1: ${{ secrets.EXCHANGE_USER }} + env_var_value_2: ${{ secrets.EXCHANGE_USER_PASSWORD }} + env_var_value_3: ${{ secrets.EXCHANGE_APPLICATION_ID }} + env_var_value_4: ${{ secrets.EXCHANGE_TENANT_ID }} + env_var_value_5: ${{ secrets.EXCHANGE_CLIENTSECRET }} \ No newline at end of file diff --git a/.github/workflows/ReadEmail_build_and_test_on_push.yml b/.github/workflows/ReadEmail_build_and_test_on_push.yml new file mode 100644 index 0000000..bda3581 --- /dev/null +++ b/.github/workflows/ReadEmail_build_and_test_on_push.yml @@ -0,0 +1,28 @@ +name: ReadEmail_build_test + +on: + push: + branches-ignore: + - main + paths: + - 'Frends.Exchange.ReadEmail/**' + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main + with: + workdir: Frends.Exchange.ReadEmail + env_var_name_1: Exchange_User + env_var_name_2: Exchange_User_Password + env_var_name_3: Exchange_Application_ID + env_var_name_4: Exchange_Tenant_ID + env_var_name_5: Exchange_ClientSecret + secrets: + badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} + test_feed_api_key: ${{ secrets.TASKS_TEST_FEED_API_KEY }} + env_var_value_1: ${{ secrets.EXCHANGE_USER }} + env_var_value_2: ${{ secrets.EXCHANGE_USER_PASSWORD }} + env_var_value_3: ${{ secrets.EXCHANGE_APPLICATION_ID }} + env_var_value_4: ${{ secrets.EXCHANGE_TENANT_ID }} + env_var_value_5: ${{ secrets.EXCHANGE_CLIENTSECRET }} \ No newline at end of file diff --git a/.github/workflows/ReadEmail_release.yml b/.github/workflows/ReadEmail_release.yml new file mode 100644 index 0000000..c70201e --- /dev/null +++ b/.github/workflows/ReadEmail_release.yml @@ -0,0 +1,12 @@ +name: ReadEmail_release + +on: + workflow_dispatch: + +jobs: + build: + uses: FrendsPlatform/FrendsTasks/.github/workflows/release.yml@main + with: + workdir: Frends.Exchange.ReadEmail + secrets: + feed_api_key: ${{ secrets.TASKS_FEED_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/Send_main.yml b/.github/workflows/Send_main.yml deleted file mode 100644 index ca3894a..0000000 --- a/.github/workflows/Send_main.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Frends.Exchange.ReadMail Main - -on: - push: - branches: - - main - paths: - - 'Frends.Exchange.ReadMail/**' - workflow_dispatch: - - -jobs: - build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/build_main.yml@main - with: - env_var_name_1: EXCHANGE_SETTINGS_FOR_TESTING - workdir: Frends.Exchange - secrets: - env_var_value_1: ${{ secrets.EXCHANGE_SETTINGS_FOR_TESTING }} - badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} - \ No newline at end of file diff --git a/.github/workflows/Send_release.yml b/.github/workflows/Send_release.yml deleted file mode 100644 index 72023bc..0000000 --- a/.github/workflows/Send_release.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Frends.Exchange.ReadMail Release - -on: - workflow_dispatch: - - -jobs: - build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/release.yml@main - with: - workdir: Frends.Exchange.ReadMail - secrets: - feed_api_key: ${{ secrets.TASKS_FEED_API_KEY }} - \ No newline at end of file diff --git a/.github/workflows/Send_test.yml b/.github/workflows/Send_test.yml deleted file mode 100644 index d88fadd..0000000 --- a/.github/workflows/Send_test.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Frends.Exchange.ReadMail Test - -on: - push: - branches-ignore: - - main - paths: - - 'Frends.Exchange.ReadMail/**' - workflow_dispatch: - - -jobs: - build: - uses: FrendsPlatform/FrendsTasks/.github/workflows/build_test.yml@main - with: - env_var_name_1: EXCHANGE_SETTINGS_FOR_TESTING - workdir: Frends.Exchange.ReadMail - secrets: - env_var_value_1: ${{ secrets.EXCHANGE_SETTINGS_FOR_TESTING }} - badge_service_api_key: ${{ secrets.BADGE_SERVICE_API_KEY }} - test_feed_api_key: ${{ secrets.TASKS_TEST_FEED_API_KEY }} - \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/CHANGELOG.md b/Frends.Exchange.ReadEmail/CHANGELOG.md new file mode 100644 index 0000000..6aa83fb --- /dev/null +++ b/Frends.Exchange.ReadEmail/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## [1.0.0] - 2023-12-05 +### Added +- Initial implementation \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Tests/Frends.Exchange.ReadEmail.Tests.csproj b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Test/Frends.Exchange.ReadEmail.Tests.csproj similarity index 55% rename from Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Tests/Frends.Exchange.ReadEmail.Tests.csproj rename to Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Test/Frends.Exchange.ReadEmail.Tests.csproj index 0d4ebfb..8735953 100644 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Tests/Frends.Exchange.ReadEmail.Tests.csproj +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Test/Frends.Exchange.ReadEmail.Tests.csproj @@ -1,18 +1,19 @@ - + net6.0 enable - false - - - - - + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Test/UnitTests.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Test/UnitTests.cs new file mode 100644 index 0000000..93f9cb4 --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Test/UnitTests.cs @@ -0,0 +1,355 @@ +using Azure.Identity; +using Frends.Exchange.ReadEmail.Definitions; +using Microsoft.Graph; +using Microsoft.Graph.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Frends.Exchange.ReadEmail.Tests; + +[TestClass] +public class UnitTests +{ + + private static readonly string? _user = Environment.GetEnvironmentVariable("Exchange_User"); + private static readonly string? _password = Environment.GetEnvironmentVariable("Exchange_User_Password"); + private static readonly string? _applicationID = Environment.GetEnvironmentVariable("Exchange_Application_ID"); + private static readonly string? _tenantID = Environment.GetEnvironmentVariable("Exchange_Tenant_ID"); + private static readonly string? _clientSecret = Environment.GetEnvironmentVariable("Exchange_ClientSecret"); + private static Connection _connection = new(); + private static Input _input = new(); + private static Options _options = new(); + private static readonly string _downloadDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Download\\"); + + [TestInitialize] + public void Setup() + { + _connection = new Connection() + { + Username = _user, + Password = _password, + ClientId = _applicationID, + TenantId = _tenantID, + AuthenticationProvider = AuthenticationProviders.UsernamePassword, + ClientSecret = null, + X509CertificateFilePath = null, + }; + + _input = new Input() + { + Select = null, + Filter = "parentFolderId eq 'INBOX'", + Skip = 0, + Top = 0, + Orderby = null, + Expand = null, + Headers = null, + DownloadAttachments = true, + DestinationDirectory = _downloadDir, + FileExistHandler = FileExistHandlers.Rename, + CreateDirectory = true, + From = _user, + UpdateReadStatus = false, + }; + + _options = new Options() + { + ThrowExceptionOnFailure = true, + }; + } + + [TestCleanup] + public async Task CleanUp() + { + if (Directory.Exists(_downloadDir)) Directory.Delete(_downloadDir, true); + await UpdateMessageRead(); + } + + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_AllFolders() + { + _input.Filter = null; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_Inbox() + { + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_Inbox_ClientCredentialsSecret() + { + _connection.AuthenticationProvider = AuthenticationProviders.ClientCredentialsSecret; + _connection.ClientSecret = _clientSecret; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_Read_DONTDownload_Inbox() + { + _input.DownloadAttachments = false; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsFalse(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_Select_Inbox() + { + _input.Select = "subject"; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_FROM_Inbox() + { + _input.From = _user; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_MarkAsRead_Inbox() + { + _input.UpdateReadStatus = true; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_Skip_Inbox() + { + _input.Skip = 2; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_Top_Inbox() + { + _input.Top = 2; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.AreEqual(2, result.Data.Count); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_OrderBy_Inbox() + { + _input.Orderby = "receivedDateTime DESC,subject ASC"; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_Expand_Inbox() + { + _input.Expand = "attachments"; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_Header_Inbox() + { + _input.Headers = new[] { new HeaderParameters() { HeaderName = "Prefer", HeaderValues = new[] { "outlook.body-content-type=\"text\"" } } }; + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_FileExistHandler_Skip() + { + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + var fileCount = Directory.GetFiles(_downloadDir).Length; + + _input.FileExistHandler = FileExistHandlers.Skip; + var resultSkip = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(resultSkip.Success); + Assert.IsTrue(resultSkip.Data.Count > 0); + Assert.AreEqual(0, resultSkip.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + Assert.AreEqual(fileCount, Directory.GetFiles(_downloadDir).Length); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_FileExistHandler_Rename() + { + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + var fileCount = Directory.GetFiles(_downloadDir).Length; + + _input.FileExistHandler = FileExistHandlers.Rename; + var resultSkip = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(resultSkip.Success); + Assert.IsTrue(resultSkip.Data.Count > 0); + Assert.AreEqual(0, resultSkip.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + Assert.AreEqual(fileCount + fileCount, Directory.GetFiles(_downloadDir).Length); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_FileExistHandler_OverWrite() + { + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + var fileCount = Directory.GetFiles(_downloadDir).Length; + + _input.FileExistHandler = FileExistHandlers.OverWrite; + var resultSkip = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(resultSkip.Success); + Assert.IsTrue(resultSkip.Data.Count > 0); + Assert.AreEqual(0, resultSkip.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + Assert.AreEqual(fileCount, Directory.GetFiles(_downloadDir).Length); + } + + [TestMethod] + public async Task ReadEmailTest_ReadAndDownload_FileExistHandler_Append() + { + var result = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(result.Success); + Assert.IsTrue(result.Data.Count > 0); + Assert.AreEqual(0, result.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + var fileCount = Directory.GetFiles(_downloadDir).Length; + + _input.FileExistHandler = FileExistHandlers.Append; + var resultSkip = await Exchange.ReadEmail(_connection, _input, _options, default); + Assert.IsTrue(resultSkip.Success); + Assert.IsTrue(resultSkip.Data.Count > 0); + Assert.AreEqual(0, resultSkip.ErrorMessages.Count); + Assert.IsTrue(Directory.Exists(_input.DestinationDirectory)); + Assert.AreEqual(fileCount, Directory.GetFiles(_downloadDir).Length); + } + + [TestMethod] + public async Task ReadEmailTest_TryToDownload_NoDir() + { + _input.CreateDirectory = false; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + } + + [TestMethod] + public async Task ReadEmailTest_MissingCredentials_UsernamePassword_Throw() + { + _connection.TenantId = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + + _connection.TenantId = _tenantID; + _connection.ClientId = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + + _connection.ClientId = _applicationID; + _connection.Username = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + + _connection.Username = _user; + _connection.Password = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + } + + [TestMethod] + public async Task ReadEmailTest_MissingCredentials_ClientCredentialsCertificate_Throw() + { + _connection.AuthenticationProvider = AuthenticationProviders.ClientCredentialsCertificate; + + _connection.X509CertificateFilePath = "Something"; + _connection.TenantId = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + + _connection.TenantId = _tenantID; + _connection.ClientId = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + + _connection.ClientId = _applicationID; + _connection.X509CertificateFilePath = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + } + + [TestMethod] + public async Task ReadEmailTest_MissingCredentials_ClientCredentialsSecret_Throw() + { + _connection.AuthenticationProvider = AuthenticationProviders.ClientCredentialsSecret; + + _connection.ClientSecret = "Something"; + _connection.TenantId = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + + _connection.TenantId = _tenantID; + _connection.ClientId = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + + _connection.ClientId = _applicationID; + _connection.ClientSecret = null; + await Assert.ThrowsExceptionAsync(async () => await Exchange.ReadEmail(_connection, _input, _options, default)); + } + + private static GraphServiceClient CreateGraphServiceClient() + { + var options = new TokenCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud }; + var credentials = new UsernamePasswordCredential(_user, _password, _tenantID, _applicationID, options); + return new GraphServiceClient(credentials); + } + + // Update message back to unread + private static async Task UpdateMessageRead() + { + var client = CreateGraphServiceClient(); + var requestBody = new Message { IsRead = false }; + await client.Me.Messages["AAMkADIxYTJiZDIzLTIyZDMtNDhhNy05YjE1LTY2NGRkNmRjZTNiNwBGAAAAAACTqlZRkDG0S6Jj-VUkGGnxBwBGg69sLcQZTZPbCQVRM7fFAAAAAAEMAABGg69sLcQZTZPbCQVRM7fFAAFJtxHfAAA="].PatchAsync(requestBody); + } +} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Tests/Tests.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Tests/Tests.cs deleted file mode 100644 index a117914..0000000 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.Tests/Tests.cs +++ /dev/null @@ -1,255 +0,0 @@ -using NUnit.Framework; -using System; -using Microsoft.Exchange.WebServices.Data; -using Frends.Exchange.ReadEmail.Definitions; -using System.Net.Security; -using System.IO; -using System.Linq; -using System.Collections.Generic; -using System.Threading; -using Newtonsoft.Json; - -namespace Frends.Exchange.ReadEmail.Tests -{ - public class Tests - { - private const string serverAddressInCorrectFormat = "https://servername/ews/exchange.amsx"; - private const string serverAddressAsFaulty = "ksdfjdosifsfsdsc/exchange.asddd"; - private const string expectedDummyTextFileContent = "This is a dummy file."; - private const string settingsEnvVarName = "EXCHANGE_SETTINGS_FOR_TESTING"; - - public static ExchangeSettings GetSettingsFromEnvironment() - { - var rawJson = Environment.GetEnvironmentVariable(settingsEnvVarName); - if (!string.IsNullOrEmpty(rawJson)) - { - try - { - return JsonConvert.DeserializeObject(rawJson); - } - catch (Exception ex) - { - throw new Exception($"Couldn't deserialize the environment variable \"{settingsEnvVarName}\". Ensure it is correct format. See inner exception.", ex); - } - } else - { - throw new InvalidOperationException($"Couldn't get Exchange settings for testing. Ensure the environment variable \"{settingsEnvVarName}\" is set."); - } - } - - [Test] - public void ConnectToExchangeServiceRejectsFaultyServerAddress() - { - var settings = new ExchangeSettings() - { - ServerAddress = serverAddressAsFaulty - }; - - Assert.Throws(typeof(UriFormatException), () => - { - Util.ConnectToExchangeService(settings); - }); - } - - [Test] - public void ConnectToExchangeServiceMethodSetsCorrectExchangeVersion() - { - TestSettingExchangeServerVersion(ExchangeServerVersion.Exchange2007_SP1, ExchangeVersion.Exchange2007_SP1); - TestSettingExchangeServerVersion(ExchangeServerVersion.Exchange2010, ExchangeVersion.Exchange2010); - TestSettingExchangeServerVersion(ExchangeServerVersion.Exchange2010_SP1, ExchangeVersion.Exchange2010_SP1); - TestSettingExchangeServerVersion(ExchangeServerVersion.Exchange2010_SP2, ExchangeVersion.Exchange2010_SP2); - TestSettingExchangeServerVersion(ExchangeServerVersion.Exchange2013_SP1, ExchangeVersion.Exchange2013_SP1); - TestSettingExchangeServerVersion(ExchangeServerVersion.Office365, ExchangeVersion.Exchange2013_SP1); - } - - [Test] - public void ConnectToExchangeServiceSetsExpectedValidationCallbackMethod() - { - Util.ConnectToExchangeService(new ExchangeSettings() - { - ServerAddress = serverAddressInCorrectFormat, - }); - - var method = System.Net.ServicePointManager.ServerCertificateValidationCallback; - Assert.IsNotNull(method?.Method, "ConnectToExchangeService method didn't set ServicePointManager.ServerCertificateValidationCallback even though it should."); - Assert.IsTrue(method!.Invoke(this, null, null, SslPolicyErrors.None)); - Assert.IsTrue(method!.Invoke(this, null, null, SslPolicyErrors.RemoteCertificateChainErrors)); - Assert.IsFalse(method!.Invoke(this, null, null, SslPolicyErrors.RemoteCertificateNameMismatch)); - } - - /// - /// Method for testing that the attachments get saved to filesystem as intended, - /// with their content as intended. - /// - [Test] - public void AttachmentsGetSavedAsShould() - { - var service = new ExchangeService(); - var tempDirPath = Path.Combine(Path.GetTempPath(), "ReadEmailTests"); - var msgAndFiles = CreateEmailMessageWithAttachments(tempDirPath, service); - - var dummyDirPath = Path.Combine(tempDirPath, "DummySaveDir"); - Directory.CreateDirectory(dummyDirPath); - var options = new ExchangeOptions() - { - AttachmentSaveDirectory = dummyDirPath, - }; - Util.SaveAttachments(msgAndFiles.Item1.Attachments, options); - - foreach (var fileInfo in msgAndFiles.Item2) - { - Assert.IsTrue(fileInfo.Exists); - var content = File.ReadAllText(fileInfo.FullName); - Assert.AreEqual(expectedDummyTextFileContent, content); - } - - Directory.Delete(tempDirPath, true); - } - - private static (EmailMessage, IEnumerable) CreateEmailMessageWithAttachments(string tempDirPath, ExchangeService service) - { - var files = CreateTempTextFiles(tempDirPath, 5); - - var msg = new EmailMessage(service); - foreach (var file in files) - { - msg.Attachments.AddFileAttachment(file.FullName); - } - - return (msg, files); - } - - private static IEnumerable CreateTempTextFiles(string dirPath, int count) - { - if (count <= 0) - { - throw new ArgumentException("Count must be over 0."); - } - - int counter = 0; - while (counter < count) - { - yield return CreateTempTextFile(dirPath); - counter++; - } - } - - private static FileInfo CreateTempTextFile(string dirPath) - { - Directory.CreateDirectory(dirPath); - var info = new FileInfo(Path.Combine(dirPath, Guid.NewGuid().ToString() + ".txt")); - using var writer = File.CreateText(info.FullName); - writer.Write(expectedDummyTextFileContent); - writer.Flush(); - return info; - } - - /// - /// Initiates a new ExchangeService object with - /// method - /// and tests if the Exchange server version in that object is what is expected. - /// If no is set, this method asserts - /// that the resulted version equals value. - /// - private static void TestSettingExchangeServerVersion(ExchangeServerVersion setServerVersion, ExchangeVersion expectedVersion) - { - var service = CreateCertainTypedServiceWithConnectMethod(setServerVersion); - - Assert.AreEqual(expectedVersion, service.RequestedServerVersion, - $"Expected service.RequestedServerVersion property value to be {expectedVersion}, but it was {service.RequestedServerVersion}"); - } - - private static ExchangeService CreateCertainTypedServiceWithConnectMethod(ExchangeServerVersion exchangeVersion) - { - return Util.ConnectToExchangeService(new ExchangeSettings() - { - ExchangeServerVersion = exchangeVersion, - ServerAddress = serverAddressInCorrectFormat - }); - } - - /// - /// Tests that Util - /// - /// contains a method with a name RedirectionUrlValidationCallback - /// the method returns a - /// the method takes one param - /// the method returns true when the passed param url has https scheme - /// the method returns false when the passed param url doesn't have https scheme - /// - /// - [Test] - public void RedirectionUrlValidationCallbackReturnsAsExpected() - { - var args = new[] - { - "http://not.nice.url.com" - }; - var method = typeof(Util).GetMethod("RedirectionUrlValidationCallback"); - Assert.IsNotNull(method); - Assert.AreEqual(typeof(bool), method!.ReturnType); - var methodParam = method.GetParameters(); - Assert.IsNotNull(methodParam); - Assert.NotZero(methodParam.Length); - Assert.AreEqual(typeof(string), methodParam[0].ParameterType); - - var result = method.Invoke(null, args); - Assert.IsInstanceOf(typeof(bool), result, "The method provided for RedirectionUrlValidationCallback doesn't return a bool."); - Assert.IsFalse((bool)result!); - - args[0] = "https://nice.url.com"; - result = method.Invoke(null, args); - Assert.IsTrue((bool)result!); - } - - private static EmailMessage SendTestEmail(ExchangeSettings settings) - { - var tempDirPath = Path.Combine(Path.GetTempPath(), "emailsendtest"); - Directory.CreateDirectory(tempDirPath); - - var service = Util.ConnectToExchangeService(settings); - var msgAndFiles = CreateEmailMessageWithAttachments(tempDirPath, service); - - msgAndFiles.Item1.Subject = $"Test email {Guid.NewGuid()}"; - msgAndFiles.Item1.Body = "Hello there! This is a test email, for testing purposes."; - msgAndFiles.Item1.ToRecipients.Add(settings.Username); - - var sendTask = msgAndFiles.Item1.Send(); - sendTask.Wait(5000); - - Directory.Delete(tempDirPath, true); - - return msgAndFiles.Item1; - } - - [Test] - public static void TestReadingMails() - { - var settings = GetSettingsFromEnvironment(); - var options = new ExchangeOptions() - { - AttachmentSaveDirectory = Path.Combine(Path.GetTempPath(), "reademailtest"), - MaxEmails = 500 - }; - - var sentEmail = SendTestEmail(settings); - - Thread.Sleep(5000); - - var readTask = Exchange.ReadEmail(settings, options); - readTask.Wait(10000); - var result = readTask.Result; - - EmailMessageResult? receivedEmail = result.Where(msg => msg.Subject == sentEmail.Subject).FirstOrDefault(); - - Assert.IsNotNull(receivedEmail); - - RemoveEmailMessage(Util.ConnectToExchangeService(settings), receivedEmail!.Id); - } - - public static void RemoveEmailMessage(ExchangeService service, string msgId) - { - var response = service.DeleteItems(new[] { new ItemId(msgId) }, DeleteMode.HardDelete, null, null); - } - } -} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.sln b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.sln index e7bd6d1..e7fb1a3 100644 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.sln +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.sln @@ -1,11 +1,23 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.0.31912.275 +VisualStudioVersion = 17.1.32319.34 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.Exchange.ReadEmail", "Frends.Exchange.ReadEmail\Frends.Exchange.ReadEmail.csproj", "{AC3A1F89-C83D-4956-9432-BAB2D1C9D4BF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.Exchange.ReadEmail", "Frends.Exchange.ReadEmail\Frends.Exchange.ReadEmail.csproj", "{35C305C0-8108-4A98-BB1D-AFE5C926239E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Frends.Exchange.ReadEmail.Tests", "Frends.Exchange.ReadEmail.Tests\Frends.Exchange.ReadEmail.Tests.csproj", "{F0CABD90-6332-4989-9392-A86230F07813}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{78F7F22E-6E20-4BCE-8362-0C558568B729}" + ProjectSection(SolutionItems) = preProject + CHANGELOG.md = CHANGELOG.md + README.md = README.md + ..\.github\workflows\ReadEmail_build_and_test_on_main.yml = ..\.github\workflows\ReadEmail_build_and_test_on_main.yml + ..\.github\workflows\ReadEmail_release.yml = ..\.github\workflows\ReadEmail_release.yml + ..\.github\workflows\ReadEmail_build_and_test_on_push.yml = ..\.github\workflows\ReadEmail_build_and_test_on_push.yml + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Frends.Exchange.ReadEmail.Tests", "Frends.Exchange.ReadEmail.Test\Frends.Exchange.ReadEmail.Tests.csproj", "{C2A1E867-FC58-4432-A4B7-5E1FC451C9EB}" + ProjectSection(ProjectDependencies) = postProject + {35C305C0-8108-4A98-BB1D-AFE5C926239E} = {35C305C0-8108-4A98-BB1D-AFE5C926239E} + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -13,19 +25,19 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {AC3A1F89-C83D-4956-9432-BAB2D1C9D4BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AC3A1F89-C83D-4956-9432-BAB2D1C9D4BF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AC3A1F89-C83D-4956-9432-BAB2D1C9D4BF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AC3A1F89-C83D-4956-9432-BAB2D1C9D4BF}.Release|Any CPU.Build.0 = Release|Any CPU - {F0CABD90-6332-4989-9392-A86230F07813}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F0CABD90-6332-4989-9392-A86230F07813}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F0CABD90-6332-4989-9392-A86230F07813}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F0CABD90-6332-4989-9392-A86230F07813}.Release|Any CPU.Build.0 = Release|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {35C305C0-8108-4A98-BB1D-AFE5C926239E}.Release|Any CPU.Build.0 = Release|Any CPU + {C2A1E867-FC58-4432-A4B7-5E1FC451C9EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C2A1E867-FC58-4432-A4B7-5E1FC451C9EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C2A1E867-FC58-4432-A4B7-5E1FC451C9EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C2A1E867-FC58-4432-A4B7-5E1FC451C9EB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A99916C6-9633-4F31-A40D-3AABF4EFBC78} + SolutionGuid = {55BC6629-85C9-48D8-8CA2-B0046AF1AF4B} EndGlobalSection EndGlobal diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions.cs deleted file mode 100644 index 485fbba..0000000 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Frends.Exchange.ReadEmail.Definitions -{ - - /// - /// Exchange server specific options. - /// - public class ExchangeSettings - { - /// - /// Which exchange server to target? - /// - public ExchangeServerVersion ExchangeServerVersion { get; set; } - - /// - /// If true, will try to auto discover server address from user name. - /// In this cae Host and Port values are not used. - /// - public bool UseAutoDiscover { get; set; } - - /// - /// Exchange server address. - /// - [DefaultValue("exchange.frends.com")] - [DisplayFormat(DataFormatString = "Text")] - [UIHint(nameof(UseAutoDiscover), "", false)] - public string ServerAddress { get; set; } - - /// - /// Try to login with agent account? - /// - [DefaultValue(false)] - [Description("Authorize with agent account")] - public bool UseAgentAccount { get; set; } - - /// - /// Email account to use. - /// - [DefaultValue("agent@frends.com")] - [DisplayFormat(DataFormatString = "Text")] - public string Username { get; set; } - - /// - /// Account password. - /// - [PasswordPropertyText] - [UIHint(nameof(UseAgentAccount), "", false)] - public string Password { get; set; } - - - /// - /// Inbox to read emails from. - /// If empty reads from default mailbox. - /// - [DefaultValue("agentinbox@frends.com")] - [DisplayFormat(DataFormatString = "Text")] - public string Mailbox { get; set; } - } - - - /// - /// Options related to Exchange reading. - /// - public class ExchangeOptions - { - /// - /// Maximum number of emails to retrieve. - /// - [DefaultValue(10)] - public int MaxEmails { get; set; } - - /// - /// Should get only unread emails? - /// - public bool GetOnlyUnreadEmails { get; set; } - - /// - /// If true, then marks queried emails as read. - /// - public bool MarkEmailsAsRead { get; set; } - - /// - /// If true, then received emails will be hard deleted. - /// - public bool DeleteReadEmails { get; set; } - - /// - /// Optional. - /// If a sender is given, it will be used to filter emails. - /// - [DefaultValue("")] - [DisplayFormat(DataFormatString = "Text")] - public string EmailSenderFilter { get; set; } - - /// - /// Optional. - /// If a subject is given, it will be used to filter emails. - /// - [DefaultValue("")] - [DisplayFormat(DataFormatString = "Text")] - public string EmailSubjectFilter { get; set; } - - /// - /// If true, the task throws an error if no messages matching search criteria were found. - /// - public bool ThrowErrorIfNoMessagesFound { get; set; } - - /// - /// If true, the task doesn't handle emails attachments. - /// - public bool IgnoreAttachments { get; set; } - - /// - /// If true, the task fetches only emails with attachments. - /// - [UIHint(nameof(IgnoreAttachments), "", false)] - public bool GetOnlyEmailsWithAttachments { get; set; } - - /// - /// Directory where attachments will be saved to. - /// - [DefaultValue("")] - [DisplayFormat(DataFormatString = "Text")] - [UIHint(nameof(IgnoreAttachments), "", false)] - public string AttachmentSaveDirectory { get; set; } - - /// - /// Should the attachment be overwritten, if the save directory already contains an attachment with the same name? - /// If no, a GUID will be added to the filename. - /// - [UIHint(nameof(IgnoreAttachments), "", false)] - public bool OverwriteAttachment { get; set; } - } - - - /// - /// Output result for read operation. - /// - public class EmailMessageResult - { - /// - /// Identifier for the email. - /// - public string Id { get; set; } - - /// - /// Recipient of the email. - /// - public string To { get; set; } - - /// - /// Carbon Copy-field of the email. - /// - public string Cc { get; set; } - - /// - /// Sender of the email. - /// - public string From { get; set; } - - /// - /// Email received date. - /// - public DateTime Date { get; set; } - - /// - /// Title of the email. - /// - public string Subject { get; set; } - - /// - /// Body of the email as text. - /// - public string BodyText { get; set; } - - /// - /// Body HTML, if available. - /// - public string BodyHtml { get; set; } - - /// - /// Attachment download path. - /// - public List AttachmentSaveDirs { get; set; } - } - - /// - /// Depicts a certain Exchange Server version. - /// - public enum ExchangeServerVersion - { -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - Exchange2007_SP1, - Exchange2010, - Exchange2010_SP1, - Exchange2010_SP2, - Exchange2013, - Exchange2013_SP1, - Office365 -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member - } -} diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Attachments.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Attachments.cs new file mode 100644 index 0000000..0868507 --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Attachments.cs @@ -0,0 +1,37 @@ +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Represents an attachment. +/// +public class Attachments +{ + /// + /// Specifies the ID of the attachment. + /// + /// AAMkADIxYTJiZDIz... + public string Id { get; set; } + + /// + /// Specifies the name of the attachment. + /// + /// C:\temp\file.txt + public string FilePath { get; set; } + + /// + /// Specifies the size of the attachment in bytes. + /// + /// 6000 + public int? Size { get; set; } + + /// + /// Specifies the data type of the attachment. + /// + /// #microsoft.graph.fileAttachment + public string OdataType { get; set; } + + /// + /// Specifies the content of the attachment. + /// + /// This is content. + public string Content { get; set; } +} diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Connection.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Connection.cs new file mode 100644 index 0000000..fdc2a22 --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Connection.cs @@ -0,0 +1,82 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Represents the parameters required to establish a connection. +/// +public class Connection +{ + /// + /// Specifies the type of authentication provider to use for the connection. + /// + /// + /// This property determines the type of authentication to use when establishing a connection. The available options are: + /// - `AuthenticationProviders.UsernamePassword`: This option uses a username and password for authentication. + /// - `AuthenticationProviders.ClientCredentialsCertificate`: This option uses a client certificate and private key for authentication. + /// - `AuthenticationProviders.ClientCredentialsSecret`: This option uses a client secret for authentication. + /// + /// + /// AuthenticationProviders.UsernamePassword + /// + [DefaultValue(AuthenticationProviders.UsernamePassword)] + public AuthenticationProviders AuthenticationProvider { get; set; } + + /// + /// Specifies the path to a file that contains both the client certificate and private key. + /// + /// + /// C:\Certificates\mycert.pfx + /// + [UIHint(nameof(AuthenticationProvider), "", AuthenticationProviders.ClientCredentialsCertificate)] + public string X509CertificateFilePath { get; set; } + + /// + /// Specifies the secret key of the client application. + /// + /// + /// Y2lzY29zeXN0ZW1zOmMxc2Nv + /// + [UIHint(nameof(AuthenticationProvider), "", AuthenticationProviders.ClientCredentialsSecret)] + [DisplayFormat(DataFormatString = "Text")] + public string ClientSecret { get; set; } + + /// + /// Specifies the username of the user. + /// + /// + /// johndoe@example.com + /// + [UIHint(nameof(AuthenticationProvider), "", AuthenticationProviders.UsernamePassword)] + public string Username { get; set; } + + /// + /// Specifies the password of the user. + /// + /// + /// SecurePassword123 + /// + [UIHint(nameof(AuthenticationProvider), "", AuthenticationProviders.UsernamePassword)] + [DisplayFormat(DataFormatString = "Text")] + [PasswordPropertyText] + public string Password { get; set; } + + /// + /// Specifies the app ID for fetching an access token. + /// This is the unique identifier for your application. + /// + /// + /// 4a1aa1d9-c16a-40a2-bd7d-2bd40babe4ff + /// + public string ClientId { get; set; } + + /// + /// Specifies the tenant ID for fetching an access token. + /// This is the unique identifier of your Azure AD tenant. + /// + /// + /// 9188040d-6c67-4c5b-b112-36a304b66dad + /// + public string TenantId { get; set; } +} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Enums.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Enums.cs new file mode 100644 index 0000000..63c3846 --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Enums.cs @@ -0,0 +1,45 @@ +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Authentication provider options. +/// +public enum AuthenticationProviders +{ + /// + /// Select this if the authentication should be done using a client certificate. This requires a certificate file and private key. + /// + ClientCredentialsCertificate, + + /// + /// Select this if the authentication should be done using a client secret. This requires a secret key from your application registration. + /// + ClientCredentialsSecret, + + /// + /// Select this if the authentication should be done using a username and password. This requires the username and password of the user. + /// + UsernamePassword +} + +/// +/// Specifies the action to take when a file already exists. +/// +public enum FileExistHandlers +{ + /// + /// Skip the file creation. + /// + Skip, + /// + /// Rename the file by appending a unique number. + /// + Rename, + /// + /// Append to the existing file. + /// + Append, + /// + /// Overwrite the existing file. + /// + OverWrite +} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/HeaderParameters.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/HeaderParameters.cs new file mode 100644 index 0000000..f2826c9 --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/HeaderParameters.cs @@ -0,0 +1,19 @@ +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Represents the parameters for a header. +/// +public class HeaderParameters +{ + /// + /// Specifies the name of the header to which values will be added. + /// + /// Prefer + public string HeaderName { get; set; } + + /// + /// Specifies the values to add to the header. + /// + /// ["outlook.body-content-type=\"text\", "foo""] + public string[] HeaderValues { get; set; } +} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Input.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Input.cs new file mode 100644 index 0000000..c7270d6 --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Input.cs @@ -0,0 +1,97 @@ +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Email content. +/// +public class Input +{ + /// + /// User entity to get emails from. Can be left empty to use default user. + /// + /// johndoe@example.com + public string From { get; set; } + + /// + /// Select properties to be returned. + /// + /// subject,body,bodyPreview,uniqueBody,hasAttachments + public string Select { get; set; } + + /// + /// Filter items by property values. + /// + /// parentFolderId eq 'INBOX' and from/emailAddress/address eq 'user@exampledomain.com' and subject eq 'This is subject' and isRead eq true and hasAttachments eq true + public string Filter { get; set; } + + /// + /// Skip the first n items. + /// + /// 10 + [DefaultValue(0)] + public int Skip { get; set; } + + /// + /// Limits the result to the first n items. If set to 0, all items are returned. + /// + /// 10 + [DefaultValue(0)] + public int Top { get; set; } + + /// + /// Order items by property values. + /// + /// receivedDateTime DESC,subject ASC + public string Orderby { get; set; } + + /// + /// Expand related entities. + /// + /// attachments + public string Expand { get; set; } + + /// + /// Specifies whether to mark an email as read after processing it. If set to true, the email will be marked as read in the mailbox. If set to false, the email's read status will remain unchanged. + /// + /// true + [DefaultValue(true)] + public bool UpdateReadStatus { get; set; } + + /// + /// Header parameters. + /// + /// { [ "Prefer", "outlook.body-content-type=\"text\"" ] } + public HeaderParameters[] Headers { get; set; } + + /// + /// Specifies whether to download attachments. + /// + /// true + [DefaultValue(true)] + public bool DownloadAttachments { get; set; } + + /// + /// Specifies the directory where the downloaded attachments will be stored. + /// + /// C:\\Users\\username\\Downloads + [UIHint(nameof(DownloadAttachments), "", true)] + public string DestinationDirectory { get; set; } + + /// + /// Create directory where the downloaded attachments will be stored. + /// + /// + [UIHint(nameof(DownloadAttachments), "", true)] + public bool CreateDirectory { get; set; } + + /// + /// Specifies the action to take when a file with the same name already exists in the destination directory. + /// If FileExistHandler.Rename is selected, an unique number will be appended to the name of the file to be downloaded (e.g., file(2).txt). + /// + /// FileExistHandlers.Skip + [UIHint(nameof(DownloadAttachments), "", true)] + [DefaultValue(FileExistHandlers.Skip)] + public FileExistHandlers FileExistHandler { get; set; } +} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Options.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Options.cs new file mode 100644 index 0000000..ab3967d --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Options.cs @@ -0,0 +1,17 @@ +using System.ComponentModel; + +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Options for controlling the behavior of a task. +/// +public class Options +{ + /// + /// Gets or sets a value indicating whether an error should stop the task and throw an exception. + /// If set to true, an exception will be thrown when an error occurs. If set to false, Task will try to continue and the error message will be added into Result.ErrorMessages and Result.Success will be set to false. + /// + /// true + [DefaultValue(true)] + public bool ThrowExceptionOnFailure { get; set; } +} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Result.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Result.cs new file mode 100644 index 0000000..32dac04 --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/Result.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Represents the result of a task. +/// +public class Result +{ + /// + /// Gets a value indicating whether the task was executed successfully. + /// + /// true + public bool Success { get; private set; } + + /// + /// Gets the result of the task. Contains exception message if exception was thrown and Options.ThrowExceptionOnFailure = false. + /// + /// { "AAMkADIxYTJiZDIz", "C:\temp\file.txt", 6000, "#microsoft.graph.fileAttachment", "This is content." } + public List Data { get; private set; } + + /// + /// Error messages. + /// + /// { "error occured", "another error occured." } + public List ErrorMessages { get; private set; } + + internal Result(bool success, List data, List errorMessage) + { + Success = success; + Data = data; + ErrorMessages = errorMessage; + } +} diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/ResultObject.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/ResultObject.cs new file mode 100644 index 0000000..fce538b --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Definitions/ResultObject.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; + +namespace Frends.Exchange.ReadEmail.Definitions; + +/// +/// Represents the result of an operation. +/// +public class ResultObject +{ + /// + /// The unique identifier of the result object. + /// + /// "AAMkADIxY..." + public string Id { get; set; } + + /// + /// The unique identifier of the parent folder. + /// + /// AAMkADIxYTJ... + public string ParentFolderId { get; set; } + + /// + /// From email address. + /// + /// johndoe@example.com + public string From { get; set; } + + /// + /// The email address of the sender. + /// + /// johndoe@example.com + public string Sender { get; set; } + + /// + /// The list of email addresses of the recipients. + /// + /// { "bar@exampledomain.com", "foo@exampledomain.com" } + public List ToRecipients { get; set; } + + /// + /// The list of email addresses of the CC recipients. + /// + /// { "bar@exampledomain.com", "foo@exampledomain.com" } + public List CcRecipients { get; set; } + + /// + /// The list of email addresses of the BCC recipients. + /// + /// { "bar@exampledomain.com", "foo@exampledomain.com" } + public List BccRecipients { get; set; } + + /// + /// The list of email addresses to reply to. + /// + /// { "bar@exampledomain.com", "foo@exampledomain.com" } + public List ReplyTo { get; set; } + + /// + /// The subject of the message. + /// + /// This is subject. + public string Subject { get; set; } + + /// + /// The content type of the message. + /// + /// HTML + public string ContentType { get; set; } + + /// + /// The content of the message. + /// + /// This is message's content. + public string Content { get; set; } + + /// + /// The categories of the message. + /// + /// { "Blue Category" } + public List Categories { get; set; } + + /// + /// The importance of the message. + /// + /// Normal + public string Importance { get; set; } + + /// + /// Indicates whether the message is a draft. + /// + /// false + public bool IsDraft { get; set; } + + /// + /// Indicates whether the message has been read. + /// + /// false + public bool IsRead { get; set; } + + /// + /// Indicates whether the message has attachments. + /// + /// true + public bool HasAttachments { get; set; } + + /// + /// The list of extensions of the message. + /// + /// { "foo", "bar" } + public List Extensions { get; set; } + + /// + /// The list of attachments of the message. + /// + /// + /// { + /// { "AAMkADIxYTJiZDIz...", "C:\temp\file.txt", 6000, "#microsoft.graph.itemAttachment", "This is content" }, + /// { "RjZTNiNwBGAAAAaa...", "C:\temp\file2.txt", 6001, "#microsoft.graph.fileAttachment", "#microsoft.graph.itemAttachment" } + /// } + /// + public List Attachments { get; set; } +} diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.cs new file mode 100644 index 0000000..bbcbc6d --- /dev/null +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.cs @@ -0,0 +1,285 @@ +using Azure.Identity; +using Frends.Exchange.ReadEmail.Definitions; +using Microsoft.Graph; +using Microsoft.Graph.Models; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Frends.Exchange.ReadEmail; + +/// +/// Microsoft Exchange Task. +/// +public class Exchange +{ + /// + /// List of temp files to be deleted. + /// + internal static List tempFilePaths = new(); + + /// + /// Read Microsoft Exchange emails and downloading their attachments. + /// [Documentation](https://tasks.frends.com/tasks/frends-tasks/Frends.Exchange.ReadEmail) + /// + /// Parameters for establishing a connection. + /// Email content + /// Options for controlling the behavior of this Task. + /// Token received from Frends to cancel this Task. + /// Object { bool Success, List<ResultObject> Data, List<dynamic> ErrorMessages } + public static async Task ReadEmail([PropertyTab] Connection connection, [PropertyTab] Input input, [PropertyTab] Options options, CancellationToken cancellationToken) + { + var resultList = new List(); + var errors = new List(); + + try + { + InputCheck(connection, input); + GraphServiceClient client = CreateGraphServiceClient(connection); + var messageCollectionResponse = await GetMessageCollectionResponse(input, connection.AuthenticationProvider, client, cancellationToken); + + if (messageCollectionResponse.Value != null) + { + foreach (var message in messageCollectionResponse.Value) + { + var resultObject = new ResultObject() + { + Id = message.Id, + ParentFolderId = message.ParentFolderId, + From = message.From?.EmailAddress?.Address, + Sender = message.Sender?.EmailAddress?.Address, + ToRecipients = message.ToRecipients?.Where(r => r != null && r.EmailAddress != null).Select(r => r.EmailAddress.Address).ToList(), + CcRecipients = message.CcRecipients?.Where(r => r != null && r.EmailAddress != null).Select(r => r.EmailAddress.Address).ToList(), + BccRecipients = message.BccRecipients?.Where(r => r != null && r.EmailAddress != null).Select(r => r.EmailAddress.Address).ToList(), + ReplyTo = message.ReplyTo?.Where(r => r != null && r.EmailAddress != null).Select(r => r.EmailAddress.Address).ToList(), + Subject = message.Subject, + ContentType = message.Body?.ContentType.ToString(), + Content = message.Body?.Content, + Categories = message.Categories, + Importance = message.Importance.ToString(), + IsDraft = message.IsDraft ?? false, + IsRead = message.IsRead ?? false, + HasAttachments = message.HasAttachments ?? false, + Extensions = message.Extensions?.Where(e => e != null).Select(e => e.Id).ToList() + }; + + if (input.DownloadAttachments && message.HasAttachments is true) + resultObject.Attachments = await DownloadAttachments(input, connection.AuthenticationProvider, message, client, cancellationToken); + + resultList.Add(resultObject); + + // Email won't be marked as read without doing it manually + if (input.UpdateReadStatus) + await UpdateMessageRead(connection.AuthenticationProvider, input.From, message.Id, client, cancellationToken); + } + } + } + catch (Exception ex) + { + if (options.ThrowExceptionOnFailure) + throw; + else + errors.Add(ex); + } + + return new Result(errors.Count <= 0, resultList, errors); + } + + private static void InputCheck(Connection connection, Input input) + { + switch (connection.AuthenticationProvider) + { + case AuthenticationProviders.ClientCredentialsCertificate: + if (string.IsNullOrWhiteSpace(connection.TenantId) || string.IsNullOrWhiteSpace(connection.ClientId) || string.IsNullOrWhiteSpace(connection.X509CertificateFilePath)) + throw new ArgumentNullException(@"One or more required connection values missing:", $"{nameof(connection.TenantId)}, {nameof(connection.ClientId)}, {nameof(connection.X509CertificateFilePath)}."); + break; + case AuthenticationProviders.ClientCredentialsSecret: + if (string.IsNullOrWhiteSpace(connection.TenantId) || string.IsNullOrWhiteSpace(connection.ClientId) || string.IsNullOrWhiteSpace(connection.ClientSecret)) + throw new ArgumentNullException(@"One or more required connection values missing:", $"{nameof(connection.TenantId)}, {nameof(connection.ClientId)}, {nameof(connection.ClientSecret)}."); + break; + case AuthenticationProviders.UsernamePassword: + if (string.IsNullOrWhiteSpace(connection.Username) || string.IsNullOrWhiteSpace(connection.Password) || string.IsNullOrWhiteSpace(connection.TenantId) || string.IsNullOrWhiteSpace(connection.ClientId)) + throw new ArgumentNullException(@"One or more required connection values missing:", $"{nameof(connection.Username)}, {nameof(connection.Password)}, {nameof(connection.TenantId)}, {nameof(connection.ClientId)}."); + break; + } + + if (input.DownloadAttachments && string.IsNullOrWhiteSpace(input.DestinationDirectory)) + throw new ArgumentNullException($@"{nameof(input.DownloadAttachments)} is set to true, but {nameof(input.DestinationDirectory)} is missing."); + + if (!string.IsNullOrWhiteSpace(input.DestinationDirectory) && !input.CreateDirectory && !Directory.Exists(input.DestinationDirectory)) + throw new Exception($@"{nameof(input.DestinationDirectory)} is set, but the directory {input.DestinationDirectory} does not exist. Set {nameof(input.CreateDirectory)} to true to create the specified directory."); + } + + private static GraphServiceClient CreateGraphServiceClient(Connection connection) + { + var options = new TokenCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud }; + + switch (connection.AuthenticationProvider) + { + case AuthenticationProviders.ClientCredentialsCertificate: + var clientCertCredential = new ClientCertificateCredential(connection.TenantId, connection.ClientId, connection.X509CertificateFilePath, options); + return new GraphServiceClient(clientCertCredential); + case AuthenticationProviders.ClientCredentialsSecret: + var clientSecretCredential = new ClientSecretCredential(connection.TenantId, connection.ClientId, connection.ClientSecret, options); + return new GraphServiceClient(clientSecretCredential); + case AuthenticationProviders.UsernamePassword: + var credentials = new UsernamePasswordCredential(connection.Username, connection.Password, connection.TenantId, connection.ClientId, options); + return new GraphServiceClient(credentials); + default: + throw new ArgumentException($"Invalid {nameof(connection.AuthenticationProvider)}."); + } + } + + private static async Task GetMessageCollectionResponse(Input input, AuthenticationProviders authenticationProviders, GraphServiceClient client, CancellationToken cancellationToken) + { + if (authenticationProviders is AuthenticationProviders.UsernamePassword) + { + return await client.Me.Messages.GetAsync((requestConfiguration) => + { + requestConfiguration.QueryParameters.Count = true; + requestConfiguration.QueryParameters.Filter = string.IsNullOrWhiteSpace(input.Filter) ? null : input.Filter; + requestConfiguration.QueryParameters.Skip = input.Skip; + requestConfiguration.QueryParameters.Top = input.Top > 0 ? input.Top : null; + requestConfiguration.QueryParameters.Orderby = string.IsNullOrWhiteSpace(input.Orderby) ? null : input.Orderby.Split(new[] { "\", \"" }, StringSplitOptions.None); + requestConfiguration.QueryParameters.Expand = string.IsNullOrWhiteSpace(input.Expand) ? null : input.Expand.Split(new[] { "\", \"" }, StringSplitOptions.None); + if (input.Headers != null && input.Headers.Length > 0) + foreach (var header in input.Headers) + requestConfiguration.Headers.Add(header.HeaderName, header.HeaderValues.ToArray()); + }, cancellationToken); + } + + return await client.Users[input.From].Messages.GetAsync((requestConfiguration) => + { + requestConfiguration.QueryParameters.Count = true; + requestConfiguration.QueryParameters.Filter = string.IsNullOrWhiteSpace(input.Filter) ? null : input.Filter; + requestConfiguration.QueryParameters.Skip = input.Skip; + requestConfiguration.QueryParameters.Top = input.Top > 0 ? input.Top : null; + requestConfiguration.QueryParameters.Orderby = string.IsNullOrWhiteSpace(input.Orderby) ? null : input.Orderby.Split(new[] { "\", \"" }, StringSplitOptions.None); + requestConfiguration.QueryParameters.Expand = string.IsNullOrWhiteSpace(input.Expand) ? null : input.Expand.Split(new[] { "\", \"" }, StringSplitOptions.None); + if (input.Headers != null && input.Headers.Length > 0) + foreach (var header in input.Headers) + requestConfiguration.Headers.Add(header.HeaderName, header.HeaderValues.ToArray()); + }, cancellationToken); + } + + private static async Task> DownloadAttachments(Input input, AuthenticationProviders authenticationProviders, Message message, GraphServiceClient client, CancellationToken cancellationToken) + { + if (input.CreateDirectory && !Directory.Exists(input.DestinationDirectory)) + Directory.CreateDirectory(input.DestinationDirectory); + + var attachmentsList = new List(); + AttachmentCollectionResponse attachments = null; + + if (authenticationProviders is AuthenticationProviders.UsernamePassword) + attachments = client.Me.Messages[message.Id].Attachments.GetAsync((requestConfiguration) => + { + requestConfiguration.QueryParameters.Expand = new string[] { "microsoft.graph.itemattachment/item" }; + }, cancellationToken: cancellationToken).Result; + else + attachments = client.Users[input.From].Messages[message.Id].Attachments.GetAsync((requestConfiguration) => + { + requestConfiguration.QueryParameters.Expand = new string[] { "microsoft.graph.itemattachment/item" }; + }, cancellationToken: cancellationToken).Result; + + foreach (var attachment in attachments.Value) + { + if (attachment is FileAttachment fileAttachment) + { + var createFileFromBytes = await CreateFileFromBytes(input.FileExistHandler, fileAttachment.ContentBytes, Path.Combine(input.DestinationDirectory, fileAttachment.Name), cancellationToken); + attachmentsList.Add(new Attachments() + { + Id = fileAttachment.Id, + FilePath = createFileFromBytes, + Size = fileAttachment.Size, + OdataType = fileAttachment.OdataType, + Content = null, + }); + } + // ItemAttachment represents an email with its own attachments, attached to another email. This will downloads the attachments of the attached email. + if (attachment is ItemAttachment itemAttachment) + { + if (itemAttachment.Item is Message item) + foreach (var innerAttachment in item.Attachments) + { + var itemAsFileAttachment = innerAttachment as FileAttachment; + var itemBytes = itemAsFileAttachment.ContentBytes; + var createFileFromBytes = await CreateFileFromBytes(input.FileExistHandler, itemBytes, Path.Combine(input.DestinationDirectory, innerAttachment.Name), cancellationToken); + + attachmentsList.Add(new Attachments() + { + Id = itemAttachment.Id, + FilePath = createFileFromBytes, + Size = itemAttachment.Size, + OdataType = itemAttachment.OdataType, + Content = null, + }); + } + } + } + return attachmentsList; + } + + private static async Task CreateFileFromBytes(FileExistHandlers fileExistHandler, byte[] contentBytes, string filePath, CancellationToken cancellationToken) + { + if (!File.Exists(filePath)) + { + using (var fs = new FileStream(filePath, FileMode.CreateNew)) + await fs.WriteAsync(contentBytes, cancellationToken); + return filePath; + } + else + { + switch (fileExistHandler) + { + case FileExistHandlers.Skip: + return $@"The file {filePath} already exists. Download skipped."; + case FileExistHandlers.Rename: + filePath = GetUniqueFilePath(filePath); + using (var fs = new FileStream(filePath, FileMode.CreateNew)) + await fs.WriteAsync(contentBytes, cancellationToken); + return filePath; + case FileExistHandlers.Append: + using (var fs = new FileStream(filePath, FileMode.Append)) + await fs.WriteAsync(contentBytes, cancellationToken); + return filePath; + case FileExistHandlers.OverWrite: + using (var fs = new FileStream(filePath, FileMode.Create)) + await fs.WriteAsync(contentBytes, cancellationToken); + return filePath; + default: throw new Exception("An exception occurred while trying to handle an already existing file."); + } + } + } + + private static string GetUniqueFilePath(string filePath) + { + if (File.Exists(filePath)) + { + var directory = Path.GetDirectoryName(filePath); + var fileName = Path.GetFileNameWithoutExtension(filePath); + var extension = Path.GetExtension(filePath); + var count = 1; + + while (File.Exists(Path.Combine(directory, $"{fileName}({count}){extension}"))) + count++; + + return Path.Combine(directory, $"{fileName}({count}){extension}"); + } + + return filePath; + } + + private static async Task UpdateMessageRead(AuthenticationProviders authenticationProviders, string from, string messageId, GraphServiceClient client, CancellationToken cancellationToken) + { + var requestBody = new Message { IsRead = true }; + + if (authenticationProviders is AuthenticationProviders.UsernamePassword) + await client.Me.Messages[messageId].PatchAsync(requestBody, cancellationToken: cancellationToken); + else + await client.Users[from].Messages[messageId].PatchAsync(requestBody, cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.csproj b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.csproj index f89df3b..294eda7 100644 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.csproj +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail.csproj @@ -1,22 +1,31 @@ - + - - net6.0 - 0.0.1 - Frends - Frends - Frends - Frends - Frends - MIT - true - Reads email via Microsoft Exchange. - https://frends.com/ - https://github.com/FrendsPlatform/Frends.Exchange - + + net6.0 + 1.0.0 + Frends + Frends + Frends + Frends + Frends + MIT + true + Frends Task for reading Microsoft Exchange emails and downloading their attachments. + https://frends.com/ + https://github.com/FrendsPlatform/Frends.Exchange + + + + + PreserveNewest + + + + + + + + + - - - - - + \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/FrendsTaskMetadata.json b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/FrendsTaskMetadata.json index 0c9d68f..fab5696 100644 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/FrendsTaskMetadata.json +++ b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/FrendsTaskMetadata.json @@ -4,4 +4,4 @@ "TaskMethod": "Frends.Exchange.ReadEmail.Exchange.ReadEmail" } ] -} \ No newline at end of file +} diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/ReadEmail.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/ReadEmail.cs deleted file mode 100644 index 323c271..0000000 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/ReadEmail.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Frends.Exchange.ReadEmail.Definitions; -using Microsoft.Exchange.WebServices.Data; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Linq; -using System.Threading.Tasks; - -namespace Frends.Exchange.ReadEmail; - -/// -/// Tasks for interacting with Microsoft Exchange. -/// -public static class Exchange -{ - /// - /// Reads emails via Microsoft Exchange. - /// - public async static Task> ReadEmail([PropertyTab] ExchangeSettings settings, [PropertyTab] ExchangeOptions options) - { - if (!options.IgnoreAttachments) - { - if (string.IsNullOrEmpty(options.AttachmentSaveDirectory)) { - throw new ArgumentNullException("No save directory given.", nameof(ExchangeOptions.AttachmentSaveDirectory)); - } - if (!Directory.Exists(options.AttachmentSaveDirectory)) - { - throw new DirectoryNotFoundException($"Could not find or access attachment save directory {options.AttachmentSaveDirectory}"); - } - } - if (options.MaxEmails <= 0) - { - throw new ArgumentException("MaxEmails can't be lower than 1."); - } - - - // Connect, create view and search filter - ExchangeService exchangeService = Util.ConnectToExchangeService(settings); - ItemView view = new(options.MaxEmails); - var searchFilter = BuildFilterCollection(options); - FindItemsResults exchangeResults; - - if (!string.IsNullOrEmpty(settings.Mailbox)) - { - var mb = new Mailbox(settings.Mailbox); - var fid = new FolderId(WellKnownFolderName.Inbox, mb); - var inbox = await Folder.Bind(exchangeService, fid); - exchangeResults = searchFilter.Count == 0 ? await inbox.FindItems(view) : await inbox.FindItems(searchFilter, view); - } - else - { - exchangeResults = searchFilter.Count == 0 ? await exchangeService.FindItems(WellKnownFolderName.Inbox, view) : await exchangeService.FindItems(WellKnownFolderName.Inbox, searchFilter, view); - } - // Get email items - var emails = exchangeResults.Where(msg => msg is EmailMessage).Cast().ToList(); - - // Check if list is empty and if an error needs to be thrown. - if (emails.Any() && options.ThrowErrorIfNoMessagesFound) - { - // If not, return a result with a notification of no found messages. - throw new ArgumentException("No messages found matching the search filter.", - paramName: nameof(options.ThrowErrorIfNoMessagesFound)); - } - - // Load properties for each email and process attachments - var result = ReadEmails(emails, exchangeService, options); - - // should delete mails? - if (options.DeleteReadEmails) - emails.ForEach(msg => msg.Delete(DeleteMode.HardDelete)); - - // should mark mails as read? - if (!options.DeleteReadEmails && options.MarkEmailsAsRead) - { - foreach (EmailMessage msg in emails) - { - msg.IsRead = true; - await msg.Update(ConflictResolutionMode.AutoResolve); - } - } - - return await result; - } - - /// - /// Build search filter from options. - /// - /// Options. - /// Search filter collection. - private static SearchFilter.SearchFilterCollection BuildFilterCollection(ExchangeOptions options) - { - // Create search filter collection. - var searchFilter = new SearchFilter.SearchFilterCollection(LogicalOperator.And); - - // Construct rest of search filter based on options - if (options.GetOnlyEmailsWithAttachments) - searchFilter.Add(new SearchFilter.IsEqualTo(ItemSchema.HasAttachments, true)); - - if (options.GetOnlyUnreadEmails) - searchFilter.Add(new SearchFilter.IsEqualTo(EmailMessageSchema.IsRead, false)); - - if (!string.IsNullOrEmpty(options.EmailSenderFilter)) - searchFilter.Add(new SearchFilter.IsEqualTo(EmailMessageSchema.Sender, options.EmailSenderFilter)); - - if (!string.IsNullOrEmpty(options.EmailSubjectFilter)) - searchFilter.Add(new SearchFilter.ContainsSubstring(EmailMessageSchema.Subject, options.EmailSubjectFilter)); - - return searchFilter; - } - - /// - /// Convert Email collection t EMailMessageResults. - /// - /// Emails collection. - /// Exchange services. - /// Options. - /// Collection of EmailMessageResult. - private async static Task> ReadEmails(IEnumerable emails, ExchangeService exchangeService, ExchangeOptions options) - { - List result = new(); - - foreach (EmailMessage email in emails) - { - // Define property set - var propSet = new PropertySet( - BasePropertySet.FirstClassProperties, - EmailMessageSchema.Body, - EmailMessageSchema.Attachments); - - // Bind and load email message with desired properties - var newEmail = await EmailMessage.Bind(exchangeService, email.Id, propSet); - - var pathList = new List(); - if (!options.IgnoreAttachments) - { - // Save all attachments to given directory - - pathList = Util.SaveAttachments(newEmail.Attachments, options); - } - - // Build result for email message - - var emailMessage = new EmailMessageResult - { - Id = newEmail.Id.UniqueId, - Date = newEmail.DateTimeReceived, - Subject = newEmail.Subject, - BodyText = "", - BodyHtml = newEmail.Body.Text, - To = string.Join(",", newEmail.ToRecipients.Select(j => j.Address)), - From = newEmail.From.Address, - Cc = string.Join(",", newEmail.CcRecipients.Select(j => j.Address)), - AttachmentSaveDirs = pathList - }; - - // Catch exception in case of server version is earlier than Exchange2013 - try { emailMessage.BodyText = newEmail.TextBody.Text; } catch { } - - result.Add(emailMessage); - } - - return result; - } - -} \ No newline at end of file diff --git a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Util.cs b/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Util.cs deleted file mode 100644 index f6dea0c..0000000 --- a/Frends.Exchange.ReadEmail/Frends.Exchange.ReadEmail/Util.cs +++ /dev/null @@ -1,158 +0,0 @@ -using Frends.Exchange.ReadEmail.Definitions; -using Microsoft.Exchange.WebServices.Data; -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; - -namespace Frends.Exchange.ReadEmail -{ - /// - /// A collection of methods that are public to ensure testing, - /// but in a separate class to hide them from UI. - /// - public static class Util - { - /// - /// Save attachments from collection to files. - /// - /// List of full paths to saved file attachments. - public static List SaveAttachments(AttachmentCollection attachments, ExchangeOptions options) - { - List pathList = new() { }; - - foreach (var attachment in attachments) - { - FileAttachment file = attachment as FileAttachment; - string path = Path.Combine( - options.AttachmentSaveDirectory, - options.OverwriteAttachment ? file.Name : - string.Concat( - Path.GetFileNameWithoutExtension(file.Name), "_", - Guid.NewGuid().ToString(), - Path.GetExtension(file.Name)) - ); - file.Load(path); - pathList.Add(path); - } - - return pathList; - } - - /// - /// As Per MSDN Example, to ensure SSL. Copy and Paste. - /// https://msdn.microsoft.com/en-us/library/office/dd633677(v=exchg.80).aspx - /// - /// - /// - /// - /// - /// bool - public static bool ExchangeCertificateValidationCallBack(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) - { - // If the certificate is a valid, signed certificate, return true. - if (sslPolicyErrors == SslPolicyErrors.None) return true; - - // If there are errors in the certificate chain, look at each error to determine the cause. - if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateChainErrors) != 0) - { - if (chain != null && chain.ChainStatus != null) - { - foreach (var status in chain.ChainStatus) - { - if (status.Status != X509ChainStatusFlags.NoError) - { - return false; - } - } - } - - // When processing reaches this line, the only errors in the certificate chain are untrusted root errors for self-signed certificates. - // These certificates are valid for default Exchange server installations, so return true. - return true; - } - // In all other cases, return false. - return false; - } - - /// - /// Helper for connecting to Exchange service. - /// - /// Exchange server related settings - /// - public static ExchangeService ConnectToExchangeService(ExchangeSettings settings) - { - ExchangeVersion ev; - var office365 = false; - switch (settings.ExchangeServerVersion) - { - case ExchangeServerVersion.Exchange2007_SP1: - ev = ExchangeVersion.Exchange2007_SP1; - break; - case ExchangeServerVersion.Exchange2010: - ev = ExchangeVersion.Exchange2010; - break; - case ExchangeServerVersion.Exchange2010_SP1: - ev = ExchangeVersion.Exchange2010_SP1; - break; - case ExchangeServerVersion.Exchange2010_SP2: - ev = ExchangeVersion.Exchange2010_SP2; - break; - case ExchangeServerVersion.Exchange2013: - ev = ExchangeVersion.Exchange2013; - break; - case ExchangeServerVersion.Exchange2013_SP1: - ev = ExchangeVersion.Exchange2013_SP1; - break; - case ExchangeServerVersion.Office365: - ev = ExchangeVersion.Exchange2013_SP1; - office365 = true; - break; - default: - ev = ExchangeVersion.Exchange2013; - break; - } - - var service = new ExchangeService(ev); - - // SSL certification check. - ServicePointManager.ServerCertificateValidationCallback = ExchangeCertificateValidationCallBack; - - if (!office365) - { - if (string.IsNullOrWhiteSpace(settings.Username)) service.UseDefaultCredentials = true; - else service.Credentials = new NetworkCredential(settings.Username, settings.Password); - } - else service.Credentials = new WebCredentials(settings.Username, settings.Password); - - if (settings.UseAutoDiscover) service.AutodiscoverUrl(settings.Username, RedirectionUrlValidationCallback); - else service.Url = new Uri(settings.ServerAddress); - - return service; - } - - // The following is a basic redirection validation callback method. - // It inspects the redirection URL and only allows the Service object to follow the redirection link if the URL is using HTTPS. - // This redirection URL validation callback provides sufficient security for development and testing of your application. - // However, it may not provide sufficient security for your deployed application. - // You should always make sure that the URL validation callback method that you use meets the security requirements of your organization. - /// - /// Returns true if the provided url has https scheme. Otherwise returns false. - /// - public static bool RedirectionUrlValidationCallback(string redirectionUrl) - { - // The default for the validation callback is to reject the URL. - var result = false; - var redirectionUri = new Uri(redirectionUrl); - - // Validate the contents of the redirection URL. - // In this simple validation callback, the redirection URL is considered valid if it is using HTTPS to encrypt the authentication credentials. - if (redirectionUri.Scheme == "https") result = true; - - return result; - } - - } -} diff --git a/Frends.Exchange.ReadEmail/README.md b/Frends.Exchange.ReadEmail/README.md new file mode 100644 index 0000000..80b9d6b --- /dev/null +++ b/Frends.Exchange.ReadEmail/README.md @@ -0,0 +1,24 @@ +# Frends.Exchange.ReadEmail +Frends Task for reading Microsoft Exchange emails and downloading their attachments. + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) +[![Build](https://github.com/FrendsPlatform/Frends.Exchange/actions/workflows/ReadEmail_build_and_test_on_main.yml/badge.svg)](https://github.com/FrendsPlatform/Frends.Exchange/actions) +![Coverage](https://app-github-custom-badges.azurewebsites.net/Badge?key=FrendsPlatform/Frends.Exchange/Frends.Exchange.ReadEmail|main) + +# Installing + +You can install the Task via Frends UI Task View. + +## Building + +Rebuild the project + +`dotnet build` + +Run tests + +`dotnet test` + +Create a NuGet package + +`dotnet pack --configuration Release` \ No newline at end of file diff --git a/README.md b/README.md index 72c6abf..f084d5c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Frends Task for Microsoft Exchange operations. # Tasks -- [Frends.Exchange.ReadMessage](Frends.Exchange.ReadMessage/README.md) -- [Frends.Exchange.SendMessage](Frends.Exchange.SendMessage/README.md) +- [Frends.Exchange.ReadEmail](Frends.Exchange.ReadEmail/README.md) +- [Frends.Exchange.SendEmail](Frends.Exchange.SendEmail/README.md) # Contributing When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change.