Skip to content
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
110 changes: 83 additions & 27 deletions src/BuildingBlocks/Mailing/Services/SmtpMailService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using MailKit.Security;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
Expand All @@ -14,68 +14,124 @@
public async Task SendAsync(MailRequest request, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
ValidateSmtpConfiguration();

using var email = BuildMimeMessage(request);
await AddAttachmentsAsync(email, request, ct);
await SendEmailAsync(email, ct);
}

private void ValidateSmtpConfiguration()
{
if (_settings.Smtp?.Host is null)
{
throw new InvalidOperationException("SMTP Host is not configured.");
}
}

using var email = new MimeMessage();
private MimeMessage BuildMimeMessage(MailRequest request)
{
var email = new MimeMessage();

ConfigureSender(email, request);
ConfigureRecipients(email, request);
ConfigureContent(email, request);

// From
return email;
}

private void ConfigureSender(MimeMessage email, MailRequest request)
{
email.From.Add(new MailboxAddress(_settings.DisplayName, request.From ?? _settings.From));
email.Sender = new MailboxAddress(request.DisplayName ?? _settings.DisplayName, request.From ?? _settings.From);
}

// To
private void ConfigureRecipients(MimeMessage email, MailRequest request)

Check warning on line 49 in src/BuildingBlocks/Mailing/Services/SmtpMailService.cs

View workflow job for this annotation

GitHub Actions / Build

Member 'ConfigureRecipients' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 49 in src/BuildingBlocks/Mailing/Services/SmtpMailService.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'ConfigureRecipients' a static method. (https://rules.sonarsource.com/csharp/RSPEC-2325)

Check warning on line 49 in src/BuildingBlocks/Mailing/Services/SmtpMailService.cs

View workflow job for this annotation

GitHub Actions / Build

Member 'ConfigureRecipients' does not access instance data and can be marked as static (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1822)

Check warning on line 49 in src/BuildingBlocks/Mailing/Services/SmtpMailService.cs

View workflow job for this annotation

GitHub Actions / Build

Make 'ConfigureRecipients' a static method. (https://rules.sonarsource.com/csharp/RSPEC-2325)
{
foreach (string address in request.To)
{
email.To.Add(MailboxAddress.Parse(address));
}

// Reply To
if (!string.IsNullOrEmpty(request.ReplyTo))
{
email.ReplyTo.Add(new MailboxAddress(request.ReplyToName, request.ReplyTo));
}

AddBccRecipients(email, request);
AddCcRecipients(email, request);
AddHeaders(email, request);
}

// Bcc
if (request.Bcc != null && request.Bcc.Count > 0)
private static void AddBccRecipients(MimeMessage email, MailRequest request)
{
if (request.Bcc is null || request.Bcc.Count == 0)
{
foreach (string address in request.Bcc.Where(bccValue => !string.IsNullOrWhiteSpace(bccValue)))
email.Bcc.Add(MailboxAddress.Parse(address.Trim()));
return;
}

// Cc
if (request.Cc != null && request.Cc.Count > 0)
foreach (string address in request.Bcc.Where(bcc => !string.IsNullOrWhiteSpace(bcc)))
{
foreach (string? address in request.Cc.Where(ccValue => !string.IsNullOrWhiteSpace(ccValue)))
email.Cc.Add(MailboxAddress.Parse(address.Trim()));
email.Bcc.Add(MailboxAddress.Parse(address.Trim()));
}
}

// Headers
if (request.Headers != null)
private static void AddCcRecipients(MimeMessage email, MailRequest request)
{
if (request.Cc is null || request.Cc.Count == 0)
{
foreach (var header in request.Headers)
email.Headers.Add(header.Key, header.Value);
return;
}

// Content
var builder = new BodyBuilder();
email.Sender = new MailboxAddress(request.DisplayName ?? _settings.DisplayName, request.From ?? _settings.From);
foreach (string? address in request.Cc.Where(cc => !string.IsNullOrWhiteSpace(cc)))
{
email.Cc.Add(MailboxAddress.Parse(address.Trim()));
}
}

private static void AddHeaders(MimeMessage email, MailRequest request)
{
if (request.Headers is null)
{
return;
}

foreach (var header in request.Headers)
{
email.Headers.Add(header.Key, header.Value);
}
}

private static void ConfigureContent(MimeMessage email, MailRequest request)
{
email.Subject = request.Subject;
builder.HtmlBody = request.Body;
}

// Create the file attachments for this e-mail message
if (request.AttachmentData != null)
private static async Task AddAttachmentsAsync(MimeMessage email, MailRequest request, CancellationToken ct)
{
var builder = new BodyBuilder { HtmlBody = request.Body };

if (request.AttachmentData is not null)
{
foreach (var attachmentInfo in request.AttachmentData)
foreach (var attachment in request.AttachmentData)
{
using var stream = new MemoryStream();
await stream.WriteAsync(attachmentInfo.Value, ct);
await stream.WriteAsync(attachment.Value, ct);
stream.Position = 0;
await builder.Attachments.AddAsync(attachmentInfo.Key, stream, ct);
await builder.Attachments.AddAsync(attachment.Key, stream, ct);
}
}

email.Body = builder.ToMessageBody();
}

private async Task SendEmailAsync(MimeMessage email, CancellationToken ct)
{
using var client = new SmtpClient();

try
{
await client.ConnectAsync(_settings.Smtp.Host, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct);
await client.ConnectAsync(_settings.Smtp!.Host, _settings.Smtp.Port, SecureSocketOptions.StartTls, ct);
await client.AuthenticateAsync(_settings.Smtp.UserName, _settings.Smtp.Password, ct);
await client.SendAsync(email, ct);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Modules.Auditing/AuditHttpMiddleware.cs
using FSH.Modules.Auditing.Contracts;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -25,94 +24,147 @@ public async Task InvokeAsync(HttpContext ctx)
return;
}

var masker = ctx.RequestServices.GetService<IAuditMaskingService>();
var requestContext = await CaptureRequestAsync(ctx);
var sw = Stopwatch.StartNew();

var originalBody = ctx.Response.Body;
await using var responseBuffer = new MemoryStream();
ctx.Response.Body = responseBuffer;

try
{
await _next(ctx);
sw.Stop();

await WriteSuccessAuditAsync(ctx, requestContext, responseBuffer, originalBody, sw);
}
catch (Exception ex)
{
sw.Stop();
await WriteExceptionAuditAsync(ctx, ex);
ctx.Response.Body = originalBody;
throw;
}
}

private async Task<RequestCaptureContext> CaptureRequestAsync(HttpContext ctx)
{
object? reqPreview = null;
int reqSize = 0;
if (_opts.CaptureBodies &&
ContentTypeHelper.IsJsonLike(ctx.Request.ContentType, _opts.AllowedContentTypes))

if (ShouldCaptureBody(ctx.Request.ContentType))
{
var masker = ctx.RequestServices.GetService<IAuditMaskingService>();
(reqPreview, reqSize) = await HttpBodyReader.ReadRequestAsync(ctx, _opts.MaxRequestBytes, ctx.RequestAborted);

if (reqPreview is not null && masker is not null)
{
reqPreview = masker.ApplyMasking(reqPreview);
}
}

var originalBody = ctx.Response.Body;
await using var tee = new MemoryStream();
await using var respBuffer = new MemoryStream();
ctx.Response.Body = tee;
return new RequestCaptureContext(reqPreview, reqSize);
}

try
private async Task WriteSuccessAuditAsync(
HttpContext ctx,
RequestCaptureContext requestContext,
MemoryStream responseBuffer,
Stream originalBody,
Stopwatch sw)
{
var (respPreview, respSize) = await CaptureResponseAsync(ctx, responseBuffer);

await RestoreResponseBodyAsync(responseBuffer, originalBody, ctx);

await WriteActivityAuditAsync(ctx, requestContext, respPreview, respSize, sw);
}

private async Task<(object? Preview, int Size)> CaptureResponseAsync(HttpContext ctx, MemoryStream responseBuffer)
{
if (!ShouldCaptureBody(ctx.Response.ContentType))
{
await _next(ctx);
sw.Stop();
return (null, 0);
}

object? respPreview = null;
int respSize = 0;
var masker = ctx.RequestServices.GetService<IAuditMaskingService>();
responseBuffer.Position = 0;

if (_opts.CaptureBodies &&
ContentTypeHelper.IsJsonLike(ctx.Response.ContentType, _opts.AllowedContentTypes))
{
tee.Position = 0;
await tee.CopyToAsync(respBuffer, ctx.RequestAborted);
(respPreview, respSize) = await HttpBodyReader.ReadResponseAsync(
respBuffer, _opts.MaxResponseBytes, ctx.RequestAborted);
if (respPreview is not null && masker is not null)
{
respPreview = masker.ApplyMasking(respPreview);
}
}
await using var respBuffer = new MemoryStream();
await responseBuffer.CopyToAsync(respBuffer, ctx.RequestAborted);

respBuffer.Position = 0;
ctx.Response.Body = originalBody;
if (respBuffer.Length > 0)
await respBuffer.CopyToAsync(originalBody, ctx.RequestAborted);

await Audit.ForActivity(Contracts.ActivityKind.Http, ctx.Request.Path)
.WithActivityResult(
statusCode: ctx.Response.StatusCode,
durationMs: (int)sw.Elapsed.TotalMilliseconds,
captured: _opts.CaptureBodies ? BodyCapture.Both : BodyCapture.None,
requestSize: reqSize,
responseSize: respSize,
requestPreview: reqPreview,
responsePreview: respPreview)
.WithSource("api")
.WithTenant((_publisher.CurrentScope?.TenantId) ?? null)
.WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName)
.WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier)
.WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier)
.WriteAsync(ctx.RequestAborted);
var (respPreview, respSize) = await HttpBodyReader.ReadResponseAsync(
respBuffer, _opts.MaxResponseBytes, ctx.RequestAborted);

if (respPreview is not null && masker is not null)
{
respPreview = masker.ApplyMasking(respPreview);
}
catch (Exception ex)

return (respPreview, respSize);
}

private static async Task RestoreResponseBodyAsync(MemoryStream responseBuffer, Stream originalBody, HttpContext ctx)
{
responseBuffer.Position = 0;
ctx.Response.Body = originalBody;

if (responseBuffer.Length > 0)
{
sw.Stop();
await responseBuffer.CopyToAsync(originalBody, ctx.RequestAborted);
}
}

var sev = ExceptionSeverityClassifier.Classify(ex);
if (sev >= _opts.MinExceptionSeverity)
{
await Audit.ForException(ex, ExceptionArea.Api,
routeOrLocation: ctx.Request.Path, severity: sev)
.WithSource("api")
.WithTenant((_publisher.CurrentScope?.TenantId) ?? null)
.WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName)
.WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier)
.WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier)
.WriteAsync(ctx.RequestAborted);
}
private async Task WriteActivityAuditAsync(
HttpContext ctx,
RequestCaptureContext requestContext,
object? respPreview,
int respSize,
Stopwatch sw)
{
await Audit.ForActivity(Contracts.ActivityKind.Http, ctx.Request.Path)
.WithActivityResult(
statusCode: ctx.Response.StatusCode,
durationMs: (int)sw.Elapsed.TotalMilliseconds,
captured: _opts.CaptureBodies ? BodyCapture.Both : BodyCapture.None,
requestSize: requestContext.Size,
responseSize: respSize,
requestPreview: requestContext.Preview,
responsePreview: respPreview)
.WithSource("api")
.WithTenant(_publisher.CurrentScope?.TenantId)
.WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName)
.WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier)
.WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier)
.WriteAsync(ctx.RequestAborted);
}

ctx.Response.Body = originalBody;
throw;
private async Task WriteExceptionAuditAsync(HttpContext ctx, Exception ex)
{
var sev = ExceptionSeverityClassifier.Classify(ex);
if (sev < _opts.MinExceptionSeverity)
{
return;
}

await Audit.ForException(ex, ExceptionArea.Api, routeOrLocation: ctx.Request.Path, severity: sev)
.WithSource("api")
.WithTenant(_publisher.CurrentScope?.TenantId)
.WithUser(_publisher.CurrentScope?.UserId, _publisher.CurrentScope?.UserName)
.WithCorrelation(_publisher.CurrentScope?.CorrelationId ?? ctx.TraceIdentifier)
.WithRequestId(_publisher.CurrentScope?.RequestId ?? ctx.TraceIdentifier)
.WriteAsync(ctx.RequestAborted);
}

private bool ShouldCaptureBody(string? contentType) =>
_opts.CaptureBodies && ContentTypeHelper.IsJsonLike(contentType, _opts.AllowedContentTypes);

private bool ShouldSkip(HttpContext ctx)
{
var path = ctx.Request.Path.Value ?? string.Empty;
return _opts.ExcludePathStartsWith.Any(prefix =>
path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
}

private readonly record struct RequestCaptureContext(object? Preview, int Size);
}
Loading