diff --git a/src/AdminConsole/Components/Pages/Organization/Verify.razor b/src/AdminConsole/Components/Pages/Organization/Verify.razor new file mode 100644 index 000000000..2873ac169 --- /dev/null +++ b/src/AdminConsole/Components/Pages/Organization/Verify.razor @@ -0,0 +1,45 @@ +@page "/organization/verify" +@using Microsoft.Extensions.Options +@using Passwordless.Common.Services.Mail + +@attribute [AllowAnonymous] + +@inject IOptionsSnapshot MailOptions +@inject IHttpContextAccessor HttpContextAccessor +@inject NavigationManager NavigationManager +@inject IWebHostEnvironment WebHostEnvironment + + + @* This to refresh the page to check if the user has verified and logged in *@ + + + + +
+ + + +

Check your email to verify your account

+

We've sent a confirmation link to the email address you provided. Click on the link in the email to verify your account.

+
+ + @if (ShouldShowFileMailPath && FileMailPath != null) + { +
+

+ Development mode: Email history is saved to @FileMailPath and displayed below. +

+ + @if (FileMailPathExists && FileMailContent != null) + { +
+                    @FileMailContent
+                
+ } + else + { +

No emails have been sent yet.

+ } +
+ } +
\ No newline at end of file diff --git a/src/AdminConsole/Components/Pages/Organization/Verify.razor.cs b/src/AdminConsole/Components/Pages/Organization/Verify.razor.cs new file mode 100644 index 000000000..6c0d54789 --- /dev/null +++ b/src/AdminConsole/Components/Pages/Organization/Verify.razor.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Components; +using Passwordless.Common.Services.Mail.File; + +namespace Passwordless.AdminConsole.Components.Pages.Organization; + +public partial class Verify : ComponentBase +{ + public bool ShouldShowFileMailPath => WebHostEnvironment.IsDevelopment() && MailOptions.Value.Providers.All(p => p.Name.Equals(FileMailProviderOptions.Provider, StringComparison.InvariantCultureIgnoreCase)); + + public string? FileMailPath { get; set; } + + public bool FileMailPathExists { get; set; } + + public string? FileMailContent { get; set; } + + protected override async Task OnInitializedAsync() + { + if (HttpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false) + { + NavigationManager.NavigateTo("/account/useronboarding"); + return; + } + + if (ShouldShowFileMailPath) + { + var fileMailProvider = MailOptions.Value.Providers.First() as FileMailProviderOptions; + FileMailPath = Path.GetFullPath(fileMailProvider!.Path); + FileMailPathExists = File.Exists(FileMailPath); + if (FileMailPathExists) + { + FileMailContent = await File.ReadAllTextAsync(FileMailPath); + } + } + } +} \ No newline at end of file diff --git a/src/AdminConsole/Pages/Organization/Verify.cshtml b/src/AdminConsole/Pages/Organization/Verify.cshtml deleted file mode 100644 index 565de1b11..000000000 --- a/src/AdminConsole/Pages/Organization/Verify.cshtml +++ /dev/null @@ -1,44 +0,0 @@ -@page -@using Microsoft.AspNetCore.Authorization -@using Microsoft.Extensions.Options -@using Passwordless.Common.Services.Mail -@inject IOptions MailOptions -@model Passwordless.AdminConsole.Pages.Organization.Verify -@attribute [AllowAnonymous] -@{ - ViewData["Title"] = "Verify your account"; -} - -@section Head { - @* This to refresh the page to check if the user has verified and logged in *@ - -} - -
- - - -

Check your email to verify your account

-

We've sent a confirmation link to the email address you provided. Click on the link in the email to verify your account.

-
- - - @{ - var mailPath = System.IO.Path.GetFullPath(MailOptions.Value.Path ?? FileMailProvider.DefaultPath); - } - -
-

- Development mode: Email history is saved to @mailPath and displayed below. -

- - @if (System.IO.File.Exists(mailPath)) - { -
@await System.IO.File.ReadAllTextAsync(mailPath)
- } - else - { -

No emails have been sent yet.

- } -
-
diff --git a/src/AdminConsole/Pages/Organization/Verify.cshtml.cs b/src/AdminConsole/Pages/Organization/Verify.cshtml.cs deleted file mode 100644 index be8b01db8..000000000 --- a/src/AdminConsole/Pages/Organization/Verify.cshtml.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.RazorPages; -using Passwordless.AdminConsole.Identity; -using Passwordless.AdminConsole.Services.MagicLinks; - -namespace Passwordless.AdminConsole.Pages.Organization; - -public class Verify : PageModel -{ - private readonly MagicLinkSignInManager _signInManager; - private readonly IHttpContextAccessor _httpContextAccessor; - - public Verify(MagicLinkSignInManager signInManager, IHttpContextAccessor httpContextAccessor) - { - _signInManager = signInManager; - _httpContextAccessor = httpContextAccessor; - } - - public IActionResult OnGet() - { - return _httpContextAccessor.HttpContext != null && _signInManager.IsSignedIn(_httpContextAccessor.HttpContext.User) - ? Redirect("/Account/useronboarding") - : Page(); - } -} \ No newline at end of file diff --git a/src/AdminConsole/readme.md b/src/AdminConsole/readme.md index e55d66903..a1b5e7bb8 100644 --- a/src/AdminConsole/readme.md +++ b/src/AdminConsole/readme.md @@ -37,11 +37,18 @@ The [Admin Console](https://admin.passwordless.dev/) is your primary GUI for cre "ApiSecret": "myAppId:secret:123456", "ApiUrl": "" }, + // https://docs.passwordless.dev/guide/self-hosting/configuration.html#e-mail "Mail": { - // Mail Providers are pluggable. Remove "Postmark" to use the FileProvider - "Postmark": { - "ApiKey": "", - "From": "" + "Smtp": { + "From": "sender@example.com", + "Username": "username", + "Password": "password", + "Host": "smtp.example.com", + "Port": 587, + "StartTls": true, + "Ssl": false, + "SslOverride": false, + "TrustServer": false } }, "ConnectionStrings": { diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index e00963eca..524e71cbf 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -11,7 +11,6 @@ - @@ -19,6 +18,8 @@ + + diff --git a/src/Common/HealthChecks/MailKitHealthCheck.cs b/src/Common/HealthChecks/MailKitHealthCheck.cs index 0b30b9f04..699821a99 100644 --- a/src/Common/HealthChecks/MailKitHealthCheck.cs +++ b/src/Common/HealthChecks/MailKitHealthCheck.cs @@ -1,16 +1,16 @@ using MailKit.Net.Smtp; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Passwordless.Common.Services.Mail; +using Passwordless.Common.Services.Mail.Smtp; namespace Passwordless.Common.HealthChecks; public class MailKitHealthCheck : IHealthCheck { - private readonly MailKitSmtpMailProvider _mailProvider; + private readonly SmtpMailProvider _smtpMailProvider; - public MailKitHealthCheck(MailKitSmtpMailProvider mailProvider) + public MailKitHealthCheck(SmtpMailProvider smtpMailProvider) { - _mailProvider = mailProvider; + _smtpMailProvider = smtpMailProvider; } public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) @@ -19,7 +19,7 @@ public async Task CheckHealthAsync(HealthCheckContext context bool hasErrors = false; try { - client = await _mailProvider.GetClientAsync(); + client = await _smtpMailProvider.GetClientAsync(); } catch { diff --git a/src/Common/Services/Mail/AggregateMailProvider.cs b/src/Common/Services/Mail/AggregateMailProvider.cs new file mode 100644 index 000000000..cb22a5133 --- /dev/null +++ b/src/Common/Services/Mail/AggregateMailProvider.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Options; + +namespace Passwordless.Common.Services.Mail; + +/// +/// Wraps multiple mail providers and tries to send the message using them in order. +/// +public class AggregateMailProvider : IMailProvider +{ + private readonly IOptionsSnapshot _options; + private readonly IMailProviderFactory _factory; + private readonly ILogger _logger; + + public const string FallBackFailedMessage = "No registered mail provider was able to send the message"; + + public AggregateMailProvider( + IOptionsSnapshot options, + IMailProviderFactory factory, + ILogger logger) + { + _options = options; + _factory = factory; + _logger = logger; + } + + public async Task SendAsync(MailMessage message) + { + if (message.From == null) + { + message.From = _options.Value.From; + } + foreach (var providerConfiguration in _options.Value.Providers) + { + try + { + _logger.LogDebug("Attempting to send message using provider '{Provider}'", providerConfiguration.Name); + var provider = _factory.Create(providerConfiguration.Name, providerConfiguration); + + await provider.SendAsync(message); + _logger.LogInformation("Sent message using provider '{Provider}'", providerConfiguration.Name); + return; + } + catch (Exception e) + { + _logger.LogError(e, "Failed to send message using provider '{Provider}'", providerConfiguration.Name); + } + } + + _logger.LogCritical(FallBackFailedMessage); + throw new InvalidOperationException(FallBackFailedMessage); + } +} \ No newline at end of file diff --git a/src/Common/Services/Mail/Aws/AwsMailProvider.cs b/src/Common/Services/Mail/Aws/AwsMailProvider.cs new file mode 100644 index 000000000..703c8cb5a --- /dev/null +++ b/src/Common/Services/Mail/Aws/AwsMailProvider.cs @@ -0,0 +1,86 @@ +using Amazon; +using Amazon.Runtime; +using Amazon.SimpleEmailV2; +using Amazon.SimpleEmailV2.Model; + +namespace Passwordless.Common.Services.Mail.Aws; + +public class AwsMailProvider : IMailProvider +{ + private readonly IAmazonSimpleEmailServiceV2 _client; + private readonly ILogger _logger; + + public AwsMailProvider( + AwsMailProviderOptions options, + ILogger logger) + { + var credentials = new BasicAWSCredentials(options.AccessKey, options.SecretKey); + _client = new AmazonSimpleEmailServiceV2Client(credentials, RegionEndpoint.GetBySystemName(options.Region)); + _logger = logger; + } + + public async Task SendAsync(MailMessage message) + { + var request = new SendEmailRequest + { + FromEmailAddress = message.From + }; + + if (message.To.Any()) + { + request.Destination = new Destination + { + ToAddresses = message.To.ToList() + }; + } + + request.Content = new EmailContent + { + Simple = new Message + { + Subject = new Content { Data = message.Subject }, + Body = new Body + { + Html = new Content { Data = message.HtmlBody }, + Text = new Content { Data = message.TextBody }, + } + } + }; + + try + { + await _client.SendEmailAsync(request); + _logger.LogInformation("Sent email with subject '{Subject}' to '{To}'", message.Subject, message.To); + } + catch (AccountSuspendedException ex) + { + _logger.LogError(ex, "The account's ability to send email has been permanently restricted."); + throw; + } + catch (MailFromDomainNotVerifiedException ex) + { + _logger.LogError(ex, "The sending domain is not verified."); + throw; + } + catch (MessageRejectedException ex) + { + _logger.LogError(ex, "The message content is invalid."); + throw; + } + catch (SendingPausedException ex) + { + _logger.LogError(ex, "The account's ability to send email is currently paused."); + throw; + } + catch (TooManyRequestsException ex) + { + _logger.LogError(ex, "Too many requests were made. Please try again later."); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while sending the email."); + throw; + } + } +} \ No newline at end of file diff --git a/src/Common/Services/Mail/Aws/AwsMailProviderOptions.cs b/src/Common/Services/Mail/Aws/AwsMailProviderOptions.cs new file mode 100644 index 000000000..c1b91352e --- /dev/null +++ b/src/Common/Services/Mail/Aws/AwsMailProviderOptions.cs @@ -0,0 +1,17 @@ +namespace Passwordless.Common.Services.Mail.Aws; + +public class AwsMailProviderOptions : BaseMailProviderOptions +{ + public const string Provider = "aws"; + + public AwsMailProviderOptions() + { + Name = Provider; + } + + public string AccessKey { get; set; } + + public string SecretKey { get; set; } + + public string Region { get; set; } +} \ No newline at end of file diff --git a/src/Common/Services/Mail/BaseMailProviderOptions.cs b/src/Common/Services/Mail/BaseMailProviderOptions.cs new file mode 100644 index 000000000..a22f6fd28 --- /dev/null +++ b/src/Common/Services/Mail/BaseMailProviderOptions.cs @@ -0,0 +1,9 @@ +namespace Passwordless.Common.Services.Mail; + +public abstract class BaseMailProviderOptions +{ + /// + /// The name of the provider. + /// + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/Common/Services/Mail/FileMailProvider.cs b/src/Common/Services/Mail/File/FileMailProvider.cs similarity index 70% rename from src/Common/Services/Mail/FileMailProvider.cs rename to src/Common/Services/Mail/File/FileMailProvider.cs index 56dafb4ee..38041a451 100644 --- a/src/Common/Services/Mail/FileMailProvider.cs +++ b/src/Common/Services/Mail/File/FileMailProvider.cs @@ -1,23 +1,21 @@ using Microsoft.Extensions.Options; -namespace Passwordless.Common.Services.Mail; +namespace Passwordless.Common.Services.Mail.File; // ReSharper disable once UnusedType.Global public class FileMailProvider : IMailProvider { - public const string DefaultPath = "mail.md"; - private readonly string _path; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public FileMailProvider( TimeProvider timeProvider, - IOptions configuration, + FileMailProviderOptions options, ILogger logger) { _timeProvider = timeProvider; - _path = string.IsNullOrEmpty(configuration.Value.Path) ? DefaultPath : configuration.Value.Path; + _path = options.Path; _logger = logger; } @@ -31,7 +29,7 @@ public async Task SendAsync(MailMessage message) """; - await File.AppendAllTextAsync(_path, content); + await System.IO.File.AppendAllTextAsync(_path, content); _logger.LogInformation("Saved email contents to '{Path}'", _path); } diff --git a/src/Common/Services/Mail/File/FileMailProviderOptions.cs b/src/Common/Services/Mail/File/FileMailProviderOptions.cs new file mode 100644 index 000000000..fa7cb98da --- /dev/null +++ b/src/Common/Services/Mail/File/FileMailProviderOptions.cs @@ -0,0 +1,14 @@ +namespace Passwordless.Common.Services.Mail.File; + +public class FileMailProviderOptions : BaseMailProviderOptions +{ + public const string Provider = "file"; + public const string DefaultPath = "../mail.md"; + + public FileMailProviderOptions() + { + Name = Provider; + } + + public string Path { get; set; } = DefaultPath; +} \ No newline at end of file diff --git a/src/Common/Services/Mail/FileMailProviderConfiguration.cs b/src/Common/Services/Mail/FileMailProviderConfiguration.cs deleted file mode 100644 index 510508241..000000000 --- a/src/Common/Services/Mail/FileMailProviderConfiguration.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Passwordless.Common.Services.Mail; - -public class FileMailProviderConfiguration -{ - public string? Path { get; set; } -} \ No newline at end of file diff --git a/src/Common/Services/Mail/IMailProvider.cs b/src/Common/Services/Mail/IMailProvider.cs index a11ce9150..fe49d83b1 100644 --- a/src/Common/Services/Mail/IMailProvider.cs +++ b/src/Common/Services/Mail/IMailProvider.cs @@ -5,5 +5,9 @@ namespace Passwordless.Common.Services.Mail; /// public interface IMailProvider { + /// + /// Sends a mail message + /// + /// Task SendAsync(MailMessage message); } \ No newline at end of file diff --git a/src/Common/Services/Mail/IMailProviderFactory.cs b/src/Common/Services/Mail/IMailProviderFactory.cs new file mode 100644 index 000000000..58684519f --- /dev/null +++ b/src/Common/Services/Mail/IMailProviderFactory.cs @@ -0,0 +1,15 @@ +namespace Passwordless.Common.Services.Mail; + +/// +/// Responsible for creating instances of . +/// +public interface IMailProviderFactory +{ + /// + /// Creates a new instance of . + /// + /// The name of the provider (case insensitive). + /// The options to use when creating the provider. + /// + IMailProvider Create(string name, BaseMailProviderOptions options); +} \ No newline at end of file diff --git a/src/Common/Services/Mail/MailBootstrap.cs b/src/Common/Services/Mail/MailBootstrap.cs index 586b914bd..3209f2597 100644 --- a/src/Common/Services/Mail/MailBootstrap.cs +++ b/src/Common/Services/Mail/MailBootstrap.cs @@ -1,39 +1,58 @@ +using System.Configuration; +using Passwordless.Common.Services.Mail.Aws; +using Passwordless.Common.Services.Mail.File; +using Passwordless.Common.Services.Mail.SendGrid; +using Passwordless.Common.Services.Mail.Smtp; + namespace Passwordless.Common.Services.Mail; public static class MailBootstrap { public static void AddMail(this WebApplicationBuilder builder) { - builder.Services.AddOptions().BindConfiguration("Mail"); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.Configure(builder.Configuration.GetSection("Mail")) + .PostConfigure(o => + { + var section = builder.Configuration.GetSection("Mail:Providers"); - if (builder.Configuration.GetSection("Mail:Postmark").Exists()) - { - var configurationSection = builder.Configuration.GetSection("Mail:PostMark"); + if (!section.GetChildren().Any()) + { + o.Providers = new List + { + new FileMailProviderOptions() + }; + return; + } - var clients = new List(); - configurationSection.GetSection("MessageStreams").Bind(clients); + // Iterate over all configured providers and add them to the list with binding. - builder.Services.AddSingleton(new PostmarkMailProviderConfiguration - { - DefaultConfiguration = new PostmarkClientConfiguration + var providers = new List(); + foreach (var child in section.GetChildren()) { - ApiKey = configurationSection.GetValue("ApiKey") ?? string.Empty, - Name = "Default", - From = configurationSection.GetValue("From") ?? string.Empty - }, - MessageStreams = clients - }); + var type = child.GetValue("Name"); - builder.Services.AddSingleton(); - } - else if (builder.Configuration.GetSection("Mail:Smtp").Exists()) - { - builder.Services.AddSingleton(); - } - else - { - builder.Services.AddOptions().BindConfiguration("Mail:File"); - builder.Services.AddSingleton(); - } + if (type == null) + { + throw new ConfigurationErrorsException("Provider type is missing"); + } + + BaseMailProviderOptions mailProviderOptions = type.ToLowerInvariant() switch + { + AwsMailProviderOptions.Provider => new AwsMailProviderOptions(), + SendGridMailProviderOptions.Provider => new SendGridMailProviderOptions(), + SmtpMailProviderOptions.Provider => new SmtpMailProviderOptions(), + FileMailProviderOptions.Provider => new FileMailProviderOptions(), + _ => throw new ConfigurationErrorsException($"Unknown mail provider type '{type}'") + }; + + // This will allow our configuration to update without having to restart the application. + child.Bind(mailProviderOptions); + + providers.Add(mailProviderOptions); + } + o.Providers = providers; + }); } } \ No newline at end of file diff --git a/src/Common/Services/Mail/MailConfiguration.cs b/src/Common/Services/Mail/MailConfiguration.cs index 9dd5bbb59..5679a53de 100644 --- a/src/Common/Services/Mail/MailConfiguration.cs +++ b/src/Common/Services/Mail/MailConfiguration.cs @@ -2,5 +2,13 @@ namespace Passwordless.Common.Services.Mail; public class MailConfiguration { + /// + /// The default email address to use as the sender. + /// public string? From { get; set; } + + /// + /// The ordered list of mail providers to use. + /// + public IReadOnlyCollection Providers { get; set; } = new List(); } \ No newline at end of file diff --git a/src/Common/Services/Mail/MailMessage.cs b/src/Common/Services/Mail/MailMessage.cs index 5dd903fb9..e70919a7f 100644 --- a/src/Common/Services/Mail/MailMessage.cs +++ b/src/Common/Services/Mail/MailMessage.cs @@ -1,7 +1,19 @@ namespace Passwordless.Common.Services.Mail; -public record MailMessage(IEnumerable To, string? From, string Subject, string TextBody, string HtmlBody, string Tag, string MessageType, string FromDisplayName) +public class MailMessage { + public MailMessage(IEnumerable to, string? from, string subject, string textBody, string htmlBody, string tag, string messageType, string fromDisplayName) + { + To = to; + From = from; + Subject = subject; + TextBody = textBody; + HtmlBody = htmlBody; + Tag = tag; + MessageType = messageType; + FromDisplayName = fromDisplayName; + } + public MailMessage(IEnumerable to, string? from, string subject, string textBody, string htmlBody, string tag, string messageType) : this(to, from, subject, textBody, htmlBody, tag, messageType, string.Empty) { @@ -12,9 +24,25 @@ public MailMessage(string to, string? from, string subject, string textBody, str { } - public MailMessage() : this(new List(), null, null, null, null, null, string.Empty) + public MailMessage() { } - public ICollection Bcc { get; set; } = new List(); + public IEnumerable To { get; init; } + + public string? From { get; set; } + + public string FromDisplayName { get; init; } + + public string Subject { get; init; } + + public string TextBody { get; init; } + + public string HtmlBody { get; init; } + + public string Tag { get; init; } + + public string MessageType { get; init; } + + public ICollection Bcc { get; init; } = new List(); } \ No newline at end of file diff --git a/src/Common/Services/Mail/MailProviderFactory.cs b/src/Common/Services/Mail/MailProviderFactory.cs new file mode 100644 index 000000000..6ca849703 --- /dev/null +++ b/src/Common/Services/Mail/MailProviderFactory.cs @@ -0,0 +1,42 @@ +using Passwordless.Common.Services.Mail.Aws; +using Passwordless.Common.Services.Mail.File; +using Passwordless.Common.Services.Mail.SendGrid; +using Passwordless.Common.Services.Mail.Smtp; + +namespace Passwordless.Common.Services.Mail; + +public class MailProviderFactory : IMailProviderFactory +{ + private readonly IServiceProvider _serviceProvider; + + public MailProviderFactory(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public IMailProvider Create(string name, BaseMailProviderOptions options) + { + switch (name) + { + case AwsMailProviderOptions.Provider: + var awsOptions = (AwsMailProviderOptions)options; + var awsMailProviderLogger = _serviceProvider.GetRequiredService>(); + return new AwsMailProvider(awsOptions, awsMailProviderLogger); + case SendGridMailProviderOptions.Provider: + var sendGridOptions = (SendGridMailProviderOptions)options; + var sendGridMailProviderLogger = _serviceProvider.GetRequiredService>(); + return new SendGridMailProvider(sendGridOptions, sendGridMailProviderLogger); + case SmtpMailProviderOptions.Provider: + var smtpOptions = (SmtpMailProviderOptions)options; + return new SmtpMailProvider(smtpOptions); + case FileMailProviderOptions.Provider: + // fall back to using the file mail provider. + var timeProvider = _serviceProvider.GetRequiredService(); + var fileOptions = (FileMailProviderOptions)options; + var fileProviderLogger = _serviceProvider.GetRequiredService>(); + return new FileMailProvider(timeProvider, fileOptions, fileProviderLogger); + default: + throw new NotSupportedException($"Unknown mail provider type '{name}'"); + } + } +} \ No newline at end of file diff --git a/src/Common/Services/Mail/PostmarkMailProvider.cs b/src/Common/Services/Mail/PostmarkMailProvider.cs deleted file mode 100644 index b7d9a5bab..000000000 --- a/src/Common/Services/Mail/PostmarkMailProvider.cs +++ /dev/null @@ -1,39 +0,0 @@ -using PostmarkDotNet; - -namespace Passwordless.Common.Services.Mail; - -public class PostmarkMailProvider(PostmarkMailProviderConfiguration configuration, ILogger logger) : IMailProvider -{ - private readonly Dictionary _clients = configuration.MessageStreams? - .ToDictionary(x => x.Name, x => x.GetConfiguredClient()) ?? []; - private readonly ConfiguredPostmarkClient _defaultClient = configuration.DefaultConfiguration.GetConfiguredClient(); - - public async Task SendAsync(MailMessage message) - { - var configuredClient = _clients.GetValueOrDefault(message.MessageType) ?? _defaultClient; - - if (!string.IsNullOrWhiteSpace(message.MessageType) && configuredClient == _defaultClient) - logger.LogWarning("MessageType {messageType} was set but default Postmark Mail client was used.", message.MessageType); - - PostmarkMessage pm = new PostmarkMessage - { - To = string.Join(',', message.To), - From = GetFromAddress(configuredClient, message), - Subject = message.Subject, - TextBody = message.TextBody, - HtmlBody = message.HtmlBody, - Tag = message.Tag, - Bcc = message.Bcc.Count != 0 ? string.Join(',', message.Bcc) : null, - MessageStream = configuredClient == _defaultClient ? null : message.MessageType - }; - - IEnumerable? res = await configuredClient.Client.SendMessagesAsync(pm); - } - - private string GetFromAddress(ConfiguredPostmarkClient client, MailMessage message) - { - var address = client.From?.ToString() ?? message.From; - - return string.IsNullOrEmpty(message.FromDisplayName) ? address! : $"{message.FromDisplayName} <{address}>"; - } -} \ No newline at end of file diff --git a/src/Common/Services/Mail/PostmarkMailProviderConfiguration.cs b/src/Common/Services/Mail/PostmarkMailProviderConfiguration.cs deleted file mode 100644 index 77005826b..000000000 --- a/src/Common/Services/Mail/PostmarkMailProviderConfiguration.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Net.Mail; -using PostmarkDotNet; - -namespace Passwordless.Common.Services.Mail; - -public class PostmarkMailProviderConfiguration -{ - public required PostmarkClientConfiguration DefaultConfiguration { get; init; } - public List MessageStreams { get; init; } = []; -} - -public class PostmarkClientConfiguration -{ - public required string Name { get; init; } - public required string ApiKey { get; init; } - public string? From { get; init; } - - public ConfiguredPostmarkClient GetConfiguredClient() => - new() - { - Client = new PostmarkClient(ApiKey), - From = string.IsNullOrWhiteSpace(From) ? null : new MailAddress(From) - }; -} - -public class ConfiguredPostmarkClient -{ - public required PostmarkClient Client { get; init; } - public MailAddress? From { get; init; } -} \ No newline at end of file diff --git a/src/Common/Services/Mail/SendGrid/SendGridMailProvider.cs b/src/Common/Services/Mail/SendGrid/SendGridMailProvider.cs new file mode 100644 index 000000000..37890d483 --- /dev/null +++ b/src/Common/Services/Mail/SendGrid/SendGridMailProvider.cs @@ -0,0 +1,35 @@ +using SendGrid; +using SendGrid.Helpers.Mail; + +namespace Passwordless.Common.Services.Mail.SendGrid; + +public class SendGridMailProvider : IMailProvider +{ + private readonly ISendGridClient _client; + private readonly ILogger _logger; + + public SendGridMailProvider( + SendGridMailProviderOptions options, + ILogger logger) + { + _client = new SendGridClient(options.ApiKey); + _logger = logger; + } + + public async Task SendAsync(MailMessage message) + { + var from = new EmailAddress(message.From, message.FromDisplayName); + var subject = message.Subject; + var recipients = message.To.Select(x => new EmailAddress(x)).ToList(); + var textContent = message.TextBody; + var htmlContent = message.HtmlBody; + var msg = MailHelper.CreateSingleEmailToMultipleRecipients(from, recipients, subject, textContent, htmlContent); + var response = await _client.SendEmailAsync(msg); + if (!response.IsSuccessStatusCode) + { + var error = await response.Body.ReadAsStringAsync(); + _logger.LogError("Failed to send email with SendGrid. Status code: {StatusCode}. Error: {Error}", response.StatusCode, error); + throw new Exception($"Failed to send email with SendGrid: {error}"); + } + } +} \ No newline at end of file diff --git a/src/Common/Services/Mail/SendGrid/SendGridMailProviderOptions.cs b/src/Common/Services/Mail/SendGrid/SendGridMailProviderOptions.cs new file mode 100644 index 000000000..098280692 --- /dev/null +++ b/src/Common/Services/Mail/SendGrid/SendGridMailProviderOptions.cs @@ -0,0 +1,13 @@ +namespace Passwordless.Common.Services.Mail.SendGrid; + +public class SendGridMailProviderOptions : BaseMailProviderOptions +{ + public const string Provider = "sendgrid"; + + public SendGridMailProviderOptions() + { + Name = Provider; + } + + public string ApiKey { get; set; } +} \ No newline at end of file diff --git a/src/Common/Services/Mail/MailKitSmtpMailProvider.cs b/src/Common/Services/Mail/Smtp/SmtpMailProvider.cs similarity index 72% rename from src/Common/Services/Mail/MailKitSmtpMailProvider.cs rename to src/Common/Services/Mail/Smtp/SmtpMailProvider.cs index a233dceae..094eb30d8 100644 --- a/src/Common/Services/Mail/MailKitSmtpMailProvider.cs +++ b/src/Common/Services/Mail/Smtp/SmtpMailProvider.cs @@ -1,9 +1,9 @@ using MailKit.Net.Smtp; using MimeKit; -namespace Passwordless.Common.Services.Mail; +namespace Passwordless.Common.Services.Mail.Smtp; -public class MailKitSmtpMailProvider : IMailProvider +public class SmtpMailProvider : IMailProvider { private readonly string? _fromEmail; private readonly string? _smtpUsername; @@ -15,19 +15,18 @@ public class MailKitSmtpMailProvider : IMailProvider private readonly bool _smtpSslOverride; private readonly bool _smtpTrustServer; - public MailKitSmtpMailProvider(IConfiguration configuration) + public SmtpMailProvider(SmtpMailProviderOptions options) { - IConfigurationSection mailOptions = configuration.GetSection("Mail"); - IConfigurationSection smtpOptions = mailOptions.GetSection("Smtp"); - _fromEmail = smtpOptions.GetValue("From", null!); - _smtpUsername = smtpOptions.GetValue("Username", null!); - _smtpPassword = smtpOptions.GetValue("Password", null!); - _smtpHost = smtpOptions.GetValue("Host", null!); - _smtpPort = smtpOptions.GetValue("Port"); - _smtpStartTls = smtpOptions.GetValue("StartTls"); - _smtpSsl = smtpOptions.GetValue("Ssl"); - _smtpSslOverride = smtpOptions.GetValue("SslOverride"); - _smtpTrustServer = smtpOptions.GetValue("TrustServer"); + + _fromEmail = options.From; + _smtpUsername = options.Username; + _smtpPassword = options.Password; + _smtpHost = options.Host; + _smtpPort = options.Port; + _smtpStartTls = options.StartTls; + _smtpSsl = options.Ssl; + _smtpSslOverride = options.SslOverride; + _smtpTrustServer = options.TrustServer; } public async Task SendAsync(MailMessage message) diff --git a/src/Common/Services/Mail/Smtp/SmtpMailProviderOptions.cs b/src/Common/Services/Mail/Smtp/SmtpMailProviderOptions.cs new file mode 100644 index 000000000..4531864fd --- /dev/null +++ b/src/Common/Services/Mail/Smtp/SmtpMailProviderOptions.cs @@ -0,0 +1,29 @@ +namespace Passwordless.Common.Services.Mail.Smtp; + +public class SmtpMailProviderOptions : BaseMailProviderOptions +{ + public const string Provider = "smtp"; + + public SmtpMailProviderOptions() + { + Name = Provider; + } + + public string? Host { get; set; } + + public int Port { get; set; } + + public string? Username { get; set; } + + public string? Password { get; set; } + + public string From { get; set; } + + public bool StartTls { get; set; } + + public bool Ssl { get; set; } + + public bool SslOverride { get; set; } + + public bool TrustServer { get; set; } +} \ No newline at end of file diff --git a/tests/AdminConsole.Tests/Components/Pages/Organization/VerifyTests.cs b/tests/AdminConsole.Tests/Components/Pages/Organization/VerifyTests.cs new file mode 100644 index 000000000..761e8c637 --- /dev/null +++ b/tests/AdminConsole.Tests/Components/Pages/Organization/VerifyTests.cs @@ -0,0 +1,74 @@ +using Bunit; +using Bunit.TestDoubles; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Moq; +using Passwordless.AdminConsole.Components.Pages.Organization; +using Passwordless.AdminConsole.Components.Pages.Organization.SettingsComponents; +using Passwordless.AdminConsole.FeatureManagement; +using Passwordless.Common.Services.Mail; +using Passwordless.Common.Services.Mail.File; +using Xunit; + +namespace Passwordless.AdminConsole.Tests.Components.Pages.Organization; + +public class VerifyTests : TestContext +{ + private readonly Mock> _mailOptionsMock = new(); + private readonly Mock _httpContextAccessorMock = new(); + private readonly Mock _webHostEnvironmentMock = new(); + + public VerifyTests() + { + Services.AddSingleton(_mailOptionsMock.Object); + Services.AddSingleton(_httpContextAccessorMock.Object); + Services.AddSingleton(_webHostEnvironmentMock.Object); + } + + [Fact] + public void Render_Renders_FileProviderDebugSection_WhenInDevelopment() + { + // Arrange + _webHostEnvironmentMock.SetupGet(x => x.EnvironmentName).Returns(Environments.Development); + var mailConfiguration = new MailConfiguration() + { + From = "", + Providers = new List() + { + new FileMailProviderOptions { Name = "File", Path = "/usr/local/var/mail" } + } + }; + _mailOptionsMock.SetupGet(x => x.Value).Returns(mailConfiguration); + + // Act + var cut = RenderComponent(); + + // Assert + Assert.NotNull(cut.Find("#file-provider-debug-section")); + } + + [Fact] + public void Render_Renders_FileProviderDebugSection_WhenNotInDevelopment() + { + // Arrange + _webHostEnvironmentMock.SetupGet(x => x.EnvironmentName).Returns(Environments.Production); + var mailConfiguration = new MailConfiguration() + { + From = "", + Providers = new List() + { + new FileMailProviderOptions { Name = "File", Path = "/usr/local/var/mail" } + } + }; + _mailOptionsMock.SetupGet(x => x.Value).Returns(mailConfiguration); + + // Act + var cut = RenderComponent(); + + // Assert + Assert.Throws(() => cut.Find("#file-provider-debug-section")); + } +} \ No newline at end of file diff --git a/tests/Api.IntegrationTests/Helpers/FakeMailProviderFactory.cs b/tests/Api.IntegrationTests/Helpers/FakeMailProviderFactory.cs new file mode 100644 index 000000000..cacef06cb --- /dev/null +++ b/tests/Api.IntegrationTests/Helpers/FakeMailProviderFactory.cs @@ -0,0 +1,11 @@ +using Passwordless.Common.Services.Mail; + +namespace Passwordless.Api.IntegrationTests.Helpers; + +public class FakeMailProviderFactory : IMailProviderFactory +{ + public IMailProvider Create(string name, BaseMailProviderOptions options) + { + return new NoopMailProvider(); + } +} \ No newline at end of file diff --git a/tests/Api.IntegrationTests/PasswordlessApi.cs b/tests/Api.IntegrationTests/PasswordlessApi.cs index b7470863a..6cffd50e3 100644 --- a/tests/Api.IntegrationTests/PasswordlessApi.cs +++ b/tests/Api.IntegrationTests/PasswordlessApi.cs @@ -41,8 +41,8 @@ public PasswordlessApi(PasswordlessApiOptions options) services.AddSingleton(Time); // Replace mail provider - services.RemoveAll(); - services.AddSingleton(); + services.RemoveAll(); + services.AddSingleton(); }); }); diff --git a/tests/Common.Tests/Common.Tests.csproj b/tests/Common.Tests/Common.Tests.csproj index 7d9ac1ee6..3ce68bf07 100644 --- a/tests/Common.Tests/Common.Tests.csproj +++ b/tests/Common.Tests/Common.Tests.csproj @@ -1,6 +1,7 @@ + diff --git a/tests/Common.Tests/Services/Mail/AggregateMailProviderTests.cs b/tests/Common.Tests/Services/Mail/AggregateMailProviderTests.cs new file mode 100644 index 000000000..827166b5e --- /dev/null +++ b/tests/Common.Tests/Services/Mail/AggregateMailProviderTests.cs @@ -0,0 +1,184 @@ +using AutoFixture; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Passwordless.Common.Services.Mail; +using Passwordless.Common.Services.Mail.Aws; +using Passwordless.Common.Services.Mail.SendGrid; + +namespace Passwordless.Common.Tests.Services.Mail; + +public class AggregateMailProviderTests +{ + private readonly Fixture _fixture = new(); + + private readonly Mock> _optionsMock; + private readonly Mock _factoryMock; + + private readonly AggregateMailProvider _sut; + + public AggregateMailProviderTests() + { + _optionsMock = new Mock>(); + _factoryMock = new Mock(); + Mock> loggerMock = new(); + + _sut = new AggregateMailProvider(_optionsMock.Object, _factoryMock.Object, loggerMock.Object); + } + + [Fact] + public async Task SendAsync_Throws_InvalidOperationException_WhenNoProvidersAreRegistered() + { + // Arrange + var mailMessage = _fixture.Create(); + var mailOptions = new MailConfiguration + { + From = "johndoe@example.com", + Providers = new List() + }; + _optionsMock.SetupGet(x => x.Value).Returns(mailOptions); + + // Act + var actual = await Assert.ThrowsAsync(async () => + await _sut.SendAsync(mailMessage)); + + // Assert + Assert.Equal(AggregateMailProvider.FallBackFailedMessage, actual.Message); + } + + [Fact] + public async Task SendAsync_Overrides_MailMessageSenderFromConfiguration_WhenMailMessageHasNoSender() + { + // Arrange + var mailMessage = _fixture.Build().Without(x => x.From).Create(); + var mailOptions = new MailConfiguration + { + From = "johndoe@example.com", + Providers = new List + { + _fixture.Build() + .With(x => x.Name, AwsMailProviderOptions.Provider) + .Create(), + _fixture.Build() + .With(x => x.Name, SendGridMailProviderOptions.Provider) + .Create() + } + }; + _optionsMock.SetupGet(x => x.Value).Returns(mailOptions); + + var awsProviderMock = new Mock(); + _factoryMock.Setup(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny())) + .Returns(awsProviderMock.Object); + + // Act + await _sut.SendAsync(mailMessage); + + // Assert + _factoryMock.Verify(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny()), Times.Once); + awsProviderMock.Verify(x => x.SendAsync(It.Is(p => p.From == mailOptions.From)), Times.Once); + } + + [Fact] + public async Task SendAsync_DoesNotOverride_MailMessageSenderFromConfiguration_WhenMailMessageHasSender() + { + // Arrange + var mailMessage = _fixture.Build().Create(); + var mailOptions = new MailConfiguration + { + From = "johndoe@example.com", + Providers = new List + { + _fixture.Build() + .With(x => x.Name, AwsMailProviderOptions.Provider) + .Create(), + _fixture.Build() + .With(x => x.Name, SendGridMailProviderOptions.Provider) + .Create() + } + }; + _optionsMock.SetupGet(x => x.Value).Returns(mailOptions); + + var awsProviderMock = new Mock(); + _factoryMock.Setup(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny())) + .Returns(awsProviderMock.Object); + + // Act + await _sut.SendAsync(mailMessage); + + // Assert + _factoryMock.Verify(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny()), Times.Once); + awsProviderMock.Verify(x => x.SendAsync(It.Is(p => p.From == mailOptions.From)), Times.Never); + awsProviderMock.Verify(x => x.SendAsync(It.Is(p => p.From == mailMessage.From)), Times.Once); + } + + + [Fact] + public async Task SendAsync_Executes_FirstRegisteredProviderFirst_WhenMultipleAreFound() + { + // Arrange + var mailMessage = _fixture.Create(); + var mailOptions = new MailConfiguration + { + From = "johndoe@example.com", + Providers = new List + { + _fixture.Build() + .With(x => x.Name, AwsMailProviderOptions.Provider) + .Create(), + _fixture.Build() + .With(x => x.Name, SendGridMailProviderOptions.Provider) + .Create() + } + }; + _optionsMock.SetupGet(x => x.Value).Returns(mailOptions); + + var awsProviderMock = new Mock(); + _factoryMock.Setup(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny())) + .Returns(awsProviderMock.Object); + + // Act + await _sut.SendAsync(mailMessage); + + // Assert + _factoryMock.Verify(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendAsync_Attempts_ToUseSecondProvider_WhenFirstProviderFails() + { + // Arrange + var mailMessage = _fixture.Create(); + var mailOptions = new MailConfiguration + { + From = "johndoe@example.com", + Providers = new List + { + _fixture.Build() + .With(x => x.Name, AwsMailProviderOptions.Provider) + .Create(), + _fixture.Build() + .With(x => x.Name, SendGridMailProviderOptions.Provider) + .Create() + } + }; + _optionsMock.SetupGet(x => x.Value).Returns(mailOptions); + + var awsProviderMock = new Mock(); + awsProviderMock.Setup(x => x.SendAsync(It.IsAny())) + .Throws(); + _factoryMock.Setup(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny())) + .Returns(awsProviderMock.Object); + + var sendGridProviderMock = new Mock(); + _factoryMock.Setup(x => x.Create(It.Is(y => y == SendGridMailProviderOptions.Provider), It.IsAny())) + .Returns(sendGridProviderMock.Object); + + // Act + await _sut.SendAsync(mailMessage); + + // Assert + _factoryMock.Verify(x => x.Create(It.Is(y => y == AwsMailProviderOptions.Provider), It.IsAny()), Times.Once); + _factoryMock.Verify(x => x.Create(It.Is(y => y == SendGridMailProviderOptions.Provider), It.IsAny()), Times.Once); + + } +} \ No newline at end of file diff --git a/tests/Common.Tests/Services/Mail/PostmarkMailProviderTests.cs b/tests/Common.Tests/Services/Mail/PostmarkMailProviderTests.cs deleted file mode 100644 index dc392e00b..000000000 --- a/tests/Common.Tests/Services/Mail/PostmarkMailProviderTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Extensions.Logging; -using Moq; -using Passwordless.Common.Services.Mail; - -namespace Passwordless.Common.Tests.Services.Mail; - -public class PostmarkMailProviderTests -{ - [Fact] - public async Task SendAsync_GivenMessageWithMessageType_WhenCorrespondingClientDoesNotExist_ThenWarningShouldBeLogged() - { - // Arrange - var configuration = new PostmarkMailProviderConfiguration - { - DefaultConfiguration = new PostmarkClientConfiguration - { - Name = "Default", - ApiKey = "ApiKey", - From = "do-not-reply@passwordless.dev" - } - }; - var logger = new Mock>(); - var sut = new PostmarkMailProvider(configuration, logger.Object); - var message = new MailMessage - { - To = new List { "mail@example.com" }, - TextBody = "Test message", - MessageType = "Misconfigured Message Stream" - }; - - // Act - try - { - await sut.SendAsync(message); - } - catch - { - // ignored - } - - // Assert - logger.Verify(x => x.Log( - LogLevel.Warning, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains($"MessageType {message.MessageType} was set but default Postmark Mail client was used.")), - It.IsAny(), - ((Func)It.IsAny())!), - Times.Once); - } -} \ No newline at end of file