From bfcd44baf5ddbc11866f8f01a158b2e4fa2a11b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Tr=C3=B8strup?= Date: Tue, 12 Nov 2024 18:17:43 +0100 Subject: [PATCH] Add EmailSender abstraction for sending mails using either SMTP or Mailgun (#293) This PR adds an `IEmailSender` abstraction along with implementations `SmtpEmailSender` and `MailgunEmailSender`, and refactors `EmailService` to use said abstraction. The main idea is to allow for local email testing using an SMTP server such as [MailHog](https://github.com/mailhog/MailHog), which has also been added to the Docker Compose configuration. image --- .../Configuration/SmtpSettings.cs | 11 ++++++ .../Services/EmailService.cs | 38 ++++--------------- .../Services/IEmailSender.cs | 9 +++++ .../Services/MailgunEmailSender.cs | 34 +++++++++++++++++ .../Services/SmtpEmailSender.cs | 28 ++++++++++++++ coffeecard/CoffeeCard.WebApi/Startup.cs | 9 +++++ coffeecard/CoffeeCard.WebApi/appsettings.json | 4 ++ coffeecard/docker-compose.yml | 6 +++ 8 files changed, 108 insertions(+), 31 deletions(-) create mode 100644 coffeecard/CoffeeCard.Common/Configuration/SmtpSettings.cs create mode 100644 coffeecard/CoffeeCard.Library/Services/IEmailSender.cs create mode 100644 coffeecard/CoffeeCard.Library/Services/MailgunEmailSender.cs create mode 100644 coffeecard/CoffeeCard.Library/Services/SmtpEmailSender.cs diff --git a/coffeecard/CoffeeCard.Common/Configuration/SmtpSettings.cs b/coffeecard/CoffeeCard.Common/Configuration/SmtpSettings.cs new file mode 100644 index 00000000..fcb8e72b --- /dev/null +++ b/coffeecard/CoffeeCard.Common/Configuration/SmtpSettings.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace CoffeeCard.Common.Configuration +{ + public class SmtpSettings + { + [Required] public string Host { get; set; } + [Required] public int Port { get; set; } + } +} + diff --git a/coffeecard/CoffeeCard.Library/Services/EmailService.cs b/coffeecard/CoffeeCard.Library/Services/EmailService.cs index 2b3f8353..bd419659 100644 --- a/coffeecard/CoffeeCard.Library/Services/EmailService.cs +++ b/coffeecard/CoffeeCard.Library/Services/EmailService.cs @@ -18,13 +18,13 @@ public class EmailService : IEmailService { private readonly IWebHostEnvironment _env; private readonly EnvironmentSettings _environmentSettings; - private readonly MailgunSettings _mailgunSettings; + private readonly IEmailSender _emailSender; private readonly IMapperService _mapperService; - public EmailService(MailgunSettings mailgunSettings, EnvironmentSettings environmentSettings, + public EmailService(IEmailSender emailSender, EnvironmentSettings environmentSettings, IWebHostEnvironment env, IMapperService mapperService) { - _mailgunSettings = mailgunSettings; + _emailSender = emailSender; _environmentSettings = environmentSettings; _env = env; _mapperService = mapperService; @@ -54,7 +54,7 @@ public async Task SendInvoiceAsync(UserDto user, PurchaseDto purchase) Log.Information("Sending invoice for PurchaseId {PurchaseId} to UserId {UserId}, E-mail {Email}", purchase.Id, user.Id, user.Email); - await SendEmailAsync(message); + await _emailSender.SendEmailAsync(message); } public async Task SendRegistrationVerificationEmailAsync(User user, string token) @@ -71,7 +71,7 @@ public async Task SendRegistrationVerificationEmailAsync(User user, string token message.Body = builder.ToMessageBody(); - await SendEmailAsync(message); + await _emailSender.SendEmailAsync(message); } public async Task SendVerificationEmailForLostPwAsync(User user, string token) @@ -87,7 +87,7 @@ public async Task SendVerificationEmailForLostPwAsync(User user, string token) message.Body = builder.ToMessageBody(); - await SendEmailAsync(message); + await _emailSender.SendEmailAsync(message); } public async Task SendVerificationEmailForDeleteAccount(User user, string token) @@ -104,7 +104,7 @@ public async Task SendVerificationEmailForDeleteAccount(User user, string token) message.Body = builder.ToMessageBody(); - await SendEmailAsync(message); + await _emailSender.SendEmailAsync(message); } public async Task SendInvoiceAsyncV2(Purchase purchase, User user) @@ -150,29 +150,5 @@ private BodyBuilder RetrieveTemplate(string templateName) return builder; } - - private async Task SendEmailAsync(MimeMessage mail) - { - var client = new RestClient(_mailgunSettings.MailgunApiUrl) - { - Authenticator = new HttpBasicAuthenticator("api", _mailgunSettings.ApiKey) - }; - - var request = new RestRequest(); - request.AddParameter("domain", _mailgunSettings.Domain, ParameterType.UrlSegment); - request.Resource = "{domain}/messages"; - request.AddParameter("from", "Cafe Analog "); - request.AddParameter("to", mail.To[0].ToString()); - request.AddParameter("subject", mail.Subject); - request.AddParameter("html", mail.HtmlBody); - request.Method = Method.Post; - - var response = await client.ExecutePostAsync(request); - - if (!response.IsSuccessful) - { - Log.Error("Error sending request to Mailgun. StatusCode: {statusCode} ErrorMessage: {errorMessage}", response.StatusCode, response.ErrorMessage); - } - } } } \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/IEmailSender.cs b/coffeecard/CoffeeCard.Library/Services/IEmailSender.cs new file mode 100644 index 00000000..ac4edc9f --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/IEmailSender.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; +using MimeKit; + +namespace CoffeeCard.Library.Services; + +public interface IEmailSender +{ + public Task SendEmailAsync(MimeMessage email); +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/MailgunEmailSender.cs b/coffeecard/CoffeeCard.Library/Services/MailgunEmailSender.cs new file mode 100644 index 00000000..227b778c --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/MailgunEmailSender.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using MimeKit; +using RestSharp; +using RestSharp.Authenticators; +using Serilog; + +namespace CoffeeCard.Library.Services; + +public class MailgunEmailSender(MailgunSettings mailgunSettings) : IEmailSender +{ + public async Task SendEmailAsync(MimeMessage mail) + { + using var client = new RestClient(mailgunSettings.MailgunApiUrl); + client.Authenticator = new HttpBasicAuthenticator("api", mailgunSettings.ApiKey); + + var request = new RestRequest(); + request.AddParameter("domain", mailgunSettings.Domain, ParameterType.UrlSegment); + request.Resource = "{domain}/messages"; + request.AddParameter("from", "Cafe Analog "); + request.AddParameter("to", mail.To[0].ToString()); + request.AddParameter("subject", mail.Subject); + request.AddParameter("html", mail.HtmlBody); + request.Method = Method.Post; + + var response = await client.ExecutePostAsync(request); + + if (!response.IsSuccessful) + { + Log.Error("Error sending request to Mailgun. StatusCode: {statusCode} ErrorMessage: {errorMessage}", + response.StatusCode, response.ErrorMessage); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.Library/Services/SmtpEmailSender.cs b/coffeecard/CoffeeCard.Library/Services/SmtpEmailSender.cs new file mode 100644 index 00000000..4676bddf --- /dev/null +++ b/coffeecard/CoffeeCard.Library/Services/SmtpEmailSender.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using CoffeeCard.Common.Configuration; +using MailKit.Net.Smtp; +using MimeKit; +using Serilog; + +namespace CoffeeCard.Library.Services; + +public class SmtpEmailSender(SmtpSettings smtpSettings) : IEmailSender +{ + public async Task SendEmailAsync(MimeMessage mail) + { + mail.From.Add(new MailboxAddress("Cafe Analog", "smtp@analogio.dk")); + + try + { + using var smtpClient = new SmtpClient(); + await smtpClient.ConnectAsync(smtpSettings.Host, smtpSettings.Port); + await smtpClient.SendAsync(mail); + await smtpClient.DisconnectAsync(true); + } + catch (Exception ex) + { + Log.Error("Error sending request to SMTP server. Error: {errorMessage}", ex.Message); + } + } +} \ No newline at end of file diff --git a/coffeecard/CoffeeCard.WebApi/Startup.cs b/coffeecard/CoffeeCard.WebApi/Startup.cs index 38425b69..320f9cff 100644 --- a/coffeecard/CoffeeCard.WebApi/Startup.cs +++ b/coffeecard/CoffeeCard.WebApi/Startup.cs @@ -76,6 +76,15 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); + if (_environment.IsDevelopment()) + { + services.AddTransient(); + services.AddSingleton(_configuration.GetSection("SmtpSettings").Get()); + } + else + { + services.AddTransient(); + } services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/coffeecard/CoffeeCard.WebApi/appsettings.json b/coffeecard/CoffeeCard.WebApi/appsettings.json index 3dbe154b..d6b97d62 100644 --- a/coffeecard/CoffeeCard.WebApi/appsettings.json +++ b/coffeecard/CoffeeCard.WebApi/appsettings.json @@ -23,6 +23,10 @@ "EmailBaseUrl": "https://localhost", "MailgunApiUrl": "https://api.mailgun.net/v3" }, + "SmtpSettings": { + "Host": "mailhog", + "Port": 1025 + }, "MobilePaySettingsV2": { "ApiUrl": "https://invalidurl.test/", "ApiKey": "DummyKey", diff --git a/coffeecard/docker-compose.yml b/coffeecard/docker-compose.yml index 046c5b37..f290a256 100644 --- a/coffeecard/docker-compose.yml +++ b/coffeecard/docker-compose.yml @@ -35,3 +35,9 @@ services: volumes: - ./CoffeeCard.WebApi/appsettings.json:/app/appsettings.json:z - ~/.aspnet/https:/https:ro + + mailhog: + image: mailhog/mailhog + ports: + - "1025:1025" + - "8025:8025"