e95ed36647
Phases 1-10 of the planned refactoring:
Phase 1: rename shared-models -> common
- namespace Shared.Models -> Common throughout
- remove stale AspNetCore.Http.Features 5.0 reference
Phase 2: create shared-data with abstract BaseEntity
- BaseEntity: required string Id { get; init; } + DateTime CreatedAt { get; init; }
Phase 3: rename myai-models -> myai-data
- namespace MyAi.Models -> MyAi.Data
- MigrationsAssembly("myai-data")
Phase 4: rename cv-search-models -> cv-search-data
- namespace CvSearch.Models -> CvSearch.Data
- move JobSearchSettings to cv-matcher-api-models
- JobSearch*Entity now inherits BaseEntity
Phase 5: extract rag-data from rag-api
- new project: Apis/rag-data with RagDbContext + entities + migrations
- RagDocumentEntity inherits BaseEntity; cache entities use CacheKey PK
- fix duplicate AddHttpClient<RagAiClient>/AddScoped registrations in rag-api
- MigrationsAssembly("rag-data")
Phase 6: extract cv-matcher-data from cv-matcher-api
- new project: Apis/cv-matcher-data with CvMatcherDbContext + entities + migrations
- CvMatchResultEntity inherits BaseEntity; CvMatcherChatCacheEntity uses CacheKey PK
- MigrationsAssembly("cv-matcher-data")
Phase 7: create empty cv-cleanup-job-models and cv-search-job-models
Phase 8: update all 5 Dockerfiles for renamed/new projects
Phase 9: reorganise .sln virtual folders (Apis/Jobs/Models/Data/Helpers)
- update root CLAUDE.md with new project taxonomy and migration commands
- update cv-matcher-api/CLAUDE.md and cv-search-job/CLAUDE.md
Phase 10: add Directory.Packages.props for centralised NuGet versions
- remove Version= from all PackageReference elements in active .csproj files
No database changes. No runtime behaviour changes.
All MigrationId strings in __EFMigrationsHistory are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
113 lines
4.4 KiB
C#
113 lines
4.4 KiB
C#
using CvMatcher.Models.Responses;
|
|
using CvSearch.Data.Entities;
|
|
using MailKit.Net.Smtp;
|
|
using MailKit.Security;
|
|
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 ITemplateService _templates;
|
|
private readonly ILogger<CvSearchEmailSender> _logger;
|
|
|
|
public CvSearchEmailSender(IConfiguration config, ITemplateService templates, ILogger<CvSearchEmailSender> logger)
|
|
{
|
|
_config = config;
|
|
_templates = templates;
|
|
_logger = logger;
|
|
}
|
|
|
|
public async Task SendResultsAsync(
|
|
string toEmail,
|
|
string? attachmentPath,
|
|
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) &&
|
|
!recipients.Any(r => string.Equals(r, contactToEmail, StringComparison.OrdinalIgnoreCase)))
|
|
recipients.Add(contactToEmail);
|
|
|
|
if (recipients.Count == 0) return;
|
|
|
|
var body = 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)
|
|
{
|
|
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
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, string language)
|
|
{
|
|
if (results.Count == 0)
|
|
return _templates.Get("email.search-results.empty", language);
|
|
|
|
var items = new System.Text.StringBuilder();
|
|
for (int i = 0; i < results.Count; i++)
|
|
{
|
|
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();
|
|
}
|
|
|
|
return _templates.Render("email.search-results.body", language,
|
|
("count", results.Count.ToString()),
|
|
("items", items.ToString()));
|
|
}
|
|
|
|
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)); }
|
|
catch { return null; }
|
|
}
|
|
}
|