feat: consume transactional email notifications

Add contract validation, SMTP delivery results, terminal failure context, neutral development seeding, and local Docker setup.

Ref: IT-1033
This commit is contained in:
2026-08-04 12:32:28 +03:00
parent b8435ac07b
commit 2757869176
37 changed files with 1465 additions and 122 deletions
@@ -1,13 +1,9 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net;
using System.Net.Mail;
using System.Text;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging;
internal sealed class SendEmailService : ISendEmailService
@@ -16,7 +12,8 @@ internal sealed class SendEmailService : ISendEmailService
private readonly IEmailChannelUsageRepository _usageRepository;
private readonly IEmailTemplateService _templateService;
private readonly IEmailTemplateRenderingService _templateRenderingService;
private readonly IRabbitMqPublisher _publisher;
private readonly ISmtpEmailSender _smtpEmailSender;
private readonly INotificationResultPublisher _resultPublisher;
private readonly ILogger<SendEmailService> _logger;
public SendEmailService(
@@ -24,24 +21,28 @@ internal sealed class SendEmailService : ISendEmailService
IEmailChannelUsageRepository usageRepository,
IEmailTemplateService templateService,
IEmailTemplateRenderingService templateRenderingService,
IRabbitMqPublisher publisher,
ISmtpEmailSender smtpEmailSender,
INotificationResultPublisher resultPublisher,
ILogger<SendEmailService> logger)
{
_channelRepository = channelRepository;
_usageRepository = usageRepository;
_templateService = templateService;
_templateRenderingService = templateRenderingService;
_publisher = publisher;
_smtpEmailSender = smtpEmailSender;
_resultPublisher = resultPublisher;
_logger = logger;
}
public async Task ProcessAsync(SendEmailMessage message, CancellationToken cancellationToken)
{
SendEmailMessageValidator.Validate(message);
SendEmailMessageData data = message.Data;
string redactedRecipient = RecipientAddressRedactor.Redact(data.RecipientEmail);
_logger.LogInformation(
"Processing SendEmail for service={Service} template={Template} recipient={Recipient} [CorrelationId={CorrelationId}]",
data.ServiceName, data.TemplateKey, data.RecipientEmail, message.CorrelationContext?.CorrelationId);
data.ServiceName, data.TemplateKey, redactedRecipient, message.CorrelationContext?.CorrelationId);
EmailChannel channel = await ResolveChannelAsync(data.ServiceName, cancellationToken);
EmailTemplate template = await GetTemplateAsync(data, cancellationToken);
@@ -56,34 +57,12 @@ internal sealed class SendEmailService : ISendEmailService
try
{
using var client = new SmtpClient(smtpChannel.Host, smtpChannel.Port)
{
EnableSsl = smtpChannel.UseSsl,
Credentials = string.IsNullOrWhiteSpace(smtpChannel.Username)
? null
: new NetworkCredential(smtpChannel.Username, smtpChannel.Password)
};
using var mail = new MailMessage
{
From = new MailAddress(smtpChannel.FromEmail, smtpChannel.FromName),
Subject = rendered.Subject,
Body = rendered.TextBody,
IsBodyHtml = false,
BodyEncoding = Encoding.UTF8,
SubjectEncoding = Encoding.UTF8
};
if (!string.IsNullOrWhiteSpace(rendered.HtmlBody))
{
var html = AlternateView.CreateAlternateViewFromString(
rendered.HtmlBody, Encoding.UTF8, "text/html");
mail.AlternateViews.Add(html);
}
mail.To.Add(new MailAddress(data.RecipientEmail, data.RecipientName));
await client.SendMailAsync(mail, cancellationToken);
await _smtpEmailSender.SendAsync(
smtpChannel,
rendered,
data.RecipientEmail,
data.RecipientName,
cancellationToken);
}
catch (Exception ex)
{
@@ -98,9 +77,9 @@ internal sealed class SendEmailService : ISendEmailService
_logger.LogInformation(
"Email sent successfully service={Service} template={Template} recipient={Recipient}",
data.ServiceName, data.TemplateKey, data.RecipientEmail);
data.ServiceName, data.TemplateKey, redactedRecipient);
await PublishResultAsync(message.CorrelationContext, data, null, cancellationToken);
await _resultPublisher.PublishAsync(message, null, cancellationToken);
}
private async Task<EmailTemplate> GetTemplateAsync(SendEmailMessageData data, CancellationToken cancellationToken)
@@ -151,48 +130,4 @@ internal sealed class SendEmailService : ISendEmailService
}
}
private async Task PublishResultAsync(
CorrelationContext? correlationContext,
SendEmailMessageData data,
string? errorMessage,
CancellationToken ct)
{
string? replyTo = correlationContext?.ReplyTo;
if (string.IsNullOrWhiteSpace(replyTo))
{
return;
}
try
{
var result = new NotificationResultMessage
{
CorrelationContext = (correlationContext ?? new CorrelationContext
{
CorrelationId = Guid.NewGuid().ToString()
}) with
{
ReplyTo = null
},
Data = new NotificationResultData
{
ServiceName = data.ServiceName,
RecipientEmail = data.RecipientEmail,
TemplateKey = data.TemplateKey,
Timestamp = DateTimeOffset.UtcNow,
ErrorMessage = errorMessage
}
};
await _publisher.PublishAsync(replyTo, result, ct);
_logger.LogDebug("Result published to reply queue '{Queue}' [CorrelationId={CorrelationId}]",
replyTo, correlationContext?.CorrelationId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish notification result to reply queue '{Queue}'", replyTo);
}
}
}