feat: extract email sending into dedicated email-api service #23
@@ -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 Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using EmailApi.Models.Clients;
|
||||
using EmailApi.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Models.Settings;
|
||||
using MyAi.Data;
|
||||
@@ -29,10 +31,10 @@ try
|
||||
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
|
||||
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
|
||||
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<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
builder.Services.Configure<EmailApiSettings>(builder.Configuration.GetSection("EmailApi"));
|
||||
|
||||
builder.Services.AddDbContext<MyAiDbContext>(options =>
|
||||
{
|
||||
@@ -46,9 +48,20 @@ try
|
||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
||||
|
||||
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>();
|
||||
|
||||
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)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
@@ -60,6 +73,9 @@ try
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
|
||||
builder.Services.AddRefitClient<IEmailApiClient>()
|
||||
.ConfigureHttpClient(ConfigureEmailApiClient);
|
||||
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||
.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.Identity" />
|
||||
<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="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<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="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using EmailApi.Models.Requests;
|
||||
using Refit;
|
||||
|
||||
namespace EmailApi.Models.Clients;
|
||||
|
||||
public interface IEmailApiClient
|
||||
{
|
||||
[Post("/api/email/send")]
|
||||
Task SendAsync(SendEmailRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace EmailApi.Models.Requests;
|
||||
|
||||
public sealed class SendEmailRequest
|
||||
{
|
||||
public required List<string> To { get; init; }
|
||||
public string? ReplyTo { get; init; }
|
||||
public required string Subject { get; init; }
|
||||
public required string HtmlBody { get; init; }
|
||||
public string? AttachmentPath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace EmailApi.Models.Settings;
|
||||
|
||||
public sealed class EmailApiSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = "";
|
||||
public string InternalApiKey { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>email-api-models</AssemblyName>
|
||||
<RootNamespace>EmailApi.Models</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
# email-api — Internal Email Sending Service
|
||||
|
||||
Internal only. Reachable at `http://email-api:8080` within `myai-network`. Not exposed to the internet.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Accepts `POST /api/email/send` requests from internal services (`api`, `cv-search-job`)
|
||||
- Wraps the provided HTML body fragment in a branded HTML shell (blue header, white card, grey footer)
|
||||
- Sends the email via SMTP using MailKit
|
||||
- Attaches files from the shared `Files` volume when `AttachmentPath` is provided
|
||||
- Protected by `X-Internal-Api-Key` via `UseInternalApiKeyProtection()`
|
||||
|
||||
## Key route
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| POST | `/api/email/send` | Send an HTML email. Returns 204 No Content. |
|
||||
|
||||
## Request body (`SendEmailRequest`)
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `To` | ✅ | List of recipient addresses |
|
||||
| `ReplyTo` | ❌ | Optional reply-to address |
|
||||
| `Subject` | ✅ | Plain text (service prepends `[ENV_NAME]`) |
|
||||
| `HtmlBody` | ✅ | HTML fragment — wrapped in branded shell by this service |
|
||||
| `AttachmentPath` | ❌ | Path relative to `FileStorage:Path`, e.g. `"{cvDocumentId}.pdf"` |
|
||||
|
||||
## Consumers
|
||||
|
||||
- `api` — via `IEmailApiClient` Refit interface (contact, subscribe, file-download, match emails)
|
||||
- `cv-search-job` — via `IEmailApiClient` Refit interface (job search results email)
|
||||
|
||||
## Settings
|
||||
|
||||
| Section | Env var | Notes |
|
||||
|---------|---------|-------|
|
||||
| `Smtp` | `Smtp__Host`, `Smtp__Username`, `Smtp__Password`, etc. | SMTP server config — only configured here, not in consumers |
|
||||
| `FileStorage` | `FileStorage__Path` | Must match the shared `Files` volume mount path |
|
||||
| `InternalApi` | `EmailApi__InternalApiKey` | API key enforced on every request |
|
||||
@@ -0,0 +1,24 @@
|
||||
using EmailApi.Models.Requests;
|
||||
using EmailApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace EmailApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/email")]
|
||||
public sealed class EmailController : ControllerBase
|
||||
{
|
||||
private readonly SmtpEmailDispatcher _dispatcher;
|
||||
|
||||
public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher;
|
||||
|
||||
[HttpPost("send")]
|
||||
[SwaggerOperation(Summary = "Send an HTML email via SMTP")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||
{
|
||||
await _dispatcher.SendAsync(request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
COPY Apis/email-api/email-api.csproj Apis/email-api/
|
||||
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
|
||||
COPY Apis/api-models/api-models.csproj Apis/api-models/
|
||||
COPY Apis/common/common.csproj Apis/common/
|
||||
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
|
||||
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
||||
COPY Directory.Packages.props ./
|
||||
|
||||
RUN dotnet restore Apis/email-api/email-api.csproj
|
||||
|
||||
COPY Apis/email-api/ Apis/email-api/
|
||||
COPY Apis/email-api-models/ Apis/email-api-models/
|
||||
COPY Apis/api-models/ Apis/api-models/
|
||||
COPY Apis/common/ Apis/common/
|
||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
|
||||
RUN dotnet publish Apis/email-api/email-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENTRYPOINT ["dotnet", "email-api.dll"]
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Reflection;
|
||||
using EmailApi.Services;
|
||||
using Models.Settings;
|
||||
using Serilog;
|
||||
using StartupHelpers;
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
|
||||
const string ServiceName = "email-api";
|
||||
var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly());
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.ConfigureJsonSerilog(ServiceName, appVersion);
|
||||
Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion);
|
||||
|
||||
builder.AddAzureKeyVaultIfConfigured();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "Email API");
|
||||
|
||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
|
||||
builder.Services.AddScoped<SmtpEmailDispatcher>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.LogStartupDiagnostics(ServiceName);
|
||||
|
||||
app.UseDefaultSerilogRequestLogging();
|
||||
app.UseJsonExceptionHandler(ServiceName);
|
||||
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
||||
|
||||
app.UseInternalApiKeyProtection();
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Information("Shutting down {Service}", ServiceName);
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
@@ -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,111 @@
|
||||
using EmailApi.Models.Requests;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using Models.Settings;
|
||||
|
||||
namespace EmailApi.Services;
|
||||
|
||||
public sealed class SmtpEmailDispatcher
|
||||
{
|
||||
private readonly SmtpSettings _smtp;
|
||||
private readonly FileStorageSettings _fileStorage;
|
||||
private readonly ILogger<SmtpEmailDispatcher> _log;
|
||||
private readonly string _environmentName;
|
||||
|
||||
private static readonly string HtmlShellStart = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
||||
<body style="margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="padding:20px 0">
|
||||
<tr><td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0"
|
||||
style="background:#ffffff;border-radius:8px;max-width:600px">
|
||||
<tr><td style="background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0">
|
||||
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">myAi</h1>
|
||||
</td></tr>
|
||||
<tr><td style="padding:32px">
|
||||
""";
|
||||
|
||||
private static readonly string HtmlShellEnd = """
|
||||
</td></tr>
|
||||
<tr><td style="background:#f8f9fa;padding:16px 32px;text-align:center;
|
||||
color:#6c757d;font-size:12px;border-radius:0 0 8px 8px">
|
||||
Automated message from myAi.
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
|
||||
public SmtpEmailDispatcher(
|
||||
IOptions<SmtpSettings> smtp,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
ILogger<SmtpEmailDispatcher> log)
|
||||
{
|
||||
_smtp = smtp.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_log = log;
|
||||
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
}
|
||||
|
||||
public async Task SendAsync(SendEmailRequest req, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_smtp.Host))
|
||||
{
|
||||
_log.LogWarning("SMTP host not configured — email skipped (to: {To})", string.Join(", ", req.To));
|
||||
return;
|
||||
}
|
||||
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
|
||||
|
||||
foreach (var to in req.To)
|
||||
msg.To.Add(MailboxAddress.Parse(to));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.ReplyTo))
|
||||
msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo));
|
||||
|
||||
msg.Subject = $"[{_environmentName}] {req.Subject}".Trim();
|
||||
|
||||
var builder = new BodyBuilder
|
||||
{
|
||||
HtmlBody = HtmlShellStart + req.HtmlBody + HtmlShellEnd
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.AttachmentPath))
|
||||
{
|
||||
var fullPath = Path.Combine(_fileStorage.Path, req.AttachmentPath);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
builder.Attachments.Add(fullPath);
|
||||
_log.LogDebug("Attachment added: {Path}", fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.LogWarning("Attachment not found, skipping: {Path}", fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
msg.Body = builder.ToMessageBody();
|
||||
|
||||
_log.LogInformation("Sending email to {Recipients} subject {Subject}",
|
||||
string.Join(", ", req.To), req.Subject);
|
||||
|
||||
using var client = new SmtpClient();
|
||||
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_smtp.Username))
|
||||
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
|
||||
|
||||
await client.SendAsync(msg, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
|
||||
_log.LogInformation("Email sent successfully to {Recipients}", string.Join(", ", req.To));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<RootNamespace>EmailApi</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\api-models\api-models.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MyAi.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MyAi.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(MyAiDbContext))]
|
||||
[Migration("20260527120000_UpdateEmailTemplatesToHtml")]
|
||||
partial class UpdateEmailTemplatesToHtml
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("myAi")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "myAi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MyAi.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateEmailTemplatesToHtml : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
void Update(string key, string lang, string value)
|
||||
=> migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang],
|
||||
["Value"], [value], "myAi");
|
||||
|
||||
// email.match.body — en
|
||||
Update("email.match.body", "en",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">CV Match Report</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:130px;border:1px solid #dee2e6;color:#495057\">CV ID</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Score</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Summary</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Strengths</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Gaps</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recommendations</h3>{{recommendations}}");
|
||||
|
||||
// email.match.body — ro
|
||||
Update("email.match.body", "ro",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">Raport Potrivire CV</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:130px;border:1px solid #dee2e6;color:#495057\">ID Document CV</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Scor</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Rezumat</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Puncte forte</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Lipsuri</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recomandări</h3>{{recommendations}}");
|
||||
|
||||
// email.match.job-search-footer — en
|
||||
Update("email.match.job-search-footer", "en",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Want to find matching jobs automatically? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Start a job search →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
|
||||
"</p>" +
|
||||
"</div>");
|
||||
|
||||
// email.match.job-search-footer — ro
|
||||
Update("email.match.job-search-footer", "ro",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Vrei să găsești joburi potrivite automat? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
|
||||
"</p>" +
|
||||
"</div>");
|
||||
|
||||
// email.search-results.body — en
|
||||
Update("email.search-results.body", "en",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
|
||||
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
|
||||
"{{items}}");
|
||||
|
||||
// email.search-results.body — ro
|
||||
Update("email.search-results.body", "ro",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
|
||||
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
|
||||
"{{items}}");
|
||||
|
||||
// email.search-results.empty — en
|
||||
Update("email.search-results.empty", "en",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
|
||||
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
|
||||
"</div>");
|
||||
|
||||
// email.search-results.empty — ro
|
||||
Update("email.search-results.empty", "ro",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
|
||||
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
|
||||
"</div>");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
void Update(string key, string lang, string value)
|
||||
=> migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang],
|
||||
["Value"], [value], "myAi");
|
||||
|
||||
Update("email.match.body", "en",
|
||||
"CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}");
|
||||
Update("email.match.body", "ro",
|
||||
"Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}");
|
||||
Update("email.match.job-search-footer", "en",
|
||||
"\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)");
|
||||
Update("email.match.job-search-footer", "ro",
|
||||
"\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)");
|
||||
Update("email.search-results.body", "en",
|
||||
"MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}");
|
||||
Update("email.search-results.body", "ro",
|
||||
"MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}");
|
||||
Update("email.search-results.empty", "en",
|
||||
"MyAi.ro found no jobs matching your CV. Try again later or update your CV.");
|
||||
Update("email.search-results.empty", "ro",
|
||||
"MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ This applies to both the staging and production repos as appropriate.
|
||||
- Entity Framework Core + SQL Server (multi-schema)
|
||||
- Refit for typed HTTP clients between services
|
||||
- Serilog (JSON structured logging, Console + File + Email sinks)
|
||||
- MailKit for SMTP
|
||||
- MailKit for SMTP (used exclusively in `email-api`)
|
||||
- Docker Compose for local and production deployment
|
||||
- Watchtower for automatic container updates in production
|
||||
|
||||
@@ -63,6 +63,8 @@ This applies to both the staging and production repos as appropriate.
|
||||
Apis/
|
||||
api/ Public-facing proxy API (port 8080). Handles CORS, rate limiting, captcha, email.
|
||||
api-models/ DTOs and settings for api only.
|
||||
email-api/ Internal SMTP email relay (no public port). All email sending goes here.
|
||||
email-api-models/ Refit client + SendEmailRequest + EmailApiSettings (shared by api and cv-search-job).
|
||||
cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch + MyAi DB migrations.
|
||||
cv-matcher-api-models/ DTOs shared between api and cv-matcher-api (incl. JobSearchSettings).
|
||||
rag-api/ Internal RAG/vector-search service (port 8081).
|
||||
@@ -148,26 +150,36 @@ EF tools version warning ("older than runtime") is expected and harmless. The `H
|
||||
|
||||
```
|
||||
web → api → cv-matcher-api → rag-api
|
||||
↑
|
||||
cv-search-job
|
||||
↓ ↓
|
||||
| email-api
|
||||
↓ ↑
|
||||
cv-search-job
|
||||
```
|
||||
|
||||
`api` and `cv-search-job` both call `email-api` for all outbound email (SMTP).
|
||||
`api` never talks directly to `rag-api` — always via `cv-matcher-api`.
|
||||
|
||||
## Internal API key auth
|
||||
|
||||
All internal service-to-service calls require the `X-Internal-Api-Key` header.
|
||||
The key is shared via the `CvMatcherApi__InternalApiKey` and `RagApi__InternalApiKey` env vars.
|
||||
`startup-helpers` provides `UseInternalApiKeyProtection()` middleware that enforces it on `cv-matcher-api` and `rag-api`.
|
||||
All internal service-to-service calls require the `X-Internal-Api-Key` header.
|
||||
|
||||
| Caller | Target | Env var for key |
|
||||
|--------|--------|-----------------|
|
||||
| `api`, `cv-search-job` | `email-api` | `EmailApi__InternalApiKey` |
|
||||
| `api`, `cv-search-job` | `cv-matcher-api` | `CvMatcherApi__InternalApiKey` |
|
||||
| `cv-matcher-api` | `rag-api` | `RagApi__InternalApiKey` |
|
||||
|
||||
`startup-helpers` provides `UseInternalApiKeyProtection()` middleware (reads `InternalApi:ApiKey`); enforced on `cv-matcher-api`, `rag-api`, and `email-api`.
|
||||
|
||||
## Shared file storage
|
||||
|
||||
CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job` and `cv-search-job`.
|
||||
All three containers mount the same bind volume:
|
||||
CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job`, `cv-search-job`, and `email-api` (for email attachments).
|
||||
All four containers mount the same bind volume:
|
||||
```yaml
|
||||
- ../Apis/api/Files:/app/Files
|
||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||
```
|
||||
The path inside containers is controlled by `FileStorage__Path` (default: `Files`).
|
||||
The path inside containers is controlled by `FileStorage__Path` (default: `Files`).
|
||||
`email-api` receives only the relative filename (e.g. `abc123.pdf`) and resolves it against `FileStorage__Path`.
|
||||
|
||||
## Job task pattern
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using CvMatcher.Models.Settings;
|
||||
using CvSearch.Data;
|
||||
using CvSearchJob.Clients;
|
||||
using CvSearchJob.Services;
|
||||
using EmailApi.Models.Clients;
|
||||
using CvSearchJob.Tasks;
|
||||
using JobScheduler.Scheduling;
|
||||
using JobScheduler.Tasks;
|
||||
@@ -64,6 +65,18 @@ try
|
||||
});
|
||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
||||
|
||||
builder.Services.AddRefitClient<IEmailApiClient>()
|
||||
.ConfigureHttpClient((sp, client) =>
|
||||
{
|
||||
var config = sp.GetRequiredService<Microsoft.Extensions.Configuration.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.Add("X-Internal-Api-Key", key);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<HtmlJobSearcher>();
|
||||
builder.Services.AddSingleton<CvSearchEmailSender>();
|
||||
|
||||
|
||||
@@ -1,43 +1,41 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Data.Entities;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using EmailApi.Models.Clients;
|
||||
using EmailApi.Models.Requests;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
using MyAi.Data.Services;
|
||||
|
||||
namespace CvSearchJob.Services;
|
||||
|
||||
public sealed class CvSearchEmailSender
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly IEmailApiClient _emailApi;
|
||||
private readonly ITemplateService _templates;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<CvSearchEmailSender> _logger;
|
||||
|
||||
public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger<CvSearchEmailSender> logger)
|
||||
public CvSearchEmailSender(
|
||||
IEmailApiClient emailApi,
|
||||
ITemplateService templates,
|
||||
IConfiguration config,
|
||||
ILogger<CvSearchEmailSender> logger)
|
||||
{
|
||||
_config = config;
|
||||
_emailApi = emailApi;
|
||||
_templates = templates;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task SendResultsAsync(
|
||||
string toEmail,
|
||||
string? attachmentPath,
|
||||
string? attachmentFileName,
|
||||
IReadOnlyList<JobSearchResultEntity> results,
|
||||
string language,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var smtpHost = _config["Smtp:Host"];
|
||||
var smtpPort = int.TryParse(_config["Smtp:Port"], out var port) ? port : 587;
|
||||
var smtpUser = _config["Smtp:Username"];
|
||||
var smtpPass = _config["Smtp:Password"];
|
||||
var useStartTls = bool.TryParse(_config["Smtp:UseStartTls"], out var tls) && tls;
|
||||
var contactToEmail = _config["Contact:ToEmail"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(smtpHost)) return;
|
||||
|
||||
var recipients = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail);
|
||||
if (!string.IsNullOrWhiteSpace(contactToEmail) &&
|
||||
@@ -46,39 +44,27 @@ public sealed class CvSearchEmailSender
|
||||
|
||||
if (recipients.Count == 0) return;
|
||||
|
||||
var body = BuildBody(results, language);
|
||||
var htmlBody = BuildBody(results, language);
|
||||
var subject = _templates.Render("email.search-results.subject", language,
|
||||
("count", results.Count.ToString()));
|
||||
var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
try
|
||||
{
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(smtpUser!));
|
||||
msg.To.Add(MailboxAddress.Parse(recipient));
|
||||
msg.Subject = $"[{environmentName}] {subject}";
|
||||
|
||||
var builder = new BodyBuilder { TextBody = body };
|
||||
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
|
||||
builder.Attachments.Add(attachmentPath);
|
||||
|
||||
msg.Body = builder.ToMessageBody();
|
||||
|
||||
try
|
||||
await _emailApi.SendAsync(new SendEmailRequest
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
var tls2 = useStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await client.ConnectAsync(smtpHost, smtpPort, tls2, ct);
|
||||
if (!string.IsNullOrWhiteSpace(smtpUser))
|
||||
await client.AuthenticateAsync(smtpUser, smtpPass ?? string.Empty, ct);
|
||||
await client.SendAsync(msg, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
_logger.LogInformation("Job search results email sent to {Recipient}", recipient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send job search results email to {Recipient}", recipient);
|
||||
}
|
||||
To = recipients,
|
||||
Subject = subject,
|
||||
HtmlBody = htmlBody,
|
||||
AttachmentPath = attachmentFileName
|
||||
}, ct);
|
||||
|
||||
_logger.LogInformation("Job search results email sent to {Recipients}",
|
||||
string.Join(", ", recipients));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to send job search results email to {Recipients}",
|
||||
string.Join(", ", recipients));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,11 +78,17 @@ public sealed class CvSearchEmailSender
|
||||
{
|
||||
var r = results[i];
|
||||
var matchResp = TryParseResult(r.ResultJson);
|
||||
items.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]");
|
||||
items.AppendLine($" {r.JobUrl}");
|
||||
if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary))
|
||||
items.AppendLine($" {matchResp.Summary}");
|
||||
items.AppendLine();
|
||||
var summary = matchResp?.Summary;
|
||||
|
||||
items.Append($"""
|
||||
<div style="border:1px solid #dee2e6;border-radius:6px;padding:16px;margin-bottom:12px">
|
||||
<strong style="color:#212529">{i + 1}. {r.JobTitle}</strong>
|
||||
<span style="background:#28a745;color:#fff;padding:2px 8px;border-radius:12px;font-size:12px;margin-left:8px">{r.Score}% match</span>
|
||||
<span style="color:#6c757d;font-size:12px;margin-left:4px">[{r.ProviderName}]</span><br>
|
||||
<a href="{r.JobUrl}" style="color:#2c5282;font-size:13px">{r.JobUrl}</a>
|
||||
{(string.IsNullOrWhiteSpace(summary) ? "" : $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5\">{summary}</p>")}
|
||||
</div>
|
||||
""");
|
||||
}
|
||||
|
||||
return _templates.Render("email.search-results.body", language,
|
||||
@@ -106,7 +98,11 @@ public sealed class CvSearchEmailSender
|
||||
|
||||
private static JobMatchResponse? TryParseResult(string json)
|
||||
{
|
||||
try { return System.Text.Json.JsonSerializer.Deserialize<JobMatchResponse>(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); }
|
||||
try
|
||||
{
|
||||
return System.Text.Json.JsonSerializer.Deserialize<JobMatchResponse>(json,
|
||||
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
private readonly ICvMatcherInternalApi _matcherApi;
|
||||
private readonly CvSearchEmailSender _emailSender;
|
||||
private readonly ILogger<CvSearchJobTask> _logger;
|
||||
private readonly string _fileStoragePath;
|
||||
|
||||
public string TaskType => "CvSearch";
|
||||
|
||||
@@ -32,7 +31,6 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
HtmlJobSearcher searcher,
|
||||
ICvMatcherInternalApi matcherApi,
|
||||
CvSearchEmailSender emailSender,
|
||||
IConfiguration config,
|
||||
ILogger<CvSearchJobTask> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
@@ -41,9 +39,6 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
_matcherApi = matcherApi;
|
||||
_emailSender = emailSender;
|
||||
_logger = logger;
|
||||
_fileStoragePath = config["FileStorage:Path"] ?? "Files";
|
||||
if (!Path.IsPathRooted(_fileStoragePath))
|
||||
_fileStoragePath = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), _fileStoragePath));
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken)
|
||||
@@ -85,8 +80,8 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
pending.Status = JobSearchStatus.Done;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var attachmentPath = BuildCvPath(pending.CvDocumentId);
|
||||
await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, pending.Language, cancellationToken);
|
||||
var attachmentFileName = BuildCvFileName(pending.CvDocumentId);
|
||||
await _emailSender.SendResultsAsync(pending.Email, attachmentFileName, results, pending.Language, cancellationToken);
|
||||
_logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -194,10 +189,10 @@ public sealed class CvSearchJobTask : IJobTask
|
||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "unknown";
|
||||
}
|
||||
|
||||
private string BuildCvPath(string cvDocumentId)
|
||||
private static string BuildCvFileName(string cvDocumentId)
|
||||
{
|
||||
var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit));
|
||||
if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv";
|
||||
return Path.Combine(_fileStoragePath, $"{safeId}.pdf");
|
||||
return $"{safeId}.pdf";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" />
|
||||
<!-- MailKit removed — email sending delegated to email-api service -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Apis\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\email-api-models\email-api-models.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\cv-search-data\cv-search-data.csproj" />
|
||||
<ProjectReference Include="..\..\Apis\common\common.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
|
||||
@@ -100,11 +100,47 @@ services:
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
email-api:
|
||||
image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-staging}
|
||||
container_name: myai-email-api
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||
|
||||
- InternalApi__ApiKey=${EmailApi__InternalApiKey:-}
|
||||
- InternalApi__RequireApiKey=true
|
||||
|
||||
- Smtp__Host=${Smtp__Host:-}
|
||||
- Smtp__Port=${Smtp__Port:-587}
|
||||
- Smtp__Username=${Smtp__Username:-}
|
||||
- Smtp__Password=${Smtp__Password:-}
|
||||
- Smtp__UseStartTls=${Smtp__UseStartTls:-false}
|
||||
|
||||
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||
|
||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
||||
volumes:
|
||||
- ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs
|
||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||
networks:
|
||||
- myai-network
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
api:
|
||||
image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging}
|
||||
container_name: myai-api
|
||||
depends_on:
|
||||
- cv-matcher-api
|
||||
- email-api
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||
- ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080}
|
||||
@@ -126,11 +162,8 @@ services:
|
||||
- Subscribe__ToEmail=${Subscribe__ToEmail:-}
|
||||
- Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-}
|
||||
|
||||
- Smtp__Host=${Smtp__Host:-}
|
||||
- Smtp__Port=${Smtp__Port:-587}
|
||||
- Smtp__Username=${Smtp__Username:-}
|
||||
- Smtp__Password=${Smtp__Password:-}
|
||||
- Smtp__UseStartTls=${Smtp__UseStartTls:-false}
|
||||
- EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080}
|
||||
- EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-}
|
||||
|
||||
- Captcha__Provider=${Captcha__Provider:-Recaptcha}
|
||||
- Captcha__SecretKey=${Captcha__SecretKey:-}
|
||||
@@ -210,6 +243,7 @@ services:
|
||||
container_name: myai-cv-search-job
|
||||
depends_on:
|
||||
- cv-matcher-api
|
||||
- email-api
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging}
|
||||
- APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging}
|
||||
@@ -224,11 +258,8 @@ services:
|
||||
- CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080}
|
||||
- CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-}
|
||||
|
||||
- Smtp__Host=${Smtp__Host:-}
|
||||
- Smtp__Port=${Smtp__Port:-587}
|
||||
- Smtp__Username=${Smtp__Username:-}
|
||||
- Smtp__Password=${Smtp__Password:-}
|
||||
- Smtp__UseStartTls=${Smtp__UseStartTls:-false}
|
||||
- EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080}
|
||||
- EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-}
|
||||
|
||||
- Contact__ToEmail=${Contact__ToEmail:-}
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{D4E5F6A7-B
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{201E8E89-A2E2-44FD-BF43-7F24B6CACA52}"
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
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|x86.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -340,6 +368,8 @@ Global
|
||||
{92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012}
|
||||
{02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||
{069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||
{434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}
|
||||
|
||||
Reference in New Issue
Block a user