This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
using Api.Services.Contracts.Models;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
public interface ICaptchaVerifier
|
||||
{
|
||||
Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Models.Requests;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
public interface IEmailSender
|
||||
{
|
||||
Task SendContactAsync(ContactRequest req, CancellationToken ct);
|
||||
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
|
||||
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
|
||||
Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Api.Services.Contracts.Models
|
||||
{
|
||||
public sealed record CaptchaVerdictModel(bool Success, string? Error, double? Score);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Api.Services.Contracts.Models;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models.Settings;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
public sealed class RecaptchaVerifier : ICaptchaVerifier
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly CaptchaSettings _opt;
|
||||
private readonly ILogger<RecaptchaVerifier> _log;
|
||||
|
||||
public RecaptchaVerifier(HttpClient http, IOptions<CaptchaSettings> options, ILogger<RecaptchaVerifier> log)
|
||||
{
|
||||
_http = http;
|
||||
_opt = options.Value;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct)
|
||||
{
|
||||
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_opt.SecretKey))
|
||||
{
|
||||
_log.LogWarning("Captcha verification attempted but SecretKey is not configured");
|
||||
return new CaptchaVerdictModel(false, "Captcha not configured", null);
|
||||
}
|
||||
|
||||
var form = new Dictionary<string, string>
|
||||
{
|
||||
["secret"] = _opt.SecretKey,
|
||||
["response"] = token
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(userIp))
|
||||
form["remoteip"] = userIp;
|
||||
|
||||
using var resp = await _http.PostAsync(
|
||||
"https://www.google.com/recaptcha/api/siteverify",
|
||||
new FormUrlEncodedContent(form),
|
||||
ct
|
||||
);
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
_log.LogWarning("Captcha HTTP request failed with status {StatusCode} for IP {Ip}",
|
||||
(int)resp.StatusCode, userIp ?? "unknown");
|
||||
return new CaptchaVerdictModel(false, $"Captcha HTTP {(int)resp.StatusCode}", null);
|
||||
}
|
||||
|
||||
var data = await resp.Content.ReadFromJsonAsync<RecaptchaResponse>(cancellationToken: ct);
|
||||
if (data is null)
|
||||
{
|
||||
_log.LogError("Failed to parse captcha response for IP {Ip}", userIp ?? "unknown");
|
||||
return new CaptchaVerdictModel(false, "Captcha parse error", null);
|
||||
}
|
||||
|
||||
if (!data.success)
|
||||
{
|
||||
_log.LogWarning("Captcha verification failed for IP {Ip}. Score={Score}",
|
||||
userIp ?? "unknown", data.score);
|
||||
return new CaptchaVerdictModel(false, "Captcha failed", data.score);
|
||||
}
|
||||
|
||||
// v3 score check (score is typically null for v2)
|
||||
if (data.score is double score && score < _opt.MinimumScore)
|
||||
{
|
||||
_log.LogWarning("Captcha score {Score} below minimum {MinScore} for IP {Ip}",
|
||||
score, _opt.MinimumScore, userIp ?? "unknown");
|
||||
return new CaptchaVerdictModel(false, "Captcha score too low", score);
|
||||
}
|
||||
|
||||
// Optional strictness (usually v3): action/hostname checks
|
||||
var actionToCheck = !string.IsNullOrWhiteSpace(expectedAction) ? expectedAction : _opt.ExpectedAction;
|
||||
if (!string.IsNullOrWhiteSpace(actionToCheck) &&
|
||||
!string.Equals(actionToCheck, data.action, StringComparison.Ordinal))
|
||||
{
|
||||
_log.LogWarning("Captcha action mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
|
||||
actionToCheck, data.action, userIp ?? "unknown");
|
||||
return new CaptchaVerdictModel(false, "Captcha action mismatch", data.score);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_opt.ExpectedHostname) &&
|
||||
!string.Equals(_opt.ExpectedHostname, data.hostname, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_log.LogWarning("Captcha hostname mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
|
||||
_opt.ExpectedHostname, data.hostname, userIp ?? "unknown");
|
||||
return new CaptchaVerdictModel(false, "Captcha hostname mismatch", data.score);
|
||||
}
|
||||
|
||||
_log.LogInformation("Captcha verified successfully for IP {Ip}. Score={Score}",
|
||||
userIp ?? "unknown", data.score);
|
||||
return new CaptchaVerdictModel(true, null, data.score);
|
||||
}
|
||||
|
||||
private sealed class RecaptchaResponse
|
||||
{
|
||||
public bool success { get; set; }
|
||||
public double? score { get; set; } // v3
|
||||
public string? action { get; set; } // v3
|
||||
public string? hostname { get; set; }
|
||||
public DateTimeOffset? challenge_ts { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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;
|
||||
|
||||
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 ILogger<SmtpEmailSender> _log;
|
||||
private readonly string _environmentName;
|
||||
|
||||
public SmtpEmailSender(IOptions<SmtpSettings> smtp,
|
||||
IOptions<ContactSettings> contact,
|
||||
IOptions<SubscribeSettings> subscribe,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
ILogger<SmtpEmailSender> log)
|
||||
{
|
||||
_smtp = smtp.Value;
|
||||
_contact = contact.Value;
|
||||
_subscribe = subscribe.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_log = log;
|
||||
// Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development"
|
||||
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
}
|
||||
|
||||
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
|
||||
{
|
||||
// Throw error if ToEmail is not configured, since contact requests are important to process.
|
||||
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)
|
||||
{
|
||||
// Throw error if ToEmail is not configured, since subscription requests are important to process.
|
||||
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)
|
||||
{
|
||||
// Skip sending if ToEmail is not configured
|
||||
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)
|
||||
{
|
||||
// If you're in enterprise environments, you may need to tweak certificate validation.
|
||||
// Don't disable it casually.
|
||||
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 static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result
|
||||
|
||||
CV Document ID: {cvDocumentId}
|
||||
Job: {jobLabel ?? "N/A"}
|
||||
Job URL: {result.JobUrl ?? "N/A"}
|
||||
Score: {result.Score}%
|
||||
|
||||
Summary:
|
||||
{result.Summary}
|
||||
|
||||
Strengths:
|
||||
- {string.Join("\n- ", result.Strengths)}
|
||||
|
||||
Gaps:
|
||||
- {string.Join("\n- ", result.Gaps)}
|
||||
|
||||
Recommendations:
|
||||
- {string.Join("\n- ", result.Recommendations)}";
|
||||
|
||||
public static string BuildMatchEmailSubject(int score, string? jobLabel)
|
||||
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user