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" />
|
||||
|
||||
<!-- 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="FluentValidation" 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>();
|
||||
|
||||
// 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 =>
|
||||
{
|
||||
// Глобальный 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="OpenIddict.EntityFrameworkCore" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Reference in a new issue