diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs index da24a719..e635c1b1 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI/Extensions/TestCaseUITestContextExtensions.cs @@ -2,7 +2,9 @@ using Lombiq.Tests.UI.Helpers; using Lombiq.Tests.UI.Services; using OpenQA.Selenium; +using Shouldly; using System; +using System.Collections.Generic; using System.Threading.Tasks; namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Tests.UI.Extensions; @@ -11,7 +13,8 @@ public static class TestCaseUITestContextExtensions { private const string SuccessfulSubject = "Successful test message"; private const string UnSuccessfulSubject = "Unsuccessful test message"; - private const string DashboardWarning = + private const string WarningSubject = "[Warning] Your site has used"; + private const string DashboardExceededMessage = "//p[contains(@class,'alert-danger')][contains(.,'It seems that your site sent out more e-mails')]"; public static async Task TestEmailQuotaManagementBehaviorAsync( @@ -21,26 +24,57 @@ public static async Task TestEmailQuotaManagementBehaviorAsync( { await context.SignInDirectlyAndGoToDashboardAsync(); - context.Missing(By.XPath(DashboardWarning)); + context.Missing(By.XPath(DashboardExceededMessage)); await context.GoToAdminRelativeUrlAsync("/Settings/email"); CheckEmailsSentWarningMessage(context, exists: moduleShouldInterfere, maximumEmailQuota, 0); - await SendTestEmailAsync(context, SuccessfulSubject); - context.SuccessMessageExists(); + var warningEmails = new List(); + for (int i = 0; i < maximumEmailQuota; i++) + { + await SendTestEmailAsync(context, SuccessfulSubject); + context.SuccessMessageExists(); + CheckEmailsSentWarningMessage(context, exists: moduleShouldInterfere, maximumEmailQuota, i + 1); + var warningLevel = Convert.ToInt32(Math.Round((double)(i + 1) / maximumEmailQuota * 100, 0)); - CheckEmailsSentWarningMessage(context, exists: moduleShouldInterfere, maximumEmailQuota, 1); + if (!moduleShouldInterfere) continue; - await context.GoToDashboardAsync(); - context.CheckExistence(By.XPath(DashboardWarning), exists: moduleShouldInterfere); + if (warningLevel >= 100) + { + await context.GoToDashboardAsync(); + context.CheckExistence(By.XPath(DashboardExceededMessage), exists: true); + } + else if (warningLevel >= 80) + { + await context.GoToDashboardAsync(); + context.CheckExistence( + By.XPath($"//p[contains(@class,'alert-warning')]" + + $"[contains(.,'It seems that your site sent out {warningLevel.ToTechnicalString()}% of e-mail')]"), + exists: true); + if (!warningEmails.Contains(warningLevel)) + { + warningEmails.Add(warningLevel); + } + } + } await SendTestEmailAsync(context, UnSuccessfulSubject); await context.GoToSmtpWebUIAsync(); context.CheckExistence(ByHelper.SmtpInboxRow(SuccessfulSubject), exists: true); context.CheckExistence( - ByHelper.SmtpInboxRow("[Action Required] Your DotNest site has run over its e-mail quota"), + ByHelper.SmtpInboxRow("[Action Required] Your site has run over its e-mail quota"), + exists: moduleShouldInterfere); + var warningMessageExists = context.CheckExistence( + ByHelper.SmtpInboxRow(WarningSubject), exists: moduleShouldInterfere); + if (moduleShouldInterfere && warningMessageExists) + { + (context.GetAll( + ByHelper.SmtpInboxRow(WarningSubject)).Count == warningEmails.Count) + .ShouldBeTrue(); + } + context.CheckExistence(ByHelper.SmtpInboxRow(UnSuccessfulSubject), exists: !moduleShouldInterfere); } diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/EmailQuotaErrorFilter.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/DashboardQuotaFilter.cs similarity index 62% rename from Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/EmailQuotaErrorFilter.cs rename to Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/DashboardQuotaFilter.cs index 65aba68d..39649395 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/EmailQuotaErrorFilter.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/DashboardQuotaFilter.cs @@ -9,20 +9,20 @@ namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Filters; -public class EmailQuotaErrorFilter : IAsyncResultFilter +public class DashboardQuotaFilter : IAsyncResultFilter { private readonly IShapeFactory _shapeFactory; private readonly ILayoutAccessor _layoutAccessor; - private readonly IQuotaService _quotaService; + private readonly IEmailQuotaService _emailQuotaService; - public EmailQuotaErrorFilter( + public DashboardQuotaFilter( IShapeFactory shapeFactory, ILayoutAccessor layoutAccessor, - IQuotaService quotaService) + IEmailQuotaService emailQuotaService) { _shapeFactory = shapeFactory; _layoutAccessor = layoutAccessor; - _quotaService = quotaService; + _emailQuotaService = emailQuotaService; } public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) @@ -41,13 +41,25 @@ public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultE actionRouteArea == $"{nameof(OrchardCore)}.{nameof(OrchardCore.Admin)}" && actionRouteValue is nameof(AdminController.Index) && context.Result is ViewResult && - _quotaService.ShouldLimitEmails() && - (await _quotaService.IsQuotaOverTheLimitAsync()).IsOverQuota) + _emailQuotaService.ShouldLimitEmails()) { var layout = await _layoutAccessor.GetLayoutAsync(); var contentZone = layout.Zones["Content"]; + var currentEmailQuota = await _emailQuotaService.IsQuotaOverTheLimitAsync(); - await contentZone.AddAsync(await _shapeFactory.CreateAsync("EmailQuotaError"), "0"); + var currentUsagePercentage = currentEmailQuota.EmailQuota + .CurrentUsagePercentage(_emailQuotaService.GetEmailQuotaPerMonth()); + + if (currentUsagePercentage >= 80) + { + await contentZone.AddAsync( + await _shapeFactory.CreateAsync("DashboardQuotaMessage", new + { + currentEmailQuota.IsOverQuota, + UsagePercentage = currentUsagePercentage, + }), + "0"); + } } await next(); diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/EmailSettingsQuotaFilter.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/EmailSettingsQuotaFilter.cs index 7fbca534..b4543ede 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/EmailSettingsQuotaFilter.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Filters/EmailSettingsQuotaFilter.cs @@ -1,8 +1,6 @@ -using Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; using Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Options; using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.Layout; using OrchardCore.Mvc.Core.Utilities; @@ -15,19 +13,16 @@ public class EmailSettingsQuotaFilter : IAsyncResultFilter { private readonly IShapeFactory _shapeFactory; private readonly ILayoutAccessor _layoutAccessor; - private readonly IQuotaService _quotaService; - private readonly EmailQuotaOptions _emailQuotaOptions; + private readonly IEmailQuotaService _emailQuotaService; public EmailSettingsQuotaFilter( IShapeFactory shapeFactory, ILayoutAccessor layoutAccessor, - IQuotaService quotaService, - IOptions emailQuotaOptions) + IEmailQuotaService emailQuotaService) { _shapeFactory = shapeFactory; _layoutAccessor = layoutAccessor; - _quotaService = quotaService; - _emailQuotaOptions = emailQuotaOptions.Value; + _emailQuotaService = emailQuotaService; } public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) @@ -48,17 +43,17 @@ actionRouteValue is nameof(AdminController.Index) && context.Result is ViewResult && context.RouteData.Values.TryGetValue("GroupId", out var groupId) && (string)groupId == "email" && - _quotaService.ShouldLimitEmails()) + _emailQuotaService.ShouldLimitEmails()) { var layout = await _layoutAccessor.GetLayoutAsync(); var contentZone = layout.Zones["Content"]; - var quota = await _quotaService.GetCurrentQuotaAsync(); + var quota = await _emailQuotaService.GetOrCreateCurrentQuotaAsync(); await contentZone.AddAsync( - await _shapeFactory.CreateAsync("EmailSettingsQuota", new + await _shapeFactory.CreateAsync("EmailSettingsQuotaMessage", new { - CurrentEmailCount = quota.CurrentEmailQuotaCount, - EmailQuota = _emailQuotaOptions.EmailQuotaPerMonth, + quota.CurrentEmailUsageCount, + EmailQuotaPerMonth = _emailQuotaService.GetEmailQuotaPerMonth(), }), "0"); } diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Indexes/EmailQuotaIndex.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Indexes/EmailQuotaIndex.cs index 9d862528..7bac0f86 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Indexes/EmailQuotaIndex.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Indexes/EmailQuotaIndex.cs @@ -6,8 +6,9 @@ namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Indexes; public class EmailQuotaIndex : MapIndex { - public int CurrentEmailQuotaCount { get; set; } - public DateTime LastReminder { get; set; } + public int CurrentEmailUsageCount { get; set; } + public DateTime LastReminderUtc { get; set; } + public int LastReminderPercentage { get; set; } } public class EmailQuotaIndexProvider : IndexProvider @@ -16,7 +17,8 @@ public override void Describe(DescribeContext context) => context.For() .Map(emailQuota => new EmailQuotaIndex { - CurrentEmailQuotaCount = emailQuota.CurrentEmailQuotaCount, - LastReminder = emailQuota.LastReminder, + CurrentEmailUsageCount = emailQuota.CurrentEmailUsageCount, + LastReminderUtc = emailQuota.LastReminderUtc, + LastReminderPercentage = emailQuota.LastReminderPercentage, }); } diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Migrations/EmailQuotaMigrations.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Migrations/EmailQuotaMigrations.cs index 4420aaad..77426ccf 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Migrations/EmailQuotaMigrations.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Migrations/EmailQuotaMigrations.cs @@ -10,9 +10,19 @@ public class EmailQuotaMigrations : DataMigration public int Create() { SchemaBuilder.CreateMapIndexTable( - table => table.Column(nameof(EmailQuotaIndex.CurrentEmailQuotaCount)) - .Column(nameof(EmailQuotaIndex.LastReminder))); + table => table.Column(nameof(EmailQuotaIndex.CurrentEmailUsageCount)) + .Column(nameof(EmailQuotaIndex.LastReminderUtc)) + .Column(nameof(EmailQuotaIndex.LastReminderPercentage))); - return 1; + return 2; + } + + public int UpdateFrom1() + { + SchemaBuilder.AlterTable(nameof(EmailQuotaIndex), table => table + .AddColumn(nameof(EmailQuotaIndex.LastReminderPercentage)) + ); + + return 2; } } diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Models/EmailQuota.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Models/EmailQuota.cs index 5061b563..905ef04d 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Models/EmailQuota.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Models/EmailQuota.cs @@ -4,6 +4,10 @@ namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; public class EmailQuota { - public int CurrentEmailQuotaCount { get; set; } - public DateTime LastReminder { get; set; } + public int CurrentEmailUsageCount { get; set; } + public DateTime LastReminderUtc { get; set; } + public int LastReminderPercentage { get; set; } + + public int CurrentUsagePercentage(int emailQuotaPerMonth) => + Convert.ToInt32(Math.Round((double)CurrentEmailUsageCount / emailQuotaPerMonth * 100, 0)); } diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaEmailService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaEmailService.cs deleted file mode 100644 index 51b56354..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaEmailService.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.AspNetCore.Identity; -using OrchardCore.Security; -using OrchardCore.Security.Services; -using OrchardCore.Users; -using OrchardCore.Users.Models; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using static OrchardCore.Security.Permissions.Permission; -using static OrchardCore.Security.StandardPermissions; - -namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; - -public class EmailQuotaEmailService : IEmailQuotaEmailService -{ - private readonly IRoleService _roleService; - private readonly UserManager _userManager; - - public EmailQuotaEmailService( - IRoleService roleService, - UserManager userManager) - { - _roleService = roleService; - _userManager = userManager; - } - - public async Task> CollectUserEmailsForExceedingQuotaAsync() - { - // Get users with site owner permission. - var roles = await _roleService.GetRolesAsync(); - var siteOwnerRoles = roles.Where(role => - (role as Role)?.RoleClaims.Exists(claim => - claim.ClaimType == ClaimType && claim.ClaimValue == SiteOwner.Name) == true); - - var siteOwners = new List(); - foreach (var role in siteOwnerRoles) - { - siteOwners.AddRange(await _userManager.GetUsersInRoleAsync(role.RoleName)); - } - - return siteOwners.Select(user => (user as User)?.Email); - } -} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaResetTask.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaResetTask.cs index 3ca811cc..2a8c7d93 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaResetTask.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaResetTask.cs @@ -13,8 +13,8 @@ public class EmailQuotaResetBackgroundTask : IBackgroundTask { public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) { - var quotaService = serviceProvider.GetRequiredService(); - var currentQuota = await quotaService.GetCurrentQuotaAsync(); - quotaService.ResetQuota(currentQuota); + var emailQuotaService = serviceProvider.GetRequiredService(); + var currentQuota = await emailQuotaService.GetOrCreateCurrentQuotaAsync(); + emailQuotaService.ResetQuota(currentQuota); } } diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaService.cs new file mode 100644 index 00000000..1228b449 --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaService.cs @@ -0,0 +1,143 @@ +using Lombiq.Hosting.Tenants.EmailQuotaManagement.Indexes; +using Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using OrchardCore.Email; +using OrchardCore.Environment.Shell.Configuration; +using OrchardCore.Modules; +using OrchardCore.Security; +using OrchardCore.Security.Services; +using OrchardCore.Users; +using OrchardCore.Users.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using YesSql; +using static OrchardCore.Security.Permissions.Permission; +using static OrchardCore.Security.StandardPermissions; + +namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; + +public class EmailQuotaService : IEmailQuotaService +{ + private readonly ISession _session; + private readonly EmailQuotaOptions _emailQuotaOptions; + private readonly IShellConfiguration _shellConfiguration; + private readonly SmtpSettings _smtpOptions; + private readonly IClock _clock; + private readonly IRoleService _roleService; + private readonly UserManager _userManager; + + public EmailQuotaService( + ISession session, + IOptions emailQuotaOptions, + IShellConfiguration shellConfiguration, + IOptions smtpOptions, + IClock clock, + IRoleService roleService, + UserManager userManager) + { + _session = session; + _emailQuotaOptions = emailQuotaOptions.Value; + _shellConfiguration = shellConfiguration; + _smtpOptions = smtpOptions.Value; + _clock = clock; + _roleService = roleService; + _userManager = userManager; + } + + public async Task> GetUserEmailsForEmailReminderAsync() + { + // Get users with site owner permission. + var roles = await _roleService.GetRolesAsync(); + var siteOwnerRoles = roles.Where(role => + (role as Role)?.RoleClaims.Exists(claim => + claim.ClaimType == ClaimType && claim.ClaimValue == SiteOwner.Name) == true); + + var siteOwners = new List(); + foreach (var role in siteOwnerRoles) + { + siteOwners.AddRange(await _userManager.GetUsersInRoleAsync(role.RoleName)); + } + + return siteOwners.Select(user => (user as User)?.Email); + } + + public bool ShouldLimitEmails() + { + var originalHost = _shellConfiguration.GetValue("SmtpSettings:Host"); + return originalHost == _smtpOptions.Host; + } + + public async Task IsQuotaOverTheLimitAsync() + { + var currentQuota = await GetOrCreateCurrentQuotaAsync(); + return new QuotaResult + { + IsOverQuota = _emailQuotaOptions.EmailQuotaPerMonth <= currentQuota.CurrentEmailUsageCount, + EmailQuota = currentQuota, + }; + } + + public async Task GetOrCreateCurrentQuotaAsync() + { + var currentQuota = await _session.Query().FirstOrDefaultAsync(); + + if (currentQuota != null) return currentQuota; + + currentQuota = new EmailQuota + { + // Need to set default value otherwise the database might complain about being 01/01/0001 out of range. + LastReminderUtc = _clock.UtcNow.AddMonths(-1), + }; + _session.Save(currentQuota); + + return currentQuota; + } + + public void IncreaseEmailUsage(EmailQuota emailQuota) + { + emailQuota.CurrentEmailUsageCount++; + _session.Save(emailQuota); + } + + public void SetQuotaOnEmailReminder(EmailQuota emailQuota) + { + emailQuota.LastReminderUtc = _clock.UtcNow; + emailQuota.LastReminderPercentage = emailQuota.CurrentUsagePercentage(GetEmailQuotaPerMonth()); + _session.Save(emailQuota); + } + + public bool ShouldSendReminderEmail(EmailQuota emailQuota, int currentUsagePercentage) + { + if (currentUsagePercentage < 80) + { + return false; + } + + var isSameMonth = IsSameMonth(_clock.UtcNow, emailQuota.LastReminderUtc); + + if (!isSameMonth) + { + return true; + } + + return !((emailQuota.LastReminderPercentage >= 80 && currentUsagePercentage < 90) || + (emailQuota.LastReminderPercentage >= 90 && currentUsagePercentage < 100) || + emailQuota.LastReminderPercentage >= 100); + } + + public void ResetQuota(EmailQuota emailQuota) + { + emailQuota.CurrentEmailUsageCount = 0; + _session.Save(emailQuota); + } + + public int GetEmailQuotaPerMonth() => + _emailQuotaOptions.EmailQuotaPerMonth; + + private static bool IsSameMonth(DateTime date1, DateTime date2) => + date1.Month == date2.Month && date1.Year == date2.Year; +} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaSubjectService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaSubjectService.cs new file mode 100644 index 00000000..433c8ab7 --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailQuotaSubjectService.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Localization; + +namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; + +public class EmailQuotaSubjectService : IEmailQuotaSubjectService +{ + private readonly IStringLocalizer T; + + public EmailQuotaSubjectService(IStringLocalizer stringLocalizer) => + T = stringLocalizer; + + public LocalizedString GetWarningEmailSubject(int percentage) => + T["[Warning] Your site has used {0}% of its e-mail quota", percentage]; + + public LocalizedString GetExceededEmailSubject() => + T["[Action Required] Your site has run over its e-mail quota"]; +} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailSenderQuotaService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailSenderQuotaService.cs deleted file mode 100644 index ae167fc8..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/EmailSenderQuotaService.cs +++ /dev/null @@ -1,95 +0,0 @@ -using Lombiq.HelpfulExtensions.Extensions.Emails.Services; -using Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; -using Microsoft.Extensions.Localization; -using OrchardCore.Email; -using OrchardCore.Environment.Shell; -using OrchardCore.Environment.Shell.Scope; -using OrchardCore.Modules; -using System; -using System.Threading.Tasks; - -namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; - -public class EmailSenderQuotaService : ISmtpService -{ - private readonly IStringLocalizer T; - private readonly ISmtpService _smtpService; - private readonly IQuotaService _quotaService; - private readonly IEmailQuotaEmailService _emailQuotaEmailService; - private readonly IClock _clock; - private readonly ShellSettings _shellSettings; - private readonly IEmailTemplateService _emailTemplateService; - - public EmailSenderQuotaService( - ISmtpService smtpService, - IStringLocalizer stringLocalizer, - IQuotaService quotaService, - IEmailQuotaEmailService emailQuotaEmailService, - IClock clock, - ShellSettings shellSettings, - IEmailTemplateService emailTemplateService) - { - _smtpService = smtpService; - T = stringLocalizer; - _quotaService = quotaService; - _emailQuotaEmailService = emailQuotaEmailService; - _clock = clock; - _shellSettings = shellSettings; - _emailTemplateService = emailTemplateService; - } - - public async Task SendAsync(MailMessage message) - { - if (!_quotaService.ShouldLimitEmails()) - { - return await _smtpService.SendAsync(message); - } - - var isQuotaOverResult = await _quotaService.IsQuotaOverTheLimitAsync(); - if (isQuotaOverResult.IsOverQuota) - { - await SendAlertEmailIfNecessaryAsync(isQuotaOverResult.EmailQuota); - - return SmtpResult.Failed(T["The email quota for the site has been exceeded."]); - } - - var emailResult = await _smtpService.SendAsync(message); - if (emailResult == SmtpResult.Success) - { - _quotaService.IncreaseQuota(isQuotaOverResult.EmailQuota); - } - - return emailResult; - } - - private async Task SendAlertEmailIfNecessaryAsync(EmailQuota emailQuota) - { - if (IsSameMonth(_clock.UtcNow, emailQuota.LastReminder)) return; - - emailQuota.LastReminder = _clock.UtcNow; - _quotaService.SaveQuota(emailQuota); - - var siteOwnerEmails = await _emailQuotaEmailService.CollectUserEmailsForExceedingQuotaAsync(); - var emailMessage = new MailMessage - { - Subject = T["[Action Required] Your DotNest site has run over its e-mail quota"], - IsHtmlBody = true, - }; - - foreach (var siteOwnerEmail in siteOwnerEmails) - { - ShellScope.AddDeferredTask(async _ => - { - emailMessage.To = siteOwnerEmail; - emailMessage.Body = await _emailTemplateService.RenderEmailTemplateAsync("EmailQuota", new - { - HostName = _shellSettings.Name, - }); - await _smtpService.SendAsync(emailMessage); - }); - } - } - - private static bool IsSameMonth(DateTime date1, DateTime date2) => - date1.Month == date2.Month && date1.Year == date2.Year; -} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaEmailService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaEmailService.cs deleted file mode 100644 index 8b48cd1f..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaEmailService.cs +++ /dev/null @@ -1,17 +0,0 @@ -using OrchardCore.Email; -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; - -/// -/// This service is responsible for creating the email that will be sent to the site owners when the email quota is -/// exceeded. -/// -public interface IEmailQuotaEmailService -{ - /// - /// Creates the that could be sent to the site owners when the email quota is exceeded. - /// - Task> CollectUserEmailsForExceedingQuotaAsync(); -} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaService.cs new file mode 100644 index 00000000..4c40ee34 --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaService.cs @@ -0,0 +1,61 @@ +using Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; + +/// +/// A service that is responsible for managing the quota of sent emails. +/// +public interface IEmailQuotaService +{ + /// + /// Collects the emails of the users who should receive the email reminder, based on the site owner permission. + /// + Task> GetUserEmailsForEmailReminderAsync(); + + /// + /// Checks if the emails should be limited, depending on the default SMTP settings. + /// + bool ShouldLimitEmails(); + + /// + /// Checks if the current email usage is over monthly limit, returns a object that + /// contains the and in the if + /// it is over the limit. + /// + Task IsQuotaOverTheLimitAsync(); + + /// + /// Gets or creates the in the database. + /// + Task GetOrCreateCurrentQuotaAsync(); + + /// + /// Increases the usage count of the given and saves it to the database. + /// + void IncreaseEmailUsage(EmailQuota emailQuota); + + /// + /// Resets the given to 0 and saves it + /// to the database. + /// + void ResetQuota(EmailQuota emailQuota); + + /// + /// Sets the to the current date and + /// to the current email quota usage percentage then saves it to the + /// database. + /// + void SetQuotaOnEmailReminder(EmailQuota emailQuota); + + /// + /// Returns , if the reminder email should be sent. + /// + bool ShouldSendReminderEmail(EmailQuota emailQuota, int currentUsagePercentage); + + /// + /// Return the email quota per month value from the configuration. + /// + int GetEmailQuotaPerMonth(); +} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaSubjectService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaSubjectService.cs new file mode 100644 index 00000000..7f6427f4 --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IEmailQuotaSubjectService.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Localization; + +namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; + +/// +/// Service for getting the subject of the email sent when the email quota is exceeded or when it's close to being exceeded. +/// +/// Provides a way for overriding the subject texts. +public interface IEmailQuotaSubjectService +{ + /// + /// Gets the subject of the email sent for the email when the email usage is above 80%. + /// + /// The current usage percentage. + public LocalizedString GetWarningEmailSubject(int percentage); + + /// + /// Gets the subject of the email sent for the email quota exceeded email. + /// + public LocalizedString GetExceededEmailSubject(); +} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IQuotaService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IQuotaService.cs deleted file mode 100644 index 4e445c4b..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/IQuotaService.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; -using System.Threading.Tasks; - -namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; - -/// -/// A service that is responsible for managing the quota of sent emails. -/// -public interface IQuotaService -{ - /// - /// Checks if the emails should be limited. - /// - bool ShouldLimitEmails(); - - /// - /// Checks if the quota is over the limit. - /// - Task IsQuotaOverTheLimitAsync(); - - /// - /// Gets the current quota. - /// - Task GetCurrentQuotaAsync(); - - /// - /// Increases the given quota value. - /// - void IncreaseQuota(EmailQuota emailQuota); - - /// - /// Resets the given quota to 0. - /// - void ResetQuota(EmailQuota emailQuota); - - /// - /// Saves the given quota. - /// - void SaveQuota(EmailQuota emailQuota); -} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/QuotaManagingSmtpServiceDecorator.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/QuotaManagingSmtpServiceDecorator.cs new file mode 100644 index 00000000..7008084e --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/QuotaManagingSmtpServiceDecorator.cs @@ -0,0 +1,118 @@ +using Lombiq.HelpfulExtensions.Extensions.Emails.Services; +using Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Email; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Scope; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; + +public class QuotaManagingSmtpServiceDecorator : ISmtpService +{ + private readonly IStringLocalizer T; + private readonly ISmtpService _smtpService; + private readonly IEmailQuotaService _emailQuotaService; + private readonly ShellSettings _shellSettings; + private readonly IEmailTemplateService _emailTemplateService; + private readonly IEmailQuotaSubjectService _emailQuotaSubjectService; + + public QuotaManagingSmtpServiceDecorator( + ISmtpService smtpService, + IStringLocalizer stringLocalizer, + IEmailQuotaService emailQuotaService, + ShellSettings shellSettings, + IEmailTemplateService emailTemplateService, + IEmailQuotaSubjectService emailQuotaSubjectService) + { + _smtpService = smtpService; + T = stringLocalizer; + _emailQuotaService = emailQuotaService; + _shellSettings = shellSettings; + _emailTemplateService = emailTemplateService; + _emailQuotaSubjectService = emailQuotaSubjectService; + } + + public async Task SendAsync(MailMessage message) + { + if (!_emailQuotaService.ShouldLimitEmails()) + { + return await _smtpService.SendAsync(message); + } + + var isQuotaOverResult = await _emailQuotaService.IsQuotaOverTheLimitAsync(); + await SendAlertEmailIfNecessaryAsync(isQuotaOverResult.EmailQuota); + + // Should send the email if the quota is not over the limit. + if (isQuotaOverResult.IsOverQuota) + { + return SmtpResult.Failed(T["The email quota for the site has been exceeded."]); + } + + var emailResult = await _smtpService.SendAsync(message); + if (emailResult == SmtpResult.Success) + { + _emailQuotaService.IncreaseEmailUsage(isQuotaOverResult.EmailQuota); + } + + return emailResult; + } + + private async Task SendAlertEmailIfNecessaryAsync(EmailQuota emailQuota) + { + var currentUsagePercentage = emailQuota.CurrentUsagePercentage(_emailQuotaService.GetEmailQuotaPerMonth()); + if (!_emailQuotaService.ShouldSendReminderEmail(emailQuota, currentUsagePercentage)) return; + + var siteOwnerEmails = (await _emailQuotaService.GetUserEmailsForEmailReminderAsync()).ToList(); + if (currentUsagePercentage >= 100) + { + SendQuotaEmail( + emailQuota, + siteOwnerEmails, + "EmailQuotaExceededError", + _emailQuotaSubjectService.GetExceededEmailSubject(), + currentUsagePercentage); + return; + } + + SendQuotaEmail( + emailQuota, + siteOwnerEmails, + $"EmailQuotaWarning", + _emailQuotaSubjectService.GetWarningEmailSubject(currentUsagePercentage), + currentUsagePercentage); + } + + private void SendQuotaEmail( + EmailQuota emailQuota, + IEnumerable siteOwnerEmails, + string emailTemplateName, + string subject, + int percentage) + { + var emailMessage = new MailMessage + { + IsHtmlBody = true, + Subject = subject, + }; + foreach (var siteOwnerEmail in siteOwnerEmails) + { + ShellScope.AddDeferredTask(async _ => + { + emailMessage.To = siteOwnerEmail; + emailMessage.Body = await _emailTemplateService.RenderEmailTemplateAsync(emailTemplateName, new + { + HostName = _shellSettings.Name, + Percentage = percentage, + }); + // ISmtpService must be used within this class otherwise it won't call the original ISmtpService + // implementation, but loop back to here. + await _smtpService.SendAsync(emailMessage); + }); + } + + _emailQuotaService.SetQuotaOnEmailReminder(emailQuota); + } +} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/QuotaService.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/QuotaService.cs deleted file mode 100644 index 89537e64..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Services/QuotaService.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Lombiq.Hosting.Tenants.EmailQuotaManagement.Indexes; -using Lombiq.Hosting.Tenants.EmailQuotaManagement.Models; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; -using OrchardCore.Email; -using OrchardCore.Environment.Shell.Configuration; -using OrchardCore.Modules; -using System.Threading.Tasks; -using YesSql; - -namespace Lombiq.Hosting.Tenants.EmailQuotaManagement.Services; - -public class QuotaService : IQuotaService -{ - private readonly ISession _session; - private readonly EmailQuotaOptions _emailQuotaOptions; - private readonly IShellConfiguration _shellConfiguration; - private readonly SmtpSettings _smtpOptions; - private readonly IClock _clock; - - public QuotaService( - ISession session, - IOptions emailQuotaOptions, - IShellConfiguration shellConfiguration, - IOptions smtpOptions, - IClock clock) - { - _session = session; - _emailQuotaOptions = emailQuotaOptions.Value; - _shellConfiguration = shellConfiguration; - _smtpOptions = smtpOptions.Value; - _clock = clock; - } - - public bool ShouldLimitEmails() - { - var originalHost = _shellConfiguration.GetValue("SmtpSettings:Host"); - return originalHost == _smtpOptions.Host; - } - - public async Task IsQuotaOverTheLimitAsync() - { - var currentQuota = await GetCurrentQuotaAsync(); - return new QuotaResult - { - IsOverQuota = _emailQuotaOptions.EmailQuotaPerMonth <= currentQuota.CurrentEmailQuotaCount, - EmailQuota = currentQuota, - }; - } - - public async Task GetCurrentQuotaAsync() - { - var currentQuota = await _session.Query().FirstOrDefaultAsync(); - - if (currentQuota != null) return currentQuota; - - currentQuota = new EmailQuota - { - // Need to set default value otherwise the database might complain about being 01/01/0001 out of range. - LastReminder = _clock.UtcNow.AddMonths(-1), - }; - _session.Save(currentQuota); - - return currentQuota; - } - - public void IncreaseQuota(EmailQuota emailQuota) - { - emailQuota.CurrentEmailQuotaCount++; - SaveQuota(emailQuota); - } - - public void SaveQuota(EmailQuota emailQuota) => - _session.Save(emailQuota); - - public void ResetQuota(EmailQuota emailQuota) - { - emailQuota.CurrentEmailQuotaCount = 0; - _session.Save(emailQuota); - } -} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Startup.cs b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Startup.cs index 9c097bde..d1baa20c 100644 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Startup.cs +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Startup.cs @@ -29,21 +29,22 @@ public override void ConfigureServices(IServiceCollection services) { services.AddDataMigration(); services.AddSingleton(); + services.AddSingleton(); + services.Configure(options => options.EmailQuotaPerMonth = _shellConfiguration.GetValue("Lombiq_Hosting_Tenants_EmailQuotaManagement:EmailQuotaPerMonth") ?? DefaultEmailQuota); - services.AddScoped(); - services.Decorate(); - services.AddSingleton(); - services.Configure(options => { - options.Filters.Add(typeof(EmailQuotaErrorFilter)); + options.Filters.Add(typeof(DashboardQuotaFilter)); options.Filters.Add(typeof(EmailSettingsQuotaFilter)); } ); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.Decorate(); } } diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/DashboardQuotaMessage.cshtml b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/DashboardQuotaMessage.cshtml new file mode 100644 index 00000000..358de90a --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/DashboardQuotaMessage.cshtml @@ -0,0 +1,15 @@ +@if (Model.IsOverQuota) +{ +

+ @T["It seems that your site sent out more e-mails than the available quota for this month. " + + "E-mail sending has been stopped until next month (any feature sending out e-mails will fail)."] +

+} +else +{ +

+ @T["It seems that your site sent out {0}% of e-mails from the available quota for this month. " + + "E-mail sending will be stopped until next month (any feature sending out e-mails will fail), when the quota is exceeded.", + Model.UsagePercentage] +

+} diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailQuotaError.cshtml b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailQuotaError.cshtml deleted file mode 100644 index afdd2c06..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailQuotaError.cshtml +++ /dev/null @@ -1 +0,0 @@ -

@T["It seems that your site sent out more e-mails than the available quota for this month. E-mail sending has been stopped until next month (any feature sending out e-mails will fail)."]

diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailSettingsQuota.cshtml b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailSettingsQuota.cshtml deleted file mode 100644 index da83fb98..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailSettingsQuota.cshtml +++ /dev/null @@ -1 +0,0 @@ -

@T["Unless you use your own SMTP server you have limited e-mails to send. You've sent {0} emails from the total of {1}.", Model.CurrentEmailCount, Model.EmailQuota]

diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailSettingsQuotaMessage.cshtml b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailSettingsQuotaMessage.cshtml new file mode 100644 index 00000000..5e7d8cae --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailSettingsQuotaMessage.cshtml @@ -0,0 +1,6 @@ +

+ @T["Unless you use your own SMTP server you have limited e-mails to send. " + + "You've sent {0} emails from the total of {1}.", + Model.CurrentEmailUsageCount, + Model.EmailQuotaPerMonth] +

diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuota.cshtml b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuota.cshtml deleted file mode 100644 index fde5c9d0..00000000 --- a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuota.cshtml +++ /dev/null @@ -1,4 +0,0 @@ -@{ - ViewLayout = "Layout__EmailTemplate"; -} -@T["

Hi

It seems that your {0} site sent out more e-mails than the available quota for this month. E-mail sending has been stopped until next month (any feature sending out e-mails will fail).

Thank you,
Your Admin Crew

", Model.HostName] diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuotaExceededError.cshtml b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuotaExceededError.cshtml new file mode 100644 index 00000000..e4001487 --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuotaExceededError.cshtml @@ -0,0 +1,7 @@ +@{ + ViewLayout = "Layout__EmailTemplate"; +} +@T["

Hi,

It seems that your {0} site sent out more e-mails than the available quota for this month. " + + "E-mail sending has been stopped until next month (any feature sending out e-mails will fail).

Thank " + + "you,
Your Admin Crew

", + Model.HostName] diff --git a/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuotaWarning.cshtml b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuotaWarning.cshtml new file mode 100644 index 00000000..817b2a5e --- /dev/null +++ b/Lombiq.Hosting.Tenants.EmailQuotaManagement/Views/EmailTemplate__EmailQuotaWarning.cshtml @@ -0,0 +1,8 @@ +@{ + ViewLayout = "Layout__EmailTemplate"; +} +@T["

Hi,

It seems that your {0} site sent out {1}% of the available e-mail quota for this month. " + + "E-mail sending will be stopped until next month (any feature sending out e-mails will fail), when the quota " + + "is exceeded.

Thank you,
Your Admin Crew

", + Model.HostName, + Model.Percentage]