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:
parent
1456f170eb
commit
76e956ea6c
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 при успешных мутациях
|
||||||
|
|
|
||||||
16
src/food-market.application/Common/Email/IEmailSender.cs
Normal file
16
src/food-market.application/Common/Email/IEmailSender.cs
Normal 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) { }
|
||||||
|
}
|
||||||
88
src/food-market.infrastructure/Email/MailKitEmailSender.cs
Normal file
88
src/food-market.infrastructure/Email/MailKitEmailSender.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue