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); } } }