From 16e3cdc6de1488e1f65bd47b137c63c817a4cd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Sj=C3=B8l=C3=B8kken?= Date: Sun, 30 Oct 2022 16:51:04 +0100 Subject: [PATCH 1/6] Add Azure Email Communication Services Sender --- FluentEmail.sln | 7 + .../AzureEmailSender.cs | 204 ++++++++++++++++++ .../FluentEmail.Azure.Email.csproj | 20 ++ .../FluentEmailAzureEmailBuilderExtensions.cs | 50 +++++ .../AzureEmailSenderTests.cs | 129 +++++++++++ 5 files changed, 410 insertions(+) create mode 100644 src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs create mode 100644 src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj create mode 100644 src/Senders/FluentEmail.Azure.Email/FluentEmailAzureEmailBuilderExtensions.cs create mode 100644 test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs diff --git a/FluentEmail.sln b/FluentEmail.sln index 7b3ce08e..03ea4079 100644 --- a/FluentEmail.sln +++ b/FluentEmail.sln @@ -43,6 +43,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FluentEmail.Liquid", "src\R EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FluentEmail.Liquid.Tests", "test\FluentEmail.Liquid.Tests\FluentEmail.Liquid.Tests.csproj", "{C8063CBA-D8F3-467A-A75C-63843F0DE862}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentEmail.Azure.Email", "src\Senders\FluentEmail.Azure.Email\FluentEmail.Azure.Email.csproj", "{7A36357D-5CE6-4E90-BE5F-8E51553F6846}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -97,6 +99,10 @@ Global {C8063CBA-D8F3-467A-A75C-63843F0DE862}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8063CBA-D8F3-467A-A75C-63843F0DE862}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8063CBA-D8F3-467A-A75C-63843F0DE862}.Release|Any CPU.Build.0 = Release|Any CPU + {7A36357D-5CE6-4E90-BE5F-8E51553F6846}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A36357D-5CE6-4E90-BE5F-8E51553F6846}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A36357D-5CE6-4E90-BE5F-8E51553F6846}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A36357D-5CE6-4E90-BE5F-8E51553F6846}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -117,6 +123,7 @@ Global {0C7819AD-BC76-465D-9B2A-BE2DA75042F2} = {926C0980-31D9-4449-903F-3C756044C28A} {17100F47-A555-4756-A25F-4F05EDAFA74E} = {12F031E5-8DDC-40A0-9862-8764A6E190C0} {C8063CBA-D8F3-467A-A75C-63843F0DE862} = {47CB89AC-9615-4FA8-90DE-2D849935C36D} + {7A36357D-5CE6-4E90-BE5F-8E51553F6846} = {926C0980-31D9-4449-903F-3C756044C28A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {23736554-5288-4B30-9710-B4D9880BCF0B} diff --git a/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs b/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs new file mode 100644 index 00000000..9dfa2852 --- /dev/null +++ b/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure; +using Azure.Communication.Email; +using Azure.Communication.Email.Models; +using Azure.Core; +using FluentEmail.Core; +using FluentEmail.Core.Interfaces; +using FluentEmail.Core.Models; + +namespace FluentEmail.Azure.Email; + +// Read more about Azure Email Communication Services here: +// https://learn.microsoft.com/en-us/azure/communication-services/quickstarts/email/send-email?pivots=programming-language-csharp +public class AzureEmailSender : ISender +{ + private EmailClient _emailClient; + + /// + /// Initializes a new instance of + /// + /// Connection string acquired from the Azure Communication Services resource. + public AzureEmailSender(string connectionString) + { + _emailClient = new EmailClient(connectionString); + } + + /// Initializes a new instance of . + /// Connection string acquired from the Azure Communication Services resource. + /// Client option exposing , , , etc. + public AzureEmailSender(string connectionString, EmailClientOptions options) + { + _emailClient = new EmailClient(connectionString, options); + } + + /// Initializes a new instance of . + /// The URI of the Azure Communication Services resource. + /// The used to authenticate requests. + /// Client option exposing , , , etc. + public AzureEmailSender(Uri endpoint, AzureKeyCredential keyCredential, EmailClientOptions options = default) + { + _emailClient = new EmailClient(endpoint, keyCredential, options); + } + + /// Initializes a new instance of . + /// The URI of the Azure Communication Services resource. + /// The TokenCredential used to authenticate requests, such as DefaultAzureCredential. + /// Client option exposing , , , etc. + public AzureEmailSender(Uri endpoint, TokenCredential tokenCredential, EmailClientOptions options = default) + { + _emailClient = new EmailClient(endpoint, tokenCredential, options); + } + + public SendResponse Send(IFluentEmail email, CancellationToken? token = null) + { + return SendAsync(email, token).GetAwaiter().GetResult(); + } + + public async Task SendAsync(IFluentEmail email, CancellationToken? token = null) + { + var emailContent = new EmailContent(email.Data.Subject); + + if (email.Data.IsHtml) + { + emailContent.Html = email.Data.Body; + } + else + { + emailContent.PlainText = email.Data.Body; + } + + var toRecipients = new List(); + + if(email.Data.ToAddresses.Any()) + { + email.Data.ToAddresses.ForEach(r => toRecipients.Add(new EmailAddress(r.EmailAddress, r.Name))); + } + + var ccRecipients = new List(); + + if(email.Data.CcAddresses.Any()) + { + email.Data.CcAddresses.ForEach(r => ccRecipients.Add(new EmailAddress($"cc{r.EmailAddress}", r.Name))); + } + + var bccRecipients = new List(); + + if(email.Data.BccAddresses.Any()) + { + email.Data.BccAddresses.ForEach(r => bccRecipients.Add(new EmailAddress($"bcc{r.EmailAddress}", r.Name))); + } + + var emailRecipients = new EmailRecipients(toRecipients, ccRecipients, bccRecipients); + + var sender = $"{email.Data.FromAddress.Name} <{email.Data.FromAddress.EmailAddress}>"; + var emailMessage = new EmailMessage(sender, emailContent, emailRecipients); + + if (email.Data.ReplyToAddresses.Any(a => !string.IsNullOrWhiteSpace(a.EmailAddress))) + { + foreach (var emailAddress in email.Data.ReplyToAddresses) + { + emailMessage.ReplyTo.Add(new EmailAddress(emailAddress.EmailAddress, emailAddress.Name)); + } + } + + if (email.Data.Headers.Any()) + { + foreach (var header in email.Data.Headers) + { + emailMessage.CustomHeaders.Add(new EmailCustomHeader(header.Key, header.Value)); + } + } + + if(email.Data.Attachments.Any()) + { + foreach (var attachment in email.Data.Attachments) + { + emailMessage.Attachments.Add(await ConvertAttachment(attachment)); + } + } + + emailMessage.Importance = email.Data.Priority switch + { + Priority.High => EmailImportance.High, + Priority.Normal => EmailImportance.Normal, + Priority.Low => EmailImportance.Low, + _ => EmailImportance.Normal + }; + + try + { + var sendEmailResult = (await _emailClient.SendAsync(emailMessage, token ?? CancellationToken.None)).Value; + + var messageId = sendEmailResult.MessageId; + if (string.IsNullOrWhiteSpace(messageId)) + { + return new SendResponse + { + ErrorMessages = new List { "Failed to send email." } + }; + } + + // wait max 2 minutes to check the send status for mail. + var cancellationToken = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + SendStatusResult sendStatusResult; + do + { + sendStatusResult = await _emailClient.GetSendStatusAsync(messageId, cancellationToken.Token); + + if (sendStatusResult.Status != SendStatus.Queued) + { + break; + } + + await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken.Token); + } while (!cancellationToken.IsCancellationRequested); + + if (cancellationToken.IsCancellationRequested) + { + return new SendResponse + { + ErrorMessages = new List { "Failed to send email, timed out while getting status." } + }; + } + + if (sendStatusResult.Status == SendStatus.OutForDelivery) + { + return new SendResponse + { + MessageId = messageId + }; + } + + return new SendResponse + { + ErrorMessages = new List { "Failed to send email." } + }; + } + catch (Exception ex) + { + return new SendResponse + { + ErrorMessages = new List { ex.Message } + }; + } + } + + private async Task ConvertAttachment(Attachment attachment) => + new(attachment.Filename, attachment.ContentType, + await GetAttachmentAsBase64String(attachment.Data)); + + private async Task GetAttachmentAsBase64String(Stream stream) + { + using var ms = new MemoryStream(); + + await stream.CopyToAsync(ms); + + return Convert.ToBase64String(ms.ToArray()); + } +} \ No newline at end of file diff --git a/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj b/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj new file mode 100644 index 00000000..8b54e952 --- /dev/null +++ b/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj @@ -0,0 +1,20 @@ + + + + Send emails via Azure Email Communication Services API + Fluent Email - Azure Email + $(PackageTags);azureemail + netstandard2.0 + FluentEmail.Azure.Email + + + + + + + + + + + + diff --git a/src/Senders/FluentEmail.Azure.Email/FluentEmailAzureEmailBuilderExtensions.cs b/src/Senders/FluentEmail.Azure.Email/FluentEmailAzureEmailBuilderExtensions.cs new file mode 100644 index 00000000..044c062d --- /dev/null +++ b/src/Senders/FluentEmail.Azure.Email/FluentEmailAzureEmailBuilderExtensions.cs @@ -0,0 +1,50 @@ +using System; +using Azure; +using Azure.Communication.Email; +using Azure.Core; +using FluentEmail.Core.Interfaces; +using FluentEmail.Azure.Email; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class FluentEmailAzureEmailBuilderExtensions + { + public static FluentEmailServicesBuilder AddAzureEmailSender( + this FluentEmailServicesBuilder builder, + string connectionString) + { + builder.Services.TryAdd(ServiceDescriptor.Scoped(_ => new AzureEmailSender(connectionString))); + return builder; + } + + public static FluentEmailServicesBuilder AddAzureEmailSender( + this FluentEmailServicesBuilder builder, + string connectionString, + EmailClientOptions options) + { + builder.Services.TryAdd(ServiceDescriptor.Scoped(_ => new AzureEmailSender(connectionString, options))); + return builder; + } + + public static FluentEmailServicesBuilder AddAzureEmailSender( + this FluentEmailServicesBuilder builder, + Uri endpoint, + AzureKeyCredential keyCredential, + EmailClientOptions options = default) + { + builder.Services.TryAdd(ServiceDescriptor.Scoped(_ => new AzureEmailSender(endpoint, keyCredential, options))); + return builder; + } + + public static FluentEmailServicesBuilder AddAzureEmailSender( + this FluentEmailServicesBuilder builder, + Uri endpoint, + TokenCredential tokenCredential, + EmailClientOptions options = default) + { + builder.Services.TryAdd(ServiceDescriptor.Scoped(_ => new AzureEmailSender(endpoint, tokenCredential, options))); + return builder; + } + } +} diff --git a/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs b/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs new file mode 100644 index 00000000..4f2c46f2 --- /dev/null +++ b/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using NUnit.Framework; +using Attachment = FluentEmail.Core.Models.Attachment; + +namespace FluentEmail.Azure.Email.Tests +{ + [NonParallelizable] + public class AzureEmailSenderTests + { + const string connectionString = ""; // TODO: Put your ConnectionString here + + const string toEmail = "fluentEmail@mailinator.com"; + const string toName = "FluentEmail Mailinator"; + const string fromEmail = "test@fluentmail.com"; // TODO: Put a valid/verified sender here + const string fromName = "AzureEmailSender Test"; + + [SetUp] + public void SetUp() + { + if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentException("Azure Communication Services Connection String needs to be supplied"); + + var sender = new AzureEmailSender(connectionString); + Core.Email.DefaultSender = sender; + } + + [Test, Ignore("No azure credentials")] + public async Task CanSendEmail() + { + const string subject = "SendMail Test"; + const string body = "This email is testing send mail functionality of Azure Email Sender."; + + var email = Core.Email + .From(fromEmail, fromName) + .To(toEmail, toName) + .Subject(subject) + .Body(body); + + var response = await email.SendAsync(); + + Assert.IsTrue(response.Successful); + } + + [Test, Ignore("No azure credentials")] + public async Task CanSendEmailWithReplyTo() + { + const string subject = "SendMail Test"; + const string body = "This email is testing send mail with ReplyTo functionality of Azure Email Sender."; + + var email = Core.Email + .From(fromEmail, fromName) + .To(toEmail, toName) + .ReplyTo(toEmail, toName) + .Subject(subject) + .Body(body); + + var response = await email.SendAsync(); + + Assert.IsTrue(response.Successful); + } + + [Test, Ignore("No azure credentials")] + public async Task CanSendEmailWithAttachments() + { + const string subject = "SendMail With Attachments Test"; + const string body = "This email is testing the attachment functionality of Azure Email Sender."; + + await using var stream = File.OpenRead($"{Directory.GetCurrentDirectory()}/test-binary.xlsx"); + var attachment = new Attachment + { + Data = stream, + ContentType = "xlsx", + Filename = "test-binary.xlsx" + }; + + var email = Core.Email + .From(fromEmail, fromName) + .To(toEmail, toName) + .Subject(subject) + .Body(body) + .Attach(attachment); + + + var response = await email.SendAsync(); + + Console.WriteLine($"Response: {JsonSerializer.Serialize(response)}"); + + Assert.IsTrue(response.Successful); + } + + [Test, Ignore("No azure credentials")] + public async Task CanSendHighPriorityEmail() + { + const string subject = "SendMail Test"; + const string body = "This email is testing send mail functionality of Azure Email Sender."; + + var email = Core.Email + .From(fromEmail, fromName) + .To(toEmail, toName) + .Subject(subject) + .Body(body) + .HighPriority(); + + var response = await email.SendAsync(); + + Assert.IsTrue(response.Successful); + } + + [Test, Ignore("No azure credentials")] + public async Task CanSendLowPriorityEmail() + { + const string subject = "SendMail Test"; + const string body = "This email is testing send mail functionality of Azure Email Sender."; + + var email = Core.Email + .From(fromEmail, fromName) + .To(toEmail, toName) + .Subject(subject) + .Body(body) + .LowPriority(); + + var response = await email.SendAsync(); + + Assert.IsTrue(response.Successful); + } + } +} From f6de1357ceb2ab9f1174cf184b737ad0cd9408bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Sj=C3=B8l=C3=B8kken?= Date: Sun, 30 Oct 2022 16:54:47 +0100 Subject: [PATCH 2/6] Update FluentEmail.Azure.Email.csproj --- .../FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj b/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj index 8b54e952..1ee9c716 100644 --- a/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj +++ b/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj @@ -5,8 +5,6 @@ Fluent Email - Azure Email $(PackageTags);azureemail netstandard2.0 - FluentEmail.Azure.Email - From 0e18d1dd2ae0f62cafe7b261eab617ac52ac98fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Sj=C3=B8l=C3=B8kken?= Date: Sun, 30 Oct 2022 16:55:54 +0100 Subject: [PATCH 3/6] Update AzureEmailSenderTests.cs --- test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs b/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs index 4f2c46f2..c9db9011 100644 --- a/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs +++ b/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs @@ -84,8 +84,6 @@ public async Task CanSendEmailWithAttachments() var response = await email.SendAsync(); - - Console.WriteLine($"Response: {JsonSerializer.Serialize(response)}"); Assert.IsTrue(response.Successful); } From caefc69225655457ef366d917b510203c5429feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Sj=C3=B8l=C3=B8kken?= Date: Sun, 30 Oct 2022 17:09:15 +0100 Subject: [PATCH 4/6] Remove unnecessary using statement --- test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs b/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs index c9db9011..83111935 100644 --- a/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs +++ b/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Text.Json; using System.Threading.Tasks; using NUnit.Framework; using Attachment = FluentEmail.Core.Models.Attachment; From eb116bcc74ebd1884f389c34232e8b5ef449d16a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Sj=C3=B8l=C3=B8kken?= Date: Sun, 30 Oct 2022 17:38:08 +0100 Subject: [PATCH 5/6] Update AzureEmailSender.cs comments --- src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs b/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs index 9dfa2852..a52bdbad 100644 --- a/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs +++ b/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs @@ -143,8 +143,11 @@ public async Task SendAsync(IFluentEmail email, CancellationToken? ErrorMessages = new List { "Failed to send email." } }; } - - // wait max 2 minutes to check the send status for mail. + + /* + We want to verify that the email was sent. + The maximum time we will wait for the message status to be sent/delivered is 2 minutes. + */ var cancellationToken = new CancellationTokenSource(TimeSpan.FromMinutes(2)); SendStatusResult sendStatusResult; do From a6b336dabd41f046b1c6fc2af869472737380ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eirik=20Sj=C3=B8l=C3=B8kken?= Date: Sun, 30 Oct 2022 17:48:27 +0100 Subject: [PATCH 6/6] Update AzureEmailSender.cs comments --- src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs b/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs index a52bdbad..03e3992e 100644 --- a/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs +++ b/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs @@ -144,10 +144,8 @@ public async Task SendAsync(IFluentEmail email, CancellationToken? }; } - /* - We want to verify that the email was sent. - The maximum time we will wait for the message status to be sent/delivered is 2 minutes. - */ + // We want to verify that the email was sent. + // The maximum time we will wait for the message status to be sent/delivered is 2 minutes. var cancellationToken = new CancellationTokenSource(TimeSpan.FromMinutes(2)); SendStatusResult sendStatusResult; do