Merge branch 'main' into v17/dev
# Conflicts: # src/Umbraco.Infrastructure/Mail/EmailSender.cs
This commit is contained in:
@@ -181,6 +181,11 @@ public class GlobalSettings
|
||||
/// </summary>
|
||||
public bool IsSmtpServerConfigured => !string.IsNullOrWhiteSpace(Smtp?.Host);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether SMTP expiry is configured.
|
||||
/// </summary>
|
||||
public bool IsSmtpExpiryConfigured => Smtp?.EmailExpiration != null && Smtp?.EmailExpiration.HasValue == true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether there is a physical pickup directory configured.
|
||||
/// </summary>
|
||||
|
||||
@@ -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";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to keep the user logged in.
|
||||
/// </summary>
|
||||
@@ -159,4 +162,16 @@ public class SecuritySettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticAuthorizeCallbackErrorPathName)]
|
||||
public string AuthorizeCallbackErrorPathName { get; set; } = StaticAuthorizeCallbackErrorPathName;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiry time for password reset emails.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticPasswordResetEmailExpiry)]
|
||||
public TimeSpan PasswordResetEmailExpiry { get; set; } = TimeSpan.Parse(StaticPasswordResetEmailExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiry time for user invite emails.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticUserInviteEmailExpiry)]
|
||||
public TimeSpan UserInviteEmailExpiry { get; set; } = TimeSpan.Parse(StaticUserInviteEmailExpiry);
|
||||
}
|
||||
|
||||
@@ -96,4 +96,9 @@ public class SmtpSettings : ValidatableEntryBase
|
||||
/// Gets or sets a value for the SMTP password.
|
||||
/// </summary>
|
||||
public string? Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the time until an email expires.
|
||||
/// </summary>
|
||||
public TimeSpan? EmailExpiration { get; set; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,9 +7,28 @@ namespace Umbraco.Cms.Core.Mail;
|
||||
/// </summary>
|
||||
public interface IEmailSender
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a message asynchronously.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")]
|
||||
Task SendAsync(EmailMessage message, string emailType);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message asynchronously.
|
||||
/// </summary>
|
||||
[Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")]
|
||||
Task SendAsync(EmailMessage message, string emailType, bool enableNotification);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message asynchronously.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Verifies if the email sender is configured to send emails.
|
||||
/// </summary>
|
||||
bool CanSendRequiredEmail();
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -15,28 +15,44 @@ namespace Umbraco.Cms.Infrastructure.Mail
|
||||
public class BasicSmtpEmailSenderClient : IEmailSenderClient
|
||||
{
|
||||
private readonly GlobalSettings _globalSettings;
|
||||
public BasicSmtpEmailSenderClient(IOptionsMonitor<GlobalSettings> globalSettings)
|
||||
{
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BasicSmtpEmailSenderClient(IOptionsMonitor<GlobalSettings> globalSettings)
|
||||
=> _globalSettings = globalSettings.CurrentValue;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendAsync(EmailMessage message)
|
||||
=> await SendAsync(message, null);
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
|
||||
@@ -17,7 +17,7 @@ using Umbraco.Cms.Infrastructure.Mail.Interfaces;
|
||||
namespace Umbraco.Cms.Infrastructure.Mail;
|
||||
|
||||
/// <summary>
|
||||
/// A utility class for sending emails
|
||||
/// A utility class for sending emails.
|
||||
/// </summary>
|
||||
public class EmailSender : IEmailSender
|
||||
{
|
||||
@@ -28,6 +28,9 @@ public class EmailSender : IEmailSender
|
||||
private GlobalSettings _globalSettings;
|
||||
private readonly IEmailSenderClient _emailSenderClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="EmailSender"/> class.
|
||||
/// </summary>
|
||||
public EmailSender(
|
||||
ILogger<EmailSender> logger,
|
||||
IOptionsMonitor<GlobalSettings> globalSettings,
|
||||
@@ -44,28 +47,28 @@ public class EmailSender : IEmailSender
|
||||
globalSettings.OnChange(x => _globalSettings = x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the message async
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
/// <inheritdoc/>
|
||||
public async Task SendAsync(EmailMessage message, string emailType) =>
|
||||
await SendAsyncInternal(message, emailType, false);
|
||||
await SendAsyncInternal(message, emailType, false, null);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification) =>
|
||||
await SendAsyncInternal(message, emailType, enableNotification);
|
||||
await SendAsyncInternal(message, emailType, enableNotification, null);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the application should be able to send a required application email
|
||||
/// </summary>
|
||||
/// <inheritdoc/>
|
||||
public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null) =>
|
||||
await SendAsyncInternal(message, emailType, enableNotification, expires);
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,15 +3,25 @@ using Umbraco.Cms.Core.Models.Email;
|
||||
namespace Umbraco.Cms.Infrastructure.Mail.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Client for sending an email from a MimeMessage
|
||||
/// Client for sending an email from a MimeMessage.
|
||||
/// </summary>
|
||||
public interface IEmailSenderClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends the email message
|
||||
/// Sends the email message.
|
||||
/// </summary>
|
||||
/// <param name="message"></param>
|
||||
/// <returns></returns>
|
||||
/// <param name="message">The <see cref="EmailMessage"/> to send.</param>
|
||||
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")]
|
||||
public Task SendAsync(EmailMessage message);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the email message with an expiration date.
|
||||
/// </summary>
|
||||
/// <param name="message">The <see cref="EmailMessage"/> to send.</param>
|
||||
/// <param name="expires">An optional time for expiry.</param>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
internal sealed class ColorPickerConfigurationEditor : ConfigurationEditor<ColorPickerConfiguration>
|
||||
internal sealed partial class ColorPickerConfigurationEditor : ConfigurationEditor<ColorPickerConfiguration>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ColorPickerConfigurationEditor"/> class.
|
||||
/// </summary>
|
||||
public ColorPickerConfigurationEditor(IIOHelper ioHelper, IConfigurationEditorJsonSerializer configurationEditorJsonSerializer)
|
||||
: base(ioHelper)
|
||||
{
|
||||
@@ -19,13 +22,17 @@ internal sealed class ColorPickerConfigurationEditor : ConfigurationEditor<Color
|
||||
items.Validators.Add(new ColorListValidator(configurationEditorJsonSerializer));
|
||||
}
|
||||
|
||||
internal sealed class ColorListValidator : IValueValidator
|
||||
internal sealed partial class ColorListValidator : IValueValidator
|
||||
{
|
||||
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ColorListValidator"/> class.
|
||||
/// </summary>
|
||||
public ColorListValidator(IConfigurationEditorJsonSerializer configurationEditorJsonSerializer)
|
||||
=> _configurationEditorJsonSerializer = configurationEditorJsonSerializer;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext)
|
||||
{
|
||||
var stringValue = value?.ToString();
|
||||
@@ -46,17 +53,53 @@ internal sealed class ColorPickerConfigurationEditor : ConfigurationEditor<Color
|
||||
|
||||
if (items is null)
|
||||
{
|
||||
yield return new ValidationResult($"The configuration value {stringValue} is not a valid color picker configuration", new[] { "items" });
|
||||
yield return new ValidationResult($"The configuration value {stringValue} is not a valid color picker configuration", ["items"]);
|
||||
yield break;
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var duplicates = new List<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> globalSettings)
|
||||
: this(
|
||||
emailSender,
|
||||
localizedTextService,
|
||||
globalSettings,
|
||||
StaticServiceProvider.Instance.GetRequiredService<IOptions<SecuritySettings>>())
|
||||
{
|
||||
}
|
||||
|
||||
public EmailUserInviteSender(
|
||||
IEmailSender emailSender,
|
||||
ILocalizedTextService localizedTextService,
|
||||
IOptions<GlobalSettings> globalSettings,
|
||||
IOptions<SecuritySettings> 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();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user