From 25fb48ccf048ed4ce30778292e59a0bb00d68456 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Thu, 14 May 2026 22:15:15 +0300 Subject: [PATCH] refactor: modularize email processing logic and improve service structure - Extract email template handling, rendering, and sending code into `Worker.Services` project. - Introduce `EmailTemplateService`, `EmailTemplateRenderingService`, and `SendEmailService`. - Simplify consumer logic by delegating to scoped services. - Update project dependencies and package references accordingly. --- .../EmailChannels/Send/SendEmailHandler.cs | 13 +- .../EmailTemplateRenderingService.cs | 24 +++ .../EmailProcessing/EmailTemplateService.cs | 31 +++ .../IEmailTemplateRenderingService.cs | 9 + .../EmailProcessing/IEmailTemplateService.cs | 12 ++ .../EmailProcessing/ISendEmailService.cs | 8 + .../EmailProcessing}/RenderedEmail.cs | 2 +- .../EmailProcessing/SendEmailService.cs | 198 ++++++++++++++++++ ...NotificationService.Worker.Services.csproj | 21 ++ .../ServiceCollectionExtensions.cs | 17 ++ .../AppSettings.cs | 2 +- HrynCo.NotificationService.Worker/Dockerfile | 4 +- .../HrynCo.NotificationService.Worker.csproj | 3 +- HrynCo.NotificationService.Worker/Program.cs | 7 +- .../SendEmailConsumer.cs | 176 ++-------------- .../appsettings.Development.json | 24 +++ HrynCo.NotificationService.slnx | 1 + README.md | 16 ++ 18 files changed, 394 insertions(+), 174 deletions(-) create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs rename {HrynCo.NotificationService.Worker => HrynCo.NotificationService.Worker.Services/EmailProcessing}/RenderedEmail.cs (53%) create mode 100644 HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs create mode 100644 HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj create mode 100644 HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs diff --git a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs index ed5a9c5..1d0426b 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Send/SendEmailHandler.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Mail; +using System.Text; using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -48,14 +49,16 @@ internal sealed class SendEmailHandler { From = new MailAddress(smtp.FromEmail, smtp.FromName), Subject = request.Subject, - Body = request.HtmlBody, - IsBodyHtml = true + Body = request.TextBody ?? string.Empty, + 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"); - mail.AlternateViews.Add(plain); + var html = AlternateView.CreateAlternateViewFromString(request.HtmlBody, Encoding.UTF8, "text/html"); + mail.AlternateViews.Add(html); } mail.To.Add(new MailAddress(request.RecipientEmail, request.RecipientName)); diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs new file mode 100644 index 0000000..c6eebaa --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateRenderingService.cs @@ -0,0 +1,24 @@ +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +using System.Text; +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Templates; + +internal sealed class EmailTemplateRenderingService : IEmailTemplateRenderingService +{ + public RenderedEmail Render(EmailTemplate template, SendEmailMessageData data) + { + 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 variables) + { + var sb = new StringBuilder(text); + foreach (var (key, value) in variables) + sb.Replace($"{{{{{key}}}}}", value); + return sb.ToString(); + } +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs new file mode 100644 index 0000000..fee311c --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/EmailTemplateService.cs @@ -0,0 +1,31 @@ +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 GetAsync( + string serviceName, + string templateKey, + string? languageCode, + CancellationToken cancellationToken) + { + var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode; + var template = await _templateRepository.GetAsync(serviceName, templateKey, lang, cancellationToken); + + if (template is null && lang != "en") + template = await _templateRepository.GetAsync(serviceName, templateKey, "en", cancellationToken); + + return template + ?? throw new InvalidOperationException( + $"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'."); + } +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs new file mode 100644 index 0000000..8c8501e --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateRenderingService.cs @@ -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); +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs new file mode 100644 index 0000000..f388c41 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/IEmailTemplateService.cs @@ -0,0 +1,12 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public interface IEmailTemplateService +{ + Task GetAsync( + string serviceName, + string templateKey, + string? languageCode, + CancellationToken cancellationToken); +} diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs new file mode 100644 index 0000000..c5436bd --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/ISendEmailService.cs @@ -0,0 +1,8 @@ +using HrynCo.NotificationService.Contracts.Messages; + +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +public interface ISendEmailService +{ + Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken); +} diff --git a/HrynCo.NotificationService.Worker/RenderedEmail.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/RenderedEmail.cs similarity index 53% rename from HrynCo.NotificationService.Worker/RenderedEmail.cs rename to HrynCo.NotificationService.Worker.Services/EmailProcessing/RenderedEmail.cs index fcb41bb..0f9637b 100644 --- a/HrynCo.NotificationService.Worker/RenderedEmail.cs +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/RenderedEmail.cs @@ -1,3 +1,3 @@ -namespace HrynCo.NotificationService.Worker; +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; public record RenderedEmail(string Subject, string HtmlBody, string TextBody); diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs new file mode 100644 index 0000000..0793ce1 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs @@ -0,0 +1,198 @@ +namespace HrynCo.NotificationService.Worker.Services.EmailProcessing; + +using System.Net; +using System.Net.Mail; +using System.Text; +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using Hrynco.RabbitMq; +using Microsoft.Extensions.Logging; + +internal sealed class SendEmailService : ISendEmailService +{ + private readonly IEmailChannelRepository _channelRepository; + private readonly IEmailChannelUsageRepository _usageRepository; + private readonly IEmailTemplateService _templateService; + private readonly IEmailTemplateRenderingService _templateRenderingService; + private readonly IRabbitMqPublisher _publisher; + private readonly ILogger _logger; + + public SendEmailService( + IEmailChannelRepository channelRepository, + IEmailChannelUsageRepository usageRepository, + IEmailTemplateService templateService, + IEmailTemplateRenderingService templateRenderingService, + IRabbitMqPublisher publisher, + ILogger logger) + { + _channelRepository = channelRepository; + _usageRepository = usageRepository; + _templateService = templateService; + _templateRenderingService = templateRenderingService; + _publisher = publisher; + _logger = logger; + } + + public async Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken) + { + SendEmailMessageData data = message.Data; + + _logger.LogInformation( + "Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]", + data.ServiceName, data.TemplateKey, data.RecipientEmail, 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 + { + using var client = new SmtpClient(smtpChannel.Host, smtpChannel.Port) + { + EnableSsl = smtpChannel.UseSsl, + Credentials = string.IsNullOrWhiteSpace(smtpChannel.Username) + ? null + : new NetworkCredential(smtpChannel.Username, smtpChannel.Password) + }; + + using var mail = new MailMessage + { + From = new MailAddress(smtpChannel.FromEmail, smtpChannel.FromName), + Subject = rendered.Subject, + Body = rendered.TextBody, + IsBodyHtml = false, + BodyEncoding = Encoding.UTF8, + SubjectEncoding = Encoding.UTF8 + }; + + if (!string.IsNullOrWhiteSpace(rendered.HtmlBody)) + { + var html = AlternateView.CreateAlternateViewFromString( + rendered.HtmlBody, Encoding.UTF8, "text/html"); + mail.AlternateViews.Add(html); + } + + mail.To.Add(new MailAddress(data.RecipientEmail, data.RecipientName)); + + await client.SendMailAsync(mail, cancellationToken); + } + 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, data.RecipientEmail); + + await PublishResultAsync(message.CorrelationContext, data, null, cancellationToken); + } + + private async Task GetTemplateAsync(SendEmailMessageData data, CancellationToken cancellationToken) + { + return await _templateService.GetAsync( + data.ServiceName, + data.TemplateKey, + data.LanguageCode, + cancellationToken); + } + + private async Task 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)."); + } + } + } + + private async Task PublishResultAsync( + CorrelationContext? correlationContext, + SendEmailMessageData data, + string? errorMessage, + CancellationToken ct) + { + string? replyTo = correlationContext?.ReplyTo; + + if (string.IsNullOrWhiteSpace(replyTo)) + { + return; + } + + try + { + var result = new NotificationResultMessage + { + CorrelationContext = (correlationContext ?? new CorrelationContext + { + CorrelationId = Guid.NewGuid().ToString() + }) with + { + ReplyTo = null + }, + Data = new NotificationResultData + { + ServiceName = data.ServiceName, + RecipientEmail = data.RecipientEmail, + TemplateKey = data.TemplateKey, + Timestamp = DateTimeOffset.UtcNow, + ErrorMessage = errorMessage + } + }; + + await _publisher.PublishAsync(replyTo, result, ct); + + _logger.LogDebug("Result published to reply queue '{Queue}' [CorrelationId={CorrelationId}]", + replyTo, correlationContext?.CorrelationId); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to publish notification result to reply queue '{Queue}'", replyTo); + } + } +} diff --git a/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj b/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj new file mode 100644 index 0000000..d2d358d --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + HrynCo.NotificationService.Worker.Services + + + + + + + + + + + + + + diff --git a/HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs b/HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..3dec0ea --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +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(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/HrynCo.NotificationService.Worker/AppSettings.cs b/HrynCo.NotificationService.Worker/AppSettings.cs index 86e7f3e..889572c 100644 --- a/HrynCo.NotificationService.Worker/AppSettings.cs +++ b/HrynCo.NotificationService.Worker/AppSettings.cs @@ -8,4 +8,4 @@ public sealed class AppSettings public string ConnectionString { get; init; } = string.Empty; public RabbitMqSettings RabbitMq { get; init; } = null!; -} \ No newline at end of file +} diff --git a/HrynCo.NotificationService.Worker/Dockerfile b/HrynCo.NotificationService.Worker/Dockerfile index df2e1ec..76cf967 100644 --- a/HrynCo.NotificationService.Worker/Dockerfile +++ b/HrynCo.NotificationService.Worker/Dockerfile @@ -11,7 +11,7 @@ COPY ["Directory.Build.props", "."] COPY ["Directory.Packages.props", "."] 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.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/"] RUN dotnet restore "HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj" @@ -28,4 +28,4 @@ RUN dotnet publish "./HrynCo.NotificationService.Worker.csproj" -c $BUILD_CONFIG FROM base AS final WORKDIR /app COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "HrynCo.NotificationService.Worker.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "HrynCo.NotificationService.Worker.dll"] diff --git a/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj b/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj index 211d07b..c9199ce 100644 --- a/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj +++ b/HrynCo.NotificationService.Worker/HrynCo.NotificationService.Worker.csproj @@ -10,7 +10,6 @@ - @@ -19,7 +18,7 @@ - + diff --git a/HrynCo.NotificationService.Worker/Program.cs b/HrynCo.NotificationService.Worker/Program.cs index 9f103eb..d9de6c9 100644 --- a/HrynCo.NotificationService.Worker/Program.cs +++ b/HrynCo.NotificationService.Worker/Program.cs @@ -1,8 +1,7 @@ using HrynCo.NotificationService.DAL.EF; -using HrynCo.NotificationService.Services; using HrynCo.NotificationService.Worker; +using HrynCo.NotificationService.Worker.Services; using Hrynco.RabbitMq; -using Microsoft.Extensions.Options; var builder = Host.CreateApplicationBuilder(args); @@ -14,7 +13,7 @@ var appSettings = builder.Configuration builder.Services.AddSingleton(appSettings); builder.Services.AddNotificationDataAccess(appSettings.ConnectionString); -builder.Services.AddNotificationServices(); +builder.Services.AddNotificationWorkerServices(); builder.Services.Configure( builder.Configuration.GetSection($"{AppSettings.SectionName}:RabbitMq")); @@ -22,4 +21,4 @@ builder.Services.Configure( builder.Services.AddHostedService(); var host = builder.Build(); -host.Run(); \ No newline at end of file +host.Run(); diff --git a/HrynCo.NotificationService.Worker/SendEmailConsumer.cs b/HrynCo.NotificationService.Worker/SendEmailConsumer.cs index 4e87250..ec3cbe1 100644 --- a/HrynCo.NotificationService.Worker/SendEmailConsumer.cs +++ b/HrynCo.NotificationService.Worker/SendEmailConsumer.cs @@ -1,174 +1,32 @@ namespace HrynCo.NotificationService.Worker; -using System.Text; -using System.Text.Json; 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.Services.EmailChannels.Send; using Hrynco.RabbitMq; -using MediatR; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using RabbitMQ.Client; +using HrynCo.NotificationService.Worker.Services.EmailProcessing; -public sealed class SendEmailConsumer( - IOptionsMonitor options, - IEmailChannelRepository channelRepository, - IEmailTemplateRepository templateRepository, - IEmailChannelUsageRepository usageRepository, - IMediator mediator, - AppSettings appSettings, - ILogger logger) - : RabbitMqConsumerBase(options, logger) +public sealed class SendEmailConsumer : RabbitMqConsumerBase { + private readonly IServiceScopeFactory _scopeFactory; + + public SendEmailConsumer( + IOptionsMonitor options, + IServiceScopeFactory scopeFactory, + ILogger logger) + : base(options, logger) + { + _scopeFactory = scopeFactory; + } + private const string IncomingQueue = "notification.send-email"; protected override string QueueName => IncomingQueue; protected override async Task HandleMessageAsync(SendEmailMessage message, CancellationToken cancellationToken) { - var data = message.Data; - - logger.LogInformation( - "Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]", - data.ServiceName, data.TemplateKey, data.RecipientEmail, message.CorrelationContext?.CorrelationId); - - 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 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 ResolveTemplateAsync( - string serviceName, string templateKey, string? languageCode, CancellationToken ct) - { - var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode; - var template = await templateRepository.GetAsync(serviceName, templateKey, lang, ct); - - if (template is null && lang != "en") - template = await templateRepository.GetAsync(serviceName, templateKey, "en", ct); - - return template - ?? throw new InvalidOperationException( - $"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'."); - } - - private async Task EnforceLimitsAsync(EmailChannel channel, CancellationToken ct) - { - var today = DateOnly.FromDateTime(DateTime.UtcNow); - - if (channel.DailyLimit.HasValue) - { - var daily = await usageRepository.GetDailyCountAsync(channel.Id, today, ct); - 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 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); - } + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessAsync(message, cancellationToken); } } diff --git a/HrynCo.NotificationService.Worker/appsettings.Development.json b/HrynCo.NotificationService.Worker/appsettings.Development.json index 3ef00ad..bf71488 100644 --- a/HrynCo.NotificationService.Worker/appsettings.Development.json +++ b/HrynCo.NotificationService.Worker/appsettings.Development.json @@ -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": { "MinimumLevel": { "Default": "Debug", @@ -7,6 +17,20 @@ "Microsoft.EntityFrameworkCore": "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" } } } \ No newline at end of file diff --git a/HrynCo.NotificationService.slnx b/HrynCo.NotificationService.slnx index 1db5b44..c124eb8 100644 --- a/HrynCo.NotificationService.slnx +++ b/HrynCo.NotificationService.slnx @@ -20,5 +20,6 @@ + diff --git a/README.md b/README.md index 6387551..fd4f3e7 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ # hrynco-notification-service + +## 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[Resolve SendEmailService] + E --> F[Pick channel and template] + F --> G[Render email content] + G --> H[Send via SMTP] + H --> I[Update usage counters] + I --> J[Optionally publish result to reply queue] + H -. failure .-> K[Log and rethrow] +```