Emails: Add Expires header (#20285)

* Add `Expiry` header to emails, set default expiry to 30 days and allow user config via `appsettings`

* Remove `IsSmtpExpirationConfigured` as it will always have a value

* Check for `emailExpiration` value

* Removed `EmailExpiration` default value as it should be opt-in

* Simplify SMTP email expiration condition

* Fix APICompat issue

* Add implementation to `NotImplementedEmailSender`

* Rename `emailExpiration` to `expires` to match the SMTP header

* Obsolete interfaces without `expires` parameter, delegate to an existing method.

* Set expiry TimeSpan values from user configurable settings with defaults

* Fix formating

* Handle breaking changes, add obsoletion messages and simplify interfaces.

* Fix default of invite expires timespan (was being parsed as 72 days not 72 hours).

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
This commit is contained in:
Rick Butterfield
2025-10-09 13:27:53 +01:00
committed by GitHub
parent 767894b723
commit bcedc8de2a
12 changed files with 131 additions and 31 deletions

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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");

View File

@@ -557,7 +557,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.");
}
@@ -569,9 +569,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);
}

View File

@@ -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);

View File

@@ -30,6 +30,9 @@ public class EmailSender : IEmailSender
private GlobalSettings _globalSettings;
private readonly IEmailSenderClient _emailSenderClient;
/// <summary>
/// Initializes a new instance of the <see cref="EmailSender"/> class.
/// </summary>
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
public EmailSender(
ILogger<EmailSender> logger,
@@ -39,6 +42,9 @@ public class EmailSender : IEmailSender
{
}
/// <summary>
/// Initializes a new instance of the <see cref="EmailSender"/> class.
/// </summary>
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
public EmailSender(
ILogger<EmailSender> logger,
@@ -55,6 +61,9 @@ public class EmailSender : IEmailSender
globalSettings.OnChange(x => _globalSettings = x);
}
/// <summary>
/// Initializes a new instance of the <see cref="EmailSender"/> class.
/// </summary>
[ActivatorUtilitiesConstructor]
public EmailSender(
ILogger<EmailSender> logger,
@@ -72,19 +81,19 @@ 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
@@ -93,7 +102,7 @@ public class EmailSender : IEmailSender
|| _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)
{
@@ -104,7 +113,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",
@@ -116,7 +125,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.",
@@ -173,7 +182,6 @@ public class EmailSender : IEmailSender
while (true);
}
await _emailSenderClient.SendAsync(message);
await _emailSenderClient.SendAsync(message, expires);
}
}

View File

@@ -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
}
}

View File

@@ -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();

View File

@@ -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();