merge: IT-628 RabbitMQ worker, contracts, usage UI in channels screen #16

Closed
agrynco wants to merge 51 commits from feature/it-1115-html-template-escaping into IT-628
38 changed files with 1492 additions and 122 deletions
Showing only changes of commit d5a2e538f0 - Show all commits
+3
View File
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
# Vim temporary swap files # Vim temporary swap files
*.swp *.swp
# Local Docker Compose secrets and ports
docker/environments/.env.local
+27
View File
@@ -0,0 +1,27 @@
# HrynCo Notification Service Agent Rules
## Git workflow
- Treat `main` as the only default and integration branch for this repository.
- Start every task branch from the latest `origin/main`.
- Before creating a task branch, fetch the remote, switch to `main`, and fast-forward it from `origin/main`.
- Create the task branch only after confirming that local `main` matches `origin/main`.
- Open pull requests from the task branch into `main`.
- Do not use `development` as the base or pull-request target for new work in this repository.
- Do not commit, push, publish, deploy, or merge unless the repository owner explicitly requests that step.
Recommended branch preparation:
```text
git fetch origin
git switch main
git pull --ff-only origin main
git switch -c <task-branch>
```
## Delivery
- Keep changes focused and preserve backward compatibility where practical.
- Update the matching repository documentation when behavior, contracts, configuration, routing, or workflows change.
- Run the solution build and relevant tests before handoff.
- Use Conventional Commit messages with a lowercase subject and finish the commit body with `Ref: <issue-id>` when an issue exists.
+2 -2
View File
@@ -30,7 +30,7 @@
<PackageVersion Include="Serilog.Sinks.Seq" Version="9.0.0" /> <PackageVersion Include="Serilog.Sinks.Seq" Version="9.0.0" />
<!-- HrynCo shared packages --> <!-- HrynCo shared packages -->
<PackageVersion Include="HrynCo.Common" Version="1.0.11" /> <PackageVersion Include="HrynCo.Common" Version="1.0.11" />
<PackageVersion Include="HrynCo.RabbitMq" Version="1.0.15" /> <PackageVersion Include="HrynCo.RabbitMq" Version="1.0.17" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" /> <PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
@@ -39,4 +39,4 @@
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" /> <PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,116 @@
namespace HrynCo.NotificationService.Migrator;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.Logging;
internal sealed class DevelopmentDataSeeder
{
internal const string DefaultServiceName = "TestService";
internal const string DefaultLanguageCode = "en";
private readonly IEmailChannelRepository _channels;
private readonly IEmailTemplateRepository _templates;
private readonly ILogger<DevelopmentDataSeeder> _logger;
public DevelopmentDataSeeder(
IEmailChannelRepository channels,
IEmailTemplateRepository templates,
ILogger<DevelopmentDataSeeder> logger)
{
_channels = channels;
_templates = templates;
_logger = logger;
}
public async Task SeedAsync(string serviceName, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(serviceName))
throw new InvalidOperationException("Development seed service name is not configured.");
await SeedMailpitChannelAsync(serviceName, cancellationToken);
foreach (EmailTemplate template in CreateTemplates(serviceName))
{
EmailTemplate? existing = await _templates.GetAsync(
template.ServiceName, template.Key, template.LanguageCode, cancellationToken);
if (existing is not null)
{
_logger.LogInformation(
"Development email template already exists; leaving it unchanged [ServiceName={ServiceName}, Key={TemplateKey}, LanguageCode={LanguageCode}]",
template.ServiceName, template.Key, template.LanguageCode);
continue;
}
await _templates.AddAsync(template, cancellationToken);
_logger.LogInformation(
"Created development email template [ServiceName={ServiceName}, Key={TemplateKey}, LanguageCode={LanguageCode}]",
template.ServiceName, template.Key, template.LanguageCode);
}
}
private async Task SeedMailpitChannelAsync(string serviceName, CancellationToken cancellationToken)
{
IReadOnlyList<EmailChannel> existingChannels = await _channels.GetByServiceAsync(
serviceName, cancellationToken);
if (existingChannels.Count > 0)
{
_logger.LogInformation(
"Development email channel already exists; leaving all channels unchanged [ServiceName={ServiceName}]",
serviceName);
return;
}
var channel = new EmailChannel
{
ServiceName = serviceName,
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "mailpit",
Port = 1025,
Username = string.Empty,
Password = string.Empty,
UseSsl = false,
FromEmail = "no-reply@local.hrynco.test",
FromName = "HrynCo Development"
},
WarnThresholdPercent = 90,
IsActive = true,
Created = DateTimeOffset.UtcNow
};
await _channels.AddAsync(channel, cancellationToken);
_logger.LogInformation(
"Created Mailpit development email channel [ServiceName={ServiceName}]",
serviceName);
}
private static IReadOnlyList<EmailTemplate> CreateTemplates(string serviceName)
{
DateTimeOffset created = DateTimeOffset.UtcNow;
return
[
new EmailTemplate
{
ServiceName = serviceName,
Key = "TestEmail",
LanguageCode = DefaultLanguageCode,
Subject = "Test notification",
HtmlBody = "<p>Hello {{RecipientName}},</p><p>{{Message}}</p>",
TextBody = "Hello {{RecipientName}}, {{Message}}",
Variables =
[
new EmailTemplateVariable { Name = "RecipientName", Required = true },
new EmailTemplateVariable { Name = "Message", Required = true }
],
Created = created
}
];
}
}
+17 -3
View File
@@ -1,5 +1,7 @@
using HrynCo.NotificationService.DAL.EF; using HrynCo.NotificationService.DAL.EF;
using HrynCo.NotificationService.Migrator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Serilog; using Serilog;
@@ -21,8 +23,8 @@ try
var connectionString = ctx.Configuration["App:ConnectionString"] var connectionString = ctx.Configuration["App:ConnectionString"]
?? throw new InvalidOperationException("App:ConnectionString is not configured."); ?? throw new InvalidOperationException("App:ConnectionString is not configured.");
services.AddDbContext<NotificationDbContext>(options => services.AddNotificationDataAccess(connectionString);
options.UseNpgsql(connectionString)); services.AddScoped<DevelopmentDataSeeder>();
}) })
.Build(); .Build();
@@ -32,6 +34,18 @@ try
Log.Information("Applying migrations..."); Log.Information("Applying migrations...");
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
Log.Information("Migrations applied successfully."); Log.Information("Migrations applied successfully.");
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
if (environment.IsDevelopment())
{
string serviceName = host.Services.GetRequiredService<IConfiguration>()["DevelopmentSeed:ServiceName"]
?? DevelopmentDataSeeder.DefaultServiceName;
Log.Information("Seeding development email configuration...");
var seeder = scope.ServiceProvider.GetRequiredService<DevelopmentDataSeeder>();
await seeder.SeedAsync(serviceName);
Log.Information("Development email configuration is ready.");
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
@@ -0,0 +1,83 @@
namespace HrynCo.NotificationService.Services.Tests;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Migrator;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
public sealed class DevelopmentDataSeederTests
{
private readonly IEmailChannelRepository _channels = Substitute.For<IEmailChannelRepository>();
private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>();
[Fact]
public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
.Returns(Array.Empty<EmailChannel>());
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
.Returns((EmailTemplate?)null);
DevelopmentDataSeeder seeder = CreateSeeder();
await seeder.SeedAsync("TestService", CancellationToken.None);
await _channels.Received(1).AddAsync(
Arg.Is<EmailChannel>(channel => IsMailpitChannel(channel)),
Arg.Any<CancellationToken>());
await _templates.Received(1).AddAsync(
Arg.Is<EmailTemplate>(template =>
template.ServiceName == "TestService" &&
template.Key == "TestEmail" &&
template.Variables.Any(variable => variable.Name == "Message" && variable.Required)),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
.Returns([new EmailChannel
{
ServiceName = "TestService",
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings()
}]);
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
.Returns(new EmailTemplate
{
ServiceName = "TestService",
Key = "TestEmail",
LanguageCode = "en",
Subject = "Existing",
HtmlBody = "Existing",
TextBody = "Existing"
});
DevelopmentDataSeeder seeder = CreateSeeder();
await seeder.SeedAsync("TestService", CancellationToken.None);
await _channels.DidNotReceive().AddAsync(Arg.Any<EmailChannel>(), Arg.Any<CancellationToken>());
await _templates.DidNotReceive().AddAsync(Arg.Any<EmailTemplate>(), Arg.Any<CancellationToken>());
await _channels.DidNotReceive().UpdateAsync(Arg.Any<EmailChannel>(), Arg.Any<CancellationToken>());
await _templates.DidNotReceive().UpdateAsync(Arg.Any<EmailTemplate>(), Arg.Any<CancellationToken>());
}
private DevelopmentDataSeeder CreateSeeder() => new(
_channels,
_templates,
NullLogger<DevelopmentDataSeeder>.Instance);
private static bool IsMailpitChannel(EmailChannel channel)
{
return channel.ServiceName == "TestService" &&
channel.IsActive &&
channel.Settings is SmtpChannelSettings smtp &&
smtp.Host == "mailpit" &&
smtp.Port == 1025 &&
!smtp.UseSsl;
}
}
@@ -0,0 +1,67 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
public sealed class EmailTemplateRenderingServiceTests
{
private readonly EmailTemplateRenderingService _service = new();
[Fact]
public void Render_InterpolatesItemTrackerVariables()
{
EmailTemplate template = CreateTemplate();
SendEmailMessageData data = CreateData(new Dictionary<string, string>
{
["AppName"] = "StoreMate",
["VerificationUrl"] = "https://example.invalid/verify"
});
RenderedEmail result = _service.Render(template, data);
Assert.Equal("Verify StoreMate", result.Subject);
Assert.Equal("<a href=\"https://example.invalid/verify\">Verify</a>", result.HtmlBody);
Assert.Equal("Verify at https://example.invalid/verify", result.TextBody);
}
[Fact]
public void Render_WhenRequiredVariableIsMissing_ThrowsObservableFailure()
{
EmailTemplate template = CreateTemplate();
SendEmailMessageData data = CreateData(new Dictionary<string, string>
{
["AppName"] = "StoreMate"
});
InvalidDataException exception = Assert.Throws<InvalidDataException>(
() => _service.Render(template, data));
Assert.Equal("Required template variables are missing: VerificationUrl.", exception.Message);
}
private static EmailTemplate CreateTemplate() => new()
{
ServiceName = "StoreMate-Prod",
Key = "EmailVerification",
LanguageCode = "uk",
Subject = "Verify {{AppName}}",
HtmlBody = "<a href=\"{{VerificationUrl}}\">Verify</a>",
TextBody = "Verify at {{VerificationUrl}}",
Variables =
[
new EmailTemplateVariable { Name = "AppName", Required = true },
new EmailTemplateVariable { Name = "VerificationUrl", Required = true }
]
};
private static SendEmailMessageData CreateData(IReadOnlyDictionary<string, string> variables) => new()
{
ServiceName = "StoreMate-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = variables
};
}
@@ -0,0 +1,30 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using NSubstitute;
public sealed class EmailTemplateServiceTests
{
[Fact]
public async Task GetAsync_WhenRequestedLanguageDoesNotExist_DoesNotSilentlyFallback()
{
IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
var service = new EmailTemplateService(repository);
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.GetAsync("StoreMate-Prod", "EmailVerification", "UK", CancellationToken.None));
Assert.Contains("language='uk'", exception.Message);
await repository.Received(1).GetAsync(
"StoreMate-Prod",
"EmailVerification",
"uk",
CancellationToken.None);
await repository.DidNotReceive().GetAsync(
"StoreMate-Prod",
"EmailVerification",
"en",
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,58 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net.Mail;
using System.Net.Sockets;
public sealed class NotificationDeliveryErrorFormatterTests
{
[Fact]
public void Format_UsesInnermostMessageAndRemovesLineBreaks()
{
var exception = new InvalidOperationException(
"outer",
new Exception("provider failed\r\nretry rejected"));
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(
"Notification delivery failed after all retry attempts (provider failed retry rejected).",
result);
}
[Fact]
public void Format_UnresolvedSmtpHost_AddsSafeChannelContext()
{
var socketException = new SocketException((int)SocketError.HostNotFound);
var exception = new SmtpException("Failure sending mail.", socketException);
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(
$"SMTP delivery failed after all retry attempts: the configured SMTP server host could not be resolved ({socketException.Message}).",
result);
}
[Fact]
public void Format_RefusedSmtpConnection_AddsSafeChannelContext()
{
var socketException = new SocketException((int)SocketError.ConnectionRefused);
var exception = new SmtpException("Failure sending mail.", socketException);
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(
$"SMTP delivery failed after all retry attempts: the configured SMTP server refused the connection ({socketException.Message}).",
result);
}
[Fact]
public void Format_LongError_IsBoundedToOutboxColumnLength()
{
var exception = new Exception(new string('x', 2100));
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(2000, result.Length);
}
}
@@ -0,0 +1,107 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
public sealed class NotificationResultPublisherTests
{
[Fact]
public async Task PublishAsync_TerminalFailure_PublishesNeutralFailureResultToReplyQueue()
{
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
var publisher = new NotificationResultPublisher(
rabbitMqPublisher,
NullLogger<NotificationResultPublisher>.Instance);
SendEmailMessage message = CreateMessage();
NotificationResultMessage? publishedResult = null;
rabbitMqPublisher
.When(x => x.PublishAsync(
"item-tracker.notifications.result",
Arg.Any<NotificationResultMessage>(),
Arg.Any<CancellationToken>()))
.Do(call => publishedResult = call.ArgAt<NotificationResultMessage>(1));
await publisher.PublishAsync(
message,
"Email provider could not deliver the notification.",
CancellationToken.None);
await rabbitMqPublisher.Received(1).PublishAsync(
"item-tracker.notifications.result",
Arg.Any<NotificationResultMessage>(),
CancellationToken.None);
Assert.NotNull(publishedResult);
Assert.Equal(message.CorrelationContext.CorrelationId, publishedResult.CorrelationContext.CorrelationId);
Assert.Null(publishedResult.CorrelationContext.ReplyTo);
Assert.Equal(message.Data.ServiceName, publishedResult.Data.ServiceName);
Assert.Equal(message.Data.TemplateKey, publishedResult.Data.TemplateKey);
Assert.Equal(message.Data.RecipientEmail, publishedResult.Data.RecipientEmail);
Assert.Equal("Email provider could not deliver the notification.", publishedResult.Data.ErrorMessage);
Assert.False(publishedResult.Data.IsSuccess);
}
[Fact]
public async Task PublishAsync_NoReplyQueue_DoesNotPublish()
{
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
var publisher = new NotificationResultPublisher(
rabbitMqPublisher,
NullLogger<NotificationResultPublisher>.Instance);
SendEmailMessage message = CreateMessage();
message.CorrelationContext = message.CorrelationContext with { ReplyTo = null };
await publisher.PublishAsync(message, "delivery failed", CancellationToken.None);
await rabbitMqPublisher.DidNotReceive()
.PublishAsync<NotificationResultData>(
Arg.Any<string>(),
Arg.Any<NotificationResultMessage>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task PublishAsync_ResultBrokerFailure_IsBestEffort()
{
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
rabbitMqPublisher
.PublishAsync(
Arg.Any<string>(),
Arg.Any<NotificationResultMessage>(),
Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("reply broker unavailable"));
var publisher = new NotificationResultPublisher(
rabbitMqPublisher,
NullLogger<NotificationResultPublisher>.Instance);
Exception? exception = await Record.ExceptionAsync(() => publisher.PublishAsync(
CreateMessage(),
"delivery failed",
CancellationToken.None));
Assert.Null(exception);
}
private static SendEmailMessage CreateMessage()
{
return new SendEmailMessage
{
CorrelationContext = new CorrelationContext
{
CorrelationId = "correlation-id",
ReplyTo = "item-tracker.notifications.result"
},
Data = new SendEmailMessageData
{
ServiceName = "TestService",
TemplateKey = "TestEmail",
RecipientEmail = "test.user@itemtracker.local",
RecipientName = "Test User",
LanguageCode = "en",
Variables = new Dictionary<string, string>()
}
};
}
}
@@ -0,0 +1,16 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
public sealed class RecipientAddressRedactorTests
{
[Theory]
[InlineData("owner@example.com", "o***@e***.com")]
[InlineData("a@b.test", "a***@b***.test")]
[InlineData("invalid", "***")]
[InlineData(null, "<missing>")]
public void Redact_DoesNotExposeFullAddress(string? address, string expected)
{
Assert.Equal(expected, RecipientAddressRedactor.Redact(address));
}
}
@@ -0,0 +1,60 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
public sealed class SendEmailContractTests
{
private const string ItemTrackerPayload = """
{
"CorrelationContext": {
"CorrelationId": "3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc",
"ReplyTo": "item-tracker.notifications.result"
},
"Data": {
"ServiceName": "StoreMate-Prod",
"TemplateKey": "EmailVerification",
"RecipientEmail": "owner@example.com",
"RecipientName": "StoreMate Owner",
"Variables": {
"AppName": "StoreMate",
"VerificationUrl": "https://example.invalid/sensitive-token"
},
"LanguageCode": "uk"
}
}
""";
[Fact]
public void PascalCaseItemTrackerPayload_DeserializesWithoutLosingValues()
{
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(ItemTrackerPayload);
Assert.NotNull(message);
SendEmailMessageValidator.Validate(message);
Assert.Equal("3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc", message.CorrelationContext.CorrelationId);
Assert.Equal("item-tracker.notifications.result", message.CorrelationContext.ReplyTo);
Assert.Equal("StoreMate-Prod", message.Data.ServiceName);
Assert.Equal("EmailVerification", message.Data.TemplateKey);
Assert.Equal("owner@example.com", message.Data.RecipientEmail);
Assert.Equal("StoreMate Owner", message.Data.RecipientName);
Assert.Equal("uk", message.Data.LanguageCode);
Assert.Equal("StoreMate", message.Data.Variables["AppName"]);
Assert.Equal(
"https://example.invalid/sensitive-token",
message.Data.Variables["VerificationUrl"]);
}
[Fact]
public void MissingLanguageCode_IsRejected()
{
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(
ItemTrackerPayload.Replace("\"uk\"", "null", StringComparison.Ordinal));
InvalidDataException exception = Assert.Throws<InvalidDataException>(
() => SendEmailMessageValidator.Validate(message!));
Assert.Equal("LanguageCode is required.", exception.Message);
}
}
@@ -0,0 +1,77 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker;
using Hrynco.RabbitMq;
public sealed class SendEmailDeliveryValidatorTests
{
[Fact]
public void ValidItemTrackerDelivery_IsAccepted()
{
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
CreateContext(),
SendEmailConsumer.SupportedMessageType);
Assert.Null(error);
}
[Fact]
public void MissingMessageId_IsRejected()
{
RabbitMqMessageContext context = CreateContext() with { MessageId = null };
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
Assert.Equal("AMQP MessageId is required.", error);
}
[Fact]
public void UnsupportedContractVersion_IsRejected()
{
RabbitMqMessageContext context = CreateContext() with
{
MessageType = "Notification.SendEmail.v2"
};
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error);
}
private static RabbitMqMessageContext CreateContext() => new()
{
QueueName = SendEmailConsumer.IncomingQueue,
Exchange = string.Empty,
RoutingKey = SendEmailConsumer.IncomingQueue,
MessageId = Guid.NewGuid().ToString("D"),
MessageType = SendEmailConsumer.SupportedMessageType,
CorrelationId = "correlation-id",
ContentType = "application/json"
};
private static SendEmailMessage CreateMessage() => new()
{
CorrelationContext = new CorrelationContext
{
CorrelationId = "correlation-id",
ReplyTo = "item-tracker.notifications.result"
},
Data = new SendEmailMessageData
{
ServiceName = "StoreMate-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
}
};
}
@@ -0,0 +1,123 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
public sealed class SendEmailServiceTests
{
[Fact]
public async Task ProcessAsync_ValidItemTrackerMessage_SendsOnceAndUpdatesUsage()
{
Guid channelId = Guid.NewGuid();
var settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid",
Port = 587,
Username = "smtp-user",
Password = "not-a-real-secret",
UseSsl = true,
FromEmail = "notifications@example.com",
FromName = "StoreMate"
};
var channel = new EmailChannel
{
Id = channelId,
ServiceName = "StoreMate-Prod",
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = settings,
IsActive = true
};
var template = new EmailTemplate
{
ServiceName = "StoreMate-Prod",
Key = "EmailVerification",
LanguageCode = "uk",
Subject = "Verify {{AppName}}",
HtmlBody = "<p>{{AppName}}</p>",
TextBody = "{{AppName}}",
Variables = [new EmailTemplateVariable { Name = "AppName", Required = true }]
};
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
.Returns([channel]);
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
.Returns(template);
var renderer = new EmailTemplateRenderingService();
var smtp = new RecordingSmtpEmailSender();
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
var service = new SendEmailService(
channels,
usage,
templates,
renderer,
smtp,
resultPublisher,
NullLogger<SendEmailService>.Instance);
var message = new SendEmailMessage
{
CorrelationContext = new CorrelationContext
{
CorrelationId = Guid.NewGuid().ToString("D")
},
Data = new SendEmailMessageData
{
ServiceName = "StoreMate-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
}
};
await service.ProcessAsync(message, CancellationToken.None);
Assert.Equal(1, smtp.SendCount);
Assert.Same(settings, smtp.Settings);
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
Assert.Equal("owner@example.com", smtp.RecipientEmail);
Assert.Equal("Owner", smtp.RecipientName);
await usage.Received(1).IncrementUsageAsync(
channelId,
Arg.Any<DateOnly>(),
CancellationToken.None);
await resultPublisher.Received(1).PublishAsync(
message,
null,
CancellationToken.None);
}
private sealed class RecordingSmtpEmailSender : ISmtpEmailSender
{
public int SendCount { get; private set; }
public SmtpChannelSettings? Settings { get; private set; }
public RenderedEmail? Email { get; private set; }
public string? RecipientEmail { get; private set; }
public string? RecipientName { get; private set; }
public Task SendAsync(
SmtpChannelSettings settings,
RenderedEmail email,
string recipientEmail,
string recipientName,
CancellationToken cancellationToken)
{
SendCount++;
Settings = settings;
Email = email;
RecipientEmail = recipientEmail;
RecipientName = recipientName;
return Task.CompletedTask;
}
}
}
@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>
@@ -21,6 +22,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" /> <ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" /> <ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Worker\HrynCo.NotificationService.Worker.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Migrator\HrynCo.NotificationService.Migrator.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,10 +0,0 @@
namespace HrynCo.NotificationService.Services.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -8,6 +8,18 @@ internal sealed class EmailTemplateRenderingService : IEmailTemplateRenderingSer
{ {
public RenderedEmail Render(EmailTemplate template, SendEmailMessageData data) public RenderedEmail Render(EmailTemplate template, SendEmailMessageData data)
{ {
string[] missingVariables = template.Variables
.Where(variable => variable.Required)
.Select(variable => variable.Name)
.Where(name => !data.Variables.TryGetValue(name, out string? value) || string.IsNullOrWhiteSpace(value))
.ToArray();
if (missingVariables.Length > 0)
{
throw new InvalidDataException(
$"Required template variables are missing: {string.Join(", ", missingVariables)}.");
}
return new RenderedEmail( return new RenderedEmail(
Interpolate(template.Subject, data.Variables), Interpolate(template.Subject, data.Variables),
Interpolate(template.HtmlBody, data.Variables), Interpolate(template.HtmlBody, data.Variables),
@@ -18,11 +18,15 @@ internal sealed class EmailTemplateService : IEmailTemplateService
string? languageCode, string? languageCode,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode; if (string.IsNullOrWhiteSpace(languageCode))
var template = await _templateRepository.GetAsync(serviceName, templateKey, lang, cancellationToken); throw new InvalidDataException("LanguageCode is required.");
if (template is null && lang != "en") string lang = languageCode.Trim().ToLowerInvariant();
template = await _templateRepository.GetAsync(serviceName, templateKey, "en", cancellationToken); EmailTemplate? template = await _templateRepository.GetAsync(
serviceName,
templateKey,
lang,
cancellationToken);
return template return template
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
@@ -0,0 +1,11 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
public interface INotificationResultPublisher
{
Task PublishAsync(
SendEmailMessage message,
string? deliveryError,
CancellationToken cancellationToken);
}
@@ -0,0 +1,13 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using HrynCo.NotificationService.DAL.Abstract.Providers;
internal interface ISmtpEmailSender
{
Task SendAsync(
SmtpChannelSettings settings,
RenderedEmail email,
string recipientEmail,
string recipientName,
CancellationToken cancellationToken);
}
@@ -0,0 +1,80 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net.Mail;
using System.Net.Sockets;
public static class NotificationDeliveryErrorFormatter
{
private const int MaximumErrorLength = 2000;
private const string FallbackError = "Notification delivery failed after all retry attempts.";
public static string Format(Exception exception)
{
string technicalMessage = Normalize(exception.GetBaseException().Message);
if (string.IsNullOrWhiteSpace(technicalMessage))
{
return FallbackError;
}
string contextualMessage = CreateContextualMessage(exception, technicalMessage);
return contextualMessage.Length <= MaximumErrorLength
? contextualMessage
: contextualMessage[..MaximumErrorLength];
}
private static string CreateContextualMessage(Exception exception, string technicalMessage)
{
if (FindException<SmtpFailedRecipientException>(exception) is not null)
{
return "SMTP delivery failed after all retry attempts: " +
"the SMTP server rejected the recipient address.";
}
SocketException? socketException = FindException<SocketException>(exception);
if (socketException is not null)
{
string reason = socketException.SocketErrorCode switch
{
SocketError.HostNotFound or SocketError.NoData =>
"the configured SMTP server host could not be resolved",
SocketError.ConnectionRefused =>
"the configured SMTP server refused the connection",
SocketError.TimedOut =>
"the connection to the configured SMTP server timed out",
_ when technicalMessage.Contains(
"Name or service not known",
StringComparison.OrdinalIgnoreCase) =>
"the configured SMTP server host could not be resolved",
_ => "the configured SMTP server could not be reached"
};
return $"SMTP delivery failed after all retry attempts: {reason} ({technicalMessage}).";
}
if (FindException<SmtpException>(exception) is not null)
{
return $"SMTP delivery failed after all retry attempts ({technicalMessage}).";
}
return $"Notification delivery failed after all retry attempts ({technicalMessage}).";
}
private static TException? FindException<TException>(Exception exception)
where TException : Exception
{
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is TException typedException)
{
return typedException;
}
}
return null;
}
private static string Normalize(string message)
{
return message.ReplaceLineEndings(" ").Trim();
}
}
@@ -0,0 +1,63 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging;
internal sealed class NotificationResultPublisher : INotificationResultPublisher
{
private readonly ILogger<NotificationResultPublisher> _logger;
private readonly IRabbitMqPublisher _publisher;
public NotificationResultPublisher(
IRabbitMqPublisher publisher,
ILogger<NotificationResultPublisher> logger)
{
_publisher = publisher;
_logger = logger;
}
public async Task PublishAsync(
SendEmailMessage message,
string? deliveryError,
CancellationToken cancellationToken)
{
CorrelationContext correlationContext = message.CorrelationContext;
string? replyTo = correlationContext.ReplyTo;
if (string.IsNullOrWhiteSpace(replyTo))
{
return;
}
try
{
var result = new NotificationResultMessage
{
CorrelationContext = correlationContext with { ReplyTo = null },
Data = new NotificationResultData
{
ServiceName = message.Data.ServiceName,
RecipientEmail = message.Data.RecipientEmail,
TemplateKey = message.Data.TemplateKey,
Timestamp = DateTimeOffset.UtcNow,
ErrorMessage = deliveryError
}
};
await _publisher.PublishAsync(replyTo, result, cancellationToken);
_logger.LogDebug(
"Notification result published to reply queue {Queue} [CorrelationId={CorrelationId}]",
replyTo,
correlationContext.CorrelationId);
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Failed to publish notification result to reply queue {Queue} [CorrelationId={CorrelationId}]",
replyTo,
correlationContext.CorrelationId);
}
}
}
@@ -0,0 +1,22 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
public static class RecipientAddressRedactor
{
public static string Redact(string? address)
{
if (string.IsNullOrWhiteSpace(address))
return "<missing>";
int separator = address.LastIndexOf('@');
if (separator <= 0 || separator == address.Length - 1)
return "***";
string local = address[..separator];
string domain = address[(separator + 1)..];
int dot = domain.LastIndexOf('.');
string domainName = dot > 0 ? domain[..dot] : domain;
string suffix = dot > 0 ? domain[dot..] : string.Empty;
return $"{local[0]}***@{domainName[0]}***{suffix}";
}
}
@@ -0,0 +1,36 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net.Mail;
using HrynCo.NotificationService.Contracts.Messages;
public static class SendEmailMessageValidator
{
public static void Validate(SendEmailMessage message)
{
ArgumentNullException.ThrowIfNull(message);
if (message.CorrelationContext is null)
throw new InvalidDataException("CorrelationContext is required.");
if (string.IsNullOrWhiteSpace(message.CorrelationContext.CorrelationId))
throw new InvalidDataException("CorrelationContext.CorrelationId is required.");
if (message.Data is null)
throw new InvalidDataException("Data is required.");
Require(message.Data.ServiceName, nameof(message.Data.ServiceName));
Require(message.Data.TemplateKey, nameof(message.Data.TemplateKey));
Require(message.Data.RecipientEmail, nameof(message.Data.RecipientEmail));
Require(message.Data.RecipientName, nameof(message.Data.RecipientName));
Require(message.Data.LanguageCode, nameof(message.Data.LanguageCode));
if (!MailAddress.TryCreate(message.Data.RecipientEmail, out _))
throw new InvalidDataException("RecipientEmail is not a valid email address.");
if (message.Data.Variables is null)
throw new InvalidDataException("Variables is required.");
}
private static void Require(string? value, string fieldName)
{
if (string.IsNullOrWhiteSpace(value))
throw new InvalidDataException($"{fieldName} is required.");
}
}
@@ -1,13 +1,9 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net;
using System.Net.Mail;
using System.Text;
using HrynCo.NotificationService.Contracts.Messages; using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
internal sealed class SendEmailService : ISendEmailService internal sealed class SendEmailService : ISendEmailService
@@ -16,7 +12,8 @@ internal sealed class SendEmailService : ISendEmailService
private readonly IEmailChannelUsageRepository _usageRepository; private readonly IEmailChannelUsageRepository _usageRepository;
private readonly IEmailTemplateService _templateService; private readonly IEmailTemplateService _templateService;
private readonly IEmailTemplateRenderingService _templateRenderingService; private readonly IEmailTemplateRenderingService _templateRenderingService;
private readonly IRabbitMqPublisher _publisher; private readonly ISmtpEmailSender _smtpEmailSender;
private readonly INotificationResultPublisher _resultPublisher;
private readonly ILogger<SendEmailService> _logger; private readonly ILogger<SendEmailService> _logger;
public SendEmailService( public SendEmailService(
@@ -24,24 +21,28 @@ internal sealed class SendEmailService : ISendEmailService
IEmailChannelUsageRepository usageRepository, IEmailChannelUsageRepository usageRepository,
IEmailTemplateService templateService, IEmailTemplateService templateService,
IEmailTemplateRenderingService templateRenderingService, IEmailTemplateRenderingService templateRenderingService,
IRabbitMqPublisher publisher, ISmtpEmailSender smtpEmailSender,
INotificationResultPublisher resultPublisher,
ILogger<SendEmailService> logger) ILogger<SendEmailService> logger)
{ {
_channelRepository = channelRepository; _channelRepository = channelRepository;
_usageRepository = usageRepository; _usageRepository = usageRepository;
_templateService = templateService; _templateService = templateService;
_templateRenderingService = templateRenderingService; _templateRenderingService = templateRenderingService;
_publisher = publisher; _smtpEmailSender = smtpEmailSender;
_resultPublisher = resultPublisher;
_logger = logger; _logger = logger;
} }
public async Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken) public async Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken)
{ {
SendEmailMessageValidator.Validate(message);
SendEmailMessageData data = message.Data; SendEmailMessageData data = message.Data;
string redactedRecipient = RecipientAddressRedactor.Redact(data.RecipientEmail);
_logger.LogInformation( _logger.LogInformation(
"Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]", "Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]",
data.ServiceName, data.TemplateKey, data.RecipientEmail, message.CorrelationContext?.CorrelationId); data.ServiceName, data.TemplateKey, redactedRecipient, message.CorrelationContext?.CorrelationId);
EmailChannel channel = await ResolveChannelAsync(data.ServiceName, cancellationToken); EmailChannel channel = await ResolveChannelAsync(data.ServiceName, cancellationToken);
EmailTemplate template = await GetTemplateAsync(data, cancellationToken); EmailTemplate template = await GetTemplateAsync(data, cancellationToken);
@@ -56,34 +57,12 @@ internal sealed class SendEmailService : ISendEmailService
try try
{ {
using var client = new SmtpClient(smtpChannel.Host, smtpChannel.Port) await _smtpEmailSender.SendAsync(
{ smtpChannel,
EnableSsl = smtpChannel.UseSsl, rendered,
Credentials = string.IsNullOrWhiteSpace(smtpChannel.Username) data.RecipientEmail,
? null data.RecipientName,
: new NetworkCredential(smtpChannel.Username, smtpChannel.Password) cancellationToken);
};
using var mail = new MailMessage
{
From = new MailAddress(smtpChannel.FromEmail, smtpChannel.FromName),
Subject = rendered.Subject,
Body = rendered.TextBody,
IsBodyHtml = false,
BodyEncoding = Encoding.UTF8,
SubjectEncoding = Encoding.UTF8
};
if (!string.IsNullOrWhiteSpace(rendered.HtmlBody))
{
var html = AlternateView.CreateAlternateViewFromString(
rendered.HtmlBody, Encoding.UTF8, "text/html");
mail.AlternateViews.Add(html);
}
mail.To.Add(new MailAddress(data.RecipientEmail, data.RecipientName));
await client.SendMailAsync(mail, cancellationToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -98,9 +77,9 @@ internal sealed class SendEmailService : ISendEmailService
_logger.LogInformation( _logger.LogInformation(
"Email sent successfully service={Service} template={Template} recipient={Recipient}", "Email sent successfully service={Service} template={Template} recipient={Recipient}",
data.ServiceName, data.TemplateKey, data.RecipientEmail); data.ServiceName, data.TemplateKey, redactedRecipient);
await PublishResultAsync(message.CorrelationContext, data, null, cancellationToken); await _resultPublisher.PublishAsync(message, null, cancellationToken);
} }
private async Task<EmailTemplate> GetTemplateAsync(SendEmailMessageData data, CancellationToken cancellationToken) private async Task<EmailTemplate> GetTemplateAsync(SendEmailMessageData data, CancellationToken cancellationToken)
@@ -151,48 +130,4 @@ internal sealed class SendEmailService : ISendEmailService
} }
} }
private async Task PublishResultAsync(
CorrelationContext? correlationContext,
SendEmailMessageData data,
string? errorMessage,
CancellationToken ct)
{
string? replyTo = correlationContext?.ReplyTo;
if (string.IsNullOrWhiteSpace(replyTo))
{
return;
}
try
{
var result = new NotificationResultMessage
{
CorrelationContext = (correlationContext ?? new CorrelationContext
{
CorrelationId = Guid.NewGuid().ToString()
}) with
{
ReplyTo = null
},
Data = new NotificationResultData
{
ServiceName = data.ServiceName,
RecipientEmail = data.RecipientEmail,
TemplateKey = data.TemplateKey,
Timestamp = DateTimeOffset.UtcNow,
ErrorMessage = errorMessage
}
};
await _publisher.PublishAsync(replyTo, result, ct);
_logger.LogDebug("Result published to reply queue '{Queue}' [CorrelationId={CorrelationId}]",
replyTo, correlationContext?.CorrelationId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish notification result to reply queue '{Queue}'", replyTo);
}
}
} }
@@ -0,0 +1,47 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net;
using System.Net.Mail;
using System.Text;
using HrynCo.NotificationService.DAL.Abstract.Providers;
internal sealed class SmtpEmailSender : ISmtpEmailSender
{
public async Task SendAsync(
SmtpChannelSettings settings,
RenderedEmail email,
string recipientEmail,
string recipientName,
CancellationToken cancellationToken)
{
using var client = new SmtpClient(settings.Host, settings.Port)
{
EnableSsl = settings.UseSsl,
Credentials = string.IsNullOrWhiteSpace(settings.Username)
? null
: new NetworkCredential(settings.Username, settings.Password)
};
using var mail = new MailMessage
{
From = new MailAddress(settings.FromEmail, settings.FromName),
Subject = email.Subject,
Body = email.TextBody,
IsBodyHtml = false,
BodyEncoding = Encoding.UTF8,
SubjectEncoding = Encoding.UTF8
};
if (!string.IsNullOrWhiteSpace(email.HtmlBody))
{
AlternateView html = AlternateView.CreateAlternateViewFromString(
email.HtmlBody,
Encoding.UTF8,
"text/html");
mail.AlternateViews.Add(html);
}
mail.To.Add(new MailAddress(recipientEmail, recipientName));
await client.SendMailAsync(mail, cancellationToken);
}
}
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
@@ -11,6 +11,8 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IRabbitMqPublisher, RabbitMqPublisher>(); services.AddSingleton<IRabbitMqPublisher, RabbitMqPublisher>();
services.AddScoped<IEmailTemplateService, EmailTemplateService>(); services.AddScoped<IEmailTemplateService, EmailTemplateService>();
services.AddScoped<IEmailTemplateRenderingService, EmailTemplateRenderingService>(); services.AddScoped<IEmailTemplateRenderingService, EmailTemplateRenderingService>();
services.AddScoped<ISmtpEmailSender, SmtpEmailSender>();
services.AddScoped<INotificationResultPublisher, NotificationResultPublisher>();
services.AddScoped<ISendEmailService, SendEmailService>(); services.AddScoped<ISendEmailService, SendEmailService>();
return services; return services;
} }
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
@@ -1,14 +1,17 @@
namespace HrynCo.NotificationService.Worker; namespace HrynCo.NotificationService.Worker;
using HrynCo.NotificationService.Contracts.Messages; using HrynCo.NotificationService.Contracts.Messages;
using Hrynco.RabbitMq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using HrynCo.NotificationService.Worker.Services.EmailProcessing; using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Options;
public sealed class SendEmailConsumer : RabbitMqConsumerBase<SendEmailMessage, SendEmailMessageData> public sealed class SendEmailConsumer : RabbitMqConsumerBase<SendEmailMessage, SendEmailMessageData>
{ {
internal const string IncomingQueue = "notification.send-email";
internal const string SupportedMessageType = "Notification.SendEmail.v1";
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SendEmailConsumer> _logger;
public SendEmailConsumer( public SendEmailConsumer(
IOptionsMonitor<RabbitMqSettings> options, IOptionsMonitor<RabbitMqSettings> options,
@@ -17,16 +20,51 @@ public sealed class SendEmailConsumer : RabbitMqConsumerBase<SendEmailMessage, S
: base(options, logger) : base(options, logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_logger = logger;
} }
private const string IncomingQueue = "notification.send-email";
protected override string QueueName => IncomingQueue; protected override string QueueName => IncomingQueue;
protected override async Task HandleMessageAsync(SendEmailMessage message, CancellationToken cancellationToken) protected override bool TryValidateMessage(
SendEmailMessage message,
RabbitMqMessageContext context,
out string? validationError)
{ {
using var scope = _scopeFactory.CreateScope(); validationError = SendEmailDeliveryValidator.GetValidationError(
var service = scope.ServiceProvider.GetRequiredService<ISendEmailService>(); message,
context,
SupportedMessageType);
return validationError is null;
}
protected override async Task HandleMessageAsync(
SendEmailMessage message,
RabbitMqMessageContext context,
CancellationToken cancellationToken)
{
string payloadCorrelationId = message.CorrelationContext.CorrelationId;
if (!string.IsNullOrWhiteSpace(context.CorrelationId) &&
!string.Equals(context.CorrelationId, payloadCorrelationId, StringComparison.Ordinal))
{
_logger.LogWarning(
"Broker and payload correlation IDs differ; payload correlation ID will be used");
}
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
ISendEmailService service = scope.ServiceProvider.GetRequiredService<ISendEmailService>();
await service.ProcessAsync(message, cancellationToken); await service.ProcessAsync(message, cancellationToken);
} }
protected override async Task HandleMessageRetriesExhaustedAsync(
SendEmailMessage message,
RabbitMqMessageContext context,
Exception exception,
CancellationToken cancellationToken)
{
string deliveryError = NotificationDeliveryErrorFormatter.Format(exception);
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
INotificationResultPublisher resultPublisher =
scope.ServiceProvider.GetRequiredService<INotificationResultPublisher>();
await resultPublisher.PublishAsync(message, deliveryError, cancellationToken);
}
} }
@@ -0,0 +1,30 @@
namespace HrynCo.NotificationService.Worker;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Hrynco.RabbitMq;
internal static class SendEmailDeliveryValidator
{
public static string? GetValidationError(
SendEmailMessage message,
RabbitMqMessageContext context,
string supportedMessageType)
{
if (string.IsNullOrWhiteSpace(context.MessageId))
return "AMQP MessageId is required.";
if (!string.Equals(context.MessageType, supportedMessageType, StringComparison.Ordinal))
return $"Unsupported AMQP message type '{context.MessageType ?? "<missing>"}'.";
try
{
SendEmailMessageValidator.Validate(message);
return null;
}
catch (InvalidDataException exception)
{
return exception.Message;
}
}
}
+104 -7
View File
@@ -1,5 +1,98 @@
# hrynco-notification-service # hrynco-notification-service
## Documentation
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
## Development environment
The development Docker Compose stack runs PostgreSQL, RabbitMQ, database migrations,
the Notification Service Web and Worker applications, Seq, and Mailpit.
Prerequisite: install and start Docker Desktop. Then run the installation script from
the repository root:
```powershell
.\docker\environments\install-development.cmd
```
The script validates Docker and the Compose configuration, builds the application
images, starts the complete stack in the background, and prints container status and
the main development URLs. It can also be launched directly from File Explorer or
from another working directory.
To validate the setup without building images or creating containers, run:
```powershell
.\docker\environments\install-development.cmd --validate-only
```
By default, the script uses the tracked `docker/environments/.env.Development` file:
```dotenv
DB_NAME=notification_service
DB_USER=postgres
DB_PASS=postgres
VOLUME_PREFIX=ns-dev
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
RABBITMQ_AMQP_PORT=5672
RABBITMQ_MANAGEMENT_PORT=15672
WEB_PORT=5200
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
```
To use personal values, copy it to the Git-ignored
`docker/environments/.env.local`, adjust the values, and run Docker Compose manually:
```powershell
docker compose --env-file docker/environments/.env.local `
-f docker/environments/docker-compose.yml `
-f docker/environments/docker-compose.Development.yml `
up --build -d
```
After startup, the main development endpoints are:
- Notification Service admin: `http://localhost:5200/admin/channels`
- Notification templates: `http://localhost:5200/admin/templates`
- RabbitMQ management: `http://localhost:15672` (`guest` / `guest`)
- Mailpit inbox: `http://localhost:8025`
- Seq logs: `http://localhost:5342`
- PostgreSQL: `localhost:5433`
During development migrations, the stack idempotently creates an active Mailpit SMTP
channel and a neutral English `TestEmail` template for
`DEVELOPMENT_EMAIL_SERVICE_NAME`. Existing channels and templates are never
overwritten. Change that environment value when the publisher uses another service
name.
Check container status and follow Worker logs:
```powershell
docker compose --env-file docker/environments/.env.local `
-f docker/environments/docker-compose.yml `
-f docker/environments/docker-compose.Development.yml `
ps
docker compose --env-file docker/environments/.env.local `
-f docker/environments/docker-compose.yml `
-f docker/environments/docker-compose.Development.yml `
logs -f worker
```
Stop the environment without deleting its database and RabbitMQ volumes:
```powershell
docker compose --env-file docker/environments/.env.local `
-f docker/environments/docker-compose.yml `
-f docker/environments/docker-compose.Development.yml `
down
```
See the [ItemTracker consumer guide](docs/itemtracker-outbox-email-consumer.md#local-end-to-end-setup)
for the complete end-to-end setup.
## Notification worker flow ## Notification worker flow
```mermaid ```mermaid
@@ -7,11 +100,15 @@ flowchart TD
A[Worker host starts] --> B[Load config and register services] A[Worker host starts] --> B[Load config and register services]
B --> C[Start SendEmailConsumer] B --> C[Start SendEmailConsumer]
C --> D[Receive message from notification.send-email] C --> D[Receive message from notification.send-email]
D --> E[Resolve SendEmailService] D --> E[Validate Notification.SendEmail.v1 metadata and payload]
E --> F[Pick channel and template] E --> F[Resolve SendEmailService]
F --> G[Render email content] F --> G[Pick channel and exact-language template]
G --> H[Send via SMTP] G --> H[Validate variables and render email content]
H --> I[Update usage counters] H --> I[Send via SMTP]
I --> J[Optionally publish result to reply queue] I --> J[Update usage counters]
H -. failure .-> K[Log and rethrow] J --> K[Optionally publish result to reply queue]
K --> L[Acknowledge RabbitMQ delivery]
I -. failure .-> M[Log and retry]
M -->|retries exhausted| N[Publish terminal failure result]
N --> O[Nack original delivery without requeue]
``` ```
+1
View File
@@ -7,3 +7,4 @@ RABBITMQ_PASSWORD=guest
RABBITMQ_AMQP_PORT=5672 RABBITMQ_AMQP_PORT=5672
RABBITMQ_MANAGEMENT_PORT=15672 RABBITMQ_MANAGEMENT_PORT=15672
WEB_PORT=5200 WEB_PORT=5200
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
@@ -3,7 +3,9 @@ name: hrynco-notification-service
services: services:
migrator: migrator:
environment: environment:
- DOTNET_ENVIRONMENT=Development
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres - App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
- DevelopmentSeed__ServiceName=${DEVELOPMENT_EMAIL_SERVICE_NAME:-TestService}
web: web:
environment: environment:
@@ -29,9 +31,6 @@ services:
environment: environment:
RABBITMQ_DEFAULT_USER: guest RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest RABBITMQ_DEFAULT_PASS: guest
ports:
- "5672:5672"
- "15672:15672"
networks: networks:
- internal - internal
@@ -55,10 +54,17 @@ services:
networks: networks:
- internal - internal
mailpit:
image: axllent/mailpit:v1.30.0
ports:
- "8025:8025"
networks:
- internal
volumes: volumes:
pgdata: pgdata:
name: ns-dev-pgdata name: ns-dev-pgdata
rabbitmq_data: rabbitmq_data:
name: ns-dev-rabbitmq-data name: ns-dev-rabbitmq-data
seq_data: seq_data:
name: ns-dev-seq name: ns-dev-seq
+2 -1
View File
@@ -40,6 +40,7 @@ services:
- App__RabbitMq__Port=5672 - App__RabbitMq__Port=5672
- App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required} - App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required}
- App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required} - App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required}
- App__RabbitMq__VirtualHost=${RABBITMQ_VIRTUAL_HOST:-/}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -92,4 +93,4 @@ volumes:
networks: networks:
internal: internal:
driver: bridge driver: bridge
@@ -0,0 +1,72 @@
@echo off
setlocal
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..\..") do set "REPOSITORY_ROOT=%%~fI"
set "ENV_FILE=%SCRIPT_DIR%.env.Development"
set "BASE_COMPOSE=%SCRIPT_DIR%docker-compose.yml"
set "DEVELOPMENT_COMPOSE=%SCRIPT_DIR%docker-compose.Development.yml"
where docker >nul 2>&1
if errorlevel 1 goto docker_missing
docker info >nul 2>&1
if errorlevel 1 goto docker_unavailable
if not exist "%ENV_FILE%" goto environment_missing
pushd "%REPOSITORY_ROOT%"
if errorlevel 1 goto repository_unavailable
echo Validating the development Docker Compose configuration...
docker compose --env-file "%ENV_FILE%" -f "%BASE_COMPOSE%" -f "%DEVELOPMENT_COMPOSE%" config --quiet
if errorlevel 1 goto compose_invalid
if /i "%~1"=="--validate-only" goto validation_complete
echo Building and starting the Notification Service development environment...
docker compose --env-file "%ENV_FILE%" -f "%BASE_COMPOSE%" -f "%DEVELOPMENT_COMPOSE%" up --build -d
if errorlevel 1 goto installation_failed
echo.
docker compose --env-file "%ENV_FILE%" -f "%BASE_COMPOSE%" -f "%DEVELOPMENT_COMPOSE%" ps
echo.
echo Notification Service development environment is running.
echo Admin: http://localhost:5200/admin/channels
echo RabbitMQ: http://localhost:15672
echo Mailpit: http://localhost:8025
echo Seq: http://localhost:5342
echo.
popd
exit /b 0
:validation_complete
echo Development Docker Compose configuration is valid.
popd
exit /b 0
:compose_invalid
echo ERROR: The development Docker Compose configuration is invalid.
popd
exit /b 1
:installation_failed
echo ERROR: Failed to build or start the development environment.
popd
exit /b 1
:docker_missing
echo ERROR: Docker CLI was not found. Install and start Docker Desktop, then run this script again.
exit /b 1
:docker_unavailable
echo ERROR: Docker is installed, but the Docker engine is not available. Start Docker Desktop and try again.
exit /b 1
:environment_missing
echo ERROR: Development environment file was not found: "%ENV_FILE%"
exit /b 1
:repository_unavailable
echo ERROR: Repository root is not available: "%REPOSITORY_ROOT%"
exit /b 1
+115
View File
@@ -0,0 +1,115 @@
# ItemTracker Outbox Email Consumer
## Contract and routing
The Worker consumes durable messages from queue `notification.send-email`. ItemTracker publishes through RabbitMQ's default exchange, so the queue name is also the routing key. No custom exchange binding is required.
`HrynCo.RabbitMq` owns the transport lifecycle: connection, queue declaration, deserialization, prefetch, structured delivery context, retry, terminal-failure notification, and manual ACK/NACK. Notification Service only supplies its queue, contract validation, email handler, and result publisher through the package's generic extension points. This integration requires package version `1.0.17` or newer.
Accepted messages must have all of the following:
- AMQP `Type`: `Notification.SendEmail.v1`
- AMQP `MessageId`: the non-empty stable ItemTracker Outbox ID
- JSON content using the PascalCase `SendEmailMessage` envelope
- non-empty `CorrelationContext.CorrelationId`
- non-empty `ServiceName`, `TemplateKey`, `RecipientEmail`, `RecipientName`, and `LanguageCode`
- a valid recipient address and a non-null `Variables` object
The service resolves a template by the exact `ServiceName`, `TemplateKey`, and normalized lower-case `LanguageCode`. It does not silently fall back to another language. Every variable marked `Required` by the selected template must have a non-empty value before rendering.
## Delivery and acknowledgement policy
The consumer uses manual acknowledgements and prefetches one message at a time.
1. Unsupported contract metadata, malformed JSON, or invalid required fields are logged and nacked without requeue.
2. Template, provider, quota, SMTP, or usage-accounting failures are retried in-process three times with a five-second delay.
3. The message is acknowledged only after SMTP accepts the email and provider usage is incremented.
4. If `CorrelationContext.ReplyTo` is present, result publication is attempted after delivery. It is best-effort: a reply-queue failure is logged but does not make the SMTP delivery fail, because retrying after SMTP success could send a duplicate email.
5. After the final processing retry fails, the shared terminal-failure hook publishes one
negative result to the same `ReplyTo`. It retains the original correlation and contains
a normalized diagnostic string limited to 2000 characters. Common SMTP DNS,
connection-refused, timeout, and recipient-rejection failures receive safe explanatory
context while retaining the low-level provider reason. Hostnames, recipient addresses,
and credentials are not added. Client outboxes store this as a neutral delivery error
rather than interpreting SMTP-specific exception types.
Success and terminal failure result publication remain best-effort. If the result queue
cannot be reached, the internal exception is logged and the original delivery follows its
normal ACK/NACK policy. Clients therefore must not interpret the absence of a result as a
confirmed failure.
Delivery remains at least once. There is no inbox/deduplication store in this MVP, so a broker redelivery or a process failure after SMTP acceptance but before acknowledgement can produce a duplicate. `MessageId`, `CorrelationId`, and `MessageType` are added to the structured log scope for correlation. Recipient addresses are masked, and template variable names/values and rendered links are not logged.
No database migration is required. Existing channel selection, SMTP settings, quota checks, and usage counters remain in place.
## Local end-to-end setup
With Docker Desktop running, install and start the complete Notification Service
development stack from the repository root:
```powershell
.\docker\environments\install-development.cmd
```
The script uses the tracked `docker/environments/.env.Development` defaults. The
following manual setup is only needed when overriding those values locally.
Create a local environment file outside source control, for example `docker/environments/.env.local`, with these values:
```dotenv
DB_NAME=notification_service
DB_USER=postgres
DB_PASS=postgres
VOLUME_PREFIX=ns-dev
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
RABBITMQ_VIRTUAL_HOST=/
RABBITMQ_AMQP_PORT=5672
RABBITMQ_MANAGEMENT_PORT=15672
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
```
Start the Notification Service stack from the repository root:
```powershell
docker compose --env-file docker/environments/.env.local `
-f docker/environments/docker-compose.yml `
-f docker/environments/docker-compose.Development.yml `
up --build -d
```
Development compose exposes:
- Notification Service admin: `http://localhost:5200/admin/channels` and `/admin/templates`
- RabbitMQ management: `http://localhost:15672`
- Mailpit inbox: `http://localhost:8025`
- Seq: `http://localhost:5342`
The development migrator idempotently creates an active SMTP channel for
`DEVELOPMENT_EMAIL_SERVICE_NAME` using host `mailpit`, port `1025`, SSL disabled, and
blank credentials. It also creates a neutral English `TestEmail` template with
`RecipientName` and `Message` variables. Existing channels and templates are left
unchanged. Use the admin UI to inspect or customize them and to add client-specific
service names, template keys, or languages.
Configure ItemTracker to use the same RabbitMQ host, port, credentials, virtual host, queue `notification.send-email`, and service name. When ItemTracker runs outside Docker against the development stack, the broker is `localhost:5672`; from a Docker container it is the reachable host or shared-network name. Start the ItemTracker Outbox Worker in `RabbitMq` mode, trigger a production-safe notification to an owned test address, then verify:
1. the ItemTracker Outbox row becomes published;
2. RabbitMQ delivers and removes the message from `notification.send-email`;
3. Notification Service logs show the same `MessageId` and `CorrelationId` without the full address;
4. Mailpit shows exactly one rendered email;
5. the Notification Service channel usage counter increments;
6. the client result queue is consumed and the matching outbox row records successful delivery.
## Production configuration and smoke test
Production requires the same `App__RabbitMq__Host`, `Port`, `User`, `Password`, and `VirtualHost` values as ItemTracker's publisher, plus the Notification Service database connection. The production compose file obtains these from deployment environment variables; secrets must stay in the deployment secret store. The queue is declared durable by both producer and consumer.
This Notification Service revision restores the published immutable
`HrynCo.RabbitMq` version `1.0.17` from NuGet.org. Build and deployment environments
therefore require NuGet.org access or a trusted package mirror containing that exact
version.
Before enabling the Worker, verify that the target service has an active SMTP channel and exact-language templates for every queued ItemTracker template key. Inspect any delayed ItemTracker backlog for expired password-reset, verification, or invitation messages before draining it.
For the smoke test, use an owned test account and a non-sensitive notification template. Record the Outbox `Id` and `CorrelationId`, trigger only one message, follow those identifiers through Outbox publication and Notification Service logs, and confirm receipt with the configured SMTP provider. Do not copy payloads, tokens, credentials, full recipient addresses, or rendered URLs into tickets or logs.