Files
myAi/Jobs/cv-search-job/Services/CvSearchEmailSender.cs
T
claude c89df975bd
Build and Push Docker Images Staging / build (push) Successful in 14m42s
Add searched location to job search results email
Show the candidate's location in the scan summary block of the results email
alongside keywords and providers, for both en and ro templates.

- CvSearchEmailSender.SendResultsAsync accepts location and passes it to BuildScanSummary
- BuildScanSummary passes {{location}} to the template (falls back to '-' when absent)
- CvSearchJobTask passes session.Location to SendResultsAsync
- New migration AddLocationToScanSummaryTemplate updates both language variants of
  email.search-results.scan-summary to include a 'Location / Locație căutată' row

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:54:38 +03:00

158 lines
6.6 KiB
C#

using CvMatcher.Models.Responses;
using CvSearch.Data.Entities;
using Email.Data.Services;
using EmailApi.Models.Clients;
using EmailApi.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;\">none detected</span>";
var providers = providerNames.Count > 0
? string.Join(", ", providerNames)
: "none";
var locationDisplay = string.IsNullOrWhiteSpace(location) ? "-" : 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; }
}
}