feat: replace direct MailKit in cv-search-job with IEmailApiClient Refit call
- CvSearchEmailSender now injects IEmailApiClient instead of IConfiguration+MailKit - BuildBody updated to produce styled HTML job cards - CvSearchJobTask passes only filename (not full path) to email sender - IEmailApiClient registered in Program.cs with EmailApi:BaseUrl + InternalApiKey - MailKit removed from cv-search-job.csproj Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using CvMatcher.Models.Settings;
|
|||||||
using CvSearch.Data;
|
using CvSearch.Data;
|
||||||
using CvSearchJob.Clients;
|
using CvSearchJob.Clients;
|
||||||
using CvSearchJob.Services;
|
using CvSearchJob.Services;
|
||||||
|
using EmailApi.Models.Clients;
|
||||||
using CvSearchJob.Tasks;
|
using CvSearchJob.Tasks;
|
||||||
using JobScheduler.Scheduling;
|
using JobScheduler.Scheduling;
|
||||||
using JobScheduler.Tasks;
|
using JobScheduler.Tasks;
|
||||||
@@ -64,6 +65,18 @@ try
|
|||||||
});
|
});
|
||||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
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.AddHttpClient<HtmlJobSearcher>();
|
||||||
builder.Services.AddSingleton<CvSearchEmailSender>();
|
builder.Services.AddSingleton<CvSearchEmailSender>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +1,41 @@
|
|||||||
using CvMatcher.Models.Responses;
|
using CvMatcher.Models.Responses;
|
||||||
using CvSearch.Data.Entities;
|
using CvSearch.Data.Entities;
|
||||||
using MailKit.Net.Smtp;
|
using EmailApi.Models.Clients;
|
||||||
using MailKit.Security;
|
using EmailApi.Models.Requests;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using MimeKit;
|
|
||||||
using MyAi.Data.Services;
|
using MyAi.Data.Services;
|
||||||
|
|
||||||
namespace CvSearchJob.Services;
|
namespace CvSearchJob.Services;
|
||||||
|
|
||||||
public sealed class CvSearchEmailSender
|
public sealed class CvSearchEmailSender
|
||||||
{
|
{
|
||||||
private readonly IConfiguration _config;
|
private readonly IEmailApiClient _emailApi;
|
||||||
private readonly ITemplateService _templates;
|
private readonly ITemplateService _templates;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
private readonly ILogger<CvSearchEmailSender> _logger;
|
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;
|
_templates = templates;
|
||||||
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendResultsAsync(
|
public async Task SendResultsAsync(
|
||||||
string toEmail,
|
string toEmail,
|
||||||
string? attachmentPath,
|
string? attachmentFileName,
|
||||||
IReadOnlyList<JobSearchResultEntity> results,
|
IReadOnlyList<JobSearchResultEntity> results,
|
||||||
string language,
|
string language,
|
||||||
CancellationToken ct)
|
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"];
|
var contactToEmail = _config["Contact:ToEmail"];
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(smtpHost)) return;
|
|
||||||
|
|
||||||
var recipients = new List<string>();
|
var recipients = new List<string>();
|
||||||
if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail);
|
if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail);
|
||||||
if (!string.IsNullOrWhiteSpace(contactToEmail) &&
|
if (!string.IsNullOrWhiteSpace(contactToEmail) &&
|
||||||
@@ -46,39 +44,27 @@ public sealed class CvSearchEmailSender
|
|||||||
|
|
||||||
if (recipients.Count == 0) return;
|
if (recipients.Count == 0) return;
|
||||||
|
|
||||||
var body = BuildBody(results, language);
|
var htmlBody = BuildBody(results, language);
|
||||||
var subject = _templates.Render("email.search-results.subject", language,
|
var subject = _templates.Render("email.search-results.subject", language,
|
||||||
("count", results.Count.ToString()));
|
("count", results.Count.ToString()));
|
||||||
var environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
|
||||||
|
|
||||||
foreach (var recipient in recipients)
|
try
|
||||||
{
|
{
|
||||||
var msg = new MimeMessage();
|
await _emailApi.SendAsync(new SendEmailRequest
|
||||||
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
|
|
||||||
{
|
{
|
||||||
using var client = new SmtpClient();
|
To = recipients,
|
||||||
var tls2 = useStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
Subject = subject,
|
||||||
await client.ConnectAsync(smtpHost, smtpPort, tls2, ct);
|
HtmlBody = htmlBody,
|
||||||
if (!string.IsNullOrWhiteSpace(smtpUser))
|
AttachmentPath = attachmentFileName
|
||||||
await client.AuthenticateAsync(smtpUser, smtpPass ?? string.Empty, ct);
|
}, ct);
|
||||||
await client.SendAsync(msg, ct);
|
|
||||||
await client.DisconnectAsync(true, ct);
|
_logger.LogInformation("Job search results email sent to {Recipients}",
|
||||||
_logger.LogInformation("Job search results email sent to {Recipient}", recipient);
|
string.Join(", ", recipients));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to send job search results email to {Recipient}", recipient);
|
_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 r = results[i];
|
||||||
var matchResp = TryParseResult(r.ResultJson);
|
var matchResp = TryParseResult(r.ResultJson);
|
||||||
items.AppendLine($"{i + 1}. {r.JobTitle} ({r.Score}% match) [{r.ProviderName}]");
|
var summary = matchResp?.Summary;
|
||||||
items.AppendLine($" {r.JobUrl}");
|
|
||||||
if (matchResp is not null && !string.IsNullOrWhiteSpace(matchResp.Summary))
|
items.Append($"""
|
||||||
items.AppendLine($" {matchResp.Summary}");
|
<div style="border:1px solid #dee2e6;border-radius:6px;padding:16px;margin-bottom:12px">
|
||||||
items.AppendLine();
|
<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,
|
return _templates.Render("email.search-results.body", language,
|
||||||
@@ -106,7 +98,11 @@ public sealed class CvSearchEmailSender
|
|||||||
|
|
||||||
private static JobMatchResponse? TryParseResult(string json)
|
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; }
|
catch { return null; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
private readonly ICvMatcherInternalApi _matcherApi;
|
private readonly ICvMatcherInternalApi _matcherApi;
|
||||||
private readonly CvSearchEmailSender _emailSender;
|
private readonly CvSearchEmailSender _emailSender;
|
||||||
private readonly ILogger<CvSearchJobTask> _logger;
|
private readonly ILogger<CvSearchJobTask> _logger;
|
||||||
private readonly string _fileStoragePath;
|
|
||||||
|
|
||||||
public string TaskType => "CvSearch";
|
public string TaskType => "CvSearch";
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
HtmlJobSearcher searcher,
|
HtmlJobSearcher searcher,
|
||||||
ICvMatcherInternalApi matcherApi,
|
ICvMatcherInternalApi matcherApi,
|
||||||
CvSearchEmailSender emailSender,
|
CvSearchEmailSender emailSender,
|
||||||
IConfiguration config,
|
|
||||||
ILogger<CvSearchJobTask> logger)
|
ILogger<CvSearchJobTask> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
@@ -41,9 +39,6 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
_matcherApi = matcherApi;
|
_matcherApi = matcherApi;
|
||||||
_emailSender = emailSender;
|
_emailSender = emailSender;
|
||||||
_logger = logger;
|
_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)
|
public async Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken)
|
||||||
@@ -85,8 +80,8 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
pending.Status = JobSearchStatus.Done;
|
pending.Status = JobSearchStatus.Done;
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
var attachmentPath = BuildCvPath(pending.CvDocumentId);
|
var attachmentFileName = BuildCvFileName(pending.CvDocumentId);
|
||||||
await _emailSender.SendResultsAsync(pending.Email, attachmentPath, results, pending.Language, cancellationToken);
|
await _emailSender.SendResultsAsync(pending.Email, attachmentFileName, results, pending.Language, cancellationToken);
|
||||||
_logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count);
|
_logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -194,10 +189,10 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "unknown";
|
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));
|
var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit));
|
||||||
if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv";
|
if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv";
|
||||||
return Path.Combine(_fileStoragePath, $"{safeId}.pdf");
|
return $"{safeId}.pdf";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MailKit" />
|
<!-- MailKit removed — email sending delegated to email-api service -->
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||||
<PackageReference Include="Refit.HttpClientFactory" />
|
<PackageReference Include="Refit.HttpClientFactory" />
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Apis\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
<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\cv-search-data\cv-search-data.csproj" />
|
||||||
<ProjectReference Include="..\..\Apis\common\common.csproj" />
|
<ProjectReference Include="..\..\Apis\common\common.csproj" />
|
||||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||||
|
|||||||
Reference in New Issue
Block a user