Fix Outlook email layout and move all HTML/prompts out of code #38

Merged
claude merged 8 commits from main into staging 2026-06-01 17:30:18 +00:00
9 changed files with 282 additions and 129 deletions
+9 -23
View File
@@ -246,38 +246,24 @@ public sealed class CvMatcherController : ControllerBase
{ {
var result = await _jobSearchApi.StartSearchAsync(t, ct); var result = await _jobSearchApi.StartSearchAsync(t, ct);
var lang = "en"; var lang = "en";
var html = result.Status switch var (title, message) = result.Status switch
{ {
StartJobSearchStatus.Started => StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
StartJobSearchStatus.AlreadyUsed => StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), _ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
StartJobSearchStatus.Expired =>
HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
_ =>
HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
}; };
return Content(html, "text/html"); return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Job search start failed for token {Token}.", t); _logger.LogError(ex, "Job search start failed for token {Token}.", t);
return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html"); var title = _templates.Get("html.job-search.error.title", "en");
var message = _templates.Get("html.job-search.error.message", "en");
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
} }
} }
private static string HtmlPage(string title, string message) => $$"""
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>{{title}} - MyAi.ro</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}
h1{font-size:1.4rem;margin-bottom:.5rem}p{color:#555}</style>
</head>
<body><div class="card"><h1>{{title}}</h1><p>{{message}}</p></div></body>
</html>
""";
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
{ {
try try
+12 -7
View File
@@ -6,6 +6,7 @@ using EmailApi.Models.Requests;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Models.Requests; using Models.Requests;
using Models.Settings; using Models.Settings;
using System.Net;
namespace Api.Services; namespace Api.Services;
@@ -194,31 +195,35 @@ public sealed class EmailApiEmailSender : IEmailSender
/// <inheritdoc /> /// <inheritdoc />
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) 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 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>" 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 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>" 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 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>" 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, var body = _emailTemplates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId), ("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"), ("jobLabel", jobLabel ?? "N/A"),
("jobUrl", result.JobUrl ?? "N/A"), ("jobUrl", result.JobUrl ?? "N/A"),
("score", result.Score.ToString()), ("score", result.Score.ToString()),
("summary", result.Summary ?? string.Empty), ("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
("strengths", strengths), ("strengths", strengths),
("gaps", gaps), ("gaps", gaps),
("recommendations", recommendations)); ("recommendations", recommendations));
// Append the job search footer if link is provided
if (!string.IsNullOrWhiteSpace(jobSearchLink)) if (!string.IsNullOrWhiteSpace(jobSearchLink))
{ {
body += _emailTemplates.Render("email.match.job-search-footer", language, body += _emailTemplates.Render("email.match.job-search-footer", language,
@@ -82,12 +82,12 @@ namespace CvMatcher.Data.Migrations
// AI system prompt for CV matching — English // AI system prompt for CV matching — English
Row("ai.cv-match.system-prompt", "en", Row("ai.cv-match.system-prompt", "en",
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\",\"strength 2 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"]}", "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."); "System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.");
// AI system prompt for CV matching — Romanian // AI system prompt for CV matching — Romanian
Row("ai.cv-match.system-prompt", "ro", Row("ai.cv-match.system-prompt", "ro",
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\",\"punct forte 2 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"]}", "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."); "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.");
} }
@@ -44,59 +44,180 @@ 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", "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"); 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", 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}}", @"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">CV Match Report</h2>
"Body for the CV match result email"); <table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
<tr style=""background-color: #2c5282; color: white;"">
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">CV ID</td>
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
</tr>
<tr>
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Score</td>
<td style=""padding: 12px 15px; border: 1px solid #ddd; 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", 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}}", @"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Report Potrivire CV</h2>
"Corpul emailului pentru rezultatul potrivirii CV"); <table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
<tr style=""background-color: #2c5282; color: white;"">
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">ID Document CV</td>
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
</tr>
<tr>
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
</tr>
<tr style=""background-color: #f8f9fa;"">
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Scor</td>
<td style=""padding: 12px 15px; border: 1px solid #ddd; 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", 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)", @"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
"Job search CTA appended to match result email"); <p style=""margin-top: 20px; font-size: 14px; color: #333;"">Want to find more jobs matching your CV?</p>
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
<tr>
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Search Jobs</a>
</td>
</tr>
</table>
<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", 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)", @"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
"CTA cautare joburi adaugat la emailul de potrivire CV"); <p style=""margin-top: 20px; font-size: 14px; color: #333;"">Vrei să găsești mai multe joburi potrivite CV-ului tău?</p>
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
<tr>
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Caută Joburi</a>
</td>
</tr>
</table>
<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 // 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", "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"); 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) // Job search results email — body preamble (items appended in code) - HTML formatted
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", "en",
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"); @"<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>
{{items}}",
"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>
{{items}}",
"Corpul emailului de rezultate cautare joburi (format HTML)");
// Job search results email — no results found // Job search results email — scan summary block (keywords + providers used)
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.scan-summary", "en",
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"); @"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
<tr>
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
<div style=""margin-bottom: 8px;""><strong>Keywords used:</strong>&nbsp;{{keywordsHtml}}</div>
<div><strong>Providers scanned:</strong>&nbsp;{{providers}}</div>
</td>
</tr>
</table>",
"Scan summary block prepended to job search results email (HTML formatted)");
Row("email.search-results.scan-summary", "ro",
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
<tr>
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
<div style=""margin-bottom: 8px;""><strong>Cuvinte cheie folosite:</strong>&nbsp;{{keywordsHtml}}</div>
<div><strong>Furnizori scanați:</strong>&nbsp;{{providers}}</div>
</td>
</tr>
</table>",
"Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)");
// HTML job-search start page messages // Job search results email — single job result item card
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); Row("email.search-results.item", "en",
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page"); @"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita"); <tr>
Row("html.job-search.started.message", "ro", "Căutarea joburilor a început. Rezultatele vor fi trimise pe email în scurt timp.", "Mesaj pagina cautare joburi pornita"); <td style=""padding: 12px 16px; background-color: #ffffff;"">
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% match</span>
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
{{summary}}
</td>
</tr>
</table>",
"Single job result card in job search results email (HTML formatted)");
Row("email.search-results.item", "ro",
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
<tr>
<td style=""padding: 12px 16px; background-color: #ffffff;"">
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% potrivire</span>
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
{{summary}}
</td>
</tr>
</table>",
"Card job individual in emailul de rezultate cautare joburi (format HTML)");
Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page"); // Job search results email — no results found - HTML formatted
Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page"); Row("email.search-results.empty", "en",
Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit"); @"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit"); <tr>
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
<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>
</td>
</tr>
</table>",
"No results message for job search results email (HTML formatted)");
Row("email.search-results.empty", "ro",
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
<tr>
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
<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>
</td>
</tr>
</table>",
"Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)");
Row("html.job-search.expired.title", "en", "Link expired", "Title for expired link page");
Row("html.job-search.expired.message", "en", "This job search link has expired. Please request a new CV match to get a fresh link.", "Message for expired link page");
Row("html.job-search.expired.title", "ro", "Link expirat", "Titlu pagina link expirat");
Row("html.job-search.expired.message", "ro", "Acest link de cautare joburi a expirat. Solicita o noua potrivire CV pentru a primi un link nou.", "Mesaj pagina link expirat");
Row("html.job-search.invalid.title", "en", "Invalid link", "Title for invalid link page");
Row("html.job-search.invalid.message", "en", "This job search link is not valid.", "Message for invalid link page");
Row("html.job-search.invalid.title", "ro", "Link invalid", "Titlu pagina link invalid");
Row("html.job-search.invalid.message", "ro", "Acest link de cautare joburi nu este valid.", "Mesaj pagina link invalid");
Row("html.job-search.error.title", "en", "Error", "Title for error page");
Row("html.job-search.error.message", "en", "An error occurred. Please try again later.", "Message for error page");
Row("html.job-search.error.title", "ro", "Eroare", "Titlu pagina eroare");
Row("html.job-search.error.message", "ro", "A apărut o eroare. Te rugăm să încerci din nou mai târziu.", "Mesaj pagina eroare");
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Email.Data; using Email.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -18,7 +18,7 @@ namespace Email.Data.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasDefaultSchema("email") .HasDefaultSchema("email")
.HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("ProductVersion", "10.0.7")
@@ -63,7 +63,8 @@ namespace Email.Data.Migrations
b.ToTable("Templates", "email"); b.ToTable("Templates", "email");
}); });
#pragma warning restore 612, 618
#pragma warning restore 612, 618
} }
} }
} }
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Email.Data; using Email.Data;
#nullable disable #nullable disable
@@ -15,14 +15,14 @@ namespace Email.Data.Migrations
migrationBuilder.InsertData( migrationBuilder.InsertData(
table: "Templates", table: "Templates",
columns: new[] { "Key", "Language", "Value", "Description" }, columns: new[] { "Key", "Language", "Value", "Description" },
values: new object[] { "email.html-shell.start", "*", "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <style>\n body { font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; }\n .email-container { max-width: 600px; margin: 0 auto; background-color: #ffffff; }\n .email-header { background-color: #2c5282; color: white; padding: 24px; text-align: center; }\n .email-header h1 { margin: 0; font-size: 24px; font-weight: 600; }\n .email-body { padding: 24px; }\n .email-footer { background-color: #f8f9fa; padding: 16px; text-align: center; color: #6c757d; font-size: 12px; border-top: 1px solid #dee2e6; }\n </style>\n</head>\n<body>\n <div class=\"email-container\">\n <div class=\"email-header\">\n <h1>MyAi.ro</h1>\n </div>\n <div class=\"email-body\">\n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, values: new object[] { "email.html-shell.start", "*", "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background-color: #f5f5f5;\">\n <tr>\n <td align=\"center\" style=\"padding: 20px 0;\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width: 600px; max-width: 600px; background-color: #ffffff;\">\n <tr>\n <td align=\"center\" style=\"background-color: #2c5282; padding: 24px; text-align: center;\">\n <h1 style=\"margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;\">MyAi.ro</h1>\n </td>\n </tr>\n <tr>\n <td style=\"padding: 24px; background-color: #ffffff;\">\n", "Opening HTML wrapper for branded emails (blue header, white content area)" },
schema: MigrationConstants.SchemaName); schema: MigrationConstants.SchemaName);
// HTML email shell — closing tags (footer) // HTML email shell — closing tags (footer)
migrationBuilder.InsertData( migrationBuilder.InsertData(
table: "Templates", table: "Templates",
columns: new[] { "Key", "Language", "Value", "Description" }, columns: new[] { "Key", "Language", "Value", "Description" },
values: new object[] { "email.html-shell.end", "*", " </div>\n <div class=\"email-footer\">\n <p>© 2026 MyAi.ro. All rights reserved.</p>\n </div>\n </div>\n</body>\n</html>\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, values: new object[] { "email.html-shell.end", "*", "\n </td>\n </tr>\n <tr>\n <td align=\"center\" style=\"background-color: #f8f9fa; padding: 16px; text-align: center; border-top: 1px solid #dee2e6;\">\n <p style=\"margin: 0; color: #6c757d; font-size: 12px;\">&copy; 2026 MyAi.ro. All rights reserved.</p>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n</body>\n</html>\n", "Closing HTML wrapper for branded emails (footer and closing tags)" },
schema: MigrationConstants.SchemaName); schema: MigrationConstants.SchemaName);
} }
@@ -71,6 +71,11 @@ namespace MyAi.Data.Migrations
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", "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"); 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");
// HTML job-search page shell — wraps title + message in a centered card page
Row("html.job-search.shell", "*",
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>{{title}} - MyAi.ro</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}h1{font-size:1.4rem;margin-bottom:.5rem;color:#2c5282}p{color:#555}</style></head><body><div class=\"card\"><h1>{{title}}</h1><p>{{message}}</p></div></body></html>",
"Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}.");
// HTML job-search start page messages // HTML job-search start page messages
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page"); Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
using MyAi.Data;
#nullable disable
namespace MyAi.Data.Migrations
{
/// <inheritdoc />
public partial class AddHtmlJobSearchShell : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: new object[]
{
"html.job-search.shell",
"*",
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>{{title}} - MyAi.ro</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}h1{font-size:1.4rem;margin-bottom:.5rem;color:#2c5282}p{color:#555}</style></head><body><div class=\"card\"><h1>{{title}}</h1><p>{{message}}</p></div></body></html>",
"Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}."
},
schema: MigrationConstants.SchemaName);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: new object[] { "html.job-search.shell", "*" },
schema: MigrationConstants.SchemaName);
}
}
}
@@ -89,7 +89,7 @@ public sealed class CvSearchEmailSender
/// </summary> /// </summary>
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language) private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language)
{ {
var scanSummary = BuildScanSummary(keywords, providerNames); var scanSummary = BuildScanSummary(keywords, providerNames, language);
if (results.Count == 0) if (results.Count == 0)
return scanSummary + _emailTemplates.Get("email.search-results.empty", language); return scanSummary + _emailTemplates.Get("email.search-results.empty", language);
@@ -98,18 +98,18 @@ public sealed class CvSearchEmailSender
for (int i = 0; i < results.Count; i++) for (int i = 0; i < results.Count; i++)
{ {
var r = results[i]; var r = results[i];
var matchResp = TryParseResult(r.ResultJson); var summary = TryParseResult(r.ResultJson)?.Summary;
var summary = matchResp?.Summary; var summaryHtml = string.IsNullOrWhiteSpace(summary)
? ""
: $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5;\">{summary}</p>";
items.Append($""" items.Append(_emailTemplates.Render("email.search-results.item", language,
<div style="border:1px solid #dee2e6;border-radius:6px;padding:16px;margin-bottom:12px"> ("index", (i + 1).ToString()),
<strong style="color:#212529">{i + 1}. {r.JobTitle}</strong> ("jobTitle", r.JobTitle),
<span style="background:#28a745;color:#fff;padding:2px 8px;border-radius:12px;font-size:12px;margin-left:8px">{r.Score}% match</span> ("score", r.Score.ToString()),
<span style="color:#6c757d;font-size:12px;margin-left:4px">[{r.ProviderName}]</span><br> ("providerName", r.ProviderName),
<a href="{r.JobUrl}" style="color:#2c5282;font-size:13px">{r.JobUrl}</a> ("jobUrl", r.JobUrl),
{(string.IsNullOrWhiteSpace(summary) ? "" : $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5\">{summary}</p>")} ("summary", summaryHtml)));
</div>
""");
} }
return _emailTemplates.Render("email.search-results.body", language, return _emailTemplates.Render("email.search-results.body", language,
@@ -118,25 +118,23 @@ public sealed class CvSearchEmailSender
} }
/// <summary> /// <summary>
/// Builds the scan summary block showing the CV keywords and providers used for the search. /// Renders the scan summary block via template, passing keyword tags and provider list as data.
/// Keyword tags are built here because they are variable-count inline elements, not structural HTML.
/// </summary> /// </summary>
private static string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames) private string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language)
{ {
var keywordsHtml = keywords.Count > 0 var keywordsHtml = keywords.Count > 0
? string.Join(" ", keywords.Select(k => ? string.Join(" ", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px\">{k}</span>")) $"<span style=\"display:inline-block;background:#e9ecef;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px;\">{k}</span>"))
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic\">none detected</span>"; : "<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">none detected</span>";
var providersText = providerNames.Count > 0 var providers = providerNames.Count > 0
? string.Join(", ", providerNames) ? string.Join(", ", providerNames)
: "none"; : "none";
return $""" return _emailTemplates.Render("email.search-results.scan-summary", language,
<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:14px 16px;margin-bottom:18px;font-size:13px;color:#495057"> ("keywordsHtml", keywordsHtml),
<div style="margin-bottom:8px"><strong>Keywords used:</strong>&nbsp;{keywordsHtml}</div> ("providers", providers));
<div><strong>Providers scanned:</strong>&nbsp;{providersText}</div>
</div>
""";
} }
/// <summary> /// <summary>