Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85b1f1fdfc | |||
| 50033a5bd4 | |||
| 01c622038d | |||
| 1ceb71b9a2 | |||
| bb0de0f2c1 | |||
| d5a2e538f0 | |||
| 9d6b0717e6 | |||
| 2757869176 | |||
| b8435ac07b | |||
| cc3857a409 | |||
| 94f0d45aaf | |||
| 07f536938f | |||
| e7d3953747 | |||
| 3381fcc2f8 | |||
| 3bd952ef9e | |||
| 285cc6abb7 | |||
| 9589095760 | |||
| 25fb48ccf0 | |||
| ab042c7617 | |||
| 0861e18cec | |||
| dfa097eb43 | |||
| c18f0b7fb1 | |||
| 50828d23ec | |||
| f3966705ad | |||
| b4d8497ea7 |
@@ -480,3 +480,6 @@ $RECYCLE.BIN/
|
|||||||
|
|
||||||
# Vim temporary swap files
|
# Vim temporary swap files
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Local Docker Compose secrets and ports
|
||||||
|
docker/environments/.env.local
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# HrynCo Notification Service Agent Rules
|
||||||
|
|
||||||
|
## Git workflow
|
||||||
|
|
||||||
|
- Treat `main` as the only default and integration branch for this repository.
|
||||||
|
- Start every task branch from the latest `origin/main`.
|
||||||
|
- Before creating a task branch, fetch the remote, switch to `main`, and fast-forward it from `origin/main`.
|
||||||
|
- Create the task branch only after confirming that local `main` matches `origin/main`.
|
||||||
|
- Open pull requests from the task branch into `main`.
|
||||||
|
- Do not use `development` as the base or pull-request target for new work in this repository.
|
||||||
|
- Do not commit, push, publish, deploy, or merge unless the repository owner explicitly requests that step.
|
||||||
|
|
||||||
|
Recommended branch preparation:
|
||||||
|
|
||||||
|
```text
|
||||||
|
git fetch origin
|
||||||
|
git switch main
|
||||||
|
git pull --ff-only origin main
|
||||||
|
git switch -c <task-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Delivery
|
||||||
|
|
||||||
|
- Keep changes focused and preserve backward compatibility where practical.
|
||||||
|
- Update the matching repository documentation when behavior, contracts, configuration, routing, or workflows change.
|
||||||
|
- Run the solution build and relevant tests before handoff.
|
||||||
|
- Use Conventional Commit messages with a lowercase subject and finish the commit body with `Ref: <issue-id>` when an issue exists.
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Entity Framework Core -->
|
<!-- Entity Framework Core -->
|
||||||
|
<PackageVersion Include="HrynCo.DAL.Abstract" Version="1.0.11" />
|
||||||
|
<PackageVersion Include="HrynCo.DAL.EF" Version="1.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||||
@@ -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.10" />
|
|
||||||
<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" />
|
||||||
|
|||||||
+4
-4
@@ -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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public sealed class EmailTemplateRenderingServiceTests
|
||||||
|
{
|
||||||
|
private readonly EmailTemplateRenderingService _service = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_InterpolatesItemTrackerVariables()
|
||||||
|
{
|
||||||
|
EmailTemplate template = CreateTemplate();
|
||||||
|
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["AppName"] = "StoreMate",
|
||||||
|
["VerificationUrl"] = "https://example.invalid/verify"
|
||||||
|
});
|
||||||
|
|
||||||
|
RenderedEmail result = _service.Render(template, data);
|
||||||
|
|
||||||
|
Assert.Equal("Verify StoreMate", result.Subject);
|
||||||
|
Assert.Equal("<a href=\"https://example.invalid/verify\">Verify</a>", result.HtmlBody);
|
||||||
|
Assert.Equal("Verify at https://example.invalid/verify", result.TextBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_WhenRequiredVariableIsMissing_ThrowsObservableFailure()
|
||||||
|
{
|
||||||
|
EmailTemplate template = CreateTemplate();
|
||||||
|
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["AppName"] = "StoreMate"
|
||||||
|
});
|
||||||
|
|
||||||
|
InvalidDataException exception = Assert.Throws<InvalidDataException>(
|
||||||
|
() => _service.Render(template, data));
|
||||||
|
|
||||||
|
Assert.Equal("Required template variables are missing: VerificationUrl.", exception.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_HtmlEncodesVariablesWithoutChangingSubjectOrTextBody()
|
||||||
|
{
|
||||||
|
EmailTemplate template = CreateTemplate();
|
||||||
|
SendEmailMessageData data = CreateData(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["AppName"] = "Invemory <script>alert('xss')</script>",
|
||||||
|
["VerificationUrl"] = "https://example.invalid/verify?next=\" onclick=\"alert('xss')"
|
||||||
|
});
|
||||||
|
|
||||||
|
RenderedEmail result = _service.Render(template, data);
|
||||||
|
|
||||||
|
Assert.Equal("Verify Invemory <script>alert('xss')</script>", result.Subject);
|
||||||
|
Assert.Equal(
|
||||||
|
"<a href=\"https://example.invalid/verify?next=" onclick="alert('xss')\">Verify</a>",
|
||||||
|
result.HtmlBody);
|
||||||
|
Assert.Equal(
|
||||||
|
"Verify at https://example.invalid/verify?next=\" onclick=\"alert('xss')",
|
||||||
|
result.TextBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EmailTemplate CreateTemplate() => new()
|
||||||
|
{
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
Key = "EmailVerification",
|
||||||
|
LanguageCode = "uk",
|
||||||
|
Subject = "Verify {{AppName}}",
|
||||||
|
HtmlBody = "<a href=\"{{VerificationUrl}}\">Verify</a>",
|
||||||
|
TextBody = "Verify at {{VerificationUrl}}",
|
||||||
|
Variables =
|
||||||
|
[
|
||||||
|
new EmailTemplateVariable { Name = "AppName", Required = true },
|
||||||
|
new EmailTemplateVariable { Name = "VerificationUrl", Required = true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SendEmailMessageData CreateData(IReadOnlyDictionary<string, string> variables) => new()
|
||||||
|
{
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
TemplateKey = "EmailVerification",
|
||||||
|
RecipientEmail = "owner@example.com",
|
||||||
|
RecipientName = "Owner",
|
||||||
|
LanguageCode = "uk",
|
||||||
|
Variables = variables
|
||||||
|
};
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
public sealed class EmailTemplateServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_WhenRequestedLanguageDoesNotExist_DoesNotSilentlyFallback()
|
||||||
|
{
|
||||||
|
IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
|
||||||
|
var service = new EmailTemplateService(repository);
|
||||||
|
|
||||||
|
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => service.GetAsync("StoreMate-Prod", "EmailVerification", "UK", CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Contains("language='uk'", exception.Message);
|
||||||
|
await repository.Received(1).GetAsync(
|
||||||
|
"StoreMate-Prod",
|
||||||
|
"EmailVerification",
|
||||||
|
"uk",
|
||||||
|
CancellationToken.None);
|
||||||
|
await repository.DidNotReceive().GetAsync(
|
||||||
|
"StoreMate-Prod",
|
||||||
|
"EmailVerification",
|
||||||
|
"en",
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
public sealed class NotificationDeliveryErrorFormatterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Format_UsesInnermostMessageAndRemovesLineBreaks()
|
||||||
|
{
|
||||||
|
var exception = new InvalidOperationException(
|
||||||
|
"outer",
|
||||||
|
new Exception("provider failed\r\nretry rejected"));
|
||||||
|
|
||||||
|
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
"Notification delivery failed after all retry attempts (provider failed retry rejected).",
|
||||||
|
result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_UnresolvedSmtpHost_AddsSafeChannelContext()
|
||||||
|
{
|
||||||
|
var socketException = new SocketException((int)SocketError.HostNotFound);
|
||||||
|
var exception = new SmtpException("Failure sending mail.", socketException);
|
||||||
|
|
||||||
|
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
$"SMTP delivery failed after all retry attempts: the configured SMTP server host could not be resolved ({socketException.Message}).",
|
||||||
|
result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_RefusedSmtpConnection_AddsSafeChannelContext()
|
||||||
|
{
|
||||||
|
var socketException = new SocketException((int)SocketError.ConnectionRefused);
|
||||||
|
var exception = new SmtpException("Failure sending mail.", socketException);
|
||||||
|
|
||||||
|
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
$"SMTP delivery failed after all retry attempts: the configured SMTP server refused the connection ({socketException.Message}).",
|
||||||
|
result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_LongError_IsBoundedToOutboxColumnLength()
|
||||||
|
{
|
||||||
|
var exception = new Exception(new string('x', 2100));
|
||||||
|
|
||||||
|
string result = NotificationDeliveryErrorFormatter.Format(exception);
|
||||||
|
|
||||||
|
Assert.Equal(2000, result.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
public sealed class NotificationResultPublisherTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishAsync_TerminalFailure_PublishesNeutralFailureResultToReplyQueue()
|
||||||
|
{
|
||||||
|
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
|
||||||
|
var publisher = new NotificationResultPublisher(
|
||||||
|
rabbitMqPublisher,
|
||||||
|
NullLogger<NotificationResultPublisher>.Instance);
|
||||||
|
SendEmailMessage message = CreateMessage();
|
||||||
|
NotificationResultMessage? publishedResult = null;
|
||||||
|
rabbitMqPublisher
|
||||||
|
.When(x => x.PublishAsync(
|
||||||
|
"item-tracker.notifications.result",
|
||||||
|
Arg.Any<NotificationResultMessage>(),
|
||||||
|
Arg.Any<CancellationToken>()))
|
||||||
|
.Do(call => publishedResult = call.ArgAt<NotificationResultMessage>(1));
|
||||||
|
|
||||||
|
await publisher.PublishAsync(
|
||||||
|
message,
|
||||||
|
"Email provider could not deliver the notification.",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
await rabbitMqPublisher.Received(1).PublishAsync(
|
||||||
|
"item-tracker.notifications.result",
|
||||||
|
Arg.Any<NotificationResultMessage>(),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.NotNull(publishedResult);
|
||||||
|
Assert.Equal(message.CorrelationContext.CorrelationId, publishedResult.CorrelationContext.CorrelationId);
|
||||||
|
Assert.Null(publishedResult.CorrelationContext.ReplyTo);
|
||||||
|
Assert.Equal(message.Data.ServiceName, publishedResult.Data.ServiceName);
|
||||||
|
Assert.Equal(message.Data.TemplateKey, publishedResult.Data.TemplateKey);
|
||||||
|
Assert.Equal(message.Data.RecipientEmail, publishedResult.Data.RecipientEmail);
|
||||||
|
Assert.Equal("Email provider could not deliver the notification.", publishedResult.Data.ErrorMessage);
|
||||||
|
Assert.False(publishedResult.Data.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishAsync_NoReplyQueue_DoesNotPublish()
|
||||||
|
{
|
||||||
|
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
|
||||||
|
var publisher = new NotificationResultPublisher(
|
||||||
|
rabbitMqPublisher,
|
||||||
|
NullLogger<NotificationResultPublisher>.Instance);
|
||||||
|
SendEmailMessage message = CreateMessage();
|
||||||
|
message.CorrelationContext = message.CorrelationContext with { ReplyTo = null };
|
||||||
|
|
||||||
|
await publisher.PublishAsync(message, "delivery failed", CancellationToken.None);
|
||||||
|
|
||||||
|
await rabbitMqPublisher.DidNotReceive()
|
||||||
|
.PublishAsync<NotificationResultData>(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<NotificationResultMessage>(),
|
||||||
|
Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PublishAsync_ResultBrokerFailure_IsBestEffort()
|
||||||
|
{
|
||||||
|
IRabbitMqPublisher rabbitMqPublisher = Substitute.For<IRabbitMqPublisher>();
|
||||||
|
rabbitMqPublisher
|
||||||
|
.PublishAsync(
|
||||||
|
Arg.Any<string>(),
|
||||||
|
Arg.Any<NotificationResultMessage>(),
|
||||||
|
Arg.Any<CancellationToken>())
|
||||||
|
.Returns<Task>(_ => throw new InvalidOperationException("reply broker unavailable"));
|
||||||
|
var publisher = new NotificationResultPublisher(
|
||||||
|
rabbitMqPublisher,
|
||||||
|
NullLogger<NotificationResultPublisher>.Instance);
|
||||||
|
|
||||||
|
Exception? exception = await Record.ExceptionAsync(() => publisher.PublishAsync(
|
||||||
|
CreateMessage(),
|
||||||
|
"delivery failed",
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SendEmailMessage CreateMessage()
|
||||||
|
{
|
||||||
|
return new SendEmailMessage
|
||||||
|
{
|
||||||
|
CorrelationContext = new CorrelationContext
|
||||||
|
{
|
||||||
|
CorrelationId = "correlation-id",
|
||||||
|
ReplyTo = "item-tracker.notifications.result"
|
||||||
|
},
|
||||||
|
Data = new SendEmailMessageData
|
||||||
|
{
|
||||||
|
ServiceName = "TestService",
|
||||||
|
TemplateKey = "TestEmail",
|
||||||
|
RecipientEmail = "test.user@itemtracker.local",
|
||||||
|
RecipientName = "Test User",
|
||||||
|
LanguageCode = "en",
|
||||||
|
Variables = new Dictionary<string, string>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public sealed class RecipientAddressRedactorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("owner@example.com", "o***@e***.com")]
|
||||||
|
[InlineData("a@b.test", "a***@b***.test")]
|
||||||
|
[InlineData("invalid", "***")]
|
||||||
|
[InlineData(null, "<missing>")]
|
||||||
|
public void Redact_DoesNotExposeFullAddress(string? address, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, RecipientAddressRedactor.Redact(address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public sealed class SendEmailContractTests
|
||||||
|
{
|
||||||
|
private const string ItemTrackerPayload = """
|
||||||
|
{
|
||||||
|
"CorrelationContext": {
|
||||||
|
"CorrelationId": "3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc",
|
||||||
|
"ReplyTo": "item-tracker.notifications.result"
|
||||||
|
},
|
||||||
|
"Data": {
|
||||||
|
"ServiceName": "StoreMate-Prod",
|
||||||
|
"TemplateKey": "EmailVerification",
|
||||||
|
"RecipientEmail": "owner@example.com",
|
||||||
|
"RecipientName": "StoreMate Owner",
|
||||||
|
"Variables": {
|
||||||
|
"AppName": "StoreMate",
|
||||||
|
"VerificationUrl": "https://example.invalid/sensitive-token"
|
||||||
|
},
|
||||||
|
"LanguageCode": "uk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PascalCaseItemTrackerPayload_DeserializesWithoutLosingValues()
|
||||||
|
{
|
||||||
|
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(ItemTrackerPayload);
|
||||||
|
|
||||||
|
Assert.NotNull(message);
|
||||||
|
SendEmailMessageValidator.Validate(message);
|
||||||
|
Assert.Equal("3ec4ad3f-0a8a-49f3-b31d-cdad5d1b95cc", message.CorrelationContext.CorrelationId);
|
||||||
|
Assert.Equal("item-tracker.notifications.result", message.CorrelationContext.ReplyTo);
|
||||||
|
Assert.Equal("StoreMate-Prod", message.Data.ServiceName);
|
||||||
|
Assert.Equal("EmailVerification", message.Data.TemplateKey);
|
||||||
|
Assert.Equal("owner@example.com", message.Data.RecipientEmail);
|
||||||
|
Assert.Equal("StoreMate Owner", message.Data.RecipientName);
|
||||||
|
Assert.Equal("uk", message.Data.LanguageCode);
|
||||||
|
Assert.Equal("StoreMate", message.Data.Variables["AppName"]);
|
||||||
|
Assert.Equal(
|
||||||
|
"https://example.invalid/sensitive-token",
|
||||||
|
message.Data.Variables["VerificationUrl"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingLanguageCode_IsRejected()
|
||||||
|
{
|
||||||
|
SendEmailMessage? message = JsonSerializer.Deserialize<SendEmailMessage>(
|
||||||
|
ItemTrackerPayload.Replace("\"uk\"", "null", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
InvalidDataException exception = Assert.Throws<InvalidDataException>(
|
||||||
|
() => SendEmailMessageValidator.Validate(message!));
|
||||||
|
|
||||||
|
Assert.Equal("LanguageCode is required.", exception.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.Worker;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
|
||||||
|
public sealed class SendEmailDeliveryValidatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ValidItemTrackerDelivery_IsAccepted()
|
||||||
|
{
|
||||||
|
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||||
|
CreateMessage(),
|
||||||
|
CreateContext(),
|
||||||
|
SendEmailConsumer.SupportedMessageType);
|
||||||
|
|
||||||
|
Assert.Null(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingMessageId_IsRejected()
|
||||||
|
{
|
||||||
|
RabbitMqMessageContext context = CreateContext() with { MessageId = null };
|
||||||
|
|
||||||
|
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||||
|
CreateMessage(),
|
||||||
|
context,
|
||||||
|
SendEmailConsumer.SupportedMessageType);
|
||||||
|
|
||||||
|
Assert.Equal("AMQP MessageId is required.", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnsupportedContractVersion_IsRejected()
|
||||||
|
{
|
||||||
|
RabbitMqMessageContext context = CreateContext() with
|
||||||
|
{
|
||||||
|
MessageType = "Notification.SendEmail.v2"
|
||||||
|
};
|
||||||
|
|
||||||
|
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||||
|
CreateMessage(),
|
||||||
|
context,
|
||||||
|
SendEmailConsumer.SupportedMessageType);
|
||||||
|
|
||||||
|
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RabbitMqMessageContext CreateContext() => new()
|
||||||
|
{
|
||||||
|
QueueName = SendEmailConsumer.IncomingQueue,
|
||||||
|
Exchange = string.Empty,
|
||||||
|
RoutingKey = SendEmailConsumer.IncomingQueue,
|
||||||
|
MessageId = Guid.NewGuid().ToString("D"),
|
||||||
|
MessageType = SendEmailConsumer.SupportedMessageType,
|
||||||
|
CorrelationId = "correlation-id",
|
||||||
|
ContentType = "application/json"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static SendEmailMessage CreateMessage() => new()
|
||||||
|
{
|
||||||
|
CorrelationContext = new CorrelationContext
|
||||||
|
{
|
||||||
|
CorrelationId = "correlation-id",
|
||||||
|
ReplyTo = "item-tracker.notifications.result"
|
||||||
|
},
|
||||||
|
Data = new SendEmailMessageData
|
||||||
|
{
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
TemplateKey = "EmailVerification",
|
||||||
|
RecipientEmail = "owner@example.com",
|
||||||
|
RecipientName = "Owner",
|
||||||
|
LanguageCode = "uk",
|
||||||
|
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
public sealed class SendEmailServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ProcessAsync_ValidItemTrackerMessage_SendsOnceAndUpdatesUsage()
|
||||||
|
{
|
||||||
|
Guid channelId = Guid.NewGuid();
|
||||||
|
var settings = new SmtpChannelSettings
|
||||||
|
{
|
||||||
|
Host = "smtp.example.invalid",
|
||||||
|
Port = 587,
|
||||||
|
Username = "smtp-user",
|
||||||
|
Password = "not-a-real-secret",
|
||||||
|
UseSsl = true,
|
||||||
|
FromEmail = "notifications@example.com",
|
||||||
|
FromName = "StoreMate"
|
||||||
|
};
|
||||||
|
var channel = new EmailChannel
|
||||||
|
{
|
||||||
|
Id = channelId,
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
Priority = 1,
|
||||||
|
EmailChannelType = EmailChannelType.Smtp,
|
||||||
|
Settings = settings,
|
||||||
|
IsActive = true
|
||||||
|
};
|
||||||
|
var template = new EmailTemplate
|
||||||
|
{
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
Key = "EmailVerification",
|
||||||
|
LanguageCode = "uk",
|
||||||
|
Subject = "Verify {{AppName}}",
|
||||||
|
HtmlBody = "<p>{{AppName}}</p>",
|
||||||
|
TextBody = "{{AppName}}",
|
||||||
|
Variables = [new EmailTemplateVariable { Name = "AppName", Required = true }]
|
||||||
|
};
|
||||||
|
|
||||||
|
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
|
||||||
|
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
|
||||||
|
.Returns([channel]);
|
||||||
|
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
|
||||||
|
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
|
||||||
|
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
|
||||||
|
.Returns(template);
|
||||||
|
var renderer = new EmailTemplateRenderingService();
|
||||||
|
var smtp = new RecordingSmtpEmailSender();
|
||||||
|
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
|
||||||
|
var service = new SendEmailService(
|
||||||
|
channels,
|
||||||
|
usage,
|
||||||
|
templates,
|
||||||
|
renderer,
|
||||||
|
smtp,
|
||||||
|
resultPublisher,
|
||||||
|
NullLogger<SendEmailService>.Instance);
|
||||||
|
var message = new SendEmailMessage
|
||||||
|
{
|
||||||
|
CorrelationContext = new CorrelationContext
|
||||||
|
{
|
||||||
|
CorrelationId = Guid.NewGuid().ToString("D")
|
||||||
|
},
|
||||||
|
Data = new SendEmailMessageData
|
||||||
|
{
|
||||||
|
ServiceName = "StoreMate-Prod",
|
||||||
|
TemplateKey = "EmailVerification",
|
||||||
|
RecipientEmail = "owner@example.com",
|
||||||
|
RecipientName = "Owner",
|
||||||
|
LanguageCode = "uk",
|
||||||
|
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.ProcessAsync(message, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, smtp.SendCount);
|
||||||
|
Assert.Same(settings, smtp.Settings);
|
||||||
|
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
|
||||||
|
Assert.Equal("owner@example.com", smtp.RecipientEmail);
|
||||||
|
Assert.Equal("Owner", smtp.RecipientName);
|
||||||
|
await usage.Received(1).IncrementUsageAsync(
|
||||||
|
channelId,
|
||||||
|
Arg.Any<DateOnly>(),
|
||||||
|
CancellationToken.None);
|
||||||
|
await resultPublisher.Received(1).PublishAsync(
|
||||||
|
message,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingSmtpEmailSender : ISmtpEmailSender
|
||||||
|
{
|
||||||
|
public int SendCount { get; private set; }
|
||||||
|
public SmtpChannelSettings? Settings { get; private set; }
|
||||||
|
public RenderedEmail? Email { get; private set; }
|
||||||
|
public string? RecipientEmail { get; private set; }
|
||||||
|
public string? RecipientName { get; private set; }
|
||||||
|
|
||||||
|
public Task SendAsync(
|
||||||
|
SmtpChannelSettings settings,
|
||||||
|
RenderedEmail email,
|
||||||
|
string recipientEmail,
|
||||||
|
string recipientName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SendCount++;
|
||||||
|
Settings = settings;
|
||||||
|
Email = email;
|
||||||
|
RecipientEmail = recipientEmail;
|
||||||
|
RecipientName = recipientName;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -10,6 +10,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" />
|
<PackageReference Include="coverlet.collector" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="NSubstitute" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" />
|
<PackageReference Include="xunit.runner.visualstudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
@@ -21,6 +22,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
|
||||||
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
|
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Worker\HrynCo.NotificationService.Worker.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Migrator\HrynCo.NotificationService.Migrator.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.Services.Tests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-2
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -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
-3
@@ -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
-3
@@ -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
-3
@@ -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)
|
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -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
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -4,4 +4,5 @@ using HrynCo.NotificationService.Services.Core;
|
|||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
namespace HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
||||||
|
|
||||||
public sealed record GetAllEmailTemplatesQuery : IRequest<ServiceResult<IReadOnlyList<EmailTemplate>>>;
|
public sealed record GetAllEmailTemplatesQuery(string? ServiceName = null, string? Key = null)
|
||||||
|
: IRequest<ServiceResult<IReadOnlyList<EmailTemplate>>>;
|
||||||
|
|||||||
+1
-3
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -25,6 +25,8 @@ public class EmailTemplateEditViewModel
|
|||||||
|
|
||||||
// JSON array: [{"name":"UserName","required":true}, ...]
|
// JSON array: [{"name":"UserName","required":true}, ...]
|
||||||
public string VariablesJson { get; set; } = "[]";
|
public string VariablesJson { get; set; } = "[]";
|
||||||
|
public string? ServiceNameFilter { get; set; }
|
||||||
|
public string? KeyFilter { get; set; }
|
||||||
|
|
||||||
public bool IsNew => Id == null;
|
public bool IsNew => Id == null;
|
||||||
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
using HrynCo.NotificationService.Web.Infrastructure;
|
using HrynCo.NotificationService.Web.Infrastructure;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Create;
|
using HrynCo.NotificationService.Services.EmailTemplates.Create;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
|
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
|
||||||
|
using HrynCo.NotificationService.Services.EmailTemplates.GetAll;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Get;
|
using HrynCo.NotificationService.Services.EmailTemplates.Get;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.GetByService;
|
using HrynCo.NotificationService.Services.EmailTemplates.GetByService;
|
||||||
using HrynCo.NotificationService.Services.EmailTemplates.Update;
|
using HrynCo.NotificationService.Services.EmailTemplates.Update;
|
||||||
@@ -15,9 +16,9 @@ public sealed class EmailTemplatesController : ApiControllerBase
|
|||||||
public EmailTemplatesController(IMediator mediator) : base(mediator) { }
|
public EmailTemplatesController(IMediator mediator) : base(mediator) { }
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] string serviceName, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetAll([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await Mediator.Send(new GetEmailTemplatesQuery(serviceName), cancellationToken);
|
var result = await Mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), cancellationToken);
|
||||||
return FromServiceResult(result);
|
return FromServiceResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,7 +80,9 @@
|
|||||||
<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 mb-3">
|
||||||
<div class="template-preview-panel-header">
|
<div class="template-preview-panel-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -73,8 +92,8 @@
|
|||||||
<span id="previewStatus" class="badge text-bg-secondary">Ready</span>
|
<span id="previewStatus" class="badge text-bg-secondary">Ready</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="template-preview-grid">
|
<div class="template-preview-body">
|
||||||
<div class="template-preview-source">
|
<div class="template-preview-source template-preview-section-block">
|
||||||
<div class="template-preview-section-title">Sample values</div>
|
<div class="template-preview-section-title">Sample values</div>
|
||||||
<div id="previewVariables" class="template-preview-variables"></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 class="form-text mt-2">Change these values to see the rendered output update immediately.</div>
|
||||||
@@ -92,12 +111,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
}
|
}
|
||||||
@@ -114,6 +135,7 @@
|
|||||||
const previewText = document.getElementById('previewText');
|
const previewText = document.getElementById('previewText');
|
||||||
const previewFrame = document.getElementById('previewHtmlFrame');
|
const previewFrame = document.getElementById('previewHtmlFrame');
|
||||||
const previewStatus = document.getElementById('previewStatus');
|
const previewStatus = document.getElementById('previewStatus');
|
||||||
|
const previewTab = document.getElementById('preview-tab');
|
||||||
|
|
||||||
if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) {
|
if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) {
|
||||||
return;
|
return;
|
||||||
@@ -267,6 +289,9 @@
|
|||||||
subjectField.addEventListener('input', updatePreview);
|
subjectField.addEventListener('input', updatePreview);
|
||||||
htmlField.addEventListener('input', updatePreview);
|
htmlField.addEventListener('input', updatePreview);
|
||||||
textField.addEventListener('input', updatePreview);
|
textField.addEventListener('input', updatePreview);
|
||||||
|
if (previewTab) {
|
||||||
|
previewTab.addEventListener('shown.bs.tab', updatePreview);
|
||||||
|
}
|
||||||
|
|
||||||
renderVariableInputs();
|
renderVariableInputs();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,26 @@ 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 ───────────────────────────── */
|
/* ── Email template preview ───────────────────────────── */
|
||||||
.template-preview-panel {
|
.template-preview-panel {
|
||||||
border: 1px solid #dce3eb;
|
border: 1px solid #dce3eb;
|
||||||
@@ -185,9 +206,9 @@ body {
|
|||||||
margin-top: .15rem;
|
margin-top: .15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-preview-grid {
|
.template-preview-body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem 1.25rem 1.25rem;
|
padding: 1rem 1.25rem 1.25rem;
|
||||||
}
|
}
|
||||||
@@ -197,6 +218,13 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-preview-section-block {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #e5ebf2;
|
||||||
|
border-radius: .65rem;
|
||||||
|
background: #fafcff;
|
||||||
|
}
|
||||||
|
|
||||||
.template-preview-section-title {
|
.template-preview-section-title {
|
||||||
font-size: .7rem;
|
font-size: .7rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -207,13 +235,26 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.template-preview-variables {
|
.template-preview-variables {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: .75rem;
|
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 {
|
.template-preview-variables .form-control-sm {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
min-height: calc(1.5em + .45rem + 2px);
|
||||||
|
padding: .2rem .45rem;
|
||||||
|
font-size: .82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-preview-subject {
|
.template-preview-subject {
|
||||||
@@ -248,7 +289,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
@media (max-width: 992px) {
|
||||||
.template-preview-grid {
|
.template-preview-body {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
@@ -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}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public interface IEmailTemplateRenderingService
|
||||||
|
{
|
||||||
|
RenderedEmail Render(EmailTemplate template, SendEmailMessageData data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public interface IEmailTemplateService
|
||||||
|
{
|
||||||
|
Task<EmailTemplate> GetAsync(
|
||||||
|
string serviceName,
|
||||||
|
string templateKey,
|
||||||
|
string? languageCode,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
|
||||||
|
public interface INotificationResultPublisher
|
||||||
|
{
|
||||||
|
Task PublishAsync(
|
||||||
|
SendEmailMessage message,
|
||||||
|
string? deliveryError,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public interface ISendEmailService
|
||||||
|
{
|
||||||
|
Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
|
||||||
|
internal interface ISmtpEmailSender
|
||||||
|
{
|
||||||
|
Task SendAsync(
|
||||||
|
SmtpChannelSettings settings,
|
||||||
|
RenderedEmail email,
|
||||||
|
string recipientEmail,
|
||||||
|
string recipientName,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
public static class NotificationDeliveryErrorFormatter
|
||||||
|
{
|
||||||
|
private const int MaximumErrorLength = 2000;
|
||||||
|
private const string FallbackError = "Notification delivery failed after all retry attempts.";
|
||||||
|
|
||||||
|
public static string Format(Exception exception)
|
||||||
|
{
|
||||||
|
string technicalMessage = Normalize(exception.GetBaseException().Message);
|
||||||
|
if (string.IsNullOrWhiteSpace(technicalMessage))
|
||||||
|
{
|
||||||
|
return FallbackError;
|
||||||
|
}
|
||||||
|
|
||||||
|
string contextualMessage = CreateContextualMessage(exception, technicalMessage);
|
||||||
|
return contextualMessage.Length <= MaximumErrorLength
|
||||||
|
? contextualMessage
|
||||||
|
: contextualMessage[..MaximumErrorLength];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateContextualMessage(Exception exception, string technicalMessage)
|
||||||
|
{
|
||||||
|
if (FindException<SmtpFailedRecipientException>(exception) is not null)
|
||||||
|
{
|
||||||
|
return "SMTP delivery failed after all retry attempts: " +
|
||||||
|
"the SMTP server rejected the recipient address.";
|
||||||
|
}
|
||||||
|
|
||||||
|
SocketException? socketException = FindException<SocketException>(exception);
|
||||||
|
if (socketException is not null)
|
||||||
|
{
|
||||||
|
string reason = socketException.SocketErrorCode switch
|
||||||
|
{
|
||||||
|
SocketError.HostNotFound or SocketError.NoData =>
|
||||||
|
"the configured SMTP server host could not be resolved",
|
||||||
|
SocketError.ConnectionRefused =>
|
||||||
|
"the configured SMTP server refused the connection",
|
||||||
|
SocketError.TimedOut =>
|
||||||
|
"the connection to the configured SMTP server timed out",
|
||||||
|
_ when technicalMessage.Contains(
|
||||||
|
"Name or service not known",
|
||||||
|
StringComparison.OrdinalIgnoreCase) =>
|
||||||
|
"the configured SMTP server host could not be resolved",
|
||||||
|
_ => "the configured SMTP server could not be reached"
|
||||||
|
};
|
||||||
|
|
||||||
|
return $"SMTP delivery failed after all retry attempts: {reason} ({technicalMessage}).";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FindException<SmtpException>(exception) is not null)
|
||||||
|
{
|
||||||
|
return $"SMTP delivery failed after all retry attempts ({technicalMessage}).";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Notification delivery failed after all retry attempts ({technicalMessage}).";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TException? FindException<TException>(Exception exception)
|
||||||
|
where TException : Exception
|
||||||
|
{
|
||||||
|
for (Exception? current = exception; current is not null; current = current.InnerException)
|
||||||
|
{
|
||||||
|
if (current is TException typedException)
|
||||||
|
{
|
||||||
|
return typedException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string message)
|
||||||
|
{
|
||||||
|
return message.ReplaceLineEndings(" ").Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
internal sealed class NotificationResultPublisher : INotificationResultPublisher
|
||||||
|
{
|
||||||
|
private readonly ILogger<NotificationResultPublisher> _logger;
|
||||||
|
private readonly IRabbitMqPublisher _publisher;
|
||||||
|
|
||||||
|
public NotificationResultPublisher(
|
||||||
|
IRabbitMqPublisher publisher,
|
||||||
|
ILogger<NotificationResultPublisher> logger)
|
||||||
|
{
|
||||||
|
_publisher = publisher;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PublishAsync(
|
||||||
|
SendEmailMessage message,
|
||||||
|
string? deliveryError,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
CorrelationContext correlationContext = message.CorrelationContext;
|
||||||
|
string? replyTo = correlationContext.ReplyTo;
|
||||||
|
if (string.IsNullOrWhiteSpace(replyTo))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = new NotificationResultMessage
|
||||||
|
{
|
||||||
|
CorrelationContext = correlationContext with { ReplyTo = null },
|
||||||
|
Data = new NotificationResultData
|
||||||
|
{
|
||||||
|
ServiceName = message.Data.ServiceName,
|
||||||
|
RecipientEmail = message.Data.RecipientEmail,
|
||||||
|
TemplateKey = message.Data.TemplateKey,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow,
|
||||||
|
ErrorMessage = deliveryError
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await _publisher.PublishAsync(replyTo, result, cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Notification result published to reply queue {Queue} [CorrelationId={CorrelationId}]",
|
||||||
|
replyTo,
|
||||||
|
correlationContext.CorrelationId);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Failed to publish notification result to reply queue {Queue} [CorrelationId={CorrelationId}]",
|
||||||
|
replyTo,
|
||||||
|
correlationContext.CorrelationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
public static class RecipientAddressRedactor
|
||||||
|
{
|
||||||
|
public static string Redact(string? address)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(address))
|
||||||
|
return "<missing>";
|
||||||
|
|
||||||
|
int separator = address.LastIndexOf('@');
|
||||||
|
if (separator <= 0 || separator == address.Length - 1)
|
||||||
|
return "***";
|
||||||
|
|
||||||
|
string local = address[..separator];
|
||||||
|
string domain = address[(separator + 1)..];
|
||||||
|
int dot = domain.LastIndexOf('.');
|
||||||
|
string domainName = dot > 0 ? domain[..dot] : domain;
|
||||||
|
string suffix = dot > 0 ? domain[dot..] : string.Empty;
|
||||||
|
|
||||||
|
return $"{local[0]}***@{domainName[0]}***{suffix}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
namespace HrynCo.NotificationService.Worker;
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
public record RenderedEmail(string Subject, string HtmlBody, string TextBody);
|
public record RenderedEmail(string Subject, string HtmlBody, string TextBody);
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using System.Net.Mail;
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
|
||||||
|
public static class SendEmailMessageValidator
|
||||||
|
{
|
||||||
|
public static void Validate(SendEmailMessage message)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
|
|
||||||
|
if (message.CorrelationContext is null)
|
||||||
|
throw new InvalidDataException("CorrelationContext is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(message.CorrelationContext.CorrelationId))
|
||||||
|
throw new InvalidDataException("CorrelationContext.CorrelationId is required.");
|
||||||
|
if (message.Data is null)
|
||||||
|
throw new InvalidDataException("Data is required.");
|
||||||
|
|
||||||
|
Require(message.Data.ServiceName, nameof(message.Data.ServiceName));
|
||||||
|
Require(message.Data.TemplateKey, nameof(message.Data.TemplateKey));
|
||||||
|
Require(message.Data.RecipientEmail, nameof(message.Data.RecipientEmail));
|
||||||
|
Require(message.Data.RecipientName, nameof(message.Data.RecipientName));
|
||||||
|
Require(message.Data.LanguageCode, nameof(message.Data.LanguageCode));
|
||||||
|
|
||||||
|
if (!MailAddress.TryCreate(message.Data.RecipientEmail, out _))
|
||||||
|
throw new InvalidDataException("RecipientEmail is not a valid email address.");
|
||||||
|
if (message.Data.Variables is null)
|
||||||
|
throw new InvalidDataException("Variables is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Require(string? value, string fieldName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
throw new InvalidDataException($"{fieldName} is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
internal sealed class SendEmailService : ISendEmailService
|
||||||
|
{
|
||||||
|
private readonly IEmailChannelRepository _channelRepository;
|
||||||
|
private readonly IEmailChannelUsageRepository _usageRepository;
|
||||||
|
private readonly IEmailTemplateService _templateService;
|
||||||
|
private readonly IEmailTemplateRenderingService _templateRenderingService;
|
||||||
|
private readonly ISmtpEmailSender _smtpEmailSender;
|
||||||
|
private readonly INotificationResultPublisher _resultPublisher;
|
||||||
|
private readonly ILogger<SendEmailService> _logger;
|
||||||
|
|
||||||
|
public SendEmailService(
|
||||||
|
IEmailChannelRepository channelRepository,
|
||||||
|
IEmailChannelUsageRepository usageRepository,
|
||||||
|
IEmailTemplateService templateService,
|
||||||
|
IEmailTemplateRenderingService templateRenderingService,
|
||||||
|
ISmtpEmailSender smtpEmailSender,
|
||||||
|
INotificationResultPublisher resultPublisher,
|
||||||
|
ILogger<SendEmailService> logger)
|
||||||
|
{
|
||||||
|
_channelRepository = channelRepository;
|
||||||
|
_usageRepository = usageRepository;
|
||||||
|
_templateService = templateService;
|
||||||
|
_templateRenderingService = templateRenderingService;
|
||||||
|
_smtpEmailSender = smtpEmailSender;
|
||||||
|
_resultPublisher = resultPublisher;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SendEmailMessageValidator.Validate(message);
|
||||||
|
SendEmailMessageData data = message.Data;
|
||||||
|
string redactedRecipient = RecipientAddressRedactor.Redact(data.RecipientEmail);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]",
|
||||||
|
data.ServiceName, data.TemplateKey, redactedRecipient, message.CorrelationContext?.CorrelationId);
|
||||||
|
|
||||||
|
EmailChannel channel = await ResolveChannelAsync(data.ServiceName, cancellationToken);
|
||||||
|
EmailTemplate template = await GetTemplateAsync(data, cancellationToken);
|
||||||
|
|
||||||
|
await EnforceLimitsAsync(channel, cancellationToken);
|
||||||
|
|
||||||
|
RenderedEmail rendered = _templateRenderingService.Render(template, data);
|
||||||
|
|
||||||
|
SmtpChannelSettings smtpChannel = channel.Settings as SmtpChannelSettings
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Channel type '{channel.EmailChannelType}' is not supported for sending.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _smtpEmailSender.SendAsync(
|
||||||
|
smtpChannel,
|
||||||
|
rendered,
|
||||||
|
data.RecipientEmail,
|
||||||
|
data.RecipientName,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "SMTP send failed for channel {ChannelId}", channel.Id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _usageRepository.IncrementUsageAsync(
|
||||||
|
channel.Id,
|
||||||
|
DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Email sent successfully service={Service} template={Template} recipient={Recipient}",
|
||||||
|
data.ServiceName, data.TemplateKey, redactedRecipient);
|
||||||
|
|
||||||
|
await _resultPublisher.PublishAsync(message, null, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmailTemplate> GetTemplateAsync(SendEmailMessageData data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _templateService.GetAsync(
|
||||||
|
data.ServiceName,
|
||||||
|
data.TemplateKey,
|
||||||
|
data.LanguageCode,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<EmailChannel> ResolveChannelAsync(string serviceName, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var channels = await _channelRepository.GetByServiceAsync(serviceName, ct);
|
||||||
|
|
||||||
|
return channels
|
||||||
|
.Where(c => c.IsActive)
|
||||||
|
.OrderBy(c => c.Priority)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No active email channel found for service '{serviceName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnforceLimitsAsync(EmailChannel channel, CancellationToken ct)
|
||||||
|
{
|
||||||
|
DateOnly today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
|
|
||||||
|
if (channel.DailyLimit.HasValue)
|
||||||
|
{
|
||||||
|
int daily = await _usageRepository.GetDailyCountAsync(channel.Id, today, ct);
|
||||||
|
|
||||||
|
if (daily >= channel.DailyLimit.Value)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Channel '{channel.Id}' daily limit of {channel.DailyLimit.Value} reached ({daily} sent today).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.MonthlyLimit.HasValue)
|
||||||
|
{
|
||||||
|
int monthly = await _usageRepository.GetMonthlyCountAsync(channel.Id, today.Year, today.Month, ct);
|
||||||
|
|
||||||
|
if (monthly >= channel.MonthlyLimit.Value)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Channel '{channel.Id}' monthly limit of {channel.MonthlyLimit.Value} reached ({monthly} sent this month).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Mail;
|
||||||
|
using System.Text;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||||
|
|
||||||
|
internal sealed class SmtpEmailSender : ISmtpEmailSender
|
||||||
|
{
|
||||||
|
public async Task SendAsync(
|
||||||
|
SmtpChannelSettings settings,
|
||||||
|
RenderedEmail email,
|
||||||
|
string recipientEmail,
|
||||||
|
string recipientName,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var client = new SmtpClient(settings.Host, settings.Port)
|
||||||
|
{
|
||||||
|
EnableSsl = settings.UseSsl,
|
||||||
|
Credentials = string.IsNullOrWhiteSpace(settings.Username)
|
||||||
|
? null
|
||||||
|
: new NetworkCredential(settings.Username, settings.Password)
|
||||||
|
};
|
||||||
|
|
||||||
|
using var mail = new MailMessage
|
||||||
|
{
|
||||||
|
From = new MailAddress(settings.FromEmail, settings.FromName),
|
||||||
|
Subject = email.Subject,
|
||||||
|
Body = email.TextBody,
|
||||||
|
IsBodyHtml = false,
|
||||||
|
BodyEncoding = Encoding.UTF8,
|
||||||
|
SubjectEncoding = Encoding.UTF8
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(email.HtmlBody))
|
||||||
|
{
|
||||||
|
AlternateView html = AlternateView.CreateAlternateViewFromString(
|
||||||
|
email.HtmlBody,
|
||||||
|
Encoding.UTF8,
|
||||||
|
"text/html");
|
||||||
|
mail.AlternateViews.Add(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
mail.To.Add(new MailAddress(recipientEmail, recipientName));
|
||||||
|
await client.SendMailAsync(mail, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>HrynCo.NotificationService.Worker.Services</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="HrynCo.RabbitMq" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.Contracts\HrynCo.NotificationService.Contracts.csproj" />
|
||||||
|
<ProjectReference Include="..\HrynCo.NotificationService.DAL.Abstract\HrynCo.NotificationService.DAL.Abstract.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("HrynCo.NotificationService.Services.Tests")]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
|
||||||
|
using Hrynco.RabbitMq;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.Worker.Services;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddNotificationWorkerServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddSingleton<IRabbitMqPublisher, RabbitMqPublisher>();
|
||||||
|
services.AddScoped<IEmailTemplateService, EmailTemplateService>();
|
||||||
|
services.AddScoped<IEmailTemplateRenderingService, EmailTemplateRenderingService>();
|
||||||
|
services.AddScoped<ISmtpEmailSender, SmtpEmailSender>();
|
||||||
|
services.AddScoped<INotificationResultPublisher, NotificationResultPublisher>();
|
||||||
|
services.AddScoped<ISendEmailService, SendEmailService>();
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,5 +20,6 @@
|
|||||||
<Project Path="HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj" />
|
<Project Path="HrynCo.NotificationService.DAL.EF/HrynCo.NotificationService.DAL.EF.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj" />
|
<Project Path="HrynCo.NotificationService.Services.Tests/HrynCo.NotificationService.Services.Tests.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj" />
|
<Project Path="HrynCo.NotificationService.Services/HrynCo.NotificationService.Services.csproj" />
|
||||||
|
<Project Path="HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj" />
|
||||||
<Project Path="HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj" />
|
<Project Path="HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<configuration>
|
<configuration>
|
||||||
<config>
|
|
||||||
<add key="globalPackagesFolder" value="%USERPROFILE%\.nuget\packages" />
|
|
||||||
</config>
|
|
||||||
<packageSources>
|
<packageSources>
|
||||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||||
</packageSources>
|
</packageSources>
|
||||||
|
|||||||
@@ -1 +1,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.
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ RABBITMQ_PASSWORD=guest
|
|||||||
RABBITMQ_AMQP_PORT=5672
|
RABBITMQ_AMQP_PORT=5672
|
||||||
RABBITMQ_MANAGEMENT_PORT=15672
|
RABBITMQ_MANAGEMENT_PORT=15672
|
||||||
WEB_PORT=5200
|
WEB_PORT=5200
|
||||||
|
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ name: hrynco-notification-service
|
|||||||
services:
|
services:
|
||||||
migrator:
|
migrator:
|
||||||
environment:
|
environment:
|
||||||
|
- DOTNET_ENVIRONMENT=Development
|
||||||
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
|
- App__ConnectionString=Host=db;Port=5432;Database=notification_service;Username=postgres;Password=postgres
|
||||||
|
- DevelopmentSeed__ServiceName=${DEVELOPMENT_EMAIL_SERVICE_NAME:-TestService}
|
||||||
|
|
||||||
web:
|
web:
|
||||||
environment:
|
environment:
|
||||||
@@ -29,9 +31,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: guest
|
RABBITMQ_DEFAULT_USER: guest
|
||||||
RABBITMQ_DEFAULT_PASS: guest
|
RABBITMQ_DEFAULT_PASS: guest
|
||||||
ports:
|
|
||||||
- "5672:5672"
|
|
||||||
- "15672:15672"
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
@@ -55,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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# ItemTracker Outbox Email Consumer
|
||||||
|
|
||||||
|
## Contract and routing
|
||||||
|
|
||||||
|
The Worker consumes durable messages from queue `notification.send-email`. ItemTracker publishes through RabbitMQ's default exchange, so the queue name is also the routing key. No custom exchange binding is required.
|
||||||
|
|
||||||
|
`HrynCo.RabbitMq` owns the transport lifecycle: connection, queue declaration, deserialization, prefetch, structured delivery context, retry, terminal-failure notification, and manual ACK/NACK. Notification Service only supplies its queue, contract validation, email handler, and result publisher through the package's generic extension points. This integration requires package version `1.0.17` or newer.
|
||||||
|
|
||||||
|
Accepted messages must have all of the following:
|
||||||
|
|
||||||
|
- AMQP `Type`: `Notification.SendEmail.v1`
|
||||||
|
- AMQP `MessageId`: the non-empty stable ItemTracker Outbox ID
|
||||||
|
- JSON content using the PascalCase `SendEmailMessage` envelope
|
||||||
|
- non-empty `CorrelationContext.CorrelationId`
|
||||||
|
- non-empty `ServiceName`, `TemplateKey`, `RecipientEmail`, `RecipientName`, and `LanguageCode`
|
||||||
|
- a valid recipient address and a non-null `Variables` object
|
||||||
|
|
||||||
|
The service resolves a template by the exact `ServiceName`, `TemplateKey`, and normalized lower-case `LanguageCode`. It does not silently fall back to another language. Every variable marked `Required` by the selected template must have a non-empty value before rendering.
|
||||||
|
|
||||||
|
## Delivery and acknowledgement policy
|
||||||
|
|
||||||
|
The consumer uses manual acknowledgements and prefetches one message at a time.
|
||||||
|
|
||||||
|
1. Unsupported contract metadata, malformed JSON, or invalid required fields are logged and nacked without requeue.
|
||||||
|
2. Template, provider, quota, SMTP, or usage-accounting failures are retried in-process three times with a five-second delay.
|
||||||
|
3. The message is acknowledged only after SMTP accepts the email and provider usage is incremented.
|
||||||
|
4. If `CorrelationContext.ReplyTo` is present, result publication is attempted after delivery. It is best-effort: a reply-queue failure is logged but does not make the SMTP delivery fail, because retrying after SMTP success could send a duplicate email.
|
||||||
|
5. After the final processing retry fails, the shared terminal-failure hook publishes one
|
||||||
|
negative result to the same `ReplyTo`. It retains the original correlation and contains
|
||||||
|
a normalized diagnostic string limited to 2000 characters. Common SMTP DNS,
|
||||||
|
connection-refused, timeout, and recipient-rejection failures receive safe explanatory
|
||||||
|
context while retaining the low-level provider reason. Hostnames, recipient addresses,
|
||||||
|
and credentials are not added. Client outboxes store this as a neutral delivery error
|
||||||
|
rather than interpreting SMTP-specific exception types.
|
||||||
|
|
||||||
|
Success and terminal failure result publication remain best-effort. If the result queue
|
||||||
|
cannot be reached, the internal exception is logged and the original delivery follows its
|
||||||
|
normal ACK/NACK policy. Clients therefore must not interpret the absence of a result as a
|
||||||
|
confirmed failure.
|
||||||
|
|
||||||
|
Delivery remains at least once. There is no inbox/deduplication store in this MVP, so a broker redelivery or a process failure after SMTP acceptance but before acknowledgement can produce a duplicate. `MessageId`, `CorrelationId`, and `MessageType` are added to the structured log scope for correlation. Recipient addresses are masked, and template variable names/values and rendered links are not logged.
|
||||||
|
|
||||||
|
No database migration is required. Existing channel selection, SMTP settings, quota checks, and usage counters remain in place.
|
||||||
|
|
||||||
|
## Local end-to-end setup
|
||||||
|
|
||||||
|
With Docker Desktop running, install and start the complete Notification Service
|
||||||
|
development stack from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\docker\environments\install-development.cmd
|
||||||
|
```
|
||||||
|
|
||||||
|
The script uses the tracked `docker/environments/.env.Development` defaults. The
|
||||||
|
following manual setup is only needed when overriding those values locally.
|
||||||
|
|
||||||
|
Create a local environment file outside source control, for example `docker/environments/.env.local`, with these values:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
DB_NAME=notification_service
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASS=postgres
|
||||||
|
VOLUME_PREFIX=ns-dev
|
||||||
|
RABBITMQ_USER=guest
|
||||||
|
RABBITMQ_PASSWORD=guest
|
||||||
|
RABBITMQ_VIRTUAL_HOST=/
|
||||||
|
RABBITMQ_AMQP_PORT=5672
|
||||||
|
RABBITMQ_MANAGEMENT_PORT=15672
|
||||||
|
DEVELOPMENT_EMAIL_SERVICE_NAME=TestService
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the Notification Service stack from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --env-file docker/environments/.env.local `
|
||||||
|
-f docker/environments/docker-compose.yml `
|
||||||
|
-f docker/environments/docker-compose.Development.yml `
|
||||||
|
up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Development compose exposes:
|
||||||
|
|
||||||
|
- Notification Service admin: `http://localhost:5200/admin/channels` and `/admin/templates`
|
||||||
|
- RabbitMQ management: `http://localhost:15672`
|
||||||
|
- Mailpit inbox: `http://localhost:8025`
|
||||||
|
- Seq: `http://localhost:5342`
|
||||||
|
|
||||||
|
The development migrator idempotently creates an active SMTP channel for
|
||||||
|
`DEVELOPMENT_EMAIL_SERVICE_NAME` using host `mailpit`, port `1025`, SSL disabled, and
|
||||||
|
blank credentials. It also creates a neutral English `TestEmail` template with
|
||||||
|
`RecipientName` and `Message` variables. Existing channels and templates are left
|
||||||
|
unchanged. Use the admin UI to inspect or customize them and to add client-specific
|
||||||
|
service names, template keys, or languages.
|
||||||
|
|
||||||
|
Configure ItemTracker to use the same RabbitMQ host, port, credentials, virtual host, queue `notification.send-email`, and service name. When ItemTracker runs outside Docker against the development stack, the broker is `localhost:5672`; from a Docker container it is the reachable host or shared-network name. Start the ItemTracker Outbox Worker in `RabbitMq` mode, trigger a production-safe notification to an owned test address, then verify:
|
||||||
|
|
||||||
|
1. the ItemTracker Outbox row becomes published;
|
||||||
|
2. RabbitMQ delivers and removes the message from `notification.send-email`;
|
||||||
|
3. Notification Service logs show the same `MessageId` and `CorrelationId` without the full address;
|
||||||
|
4. Mailpit shows exactly one rendered email;
|
||||||
|
5. the Notification Service channel usage counter increments;
|
||||||
|
6. the client result queue is consumed and the matching outbox row records successful delivery.
|
||||||
|
|
||||||
|
## Production configuration and smoke test
|
||||||
|
|
||||||
|
Production requires the same `App__RabbitMq__Host`, `Port`, `User`, `Password`, and `VirtualHost` values as ItemTracker's publisher, plus the Notification Service database connection. The production compose file obtains these from deployment environment variables; secrets must stay in the deployment secret store. The queue is declared durable by both producer and consumer.
|
||||||
|
|
||||||
|
This Notification Service revision restores the published immutable
|
||||||
|
`HrynCo.RabbitMq` version `1.0.17` from NuGet.org. Build and deployment environments
|
||||||
|
therefore require NuGet.org access or a trusted package mirror containing that exact
|
||||||
|
version.
|
||||||
|
|
||||||
|
Before enabling the Worker, verify that the target service has an active SMTP channel and exact-language templates for every queued ItemTracker template key. Inspect any delayed ItemTracker backlog for expired password-reset, verification, or invitation messages before draining it.
|
||||||
|
|
||||||
|
The template administration list preserves optional Service Name and Key filters when an
|
||||||
|
administrator opens the create or edit screen. These links use explicit Razor expression
|
||||||
|
boundaries so the filter query is appended as query parameters rather than rendered as a
|
||||||
|
literal `@filterQuery` path segment. With no active filters, the create route is exactly
|
||||||
|
`/admin/templates/create`.
|
||||||
|
|
||||||
|
For the smoke test, use an owned test account and a non-sensitive notification template. Record the Outbox `Id` and `CorrelationId`, trigger only one message, follow those identifiers through Outbox publication and Notification Service logs, and confirm receipt with the configured SMTP provider. Do not copy payloads, tokens, credentials, full recipient addresses, or rendered URLs into tickets or logs.
|
||||||
Reference in New Issue
Block a user