Skip to content

Commit

Permalink
Clean up verification email sending in HangfireBackgroundService
Browse files Browse the repository at this point in the history
  • Loading branch information
Jack-Edwards committed Dec 27, 2023
1 parent e9d0707 commit 753caae
Show file tree
Hide file tree
Showing 7 changed files with 193 additions and 48 deletions.
35 changes: 35 additions & 0 deletions Crypter.Core/Exceptions/HangfireJobException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (C) 2023 Crypter File Transfer
*
* This file is part of the Crypter file transfer project.
*
* Crypter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Crypter source code is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the aforementioned license
* by purchasing a commercial license. Buying such a license is mandatory
* as soon as you develop commercial activities involving the Crypter source
* code without disclosing the source code of your own applications.
*
* Contact the current copyright holder to discuss commercial license options.
*/

using System;

namespace Crypter.Core.Exceptions;

public class HangfireJobException : Exception
{
public HangfireJobException(string message) : base(message)
{ }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* Copyright (C) 2023 Crypter File Transfer
*
* This file is part of the Crypter file transfer project.
*
* Crypter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Crypter source code is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the aforementioned license
* by purchasing a commercial license. Buying such a license is mandatory
* as soon as you develop commercial activities involving the Crypter source
* code without disclosing the source code of your own applications.
*
* Contact the current copyright holder to discuss commercial license options.
*/

using System;
using System.Threading;
using System.Threading.Tasks;
using Crypter.Core.Models;
using Crypter.Core.Services;
using Crypter.Crypto.Common;
using Crypter.DataAccess;
using Crypter.DataAccess.Entities;
using EasyMonads;
using MediatR;

namespace Crypter.Core.Features.UserEmailVerification.Commands;

/// <summary>
/// A command that will attempt to send a verification email to the provided
/// user.
///
/// Returns 'true' if the email was successfully sent or if the user's current
/// state is such that they should not receive a verification email.
///
/// Returns 'false' if the email should be sent, but failed to send.
/// </summary>
/// <param name="UserId"></param>
public sealed record SendVerificationEmailCommand(Guid UserId) : IRequest<bool>;

internal sealed class SendVerificationEmailCommandHandler
: IRequestHandler<SendVerificationEmailCommand, bool>
{
private readonly ICryptoProvider _cryptoProvider;
private readonly DataContext _dataContext;
private readonly IEmailService _emailService;

public SendVerificationEmailCommandHandler(
ICryptoProvider cryptoProvider,
DataContext dataContext,
IEmailService emailService)
{
_cryptoProvider = cryptoProvider;
_dataContext = dataContext;
_emailService = emailService;
}

public async Task<bool> Handle(SendVerificationEmailCommand request, CancellationToken cancellationToken)
{
return await UserEmailVerificationQueries
.GenerateVerificationParametersAsync(_dataContext, _cryptoProvider, request.UserId)
.BindAsync<UserEmailAddressVerificationParameters, bool>(async parameters =>
{
bool deliverySuccess = await _emailService.SendEmailVerificationAsync(parameters);
if (deliverySuccess)
{
UserEmailVerificationEntity newEntity = new UserEmailVerificationEntity(parameters.UserId,
parameters.VerificationCode, parameters.VerificationKey, DateTime.UtcNow);
_dataContext.UserEmailVerifications.Add(newEntity);
await _dataContext.SaveChangesAsync(CancellationToken.None);
}

return deliverySuccess;
}).SomeOrDefaultAsync(true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@

using System;
using System.Threading.Tasks;
using Crypter.Core.Models;
using Crypter.DataAccess;
using Crypter.DataAccess.Entities;
using Microsoft.EntityFrameworkCore;
Expand All @@ -35,15 +34,6 @@ namespace Crypter.Core.Features.UserEmailVerification;

internal static class UserEmailVerificationCommands
{
internal static Task SaveVerificationParametersAsync(DataContext dataContext,
UserEmailAddressVerificationParameters parameters)
{
UserEmailVerificationEntity newEntity = new UserEmailVerificationEntity(parameters.UserId,
parameters.VerificationCode, parameters.VerificationKey, DateTime.UtcNow);
dataContext.UserEmailVerifications.Add(newEntity);
return dataContext.SaveChangesAsync();
}

internal static async Task DeleteUserEmailVerificationEntity(DataContext dataContext, Guid userId, bool saveChanges)
{
UserEmailVerificationEntity foundEntity = await dataContext.UserEmailVerifications
Expand Down
23 changes: 12 additions & 11 deletions Crypter.Core/Services/EmailService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
using MailKit.Security;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;

Expand Down Expand Up @@ -63,11 +64,13 @@ public static void AddEmailService(this IServiceCollection services, Action<Emai

public class EmailService : IEmailService
{
private readonly EmailSettings Settings;
private readonly IOptions<EmailSettings> _settings;
private readonly ILogger<EmailService> _logger;

public EmailService(IOptions<EmailSettings> emailSettings)
public EmailService(IOptions<EmailSettings> emailSettings, ILogger<EmailService> logger)
{
Settings = emailSettings.Value;
_settings = emailSettings;
_logger = logger;
}

/// <summary>
Expand All @@ -80,13 +83,13 @@ public EmailService(IOptions<EmailSettings> emailSettings)
/// <returns></returns>
public virtual async Task<bool> SendAsync(string subject, string message, EmailAddress recipient)
{
if (!Settings.Enabled)
if (!_settings.Value.Enabled)
{
return true;
}

var mailMessage = new MimeMessage();
mailMessage.From.Add(MailboxAddress.Parse(Settings.From));
mailMessage.From.Add(MailboxAddress.Parse(_settings.Value.From));
mailMessage.To.Add(MailboxAddress.Parse(recipient.Value));
mailMessage.Subject = subject;
mailMessage.Body = new TextPart("plain")
Expand All @@ -98,13 +101,13 @@ public virtual async Task<bool> SendAsync(string subject, string message, EmailA

try
{
await smtpClient.ConnectAsync(Settings.Host, Settings.Port, SecureSocketOptions.StartTls);
await smtpClient.AuthenticateAsync(Settings.Username, Settings.Password);
await smtpClient.ConnectAsync(_settings.Value.Host, _settings.Value.Port, SecureSocketOptions.StartTls);
await smtpClient.AuthenticateAsync(_settings.Value.Username, _settings.Value.Password);
await smtpClient.SendAsync(mailMessage);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
_logger.LogError("An error occured while sending an email: {error}", ex.Message);
return false;
}
finally
Expand All @@ -130,9 +133,7 @@ public async Task<bool> SendAccountRecoveryLinkAsync(UserRecoveryParameters para
/// <summary>
/// Send a verification email to the provided recipient.
/// </summary>
/// <param name="emailAddress"></param>
/// <param name="verificationCode"></param>
/// <param name="ecdsaPrivateKey"></param>
/// <param name="parameters"></param>
/// <remarks>This method is 'virtual' to enable some unit tests.</remarks>
/// <returns></returns>
public virtual async Task<bool> SendEmailVerificationAsync(UserEmailAddressVerificationParameters parameters)
Expand Down
53 changes: 34 additions & 19 deletions Crypter.Core/Services/HangfireBackgroundService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@
using System.Threading.Tasks;
using Crypter.Common.Enums;
using Crypter.Common.Primitives;
using Crypter.Core.Exceptions;
using Crypter.Core.Features.AccountRecovery;
using Crypter.Core.Features.Keys.Commands;
using Crypter.Core.Features.Transfer;
using Crypter.Core.Features.UserAuthentication;
using Crypter.Core.Features.UserEmailVerification;
using Crypter.Core.Features.UserEmailVerification.Commands;
using Crypter.Core.Features.UserToken;
using Crypter.Core.LinqExpressions;
using Crypter.Core.Repositories;
Expand All @@ -43,13 +44,16 @@
using Crypter.DataAccess.Entities;
using EasyMonads;
using Hangfire;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Unit = EasyMonads.Unit;

namespace Crypter.Core.Services;

public interface IHangfireBackgroundService
{
Task SendEmailVerificationAsync(Guid userId);
Task<Unit> SendEmailVerificationAsync(Guid userId);
Task SendTransferNotificationAsync(Guid itemId, TransferItemType itemType);
Task SendRecoveryEmailAsync(string emailAddress);

Expand Down Expand Up @@ -85,35 +89,45 @@ Task DeleteTransferAsync(Guid itemId, TransferItemType itemType, TransferUserTyp
public class HangfireBackgroundService : IHangfireBackgroundService
{
private readonly DataContext _dataContext;
private readonly ISender _sender;
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly ICryptoProvider _cryptoProvider;
private readonly IEmailService _emailService;
private readonly ITransferRepository _transferRepository;
private readonly ILogger<HangfireBackgroundService> _logger;

private const int _accountRecoveryEmailExpirationMinutes = 30;
private const int AccountRecoveryEmailExpirationMinutes = 30;

public HangfireBackgroundService(
DataContext dataContext, IBackgroundJobClient backgroundJobClient, ICryptoProvider cryptoProvider,
IEmailService emailService, ITransferRepository transferRepository)
DataContext dataContext,
ISender sender,
IBackgroundJobClient backgroundJobClient,
ICryptoProvider cryptoProvider,
IEmailService emailService,
ITransferRepository transferRepository,
ILogger<HangfireBackgroundService> logger)
{
_dataContext = dataContext;
_sender = sender;
_backgroundJobClient = backgroundJobClient;
_cryptoProvider = cryptoProvider;
_emailService = emailService;
_transferRepository = transferRepository;
_logger = logger;
}

public Task SendEmailVerificationAsync(Guid userId)
public async Task<Unit> SendEmailVerificationAsync(Guid userId)
{
return UserEmailVerificationQueries.GenerateVerificationParametersAsync(_dataContext, _cryptoProvider, userId)
.IfSomeAsync(async x =>
{
bool deliverySuccess = await _emailService.SendEmailVerificationAsync(x);
if (deliverySuccess)
{
await UserEmailVerificationCommands.SaveVerificationParametersAsync(_dataContext, x);
}
});
var request = new SendVerificationEmailCommand(userId);
var result = await _sender.Send(request);

if (!result)
{
_logger.LogError("Failed to send verification email for user: {userId}", userId);
throw new HangfireJobException($"{nameof(SendEmailVerificationAsync)} failed");
}

return Unit.Default;
}

public async Task SendTransferNotificationAsync(Guid itemId, TransferItemType itemType)
Expand Down Expand Up @@ -145,13 +159,13 @@ public Task SendRecoveryEmailAsync(string emailAddress)
{
bool deliverySuccess =
await _emailService.SendAccountRecoveryLinkAsync(parameters,
_accountRecoveryEmailExpirationMinutes);
AccountRecoveryEmailExpirationMinutes);
if (deliverySuccess)
{
DateTime utcNow = DateTime.UtcNow;
await UserRecoveryCommands.SaveRecoveryParametersAsync(_dataContext, parameters, utcNow);

DateTime recoveryExpiration = utcNow.AddMinutes(_accountRecoveryEmailExpirationMinutes);
DateTime recoveryExpiration = utcNow.AddMinutes(AccountRecoveryEmailExpirationMinutes);
_backgroundJobClient.Schedule(() => DeleteRecoveryParametersAsync(parameters.UserId),
recoveryExpiration);
}
Expand Down Expand Up @@ -180,9 +194,10 @@ public Task DeleteRecoveryParametersAsync(Guid userId)
return UserRecoveryCommands.DeleteRecoveryParametersAsync(_dataContext, userId);
}

public Task<Unit> DeleteUserKeysAsync(Guid userId)
public async Task<Unit> DeleteUserKeysAsync(Guid userId)
{
return DeleteUserKeysCommandHandler.HandleAsync(_dataContext, userId);
var request = new DeleteUserKeysCommand(userId);
return await _sender.Send(request);
}

public async Task<Unit> DeleteReceivedTransfersAsync(Guid userId)
Expand Down
15 changes: 12 additions & 3 deletions Crypter.Test/Core_Tests/Services_Tests/EmailService_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
using Crypter.Common.Primitives;
using Crypter.Core.Services;
using Crypter.Core.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NUnit.Framework;

Expand All @@ -37,18 +39,25 @@ namespace Crypter.Test.Core_Tests.Services_Tests;
public class EmailService_Tests
{
private IOptions<EmailSettings> _defaultEmailSettings;
private ILogger<EmailService> _logger;

[OneTimeSetUp]
public void OneTimeSetup()
[SetUp]
public void Setup()
{
EmailSettings settings = new EmailSettings();
_defaultEmailSettings = Options.Create(settings);

var serviceProvider = new ServiceCollection()
.AddLogging()
.BuildServiceProvider();
var factory = serviceProvider.GetService<ILoggerFactory>();
_logger = factory.CreateLogger<EmailService>();
}

[Test]
public async Task ServiceDisabled_SendAsync_ReturnsTrue()
{
var sut = new EmailService(_defaultEmailSettings);
var sut = new EmailService(_defaultEmailSettings, _logger);
var emailAddress = EmailAddress.From("[email protected]");
var result = await sut.SendAsync("foo", "bar", emailAddress);
Assert.IsTrue(result);
Expand Down
Loading

0 comments on commit 753caae

Please sign in to comment.