38 Commits

Author SHA1 Message Date
agrynco 50033a5bd4 fix: escape html notification variables
Encode template variables in HTML bodies while preserving text and subjects.

Ref: IT-1115
2026-08-19 23:04:15 +03:00
agrynco 01c622038d Merge pull request 'fix: correct notification template filter links' (#15) from bugfix/it-1039-template-filter-links into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/15
2026-08-04 21:02:59 +03:00
agrynco 1ceb71b9a2 fix: correct notification template filter links
Use explicit Razor interpolation for create and edit URLs and cover the rendered link syntax with a regression test.

Ref: IT-1039
2026-08-04 21:01:46 +03:00
agrynco bb0de0f2c1 Merge pull request 'feat: consume transactional email notifications' (#14) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/14
2026-08-04 13:39:28 +03:00
agrynco d5a2e538f0 Merge pull request 'Merge pull request 'chore: update package versions and refactor TransactionBehavior' (#4) from development into main' (#13) from IT-1033 into development
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/13
2026-08-04 13:39:04 +03:00
agrynco 9d6b0717e6 docs: document repository git workflow
Record the main-based branch and delivery rules for future changes.

Ref: IT-1033
2026-08-04 12:32:28 +03:00
agrynco 2757869176 feat: consume transactional email notifications
Add contract validation, SMTP delivery results, terminal failure context, neutral development seeding, and local Docker setup.

Ref: IT-1033
2026-08-04 12:32:28 +03:00
agrynco b8435ac07b Merge pull request 'feat: add persistence and restoration of email template filters in UI and backend' (#12) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/12
2026-05-17 13:53:58 +03:00
agrynco cc3857a409 feat: add persistence and restoration of email template filters in UI and backend 2026-05-17 13:53:37 +03:00
agrynco 94f0d45aaf Merge pull request 'feat: add filtering by ServiceName and Key in email templates query and UI' (#11) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/11
2026-05-17 11:43:55 +03:00
agrynco 07f536938f feat: add filtering by ServiceName and Key in email templates query and UI 2026-05-17 11:43:25 +03:00
agrynco e7d3953747 Merge pull request 'refactor: add ordering by ServiceName and Key in GetAllAsync method' (#10) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/10
2026-05-17 11:34:15 +03:00
agrynco 3381fcc2f8 refactor: add ordering by ServiceName and Key in GetAllAsync method 2026-05-17 11:31:44 +03:00
agrynco 3bd952ef9e Merge pull request 'refactor: simplify Program.cs and clean up NuGet.Config' (#9) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/9
2026-05-17 11:18:43 +03:00
agrynco 285cc6abb7 refactor: simplify Program.cs and clean up NuGet.Config 2026-05-17 11:18:05 +03:00
agrynco 9589095760 Merge pull request 'refactor: modularize email processing logic and improve service structure' (#8) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/8
2026-05-14 22:21:30 +03:00
Anatolii Grynchuk 25fb48ccf0 refactor: modularize email processing logic and improve service structure
- Extract email template handling, rendering, and sending code into `Worker.Services` project.
- Introduce `EmailTemplateService`, `EmailTemplateRenderingService`, and `SendEmailService`.
- Simplify consumer logic by delegating to scoped services.
- Update project dependencies and package references accordingly.
2026-05-14 22:15:15 +03:00
agrynco ab042c7617 Merge pull request 'refactor: improve UpdateAsync in EmailTemplateRepository for better entity handling' (#7) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/7
2026-05-13 03:40:31 +03:00
Anatolii Grynchuk 0861e18cec refactor: improve UpdateAsync in EmailTemplateRepository for better entity handling
- Add null check and fetch entity before updating fields.
- Replace the direct property assignment with detailed updates.
- Ensure changes are saved using SaveChangesAsync.
2026-05-13 03:39:44 +03:00
agrynco dfa097eb43 Merge pull request 'refactor: replace internal UnitOfWork with NotificationUnitOfWork and NotificationBaseRepository' (#6) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/6
2026-05-13 02:12:35 +03:00
Anatolii Grynchuk c18f0b7fb1 refactor: update admin CSS for improved layout and readability
- Switch `.template-preview-variables` to grid layout for better responsiveness.
- Adjust form label font size and spacing for consistency.
- Enhance styles of `.form-control-sm` for improved usability.
2026-05-13 02:11:59 +03:00
Anatolii Grynchuk 50828d23ec refactor: replace internal UnitOfWork with NotificationUnitOfWork and NotificationBaseRepository
- Consolidate unit of work implementation with NotificationUnitOfWork.
- Refactor repositories to use NotificationBaseRepository for consistency.
- Simplify request handlers by removing IUnitOfWork dependency.
- Update related tests and service registration.
2026-05-13 02:08:43 +03:00
agrynco f3966705ad Merge pull request 'refactor: use AsNoTracking for email template queries to improve performance' (#5) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/5
2026-05-12 22:21:32 +03:00
Anatolii Grynchuk b4d8497ea7 refactor: use AsNoTracking for email template queries to improve performance 2026-05-12 22:21:00 +03:00
agrynco 7b77062aa7 Merge pull request 'chore: update package versions and refactor TransactionBehavior' (#4) from development into main
Reviewed-on: https://gitea.grynco.com.ua/hrynco/hrynco-notification-service/pulls/4
2026-05-12 21:58:57 +03:00
Anatolii Grynchuk 9490718c04 feat: add real-time email template preview with variable interpolation
- Introduce preview panel to edit form for rendering subject, HTML, and text with sample values.
- Add support for real-time updates using JavaScript.
- Include supporting styles for preview panel in admin CSS.
- Add optional `Scripts` section rendering to `_EditorLayout`.
- Create `.env.Development` for better development environment configuration.
2026-05-12 21:56:50 +03:00
Anatolii Grynchuk 0d1aa4f6be chore: update package versions and refactor TransactionBehavior
- Upgrade EF Core packages to 10.0.x and HrynCo.DAL.Abstract to 1.0.10
- Refactor TransactionBehavior to simplify transaction handling logic
2026-05-12 21:37:38 +03:00
agrynco 637af06a9c Merge pull request 'refactor: replace local DAL abstractions with hrynco-ef packages' (#3) from development into main 2026-05-05 20:40:15 +03:00
agrynco 9c2edd4712 Merge pull request 'release: development -> main' (#1) from development into main 2026-05-02 23:48:34 +03:00
Anatolii Grynchuk 5c7b5f7b10 Merge branch 'development' 2026-05-02 19:53:26 +03:00
Anatolii Grynchuk b07cd06477 Merge branch 'development' 2026-05-02 18:50:17 +03:00
Anatolii Grynchuk 9a0aaf629b Merge branch 'development' 2026-05-02 18:43:21 +03:00
Anatolii Grynchuk 859ae0b50d Merge branch 'development' 2026-05-02 18:31:36 +03:00
Anatolii Grynchuk 09c3985fad Merge branch 'development' 2026-05-02 16:38:10 +03:00
Anatolii Grynchuk 8dab3c0dc0 Merge branch 'development' 2026-05-02 15:40:13 +03:00
Anatolii Grynchuk a6f9a0a530 Merge branch 'development' 2026-05-02 15:25:10 +03:00
Anatolii Grynchuk c303514414 Merge branch 'development' 2026-05-02 14:40:07 +03:00
Anatolii Grynchuk 18f7981ccc merge: development -> main 2026-05-02 14:23:18 +03:00
92 changed files with 2628 additions and 405 deletions
+3
View File
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
# Vim temporary swap files # Vim temporary swap files
*.swp *.swp
# Local Docker Compose secrets and ports
docker/environments/.env.local
+27
View File
@@ -0,0 +1,27 @@
# HrynCo Notification Service Agent Rules
## Git workflow
- Treat `main` as the only default and integration branch for this repository.
- Start every task branch from the latest `origin/main`.
- Before creating a task branch, fetch the remote, switch to `main`, and fast-forward it from `origin/main`.
- Create the task branch only after confirming that local `main` matches `origin/main`.
- Open pull requests from the task branch into `main`.
- Do not use `development` as the base or pull-request target for new work in this repository.
- Do not commit, push, publish, deploy, or merge unless the repository owner explicitly requests that step.
Recommended branch preparation:
```text
git fetch origin
git switch main
git pull --ff-only origin main
git switch -c <task-branch>
```
## Delivery
- Keep changes focused and preserve backward compatibility where practical.
- Update the matching repository documentation when behavior, contracts, configuration, routing, or workflows change.
- Run the solution build and relevant tests before handoff.
- Use Conventional Commit messages with a lowercase subject and finish the commit body with `Ref: <issue-id>` when an issue exists.
+7 -7
View File
@@ -4,10 +4,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- Entity Framework Core --> <!-- Entity Framework Core -->
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.5" /> <PackageVersion Include="HrynCo.DAL.Abstract" Version="1.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5" /> <PackageVersion Include="HrynCo.DAL.EF" Version="1.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.5" /> <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<!-- MediatR --> <!-- MediatR -->
<PackageVersion Include="MediatR" Version="12.4.1" /> <PackageVersion Include="MediatR" Version="12.4.1" />
<PackageVersion Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" /> <PackageVersion Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
@@ -28,9 +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="HrynCo.DAL.Abstract" Version="1.0.1" />
<PackageVersion Include="HrynCo.DAL.EF" Version="1.0.1" />
<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" />
@@ -1,13 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="HrynCo.DAL.Abstract" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="HrynCo.DAL.Abstract" />
</ItemGroup>
</Project> </Project>
@@ -1,13 +1,14 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.Abstract.Providers; namespace HrynCo.NotificationService.DAL.Abstract.Providers;
/// <summary> /// <summary>
/// Tracks email send counts per EmailChannel per calendar day. /// Tracks email send counts per EmailChannel per calendar day.
/// Monthly counts are derived by summing daily records within a month. /// Monthly counts are derived by summing daily records within a month.
/// </summary> /// </summary>
public class EmailChannelUsage : Entity public class EmailChannelUsage
{ {
public Guid Id { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset? Updated { get; set; }
public Guid ProviderId { get; set; } public Guid ProviderId { get; set; }
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public int SentCount { get; set; } public int SentCount { get; set; }
@@ -4,7 +4,7 @@ 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);
@@ -0,0 +1,20 @@
namespace HrynCo.NotificationService.DAL.EF.Core;
using HrynCo.DAL.Abstract.Entities;
using HrynCo.DAL.EF.Core;
public abstract class NotificationBaseRepository<TEntity>
: BaseRepository<NotificationEfRepository<TEntity>, NotificationDbContext, TEntity, Guid> where TEntity : Entity
{
protected NotificationBaseRepository(NotificationDbContext dbContext)
{
DbContext = dbContext;
}
private NotificationDbContext DbContext { get; set; }
protected override NotificationEfRepository<TEntity> CreateEfRepository()
{
return new NotificationEfRepository<TEntity>(DbContext);
}
}
@@ -0,0 +1,13 @@
namespace HrynCo.NotificationService.DAL.EF.Core;
using HrynCo.DAL.Abstract.Entities;
using HrynCo.DAL.EF.Core;
public class NotificationEfRepository<TEntity> : BaseEfRepository<NotificationDbContext, TEntity, Guid>
where TEntity : class, IEntity<Guid>
{
public NotificationEfRepository(NotificationDbContext dbContext) :
base(dbContext)
{
}
}
@@ -1,10 +0,0 @@
using HrynCo.DAL.EF.Core;
namespace HrynCo.NotificationService.DAL.EF.Core;
internal sealed class UnitOfWork : EfUnitOfWork<NotificationDbContext>
{
public UnitOfWork(NotificationDbContext context) : base(context)
{
}
}
@@ -6,7 +6,6 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="HrynCo.DAL.EF" /> <PackageReference Include="HrynCo.DAL.EF" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.DAL.EF;
using HrynCo.DAL.EF.Core;
public class NotificationUnitOfWork : EfUnitOfWork<NotificationDbContext>
{
public NotificationUnitOfWork(NotificationDbContext context) : base(context)
{
}
}
@@ -1,13 +1,13 @@
namespace HrynCo.NotificationService.DAL.EF.Repositories;
using System.Text.Json; using System.Text.Json;
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.DAL.EF.Core; using HrynCo.NotificationService.DAL.EF.Core;
using HrynCo.NotificationService.DAL.EF.Entities; using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories; internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailChannelEntity>, IEmailChannelRepository
internal sealed class EmailChannelRepository : EfRepository<NotificationDbContext, EmailChannelEntity>, IEmailChannelRepository
{ {
public EmailChannelRepository(NotificationDbContext dbContext) : base(dbContext) public EmailChannelRepository(NotificationDbContext dbContext) : base(dbContext)
{ {
@@ -15,20 +15,14 @@ internal sealed class EmailChannelRepository : EfRepository<NotificationDbContex
public async Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default)
{ {
var entities = await DbSet var entities = await EfRepository.Get().ToListAsync(ct);
.AsNoTracking()
.OrderBy(x => x.ServiceName)
.ThenBy(x => x.Priority)
.ToListAsync(ct);
return entities.Select(MapToDomain).ToList(); return entities.Select(MapToDomain).ToList();
} }
public async Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default) public async Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default)
{ {
var entities = await DbSet var entities = await EfRepository.Get(x => x.ServiceName == serviceName)
.AsNoTracking()
.Where(x => x.ServiceName == serviceName)
.OrderBy(x => x.Priority) .OrderBy(x => x.Priority)
.ToListAsync(ct); .ToListAsync(ct);
@@ -38,8 +32,7 @@ internal sealed class EmailChannelRepository : EfRepository<NotificationDbContex
public async Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync( public async Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(
DateOnly today, CancellationToken ct = default) DateOnly today, CancellationToken ct = default)
{ {
var rows = await DbSet var rows = await EfRepository.Get()
.AsNoTracking()
.OrderBy(c => c.ServiceName) .OrderBy(c => c.ServiceName)
.ThenBy(c => c.Priority) .ThenBy(c => c.Priority)
.Select(c => new .Select(c => new
@@ -61,30 +54,24 @@ internal sealed class EmailChannelRepository : EfRepository<NotificationDbContex
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default) public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default)
{ {
EmailChannelEntity? entity = await DbSet.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); EmailChannelEntity? entity = await EfRepository.GetByIdAsync(id);
return entity is null ? null : MapToDomain(entity); return entity is null ? null : MapToDomain(entity);
} }
public Task AddAsync(EmailChannel channel, CancellationToken ct = default) public Task AddAsync(EmailChannel channel, CancellationToken ct = default)
{ {
return base.AddAsync(MapToEntity(channel), ct); return EfRepository.AddAsync(MapToEntity(channel));
} }
public Task UpdateAsync(EmailChannel channel, CancellationToken ct = default) public async Task UpdateAsync(EmailChannel channel, CancellationToken ct = default)
{ {
EmailChannelEntity entity = MapToEntity(channel); EmailChannelEntity entity = MapToEntity(channel);
entity.Updated = DateTimeOffset.UtcNow; await EfRepository.UpdateAsync(entity);
Update(entity);
return Task.CompletedTask;
} }
public async Task DeleteAsync(EmailChannel channel, CancellationToken ct = default) public async Task DeleteAsync(EmailChannel channel, CancellationToken ct = default)
{ {
EmailChannelEntity? entity = await DbSet.FindAsync([channel.Id], ct); await EfRepository.DeleteAsync(channel.Id);
if (entity is not null)
{
Delete(entity);
}
} }
private static EmailChannel MapToDomain(EmailChannelEntity e) private static EmailChannel MapToDomain(EmailChannelEntity e)
@@ -1,11 +1,11 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.DAL.EF.Core; using HrynCo.NotificationService.DAL.EF.Core;
using HrynCo.NotificationService.DAL.EF.Entities; using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories; namespace HrynCo.NotificationService.DAL.EF.Repositories;
internal sealed class EmailChannelUsageRepository : EfRepository<NotificationDbContext, EmailChannelUsageEntity>, IEmailChannelUsageRepository internal sealed class EmailChannelUsageRepository : NotificationBaseRepository<EmailChannelUsageEntity>, IEmailChannelUsageRepository
{ {
public EmailChannelUsageRepository(NotificationDbContext dbContext) : base(dbContext) public EmailChannelUsageRepository(NotificationDbContext dbContext) : base(dbContext)
{ {
@@ -13,7 +13,8 @@ internal sealed class EmailChannelUsageRepository : EfRepository<NotificationDbC
public async Task<int> GetDailyCountAsync(Guid providerId, DateOnly date, CancellationToken ct = default) public async Task<int> GetDailyCountAsync(Guid providerId, DateOnly date, CancellationToken ct = default)
{ {
EmailChannelUsageEntity? entity = await DbSet EmailChannelUsageEntity? entity = await EfRepository.Get()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct); .FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct);
return entity?.SentCount ?? 0; return entity?.SentCount ?? 0;
@@ -21,7 +22,7 @@ internal sealed class EmailChannelUsageRepository : EfRepository<NotificationDbC
public async Task<int> GetMonthlyCountAsync(Guid providerId, int year, int month, CancellationToken ct = default) public async Task<int> GetMonthlyCountAsync(Guid providerId, int year, int month, CancellationToken ct = default)
{ {
return await DbSet return await EfRepository.Get()
.Where(x => x.ProviderId == providerId .Where(x => x.ProviderId == providerId
&& x.Date.Year == year && x.Date.Year == year
&& x.Date.Month == month) && x.Date.Month == month)
@@ -30,15 +31,16 @@ internal sealed class EmailChannelUsageRepository : EfRepository<NotificationDbC
public async Task IncrementUsageAsync(Guid providerId, DateOnly date, CancellationToken ct = default) public async Task IncrementUsageAsync(Guid providerId, DateOnly date, CancellationToken ct = default)
{ {
EmailChannelUsageEntity? entity = await DbSet EmailChannelUsageEntity? entity = await EfRepository.Get()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct); .FirstOrDefaultAsync(x => x.ProviderId == providerId && x.Date == date, ct);
if (entity is null) if (entity is null)
await AddAsync(new EmailChannelUsageEntity { ProviderId = providerId, Date = date, SentCount = 1 }, ct); await EfRepository.AddAsync(new EmailChannelUsageEntity { ProviderId = providerId, Date = date, SentCount = 1 });
else else
{ {
entity.SentCount++; entity.SentCount++;
Update(entity); await EfRepository.UpdateAsync(entity);
} }
} }
} }
@@ -1,26 +1,43 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.DAL.EF.Core; using HrynCo.NotificationService.DAL.EF.Core;
using HrynCo.NotificationService.DAL.EF.Entities; using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories; namespace HrynCo.NotificationService.DAL.EF.Repositories;
internal sealed class EmailTemplateRepository : EfRepository<NotificationDbContext, EmailTemplateEntity>, IEmailTemplateRepository internal sealed class EmailTemplateRepository
: NotificationBaseRepository<EmailTemplateEntity>, IEmailTemplateRepository
{ {
public EmailTemplateRepository(NotificationDbContext dbContext) : base(dbContext) public EmailTemplateRepository(NotificationDbContext dbContext) : base(dbContext)
{ {
} }
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 DbSet.ToListAsync(ct); IQueryable<EmailTemplateEntity> query = EfRepository.Get();
if (!string.IsNullOrWhiteSpace(serviceName))
{
query = query.Where(x => x.ServiceName == serviceName);
}
if (!string.IsNullOrWhiteSpace(key))
{
query = query.Where(x => x.Key == key);
}
List<EmailTemplateEntity> entities = await query
.OrderBy(x => x.ServiceName).ThenBy(x => x.Key)
.AsNoTracking()
.ToListAsync(ct);
return entities.Select(MapToDomain).ToList(); return entities.Select(MapToDomain).ToList();
} }
public async Task<IReadOnlyList<EmailTemplate>> GetByServiceAsync(string serviceName, CancellationToken ct = default) public async Task<IReadOnlyList<EmailTemplate>> GetByServiceAsync(string serviceName, CancellationToken ct = default)
{ {
List<EmailTemplateEntity> entities = await DbSet List<EmailTemplateEntity> entities = await EfRepository.Get()
.AsNoTracking()
.Where(x => x.ServiceName == serviceName) .Where(x => x.ServiceName == serviceName)
.ToListAsync(ct); .ToListAsync(ct);
@@ -29,28 +46,48 @@ internal sealed class EmailTemplateRepository : EfRepository<NotificationDbConte
public async Task<EmailTemplate?> GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default) public async Task<EmailTemplate?> GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default)
{ {
EmailTemplateEntity? entity = await DbSet.FirstOrDefaultAsync( EmailTemplateEntity? entity = await EfRepository.Get()
.AsNoTracking()
.FirstOrDefaultAsync(
x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct);
return entity is null ? null : MapToDomain(entity); return entity is null ? null : MapToDomain(entity);
} }
public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) => public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default)
base.AddAsync(MapToEntity(EmailTemplate), ct);
public Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default)
{ {
EmailTemplateEntity entity = MapToEntity(EmailTemplate); return EfRepository.AddAsync(MapToEntity(EmailTemplate));
entity.Updated = DateTimeOffset.UtcNow; }
Update(entity);
return Task.CompletedTask; public async Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default)
{
EmailTemplateEntity? entity = await EfRepository.Get()
.FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct);
if (entity is null)
{
return;
}
entity.ServiceName = EmailTemplate.ServiceName;
entity.Key = EmailTemplate.Key;
entity.LanguageCode = EmailTemplate.LanguageCode;
entity.Subject = EmailTemplate.Subject;
entity.HtmlBody = EmailTemplate.HtmlBody;
entity.TextBody = EmailTemplate.TextBody;
entity.Variables = EmailTemplate.Variables
.Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required })
.ToList();
await EfRepository.SaveChangesAsync();
} }
public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default)
{ {
EmailTemplateEntity? entity = await DbSet.FindAsync([EmailTemplate.Id], ct); EmailTemplateEntity? entity = await EfRepository.Get()
.FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct);
if (entity is not null) if (entity is not null)
Delete(entity); await EfRepository.DeleteAsync(entity);
} }
private static EmailTemplate MapToDomain(EmailTemplateEntity e) => new() private static EmailTemplate MapToDomain(EmailTemplateEntity e) => new()
@@ -16,10 +16,10 @@ public static class ServiceCollectionExtensions
services.AddDbContext<NotificationDbContext>(options => services.AddDbContext<NotificationDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
services.AddScoped<IUnitOfWork, UnitOfWork>();
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>(); services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
services.AddScoped<IEmailChannelRepository, EmailChannelRepository>(); services.AddScoped<IEmailChannelRepository, EmailChannelRepository>();
services.AddScoped<IEmailChannelUsageRepository, EmailChannelUsageRepository>(); services.AddScoped<IEmailChannelUsageRepository, EmailChannelUsageRepository>();
services.AddScoped<IUnitOfWork, NotificationUnitOfWork>();
return services; return services;
} }
@@ -0,0 +1,116 @@
namespace HrynCo.NotificationService.Migrator;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.Logging;
internal sealed class DevelopmentDataSeeder
{
internal const string DefaultServiceName = "TestService";
internal const string DefaultLanguageCode = "en";
private readonly IEmailChannelRepository _channels;
private readonly IEmailTemplateRepository _templates;
private readonly ILogger<DevelopmentDataSeeder> _logger;
public DevelopmentDataSeeder(
IEmailChannelRepository channels,
IEmailTemplateRepository templates,
ILogger<DevelopmentDataSeeder> logger)
{
_channels = channels;
_templates = templates;
_logger = logger;
}
public async Task SeedAsync(string serviceName, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(serviceName))
throw new InvalidOperationException("Development seed service name is not configured.");
await SeedMailpitChannelAsync(serviceName, cancellationToken);
foreach (EmailTemplate template in CreateTemplates(serviceName))
{
EmailTemplate? existing = await _templates.GetAsync(
template.ServiceName, template.Key, template.LanguageCode, cancellationToken);
if (existing is not null)
{
_logger.LogInformation(
"Development email template already exists; leaving it unchanged [ServiceName={ServiceName}, Key={TemplateKey}, LanguageCode={LanguageCode}]",
template.ServiceName, template.Key, template.LanguageCode);
continue;
}
await _templates.AddAsync(template, cancellationToken);
_logger.LogInformation(
"Created development email template [ServiceName={ServiceName}, Key={TemplateKey}, LanguageCode={LanguageCode}]",
template.ServiceName, template.Key, template.LanguageCode);
}
}
private async Task SeedMailpitChannelAsync(string serviceName, CancellationToken cancellationToken)
{
IReadOnlyList<EmailChannel> existingChannels = await _channels.GetByServiceAsync(
serviceName, cancellationToken);
if (existingChannels.Count > 0)
{
_logger.LogInformation(
"Development email channel already exists; leaving all channels unchanged [ServiceName={ServiceName}]",
serviceName);
return;
}
var channel = new EmailChannel
{
ServiceName = serviceName,
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "mailpit",
Port = 1025,
Username = string.Empty,
Password = string.Empty,
UseSsl = false,
FromEmail = "no-reply@local.hrynco.test",
FromName = "HrynCo Development"
},
WarnThresholdPercent = 90,
IsActive = true,
Created = DateTimeOffset.UtcNow
};
await _channels.AddAsync(channel, cancellationToken);
_logger.LogInformation(
"Created Mailpit development email channel [ServiceName={ServiceName}]",
serviceName);
}
private static IReadOnlyList<EmailTemplate> CreateTemplates(string serviceName)
{
DateTimeOffset created = DateTimeOffset.UtcNow;
return
[
new EmailTemplate
{
ServiceName = serviceName,
Key = "TestEmail",
LanguageCode = DefaultLanguageCode,
Subject = "Test notification",
HtmlBody = "<p>Hello {{RecipientName}},</p><p>{{Message}}</p>",
TextBody = "Hello {{RecipientName}}, {{Message}}",
Variables =
[
new EmailTemplateVariable { Name = "RecipientName", Required = true },
new EmailTemplateVariable { Name = "Message", Required = true }
],
Created = created
}
];
}
}
+17 -3
View File
@@ -1,5 +1,7 @@
using HrynCo.NotificationService.DAL.EF; using HrynCo.NotificationService.DAL.EF;
using HrynCo.NotificationService.Migrator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Serilog; using Serilog;
@@ -21,8 +23,8 @@ try
var connectionString = ctx.Configuration["App:ConnectionString"] var connectionString = ctx.Configuration["App:ConnectionString"]
?? throw new InvalidOperationException("App:ConnectionString is not configured."); ?? throw new InvalidOperationException("App:ConnectionString is not configured.");
services.AddDbContext<NotificationDbContext>(options => services.AddNotificationDataAccess(connectionString);
options.UseNpgsql(connectionString)); services.AddScoped<DevelopmentDataSeeder>();
}) })
.Build(); .Build();
@@ -32,6 +34,18 @@ try
Log.Information("Applying migrations..."); Log.Information("Applying migrations...");
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();
Log.Information("Migrations applied successfully."); Log.Information("Migrations applied successfully.");
var environment = scope.ServiceProvider.GetRequiredService<IHostEnvironment>();
if (environment.IsDevelopment())
{
string serviceName = host.Services.GetRequiredService<IConfiguration>()["DevelopmentSeed:ServiceName"]
?? DevelopmentDataSeeder.DefaultServiceName;
Log.Information("Seeding development email configuration...");
var seeder = scope.ServiceProvider.GetRequiredService<DevelopmentDataSeeder>();
await seeder.SeedAsync(serviceName);
Log.Information("Development email configuration is ready.");
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
@@ -0,0 +1,83 @@
namespace HrynCo.NotificationService.Services.Tests;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Migrator;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
public sealed class DevelopmentDataSeederTests
{
private readonly IEmailChannelRepository _channels = Substitute.For<IEmailChannelRepository>();
private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>();
[Fact]
public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
.Returns(Array.Empty<EmailChannel>());
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
.Returns((EmailTemplate?)null);
DevelopmentDataSeeder seeder = CreateSeeder();
await seeder.SeedAsync("TestService", CancellationToken.None);
await _channels.Received(1).AddAsync(
Arg.Is<EmailChannel>(channel => IsMailpitChannel(channel)),
Arg.Any<CancellationToken>());
await _templates.Received(1).AddAsync(
Arg.Is<EmailTemplate>(template =>
template.ServiceName == "TestService" &&
template.Key == "TestEmail" &&
template.Variables.Any(variable => variable.Name == "Message" && variable.Required)),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
.Returns([new EmailChannel
{
ServiceName = "TestService",
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings()
}]);
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
.Returns(new EmailTemplate
{
ServiceName = "TestService",
Key = "TestEmail",
LanguageCode = "en",
Subject = "Existing",
HtmlBody = "Existing",
TextBody = "Existing"
});
DevelopmentDataSeeder seeder = CreateSeeder();
await seeder.SeedAsync("TestService", CancellationToken.None);
await _channels.DidNotReceive().AddAsync(Arg.Any<EmailChannel>(), Arg.Any<CancellationToken>());
await _templates.DidNotReceive().AddAsync(Arg.Any<EmailTemplate>(), Arg.Any<CancellationToken>());
await _channels.DidNotReceive().UpdateAsync(Arg.Any<EmailChannel>(), Arg.Any<CancellationToken>());
await _templates.DidNotReceive().UpdateAsync(Arg.Any<EmailTemplate>(), Arg.Any<CancellationToken>());
}
private DevelopmentDataSeeder CreateSeeder() => new(
_channels,
_templates,
NullLogger<DevelopmentDataSeeder>.Instance);
private static bool IsMailpitChannel(EmailChannel channel)
{
return channel.ServiceName == "TestService" &&
channel.IsActive &&
channel.Settings is SmtpChannelSettings smtp &&
smtp.Host == "mailpit" &&
smtp.Port == 1025 &&
!smtp.UseSsl;
}
}
@@ -0,0 +1,88 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
public sealed class EmailTemplateRenderingServiceTests
{
private readonly EmailTemplateRenderingService _service = new();
[Fact]
public void Render_InterpolatesItemTrackerVariables()
{
EmailTemplate template = CreateTemplate();
SendEmailMessageData data = CreateData(new Dictionary<string, string>
{
["AppName"] = "StoreMate",
["VerificationUrl"] = "https://example.invalid/verify"
});
RenderedEmail result = _service.Render(template, data);
Assert.Equal("Verify StoreMate", result.Subject);
Assert.Equal("<a href=\"https://example.invalid/verify\">Verify</a>", result.HtmlBody);
Assert.Equal("Verify at https://example.invalid/verify", result.TextBody);
}
[Fact]
public void Render_WhenRequiredVariableIsMissing_ThrowsObservableFailure()
{
EmailTemplate template = CreateTemplate();
SendEmailMessageData data = CreateData(new Dictionary<string, string>
{
["AppName"] = "StoreMate"
});
InvalidDataException exception = Assert.Throws<InvalidDataException>(
() => _service.Render(template, data));
Assert.Equal("Required template variables are missing: VerificationUrl.", exception.Message);
}
[Fact]
public void Render_HtmlEncodesVariablesWithoutChangingSubjectOrTextBody()
{
EmailTemplate template = CreateTemplate();
SendEmailMessageData data = CreateData(new Dictionary<string, string>
{
["AppName"] = "Invemory <script>alert('xss')</script>",
["VerificationUrl"] = "https://example.invalid/verify?next=\" onclick=\"alert('xss')"
});
RenderedEmail result = _service.Render(template, data);
Assert.Equal("Verify Invemory <script>alert('xss')</script>", result.Subject);
Assert.Equal(
"<a href=\"https://example.invalid/verify?next=&quot; onclick=&quot;alert(&#39;xss&#39;)\">Verify</a>",
result.HtmlBody);
Assert.Equal(
"Verify at https://example.invalid/verify?next=\" onclick=\"alert('xss')",
result.TextBody);
}
private static EmailTemplate CreateTemplate() => new()
{
ServiceName = "StoreMate-Prod",
Key = "EmailVerification",
LanguageCode = "uk",
Subject = "Verify {{AppName}}",
HtmlBody = "<a href=\"{{VerificationUrl}}\">Verify</a>",
TextBody = "Verify at {{VerificationUrl}}",
Variables =
[
new EmailTemplateVariable { Name = "AppName", Required = true },
new EmailTemplateVariable { Name = "VerificationUrl", Required = true }
]
};
private static SendEmailMessageData CreateData(IReadOnlyDictionary<string, string> variables) => new()
{
ServiceName = "StoreMate-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = variables
};
}
@@ -0,0 +1,30 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using NSubstitute;
public sealed class EmailTemplateServiceTests
{
[Fact]
public async Task GetAsync_WhenRequestedLanguageDoesNotExist_DoesNotSilentlyFallback()
{
IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
var service = new EmailTemplateService(repository);
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.GetAsync("StoreMate-Prod", "EmailVerification", "UK", CancellationToken.None));
Assert.Contains("language='uk'", exception.Message);
await repository.Received(1).GetAsync(
"StoreMate-Prod",
"EmailVerification",
"uk",
CancellationToken.None);
await repository.DidNotReceive().GetAsync(
"StoreMate-Prod",
"EmailVerification",
"en",
Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,58 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net.Mail;
using System.Net.Sockets;
public sealed class NotificationDeliveryErrorFormatterTests
{
[Fact]
public void Format_UsesInnermostMessageAndRemovesLineBreaks()
{
var exception = new InvalidOperationException(
"outer",
new Exception("provider failed\r\nretry rejected"));
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(
"Notification delivery failed after all retry attempts (provider failed retry rejected).",
result);
}
[Fact]
public void Format_UnresolvedSmtpHost_AddsSafeChannelContext()
{
var socketException = new SocketException((int)SocketError.HostNotFound);
var exception = new SmtpException("Failure sending mail.", socketException);
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(
$"SMTP delivery failed after all retry attempts: the configured SMTP server host could not be resolved ({socketException.Message}).",
result);
}
[Fact]
public void Format_RefusedSmtpConnection_AddsSafeChannelContext()
{
var socketException = new SocketException((int)SocketError.ConnectionRefused);
var exception = new SmtpException("Failure sending mail.", socketException);
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(
$"SMTP delivery failed after all retry attempts: the configured SMTP server refused the connection ({socketException.Message}).",
result);
}
[Fact]
public void Format_LongError_IsBoundedToOutboxColumnLength()
{
var exception = new Exception(new string('x', 2100));
string result = NotificationDeliveryErrorFormatter.Format(exception);
Assert.Equal(2000, result.Length);
}
}
@@ -0,0 +1,107 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
public sealed class NotificationResultPublisherTests
{
[Fact]
public async Task PublishAsync_TerminalFailure_PublishesNeutralFailureResultToReplyQueue()
{
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
var publisher = new NotificationResultPublisher(
rabbitMqPublisher,
NullLogger<NotificationResultPublisher>.Instance);
SendEmailMessage message = CreateMessage();
NotificationResultMessage? publishedResult = null;
rabbitMqPublisher
.When(x => x.PublishAsync(
"item-tracker.notifications.result",
Arg.Any<NotificationResultMessage>(),
Arg.Any<CancellationToken>()))
.Do(call => publishedResult = call.ArgAt<NotificationResultMessage>(1));
await publisher.PublishAsync(
message,
"Email provider could not deliver the notification.",
CancellationToken.None);
await rabbitMqPublisher.Received(1).PublishAsync(
"item-tracker.notifications.result",
Arg.Any<NotificationResultMessage>(),
CancellationToken.None);
Assert.NotNull(publishedResult);
Assert.Equal(message.CorrelationContext.CorrelationId, publishedResult.CorrelationContext.CorrelationId);
Assert.Null(publishedResult.CorrelationContext.ReplyTo);
Assert.Equal(message.Data.ServiceName, publishedResult.Data.ServiceName);
Assert.Equal(message.Data.TemplateKey, publishedResult.Data.TemplateKey);
Assert.Equal(message.Data.RecipientEmail, publishedResult.Data.RecipientEmail);
Assert.Equal("Email provider could not deliver the notification.", publishedResult.Data.ErrorMessage);
Assert.False(publishedResult.Data.IsSuccess);
}
[Fact]
public async Task PublishAsync_NoReplyQueue_DoesNotPublish()
{
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
var publisher = new NotificationResultPublisher(
rabbitMqPublisher,
NullLogger<NotificationResultPublisher>.Instance);
SendEmailMessage message = CreateMessage();
message.CorrelationContext = message.CorrelationContext with { ReplyTo = null };
await publisher.PublishAsync(message, "delivery failed", CancellationToken.None);
await rabbitMqPublisher.DidNotReceive()
.PublishAsync<NotificationResultData>(
Arg.Any<string>(),
Arg.Any<NotificationResultMessage>(),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task PublishAsync_ResultBrokerFailure_IsBestEffort()
{
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
rabbitMqPublisher
.PublishAsync(
Arg.Any<string>(),
Arg.Any<NotificationResultMessage>(),
Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new InvalidOperationException("reply broker unavailable"));
var publisher = new NotificationResultPublisher(
rabbitMqPublisher,
NullLogger<NotificationResultPublisher>.Instance);
Exception? exception = await Record.ExceptionAsync(() => publisher.PublishAsync(
CreateMessage(),
"delivery failed",
CancellationToken.None));
Assert.Null(exception);
}
private static SendEmailMessage CreateMessage()
{
return new SendEmailMessage
{
CorrelationContext = new CorrelationContext
{
CorrelationId = "correlation-id",
ReplyTo = "item-tracker.notifications.result"
},
Data = new SendEmailMessageData
{
ServiceName = "TestService",
TemplateKey = "TestEmail",
RecipientEmail = "test.user@itemtracker.local",
RecipientName = "Test User",
LanguageCode = "en",
Variables = new Dictionary<string, string>()
}
};
}
}
@@ -0,0 +1,16 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
public sealed class RecipientAddressRedactorTests
{
[Theory]
[InlineData("owner@example.com", "o***@e***.com")]
[InlineData("a@b.test", "a***@b***.test")]
[InlineData("invalid", "***")]
[InlineData(null, "<missing>")]
public void Redact_DoesNotExposeFullAddress(string? address, string expected)
{
Assert.Equal(expected, RecipientAddressRedactor.Redact(address));
}
}
@@ -0,0 +1,60 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
public sealed class SendEmailContractTests
{
private const string ItemTrackerPayload = """
{
"CorrelationContext": {
"CorrelationId": "3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc",
"ReplyTo": "item-tracker.notifications.result"
},
"Data": {
"ServiceName": "StoreMate-Prod",
"TemplateKey": "EmailVerification",
"RecipientEmail": "owner@example.com",
"RecipientName": "StoreMate Owner",
"Variables": {
"AppName": "StoreMate",
"VerificationUrl": "https://example.invalid/sensitive-token"
},
"LanguageCode": "uk"
}
}
""";
[Fact]
public void PascalCaseItemTrackerPayload_DeserializesWithoutLosingValues()
{
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(ItemTrackerPayload);
Assert.NotNull(message);
SendEmailMessageValidator.Validate(message);
Assert.Equal("3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc", message.CorrelationContext.CorrelationId);
Assert.Equal("item-tracker.notifications.result", message.CorrelationContext.ReplyTo);
Assert.Equal("StoreMate-Prod", message.Data.ServiceName);
Assert.Equal("EmailVerification", message.Data.TemplateKey);
Assert.Equal("owner@example.com", message.Data.RecipientEmail);
Assert.Equal("StoreMate Owner", message.Data.RecipientName);
Assert.Equal("uk", message.Data.LanguageCode);
Assert.Equal("StoreMate", message.Data.Variables["AppName"]);
Assert.Equal(
"https://example.invalid/sensitive-token",
message.Data.Variables["VerificationUrl"]);
}
[Fact]
public void MissingLanguageCode_IsRejected()
{
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(
ItemTrackerPayload.Replace("\"uk\"", "null", StringComparison.Ordinal));
InvalidDataException exception = Assert.Throws<InvalidDataException>(
() => SendEmailMessageValidator.Validate(message!));
Assert.Equal("LanguageCode is required.", exception.Message);
}
}
@@ -0,0 +1,77 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker;
using Hrynco.RabbitMq;
public sealed class SendEmailDeliveryValidatorTests
{
[Fact]
public void ValidItemTrackerDelivery_IsAccepted()
{
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
CreateContext(),
SendEmailConsumer.SupportedMessageType);
Assert.Null(error);
}
[Fact]
public void MissingMessageId_IsRejected()
{
RabbitMqMessageContext context = CreateContext() with { MessageId = null };
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
Assert.Equal("AMQP MessageId is required.", error);
}
[Fact]
public void UnsupportedContractVersion_IsRejected()
{
RabbitMqMessageContext context = CreateContext() with
{
MessageType = "Notification.SendEmail.v2"
};
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error);
}
private static RabbitMqMessageContext CreateContext() => new()
{
QueueName = SendEmailConsumer.IncomingQueue,
Exchange = string.Empty,
RoutingKey = SendEmailConsumer.IncomingQueue,
MessageId = Guid.NewGuid().ToString("D"),
MessageType = SendEmailConsumer.SupportedMessageType,
CorrelationId = "correlation-id",
ContentType = "application/json"
};
private static SendEmailMessage CreateMessage() => new()
{
CorrelationContext = new CorrelationContext
{
CorrelationId = "correlation-id",
ReplyTo = "item-tracker.notifications.result"
},
Data = new SendEmailMessageData
{
ServiceName = "StoreMate-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
}
};
}
@@ -0,0 +1,123 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
public sealed class SendEmailServiceTests
{
[Fact]
public async Task ProcessAsync_ValidItemTrackerMessage_SendsOnceAndUpdatesUsage()
{
Guid channelId = Guid.NewGuid();
var settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid",
Port = 587,
Username = "smtp-user",
Password = "not-a-real-secret",
UseSsl = true,
FromEmail = "notifications@example.com",
FromName = "StoreMate"
};
var channel = new EmailChannel
{
Id = channelId,
ServiceName = "StoreMate-Prod",
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = settings,
IsActive = true
};
var template = new EmailTemplate
{
ServiceName = "StoreMate-Prod",
Key = "EmailVerification",
LanguageCode = "uk",
Subject = "Verify {{AppName}}",
HtmlBody = "<p>{{AppName}}</p>",
TextBody = "{{AppName}}",
Variables = [new EmailTemplateVariable { Name = "AppName", Required = true }]
};
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
.Returns([channel]);
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
.Returns(template);
var renderer = new EmailTemplateRenderingService();
var smtp = new RecordingSmtpEmailSender();
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
var service = new SendEmailService(
channels,
usage,
templates,
renderer,
smtp,
resultPublisher,
NullLogger<SendEmailService>.Instance);
var message = new SendEmailMessage
{
CorrelationContext = new CorrelationContext
{
CorrelationId = Guid.NewGuid().ToString("D")
},
Data = new SendEmailMessageData
{
ServiceName = "StoreMate-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
}
};
await service.ProcessAsync(message, CancellationToken.None);
Assert.Equal(1, smtp.SendCount);
Assert.Same(settings, smtp.Settings);
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
Assert.Equal("owner@example.com", smtp.RecipientEmail);
Assert.Equal("Owner", smtp.RecipientName);
await usage.Received(1).IncrementUsageAsync(
channelId,
Arg.Any<DateOnly>(),
CancellationToken.None);
await resultPublisher.Received(1).PublishAsync(
message,
null,
CancellationToken.None);
}
private sealed class RecordingSmtpEmailSender : ISmtpEmailSender
{
public int SendCount { get; private set; }
public SmtpChannelSettings? Settings { get; private set; }
public RenderedEmail? Email { get; private set; }
public string? RecipientEmail { get; private set; }
public string? RecipientName { get; private set; }
public Task SendAsync(
SmtpChannelSettings settings,
RenderedEmail email,
string recipientEmail,
string recipientName,
CancellationToken cancellationToken)
{
SendCount++;
Settings = settings;
Email = email;
RecipientEmail = recipientEmail;
RecipientName = recipientName;
return Task.CompletedTask;
}
}
}
@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>
@@ -21,6 +22,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" /> <ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" /> <ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Worker\HrynCo.NotificationService.Worker.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Migrator\HrynCo.NotificationService.Migrator.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,10 +0,0 @@
namespace HrynCo.NotificationService.Services.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -18,11 +18,15 @@ public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TReque
public Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) => public Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken cancellationToken) =>
_profiler.MeasureExecutionAsync( _profiler.MeasureExecutionAsync(
() => _unitOfWork.ExecuteInTransactionAsync(async () => async () =>
{ {
TResponse response = await next(); TResponse? response = default;
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.ExecuteInTransactionAsync(async () =>
return response; {
}), response = await next();
});
return response!;
},
typeof(TRequest).Name); typeof(TRequest).Name);
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.Services.Logging; using HrynCo.NotificationService.Services.Logging;
using MediatR; using MediatR;
using Serilog; using Serilog;
@@ -8,14 +7,12 @@ namespace HrynCo.NotificationService.Services.Core;
public abstract class RequestHandler<TRequest, TResponse> : IRequestHandler<TRequest, TResponse> public abstract class RequestHandler<TRequest, TResponse> : IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse> where TRequest : IRequest<TResponse>
{ {
protected RequestHandler(IContextualSerilogLogger<TRequest> logger, IUnitOfWork unitOfWork) protected RequestHandler(IContextualSerilogLogger<TRequest> logger)
{ {
Logger = logger.Logger; Logger = logger.Logger;
UnitOfWork = unitOfWork;
} }
protected ILogger Logger { get; } protected ILogger Logger { get; }
protected IUnitOfWork UnitOfWork { get; }
public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken) public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken)
{ {
@@ -14,9 +14,8 @@ internal sealed class CreateEmailChannelHandler
public CreateEmailChannelHandler( public CreateEmailChannelHandler(
IContextualSerilogLogger<CreateEmailChannelCommand> logger, IContextualSerilogLogger<CreateEmailChannelCommand> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channels) IEmailChannelRepository channels)
: base(logger, unitOfWork) : base(logger)
{ {
_channels = channels; _channels = channels;
} }
@@ -13,9 +13,8 @@ internal sealed class DeleteEmailChannelHandler
public DeleteEmailChannelHandler( public DeleteEmailChannelHandler(
IContextualSerilogLogger<DeleteEmailChannelCommand> logger, IContextualSerilogLogger<DeleteEmailChannelCommand> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channels) IEmailChannelRepository channels)
: base(logger, unitOfWork) : base(logger)
{ {
_channels = channels; _channels = channels;
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
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;
@@ -14,9 +13,8 @@ internal sealed class GetEmailChannelHandler
public GetEmailChannelHandler( public GetEmailChannelHandler(
IContextualSerilogLogger<GetEmailChannelQuery> logger, IContextualSerilogLogger<GetEmailChannelQuery> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channels) IEmailChannelRepository channels)
: base(logger, unitOfWork) : base(logger)
{ {
_channels = channels; _channels = channels;
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
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;
@@ -14,9 +13,8 @@ internal sealed class GetAllEmailChannelsHandler
public GetAllEmailChannelsHandler( public GetAllEmailChannelsHandler(
IContextualSerilogLogger<GetAllEmailChannelsQuery> logger, IContextualSerilogLogger<GetAllEmailChannelsQuery> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channels) IEmailChannelRepository channels)
: base(logger, unitOfWork) : base(logger)
{ {
_channels = channels; _channels = channels;
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
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;
@@ -14,9 +13,8 @@ internal sealed class GetEmailChannelsHandler
public GetEmailChannelsHandler( public GetEmailChannelsHandler(
IContextualSerilogLogger<GetEmailChannelsQuery> logger, IContextualSerilogLogger<GetEmailChannelsQuery> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channels) IEmailChannelRepository channels)
: base(logger, unitOfWork) : base(logger)
{ {
_channels = channels; _channels = channels;
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging; using HrynCo.NotificationService.Services.Logging;
@@ -13,9 +12,8 @@ internal sealed class GetChannelUsageSummaryHandler
public GetChannelUsageSummaryHandler( public GetChannelUsageSummaryHandler(
IContextualSerilogLogger<GetChannelUsageSummaryQuery> logger, IContextualSerilogLogger<GetChannelUsageSummaryQuery> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channelsRepository) IEmailChannelRepository channelsRepository)
: base(logger, unitOfWork) : base(logger)
{ {
_channelsRepository = channelsRepository; _channelsRepository = channelsRepository;
} }
@@ -1,6 +1,6 @@
using System.Net; using System.Net;
using System.Net.Mail; using System.Net.Mail;
using HrynCo.DAL.Abstract; 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;
@@ -17,10 +17,9 @@ internal sealed class SendEmailHandler
public SendEmailHandler( public SendEmailHandler(
IContextualSerilogLogger<SendEmailCommand> logger, IContextualSerilogLogger<SendEmailCommand> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channels, IEmailChannelRepository channels,
IEmailChannelUsageRepository usage) IEmailChannelUsageRepository usage)
: base(logger, unitOfWork) : base(logger)
{ {
_channels = channels; _channels = channels;
_usage = usage; _usage = usage;
@@ -50,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,6 +1,5 @@
using System.Net; using System.Net;
using System.Net.Mail; using System.Net.Mail;
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging; using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
@@ -11,9 +10,8 @@ internal sealed class TestSmtpHandler
: RequestHandler<TestSmtpCommand, ServiceResult<Unit>> : RequestHandler<TestSmtpCommand, ServiceResult<Unit>>
{ {
public TestSmtpHandler( public TestSmtpHandler(
IContextualSerilogLogger<TestSmtpCommand> logger, IContextualSerilogLogger<TestSmtpCommand> logger)
IUnitOfWork unitOfWork) : base(logger)
: base(logger, unitOfWork)
{ {
} }
@@ -13,9 +13,8 @@ internal sealed class UpdateEmailChannelHandler
public UpdateEmailChannelHandler( public UpdateEmailChannelHandler(
IContextualSerilogLogger<UpdateEmailChannelCommand> logger, IContextualSerilogLogger<UpdateEmailChannelCommand> logger,
IUnitOfWork unitOfWork,
IEmailChannelRepository channels) IEmailChannelRepository channels)
: base(logger, unitOfWork) : base(logger)
{ {
_channels = channels; _channels = channels;
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
@@ -14,9 +13,8 @@ internal sealed class CreateEmailTemplateHandler
public CreateEmailTemplateHandler( public CreateEmailTemplateHandler(
IContextualSerilogLogger<CreateEmailTemplateCommand> logger, IContextualSerilogLogger<CreateEmailTemplateCommand> logger,
IUnitOfWork unitOfWork,
IEmailTemplateRepository templates) IEmailTemplateRepository templates)
: base(logger, unitOfWork) : base(logger)
{ {
_templates = templates; _templates = templates;
} }
@@ -13,9 +13,8 @@ internal sealed class DeleteEmailTemplateHandler
public DeleteEmailTemplateHandler( public DeleteEmailTemplateHandler(
IContextualSerilogLogger<DeleteEmailTemplateCommand> logger, IContextualSerilogLogger<DeleteEmailTemplateCommand> logger,
IUnitOfWork unitOfWork,
IEmailTemplateRepository templates) IEmailTemplateRepository templates)
: base(logger, unitOfWork) : base(logger)
{ {
_templates = templates; _templates = templates;
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
@@ -14,9 +13,8 @@ internal sealed class GetEmailTemplateHandler
public GetEmailTemplateHandler( public GetEmailTemplateHandler(
IContextualSerilogLogger<GetEmailTemplateQuery> logger, IContextualSerilogLogger<GetEmailTemplateQuery> logger,
IUnitOfWork unitOfWork,
IEmailTemplateRepository templates) IEmailTemplateRepository templates)
: base(logger, unitOfWork) : base(logger)
{ {
_templates = templates; _templates = templates;
} }
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
@@ -14,9 +13,8 @@ internal sealed class GetAllEmailTemplatesHandler
public GetAllEmailTemplatesHandler( public GetAllEmailTemplatesHandler(
IContextualSerilogLogger<GetAllEmailTemplatesQuery> logger, IContextualSerilogLogger<GetAllEmailTemplatesQuery> logger,
IUnitOfWork unitOfWork,
IEmailTemplateRepository templates) IEmailTemplateRepository templates)
: base(logger, unitOfWork) : base(logger)
{ {
_templates = templates; _templates = templates;
} }
@@ -24,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);
} }
} }
@@ -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>>>;
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
@@ -14,9 +13,8 @@ internal sealed class GetEmailTemplatesHandler
public GetEmailTemplatesHandler( public GetEmailTemplatesHandler(
IContextualSerilogLogger<GetEmailTemplatesQuery> logger, IContextualSerilogLogger<GetEmailTemplatesQuery> logger,
IUnitOfWork unitOfWork,
IEmailTemplateRepository templates) IEmailTemplateRepository templates)
: base(logger, unitOfWork) : base(logger)
{ {
_templates = templates; _templates = templates;
} }
@@ -13,9 +13,8 @@ internal sealed class UpdateEmailTemplateHandler
public UpdateEmailTemplateHandler( public UpdateEmailTemplateHandler(
IContextualSerilogLogger<UpdateEmailTemplateCommand> logger, IContextualSerilogLogger<UpdateEmailTemplateCommand> logger,
IUnitOfWork unitOfWork,
IEmailTemplateRepository templates) IEmailTemplateRepository templates)
: base(logger, unitOfWork) : base(logger)
{ {
_templates = templates; _templates = templates;
} }
@@ -0,0 +1,32 @@
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminTemplatesIndexViewTests
{
[Fact]
public void CreateAndEditLinks_UseExplicitFilterQueryInterpolation()
{
string view = File.ReadAllText(FindIndexView());
Assert.Contains("/admin/templates/create@(filterQuery)", view);
Assert.Contains("@t.LanguageCode@(filterQuery)", view);
Assert.DoesNotContain("create@filterQuery", view);
Assert.DoesNotContain("LanguageCode@filterQuery", view);
}
private static string FindIndexView()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "HrynCo.NotificationService.slnx")))
{
directory = directory.Parent;
}
Assert.NotNull(directory);
return Path.Combine(
directory.FullName,
"HrynCo.NotificationService.Web",
"Views",
"AdminTemplates",
"Index.cshtml");
}
}
@@ -1,10 +0,0 @@
namespace HrynCo.NotificationService.Web.IntegrationTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
@@ -23,9 +23,12 @@ public class AdminTemplatesController : Controller
// GET /admin/templates // 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 });
} }
} }
@@ -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";
@@ -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);
} }
@@ -1,6 +1,47 @@
@HrynCo.NotificationService.Api_HostAddress = http://localhost:5188 @host = http://localhost:5188
GET {{HrynCo.NotificationService.Api_HostAddress}}/weatherforecast/ ### Create a new email template
Accept: application/json POST {{host}}/api/v1/email-templates
Content-Type: application/json
### {
"ServiceName": "StoreMate-Prod",
"Key": "ShareInvite",
"LanguageCode": "uk",
"Subject": "Вас запрошено",
"HtmlBody": "<html><body><div style=\"font-family: Arial, sans-serif; color: #1f2937;\"><p>Вітаємо, \u007b\u007bRecipientName\u007d\u007d.</p><h1>Вас запрошено</h1><p>\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d, щоб ви могли безпечно співпрацювати.</p><p><a href=\"\u007b\u007bInviteLink\u007d\u007d\">Відкрити запрошення</a></p><p>Запрошення дійсне до <strong>\u007b\u007bValidUntil\u007d\u007d</strong>.</p></div></body></html>",
"TextBody": "Вітаємо, \u007b\u007bRecipientName\u007d\u007d.\n\n\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d, щоб ви могли безпечно співпрацювати.\n\nВідкрийте запрошення: \u007b\u007bInviteLink\u007d\u007d\nДійсне до: \u007b\u007bValidUntil\u007d\u007d",
"Variables": [
{ "Name": "RecipientName", "Required": false },
{ "Name": "InviterName", "Required": false },
{ "Name": "AppName", "Required": false },
{ "Name": "InviteLink", "Required": false },
{ "Name": "ValidUntil", "Required": false }
]
}
### Get the created template
GET {{host}}/api/v1/email-templates/StoreMate-Prod/ShareInvite/uk
### List all templates for the service
GET {{host}}/api/v1/email-templates?serviceName=StoreMate-Prod
### Update the template
PUT {{host}}/api/v1/email-templates/StoreMate-Prod/ShareInvite/uk
Content-Type: application/json
{
"Subject": "Вас запрошено",
"HtmlBody": "<html><body><p>Вітаємо, \u007b\u007bRecipientName\u007d\u007d.</p><p>\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d.</p><p><a href=\"\u007b\u007bInviteLink\u007d\u007d\">Відкрити запрошення</a></p><p>Дійсне до <strong>\u007b\u007bValidUntil\u007d\u007d</strong>.</p></body></html>",
"TextBody": "Вітаємо, \u007b\u007bRecipientName\u007d\u007d.\n\n\u007b\u007bInviterName\u007d\u007d запросив вас приєднатися до \u007b\u007bAppName\u007d\u007d.\n\nВідкрийте запрошення: \u007b\u007bInviteLink\u007d\u007d\nДійсне до: \u007b\u007bValidUntil\u007d\u007d",
"Variables": [
{ "Name": "RecipientName", "Required": false },
{ "Name": "InviterName", "Required": false },
{ "Name": "AppName", "Required": false },
{ "Name": "InviteLink", "Required": false },
{ "Name": "ValidUntil", "Required": false }
]
}
### Delete the template
DELETE {{host}}/api/v1/email-templates/StoreMate-Prod/ShareInvite/uk
+3 -6
View File
@@ -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.MapOpenApi();
app.MapScalarApiReference(options => app.MapScalarApiReference(options =>
{ {
options.Title = "HrynCo Notification Service"; options.Title = "HrynCo Notification Service";
options.Theme = ScalarTheme.DeepSpace; 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)
{ {
@@ -21,6 +23,21 @@
</div> </div>
} }
<ul class="nav nav-tabs template-editor-tabs mb-3" id="templateEditorTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="edit-tab" data-bs-toggle="tab" data-bs-target="#edit-pane" type="button" role="tab" aria-controls="edit-pane" aria-selected="true">
Edit
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="preview-tab" data-bs-toggle="tab" data-bs-target="#preview-pane" type="button" role="tab" aria-controls="preview-pane" aria-selected="false">
Preview
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="edit-pane" role="tabpanel" aria-labelledby="edit-tab" tabindex="0">
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-5"> <div class="col-md-5">
<label asp-for="ServiceName" class="form-label fw-semibold">Service Name</label> <label asp-for="ServiceName" class="form-label fw-semibold">Service Name</label>
@@ -63,13 +80,221 @@
<span asp-validation-for="VariablesJson" class="text-danger small"></span> <span asp-validation-for="VariablesJson" class="text-danger small"></span>
<div class="form-text">JSON array of <code>{"name":"...", "required":true|false}</code></div> <div class="form-text">JSON array of <code>{"name":"...", "required":true|false}</code></div>
</div> </div>
</div>
<div class="tab-pane fade" id="preview-pane" role="tabpanel" aria-labelledby="preview-tab" tabindex="0">
<div class="template-preview-panel mb-3">
<div class="template-preview-panel-header">
<div>
<div class="template-preview-title">Preview</div>
<div class="template-preview-subtitle">Rendered with sample values from the variable list.</div>
</div>
<span id="previewStatus" class="badge text-bg-secondary">Ready</span>
</div>
<div class="template-preview-body">
<div class="template-preview-source template-preview-section-block">
<div class="template-preview-section-title">Sample values</div>
<div id="previewVariables" class="template-preview-variables"></div>
<div class="form-text mt-2">Change these values to see the rendered output update immediately.</div>
</div>
<div class="template-preview-output">
<div class="template-preview-section-title">Rendered subject</div>
<div id="previewSubject" class="template-preview-subject"></div>
<div class="template-preview-section-title mt-3">Rendered HTML</div>
<iframe id="previewHtmlFrame" class="template-preview-frame" title="Email HTML preview"></iframe>
<div class="template-preview-section-title mt-3">Rendered text</div>
<pre id="previewText" class="template-preview-text mb-0"></pre>
</div>
</div>
</div>
</div>
</div>
@section FormActions { @section FormActions {
<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>
} }
@section Scripts {
<script>
(function () {
const subjectField = document.getElementById('Subject');
const htmlField = document.getElementById('HtmlBody');
const textField = document.getElementById('TextBody');
const variablesField = document.getElementById('VariablesJson');
const previewVariablesHost = document.getElementById('previewVariables');
const previewSubject = document.getElementById('previewSubject');
const previewText = document.getElementById('previewText');
const previewFrame = document.getElementById('previewHtmlFrame');
const previewStatus = document.getElementById('previewStatus');
const previewTab = document.getElementById('preview-tab');
if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) {
return;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function parseVariables() {
const raw = variablesField.value?.trim() || '[]';
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
throw new Error('Variables JSON must be an array.');
}
return parsed
.filter(item => item && typeof item.Name === 'string' && item.Name.trim().length > 0)
.map(item => ({
name: item.Name.trim(),
required: !!item.Required
}));
}
function buildSampleValue(name) {
return `Sample ${name}`;
}
function renderVariableInputs() {
let variables = [];
try {
variables = parseVariables();
previewStatus.className = 'badge text-bg-secondary';
previewStatus.textContent = 'Ready';
} catch (error) {
previewVariablesHost.innerHTML = `<div class="alert alert-warning mb-0">${escapeHtml(error.message || 'Invalid variables JSON')}</div>`;
previewStatus.className = 'badge text-bg-danger';
previewStatus.textContent = 'Invalid JSON';
updatePreview();
return;
}
if (variables.length === 0) {
previewVariablesHost.innerHTML = '<div class="text-muted small">No variables defined.</div>';
updatePreview();
return;
}
const currentValues = readVariableValues();
previewVariablesHost.innerHTML = variables.map(variable => {
const value = Object.prototype.hasOwnProperty.call(currentValues, variable.name)
? currentValues[variable.name]
: buildSampleValue(variable.name);
return `
<div class="mb-2">
<label class="form-label small fw-semibold mb-1">${escapeHtml(variable.name)}${variable.required ? ' *' : ''}</label>
<input type="text" class="form-control form-control-sm preview-variable-input" data-variable-name="${escapeHtml(variable.name)}" value="${escapeHtml(value)}" />
</div>
`;
}).join('');
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
input.addEventListener('input', updatePreview);
});
updatePreview();
}
function readVariableValues() {
const values = {};
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
values[input.dataset.variableName] = input.value;
});
return values;
}
function interpolate(text, values) {
let result = text || '';
Object.keys(values).forEach(key => {
const token = new RegExp(`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, 'g');
result = result.replace(token, values[key] ?? '');
});
return result;
}
function updatePreview() {
let values = {};
try {
values = readVariableValues();
} catch (error) {
values = {};
}
const renderedSubject = interpolate(subjectField.value, values);
const renderedHtml = interpolate(htmlField.value, values);
const renderedText = interpolate(textField.value, values);
previewSubject.textContent = renderedSubject || '(empty subject)';
previewText.textContent = renderedText || '(empty text body)';
previewFrame.srcdoc = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
margin: 0;
padding: 24px;
background: #eef2f7;
color: #1f2937;
font-family: Arial, Helvetica, sans-serif;
}
.email-shell {
max-width: 640px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #dbe3ee;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 12px 30px rgba(15, 23, 42, .08);
}
.email-body {
padding: 28px 36px;
}
img { max-width: 100%; height: auto; }
a { color: #2563eb; }
</style>
</head>
<body>
<div class="email-shell">
<div class="email-body">
${renderedHtml || '<div style="color:#6b7280">No HTML body provided.</div>'}
</div>
</div>
</body>
</html>`;
}
variablesField.addEventListener('input', renderVariableInputs);
subjectField.addEventListener('input', updatePreview);
htmlField.addEventListener('input', updatePreview);
textField.addEventListener('input', updatePreview);
if (previewTab) {
previewTab.addEventListener('shown.bs.tab', updatePreview);
}
renderVariableInputs();
})();
</script>
}
</form> </form>
@@ -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"
@@ -14,3 +14,4 @@
</div> </div>
</div> </div>
</div> </div>
@RenderSection("Scripts", required: false)
@@ -118,9 +118,10 @@ body {
.empty-state .bi { font-size: 2.5rem; opacity: .35; display: block; margin-bottom: .75rem; } .empty-state .bi { font-size: 2.5rem; opacity: .35; display: block; margin-bottom: .75rem; }
.empty-state p { font-size: .9rem; margin-bottom: 0; } .empty-state p { font-size: .9rem; margin-bottom: 0; }
/* ── Editor wrapper — constrains width ───────────────── */ /* ── Editor wrapper ──────────────────────────────────── */
.editor-wrapper { .editor-wrapper {
max-width: 860px; width: 100%;
max-width: none;
} }
/* ── Editor card ──────────────────────────────────────── */ /* ── Editor card ──────────────────────────────────────── */
@@ -154,6 +155,149 @@ body {
border-radius: 0 0 .5rem .5rem !important; border-radius: 0 0 .5rem .5rem !important;
} }
/* ── Editor tabs ─────────────────────────────────────── */
.template-editor-tabs {
border-bottom-color: #dce3eb;
}
.template-editor-tabs .nav-link {
color: #526072;
font-weight: 600;
border-radius: .5rem .5rem 0 0;
}
.template-editor-tabs .nav-link.active {
color: #0d6efd;
}
/* ── Tab content ──────────────────────────────────────── */
.tab-content {
min-width: 0;
}
/* ── Email template preview ───────────────────────────── */
.template-preview-panel {
border: 1px solid #dce3eb;
border-radius: .75rem;
background: #fff;
overflow: hidden;
box-shadow: 0 10px 24px rgba(15, 23, 42, .06);
}
.template-preview-panel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem;
background: linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%);
border-bottom: 1px solid #e1e7ef;
}
.template-preview-title {
font-size: .9rem;
font-weight: 700;
color: #1f2937;
}
.template-preview-subtitle {
font-size: .82rem;
color: #6b7280;
margin-top: .15rem;
}
.template-preview-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
padding: 1rem 1.25rem 1.25rem;
}
.template-preview-source,
.template-preview-output {
min-width: 0;
}
.template-preview-section-block {
padding: 1rem;
border: 1px solid #e5ebf2;
border-radius: .65rem;
background: #fafcff;
}
.template-preview-section-title {
font-size: .7rem;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: .5rem;
}
.template-preview-variables {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: .75rem 1rem;
max-width: none;
}
.template-preview-variables > div {
margin-bottom: 0 !important;
}
.template-preview-variables .form-label {
font-size: .72rem;
margin-bottom: .2rem;
}
.template-preview-variables .form-control-sm {
background: #fff;
min-height: calc(1.5em + .45rem + 2px);
padding: .2rem .45rem;
font-size: .82rem;
}
.template-preview-subject {
padding: .75rem 1rem;
border: 1px dashed #cfd8e3;
border-radius: .5rem;
background: #f8fafc;
min-height: 3rem;
font-weight: 600;
color: #111827;
white-space: pre-wrap;
word-break: break-word;
}
.template-preview-frame {
width: 100%;
height: 420px;
border: 1px solid #cfd8e3;
border-radius: .5rem;
background: #eef2f7;
}
.template-preview-text {
padding: .75rem 1rem;
border: 1px solid #cfd8e3;
border-radius: .5rem;
background: #0f172a;
color: #e2e8f0;
min-height: 120px;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 992px) {
.template-preview-body {
grid-template-columns: 1fr;
}
.template-preview-frame {
height: 360px;
}
}
/* ── Form-section divider ─────────────────────────────── */ /* ── Form-section divider ─────────────────────────────── */
.form-section-title { .form-section-title {
font-size: .68rem; font-size: .68rem;
@@ -0,0 +1,50 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net;
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),
InterpolateHtml(template.HtmlBody, data.Variables),
Interpolate(template.TextBody, data.Variables));
}
private static string InterpolateHtml(string text, IReadOnlyDictionary<string, string> variables)
{
return Interpolate(text, variables, WebUtility.HtmlEncode);
}
private static string Interpolate(string text, IReadOnlyDictionary<string, string> variables)
{
return Interpolate(text, variables, static value => value);
}
private static string Interpolate(
string text,
IReadOnlyDictionary<string, string> variables,
Func<string, string> encodeValue)
{
var sb = new StringBuilder(text);
foreach (var (key, value) in variables)
sb.Replace($"{{{{{key}}}}}", encodeValue(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}'.");
}
}
@@ -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);
}
@@ -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);
}
@@ -0,0 +1,80 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net.Mail;
using System.Net.Sockets;
public static class NotificationDeliveryErrorFormatter
{
private const int MaximumErrorLength = 2000;
private const string FallbackError = "Notification delivery failed after all retry attempts.";
public static string Format(Exception exception)
{
string technicalMessage = Normalize(exception.GetBaseException().Message);
if (string.IsNullOrWhiteSpace(technicalMessage))
{
return FallbackError;
}
string contextualMessage = CreateContextualMessage(exception, technicalMessage);
return contextualMessage.Length <= MaximumErrorLength
? contextualMessage
: contextualMessage[..MaximumErrorLength];
}
private static string CreateContextualMessage(Exception exception, string technicalMessage)
{
if (FindException<SmtpFailedRecipientException>(exception) is not null)
{
return "SMTP delivery failed after all retry attempts: " +
"the SMTP server rejected the recipient address.";
}
SocketException? socketException = FindException<SocketException>(exception);
if (socketException is not null)
{
string reason = socketException.SocketErrorCode switch
{
SocketError.HostNotFound or SocketError.NoData =>
"the configured SMTP server host could not be resolved",
SocketError.ConnectionRefused =>
"the configured SMTP server refused the connection",
SocketError.TimedOut =>
"the connection to the configured SMTP server timed out",
_ when technicalMessage.Contains(
"Name or service not known",
StringComparison.OrdinalIgnoreCase) =>
"the configured SMTP server host could not be resolved",
_ => "the configured SMTP server could not be reached"
};
return $"SMTP delivery failed after all retry attempts: {reason} ({technicalMessage}).";
}
if (FindException<SmtpException>(exception) is not null)
{
return $"SMTP delivery failed after all retry attempts ({technicalMessage}).";
}
return $"Notification delivery failed after all retry attempts ({technicalMessage}).";
}
private static TException? FindException<TException>(Exception exception)
where TException : Exception
{
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is TException typedException)
{
return typedException;
}
}
return null;
}
private static string Normalize(string message)
{
return message.ReplaceLineEndings(" ").Trim();
}
}
@@ -0,0 +1,63 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging;
internal sealed class NotificationResultPublisher : INotificationResultPublisher
{
private readonly ILogger<NotificationResultPublisher> _logger;
private readonly IRabbitMqPublisher _publisher;
public NotificationResultPublisher(
IRabbitMqPublisher publisher,
ILogger<NotificationResultPublisher> logger)
{
_publisher = publisher;
_logger = logger;
}
public async Task PublishAsync(
SendEmailMessage message,
string? deliveryError,
CancellationToken cancellationToken)
{
CorrelationContext correlationContext = message.CorrelationContext;
string? replyTo = correlationContext.ReplyTo;
if (string.IsNullOrWhiteSpace(replyTo))
{
return;
}
try
{
var result = new NotificationResultMessage
{
CorrelationContext = correlationContext with { ReplyTo = null },
Data = new NotificationResultData
{
ServiceName = message.Data.ServiceName,
RecipientEmail = message.Data.RecipientEmail,
TemplateKey = message.Data.TemplateKey,
Timestamp = DateTimeOffset.UtcNow,
ErrorMessage = deliveryError
}
};
await _publisher.PublishAsync(replyTo, result, cancellationToken);
_logger.LogDebug(
"Notification result published to reply queue {Queue} [CorrelationId={CorrelationId}]",
replyTo,
correlationContext.CorrelationId);
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Failed to publish notification result to reply queue {Queue} [CorrelationId={CorrelationId}]",
replyTo,
correlationContext.CorrelationId);
}
}
}
@@ -0,0 +1,22 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
public static class RecipientAddressRedactor
{
public static string Redact(string? address)
{
if (string.IsNullOrWhiteSpace(address))
return "<missing>";
int separator = address.LastIndexOf('@');
if (separator <= 0 || separator == address.Length - 1)
return "***";
string local = address[..separator];
string domain = address[(separator + 1)..];
int dot = domain.LastIndexOf('.');
string domainName = dot > 0 ? domain[..dot] : domain;
string suffix = dot > 0 ? domain[dot..] : string.Empty;
return $"{local[0]}***@{domainName[0]}***{suffix}";
}
}
@@ -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);
@@ -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);
}
}
@@ -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;
}
}
+1 -1
View File
@@ -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"
@@ -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>
+2 -3
View File
@@ -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"));
@@ -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) _logger.LogWarning(
.FirstOrDefault() "Broker and payload correlation IDs differ; payload correlation ID will be used");
?? throw new InvalidOperationException(
$"No active email channel found for service '{serviceName}'.");
} }
private async Task<EmailTemplate> ResolveTemplateAsync( await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
string serviceName, string templateKey, string? languageCode, CancellationToken ct) ISendEmailService service = scope.ServiceProvider.GetRequiredService<ISendEmailService>();
{ await service.ProcessAsync(message, cancellationToken);
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) protected override async Task HandleMessageRetriesExhaustedAsync(
SendEmailMessage message,
RabbitMqMessageContext context,
Exception exception,
CancellationToken cancellationToken)
{ {
var today = DateOnly.FromDateTime(DateTime.UtcNow); string deliveryError = NotificationDeliveryErrorFormatter.Format(exception);
await using AsyncServiceScope scope = _scopeFactory.CreateAsyncScope();
if (channel.DailyLimit.HasValue) INotificationResultPublisher resultPublisher =
{ scope.ServiceProvider.GetRequiredService<INotificationResultPublisher>();
var daily = await usageRepository.GetDailyCountAsync(channel.Id, today, ct); await resultPublisher.PublishAsync(message, deliveryError, cancellationToken);
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)
{
var 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).");
}
}
private static RenderedEmail RenderTemplate(EmailTemplate template, SendEmailMessageData data)
{
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();
}
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"
} }
} }
} }
+1
View File
@@ -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>
-3
View File
@@ -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>
+117
View File
@@ -1 +1,118 @@
# 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]
```
Template variables are treated as plain text. The worker HTML-encodes every variable while
rendering `HtmlBody`; subject and plain-text body interpolation preserve the original value.
Templates must express markup in `body.html` instead of supplying HTML through variables.
+10
View File
@@ -0,0 +1,10 @@
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
@@ -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,6 +54,13 @@ 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
+1
View File
@@ -40,6 +40,7 @@ services:
- App__RabbitMq__Port=5672 - App__RabbitMq__Port=5672
- App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required} - App__RabbitMq__User=${RABBITMQ_USER:?RABBITMQ_USER is required}
- App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required} - App__RabbitMq__Password=${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD is required}
- App__RabbitMq__VirtualHost=${RABBITMQ_VIRTUAL_HOST:-/}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -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
+121
View File
@@ -0,0 +1,121 @@
# ItemTracker Outbox Email Consumer
## Contract and routing
The Worker consumes durable messages from queue `notification.send-email`. ItemTracker publishes through RabbitMQ's default exchange, so the queue name is also the routing key. No custom exchange binding is required.
`HrynCo.RabbitMq` owns the transport lifecycle: connection, queue declaration, deserialization, prefetch, structured delivery context, retry, terminal-failure notification, and manual ACK/NACK. Notification Service only supplies its queue, contract validation, email handler, and result publisher through the package's generic extension points. This integration requires package version `1.0.17` or newer.
Accepted messages must have all of the following:
- AMQP `Type`: `Notification.SendEmail.v1`
- AMQP `MessageId`: the non-empty stable ItemTracker Outbox ID
- JSON content using the PascalCase `SendEmailMessage` envelope
- non-empty `CorrelationContext.CorrelationId`
- non-empty `ServiceName`, `TemplateKey`, `RecipientEmail`, `RecipientName`, and `LanguageCode`
- a valid recipient address and a non-null `Variables` object
The service resolves a template by the exact `ServiceName`, `TemplateKey`, and normalized lower-case `LanguageCode`. It does not silently fall back to another language. Every variable marked `Required` by the selected template must have a non-empty value before rendering.
## Delivery and acknowledgement policy
The consumer uses manual acknowledgements and prefetches one message at a time.
1. Unsupported contract metadata, malformed JSON, or invalid required fields are logged and nacked without requeue.
2. Template, provider, quota, SMTP, or usage-accounting failures are retried in-process three times with a five-second delay.
3. The message is acknowledged only after SMTP accepts the email and provider usage is incremented.
4. If `CorrelationContext.ReplyTo` is present, result publication is attempted after delivery. It is best-effort: a reply-queue failure is logged but does not make the SMTP delivery fail, because retrying after SMTP success could send a duplicate email.
5. After the final processing retry fails, the shared terminal-failure hook publishes one
negative result to the same `ReplyTo`. It retains the original correlation and contains
a normalized diagnostic string limited to 2000 characters. Common SMTP DNS,
connection-refused, timeout, and recipient-rejection failures receive safe explanatory
context while retaining the low-level provider reason. Hostnames, recipient addresses,
and credentials are not added. Client outboxes store this as a neutral delivery error
rather than interpreting SMTP-specific exception types.
Success and terminal failure result publication remain best-effort. If the result queue
cannot be reached, the internal exception is logged and the original delivery follows its
normal ACK/NACK policy. Clients therefore must not interpret the absence of a result as a
confirmed failure.
Delivery remains at least once. There is no inbox/deduplication store in this MVP, so a broker redelivery or a process failure after SMTP acceptance but before acknowledgement can produce a duplicate. `MessageId`, `CorrelationId`, and `MessageType` are added to the structured log scope for correlation. Recipient addresses are masked, and template variable names/values and rendered links are not logged.
No database migration is required. Existing channel selection, SMTP settings, quota checks, and usage counters remain in place.
## Local end-to-end setup
With Docker Desktop running, install and start the complete Notification Service
development stack from the repository root:
```powershell
.\docker\environments\install-development.cmd
```
The script uses the tracked `docker/environments/.env.Development` defaults. The
following manual setup is only needed when overriding those values locally.
Create a local environment file outside source control, for example `docker/environments/.env.local`, with these values:
```dotenv
DB_NAME=notification_service
DB_USER=postgres
DB_PASS=postgres
VOLUME_PREFIX=ns-dev
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
RABBITMQ_VIRTUAL_HOST=/
RABBITMQ_AMQP_PORT=5672
RABBITMQ_MANAGEMENT_PORT=15672
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
```
Start the Notification Service stack from the repository root:
```powershell
docker compose --env-file docker/environments/.env.local `
-f docker/environments/docker-compose.yml `
-f docker/environments/docker-compose.Development.yml `
up --build -d
```
Development compose exposes:
- Notification Service admin: `http://localhost:5200/admin/channels` and `/admin/templates`
- RabbitMQ management: `http://localhost:15672`
- Mailpit inbox: `http://localhost:8025`
- Seq: `http://localhost:5342`
The development migrator idempotently creates an active SMTP channel for
`DEVELOPMENT_EMAIL_SERVICE_NAME` using host `mailpit`, port `1025`, SSL disabled, and
blank credentials. It also creates a neutral English `TestEmail` template with
`RecipientName` and `Message` variables. Existing channels and templates are left
unchanged. Use the admin UI to inspect or customize them and to add client-specific
service names, template keys, or languages.
Configure ItemTracker to use the same RabbitMQ host, port, credentials, virtual host, queue `notification.send-email`, and service name. When ItemTracker runs outside Docker against the development stack, the broker is `localhost:5672`; from a Docker container it is the reachable host or shared-network name. Start the ItemTracker Outbox Worker in `RabbitMq` mode, trigger a production-safe notification to an owned test address, then verify:
1. the ItemTracker Outbox row becomes published;
2. RabbitMQ delivers and removes the message from `notification.send-email`;
3. Notification Service logs show the same `MessageId` and `CorrelationId` without the full address;
4. Mailpit shows exactly one rendered email;
5. the Notification Service channel usage counter increments;
6. the client result queue is consumed and the matching outbox row records successful delivery.
## Production configuration and smoke test
Production requires the same `App__RabbitMq__Host`, `Port`, `User`, `Password`, and `VirtualHost` values as ItemTracker's publisher, plus the Notification Service database connection. The production compose file obtains these from deployment environment variables; secrets must stay in the deployment secret store. The queue is declared durable by both producer and consumer.
This Notification Service revision restores the published immutable
`HrynCo.RabbitMq` version `1.0.17` from NuGet.org. Build and deployment environments
therefore require NuGet.org access or a trusted package mirror containing that exact
version.
Before enabling the Worker, verify that the target service has an active SMTP channel and exact-language templates for every queued ItemTracker template key. Inspect any delayed ItemTracker backlog for expired password-reset, verification, or invitation messages before draining it.
The template administration list preserves optional Service Name and Key filters when an
administrator opens the create or edit screen. These links use explicit Razor expression
boundaries so the filter query is appended as query parameters rather than rendered as a
literal `@filterQuery` path segment. With no active filters, the create route is exactly
`/admin/templates/create`.
For the smoke test, use an owned test account and a non-sensitive notification template. Record the Outbox `Id` and `CorrelationId`, trigger only one message, follow those identifiers through Outbox publication and Notification Service logs, and confirm receipt with the configured SMTP provider. Do not copy payloads, tokens, credentials, full recipient addresses, or rendered URLs into tickets or logs.