diff --git a/README.md b/README.md index 0a187e1..6174c00 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This repository offers a wide collection of .NET packages for use in microservic - [Kubernetes Insights](#Insights) - [Kubernetes Health Checks](#Health-Checks) - [NHibernate](#NHibernate) +- [Notifications](#Notifications) ## RabbitMQ @@ -272,3 +273,50 @@ To mock IQueryable, in your code, call: var entity = new Entity(); repository.GetQueryable().Returns(new TestQueryable(entity)); ``` + +## Notifications + +To use platform senders for notifications, first install the [NuGet package](https://www.nuget.org/packages/Luxoft.Bss.Platform.Notifications): +```shell +dotnet add package Luxoft.Bss.Platform.Notifications +``` + +Then register notifications service in DI +```C# +services + .AddPlatformNotifications(builder.Environment, builder.Configuration) +``` + +Then fill configuration settings: +```json +{ + "NotificationSender": { + "Server": "smtp.server.com", -- smtp server host + "RedirectTo": ["test@email.com"] -- this address will be used as single recipient for all messages on non-prod environments + } +} +``` + +Now you can send messages to smtp server: +```C# +public class YourNotificationRequestHandler(IEmailSender sender) : IRequestHandler +{ + public async Task Handle(YourNotificationRequest request, CancellationToken cancellationToken) + { + var attachment = new Attachment(new MemoryStream(), request.AttachmentName); + attachment.ContentDisposition!.Inline = true; + + var message = new EmailModel( + request.Subject, + request.Body, + new MailAddress(request.From), + new[] { new MailAddress(request.To) }, + Attachments: new[] { attachment }); + + await sender.SendAsync(message, token); + } +} +``` + +> [!NOTE] +> Note that attachment will be inlined only if its 'Inline' field is true and its name is referred as image source in message body. \ No newline at end of file diff --git a/src/Bss.Platform.Notifications/Bss.Platform.Notifications.csproj b/src/Bss.Platform.Notifications/Bss.Platform.Notifications.csproj new file mode 100644 index 0000000..644e577 --- /dev/null +++ b/src/Bss.Platform.Notifications/Bss.Platform.Notifications.csproj @@ -0,0 +1,9 @@ + + + Luxoft.Bss.Platform.Notifications + + + + + + diff --git a/src/Bss.Platform.Notifications/DependencyInjection.cs b/src/Bss.Platform.Notifications/DependencyInjection.cs new file mode 100644 index 0000000..1dedfa9 --- /dev/null +++ b/src/Bss.Platform.Notifications/DependencyInjection.cs @@ -0,0 +1,58 @@ +using Bss.Platform.Notifications.Interfaces; +using Bss.Platform.Notifications.Models; +using Bss.Platform.Notifications.Services; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Bss.Platform.Notifications; + +public static class DependencyInjection +{ + public static IServiceCollection AddPlatformNotifications( + this IServiceCollection services, + IHostEnvironment hostEnvironment, + IConfiguration configuration) + { + var settings = configuration.GetSection(NotificationSenderOptions.SectionName).Get()!; + + return AddTestEnvironmentRedirection(services, hostEnvironment, settings) + .AddMailMessageSenders(settings) + .Configure(configuration.GetSection(NotificationSenderOptions.SectionName)) + .AddScoped(); + } + + private static IServiceCollection AddTestEnvironmentRedirection( + this IServiceCollection services, + IHostEnvironment hostEnvironment, + NotificationSenderOptions settings) + { + if (hostEnvironment.IsProduction()) + { + return services; + } + + if (settings.RedirectTo?.Length == 0) + { + throw new ArgumentException("Test email address is not provided"); + } + + return services.AddScoped(); + } + + private static IServiceCollection AddMailMessageSenders(this IServiceCollection services, NotificationSenderOptions settings) + { + if (settings.IsSmtpEnabled) + { + services.AddScoped(); + } + + if (!string.IsNullOrWhiteSpace(settings.OutputFolder)) + { + services.AddScoped(); + } + + return services; + } +} diff --git a/src/Bss.Platform.Notifications/Interfaces/IEmailSender.cs b/src/Bss.Platform.Notifications/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..e8a4daf --- /dev/null +++ b/src/Bss.Platform.Notifications/Interfaces/IEmailSender.cs @@ -0,0 +1,10 @@ +using System.Net.Mail; + +using Bss.Platform.Notifications.Models; + +namespace Bss.Platform.Notifications.Interfaces; + +public interface IEmailSender +{ + Task SendAsync(EmailModel model, CancellationToken token); +} diff --git a/src/Bss.Platform.Notifications/Interfaces/IMailMessageSender.cs b/src/Bss.Platform.Notifications/Interfaces/IMailMessageSender.cs new file mode 100644 index 0000000..762f2b7 --- /dev/null +++ b/src/Bss.Platform.Notifications/Interfaces/IMailMessageSender.cs @@ -0,0 +1,8 @@ +using System.Net.Mail; + +namespace Bss.Platform.Notifications.Interfaces; + +public interface IMailMessageSender +{ + Task SendAsync(MailMessage message, CancellationToken token); +} diff --git a/src/Bss.Platform.Notifications/Interfaces/IRedirectService.cs b/src/Bss.Platform.Notifications/Interfaces/IRedirectService.cs new file mode 100644 index 0000000..6357d19 --- /dev/null +++ b/src/Bss.Platform.Notifications/Interfaces/IRedirectService.cs @@ -0,0 +1,8 @@ +using System.Net.Mail; + +namespace Bss.Platform.Notifications.Interfaces; + +public interface IRedirectService +{ + void Redirect(MailMessage message); +} diff --git a/src/Bss.Platform.Notifications/Models/EmailModel.cs b/src/Bss.Platform.Notifications/Models/EmailModel.cs new file mode 100644 index 0000000..831a557 --- /dev/null +++ b/src/Bss.Platform.Notifications/Models/EmailModel.cs @@ -0,0 +1,12 @@ +using System.Net.Mail; + +namespace Bss.Platform.Notifications.Models; + +public record EmailModel( + string Subject, + string Body, + MailAddress From, + MailAddress[] To, + MailAddress[]? Cc = null, + MailAddress[]? ReplyTo = null, + Attachment[]? Attachments = null); diff --git a/src/Bss.Platform.Notifications/Models/NotificationSenderOptions.cs b/src/Bss.Platform.Notifications/Models/NotificationSenderOptions.cs new file mode 100644 index 0000000..c4d3b02 --- /dev/null +++ b/src/Bss.Platform.Notifications/Models/NotificationSenderOptions.cs @@ -0,0 +1,16 @@ +namespace Bss.Platform.Notifications.Models; + +public class NotificationSenderOptions +{ + public const string SectionName = "NotificationSender"; + + public bool IsSmtpEnabled { get; set; } = true; + + public string? OutputFolder { get; set; } + + public string Server { get; set; } = default!; + + public int Port { get; set; } = 25; + + public string[]? RedirectTo { get; set; } +} diff --git a/src/Bss.Platform.Notifications/Services/EmailSender.cs b/src/Bss.Platform.Notifications/Services/EmailSender.cs new file mode 100644 index 0000000..8f6f1a5 --- /dev/null +++ b/src/Bss.Platform.Notifications/Services/EmailSender.cs @@ -0,0 +1,76 @@ +using System.Net.Mail; +using System.Text.RegularExpressions; + +using Bss.Platform.Notifications.Interfaces; +using Bss.Platform.Notifications.Models; + +namespace Bss.Platform.Notifications.Services; + +internal class EmailSender(IEnumerable senders, IRedirectService? redirectService = null) : IEmailSender +{ + public async Task SendAsync(EmailModel model, CancellationToken token) + { + var message = Convert(model); + + redirectService?.Redirect(message); + + foreach (var sender in senders) + { + await sender.SendAsync(message, token); + } + + return message; + } + + private static MailMessage Convert(EmailModel model) + { + var mailMessage = new MailMessage { Subject = model.Subject, Body = model.Body, From = model.From, IsBodyHtml = true }; + + AddRange(mailMessage.To, model.To); + + if (model.Cc?.Length > 0) + { + AddRange(mailMessage.CC, model.Cc); + } + + if (model.ReplyTo?.Length > 0) + { + AddRange(mailMessage.ReplyToList, model.ReplyTo); + } + + if (model.Attachments?.Length > 0) + { + SetAttachments(model.Attachments, mailMessage); + } + + return mailMessage; + } + + private static void SetAttachments(Attachment[] attachments, MailMessage mailMessage) + { + foreach (var attachment in attachments) + { + mailMessage.Attachments.Add(attachment); + if (!attachment.ContentDisposition!.Inline) + { + continue; + } + + var srcRegex = $"src\\s*=\\s*\"{attachment.Name}\""; + if (!Regex.IsMatch(mailMessage.Body, srcRegex, RegexOptions.IgnoreCase)) + { + continue; + } + + mailMessage.Body = Regex.Replace(mailMessage.Body, srcRegex, $"src=\"cid:{attachment.ContentId}\"", RegexOptions.IgnoreCase); + } + } + + private static void AddRange(MailAddressCollection collection, IEnumerable addresses) + { + foreach (var address in addresses) + { + collection.Add(address); + } + } +} diff --git a/src/Bss.Platform.Notifications/Services/FileSender.cs b/src/Bss.Platform.Notifications/Services/FileSender.cs new file mode 100644 index 0000000..67cd85e --- /dev/null +++ b/src/Bss.Platform.Notifications/Services/FileSender.cs @@ -0,0 +1,25 @@ +using System.Net.Mail; + +using Bss.Platform.Notifications.Interfaces; +using Bss.Platform.Notifications.Models; + +using Microsoft.Extensions.Options; + +namespace Bss.Platform.Notifications.Services; + +internal class FileSender(IOptions settings) : IMailMessageSender +{ + public async Task SendAsync(MailMessage message, CancellationToken token) + { + using var client = this.GetSmtpClient(); + await client.SendMailAsync(message, token); + } + + private SmtpClient GetSmtpClient() => + new() + { + UseDefaultCredentials = true, + DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory, + PickupDirectoryLocation = settings.Value.OutputFolder + }; +} diff --git a/src/Bss.Platform.Notifications/Services/RedirectService.cs b/src/Bss.Platform.Notifications/Services/RedirectService.cs new file mode 100644 index 0000000..5d3c1cf --- /dev/null +++ b/src/Bss.Platform.Notifications/Services/RedirectService.cs @@ -0,0 +1,42 @@ +using System.Net.Mail; + +using Bss.Platform.Notifications.Interfaces; +using Bss.Platform.Notifications.Models; + +using Microsoft.Extensions.Options; + +namespace Bss.Platform.Notifications.Services; + +internal class RedirectService(IOptions settings) : IRedirectService +{ + public void Redirect(MailMessage message) + { + AddRecipientsToBody(message); + + ClearRecipients(message); + + foreach (var address in settings.Value.RedirectTo!.Select(z => z.Trim()).Distinct().Select(z => new MailAddress(z))) + { + message.To.Add(address); + } + } + + private static void ClearRecipients(MailMessage message) + { + message.To.Clear(); + message.CC.Clear(); + message.Bcc.Clear(); + message.ReplyToList.Clear(); + } + + private static void AddRecipientsToBody(MailMessage message) + { + var originalRecipients = + $"From: {message.From!.Address}
" + + $"To: {string.Join("; ", message.To.Select(x => x.Address))}
" + + $"CC: {string.Join("; ", message.CC.Select(x => x.Address))}
" + + $"Reply To: {string.Join("; ", message.ReplyToList.Select(x => x.Address))}

"; + + message.Body = $"{originalRecipients}{message.Body}"; + } +} diff --git a/src/Bss.Platform.Notifications/Services/SmtpSender.cs b/src/Bss.Platform.Notifications/Services/SmtpSender.cs new file mode 100644 index 0000000..6809d57 --- /dev/null +++ b/src/Bss.Platform.Notifications/Services/SmtpSender.cs @@ -0,0 +1,19 @@ +using System.Net.Mail; + +using Bss.Platform.Notifications.Interfaces; +using Bss.Platform.Notifications.Models; + +using Microsoft.Extensions.Options; + +namespace Bss.Platform.Notifications.Services; + +internal class SmtpSender(IOptions settings) : IMailMessageSender +{ + public async Task SendAsync(MailMessage message, CancellationToken token) + { + using var client = this.GetSmtpClient(); + await client.SendMailAsync(message, token); + } + + private SmtpClient GetSmtpClient() => new(settings.Value.Server, settings.Value.Port) { UseDefaultCredentials = true }; +} diff --git a/src/Bss.Platform.sln b/src/Bss.Platform.sln index 8ae9840..b08b6aa 100644 --- a/src/Bss.Platform.sln +++ b/src/Bss.Platform.sln @@ -34,6 +34,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bss.Platform.Events", "Bss. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bss.Platform.Events.Abstractions", "Bss.Platform.Events.Abstractions\Bss.Platform.Events.Abstractions.csproj", "{BE2E219F-3F3B-48E8-B13B-A71321BB6372}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bss.Platform.Notifications", "Bss.Platform.Notifications\Bss.Platform.Notifications.csproj", "{A97421A9-58B7-4948-8231-2028B5D11F36}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Notifications", "Notifications", "{94640F4D-8BC6-407F-835E-73B454972CAD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -50,6 +54,7 @@ Global {285EBB7B-6B2A-453E-98F6-30AC38317A76} = {8F22FE4E-8FD4-4309-8B47-C33AA6E52052} {51668BE6-C4A4-4E62-9BEC-529C33A434DB} = {80AA8C01-842D-4A5F-BCBE-48E0361783A0} {BE2E219F-3F3B-48E8-B13B-A71321BB6372} = {80AA8C01-842D-4A5F-BCBE-48E0361783A0} + {A97421A9-58B7-4948-8231-2028B5D11F36} = {94640F4D-8BC6-407F-835E-73B454972CAD} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {860BFBD8-26EA-44F9-980E-21B828FC8F72}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -92,5 +97,9 @@ Global {BE2E219F-3F3B-48E8-B13B-A71321BB6372}.Debug|Any CPU.Build.0 = Debug|Any CPU {BE2E219F-3F3B-48E8-B13B-A71321BB6372}.Release|Any CPU.ActiveCfg = Release|Any CPU {BE2E219F-3F3B-48E8-B13B-A71321BB6372}.Release|Any CPU.Build.0 = Release|Any CPU + {A97421A9-58B7-4948-8231-2028B5D11F36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A97421A9-58B7-4948-8231-2028B5D11F36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A97421A9-58B7-4948-8231-2028B5D11F36}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A97421A9-58B7-4948-8231-2028B5D11F36}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/__SolutionItems/CommonAssemblyInfo.cs b/src/__SolutionItems/CommonAssemblyInfo.cs index 5779d70..943fdf8 100644 --- a/src/__SolutionItems/CommonAssemblyInfo.cs +++ b/src/__SolutionItems/CommonAssemblyInfo.cs @@ -4,9 +4,9 @@ [assembly: AssemblyCompany("Luxoft")] [assembly: AssemblyCopyright("Copyright © Luxoft 2024")] -[assembly: AssemblyVersion("1.4.0.0")] -[assembly: AssemblyFileVersion("1.4.0.0")] -[assembly: AssemblyInformationalVersion("1.4.0.0")] +[assembly: AssemblyVersion("1.5.0.0")] +[assembly: AssemblyFileVersion("1.5.0.0")] +[assembly: AssemblyInformationalVersion("1.5.0.0")] #if DEBUG [assembly: AssemblyConfiguration("Debug")]