Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notification audit package - log sent notifications #14

Merged
merged 6 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This repository offers a wide collection of .NET packages for use in microservic
- [Kubernetes Health Checks](#Health-Checks)
- [NHibernate](#NHibernate)
- [Notifications](#Notifications)
- [Notifications Audit](#Notifications-Audit)

## RabbitMQ

Expand Down Expand Up @@ -319,4 +320,21 @@ public class YourNotificationRequestHandler(IEmailSender sender) : IRequestHandl
```

> [!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.
> Attachment will be added to notification only if:
> - it is not inlined
> - it is inlined and referred by name as image source in notification text

### Notifications Audit

To audit sent notifications, first install the [NuGet package](https://www.nuget.org/packages/Luxoft.Bss.Platform.Notifications.Audit):
```shell
dotnet add package Luxoft.Bss.Platform.Notifications.Audit
```

Then register notifications service in DI with provided sql connection
```C#
services
.AddPlatformNotificationsAudit(o => o.ConnectionString = builder.Configuration.GetConnectionString("DefaultConnection")!);
```

Thats all - db schema and tables will be generated on application start (you can customize schema and table names on DI step).
9 changes: 7 additions & 2 deletions src/Bss.Platform.Api.Documentation/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;

using Swashbuckle.AspNetCore.SwaggerGen;

namespace Bss.Platform.Api.Documentation;

public static class DependencyInjection
Expand All @@ -15,7 +17,8 @@ public static class DependencyInjection
public static IServiceCollection AddPlatformApiDocumentation(
this IServiceCollection services,
IWebHostEnvironment hostEnvironment,
string title = "API")
string title = "API",
Action<SwaggerGenOptions>? setupAction = null)
{
if (hostEnvironment.IsProduction())
{
Expand All @@ -27,8 +30,8 @@ public static IServiceCollection AddPlatformApiDocumentation(
.AddSwaggerGen(
x =>
{
x.SchemaFilter<XEnumNamesSchemaFilter>();
x.SwaggerDoc("api", new OpenApiInfo { Title = title });
x.SchemaFilter<XEnumNamesSchemaFilter>();

x.AddSecurityDefinition(
AuthorizationScheme,
Expand All @@ -52,6 +55,8 @@ public static IServiceCollection AddPlatformApiDocumentation(
new List<string>()
}
});

setupAction?.Invoke(x);
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackageId>Luxoft.Bss.Platform.Notifications.Audit</PackageId>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Bss.Platform.Notifications\Bss.Platform.Notifications.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper"/>
<PackageReference Include="Microsoft.SqlServer.SqlManagementObjects"/>
</ItemGroup>
</Project>
24 changes: 24 additions & 0 deletions src/Bss.Platform.Notifications.Audit/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Bss.Platform.Notifications.Audit.Models;
using Bss.Platform.Notifications.Audit.Services;
using Bss.Platform.Notifications.Interfaces;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Bss.Platform.Notifications.Audit;

public static class DependencyInjection
{
public static IServiceCollection AddPlatformNotificationsAudit(
this IServiceCollection services,
Action<NotificationAuditOptions>? setup = null)
{
var settings = new NotificationAuditOptions();
setup?.Invoke(settings);

return services
.AddHostedService<AuditSchemaMigrationService>()
.AddScoped<IAuditService, AuditService>()
.AddSingleton<IOptions<NotificationAuditOptions>>(_ => Options.Create(settings));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Bss.Platform.Notifications.Audit.Models;

public class NotificationAuditOptions
{
public string Schema { get; set; } = "notifications";

public string Table { get; set; } = "SentMessages";

public string ConnectionString { get; set; } = default!;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using Bss.Platform.Notifications.Audit.Models;

using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SqlServer.Management.Common;
using Microsoft.SqlServer.Management.Smo;

namespace Bss.Platform.Notifications.Audit.Services;

public class AuditSchemaMigrationService(ILogger<AuditSchemaMigrationService> logger, IOptions<NotificationAuditOptions> settings)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await using var connection = new SqlConnection(settings.Value.ConnectionString);
await connection.OpenAsync(stoppingToken);

var server = new Server(new ServerConnection(connection));
var catalog = server.ConnectionContext.CurrentDatabase;

if (string.IsNullOrWhiteSpace(catalog) || !server.Databases.Contains(catalog))
{
throw new ArgumentException("Initial catalog not provided or does not exist");
}

var database = server.Databases[catalog];

if (!database.Tables.Contains(settings.Value.Table, settings.Value.Schema))
{
if (!database.Schemas.Contains(settings.Value.Schema))
{
logger.LogInformation("Creating schema [{Schema}] ...", settings.Value.Schema);
server.ConnectionContext.ExecuteNonQuery($"CREATE SCHEMA [{settings.Value.Schema}]");
}

logger.LogInformation("Creating table [{Table}] ...", settings.Value.Table);
server.ConnectionContext.ExecuteNonQuery(
$"""
CREATE TABLE [{settings.Value.Schema}].[{settings.Value.Table}] (
[id] [uniqueidentifier] NOT NULL PRIMARY KEY,
[from] [nvarchar](255) NOT NULL,
[to] [nvarchar](max) NULL,
[copy] [nvarchar](max) NULL,
[replyTo] [nvarchar](max) NULL,
[subject] [nvarchar](max) NULL,
[message] [nvarchar](max) NULL,
[timestamp] [datetime2](7) NOT NULL)
""");
}

await connection.CloseAsync();

logger.LogInformation("Schema migration successfully complete");
}
catch (Exception e)
{
logger.LogError(e, "Schema migration was failed");
throw;
}
}
}
47 changes: 47 additions & 0 deletions src/Bss.Platform.Notifications.Audit/Services/AuditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using System.Net.Mail;

using Bss.Platform.Notifications.Audit.Models;
using Bss.Platform.Notifications.Interfaces;

using Dapper;

using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Bss.Platform.Notifications.Audit.Services;

public class AuditService(ILogger<AuditService> logger, IOptions<NotificationAuditOptions> settings) : IAuditService
{
private const string Sql = """
insert into [{0}].[{1}]
([id], [from], [to], [copy], [replyTo], [subject], [message], [timestamp])
values
(newid(), @from, @to, @copy, @replyTo, @subject, @message, getdate())
""";

public async Task LogAsync(MailMessage message, CancellationToken token)
{
try
{
await using var db = new SqlConnection(settings.Value.ConnectionString);
await db.OpenAsync(token);

await db.ExecuteAsync(
string.Format(Sql, settings.Value.Schema, settings.Value.Table),
new
{
from = message.From!.Address,
to = string.Join(";", message.To.Select(x => x.Address)),
copy = string.Join(";", message.CC.Select(x => x.Address)),
replyTo = string.Join(";", message.ReplyToList.Select(x => x.Address)),
subject = message.Subject,
message = message.Body
});
}
catch (Exception e)
{
logger.LogError(e, "Failed to log sent message");
}
}
}
13 changes: 6 additions & 7 deletions src/Bss.Platform.Notifications/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,27 @@ public static IServiceCollection AddPlatformNotifications(
{
var settings = configuration.GetSection(NotificationSenderOptions.SectionName).Get<NotificationSenderOptions>()!;

return AddTestEnvironmentRedirection(services, hostEnvironment, settings)
.AddMailMessageSenders(settings)
.Configure<NotificationSenderOptions>(configuration.GetSection(NotificationSenderOptions.SectionName))
.AddScoped<IEmailSender, EmailSender>();
return services.AddEmailSender(hostEnvironment, settings)
.AddMailMessageSenders(settings)
.Configure<NotificationSenderOptions>(configuration.GetSection(NotificationSenderOptions.SectionName));
}

private static IServiceCollection AddTestEnvironmentRedirection(
private static IServiceCollection AddEmailSender(
this IServiceCollection services,
IHostEnvironment hostEnvironment,
NotificationSenderOptions settings)
{
if (hostEnvironment.IsProduction())
{
return services;
return services.AddScoped<IEmailSender, EmailSender>();
}

if (settings.RedirectTo?.Length == 0)
{
throw new ArgumentException("Test email address is not provided");
}

return services.AddScoped<IRedirectService, RedirectService>();
return services.AddScoped<IEmailSender, EmailSenderTest>();
}

private static IServiceCollection AddMailMessageSenders(this IServiceCollection services, NotificationSenderOptions settings)
Expand Down
8 changes: 8 additions & 0 deletions src/Bss.Platform.Notifications/Interfaces/IAuditService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Net.Mail;

namespace Bss.Platform.Notifications.Interfaces;

public interface IAuditService
{
Task LogAsync(MailMessage message, CancellationToken token);
}
8 changes: 0 additions & 8 deletions src/Bss.Platform.Notifications/Interfaces/IRedirectService.cs

This file was deleted.

18 changes: 11 additions & 7 deletions src/Bss.Platform.Notifications/Services/EmailSender.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@

namespace Bss.Platform.Notifications.Services;

internal class EmailSender(IEnumerable<IMailMessageSender> senders, IRedirectService? redirectService = null) : IEmailSender
internal class EmailSender(IEnumerable<IMailMessageSender> senders, IAuditService? auditService = null) : IEmailSender
{
public async Task<MailMessage> SendAsync(EmailModel model, CancellationToken token)
public async Task<MailMessage> SendAsync(EmailModel emailModel, CancellationToken token)
{
var message = Convert(model);

redirectService?.Redirect(message);
var message = this.Convert(emailModel);

foreach (var sender in senders)
{
await sender.SendAsync(message, token);
}

if (auditService is not null)
{
await auditService.LogAsync(message, token);
}

return message;
}

private static MailMessage Convert(EmailModel model)
protected virtual MailMessage Convert(EmailModel model)
{
var mailMessage = new MailMessage { Subject = model.Subject, Body = model.Body, From = model.From, IsBodyHtml = true };

Expand Down Expand Up @@ -50,9 +53,9 @@ private static void SetAttachments(Attachment[] attachments, MailMessage mailMes
{
foreach (var attachment in attachments)
{
mailMessage.Attachments.Add(attachment);
if (!attachment.ContentDisposition!.Inline)
{
mailMessage.Attachments.Add(attachment);
continue;
}

Expand All @@ -63,6 +66,7 @@ private static void SetAttachments(Attachment[] attachments, MailMessage mailMes
}

mailMessage.Body = Regex.Replace(mailMessage.Body, srcRegex, $"src=\"cid:{attachment.ContentId}\"", RegexOptions.IgnoreCase);
mailMessage.Attachments.Add(attachment);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,21 @@

namespace Bss.Platform.Notifications.Services;

internal class RedirectService(IOptions<NotificationSenderOptions> settings) : IRedirectService
internal class EmailSenderTest(
IEnumerable<IMailMessageSender> senders,
IOptions<NotificationSenderOptions> settings,
IAuditService? auditService = null)
: EmailSender(senders, auditService)
{
public void Redirect(MailMessage message)
protected override MailMessage Convert(EmailModel model)
{
var message = base.Convert(model);
this.ChangeRecipients(message);

return message;
}

private void ChangeRecipients(MailMessage message)
{
AddRecipientsToBody(message);

Expand All @@ -21,14 +33,6 @@ public void Redirect(MailMessage message)
}
}

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 =
Expand All @@ -39,4 +43,12 @@ private static void AddRecipientsToBody(MailMessage message)

message.Body = $"{originalRecipients}{message.Body}";
}

private static void ClearRecipients(MailMessage message)
{
message.To.Clear();
message.CC.Clear();
message.Bcc.Clear();
message.ReplyToList.Clear();
}
}
Loading