diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index d02555c850..8bd2e04dae 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -181,6 +181,11 @@ public class GlobalSettings /// public bool IsSmtpServerConfigured => !string.IsNullOrWhiteSpace(Smtp?.Host); + /// + /// Gets a value indicating whether SMTP expiry is configured. + /// + public bool IsSmtpExpiryConfigured => Smtp?.EmailExpiration != null && Smtp?.EmailExpiration.HasValue == true; + /// /// Gets a value indicating whether there is a physical pickup directory configured. /// diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index bfff570c4f..0602ab6e8e 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -34,6 +34,9 @@ public class SecuritySettings internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout"; internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error"; + internal const string StaticPasswordResetEmailExpiry = "01:00:00"; + internal const string StaticUserInviteEmailExpiry = "3.00:00:00"; + /// /// Gets or sets a value indicating whether to keep the user logged in. /// @@ -159,4 +162,16 @@ public class SecuritySettings /// [DefaultValue(StaticAuthorizeCallbackErrorPathName)] public string AuthorizeCallbackErrorPathName { get; set; } = StaticAuthorizeCallbackErrorPathName; + + /// + /// Gets or sets the expiry time for password reset emails. + /// + [DefaultValue(StaticPasswordResetEmailExpiry)] + public TimeSpan PasswordResetEmailExpiry { get; set; } = TimeSpan.Parse(StaticPasswordResetEmailExpiry); + + /// + /// Gets or sets the expiry time for user invite emails. + /// + [DefaultValue(StaticUserInviteEmailExpiry)] + public TimeSpan UserInviteEmailExpiry { get; set; } = TimeSpan.Parse(StaticUserInviteEmailExpiry); } diff --git a/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs b/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs index 92229b1b6d..ea56445aa2 100644 --- a/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SmtpSettings.cs @@ -96,4 +96,9 @@ public class SmtpSettings : ValidatableEntryBase /// Gets or sets a value for the SMTP password. /// public string? Password { get; set; } + + /// + /// Gets or sets a value for the time until an email expires. + /// + public TimeSpan? EmailExpiration { get; set; } } diff --git a/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs b/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs index 022531c1ec..6f373cb006 100644 --- a/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs +++ b/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs @@ -74,7 +74,7 @@ public class EmailNotificationMethod : NotificationMethodBase var subject = _textService?.Localize("healthcheck", "scheduledHealthCheckEmailSubject", new[] { host }); EmailMessage mailMessage = CreateMailMessage(subject, message); - Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck); + Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck, false, null); if (task is not null) { await task; diff --git a/src/Umbraco.Core/Mail/IEmailSender.cs b/src/Umbraco.Core/Mail/IEmailSender.cs index 2eb8cc8263..44cd9bd862 100644 --- a/src/Umbraco.Core/Mail/IEmailSender.cs +++ b/src/Umbraco.Core/Mail/IEmailSender.cs @@ -7,9 +7,28 @@ namespace Umbraco.Cms.Core.Mail; /// public interface IEmailSender { + /// + /// Sends a message asynchronously. + /// + [Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")] Task SendAsync(EmailMessage message, string emailType); + /// + /// Sends a message asynchronously. + /// + [Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")] Task SendAsync(EmailMessage message, string emailType, bool enableNotification); + /// + /// Sends a message asynchronously. + /// + Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null) +#pragma warning disable CS0618 // Type or member is obsolete + => SendAsync(message, emailType, enableNotification); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Verifies if the email sender is configured to send emails. + /// bool CanSendRequiredEmail(); } diff --git a/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs b/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs index 7d0d2b4865..21d49db76b 100644 --- a/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs +++ b/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs @@ -12,6 +12,10 @@ internal sealed class NotImplementedEmailSender : IEmailSender throw new NotImplementedException( "To send an Email ensure IEmailSender is implemented with a custom implementation"); + public Task SendAsync(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires) => + throw new NotImplementedException( + "To send an Email ensure IEmailSender is implemented with a custom implementation"); + public bool CanSendRequiredEmail() => throw new NotImplementedException( "To send an Email ensure IEmailSender is implemented with a custom implementation"); diff --git a/src/Umbraco.Core/Services/NotificationService.cs b/src/Umbraco.Core/Services/NotificationService.cs index d12ef6c6df..89c6e6e4ed 100644 --- a/src/Umbraco.Core/Services/NotificationService.cs +++ b/src/Umbraco.Core/Services/NotificationService.cs @@ -552,7 +552,7 @@ public class NotificationService : INotificationService { ThreadPool.QueueUserWorkItem(state => { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Begin processing notifications."); } @@ -564,9 +564,9 @@ public class NotificationService : INotificationService { try { - _emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification).GetAwaiter() + _emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification, false, null).GetAwaiter() .GetResult(); - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("Notification '{Action}' sent to {Username} ({Email})", request.Action, request.UserName, request.Email); } diff --git a/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs b/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs index 15ae1d0f49..03687f6be8 100644 --- a/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs +++ b/src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs @@ -15,28 +15,44 @@ namespace Umbraco.Cms.Infrastructure.Mail public class BasicSmtpEmailSenderClient : IEmailSenderClient { private readonly GlobalSettings _globalSettings; - public BasicSmtpEmailSenderClient(IOptionsMonitor globalSettings) - { - _globalSettings = globalSettings.CurrentValue; - } + /// + public BasicSmtpEmailSenderClient(IOptionsMonitor globalSettings) + => _globalSettings = globalSettings.CurrentValue; + + /// public async Task SendAsync(EmailMessage message) + => await SendAsync(message, null); + + /// + public async Task SendAsync(EmailMessage message, TimeSpan? expires) { using var client = new SmtpClient(); await client.ConnectAsync( _globalSettings.Smtp!.Host, - _globalSettings.Smtp.Port, + _globalSettings.Smtp.Port, (SecureSocketOptions)(int)_globalSettings.Smtp.SecureSocketOptions); if (!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Username) && - !string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password)) + !string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password)) { await client.AuthenticateAsync(_globalSettings.Smtp.Username, _globalSettings.Smtp.Password); } var mimeMessage = message.ToMimeMessage(_globalSettings.Smtp!.From); + if (_globalSettings.IsSmtpExpiryConfigured) + { + expires ??= _globalSettings.Smtp.EmailExpiration; + } + + if (expires.HasValue) + { + // `Expires` header needs to be in RFC 1123/2822 compatible format + mimeMessage.Headers.Add("Expires", DateTimeOffset.UtcNow.Add(expires.GetValueOrDefault()).ToString("R")); + } + if (_globalSettings.Smtp.DeliveryMethod == SmtpDeliveryMethod.Network) { await client.SendAsync(mimeMessage); diff --git a/src/Umbraco.Infrastructure/Mail/EmailSender.cs b/src/Umbraco.Infrastructure/Mail/EmailSender.cs index 618323bcd1..e88737e7ef 100644 --- a/src/Umbraco.Infrastructure/Mail/EmailSender.cs +++ b/src/Umbraco.Infrastructure/Mail/EmailSender.cs @@ -17,7 +17,7 @@ using Umbraco.Cms.Infrastructure.Mail.Interfaces; namespace Umbraco.Cms.Infrastructure.Mail; /// -/// A utility class for sending emails +/// A utility class for sending emails. /// public class EmailSender : IEmailSender { @@ -28,6 +28,9 @@ public class EmailSender : IEmailSender private GlobalSettings _globalSettings; private readonly IEmailSenderClient _emailSenderClient; + /// + /// Initializes a new instance of the class. + /// public EmailSender( ILogger logger, IOptionsMonitor globalSettings, @@ -44,28 +47,28 @@ public class EmailSender : IEmailSender globalSettings.OnChange(x => _globalSettings = x); } - /// - /// Sends the message async - /// - /// + /// public async Task SendAsync(EmailMessage message, string emailType) => - await SendAsyncInternal(message, emailType, false); + await SendAsyncInternal(message, emailType, false, null); + /// public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification) => - await SendAsyncInternal(message, emailType, enableNotification); + await SendAsyncInternal(message, emailType, enableNotification, null); - /// - /// Returns true if the application should be able to send a required application email - /// + /// + public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null) => + await SendAsyncInternal(message, emailType, enableNotification, expires); + + /// /// /// We assume this is possible if either an event handler is registered or an smtp server is configured - /// or a pickup directory location is configured + /// or a pickup directory location is configured. /// public bool CanSendRequiredEmail() => _globalSettings.IsSmtpServerConfigured || _globalSettings.IsPickupDirectoryLocationConfigured || _notificationHandlerRegistered; - private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification) + private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires) { if (enableNotification) { @@ -76,7 +79,7 @@ public class EmailSender : IEmailSender // if a handler handled sending the email then don't continue. if (notification.IsHandled) { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( "The email sending for {Subject} was handled by a notification handler", @@ -88,7 +91,7 @@ public class EmailSender : IEmailSender if (!_globalSettings.IsSmtpServerConfigured && !_globalSettings.IsPickupDirectoryLocationConfigured) { - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( "Could not send email for {Subject}. It was not handled by a notification handler and there is no SMTP configured.", @@ -145,7 +148,6 @@ public class EmailSender : IEmailSender while (true); } - await _emailSenderClient.SendAsync(message); + await _emailSenderClient.SendAsync(message, expires); } - } diff --git a/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs b/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs index 10dd5284c4..3749d71bed 100644 --- a/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs +++ b/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs @@ -3,15 +3,25 @@ using Umbraco.Cms.Core.Models.Email; namespace Umbraco.Cms.Infrastructure.Mail.Interfaces { /// - /// Client for sending an email from a MimeMessage + /// Client for sending an email from a MimeMessage. /// public interface IEmailSenderClient { /// - /// Sends the email message + /// Sends the email message. /// - /// - /// + /// The to send. + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")] public Task SendAsync(EmailMessage message); + + /// + /// Sends the email message with an expiration date. + /// + /// The to send. + /// An optional time for expiry. + public Task SendAsync(EmailMessage message, TimeSpan? expires) +#pragma warning disable CS0618 // Type or member is obsolete + => SendAsync(message); +#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs index fe419a6566..f1da661848 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ColorPickerConfigurationEditor.cs @@ -10,8 +10,11 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; -internal sealed class ColorPickerConfigurationEditor : ConfigurationEditor +internal sealed partial class ColorPickerConfigurationEditor : ConfigurationEditor { + /// + /// Initializes a new instance of the class. + /// public ColorPickerConfigurationEditor(IIOHelper ioHelper, IConfigurationEditorJsonSerializer configurationEditorJsonSerializer) : base(ioHelper) { @@ -19,13 +22,17 @@ internal sealed class ColorPickerConfigurationEditor : ConfigurationEditor + /// Initializes a new instance of the class. + /// public ColorListValidator(IConfigurationEditorJsonSerializer configurationEditorJsonSerializer) => _configurationEditorJsonSerializer = configurationEditorJsonSerializer; + /// public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) { var stringValue = value?.ToString(); @@ -46,17 +53,53 @@ internal sealed class ColorPickerConfigurationEditor : ConfigurationEditor(StringComparer.OrdinalIgnoreCase); + var duplicates = new List(); foreach (ColorPickerConfiguration.ColorPickerItem item in items) { - if (Regex.IsMatch(item.Value, "^([0-9a-f]{3}|[0-9a-f]{6})$", RegexOptions.IgnoreCase) == false) + if (ColorPattern().IsMatch(item.Value) == false) { - yield return new ValidationResult($"The value {item.Value} is not a valid hex color", new[] { "items" }); + yield return new ValidationResult($"The value {item.Value} is not a valid hex color", ["items"]); + continue; + } + + var normalized = Normalize(item.Value); + if (seen.Add(normalized) is false) + { + duplicates.Add(normalized); } } + + if (duplicates.Count > 0) + { + yield return new ValidationResult( + $"Duplicate color values are not allowed: {string.Join(", ", duplicates)}", + ["items"]); + } } + + private static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var normalizedValue = value.Trim().ToLowerInvariant(); + + if (normalizedValue.Length == 3) + { + normalizedValue = $"{normalizedValue[0]}{normalizedValue[0]}{normalizedValue[1]}{normalizedValue[1]}{normalizedValue[2]}{normalizedValue[2]}"; + } + + return normalizedValue; + } + + [GeneratedRegex("^([0-9a-f]{3}|[0-9a-f]{6})$", RegexOptions.IgnoreCase, "en-GB")] + private static partial Regex ColorPattern(); } } diff --git a/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs index 6c276a21bb..784b14af76 100644 --- a/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs +++ b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs @@ -68,7 +68,7 @@ public class EmailUserForgotPasswordSender : IUserForgotPasswordSender var message = new EmailMessage(senderEmail, address.ToString(), emailSubject, emailBody, true); - await _emailSender.SendAsync(message, Constants.Web.EmailTypes.PasswordReset, true); + await _emailSender.SendAsync(message, Constants.Web.EmailTypes.PasswordReset, true, _securitySettings.PasswordResetEmailExpiry); } public bool CanSend() => _securitySettings.AllowPasswordReset && _emailSender.CanSendRequiredEmail(); diff --git a/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs b/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs index b6ef7a7447..6222e01ef2 100644 --- a/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs +++ b/src/Umbraco.Infrastructure/Security/EmailUserInviteSender.cs @@ -1,9 +1,11 @@ -using System.Globalization; +using System.Globalization; using System.Net; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using MimeKit; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mail; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Email; @@ -18,15 +20,31 @@ public class EmailUserInviteSender : IUserInviteSender private readonly IEmailSender _emailSender; private readonly ILocalizedTextService _localizedTextService; private readonly GlobalSettings _globalSettings; + private readonly SecuritySettings _securitySettings; + [Obsolete("Please use the constructor with all parameters. Scheduled for removal in Umbraco 18.")] public EmailUserInviteSender( IEmailSender emailSender, ILocalizedTextService localizedTextService, IOptions globalSettings) + : this( + emailSender, + localizedTextService, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + public EmailUserInviteSender( + IEmailSender emailSender, + ILocalizedTextService localizedTextService, + IOptions globalSettings, + IOptions securitySettings) { _emailSender = emailSender; _localizedTextService = localizedTextService; _globalSettings = globalSettings.Value; + _securitySettings = securitySettings.Value; } public async Task InviteUser(UserInvitationMessage invite) @@ -67,7 +85,7 @@ public class EmailUserInviteSender : IUserInviteSender var message = new EmailMessage(senderEmail, address.ToString(), emailSubject, emailBody, true); - await _emailSender.SendAsync(message, Constants.Web.EmailTypes.UserInvite, true); + await _emailSender.SendAsync(message, Constants.Web.EmailTypes.UserInvite, true, _securitySettings.UserInviteEmailExpiry); } public bool CanSendInvites() => _emailSender.CanSendRequiredEmail(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs index 72de48631f..01b8a428c3 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorListValidatorTest.cs @@ -58,4 +58,24 @@ public class ColorListValidatorTest PropertyValidationContext.Empty()); Assert.AreEqual(2, result.Count()); } + + [Test] + public void Validates_Color_Vals_Are_Unique() + { + var validator = new ColorPickerConfigurationEditor.ColorListValidator(ConfigurationEditorJsonSerializer()); + var result = + validator.Validate( + new JsonArray( + JsonNode.Parse("""{"value": "FFFFFF", "label": "One"}"""), + JsonNode.Parse("""{"value": "000000", "label": "Two"}"""), + JsonNode.Parse("""{"value": "FF00AA", "label": "Three"}"""), + JsonNode.Parse("""{"value": "fff", "label": "Four"}"""), + JsonNode.Parse("""{"value": "000000", "label": "Five"}"""), + JsonNode.Parse("""{"value": "F0A", "label": "Six"}""")), + null, + null, + PropertyValidationContext.Empty()); + Assert.AreEqual(1, result.Count()); + Assert.IsTrue(result.First().ErrorMessage.Contains("ffffff, 000000, ff00aa")); + } }