From 0c5c74bc450e0dc352ff2c6dac5a8d09fbd8ebe5 Mon Sep 17 00:00:00 2001 From: Jezz Santos Date: Sat, 30 Nov 2024 21:54:36 +1300 Subject: [PATCH] Added Templated email sending. Closes #60 --- docs/design-principles/0100-email-delivery.md | 28 ++- iac/AzureSQLServer-Seed-Eventing-Generic.sql | 3 + .../AncillaryApplication.EmailingSpec.cs | 203 ++++++++++++++---- .../AncillaryApplication.Emailing.cs | 78 +++++-- .../Persistence/ReadModels/EmailDelivery.cs | 7 + .../Resources.Designer.cs | 20 +- src/AncillaryApplication/Resources.resx | 8 +- .../EmailDeliverRootSpec.cs | 50 ++++- src/AncillaryDomain/EmailDeliveryRoot.cs | 32 ++- src/AncillaryDomain/Events.cs | 15 +- src/AncillaryDomain/Resources.Designer.cs | 19 +- src/AncillaryDomain/Resources.resx | 9 +- .../EmailsApiSpec.cs | 36 ++-- .../MailgunApiSpec.cs | 4 +- .../Stubs/StubEmailDeliveryService.cs | 27 ++- .../NoOpEmailDeliveryService.cs | 23 +- .../ReadModels/EmailDeliveryProjection.cs | 3 + .../IEmailDeliveryService.cs | 15 +- .../ReadModels/EmailMessage.cs | 25 ++- .../IEmailSchedulingService.cs | 29 ++- .../EmailDelivery/EmailDetailsChanged.cs | 11 +- .../Ancillary/DeliveredEmailContentType.cs | 7 + .../External/MailgunHttpServiceClientSpec.cs | 18 +- .../External/MailgunClientSpec.cs | 8 +- .../External/MailgunHttpServiceClientSpec.cs | 7 +- .../MailgunHttpServiceClient.MailgunClient.cs | 81 ++++++- .../External/MailgunHttpServiceClient.cs | 37 +++- .../QueuingEmailSchedulingService.cs | 53 ++++- ...equest.cs => MailgunSendMessageRequest.cs} | 8 +- ...ponse.cs => MailgunSendMessageResponse.cs} | 2 +- .../SendEmailSpecBase.cs | 4 +- src/SaaStack.sln.DotSettings | 1 + src/TestingStubApiHost/Api/StubMailgunApi.cs | 10 +- 33 files changed, 715 insertions(+), 166 deletions(-) create mode 100644 src/Domain.Shared/Ancillary/DeliveredEmailContentType.cs rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/{MailgunSendRequest.cs => MailgunSendMessageRequest.cs} (73%) rename src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/{MailgunSendResponse.cs => MailgunSendMessageResponse.cs} (84%) diff --git a/docs/design-principles/0100-email-delivery.md b/docs/design-principles/0100-email-delivery.md index ad2d8139..62d6c4d2 100644 --- a/docs/design-principles/0100-email-delivery.md +++ b/docs/design-principles/0100-email-delivery.md @@ -6,17 +6,19 @@ Many processes in the backend of a SaaS product aim to notify/alert the end user Sending emails/SMS is often done via 3rd party systems like SendGrid, Twilio, Mailgun, Postmark, etc., over HTTP. -> Due to its nature, doing this directly in a use case, isn't very reliable, nor is it optimal with systems under load, and with 3rd parties applying rate limits. All of these aspects can lead to a poor user experience is the user is waiting for these workloads to complete before they can move on with their work. +> Sending emails/SMS directly in a use case, can be very unreliable, and is it optimal with systems under load, where 3rd party API availability may not be 100%, and they can also apply rate limits. These aspects can lead to a poor user experience is the user is waiting for these workloads to complete before they can move on with their work. + +Messaging, especially email/SMS is inherently "asynchronous" (from the users' point of view) because it is always out of band (OOB) from the application sending the messages. Thus: * We need to "broker" between the sending of emails/SMS, and the delivering of them, to make the entire process more reliable, and we need to provide observability when things go wrong. -* Since an inbound API request to any API backend can yield of the order ~10 emails per API call, delivering them reliably across HTTP can require minutes of time, if you consider the possibility of retries and back-offs, etc. We simply could not afford to keep API clients blocked and waiting while email delivery takes place, let alone the risk of timing out their connection to the inbound API call in the first place. -* Delivery of some emails is critical to the functioning of the product, and this data cannot be lost. Consider an email to confirm the email of a registered account, for example. +* Since an inbound API request to any API backend can yield of the order ~10 emails per API call, delivering them all reliably across HTTP can require many seconds/minutes of time, if you consider the possibility of retries and back-offs, etc. We simply could not afford to keep API clients blocked and waiting while email delivery takes place, let alone the risk of timing out their connection to the inbound API call in the first place. +* Delivery of some emails is critical to the functioning of the product, and this data cannot be lost, in a failure. e.g. Consider the email sent to a user to confirm the registration of their new account, or the SMS used to provide two-factor authentication to gain access to your account. These messages must be delivered, albeit in a reasonably short period of time. -Fortunately, an individual email arriving in a person's inbox is not a time-critical and synchronous usability function to begin with. +Fortunately, an individual email arriving in a person's inbox, or SMS text message arriving on your phone, is not a time-critical and synchronous usability function to begin with. -> Some delay, in the order of seconds to minutes, is anticipated. +> Some delay, in the order of seconds to minutes, is anticipated by the user, and is common even today. -Thus, we need to take advantage of all these facts and engineer a reliable mechanism. +Thus, we need to take advantage of all these facts and engineer a reliable and usable mechanism. ## Implementation @@ -24,9 +26,11 @@ This is how emails and SMS messages are delivered from subdomain, using the Anci ![Email/SMS Delivery](../../docs/images/Email-Delivery.png) +> Although, not shown in this diagram (explicitly), SMS text messages have the same mechanisms. + ### Sending notifications -Any API Host (any subdomain) may want to send notifications to users. +Any API Host (any deployed subdomain) may want to send notifications to users. They do this by calling very specific and custom `IUserNotificationsService.NotifyXXXAsync()` methods. @@ -92,6 +96,16 @@ The specific adapter will likely use an exponential back-off retry policy, which Any exception that is raised from this processing will fail the API call, and that will start another delivery cycle from the queue. +#### Templating + +In some products, you might want to control the HTML body of emails being sent. + +> In some products you may also want to allow your customers to control their emails. + +Most 3rd party provides support email templating in some capacity or another. + +Emails can be sent either as hardcoded HMTL, or by specifying a collection of "substitutions" that are rendered by the templating engine of the 3rd party provider (e.g., MailGun, Twilio, etc). + ### Delivery status Even though the attempt to send the email message to the 3rd party service succeeds, that 3rd party service may, later, fail to deliver the email message, even though they respond to the delivery request with success. diff --git a/iac/AzureSQLServer-Seed-Eventing-Generic.sql b/iac/AzureSQLServer-Seed-Eventing-Generic.sql index 3c920849..e9cb49f8 100644 --- a/iac/AzureSQLServer-Seed-Eventing-Generic.sql +++ b/iac/AzureSQLServer-Seed-Eventing-Generic.sql @@ -267,6 +267,7 @@ CREATE TABLE [dbo].[EmailDelivery] [IsDeleted] [bit] NULL, [Attempts] [nvarchar](max) NULL, [Body] [nvarchar](max) NULL, + [ContentType] [nvarchar](max) NULL, [Delivered] [datetime] NULL, [DeliveryFailed] [datetime] NULL, [DeliveryFailedReason] [nvarchar](max) NULL, @@ -276,7 +277,9 @@ CREATE TABLE [dbo].[EmailDelivery] [SendFailed] [datetime] NULL, [Sent] [datetime] NULL, [Subject] [nvarchar](max) NULL, + [Substitutions] [nvarchar](max) NULL, [Tags] [nvarchar](max) NULL, + [TemplateId] [nvarchar](max) NULL, [ToDisplayName] [nvarchar](max) NULL, [ToEmailAddress] [nvarchar](max) NULL, ) ON [PRIMARY] diff --git a/src/AncillaryApplication.UnitTests/AncillaryApplication.EmailingSpec.cs b/src/AncillaryApplication.UnitTests/AncillaryApplication.EmailingSpec.cs index a1d917cc..d9a2f6a4 100644 --- a/src/AncillaryApplication.UnitTests/AncillaryApplication.EmailingSpec.cs +++ b/src/AncillaryApplication.UnitTests/AncillaryApplication.EmailingSpec.cs @@ -43,7 +43,7 @@ public AncillaryApplicationEmailingSpec() var auditRepository = new Mock(); _emailMessageQueue = new Mock(); _emailDeliveryService = new Mock(); - _emailDeliveryService.Setup(eds => eds.SendAsync(It.IsAny(), It.IsAny(), + _emailDeliveryService.Setup(eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new EmailDeliveryReceipt()); @@ -68,57 +68,57 @@ public AncillaryApplicationEmailingSpec() } [Fact] - public async Task WhenSendEmailAsyncAndMessageIsNotRehydratable_ThenReturnsError() + public async Task WhenSendEmailAsyncAndHtmlMessageIsNotRehydratable_ThenReturnsError() { var result = await _application.SendEmailAsync(_caller.Object, "anunknownmessage", CancellationToken.None); result.Should().BeError(ErrorCode.RuleViolation, Resources.AncillaryApplication_InvalidQueuedMessage.Format(nameof(EmailMessage), "anunknownmessage")); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), + eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task WhenSendEmailAsyncAndMessageHasNoHtml_ThenReturnsError() + public async Task WhenSendEmailAsyncAndMessageHasNoHtmlNorTemplate_ThenReturnsError() { var messageAsJson = new EmailMessage { - Message = null + Html = null, + Template = null }.ToJson()!; var result = await _application.SendEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); - result.Should().BeError(ErrorCode.RuleViolation, - Resources.AncillaryApplication_Email_MissingMessage); + result.Should().BeError(ErrorCode.RuleViolation, Resources.AncillaryApplication_Email_MissingMessage); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), + eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] - public async Task WhenSendEmailAsyncAndSent_ThenSends() + public async Task WhenSendEmailAsyncWithHtmlMessage_ThenSends() { var messageId = CreateMessageId(); var messageAsJson = new EmailMessage { MessageId = messageId, - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "abody", + Body = "abody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "arecipient", FromEmailAddress = "asender@company.com", FromDisplayName = "asender", - Tags = new List { "atag" } + Tags = ["atag"] } }.ToJson()!; var email = EmailDeliveryRoot .Create(_recorder.Object, _idFactory.Object, QueuedMessageId.Create(messageId).Value).Value; - email.SetEmailDetails("asubject", "abody", + email.SetContent("asubject", "abody", EmailRecipient.Create(EmailAddress.Create("arecipient@company.com").Value, "adisplayname").Value, new List { "atag" }); email.AttemptSending(); @@ -126,7 +126,7 @@ public async Task WhenSendEmailAsyncAndSent_ThenSends() _emailDeliveryRepository.Setup(edr => edr.FindByMessageIdAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(email.ToOptional()); - _emailDeliveryService.Setup(eds => eds.SendAsync(It.IsAny(), It.IsAny(), + _emailDeliveryService.Setup(eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new EmailDeliveryReceipt()); @@ -135,7 +135,67 @@ public async Task WhenSendEmailAsyncAndSent_ThenSends() result.Should().BeSuccess(); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), + eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _emailDeliveryService.Verify( + eds => eds.SendTemplatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _emailDeliveryRepository.Verify( + edr => edr.SaveAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task WhenSendEmailAsyncWithTemplatedMessage_ThenSends() + { + var messageId = CreateMessageId(); + var messageAsJson = new EmailMessage + { + MessageId = messageId, + Html = null, + Template = new QueuedEmailTemplatedMessage + { + TemplateId = "atemplateid", + Subject = "asubject", + Substitutions = new Dictionary { { "aname", "avalue" } }, + ToEmailAddress = "arecipient@company.com", + ToDisplayName = "arecipient", + FromEmailAddress = "asender@company.com", + FromDisplayName = "asender", + Tags = ["atag"] + } + }.ToJson()!; + var email = EmailDeliveryRoot + .Create(_recorder.Object, _idFactory.Object, QueuedMessageId.Create(messageId).Value).Value; + email.SetContent("asubject", "abody", + EmailRecipient.Create(EmailAddress.Create("arecipient@company.com").Value, "adisplayname").Value, + new List { "atag" }); + email.AttemptSending(); + email.SucceededSending("areceiptid"); + _emailDeliveryRepository.Setup(edr => + edr.FindByMessageIdAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(email.ToOptional()); + _emailDeliveryService.Setup(eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EmailDeliveryReceipt()); + + var result = await _application.SendEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeSuccess(); + _emailDeliveryService.Verify( + eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _emailDeliveryService.Verify( + eds => eds.SendTemplatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); @@ -151,17 +211,17 @@ public async Task WhenSendEmailAsyncAndNotDelivered_ThenFailsSending() var messageAsJson = new EmailMessage { MessageId = messageId, - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "abody", + Body = "abody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "arecipient", FromEmailAddress = "asender@company.com", FromDisplayName = "asender" } }.ToJson()!; - _emailDeliveryService.Setup(eds => eds.SendAsync(It.IsAny(), It.IsAny(), + _emailDeliveryService.Setup(eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Error.Unexpected()); @@ -170,9 +230,15 @@ public async Task WhenSendEmailAsyncAndNotDelivered_ThenFailsSending() result.Should().BeError(ErrorCode.Unexpected); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), "asubject", "abody", "arecipient@company.com", + eds => eds.SendHtmlAsync(It.IsAny(), "asubject", "abody", "arecipient@company.com", "arecipient", "asender@company.com", "asender", It.IsAny())); + _emailDeliveryService.Verify( + eds => eds.SendTemplatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); _emailDeliveryRepository.Verify(edr => edr.SaveAsync(It.Is(root => root.MessageId == messageId && root.Recipient.Value.EmailAddress == "arecipient@company.com" @@ -184,23 +250,23 @@ public async Task WhenSendEmailAsyncAndNotDelivered_ThenFailsSending() } [Fact] - public async Task WhenSendEmailAsyncAndAlreadyDelivered_ThenDoesNotResend() + public async Task WhenSendEmailAsyncWithHtmlMessageAndAlreadyDelivered_ThenDoesNotResend() { var messageId = CreateMessageId(); var messageAsJson = new EmailMessage { MessageId = messageId, - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "abody", + Body = "abody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "arecipient", FromEmailAddress = "asender@company.com", FromDisplayName = "asender" } }.ToJson()!; - _emailDeliveryService.Setup(eds => eds.SendAsync(It.IsAny(), It.IsAny(), + _emailDeliveryService.Setup(eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new EmailDeliveryReceipt()); @@ -209,9 +275,62 @@ public async Task WhenSendEmailAsyncAndAlreadyDelivered_ThenDoesNotResend() result.Should().BeSuccess(); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), "asubject", "abody", "arecipient@company.com", + eds => eds.SendHtmlAsync(It.IsAny(), "asubject", "abody", "arecipient@company.com", "arecipient", "asender@company.com", "asender", It.IsAny())); + _emailDeliveryService.Verify( + eds => eds.SendTemplatedAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _emailDeliveryRepository.Verify(edr => edr.SaveAsync(It.Is(root => + root.MessageId == messageId + && root.Recipient.Value.EmailAddress == "arecipient@company.com" + && root.Recipient.Value.DisplayName == "arecipient" + && root.Attempts.Attempts.Count == 1 + && root.Attempts.Attempts[0].IsNear(DateTime.UtcNow) + && root.IsSent == true + ), true, It.IsAny())); + } + + [Fact] + public async Task WhenSendEmailAsyncWithTemplatedMessageAndAlreadyDelivered_ThenDoesNotResend() + { + var messageId = CreateMessageId(); + var messageAsJson = new EmailMessage + { + MessageId = messageId, + Template = new QueuedEmailTemplatedMessage + { + TemplateId = "atemplateid", + Subject = "asubject", + Substitutions = null, + ToEmailAddress = "arecipient@company.com", + ToDisplayName = "arecipient", + FromEmailAddress = "asender@company.com", + FromDisplayName = "asender" + } + }.ToJson()!; + _emailDeliveryService.Setup(eds => eds.SendTemplatedAsync(It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new EmailDeliveryReceipt()); + + var result = await _application.SendEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeSuccess(); + _emailDeliveryService.Verify( + eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _emailDeliveryService.Verify( + eds => eds.SendTemplatedAsync(_caller.Object, "atemplateid", "asubject", + It.IsAny>(), + "arecipient@company.com", "arecipient", "asender@company.com", "asender", + It.IsAny())); _emailDeliveryRepository.Verify(edr => edr.SaveAsync(It.Is(root => root.MessageId == messageId && root.Recipient.Value.EmailAddress == "arecipient@company.com" @@ -280,7 +399,7 @@ public async Task WhenConfirmEmailDeliveredAsyncAndAlreadyDelivered_ThenReturns( var messageId = CreateMessageId(); var email = EmailDeliveryRoot .Create(_recorder.Object, _idFactory.Object, QueuedMessageId.Create(messageId).Value).Value; - email.SetEmailDetails("asubject", "abody", + email.SetContent("asubject", "abody", EmailRecipient.Create(EmailAddress.Create("arecipient@company.com").Value, "adisplayname").Value, new List { "atag" }); email.SucceededSending("areceiptid"); @@ -304,7 +423,7 @@ public async Task WhenConfirmEmailDeliveredAsync_ThenDelivers() var messageId = CreateMessageId(); var email = EmailDeliveryRoot .Create(_recorder.Object, _idFactory.Object, QueuedMessageId.Create(messageId).Value).Value; - email.SetEmailDetails("asubject", "abody", + email.SetContent("asubject", "abody", EmailRecipient.Create(EmailAddress.Create("arecipient@company.com").Value, "adisplayname").Value, new List { "atag" }); email.AttemptSending(); @@ -348,7 +467,7 @@ public async Task WhenConfirmEmailDeliveryFailedAsyncAndAlreadyDelivered_ThenRet var messageId = CreateMessageId(); var email = EmailDeliveryRoot .Create(_recorder.Object, _idFactory.Object, QueuedMessageId.Create(messageId).Value).Value; - email.SetEmailDetails("asubject", "abody", + email.SetContent("asubject", "abody", EmailRecipient.Create(EmailAddress.Create("arecipient@company.com").Value, "adisplayname").Value, new List { "atag" }); email.SucceededSending("areceiptid"); @@ -372,7 +491,7 @@ public async Task WhenConfirmEmailDeliveryFailedAsync_ThenDelivers() var messageId = CreateMessageId(); var email = EmailDeliveryRoot .Create(_recorder.Object, _idFactory.Object, QueuedMessageId.Create(messageId).Value).Value; - email.SetEmailDetails("asubject", "abody", + email.SetContent("asubject", "abody", EmailRecipient.Create(EmailAddress.Create("arecipient@company.com").Value, "adisplayname").Value, new List { "atag" }); email.AttemptSending(); @@ -398,8 +517,8 @@ public async Task WhenConfirmEmailDeliveryFailedAsync_ThenDelivers() [Fact] public async Task WhenDrainAllEmailsAsyncAndNoneOnQueue_ThenDoesNotDeliver() { - _emailMessageQueue.Setup(umr => - umr.PopSingleAsync(It.IsAny>>>(), + _emailMessageQueue.Setup(emq => + emq.PopSingleAsync(It.IsAny>>>(), It.IsAny())) .ReturnsAsync(false); @@ -407,10 +526,10 @@ public async Task WhenDrainAllEmailsAsyncAndNoneOnQueue_ThenDoesNotDeliver() result.Should().BeSuccess(); _emailMessageQueue.Verify( - urs => urs.PopSingleAsync(It.IsAny>>>(), + emq => emq.PopSingleAsync(It.IsAny>>>(), It.IsAny())); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), + eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -424,10 +543,10 @@ public async Task WhenDrainAllEmailsAsyncAndSomeOnQueue_ThenDeliversAll() var message1 = new EmailMessage { MessageId = message1Id, - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject1", - HtmlBody = "abody1", + Body = "abody1", ToEmailAddress = "arecipient1@company.com", ToDisplayName = "arecipient1", FromEmailAddress = "asender1@company.com", @@ -438,10 +557,10 @@ public async Task WhenDrainAllEmailsAsyncAndSomeOnQueue_ThenDeliversAll() var message2 = new EmailMessage { MessageId = message2Id, - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject2", - HtmlBody = "abody2", + Body = "abody2", ToEmailAddress = "arecipient2@company.com", ToDisplayName = "arecipient2", FromEmailAddress = "asender2@company.com", @@ -449,8 +568,8 @@ public async Task WhenDrainAllEmailsAsyncAndSomeOnQueue_ThenDeliversAll() } }; var callbackCount = 1; - _emailMessageQueue.Setup(umr => - umr.PopSingleAsync(It.IsAny>>>(), + _emailMessageQueue.Setup(emq => + emq.PopSingleAsync(It.IsAny>>>(), It.IsAny())) .Callback((Func>> action, CancellationToken _) => { @@ -474,18 +593,18 @@ public async Task WhenDrainAllEmailsAsyncAndSomeOnQueue_ThenDeliversAll() result.Should().BeSuccess(); _emailMessageQueue.Verify( - urs => urs.PopSingleAsync(It.IsAny>>>(), + emq => emq.PopSingleAsync(It.IsAny>>>(), It.IsAny()), Times.Exactly(2)); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), "asubject1", "abody1", "arecipient1@company.com", + eds => eds.SendHtmlAsync(It.IsAny(), "asubject1", "abody1", "arecipient1@company.com", "arecipient1", "asender1@company.com", "asender1", It.IsAny())); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), "asubject2", "abody2", "arecipient2@company.com", + eds => eds.SendHtmlAsync(It.IsAny(), "asubject2", "abody2", "arecipient2@company.com", "arecipient2", "asender2@company.com", "asender2", It.IsAny())); _emailDeliveryService.Verify( - urs => urs.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), + eds => eds.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); diff --git a/src/AncillaryApplication/AncillaryApplication.Emailing.cs b/src/AncillaryApplication/AncillaryApplication.Emailing.cs index e2f25367..5eb33282 100644 --- a/src/AncillaryApplication/AncillaryApplication.Emailing.cs +++ b/src/AncillaryApplication/AncillaryApplication.Emailing.cs @@ -2,6 +2,7 @@ using AncillaryDomain; using Application.Common.Extensions; using Application.Interfaces; +using Application.Persistence.Shared; using Application.Persistence.Shared.ReadModels; using Application.Resources.Shared; using Common; @@ -144,7 +145,7 @@ public async Task> SendEmailAsync(ICallerContext caller, str private async Task> SendEmailInternalAsync(ICallerContext caller, EmailMessage message, CancellationToken cancellationToken) { - if (message.Message.IsInvalidParameter(x => x.Exists(), nameof(EmailMessage.Message), out _)) + if (message.Html.NotExists() && message.Template.NotExists()) { return Error.RuleViolation(Resources.AncillaryApplication_Email_MissingMessage); } @@ -161,36 +162,45 @@ private async Task> SendEmailInternalAsync(ICallerContext ca return retrieved.Error; } - var subject = message.Message!.Subject; - var body = message.Message!.HtmlBody; - var recipientEmailAddress = EmailAddress.Create(message.Message!.ToEmailAddress!); + var toEmailAddress = message.Html.Exists() + ? message.Html!.ToEmailAddress! + : message.Template!.ToEmailAddress!; + var recipientEmailAddress = EmailAddress.Create(toEmailAddress); if (recipientEmailAddress.IsFailure) { return recipientEmailAddress.Error; } - var recipientName = message.Message!.ToDisplayName ?? string.Empty; - var recipient = EmailRecipient.Create(recipientEmailAddress.Value, recipientName); + var toDisplayName = (message.Html.Exists() + ? message.Html!.ToDisplayName + : message.Template!.ToDisplayName) ?? string.Empty; + var recipient = EmailRecipient.Create(recipientEmailAddress.Value, toDisplayName); if (recipient.IsFailure) { return recipient.Error; } - var senderEmailAddress = EmailAddress.Create(message.Message!.FromEmailAddress!); + var fromEmailAddress = message.Html.Exists() + ? message.Html!.FromEmailAddress! + : message.Template!.FromEmailAddress!; + var senderEmailAddress = EmailAddress.Create(fromEmailAddress); if (senderEmailAddress.IsFailure) { return senderEmailAddress.Error; } - var senderName = message.Message!.FromDisplayName ?? string.Empty; - var sender = EmailRecipient.Create(senderEmailAddress.Value, senderName); + var fromDisplayName = (message.Html.Exists() + ? message.Html!.FromDisplayName + : message.Template!.FromDisplayName) ?? string.Empty; + var sender = EmailRecipient.Create(senderEmailAddress.Value, fromDisplayName); if (sender.IsFailure) { return sender.Error; } - var tags = message.Message!.Tags; - + var tags = message.Html.Exists() + ? message.Html!.Tags + : message.Template!.Tags; EmailDeliveryRoot email; var found = retrieved.Value.HasValue; if (found) @@ -207,10 +217,27 @@ private async Task> SendEmailInternalAsync(ICallerContext ca email = created.Value; - var detailed = email.SetEmailDetails(subject, body, recipient.Value, tags); - if (detailed.IsFailure) + if (message.Html.Exists()) { - return detailed.Error; + var subject = message.Html!.Subject; + var body = message.Html!.Body; + var detailed = email.SetContent(subject, body, recipient.Value, tags); + if (detailed.IsFailure) + { + return detailed.Error; + } + } + + if (message.Template.Exists()) + { + var templateId = message.Template!.TemplateId; + var subject = message.Template!.Subject; + var substitutions = message.Template!.Substitutions; + var detailed = email.SetContent(templateId, subject, substitutions, recipient.Value, tags); + if (detailed.IsFailure) + { + return detailed.Error; + } } } @@ -235,9 +262,26 @@ private async Task> SendEmailInternalAsync(ICallerContext ca } email = saved.Value; - var sent = await _emailDeliveryService.SendAsync(caller, subject!, body!, recipient.Value.EmailAddress, - recipient.Value.DisplayName, sender.Value.EmailAddress, - sender.Value.DisplayName, cancellationToken); + var sent = new Result(Error.Unexpected()); + if (message.Html.Exists()) + { + var subject = message.Html.Subject; + var body = message.Html.Body; + sent = await _emailDeliveryService.SendHtmlAsync(caller, subject!, body!, recipient.Value.EmailAddress, + recipient.Value.DisplayName, sender.Value.EmailAddress, + sender.Value.DisplayName, cancellationToken); + } + + if (message.Template.Exists()) + { + var templateId = message.Template.TemplateId!; + var subject = message.Template.Subject; + var substitutions = message.Template.Substitutions!; + sent = await _emailDeliveryService.SendTemplatedAsync(caller, templateId, subject, substitutions, + recipient.Value.EmailAddress, recipient.Value.DisplayName, sender.Value.EmailAddress, + sender.Value.DisplayName, cancellationToken); + } + if (sent.IsFailure) { var failed = email.FailedSending(); diff --git a/src/AncillaryApplication/Persistence/ReadModels/EmailDelivery.cs b/src/AncillaryApplication/Persistence/ReadModels/EmailDelivery.cs index f090f4d5..9b1db16c 100644 --- a/src/AncillaryApplication/Persistence/ReadModels/EmailDelivery.cs +++ b/src/AncillaryApplication/Persistence/ReadModels/EmailDelivery.cs @@ -1,6 +1,7 @@ using AncillaryDomain; using Application.Persistence.Common; using Common; +using Domain.Shared.Ancillary; using QueryAny; namespace AncillaryApplication.Persistence.ReadModels; @@ -12,6 +13,8 @@ public class EmailDelivery : ReadModelEntity public Optional Body { get; set; } + public DeliveredEmailContentType ContentType { get; set; } + public Optional Delivered { get; set; } public Optional DeliveryFailed { get; set; } @@ -30,8 +33,12 @@ public class EmailDelivery : ReadModelEntity public Optional Subject { get; set; } + public Optional Substitutions { get; set; } + public Optional Tags { get; set; } + public Optional TemplateId { get; set; } + public Optional ToDisplayName { get; set; } public Optional ToEmailAddress { get; set; } diff --git a/src/AncillaryApplication/Resources.Designer.cs b/src/AncillaryApplication/Resources.Designer.cs index a33943e6..66f4df6e 100644 --- a/src/AncillaryApplication/Resources.Designer.cs +++ b/src/AncillaryApplication/Resources.Designer.cs @@ -69,7 +69,16 @@ internal static string AncillaryApplication_Audit_MissingCode { } /// - /// Looks up a localized string similar to The email message is missing the 'Message'. + /// Looks up a localized string similar to The email message is missing a 'Body'. + /// + internal static string AncillaryApplication_Email_HtmlMissingBody { + get { + return ResourceManager.GetString("AncillaryApplication_Email_HtmlMissingBody", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email message is missing either a 'Html' or 'Template' message. /// internal static string AncillaryApplication_Email_MissingMessage { get { @@ -77,6 +86,15 @@ internal static string AncillaryApplication_Email_MissingMessage { } } + /// + /// Looks up a localized string similar to The email message is missing a 'TemplateId'. + /// + internal static string AncillaryApplication_Email_TemplateMissingId { + get { + return ResourceManager.GetString("AncillaryApplication_Email_TemplateMissingId", resourceCulture); + } + } + /// /// Looks up a localized string similar to The queued message was not valid JSON for its type: '{0}', message was: {1}. /// diff --git a/src/AncillaryApplication/Resources.resx b/src/AncillaryApplication/Resources.resx index 1a7af1d6..63f94370 100644 --- a/src/AncillaryApplication/Resources.resx +++ b/src/AncillaryApplication/Resources.resx @@ -37,7 +37,13 @@ The audit message is missing a 'AuditCode' - The email message is missing the 'Message' + The email message is missing either a 'Html' or 'Template' message + + + The email message is missing a 'Body' + + + The email message is missing a 'TemplateId' The sms message is missing the 'Message' diff --git a/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs b/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs index d92b1068..e501b0c9 100644 --- a/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs +++ b/src/AncillaryDomain.UnitTests/EmailDeliverRootSpec.cs @@ -6,6 +6,7 @@ using Domain.Events.Shared.Ancillary.EmailDelivery; using Domain.Interfaces.Entities; using Domain.Shared; +using Domain.Shared.Ancillary; using FluentAssertions; using Moq; using UnitTesting.Common; @@ -40,39 +41,72 @@ public void WhenCreate_ThenReturnsAssigned() } [Fact] - public void WhenSetEmailDetailsAndMissingSubject_ThenReturnsError() + public void WhenSetContentForHtmlEmailAndMissingSubject_ThenReturnsError() { var messageId = CreateMessageId(); var root = EmailDeliveryRoot.Create(_recorder.Object, _idFactory.Object, messageId).Value; var recipient = EmailRecipient.Create(EmailAddress.Create("auser@company.com").Value, "adisplayname").Value; - var result = root.SetEmailDetails(string.Empty, "abody", recipient, new List()); + var result = root.SetContent(string.Empty, "abody", recipient, new List()); - result.Should().BeError(ErrorCode.Validation, Resources.EmailDeliveryRoot_MissingEmailSubject); + result.Should().BeError(ErrorCode.Validation, Resources.EmailDeliveryRoot_HtmlEmail_MissingSubject); } [Fact] - public void WhenSetEmailDetailsAndMissingBody_ThenReturnsError() + public void WhenSetContentForHtmlEmailAndMissingBody_ThenReturnsError() { var messageId = CreateMessageId(); var root = EmailDeliveryRoot.Create(_recorder.Object, _idFactory.Object, messageId).Value; var recipient = EmailRecipient.Create(EmailAddress.Create("auser@company.com").Value, "adisplayname").Value; - var result = root.SetEmailDetails("asubject", string.Empty, recipient, new List()); + var result = root.SetContent("asubject", string.Empty, recipient, new List()); - result.Should().BeError(ErrorCode.Validation, Resources.EmailDeliveryRoot_MissingEmailBody); + result.Should().BeError(ErrorCode.Validation, Resources.EmailDeliveryRoot_HtmlEmail_MissingBody); } [Fact] - public void WhenSetEmailDetails_ThenDetailsAssigned() + public void WhenSetContentForHtmlEmail_ThenDetailsAssigned() { var messageId = CreateMessageId(); var root = EmailDeliveryRoot.Create(_recorder.Object, _idFactory.Object, messageId).Value; var recipient = EmailRecipient.Create(EmailAddress.Create("auser@company.com").Value, "adisplayname").Value; - var result = root.SetEmailDetails("asubject", "abody", recipient, new List { "atag" }); + var result = root.SetContent("asubject", "abody", recipient, new List { "atag" }); result.Should().BeSuccess(); + root.ContentType.Should().Be(DeliveredEmailContentType.Html); + root.Recipient.Should().Be(recipient); + root.Tags.Count.Should().Be(1); + root.Tags[0].Should().Be("atag"); + root.Events.Last().Should().BeOfType(); + } + + [Fact] + public void WhenSetContentForTemplatedEmailAndMissingTemplateId_ThenReturnsError() + { + var messageId = CreateMessageId(); + var root = EmailDeliveryRoot.Create(_recorder.Object, _idFactory.Object, messageId).Value; + var recipient = EmailRecipient.Create(EmailAddress.Create("auser@company.com").Value, "adisplayname").Value; + + var result = root.SetContent(string.Empty, "asubject", new Dictionary(), recipient, + new List()); + + result.Should().BeError(ErrorCode.Validation, Resources.EmailDeliveryRoot_TemplatedEmail_MissingTemplateId); + } + + [Fact] + public void WhenSetContentForTemplatedEmail_ThenDetailsAssigned() + { + var messageId = CreateMessageId(); + var root = EmailDeliveryRoot.Create(_recorder.Object, _idFactory.Object, messageId).Value; + var recipient = EmailRecipient.Create(EmailAddress.Create("auser@company.com").Value, "adisplayname").Value; + + var result = root.SetContent("atemplateid", "asubject", + new Dictionary { { "aname", "avalue" } }, recipient, + new List { "atag" }); + + result.Should().BeSuccess(); + root.ContentType.Should().Be(DeliveredEmailContentType.Templated); root.Recipient.Should().Be(recipient); root.Tags.Count.Should().Be(1); root.Tags[0].Should().Be("atag"); diff --git a/src/AncillaryDomain/EmailDeliveryRoot.cs b/src/AncillaryDomain/EmailDeliveryRoot.cs index 06f9ab11..6305486a 100644 --- a/src/AncillaryDomain/EmailDeliveryRoot.cs +++ b/src/AncillaryDomain/EmailDeliveryRoot.cs @@ -7,6 +7,7 @@ using Domain.Interfaces.Entities; using Domain.Interfaces.ValueObjects; using Domain.Shared; +using Domain.Shared.Ancillary; namespace AncillaryDomain; @@ -50,6 +51,11 @@ private EmailDeliveryRoot(IRecorder recorder, IIdentifierFactory idFactory, public Optional Sent { get; private set; } = Optional.None; + public List Tags { get; private set; } = []; + + public Optional ContentType { get; private set; } = + Optional.None; + public static AggregateRootFactory Rehydrate() { return (identifier, container, _) => new EmailDeliveryRoot(container.GetRequiredService(), @@ -97,6 +103,7 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco return recipient.Error; } + ContentType = changed.ContentType; Recipient = recipient.Value; Tags = changed.Tags; Recorder.TraceDebug(null, "EmailDelivery {Id} has updated the email details", Id); @@ -151,8 +158,6 @@ protected override Result OnStateChanged(IDomainEvent @event, bool isReco } } - public List Tags { get; private set; } = []; - public Result AttemptSending() { if (IsSent) @@ -222,23 +227,38 @@ public Result FailedSending() return RaiseChangeEvent(AncillaryDomain.Events.EmailDelivery.SendingFailed(Id, when)); } - public Result SetEmailDetails(string? subject, string? body, EmailRecipient recipient, + public Result SetContent(string? subject, string? body, EmailRecipient recipient, IReadOnlyList? tags) { if (subject.IsInvalidParameter(x => x.HasValue(), nameof(subject), - Resources.EmailDeliveryRoot_MissingEmailSubject, out var error1)) + Resources.EmailDeliveryRoot_HtmlEmail_MissingSubject, out var error1)) { return error1; } - if (body.IsInvalidParameter(x => x.HasValue(), nameof(body), Resources.EmailDeliveryRoot_MissingEmailBody, + if (body.IsInvalidParameter(x => x.HasValue(), nameof(body), Resources.EmailDeliveryRoot_HtmlEmail_MissingBody, out var error2)) { return error2; } return RaiseChangeEvent( - AncillaryDomain.Events.EmailDelivery.EmailDetailsChanged(Id, subject!, body!, recipient, tags)); + AncillaryDomain.Events.EmailDelivery.EmailDetailsChanged(Id, subject!, body!, Optional.None, + Optional>.None, recipient, tags)); + } + + public Result SetContent(string? templateId, string? subject, Dictionary? substitutions, + EmailRecipient recipient, IReadOnlyList? tags) + { + if (templateId.IsInvalidParameter(x => x.HasValue(), nameof(templateId), + Resources.EmailDeliveryRoot_TemplatedEmail_MissingTemplateId, out var error1)) + { + return error1; + } + + return RaiseChangeEvent( + AncillaryDomain.Events.EmailDelivery.EmailDetailsChanged(Id, subject, Optional.None, + templateId!, substitutions, recipient, tags)); } public Result SucceededSending(Optional receiptId) diff --git a/src/AncillaryDomain/Events.cs b/src/AncillaryDomain/Events.cs index 8017c07b..50627090 100644 --- a/src/AncillaryDomain/Events.cs +++ b/src/AncillaryDomain/Events.cs @@ -3,6 +3,7 @@ using Domain.Events.Shared.Ancillary.EmailDelivery; using Domain.Events.Shared.Ancillary.SmsDelivery; using Domain.Shared; +using Domain.Shared.Ancillary; using Created = Domain.Events.Shared.Ancillary.Audits.Created; using DeliveryConfirmed = Domain.Events.Shared.Ancillary.EmailDelivery.DeliveryConfirmed; using DeliveryFailureConfirmed = Domain.Events.Shared.Ancillary.EmailDelivery.DeliveryFailureConfirmed; @@ -45,16 +46,24 @@ public static DeliveryFailureConfirmed DeliveryFailureConfirmed(Identifier id, s }; } - public static EmailDetailsChanged EmailDetailsChanged(Identifier id, string subject, string body, + public static EmailDetailsChanged EmailDetailsChanged(Identifier id, Optional subject, + Optional body, Optional templateId, Optional> substitutions, EmailRecipient to, IReadOnlyList? tags) { return new EmailDetailsChanged(id) { + ContentType = templateId.HasValue + ? DeliveredEmailContentType.Templated + : DeliveredEmailContentType.Html, Subject = subject, Body = body, + TemplateId = templateId, + Substitutions = substitutions.HasValue + ? substitutions.Value + : new Dictionary(), ToEmailAddress = to.EmailAddress, ToDisplayName = to.DisplayName, - Tags = new List(tags ?? new List()) + Tags = [..tags ?? new List()] }; } @@ -156,7 +165,7 @@ public static SmsDetailsChanged SmsDetailsChanged(Identifier id, string body, { Body = body, ToPhoneNumber = to.Number, - Tags = new List(tags ?? new List()) + Tags = [..tags ?? new List()] }; } } diff --git a/src/AncillaryDomain/Resources.Designer.cs b/src/AncillaryDomain/Resources.Designer.cs index ca860f54..cc839e6e 100644 --- a/src/AncillaryDomain/Resources.Designer.cs +++ b/src/AncillaryDomain/Resources.Designer.cs @@ -78,20 +78,20 @@ internal static string EmailDeliveryRoot_AlreadySent { } /// - /// Looks up a localized string similar to The email message is missing a 'HtmlBody'. + /// Looks up a localized string similar to The email message is missing a 'Body'. /// - internal static string EmailDeliveryRoot_MissingEmailBody { + internal static string EmailDeliveryRoot_HtmlEmail_MissingBody { get { - return ResourceManager.GetString("EmailDeliveryRoot_MissingEmailBody", resourceCulture); + return ResourceManager.GetString("EmailDeliveryRoot_HtmlEmail_MissingBody", resourceCulture); } } /// /// Looks up a localized string similar to The email message is missing a 'Subject'. /// - internal static string EmailDeliveryRoot_MissingEmailSubject { + internal static string EmailDeliveryRoot_HtmlEmail_MissingSubject { get { - return ResourceManager.GetString("EmailDeliveryRoot_MissingEmailSubject", resourceCulture); + return ResourceManager.GetString("EmailDeliveryRoot_HtmlEmail_MissingSubject", resourceCulture); } } @@ -113,6 +113,15 @@ internal static string EmailDeliveryRoot_NotSent { } } + /// + /// Looks up a localized string similar to The email message is missing a 'TemplateId'. + /// + internal static string EmailDeliveryRoot_TemplatedEmail_MissingTemplateId { + get { + return ResourceManager.GetString("EmailDeliveryRoot_TemplatedEmail_MissingTemplateId", resourceCulture); + } + } + /// /// Looks up a localized string similar to The ID of the message is invalid. /// diff --git a/src/AncillaryDomain/Resources.resx b/src/AncillaryDomain/Resources.resx index b8252174..8fa9112b 100644 --- a/src/AncillaryDomain/Resources.resx +++ b/src/AncillaryDomain/Resources.resx @@ -27,11 +27,14 @@ The ID of the message is invalid - + The email message is missing a 'Subject' - - The email message is missing a 'HtmlBody' + + The email message is missing a 'Body' + + + The email message is missing a 'TemplateId' The email has already been sent for delivery diff --git a/src/AncillaryInfrastructure.IntegrationTests/EmailsApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/EmailsApiSpec.cs index c9beed25..05329bcf 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/EmailsApiSpec.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/EmailsApiSpec.cs @@ -47,10 +47,10 @@ public async Task WhenSendEmailAndDeliverySucceeds_ThenDelivered() MessageId = CreateMessageId(), CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -97,10 +97,10 @@ public async Task WhenSendEmailAndDeliveryFails_ThenNotDelivered() MessageId = CreateMessageId(), CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -147,10 +147,10 @@ public async Task WhenSendEmailAndDeliveryFailsFirstTimeAndSucceedsSecondTime_Th MessageId = CreateMessageId(), CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -200,10 +200,10 @@ public async Task WhenConfirmDelivery_ThenDelivered() MessageId = CreateMessageId(), CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -258,10 +258,10 @@ public async Task WhenConfirmDeliveryFailed_ThenFailsDelivery() MessageId = CreateMessageId(), CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -318,10 +318,10 @@ public async Task WhenSearchEmailDeliveriesWithTags_TheReturnsEmails() MessageId = CreateMessageId(), CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -374,10 +374,10 @@ public async Task WhenDrainAllEmailsAndSome_ThenDrains() MessageId = messageId1, CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject1", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -390,10 +390,10 @@ public async Task WhenDrainAllEmailsAndSome_ThenDrains() MessageId = messageId2, CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject2", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", @@ -406,10 +406,10 @@ public async Task WhenDrainAllEmailsAndSome_ThenDrains() MessageId = messageId3, CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject3", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", diff --git a/src/AncillaryInfrastructure.IntegrationTests/MailgunApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/MailgunApiSpec.cs index 20b28437..a740a21e 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/MailgunApiSpec.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/MailgunApiSpec.cs @@ -197,10 +197,10 @@ private async Task DeliveryEmailAsync() MessageId = CreateMessageId(), CallId = "acallid", CallerId = "acallerid", - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { Subject = "asubject", - HtmlBody = "anhtmlbody", + Body = "anhtmlbody", ToEmailAddress = "arecipient@company.com", ToDisplayName = "atodisplayname", FromEmailAddress = "asender@company.com", diff --git a/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubEmailDeliveryService.cs b/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubEmailDeliveryService.cs index 8983c943..9b45a4c7 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubEmailDeliveryService.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubEmailDeliveryService.cs @@ -8,13 +8,18 @@ public sealed class StubEmailDeliveryService : IEmailDeliveryService { public List AllSubjects { get; private set; } = new(); + public List AllTemplates { get; private set; } = new(); + public Optional LastReceiptId { get; private set; } = Optional.None; public Optional LastSubject { get; private set; } = Optional.None; + public Optional LastTemplate { get; private set; } = Optional.None; + public bool SendingSucceeds { get; set; } = true; - public Task> SendAsync(ICallerContext caller, string subject, string htmlBody, + public Task> SendHtmlAsync(ICallerContext caller, string subject, + string htmlBody, string toEmailAddress, string? toDisplayName, string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken) { @@ -31,10 +36,30 @@ public Task> SendAsync(ICallerContext caller : Task.FromResult>(Error.Unexpected()); } + public Task> SendTemplatedAsync(ICallerContext caller, string templateId, + string? subject, + Dictionary substitutions, string toEmailAddress, + string? toDisplayName, string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken) + { + var receiptId = $"receipt_{Guid.NewGuid():N}"; + AllTemplates.Add(templateId); + LastTemplate = Optional.Some(templateId); + LastReceiptId = receiptId; + + return SendingSucceeds + ? Task.FromResult>(new EmailDeliveryReceipt + { + ReceiptId = receiptId + }) + : Task.FromResult>(Error.Unexpected()); + } + public void Reset() { AllSubjects = new List(); LastSubject = Optional.None; LastReceiptId = Optional.None; + AllTemplates = new List(); + LastTemplate = Optional.None; } } \ No newline at end of file diff --git a/src/AncillaryInfrastructure/ApplicationServices/NoOpEmailDeliveryService.cs b/src/AncillaryInfrastructure/ApplicationServices/NoOpEmailDeliveryService.cs index 5a26b954..61198d3e 100644 --- a/src/AncillaryInfrastructure/ApplicationServices/NoOpEmailDeliveryService.cs +++ b/src/AncillaryInfrastructure/ApplicationServices/NoOpEmailDeliveryService.cs @@ -2,6 +2,7 @@ using Application.Interfaces; using Application.Persistence.Shared; using Common; +using Common.Extensions; using Task = System.Threading.Tasks.Task; namespace AncillaryInfrastructure.ApplicationServices; @@ -18,12 +19,13 @@ public NoOpEmailDeliveryService(IRecorder recorder) _recorder = recorder; } - public Task> SendAsync(ICallerContext caller, string subject, string htmlBody, + public Task> SendHtmlAsync(ICallerContext caller, string subject, + string htmlBody, string toEmailAddress, string? toDisplayName, string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken) { _recorder.TraceInformation(caller.ToCall(), - $"{nameof(NoOpEmailDeliveryService)} would have delivered email message {{To}}, from {{From}}, with subject {{Subject}}, body {{Body}}", + $"{nameof(NoOpEmailDeliveryService)} would have delivered HTML email message {{To}}, from {{From}}, with subject {{Subject}}, body {{Body}}", toEmailAddress, fromEmailAddress, subject, htmlBody); return Task.FromResult>(new EmailDeliveryReceipt @@ -31,4 +33,21 @@ public Task> SendAsync(ICallerContext caller ReceiptId = $"receipt_{Guid.NewGuid():N}" }); } + + public Task> SendTemplatedAsync(ICallerContext caller, string templateId, + string? subject, + Dictionary substitutions, string toEmailAddress, + string? toDisplayName, string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken) + { + _recorder.TraceInformation(caller.ToCall(), + $"{nameof(NoOpEmailDeliveryService)} would have delivered Templated email message {{To}}, from {{From}}, with template {{Template}}, and substitutions {{Substitutions}}", + toEmailAddress, fromEmailAddress, templateId, substitutions.Exists() + ? substitutions.ToJson()! + : "none"); + + return Task.FromResult>(new EmailDeliveryReceipt + { + ReceiptId = $"receipt_{Guid.NewGuid():N}" + }); + } } \ No newline at end of file diff --git a/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs b/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs index ef2b45fa..95971ac6 100644 --- a/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs +++ b/src/AncillaryInfrastructure/Persistence/ReadModels/EmailDeliveryProjection.cs @@ -40,8 +40,11 @@ public async Task> ProjectEventAsync(IDomainEvent changeEven case EmailDetailsChanged e: return await _deliveries.HandleUpdateAsync(e.RootId, dto => { + dto.ContentType = e.ContentType; dto.Subject = e.Subject; dto.Body = e.Body; + dto.TemplateId = e.TemplateId; + dto.Substitutions = e.Substitutions.ToJson(); dto.ToEmailAddress = e.ToEmailAddress; dto.ToDisplayName = e.ToDisplayName; dto.Tags = e.Tags.ToJson(); diff --git a/src/Application.Persistence.Shared/IEmailDeliveryService.cs b/src/Application.Persistence.Shared/IEmailDeliveryService.cs index 2e25d5b1..cbb7afac 100644 --- a/src/Application.Persistence.Shared/IEmailDeliveryService.cs +++ b/src/Application.Persistence.Shared/IEmailDeliveryService.cs @@ -5,14 +5,23 @@ namespace Application.Persistence.Shared; /// /// Defines a service to which we can send email messages. -/// Delivery of the message can be confirmed by the service later +/// Delivery of the message can be confirmed by the specific 3rd party service, later on /// public interface IEmailDeliveryService { /// - /// Sends the email for delivery + /// Sends an HTML email for delivery /// - Task> SendAsync(ICallerContext caller, string subject, string htmlBody, + Task> SendHtmlAsync(ICallerContext caller, string subject, string htmlBody, + string toEmailAddress, string? toDisplayName, string fromEmailAddress, string? fromDisplayName, + CancellationToken cancellationToken); + + /// + /// Sends a templated email for delivery + /// + Task> SendTemplatedAsync(ICallerContext caller, string templateId, + string? subject, + Dictionary substitutions, string toEmailAddress, string? toDisplayName, string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken); } diff --git a/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs index b4ebd43a..76da6ca5 100644 --- a/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs @@ -6,21 +6,42 @@ namespace Application.Persistence.Shared.ReadModels; [EntityName(WorkerConstants.Queues.Emails)] public class EmailMessage : QueuedMessage { - public QueuedEmailHtmlMessage? Message { get; set; } + public QueuedEmailHtmlMessage? Html { get; set; } + + public QueuedEmailTemplatedMessage? Template { get; set; } } public class QueuedEmailHtmlMessage { + public string? Body { get; set; } + public string? FromDisplayName { get; set; } public string? FromEmailAddress { get; set; } - public string? HtmlBody { get; set; } + public string? Subject { get; set; } + + public List? Tags { get; set; } + + public string? ToDisplayName { get; set; } + + public string? ToEmailAddress { get; set; } +} + +public class QueuedEmailTemplatedMessage +{ + public string? FromDisplayName { get; set; } + + public string? FromEmailAddress { get; set; } public string? Subject { get; set; } + public Dictionary? Substitutions { get; set; } + public List? Tags { get; set; } + public string? TemplateId { get; set; } + public string? ToDisplayName { get; set; } public string? ToEmailAddress { get; set; } diff --git a/src/Application.Services.Shared/IEmailSchedulingService.cs b/src/Application.Services.Shared/IEmailSchedulingService.cs index 0e56dc8f..23cf7e8e 100644 --- a/src/Application.Services.Shared/IEmailSchedulingService.cs +++ b/src/Application.Services.Shared/IEmailSchedulingService.cs @@ -8,12 +8,15 @@ namespace Application.Services.Shared; /// public interface IEmailSchedulingService { - Task> ScheduleHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, + Task> ScheduleHtmlEmail(ICallerContext caller, HtmlEmail email, + CancellationToken cancellationToken); + + Task> ScheduleTemplatedEmail(ICallerContext caller, TemplatedEmail email, CancellationToken cancellationToken); } /// -/// Defines the contents of an HTML email message +/// Defines an HTML email message /// public class HtmlEmail { @@ -25,9 +28,31 @@ public class HtmlEmail public required string Subject { get; set; } + public List? Tags { get; set; } + public required string ToDisplayName { get; set; } public required string ToEmailAddress { get; set; } +} + +/// +/// Defines an email template message +/// +public class TemplatedEmail +{ + public required string FromDisplayName { get; set; } + + public required string FromEmailAddress { get; set; } + + public string? Subject { get; set; } + + public Dictionary Substitutions { get; set; } = new(); public List? Tags { get; set; } + + public required string TemplateId { get; set; } + + public required string ToDisplayName { get; set; } + + public required string ToEmailAddress { get; set; } } \ No newline at end of file diff --git a/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs b/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs index 121699c0..4cdadab8 100644 --- a/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs +++ b/src/Domain.Events.Shared/Ancillary/EmailDelivery/EmailDetailsChanged.cs @@ -1,5 +1,6 @@ using Domain.Common; using Domain.Common.ValueObjects; +using Domain.Shared.Ancillary; using JetBrains.Annotations; namespace Domain.Events.Shared.Ancillary.EmailDelivery; @@ -15,13 +16,19 @@ public EmailDetailsChanged() { } - public required string Body { get; set; } + public string? Body { get; set; } - public required string Subject { get; set; } + public string? Subject { get; set; } + + public Dictionary? Substitutions { get; set; } public required List Tags { get; set; } + public string? TemplateId { get; set; } + public required string ToDisplayName { get; set; } public required string ToEmailAddress { get; set; } + + public required DeliveredEmailContentType ContentType { get; set; } } \ No newline at end of file diff --git a/src/Domain.Shared/Ancillary/DeliveredEmailContentType.cs b/src/Domain.Shared/Ancillary/DeliveredEmailContentType.cs new file mode 100644 index 00000000..0d0e9730 --- /dev/null +++ b/src/Domain.Shared/Ancillary/DeliveredEmailContentType.cs @@ -0,0 +1,7 @@ +namespace Domain.Shared.Ancillary; + +public enum DeliveredEmailContentType +{ + Html = 0, + Templated = 1 +} \ No newline at end of file diff --git a/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs index f01df1de..569c4bff 100644 --- a/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs +++ b/src/Infrastructure.Shared.IntegrationTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs @@ -26,9 +26,23 @@ public MailgunHttpServiceClientSpec(ExternalApiSetup setup) : base(setup, Overri } [Fact] - public async Task WhenSendAsync_ThenSends() + public async Task WhenSendHtmlAsync_ThenSends() { - var result = await _serviceClient.SendAsync(new TestCaller(), "asubject", "abody", + var result = await _serviceClient.SendHtmlAsync(new TestCaller(), "asubject", "abody", + _recipientEmail, "arecipient", _senderEmail, "asender", CancellationToken.None); + + result.Should().BeSuccess(); + result.Value.ReceiptId.Should().NotBeEmpty(); + } + + [Fact] + public async Task WhenSendTemplatedAsync_ThenSends() + { + var result = await _serviceClient.SendTemplatedAsync(new TestCaller(), "testingonly", "asubject", + new Dictionary + { + { "aname", "avalue" } + }, _recipientEmail, "arecipient", _senderEmail, "asender", CancellationToken.None); result.Should().BeSuccess(); diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunClientSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunClientSpec.cs index 9b153227..a3b3921c 100644 --- a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunClientSpec.cs +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunClientSpec.cs @@ -31,20 +31,20 @@ public MailgunClientSpec() [Fact] public async Task WhenSendAsync_ThenSends() { - _serviceClient.Setup(sc => sc.PostAsync(It.IsAny(), It.IsAny(), + _serviceClient.Setup(sc => sc.PostAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) - .ReturnsAsync(new MailgunSendResponse + .ReturnsAsync(new MailgunSendMessageResponse { Id = "areceiptid" }); - var result = await _client.SendAsync(_call.Object, "asubject", "afromemailaddress", "afromdisplayname", + var result = await _client.SendHtmlAsync(_call.Object, "asubject", "afromemailaddress", "afromdisplayname", new MailGunRecipient { DisplayName = "atodisplayname", EmailAddress = "atoemailaddress" }, "anhtmlmessage", CancellationToken.None); result.Should().BeSuccess(); result.Value.ReceiptId.Should().Be("areceiptid"); - _serviceClient.Verify(sc => sc.PostAsync(It.IsAny(), It.Is(req => + _serviceClient.Verify(sc => sc.PostAsync(It.IsAny(), It.Is(req => req.DomainName == "adomainname" && req.To == "atoemailaddress" && req.From == "afromdisplayname " diff --git a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs index 82b652ea..064b47d1 100644 --- a/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs +++ b/src/Infrastructure.Shared.UnitTests/ApplicationServices/External/MailgunHttpServiceClientSpec.cs @@ -28,19 +28,20 @@ public MailgunHttpServiceClientSpec() [Fact] public async Task WhenDeliverAsync_ThenSends() { - _client.Setup(c => c.SendAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _client.Setup(c => c.SendHtmlAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new EmailDeliveryReceipt { ReceiptId = "areceiptid" }); - var result = await _serviceClient.SendAsync(_caller.Object, "asubject", "anhtmlbody", "atoemailaddress", + var result = await _serviceClient.SendHtmlAsync(_caller.Object, "asubject", "anhtmlbody", "atoemailaddress", "atodisplayname", "afromemailaddress", "afromdisplayname", CancellationToken.None); result.Should().BeSuccess(); result.Value.ReceiptId.Should().Be("areceiptid"); - _client.Verify(c => c.SendAsync(It.IsAny(), "asubject", "afromemailaddress", "afromdisplayname", + _client.Verify(c => c.SendHtmlAsync(It.IsAny(), "asubject", "afromemailaddress", + "afromdisplayname", It.Is(r => r.EmailAddress == "atoemailaddress" && r.DisplayName == "atodisplayname"), "anhtmlbody", It.IsAny())); } diff --git a/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.MailgunClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.MailgunClient.cs index 987a5739..1479f139 100644 --- a/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.MailgunClient.cs +++ b/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.MailgunClient.cs @@ -16,8 +16,13 @@ namespace Infrastructure.Shared.ApplicationServices.External; public interface IMailgunClient { - Task> SendAsync(ICallContext call, string subject, string from, + Task> SendHtmlAsync(ICallContext call, string subject, string from, string? fromDisplayName, MailGunRecipient to, string htmlMessage, CancellationToken cancellationToken); + + Task> SendTemplatedAsync(ICallContext call, string templateId, string? subject, + string from, + string? fromDisplayName, MailGunRecipient to, + Dictionary substitutions, CancellationToken cancellationToken); } public class MailGunRecipient @@ -41,6 +46,10 @@ public class MailGunRecipient } } +/// +/// Provides a client for sending emails via the Mailgun API. +/// +/// public class MailgunClient : IMailgunClient { private readonly string _apiKey; @@ -75,30 +84,87 @@ private MailgunClient(IRecorder recorder, string baseUrl, string apiKey, string { } - public async Task> SendAsync(ICallContext call, string subject, string from, + public async Task> SendHtmlAsync(ICallContext call, string subject, string from, string? fromDisplayName, MailGunRecipient to, string htmlMessage, CancellationToken cancellationToken) + { + var recipient = to.ToVariable(); + var recipientVariables = recipient.Exists() + ? new Dictionary> + { + { recipient.Value.Key, recipient.Value.Value } + } + .ToJson(casing: StringExtensions.JsonCasing.Camel) + : null; + var sender = fromDisplayName.HasValue() + ? $"{fromDisplayName} <{from}>" + : from; + + var caller = Caller.CreateAsCallerFromCall(call); + try + { + var response = await _retryPolicy.ExecuteAsync(async () => await _serviceClient.PostAsync(caller, + new MailgunSendMessageRequest + { + DomainName = _domainName, + From = sender, + To = to.EmailAddress, + Subject = subject, + Html = htmlMessage, + RecipientVariables = recipientVariables, +#if TESTINGONLY + TestingOnly = "yes", +#else + TestingOnly = "no", +#endif + Tracking = "no" + }, req => PrepareRequest(req, _apiKey), cancellationToken)); + if (response.IsFailure) + { + return response.Error.ToError(); + } + + return new EmailDeliveryReceipt + { + ReceiptId = response.Value.Id ?? string.Empty.TrimStart('<').TrimEnd('>') + }; + } + catch (HttpRequestException ex) + { + _recorder.TraceError(call, ex, "Error sending Mailgun HTML email to {To}", to); + return ex.ToError(ErrorCode.Unexpected); + } + } + + public async Task> SendTemplatedAsync(ICallContext call, string templateId, + string? subject, + string from, string? fromDisplayName, MailGunRecipient to, Dictionary substitutions, + CancellationToken cancellationToken) { var recipient = to.ToVariable(); var recipients = recipient.Exists() ? new Dictionary> { { recipient.Value.Key, recipient.Value.Value } } - .ToJson( - casing: StringExtensions.JsonCasing.Camel) + .ToJson(casing: StringExtensions.JsonCasing.Camel) : null; var sender = fromDisplayName.HasValue() ? $"{fromDisplayName} <{from}>" : from; + var variables = substitutions.HasAny() + ? substitutions.ToDictionary(pair => pair.Key, pair => pair.Value) + .ToJson(casing: StringExtensions.JsonCasing.Camel) + : null; var caller = Caller.CreateAsCallerFromCall(call); try { var response = await _retryPolicy.ExecuteAsync(async () => await _serviceClient.PostAsync(caller, - new MailgunSendRequest + new MailgunSendMessageRequest { DomainName = _domainName, From = sender, To = to.EmailAddress, Subject = subject, - Html = htmlMessage, + Template = templateId, + TemplateVariables = variables, RecipientVariables = recipients, #if TESTINGONLY TestingOnly = "yes", @@ -119,7 +185,8 @@ public async Task> SendAsync(ICallContext ca } catch (HttpRequestException ex) { - _recorder.TraceError(call, ex, "Error sending Mailgun email to {To}", to); + _recorder.TraceError(call, ex, "Error sending Mailgun templated email to {To} with template {Template}", to, + templateId); return ex.ToError(ErrorCode.Unexpected); } } diff --git a/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.cs b/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.cs index 3e50bd45..d2b4235a 100644 --- a/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.cs +++ b/src/Infrastructure.Shared/ApplicationServices/External/MailgunHttpServiceClient.cs @@ -16,8 +16,7 @@ public class MailgunHttpServiceClient : IEmailDeliveryService private readonly IMailgunClient _serviceClient; public MailgunHttpServiceClient(IRecorder recorder, IConfigurationSettings settings, - IHttpClientFactory httpClientFactory) : this(recorder, - new MailgunClient(recorder, settings, httpClientFactory)) + IHttpClientFactory httpClientFactory) : this(recorder, new MailgunClient(recorder, settings, httpClientFactory)) { } @@ -27,14 +26,14 @@ public MailgunHttpServiceClient(IRecorder recorder, IMailgunClient serviceClient _serviceClient = serviceClient; } - public async Task> SendAsync(ICallerContext caller, string subject, + public async Task> SendHtmlAsync(ICallerContext caller, string subject, string htmlBody, string toEmailAddress, string? toDisplayName, string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken) { - _recorder.TraceInformation(caller.ToCall(), "Sending email to {To} in Mailgun from {From}", toEmailAddress, + _recorder.TraceInformation(caller.ToCall(), "Sending HTML email to {To} in Mailgun from {From}", toEmailAddress, fromEmailAddress); - var sent = await _serviceClient.SendAsync(caller.ToCall(), subject, fromEmailAddress, fromDisplayName, + var sent = await _serviceClient.SendHtmlAsync(caller.ToCall(), subject, fromEmailAddress, fromDisplayName, new MailGunRecipient { DisplayName = toDisplayName ?? string.Empty, @@ -46,7 +45,33 @@ public async Task> SendAsync(ICallerContext return sent.Error; } - _recorder.TraceInformation(caller.ToCall(), "Sent email to {To} in Mailgun, from {From} successfully", + _recorder.TraceInformation(caller.ToCall(), "Sent HTML email to {To} in Mailgun, from {From} successfully", + toEmailAddress, fromEmailAddress); + + return sent.Value; + } + + public async Task> SendTemplatedAsync(ICallerContext caller, string templateId, + string? subject, Dictionary substitutions, string toEmailAddress, + string? toDisplayName, string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken) + { + _recorder.TraceInformation(caller.ToCall(), "Sending templated email to {To} in Mailgun from {From}", + toEmailAddress, + fromEmailAddress); + + var sent = await _serviceClient.SendTemplatedAsync(caller.ToCall(), templateId, subject, fromEmailAddress, + fromDisplayName, new MailGunRecipient + { + DisplayName = toDisplayName ?? string.Empty, + EmailAddress = toEmailAddress + }, + substitutions, cancellationToken); + if (sent.IsFailure) + { + return sent.Error; + } + + _recorder.TraceInformation(caller.ToCall(), "Sent templated email to {To} in Mailgun, from {From} successfully", toEmailAddress, fromEmailAddress); return sent.Value; diff --git a/src/Infrastructure.Shared/ApplicationServices/QueuingEmailSchedulingService.cs b/src/Infrastructure.Shared/ApplicationServices/QueuingEmailSchedulingService.cs index 36f982ab..8e876034 100644 --- a/src/Infrastructure.Shared/ApplicationServices/QueuingEmailSchedulingService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/QueuingEmailSchedulingService.cs @@ -21,20 +21,20 @@ public QueuingEmailSchedulingService(IRecorder recorder, IEmailMessageQueue queu _queue = queue; } - public async Task> ScheduleHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, + public async Task> ScheduleHtmlEmail(ICallerContext caller, HtmlEmail email, CancellationToken cancellationToken) { var queued = await _queue.PushAsync(caller.ToCall(), new EmailMessage { - Message = new QueuedEmailHtmlMessage + Html = new QueuedEmailHtmlMessage { - Subject = htmlEmail.Subject, - FromEmailAddress = htmlEmail.FromEmailAddress, - FromDisplayName = htmlEmail.FromDisplayName, - HtmlBody = htmlEmail.Body, - ToEmailAddress = htmlEmail.ToEmailAddress, - ToDisplayName = htmlEmail.ToDisplayName, - Tags = htmlEmail.Tags + Subject = email.Subject, + Body = email.Body, + FromEmailAddress = email.FromEmailAddress, + FromDisplayName = email.FromDisplayName, + ToEmailAddress = email.ToEmailAddress, + ToDisplayName = email.ToDisplayName, + Tags = email.Tags } }, cancellationToken); if (queued.IsFailure) @@ -44,8 +44,39 @@ public async Task> ScheduleHtmlEmail(ICallerContext caller, HtmlEm var message = queued.Value; _recorder.TraceInformation(caller.ToCall(), - "Pended email message {Id} for {To} with subject {Subject}, and tags {Tags}", message.MessageId!, - htmlEmail.ToEmailAddress, htmlEmail.Subject, htmlEmail.Tags ?? new List { "(none)" }); + "Pended HTML email message {Id} for {To} with subject {Subject}, and tags {Tags}", message.MessageId!, + email.ToEmailAddress, email.Subject, email.Tags ?? ["(none)"]); + + return Result.Ok; + } + + public async Task> ScheduleTemplatedEmail(ICallerContext caller, TemplatedEmail email, + CancellationToken cancellationToken) + { + var queued = await _queue.PushAsync(caller.ToCall(), new EmailMessage + { + Template = new QueuedEmailTemplatedMessage + { + TemplateId = email.TemplateId, + Subject = email.Subject, + Substitutions = email.Substitutions, + FromEmailAddress = email.FromEmailAddress, + FromDisplayName = email.FromDisplayName, + ToEmailAddress = email.ToEmailAddress, + ToDisplayName = email.ToDisplayName, + Tags = email.Tags + } + }, cancellationToken); + if (queued.IsFailure) + { + return queued.Error; + } + + var message = queued.Value; + _recorder.TraceInformation(caller.ToCall(), + "Pended templated email message {Id} for {To} with template {Template}, and tags {Tags}", + message.MessageId!, + email.ToEmailAddress, email.TemplateId, email.Tags ?? ["(none)"]); return Result.Ok; } diff --git a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/MailgunSendRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/MailgunSendMessageRequest.cs similarity index 73% rename from src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/MailgunSendRequest.cs rename to src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/MailgunSendMessageRequest.cs index 2345716a..fe39f32c 100644 --- a/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/MailgunSendRequest.cs +++ b/src/Infrastructure.Web.Api.Operations.Shared/3rdParties/Mailgun/MailgunSendMessageRequest.cs @@ -7,7 +7,8 @@ namespace Infrastructure.Web.Api.Operations.Shared._3rdParties.Mailgun; /// Sends an email /// [Route("/{DomainName}/messages", OperationMethod.Post)] -public class MailgunSendRequest : WebRequest, IHasMultipartFormData +public class MailgunSendMessageRequest : WebRequest, + IHasMultipartFormData { [JsonIgnore] public string? DomainName { get; set; } @@ -20,6 +21,11 @@ public class MailgunSendRequest : WebRequestTrue True True + True True True True diff --git a/src/TestingStubApiHost/Api/StubMailgunApi.cs b/src/TestingStubApiHost/Api/StubMailgunApi.cs index e457e87b..ed9bb90a 100644 --- a/src/TestingStubApiHost/Api/StubMailgunApi.cs +++ b/src/TestingStubApiHost/Api/StubMailgunApi.cs @@ -20,13 +20,15 @@ public StubMailgunApi(IRecorder recorder, IConfigurationSettings settings, IServ _serviceClient = serviceClient; } - public async Task> SendMessage(MailgunSendRequest request, + public async Task> SendMessage(MailgunSendMessageRequest request, CancellationToken cancellationToken) { await Task.CompletedTask; Recorder.TraceInformation(null, - "StubMailgun: SendMessage to {To}{Recipient}, from {From}, with subject {Subject}, and body {Body}", - request.To!, request.RecipientVariables!, request.From!, request.Subject!, request.Html!); + "StubMailgun: SendMessage to {To}{Recipient}, from {From}, with subject {Subject}. Either body {Body}, or template {Template} with variables {Variables}", + request.To!, request.RecipientVariables ?? "none", request.From!, request.Subject ?? "none", + request.Html ?? "none", + request.Template ?? "none", request.TemplateVariables ?? "none"); // Fire the webhook event after returning var receiptId = $"receipt_{Guid.NewGuid():N}"; @@ -56,7 +58,7 @@ public async Task> SendMessage(Mailgu }), cancellationToken); return () => - new PostResult(new MailgunSendResponse + new PostResult(new MailgunSendMessageResponse { Id = receiptId, Message = "Queued. Thank you."