diff --git a/Apis/api-models/Settings/SmtpSettings.cs b/Apis/api-models/Settings/SmtpSettings.cs deleted file mode 100644 index d719a29..0000000 --- a/Apis/api-models/Settings/SmtpSettings.cs +++ /dev/null @@ -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; - } -} diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index ae73a22..5cf8974 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -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(builder.Configuration.GetSection("Google")); builder.Services.Configure(builder.Configuration.GetSection("Contact")); builder.Services.Configure(builder.Configuration.GetSection("Subscribe")); - builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("EmailApi")); builder.Services.AddDbContext(options => { @@ -46,9 +48,20 @@ try builder.Services.AddSingleton(); builder.Services.AddHttpClient(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); + static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client) + { + var config = sp.GetRequiredService(); + 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(); @@ -60,6 +73,9 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); } + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureEmailApiClient); + builder.Services.AddRefitClient() .ConfigureHttpClient(ConfigureCvMatcherApiClient); diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs new file mode 100644 index 0000000..6a76466 --- /dev/null +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -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 _log; + + public EmailApiEmailSender( + IEmailApiClient emailApi, + IOptions contact, + IOptions subscribe, + IOptions fileStorage, + ITemplateService templates, + ILogger 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 = $""" +

New Contact Message

+ + + + + + + + + + + + + +
Name{req.Name}
Email{req.Email}
Subject{req.Subject}
+

Message

+

{req.Message}

+ """; + + 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 = $""" +

New Subscription Request

+

A new user has subscribed:

+ + + + + +
Email{req.Email}
+ """; + + 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 = $""" +

File Download Notification

+ + + + + + + + + + + + + +
File{fileName}
Downloaded at{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address{userIp ?? "Unknown"}
+ """; + + 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(); + 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 + ? "
    " + + string.Join("", result.Strengths.Select(s => $"
  • {s}
  • ")) + "
" + : "

"; + + var gaps = result.Gaps?.Count > 0 + ? "
    " + + string.Join("", result.Gaps.Select(g => $"
  • {g}
  • ")) + "
" + : "

"; + + var recommendations = result.Recommendations?.Count > 0 + ? "
    " + + string.Join("", result.Recommendations.Select(r => $"
  • {r}
  • ")) + "
" + : "

"; + + 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")); +} diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs deleted file mode 100644 index ee564b4..0000000 --- a/Apis/api/Services/SmtpEmailSender.cs +++ /dev/null @@ -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 _log; - private readonly string _environmentName; - - public SmtpEmailSender( - IOptions smtp, - IOptions contact, - IOptions subscribe, - IOptions fileStorage, - ITemplateService templates, - ILogger log) - { - _smtp = smtp.Value; - _contact = contact.Value; - _subscribe = subscribe.Value; - _fileStorage = fileStorage.Value; - _templates = templates; - _log = log; - _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; - } - - public async Task SendContactAsync(ContactRequest req, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(_contact.ToEmail)) - { - _log.LogDebug("Contact email skipped - ToEmail not configured"); - throw new InvalidOperationException("Contact email recipient is not configured."); - } - - _log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}", - req.Email, _contact.ToEmail); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(_contact.ToEmail)); - msg.ReplyTo.Add(MailboxAddress.Parse(req.Email)); - msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim(); - - var body = - $@"New contact form submission: - - Name: {req.Name} - Email: {req.Email} - Subject: {req.Subject} - - Message: - {req.Message} - "; - - msg.Body = new TextPart("plain") { Text = body }; - - await SendEmailAsync(msg, "contact email", ct); - - _log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email); - } - - public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(_subscribe.ToEmail)) - { - _log.LogDebug("Subscription email skipped - ToEmail not configured"); - throw new InvalidOperationException("Subscription email recipient is not configured."); - } - - _log.LogInformation("Processing subscription request for {Email}", req.Email); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail)); - msg.ReplyTo.Add(MailboxAddress.Parse(req.Email)); - msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim(); - - var body = - $@"New subscription request: - - Email: {req.Email} - "; - - msg.Body = new TextPart("plain") { Text = body }; - - await SendEmailAsync(msg, "subscription email", ct); - - _log.LogInformation("Subscription email sent successfully for {Email}", req.Email); - } - - public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail)) - { - _log.LogDebug("File download notification skipped - ToEmail not configured"); - return; - } - - _log.LogInformation("Preparing file download notification for {FileName}", fileName); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail)); - msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim(); - - var body = - $@"File download notification: - - File: {fileName} - Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC - IP Address: {userIp ?? "Unknown"} - "; - - msg.Body = new TextPart("plain") { Text = body }; - - await SendEmailAsync(msg, "file download notification email", ct); - - _log.LogInformation("File download notification sent successfully for {FileName}", fileName); - } - - /// - /// Connects to the SMTP server and authenticates if credentials are configured. - /// - private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct) - { - var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; - - _log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}", - _smtp.Host, _smtp.Port, tls); - - await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct); - - if (!string.IsNullOrWhiteSpace(_smtp.Username)) - { - _log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username); - await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct); - } - } - - /// - /// Sends an email message using SMTP. - /// - /// The email message to send. - /// Description of the message type for logging purposes. - /// Cancellation token. - private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct) - { - using var client = new SmtpClient(); - - await ConnectAndAuthenticateAsync(client, ct); - - _log.LogDebug("Sending {MessageType} message", messageType); - await client.SendAsync(message, ct); - await client.DisconnectAsync(true, ct); - } - - public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) - { - var recipients = new List(); - if (!string.IsNullOrWhiteSpace(explicitTo)) - { - recipients.Add(explicitTo); - } - - if (!string.IsNullOrWhiteSpace(_contact.ToEmail) && - !recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase))) - { - recipients.Add(_contact.ToEmail); - } - - if (recipients.Count == 0) - { - _log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)"); - return; - } - - foreach (var recipient in recipients) - { - _log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(recipient)); - msg.Subject = $"[{_environmentName}] {subject}".Trim(); - - var builder = new BodyBuilder - { - TextBody = body - }; - - if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath)) - { - builder.Attachments.Add(attachmentPath); - } - - msg.Body = builder.ToMessageBody(); - - await SendEmailAsync(msg, "cv match email", ct); - _log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient); - } - } - - public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) - { - var strengths = result.Strengths?.Count > 0 - ? "- " + string.Join("\n- ", result.Strengths) - : string.Empty; - var gaps = result.Gaps?.Count > 0 - ? "- " + string.Join("\n- ", result.Gaps) - : string.Empty; - var recommendations = result.Recommendations?.Count > 0 - ? "- " + string.Join("\n- ", result.Recommendations) - : string.Empty; - - var body = _templates.Render("email.match.body", language, - ("cvDocumentId", cvDocumentId), - ("jobLabel", jobLabel ?? "N/A"), - ("jobUrl", result.JobUrl ?? "N/A"), - ("score", result.Score.ToString()), - ("summary", result.Summary ?? string.Empty), - ("strengths", strengths), - ("gaps", gaps), - ("recommendations", recommendations)); - - if (!string.IsNullOrWhiteSpace(jobSearchLink)) - { - body += _templates.Render("email.match.job-search-footer", language, - ("jobSearchLink", jobSearchLink), - ("expiryDays", expiryDays.ToString())); - } - - return body; - } - - public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => - _templates.Render("email.match.subject", language, - ("score", score.ToString()), - ("jobLabel", jobLabel ?? "Job")); - } -} diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index 4a628cc..f91a051 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -19,7 +19,7 @@ - + @@ -36,6 +36,7 @@ + diff --git a/Apis/email-api-models/Clients/IEmailApiClient.cs b/Apis/email-api-models/Clients/IEmailApiClient.cs new file mode 100644 index 0000000..73aa438 --- /dev/null +++ b/Apis/email-api-models/Clients/IEmailApiClient.cs @@ -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); +} diff --git a/Apis/email-api-models/Requests/SendEmailRequest.cs b/Apis/email-api-models/Requests/SendEmailRequest.cs new file mode 100644 index 0000000..5d2f021 --- /dev/null +++ b/Apis/email-api-models/Requests/SendEmailRequest.cs @@ -0,0 +1,10 @@ +namespace EmailApi.Models.Requests; + +public sealed class SendEmailRequest +{ + public required List 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; } +} diff --git a/Apis/email-api-models/Settings/EmailApiSettings.cs b/Apis/email-api-models/Settings/EmailApiSettings.cs new file mode 100644 index 0000000..1a27d41 --- /dev/null +++ b/Apis/email-api-models/Settings/EmailApiSettings.cs @@ -0,0 +1,7 @@ +namespace EmailApi.Models.Settings; + +public sealed class EmailApiSettings +{ + public string BaseUrl { get; set; } = ""; + public string InternalApiKey { get; set; } = ""; +} diff --git a/Apis/email-api-models/email-api-models.csproj b/Apis/email-api-models/email-api-models.csproj new file mode 100644 index 0000000..45aaa9d --- /dev/null +++ b/Apis/email-api-models/email-api-models.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + email-api-models + EmailApi.Models + + + + + diff --git a/Apis/email-api/CLAUDE.md b/Apis/email-api/CLAUDE.md new file mode 100644 index 0000000..0ea58d0 --- /dev/null +++ b/Apis/email-api/CLAUDE.md @@ -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 | diff --git a/Apis/email-api/Controllers/EmailController.cs b/Apis/email-api/Controllers/EmailController.cs new file mode 100644 index 0000000..e7e628f --- /dev/null +++ b/Apis/email-api/Controllers/EmailController.cs @@ -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 Send([FromBody] SendEmailRequest request, CancellationToken ct) + { + await _dispatcher.SendAsync(request, ct); + return NoContent(); + } +} diff --git a/Apis/email-api/Dockerfile b/Apis/email-api/Dockerfile new file mode 100644 index 0000000..691ff27 --- /dev/null +++ b/Apis/email-api/Dockerfile @@ -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"] diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs new file mode 100644 index 0000000..e174e42 --- /dev/null +++ b/Apis/email-api/Program.cs @@ -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(builder.Configuration.GetSection("Smtp")); + builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + + builder.Services.AddScoped(); + + 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(); +} diff --git a/Apis/email-api/Properties/launchSettings.json b/Apis/email-api/Properties/launchSettings.json new file mode 100644 index 0000000..48b6a0b --- /dev/null +++ b/Apis/email-api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "email-api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61871;http://localhost:61872" + } + } +} \ No newline at end of file diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs new file mode 100644 index 0000000..3ca3eb0 --- /dev/null +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -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 _log; + private readonly string _environmentName; + + private static readonly string HtmlShellStart = """ + + + + + + +
+ + + + +
+

myAi

+
+ """; + + private static readonly string HtmlShellEnd = """ +
+ Automated message from myAi. +
+
+ + + """; + + public SmtpEmailDispatcher( + IOptions smtp, + IOptions fileStorage, + ILogger 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)); + } +} diff --git a/Apis/email-api/Settings/SmtpSettings.cs b/Apis/email-api/Settings/SmtpSettings.cs new file mode 100644 index 0000000..0d80e5a --- /dev/null +++ b/Apis/email-api/Settings/SmtpSettings.cs @@ -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; +} diff --git a/Apis/email-api/email-api.csproj b/Apis/email-api/email-api.csproj new file mode 100644 index 0000000..111de58 --- /dev/null +++ b/Apis/email-api/email-api.csproj @@ -0,0 +1,27 @@ + + + net10.0 + enable + enable + Linux + EmailApi + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + diff --git a/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs new file mode 100644 index 0000000..1bde4cd --- /dev/null +++ b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs @@ -0,0 +1,62 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs new file mode 100644 index 0000000..8568a2d --- /dev/null +++ b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs @@ -0,0 +1,143 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class UpdateEmailTemplatesToHtml : Migration + { + /// + 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", + "

CV Match Report

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
CV ID{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Score{{score}}%
" + + "

Summary

" + + "

{{summary}}

" + + "

Strengths

{{strengths}}" + + "

Gaps

{{gaps}}" + + "

Recommendations

{{recommendations}}"); + + // email.match.body — ro + Update("email.match.body", "ro", + "

Raport Potrivire CV

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
ID Document CV{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Scor{{score}}%
" + + "

Rezumat

" + + "

{{summary}}

" + + "

Puncte forte

{{strengths}}" + + "

Lipsuri

{{gaps}}" + + "

Recomandări

{{recommendations}}"); + + // email.match.job-search-footer — en + Update("email.match.job-search-footer", "en", + "
" + + "

" + + "Want to find matching jobs automatically? " + + "Start a job search →
" + + "Link valid for {{expiryDays}} days." + + "

" + + "
"); + + // email.match.job-search-footer — ro + Update("email.match.job-search-footer", "ro", + "
" + + "

" + + "Vrei să găsești joburi potrivite automat? " + + "Pornește o căutare de joburi →
" + + "Link valabil {{expiryDays}} zile." + + "

" + + "
"); + + // email.search-results.body — en + Update("email.search-results.body", "en", + "

Job Search Results

" + + "

Found {{count}} matching job(s):

" + + "{{items}}"); + + // email.search-results.body — ro + Update("email.search-results.body", "ro", + "

Rezultate Căutare Joburi

" + + "

Am găsit {{count}} job(uri) potrivite:

" + + "{{items}}"); + + // email.search-results.empty — en + Update("email.search-results.empty", "en", + "
" + + "

No matching jobs found

" + + "

Your job search completed but no matching jobs were found. Try again later or adjust your CV.

" + + "
"); + + // email.search-results.empty — ro + Update("email.search-results.empty", "ro", + "
" + + "

Niciun job potrivit găsit

" + + "

Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.

" + + "
"); + } + + /// + 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."); + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 27a2b35..6443417 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 1bdf24a..59abf8d 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -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(); + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + 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(); builder.Services.AddSingleton(); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 58ef994..8eeedc6 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -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 _logger; - public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger logger) + public CvSearchEmailSender( + IEmailApiClient emailApi, + ITemplateService templates, + IConfiguration config, + ILogger logger) { - _config = config; + _emailApi = emailApi; _templates = templates; + _config = config; _logger = logger; } public async Task SendResultsAsync( string toEmail, - string? attachmentPath, + string? attachmentFileName, IReadOnlyList 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(); 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($""" +
+ {i + 1}. {r.JobTitle} + {r.Score}% match + [{r.ProviderName}]
+ {r.JobUrl} + {(string.IsNullOrWhiteSpace(summary) ? "" : $"

{summary}

")} +
+ """); } 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(json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); } + try + { + return System.Text.Json.JsonSerializer.Deserialize(json, + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + } catch { return null; } } } diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 7993736..593baf7 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -22,7 +22,6 @@ public sealed class CvSearchJobTask : IJobTask private readonly ICvMatcherInternalApi _matcherApi; private readonly CvSearchEmailSender _emailSender; private readonly ILogger _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 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"; } } diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 7a695c2..94657fc 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -9,7 +9,7 @@ - + @@ -21,6 +21,7 @@ + diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index e10f628..e873de2 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -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:-} diff --git a/myAi.sln b/myAi.sln index 1661e47..a937763 100644 --- a/myAi.sln +++ b/myAi.sln @@ -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}