diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index dc6637a9..69dac582 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,7 +24,7 @@ jobs: sed -i 's/"__DATABASE_CONNECTION_STRING",/"${{ vars.DATABASE_CONNECTION_STRING }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__TELEGRAM_BOT_KEY",/"${{ secrets.TELEGRAM_BOT_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__PROFILE_GITHUB_BOT_KEY",/"${{ secrets.PROFILE_GITHUB_BOT_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} - sed -i 's/"__SENDGRID_API_KEY",/"${{ secrets.SENDGRID_API_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} + sed -i 's/"__RESEND_COM_API_KEY",/"${{ secrets.RESEND_COM_API_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__SENDGRID_WEBHOOK_SIGNATURE",/"${{ secrets.SENDGRID_WEBHOOK_SIGNATURE }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i 's/"__OPEN_AI_SECRET",/"${{ secrets.OPEN_AI_SECRET }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}} sed -i "s|__OPENAI_API_KEY|${{ secrets.OPENAI_API_KEY }}|g" ${{env.PRODUCTION_SETTINGS_FILE}} diff --git a/src/Infrastructure/Emails/ResendEmailApiSender.cs b/src/Infrastructure/Emails/ResendEmailApiSender.cs new file mode 100644 index 00000000..2f19b31d --- /dev/null +++ b/src/Infrastructure/Emails/ResendEmailApiSender.cs @@ -0,0 +1,77 @@ +using Domain.Validation; +using Infrastructure.Emails.Contracts; +using Infrastructure.Emails.Contracts.Requests; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Resend; + +namespace Infrastructure.Emails; + +public class ResendEmailApiSender : IEmailApiSender +{ + private readonly IResend _client; + private readonly ILogger _logger; + + public ResendEmailApiSender( + IConfiguration configuration, + ILogger logger) + { + _logger = logger; + var apiKey = configuration["ResendComApiKey"] + ?? throw new InvalidOperationException("ResendComApiKey configuration is missing"); + + _client = ResendClient.Create(apiKey); + } + + public async Task SendAsync( + EmailContent email, + CancellationToken cancellationToken) + { + email.ThrowIfNull(nameof(email)); + + try + { + var message = new EmailMessage + { + From = email.From, + Subject = email.Subject, + HtmlBody = email.HtmlBody + }; + + foreach (var recipient in email.Recipients) + { + message.To.Add(recipient); + } + + if (email.Cc.Any()) + { + message.Cc ??= new EmailAddressList(); + foreach (var cc in email.Cc) + { + message.Cc.Add(cc); + } + } + + if (email.HiddenCc.Any()) + { + message.Bcc ??= new EmailAddressList(); + foreach (var bcc in email.HiddenCc) + { + message.Bcc.Add(bcc); + } + } + + var response = await _client.EmailSendAsync(message, cancellationToken); + + _logger.LogInformation( + "Email was sent successfully via Resend. MessageId: {MessageId}", + response.Content); + } + catch (Exception e) + { + _logger.LogError( + e, + "Could not send email via Resend"); + } + } +} diff --git a/src/Infrastructure/Emails/SendgridEmailApiSender.cs b/src/Infrastructure/Emails/SendgridEmailApiSender.cs deleted file mode 100644 index 1c818195..00000000 --- a/src/Infrastructure/Emails/SendgridEmailApiSender.cs +++ /dev/null @@ -1,78 +0,0 @@ -using Domain.Validation; -using Infrastructure.Emails.Contracts; -using Infrastructure.Emails.Contracts.Requests; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using SendGrid; -using SendGrid.Helpers.Mail; - -namespace Infrastructure.Emails; - -public class SendgridEmailApiSender : IEmailApiSender -{ - private readonly SendGridClient _client; - private readonly ILogger _logger; - - public SendgridEmailApiSender( - IConfiguration configuration, - ILogger logger) - { - _logger = logger; - _client = new SendGridClient(configuration["SendGridApiKey"]); - } - - public async Task SendAsync( - EmailContent email, - CancellationToken cancellationToken) - { - email.ThrowIfNull(nameof(email)); - - try - { - var response = await _client.SendEmailAsync(Message(email), cancellationToken); - if (!response.IsSuccessStatusCode) - { - _logger.LogError( - "SendGrid returned unsuccessful status code: {ResponseStatusCode}", - response.StatusCode); - - return; - } - - var responseString = await response.Body.ReadAsStringAsync(cancellationToken); - _logger.LogInformation( - "Email was sent successfully. StatusCode: {StatusCode}. Response: {Response}", - response.StatusCode, - responseString); - } - catch (Exception e) - { - _logger.LogError( - e, - "Could not send email via SendGrid"); - } - } - - private SendGridMessage Message( - EmailContent email) - { - var msg = MailHelper.CreateSingleEmailToMultipleRecipients( - from: new EmailAddress(email.From), - tos: email.Recipients.Select(x => new EmailAddress(x)).ToList(), - subject: email.Subject, - plainTextContent: string.Empty, - htmlContent: email.HtmlBody); - - if (email.Cc.Any()) - { - msg.AddCcs(email.Cc.Select(x => new EmailAddress(x)).ToList()); - } - - if (email.HiddenCc.Any()) - { - msg.AddBccs(email.HiddenCc.Select(x => new EmailAddress(x)).ToList()); - } - - return msg; - } -} \ No newline at end of file diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index cdb23ab2..4685047c 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -22,13 +22,12 @@ - + - diff --git a/src/Web.Api/Features/Admin/AdminEmailController.cs b/src/Web.Api/Features/Admin/AdminEmailController.cs new file mode 100644 index 00000000..33219080 --- /dev/null +++ b/src/Web.Api/Features/Admin/AdminEmailController.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Threading; +using System.Threading.Tasks; +using Domain.Enums; +using Infrastructure.Emails.Contracts; +using Infrastructure.Emails.Contracts.Requests; +using Microsoft.AspNetCore.Mvc; +using Web.Api.Setup.Attributes; + +namespace Web.Api.Features.Admin; + +[ApiController] +[Route("api/admin/emails")] +[HasAnyRole(Role.Admin)] +public class AdminEmailController : ControllerBase +{ + private readonly IEmailApiSender _emailApiSender; + + public AdminEmailController(IEmailApiSender emailApiSender) + { + _emailApiSender = emailApiSender; + } + + [HttpPost("send")] + public async Task SendCustomEmailAsync( + [FromBody] SendCustomEmailRequest request, + CancellationToken cancellationToken) + { + var recipients = new List { request.To }; + + var cc = !string.IsNullOrWhiteSpace(request.Cc) + ? new List { request.Cc } + : new List(); + + await _emailApiSender.SendAsync( + new EmailContent( + request.From, + request.Subject, + request.HtmlBody, + recipients, + cc), + cancellationToken); + + return Ok(new SendCustomEmailResponse + { + Success = true, + Message = "Email sent successfully" + }); + } + + public record SendCustomEmailRequest + { + [Required] + [EmailAddress] + public string To { get; init; } + + [EmailAddress] + public string Cc { get; init; } + + [Required] + [EmailAddress] + public string From { get; init; } + + [Required] + public string Subject { get; init; } + + [Required] + public string HtmlBody { get; init; } + } + + public record SendCustomEmailResponse + { + public bool Success { get; init; } + + public string Message { get; init; } + } +} diff --git a/src/Web.Api/Features/Salaries/UpdateSalary/UpdateSalaryHandler.cs b/src/Web.Api/Features/Salaries/UpdateSalary/UpdateSalaryHandler.cs index fea19352..2cc561b2 100644 --- a/src/Web.Api/Features/Salaries/UpdateSalary/UpdateSalaryHandler.cs +++ b/src/Web.Api/Features/Salaries/UpdateSalary/UpdateSalaryHandler.cs @@ -2,11 +2,11 @@ using System.Threading.Tasks; using Domain.Entities.Salaries; using Domain.Enums; +using Domain.Validation.Exceptions; using Infrastructure.Authentication.Contracts; using Infrastructure.Database; using Infrastructure.Salaries; using Microsoft.EntityFrameworkCore; -using SendGrid.Helpers.Errors.Model; using Web.Api.Features.Salaries.Models; using NotFoundException = Domain.Validation.Exceptions.NotFoundException; @@ -34,12 +34,12 @@ public async Task Handle( .FirstOrDefaultAsync(x => x.Id == request.Id, cancellationToken) ?? throw new NotFoundException("Salary record not found"); - var currentUser = await _auth.GetCurrentUserOrNullAsync(); + var currentUser = await _auth.GetCurrentUserOrNullAsync(cancellationToken); if (!currentUser.Has(Role.Admin) && salary.UserId != currentUser.Id) { - throw new ForbiddenException("You can only edit your own salary records"); + throw new NoPermissionsException("You can only edit your own salary records"); } Skill skill = null; diff --git a/src/Web.Api/Features/Webhooks/WebhooksController.cs b/src/Web.Api/Features/Webhooks/WebhooksController.cs deleted file mode 100644 index 30680964..00000000 --- a/src/Web.Api/Features/Webhooks/WebhooksController.cs +++ /dev/null @@ -1,136 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Security; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Infrastructure.Services.Correlation; -using Infrastructure.Services.Mediator; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using StrongGrid; -using StrongGrid.Models; -using StrongGrid.Models.Webhooks; -using Web.Api.Features.Users.UnsubscribeUserFromEmails; - -namespace Web.Api.Features.Webhooks; - -[ApiController] -[Route("api/webhooks")] -public class WebhooksController : ControllerBase -{ - public const string HeadersSignatureKey = "X-Twilio-Email-Event-Webhook-Signature"; - public const string HeadersTimestampKey = "X-Twilio-Email-Event-Webhook-Timestamp"; - - private readonly ILogger _logger; - private readonly IConfiguration _configuration; - private readonly ICorrelationIdAccessor _correlationIdAccessor; - private readonly IServiceProvider _serviceProvider; - - public WebhooksController( - ILogger logger, - IConfiguration configuration, - ICorrelationIdAccessor correlationIdAccessor, - IServiceProvider serviceProvider) - { - _logger = logger; - _configuration = configuration; - _correlationIdAccessor = correlationIdAccessor; - _serviceProvider = serviceProvider; - } - - [HttpPost("sendgrid")] - public async Task SendgridWebhook( - CancellationToken cancellationToken) - { - var allItems = await TryParseBodyAsync(cancellationToken); - - // Verify signature using the raw body - if (allItems.Count == 0) - { - _logger.LogWarning( - "No items found in Sendgrid webhook. CorrelationId: {CorrelationId}", - _correlationIdAccessor.GetValue()); - - return Ok(); - } - - var spamReportEvents = allItems - .Where(x => x.EventType is EventType.SpamReport) - .ToList(); - - if (spamReportEvents.Count > 0) - { - var result = await _serviceProvider.HandleBy, bool>( - spamReportEvents.Select(x => x.Email).ToList(), - cancellationToken); - - _logger.LogInformation( - "Received {Count} spam report events from Sendgrid. Emails: {Emails}. Processed: {Processed}. CorrelationId: {CorrelationId}", - spamReportEvents.Count, - string.Join(", ", spamReportEvents.Select(x => x.Email)), - result, - _correlationIdAccessor.GetValue()); - } - else - { - _logger.LogInformation( - "No spam report events found in Sendgrid webhook. Items: {Items}. CorrelationId: {CorrelationId}", - JsonSerializer.Serialize(allItems), - _correlationIdAccessor.GetValue()); - } - - return Ok(); - } - - private async Task> TryParseBodyAsync( - CancellationToken cancellationToken) - { - var signatureFromHeaders = Request.Headers[HeadersSignatureKey].ToString(); - var timestamp = Request.Headers[HeadersTimestampKey].ToString(); - - var signatureFromConfigs = _configuration["SendGridWebhookSignature"]; - if (string.IsNullOrEmpty(signatureFromConfigs)) - { - _logger.LogWarning( - "SendGridWebhookSignature is not configured. CorrelationId: {CorrelationId}", - _correlationIdAccessor.GetValue()); - - return new List(0); - } - - try - { - var parser = new WebhookParser(); - var events = await parser.ParseSignedEventsWebhookAsync( - Request.Body, - signatureFromConfigs, - signatureFromHeaders, - timestamp, - cancellationToken: cancellationToken) - .ConfigureAwait(false); - - return events.ToList(); - } - catch (SecurityException e) - { - _logger.LogError( - e, - "Security error during signed body verification. CorrelationId: {CorrelationId}", - _correlationIdAccessor.GetValue()); - - return new List(0); - } - catch (Exception ex) - { - _logger.LogError( - ex, - "General error during signed body verification. CorrelationId: {CorrelationId}", - _correlationIdAccessor.GetValue()); - - return new List(0); - } - } -} \ No newline at end of file diff --git a/src/Web.Api/Setup/ServiceRegistration.cs b/src/Web.Api/Setup/ServiceRegistration.cs index 80ea3c16..48ce9b2e 100644 --- a/src/Web.Api/Setup/ServiceRegistration.cs +++ b/src/Web.Api/Setup/ServiceRegistration.cs @@ -98,7 +98,7 @@ public static IServiceCollection SetupEmailIntegration( else { services - .AddScoped(); + .AddScoped(); } return services; diff --git a/src/Web.Api/appsettings.Production.json b/src/Web.Api/appsettings.Production.json index 84643ba8..8e3c7e21 100644 --- a/src/Web.Api/appsettings.Production.json +++ b/src/Web.Api/appsettings.Production.json @@ -23,6 +23,7 @@ }, "OpenAiApiSecret": "__OPEN_AI_SECRET", "SendGridApiKey": "__SENDGRID_API_KEY", + "ResendComApiKey": "__RESEND_COM_API_KEY", "SendGridWebhookSignature": "__SENDGRID_WEBHOOK_SIGNATURE", "BaseUrl": "https://techinterview.space", "ImageBaseUrl": "https://api.techinterview.space", diff --git a/src/Web.Api/appsettings.json b/src/Web.Api/appsettings.json index 9e681b66..333e0967 100644 --- a/src/Web.Api/appsettings.json +++ b/src/Web.Api/appsettings.json @@ -26,6 +26,7 @@ }, "SendGridApiKey": "", "SendGridWebhookSignature": "", + "ResendComApiKey": "", "OpenAiApiSecret": "", "OpenAiApiUrl": "https://wf.belyaev.live/webhook/e69fa36a-adff-410c-8cd0-04ec833d849e", "S3": { diff --git a/src/techinterview.sln b/src/techinterview.sln index a57bc7ef..bf91b266 100644 --- a/src/techinterview.sln +++ b/src/techinterview.sln @@ -17,6 +17,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Web.Api.Tests", "Web.Api.Te EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Items", "Items", "{0613B82A-F688-480D-B644-FED4A8FF9B7D}" ProjectSection(SolutionItems) = preProject + ..\.github\workflows\deploy.yml = ..\.github\workflows\deploy.yml Dockerfile = Dockerfile ..\docker-compose.deploy.yml = ..\docker-compose.deploy.yml ..\docker-compose.yml = ..\docker-compose.yml