Update email templates to HTML format and fix EmailApiEmailSender

- Convert email.match.body, email.match.job-search-footer, email.search-results.body, and email.search-results.empty templates from plain text to proper HTML format in InitialSchema migration
- Update EmailApiEmailSender.BuildMatchEmailBody() to work with HTML templates instead of plain text
- Add WebUtility.HtmlEncode() for security when inserting dynamic content (summary)
- Templates now use semantic HTML tags (table, h2, h3, ul, li, p, div, hr, a) instead of plain text with newlines
- All 32 email template variants (16 keys × 2 languages) and 8 html.job-search.* templates seeded via migration

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:16:02 +03:00
parent f9530b168f
commit 978dd3a069
2 changed files with 112 additions and 23 deletions
+12 -7
View File
@@ -6,6 +6,7 @@ using EmailApi.Models.Requests;
using Microsoft.Extensions.Options;
using Models.Requests;
using Models.Settings;
using System.Net;
namespace Api.Services;
@@ -194,31 +195,35 @@ public sealed class EmailApiEmailSender : IEmailSender
/// <inheritdoc />
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
// Build HTML lists for strengths, gaps, and recommendations
var strengths = result.Strengths?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
: "<p style=\"color:#6c757d;margin:0\">—</p>";
var gaps = result.Gaps?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
: "<p style=\"color:#6c757d;margin:0\">—</p>";
var recommendations = result.Recommendations?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
: "<p style=\"color:#6c757d;margin:0\">—</p>";
// Render the HTML template with substituted values
// email.match.body is now stored as HTML in the database
var body = _emailTemplates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"),
("jobUrl", result.JobUrl ?? "N/A"),
("score", result.Score.ToString()),
("summary", result.Summary ?? string.Empty),
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
// Append the job search footer if link is provided
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += _emailTemplates.Render("email.match.job-search-footer", language,
@@ -44,33 +44,117 @@ namespace Email.Data.Migrations
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email");
Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
// Match result email — body
// Match result email — body (HTML formatted)
Row("email.match.body", "en",
"CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}",
"Body for the CV match result email");
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">CV Match Report</h2>
<table style=""width: 100%; border-collapse: collapse; margin-bottom: 30px; border: 1px solid #ddd;"">
<tr style=""background-color: #2c5282; color: white;"">
<td style=""padding: 12px 15px; font-weight: bold; width: 35%;"">CV ID</td>
<td style=""padding: 12px 15px;"">{{cvDocumentId}}</td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""padding: 12px 15px; font-weight: bold;"">Job</td>
<td style=""padding: 12px 15px;"">{{jobLabel}}</td>
</tr>
<tr>
<td style=""padding: 12px 15px; font-weight: bold;"">URL</td>
<td style=""padding: 12px 15px;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""padding: 12px 15px; font-weight: bold;"">Score</td>
<td style=""padding: 12px 15px; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
</tr>
</table>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Summary</h3>
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Strengths</h3>
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Gaps</h3>
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recommendations</h3>
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
"Body for the CV match result email (HTML formatted)");
Row("email.match.body", "ro",
"Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}",
"Corpul emailului pentru rezultatul potrivirii CV");
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Report Potrivire CV</h2>
<table style=""width: 100%; border-collapse: collapse; margin-bottom: 30px; border: 1px solid #ddd;"">
<tr style=""background-color: #2c5282; color: white;"">
<td style=""padding: 12px 15px; font-weight: bold; width: 35%;"">ID Document CV</td>
<td style=""padding: 12px 15px;"">{{cvDocumentId}}</td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""padding: 12px 15px; font-weight: bold;"">Job</td>
<td style=""padding: 12px 15px;"">{{jobLabel}}</td>
</tr>
<tr>
<td style=""padding: 12px 15px; font-weight: bold;"">URL</td>
<td style=""padding: 12px 15px;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""padding: 12px 15px; font-weight: bold;"">Scor</td>
<td style=""padding: 12px 15px; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
</tr>
</table>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Rezumat</h3>
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Puncte Forte</h3>
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Lipsuri</h3>
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recomandări</h3>
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
"Corpul emailului pentru rezultatul potrivirii CV (format HTML)");
// Match result email — job search CTA footer
// Match result email — job search CTA footer (HTML formatted)
Row("email.match.job-search-footer", "en",
"\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)",
"Job search CTA appended to match result email");
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Want to find more jobs matching your CV?</p>
<p style=""margin-bottom: 20px;""><a href=""{{jobSearchLink}}"" style=""background-color: #2c5282; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;"">Search Jobs</a></p>
<p style=""font-size: 12px; color: #666;"">(link valid for {{expiryDays}} days)</p>",
"Job search CTA appended to match result email (HTML formatted)");
Row("email.match.job-search-footer", "ro",
"\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)",
"CTA cautare joburi adaugat la emailul de potrivire CV");
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Vrei să găsești mai multe joburi potrivite CV-ului tău?</p>
<p style=""margin-bottom: 20px;""><a href=""{{jobSearchLink}}"" style=""background-color: #2c5282; color: white; padding: 10px 20px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;"">Caută Joburi</a></p>
<p style=""font-size: 12px; color: #666;"">(link valabil {{expiryDays}} zile)</p>",
"CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)");
// Job search results email — subject
Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email");
Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi");
// Job search results email — body preamble (items appended in code)
Row("email.search-results.body", "en", "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}", "Body preamble for job search results email");
Row("email.search-results.body", "ro", "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}", "Corpul emailului de rezultate cautare joburi");
// Job search results email — body preamble (items appended in code) - HTML formatted
Row("email.search-results.body", "en",
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Job Search Results</h2>
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro found <strong>{{count}}</strong> jobs matching your CV:</p>
<div style=""margin-top: 20px; color: #333;"">
{{items}}
</div>",
"Body preamble for job search results email (HTML formatted)");
Row("email.search-results.body", "ro",
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Rezultate Căutare Joburi</h2>
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro a găsit <strong>{{count}}</strong> joburi potrivite CV-ului tău:</p>
<div style=""margin-top: 20px; color: #333;"">
{{items}}
</div>",
"Corpul emailului de rezultate cautare joburi (format HTML)");
// Job search results email — no results found
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
// Job search results email — no results found - HTML formatted
Row("email.search-results.empty", "en",
@"<div style=""background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;"">
<p style=""margin: 0; color: #856404;"">
<strong>No jobs found</strong><br>
MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results.
</p>
</div>",
"No results message for job search results email (HTML formatted)");
Row("email.search-results.empty", "ro",
@"<div style=""background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 15px; margin: 20px 0;"">
<p style=""margin: 0; color: #856404;"">
<strong>Niciun job găsit</strong><br>
MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune.
</p>
</div>",
"Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)");
// HTML job-search start page messages
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");