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, @"\s*(p|h\d|div|li|tr)\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, @"\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();
+ }
}
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"] = "" });
+ r.Should().Be("");
+ }
+
+ [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 = "";
+ var (_, html, _) = tpl.Render("low-stock", new Dictionary
+ {
+ ["organizationName"] = "ТОО Тест",
+ ["productCount"] = "3",
+ ["itemsHtml"] = itemsHtml,
+ ["stockUrl"] = "https://admin.test.kz/inventory/stock",
+ });
+ // Тройные фигурные → вставка как есть, без двойного экранирования.
+ html.Should().Contain(itemsHtml);
+ }
+}