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}")) : "none detected"; 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)); } /// /// 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; } } }