Staging to Production #51
@@ -1,11 +0,0 @@
|
|||||||
namespace Models.Settings
|
|
||||||
{
|
|
||||||
public class SmtpSettings
|
|
||||||
{
|
|
||||||
public string Host { get; set; } = "";
|
|
||||||
public int Port { get; set; } = 587;
|
|
||||||
public string Username { get; set; } = "";
|
|
||||||
public string Password { get; set; } = "";
|
|
||||||
public bool UseStartTls { get; set; } = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+18
-2
@@ -1,6 +1,8 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Api.Services;
|
using Api.Services;
|
||||||
using Api.Services.Contracts;
|
using Api.Services.Contracts;
|
||||||
|
using EmailApi.Models.Clients;
|
||||||
|
using EmailApi.Models.Settings;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
using MyAi.Data;
|
using MyAi.Data;
|
||||||
@@ -29,10 +31,10 @@ try
|
|||||||
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
|
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
|
||||||
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
|
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
|
||||||
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
|
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
|
||||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
|
||||||
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
||||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||||
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||||
|
builder.Services.Configure<EmailApiSettings>(builder.Configuration.GetSection("EmailApi"));
|
||||||
|
|
||||||
builder.Services.AddDbContext<MyAiDbContext>(options =>
|
builder.Services.AddDbContext<MyAiDbContext>(options =>
|
||||||
{
|
{
|
||||||
@@ -46,9 +48,20 @@ try
|
|||||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
||||||
|
|
||||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
builder.Services.AddSingleton<IEmailSender, EmailApiEmailSender>();
|
||||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||||
|
|
||||||
|
static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client)
|
||||||
|
{
|
||||||
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
|
var baseUrl = config["EmailApi:BaseUrl"] ?? string.Empty;
|
||||||
|
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||||
|
var key = config["EmailApi:InternalApiKey"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||||
|
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||||
|
}
|
||||||
|
|
||||||
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
|
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
|
||||||
{
|
{
|
||||||
var config = sp.GetRequiredService<IConfiguration>();
|
var config = sp.GetRequiredService<IConfiguration>();
|
||||||
@@ -60,6 +73,9 @@ try
|
|||||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
builder.Services.AddRefitClient<IEmailApiClient>()
|
||||||
|
.ConfigureHttpClient(ConfigureEmailApiClient);
|
||||||
|
|
||||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||||
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
@@ -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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-1
@@ -19,7 +19,7 @@
|
|||||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
|
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
|
||||||
<PackageReference Include="Azure.Identity" />
|
<PackageReference Include="Azure.Identity" />
|
||||||
<PackageReference Include="DotNetEnv" />
|
<PackageReference Include="DotNetEnv" />
|
||||||
<PackageReference Include="MailKit" />
|
<!-- MailKit removed — email sending delegated to email-api service -->
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\api-models\api-models.csproj" />
|
<ProjectReference Include="..\api-models\api-models.csproj" />
|
||||||
|
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
||||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||||
<ProjectReference Include="..\common\common.csproj" />
|
<ProjectReference Include="..\common\common.csproj" />
|
||||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"profiles": {
|
||||||
|
"email-api": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
},
|
||||||
|
"applicationUrl": "https://localhost:61871;http://localhost:61872"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Models.Settings;
|
||||||
|
|
||||||
|
public sealed class SmtpSettings
|
||||||
|
{
|
||||||
|
public string Host { get; set; } = "";
|
||||||
|
public int Port { get; set; } = 587;
|
||||||
|
public string Username { get; set; } = "";
|
||||||
|
public string Password { get; set; } = "";
|
||||||
|
public bool UseStartTls { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -57,6 +57,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{D4E5F6A7-B
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{201E8E89-A2E2-44FD-BF43-7F24B6CACA52}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{201E8E89-A2E2-44FD-BF43-7F24B6CACA52}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\email-api-models\email-api-models.csproj", "{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -315,6 +319,30 @@ Global
|
|||||||
{069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x64.Build.0 = Release|Any CPU
|
{069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.ActiveCfg = Release|Any CPU
|
{069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.Build.0 = Release|Any CPU
|
{069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -340,6 +368,8 @@ Global
|
|||||||
{92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012}
|
{92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012}
|
||||||
{02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
{02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||||
{069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
{069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||||
|
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||||
|
{434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}
|
SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}
|
||||||
|
|||||||
Reference in New Issue
Block a user