- EmailTemplates (invite/low-stock/weekly-summary): шапка с текстовым вордмарком «all market» (бренд-цвета, всегда рендерится — без внешних картинок и привязки к домену) + подпись «ALL MARKET — система учёта торговли». - Имя отправителя по умолчанию, темы/тела системных писем, экспорт-README, дневная сводка, тест SMTP → ALL MARKET. - Цвета писем и домены не менялись. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
124 lines
5.6 KiB
C#
124 lines
5.6 KiB
C#
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 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<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 ?? "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);
|
||
}
|
||
}
|
||
|
||
/// <summary>Грубый strip HTML→plain для multipart fallback'а.
|
||
/// Не для произвольного HTML, но для наших шаблонов достаточно.</summary>
|
||
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();
|
||
}
|
||
}
|