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>
246 lines
10 KiB
C#
246 lines
10 KiB
C#
using Api.Services.Contracts;
|
|
using CvMatcher.Models.Responses;
|
|
using Email.Data.Services;
|
|
using Email.Models.Clients;
|
|
using Email.Models.Requests;
|
|
using Microsoft.Extensions.Options;
|
|
using Models.Requests;
|
|
using Models.Settings;
|
|
using System.Net;
|
|
|
|
namespace Api.Services;
|
|
|
|
/// <summary>
|
|
/// Implements <see cref="IEmailSender"/> by delegating all email dispatch to the internal email-api service via Refit.
|
|
/// </summary>
|
|
public sealed class EmailApiEmailSender : IEmailSender
|
|
{
|
|
private readonly IEmailApiClient _emailApi;
|
|
private readonly ContactSettings _contact;
|
|
private readonly SubscribeSettings _subscribe;
|
|
private readonly FileStorageSettings _fileStorage;
|
|
private readonly IEmailTemplateService _emailTemplates;
|
|
private readonly ILogger<EmailApiEmailSender> _log;
|
|
|
|
public EmailApiEmailSender(
|
|
IEmailApiClient emailApi,
|
|
IOptions<ContactSettings> contact,
|
|
IOptions<SubscribeSettings> subscribe,
|
|
IOptions<FileStorageSettings> fileStorage,
|
|
IEmailTemplateService emailTemplates,
|
|
ILogger<EmailApiEmailSender> log)
|
|
{
|
|
_emailApi = emailApi;
|
|
_contact = contact.Value;
|
|
_subscribe = subscribe.Value;
|
|
_fileStorage = fileStorage.Value;
|
|
_emailTemplates = emailTemplates;
|
|
_log = log;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
|
|
{
|
|
_log.LogDebug("Contact email skipped - ToEmail not configured");
|
|
throw new InvalidOperationException("Contact email recipient is not configured.");
|
|
}
|
|
|
|
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
|
|
req.Email, _contact.ToEmail);
|
|
|
|
var htmlBody = $"""
|
|
<h2 style="color:#2c5282;margin:0 0 20px">New Contact Message</h2>
|
|
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:24px">
|
|
<tr style="background:#f8f9fa">
|
|
<td style="font-weight:600;width:100px;border:1px solid #dee2e6;color:#495057">Name</td>
|
|
<td style="border:1px solid #dee2e6">{req.Name}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Email</td>
|
|
<td style="border:1px solid #dee2e6">{req.Email}</td>
|
|
</tr>
|
|
<tr style="background:#f8f9fa">
|
|
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Subject</td>
|
|
<td style="border:1px solid #dee2e6">{req.Subject}</td>
|
|
</tr>
|
|
</table>
|
|
<h3 style="color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px">Message</h3>
|
|
<p style="color:#495057;line-height:1.7;white-space:pre-wrap">{req.Message}</p>
|
|
""";
|
|
|
|
await _emailApi.SendAsync(new SendEmailRequest
|
|
{
|
|
To = [_contact.ToEmail],
|
|
ReplyTo = req.Email,
|
|
Subject = $"{_contact.SubjectPrefix} {req.Subject}".Trim(),
|
|
HtmlBody = htmlBody
|
|
}, ct);
|
|
|
|
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
|
|
{
|
|
_log.LogDebug("Subscription email skipped - ToEmail not configured");
|
|
throw new InvalidOperationException("Subscription email recipient is not configured.");
|
|
}
|
|
|
|
_log.LogInformation("Processing subscription request for {Email}", req.Email);
|
|
|
|
var htmlBody = $"""
|
|
<h2 style="color:#2c5282;margin:0 0 20px">New Subscription Request</h2>
|
|
<p style="color:#495057">A new user has subscribed:</p>
|
|
<table cellpadding="10" cellspacing="0" style="border-collapse:collapse;margin-top:12px">
|
|
<tr style="background:#f8f9fa">
|
|
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057;padding:10px 16px">Email</td>
|
|
<td style="border:1px solid #dee2e6;padding:10px 16px">{req.Email}</td>
|
|
</tr>
|
|
</table>
|
|
""";
|
|
|
|
await _emailApi.SendAsync(new SendEmailRequest
|
|
{
|
|
To = [_subscribe.ToEmail],
|
|
ReplyTo = req.Email,
|
|
Subject = _subscribe.SubjectPrefix.Trim(),
|
|
HtmlBody = htmlBody
|
|
}, ct);
|
|
|
|
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
|
|
{
|
|
_log.LogDebug("File download notification skipped - ToEmail not configured");
|
|
return;
|
|
}
|
|
|
|
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
|
|
|
|
var htmlBody = $"""
|
|
<h2 style="color:#2c5282;margin:0 0 20px">File Download Notification</h2>
|
|
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse">
|
|
<tr style="background:#f8f9fa">
|
|
<td style="font-weight:600;width:120px;border:1px solid #dee2e6;color:#495057">File</td>
|
|
<td style="border:1px solid #dee2e6">{fileName}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Downloaded at</td>
|
|
<td style="border:1px solid #dee2e6">{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</td>
|
|
</tr>
|
|
<tr style="background:#f8f9fa">
|
|
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
|
|
<td style="border:1px solid #dee2e6">{userIp ?? _emailTemplates.Get("email.notification.unknown-ip", "en")}</td>
|
|
</tr>
|
|
</table>
|
|
""";
|
|
|
|
await _emailApi.SendAsync(new SendEmailRequest
|
|
{
|
|
To = [_fileStorage.ToEmail],
|
|
Subject = $"{_fileStorage.SubjectPrefix} {fileName}".Trim(),
|
|
HtmlBody = htmlBody
|
|
}, ct);
|
|
|
|
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
|
|
{
|
|
var operatorCopy = _emailTemplates.GetOperatorCopy("email.match.subject", "en");
|
|
|
|
var recipients = new List<string>();
|
|
if (!string.IsNullOrWhiteSpace(explicitTo))
|
|
recipients.Add(explicitTo);
|
|
|
|
if (!string.IsNullOrWhiteSpace(operatorCopy) &&
|
|
!recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase)))
|
|
recipients.Add(operatorCopy);
|
|
|
|
if (recipients.Count == 0)
|
|
{
|
|
_log.LogDebug("Match email skipped - no recipients configured");
|
|
return;
|
|
}
|
|
|
|
string? relativeAttachment = null;
|
|
if (!string.IsNullOrWhiteSpace(attachmentPath))
|
|
relativeAttachment = Path.GetFileName(attachmentPath);
|
|
|
|
foreach (var recipient in recipients)
|
|
{
|
|
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
|
|
|
|
await _emailApi.SendAsync(new SendEmailRequest
|
|
{
|
|
To = [recipient],
|
|
Subject = subject,
|
|
HtmlBody = body,
|
|
AttachmentPath = relativeAttachment
|
|
}, ct);
|
|
|
|
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
|
{
|
|
// Build HTML lists for strengths, gaps, and recommendations
|
|
var strengths = result.Strengths?.Count > 0
|
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
|
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
|
|
|
var gaps = result.Gaps?.Count > 0
|
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
|
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
|
|
|
var recommendations = result.Recommendations?.Count > 0
|
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
|
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
|
|
|
// Render the HTML template with substituted values
|
|
// email.match.body is now stored as HTML in the database
|
|
var body = _emailTemplates.Render("email.match.body", language,
|
|
("cvDocumentId", cvDocumentId),
|
|
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.fallback-na", language)),
|
|
("jobUrl", result.JobUrl ?? _emailTemplates.Get("email.match.fallback-na", language)),
|
|
("score", result.Score.ToString()),
|
|
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
|
|
("strengths", strengths),
|
|
("gaps", gaps),
|
|
("recommendations", recommendations));
|
|
|
|
// Append the job search footer if link is provided
|
|
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
|
{
|
|
body += _emailTemplates.Render("email.match.job-search-footer", language,
|
|
("jobSearchLink", jobSearchLink),
|
|
("expiryDays", expiryDays.ToString()));
|
|
}
|
|
|
|
return body;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
|
_emailTemplates.Render("email.match.subject", language,
|
|
("score", score.ToString()),
|
|
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.subject-fallback-label", language)));
|
|
|
|
public string GetManualJobLabel(string language) =>
|
|
_emailTemplates.Get("email.match.manual-job-label", language);
|
|
}
|