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..03e3992e --- /dev/null +++ b/src/Senders/FluentEmail.Azure.Email/AzureEmailSender.cs @@ -0,0 +1,205 @@ +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." } + }; + } + + // 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 + { + 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..1ee9c716 --- /dev/null +++ b/src/Senders/FluentEmail.Azure.Email/FluentEmail.Azure.Email.csproj @@ -0,0 +1,18 @@ + + + + Send emails via Azure Email Communication Services API + Fluent Email - Azure Email + $(PackageTags);azureemail + netstandard2.0 + + + + + + + + + + + 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..83111935 --- /dev/null +++ b/test/FluentEmail.Core.Tests/AzureEmailSenderTests.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +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(); + + 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); + } + } +}