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:
parent
ee0cd3ae86
commit
25d92c989a
176
src/food-market.api/Background/EmailNotificationJobs.cs
Normal file
176
src/food-market.api/Background/EmailNotificationJobs.cs
Normal 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 < 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,26 @@ public Task StartAsync(CancellationToken ct)
|
||||||
cronExpression: cronAudit,
|
cronExpression: cronAudit,
|
||||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
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;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,17 @@ public class EmployeesController : ControllerBase
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly ITenantContext _tenant;
|
private readonly ITenantContext _tenant;
|
||||||
private readonly UserManager<User> _userMgr;
|
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;
|
_db = db; _tenant = tenant; _userMgr = userMgr;
|
||||||
|
_email = email; _templates = templates; _log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record EmployeeDto(
|
public record EmployeeDto(
|
||||||
|
|
@ -46,7 +53,12 @@ public record EmployeeInput(
|
||||||
IReadOnlyList<Guid>? RetailPointIds,
|
IReadOnlyList<Guid>? RetailPointIds,
|
||||||
// CreateAccount=true → создаём User c email + temp password.
|
// CreateAccount=true → создаём User c email + temp password.
|
||||||
// Возвращается в response один раз (showOnce).
|
// Возвращается в 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);
|
public record EmployeeCreateResult(EmployeeDto Employee, string? GeneratedPassword);
|
||||||
|
|
||||||
|
|
@ -170,6 +182,37 @@ public async Task<ActionResult<EmployeeCreateResult>> Create([FromBody] Employee
|
||||||
_db.Employees.Add(employee);
|
_db.Employees.Add(employee);
|
||||||
await _db.SaveChangesAsync(ct);
|
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);
|
var dto = await ProjectAsync(employee.Id, ct);
|
||||||
return new EmployeeCreateResult(dto!, tempPassword);
|
return new EmployeeCreateResult(dto!, tempPassword);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
src/food-market.api/Infrastructure/Email/EmailTemplates.cs
Normal file
75
src/food-market.api/Infrastructure/Email/EmailTemplates.cs
Normal 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(" ", " ").Replace("&", "&")
|
||||||
|
.Replace("<", "<").Replace(">", ">")
|
||||||
|
.Replace(""", "\"").Replace("'", "'");
|
||||||
|
// Свернуть множественные пустые строки.
|
||||||
|
s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n");
|
||||||
|
return s.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -163,6 +163,9 @@
|
||||||
// на каждой отправке без рестарта приложения.
|
// на каждой отправке без рестарта приложения.
|
||||||
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
|
||||||
foodmarket.Infrastructure.Email.MailKitEmailSender>();
|
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.AddDataProtection();
|
||||||
builder.Services.AddControllers(o =>
|
builder.Services.AddControllers(o =>
|
||||||
{
|
{
|
||||||
|
|
@ -266,6 +269,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireJobsConfigurator>();
|
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireJobsConfigurator>();
|
||||||
}
|
}
|
||||||
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
|
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
|
||||||
|
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
||||||
|
|
||||||
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
builder.Services.AddHostedService<OpenIddictClientSeeder>();
|
||||||
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
builder.Services.AddHostedService<SystemReferenceSeeder>();
|
||||||
|
|
|
||||||
24
src/food-market.api/Resources/EmailTemplates/invite.html
Normal file
24
src/food-market.api/Resources/EmailTemplates/invite.html
Normal 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>
|
||||||
19
src/food-market.api/Resources/EmailTemplates/low-stock.html
Normal file
19
src/food-market.api/Resources/EmailTemplates/low-stock.html
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -27,4 +27,9 @@
|
||||||
<PackageReference Include="prometheus-net.AspNetCore" />
|
<PackageReference Include="prometheus-net.AspNetCore" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Email-шаблоны embedded в сборку — см. EmailTemplates.LoadRaw. -->
|
||||||
|
<EmbeddedResource Include="Resources/EmailTemplates/*.html" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -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("&", "&")
|
||||||
|
.Replace("<", "<")
|
||||||
|
.Replace(">", ">")
|
||||||
|
.Replace("\"", """)
|
||||||
|
.Replace("'", "'");
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,13 @@ namespace foodmarket.Application.Common.Email;
|
||||||
public interface IEmailSender
|
public interface IEmailSender
|
||||||
{
|
{
|
||||||
Task SendAsync(string toEmail, string subject, string body, CancellationToken ct = default);
|
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 не настроен. Контроллеры должны
|
/// <summary>Бросается когда платформенный SMTP не настроен. Контроллеры должны
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,15 @@ public MailKitEmailSender(IServiceScopeFactory scopes, IDataProtectionProvider d
|
||||||
_scopes = scopes; _dpProvider = dpProvider; _logger = logger;
|
_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();
|
using var scope = _scopes.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
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.From.Add(new MailboxAddress(s.FromName ?? "Food Market", s.FromEmail));
|
||||||
msg.To.Add(MailboxAddress.Parse(toEmail));
|
msg.To.Add(MailboxAddress.Parse(toEmail));
|
||||||
msg.Subject = subject;
|
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.
|
// Implicit TLS (SmtpUseSsl) — обычно 465. STARTTLS (SmtpStartTls) — 587.
|
||||||
// Если оба false — открытое соединение (SmtpClient.Connect c None).
|
// Если оба 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);
|
await client.DisconnectAsync(true, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Грубый strip HTML→plain для multipart fallback'а.
|
||||||
|
/// Не для произвольного HTML, но для наших шаблонов достаточно.</summary>
|
||||||
|
private static string StripHtml(string html)
|
||||||
|
{
|
||||||
|
var s = System.Text.RegularExpressions.Regex.Replace(html, @"<\s*br\s*/?\s*>", "\n",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
s = System.Text.RegularExpressions.Regex.Replace(s, @"</\s*(p|h\d|div|li|tr)\s*>", "\n",
|
||||||
|
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
s = System.Text.RegularExpressions.Regex.Replace(s, @"<[^>]+>", "");
|
||||||
|
return s.Replace(" ", " ").Replace("&", "&")
|
||||||
|
.Replace("<", "<").Replace(">", ">").Trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
108
tests/food-market.UnitTests/EmailTemplateRendererTests.cs
Normal file
108
tests/food-market.UnitTests/EmailTemplateRendererTests.cs
Normal 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><script>x</script></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>Молоко < 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue