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 Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default)
=> SendInternalAsync(toEmail, subject, htmlBody: null, textBody: body, ct);
public Task SendHtmlAsync(string toEmail, string subject, string htmlBody, string? textBody = null,
CancellationToken ct = default)
=> SendInternalAsync(toEmail, subject, htmlBody: htmlBody, textBody: textBody, ct);
private async Task SendInternalAsync(string toEmail, string subject,
string? htmlBody, string? textBody, CancellationToken ct)
{
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 ?? "ALL MARKET", s.FromEmail));
msg.To.Add(MailboxAddress.Parse(toEmail));
msg.Subject = subject;
if (htmlBody is not null)
{
// multipart/alternative: text fallback для клиентов без HTML
// (редкость, но включаем — не повредит).
var builder = new BodyBuilder
{
HtmlBody = htmlBody,
TextBody = textBody ?? StripHtml(htmlBody),
};
msg.Body = builder.ToMessageBody();
}
else
{
msg.Body = new TextPart("plain") { Text = textBody ?? "" };
}
// 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);
}
}
/// Грубый strip HTML→plain для multipart fallback'а.
/// Не для произвольного HTML, но для наших шаблонов достаточно.
private static string StripHtml(string html)
{
var s = System.Text.RegularExpressions.Regex.Replace(html, @"<\s*br\s*/?\s*>", "\n",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
s = System.Text.RegularExpressions.Regex.Replace(s, @"\s*(p|h\d|div|li|tr)\s*>", "\n",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
s = System.Text.RegularExpressions.Regex.Replace(s, @"<[^>]+>", "");
return s.Replace(" ", " ").Replace("&", "&")
.Replace("<", "<").Replace(">", ">").Trim();
}
}