feat(email): HTML-шаблоны MailKit + invite/weekly/low-stock джобы (P1-22)

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)<MinStock,
  шлёт Admin'ам. AsyncLocal tenant override на каждую орг чтобы
  query-filter работал корректно.

Тесты: 8 unit на EmailTemplateRenderer + EmailTemplates (escape, raw,
условные блоки, invite/low-stock-шаблон-loaders). Все 35 unit
зелёные (27 + 8 новых).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 16:37:32 +05:00
parent ee0cd3ae86
commit 25d92c989a
13 changed files with 617 additions and 4 deletions

View file

@ -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;
/// <summary>Hangfire-джобы рассылки писем: weekly-summary (понедельник 07:00)
/// и low-stock (ежедневно 08:00). Бегут per-org через IgnoreQueryFilters +
/// AsyncLocal-tenant override для каждой обрабатываемой организации
/// (см. HttpContextTenantContext.UseOverride).
///
/// Каждая ошибка отправки логируется, но не валит весь джоб — продолжаем по
/// следующим оргам. Идемпотентность по неделе/дню — не реализована: повтор
/// джоба тем же утром обогатит почту админам, что приемлемо для MVP.</summary>
public class EmailNotificationJobs
{
private readonly AppDbContext _db;
private readonly IEmailSender _email;
private readonly EmailTemplates _templates;
private readonly UserManager<User> _users;
private readonly IConfiguration _cfg;
private readonly ILogger<EmailNotificationJobs> _log;
public EmailNotificationJobs(AppDbContext db, IEmailSender email, EmailTemplates templates,
UserManager<User> users, IConfiguration cfg, ILogger<EmailNotificationJobs> log)
{
_db = db; _email = email; _templates = templates; _users = users; _cfg = cfg; _log = log;
}
/// <summary>Weekly summary: владельцу каждой орги — выручка / транзакции /
/// топ-5 товаров за последние 7 дней. Cron в HangfireJobsConfigurator —
/// "0 7 * * 1" (понедельник 07:00 UTC).</summary>
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 ? "" :
"<table style=\"border-collapse:collapse;width:100%;\"><tr><th align=\"left\">Товар</th><th align=\"right\">Кол-во</th><th align=\"right\">Выручка</th></tr>"
+ string.Join("", top.Select(t =>
$"<tr><td>{System.Net.WebUtility.HtmlEncode(t.Name)}</td><td align=\"right\">{t.Qty:0.##}</td><td align=\"right\">{t.Revenue:0.##}</td></tr>"))
+ "</table>";
var recipients = await GetOwnerEmailsAsync(org.Id, ct);
if (recipients.Count == 0) continue;
var (subject, html, text) = _templates.Render("weekly-summary", new Dictionary<string, string?>
{
["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);
}
}
}
/// <summary>Low-stock: владельцу каждой орги — список товаров,
/// у которых quantity &lt; MinStock. Cron — "0 8 * * *" (ежедневно 08:00 UTC).</summary>
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 = "<table style=\"border-collapse:collapse;width:100%;\"><tr><th align=\"left\">Товар</th><th align=\"left\">Артикул</th><th align=\"right\">Остаток</th><th align=\"right\">Минимум</th></tr>"
+ string.Join("", lowItems.Select(i =>
$"<tr><td>{System.Net.WebUtility.HtmlEncode(i.Name)}</td><td>{System.Net.WebUtility.HtmlEncode(i.Article ?? "")}</td><td align=\"right\" style=\"color:#b91c1c;\">{i.Stock:0.##}</td><td align=\"right\">{i.MinStock:0.##}</td></tr>"))
+ "</table>";
var (subject, html, text) = _templates.Render("low-stock", new Dictionary<string, string?>
{
["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<List<string>> 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);
}
}

View file

@ -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<EmailNotificationJobs>(
recurringJobId: "weekly-summary",
methodCall: j => j.SendWeeklySummariesAsync(CancellationToken.None),
cronExpression: cronWeekly,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
_jobs.AddOrUpdate<EmailNotificationJobs>(
recurringJobId: "low-stock-alert",
methodCall: j => j.SendLowStockAlertsAsync(CancellationToken.None),
cronExpression: cronLowStock,
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
return Task.CompletedTask;
}

View file

@ -20,10 +20,17 @@ public class EmployeesController : ControllerBase
private readonly AppDbContext _db;
private readonly ITenantContext _tenant;
private readonly UserManager<User> _userMgr;
private readonly foodmarket.Application.Common.Email.IEmailSender _email;
private readonly foodmarket.Api.Infrastructure.Email.EmailTemplates _templates;
private readonly ILogger<EmployeesController> _log;
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr)
public EmployeesController(AppDbContext db, ITenantContext tenant, UserManager<User> userMgr,
foodmarket.Application.Common.Email.IEmailSender email,
foodmarket.Api.Infrastructure.Email.EmailTemplates templates,
ILogger<EmployeesController> log)
{
_db = db; _tenant = tenant; _userMgr = userMgr;
_email = email; _templates = templates; _log = log;
}
public record EmployeeDto(
@ -46,7 +53,12 @@ public record EmployeeInput(
IReadOnlyList<Guid>? 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<ActionResult<EmployeeCreateResult>> 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<string, string?>
{
["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);
}

View file

@ -0,0 +1,75 @@
using System.Collections.Concurrent;
using System.Reflection;
using foodmarket.Application.Common.Email;
namespace foodmarket.Api.Infrastructure.Email;
/// <summary>Загружает HTML-шаблоны из embedded resources
/// (`Resources/EmailTemplates/*.html`) и рендерит их подстановкой словаря.
/// Шаблоны кешируются после первого чтения — files embed'нуты в сборку,
/// перечитывать незачем.
///
/// Зачем embedded, а не файлы на диске: на проде нет gauantee, что
/// `ContentRootPath/Resources/EmailTemplates/` доедет до контейнера —
/// docker может монтировать только то, что ему нужно. Embedded — один
/// .dll, нет I/O при рендере, нет misconfiguration на staging.</summary>
public class EmailTemplates
{
private static readonly ConcurrentDictionary<string, string> _cache = new();
private static readonly Assembly _asm = typeof(EmailTemplates).Assembly;
/// <summary>Базовое имя шаблона (без .html): "invite", "weekly-summary",
/// "low-stock". Кидает FileNotFoundException если ресурс отсутствует —
/// это явный bug в сборке, не runtime-условие.</summary>
public (string Subject, string HtmlBody, string TextBody) Render(
string name, IReadOnlyDictionary<string, string?> 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.<folder>.<file> с подстановкой
// 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'а. Заменяет <br>/</p> на \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("&nbsp;", " ").Replace("&amp;", "&")
.Replace("&lt;", "<").Replace("&gt;", ">")
.Replace("&quot;", "\"").Replace("&#39;", "'");
// Свернуть множественные пустые строки.
s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n");
return s.Trim();
}
}

View file

@ -163,6 +163,9 @@
// на каждой отправке без рестарта приложения.
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
foodmarket.Infrastructure.Email.MailKitEmailSender>();
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
builder.Services.AddDataProtection();
builder.Services.AddControllers(o =>
{
@ -266,6 +269,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireJobsConfigurator>();
}
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();

View file

@ -0,0 +1,24 @@
Subject: Приглашение в {{organizationName}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Приглашение в Food Market</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #1f2937;">
<h2 style="color: #0f766e;">Добро пожаловать в {{organizationName}}</h2>
<p>Здравствуйте, {{employeeName}}.</p>
<p>Для вас создан доступ к системе Food Market. Войдите по адресу:</p>
<p style="margin: 20px 0;">
<a href="{{loginUrl}}" style="background: #0f766e; color: white; padding: 10px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Открыть Food Market</a>
</p>
<table style="border-collapse: collapse; background: #f9fafb; padding: 16px; border-radius: 6px;">
<tr><td style="padding: 4px 12px;"><strong>Логин</strong></td><td style="padding: 4px 12px;"><code>{{email}}</code></td></tr>
<tr><td style="padding: 4px 12px;"><strong>Временный пароль</strong></td><td style="padding: 4px 12px;"><code>{{temporaryPassword}}</code></td></tr>
<tr><td style="padding: 4px 12px;"><strong>Роль</strong></td><td style="padding: 4px 12px;">{{roleName}}</td></tr>
</table>
<p style="margin-top: 16px; color: #6b7280; font-size: 13px;">
После первого входа смените пароль в личном кабинете. Если вы не ожидали это письмо — просто игнорируйте его, доступ без первого входа не активируется.
</p>
</body>
</html>

View file

@ -0,0 +1,19 @@
Subject: Низкие остатки · {{organizationName}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Низкие остатки</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 640px; margin: 0 auto; padding: 24px; color: #1f2937;">
<h2 style="color: #b91c1c;">Низкие остатки</h2>
<p>{{productCount}} товаров опустились ниже MinStock. Время сделать заказ.</p>
{{{itemsHtml}}}
<p style="margin-top: 24px; color: #6b7280; font-size: 13px;">
Полный список — в разделе <a href="{{stockUrl}}">Остатки</a>.
Отключить уведомления можно в настройках организации.
</p>
</body>
</html>

View file

@ -0,0 +1,36 @@
Subject: Итоги недели · {{organizationName}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Еженедельная сводка</title>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 640px; margin: 0 auto; padding: 24px; color: #1f2937;">
<h2 style="color: #0f766e;">Итоги недели</h2>
<p>{{periodFrom}} — {{periodTo}}</p>
<div style="display: flex; gap: 12px; margin: 20px 0;">
<div style="flex: 1; background: #ecfdf5; padding: 16px; border-radius: 8px;">
<div style="color: #047857; font-size: 13px;">Выручка</div>
<div style="font-size: 24px; font-weight: 600; color: #064e3b;">{{revenue}}</div>
</div>
<div style="flex: 1; background: #eff6ff; padding: 16px; border-radius: 8px;">
<div style="color: #1d4ed8; font-size: 13px;">Чеков</div>
<div style="font-size: 24px; font-weight: 600; color: #1e3a8a;">{{transactions}}</div>
</div>
<div style="flex: 1; background: #fef3c7; padding: 16px; border-radius: 8px;">
<div style="color: #b45309; font-size: 13px;">Средний чек</div>
<div style="font-size: 24px; font-weight: 600; color: #78350f;">{{avgTicket}}</div>
</div>
</div>
{{#topProductsHtml}}
<h3 style="margin-top: 24px;">Топ-товары</h3>
{{{topProductsHtml}}}
{{/topProductsHtml}}
<p style="margin-top: 32px; color: #6b7280; font-size: 13px;">
Подробная аналитика — в разделе <a href="{{reportsUrl}}">Отчёты</a>.
</p>
</body>
</html>

View file

@ -27,4 +27,9 @@
<PackageReference Include="prometheus-net.AspNetCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
</ItemGroup>
<ItemGroup>
<!-- Email-шаблоны embedded в сборку — см. EmailTemplates.LoadRaw. -->
<EmbeddedResource Include="Resources/EmailTemplates/*.html" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,61 @@
using System.Text.RegularExpressions;
namespace foodmarket.Application.Common.Email;
/// <summary>Лёгкий «mustache-light» рендерер для email-шаблонов: подстановка
/// `{{key}}` из словаря и условные блоки `{{#key}}...{{/key}}` (показываются
/// только если key есть и значение truthy).
///
/// Не Razor/Liquid — намеренно минимальный набор, потому что эти шаблоны:
/// • короткие и редкие (3 типа писем),
/// • не требуют циклов/выражений (структурированные DTO в коде формирует
/// payload построчно перед рендером),
/// • не должны давать SSRF/RCE-поверхность (как у любого «полноценного»
/// шаблонизатора).
///
/// HTML-escape по умолчанию ВКЛЮЧЁН — `{{name}}` экранирует, `{{{name}}}`
/// (тройные фигурные) вставляет как есть (для уже-готового HTML вставок,
/// типа таблиц). Используйте с осторожностью.</summary>
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<string, string?> 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("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}

View file

@ -6,6 +6,13 @@ namespace foodmarket.Application.Common.Email;
public interface IEmailSender
{
Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default);
/// <summary>HTML-версия с опциональным plain-text fallback. Используется
/// шаблонизированными письмами (приглашения, weekly-summary, low-stock).
/// Если <paramref name="textBody"/> null — клиент должен сам уметь
/// рендерить HTML; для большинства современных clients это не проблема.</summary>
Task SendHtmlAsync(string toEmail, string subject, string htmlBody, string? textBody = null,
CancellationToken ct = default);
}
/// <summary>Бросается когда платформенный SMTP не настроен. Контроллеры должны

View file

@ -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<AppDbContext>();
@ -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);
}
}
/// <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();
}
}

View file

@ -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<string, string?> { ["name"] = "Алия" });
r.Should().Be("Привет, Алия!");
}
[Fact]
public void Escapes_html_in_double_braces()
{
var r = EmailTemplateRenderer.Render(
"<p>{{text}}</p>",
new Dictionary<string, string?> { ["text"] = "<script>x</script>" });
r.Should().Be("<p>&lt;script&gt;x&lt;/script&gt;</p>");
}
[Fact]
public void Triple_braces_render_raw_html()
{
var r = EmailTemplateRenderer.Render(
"<div>{{{table}}}</div>",
new Dictionary<string, string?> { ["table"] = "<table><tr><td>1</td></tr></table>" });
r.Should().Be("<div><table><tr><td>1</td></tr></table></div>");
}
[Fact]
public void Conditional_block_shown_when_truthy()
{
var r = EmailTemplateRenderer.Render(
"Hi{{#bonus}}, бонус: {{bonus}}{{/bonus}}!",
new Dictionary<string, string?> { ["bonus"] = "100₸" });
r.Should().Be("Hi, бонус: 100₸!");
}
[Fact]
public void Conditional_block_hidden_when_falsy()
{
// null
EmailTemplateRenderer.Render(
"Hi{{#bonus}}, бонус!{{/bonus}}",
new Dictionary<string, string?> { ["bonus"] = null }).Should().Be("Hi");
// empty
EmailTemplateRenderer.Render(
"Hi{{#bonus}}, бонус!{{/bonus}}",
new Dictionary<string, string?> { ["bonus"] = "" }).Should().Be("Hi");
// "0"
EmailTemplateRenderer.Render(
"Hi{{#bonus}}, бонус!{{/bonus}}",
new Dictionary<string, string?> { ["bonus"] = "0" }).Should().Be("Hi");
}
[Fact]
public void Missing_key_renders_empty()
{
EmailTemplateRenderer.Render(
"Hi, {{nope}}!",
new Dictionary<string, string?>()).Should().Be("Hi, !");
}
[Fact]
public void Invite_template_loads_and_substitutes()
{
var tpl = new EmailTemplates();
var (subject, html, text) = tpl.Render("invite", new Dictionary<string, string?>
{
["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("<html>", "plain-text fallback не должен содержать теги");
}
[Fact]
public void Low_stock_template_uses_raw_html_for_items()
{
var tpl = new EmailTemplates();
var itemsHtml = "<ul><li>Молоко &lt; 10</li></ul>";
var (_, html, _) = tpl.Render("low-stock", new Dictionary<string, string?>
{
["organizationName"] = "ТОО Тест",
["productCount"] = "3",
["itemsHtml"] = itemsHtml,
["stockUrl"] = "https://admin.test.kz/inventory/stock",
});
// Тройные фигурные → вставка как есть, без двойного экранирования.
html.Should().Contain(itemsHtml);
}
}