feat: replace SmtpEmailSender with EmailApiEmailSender in api

- EmailApiEmailSender calls email-api via IEmailApiClient Refit client
- HTML bodies built inline for contact/subscribe/file-download emails
- match and job-search emails use DB templates (rendered in caller)
- SmtpSettings moved from api-models to email-api (kept in Models.Settings namespace)
- MailKit removed from api.csproj
- SmtpEmailSender deleted; IEmailSender interface unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 16:18:11 +03:00
parent 26b13f6dbf
commit 8126e7c112
8 changed files with 298 additions and 267 deletions
+226
View File
@@ -0,0 +1,226 @@
using Api.Services.Contracts;
using CvMatcher.Models.Responses;
using EmailApi.Models.Clients;
using EmailApi.Models.Requests;
using Microsoft.Extensions.Options;
using Models.Requests;
using Models.Settings;
using MyAi.Data.Services;
namespace Api.Services;
public sealed class EmailApiEmailSender : IEmailSender
{
private readonly IEmailApiClient _emailApi;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ITemplateService _templates;
private readonly ILogger<EmailApiEmailSender> _log;
public EmailApiEmailSender(
IEmailApiClient emailApi,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ITemplateService templates,
ILogger<EmailApiEmailSender> log)
{
_emailApi = emailApi;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_templates = templates;
_log = log;
}
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);
}
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);
}
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 ?? "Unknown"}</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);
}
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
recipients.Add(explicitTo);
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
recipients.Add(_contact.ToEmail);
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);
}
}
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
var strengths = result.Strengths?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
var gaps = result.Gaps?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
var recommendations = result.Recommendations?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
var body = _templates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"),
("jobUrl", result.JobUrl ?? "N/A"),
("score", result.Score.ToString()),
("summary", result.Summary ?? string.Empty),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += _templates.Render("email.match.job-search-footer", language,
("jobSearchLink", jobSearchLink),
("expiryDays", expiryDays.ToString()));
}
return body;
}
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_templates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
}
-253
View File
@@ -1,253 +0,0 @@
using Api.Services.Contracts;
using Microsoft.Extensions.Options;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Models.Settings;
using Models.Requests;
using CvMatcher.Models.Responses;
using MyAi.Data.Services;
namespace Api.Services
{
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpSettings _smtp;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ITemplateService _templates;
private readonly ILogger<SmtpEmailSender> _log;
private readonly string _environmentName;
public SmtpEmailSender(
IOptions<SmtpSettings> smtp,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ITemplateService templates,
ILogger<SmtpEmailSender> log)
{
_smtp = smtp.Value;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_templates = templates;
_log = log;
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
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 msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_contact.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim();
var body =
$@"New contact form submission:
Name: {req.Name}
Email: {req.Email}
Subject: {req.Subject}
Message:
{req.Message}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "contact email", ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
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 msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim();
var body =
$@"New subscription request:
Email: {req.Email}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "subscription email", ct);
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
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 msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail));
msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim();
var body =
$@"File download notification:
File: {fileName}
Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address: {userIp ?? "Unknown"}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "file download notification email", ct);
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <summary>
/// Connects to the SMTP server and authenticates if credentials are configured.
/// </summary>
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
{
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
_smtp.Host, _smtp.Port, tls);
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
if (!string.IsNullOrWhiteSpace(_smtp.Username))
{
_log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username);
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
}
}
/// <summary>
/// Sends an email message using SMTP.
/// </summary>
/// <param name="message">The email message to send.</param>
/// <param name="messageType">Description of the message type for logging purposes.</param>
/// <param name="ct">Cancellation token.</param>
private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct)
{
using var client = new SmtpClient();
await ConnectAndAuthenticateAsync(client, ct);
_log.LogDebug("Sending {MessageType} message", messageType);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
{
recipients.Add(explicitTo);
}
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
{
recipients.Add(_contact.ToEmail);
}
if (recipients.Count == 0)
{
_log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)");
return;
}
foreach (var recipient in recipients)
{
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(recipient));
msg.Subject = $"[{_environmentName}] {subject}".Trim();
var builder = new BodyBuilder
{
TextBody = body
};
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
{
builder.Attachments.Add(attachmentPath);
}
msg.Body = builder.ToMessageBody();
await SendEmailAsync(msg, "cv match email", ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
var strengths = result.Strengths?.Count > 0
? "- " + string.Join("\n- ", result.Strengths)
: string.Empty;
var gaps = result.Gaps?.Count > 0
? "- " + string.Join("\n- ", result.Gaps)
: string.Empty;
var recommendations = result.Recommendations?.Count > 0
? "- " + string.Join("\n- ", result.Recommendations)
: string.Empty;
var body = _templates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"),
("jobUrl", result.JobUrl ?? "N/A"),
("score", result.Score.ToString()),
("summary", result.Summary ?? string.Empty),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += _templates.Render("email.match.job-search-footer", language,
("jobSearchLink", jobSearchLink),
("expiryDays", expiryDays.ToString()));
}
return body;
}
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_templates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
}
}