From 25d92c989a41b2edf11a023ea6295bf9c5b9b3f9 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 16:37:32 +0500 Subject: [PATCH] =?UTF-8?q?feat(email):=20HTML-=D1=88=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BD=D1=8B=20MailKit=20+=20invite/weekly/low-stock=20?= =?UTF-8?q?=D0=B4=D0=B6=D0=BE=D0=B1=D1=8B=20(P1-22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application: - IEmailSender.SendHtmlAsync(html, textFallback) - multipart/alternative с plain-text для клиентов без HTML; - EmailTemplateRenderer - минимальный mustache-light: {{key}} (HTML-escape), {{{raw}}} (без escape), {{#key}}...{{/key}} (условный блок, truthy если не-null/не-пусто/не-"0"/не-"false"); - EmailTemplates - загрузчик embedded HTML-шаблонов из Resources/EmailTemplates/*.html, кеш after-first-read, parse "Subject: <тема>" из первой строки, плюс plain-text strip для fallback. Шаблоны (embedded в food-market.api.dll): - invite.html - приглашение сотрудника с временным паролем и кнопкой «Открыть Food Market». - weekly-summary.html - выручка/чеки/средний чек за неделю + топ-товары. - low-stock.html - таблица товаров с stock < MinStock. EmployeesController.Create принимает SendInvite (требует CreateAccount): формирует payload из orgName/loginUrl/role и шлёт через SendHtmlAsync. SMTP-ошибки логируются warning'ом, не блокируют создание (showOnce tempPassword фронту всё равно отдаётся). Hangfire recurring jobs (EmailNotificationJobs): - weekly-summary: cron "0 7 * * 1" (понедельник 07:00 UTC) - по каждой активной орге считает revenue/tx/avgTicket/top-5, шлёт Admin'ам; - low-stock-alert: cron "0 8 * * *" - товары с sum(stock) --- .../Background/EmailNotificationJobs.cs | 176 ++++++++++++++++++ .../Background/HangfireJobsConfigurator.cs | 20 ++ .../Organizations/EmployeesController.cs | 47 ++++- .../Infrastructure/Email/EmailTemplates.cs | 75 ++++++++ src/food-market.api/Program.cs | 4 + .../Resources/EmailTemplates/invite.html | 24 +++ .../Resources/EmailTemplates/low-stock.html | 19 ++ .../EmailTemplates/weekly-summary.html | 36 ++++ src/food-market.api/food-market.api.csproj | 5 + .../Common/Email/EmailTemplateRenderer.cs | 61 ++++++ .../Common/Email/IEmailSender.cs | 7 + .../Email/MailKitEmailSender.cs | 39 +++- .../EmailTemplateRendererTests.cs | 108 +++++++++++ 13 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 src/food-market.api/Background/EmailNotificationJobs.cs create mode 100644 src/food-market.api/Infrastructure/Email/EmailTemplates.cs create mode 100644 src/food-market.api/Resources/EmailTemplates/invite.html create mode 100644 src/food-market.api/Resources/EmailTemplates/low-stock.html create mode 100644 src/food-market.api/Resources/EmailTemplates/weekly-summary.html create mode 100644 src/food-market.application/Common/Email/EmailTemplateRenderer.cs create mode 100644 tests/food-market.UnitTests/EmailTemplateRendererTests.cs diff --git a/src/food-market.api/Background/EmailNotificationJobs.cs b/src/food-market.api/Background/EmailNotificationJobs.cs new file mode 100644 index 0000000..b93d109 --- /dev/null +++ b/src/food-market.api/Background/EmailNotificationJobs.cs @@ -0,0 +1,176 @@ +using foodmarket.Api.Infrastructure.Email; +using foodmarket.Api.Infrastructure.Tenancy; +using foodmarket.Application.Common.Email; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Identity; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Background; + +/// Hangfire-джобы рассылки писем: weekly-summary (понедельник 07:00) +/// и low-stock (ежедневно 08:00). Бегут per-org через IgnoreQueryFilters + +/// AsyncLocal-tenant override для каждой обрабатываемой организации +/// (см. HttpContextTenantContext.UseOverride). +/// +/// Каждая ошибка отправки логируется, но не валит весь джоб — продолжаем по +/// следующим оргам. Идемпотентность по неделе/дню — не реализована: повтор +/// джоба тем же утром обогатит почту админам, что приемлемо для MVP. +public class EmailNotificationJobs +{ + private readonly AppDbContext _db; + private readonly IEmailSender _email; + private readonly EmailTemplates _templates; + private readonly UserManager _users; + private readonly IConfiguration _cfg; + private readonly ILogger _log; + + public EmailNotificationJobs(AppDbContext db, IEmailSender email, EmailTemplates templates, + UserManager users, IConfiguration cfg, ILogger log) + { + _db = db; _email = email; _templates = templates; _users = users; _cfg = cfg; _log = log; + } + + /// Weekly summary: владельцу каждой орги — выручка / транзакции / + /// топ-5 товаров за последние 7 дней. Cron в HangfireJobsConfigurator — + /// "0 7 * * 1" (понедельник 07:00 UTC). + public async Task SendWeeklySummariesAsync(CancellationToken ct = default) + { + // `from` — это LINQ-ключевое слово, в query-синтаксе ломает парсер + // даже как имя переменной (CS1525); используем fromDate/toDate. + var toDate = DateTime.UtcNow; + var fromDate = toDate.AddDays(-7); + var orgs = await _db.Organizations.IgnoreQueryFilters() + .Where(o => !o.IsArchived) + .Select(o => new { o.Id, o.Name }) + .ToListAsync(ct); + + foreach (var org in orgs) + { + try + { + using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false); + var revenueRaw = await _db.RetailSales + .Where(s => s.Status == RetailSaleStatus.Posted && s.Date >= fromDate && s.Date <= toDate) + .GroupBy(_ => 1) + .Select(g => new { Sum = g.Sum(s => s.IsReturn ? -s.Total : s.Total), Count = g.Count() }) + .FirstOrDefaultAsync(ct); + var revenue = revenueRaw?.Sum ?? 0m; + var tx = revenueRaw?.Count ?? 0; + var avgTicket = tx == 0 ? 0m : decimal.Round(revenue / tx, 2); + + // Топ-5 по выручке. + var topRaw = await (from l in _db.RetailSaleLines.AsNoTracking() + join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id + join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id + where s.Status == RetailSaleStatus.Posted + && s.Date >= fromDate && s.Date <= toDate + select new + { + p.Name, + Revenue = s.IsReturn ? -l.LineTotal : l.LineTotal, + Qty = s.IsReturn ? -l.Quantity : l.Quantity, + }).ToListAsync(ct); + var top = topRaw.GroupBy(x => x.Name) + .Select(g => new { Name = g.Key, Revenue = g.Sum(x => x.Revenue), Qty = g.Sum(x => x.Qty) }) + .OrderByDescending(x => x.Revenue).Take(5).ToList(); + var topHtml = top.Count == 0 ? "" : + "" + + string.Join("", top.Select(t => + $"")) + + "
ТоварКол-воВыручка
{System.Net.WebUtility.HtmlEncode(t.Name)}{t.Qty:0.##}{t.Revenue:0.##}
"; + + var recipients = await GetOwnerEmailsAsync(org.Id, ct); + if (recipients.Count == 0) continue; + + var (subject, html, text) = _templates.Render("weekly-summary", new Dictionary + { + ["organizationName"] = org.Name, + ["periodFrom"] = fromDate.ToString("yyyy-MM-dd"), + ["periodTo"] = toDate.ToString("yyyy-MM-dd"), + ["revenue"] = revenue.ToString("0.##"), + ["transactions"] = tx.ToString(), + ["avgTicket"] = avgTicket.ToString("0.##"), + ["topProductsHtml"] = topHtml, + ["reportsUrl"] = $"{_cfg["App:PublicUrl"]}/reports/sales", + }); + + foreach (var to_ in recipients) + await _email.SendHtmlAsync(to_, subject, html, text, ct); + _log.LogInformation("Weekly summary отправлен для {Org} → {Count} получателей", + org.Name, recipients.Count); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Weekly summary upset для org {OrgId}", org.Id); + } + } + } + + /// Low-stock: владельцу каждой орги — список товаров, + /// у которых quantity < MinStock. Cron — "0 8 * * *" (ежедневно 08:00 UTC). + public async Task SendLowStockAlertsAsync(CancellationToken ct = default) + { + var orgs = await _db.Organizations.IgnoreQueryFilters() + .Where(o => !o.IsArchived).Select(o => new { o.Id, o.Name }).ToListAsync(ct); + + foreach (var org in orgs) + { + try + { + using var scope = HttpContextTenantContext.UseOverride(org.Id, isSuperAdmin: false); + // Считаем по сумме остатков на всех складах vs MinStock. + var lowItems = await (from p in _db.Products.AsNoTracking() + where p.MinStock != null + let stockSum = _db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m + where stockSum < p.MinStock + orderby (decimal)(p.MinStock! - stockSum) descending + select new { p.Name, p.Article, MinStock = p.MinStock!.Value, Stock = stockSum }) + .Take(50).ToListAsync(ct); + + if (lowItems.Count == 0) continue; + + var recipients = await GetOwnerEmailsAsync(org.Id, ct); + if (recipients.Count == 0) continue; + + var itemsHtml = "" + + string.Join("", lowItems.Select(i => + $"")) + + "
ТоварАртикулОстатокМинимум
{System.Net.WebUtility.HtmlEncode(i.Name)}{System.Net.WebUtility.HtmlEncode(i.Article ?? "—")}{i.Stock:0.##}{i.MinStock:0.##}
"; + + var (subject, html, text) = _templates.Render("low-stock", new Dictionary + { + ["organizationName"] = org.Name, + ["productCount"] = lowItems.Count.ToString(), + ["itemsHtml"] = itemsHtml, + ["stockUrl"] = $"{_cfg["App:PublicUrl"]}/inventory/stock", + }); + foreach (var to_ in recipients) + await _email.SendHtmlAsync(to_, subject, html, text, ct); + _log.LogInformation("Low-stock alert для {Org}: {Count} товаров → {Recipients} получателей", + org.Name, lowItems.Count, recipients.Count); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Low-stock upset для org {OrgId}", org.Id); + } + } + } + + private async Task> GetOwnerEmailsAsync(Guid orgId, CancellationToken ct) + { + // Получатели — активные User'ы орги с ролью Admin (через Identity-маппинг). + // Не Employee, а User: у Employee email — контактный, у User — для входа. + var adminUserIds = await _db.UserRoles.AsNoTracking() + .Join(_db.Roles.AsNoTracking(), ur => ur.RoleId, r => r.Id, (ur, r) => new { ur.UserId, r.Name }) + .Where(x => x.Name == "Admin") + .Select(x => x.UserId) + .ToListAsync(ct); + return await _db.Users.IgnoreQueryFilters() + .Where(u => adminUserIds.Contains(u.Id) && u.OrganizationId == orgId + && u.IsActive && u.Email != null) + .Select(u => u.Email!) + .ToListAsync(ct); + } +} diff --git a/src/food-market.api/Background/HangfireJobsConfigurator.cs b/src/food-market.api/Background/HangfireJobsConfigurator.cs index 4433e07..b55fa87 100644 --- a/src/food-market.api/Background/HangfireJobsConfigurator.cs +++ b/src/food-market.api/Background/HangfireJobsConfigurator.cs @@ -39,6 +39,26 @@ public Task StartAsync(CancellationToken ct) cronExpression: cronAudit, options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + // Email-уведомления: weekly-summary в понедельник 07:00 UTC, + // low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить + // если оба совпадают). Cron-ы конфигурируются — на тестовом стенде + // имеет смысл сдвинуть/отключить через "off" специальное значение + // (не реализовано, пока просто кастомные cron). + var cronWeekly = _cfg["Hangfire:Cron:WeeklySummary"] ?? "0 7 * * 1"; + var cronLowStock = _cfg["Hangfire:Cron:LowStockAlert"] ?? "0 8 * * *"; + + _jobs.AddOrUpdate( + recurringJobId: "weekly-summary", + methodCall: j => j.SendWeeklySummariesAsync(CancellationToken.None), + cronExpression: cronWeekly, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + + _jobs.AddOrUpdate( + recurringJobId: "low-stock-alert", + methodCall: j => j.SendLowStockAlertsAsync(CancellationToken.None), + cronExpression: cronLowStock, + options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + return Task.CompletedTask; } diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index 1b2f0b3..37113de 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -20,10 +20,17 @@ public class EmployeesController : ControllerBase private readonly AppDbContext _db; private readonly ITenantContext _tenant; private readonly UserManager _userMgr; + private readonly foodmarket.Application.Common.Email.IEmailSender _email; + private readonly foodmarket.Api.Infrastructure.Email.EmailTemplates _templates; + private readonly ILogger _log; - public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager userMgr) + public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager userMgr, + foodmarket.Application.Common.Email.IEmailSender email, + foodmarket.Api.Infrastructure.Email.EmailTemplates templates, + ILogger log) { _db = db; _tenant = tenant; _userMgr = userMgr; + _email = email; _templates = templates; _log = log; } public record EmployeeDto( @@ -46,7 +53,12 @@ public record EmployeeInput( IReadOnlyList? RetailPointIds, // CreateAccount=true → создаём User c email + temp password. // Возвращается в response один раз (showOnce). - bool CreateAccount = false); + bool CreateAccount = false, + // SendInvite=true (требует CreateAccount=true) → шлём email-приглашение + // с временным паролем по шаблону EmailTemplates.invite. SMTP-ошибка + // не блокирует создание сотрудника — пишем warning, возвращаем пароль + // в ответе как обычно (showOnce). + bool SendInvite = false); public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword); @@ -170,6 +182,37 @@ public async Task> Create([FromBody] Employee _db.Employees.Add(employee); await _db.SaveChangesAsync(ct); + // Email-приглашение. SMTP-ошибка не блокирует создание — логируем + // warning, фронт всё равно получит tempPassword в ответе. + if (input.SendInvite && input.CreateAccount && tempPassword is not null + && !string.IsNullOrWhiteSpace(input.Email)) + { + try + { + var orgName = await _db.Organizations.Where(o => o.Id == orgId) + .Select(o => o.Name).FirstOrDefaultAsync(ct) ?? "Food Market"; + var loginUrl = (Request.Scheme + "://" + Request.Host.Value + "/login"); + var (subject, html, text) = _templates.Render("invite", new Dictionary + { + ["organizationName"] = orgName, + ["employeeName"] = $"{input.FirstName} {input.LastName}".Trim(), + ["email"] = input.Email, + ["temporaryPassword"] = tempPassword, + ["roleName"] = role.Name, + ["loginUrl"] = loginUrl, + }); + await _email.SendHtmlAsync(input.Email!, subject, html, text, ct); + } + catch (foodmarket.Application.Common.Email.EmailNotConfiguredException ex) + { + _log.LogWarning("Не удалось отправить приглашение: SMTP не настроен ({Msg})", ex.Message); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Не удалось отправить приглашение сотруднику {Email}", input.Email); + } + } + var dto = await ProjectAsync(employee.Id, ct); return new EmployeeCreateResult(dto!, tempPassword); } diff --git a/src/food-market.api/Infrastructure/Email/EmailTemplates.cs b/src/food-market.api/Infrastructure/Email/EmailTemplates.cs new file mode 100644 index 0000000..7c6ab60 --- /dev/null +++ b/src/food-market.api/Infrastructure/Email/EmailTemplates.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using System.Reflection; +using foodmarket.Application.Common.Email; + +namespace foodmarket.Api.Infrastructure.Email; + +/// Загружает HTML-шаблоны из embedded resources +/// (`Resources/EmailTemplates/*.html`) и рендерит их подстановкой словаря. +/// Шаблоны кешируются после первого чтения — files embed'нуты в сборку, +/// перечитывать незачем. +/// +/// Зачем embedded, а не файлы на диске: на проде нет gauantee, что +/// `ContentRootPath/Resources/EmailTemplates/` доедет до контейнера — +/// docker может монтировать только то, что ему нужно. Embedded — один +/// .dll, нет I/O при рендере, нет misconfiguration на staging. +public class EmailTemplates +{ + private static readonly ConcurrentDictionary _cache = new(); + private static readonly Assembly _asm = typeof(EmailTemplates).Assembly; + + /// Базовое имя шаблона (без .html): "invite", "weekly-summary", + /// "low-stock". Кидает FileNotFoundException если ресурс отсутствует — + /// это явный bug в сборке, не runtime-условие. + public (string Subject, string HtmlBody, string TextBody) Render( + string name, IReadOnlyDictionary values) + { + var raw = LoadRaw(name); + // Первая строка файла — Subject: <тема>. Остальное — HTML body. Plain text + // получаем грубым strip-тагов из HTML — для типовых шаблонов выглядит ок. + var lines = raw.Split('\n', 2); + if (lines.Length < 2 || !lines[0].StartsWith("Subject:", StringComparison.Ordinal)) + throw new InvalidOperationException( + $"Шаблон {name} должен начинаться с 'Subject: <тема>' первой строкой."); + var subjectTemplate = lines[0]["Subject:".Length..].Trim(); + var htmlTemplate = lines[1]; + + var subject = EmailTemplateRenderer.Render(subjectTemplate, values); + var html = EmailTemplateRenderer.Render(htmlTemplate, values); + var text = StripHtml(html); + return (subject, html, text); + } + + private static string LoadRaw(string name) + { + return _cache.GetOrAdd(name, key => + { + // Имя ресурса формируется через AssemblyName.. с подстановкой + // path separator → '.'. У нас embedded под "foodmarket.Api.Resources.EmailTemplates.{key}.html". + var resource = $"foodmarket.Api.Resources.EmailTemplates.{key}.html"; + using var stream = _asm.GetManifestResourceStream(resource) + ?? throw new FileNotFoundException( + $"Email-шаблон {key}.html не найден в embedded resources. " + + $"Доступно: {string.Join(", ", _asm.GetManifestResourceNames())}"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + }); + } + + private static string StripHtml(string html) + { + // Грубый strip для plain-fallback'а. Заменяет
/

на \n, удаляет остальные теги, + // декодирует основные entities. Не для произвольного 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, @"", "\n", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + s = System.Text.RegularExpressions.Regex.Replace(s, @"<[^>]+>", ""); + s = s.Replace(" ", " ").Replace("&", "&") + .Replace("<", "<").Replace(">", ">") + .Replace(""", "\"").Replace("'", "'"); + // Свернуть множественные пустые строки. + s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n"); + return s.Trim(); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 56668f3..4f423ca 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -163,6 +163,9 @@ // на каждой отправке без рестарта приложения. builder.Services.AddSingleton(); + // EmailTemplates загружает embedded HTML и подставляет {{key}} — + // см. Resources/EmailTemplates/*.html. Singleton с in-memory cache. + builder.Services.AddSingleton(); builder.Services.AddDataProtection(); builder.Services.AddControllers(o => { @@ -266,6 +269,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme builder.Services.AddHostedService(); } builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/food-market.api/Resources/EmailTemplates/invite.html b/src/food-market.api/Resources/EmailTemplates/invite.html new file mode 100644 index 0000000..7b70a9c --- /dev/null +++ b/src/food-market.api/Resources/EmailTemplates/invite.html @@ -0,0 +1,24 @@ +Subject: Приглашение в {{organizationName}} + + + + +Приглашение в Food Market + + +

Добро пожаловать в {{organizationName}}

+

Здравствуйте, {{employeeName}}.

+

Для вас создан доступ к системе Food Market. Войдите по адресу:

+

+ Открыть Food Market +

+ + + + +
Логин{{email}}
Временный пароль{{temporaryPassword}}
Роль{{roleName}}
+

+ После первого входа смените пароль в личном кабинете. Если вы не ожидали это письмо — просто игнорируйте его, доступ без первого входа не активируется. +

+ + diff --git a/src/food-market.api/Resources/EmailTemplates/low-stock.html b/src/food-market.api/Resources/EmailTemplates/low-stock.html new file mode 100644 index 0000000..95713bd --- /dev/null +++ b/src/food-market.api/Resources/EmailTemplates/low-stock.html @@ -0,0 +1,19 @@ +Subject: Низкие остатки · {{organizationName}} + + + + +Низкие остатки + + +

Низкие остатки

+

{{productCount}} товаров опустились ниже MinStock. Время сделать заказ.

+ + {{{itemsHtml}}} + +

+ Полный список — в разделе Остатки. + Отключить уведомления можно в настройках организации. +

+ + diff --git a/src/food-market.api/Resources/EmailTemplates/weekly-summary.html b/src/food-market.api/Resources/EmailTemplates/weekly-summary.html new file mode 100644 index 0000000..859a6ba --- /dev/null +++ b/src/food-market.api/Resources/EmailTemplates/weekly-summary.html @@ -0,0 +1,36 @@ +Subject: Итоги недели · {{organizationName}} + + + + +Еженедельная сводка + + +

Итоги недели

+

{{periodFrom}} — {{periodTo}}

+ +
+
+
Выручка
+
{{revenue}}
+
+
+
Чеков
+
{{transactions}}
+
+
+
Средний чек
+
{{avgTicket}}
+
+
+ + {{#topProductsHtml}} +

Топ-товары

+ {{{topProductsHtml}}} + {{/topProductsHtml}} + +

+ Подробная аналитика — в разделе Отчёты. +

+ + diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj index b2f9c34..9df0131 100644 --- a/src/food-market.api/food-market.api.csproj +++ b/src/food-market.api/food-market.api.csproj @@ -27,4 +27,9 @@ + + + + + diff --git a/src/food-market.application/Common/Email/EmailTemplateRenderer.cs b/src/food-market.application/Common/Email/EmailTemplateRenderer.cs new file mode 100644 index 0000000..4ca407d --- /dev/null +++ b/src/food-market.application/Common/Email/EmailTemplateRenderer.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; + +namespace foodmarket.Application.Common.Email; + +/// Лёгкий «mustache-light» рендерер для email-шаблонов: подстановка +/// `{{key}}` из словаря и условные блоки `{{#key}}...{{/key}}` (показываются +/// только если key есть и значение truthy). +/// +/// Не Razor/Liquid — намеренно минимальный набор, потому что эти шаблоны: +/// • короткие и редкие (3 типа писем), +/// • не требуют циклов/выражений (структурированные DTO в коде формирует +/// payload построчно перед рендером), +/// • не должны давать SSRF/RCE-поверхность (как у любого «полноценного» +/// шаблонизатора). +/// +/// HTML-escape по умолчанию ВКЛЮЧЁН — `{{name}}` экранирует, `{{{name}}}` +/// (тройные фигурные) вставляет как есть (для уже-готового HTML вставок, +/// типа таблиц). Используйте с осторожностью. +public static class EmailTemplateRenderer +{ + private static readonly Regex BlockRegex = new(@"\{\{#(\w+)\}\}(.*?)\{\{/\1\}\}", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex RawRegex = new(@"\{\{\{(\w+)\}\}\}", RegexOptions.Compiled); + private static readonly Regex EscRegex = new(@"\{\{(\w+)\}\}", RegexOptions.Compiled); + + public static string Render(string template, IReadOnlyDictionary values) + { + // 1. Условные блоки. Truthy = не-null, не пустая строка, не "0", не "false". + var result = BlockRegex.Replace(template, m => + { + var key = m.Groups[1].Value; + values.TryGetValue(key, out var v); + return IsTruthy(v) ? m.Groups[2].Value : ""; + }); + + // 2. Raw (без escape). После блоков чтобы тройные внутри условного блока тоже рендерились. + result = RawRegex.Replace(result, m => + { + values.TryGetValue(m.Groups[1].Value, out var v); + return v ?? ""; + }); + + // 3. Escaped (по умолчанию). + result = EscRegex.Replace(result, m => + { + values.TryGetValue(m.Groups[1].Value, out var v); + return v is null ? "" : HtmlEscape(v); + }); + + return result; + } + + private static bool IsTruthy(string? v) => + !string.IsNullOrWhiteSpace(v) && v != "0" && !string.Equals(v, "false", StringComparison.OrdinalIgnoreCase); + + private static string HtmlEscape(string s) => s + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); +} diff --git a/src/food-market.application/Common/Email/IEmailSender.cs b/src/food-market.application/Common/Email/IEmailSender.cs index 13f9e46..780c07b 100644 --- a/src/food-market.application/Common/Email/IEmailSender.cs +++ b/src/food-market.application/Common/Email/IEmailSender.cs @@ -6,6 +6,13 @@ namespace foodmarket.Application.Common.Email; public interface IEmailSender { Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default); + + /// HTML-версия с опциональным plain-text fallback. Используется + /// шаблонизированными письмами (приглашения, weekly-summary, low-stock). + /// Если null — клиент должен сам уметь + /// рендерить HTML; для большинства современных clients это не проблема. + Task SendHtmlAsync(string toEmail, string subject, string htmlBody, string? textBody = null, + CancellationToken ct = default); } /// Бросается когда платформенный SMTP не настроен. Контроллеры должны diff --git a/src/food-market.infrastructure/Email/MailKitEmailSender.cs b/src/food-market.infrastructure/Email/MailKitEmailSender.cs index d995151..3af1a24 100644 --- a/src/food-market.infrastructure/Email/MailKitEmailSender.cs +++ b/src/food-market.infrastructure/Email/MailKitEmailSender.cs @@ -27,7 +27,15 @@ public MailKitEmailSender(IServiceScopeFactory scopes, IDataProtectionProvider d _scopes = scopes; _dpProvider = dpProvider; _logger = logger; } - public async Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default) + 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(); @@ -43,7 +51,21 @@ public async Task SendAsync(string toEmail, string subject, string body, Cancell 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 }; + 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). @@ -85,4 +107,17 @@ public async Task SendAsync(string toEmail, string subject, string body, Cancell 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, @"", "\n", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + s = System.Text.RegularExpressions.Regex.Replace(s, @"<[^>]+>", ""); + return s.Replace(" ", " ").Replace("&", "&") + .Replace("<", "<").Replace(">", ">").Trim(); + } } diff --git a/tests/food-market.UnitTests/EmailTemplateRendererTests.cs b/tests/food-market.UnitTests/EmailTemplateRendererTests.cs new file mode 100644 index 0000000..eb71bb5 --- /dev/null +++ b/tests/food-market.UnitTests/EmailTemplateRendererTests.cs @@ -0,0 +1,108 @@ +using FluentAssertions; +using foodmarket.Api.Infrastructure.Email; +using foodmarket.Application.Common.Email; +using Xunit; + +namespace foodmarket.UnitTests; + +public class EmailTemplateRendererTests +{ + [Fact] + public void Substitutes_simple_placeholder() + { + var r = EmailTemplateRenderer.Render( + "Привет, {{name}}!", + new Dictionary { ["name"] = "Алия" }); + r.Should().Be("Привет, Алия!"); + } + + [Fact] + public void Escapes_html_in_double_braces() + { + var r = EmailTemplateRenderer.Render( + "

{{text}}

", + new Dictionary { ["text"] = "" }); + r.Should().Be("

<script>x</script>

"); + } + + [Fact] + public void Triple_braces_render_raw_html() + { + var r = EmailTemplateRenderer.Render( + "
{{{table}}}
", + new Dictionary { ["table"] = "
1
" }); + r.Should().Be("
1
"); + } + + [Fact] + public void Conditional_block_shown_when_truthy() + { + var r = EmailTemplateRenderer.Render( + "Hi{{#bonus}}, бонус: {{bonus}}{{/bonus}}!", + new Dictionary { ["bonus"] = "100₸" }); + r.Should().Be("Hi, бонус: 100₸!"); + } + + [Fact] + public void Conditional_block_hidden_when_falsy() + { + // null + EmailTemplateRenderer.Render( + "Hi{{#bonus}}, бонус!{{/bonus}}", + new Dictionary { ["bonus"] = null }).Should().Be("Hi"); + // empty + EmailTemplateRenderer.Render( + "Hi{{#bonus}}, бонус!{{/bonus}}", + new Dictionary { ["bonus"] = "" }).Should().Be("Hi"); + // "0" + EmailTemplateRenderer.Render( + "Hi{{#bonus}}, бонус!{{/bonus}}", + new Dictionary { ["bonus"] = "0" }).Should().Be("Hi"); + } + + [Fact] + public void Missing_key_renders_empty() + { + EmailTemplateRenderer.Render( + "Hi, {{nope}}!", + new Dictionary()).Should().Be("Hi, !"); + } + + [Fact] + public void Invite_template_loads_and_substitutes() + { + var tpl = new EmailTemplates(); + var (subject, html, text) = tpl.Render("invite", new Dictionary + { + ["organizationName"] = "ТОО Тест", + ["employeeName"] = "Иван Иванов", + ["email"] = "ivan@test.kz", + ["temporaryPassword"] = "Pass123!", + ["roleName"] = "Кассир", + ["loginUrl"] = "https://admin.test.kz/login", + }); + subject.Should().Contain("ТОО Тест"); + html.Should().Contain("Иван Иванов"); + html.Should().Contain("Pass123!"); + html.Should().Contain("Кассир"); + html.Should().Contain("https://admin.test.kz/login"); + text.Should().Contain("Иван Иванов"); + text.Should().NotContain("", "plain-text fallback не должен содержать теги"); + } + + [Fact] + public void Low_stock_template_uses_raw_html_for_items() + { + var tpl = new EmailTemplates(); + var itemsHtml = "
  • Молоко < 10
"; + var (_, html, _) = tpl.Render("low-stock", new Dictionary + { + ["organizationName"] = "ТОО Тест", + ["productCount"] = "3", + ["itemsHtml"] = itemsHtml, + ["stockUrl"] = "https://admin.test.kz/inventory/stock", + }); + // Тройные фигурные → вставка как есть, без двойного экранирования. + html.Should().Contain(itemsHtml); + } +}