diff --git a/OrchardCore.sln b/OrchardCore.sln index a390bc69109..daf9c192515 100644 --- a/OrchardCore.sln +++ b/OrchardCore.sln @@ -427,8 +427,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.AuditTrail.Abst EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.AuditTrail", "src\OrchardCore.Modules\OrchardCore.AuditTrail\OrchardCore.AuditTrail.csproj", "{67509F89-E3D6-4FF9-98EC-A9519937A364}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Email.Azure", "src\OrchardCore.Modules\OrchardCore.Email.Azure\OrchardCore.Email.Azure.csproj", "{91DD6469-20AE-4F94-AF49-C13E8FCA4343}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Email.Core", "src\OrchardCore\OrchardCore.Email.Core\OrchardCore.Email.Core.csproj", "{D00CF459-396D-49C9-92E2-3FD3C2A59847}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Email.Smtp", "src\OrchardCore.Modules\OrchardCore.Email.Smtp\OrchardCore.Email.Smtp.csproj", "{44BF04A1-5C0C-4773-9C1E-FE1013841857}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OrchardCore", "OrchardCore", "{2BC850C3-9846-47E1-9068-AC0A8E5537AC}" ProjectSection(SolutionItems) = preProject src\OrchardCore\Directory.Build.props = src\OrchardCore\Directory.Build.props @@ -511,9 +515,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Search.AzureAI" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Search.AzureAI.Core", "src\OrchardCore\OrchardCore.Search.AzureAI.Core\OrchardCore.Search.AzureAI.Core.csproj", "{E9428DE8-5D81-4359-BF84-31435041FF1A}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Media.Indexing.Pdf", "src\OrchardCore.Modules\OrchardCore.Media.Indexing.Pdf\OrchardCore.Media.Indexing.Pdf.csproj", "{95187E6A-5B74-4475-8FEB-758ACD012DCC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Media.Indexing.Pdf", "src\OrchardCore.Modules\OrchardCore.Media.Indexing.Pdf\OrchardCore.Media.Indexing.Pdf.csproj", "{95187E6A-5B74-4475-8FEB-758ACD012DCC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OrchardCore.Media.Indexing.OpenXML", "src\OrchardCore.Modules\OrchardCore.Media.Indexing.OpenXML\OrchardCore.Media.Indexing.OpenXML.csproj", "{47777735-7432-4CCA-A8C5-672E9EE65121}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OrchardCore.Media.Indexing.OpenXML", "src\OrchardCore.Modules\OrchardCore.Media.Indexing.OpenXML\OrchardCore.Media.Indexing.OpenXML.csproj", "{47777735-7432-4CCA-A8C5-672E9EE65121}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -1233,10 +1237,18 @@ Global {67509F89-E3D6-4FF9-98EC-A9519937A364}.Debug|Any CPU.Build.0 = Debug|Any CPU {67509F89-E3D6-4FF9-98EC-A9519937A364}.Release|Any CPU.ActiveCfg = Release|Any CPU {67509F89-E3D6-4FF9-98EC-A9519937A364}.Release|Any CPU.Build.0 = Release|Any CPU + {91DD6469-20AE-4F94-AF49-C13E8FCA4343}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91DD6469-20AE-4F94-AF49-C13E8FCA4343}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91DD6469-20AE-4F94-AF49-C13E8FCA4343}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91DD6469-20AE-4F94-AF49-C13E8FCA4343}.Release|Any CPU.Build.0 = Release|Any CPU {D00CF459-396D-49C9-92E2-3FD3C2A59847}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D00CF459-396D-49C9-92E2-3FD3C2A59847}.Debug|Any CPU.Build.0 = Debug|Any CPU {D00CF459-396D-49C9-92E2-3FD3C2A59847}.Release|Any CPU.ActiveCfg = Release|Any CPU {D00CF459-396D-49C9-92E2-3FD3C2A59847}.Release|Any CPU.Build.0 = Release|Any CPU + {44BF04A1-5C0C-4773-9C1E-FE1013841857}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44BF04A1-5C0C-4773-9C1E-FE1013841857}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44BF04A1-5C0C-4773-9C1E-FE1013841857}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44BF04A1-5C0C-4773-9C1E-FE1013841857}.Release|Any CPU.Build.0 = Release|Any CPU {7F31D1A5-2B6F-448A-9337-482F876B85EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7F31D1A5-2B6F-448A-9337-482F876B85EF}.Debug|Any CPU.Build.0 = Debug|Any CPU {7F31D1A5-2B6F-448A-9337-482F876B85EF}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -1556,7 +1568,9 @@ Global {AB47A65C-7BA9-4CE7-BA73-285EB7A2CEFD} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {442C544F-6587-4FA5-8459-710ED8492AD4} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} {67509F89-E3D6-4FF9-98EC-A9519937A364} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} + {91DD6469-20AE-4F94-AF49-C13E8FCA4343} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} {D00CF459-396D-49C9-92E2-3FD3C2A59847} = {F23AC6C2-DE44-4699-999D-3C478EF3D691} + {44BF04A1-5C0C-4773-9C1E-FE1013841857} = {A066395F-6F73-45DC-B5A6-B4E306110DCE} {2BC850C3-9846-47E1-9068-AC0A8E5537AC} = {184139CF-C4AB-4FBE-AE19-54C8B3FE5C5E} {85EF279B-8F35-476D-9BBD-F503F20712B5} = {184139CF-C4AB-4FBE-AE19-54C8B3FE5C5E} {21F459C1-494E-41C9-B221-6C102774A47F} = {184139CF-C4AB-4FBE-AE19-54C8B3FE5C5E} diff --git a/mkdocs.yml b/mkdocs.yml index a3f914db30e..a6dd66bacc2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -197,6 +197,7 @@ nav: - Core Modules: - Audit Trail: docs/reference/modules/AuditTrail/README.md - Auto Setup: docs/reference/modules/AutoSetup/README.md + - Azure Email: docs/reference/modules/Email.Azure/README.md - Features: docs/reference/modules/Features/README.md - Contents: docs/reference/modules/Contents/README.md - Configuration: docs/reference/core/Configuration/README.md @@ -229,6 +230,7 @@ nav: - Security: docs/reference/modules/Security/README.md - Setup: docs/reference/modules/Setup/README.md - Shells: docs/reference/core/Shells/README.md + - SMTP Email: docs/reference/modules/Email.Smtp/README.md - Tenants: docs/reference/modules/Tenants/README.md - Workflows: docs/reference/modules/Workflows/README.md - DataProtection (Azure Storage): docs/reference/modules/DataProtection.Azure/README.md diff --git a/src/OrchardCore.Build/Dependencies.props b/src/OrchardCore.Build/Dependencies.props index 5a8a789d9bc..02251ae0595 100644 --- a/src/OrchardCore.Build/Dependencies.props +++ b/src/OrchardCore.Build/Dependencies.props @@ -12,6 +12,7 @@ + diff --git a/src/OrchardCore.Cms.Web/appsettings.json b/src/OrchardCore.Cms.Web/appsettings.json index 30d17294534..9e5123a0165 100644 --- a/src/OrchardCore.Cms.Web/appsettings.json +++ b/src/OrchardCore.Cms.Web/appsettings.json @@ -32,6 +32,29 @@ // "BlobName": "", // Optional, defaults to Sites/tenant_name/DataProtectionKeys.xml. Templatable, refer docs. // "CreateContainer": true // Creates the container during app startup if it does not already exist. //}, + //"OrchardCore_Email": { + // "DefaultSender": "" + //}, + //"OrchardCore_Email_Azure": { + // "DefaultSender": "", + // "ConnectionString": "" // Set to your Azure Communication Service email connection string. + //}, + //"OrchardCore_Email_Smtp": { + // "DefaultSender": "", + // "DeliveryMethod": "Network", + // "PickupDirectoryLocation": "", + // "Host": "localhost", + // "Port": 25, + // // Uncomment if SMTP server runs through a proxy server + // //"ProxyHost": "proxy.domain.com", + // //"ProxyPort": 5050, + // "EncryptionMethod": "SSLTLS", + // "AutoSelectEncryption": false, + // "UseDefaultCredentials": false, + // "RequireCredentials": true, + // "Username": "", + // "Password": "" + //}, // See https://docs.orchardcore.net/en/latest/docs/reference/modules/Markdown/#markdown-configuration //"OrchardCore_Markdown": { // "Extensions": "nohtml+advanced" @@ -201,22 +224,6 @@ //"OrchardCore_HealthChecks": { // "Url": "/health/live" //}, - //"OrchardCore_Email": { - // "DefaultSender": "", - // "DeliveryMethod": "Network", - // "PickupDirectoryLocation": "", - // "Host": "localhost", - // "Port": 25, - // // Uncomment if SMTP server runs through a proxy server - // //"ProxyHost": "proxy.domain.com", - // //"ProxyPort": 5050, - // "EncryptionMethod": "SSLTLS", - // "AutoSelectEncryption": false, - // "UseDefaultCredentials": false, - // "RequireCredentials": true, - // "Username": "", - // "Password": "" - //}, //"OrchardCore_ReverseProxy": { // "ForwardedHeaders": "None" //}, diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/AdminMenu.cs new file mode 100644 index 00000000000..8a464cc5b9f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/AdminMenu.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace OrchardCore.Email.Azure; + +public class AdminMenu : INavigationProvider +{ + private static readonly RouteValueDictionary _routeValues = new() + { + { "area", "OrchardCore.Email.Azure" } + }; + + protected readonly IStringLocalizer S; + + public AdminMenu(IStringLocalizer localizer) + { + S = localizer; + } + + public Task BuildNavigationAsync(string name, NavigationBuilder builder) + { + if (!NavigationHelper.IsAdminMenu(name)) + { + return Task.CompletedTask; + } + + builder + .Add(S["Configuration"], configuration => configuration + .Add(S["Settings"], settings => settings + .Add(S["Email"], S["Email"].PrefixPosition(), entry => entry + .AddClass("email").Id("email") + .Add(S["Azure Email Settings"], S["Azure Email Settings"].PrefixPosition(), entry => entry + .Action("Options", "Admin", _routeValues) + .Permission(Permissions.ViewAzureEmailOptions) + .LocalNav() + ) + ) + ) + ); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/AzureEmailConstants.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/AzureEmailConstants.cs new file mode 100644 index 00000000000..6475d77ed41 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/AzureEmailConstants.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Email.Azure; + +public static class AzureEmailConstants +{ + public const string EmailDeliveryServiceName = "azure"; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/AzureEmailSettings.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/AzureEmailSettings.cs new file mode 100644 index 00000000000..b89d572bcd3 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/AzureEmailSettings.cs @@ -0,0 +1,12 @@ +namespace OrchardCore.Email.Azure; + +/// +/// Represents a settings for Azure email. +/// +public class AzureEmailSettings : EmailSettings +{ + /// + /// Gets or sets the connection string. + /// + public string ConnectionString { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Controllers/AdminController.cs new file mode 100644 index 00000000000..1516e301da4 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Controllers/AdminController.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using OrchardCore.Email.Azure.ViewModels; + +namespace OrchardCore.Email.Azure.Controllers; + +public class AdminController : Controller +{ + private readonly IAuthorizationService _authorizationService; + private readonly AzureEmailSettings _options; + + public AdminController(IAuthorizationService authorizationService, IOptions options) + { + _authorizationService = authorizationService; + _options = options.Value; + } + + public async Task Options() + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ViewAzureEmailOptions)) + { + return Forbid(); + } + + var model = new OptionsViewModel + { + DefaultSender = _options.DefaultSender, + ConnectionString = _options.ConnectionString + }; + + return View(model); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Extensions/OrchardCoreBuilderExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Extensions/OrchardCoreBuilderExtensions.cs new file mode 100644 index 00000000000..df9be559a5f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Extensions/OrchardCoreBuilderExtensions.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Configuration; +using OrchardCore.Email.Azure; +using OrchardCore.Environment.Shell.Configuration; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class OrchardCoreBuilderExtensions +{ + public static OrchardCoreBuilder ConfigureAzureEmailSettings(this OrchardCoreBuilder builder) + { + builder.ConfigureServices((tenantServices, serviceProvider) => + { + var configurationSection = serviceProvider.GetRequiredService().GetSection("OrchardCore_Email_Azure"); + + tenantServices.PostConfigure(settings => configurationSection.Bind(settings)); + }); + + return builder; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Manifest.cs new file mode 100644 index 00000000000..3bf56ab91f0 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Manifest.cs @@ -0,0 +1,11 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Azure Email", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion, + Description = "Provides the configuration of email settings and a default email service utilizing Azure Communication Services (ACS).", + Dependencies = ["OrchardCore.Email"], + Category = "Messaging" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/OrchardCore.Email.Azure.csproj b/src/OrchardCore.Modules/OrchardCore.Email.Azure/OrchardCore.Email.Azure.csproj new file mode 100644 index 00000000000..10ffd3cedde --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/OrchardCore.Email.Azure.csproj @@ -0,0 +1,29 @@ + + + + true + + OrchardCore Azure Email + $(OCFrameworkDescription) + + Provides the configuration of email settings and a default email service utilizing Azure Communication Services (ACS). + $(PackageTags) OrchardCoreFramework + + + + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Permissions.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Permissions.cs new file mode 100644 index 00000000000..bea658e2e12 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Permissions.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Email.Azure; + +public class Permissions : IPermissionProvider +{ + public static readonly Permission ViewAzureEmailOptions = new("ViewAzureEmailOptions", "View Azure Email Options"); + + private readonly IEnumerable _allPermissions = + [ + ViewAzureEmailOptions, + ]; + + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); + + public IEnumerable GetDefaultStereotypes() => + [ + new PermissionStereotype + { + Name = "Administrator", + Permissions = _allPermissions, + }, + ]; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailDeliveryService.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailDeliveryService.cs new file mode 100644 index 00000000000..3e94d1a075a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailDeliveryService.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Azure; +using Azure.Communication.Email; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Email.Services; + +namespace OrchardCore.Email.Azure.Services; + +public class AzureEmailDeliveryService : IEmailDeliveryService +{ + // https://learn.microsoft.com/en-us/azure/communication-services/concepts/email/email-attachment-allowed-mime-types + private static readonly Dictionary _allowedMimeTypes = new() + { + { ".3gp", "video/3gpp" }, + { ".3g2", "video/3gpp2" }, + { ".7z", "application/x-7z-compressed" }, + { ".aac", "audio/aac" }, + { ".avi", "video/x-msvideo" }, + { ".bmp", "image/bmp" }, + { ".csv", "text/csv" }, + { ".doc", "application/msword" }, + { ".docm", "application/vnd.ms-word.document.macroEnabled.12" }, + { ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + { ".eot", "application/vnd.ms-fontobject" }, + { ".epub", "application/epub+zip" }, + { ".gif", "image/gif" }, + { ".gz", "application/gzip" }, + { ".ico", "image/vnd.microsoft.icon" }, + { ".ics", "text/calendar" }, + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".json", "application/json" }, + { ".mid", ".midi audio/midi" }, + { ".midi", ".midi audio/midi" }, + { ".mp3", "audio/mpeg" }, + { ".mp4", "video/mp4" }, + { ".mpeg", "video/mpeg" }, + { ".oga", "audio/ogg" }, + { ".ogv", "video/ogg" }, + { ".ogx", "application/ogg" }, + { ".one", "application/onenote" }, + { ".opus", "audio/opus" }, + { ".otf", "font/otf" }, + { ".pdf", "application/pdf" }, + { ".png", "image/png" }, + { ".ppsm", "application/vnd.ms-powerpoint.slideshow.macroEnabled.12" }, + { ".ppsx", "application/vnd.openxmlformats-officedocument.presentationml.slideshow" }, + { ".ppt", "application/vnd.ms-powerpoint" }, + { ".pptm", "application/vnd.ms-powerpoint.presentation.macroEnabled.12" }, + { ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, + { ".pub", "application/vnd.ms-publisher" }, + { ".rar", "application/x-rar-compressed" }, + { ".rpmsg", "application/vnd.ms-outlook" }, + { ".rtf", "application/rtf" }, + { ".svg", "image/svg+xml" }, + { ".tar", "application/x-tar" }, + { ".tif", "image/tiff" }, + { ".tiff", "image/tiff" }, + { ".ttf", "font/ttf" }, + { ".txt", "text/plain" }, + { ".vsd", "application/vnd.visio" }, + { ".wav", "audio/wav" }, + { ".weba", "audio/webm" }, + { ".webm", "video/webm" }, + { ".webp", "image/webp" }, + { ".wma", "audio/x-ms-wma" }, + { ".wmv", "video/x-ms-wmv" }, + { ".woff", "font/woff" }, + { ".woff2", "font/woff2" }, + { ".xls", "application/vnd.ms-excel" }, + { ".xlsb", "application/vnd.ms-excel.sheet.binary.macroEnabled.12" }, + { ".xlsm", "application/vnd.ms-excel.sheet.macroEnabled.12" }, + { ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + { ".xml", "application/xml" }, + { ".zip", "application/zip" } + }; + + private readonly AzureEmailSettings _emailSettings; + private readonly ILogger _logger; + + protected readonly IStringLocalizer S; + + public AzureEmailDeliveryService( + IOptions options, + ILogger logger, + IStringLocalizer stringLocalizer) + { + _emailSettings = options.Value; + _logger = logger; + S = stringLocalizer; + } + + public async Task DeliverAsync(MailMessage message) + { + ArgumentNullException.ThrowIfNull(message); + + if (_emailSettings == null) + { + return EmailResult.Failed(S["Azure Email settings must be configured before an email can be sent."]); + } + + IEmailResult result; + + try + { + var senderAddress = string.IsNullOrWhiteSpace(message.From) + ? _emailSettings.DefaultSender + : message.From; + + if (!string.IsNullOrWhiteSpace(senderAddress)) + { + message.From = senderAddress; + } + + var emailMessage = FromMailMessage(message, out result); + + var client = new EmailClient(_emailSettings.ConnectionString); + await client.SendAsync(WaitUntil.Completed, emailMessage); + + result = EmailResult.Success; + } + catch (Exception ex) + { + result = EmailResult.Failed(S["An error occurred while sending an email: '{0}'", ex.Message]); + + _logger.LogError(ex, "Error while sending an email '{message}'.", message); + } + + return result; + } + + private EmailMessage FromMailMessage(MailMessage message, out IEmailResult result) + { + var recipients = message.GetRecipients(); + + List toRecipients = null; + if (recipients.To.Count > 0) + { + toRecipients = [.. recipients.To.Select(r => new EmailAddress(r))]; + } + + List ccRecipients = null; + if (recipients.Cc.Count > 0) + { + ccRecipients = [.. recipients.Cc.Select(r => new EmailAddress(r))]; + } + + List bccRecipients = null; + if (recipients.Bcc.Count > 0) + { + bccRecipients = [.. recipients.Bcc.Select(r => new EmailAddress(r))]; + } + + var content = new EmailContent(message.Subject); + if (message.IsHtmlBody) + { + content.Html = message.Body; + } + else + { + content.PlainText = message.Body; + } + + var emailMessage = new EmailMessage( + message.From, + new EmailRecipients(toRecipients, ccRecipients, bccRecipients), + content); + + foreach (var address in message.GetReplyTo()) + { + emailMessage.ReplyTo.Add(new EmailAddress(address)); + } + + foreach (var attachment in message.Attachments) + { + if (attachment.Stream == null) + { + continue; + } + var extension = Path.GetExtension(attachment.Filename); + + if (_allowedMimeTypes.TryGetValue(extension, out var contentType)) + { + var data = new byte[attachment.Stream.Length]; + + attachment.Stream.Read(data, 0, (int)attachment.Stream.Length); + + emailMessage.Attachments.Add(new EmailAttachment(attachment.Filename, contentType, new BinaryData(data))); + } + else + { + result = EmailResult.Failed(S["Unable to attach the file named '{0}' since its type is not supported.", attachment.Filename]); + + _logger.LogWarning("The MIME type for the attachment '{attachment}' is not supported.", attachment.Filename); + } + } + + result = EmailResult.Success; + + return emailMessage; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailSettingsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailSettingsConfiguration.cs new file mode 100644 index 00000000000..59a04d279c4 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Services/AzureEmailSettingsConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using OrchardCore.Email.Azure; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Settings; + +namespace OrchardCore.Email.Services; + +public class AzureEmailSettingsConfiguration(IShellConfiguration shellConfiguration, ISiteService site) : IConfigureOptions +{ + private readonly IShellConfiguration _shellConfiguration = shellConfiguration; + private readonly ISiteService _site = site; + + public void Configure(AzureEmailSettings options) + { + var emailSettings = _site.GetSiteSettingsAsync() + .GetAwaiter() + .GetResult() + .As(); + + var section = _shellConfiguration.GetSection("OrchardCore_Email_Azure"); + + options.DefaultSender = section.GetValue(nameof(options.DefaultSender), emailSettings.DefaultSender); + options.ConnectionString = section.GetValue(nameof(options.ConnectionString)); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Startup.cs new file mode 100644 index 00000000000..2bc1c49d4c9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Startup.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.Email.Azure.Controllers; +using OrchardCore.Email.Azure.Services; +using OrchardCore.Email.Services; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Navigation; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Email.Azure; + +public class Startup : StartupBase +{ + private readonly AdminOptions _adminOptions; + private readonly ILogger _logger; + private readonly IShellConfiguration _configuration; + + public Startup( + IOptions adminOptions, + ILogger logger, + IShellConfiguration configuration) + { + _adminOptions = adminOptions.Value; + _logger = logger; + _configuration = configuration; + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddEmailDeliveryService(AzureEmailConstants.EmailDeliveryServiceName); + + services.AddTransient, AzureEmailSettingsConfiguration>(); + + var connectionString = _configuration[$"OrchardCore_Email_Azure:{nameof(AzureEmailSettings.ConnectionString)}"]; + + if (string.IsNullOrWhiteSpace(connectionString)) + { + _logger.LogError("Azure Email is enabled but not active because the 'ConnectionString' is missing or empty in application configuration."); + } + } + + public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + routes.MapAreaControllerRoute( + name: "AzureEmail.Options", + areaName: "OrchardCore.Email.Azure", + pattern: _adminOptions.AdminUrlPrefix + "/AzureEmail/Options", + defaults: new { controller = typeof(AdminController).ControllerName(), action = nameof(AdminController.Options) } + ); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/ViewModels/OptionsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Email.Azure/ViewModels/OptionsViewModel.cs new file mode 100644 index 00000000000..a640ef09a9a --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/ViewModels/OptionsViewModel.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Email.Azure.ViewModels; + +public class OptionsViewModel +{ + public string DefaultSender { get; set; } + + public string ConnectionString { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Views/Admin/Options.cshtml b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Views/Admin/Options.cshtml new file mode 100644 index 00000000000..f464b1555bf --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Views/Admin/Options.cshtml @@ -0,0 +1,21 @@ +@model OrchardCore.Email.Azure.ViewModels.OptionsViewModel +

@RenderTitleSegments(T["Azure email options"])

+ +

+ @T["Azure email is configured with appsettings.json."] + @T["See documentation."] +

+ +
+ +
+ +
+
+ +
+ +
+ @(string.IsNullOrEmpty(Model.ConnectionString) ? T["Not Configured"] : T["Configured"]) +
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Azure/Views/_ViewImports.cshtml b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..252fd654bb8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Azure/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/AdminMenu.cs new file mode 100644 index 00000000000..8292f852316 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/AdminMenu.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using OrchardCore.Email.Smtp.Drivers; +using OrchardCore.Navigation; + +namespace OrchardCore.Email.Smtp; + +public class AdminMenu : INavigationProvider +{ + private static readonly RouteValueDictionary _routeValues = new() + { + { "area", "OrchardCore.Settings" }, + { "groupId", SmtpSettingsDisplayDriver.GroupId }, + }; + + protected readonly IStringLocalizer S; + + public AdminMenu(IStringLocalizer localizer) + { + S = localizer; + } + + public Task BuildNavigationAsync(string name, NavigationBuilder builder) + { + if (!NavigationHelper.IsAdminMenu(name)) + { + return Task.CompletedTask; + } + + builder + .Add(S["Configuration"], configuration => configuration + .Add(S["Settings"], settings => settings + .Add(S["Email"], S["Email"].PrefixPosition(), entry => entry + .AddClass("email").Id("email") + .Add(S["SMTP Settings"], S["SMTP Settings"].PrefixPosition(), entry => entry + .Action("Index", "Admin", _routeValues) + .Permission(Permissions.ManageSmtpEmailSettings) + .LocalNav() + ) + ) + ) + ); + + return Task.CompletedTask; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Controllers/AdminController.cs new file mode 100644 index 00000000000..df54585a4d5 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Controllers/AdminController.cs @@ -0,0 +1,101 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Email.Services; +using OrchardCore.Email.Smtp.Drivers; +using OrchardCore.Email.Smtp.ViewModels; + +namespace OrchardCore.Email.Smtp.Controllers; + +public class AdminController : Controller +{ + private readonly IAuthorizationService _authorizationService; + private readonly INotifier _notifier; + private readonly IEmailService _emailService; + protected readonly IHtmlLocalizer H; + + public AdminController( + IHtmlLocalizer h, + IAuthorizationService authorizationService, + INotifier notifier, + IEmailService emailService) + { + H = h; + _authorizationService = authorizationService; + _notifier = notifier; + _emailService = emailService; + } + + [HttpGet] + public async Task Index() + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSmtpEmailSettings)) + { + return Forbid(); + } + + return View(); + } + + [HttpPost, ActionName(nameof(Index))] + public async Task IndexPost(SmtpEmailSettingsViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageSmtpEmailSettings)) + { + return Forbid(); + } + + if (ModelState.IsValid) + { + var message = CreateMessageFromViewModel(model); + + var result = await _emailService.SendAsync(message, SmtpEmailConstants.EmailDeliveryServiceName); + + if (!result.Succeeded) + { + foreach (var error in result.Errors) + { + ModelState.AddModelError("*", error.ToString()); + } + } + else + { + await _notifier.SuccessAsync(H["Message sent successfully."]); + + return Redirect(Url.Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = SmtpSettingsDisplayDriver.GroupId })); + } + } + + return View(model); + } + + private static MailMessage CreateMessageFromViewModel(SmtpEmailSettingsViewModel testSettings) + { + var message = new MailMessage + { + To = testSettings.To, + Bcc = testSettings.Bcc, + Cc = testSettings.Cc, + ReplyTo = testSettings.ReplyTo + }; + + if (!string.IsNullOrWhiteSpace(testSettings.Sender)) + { + message.Sender = testSettings.Sender; + } + + if (!string.IsNullOrWhiteSpace(testSettings.Subject)) + { + message.Subject = testSettings.Subject; + } + + if (!string.IsNullOrWhiteSpace(testSettings.Body)) + { + message.Body = testSettings.Body; + } + + return message; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Drivers/SmtpSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Drivers/SmtpSettingsDisplayDriver.cs new file mode 100644 index 00000000000..f1cf6ef917e --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Drivers/SmtpSettingsDisplayDriver.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Email.Smtp.Services; +using OrchardCore.Environment.Shell; +using OrchardCore.Settings; + +namespace OrchardCore.Email.Smtp.Drivers; + +public class SmtpSettingsDisplayDriver : SectionDisplayDriver +{ + public const string GroupId = "smtp-email"; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly IShellHost _shellHost; + private readonly ShellSettings _shellSettings; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly SmtpEmailSettings _smtpEmailSettings; + + public SmtpSettingsDisplayDriver( + IDataProtectionProvider dataProtectionProvider, + IShellHost shellHost, + ShellSettings shellSettings, + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IOptions smtpEmailSettings) + { + _dataProtectionProvider = dataProtectionProvider; + _shellHost = shellHost; + _shellSettings = shellSettings; + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _smtpEmailSettings = smtpEmailSettings.Value; + } + + public override async Task EditAsync(SmtpEmailSettings settings, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageSmtpEmailSettings)) + { + return null; + } + + var shapes = new List + { + Initialize("SmtpEmailSettings_Edit", model => + { + model.DefaultSender = _smtpEmailSettings.DefaultSender; + model.DeliveryMethod = settings.DeliveryMethod; + model.PickupDirectoryLocation = settings.PickupDirectoryLocation; + model.Host = settings.Host; + model.Port = settings.Port; + model.ProxyHost = settings.ProxyHost; + model.ProxyPort = settings.ProxyPort; + model.EncryptionMethod = settings.EncryptionMethod; + model.AutoSelectEncryption = settings.AutoSelectEncryption; + model.RequireCredentials = settings.RequireCredentials; + model.UseDefaultCredentials = settings.UseDefaultCredentials; + model.UserName = settings.UserName; + model.Password = settings.Password; + model.IgnoreInvalidSslCertificate = settings.IgnoreInvalidSslCertificate; + }).Location("Content:5").OnGroup(GroupId), + }; + + if (_smtpEmailSettings.DefaultSender != null) + { + shapes.Add(Dynamic("SmtpEmailSettings_TestButton").Location("Actions").OnGroup(GroupId)); + } + + return Combine(shapes); + } + + public override async Task UpdateAsync(SmtpEmailSettings section, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageSmtpEmailSettings)) + { + return null; + } + + if (!context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + await context.Updater.TryUpdateModelAsync(section, Prefix); + + var previousPassword = section.Password; + // Restore password if the input is empty, meaning that it has not been reset. + if (string.IsNullOrWhiteSpace(section.Password)) + { + section.Password = previousPassword; + } + else + { + // Encrypt the password. + var protector = _dataProtectionProvider.CreateProtector(nameof(SmtpEmailSettingsConfiguration)); + section.Password = protector.Protect(section.Password); + } + + // Release the tenant to apply the settings. + await _shellHost.ReleaseShellContextAsync(_shellSettings); + + return await EditAsync(section, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Extensions/OrchardCoreBuilderExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Extensions/OrchardCoreBuilderExtensions.cs new file mode 100644 index 00000000000..3cb278f0001 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Extensions/OrchardCoreBuilderExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using OrchardCore.Email.Smtp; +using OrchardCore.Email.Smtp.Services; +using OrchardCore.Environment.Shell.Configuration; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class OrchardCoreBuilderExtensions +{ + public static OrchardCoreBuilder ConfigureSmtpEmailSettings(this OrchardCoreBuilder builder) + { + builder.ConfigureServices((tenantServices, serviceProvider) => + { + var configurationSection = serviceProvider.GetRequiredService().GetSection("OrchardCore_Email_Smtp"); + + if (configurationSection.Value == null) + { + // Fall back to the old configuration section. + configurationSection = serviceProvider.GetRequiredService().GetSection("OrchardCore_Email"); + + var logger = serviceProvider.GetRequiredService>(); + + logger.LogWarning("The {name} configuration section has been renamed to OrchardCore_Email_Smtp. Please update your configuration.", nameof(SmtpEmailSettings)); + } + + tenantServices.PostConfigure(settings => configurationSection.Bind(settings)); + }); + + return builder; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Manifest.cs new file mode 100644 index 00000000000..db3938de060 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Manifest.cs @@ -0,0 +1,11 @@ +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "SMTP Email", + Author = ManifestConstants.OrchardCoreTeam, + Website = ManifestConstants.OrchardCoreWebsite, + Version = ManifestConstants.OrchardCoreVersion, + Description = "Provides email settings configuration and a default email service based on SMTP", + Dependencies = ["OrchardCore.Email"], + Category = "Messaging" +)] diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/OrchardCore.Email.Smtp.csproj b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/OrchardCore.Email.Smtp.csproj new file mode 100644 index 00000000000..eb88cd1b9a7 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/OrchardCore.Email.Smtp.csproj @@ -0,0 +1,26 @@ + + + + true + + OrchardCore SMTP Email + $(OCFrameworkDescription) + + Provides email settings configuration and a default email service based on SMTP. + $(PackageTags) OrchardCoreFramework + + + + + + + + + + + + + + + + diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Permissions.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Permissions.cs new file mode 100644 index 00000000000..5551b50ec8c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Permissions.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using OrchardCore.Security.Permissions; + +namespace OrchardCore.Email; + +public class Permissions : IPermissionProvider +{ + public static readonly Permission ManageSmtpEmailSettings = new("ManageSmtpEmailSettings", "Manage SMTP Email Settings"); + + private readonly IEnumerable _allPermissions = + [ + ManageSmtpEmailSettings, + ]; + + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); + + public IEnumerable GetDefaultStereotypes() => + [ + new PermissionStereotype + { + Name = "Administrator", + Permissions = _allPermissions, + }, + ]; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailDeliveryService.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailDeliveryService.cs new file mode 100644 index 00000000000..62e9640de9c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailDeliveryService.cs @@ -0,0 +1,221 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using MailKit.Net.Proxy; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; +using OrchardCore.Email.Services; + +namespace OrchardCore.Email.Smtp.Services; + +public class SmtpEmailDeliveryService : IEmailDeliveryService +{ + private const string EmailExtension = ".eml"; + + private readonly SmtpEmailSettings _emailSettings; + private readonly ILogger _logger; + + protected readonly IStringLocalizer S; + + public SmtpEmailDeliveryService( + IOptions options, + ILogger logger, + IStringLocalizer stringLocalizer) + { + _emailSettings = options.Value; + _logger = logger; + S = stringLocalizer; + } + + /// + /// This method allows to send an email without setting if or is provided. + public async Task DeliverAsync(MailMessage message) + { + ArgumentNullException.ThrowIfNull(message); + + if (_emailSettings == null) + { + return EmailResult.Failed(S["SMTP settings must be configured before an email can be sent."]); + } + + SmtpEmailResult result; + var response = default(string); + try + { + // Set the MailMessage.From, to avoid the confusion between Options.DefaultSender (Author) and submitter (Sender). + var senderAddress = string.IsNullOrWhiteSpace(message.From) + ? _emailSettings.DefaultSender + : message.From; + + if (!string.IsNullOrWhiteSpace(senderAddress)) + { + message.From = senderAddress; + } + + var mimeMessage = FromMailMessage(message); + + switch (_emailSettings.DeliveryMethod) + { + case SmtpDeliveryMethod.Network: + response = await SendOnlineMessageAsync(mimeMessage); + break; + case SmtpDeliveryMethod.SpecifiedPickupDirectory: + await SendOfflineMessageAsync(mimeMessage, _emailSettings.PickupDirectoryLocation); + break; + default: + throw new NotSupportedException($"The '{_emailSettings.DeliveryMethod}' delivery method is not supported."); + } + + result = new SmtpEmailResult(true); + } + catch (Exception ex) + { + result = new SmtpEmailResult(new[] { S["An error occurred while sending an email: '{0}'", ex.Message] }); + } + + result.Response = response; + + return result; + } + + private MimeMessage FromMailMessage(MailMessage message) + { + var mimeMessage = new MimeMessage(); + var submitterAddress = string.IsNullOrWhiteSpace(message.Sender) + ? _emailSettings.DefaultSender + : message.Sender; + + if (!string.IsNullOrEmpty(submitterAddress)) + { + mimeMessage.Sender = MailboxAddress.Parse(submitterAddress); + } + + mimeMessage.From.AddRange(message.GetSender().Select(MailboxAddress.Parse)); + + var recipients = message.GetRecipients(); + mimeMessage.To.AddRange(recipients.To.Select(MailboxAddress.Parse)); + mimeMessage.Cc.AddRange(recipients.Cc.Select(MailboxAddress.Parse)); + mimeMessage.Bcc.AddRange(recipients.Bcc.Select(MailboxAddress.Parse)); + + mimeMessage.ReplyTo.AddRange(message.GetReplyTo().Select(MailboxAddress.Parse)); + + mimeMessage.Subject = message.Subject; + + var body = new BodyBuilder(); + + if (message.IsHtmlBody) + { + body.HtmlBody = message.Body; + } + else + { + body.TextBody = message.Body; + } + + foreach (var attachment in message.Attachments) + { + // Stream must not be null, otherwise it would try to get the filesystem path + if (attachment.Stream != null) + { + body.Attachments.Add(attachment.Filename, attachment.Stream); + } + } + + mimeMessage.Body = body.ToMessageBody(); + + return mimeMessage; + } + + private Task OnMessageSendingAsync(SmtpClient client, MimeMessage message) => Task.CompletedTask; + + private async Task SendOnlineMessageAsync(MimeMessage message) + { + var secureSocketOptions = SecureSocketOptions.Auto; + + if (!_emailSettings.AutoSelectEncryption) + { + secureSocketOptions = _emailSettings.EncryptionMethod switch + { + SmtpEncryptionMethod.None => SecureSocketOptions.None, + SmtpEncryptionMethod.SslTls => SecureSocketOptions.SslOnConnect, + SmtpEncryptionMethod.StartTls => SecureSocketOptions.StartTls, + _ => SecureSocketOptions.Auto, + }; + } + + using var client = new SmtpClient(); + + client.ServerCertificateValidationCallback = CertificateValidationCallback; + + await OnMessageSendingAsync(client, message); + + await client.ConnectAsync(_emailSettings.Host, _emailSettings.Port, secureSocketOptions); + + if (_emailSettings.RequireCredentials) + { + if (_emailSettings.UseDefaultCredentials) + { + // There's no notion of 'UseDefaultCredentials' in MailKit, so empty credentials is passed in. + await client.AuthenticateAsync(string.Empty, string.Empty); + } + else if (!string.IsNullOrWhiteSpace(_emailSettings.UserName)) + { + await client.AuthenticateAsync(_emailSettings.UserName, _emailSettings.Password); + } + } + + if (!string.IsNullOrEmpty(_emailSettings.ProxyHost)) + { + client.ProxyClient = new Socks5Client(_emailSettings.ProxyHost, _emailSettings.ProxyPort); + } + + var response = await client.SendAsync(message); + + await client.DisconnectAsync(true); + + return response; + } + + private static Task SendOfflineMessageAsync(MimeMessage message, string pickupDirectory) + { + var mailPath = Path.Combine(pickupDirectory, Guid.NewGuid().ToString() + EmailExtension); + return message.WriteToAsync(mailPath, CancellationToken.None); + } + + private bool CertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + { + const string logErrorMessage = "SMTP Server's certificate {CertificateSubject} issued by {CertificateIssuer} " + + "with thumbprint {CertificateThumbprint} and expiration date {CertificateExpirationDate} " + + "is considered invalid with {SslPolicyErrors} policy errors"; + + if (sslPolicyErrors == SslPolicyErrors.None) + { + return true; + } + + _logger.LogError(logErrorMessage, + certificate.Subject, + certificate.Issuer, + certificate.GetCertHashString(), + certificate.GetExpirationDateString(), + sslPolicyErrors); + + if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain?.ChainStatus != null) + { + foreach (var chainStatus in chain.ChainStatus) + { + _logger.LogError("Status: {Status} - {StatusInformation}", chainStatus.Status, chainStatus.StatusInformation); + } + } + + return _emailSettings.IgnoreInvalidSslCertificate; + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailResult.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailResult.cs new file mode 100644 index 00000000000..6b9cbb30f36 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailResult.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Localization; +using OrchardCore.Email.Services; + +namespace OrchardCore.Email.Smtp.Services; + +/// +/// Represents the result of sending an email. +/// +public class SmtpEmailResult : EmailResult +{ + public SmtpEmailResult(bool succeeded) + { + Succeeded = succeeded; + } + + public SmtpEmailResult(IEnumerable errors) + { + Errors = errors; + Succeeded = false; + } + + /// + /// Get or sets the response text from the SMTP server. + /// + public string Response { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailSettingsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailSettingsConfiguration.cs new file mode 100644 index 00000000000..4bbcd4f4d63 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Services/SmtpEmailSettingsConfiguration.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Settings; + +namespace OrchardCore.Email.Smtp.Services; + +public class SmtpEmailSettingsConfiguration( + IShellConfiguration shellConfiguration, + ISiteService site, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) : IConfigureOptions +{ + private readonly IShellConfiguration _shellConfiguration = shellConfiguration; + private readonly ISiteService _site = site; + private readonly IDataProtectionProvider _dataProtectionProvider = dataProtectionProvider; + private readonly ILogger _logger = logger; + + public void Configure(SmtpEmailSettings options) + { + var emailSettings = _site.GetSiteSettingsAsync() + .GetAwaiter() + .GetResult() + .As(); + + var smtpEmailSettings = _site.GetSiteSettingsAsync() + .GetAwaiter() + .GetResult() + .As(); + + var defaultSender = emailSettings.DefaultSender; + + var section = _shellConfiguration.GetSection("OrchardCore_Email_Smtp"); + + options.DefaultSender = section.GetValue(nameof(options.DefaultSender), defaultSender); + options.DeliveryMethod = smtpEmailSettings.DeliveryMethod; + options.PickupDirectoryLocation = smtpEmailSettings.PickupDirectoryLocation; + options.Host = smtpEmailSettings.Host; + options.Port = smtpEmailSettings.Port; + options.ProxyHost = smtpEmailSettings.ProxyHost; + options.ProxyPort = smtpEmailSettings.ProxyPort; + options.EncryptionMethod = smtpEmailSettings.EncryptionMethod; + options.AutoSelectEncryption = smtpEmailSettings.AutoSelectEncryption; + options.RequireCredentials = smtpEmailSettings.RequireCredentials; + options.UseDefaultCredentials = smtpEmailSettings.UseDefaultCredentials; + options.UserName = smtpEmailSettings.UserName; + options.Password = smtpEmailSettings.Password; + options.IgnoreInvalidSslCertificate = smtpEmailSettings.IgnoreInvalidSslCertificate; + + // Decrypt the password + if (!string.IsNullOrWhiteSpace(options.Password)) + { + try + { + var protector = _dataProtectionProvider.CreateProtector(nameof(SmtpEmailSettingsConfiguration)); + + options.Password = protector.Unprotect(options.Password); + } + catch + { + _logger.LogError("The Smtp password could not be decrypted. It may have been encrypted using a different key."); + } + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpDeliveryMethod.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpDeliveryMethod.cs new file mode 100644 index 00000000000..26140615b82 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpDeliveryMethod.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Email.Smtp; + +/// +/// Represents an enumeration for the mail delivery methods. +/// +public enum SmtpDeliveryMethod +{ + Network, + SpecifiedPickupDirectory +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEmailConstants.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEmailConstants.cs new file mode 100644 index 00000000000..858336f2678 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEmailConstants.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Email.Smtp; + +public static class SmtpEmailConstants +{ + public const string EmailDeliveryServiceName = "smtp"; +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEmailSettings.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEmailSettings.cs new file mode 100644 index 00000000000..fa236288276 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEmailSettings.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace OrchardCore.Email.Smtp; + +/// +/// Represents a settings for SMTP. +/// +public class SmtpEmailSettings : EmailSettings +{ + /// + /// Gets or sets the mail delivery method. + /// + [Required] + public SmtpDeliveryMethod DeliveryMethod { get; set; } + + /// + /// Gets or sets the mailbox directory, this used for option. + /// + public string PickupDirectoryLocation { get; set; } + + /// + /// Gets or sets the SMTP server/host. + /// + public string Host { get; set; } + + /// + /// Gets or sets the SMTP port number. Defaults to 25. + /// + [Range(0, 65535)] + public int Port { get; set; } = 25; + + /// + /// Gets or sets whether the encryption is automatically selected. + /// + public bool AutoSelectEncryption { get; set; } + + /// + /// Gets or sets whether the user credentials is required. + /// + public bool RequireCredentials { get; set; } + + /// + /// Gets or sets whether to use the default user credentials. + /// + public bool UseDefaultCredentials { get; set; } + + /// + /// Gets or sets the mail encryption method. + /// + public SmtpEncryptionMethod EncryptionMethod { get; set; } + + /// + /// Gets or sets the user name. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the user password. + /// + public string Password { get; set; } + + /// + /// Gets or sets the proxy server. + /// + public string ProxyHost { get; set; } + + /// + /// Gets or sets the proxy port number. + /// + public int ProxyPort { get; set; } + + /// + /// Gets or sets whether invalid SSL certificates should be ignored. + /// + public bool IgnoreInvalidSslCertificate { get; set; } + + /// + public IEnumerable Validate(ValidationContext validationContext) + { + var S = validationContext.GetService>(); + + switch (DeliveryMethod) + { + case SmtpDeliveryMethod.Network: + if (string.IsNullOrEmpty(Host)) + { + yield return new ValidationResult(S["The {0} field is required.", "Host name"], new[] { nameof(Host) }); + } + break; + case SmtpDeliveryMethod.SpecifiedPickupDirectory: + if (string.IsNullOrEmpty(PickupDirectoryLocation)) + { + yield return new ValidationResult(S["The {0} field is required.", "Pickup directory location"], new[] { nameof(PickupDirectoryLocation) }); + } + break; + default: + throw new NotSupportedException(S["The '{0}' delivery method is not supported.", DeliveryMethod]); + } + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEncryptionMethod.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEncryptionMethod.cs new file mode 100644 index 00000000000..ea8c3b50d85 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/SmtpEncryptionMethod.cs @@ -0,0 +1,11 @@ +namespace OrchardCore.Email.Smtp; + +/// +/// Represents an enumeration for mail encryption methods. +/// +public enum SmtpEncryptionMethod +{ + None = 0, + SslTls = 1, + StartTls = 2 +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Startup.cs new file mode 100644 index 00000000000..df15a1b6b4d --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Startup.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Email.Smtp.Controllers; +using OrchardCore.Email.Smtp.Drivers; +using OrchardCore.Email.Smtp.Services; +using OrchardCore.Modules; +using OrchardCore.Mvc.Core.Utilities; +using OrchardCore.Navigation; +using OrchardCore.Security.Permissions; +using OrchardCore.Settings; + +namespace OrchardCore.Email.Smtp; + +public class Startup : StartupBase +{ + private readonly AdminOptions _adminOptions; + + public Startup(IOptions adminOptions) + { + _adminOptions = adminOptions.Value; + } + + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped, SmtpSettingsDisplayDriver>(); + services.AddScoped(); + services.AddEmailDeliveryService(SmtpEmailConstants.EmailDeliveryServiceName); + + services.AddTransient, SmtpEmailSettingsConfiguration>(); + } + + public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + routes.MapAreaControllerRoute( + name: "SmtpEmailIndex", + areaName: "OrchardCore.Email.Smtp", + pattern: _adminOptions.AdminUrlPrefix + "/Email/Smtp/Index", + defaults: new { controller = typeof(AdminController).ControllerName(), action = nameof(AdminController.Index) } + ); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/ViewModels/SmtpEmailSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/ViewModels/SmtpEmailSettingsViewModel.cs new file mode 100644 index 00000000000..ffdbcd5f24f --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/ViewModels/SmtpEmailSettingsViewModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCore.Email.Smtp.ViewModels; + +public class SmtpEmailSettingsViewModel +{ + [Required(AllowEmptyStrings = false)] + public string To { get; set; } + + [EmailAddress(ErrorMessage = "Invalid Email.")] + public string Sender { get; set; } + + public string Bcc { get; set; } + + public string Cc { get; set; } + + public string ReplyTo { get; set; } + + public string Subject { get; set; } + + public string Body { get; set; } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Views/Admin/Index.cshtml b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/Admin/Index.cshtml similarity index 97% rename from src/OrchardCore.Modules/OrchardCore.Email/Views/Admin/Index.cshtml rename to src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/Admin/Index.cshtml index be67593c984..969e60e5023 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Views/Admin/Index.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/Admin/Index.cshtml @@ -1,4 +1,4 @@ -@model OrchardCore.Email.ViewModels.SmtpSettingsViewModel +@model OrchardCore.Email.Smtp.ViewModels.SmtpEmailSettingsViewModel

@RenderTitleSegments(T["Settings"])

diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/SmtpEmailSettings.Edit.cshtml similarity index 95% rename from src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.Edit.cshtml rename to src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/SmtpEmailSettings.Edit.cshtml index c929cbbf739..d88588a3293 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.Edit.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/SmtpEmailSettings.Edit.cshtml @@ -1,13 +1,12 @@ -@using OrchardCore.Email -@model SmtpSettings +@using OrchardCore.Email.Smtp +@model SmtpEmailSettings

@T["The current tenant will be reloaded when the settings are saved."]

-
- - - - @T["The default email address to use as a sender, unless the email sender is set."] +
+ + + @T["The email sender is set by Email module. Set it from the module's admin menu or via a configuration provider (like appsettings.json) using the OrchardCore_Email_Smtp.DefaultSender key."] @T["See documentation."]
diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/SmtpEmailSettings.TestButton.cshtml b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/SmtpEmailSettings.TestButton.cshtml new file mode 100644 index 00000000000..4f5c650878c --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/SmtpEmailSettings.TestButton.cshtml @@ -0,0 +1 @@ +@T["Test settings"] diff --git a/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/_ViewImports.cshtml b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/_ViewImports.cshtml new file mode 100644 index 00000000000..252fd654bb8 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email.Smtp/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement diff --git a/src/OrchardCore.Modules/OrchardCore.Email/AdminMenu.cs b/src/OrchardCore.Modules/OrchardCore.Email/AdminMenu.cs index f4db7cac39a..e081fdf543c 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/AdminMenu.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/AdminMenu.cs @@ -11,7 +11,7 @@ public class AdminMenu : INavigationProvider private static readonly RouteValueDictionary _routeValues = new() { { "area", "OrchardCore.Settings" }, - { "groupId", SmtpSettingsDisplayDriver.GroupId }, + { "groupId", EmailSettingsDisplayDriver.GroupId }, }; protected readonly IStringLocalizer S; @@ -33,10 +33,12 @@ public Task BuildNavigationAsync(string name, NavigationBuilder builder) .Add(S["Settings"], settings => settings .Add(S["Email"], S["Email"].PrefixPosition(), entry => entry .AddClass("email").Id("email") - .Action("Index", "Admin", _routeValues) - .Permission(Permissions.ManageEmailSettings) - .LocalNav() - ) + .Add(S["Email Settings"], S["Email Settings"].PrefixPosition(), entry => entry + .Action("Index", "Admin", _routeValues) + .Permission(Permissions.ManageEmailSettings) + .LocalNav() + ) + ) ) ); diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Controllers/AdminController.cs b/src/OrchardCore.Modules/OrchardCore.Email/Controllers/AdminController.cs deleted file mode 100644 index 6d85301f904..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Email/Controllers/AdminController.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Localization; -using OrchardCore.DisplayManagement.Notify; -using OrchardCore.Email.Drivers; -using OrchardCore.Email.ViewModels; - -namespace OrchardCore.Email.Controllers -{ - public class AdminController : Controller - { - private readonly IAuthorizationService _authorizationService; - private readonly INotifier _notifier; - private readonly ISmtpService _smtpService; - protected readonly IHtmlLocalizer H; - - public AdminController( - IHtmlLocalizer h, - IAuthorizationService authorizationService, - INotifier notifier, - ISmtpService smtpService) - { - H = h; - _authorizationService = authorizationService; - _notifier = notifier; - _smtpService = smtpService; - } - - [HttpGet] - public async Task Index() - { - if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageEmailSettings)) - { - return Forbid(); - } - - return View(); - } - - [HttpPost, ActionName(nameof(Index))] - public async Task IndexPost(SmtpSettingsViewModel model) - { - if (!await _authorizationService.AuthorizeAsync(User, Permissions.ManageEmailSettings)) - { - return Forbid(); - } - - if (ModelState.IsValid) - { - var message = CreateMessageFromViewModel(model); - - var result = await _smtpService.SendAsync(message); - - if (!result.Succeeded) - { - foreach (var error in result.Errors) - { - ModelState.AddModelError("*", error.ToString()); - } - } - else - { - await _notifier.SuccessAsync(H["Message sent successfully."]); - - return Redirect(Url.Action("Index", "Admin", new { area = "OrchardCore.Settings", groupId = SmtpSettingsDisplayDriver.GroupId })); - } - } - - return View(model); - } - - private static MailMessage CreateMessageFromViewModel(SmtpSettingsViewModel testSettings) - { - var message = new MailMessage - { - To = testSettings.To, - Bcc = testSettings.Bcc, - Cc = testSettings.Cc, - ReplyTo = testSettings.ReplyTo - }; - - if (!string.IsNullOrWhiteSpace(testSettings.Sender)) - { - message.Sender = testSettings.Sender; - } - - if (!string.IsNullOrWhiteSpace(testSettings.Subject)) - { - message.Subject = testSettings.Subject; - } - - if (!string.IsNullOrWhiteSpace(testSettings.Body)) - { - message.Body = testSettings.Body; - } - - return message; - } - } -} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Drivers/EmailSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Email/Drivers/EmailSettingsDisplayDriver.cs new file mode 100644 index 00000000000..978cd919ce9 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email/Drivers/EmailSettingsDisplayDriver.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Environment.Shell; +using OrchardCore.Settings; + +namespace OrchardCore.Email.Drivers; + +public class EmailSettingsDisplayDriver : SectionDisplayDriver +{ + public const string GroupId = "email"; + private readonly IShellHost _shellHost; + private readonly ShellSettings _shellSettings; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + + public EmailSettingsDisplayDriver( + IShellHost shellHost, + ShellSettings shellSettings, + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService) + { + _shellHost = shellHost; + _shellSettings = shellSettings; + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + } + + public override async Task EditAsync(EmailSettings settings, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageEmailSettings)) + { + return null; + } + + var shapes = new List + { + Initialize("EmailSettings_Edit", model => + { + model.DefaultSender = settings.DefaultSender; + }).Location("Content:5").OnGroup(GroupId), + }; + + return Combine(shapes); + } + + public override async Task UpdateAsync(EmailSettings section, BuildEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageEmailSettings)) + { + return null; + } + + if (!context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + await context.Updater.TryUpdateModelAsync(section, Prefix); + + // Release the tenant to apply the settings. + await _shellHost.ReleaseShellContextAsync(_shellSettings); + + return await EditAsync(section, context); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Drivers/SmtpSettingsDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Email/Drivers/SmtpSettingsDisplayDriver.cs deleted file mode 100644 index 789dad473af..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Email/Drivers/SmtpSettingsDisplayDriver.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.AspNetCore.Http; -using OrchardCore.DisplayManagement.Entities; -using OrchardCore.DisplayManagement.Handlers; -using OrchardCore.DisplayManagement.Views; -using OrchardCore.Email.Services; -using OrchardCore.Environment.Shell; -using OrchardCore.Settings; - -namespace OrchardCore.Email.Drivers -{ - public class SmtpSettingsDisplayDriver : SectionDisplayDriver - { - public const string GroupId = "email"; - private readonly IDataProtectionProvider _dataProtectionProvider; - private readonly IShellHost _shellHost; - private readonly ShellSettings _shellSettings; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IAuthorizationService _authorizationService; - - public SmtpSettingsDisplayDriver( - IDataProtectionProvider dataProtectionProvider, - IShellHost shellHost, - ShellSettings shellSettings, - IHttpContextAccessor httpContextAccessor, - IAuthorizationService authorizationService) - { - _dataProtectionProvider = dataProtectionProvider; - _shellHost = shellHost; - _shellSettings = shellSettings; - _httpContextAccessor = httpContextAccessor; - _authorizationService = authorizationService; - } - - public override async Task EditAsync(SmtpSettings settings, BuildEditorContext context) - { - var user = _httpContextAccessor.HttpContext?.User; - - if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageEmailSettings)) - { - return null; - } - - var shapes = new List - { - Initialize("SmtpSettings_Edit", model => - { - model.DefaultSender = settings.DefaultSender; - model.DeliveryMethod = settings.DeliveryMethod; - model.PickupDirectoryLocation = settings.PickupDirectoryLocation; - model.Host = settings.Host; - model.Port = settings.Port; - model.ProxyHost = settings.ProxyHost; - model.ProxyPort = settings.ProxyPort; - model.EncryptionMethod = settings.EncryptionMethod; - model.AutoSelectEncryption = settings.AutoSelectEncryption; - model.RequireCredentials = settings.RequireCredentials; - model.UseDefaultCredentials = settings.UseDefaultCredentials; - model.UserName = settings.UserName; - model.Password = settings.Password; - model.IgnoreInvalidSslCertificate = settings.IgnoreInvalidSslCertificate; - }).Location("Content:5").OnGroup(GroupId), - }; - - if (settings?.DefaultSender != null) - { - shapes.Add(Dynamic("SmtpSettings_TestButton").Location("Actions").OnGroup(GroupId)); - } - - return Combine(shapes); - } - - public override async Task UpdateAsync(SmtpSettings section, BuildEditorContext context) - { - var user = _httpContextAccessor.HttpContext?.User; - - if (!await _authorizationService.AuthorizeAsync(user, Permissions.ManageEmailSettings)) - { - return null; - } - - if (context.GroupId.Equals(GroupId, StringComparison.OrdinalIgnoreCase)) - { - var previousPassword = section.Password; - await context.Updater.TryUpdateModelAsync(section, Prefix); - - // Restore password if the input is empty, meaning that it has not been reset. - if (string.IsNullOrWhiteSpace(section.Password)) - { - section.Password = previousPassword; - } - else - { - // encrypt the password - var protector = _dataProtectionProvider.CreateProtector(nameof(SmtpSettingsConfiguration)); - section.Password = protector.Protect(section.Password); - } - - // Release the tenant to apply the settings. - await _shellHost.ReleaseShellContextAsync(_shellSettings); - } - - return await EditAsync(section, context); - } - } -} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Extensions/OrchardCoreBuilderExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Email/Extensions/OrchardCoreBuilderExtensions.cs index 203210e73a5..d0ca9be77da 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Extensions/OrchardCoreBuilderExtensions.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/Extensions/OrchardCoreBuilderExtensions.cs @@ -2,20 +2,19 @@ using OrchardCore.Email; using OrchardCore.Environment.Shell.Configuration; -namespace Microsoft.Extensions.DependencyInjection +namespace Microsoft.Extensions.DependencyInjection; + +public static class OrchardCoreBuilderExtensions { - public static class OrchardCoreBuilderExtensions + public static OrchardCoreBuilder ConfigureEmailSettings(this OrchardCoreBuilder builder) { - public static OrchardCoreBuilder ConfigureEmailSettings(this OrchardCoreBuilder builder) + builder.ConfigureServices((tenantServices, serviceProvider) => { - builder.ConfigureServices((tenantServices, serviceProvider) => - { - var configurationSection = serviceProvider.GetRequiredService().GetSection("OrchardCore_Email"); + var configurationSection = serviceProvider.GetRequiredService().GetSection("OrchardCore_Email"); - tenantServices.PostConfigure(settings => configurationSection.Bind(settings)); - }); + tenantServices.PostConfigure(settings => configurationSection.Bind(settings)); + }); - return builder; - } + return builder; } } diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Manifest.cs b/src/OrchardCore.Modules/OrchardCore.Email/Manifest.cs index d3ea8d15f3f..9abcf58e728 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Manifest.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/Manifest.cs @@ -5,7 +5,9 @@ Author = ManifestConstants.OrchardCoreTeam, Website = ManifestConstants.OrchardCoreWebsite, Version = ManifestConstants.OrchardCoreVersion, - Description = "Provides email settings configuration and a default email service based on SMTP.", + Description = "Provides email settings configuration.", Dependencies = ["OrchardCore.Resources"], - Category = "Messaging" + Category = "Messaging", + EnabledByDependencyOnly = true )] + diff --git a/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj b/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj index eda177bd7df..56554bd3435 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Email/OrchardCore.Email.csproj @@ -6,7 +6,7 @@ OrchardCore Email $(OCFrameworkDescription) - Provides email settings configuration and a default email service based on SMTP. + Provides email settings configuration. $(PackageTags) OrchardCoreFramework diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Services/EmailSettingsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Email/Services/EmailSettingsConfiguration.cs new file mode 100644 index 00000000000..653645ab479 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email/Services/EmailSettingsConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Settings; + +namespace OrchardCore.Email.Services; + +public class EmailSettingsConfiguration(IShellConfiguration shellConfiguration, ISiteService site) : IConfigureOptions +{ + private readonly IShellConfiguration _shellConfiguration = shellConfiguration; + private readonly ISiteService _site = site; + + public void Configure(EmailSettings options) + { + var section = _shellConfiguration.GetSection("OrchardCore_Email"); + + var emailSettings = _site.GetSiteSettingsAsync().GetAwaiter().GetResult().As(); + + options.DefaultSender = section.GetValue(nameof(options.DefaultSender), emailSettings.DefaultSender); + } +} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Services/SmtpSettingsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Email/Services/SmtpSettingsConfiguration.cs deleted file mode 100644 index 4d2c9b3ce8d..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Email/Services/SmtpSettingsConfiguration.cs +++ /dev/null @@ -1,60 +0,0 @@ -using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using OrchardCore.Settings; - -namespace OrchardCore.Email.Services -{ - public class SmtpSettingsConfiguration : IConfigureOptions - { - private readonly ISiteService _site; - private readonly IDataProtectionProvider _dataProtectionProvider; - private readonly ILogger _logger; - - public SmtpSettingsConfiguration( - ISiteService site, - IDataProtectionProvider dataProtectionProvider, - ILogger logger) - { - _site = site; - _dataProtectionProvider = dataProtectionProvider; - _logger = logger; - } - - public void Configure(SmtpSettings options) - { - var settings = _site.GetSiteSettingsAsync() - .GetAwaiter().GetResult() - .As(); - - options.DefaultSender = settings.DefaultSender; - options.DeliveryMethod = settings.DeliveryMethod; - options.PickupDirectoryLocation = settings.PickupDirectoryLocation; - options.Host = settings.Host; - options.Port = settings.Port; - options.ProxyHost = settings.ProxyHost; - options.ProxyPort = settings.ProxyPort; - options.EncryptionMethod = settings.EncryptionMethod; - options.AutoSelectEncryption = settings.AutoSelectEncryption; - options.RequireCredentials = settings.RequireCredentials; - options.UseDefaultCredentials = settings.UseDefaultCredentials; - options.UserName = settings.UserName; - options.Password = settings.Password; - options.IgnoreInvalidSslCertificate = settings.IgnoreInvalidSslCertificate; - - // Decrypt the password - if (!string.IsNullOrWhiteSpace(settings.Password)) - { - try - { - var protector = _dataProtectionProvider.CreateProtector(nameof(SmtpSettingsConfiguration)); - options.Password = protector.Unprotect(settings.Password); - } - catch - { - _logger.LogError("The Smtp password could not be decrypted. It may have been encrypted using a different key."); - } - } - } - } -} diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Email/Startup.cs index 20071c440e5..ced0315e536 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/Startup.cs @@ -1,48 +1,25 @@ -using System; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using OrchardCore.Admin; using OrchardCore.DisplayManagement.Handlers; -using OrchardCore.Email.Controllers; using OrchardCore.Email.Drivers; using OrchardCore.Email.Services; using OrchardCore.Modules; -using OrchardCore.Mvc.Core.Utilities; using OrchardCore.Navigation; using OrchardCore.Security.Permissions; using OrchardCore.Settings; -namespace OrchardCore.Email +namespace OrchardCore.Email; + +public class Startup : StartupBase { - public class Startup : StartupBase + public override void ConfigureServices(IServiceCollection services) { - private readonly AdminOptions _adminOptions; - - public Startup(IOptions adminOptions) - { - _adminOptions = adminOptions.Value; - } - - public override void ConfigureServices(IServiceCollection services) - { - services.AddScoped(); - services.AddScoped, SmtpSettingsDisplayDriver>(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped, EmailSettingsDisplayDriver>(); + services.AddScoped(); - services.AddTransient, SmtpSettingsConfiguration>(); - services.AddScoped(); - } + services.AddEmailServices(); - public override void Configure(IApplicationBuilder builder, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) - { - routes.MapAreaControllerRoute( - name: "EmailIndex", - areaName: "OrchardCore.Email", - pattern: _adminOptions.AdminUrlPrefix + "/Email/Index", - defaults: new { controller = typeof(AdminController).ControllerName(), action = nameof(AdminController.Index) } - ); - } + services.AddTransient, EmailSettingsConfiguration>(); } } diff --git a/src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpSettingsViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpEmailSettingsViewModel.cs similarity index 91% rename from src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpSettingsViewModel.cs rename to src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpEmailSettingsViewModel.cs index 2b1494681eb..00dacd91a3b 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpSettingsViewModel.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/ViewModels/SmtpEmailSettingsViewModel.cs @@ -2,7 +2,7 @@ namespace OrchardCore.Email.ViewModels { - public class SmtpSettingsViewModel + public class SmtpEmailSettingsViewModel { [Required(AllowEmptyStrings = false)] public string To { get; set; } diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Views/EmailSettings.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Email/Views/EmailSettings.Edit.cshtml new file mode 100644 index 00000000000..cada2c6e534 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Email/Views/EmailSettings.Edit.cshtml @@ -0,0 +1,11 @@ +@using OrchardCore.Email +@model EmailSettings + +

@T["The current tenant will be reloaded when the settings are saved."]

+ +
+ + + + @T["The default email address to use as a sender, unless the email sender is set by the active email provider."] +
diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.TestButton.cshtml b/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.TestButton.cshtml deleted file mode 100644 index 5f2ddd187cc..00000000000 --- a/src/OrchardCore.Modules/OrchardCore.Email/Views/SmtpSettings.TestButton.cshtml +++ /dev/null @@ -1 +0,0 @@ -@T["Test settings"] diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Activities/EmailTask.cs b/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Activities/EmailTask.cs index cf7b9d1c8ff..6bfc1003558 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Activities/EmailTask.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Activities/EmailTask.cs @@ -2,6 +2,7 @@ using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.Extensions.Localization; +using OrchardCore.Email.Services; using OrchardCore.Workflows.Abstractions.Models; using OrchardCore.Workflows.Activities; using OrchardCore.Workflows.Models; @@ -11,19 +12,19 @@ namespace OrchardCore.Email.Workflows.Activities { public class EmailTask : TaskActivity { - private readonly ISmtpService _smtpService; + private readonly IEmailService _emailService; private readonly IWorkflowExpressionEvaluator _expressionEvaluator; protected readonly IStringLocalizer S; private readonly HtmlEncoder _htmlEncoder; public EmailTask( - ISmtpService smtpService, + IEmailService emailService, IWorkflowExpressionEvaluator expressionEvaluator, IStringLocalizer localizer, HtmlEncoder htmlEncoder ) { - _smtpService = smtpService; + _emailService = emailService; _expressionEvaluator = expressionEvaluator; S = localizer; _htmlEncoder = htmlEncoder; @@ -124,7 +125,7 @@ public override async Task ExecuteAsync(WorkflowExecuti message.Sender = sender.Trim(); } - var result = await _smtpService.SendAsync(message); + var result = await _emailService.SendAsync(message); workflowContext.LastResult = result; if (!result.Succeeded) diff --git a/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Startup.cs index a59502e49e7..a3dd30b769f 100644 --- a/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Email/Workflows/Startup.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Email.Services; using OrchardCore.Email.Workflows.Activities; using OrchardCore.Email.Workflows.Drivers; using OrchardCore.Modules; diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Views/Admin/Options.cshtml b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Views/Admin/Options.cshtml index 1ce8c62fa89..09c83283e61 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Views/Admin/Options.cshtml +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Views/Admin/Options.cshtml @@ -8,7 +8,7 @@
-
+
@if (!string.IsNullOrEmpty(Model.ConnectionString)) { @@ -24,7 +24,7 @@
-
+
diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs index 323d11492be..b4a0f07a60a 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/ControllerExtensions.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using OrchardCore.DisplayManagement; using OrchardCore.Email; +using OrchardCore.Email.Services; using OrchardCore.Entities; using OrchardCore.Modules; using OrchardCore.Settings; @@ -23,7 +24,7 @@ internal static class ControllerExtensions { internal static async Task SendEmailAsync(this Controller controller, string email, string subject, IShape model) { - var smtpService = controller.HttpContext.RequestServices.GetRequiredService(); + var emailService = controller.HttpContext.RequestServices.GetRequiredService(); var displayHelper = controller.HttpContext.RequestServices.GetRequiredService(); var htmlEncoder = controller.HttpContext.RequestServices.GetRequiredService(); var body = string.Empty; @@ -43,7 +44,7 @@ internal static async Task SendEmailAsync(this Controller controller, stri IsHtmlBody = true }; - var result = await smtpService.SendAsync(message); + var result = await emailService.SendAsync(message); return result.Succeeded; } diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/EmailAuthenticatorController.cs b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/EmailAuthenticatorController.cs index 7295e12b886..30307b03029 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Controllers/EmailAuthenticatorController.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Controllers/EmailAuthenticatorController.cs @@ -14,6 +14,7 @@ using OrchardCore.Admin; using OrchardCore.DisplayManagement.Notify; using OrchardCore.Email; +using OrchardCore.Email.Services; using OrchardCore.Entities; using OrchardCore.Liquid; using OrchardCore.Modules; @@ -29,7 +30,7 @@ namespace OrchardCore.Users.Controllers; public class EmailAuthenticatorController : TwoFactorAuthenticationBaseController { private readonly IUserService _userService; - private readonly ISmtpService _smtpService; + private readonly IEmailService _emailService; private readonly ILiquidTemplateManager _liquidTemplateManager; private readonly HtmlEncoder _htmlEncoder; @@ -43,7 +44,7 @@ public EmailAuthenticatorController( INotifier notifier, IDistributedCache distributedCache, IUserService userService, - ISmtpService smtpService, + IEmailService emailService, ILiquidTemplateManager liquidTemplateManager, HtmlEncoder htmlEncoder, ITwoFactorAuthenticationHandlerCoordinator twoFactorAuthenticationHandlerCoordinator) @@ -59,7 +60,7 @@ public EmailAuthenticatorController( twoFactorOptions) { _userService = userService; - _smtpService = smtpService; + _emailService = emailService; _liquidTemplateManager = liquidTemplateManager; _htmlEncoder = htmlEncoder; } @@ -106,7 +107,7 @@ public async Task RequestCode() IsHtmlBody = true, }; - var result = await _smtpService.SendAsync(message); + var result = await _emailService.SendAsync(message); if (!result.Succeeded) { @@ -178,7 +179,7 @@ public async Task SendCode() IsHtmlBody = true, }; - var result = await _smtpService.SendAsync(message); + var result = await _emailService.SendAsync(message); return Ok(new { diff --git a/src/OrchardCore.Modules/OrchardCore.Users/Workflows/Activities/RegisterUserTask.cs b/src/OrchardCore.Modules/OrchardCore.Users/Workflows/Activities/RegisterUserTask.cs index 0400668aeea..b12e49fa3b0 100644 --- a/src/OrchardCore.Modules/OrchardCore.Users/Workflows/Activities/RegisterUserTask.cs +++ b/src/OrchardCore.Modules/OrchardCore.Users/Workflows/Activities/RegisterUserTask.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Localization; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.Email; +using OrchardCore.Email.Services; using OrchardCore.Users.Models; using OrchardCore.Users.Services; using OrchardCore.Workflows.Abstractions.Models; @@ -141,9 +142,9 @@ public override async Task ExecuteAsync(WorkflowExecuti Body = body, IsHtmlBody = true }; - var smtpService = _httpContextAccessor.HttpContext.RequestServices.GetService(); + var emailService = _httpContextAccessor.HttpContext.RequestServices.GetService(); - if (smtpService == null) + if (emailService == null) { var updater = _updateModelAccessor.ModelUpdater; updater?.ModelState.TryAddModelError("", S["No email service is available"]); @@ -151,7 +152,7 @@ public override async Task ExecuteAsync(WorkflowExecuti } else { - var result = await smtpService.SendAsync(message); + var result = await emailService.SendAsync(message); if (!result.Succeeded) { var updater = _updateModelAccessor.ModelUpdater; diff --git a/src/OrchardCore.Themes/TheComingSoonTheme/Recipes/comingsoon.recipe.json b/src/OrchardCore.Themes/TheComingSoonTheme/Recipes/comingsoon.recipe.json index 6491a21a8d0..5b9d3077522 100644 --- a/src/OrchardCore.Themes/TheComingSoonTheme/Recipes/comingsoon.recipe.json +++ b/src/OrchardCore.Themes/TheComingSoonTheme/Recipes/comingsoon.recipe.json @@ -30,7 +30,7 @@ "OrchardCore.Deployment", "OrchardCore.Diagnostics", "OrchardCore.DynamicCache", - "OrchardCore.Email", + "OrchardCore.Email.Smtp", "OrchardCore.ReCaptcha", "OrchardCore.Features", "OrchardCore.Flows", diff --git a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj index e733b5e20b0..a95239aebbf 100644 --- a/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj +++ b/src/OrchardCore/OrchardCore.Application.Cms.Core.Targets/OrchardCore.Application.Cms.Core.Targets.csproj @@ -60,6 +60,8 @@ + + diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/EmailSettings.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/EmailSettings.cs new file mode 100644 index 00000000000..12bb2aa7f31 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/EmailSettings.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace OrchardCore.Email; + +/// +/// Represents a settings for an email. +/// +public class EmailSettings +{ + /// + /// Gets or sets the default sender mail. + /// + [Required(AllowEmptyStrings = false)] + [EmailAddress] + public string DefaultSender { get; set; } +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/Events/IEmailServiceEvents.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Events/IEmailServiceEvents.cs new file mode 100644 index 00000000000..21026d483a9 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Events/IEmailServiceEvents.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; + +namespace OrchardCore.Email.Events; + +public interface IEmailServiceEvents +{ + Task OnMessageSendingAsync(MailMessage message); + + Task OnMessageSentAsync(); +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/Extensions/MailMessageExtensions.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Extensions/MailMessageExtensions.cs new file mode 100644 index 00000000000..14983abd8c9 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Extensions/MailMessageExtensions.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OrchardCore.Email; + +public static class MailMessageExtensions +{ + private static readonly char[] _emailsSeparator = [',', ';']; + + public static MailMessageRecipients GetRecipients(this MailMessage message) + { + var recipients = new MailMessageRecipients(); + recipients.To.AddRange(SplitMailMessageRecipients(message.To)); + recipients.Cc.AddRange(SplitMailMessageRecipients(message.Cc)); + recipients.Bcc.AddRange(SplitMailMessageRecipients(message.Bcc)); + + return recipients; + } + public static IEnumerable GetSender(this MailMessage message) => + string.IsNullOrWhiteSpace(message.From) ? Enumerable.Empty() : SplitMailMessageRecipients(message.From); + + public static IEnumerable GetReplyTo(this MailMessage message) => + string.IsNullOrWhiteSpace(message.ReplyTo) ? message.GetSender() : SplitMailMessageRecipients(message.ReplyTo); + + private static IEnumerable SplitMailMessageRecipients(string recipients) => recipients + ?.Split(_emailsSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) ?? Enumerable.Empty(); +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/ISmtpService.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/ISmtpService.cs deleted file mode 100644 index 5c96d62e933..00000000000 --- a/src/OrchardCore/OrchardCore.Email.Abstractions/ISmtpService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Threading.Tasks; - -namespace OrchardCore.Email -{ - /// - /// Represents a contract for SMTP service. - /// - public interface ISmtpService - { - /// - /// Sends the specified message to an SMTP server for delivery. - /// - /// The message to be sent. - /// A that holds information about the sent message, for instance if it has sent successfully or if it has failed. - Task SendAsync(MailMessage message); - } -} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/MailMessageRecipients.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/MailMessageRecipients.cs new file mode 100644 index 00000000000..75b6c47d08f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/MailMessageRecipients.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace OrchardCore.Email; + +public class MailMessageRecipients +{ + public List To { get; } = []; + + public List Cc { get; } = []; + + public List Bcc { get; } = []; +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/OrchardCore.Email.Abstractions.csproj b/src/OrchardCore/OrchardCore.Email.Abstractions/OrchardCore.Email.Abstractions.csproj index f2ed0324f69..ef788feb80c 100644 --- a/src/OrchardCore/OrchardCore.Email.Abstractions/OrchardCore.Email.Abstractions.csproj +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/OrchardCore.Email.Abstractions.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpResult.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/EmailResult.cs similarity index 51% rename from src/OrchardCore/OrchardCore.Email.Abstractions/SmtpResult.cs rename to src/OrchardCore/OrchardCore.Email.Abstractions/Services/EmailResult.cs index 729449d8cf8..948444c0412 100644 --- a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpResult.cs +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/EmailResult.cs @@ -1,37 +1,32 @@ using System.Collections.Generic; using Microsoft.Extensions.Localization; -namespace OrchardCore.Email +namespace OrchardCore.Email.Services { /// /// Represents the result of sending an email. /// - public class SmtpResult + public class EmailResult : IEmailResult { /// - /// Returns an indicating a successful Smtp operation. + /// Returns an indicating a successful email operation. /// - public static SmtpResult Success { get; } = new SmtpResult { Succeeded = true }; + public static IEmailResult Success { get; } = new EmailResult() { Succeeded = true }; /// - /// An containing an errors that occurred during the Smtp operation. + /// An containing an errors that occurred during the email operation. /// public IEnumerable Errors { get; protected set; } - /// - /// Get or sets the response text from the SMTP server. - /// - public string Response { get; set; } - /// /// Whether the operation succeeded or not. /// public bool Succeeded { get; protected set; } /// - /// Creates an indicating a failed Smtp operation, with a list of errors if applicable. + /// Creates an indicating a failed email operation, with a list of errors if applicable. /// /// An optional array of which caused the operation to fail. - public static SmtpResult Failed(params LocalizedString[] errors) => new() { Succeeded = false, Errors = errors }; + public static IEmailResult Failed(params LocalizedString[] errors) => new EmailResult() { Succeeded = false, Errors = errors }; } } diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailDeliveryService.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailDeliveryService.cs new file mode 100644 index 00000000000..6dd7b56b431 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailDeliveryService.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace OrchardCore.Email.Services; + +public interface IEmailDeliveryService +{ + Task DeliverAsync(MailMessage message); +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailDeliveryServiceResolver.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailDeliveryServiceResolver.cs new file mode 100644 index 00000000000..bb2e46ef6bc --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailDeliveryServiceResolver.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Email.Services; + +public interface IEmailDeliveryServiceResolver +{ + IEmailDeliveryService Resolve(string name); +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailMessageValidator.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailMessageValidator.cs new file mode 100644 index 00000000000..8d11b3b539e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailMessageValidator.cs @@ -0,0 +1,9 @@ +using Microsoft.Extensions.Localization; +using System.Collections.Generic; + +namespace OrchardCore.Email.Services; + +public interface IEmailMessageValidator +{ + bool IsValid(MailMessage message, out List errors); +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailResult.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailResult.cs new file mode 100644 index 00000000000..35c3530b1cb --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailResult.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Microsoft.Extensions.Localization; + +namespace OrchardCore.Email.Services; + +public interface IEmailResult +{ + IEnumerable Errors { get; } + + bool Succeeded { get; } +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailService.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailService.cs new file mode 100644 index 00000000000..03b20d909ed --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Abstractions/Services/IEmailService.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace OrchardCore.Email.Services; + +/// +/// Represents a contract for email service. +/// +public interface IEmailService +{ + /// + /// Sends the specified message in email. + /// + /// The message to be sent in email. + /// The name of the delivery service to send the email. If no name is specified then `IEmailDeliveryServiceResolver` will select the last registered one. + /// An that holds information about the message sent, for instance, if it was sent successfully or if it has failed. + Task SendAsync(MailMessage message, string deliveryServiceName = null); +} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpDeliveryMethod.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpDeliveryMethod.cs deleted file mode 100644 index 43b8aed4fc6..00000000000 --- a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpDeliveryMethod.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace OrchardCore.Email -{ - /// - /// Represents an enumeration for the mail delivery methods. - /// - public enum SmtpDeliveryMethod - { - Network, - SpecifiedPickupDirectory - } -} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpEncryptionMethod.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpEncryptionMethod.cs deleted file mode 100644 index 4458bb4271e..00000000000 --- a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpEncryptionMethod.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace OrchardCore.Email -{ - /// - /// Represents an enumeration for mail encryption methods. - /// - public enum SmtpEncryptionMethod - { - None = 0, - SslTls = 1, - StartTls = 2 - } -} diff --git a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpSettings.cs b/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpSettings.cs deleted file mode 100644 index 8ada10b03e6..00000000000 --- a/src/OrchardCore/OrchardCore.Email.Abstractions/SmtpSettings.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Localization; - -namespace OrchardCore.Email -{ - /// - /// Represents a settings for SMTP. - /// - public class SmtpSettings : IValidatableObject - { - /// - /// Gets or sets the default sender mail. - /// - [Required(AllowEmptyStrings = false), EmailAddress] - public string DefaultSender { get; set; } - - /// - /// Gets or sets the mail delivery method. - /// - [Required] - public SmtpDeliveryMethod DeliveryMethod { get; set; } - - /// - /// Gets or sets the mailbox directory, this used for option. - /// - public string PickupDirectoryLocation { get; set; } - - /// - /// Gets or sets the SMTP server/host. - /// - public string Host { get; set; } - - /// - /// Gets or sets the SMTP port number. Defaults to 25. - /// - [Range(0, 65535)] - public int Port { get; set; } = 25; - - /// - /// Gets or sets whether the encryption is automatically selected. - /// - public bool AutoSelectEncryption { get; set; } - - /// - /// Gets or sets whether the user credentials is required. - /// - public bool RequireCredentials { get; set; } - - /// - /// Gets or sets whether to use the default user credentials. - /// - public bool UseDefaultCredentials { get; set; } - - /// - /// Gets or sets the mail encryption method. - /// - public SmtpEncryptionMethod EncryptionMethod { get; set; } - - /// - /// Gets or sets the user name. - /// - public string UserName { get; set; } - - /// - /// Gets or sets the user password. - /// - public string Password { get; set; } - - /// - /// Gets or sets the proxy server. - /// - public string ProxyHost { get; set; } - - /// - /// Gets or sets the proxy port number. - /// - public int ProxyPort { get; set; } - - /// - /// Gets or sets whether invalid SSL certificates should be ignored. - /// - public bool IgnoreInvalidSslCertificate { get; set; } - - /// - public IEnumerable Validate(ValidationContext validationContext) - { - var S = validationContext.GetService>(); - - switch (DeliveryMethod) - { - case SmtpDeliveryMethod.Network: - if (string.IsNullOrEmpty(Host)) - { - yield return new ValidationResult(S["The {0} field is required.", "Host name"], new[] { nameof(Host) }); - } - break; - case SmtpDeliveryMethod.SpecifiedPickupDirectory: - if (string.IsNullOrEmpty(PickupDirectoryLocation)) - { - yield return new ValidationResult(S["The {0} field is required.", "Pickup directory location"], new[] { nameof(PickupDirectoryLocation) }); - } - break; - default: - throw new NotSupportedException(S["The '{0}' delivery method is not supported.", DeliveryMethod]); - } - } - } -} diff --git a/src/OrchardCore/OrchardCore.Email.Core/EmailConstants.cs b/src/OrchardCore/OrchardCore.Email.Core/EmailConstants.cs new file mode 100644 index 00000000000..266e3d2ab27 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Core/EmailConstants.cs @@ -0,0 +1,6 @@ +namespace OrchardCore.Email; + +public static class EmailConstants +{ + public const string NullEmailDeliveryServiceName = "null"; +} diff --git a/src/OrchardCore/OrchardCore.Email.Core/Extensions/EmailServiceCollectionExtensions.cs b/src/OrchardCore/OrchardCore.Email.Core/Extensions/EmailServiceCollectionExtensions.cs new file mode 100644 index 00000000000..75c93caa2a2 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Core/Extensions/EmailServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Email.Core.Services; +using OrchardCore.Email.Services; + +namespace OrchardCore.Email; + +public static class EmailServiceCollectionExtensions +{ + public static IServiceCollection AddEmailServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddEmailDeliveryService(EmailConstants.NullEmailDeliveryServiceName); + services.AddScoped(); + services.AddScoped(); + + return services; + } + + public static IServiceCollection AddEmailDeliveryService(this IServiceCollection services, string key) + where TEmailDeliveryService : class, IEmailDeliveryService + { + services.AddScoped(); + services.AddKeyedScoped(key); + + return services; + } +} diff --git a/src/OrchardCore/OrchardCore.Email.Core/Services/EmailDeliveryServiceDictionary.cs b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailDeliveryServiceDictionary.cs new file mode 100644 index 00000000000..bdbddddfb4f --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailDeliveryServiceDictionary.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; + +namespace OrchardCore.Email.Services; + +public class EmailDeliveryServiceDictionary : Dictionary +{ +} diff --git a/src/OrchardCore/OrchardCore.Email.Core/Services/EmailDeliveryServiceResolver.cs b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailDeliveryServiceResolver.cs new file mode 100644 index 00000000000..7bd169bd143 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailDeliveryServiceResolver.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Email.Services; + +namespace OrchardCore.Email.Core.Services; + +public class EmailDeliveryServiceResolver : IEmailDeliveryServiceResolver +{ + private readonly IServiceProvider _serviceProvider; + + public EmailDeliveryServiceResolver(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + public IEmailDeliveryService Resolve(string name) => + _serviceProvider.GetKeyedService(name) ?? _serviceProvider.GetRequiredService(); +} diff --git a/src/OrchardCore/OrchardCore.Email.Core/Services/EmailMessageValidator.cs b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailMessageValidator.cs new file mode 100644 index 00000000000..45d9ba30fda --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailMessageValidator.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +namespace OrchardCore.Email.Services; + +public class EmailMessageValidator : IEmailMessageValidator +{ + private readonly IEmailAddressValidator _emailAddressValidator; + private readonly EmailSettings _emailSettings; + + protected readonly IStringLocalizer S; + + public EmailMessageValidator(IEmailAddressValidator emailAddressValidator, + IOptions options, + IStringLocalizer stringLocalizer) + { + _emailAddressValidator = emailAddressValidator; + _emailSettings = options.Value; + S = stringLocalizer; + } + + public bool IsValid(MailMessage message, out List errors) + { + errors = []; + var senderAddress = string.IsNullOrWhiteSpace(message.Sender) + ? _emailSettings.DefaultSender + : message.Sender; + + if (!string.IsNullOrEmpty(senderAddress)) + { + if (!_emailAddressValidator.Validate(senderAddress)) + { + errors.Add(S["Invalid email address for the sender: '{0}'.", senderAddress]); + } + } + + errors.AddRange(message.GetSender() + .Where(address => !_emailAddressValidator.Validate(address)) + .Select(address => S["Invalid email address for the sender: '{0}'.", address])); + + var recipients = message.GetRecipients(); + + errors.AddRange(recipients.To + .Where(address => !_emailAddressValidator.Validate(address)) + .Select(address => S["Invalid email address for the recipient: '{0}'.", address])); + + errors.AddRange(recipients.Cc + .Where(address => !_emailAddressValidator.Validate(address)) + .Select(address => S["Invalid email address for the recipient: '{0}'.", address])); + + errors.AddRange(recipients.Bcc + .Where(address => !_emailAddressValidator.Validate(address)) + .Select(address => S["Invalid email address for the recipient: '{0}'.", address])); + + errors.AddRange(message.GetReplyTo() + .Where(address => !_emailAddressValidator.Validate(address)) + .Select(address => S["Invalid email address for the recipient: '{0}'.", address])); + + if (recipients.To.Count == 0 && recipients.Cc.Count == 0 && recipients.Bcc.Count == 0) + { + errors.Add(S["The mail message should have at least one of these headers: To, Cc or Bcc."]); + } + + return errors.Count == 0; + } +} diff --git a/src/OrchardCore/OrchardCore.Email.Core/Services/EmailService.cs b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailService.cs new file mode 100644 index 00000000000..594e5a2b76e --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Core/Services/EmailService.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using OrchardCore.Email.Events; +using OrchardCore.Modules; + +namespace OrchardCore.Email.Services; + +public class EmailService : IEmailService +{ + private readonly IEmailMessageValidator _emailMessageValidator; + private readonly IEmailDeliveryServiceResolver _emailDeliveryServiceResolver; + private readonly IEnumerable _emailServiceEvents; + private readonly ILogger _logger; + + public EmailService( + IEmailMessageValidator emailMessageValidator, + IEmailDeliveryServiceResolver emailDeliveryServiceResolver, + IEnumerable emailServiceEvents, + ILogger logger) + { + _emailMessageValidator = emailMessageValidator; + _emailDeliveryServiceResolver = emailDeliveryServiceResolver; + _emailServiceEvents = emailServiceEvents; + _logger = logger; + } + + public async Task SendAsync(MailMessage message, string deliveryServiceName = null) + { + await _emailServiceEvents.InvokeAsync((e, message) => e.OnMessageSendingAsync(message), message, _logger); + + if (!_emailMessageValidator.IsValid(message, out var errors)) + { + return EmailResult.Failed([.. errors]); + } + + var emailDeliveryService = _emailDeliveryServiceResolver.Resolve(deliveryServiceName); + + var result = await emailDeliveryService.DeliverAsync(message); + + await _emailServiceEvents.InvokeAsync((e) => e.OnMessageSentAsync(), _logger); + + return result; + } +} diff --git a/src/OrchardCore/OrchardCore.Email.Core/Services/NullEmailDeliveryService.cs b/src/OrchardCore/OrchardCore.Email.Core/Services/NullEmailDeliveryService.cs new file mode 100644 index 00000000000..6908d4ccc67 --- /dev/null +++ b/src/OrchardCore/OrchardCore.Email.Core/Services/NullEmailDeliveryService.cs @@ -0,0 +1,25 @@ +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; + +namespace OrchardCore.Email.Services; + +public class NullEmailDeliveryService : IEmailDeliveryService +{ + private readonly ILogger _logger; + + protected readonly IStringLocalizer S; + + public NullEmailDeliveryService(ILogger logger, IStringLocalizer stringLocalizer) + { + _logger = logger; + S = stringLocalizer; + } + + public async Task DeliverAsync(MailMessage message) + { + _logger.LogWarning("No email delivery service is configured. Please enable an actual implementation so email can be sent."); + + return await Task.FromResult(EmailResult.Failed(S["Please enable an actual implementation so email can be sent."])); + } +} diff --git a/src/OrchardCore/OrchardCore.Email.Core/Services/SmtpService.cs b/src/OrchardCore/OrchardCore.Email.Core/Services/SmtpService.cs deleted file mode 100644 index 147da710ab1..00000000000 --- a/src/OrchardCore/OrchardCore.Email.Core/Services/SmtpService.cs +++ /dev/null @@ -1,327 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net.Security; -using System.Security.Cryptography.X509Certificates; -using System.Threading; -using System.Threading.Tasks; -using MailKit.Net.Proxy; -using MailKit.Net.Smtp; -using MailKit.Security; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using MimeKit; - -namespace OrchardCore.Email.Services -{ - /// - /// Represents a SMTP service that allows to send emails. - /// - public class SmtpService : ISmtpService - { - private const string EmailExtension = ".eml"; - - private static readonly char[] _emailsSeparator = [',', ';']; - - private readonly SmtpSettings _options; - private readonly ILogger _logger; - protected readonly IStringLocalizer S; - - /// - /// Initializes a new instance of a . - /// - /// The . - /// The . - /// The . - public SmtpService( - IOptions options, - ILogger logger, - IStringLocalizer stringLocalizer) - { - _options = options.Value; - _logger = logger; - S = stringLocalizer; - } - - /// - /// Sends the specified message to an SMTP server for delivery. - /// - /// The message to be sent. - /// A that holds information about the sent message, for instance if it has sent successfully or if it has failed. - /// This method allows to send an email without setting if or is provided. - public async Task SendAsync(MailMessage message) - { - if (_options == null) - { - return SmtpResult.Failed(S["SMTP settings must be configured before an email can be sent."]); - } - - SmtpResult result; - var response = default(string); - try - { - // Set the MailMessage.From, to avoid the confusion between _options.DefaultSender (Author) and submitter (Sender) - var senderAddress = string.IsNullOrWhiteSpace(message.From) - ? _options.DefaultSender - : message.From; - - if (!string.IsNullOrWhiteSpace(senderAddress)) - { - message.From = senderAddress; - } - - var errors = new List(); - - var mimeMessage = FromMailMessage(message, errors); - - if (errors.Count > 0) - { - return SmtpResult.Failed(errors.ToArray()); - } - - if (mimeMessage.To.Count == 0 && mimeMessage.Cc.Count == 0 && mimeMessage.Bcc.Count == 0) - { - return SmtpResult.Failed(S["The mail message should have at least one of these headers: To, Cc or Bcc."]); - } - - switch (_options.DeliveryMethod) - { - case SmtpDeliveryMethod.Network: - response = await SendOnlineMessageAsync(mimeMessage); - break; - case SmtpDeliveryMethod.SpecifiedPickupDirectory: - await SendOfflineMessageAsync(mimeMessage, _options.PickupDirectoryLocation); - break; - default: - throw new NotSupportedException($"The '{_options.DeliveryMethod}' delivery method is not supported."); - } - - result = SmtpResult.Success; - } - catch (Exception ex) - { - result = SmtpResult.Failed(S["An error occurred while sending an email: '{0}'", ex.Message]); - } - - result.Response = response; - - return result; - } - - private MimeMessage FromMailMessage(MailMessage message, List errors) - { - var submitterAddress = string.IsNullOrWhiteSpace(message.Sender) - ? _options.DefaultSender - : message.Sender; - - var mimeMessage = new MimeMessage(); - - if (!string.IsNullOrEmpty(submitterAddress)) - { - if (MailboxAddress.TryParse(submitterAddress, out var mailBox)) - { - mimeMessage.Sender = mailBox; - - } - else - { - errors.Add(S["Invalid email address: '{0}'", submitterAddress]); - } - } - - if (!string.IsNullOrWhiteSpace(message.From)) - { - foreach (var address in message.From.Split(_emailsSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (MailboxAddress.TryParse(address, out var mailBox)) - { - mimeMessage.From.Add(mailBox); - } - else - { - errors.Add(S["Invalid email address: '{0}'", address]); - } - } - } - - if (!string.IsNullOrWhiteSpace(message.To)) - { - foreach (var address in message.To.Split(_emailsSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (MailboxAddress.TryParse(address, out var mailBox)) - { - mimeMessage.To.Add(mailBox); - } - else - { - errors.Add(S["Invalid email address: '{0}'", address]); - } - } - } - - if (!string.IsNullOrWhiteSpace(message.Cc)) - { - foreach (var address in message.Cc.Split(_emailsSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (MailboxAddress.TryParse(address, out var mailBox)) - { - mimeMessage.Cc.Add(mailBox); - } - else - { - errors.Add(S["Invalid email address: '{0}'", address]); - } - } - } - - if (!string.IsNullOrWhiteSpace(message.Bcc)) - { - foreach (var address in message.Bcc.Split(_emailsSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) - { - if (MailboxAddress.TryParse(address, out var mailBox)) - { - mimeMessage.Bcc.Add(mailBox); - } - else - { - errors.Add(S["Invalid email address: '{0}'", address]); - } - } - } - - if (string.IsNullOrWhiteSpace(message.ReplyTo)) - { - foreach (var address in mimeMessage.From) - { - mimeMessage.ReplyTo.Add(address); - } - } - else - { - foreach (var address in message.ReplyTo.Split(_emailsSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - if (MailboxAddress.TryParse(address, out var mailBox)) - { - mimeMessage.ReplyTo.Add(mailBox); - } - else - { - errors.Add(S["Invalid email address: '{0}'", address]); - } - } - } - - mimeMessage.Subject = message.Subject; - - var body = new BodyBuilder(); - - if (message.IsHtmlBody) - { - body.HtmlBody = message.Body; - } - else - { - body.TextBody = message.Body; - } - - foreach (var attachment in message.Attachments) - { - // Stream must not be null, otherwise it would try to get the filesystem path - if (attachment.Stream != null) - { - body.Attachments.Add(attachment.Filename, attachment.Stream); - } - } - - mimeMessage.Body = body.ToMessageBody(); - - return mimeMessage; - } - - protected virtual Task OnMessageSendingAsync(SmtpClient client, MimeMessage message) => Task.CompletedTask; - - private async Task SendOnlineMessageAsync(MimeMessage message) - { - var secureSocketOptions = SecureSocketOptions.Auto; - - if (!_options.AutoSelectEncryption) - { - secureSocketOptions = _options.EncryptionMethod switch - { - SmtpEncryptionMethod.None => SecureSocketOptions.None, - SmtpEncryptionMethod.SslTls => SecureSocketOptions.SslOnConnect, - SmtpEncryptionMethod.StartTls => SecureSocketOptions.StartTls, - _ => SecureSocketOptions.Auto, - }; - } - - using var client = new SmtpClient(); - - client.ServerCertificateValidationCallback = CertificateValidationCallback; - - await OnMessageSendingAsync(client, message); - - await client.ConnectAsync(_options.Host, _options.Port, secureSocketOptions); - - if (_options.RequireCredentials) - { - if (_options.UseDefaultCredentials) - { - // There's no notion of 'UseDefaultCredentials' in MailKit, so empty credentials is passed in - await client.AuthenticateAsync(string.Empty, string.Empty); - } - else if (!string.IsNullOrWhiteSpace(_options.UserName)) - { - await client.AuthenticateAsync(_options.UserName, _options.Password); - } - } - - if (!string.IsNullOrEmpty(_options.ProxyHost)) - { - client.ProxyClient = new Socks5Client(_options.ProxyHost, _options.ProxyPort); - } - - var response = await client.SendAsync(message); - - await client.DisconnectAsync(true); - - return response; - } - - private static Task SendOfflineMessageAsync(MimeMessage message, string pickupDirectory) - { - var mailPath = Path.Combine(pickupDirectory, Guid.NewGuid().ToString() + EmailExtension); - return message.WriteToAsync(mailPath, CancellationToken.None); - } - - private bool CertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) - { - const string logErrorMessage = "SMTP Server's certificate {CertificateSubject} issued by {CertificateIssuer} " + - "with thumbprint {CertificateThumbprint} and expiration date {CertificateExpirationDate} " + - "is considered invalid with {SslPolicyErrors} policy errors"; - - if (sslPolicyErrors == SslPolicyErrors.None) - { - return true; - } - - _logger.LogError(logErrorMessage, - certificate.Subject, - certificate.Issuer, - certificate.GetCertHashString(), - certificate.GetExpirationDateString(), - sslPolicyErrors); - - if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateChainErrors) && chain?.ChainStatus != null) - { - foreach (var chainStatus in chain.ChainStatus) - { - _logger.LogError("Status: {Status} - {StatusInformation}", chainStatus.Status, chainStatus.StatusInformation); - } - } - - return _options.IgnoreInvalidSslCertificate; - } - } -} diff --git a/src/OrchardCore/OrchardCore.Notifications.Core/Services/EmailNotificationProvider.cs b/src/OrchardCore/OrchardCore.Notifications.Core/Services/EmailNotificationProvider.cs index e28d5a85664..4da59a6135d 100644 --- a/src/OrchardCore/OrchardCore.Notifications.Core/Services/EmailNotificationProvider.cs +++ b/src/OrchardCore/OrchardCore.Notifications.Core/Services/EmailNotificationProvider.cs @@ -2,20 +2,21 @@ using System.Threading.Tasks; using Microsoft.Extensions.Localization; using OrchardCore.Email; +using OrchardCore.Email.Services; using OrchardCore.Users.Models; namespace OrchardCore.Notifications.Services; public class EmailNotificationProvider : INotificationMethodProvider { - private readonly ISmtpService _smtpService; + private readonly IEmailService _emailService; protected readonly IStringLocalizer S; public EmailNotificationProvider( - ISmtpService smtpService, + IEmailService emailService, IStringLocalizer stringLocalizer) { - _smtpService = smtpService; + _emailService = emailService; S = stringLocalizer; } @@ -49,7 +50,7 @@ public async Task TrySendAsync(object notify, INotificationMessage message mailMessage.IsHtmlBody = false; } - var result = await _smtpService.SendAsync(mailMessage); + var result = await _emailService.SendAsync(mailMessage); return result.Succeeded; } diff --git a/src/docs/reference/README.md b/src/docs/reference/README.md index 7dc3372e4b3..a8130f28b4f 100644 --- a/src/docs/reference/README.md +++ b/src/docs/reference/README.md @@ -114,7 +114,10 @@ Here's a categorized overview of all built-in Orchard Core features at a glance. - [Logging Serilog](core/Logging.Serilog/README.md) - [Mini Profiler](modules/MiniProfiler/README.md) - [Response Compression](modules/ResponseCompression/README.md) -- [Email](modules/Email/README.md) +- Email: + - [Email](modules/Email/README.md) + - [SMTP Email](modules/Email.Smtp/README.md) + - [Azure Email](modules/Email.Azure/README.md) - [Redis](modules/Redis/README.md) - [Deployment](modules/Deployment/README.md) - [Diagnostics](modules/Diagnostics/README.md) diff --git a/src/docs/reference/modules/Email.Azure/README.md b/src/docs/reference/modules/Email.Azure/README.md new file mode 100644 index 00000000000..5c6c53a88d8 --- /dev/null +++ b/src/docs/reference/modules/Email.Azure/README.md @@ -0,0 +1,34 @@ +# Azure Email (`OrchardCore.Email.Azure`) + +This module provides the infrastructure necessary to send emails using [Azure Communication Services Email](https://learn.microsoft.com/en-us/azure/communication-services/concepts/email/email-overview). + +## Azure Email Settings + +Enabling the `OrchardCore.Email.Azure` module will allow the user to set the following settings: + +| Setting | Description | +| --- | --- | +| `ConnectionString` | The ACS connection string that will be used to deliver the email. +| `DefaultSender` | The email of the sender. This will overrides the `DefaultSender` setting in [`OrchardCore.Email`](../Email/README.md). | + +## Azure Email Settings Configuration + +The `OrchardCore.Email.Azure` module allows the user to use configuration values to override the settings configured from the admin area by calling the `ConfigureAzureEmailSettings()` extension method on `OrchardCoreBuilder` when initializing the app. + +The following configuration values can be customized: + +```json + "OrchardCore_Email_Azure": { + "DefaultSender": "", + "ConnectionString": "", + } +``` + +!!! note + Configuring `DefaultSender` will override the email settings. + +For more information please refer to [Configuration](../../core/Configuration/README.md). + +## Video + + diff --git a/src/docs/reference/modules/Email.Smtp/README.md b/src/docs/reference/modules/Email.Smtp/README.md new file mode 100644 index 00000000000..d4fd6f38cc0 --- /dev/null +++ b/src/docs/reference/modules/Email.Smtp/README.md @@ -0,0 +1,64 @@ +# SMTP Email (`OrchardCore.Email.Smtp`) + +This module provides the infrastructure necessary to send emails using `SMTP`. + +## SMTP Settings + +Enabling the `OrchardCore.Email.Smtp` feature will allow the user to set the following settings: + +| Setting | Description | +| --- | --- | +| `DefaultSender` | The email of the sender. This will override the `DefaultSender` setting in [OrchardCore.Email](../Email/README.md). | +| `DeliveryMethod` | The method for sending the email, `SmtpDeliveryMethod.Network` (online) or `SmtpDeliveryMethod.SpecifiedPickupDirectory` (offline). | +| `PickupDirectoryLocation` | The directory location for the mailbox (`SmtpDeliveryMethod.SpecifiedPickupDirectory`). | +| `Host` | The SMTP server. | +| `Port` | The SMTP port number. | +| `AutoSelectEncryption` | Whether the SMTP selects the encryption automatically. | +| `RequireCredentials` | Whether the SMTP requires the user credentials. | +| `UseDefaultCredentials` | Whether the SMTP will use the default credentials. | +| `EncryptionMethod` | The SMTP encryption method `SmtpEncryptionMethod.None`, `SmtpEncryptionMethod.SSLTLS` or `SmtpEncryptionMethodSTARTTLS`. | +| `UserName` | The username for the sender. | +| `Password` | The password for the sender. | +| `ProxyHost` | The proxy server. | +| `ProxyPort` | The proxy port number. | + +!!! note + You must configure `ProxyHost` and `ProxyPort` if the SMTP server runs through a proxy server. + +## SMTP Email Settings Configuration + +The `OrchardCore.Email.Smtp` module allows the user to use configuration values to override the settings configured from the admin area by calling the `ConfigureSmtpEmailSettings()` extension method on `OrchardCoreBuilder` when initializing the app. + +The following configuration values can be customized: + +```json + "OrchardCore_Email_Smtp": { + "DefaultSender": "Network", + "PickupDirectoryLocation": "", + "Host": "localhost", + "Port": 25, + // Uncomment if the SMTP server runs through a proxy server + //"ProxyHost": "proxy.domain.com", + //"ProxyPort": 5050, + "EncryptionMethod": "SSLTLS", + "AutoSelectEncryption": false, + "UseDefaultCredentials": false, + "RequireCredentials": true, + "Username": "", + "Password": "" + } +``` + +For more information please refer to [Configuration](../../core/Configuration/README.md). + +!!! note + You can use still use the old `OrchardCore_Email` section for backward compatibility, but we encourage every one to use `OrchardCore_Email_Smtp` section instead. + +## Credits + +### MailKit + + + +Copyright 2013-2019 Xamarin Inc +Licensed under the MIT License diff --git a/src/docs/reference/modules/Email/README.md b/src/docs/reference/modules/Email/README.md index ca0f7b9e605..ec55a41ca52 100644 --- a/src/docs/reference/modules/Email/README.md +++ b/src/docs/reference/modules/Email/README.md @@ -1,29 +1,17 @@ # Email (`OrchardCore.Email`) -This module provides the infrastructure necessary to send emails using `SMTP`. +This module facilitates the configuration of email settings and will automatically activate upon demand when utilizing at least one email provider service. For further details, refer to the documentation for the [Azure Email](../Email.Azure/README.md) and [SMTP Email](../Email.Smtp/README.md) modules. -## SMTP Settings +## Email Settings Enabling the `OrchardCore.Email` module will allow the user to set the following settings: | Setting | Description | | --- | --- | -| `DefaultSender` | The email of the sender. | -| `DeliveryMethod` | The method for sending the email, `SmtpDeliveryMethod.Network` (online) or `SmtpDeliveryMethod.SpecifiedPickupDirectory` (offline). | -| `PickupDirectoryLocation` | The directory location for the mailbox (`SmtpDeliveryMethod.SpecifiedPickupDirectory`). | -| `Host` | The SMTP server. | -| `Port` | The SMTP port number. | -| `AutoSelectEncryption` | Whether the SMTP select the encryption automatically. | -| `RequireCredentials` | Whether the SMTP requires the user credentials. | -| `UseDefaultCredentials` | Whether the SMTP will use the default credentials. | -| `EncryptionMethod` | The SMTP encryption method `SmtpEncryptionMethod.None`, `SmtpEncryptionMethod.SSLTLS` or `SmtpEncryptionMethodSTARTTLS`. | -| `UserName` | The username for the sender. | -| `Password` | The password for the sender. | -| `ProxyHost` | The proxy server. | -| `ProxyPort` | The proxy port number. | +| `DefaultSender` | The email of the sender. !!! note - You must configure `ProxyHost` and `ProxyPort` if the SMTP server runs through a proxy server. + When [OrchardCore.Email.Azure](../Email.Azure/README.md) or [OrchardCore.Email.Smtp](../Email.Smtp/README.md) is enabled you can override the `DefaultSender` setting from the module-specific settings, otherwise, it will fall back to this setting. ## Email Settings Configuration @@ -34,29 +22,7 @@ The following configuration values can be customized: ```json "OrchardCore_Email": { "DefaultSender": "", - "DefaultSender": "Network", - "PickupDirectoryLocation": "", - "Host": "localhost", - "Port": 25, - // Uncomment if SMTP server runs through a proxy server - //"ProxyHost": "proxy.domain.com", - //"ProxyPort": 5050, - "EncryptionMethod": "SSLTLS", - "AutoSelectEncryption": false, - "UseDefaultCredentials": false, - "RequireCredentials": true, - "Username": "", - "Password": "" } ``` For more information please refer to [Configuration](../../core/Configuration/README.md). - -## Credits - -### MailKit - - - -Copyright 2013-2019 Xamarin Inc -Licensed under the MIT License diff --git a/src/docs/releases/1.9.0.md b/src/docs/releases/1.9.0.md index 017373d3c90..1951d36502e 100644 --- a/src/docs/releases/1.9.0.md +++ b/src/docs/releases/1.9.0.md @@ -4,6 +4,21 @@ Release date: Not yet released ## Breaking Changes +### Email Module + +The `OrchardCore.Email` module now only provides email settings and the basics of the email infrastructure, and can't be enabled in itself. To actually send out e-mails, you need to enable an email provider, like `OrchardCore.Email.Smtp` or the new `OrchardCore.Email.Azure` (see notes below). The new `OrchardCore.Email.Smtp` module provides the same capabilities previously built into the `OrchardCore.Email` module, so for simple SMTP emails, enable that. + +Module authors who were using `ISmtpService` service previously should now use `IEmailDeliveryService` instead. + +**Migration Process** + +- If the email settings have been configured in the site settings: + 1. Copy the settings before the upgrade. + 2. Upgrade Orchard Core to the new version. + 3. Re-configure settings. Until you do this, the site won't be able to send e-mails and the old settings won't be loaded. +- If the email settings have been configured in the `appsettings.json` or other configuration provider: + 1. You can still use the old `OrchardCore_Email` section, but we encourage you to use `OrchardCore_Email_Smtp` section instead. + ### Drop Newtonsoft.Json support The utilization of [Newtonsoft.Json](https://www.nuget.org/packages/Newtonsoft.Json) has been discontinued in both **YesSql** and **OrchardCore**. Instead, we have transitioned to utilize `System.Text.Json` due to its enhanced performance capabilities. To ensure compatibility with `System.Text.Json` during the serialization or deserialization of objects, the following steps need to be undertaken: @@ -64,6 +79,11 @@ Additionally, `Twilio` provider is no longer enabled by default. If you want to ## Change Logs +### Azure Email Module + +Introducing a new "Azure Email" module, designed to send emails using Azure Email Communication Services (ACS). For more info read the [Azure Email](../reference/modules/Email.Azure/README.md) docs. + + ### Azure AI Search Module Introducing a new "Azure AI Search" module, designed to empower you in the administration of Azure AI Search indices. When enabled with the "Search" module, it facilitates frontend full-text search capabilities through Azure AI Search. For more info read the [Azure AI Search](../reference/modules/AzureAISearch/README.md) docs. @@ -81,6 +101,7 @@ Added new extensions to make registering custom deployment step easier: The method `Task TriggerEventAsync(string name, IDictionary input = null, string correlationId = null, bool isExclusive = false, bool isAlwaysCorrelated = false)` was changed to return `Task>` instead. + ### GraphQL Module When identifying content types for GraphQL exposure, we identify those without a stereotype to provide you with control over the behavior of stereotyped content types. A new option, `DiscoverableSterotypes`, has been introduced in `GraphQLContentOptions`. This allows you to specify stereotypes that should be discoverable by default. @@ -138,3 +159,9 @@ public class AdminMenu : INavigationProvider } } ``` + +### Smtp Email Module + +The "Smtp Email" module is a replacement for old Email module. For more info read the [Smtp Email](../reference/modules/Email.Smtp/README.md) docs. + +You can still use the old email module configuration, but it is recommended to use the new one. diff --git a/test/OrchardCore.Tests/Email/EmailTests.cs b/test/OrchardCore.Tests/Email/EmailTests.cs deleted file mode 100644 index d54cb406982..00000000000 --- a/test/OrchardCore.Tests/Email/EmailTests.cs +++ /dev/null @@ -1,278 +0,0 @@ -using MimeKit; -using OrchardCore.Email; -using OrchardCore.Email.Services; - -namespace OrchardCore.Tests.Email -{ - public class EmailTests - { - [Fact] - public async Task SendEmail_WithToHeader() - { - // Arrange - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message" - }; - - // Act - var content = await SendEmailAsync(message, "Your Name "); - - // Assert - Assert.Contains("From: Your Name ", content); - } - - [Fact] - public async Task SendEmail_WithCcHeader() - { - // Arrange - var message = new MailMessage - { - Cc = "info@oc.com", - Subject = "Test", - Body = "Test Message" - }; - - // Act - var content = await SendEmailAsync(message); - - // Assert - Assert.Contains("Cc: info@oc.com", content); - } - - [Fact] - public async Task SendEmail_WithBccHeader() - { - // Arrange - var message = new MailMessage - { - Bcc = "info@oc.com", - Subject = "Test", - Body = "Test Message" - }; - - // Act - var content = await SendEmailAsync(message); - - // Assert - Assert.Contains("Bcc: info@oc.com", content); - } - - [Fact] - public async Task SendEmail_WithDisplayName() - { - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message" - }; - - await SendEmailAsync(message, "Your Name "); - } - - [Fact] - public async Task SendEmail_UsesDefaultSender() - { - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message" - }; - var content = await SendEmailAsync(message, "Your Name "); - - Assert.Contains("From: Your Name ", content); - } - - [Fact] - public async Task SendEmail_UsesCustomSender() - { - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message", - From = "My Name ", - }; - var content = await SendEmailAsync(message, "Your Name "); - - Assert.Contains("From: My Name ", content); - Assert.Contains("Sender: Your Name ", content); - } - - [Fact] - public async Task SendEmail_UsesCustomAuthorAndSender() - { - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message", - Sender = "Hisham Bin Ateya ", - }; - var content = await SendEmailAsync(message, "Sebastien Ros "); - - Assert.Contains("From: Sebastien Ros ", content); - Assert.Contains("Sender: Hisham Bin Ateya ", content); - } - - [Fact] - public async Task SendEmail_UsesMultipleAuthors() - { - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message", - From = "sebastienros@gmail.com,hishamco_2007@hotmail.com" - }; - var content = await SendEmailAsync(message, "Hisham Bin Ateya "); - - Assert.Contains("From: sebastienros@gmail.com, hishamco_2007@hotmail.com", content); - Assert.Contains("Sender: Hisham Bin Ateya ", content); - } - - [Fact] - public async Task SendEmail_UsesReplyTo() - { - var message = new MailMessage - { - To = "Hisham Bin Ateya ", - Subject = "Test", - Body = "Test Message", - From = "Hisham Bin Ateya ", - ReplyTo = "Hisham Bin Ateya ", - }; - var content = await SendEmailAsync(message, "Your Name "); - - Assert.Contains("From: Hisham Bin Ateya ", content); - Assert.Contains("Reply-To: Hisham Bin Ateya ", content); - } - - [Fact] - public async Task ReplyTo_ShouldHaveAuthors_IfNotSet() - { - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message", - From = "Sebastien Ros " - }; - var content = await SendEmailAsync(message, "Your Name "); - - Assert.Contains("From: Sebastien Ros ", content); - Assert.Contains("Reply-To: Sebastien Ros ", content); - } - - [Theory] - [InlineData("me ", "me", "mailbox@domain.com")] - [InlineData("me", "me", "mailbox@domain.com")] - [InlineData("me ", "me", "mailbox@domain.com")] - [InlineData("", "", "mailbox@domain.com")] - [InlineData("mailbox@domain.com", "", "mailbox@domain.com")] - [InlineData("(comment)mailbox(comment)@(comment)domain.com(me) ", "me", "mailbox@domain.com")] - [InlineData("Sébastien ", "Sébastien", "sébastien@domain.com")] - public void MailBoxAddress_ShouldParseEmail(string text, string name, string address) - { - Assert.True(MailboxAddress.TryParse(text, out var mailboxAddress)); - Assert.Equal(name, mailboxAddress.Name); - Assert.Equal(address, mailboxAddress.Address); - } - - [Fact] - public async Task SendEmail_WithoutToAndCcAndBccHeaders_ShouldThrowsException() - { - // Arrange - var message = new MailMessage - { - Subject = "Test", - Body = "Test Message" - }; - var settings = new SmtpSettings - { - DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory - }; - - var smtp = CreateSmtpService(settings); - - // Act - var result = await smtp.SendAsync(message); - - // Assert - Assert.True(result.Errors.Any()); - } - - [Fact] - public async Task SendOfflineEmailHasNoResponse() - { - // Arrange - var message = new MailMessage - { - To = "info@oc.com", - Subject = "Test", - Body = "Test Message" - }; - var settings = new SmtpSettings - { - DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory - }; - - var smtp = CreateSmtpService(settings); - - // Act - var result = await smtp.SendAsync(message); - - // Assert - Assert.Null(result.Response); - } - - private static async Task SendEmailAsync(MailMessage message, string defaultSender = null) - { - var pickupDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), "Email"); - - if (Directory.Exists(pickupDirectoryPath)) - { - var directory = new DirectoryInfo(pickupDirectoryPath); - directory.GetFiles().ToList().ForEach(f => f.Delete()); - } - - Directory.CreateDirectory(pickupDirectoryPath); - - var settings = new SmtpSettings - { - DefaultSender = defaultSender, - DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory, - PickupDirectoryLocation = pickupDirectoryPath - }; - var smtp = CreateSmtpService(settings); - - var result = await smtp.SendAsync(message); - - Assert.True(result.Succeeded); - - var file = new DirectoryInfo(pickupDirectoryPath).GetFiles().FirstOrDefault(); - - Assert.NotNull(file); - - var content = File.ReadAllText(file.FullName); - - return content; - } - - private static SmtpService CreateSmtpService(SmtpSettings settings) - { - var options = new Mock>(); - options.Setup(o => o.Value).Returns(settings); - - var logger = new Mock>(); - var localizer = new Mock>(); - var smtp = new SmtpService(options.Object, logger.Object, localizer.Object); - - return smtp; - } - } -} diff --git a/test/OrchardCore.Tests/Email/NullEmailDeliveryServiceTests.cs b/test/OrchardCore.Tests/Email/NullEmailDeliveryServiceTests.cs new file mode 100644 index 00000000000..e39ae1a62eb --- /dev/null +++ b/test/OrchardCore.Tests/Email/NullEmailDeliveryServiceTests.cs @@ -0,0 +1,27 @@ +using OrchardCore.Email.Services; + +namespace OrchardCore.Email.Tests; + +public class NullEmailDeliveryServiceTests +{ + [Fact] + public async Task SendEmail() + { + // Arrange + var logger = NullLogger.Instance; + var localizer = Mock.Of>(); + var emailDeliveryService = new NullEmailDeliveryService(logger, localizer); + var message = new MailMessage + { + To = "test@orchardcore.net", + Subject = "Orchard Core", + Body = "This is a test message." + }; + + // Act + var result = await emailDeliveryService.DeliverAsync(message); + + // Assert + Assert.False(result.Succeeded); + } +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Email.Azure/Services/AzureEmailDeliveryServiceTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Email.Azure/Services/AzureEmailDeliveryServiceTests.cs new file mode 100644 index 00000000000..e62539385d8 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Email.Azure/Services/AzureEmailDeliveryServiceTests.cs @@ -0,0 +1,31 @@ +namespace OrchardCore.Email.Azure.Services.Tests; + +public class AzureEmailDeliveryServiceTests +{ + [Fact(Skip = "Configure the default sender and connection string for Email Communication Services (ECS) before run this test.")] + public async Task SendEmailShouldSucceed() + { + // Arrange + var emailOptions = Options.Create(new AzureEmailSettings + { + DefaultSender = "<>", + ConnectionString = "<>" + }); + var emailDeliveryService = new AzureEmailDeliveryService( + emailOptions, + Mock.Of>(), + Mock.Of>()); + var message = new MailMessage + { + To = "test@orchardcore.net", + Subject = "Orchard Core", + Body = "This is a test message." + }; + + // Act + var result = await emailDeliveryService.DeliverAsync(message); + + // Assert + Assert.True(result.Succeeded); + } +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Email.Smtp/SmtpEmailDeliveryServiceTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Email.Smtp/SmtpEmailDeliveryServiceTests.cs new file mode 100644 index 00000000000..891cc7055c9 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Email.Smtp/SmtpEmailDeliveryServiceTests.cs @@ -0,0 +1,249 @@ +using MimeKit; +using OrchardCore.Email; +using OrchardCore.Email.Smtp; +using OrchardCore.Email.Smtp.Services; + +namespace OrchardCore.Modules.Email.Smtp.Tests; + +public class SmtpEmailDeliveryServiceTests +{ + [Fact] + public async Task SendEmail_WithToHeader() + { + // Arrange + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message" + }; + + // Act + var content = await SendEmailAsync(message, "Your Name "); + + // Assert + Assert.Contains("From: Your Name ", content); + } + + [Fact] + public async Task SendEmail_WithCcHeader() + { + // Arrange + var message = new MailMessage + { + Cc = "info@oc.com", + Subject = "Test", + Body = "Test Message" + }; + + // Act + var content = await SendEmailAsync(message); + + // Assert + Assert.Contains("Cc: info@oc.com", content); + } + + [Fact] + public async Task SendEmail_WithBccHeader() + { + // Arrange + var message = new MailMessage + { + Bcc = "info@oc.com", + Subject = "Test", + Body = "Test Message" + }; + + // Act + var content = await SendEmailAsync(message); + + // Assert + Assert.Contains("Bcc: info@oc.com", content); + } + + [Fact] + public async Task SendEmail_WithDisplayName() + { + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message" + }; + + await SendEmailAsync(message, "Your Name "); + } + + [Fact] + public async Task SendEmail_UsesDefaultSender() + { + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message" + }; + var content = await SendEmailAsync(message, "Your Name "); + + Assert.Contains("From: Your Name ", content); + } + + [Fact] + public async Task SendEmail_UsesCustomSender() + { + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message", + From = "My Name ", + }; + var content = await SendEmailAsync(message, "Your Name "); + + Assert.Contains("From: My Name ", content); + Assert.Contains("Sender: Your Name ", content); + } + + [Fact] + public async Task SendEmail_UsesCustomAuthorAndSender() + { + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message", + Sender = "Hisham Bin Ateya ", + }; + var content = await SendEmailAsync(message, "Sebastien Ros "); + + Assert.Contains("From: Sebastien Ros ", content); + Assert.Contains("Sender: Hisham Bin Ateya ", content); + } + + [Fact] + public async Task SendEmail_UsesMultipleAuthors() + { + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message", + From = "sebastienros@gmail.com,hishamco_2007@hotmail.com" + }; + var content = await SendEmailAsync(message, "Hisham Bin Ateya "); + + Assert.Contains("From: sebastienros@gmail.com, hishamco_2007@hotmail.com", content); + Assert.Contains("Sender: Hisham Bin Ateya ", content); + } + + [Fact] + public async Task SendEmail_UsesReplyTo() + { + var message = new MailMessage + { + To = "Hisham Bin Ateya ", + Subject = "Test", + Body = "Test Message", + From = "Hisham Bin Ateya ", + ReplyTo = "Hisham Bin Ateya ", + }; + var content = await SendEmailAsync(message, "Your Name "); + + Assert.Contains("From: Hisham Bin Ateya ", content); + Assert.Contains("Reply-To: Hisham Bin Ateya ", content); + } + + [Fact] + public async Task ReplyTo_ShouldHaveAuthors_IfNotSet() + { + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message", + From = "Sebastien Ros " + }; + var content = await SendEmailAsync(message, "Your Name "); + + Assert.Contains("From: Sebastien Ros ", content); + Assert.Contains("Reply-To: Sebastien Ros ", content); + } + + [Theory] + [InlineData("me ", "me", "mailbox@domain.com")] + [InlineData("me", "me", "mailbox@domain.com")] + [InlineData("me ", "me", "mailbox@domain.com")] + [InlineData("", "", "mailbox@domain.com")] + [InlineData("mailbox@domain.com", "", "mailbox@domain.com")] + [InlineData("(comment)mailbox(comment)@(comment)domain.com(me) ", "me", "mailbox@domain.com")] + [InlineData("Sébastien ", "Sébastien", "sébastien@domain.com")] + public void MailBoxAddress_ShouldParseEmail(string text, string name, string address) + { + Assert.True(MailboxAddress.TryParse(text, out var mailboxAddress)); + Assert.Equal(name, mailboxAddress.Name); + Assert.Equal(address, mailboxAddress.Address); + } + + [Fact] + public async Task SendOfflineEmailHasNoResponse() + { + // Arrange + var message = new MailMessage + { + To = "info@oc.com", + Subject = "Test", + Body = "Test Message" + }; + var settings = new SmtpEmailSettings + { + DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory + }; + + var smtp = CreateSmtpEmailDeliveryService(settings); + + // Act + var result = await smtp.DeliverAsync(message); + + // Assert + Assert.Null((result as SmtpEmailResult).Response); + } + + private static async Task SendEmailAsync(MailMessage message, string defaultSender = null) + { + var pickupDirectoryPath = Path.Combine(Directory.GetCurrentDirectory(), "Email"); + + if (Directory.Exists(pickupDirectoryPath)) + { + var directory = new DirectoryInfo(pickupDirectoryPath); + directory.GetFiles().ToList().ForEach(f => f.Delete()); + } + + Directory.CreateDirectory(pickupDirectoryPath); + + var settings = new SmtpEmailSettings + { + DefaultSender = defaultSender, + DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory, + PickupDirectoryLocation = pickupDirectoryPath + }; + var smtp = CreateSmtpEmailDeliveryService(settings); + + var result = await smtp.DeliverAsync(message); + + Assert.True(result.Succeeded); + + var file = new DirectoryInfo(pickupDirectoryPath).GetFiles().FirstOrDefault(); + + Assert.NotNull(file); + + var content = File.ReadAllText(file.FullName); + + return content; + } + + private static SmtpEmailDeliveryService CreateSmtpEmailDeliveryService(SmtpEmailSettings settings) => new( + Options.Create(settings), + Mock.Of>(), + Mock.Of>() + ); +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Email/EmailValidatorTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Email/EmailValidatorTests.cs new file mode 100644 index 00000000000..f75ee373e95 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Email/EmailValidatorTests.cs @@ -0,0 +1,39 @@ +using OrchardCore.Email; +using OrchardCore.Email.Services; +using OrchardCore.Email.Smtp; +using OrchardCore.Email.Smtp.Services; + +namespace OrchardCore.Modules.Email.Smtp.Tests; + +public class EmailValidatorTests +{ + [Fact] + public async Task SendEmail_WithoutToAndCcAndBccHeaders_ShouldReturnErrors() + { + // Arrange + var message = new MailMessage + { + Subject = "Test", + Body = "Test Message" + }; + var settings = new SmtpEmailSettings + { + DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory + }; + + var smtp = CreateSmtpEmailDeliveryService(settings); + + // Act + var result = await smtp.DeliverAsync(message); + + // Assert + Assert.True(result.Errors.Any()); + Assert.NotEqual(EmailResult.Success, result); + } + + private static SmtpEmailDeliveryService CreateSmtpEmailDeliveryService(SmtpEmailSettings settings) => new( + Options.Create(settings), + Mock.Of>(), + Mock.Of>() + ); +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs index ea181507ea0..f5c002a2c0e 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Email/Workflows/EmailTaskTests.cs @@ -1,10 +1,13 @@ using OrchardCore.Email; +using OrchardCore.Email.Core.Services; +using OrchardCore.Email.Events; using OrchardCore.Email.Services; +using OrchardCore.Email.Smtp; using OrchardCore.Email.Workflows.Activities; using OrchardCore.Workflows.Models; using OrchardCore.Workflows.Services; -namespace OrchardCore.Tests.Modules.OrchardCore.Email.Workflows +namespace OrchardCore.Modules.Email.Workflows.Tests { public class EmailTaskTests { @@ -14,9 +17,19 @@ public class EmailTaskTests public async Task ExecuteTask_WhenToAndCcAndBccAreNotSet_ShouldFails() { // Arrange - var smtpService = CreateSmtpService(new SmtpSettings()); + var emailSettingsOptions = Options.Create(new SmtpEmailSettings()); + var emailMessageValidator = new EmailMessageValidator( + new EmailAddressValidator(), + emailSettingsOptions, + Mock.Of>()); + var emailDeliveryServiceResolver = new EmailDeliveryServiceResolver(Mock.Of()); + var emailService = new EmailService( + emailMessageValidator, + emailDeliveryServiceResolver, + Enumerable.Empty(), + NullLogger.Instance); var task = new EmailTask( - smtpService, + emailService, new SimpleWorkflowExpressionEvaluator(), Mock.Of>(), HtmlEncoder.Default) @@ -43,18 +56,6 @@ public async Task ExecuteTask_WhenToAndCcAndBccAreNotSet_ShouldFails() Assert.Equal("Failed", result.Outcomes.First()); } - private static ISmtpService CreateSmtpService(SmtpSettings settings) - { - var options = new Mock>(); - var logger = new Mock>(); - var localizer = new Mock>(); - var smtp = new SmtpService(options.Object, logger.Object, localizer.Object); - - options.Setup(o => o.Value).Returns(settings); - - return smtp; - } - private class SimpleWorkflowExpressionEvaluator : IWorkflowExpressionEvaluator { public async Task EvaluateAsync(WorkflowExpression expression, WorkflowExecutionContext workflowContext, TextEncoder encoder) diff --git a/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs b/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs index de95ccf00f3..9b78f73f92b 100644 --- a/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs +++ b/test/OrchardCore.Tests/OrchardCore.Users/RegistrationControllerTests.cs @@ -1,6 +1,7 @@ using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.Notify; using OrchardCore.Email; +using OrchardCore.Email.Services; using OrchardCore.Settings; using OrchardCore.Tests.Utilities; using OrchardCore.Users; @@ -140,7 +141,11 @@ private static RegistrationController SetupRegistrationController(RegistrationSe var mockSite = SiteMockHelper.GetSite(registrationSettings); var mockSiteService = Mock.Of(ss => ss.GetSiteSettingsAsync() == Task.FromResult(mockSite.Object)); - var mockSmtpService = Mock.Of(x => x.SendAsync(It.IsAny()) == Task.FromResult(SmtpResult.Success)); + var mockEmailService = new Mock(); + mockEmailService + .Setup(emailService => emailService.SendAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(EmailResult.Success)); + var mockStringLocalizer = new Mock>(); mockStringLocalizer.Setup(l => l[It.IsAny()]) .Returns(s => new LocalizedString(s, s)); @@ -176,8 +181,8 @@ private static RegistrationController SetupRegistrationController(RegistrationSe var mockServiceProvider = new Mock(); mockServiceProvider - .Setup(x => x.GetService(typeof(ISmtpService))) - .Returns(mockSmtpService); + .Setup(x => x.GetService(typeof(IEmailService))) + .Returns(mockEmailService.Object); mockServiceProvider .Setup(x => x.GetService(typeof(UserManager))) .Returns(mockUserManager.Object);