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 _log; private readonly string _environmentName; public SmtpEmailSender( IOptions smtp, IOptions contact, IOptions subscribe, IOptions fileStorage, ITemplateService templates, ILogger 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); } /// /// Connects to the SMTP server and authenticates if credentials are configured. /// 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); } } /// /// Sends an email message using SMTP. /// /// The email message to send. /// Description of the message type for logging purposes. /// Cancellation token. 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(); 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")); } }