Add contact feature

This commit is contained in:
2024-03-09 09:47:49 +00:00
parent 78bef4c113
commit a00a8d11ac
33 changed files with 1126 additions and 44 deletions

View File

@@ -0,0 +1,9 @@
using BeauFindlay.Shared.Abstractions;
namespace BeauFindlay.Api.Features.Contact;
internal interface IRecaptchaService
{
Task<Result> ValidateResponseAsync(string recaptchaResponse,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
using BeauFindlay.Shared.Abstractions;
namespace BeauFindlay.Api.Features.Contact;
internal interface ISendGridService
{
Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
string htmlContent);
}

View File

@@ -0,0 +1,93 @@
using BeauFindlay.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace BeauFindlay.Api.Features.Contact;
internal sealed class RecaptchaService(
HttpClient httpClient,
ILogger<RecaptchaService> logger,
RecaptchaSettings settings)
: IRecaptchaService
{
public async Task<Result> ValidateResponseAsync(string recaptchaResponse,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(recaptchaResponse))
{
logger.LogWarning("Recaptcha response is null.");
return Result.Failure(RecaptchaErrors.ResponseNull);
}
var response = await httpClient.PostAsync(
$"https://www.google.com/recaptcha/api/siteverify?secret={settings.ApiKey}&response={recaptchaResponse}",
null, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogError(
"Google Recaptcha API validation request failed. Code: {StatusCode}",
response.StatusCode);
return Result.Failure(RecaptchaErrors.ApiRequestFailed);
}
var responseString = await response.Content.ReadAsStringAsync(cancellationToken);
var recaptchaResult = JsonConvert.DeserializeObject<RecaptchaVerificationResult>(responseString);
if (recaptchaResult is null)
{
logger.LogError("Unable to deserialize Recaptcha result.");
return Result.Failure(RecaptchaErrors.ResponseSerializationFailed);
}
if (!recaptchaResult.Success)
{
logger.LogWarning(
"Google Recaptcha validation failed. Errors: {Errors}",
recaptchaResult.ErrorCodes);
return Result.Failure(RecaptchaErrors.ValidationFailed);
}
logger.LogInformation("Recaptcha validation passed.");
return Result.Success();
}
private class RecaptchaVerificationResult
{
[JsonProperty("success")]
public bool Success { get; set; }
[JsonProperty("challenge_ts")]
public DateTime ChallengeTs { get; set; }
[JsonProperty("hostname")]
public string Hostname { get; set; } = string.Empty;
[JsonProperty("error-codes")]
public List<string> ErrorCodes { get; set; } = [];
}
}
public static class RecaptchaErrors
{
public static readonly Error ResponseNull = new(
"Recaptcha.ResponseNull",
"Recaptcha response is null.");
public static readonly Error ValidationFailed = new(
"Recaptcha.ValidationFailed",
"Recaptcha validation failed.");
public static readonly Error ResponseSerializationFailed = new(
"Recaptcha.ResponseSerializationFailed",
"Unable to deserialize Recaptcha result.");
public static readonly Error ApiRequestFailed = new(
"Recaptcha.ApiRequestFailed",
"Recaptcha API validation request failed.");
}

View File

@@ -0,0 +1,3 @@
namespace BeauFindlay.Api.Features.Contact;
internal sealed record RecaptchaSettings(string ApiKey);

View File

@@ -0,0 +1,71 @@
using BeauFindlay.Api.Abstractions.Messaging;
using BeauFindlay.Shared.Abstractions;
using FluentValidation;
using Microsoft.Extensions.Logging;
namespace BeauFindlay.Api.Features.Contact;
public sealed record SendContactEmailCommand(string Name, string FromEmail, string Message, string RecaptchaResponse)
: ICommand;
internal sealed class SendContactEmailCommandValidator : AbstractValidator<SendContactEmailCommand>
{
public SendContactEmailCommandValidator(IRecaptchaService recaptchaService)
{
RuleFor(c => c.Name)
.NotEmpty()
.MaximumLength(50);
RuleFor(c => c.FromEmail)
.NotEmpty()
.EmailAddress();
RuleFor(c => c.Message)
.NotEmpty()
.MaximumLength(500);
RuleFor(c => c.RecaptchaResponse)
.NotEmpty()
.MustAsync(async (response, cancellation) =>
{
var validationResult = await recaptchaService.ValidateResponseAsync(response, cancellation);
return validationResult.IsSuccess;
});
}
}
internal sealed class SendContactEmailCommandHandler(
ISendGridService sendGridService,
IValidator<SendContactEmailCommand> validator,
ILogger<SendContactEmailCommandHandler> logger)
: ICommandHandler<SendContactEmailCommand>
{
private const string EmailSubjectBase = "New website enquiry";
private const string MyEmail = "me@beaufindlay.com";
public async Task<Result> Handle(SendContactEmailCommand request, CancellationToken cancellationToken)
{
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
logger.LogError("Command validation failed. Errors: {ValidationErrors}", validationResult.ToString());
return Result.Failure(new Error("ValidationFailed", "Command validation failed"));
}
var subject = $"{EmailSubjectBase} - {request.FromEmail}";
var message = $"From: {request.FromEmail}. Message: {request.Message}";
var htmlMessage = $"<p><b>From:</b> {request.FromEmail}</p> <p><b>Message:</b> <br /> {request.Message}</p>";
var emailResult = await sendGridService.SendEmailAsync(
MyEmail,
MyEmail,
subject,
message,
htmlMessage);
return emailResult.IsFailure ? Result.Failure(emailResult.Error) : Result.Success();
}
}

View File

@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography;
using BeauFindlay.Shared.Contracts;
using MediatR;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace BeauFindlay.Api.Features.Contact;
public class SendContactEmailFunction(ILoggerFactory loggerFactory, ISender sender)
{
private readonly ILogger logger = loggerFactory.CreateLogger<SendContactEmailFunction>();
[Function(nameof(SendContactEmailFunction))]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "send-contact-email")] HttpRequestData req,
FunctionContext executionContext, CancellationToken cancellationToken)
{
logger.LogInformation($"{nameof(SendContactEmailFunction)} function received a request.");
var requestBody = await new StreamReader(req.Body).ReadToEndAsync(cancellationToken);
var request = JsonConvert.DeserializeObject<SendContactEmailRequest>(requestBody);
HttpResponseData response;
try
{
if (request == null)
{
throw new ApplicationException("Unable to deserialize response.");
}
var emailCommand = new SendContactEmailCommand(
request.Name,
request.FromEmail,
request.Message,
request.RecaptchaResponse);
var sendEmailResult = await sender.Send(emailCommand, cancellationToken);
if (sendEmailResult.IsFailure)
{
logger.LogError("Send email command failed. Error: {Error}", sendEmailResult.Error.Message);
response = req.CreateResponse(HttpStatusCode.BadRequest);
var error = new ErrorResponse
{
Code = (int)HttpStatusCode.BadRequest,
Message = sendEmailResult.Error.Message
};
await response.WriteAsJsonAsync(error, cancellationToken);
}
else
{
response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync("", cancellationToken: cancellationToken);
}
}
catch (Exception e)
{
logger.LogError(e, "Exception occured. Error: '{Message}'", e.Message);
response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(ErrorResponse.Generic, cancellationToken);
}
return response;
}
}

View File

@@ -0,0 +1,38 @@
using BeauFindlay.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace BeauFindlay.Api.Features.Contact;
internal sealed class SendGridService(ISendGridClient sendGridClient, ILogger<SendGridService> logger)
: ISendGridService
{
public async Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
string htmlContent)
{
ArgumentException.ThrowIfNullOrWhiteSpace(from, nameof(from));
ArgumentException.ThrowIfNullOrWhiteSpace(to, nameof(to));
ArgumentException.ThrowIfNullOrWhiteSpace(subject, nameof(subject));
ArgumentException.ThrowIfNullOrWhiteSpace(plainTextContent, nameof(plainTextContent));
ArgumentException.ThrowIfNullOrWhiteSpace(htmlContent, nameof(htmlContent));
var fromEmail = new EmailAddress(from);
var toEmail = new EmailAddress(to);
var message = MailHelper.CreateSingleEmail(fromEmail, toEmail, subject, plainTextContent, htmlContent);
var response = await sendGridClient.SendEmailAsync(message);
if (response is not { IsSuccessStatusCode: true })
{
logger.LogError("Failed to send email. Status code: '{StatusCode}'", response?.StatusCode);
return Result.Failure(new Error("Email.SendFailed", "Failed to send email."));
}
logger.LogInformation("Email sent successfully.");
return Result.Success();
}
}