Files
myAi/Jobs/cv-search-job/Services/CvSearchEmailSender.cs
T
claude 9cf3db089d fix(cv-search-job): separate keyword badges with whitespace in results email
string.Join("") produced no whitespace between inline-block spans,
causing keywords to visually merge in email clients that collapse margins.
Switched to string.Join(" ") and zeroed left margin on each badge so
they wrap cleanly without a gap on the first item.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 13:05:33 +03:00

156 lines
6.7 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,
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);
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)
{
var scanSummary = BuildScanSummary(keywords, providerNames);
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 matchResp = TryParseResult(r.ResultJson);
var summary = matchResp?.Summary;
items.Append($"""
<div style="border:1px solid #dee2e6;border-radius:6px;padding:16px;margin-bottom:12px">
<strong style="color:#212529">{i + 1}. {r.JobTitle}</strong>
<span style="background:#28a745;color:#fff;padding:2px 8px;border-radius:12px;font-size:12px;margin-left:8px">{r.Score}% match</span>
<span style="color:#6c757d;font-size:12px;margin-left:4px">[{r.ProviderName}]</span><br>
<a href="{r.JobUrl}" style="color:#2c5282;font-size:13px">{r.JobUrl}</a>
{(string.IsNullOrWhiteSpace(summary) ? "" : $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5\">{summary}</p>")}
</div>
""");
}
return _emailTemplates.Render("email.search-results.body", language,
("count", results.Count.ToString()),
("items", scanSummary + items.ToString()));
}
/// <summary>
/// Builds the scan summary block showing the CV keywords and providers used for the search.
/// </summary>
private static string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames)
{
var keywordsHtml = keywords.Count > 0
? string.Join(" ", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;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 providersText = providerNames.Count > 0
? string.Join(", ", providerNames)
: "none";
return $"""
<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:14px 16px;margin-bottom:18px;font-size:13px;color:#495057">
<div style="margin-bottom:8px"><strong>Keywords used:</strong>&nbsp;{keywordsHtml}</div>
<div><strong>Providers scanned:</strong>&nbsp;{providersText}</div>
</div>
""";
}
/// <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; }
}
}