Add contact feature
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
using BeauFindlay.Shared.Abstractions;
|
||||
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal interface IRecaptchaService
|
||||
{
|
||||
Task<Result> ValidateResponseAsync(string recaptchaResponse,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace BeauFindlay.Api.Features.Contact;
|
||||
|
||||
internal sealed record RecaptchaSettings(string ApiKey);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user