Пункт 2 + 3 пакета SMTP-настроек.
Backend:
- IEmailSender (Application/Common/Email) — общий интерфейс отправки
одного письма; EmailNotConfiguredException — для контроллеров чтобы
ловить и отдавать понятный 400 вместо 500.
- MailKitEmailSender (Infrastructure/Email) — реализация:
· регистрируется Singleton, на каждой отправке открывает scope для
свежего AppDbContext (конфиг перечитывается без рестарта);
· читает PlatformSettings из БД, расшифровывает пароль через
IDataProtector("foodmarket.smtp");
· поддержка SmtpUseSsl (implicit TLS / 465) и SmtpStartTls (587);
оба false → открытое соединение (для dev/MailHog);
· бросает EmailNotConfiguredException если host или from-email пусты,
или если расшифровка пароля падает (ключ DataProtection ротировался).
API:
- PlatformSettingsController:
GET /api/super-admin/platform-settings — все поля КРОМЕ пароля
(только has-password флаг + updatedAt).
PUT — принимает Reason (≥10) + все поля + опциональный
NewSmtpPassword. Спец-значение "__clear__" снимает пароль.
Пароль шифруется через DataProtection при записи. Audit-log.
POST /test-send — реальная отправка через текущие настройки;
ловит EmailNotConfiguredException → 400, остальные → 500
с message (SuperAdmin-only, diagnostic-info нужна).
DI:
- AddSingleton<IEmailSender, MailKitEmailSender>;
- AddDataProtection (default file-system key store ASP.NET Core).
Пакеты:
- MailKit 4.10.0 (4.8 имел moderate-severity advisory).
- Microsoft.AspNetCore.DataProtection 8.0.11 (transitive в API уже
был через OpenIddict, но Infrastructure нужен явный reference).
89 lines
3.9 KiB
C#
89 lines
3.9 KiB
C#
using foodmarket.Application.Common.Email;
|
||
using foodmarket.Infrastructure.Persistence;
|
||
using MailKit.Net.Smtp;
|
||
using MailKit.Security;
|
||
using Microsoft.AspNetCore.DataProtection;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Logging;
|
||
using MimeKit;
|
||
|
||
namespace foodmarket.Infrastructure.Email;
|
||
|
||
/// <summary>SMTP-отправка через MailKit. Зарегистрирован Singleton, но на
|
||
/// каждую отправку создаёт scope для свежего DbContext'а — конфиг
|
||
/// (PlatformSettings) перечитывается на каждой отправке без рестарта.
|
||
///
|
||
/// Если SMTP не настроен (host пуст / from-email пуст) — кидает
|
||
/// EmailNotConfiguredException. Контроллер ловит и возвращает 400/500.</summary>
|
||
public class MailKitEmailSender : IEmailSender
|
||
{
|
||
private readonly IServiceScopeFactory _scopes;
|
||
private readonly IDataProtectionProvider _dpProvider;
|
||
private readonly ILogger<MailKitEmailSender> _logger;
|
||
|
||
public MailKitEmailSender(IServiceScopeFactory scopes, IDataProtectionProvider dpProvider, ILogger<MailKitEmailSender> logger)
|
||
{
|
||
_scopes = scopes; _dpProvider = dpProvider; _logger = logger;
|
||
}
|
||
|
||
public async Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default)
|
||
{
|
||
using var scope = _scopes.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||
var s = await db.PlatformSettings.AsNoTracking().FirstOrDefaultAsync(ct);
|
||
|
||
if (s is null || string.IsNullOrWhiteSpace(s.SmtpHost) || string.IsNullOrWhiteSpace(s.FromEmail))
|
||
{
|
||
throw new EmailNotConfiguredException(
|
||
"SMTP не настроен. Откройте «Системная консоль → Настройки платформы» и заполните SmtpHost, FromEmail, креды.");
|
||
}
|
||
|
||
var msg = new MimeMessage();
|
||
msg.From.Add(new MailboxAddress(s.FromName ?? "Food Market", s.FromEmail));
|
||
msg.To.Add(MailboxAddress.Parse(toEmail));
|
||
msg.Subject = subject;
|
||
msg.Body = new TextPart("plain") { Text = body };
|
||
|
||
// Implicit TLS (SmtpUseSsl) — обычно 465. STARTTLS (SmtpStartTls) — 587.
|
||
// Если оба false — открытое соединение (SmtpClient.Connect c None).
|
||
var secureOption = s.SmtpUseSsl
|
||
? SecureSocketOptions.SslOnConnect
|
||
: (s.SmtpStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.None);
|
||
var port = s.SmtpPort ?? (s.SmtpUseSsl ? 465 : 587);
|
||
|
||
using var client = new SmtpClient();
|
||
await client.ConnectAsync(s.SmtpHost, port, secureOption, ct);
|
||
|
||
if (!string.IsNullOrEmpty(s.SmtpUsername))
|
||
{
|
||
var password = string.Empty;
|
||
if (!string.IsNullOrEmpty(s.SmtpPasswordEncrypted))
|
||
{
|
||
try
|
||
{
|
||
var protector = _dpProvider.CreateProtector("foodmarket.smtp");
|
||
password = protector.Unprotect(s.SmtpPasswordEncrypted);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
throw new EmailNotConfiguredException(
|
||
"Не удалось расшифровать SMTP-пароль (DataProtection ключ изменился?). " +
|
||
"Введите пароль заново в настройках платформы. Detail: " + ex.Message);
|
||
}
|
||
}
|
||
await client.AuthenticateAsync(s.SmtpUsername, password, ct);
|
||
}
|
||
|
||
try
|
||
{
|
||
await client.SendAsync(msg, ct);
|
||
_logger.LogInformation("Email sent to {To} subject={Subject}", toEmail, subject);
|
||
}
|
||
finally
|
||
{
|
||
await client.DisconnectAsync(true, ct);
|
||
}
|
||
}
|
||
}
|