food-market/src/food-market.infrastructure/Email/MailKitEmailSender.cs
nns 76e956ea6c feat(platform): IEmailSender + MailKit + PlatformSettingsController
Пункт 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).
2026-05-06 12:39:18 +05:00

89 lines
3.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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