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 @@
+
+