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;
///
/// Sends job search results emails to the session user and the operator copy address,
/// with an optional CV PDF attachment.
///
public sealed class CvSearchEmailSender
{
private readonly IEmailApiClient _emailApi;
private readonly IEmailTemplateService _emailTemplates;
private readonly ILogger _logger;
public CvSearchEmailSender(
IEmailApiClient emailApi,
IEmailTemplateService emailTemplates,
ILogger logger)
{
_emailApi = emailApi;
_emailTemplates = emailTemplates;
_logger = logger;
}
///
/// Builds and sends the job search results email.
/// Resolves the recipient list from and the operator copy address
/// stored in the email template. Does nothing when no recipients can be resolved.
///
/// Primary recipient (the user who triggered the search).
/// Relative filename of the CV PDF to attach, or null.
/// Ranked list of job search results to include in the email body.
/// CV keywords used to drive the job search.
/// Names of the providers that were scanned.
/// Two-letter language code for template rendering.
/// Cancellation token.
public async Task SendResultsAsync(
string toEmail,
string? attachmentFileName,
IReadOnlyList results,
IReadOnlyList keywords,
IReadOnlyList providerNames,
string language,
string? location,
CancellationToken ct)
{
var operatorCopy = _emailTemplates.GetOperatorCopy("email.search-results.subject", language);
var recipients = new List();
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));
}
}
///
/// 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.
///
private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList 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)
? ""
: $"{summary}
";
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()));
}
///
/// 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.
///
private string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames, string language, string? location)
{
var keywordsHtml = keywords.Count > 0
? string.Join(" ", keywords.Select(k =>
$"{k}"))
: $"{_emailTemplates.Get("email.search-results.keywords-empty", language)}";
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));
}
///
/// Attempts to deserialise the stored result JSON into a .
/// Returns null on parse failure so the email still renders without a summary.
///
private static JobMatchResponse? TryParseResult(string json)
{
try
{
return System.Text.Json.JsonSerializer.Deserialize(json,
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web));
}
catch { return null; }
}
}