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"));
+ }
}