feat: DB-backed localized templates + language-aware emails

- New Apis/myai-models project: MyAiDbContext (schema myAi), TemplateEntity,
  ITemplateService, DbTemplateService with 10-min in-memory cache
- Seeds EN+RO variants for all user-facing templates (match email, job search
  results email, HTML status pages, AI system prompt)
- Match result email now sent in user's UI language (en/ro)
- Job search results email now respects session language
- Language propagates: MatchJobRequest -> token -> session -> email
- Add Language column to JobSearchTokens and JobSearchSessions (default 'en')
- All three Dockerfiles updated to include myai-models in build context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:06:44 +03:00
parent 2cada13fe3
commit fc6fe7a78b
34 changed files with 927 additions and 98 deletions
@@ -5,17 +5,20 @@ using MailKit.Security;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MimeKit;
using MyAi.Models.Services;
namespace CvSearchJob.Services;
public sealed class CvSearchEmailSender
{
private readonly IConfiguration _config;
private readonly ITemplateService _templates;
private readonly ILogger<CvSearchEmailSender> _logger;
public CvSearchEmailSender(IConfiguration config, ILogger<CvSearchEmailSender> logger)
public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger<CvSearchEmailSender> logger)
{
_config = config;
_templates = templates;
_logger = logger;
}
@@ -23,6 +26,7 @@ public sealed class CvSearchEmailSender
string toEmail,
string? attachmentPath,
IReadOnlyList<JobSearchResultEntity> results,
string language,
CancellationToken ct)
{
var smtpHost = _config["Smtp:Host"];
@@ -42,8 +46,9 @@ public sealed class CvSearchEmailSender
if (recipients.Count == 0) return;
var body = BuildBody(results);
var subject = $"MyAi.ro: {results.Count} joburi potrivite CV-ului tau";
var body = BuildBody(results, language);
var subject = _templates.Render("email.search-results.subject", language,
("count", results.Count.ToString()));
var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
foreach (var recipient in recipients)
@@ -77,27 +82,26 @@ public sealed class CvSearchEmailSender
}
}
private static string BuildBody(IReadOnlyList<JobSearchResultEntity> results)
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, string language)
{
if (results.Count == 0)
return "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.";
var lines = new System.Text.StringBuilder();
lines.AppendLine($"MyAi.ro a gasit {results.Count} joburi potrivite CV-ului tau:");
lines.AppendLine();
return _templates.Get("email.search-results.empty", language);
var items = new System.Text.StringBuilder();
for (int i = 0; i < results.Count; i++)
{
var r = results[i];
var matchResp = TryParseResult(r.ResultJson);
lines.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]");
lines.AppendLine($" {r.JobUrl}");
items.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]");
items.AppendLine($" {r.JobUrl}");
if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary))
lines.AppendLine($" {matchResp.Summary}");
lines.AppendLine();
items.AppendLine($" {matchResp.Summary}");
items.AppendLine();
}
return lines.ToString();
return _templates.Render("email.search-results.body", language,
("count", results.Count.ToString()),
("items", items.ToString()));
}
private static JobMatchResponse? TryParseResult(string json)