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).
This commit is contained in:
nns 2026-05-06 12:39:18 +05:00
parent 1456f170eb
commit 76e956ea6c
6 changed files with 277 additions and 0 deletions

View file

@ -27,6 +27,8 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<!-- App services --> <!-- App services -->
<PackageVersion Include="MailKit" Version="4.10.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
<PackageVersion Include="MediatR" Version="12.4.1" /> <PackageVersion Include="MediatR" Version="12.4.1" />
<PackageVersion Include="FluentValidation" Version="11.11.0" /> <PackageVersion Include="FluentValidation" Version="11.11.0" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" /> <PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />

View file

@ -0,0 +1,162 @@
using System.Security.Claims;
using foodmarket.Application.Common.Email;
using foodmarket.Domain.Organizations;
using foodmarket.Domain.Platform;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.SuperAdmin;
/// <summary>SuperAdmin: единая платформенная настройка SMTP. GET отдаёт всё
/// КРОМЕ пароля (только has-password флаг). PUT принимает все поля + опционально
/// новый пароль. Пароль шифруется через DataProtection (purpose="foodmarket.smtp")
/// перед записью в БД. Все мутации пишутся в SuperAdminAuditLog с reason.</summary>
[ApiController]
[Authorize(Roles = "SuperAdmin")]
[Route("api/super-admin/platform-settings")]
public class PlatformSettingsController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IDataProtectionProvider _dpProvider;
private readonly IEmailSender _email;
public PlatformSettingsController(AppDbContext db, IDataProtectionProvider dpProvider, IEmailSender email)
{
_db = db; _dpProvider = dpProvider; _email = email;
}
public record PlatformSettingsDto(
string? SmtpHost, int? SmtpPort,
bool SmtpUseSsl, bool SmtpStartTls,
string? SmtpUsername, bool HasSmtpPassword,
string? FromEmail, string? FromName,
DateTime? UpdatedAt);
public record PlatformSettingsInput(
string Reason,
string? SmtpHost, int? SmtpPort,
bool SmtpUseSsl, bool SmtpStartTls,
string? SmtpUsername,
// Если null/пусто — пароль не меняется. Если задан — шифруется и
// записывается. Чтобы СНЯТЬ пароль (отправлять без auth), используем
// спец-значение "__clear__".
string? NewSmtpPassword,
string? FromEmail, string? FromName);
public record TestSendInput(string ToEmail, string Subject, string Body);
[HttpGet]
public async Task<ActionResult<PlatformSettingsDto>> Get(CancellationToken ct)
{
var s = await GetOrCreateAsync(ct);
return new PlatformSettingsDto(
s.SmtpHost, s.SmtpPort,
s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
s.FromEmail, s.FromName,
s.UpdatedAt);
}
[HttpPut]
public async Task<IActionResult> Update([FromBody] PlatformSettingsInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.Reason) || input.Reason.Trim().Length < 10)
return BadRequest(new { error = "Причина изменения обязательна (≥ 10 символов) — она пишется в журнал." });
var s = await GetOrCreateAsync(ct);
var prev = new
{
s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, s.FromEmail, s.FromName,
HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
};
s.SmtpHost = input.SmtpHost?.Trim();
s.SmtpPort = input.SmtpPort;
s.SmtpUseSsl = input.SmtpUseSsl;
s.SmtpStartTls = input.SmtpStartTls;
s.SmtpUsername = input.SmtpUsername?.Trim();
s.FromEmail = input.FromEmail?.Trim();
s.FromName = input.FromName?.Trim();
s.UpdatedAt = DateTime.UtcNow;
if (input.NewSmtpPassword == "__clear__")
{
s.SmtpPasswordEncrypted = null;
}
else if (!string.IsNullOrEmpty(input.NewSmtpPassword))
{
var protector = _dpProvider.CreateProtector("foodmarket.smtp");
s.SmtpPasswordEncrypted = protector.Protect(input.NewSmtpPassword);
}
await _db.SaveChangesAsync(ct);
await LogAsync("PlatformSettingsUpdate",
$"Обновлены SMTP-настройки платформы (host={s.SmtpHost} from={s.FromEmail})",
input.Reason,
System.Text.Json.JsonSerializer.Serialize(new { prev, next = new {
s.SmtpHost, s.SmtpPort, s.SmtpUseSsl, s.SmtpStartTls,
s.SmtpUsername, s.FromEmail, s.FromName,
HasPassword = !string.IsNullOrEmpty(s.SmtpPasswordEncrypted),
} }), ct);
return NoContent();
}
[HttpPost("test-send")]
public async Task<IActionResult> TestSend([FromBody] TestSendInput input, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(input.ToEmail))
return BadRequest(new { error = "Адрес получателя обязателен." });
try
{
await _email.SendAsync(input.ToEmail.Trim(),
string.IsNullOrWhiteSpace(input.Subject) ? "Food Market — тестовое сообщение" : input.Subject,
string.IsNullOrWhiteSpace(input.Body) ? "Тестовое письмо от Food Market." : input.Body,
ct);
return Ok(new { ok = true, sentTo = input.ToEmail });
}
catch (EmailNotConfiguredException ex)
{
return BadRequest(new { ok = false, error = ex.Message });
}
catch (Exception ex)
{
// Полный текст ошибки SMTP пишем в response — это SuperAdmin-only,
// diagnostic-info ему нужна.
return StatusCode(StatusCodes.Status500InternalServerError, new { ok = false, error = ex.Message });
}
}
private async Task<PlatformSettings> GetOrCreateAsync(CancellationToken ct)
{
var s = await _db.PlatformSettings.FirstOrDefaultAsync(ct);
if (s is null)
{
s = new PlatformSettings();
_db.PlatformSettings.Add(s);
await _db.SaveChangesAsync(ct);
}
return s;
}
private async Task LogAsync(string actionType, string description, string? reason, string changesJson, CancellationToken ct)
{
var userIdRaw = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
Guid.TryParse(userIdRaw, out var uid);
_db.SuperAdminAuditLogs.Add(new SuperAdminAuditLog
{
SuperAdminUserId = uid,
ActionType = actionType,
OrganizationId = null,
Description = description, Reason = reason,
ChangesJson = changesJson,
IpAddress = HttpContext?.Connection?.RemoteIpAddress?.ToString() ?? "",
});
await _db.SaveChangesAsync(ct);
}
}

View file

@ -128,6 +128,13 @@
}); });
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>(); builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Email-отправка через MailKit. Singleton — внутри открывает scope для
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
// на каждой отправке без рестарта приложения.
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
foodmarket.Infrastructure.Email.MailKitEmailSender>();
builder.Services.AddDataProtection();
builder.Services.AddControllers(o => builder.Services.AddControllers(o =>
{ {
// Глобальный action filter — пишет audit-log при успешных мутациях // Глобальный action filter — пишет audit-log при успешных мутациях

View file

@ -0,0 +1,16 @@
namespace foodmarket.Application.Common.Email;
/// <summary>Отправка одного письма через текущие платформенные SMTP-настройки.
/// Конфиг читается из БД (PlatformSettings) на каждой отправке — без рестарта
/// сервиса можно поменять SMTP-сервер. Реализация — MailKit.</summary>
public interface IEmailSender
{
Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default);
}
/// <summary>Бросается когда платформенный SMTP не настроен. Контроллеры должны
/// ловить и возвращать понятный 503/400 — не падать в 500.</summary>
public class EmailNotConfiguredException : Exception
{
public EmailNotConfiguredException(string message) : base(message) { }
}

View file

@ -0,0 +1,88 @@
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);
}
}
}

View file

@ -18,5 +18,7 @@
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" /> <PackageReference Include="OpenIddict.EntityFrameworkCore" />
<PackageReference Include="Hangfire.PostgreSql" /> <PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="MailKit" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
</ItemGroup> </ItemGroup>
</Project> </Project>