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
+4 -1
View File
@@ -1,4 +1,5 @@
using Models.Requests;
using CvMatcher.Models.Responses;
using Models.Requests;
namespace Api.Services.Contracts
{
@@ -8,5 +9,7 @@ namespace Api.Services.Contracts
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct);
string BuildMatchEmailSubject(int score, string? jobLabel, string language);
string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
}
}
+33 -34
View File
@@ -6,6 +6,7 @@ using MimeKit;
using Models.Settings;
using Models.Requests;
using CvMatcher.Models.Responses;
using MyAi.Models.Services;
namespace Api.Services
{
@@ -15,27 +16,29 @@ namespace Api.Services
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ITemplateService _templates;
private readonly ILogger<SmtpEmailSender> _log;
private readonly string _environmentName;
public SmtpEmailSender(IOptions<SmtpSettings> smtp,
public SmtpEmailSender(
IOptions<SmtpSettings> smtp,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ITemplateService templates,
ILogger<SmtpEmailSender> log)
{
_smtp = smtp.Value;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_templates = templates;
_log = log;
// Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development"
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since contact requests are important to process.
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
@@ -71,7 +74,6 @@ namespace Api.Services
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since subscription requests are important to process.
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
@@ -101,7 +103,6 @@ namespace Api.Services
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
// Skip sending if ToEmail is not configured
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
@@ -135,8 +136,6 @@ namespace Api.Services
/// </summary>
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
{
// If you're in enterprise environments, you may need to tweak certificate validation.
// Don't disable it casually.
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
@@ -212,43 +211,43 @@ namespace Api.Services
await SendEmailAsync(msg, "cv match email", ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
}
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null)
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
var body = $@"CV Matcher result
var strengths = result.Strengths?.Count > 0
? "- " + string.Join("\n- ", result.Strengths)
: string.Empty;
var gaps = result.Gaps?.Count > 0
? "- " + string.Join("\n- ", result.Gaps)
: string.Empty;
var recommendations = result.Recommendations?.Count > 0
? "- " + string.Join("\n- ", result.Recommendations)
: string.Empty;
CV Document ID: {cvDocumentId}
Job: {jobLabel ?? "N/A"}
Job URL: {result.JobUrl ?? "N/A"}
Score: {result.Score}%
Summary:
{result.Summary}
Strengths:
- {string.Join("\n- ", result.Strengths)}
Gaps:
- {string.Join("\n- ", result.Gaps)}
Recommendations:
- {string.Join("\n- ", result.Recommendations)}";
var body = _templates.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),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += $@"
---
Vrei sa gasesti mai multe joburi potrivite CV-ului tau?
Click: {jobSearchLink}
(link valabil 7 zile)";
body += _templates.Render("email.match.job-search-footer", language,
("jobSearchLink", jobSearchLink),
("expiryDays", expiryDays.ToString()));
}
return body;
}
public static string BuildMatchEmailSubject(int score, string? jobLabel)
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_templates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
}
}