Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85b1f1fdfc | |||
| 50033a5bd4 | |||
| 01c622038d | |||
| 1ceb71b9a2 | |||
| bb0de0f2c1 | |||
| d5a2e538f0 | |||
| 9d6b0717e6 | |||
| 2757869176 | |||
| b8435ac07b | |||
| cc3857a409 | |||
| 94f0d45aaf | |||
| 07f536938f | |||
| e7d3953747 | |||
| 3381fcc2f8 |
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# Local Docker Compose secrets and ports
|
||||
docker/environments/.env.local
|
||||
|
||||
@@ -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.
|
||||
@@ -30,7 +30,7 @@
|
||||
<PackageVersion Include="Serilog.Sinks.Seq" Version="9.0.0" />
|
||||
<!-- HrynCo shared packages -->
|
||||
<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="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
@@ -39,4 +39,4 @@
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
|
||||
public interface IEmailTemplateRepository
|
||||
{
|
||||
Task<IReadOnlyList<EmailTemplate>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<EmailTemplate>> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<EmailTemplate>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
|
||||
Task<EmailTemplate?> GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default);
|
||||
Task AddAsync(EmailTemplate template, CancellationToken ct = default);
|
||||
Task UpdateAsync(EmailTemplate template, CancellationToken ct = default);
|
||||
Task DeleteAsync(EmailTemplate template, CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,22 @@ internal sealed class EmailTemplateRepository
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailTemplate>> GetAllAsync(CancellationToken ct = default)
|
||||
public async Task<IReadOnlyList<EmailTemplate>> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default)
|
||||
{
|
||||
List<EmailTemplateEntity> entities = await EfRepository.Get()
|
||||
IQueryable<EmailTemplateEntity> query = EfRepository.Get();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(serviceName))
|
||||
{
|
||||
query = query.Where(x => x.ServiceName == serviceName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
query = query.Where(x => x.Key == key);
|
||||
}
|
||||
|
||||
List<EmailTemplateEntity> entities = await query
|
||||
.OrderBy(x => x.ServiceName).ThenBy(x => x.Key)
|
||||
.AsNoTracking()
|
||||
.ToListAsync(ct);
|
||||
return entities.Select(MapToDomain).ToList();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using HrynCo.NotificationService.Migrator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Serilog;
|
||||
@@ -21,8 +23,8 @@ try
|
||||
var connectionString = ctx.Configuration["App:ConnectionString"]
|
||||
?? throw new InvalidOperationException("App:ConnectionString is not configured.");
|
||||
|
||||
services.AddDbContext<NotificationDbContext>(options =>
|
||||
options.UseNpgsql(connectionString));
|
||||
services.AddNotificationDataAccess(connectionString);
|
||||
services.AddScoped<DevelopmentDataSeeder>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
@@ -32,6 +34,18 @@ try
|
||||
Log.Information("Applying migrations...");
|
||||
await db.Database.MigrateAsync();
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_HtmlEncodesVariablesWithoutChangingSubjectOrTextBody()
|
||||
{
|
||||
EmailTemplate template = CreateTemplate();
|
||||
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||
{
|
||||
["AppName"] = "Invemory <script>alert('xss')</script>",
|
||||
["VerificationUrl"] = "https://example.invalid/verify?next=\" onclick=\"alert('xss')"
|
||||
});
|
||||
|
||||
RenderedEmail result = _service.Render(template, data);
|
||||
|
||||
Assert.Equal("Verify Invemory <script>alert('xss')</script>", result.Subject);
|
||||
Assert.Equal(
|
||||
"<a href=\"https://example.invalid/verify?next=" onclick="alert('xss')\">Verify</a>",
|
||||
result.HtmlBody);
|
||||
Assert.Equal(
|
||||
"Verify at https://example.invalid/verify?next=\" onclick=\"alert('xss')",
|
||||
result.TextBody);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
+30
@@ -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>());
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+107
@@ -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>()
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -10,6 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
</ItemGroup>
|
||||
@@ -21,6 +22,10 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.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>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace HrynCo.NotificationService.Services.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ internal sealed class GetAllEmailTemplatesHandler
|
||||
protected override async Task<ServiceResult<IReadOnlyList<EmailTemplate>>> DoOnHandle(
|
||||
GetAllEmailTemplatesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var templates = await _templates.GetAllAsync(cancellationToken);
|
||||
var templates = await _templates.GetAllAsync(request.ServiceName, request.Key, cancellationToken);
|
||||
return Success(templates);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,4 +4,5 @@ using HrynCo.NotificationService.Services.Core;
|
||||
|
||||
namespace HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
||||
|
||||
public sealed record GetAllEmailTemplatesQuery : IRequest<ServiceResult<IReadOnlyList<EmailTemplate>>>;
|
||||
public sealed record GetAllEmailTemplatesQuery(string? ServiceName = null, string? Key = null)
|
||||
: IRequest<ServiceResult<IReadOnlyList<EmailTemplate>>>;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class AdminTemplatesIndexViewTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateAndEditLinks_UseExplicitFilterQueryInterpolation()
|
||||
{
|
||||
string view = File.ReadAllText(FindIndexView());
|
||||
|
||||
Assert.Contains("/admin/templates/create@(filterQuery)", view);
|
||||
Assert.Contains("@t.LanguageCode@(filterQuery)", view);
|
||||
Assert.DoesNotContain("create@filterQuery", view);
|
||||
Assert.DoesNotContain("LanguageCode@filterQuery", view);
|
||||
}
|
||||
|
||||
private static string FindIndexView()
|
||||
{
|
||||
DirectoryInfo? directory = new(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "HrynCo.NotificationService.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
Assert.NotNull(directory);
|
||||
return Path.Combine(
|
||||
directory.FullName,
|
||||
"HrynCo.NotificationService.Web",
|
||||
"Views",
|
||||
"AdminTemplates",
|
||||
"Index.cshtml");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,12 @@ public class AdminTemplatesController : Controller
|
||||
|
||||
// GET /admin/templates
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index(CancellationToken ct)
|
||||
public async Task<IActionResult> Index([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken ct)
|
||||
{
|
||||
var result = await _mediator.Send(new GetAllEmailTemplatesQuery(), ct);
|
||||
ViewData["ServiceNameFilter"] = serviceName;
|
||||
ViewData["KeyFilter"] = key;
|
||||
|
||||
var result = await _mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), ct);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load templates.");
|
||||
@@ -37,14 +40,24 @@ public class AdminTemplatesController : Controller
|
||||
|
||||
// GET /admin/templates/create
|
||||
[HttpGet("create")]
|
||||
public IActionResult Create()
|
||||
public IActionResult Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter)
|
||||
{
|
||||
return View("Edit", new EmailTemplateEditViewModel());
|
||||
return View("Edit", new EmailTemplateEditViewModel
|
||||
{
|
||||
ServiceNameFilter = serviceNameFilter,
|
||||
KeyFilter = keyFilter
|
||||
});
|
||||
}
|
||||
|
||||
// GET /admin/templates/{serviceName}/{key}/{languageCode}
|
||||
[HttpGet("{serviceName}/{key}/{languageCode}")]
|
||||
public async Task<IActionResult> Edit(string serviceName, string key, string languageCode, CancellationToken ct)
|
||||
public async Task<IActionResult> Edit(
|
||||
string serviceName,
|
||||
string key,
|
||||
string languageCode,
|
||||
[FromQuery] string? serviceNameFilter,
|
||||
[FromQuery] string? keyFilter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await _mediator.Send(new GetEmailTemplateQuery(serviceName, key, languageCode), ct);
|
||||
if (!result.IsSuccess || result.Result is null)
|
||||
@@ -60,7 +73,9 @@ public class AdminTemplatesController : Controller
|
||||
Subject = template.Subject,
|
||||
HtmlBody = template.HtmlBody,
|
||||
TextBody = template.TextBody,
|
||||
VariablesJson = JsonSerializer.Serialize(template.Variables)
|
||||
VariablesJson = JsonSerializer.Serialize(template.Variables),
|
||||
ServiceNameFilter = serviceNameFilter,
|
||||
KeyFilter = keyFilter
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
@@ -124,15 +139,21 @@ public class AdminTemplatesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
return RedirectToAction(nameof(Index), new { serviceName = model.ServiceNameFilter, key = model.KeyFilter });
|
||||
}
|
||||
|
||||
// POST /admin/templates/{serviceName}/{key}/{languageCode}/delete
|
||||
[HttpPost("{serviceName}/{key}/{languageCode}/delete")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(string serviceName, string key, string languageCode, CancellationToken ct)
|
||||
public async Task<IActionResult> Delete(
|
||||
string serviceName,
|
||||
string key,
|
||||
string languageCode,
|
||||
[FromForm] string? serviceNameFilter,
|
||||
[FromForm] string? keyFilter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await _mediator.Send(new DeleteEmailTemplateCommand(serviceName, key, languageCode), ct);
|
||||
return RedirectToAction(nameof(Index));
|
||||
return RedirectToAction(nameof(Index), new { serviceName = serviceNameFilter, key = keyFilter });
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -25,6 +25,8 @@ public class EmailTemplateEditViewModel
|
||||
|
||||
// JSON array: [{"name":"UserName","required":true}, ...]
|
||||
public string VariablesJson { get; set; } = "[]";
|
||||
public string? ServiceNameFilter { get; set; }
|
||||
public string? KeyFilter { get; set; }
|
||||
|
||||
public bool IsNew => Id == null;
|
||||
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
using HrynCo.NotificationService.Web.Infrastructure;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Create;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Get;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.GetByService;
|
||||
using HrynCo.NotificationService.Services.EmailTemplates.Update;
|
||||
@@ -15,9 +16,9 @@ public sealed class EmailTemplatesController : ApiControllerBase
|
||||
public EmailTemplatesController(IMediator mediator) : base(mediator) { }
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] string serviceName, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> GetAll([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await Mediator.Send(new GetEmailTemplatesQuery(serviceName), cancellationToken);
|
||||
var result = await Mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), cancellationToken);
|
||||
return FromServiceResult(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<input type="hidden" name="IsNew" value="@Model.IsNew" />
|
||||
<input asp-for="ServiceNameFilter" type="hidden" />
|
||||
<input asp-for="KeyFilter" type="hidden" />
|
||||
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
{
|
||||
@@ -116,7 +118,7 @@
|
||||
<button type="submit" form="templateForm" class="btn btn-primary">
|
||||
<i class="bi bi-floppy me-1"></i> Save
|
||||
</button>
|
||||
<a href="/admin/templates" class="btn btn-secondary">
|
||||
<a href="/admin/templates@(string.IsNullOrWhiteSpace(Model.ServiceNameFilter) && string.IsNullOrWhiteSpace(Model.KeyFilter) ? string.Empty : $"?serviceName={Uri.EscapeDataString(Model.ServiceNameFilter ?? string.Empty)}&key={Uri.EscapeDataString(Model.KeyFilter ?? string.Empty)}")" class="btn btn-secondary">
|
||||
<i class="bi bi-x-lg me-1"></i> Cancel
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -2,15 +2,50 @@
|
||||
@model IReadOnlyList<EmailTemplate>
|
||||
@{
|
||||
ViewData["Title"] = "Email Templates";
|
||||
var serviceNameFilter = ViewData["ServiceNameFilter"] as string ?? string.Empty;
|
||||
var keyFilter = ViewData["KeyFilter"] as string ?? string.Empty;
|
||||
var filterQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
|
||||
? string.Empty
|
||||
: $"?serviceNameFilter={Uri.EscapeDataString(serviceNameFilter)}&keyFilter={Uri.EscapeDataString(keyFilter)}";
|
||||
var listQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
|
||||
? string.Empty
|
||||
: $"?serviceName={Uri.EscapeDataString(serviceNameFilter)}&key={Uri.EscapeDataString(keyFilter)}";
|
||||
}
|
||||
|
||||
<div class="page-header">
|
||||
<h2><i class="bi bi-envelope-paper"></i> Email Templates</h2>
|
||||
<a href="/admin/templates/create" class="btn btn-primary btn-sm">
|
||||
<a href="/admin/templates/create@(filterQuery)" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i> Create New Template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<form id="templateFiltersForm" method="get" action="/admin/templates" class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label fw-semibold" for="serviceName">Service Name</label>
|
||||
<input id="serviceName"
|
||||
name="serviceName"
|
||||
value="@serviceNameFilter"
|
||||
class="form-control"
|
||||
placeholder="Filter by service name" />
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label fw-semibold" for="key">Key</label>
|
||||
<input id="key"
|
||||
name="key"
|
||||
value="@keyFilter"
|
||||
class="form-control"
|
||||
placeholder="Filter by key" />
|
||||
</div>
|
||||
<div class="col-12 col-md-2 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
||||
<a id="clearTemplateFilters" href="/admin/templates" class="btn btn-outline-secondary w-100">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
@@ -21,6 +56,61 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const storageKey = 'hrynco.notificationService.adminTemplates.filters';
|
||||
const form = document.getElementById('templateFiltersForm');
|
||||
const serviceNameInput = document.getElementById('serviceName');
|
||||
const keyInput = document.getElementById('key');
|
||||
const clearLink = document.getElementById('clearTemplateFilters');
|
||||
|
||||
if (!form || !serviceNameInput || !keyInput || !clearLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
const saveState = () => {
|
||||
const state = {
|
||||
serviceName: serviceNameInput.value ?? '',
|
||||
key: keyInput.value ?? ''
|
||||
};
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
||||
};
|
||||
|
||||
const restoreState = () => {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const state = JSON.parse(raw);
|
||||
const serviceName = typeof state.serviceName === 'string' ? state.serviceName : '';
|
||||
const key = typeof state.key === 'string' ? state.key : '';
|
||||
|
||||
serviceNameInput.value = serviceName;
|
||||
keyInput.value = key;
|
||||
|
||||
return serviceName.length > 0 || key.length > 0;
|
||||
} catch {
|
||||
localStorage.removeItem(storageKey);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener('submit', saveState);
|
||||
clearLink.addEventListener('click', () => localStorage.removeItem(storageKey));
|
||||
|
||||
const hasQueryParams = new URLSearchParams(window.location.search).toString().length > 0;
|
||||
if (!hasQueryParams && restoreState()) {
|
||||
form.requestSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
saveState();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@if (Model is null || Model.Count == 0)
|
||||
{
|
||||
<div class="card shadow-sm table-card">
|
||||
@@ -56,13 +146,15 @@ else
|
||||
<td>@t.LanguageCode</td>
|
||||
<td>@t.Subject</td>
|
||||
<td class="text-end">
|
||||
<a href="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode"
|
||||
<a href="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode@(filterQuery)"
|
||||
class="btn btn-sm btn-outline-primary me-1">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</a>
|
||||
<form method="post"
|
||||
action="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode/delete"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="serviceNameFilter" value="@serviceNameFilter" />
|
||||
<input type="hidden" name="keyFilter" value="@keyFilter" />
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
|
||||
+28
-2
@@ -1,5 +1,6 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
@@ -8,17 +9,42 @@ internal sealed class EmailTemplateRenderingService : IEmailTemplateRenderingSer
|
||||
{
|
||||
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(
|
||||
Interpolate(template.Subject, data.Variables),
|
||||
Interpolate(template.HtmlBody, data.Variables),
|
||||
InterpolateHtml(template.HtmlBody, data.Variables),
|
||||
Interpolate(template.TextBody, data.Variables));
|
||||
}
|
||||
|
||||
private static string InterpolateHtml(string text, IReadOnlyDictionary<string, string> variables)
|
||||
{
|
||||
return Interpolate(text, variables, WebUtility.HtmlEncode);
|
||||
}
|
||||
|
||||
private static string Interpolate(string text, IReadOnlyDictionary<string, string> variables)
|
||||
{
|
||||
return Interpolate(text, variables, static value => value);
|
||||
}
|
||||
|
||||
private static string Interpolate(
|
||||
string text,
|
||||
IReadOnlyDictionary<string, string> variables,
|
||||
Func<string, string> encodeValue)
|
||||
{
|
||||
var sb = new StringBuilder(text);
|
||||
foreach (var (key, value) in variables)
|
||||
sb.Replace($"{{{{{key}}}}}", value);
|
||||
sb.Replace($"{{{{{key}}}}}", encodeValue(value));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,15 @@ internal sealed class EmailTemplateService : IEmailTemplateService
|
||||
string? languageCode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode;
|
||||
var template = await _templateRepository.GetAsync(serviceName, templateKey, lang, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(languageCode))
|
||||
throw new InvalidDataException("LanguageCode is required.");
|
||||
|
||||
if (template is null && lang != "en")
|
||||
template = await _templateRepository.GetAsync(serviceName, templateKey, "en", cancellationToken);
|
||||
string lang = languageCode.Trim().ToLowerInvariant();
|
||||
EmailTemplate? template = await _templateRepository.GetAsync(
|
||||
serviceName,
|
||||
templateKey,
|
||||
lang,
|
||||
cancellationToken);
|
||||
|
||||
return template
|
||||
?? throw new InvalidOperationException(
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
+80
@@ -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();
|
||||
}
|
||||
}
|
||||
+63
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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}";
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using System.Text;
|
||||
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.RabbitMq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
internal sealed class SendEmailService : ISendEmailService
|
||||
@@ -16,7 +12,8 @@ internal sealed class SendEmailService : ISendEmailService
|
||||
private readonly IEmailChannelUsageRepository _usageRepository;
|
||||
private readonly IEmailTemplateService _templateService;
|
||||
private readonly IEmailTemplateRenderingService _templateRenderingService;
|
||||
private readonly IRabbitMqPublisher _publisher;
|
||||
private readonly ISmtpEmailSender _smtpEmailSender;
|
||||
private readonly INotificationResultPublisher _resultPublisher;
|
||||
private readonly ILogger<SendEmailService> _logger;
|
||||
|
||||
public SendEmailService(
|
||||
@@ -24,24 +21,28 @@ internal sealed class SendEmailService : ISendEmailService
|
||||
IEmailChannelUsageRepository usageRepository,
|
||||
IEmailTemplateService templateService,
|
||||
IEmailTemplateRenderingService templateRenderingService,
|
||||
IRabbitMqPublisher publisher,
|
||||
ISmtpEmailSender smtpEmailSender,
|
||||
INotificationResultPublisher resultPublisher,
|
||||
ILogger<SendEmailService> logger)
|
||||
{
|
||||
_channelRepository = channelRepository;
|
||||
_usageRepository = usageRepository;
|
||||
_templateService = templateService;
|
||||
_templateRenderingService = templateRenderingService;
|
||||
_publisher = publisher;
|
||||
_smtpEmailSender = smtpEmailSender;
|
||||
_resultPublisher = resultPublisher;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
SendEmailMessageValidator.Validate(message);
|
||||
SendEmailMessageData data = message.Data;
|
||||
string redactedRecipient = RecipientAddressRedactor.Redact(data.RecipientEmail);
|
||||
|
||||
_logger.LogInformation(
|
||||
"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);
|
||||
EmailTemplate template = await GetTemplateAsync(data, cancellationToken);
|
||||
@@ -56,34 +57,12 @@ internal sealed class SendEmailService : ISendEmailService
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new SmtpClient(smtpChannel.Host, smtpChannel.Port)
|
||||
{
|
||||
EnableSsl = smtpChannel.UseSsl,
|
||||
Credentials = string.IsNullOrWhiteSpace(smtpChannel.Username)
|
||||
? null
|
||||
: new NetworkCredential(smtpChannel.Username, smtpChannel.Password)
|
||||
};
|
||||
|
||||
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);
|
||||
await _smtpEmailSender.SendAsync(
|
||||
smtpChannel,
|
||||
rendered,
|
||||
data.RecipientEmail,
|
||||
data.RecipientName,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -98,9 +77,9 @@ internal sealed class SendEmailService : ISendEmailService
|
||||
|
||||
_logger.LogInformation(
|
||||
"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)
|
||||
@@ -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.AddScoped<IEmailTemplateService, EmailTemplateService>();
|
||||
services.AddScoped<IEmailTemplateRenderingService, EmailTemplateRenderingService>();
|
||||
services.AddScoped<ISmtpEmailSender, SmtpEmailSender>();
|
||||
services.AddScoped<INotificationResultPublisher, NotificationResultPublisher>();
|
||||
services.AddScoped<ISendEmailService, SendEmailService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
|
||||
@@ -1,14 +1,17 @@
|
||||
namespace HrynCo.NotificationService.Worker;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using Hrynco.RabbitMq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||
using Hrynco.RabbitMq;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
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 ILogger<SendEmailConsumer> _logger;
|
||||
|
||||
public SendEmailConsumer(
|
||||
IOptionsMonitor<RabbitMqSettings> options,
|
||||
@@ -17,16 +20,51 @@ public sealed class SendEmailConsumer : RabbitMqConsumerBase<SendEmailMessage, S
|
||||
: base(options, logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private const string IncomingQueue = "notification.send-email";
|
||||
|
||||
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();
|
||||
var service = scope.ServiceProvider.GetRequiredService<ISendEmailService>();
|
||||
validationError = SendEmailDeliveryValidator.GetValidationError(
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,98 @@
|
||||
# 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
|
||||
|
||||
```mermaid
|
||||
@@ -7,11 +100,19 @@ flowchart TD
|
||||
A[Worker host starts] --> B[Load config and register services]
|
||||
B --> C[Start SendEmailConsumer]
|
||||
C --> D[Receive message from notification.send-email]
|
||||
D --> E[Resolve SendEmailService]
|
||||
E --> F[Pick channel and template]
|
||||
F --> G[Render email content]
|
||||
G --> H[Send via SMTP]
|
||||
H --> I[Update usage counters]
|
||||
I --> J[Optionally publish result to reply queue]
|
||||
H -. failure .-> K[Log and rethrow]
|
||||
D --> E[Validate Notification.SendEmail.v1 metadata and payload]
|
||||
E --> F[Resolve SendEmailService]
|
||||
F --> G[Pick channel and exact-language template]
|
||||
G --> H[Validate variables and render email content]
|
||||
H --> I[Send via SMTP]
|
||||
I --> J[Update usage counters]
|
||||
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]
|
||||
```
|
||||
|
||||
Template variables are treated as plain text. The worker HTML-encodes every variable while
|
||||
rendering `HtmlBody`; subject and plain-text body interpolation preserve the original value.
|
||||
Templates must express markup in `body.html` instead of supplying HTML through variables.
|
||||
|
||||
@@ -7,3 +7,4 @@ RABBITMQ_PASSWORD=guest
|
||||
RABBITMQ_AMQP_PORT=5672
|
||||
RABBITMQ_MANAGEMENT_PORT=15672
|
||||
WEB_PORT=5200
|
||||
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
|
||||
|
||||
@@ -3,7 +3,9 @@ name: hrynco-notification-service
|
||||
services:
|
||||
migrator:
|
||||
environment:
|
||||
- DOTNET_ENVIRONMENT=Development
|
||||
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
|
||||
- DevelopmentSeed__ServiceName=${DEVELOPMENT_EMAIL_SERVICE_NAME:-TestService}
|
||||
|
||||
web:
|
||||
environment:
|
||||
@@ -29,9 +31,6 @@ services:
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: guest
|
||||
RABBITMQ_DEFAULT_PASS: guest
|
||||
ports:
|
||||
- "5672:5672"
|
||||
- "15672:15672"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
@@ -55,10 +54,17 @@ services:
|
||||
networks:
|
||||
- internal
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
name: ns-dev-pgdata
|
||||
rabbitmq_data:
|
||||
name: ns-dev-rabbitmq-data
|
||||
seq_data:
|
||||
name: ns-dev-seq
|
||||
name: ns-dev-seq
|
||||
|
||||
@@ -40,6 +40,7 @@ services:
|
||||
- App__RabbitMq__Port=5672
|
||||
- App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required}
|
||||
- App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required}
|
||||
- App__RabbitMq__VirtualHost=${RABBITMQ_VIRTUAL_HOST:-/}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -92,4 +93,4 @@ volumes:
|
||||
|
||||
networks:
|
||||
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
|
||||
@@ -0,0 +1,121 @@
|
||||
# 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.
|
||||
|
||||
The template administration list preserves optional Service Name and Key filters when an
|
||||
administrator opens the create or edit screen. These links use explicit Razor expression
|
||||
boundaries so the filter query is appended as query parameters rather than rendered as a
|
||||
literal `@filterQuery` path segment. With no active filters, the create route is exactly
|
||||
`/admin/templates/create`.
|
||||
|
||||
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.
|
||||
Reference in New Issue
Block a user