1e8758796e
- Frontend: update extractApiError to check body.code first via i18n 'error.<code>' keys; add en/ro translations for cv_file_missing, captcha_verification_failed, request_cancelled - email-data migration: seed 6 fallback template keys (match N/A, subject label, unknown IP, job search results empty states for keywords/providers/location) - EmailApiEmailSender: replace "N/A", "Job", "Unknown" literals with template lookups - CvSearchEmailSender: replace "none detected", "none", "-" literals with template lookups - cv-matcher-data migration: seed parse-error.summary and parse-error.recommendation in AiPrompts - CvMatcherService: look up localized parse-error messages from AiPrompts before calling ParseResult Closes #53 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
160 lines
6.8 KiB
C#
160 lines
6.8 KiB
C#
using CvMatcher.Models.Responses;
|
|
using CvSearch.Data.Entities;
|
|
using Email.Data.Services;
|
|
using Email.Models.Clients;
|
|
using Email.Models.Requests;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace CvSearchJob.Services;
|
|
|
|
/// <summary>
|
|
/// Sends job search results emails to the session user and the operator copy address,
|
|
/// with an optional CV PDF attachment.
|
|
/// </summary>
|
|
public sealed class CvSearchEmailSender
|
|
{
|
|
private readonly IEmailApiClient _emailApi;
|
|
private readonly IEmailTemplateService _emailTemplates;
|
|
private readonly ILogger<CvSearchEmailSender> _logger;
|
|
|
|
public CvSearchEmailSender(
|
|
IEmailApiClient emailApi,
|
|
IEmailTemplateService emailTemplates,
|
|
ILogger<CvSearchEmailSender> logger)
|
|
{
|
|
_emailApi = emailApi;
|
|
_emailTemplates = emailTemplates;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds and sends the job search results email.
|
|
/// Resolves the recipient list from <paramref name="toEmail"/> and the operator copy address
|
|
/// stored in the email template. Does nothing when no recipients can be resolved.
|
|
/// </summary>
|
|
/// <param name="toEmail">Primary recipient (the user who triggered the search).</param>
|
|
/// <param name="attachmentFileName">Relative filename of the CV PDF to attach, or <c>null</c>.</param>
|
|
/// <param name="results">Ranked list of job search results to include in the email body.</param>
|
|
/// <param name="keywords">CV keywords used to drive the job search.</param>
|
|
/// <param name="providerNames">Names of the providers that were scanned.</param>
|
|
/// <param name="language">Two-letter language code for template rendering.</param>
|
|
/// <param name="ct">Cancellation token.</param>
|
|
public async Task SendResultsAsync(
|
|
string toEmail,
|
|
string? attachmentFileName,
|
|
IReadOnlyList<JobSearchResultEntity> results,
|
|
IReadOnlyList<string> keywords,
|
|
IReadOnlyList<string> providerNames,
|
|
string language,
|
|
string? location,
|
|
CancellationToken ct)
|
|
{
|
|
var operatorCopy = _emailTemplates.GetOperatorCopy("email.search-results.subject", language);
|
|
|
|
var recipients = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail);
|
|
if (!string.IsNullOrWhiteSpace(operatorCopy) &&
|
|
!recipients.Any(r => string.Equals(r, operatorCopy, StringComparison.OrdinalIgnoreCase)))
|
|
recipients.Add(operatorCopy);
|
|
|
|
if (recipients.Count == 0) return;
|
|
|
|
var htmlBody = BuildBody(results, keywords, providerNames, language, location);
|
|
var subject = _emailTemplates.Render("email.search-results.subject", language,
|
|
("count", results.Count.ToString()));
|
|
|
|
try
|
|
{
|
|
await _emailApi.SendAsync(new SendEmailRequest
|
|
{
|
|
To = recipients,
|
|
Subject = subject,
|
|
HtmlBody = htmlBody,
|
|
AttachmentPath = attachmentFileName
|
|
}, ct);
|
|
|
|
_logger.LogInformation("Job search results email sent to {Recipients}",
|
|
string.Join(", ", recipients));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send job search results email to {Recipients}",
|
|
string.Join(", ", recipients));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the HTML email body from the results list.
|
|
/// Returns the empty-results template when no results are present.
|
|
/// Prepends a scan summary block showing the keywords and providers used.
|
|
/// </summary>
|
|
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language, string? location)
|
|
{
|
|
var scanSummary = BuildScanSummary(keywords, providerNames, language, location);
|
|
|
|
if (results.Count == 0)
|
|
return scanSummary + _emailTemplates.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 summary = TryParseResult(r.ResultJson)?.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(_emailTemplates.Render("email.search-results.item", language,
|
|
("index", (i + 1).ToString()),
|
|
("jobTitle", r.JobTitle),
|
|
("score", r.Score.ToString()),
|
|
("providerName", r.ProviderName),
|
|
("jobUrl", r.JobUrl),
|
|
("summary", summaryHtml)));
|
|
}
|
|
|
|
return _emailTemplates.Render("email.search-results.body", language,
|
|
("count", results.Count.ToString()),
|
|
("items", scanSummary + items.ToString()));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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>
|
|
private string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language, string? location)
|
|
{
|
|
var keywordsHtml = keywords.Count > 0
|
|
? string.Join(" ", keywords.Select(k =>
|
|
$"<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;\">{_emailTemplates.Get("email.search-results.keywords-empty", language)}</span>";
|
|
|
|
var providers = providerNames.Count > 0
|
|
? string.Join(", ", providerNames)
|
|
: _emailTemplates.Get("email.search-results.providers-empty", language);
|
|
|
|
var locationDisplay = string.IsNullOrWhiteSpace(location)
|
|
? _emailTemplates.Get("email.search-results.location-empty", language)
|
|
: location;
|
|
|
|
return _emailTemplates.Render("email.search-results.scan-summary", language,
|
|
("keywordsHtml", keywordsHtml),
|
|
("providers", providers),
|
|
("location", locationDisplay));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to deserialise the stored result JSON into a <see cref="JobMatchResponse"/>.
|
|
/// Returns <c>null</c> on parse failure so the email still renders without a summary.
|
|
/// </summary>
|
|
private static JobMatchResponse? TryParseResult(string json)
|
|
{
|
|
try
|
|
{
|
|
return System.Text.Json.JsonSerializer.Deserialize<JobMatchResponse>(json,
|
|
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web));
|
|
}
|
|
catch { return null; }
|
|
}
|
|
}
|