fc6fe7a78b
- New Apis/myai-models project: MyAiDbContext (schema myAi), TemplateEntity, ITemplateService, DbTemplateService with 10-min in-memory cache - Seeds EN+RO variants for all user-facing templates (match email, job search results email, HTML status pages, AI system prompt) - Match result email now sent in user's UI language (en/ro) - Job search results email now respects session language - Language propagates: MatchJobRequest -> token -> session -> email - Add Language column to JobSearchTokens and JobSearchSessions (default 'en') - All three Dockerfiles updated to include myai-models in build context Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
254 lines
9.8 KiB
C#
254 lines
9.8 KiB
C#
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.Models.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"));
|
|
}
|
|
}
|