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
@@ -8,6 +8,18 @@ internal sealed class EmailTemplateRenderingService : IEmailTemplateRenderingSer
{
public RenderedEmail Render(EmailTemplate template, SendEmailMessageData data)
{
string[] missingVariables = template.Variables
.Where(variable => variable.Required)
.Select(variable => variable.Name)
.Where(name => !data.Variables.TryGetValue(name, out string? value) || string.IsNullOrWhiteSpace(value))
.ToArray();
if (missingVariables.Length > 0)
{
throw new InvalidDataException(
$"Required template variables are missing: {string.Join(", ", missingVariables)}.");
}
return new RenderedEmail(
Interpolate(template.Subject, data.Variables),
Interpolate(template.HtmlBody, data.Variables),
@@ -18,11 +18,15 @@ internal sealed class EmailTemplateService : IEmailTemplateService
string? languageCode,
CancellationToken cancellationToken)
{
var lang = string.IsNullOrWhiteSpace(languageCode) ? "en" : languageCode;
var template = await _templateRepository.GetAsync(serviceName, templateKey, lang, cancellationToken);
if (string.IsNullOrWhiteSpace(languageCode))
throw new InvalidDataException("LanguageCode is required.");
if (template is null && lang != "en")
template = await _templateRepository.GetAsync(serviceName, templateKey, "en", cancellationToken);
string lang = languageCode.Trim().ToLowerInvariant();
EmailTemplate? template = await _templateRepository.GetAsync(
serviceName,
templateKey,
lang,
cancellationToken);
return template
?? throw new InvalidOperationException(
@@ -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,13 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using HrynCo.NotificationService.DAL.Abstract.Providers;
internal interface ISmtpEmailSender
{
Task SendAsync(
SmtpChannelSettings settings,
RenderedEmail email,
string recipientEmail,
string recipientName,
CancellationToken cancellationToken);
}
@@ -0,0 +1,80 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using System.Net.Mail;
using System.Net.Sockets;
public static class NotificationDeliveryErrorFormatter
{
private const int MaximumErrorLength = 2000;
private const string FallbackError = "Notification delivery failed after all retry attempts.";
public static string Format(Exception exception)
{
string technicalMessage = Normalize(exception.GetBaseException().Message);
if (string.IsNullOrWhiteSpace(technicalMessage))
{
return FallbackError;
}
string contextualMessage = CreateContextualMessage(exception, technicalMessage);
return contextualMessage.Length <= MaximumErrorLength
? contextualMessage
: contextualMessage[..MaximumErrorLength];
}
private static string CreateContextualMessage(Exception exception, string technicalMessage)
{
if (FindException<SmtpFailedRecipientException>(exception) is not null)
{
return "SMTP delivery failed after all retry attempts: " +
"the SMTP server rejected the recipient address.";
}
SocketException? socketException = FindException<SocketException>(exception);
if (socketException is not null)
{
string reason = socketException.SocketErrorCode switch
{
SocketError.HostNotFound or SocketError.NoData =>
"the configured SMTP server host could not be resolved",
SocketError.ConnectionRefused =>
"the configured SMTP server refused the connection",
SocketError.TimedOut =>
"the connection to the configured SMTP server timed out",
_ when technicalMessage.Contains(
"Name or service not known",
StringComparison.OrdinalIgnoreCase) =>
"the configured SMTP server host could not be resolved",
_ => "the configured SMTP server could not be reached"
};
return $"SMTP delivery failed after all retry attempts: {reason} ({technicalMessage}).";
}
if (FindException<SmtpException>(exception) is not null)
{
return $"SMTP delivery failed after all retry attempts ({technicalMessage}).";
}
return $"Notification delivery failed after all retry attempts ({technicalMessage}).";
}
private static TException? FindException<TException>(Exception exception)
where TException : Exception
{
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is TException typedException)
{
return typedException;
}
}
return null;
}
private static string Normalize(string message)
{
return message.ReplaceLineEndings(" ").Trim();
}
}
@@ -0,0 +1,63 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
using HrynCo.NotificationService.Contracts.Messages;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Logging;
internal sealed class NotificationResultPublisher : INotificationResultPublisher
{
private readonly ILogger<NotificationResultPublisher> _logger;
private readonly IRabbitMqPublisher _publisher;
public NotificationResultPublisher(
IRabbitMqPublisher publisher,
ILogger<NotificationResultPublisher> logger)
{
_publisher = publisher;
_logger = logger;
}
public async Task PublishAsync(
SendEmailMessage message,
string? deliveryError,
CancellationToken cancellationToken)
{
CorrelationContext correlationContext = message.CorrelationContext;
string? replyTo = correlationContext.ReplyTo;
if (string.IsNullOrWhiteSpace(replyTo))
{
return;
}
try
{
var result = new NotificationResultMessage
{
CorrelationContext = correlationContext with { ReplyTo = null },
Data = new NotificationResultData
{
ServiceName = message.Data.ServiceName,
RecipientEmail = message.Data.RecipientEmail,
TemplateKey = message.Data.TemplateKey,
Timestamp = DateTimeOffset.UtcNow,
ErrorMessage = deliveryError
}
};
await _publisher.PublishAsync(replyTo, result, cancellationToken);
_logger.LogDebug(
"Notification result published to reply queue {Queue} [CorrelationId={CorrelationId}]",
replyTo,
correlationContext.CorrelationId);
}
catch (Exception exception)
{
_logger.LogWarning(
exception,
"Failed to publish notification result to reply queue {Queue} [CorrelationId={CorrelationId}]",
replyTo,
correlationContext.CorrelationId);
}
}
}
@@ -0,0 +1,22 @@
namespace HrynCo.NotificationService.Worker.Services.EmailProcessing;
public static class RecipientAddressRedactor
{
public static string Redact(string? address)
{
if (string.IsNullOrWhiteSpace(address))
return "<missing>";
int separator = address.LastIndexOf('@');
if (separator <= 0 || separator == address.Length - 1)
return "***";
string local = address[..separator];
string domain = address[(separator + 1)..];
int dot = domain.LastIndexOf('.');
string domainName = dot > 0 ? domain[..dot] : domain;
string suffix = dot > 0 ? domain[dot..] : string.Empty;
return $"{local[0]}***@{domainName[0]}***{suffix}";
}
}
@@ -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.");
}
}
@@ -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);
}
}
}
@@ -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);
}
}