feat: добавил отправку уведомлений по почте
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 10s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 1m17s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 12s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 6s

This commit is contained in:
2026-05-11 05:09:00 +03:00
parent 44234cc42d
commit a0a0575a99
22 changed files with 464 additions and 16 deletions
@@ -0,0 +1,12 @@
namespace UniVerse.Infrastructure.Notifications;
public class EmailNotificationOptions
{
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 587;
public bool EnableSsl { get; set; } = true;
public string? UserName { get; set; }
public string? Password { get; set; }
public string FromAddress { get; set; } = string.Empty;
public string? FromName { get; set; }
}
@@ -0,0 +1,57 @@
using System.Net;
using System.Net.Mail;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
namespace UniVerse.Infrastructure.Notifications;
public class EmailNotificationProvider : INotificationProvider
{
private readonly EmailNotificationOptions _options;
private readonly ILogger<EmailNotificationProvider> _logger;
public EmailNotificationProvider(IOptions<EmailNotificationOptions> options, ILogger<EmailNotificationProvider> logger)
{
_options = options.Value;
_logger = logger;
}
public string Channel => NotificationChannels.Email;
public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default)
{
ValidateOptions();
using var mailMessage = new MailMessage
{
From = new MailAddress(_options.FromAddress, _options.FromName),
Subject = message.Subject,
Body = message.Body,
IsBodyHtml = false
};
mailMessage.To.Add(new MailAddress(message.Recipient, message.RecipientName));
using var client = new SmtpClient(_options.Host, _options.Port)
{
EnableSsl = _options.EnableSsl
};
if (!string.IsNullOrWhiteSpace(_options.UserName))
{
client.Credentials = new NetworkCredential(_options.UserName, _options.Password);
}
_logger.LogInformation("Sending email notification to {Recipient}", message.Recipient);
await client.SendMailAsync(mailMessage, cancellationToken);
}
private void ValidateOptions()
{
if (string.IsNullOrWhiteSpace(_options.Host))
throw new InvalidOperationException("Email:Smtp:Host is not configured.");
if (string.IsNullOrWhiteSpace(_options.FromAddress))
throw new InvalidOperationException("Email:Smtp:FromAddress is not configured.");
}
}
@@ -0,0 +1,37 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Quartz;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
namespace UniVerse.Infrastructure.Notifications;
[DisallowConcurrentExecution]
public class NotificationJob : IJob
{
public const string MessageDataKey = "message";
private readonly INotificationService _notifications;
private readonly ILogger<NotificationJob> _logger;
public NotificationJob(INotificationService notifications, ILogger<NotificationJob> logger)
{
_notifications = notifications;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
var payload = context.MergedJobDataMap.GetString(MessageDataKey);
if (string.IsNullOrWhiteSpace(payload))
{
_logger.LogWarning("Notification job {JobKey} does not contain message payload", context.JobDetail.Key);
return;
}
var message = JsonSerializer.Deserialize<NotificationMessage>(payload)
?? throw new InvalidOperationException("Scheduled notification payload cannot be deserialized.");
await _notifications.SendAsync(message, context.CancellationToken);
}
}
@@ -0,0 +1,49 @@
using Microsoft.Extensions.Logging;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
namespace UniVerse.Infrastructure.Notifications;
public class NotificationService : INotificationService
{
private readonly IEnumerable<INotificationProvider> _providers;
private readonly INotificationScheduler _scheduler;
private readonly ILogger<NotificationService> _logger;
public NotificationService(
IEnumerable<INotificationProvider> providers,
INotificationScheduler scheduler,
ILogger<NotificationService> logger)
{
_providers = providers;
_scheduler = scheduler;
_logger = logger;
}
public async Task SendAsync(NotificationMessage message, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(message.Channel);
ArgumentException.ThrowIfNullOrWhiteSpace(message.Recipient);
ArgumentException.ThrowIfNullOrWhiteSpace(message.Subject);
ArgumentException.ThrowIfNullOrWhiteSpace(message.Body);
var provider = _providers.FirstOrDefault(p => string.Equals(p.Channel, message.Channel, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException($"Notification provider for channel '{message.Channel}' is not registered.");
_logger.LogInformation("Dispatching notification through {Channel} to {Recipient}", message.Channel, message.Recipient);
await provider.SendAsync(message, cancellationToken);
}
public Task<ScheduledNotificationResponse> ScheduleAsync(ScheduleNotificationRequest request, CancellationToken cancellationToken = default)
{
var message = new NotificationMessage(
request.Channel,
request.Recipient,
request.Subject,
request.Body,
request.RecipientName,
request.Metadata);
return _scheduler.ScheduleAsync(message, request.SendAt, cancellationToken);
}
}
@@ -0,0 +1,48 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Quartz;
using UniVerse.Application.DTOs.Notifications;
using UniVerse.Application.Interfaces;
namespace UniVerse.Infrastructure.Notifications;
public class QuartzNotificationScheduler : INotificationScheduler
{
private const string NotificationGroup = "notifications";
private readonly ISchedulerFactory _schedulerFactory;
private readonly ILogger<QuartzNotificationScheduler> _logger;
public QuartzNotificationScheduler(ISchedulerFactory schedulerFactory, ILogger<QuartzNotificationScheduler> logger)
{
_schedulerFactory = schedulerFactory;
_logger = logger;
}
public async Task<ScheduledNotificationResponse> ScheduleAsync(NotificationMessage message, DateTimeOffset sendAt, CancellationToken cancellationToken = default)
{
if (sendAt <= DateTimeOffset.UtcNow)
throw new ArgumentException("Scheduled notification time must be in the future.", nameof(sendAt));
var scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
var jobId = Guid.NewGuid().ToString("N");
var jobKey = new JobKey(jobId, NotificationGroup);
var payload = JsonSerializer.Serialize(message);
var job = JobBuilder.Create<NotificationJob>()
.WithIdentity(jobKey)
.UsingJobData(NotificationJob.MessageDataKey, payload)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"{jobId}.trigger", NotificationGroup)
.ForJob(job)
.StartAt(sendAt)
.Build();
await scheduler.ScheduleJob(job, trigger, cancellationToken);
_logger.LogInformation("Scheduled notification job {JobId} for {SendAt}", jobId, sendAt);
return new ScheduledNotificationResponse(jobId, sendAt);
}
}