diff --git a/Directory.Packages.props b/Directory.Packages.props index d74d81f..27d447b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,6 +27,8 @@ + + diff --git a/src/food-market.api/Controllers/SuperAdmin/PlatformSettingsController.cs b/src/food-market.api/Controllers/SuperAdmin/PlatformSettingsController.cs new file mode 100644 index 0000000..a176afa --- /dev/null +++ b/src/food-market.api/Controllers/SuperAdmin/PlatformSettingsController.cs @@ -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; + +/// SuperAdmin: единая платформенная настройка SMTP. GET отдаёт всё +/// КРОМЕ пароля (только has-password флаг). PUT принимает все поля + опционально +/// новый пароль. Пароль шифруется через DataProtection (purpose="foodmarket.smtp") +/// перед записью в БД. Все мутации пишутся в SuperAdminAuditLog с reason. +[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> 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 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 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 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); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index dbb3d2e..7b94dd9 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -128,6 +128,13 @@ }); builder.Services.AddScoped(); + + // Email-отправка через MailKit. Singleton — внутри открывает scope для + // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается + // на каждой отправке без рестарта приложения. + builder.Services.AddSingleton(); + builder.Services.AddDataProtection(); builder.Services.AddControllers(o => { // Глобальный action filter — пишет audit-log при успешных мутациях diff --git a/src/food-market.application/Common/Email/IEmailSender.cs b/src/food-market.application/Common/Email/IEmailSender.cs new file mode 100644 index 0000000..13f9e46 --- /dev/null +++ b/src/food-market.application/Common/Email/IEmailSender.cs @@ -0,0 +1,16 @@ +namespace foodmarket.Application.Common.Email; + +/// Отправка одного письма через текущие платформенные SMTP-настройки. +/// Конфиг читается из БД (PlatformSettings) на каждой отправке — без рестарта +/// сервиса можно поменять SMTP-сервер. Реализация — MailKit. +public interface IEmailSender +{ + Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default); +} + +/// Бросается когда платформенный SMTP не настроен. Контроллеры должны +/// ловить и возвращать понятный 503/400 — не падать в 500. +public class EmailNotConfiguredException : Exception +{ + public EmailNotConfiguredException(string message) : base(message) { } +} diff --git a/src/food-market.infrastructure/Email/MailKitEmailSender.cs b/src/food-market.infrastructure/Email/MailKitEmailSender.cs new file mode 100644 index 0000000..d995151 --- /dev/null +++ b/src/food-market.infrastructure/Email/MailKitEmailSender.cs @@ -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; + +/// SMTP-отправка через MailKit. Зарегистрирован Singleton, но на +/// каждую отправку создаёт scope для свежего DbContext'а — конфиг +/// (PlatformSettings) перечитывается на каждой отправке без рестарта. +/// +/// Если SMTP не настроен (host пуст / from-email пуст) — кидает +/// EmailNotConfiguredException. Контроллер ловит и возвращает 400/500. +public class MailKitEmailSender : IEmailSender +{ + private readonly IServiceScopeFactory _scopes; + private readonly IDataProtectionProvider _dpProvider; + private readonly ILogger _logger; + + public MailKitEmailSender(IServiceScopeFactory scopes, IDataProtectionProvider dpProvider, ILogger 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(); + 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); + } + } +} diff --git a/src/food-market.infrastructure/food-market.infrastructure.csproj b/src/food-market.infrastructure/food-market.infrastructure.csproj index 4988cc3..1cf23a0 100644 --- a/src/food-market.infrastructure/food-market.infrastructure.csproj +++ b/src/food-market.infrastructure/food-market.infrastructure.csproj @@ -18,5 +18,7 @@ + +