food-market/src/food-market.infrastructure/Email/MailKitEmailSender.cs
nns 03bdf4d4f1 rebrand: письма и серверные строки ALL MARKET
- EmailTemplates (invite/low-stock/weekly-summary): шапка с текстовым
  вордмарком «all market» (бренд-цвета, всегда рендерится — без внешних
  картинок и привязки к домену) + подпись «ALL MARKET — система учёта торговли».
- Имя отправителя по умолчанию, темы/тела системных писем, экспорт-README,
  дневная сводка, тест SMTP → ALL MARKET.
- Цвета писем и домены не менялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:34:38 +05:00

124 lines
5.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("&nbsp;", " ").Replace("&amp;", "&")
.Replace("&lt;", "<").Replace("&gt;", ">").Trim();
}
}