Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d6b0717e6 | |||
| 2757869176 | |||
| b8435ac07b | |||
| cc3857a409 | |||
| 94f0d45aaf | |||
| 07f536938f | |||
| e7d3953747 | |||
| 3381fcc2f8 | |||
| 3bd952ef9e | |||
| 285cc6abb7 | |||
| 9589095760 | |||
| 25fb48ccf0 |
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
# Vim temporary swap files
|
# Vim temporary swap files
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Local Docker Compose secrets and ports
|
||||||
|
docker/environments/.env.local
|
||||||
|
|||||||
@@ -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" />
|
<PackageVersion Include="Serilog.Sinks.Seq" Version="9.0.0" />
|
||||||
<!-- HrynCo shared packages -->
|
<!-- HrynCo shared packages -->
|
||||||
<PackageVersion Include="HrynCo.Common" Version="1.0.11" />
|
<PackageVersion Include="HrynCo.Common" Version="1.0.11" />
|
||||||
<PackageVersion Include="HrynCo.RabbitMq" Version="1.0.15" />
|
<PackageVersion Include="HrynCo.RabbitMq" Version="1.0.17" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
@@ -39,4 +39,4 @@
|
|||||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
|||||||
|
|
||||||
public interface IEmailTemplateRepository
|
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<IReadOnlyList<EmailTemplate>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
|
||||||
Task<EmailTemplate?> GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default);
|
Task<EmailTemplate?> GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default);
|
||||||
Task AddAsync(EmailTemplate template, CancellationToken ct = default);
|
Task AddAsync(EmailTemplate template, CancellationToken ct = default);
|
||||||
Task UpdateAsync(EmailTemplate template, CancellationToken ct = default);
|
Task UpdateAsync(EmailTemplate template, CancellationToken ct = default);
|
||||||
Task DeleteAsync(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()
|
.AsNoTracking()
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return entities.Select(MapToDomain).ToList();
|
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.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
@@ -21,8 +23,8 @@ try
|
|||||||
var connectionString = ctx.Configuration["App:ConnectionString"]
|
var connectionString = ctx.Configuration["App:ConnectionString"]
|
||||||
?? throw new InvalidOperationException("App:ConnectionString is not configured.");
|
?? throw new InvalidOperationException("App:ConnectionString is not configured.");
|
||||||
|
|
||||||
services.AddDbContext<NotificationDbContext>(options =>
|
services.AddNotificationDataAccess(connectionString);
|
||||||
options.UseNpgsql(connectionString));
|
services.AddScoped<DevelopmentDataSeeder>();
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
@@ -32,6 +34,18 @@ try
|
|||||||
Log.Information("Applying migrations...");
|
Log.Information("Applying migrations...");
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
Log.Information("Migrations applied successfully.");
|
Log.Information("Migrations applied successfully.");
|
||||||
|
|
||||||
|
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
|
||||||
|
if (environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
string serviceName = host.Services.GetRequiredService<IConfiguration>()["DevelopmentSeed:ServiceName"]
|
||||||
|
?? DevelopmentDataSeeder.DefaultServiceName;
|
||||||
|
|
||||||
|
Log.Information("Seeding development email configuration...");
|
||||||
|
var seeder = scope.ServiceProvider.GetRequiredService<DevelopmentDataSeeder>();
|
||||||
|
await seeder.SeedAsync(serviceName);
|
||||||
|
Log.Information("Development email configuration is ready.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
using HrynCo.NotificationService.Migrator;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
public sealed class DevelopmentDataSeederTests
|
||||||
|
{
|
||||||
|
private readonly IEmailChannelRepository _channels = Substitute.For<IEmailChannelRepository>();
|
||||||
|
private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate()
|
||||||
|
{
|
||||||
|
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(Array.Empty<EmailChannel>());
|
||||||
|
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
|
||||||
|
.Returns((EmailTemplate?)null);
|
||||||
|
|
||||||
|
DevelopmentDataSeeder seeder = CreateSeeder();
|
||||||
|
|
||||||
|
await seeder.SeedAsync("TestService", CancellationToken.None);
|
||||||
|
|
||||||
|
await _channels.Received(1).AddAsync(
|
||||||
|
Arg.Is<EmailChannel>(channel => IsMailpitChannel(channel)),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
await _templates.Received(1).AddAsync(
|
||||||
|
Arg.Is<EmailTemplate>(template =>
|
||||||
|
template.ServiceName == "TestService" &&
|
||||||
|
template.Key == "TestEmail" &&
|
||||||
|
template.Variables.Any(variable => variable.Name == "Message" && variable.Required)),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt()
|
||||||
|
{
|
||||||
|
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
|
||||||
|
.Returns([new EmailChannel
|
||||||
|
{
|
||||||
|
ServiceName = "TestService",
|
||||||
|
EmailChannelType = EmailChannelType.Smtp,
|
||||||
|
Settings = new SmtpChannelSettings()
|
||||||
|
}]);
|
||||||
|
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(new EmailTemplate
|
||||||
|
{
|
||||||
|
ServiceName = "TestService",
|
||||||
|
Key = "TestEmail",
|
||||||
|
LanguageCode = "en",
|
||||||
|
Subject = "Existing",
|
||||||
|
HtmlBody = "Existing",
|
||||||
|
TextBody = "Existing"
|
||||||
|
});
|
||||||
|
|
||||||
|
DevelopmentDataSeeder seeder = CreateSeeder();
|
||||||
|
|
||||||
|
await seeder.SeedAsync("TestService", CancellationToken.None);
|
||||||
|
|
||||||
|
await _channels.DidNotReceive().AddAsync(Arg.Any<EmailChannel>(), Arg.Any<CancellationToken>());
|
||||||
|
await _templates.DidNotReceive().AddAsync(Arg.Any<EmailTemplate>(), Arg.Any<CancellationToken>());
|
||||||
|
await _channels.DidNotReceive().UpdateAsync(Arg.Any<EmailChannel>(), Arg.Any<CancellationToken>());
|
||||||
|
await _templates.DidNotReceive().UpdateAsync(Arg.Any<EmailTemplate>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private DevelopmentDataSeeder CreateSeeder() => new(
|
||||||
|
_channels,
|
||||||
|
_templates,
|
||||||
|
NullLogger<DevelopmentDataSeeder>.Instance);
|
||||||
|
|
||||||
|
private static bool IsMailpitChannel(EmailChannel channel)
|
||||||
|
{
|
||||||
|
return channel.ServiceName == "TestService" &&
|
||||||
|
channel.IsActive &&
|
||||||
|
channel.Settings is SmtpChannelSettings smtp &&
|
||||||
|
smtp.Host == "mailpit" &&
|
||||||
|
smtp.Port == 1025 &&
|
||||||
|
!smtp.UseSsl;
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public sealed class EmailTemplateRenderingServiceTests
|
||||||
|
{
|
||||||
|
private readonly EmailTemplateRenderingService _service = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_InterpolatesItemTrackerVariables()
|
||||||
|
{
|
||||||
|
EmailTemplate template = CreateTemplate();
|
||||||
|
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["AppName"] = "StoreMate",
|
||||||
|
["VerificationUrl"] = "https://example.invalid/verify"
|
||||||
|
});
|
||||||
|
|
||||||
|
RenderedEmail result = _service.Render(template, data);
|
||||||
|
|
||||||
|
Assert.Equal("Verify StoreMate", result.Subject);
|
||||||
|
Assert.Equal("<a href=\"https://example.invalid/verify\">Verify</a>", result.HtmlBody);
|
||||||
|
Assert.Equal("Verify at https://example.invalid/verify", result.TextBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_WhenRequiredVariableIsMissing_ThrowsObservableFailure()
|
||||||
|
{
|
||||||
|
EmailTemplate template = CreateTemplate();
|
||||||
|
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["AppName"] = "StoreMate"
|
||||||
|
});
|
||||||
|
|
||||||
|
InvalidDataException exception = Assert.Throws<InvalidDataException>(
|
||||||
|
() => _service.Render(template, data));
|
||||||
|
|
||||||
|
Assert.Equal("Required template variables are missing: VerificationUrl.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EmailTemplate CreateTemplate() => new()
|
||||||
|
{
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
Key = "EmailVerification",
|
||||||
|
LanguageCode = "uk",
|
||||||
|
Subject = "Verify {{AppName}}",
|
||||||
|
HtmlBody = "<a href=\"{{VerificationUrl}}\">Verify</a>",
|
||||||
|
TextBody = "Verify at {{VerificationUrl}}",
|
||||||
|
Variables =
|
||||||
|
[
|
||||||
|
new EmailTemplateVariable { Name = "AppName", Required = true },
|
||||||
|
new EmailTemplateVariable { Name = "VerificationUrl", Required = true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SendEmailMessageData CreateData(IReadOnlyDictionary<string, string> variables) => new()
|
||||||
|
{
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
TemplateKey = "EmailVerification",
|
||||||
|
RecipientEmail = "owner@example.com",
|
||||||
|
RecipientName = "Owner",
|
||||||
|
LanguageCode = "uk",
|
||||||
|
Variables = variables
|
||||||
|
};
|
||||||
|
}
|
||||||
+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>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="NSubstitute" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -21,6 +22,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Worker\HrynCo.NotificationService.Worker.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Migrator\HrynCo.NotificationService.Migrator.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.Services.Tests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Mail;
|
using System.Net.Mail;
|
||||||
|
using System.Text;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
using HrynCo.NotificationService.Services.Core;
|
using HrynCo.NotificationService.Services.Core;
|
||||||
@@ -48,14 +49,16 @@ internal sealed class SendEmailHandler
|
|||||||
{
|
{
|
||||||
From = new MailAddress(smtp.FromEmail, smtp.FromName),
|
From = new MailAddress(smtp.FromEmail, smtp.FromName),
|
||||||
Subject = request.Subject,
|
Subject = request.Subject,
|
||||||
Body = request.HtmlBody,
|
Body = request.TextBody ?? string.Empty,
|
||||||
IsBodyHtml = true
|
IsBodyHtml = false,
|
||||||
|
BodyEncoding = Encoding.UTF8,
|
||||||
|
SubjectEncoding = Encoding.UTF8
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.TextBody))
|
if (!string.IsNullOrWhiteSpace(request.HtmlBody))
|
||||||
{
|
{
|
||||||
var plain = AlternateView.CreateAlternateViewFromString(request.TextBody, null, "text/plain");
|
var html = AlternateView.CreateAlternateViewFromString(request.HtmlBody, Encoding.UTF8, "text/html");
|
||||||
mail.AlternateViews.Add(plain);
|
mail.AlternateViews.Add(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
mail.To.Add(new MailAddress(request.RecipientEmail, request.RecipientName));
|
mail.To.Add(new MailAddress(request.RecipientEmail, request.RecipientName));
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ internal sealed class GetAllEmailTemplatesHandler
|
|||||||
protected override async Task<ServiceResult<IReadOnlyList<EmailTemplate>>> DoOnHandle(
|
protected override async Task<ServiceResult<IReadOnlyList<EmailTemplate>>> DoOnHandle(
|
||||||
GetAllEmailTemplatesQuery request, CancellationToken cancellationToken)
|
GetAllEmailTemplatesQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var templates = await _templates.GetAllAsync(cancellationToken);
|
var templates = await _templates.GetAllAsync(request.ServiceName, request.Key, cancellationToken);
|
||||||
return Success(templates);
|
return Success(templates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,4 +4,5 @@ using HrynCo.NotificationService.Services.Core;
|
|||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
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>>>;
|
||||||
|
|||||||
@@ -23,9 +23,12 @@ public class AdminTemplatesController : Controller
|
|||||||
|
|
||||||
// GET /admin/templates
|
// GET /admin/templates
|
||||||
[HttpGet("")]
|
[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)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load templates.");
|
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load templates.");
|
||||||
@@ -37,14 +40,24 @@ public class AdminTemplatesController : Controller
|
|||||||
|
|
||||||
// GET /admin/templates/create
|
// GET /admin/templates/create
|
||||||
[HttpGet("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}
|
// GET /admin/templates/{serviceName}/{key}/{languageCode}
|
||||||
[HttpGet("{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);
|
var result = await _mediator.Send(new GetEmailTemplateQuery(serviceName, key, languageCode), ct);
|
||||||
if (!result.IsSuccess || result.Result is null)
|
if (!result.IsSuccess || result.Result is null)
|
||||||
@@ -60,7 +73,9 @@ public class AdminTemplatesController : Controller
|
|||||||
Subject = template.Subject,
|
Subject = template.Subject,
|
||||||
HtmlBody = template.HtmlBody,
|
HtmlBody = template.HtmlBody,
|
||||||
TextBody = template.TextBody,
|
TextBody = template.TextBody,
|
||||||
VariablesJson = JsonSerializer.Serialize(template.Variables)
|
VariablesJson = JsonSerializer.Serialize(template.Variables),
|
||||||
|
ServiceNameFilter = serviceNameFilter,
|
||||||
|
KeyFilter = keyFilter
|
||||||
};
|
};
|
||||||
|
|
||||||
return View(vm);
|
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
|
// POST /admin/templates/{serviceName}/{key}/{languageCode}/delete
|
||||||
[HttpPost("{serviceName}/{key}/{languageCode}/delete")]
|
[HttpPost("{serviceName}/{key}/{languageCode}/delete")]
|
||||||
[ValidateAntiForgeryToken]
|
[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);
|
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}, ...]
|
// JSON array: [{"name":"UserName","required":true}, ...]
|
||||||
public string VariablesJson { get; set; } = "[]";
|
public string VariablesJson { get; set; } = "[]";
|
||||||
|
public string? ServiceNameFilter { get; set; }
|
||||||
|
public string? KeyFilter { get; set; }
|
||||||
|
|
||||||
public bool IsNew => Id == null;
|
public bool IsNew => Id == null;
|
||||||
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
using HrynCo.NotificationService.Web.Infrastructure;
|
using HrynCo.NotificationService.Web.Infrastructure;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Create;
|
using HrynCo.NotificationService.Services.EmailTemplates.Create;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
|
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
|
||||||
|
using HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Get;
|
using HrynCo.NotificationService.Services.EmailTemplates.Get;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.GetByService;
|
using HrynCo.NotificationService.Services.EmailTemplates.GetByService;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Update;
|
using HrynCo.NotificationService.Services.EmailTemplates.Update;
|
||||||
@@ -15,9 +16,9 @@ public sealed class EmailTemplatesController : ApiControllerBase
|
|||||||
public EmailTemplatesController(IMediator mediator) : base(mediator) { }
|
public EmailTemplatesController(IMediator mediator) : base(mediator) { }
|
||||||
|
|
||||||
[HttpGet]
|
[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);
|
return FromServiceResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ using HrynCo.NotificationService.DAL.EF;
|
|||||||
using HrynCo.NotificationService.Services;
|
using HrynCo.NotificationService.Services;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.AddSerilog();
|
builder.AddSerilog();
|
||||||
|
|
||||||
var appSettings = builder.Configuration
|
AppSettings appSettings = builder.Configuration
|
||||||
.GetSection(AppSettings.SectionName)
|
.GetSection(AppSettings.SectionName)
|
||||||
.Get<AppSettings>() ?? throw new InvalidOperationException("App settings are not configured.");
|
.Get<AppSettings>() ?? throw new InvalidOperationException("App settings are not configured.");
|
||||||
|
|
||||||
@@ -18,17 +18,14 @@ builder.Services.AddControllersWithViews()
|
|||||||
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
|
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
|
||||||
builder.Services.AddNotificationServices();
|
builder.Services.AddNotificationServices();
|
||||||
|
|
||||||
var app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
app.MapOpenApi();
|
||||||
|
app.MapScalarApiReference(options =>
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
options.Title = "HrynCo Notification Service";
|
||||||
app.MapScalarApiReference(options =>
|
options.Theme = ScalarTheme.DeepSpace;
|
||||||
{
|
});
|
||||||
options.Title = "HrynCo Notification Service";
|
|
||||||
options.Theme = ScalarTheme.DeepSpace;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input asp-for="Id" type="hidden" />
|
<input asp-for="Id" type="hidden" />
|
||||||
<input type="hidden" name="IsNew" value="@Model.IsNew" />
|
<input type="hidden" name="IsNew" value="@Model.IsNew" />
|
||||||
|
<input asp-for="ServiceNameFilter" type="hidden" />
|
||||||
|
<input asp-for="KeyFilter" type="hidden" />
|
||||||
|
|
||||||
@if (!ViewData.ModelState.IsValid)
|
@if (!ViewData.ModelState.IsValid)
|
||||||
{
|
{
|
||||||
@@ -116,7 +118,7 @@
|
|||||||
<button type="submit" form="templateForm" class="btn btn-primary">
|
<button type="submit" form="templateForm" class="btn btn-primary">
|
||||||
<i class="bi bi-floppy me-1"></i> Save
|
<i class="bi bi-floppy me-1"></i> Save
|
||||||
</button>
|
</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
|
<i class="bi bi-x-lg me-1"></i> Cancel
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,50 @@
|
|||||||
@model IReadOnlyList<EmailTemplate>
|
@model IReadOnlyList<EmailTemplate>
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Email Templates";
|
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">
|
<div class="page-header">
|
||||||
<h2><i class="bi bi-envelope-paper"></i> Email Templates</h2>
|
<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
|
<i class="bi bi-plus-lg me-1"></i> Create New Template
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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)
|
@if (!ViewData.ModelState.IsValid)
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger">
|
<div class="alert alert-danger">
|
||||||
@@ -21,6 +56,61 @@
|
|||||||
</div>
|
</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)
|
@if (Model is null || Model.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="card shadow-sm table-card">
|
<div class="card shadow-sm table-card">
|
||||||
@@ -56,13 +146,15 @@ else
|
|||||||
<td>@t.LanguageCode</td>
|
<td>@t.LanguageCode</td>
|
||||||
<td>@t.Subject</td>
|
<td>@t.Subject</td>
|
||||||
<td class="text-end">
|
<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">
|
class="btn btn-sm btn-outline-primary me-1">
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
</a>
|
</a>
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode/delete"
|
action="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode/delete"
|
||||||
class="d-inline">
|
class="d-inline">
|
||||||
|
<input type="hidden" name="serviceNameFilter" value="@serviceNameFilter" />
|
||||||
|
<input type="hidden" name="keyFilter" value="@keyFilter" />
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="btn btn-sm btn-outline-danger"
|
class="btn btn-sm btn-outline-danger"
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
|
||||||
|
internal sealed class EmailTemplateRenderingService : IEmailTemplateRenderingService
|
||||||
|
{
|
||||||
|
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),
|
||||||
|
Interpolate(template.TextBody, data.Variables));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Interpolate(string text, IReadOnlyDictionary<string, string> variables)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder(text);
|
||||||
|
foreach (var (key, value) in variables)
|
||||||
|
sb.Replace($"{{{{{key}}}}}", value);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
|
||||||
|
internal sealed class EmailTemplateService : IEmailTemplateService
|
||||||
|
{
|
||||||
|
private readonly IEmailTemplateRepository _templateRepository;
|
||||||
|
|
||||||
|
public EmailTemplateService(IEmailTemplateRepository templateRepository)
|
||||||
|
{
|
||||||
|
_templateRepository = templateRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailTemplate> GetAsync(
|
||||||
|
string serviceName,
|
||||||
|
string templateKey,
|
||||||
|
string? languageCode,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
|
throw new InvalidDataException("LanguageCode is required.");
|
||||||
|
|
||||||
|
string lang = languageCode.Trim().ToLowerInvariant();
|
||||||
|
EmailTemplate? template = await _templateRepository.GetAsync(
|
||||||
|
serviceName,
|
||||||
|
templateKey,
|
||||||
|
lang,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return template
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public interface IEmailTemplateRenderingService
|
||||||
|
{
|
||||||
|
RenderedEmail Render(EmailTemplate template, SendEmailMessageData data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public interface IEmailTemplateService
|
||||||
|
{
|
||||||
|
Task<EmailTemplate> GetAsync(
|
||||||
|
string serviceName,
|
||||||
|
string templateKey,
|
||||||
|
string? languageCode,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+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,8 @@
|
|||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public interface ISendEmailService
|
||||||
|
{
|
||||||
|
Task ProcessAsync(SendEmailMessage message, 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
namespace HrynCo.NotificationService.Worker;
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
public record RenderedEmail(string Subject, string HtmlBody, string TextBody);
|
public record RenderedEmail(string Subject, string HtmlBody, string TextBody);
|
||||||
+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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.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 Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
internal sealed class SendEmailService : ISendEmailService
|
||||||
|
{
|
||||||
|
private readonly IEmailChannelRepository _channelRepository;
|
||||||
|
private readonly IEmailChannelUsageRepository _usageRepository;
|
||||||
|
private readonly IEmailTemplateService _templateService;
|
||||||
|
private readonly IEmailTemplateRenderingService _templateRenderingService;
|
||||||
|
private readonly ISmtpEmailSender _smtpEmailSender;
|
||||||
|
private readonly INotificationResultPublisher _resultPublisher;
|
||||||
|
private readonly ILogger<SendEmailService> _logger;
|
||||||
|
|
||||||
|
public SendEmailService(
|
||||||
|
IEmailChannelRepository channelRepository,
|
||||||
|
IEmailChannelUsageRepository usageRepository,
|
||||||
|
IEmailTemplateService templateService,
|
||||||
|
IEmailTemplateRenderingService templateRenderingService,
|
||||||
|
ISmtpEmailSender smtpEmailSender,
|
||||||
|
INotificationResultPublisher resultPublisher,
|
||||||
|
ILogger<SendEmailService> logger)
|
||||||
|
{
|
||||||
|
_channelRepository = channelRepository;
|
||||||
|
_usageRepository = usageRepository;
|
||||||
|
_templateService = templateService;
|
||||||
|
_templateRenderingService = templateRenderingService;
|
||||||
|
_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, redactedRecipient, message.CorrelationContext?.CorrelationId);
|
||||||
|
|
||||||
|
EmailChannel channel = await ResolveChannelAsync(data.ServiceName, cancellationToken);
|
||||||
|
EmailTemplate template = await GetTemplateAsync(data, cancellationToken);
|
||||||
|
|
||||||
|
await EnforceLimitsAsync(channel, cancellationToken);
|
||||||
|
|
||||||
|
RenderedEmail rendered = _templateRenderingService.Render(template, data);
|
||||||
|
|
||||||
|
SmtpChannelSettings smtpChannel = channel.Settings as SmtpChannelSettings
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Channel type '{channel.EmailChannelType}' is not supported for sending.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _smtpEmailSender.SendAsync(
|
||||||
|
smtpChannel,
|
||||||
|
rendered,
|
||||||
|
data.RecipientEmail,
|
||||||
|
data.RecipientName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "SMTP send failed for channel {ChannelId}", channel.Id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _usageRepository.IncrementUsageAsync(
|
||||||
|
channel.Id,
|
||||||
|
DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Email sent successfully service={Service} template={Template} recipient={Recipient}",
|
||||||
|
data.ServiceName, data.TemplateKey, redactedRecipient);
|
||||||
|
|
||||||
|
await _resultPublisher.PublishAsync(message, null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmailTemplate> GetTemplateAsync(SendEmailMessageData data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _templateService.GetAsync(
|
||||||
|
data.ServiceName,
|
||||||
|
data.TemplateKey,
|
||||||
|
data.LanguageCode,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmailChannel> ResolveChannelAsync(string serviceName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var channels = await _channelRepository.GetByServiceAsync(serviceName, ct);
|
||||||
|
|
||||||
|
return channels
|
||||||
|
.Where(c => c.IsActive)
|
||||||
|
.OrderBy(c => c.Priority)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No active email channel found for service '{serviceName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnforceLimitsAsync(EmailChannel channel, CancellationToken ct)
|
||||||
|
{
|
||||||
|
DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
|
||||||
|
if (channel.DailyLimit.HasValue)
|
||||||
|
{
|
||||||
|
int daily = await _usageRepository.GetDailyCountAsync(channel.Id, today, ct);
|
||||||
|
|
||||||
|
if (daily >= channel.DailyLimit.Value)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Channel '{channel.Id}' daily limit of {channel.DailyLimit.Value} reached ({daily} sent today).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.MonthlyLimit.HasValue)
|
||||||
|
{
|
||||||
|
int monthly = await _usageRepository.GetMonthlyCountAsync(channel.Id, today.Year, today.Month, ct);
|
||||||
|
|
||||||
|
if (monthly >= channel.MonthlyLimit.Value)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Channel '{channel.Id}' monthly limit of {channel.MonthlyLimit.Value} reached ({monthly} sent this month).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>HrynCo.NotificationService.Worker.Services</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="HrynCo.RabbitMq" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddNotificationWorkerServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,4 @@ public sealed class AppSettings
|
|||||||
|
|
||||||
public string ConnectionString { get; init; } = string.Empty;
|
public string ConnectionString { get; init; } = string.Empty;
|
||||||
public RabbitMqSettings RabbitMq { get; init; } = null!;
|
public RabbitMqSettings RabbitMq { get; init; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ COPY ["Directory.Build.props", "."]
|
|||||||
COPY ["Directory.Packages.props", "."]
|
COPY ["Directory.Packages.props", "."]
|
||||||
COPY ["HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj", "HrynCo.NotificationService.DAL.Abstract/"]
|
COPY ["HrynCo.NotificationService.DAL.Abstract/HrynCo.NotificationService.DAL.Abstract.csproj", "HrynCo.NotificationService.DAL.Abstract/"]
|
||||||
COPY ["HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj", "HrynCo.NotificationService.DAL.EF/"]
|
COPY ["HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj", "HrynCo.NotificationService.DAL.EF/"]
|
||||||
COPY ["HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj", "HrynCo.NotificationService.Services/"]
|
COPY ["HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj", "HrynCo.NotificationService.Worker.Services/"]
|
||||||
COPY ["HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj", "HrynCo.NotificationService.Worker/"]
|
COPY ["HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj", "HrynCo.NotificationService.Worker/"]
|
||||||
|
|
||||||
RUN dotnet restore "HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj"
|
RUN dotnet restore "HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj"
|
||||||
@@ -28,4 +28,4 @@ RUN dotnet publish "./HrynCo.NotificationService.Worker.csproj" -c $BUILD_CONFIG
|
|||||||
FROM base AS final
|
FROM base AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
ENTRYPOINT ["dotnet", "HrynCo.NotificationService.Worker.dll"]
|
ENTRYPOINT ["dotnet", "HrynCo.NotificationService.Worker.dll"]
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
<PackageReference Include="HrynCo.RabbitMq" />
|
<PackageReference Include="HrynCo.RabbitMq" />
|
||||||
<PackageReference Include="MediatR" />
|
|
||||||
<PackageReference Include="Serilog.Extensions.Hosting" />
|
<PackageReference Include="Serilog.Extensions.Hosting" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" />
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
|
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.DAL.EF\HrynCo.NotificationService.DAL.EF.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.DAL.EF\HrynCo.NotificationService.DAL.EF.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
using HrynCo.NotificationService.DAL.EF;
|
using HrynCo.NotificationService.DAL.EF;
|
||||||
using HrynCo.NotificationService.Services;
|
|
||||||
using HrynCo.NotificationService.Worker;
|
using HrynCo.NotificationService.Worker;
|
||||||
|
using HrynCo.NotificationService.Worker.Services;
|
||||||
using Hrynco.RabbitMq;
|
using Hrynco.RabbitMq;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
@@ -14,7 +13,7 @@ var appSettings = builder.Configuration
|
|||||||
|
|
||||||
builder.Services.AddSingleton(appSettings);
|
builder.Services.AddSingleton(appSettings);
|
||||||
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
|
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
|
||||||
builder.Services.AddNotificationServices();
|
builder.Services.AddNotificationWorkerServices();
|
||||||
|
|
||||||
builder.Services.Configure<RabbitMqSettings>(
|
builder.Services.Configure<RabbitMqSettings>(
|
||||||
builder.Configuration.GetSection($"{AppSettings.SectionName}:RabbitMq"));
|
builder.Configuration.GetSection($"{AppSettings.SectionName}:RabbitMq"));
|
||||||
@@ -22,4 +21,4 @@ builder.Services.Configure<RabbitMqSettings>(
|
|||||||
builder.Services.AddHostedService<SendEmailConsumer>();
|
builder.Services.AddHostedService<SendEmailConsumer>();
|
||||||
|
|
||||||
var host = builder.Build();
|
var host = builder.Build();
|
||||||
host.Run();
|
host.Run();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
|
||||||
@@ -1,174 +1,70 @@
|
|||||||
namespace HrynCo.NotificationService.Worker;
|
namespace HrynCo.NotificationService.Worker;
|
||||||
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using HrynCo.NotificationService.Contracts.Messages;
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
using HrynCo.NotificationService.Services.EmailChannels.Send;
|
|
||||||
using Hrynco.RabbitMq;
|
using Hrynco.RabbitMq;
|
||||||
using MediatR;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using RabbitMQ.Client;
|
|
||||||
|
|
||||||
public sealed class SendEmailConsumer(
|
public sealed class SendEmailConsumer : RabbitMqConsumerBase<SendEmailMessage, SendEmailMessageData>
|
||||||
IOptionsMonitor<RabbitMqSettings> options,
|
|
||||||
IEmailChannelRepository channelRepository,
|
|
||||||
IEmailTemplateRepository templateRepository,
|
|
||||||
IEmailChannelUsageRepository usageRepository,
|
|
||||||
IMediator mediator,
|
|
||||||
AppSettings appSettings,
|
|
||||||
ILogger<SendEmailConsumer> logger)
|
|
||||||
: RabbitMqConsumerBase<SendEmailMessage, SendEmailMessageData>(options, logger)
|
|
||||||
{
|
{
|
||||||
private const string IncomingQueue = "notification.send-email";
|
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,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<SendEmailConsumer> logger)
|
||||||
|
: base(options, logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
protected override string QueueName => IncomingQueue;
|
protected override string QueueName => IncomingQueue;
|
||||||
|
|
||||||
protected override async Task HandleMessageAsync(SendEmailMessage message, CancellationToken cancellationToken)
|
protected override bool TryValidateMessage(
|
||||||
|
SendEmailMessage message,
|
||||||
|
RabbitMqMessageContext context,
|
||||||
|
out string? validationError)
|
||||||
{
|
{
|
||||||
var data = message.Data;
|
validationError = SendEmailDeliveryValidator.GetValidationError(
|
||||||
|
message,
|
||||||
logger.LogInformation(
|
context,
|
||||||
"Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]",
|
SupportedMessageType);
|
||||||
data.ServiceName, data.TemplateKey, data.RecipientEmail, message.CorrelationContext?.CorrelationId);
|
return validationError is null;
|
||||||
|
|
||||||
var channel = await ResolveChannelAsync(data.ServiceName, cancellationToken);
|
|
||||||
var template = await ResolveTemplateAsync(data.ServiceName, data.TemplateKey, data.LanguageCode, cancellationToken);
|
|
||||||
|
|
||||||
await EnforceLimitsAsync(channel, cancellationToken);
|
|
||||||
|
|
||||||
var rendered = RenderTemplate(template, data);
|
|
||||||
|
|
||||||
var sendResult = await mediator.Send(
|
|
||||||
new SendEmailCommand(channel.Id, data.RecipientEmail, data.RecipientName,
|
|
||||||
rendered.Subject, rendered.HtmlBody, rendered.TextBody),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
if (!sendResult.IsSuccess)
|
|
||||||
throw new InvalidOperationException(sendResult.Error?.Message ?? "Send failed.");
|
|
||||||
|
|
||||||
logger.LogInformation(
|
|
||||||
"Email sent successfully service={Service} template={Template} recipient={Recipient}",
|
|
||||||
data.ServiceName, data.TemplateKey, data.RecipientEmail);
|
|
||||||
|
|
||||||
await PublishResultAsync(message.CorrelationContext, data, errorMessage: null, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<EmailChannel> ResolveChannelAsync(string serviceName, CancellationToken ct)
|
protected override async Task HandleMessageAsync(
|
||||||
|
SendEmailMessage message,
|
||||||
|
RabbitMqMessageContext context,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var channels = await channelRepository.GetByServiceAsync(serviceName, ct);
|
string payloadCorrelationId = message.CorrelationContext.CorrelationId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(context.CorrelationId) &&
|
||||||
return channels
|
!string.Equals(context.CorrelationId, payloadCorrelationId, StringComparison.Ordinal))
|
||||||
.Where(c => c.IsActive)
|
|
||||||
.OrderBy(c => c.Priority)
|
|
||||||
.FirstOrDefault()
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
$"No active email channel found for service '{serviceName}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<EmailTemplate> ResolveTemplateAsync(
|
|
||||||
string serviceName, string templateKey, string? languageCode, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode;
|
|
||||||
var template = await templateRepository.GetAsync(serviceName, templateKey, lang, ct);
|
|
||||||
|
|
||||||
if (template is null && lang != "en")
|
|
||||||
template = await templateRepository.GetAsync(serviceName, templateKey, "en", ct);
|
|
||||||
|
|
||||||
return template
|
|
||||||
?? throw new InvalidOperationException(
|
|
||||||
$"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task EnforceLimitsAsync(EmailChannel channel, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
|
||||||
|
|
||||||
if (channel.DailyLimit.HasValue)
|
|
||||||
{
|
{
|
||||||
var daily = await usageRepository.GetDailyCountAsync(channel.Id, today, ct);
|
_logger.LogWarning(
|
||||||
if (daily >= channel.DailyLimit.Value)
|
"Broker and payload correlation IDs differ; payload correlation ID will be used");
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Channel '{channel.Id}' daily limit of {channel.DailyLimit.Value} reached ({daily} sent today).");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel.MonthlyLimit.HasValue)
|
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
|
||||||
{
|
ISendEmailService service = scope.ServiceProvider.GetRequiredService<ISendEmailService>();
|
||||||
var monthly = await usageRepository.GetMonthlyCountAsync(channel.Id, today.Year, today.Month, ct);
|
await service.ProcessAsync(message, cancellationToken);
|
||||||
if (monthly >= channel.MonthlyLimit.Value)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Channel '{channel.Id}' monthly limit of {channel.MonthlyLimit.Value} reached ({monthly} sent this month).");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RenderedEmail RenderTemplate(EmailTemplate template, SendEmailMessageData data)
|
protected override async Task HandleMessageRetriesExhaustedAsync(
|
||||||
|
SendEmailMessage message,
|
||||||
|
RabbitMqMessageContext context,
|
||||||
|
Exception exception,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return new RenderedEmail(
|
string deliveryError = NotificationDeliveryErrorFormatter.Format(exception);
|
||||||
Interpolate(template.Subject, data.Variables),
|
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
|
||||||
Interpolate(template.HtmlBody, data.Variables),
|
INotificationResultPublisher resultPublisher =
|
||||||
Interpolate(template.TextBody, data.Variables));
|
scope.ServiceProvider.GetRequiredService<INotificationResultPublisher>();
|
||||||
}
|
await resultPublisher.PublishAsync(message, deliveryError, cancellationToken);
|
||||||
|
|
||||||
private static string Interpolate(string text, IReadOnlyDictionary<string, string> variables)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder(text);
|
|
||||||
foreach (var (key, value) in variables)
|
|
||||||
sb.Replace($"{{{{{key}}}}}", value);
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PublishResultAsync(
|
|
||||||
CorrelationContext? correlationContext,
|
|
||||||
SendEmailMessageData data,
|
|
||||||
string? errorMessage,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
var 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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result));
|
|
||||||
|
|
||||||
var factory = new ConnectionFactory
|
|
||||||
{
|
|
||||||
HostName = appSettings.RabbitMq.Host,
|
|
||||||
Port = appSettings.RabbitMq.Port,
|
|
||||||
UserName = appSettings.RabbitMq.User,
|
|
||||||
Password = appSettings.RabbitMq.Password,
|
|
||||||
VirtualHost = appSettings.RabbitMq.VirtualHost
|
|
||||||
};
|
|
||||||
|
|
||||||
await using var conn = await factory.CreateConnectionAsync(ct);
|
|
||||||
await using var ch = await conn.CreateChannelAsync(cancellationToken: ct);
|
|
||||||
|
|
||||||
await ch.QueueDeclareAsync(replyTo, durable: true, exclusive: false, autoDelete: false,
|
|
||||||
cancellationToken: ct);
|
|
||||||
await ch.BasicPublishAsync(exchange: string.Empty, routingKey: replyTo, body: body,
|
|
||||||
cancellationToken: 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,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,4 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"App": {
|
||||||
|
"ConnectionString": "Host=192.168.2.121;Port=55435;Database=hrynco_ns_prod;Username=ns_user;Password=HAwS0c4A1QmH",
|
||||||
|
"RabbitMq": {
|
||||||
|
"Host": "192.168.2.121",
|
||||||
|
"Port": 5675,
|
||||||
|
"User": "ns_user",
|
||||||
|
"Password": "LN22mEWYdfCy",
|
||||||
|
"VirtualHost": "/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Debug",
|
"Default": "Debug",
|
||||||
@@ -7,6 +17,20 @@
|
|||||||
"Microsoft.EntityFrameworkCore": "Information",
|
"Microsoft.EntityFrameworkCore": "Information",
|
||||||
"Microsoft.AspNetCore": "Information"
|
"Microsoft.AspNetCore": "Information"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{ "Name": "Console" },
|
||||||
|
{
|
||||||
|
"Name": "Seq",
|
||||||
|
"Args": {
|
||||||
|
"serverUrl": "http://192.168.2.121:5341"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Enrich": [ "FromLogContext" ],
|
||||||
|
"Properties": {
|
||||||
|
"Application": "hrynco-notification-service-worker",
|
||||||
|
"Environment": "Development"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,5 +20,6 @@
|
|||||||
<Project Path="HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj" />
|
<Project Path="HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj" />
|
<Project Path="HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj" />
|
<Project Path="HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj" />
|
||||||
|
<Project Path="HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj" />
|
<Project Path="HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<configuration>
|
<configuration>
|
||||||
<config>
|
|
||||||
<add key="globalPackagesFolder" value="%USERPROFILE%\.nuget\packages" />
|
|
||||||
</config>
|
|
||||||
<packageSources>
|
<packageSources>
|
||||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
</packageSources>
|
</packageSources>
|
||||||
|
|||||||
@@ -1 +1,114 @@
|
|||||||
# hrynco-notification-service
|
# hrynco-notification-service
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
|
||||||
|
|
||||||
|
## Development environment
|
||||||
|
|
||||||
|
The development Docker Compose stack runs PostgreSQL, RabbitMQ, database migrations,
|
||||||
|
the Notification Service Web and Worker applications, Seq, and Mailpit.
|
||||||
|
|
||||||
|
Prerequisite: install and start Docker Desktop. Then run the installation script from
|
||||||
|
the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\docker\environments\install-development.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
The script validates Docker and the Compose configuration, builds the application
|
||||||
|
images, starts the complete stack in the background, and prints container status and
|
||||||
|
the main development URLs. It can also be launched directly from File Explorer or
|
||||||
|
from another working directory.
|
||||||
|
|
||||||
|
To validate the setup without building images or creating containers, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\docker\environments\install-development.cmd --validate-only
|
||||||
|
```
|
||||||
|
|
||||||
|
By default, the script uses the tracked `docker/environments/.env.Development` file:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DB_NAME=notification_service
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASS=postgres
|
||||||
|
VOLUME_PREFIX=ns-dev
|
||||||
|
RABBITMQ_USER=guest
|
||||||
|
RABBITMQ_PASSWORD=guest
|
||||||
|
RABBITMQ_AMQP_PORT=5672
|
||||||
|
RABBITMQ_MANAGEMENT_PORT=15672
|
||||||
|
WEB_PORT=5200
|
||||||
|
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
|
||||||
|
```
|
||||||
|
|
||||||
|
To use personal values, copy it to the Git-ignored
|
||||||
|
`docker/environments/.env.local`, adjust the values, and run Docker Compose manually:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --env-file docker/environments/.env.local `
|
||||||
|
-f docker/environments/docker-compose.yml `
|
||||||
|
-f docker/environments/docker-compose.Development.yml `
|
||||||
|
up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
After startup, the main development endpoints are:
|
||||||
|
|
||||||
|
- Notification Service admin: `http://localhost:5200/admin/channels`
|
||||||
|
- Notification templates: `http://localhost:5200/admin/templates`
|
||||||
|
- RabbitMQ management: `http://localhost:15672` (`guest` / `guest`)
|
||||||
|
- Mailpit inbox: `http://localhost:8025`
|
||||||
|
- Seq logs: `http://localhost:5342`
|
||||||
|
- PostgreSQL: `localhost:5433`
|
||||||
|
|
||||||
|
During development migrations, the stack idempotently creates an active Mailpit SMTP
|
||||||
|
channel and a neutral English `TestEmail` template for
|
||||||
|
`DEVELOPMENT_EMAIL_SERVICE_NAME`. Existing channels and templates are never
|
||||||
|
overwritten. Change that environment value when the publisher uses another service
|
||||||
|
name.
|
||||||
|
|
||||||
|
Check container status and follow Worker logs:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --env-file docker/environments/.env.local `
|
||||||
|
-f docker/environments/docker-compose.yml `
|
||||||
|
-f docker/environments/docker-compose.Development.yml `
|
||||||
|
ps
|
||||||
|
|
||||||
|
docker compose --env-file docker/environments/.env.local `
|
||||||
|
-f docker/environments/docker-compose.yml `
|
||||||
|
-f docker/environments/docker-compose.Development.yml `
|
||||||
|
logs -f worker
|
||||||
|
```
|
||||||
|
|
||||||
|
Stop the environment without deleting its database and RabbitMQ volumes:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --env-file docker/environments/.env.local `
|
||||||
|
-f docker/environments/docker-compose.yml `
|
||||||
|
-f docker/environments/docker-compose.Development.yml `
|
||||||
|
down
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [ItemTracker consumer guide](docs/itemtracker-outbox-email-consumer.md#local-end-to-end-setup)
|
||||||
|
for the complete end-to-end setup.
|
||||||
|
|
||||||
|
## Notification worker flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
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[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]
|
||||||
|
```
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ RABBITMQ_PASSWORD=guest
|
|||||||
RABBITMQ_AMQP_PORT=5672
|
RABBITMQ_AMQP_PORT=5672
|
||||||
RABBITMQ_MANAGEMENT_PORT=15672
|
RABBITMQ_MANAGEMENT_PORT=15672
|
||||||
WEB_PORT=5200
|
WEB_PORT=5200
|
||||||
|
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ name: hrynco-notification-service
|
|||||||
services:
|
services:
|
||||||
migrator:
|
migrator:
|
||||||
environment:
|
environment:
|
||||||
|
- DOTNET_ENVIRONMENT=Development
|
||||||
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
|
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
|
||||||
|
- DevelopmentSeed__ServiceName=${DEVELOPMENT_EMAIL_SERVICE_NAME:-TestService}
|
||||||
|
|
||||||
web:
|
web:
|
||||||
environment:
|
environment:
|
||||||
@@ -29,9 +31,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: guest
|
RABBITMQ_DEFAULT_USER: guest
|
||||||
RABBITMQ_DEFAULT_PASS: guest
|
RABBITMQ_DEFAULT_PASS: guest
|
||||||
ports:
|
|
||||||
- "5672:5672"
|
|
||||||
- "15672:15672"
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
@@ -55,10 +54,17 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.30.0
|
||||||
|
ports:
|
||||||
|
- "8025:8025"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
name: ns-dev-pgdata
|
name: ns-dev-pgdata
|
||||||
rabbitmq_data:
|
rabbitmq_data:
|
||||||
name: ns-dev-rabbitmq-data
|
name: ns-dev-rabbitmq-data
|
||||||
seq_data:
|
seq_data:
|
||||||
name: ns-dev-seq
|
name: ns-dev-seq
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ services:
|
|||||||
- App__RabbitMq__Port=5672
|
- App__RabbitMq__Port=5672
|
||||||
- App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required}
|
- App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required}
|
||||||
- App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required}
|
- App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required}
|
||||||
|
- App__RabbitMq__VirtualHost=${RABBITMQ_VIRTUAL_HOST:-/}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -92,4 +93,4 @@ volumes:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
for %%I in ("%SCRIPT_DIR%..\..") do set "REPOSITORY_ROOT=%%~fI"
|
||||||
|
set "ENV_FILE=%SCRIPT_DIR%.env.Development"
|
||||||
|
set "BASE_COMPOSE=%SCRIPT_DIR%docker-compose.yml"
|
||||||
|
set "DEVELOPMENT_COMPOSE=%SCRIPT_DIR%docker-compose.Development.yml"
|
||||||
|
|
||||||
|
where docker >nul 2>&1
|
||||||
|
if errorlevel 1 goto docker_missing
|
||||||
|
|
||||||
|
docker info >nul 2>&1
|
||||||
|
if errorlevel 1 goto docker_unavailable
|
||||||
|
|
||||||
|
if not exist "%ENV_FILE%" goto environment_missing
|
||||||
|
|
||||||
|
pushd "%REPOSITORY_ROOT%"
|
||||||
|
if errorlevel 1 goto repository_unavailable
|
||||||
|
|
||||||
|
echo Validating the development Docker Compose configuration...
|
||||||
|
docker compose --env-file "%ENV_FILE%" -f "%BASE_COMPOSE%" -f "%DEVELOPMENT_COMPOSE%" config --quiet
|
||||||
|
if errorlevel 1 goto compose_invalid
|
||||||
|
if /i "%~1"=="--validate-only" goto validation_complete
|
||||||
|
|
||||||
|
echo Building and starting the Notification Service development environment...
|
||||||
|
docker compose --env-file "%ENV_FILE%" -f "%BASE_COMPOSE%" -f "%DEVELOPMENT_COMPOSE%" up --build -d
|
||||||
|
if errorlevel 1 goto installation_failed
|
||||||
|
|
||||||
|
echo.
|
||||||
|
docker compose --env-file "%ENV_FILE%" -f "%BASE_COMPOSE%" -f "%DEVELOPMENT_COMPOSE%" ps
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo Notification Service development environment is running.
|
||||||
|
echo Admin: http://localhost:5200/admin/channels
|
||||||
|
echo RabbitMQ: http://localhost:15672
|
||||||
|
echo Mailpit: http://localhost:8025
|
||||||
|
echo Seq: http://localhost:5342
|
||||||
|
echo.
|
||||||
|
popd
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:validation_complete
|
||||||
|
echo Development Docker Compose configuration is valid.
|
||||||
|
popd
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:compose_invalid
|
||||||
|
echo ERROR: The development Docker Compose configuration is invalid.
|
||||||
|
popd
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:installation_failed
|
||||||
|
echo ERROR: Failed to build or start the development environment.
|
||||||
|
popd
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:docker_missing
|
||||||
|
echo ERROR: Docker CLI was not found. Install and start Docker Desktop, then run this script again.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:docker_unavailable
|
||||||
|
echo ERROR: Docker is installed, but the Docker engine is not available. Start Docker Desktop and try again.
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:environment_missing
|
||||||
|
echo ERROR: Development environment file was not found: "%ENV_FILE%"
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:repository_unavailable
|
||||||
|
echo ERROR: Repository root is not available: "%REPOSITORY_ROOT%"
|
||||||
|
exit /b 1
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# ItemTracker Outbox Email Consumer
|
||||||
|
|
||||||
|
## Contract and routing
|
||||||
|
|
||||||
|
The Worker consumes durable messages from queue `notification.send-email`. ItemTracker publishes through RabbitMQ's default exchange, so the queue name is also the routing key. No custom exchange binding is required.
|
||||||
|
|
||||||
|
`HrynCo.RabbitMq` owns the transport lifecycle: connection, queue declaration, deserialization, prefetch, structured delivery context, retry, terminal-failure notification, and manual ACK/NACK. Notification Service only supplies its queue, contract validation, email handler, and result publisher through the package's generic extension points. This integration requires package version `1.0.17` or newer.
|
||||||
|
|
||||||
|
Accepted messages must have all of the following:
|
||||||
|
|
||||||
|
- AMQP `Type`: `Notification.SendEmail.v1`
|
||||||
|
- AMQP `MessageId`: the non-empty stable ItemTracker Outbox ID
|
||||||
|
- JSON content using the PascalCase `SendEmailMessage` envelope
|
||||||
|
- non-empty `CorrelationContext.CorrelationId`
|
||||||
|
- non-empty `ServiceName`, `TemplateKey`, `RecipientEmail`, `RecipientName`, and `LanguageCode`
|
||||||
|
- a valid recipient address and a non-null `Variables` object
|
||||||
|
|
||||||
|
The service resolves a template by the exact `ServiceName`, `TemplateKey`, and normalized lower-case `LanguageCode`. It does not silently fall back to another language. Every variable marked `Required` by the selected template must have a non-empty value before rendering.
|
||||||
|
|
||||||
|
## Delivery and acknowledgement policy
|
||||||
|
|
||||||
|
The consumer uses manual acknowledgements and prefetches one message at a time.
|
||||||
|
|
||||||
|
1. Unsupported contract metadata, malformed JSON, or invalid required fields are logged and nacked without requeue.
|
||||||
|
2. Template, provider, quota, SMTP, or usage-accounting failures are retried in-process three times with a five-second delay.
|
||||||
|
3. The message is acknowledged only after SMTP accepts the email and provider usage is incremented.
|
||||||
|
4. If `CorrelationContext.ReplyTo` is present, result publication is attempted after delivery. It is best-effort: a reply-queue failure is logged but does not make the SMTP delivery fail, because retrying after SMTP success could send a duplicate email.
|
||||||
|
5. After the final processing retry fails, the shared terminal-failure hook publishes one
|
||||||
|
negative result to the same `ReplyTo`. It retains the original correlation and contains
|
||||||
|
a normalized diagnostic string limited to 2000 characters. Common SMTP DNS,
|
||||||
|
connection-refused, timeout, and recipient-rejection failures receive safe explanatory
|
||||||
|
context while retaining the low-level provider reason. Hostnames, recipient addresses,
|
||||||
|
and credentials are not added. Client outboxes store this as a neutral delivery error
|
||||||
|
rather than interpreting SMTP-specific exception types.
|
||||||
|
|
||||||
|
Success and terminal failure result publication remain best-effort. If the result queue
|
||||||
|
cannot be reached, the internal exception is logged and the original delivery follows its
|
||||||
|
normal ACK/NACK policy. Clients therefore must not interpret the absence of a result as a
|
||||||
|
confirmed failure.
|
||||||
|
|
||||||
|
Delivery remains at least once. There is no inbox/deduplication store in this MVP, so a broker redelivery or a process failure after SMTP acceptance but before acknowledgement can produce a duplicate. `MessageId`, `CorrelationId`, and `MessageType` are added to the structured log scope for correlation. Recipient addresses are masked, and template variable names/values and rendered links are not logged.
|
||||||
|
|
||||||
|
No database migration is required. Existing channel selection, SMTP settings, quota checks, and usage counters remain in place.
|
||||||
|
|
||||||
|
## Local end-to-end setup
|
||||||
|
|
||||||
|
With Docker Desktop running, install and start the complete Notification Service
|
||||||
|
development stack from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\docker\environments\install-development.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses the tracked `docker/environments/.env.Development` defaults. The
|
||||||
|
following manual setup is only needed when overriding those values locally.
|
||||||
|
|
||||||
|
Create a local environment file outside source control, for example `docker/environments/.env.local`, with these values:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DB_NAME=notification_service
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASS=postgres
|
||||||
|
VOLUME_PREFIX=ns-dev
|
||||||
|
RABBITMQ_USER=guest
|
||||||
|
RABBITMQ_PASSWORD=guest
|
||||||
|
RABBITMQ_VIRTUAL_HOST=/
|
||||||
|
RABBITMQ_AMQP_PORT=5672
|
||||||
|
RABBITMQ_MANAGEMENT_PORT=15672
|
||||||
|
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the Notification Service stack from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --env-file docker/environments/.env.local `
|
||||||
|
-f docker/environments/docker-compose.yml `
|
||||||
|
-f docker/environments/docker-compose.Development.yml `
|
||||||
|
up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Development compose exposes:
|
||||||
|
|
||||||
|
- Notification Service admin: `http://localhost:5200/admin/channels` and `/admin/templates`
|
||||||
|
- RabbitMQ management: `http://localhost:15672`
|
||||||
|
- Mailpit inbox: `http://localhost:8025`
|
||||||
|
- Seq: `http://localhost:5342`
|
||||||
|
|
||||||
|
The development migrator idempotently creates an active SMTP channel for
|
||||||
|
`DEVELOPMENT_EMAIL_SERVICE_NAME` using host `mailpit`, port `1025`, SSL disabled, and
|
||||||
|
blank credentials. It also creates a neutral English `TestEmail` template with
|
||||||
|
`RecipientName` and `Message` variables. Existing channels and templates are left
|
||||||
|
unchanged. Use the admin UI to inspect or customize them and to add client-specific
|
||||||
|
service names, template keys, or languages.
|
||||||
|
|
||||||
|
Configure ItemTracker to use the same RabbitMQ host, port, credentials, virtual host, queue `notification.send-email`, and service name. When ItemTracker runs outside Docker against the development stack, the broker is `localhost:5672`; from a Docker container it is the reachable host or shared-network name. Start the ItemTracker Outbox Worker in `RabbitMq` mode, trigger a production-safe notification to an owned test address, then verify:
|
||||||
|
|
||||||
|
1. the ItemTracker Outbox row becomes published;
|
||||||
|
2. RabbitMQ delivers and removes the message from `notification.send-email`;
|
||||||
|
3. Notification Service logs show the same `MessageId` and `CorrelationId` without the full address;
|
||||||
|
4. Mailpit shows exactly one rendered email;
|
||||||
|
5. the Notification Service channel usage counter increments;
|
||||||
|
6. the client result queue is consumed and the matching outbox row records successful delivery.
|
||||||
|
|
||||||
|
## Production configuration and smoke test
|
||||||
|
|
||||||
|
Production requires the same `App__RabbitMq__Host`, `Port`, `User`, `Password`, and `VirtualHost` values as ItemTracker's publisher, plus the Notification Service database connection. The production compose file obtains these from deployment environment variables; secrets must stay in the deployment secret store. The queue is declared durable by both producer and consumer.
|
||||||
|
|
||||||
|
This Notification Service revision restores the published immutable
|
||||||
|
`HrynCo.RabbitMq` version `1.0.17` from NuGet.org. Build and deployment environments
|
||||||
|
therefore require NuGet.org access or a trusted package mirror containing that exact
|
||||||
|
version.
|
||||||
|
|
||||||
|
Before enabling the Worker, verify that the target service has an active SMTP channel and exact-language templates for every queued ItemTracker template key. Inspect any delayed ItemTracker backlog for expired password-reset, verification, or invitation messages before draining it.
|
||||||
|
|
||||||
|
For the smoke test, use an owned test account and a non-sensitive notification template. Record the Outbox `Id` and `CorrelationId`, trigger only one message, follow those identifiers through Outbox publication and Notification Service logs, and confirm receipt with the configured SMTP provider. Do not copy payloads, tokens, credentials, full recipient addresses, or rendered URLs into tickets or logs.
|
||||||
Reference in New Issue
Block a user