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,
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
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>();
|
||||
|
|
|
|||
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="Microsoft.EntityFrameworkCore.Design" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Email-шаблоны embedded в сборку — см. EmailTemplates.LoadRaw. -->
|
||||
<EmbeddedResource Include="Resources/EmailTemplates/*.html" />
|
||||
</ItemGroup>
|
||||
</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
|
||||
{
|
||||
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 не настроен. Контроллеры должны
|
||||
|
|
|
|||
|
|
@ -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(" ", " ").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