diff --git a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs index d02555c850b5..8bd2e04dae79 100644 --- a/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/GlobalSettings.cs @@ -181,6 +181,11 @@ internal const string /// 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 bfff570c4f7c..0602ab6e8ea3 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 92229b1b6d08..ea56445aa28e 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 022531c1eccf..6f373cb006c0 100644 --- a/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs +++ b/src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs @@ -74,7 +74,7 @@ public override async Task SendAsync(HealthCheckResults results) 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 2eb8cc826358..44cd9bd862b6 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 7d0d2b486519..21d49db76bf9 100644 --- a/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs +++ b/src/Umbraco.Core/Mail/NotImplementedEmailSender.cs @@ -12,6 +12,10 @@ public Task SendAsync(EmailMessage message, string emailType, bool enableNotific 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 3d5673fd7ad1..5a210777a044 100644 --- a/src/Umbraco.Core/Services/NotificationService.cs +++ b/src/Umbraco.Core/Services/NotificationService.cs @@ -557,7 +557,7 @@ private void Process(BlockingCollection notificationRequest { 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 @@ private void Process(BlockingCollection notificationRequest { 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 15ae1d0f4992..03687f6be832 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; - } + => _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 feb00735f223..fc9f71c95fab 100644 --- a/src/Umbraco.Infrastructure/Mail/EmailSender.cs +++ b/src/Umbraco.Infrastructure/Mail/EmailSender.cs @@ -30,6 +30,9 @@ public class EmailSender : IEmailSender private GlobalSettings _globalSettings; private readonly IEmailSenderClient _emailSenderClient; + /// + /// Initializes a new instance of the class. + /// [Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")] public EmailSender( ILogger logger, @@ -39,6 +42,9 @@ public EmailSender( { } + /// + /// Initializes a new instance of the class. + /// [Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")] public EmailSender( ILogger logger, @@ -55,6 +61,9 @@ public EmailSender( globalSettings.OnChange(x => _globalSettings = x); } + /// + /// Initializes a new instance of the class. + /// [ActivatorUtilitiesConstructor] public EmailSender( ILogger logger, @@ -72,19 +81,19 @@ public EmailSender( 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 @@ -93,7 +102,7 @@ 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) { @@ -104,7 +113,7 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo // 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 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo 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 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo 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 10dd5284c40f..3749d71bed3e 100644 --- a/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs +++ b/src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs @@ -3,15 +3,25 @@ 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/Security/EmailUserForgotPasswordSender.cs b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs index 6c276a21bb4d..784b14af7660 100644 --- a/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs +++ b/src/Umbraco.Infrastructure/Security/EmailUserForgotPasswordSender.cs @@ -68,7 +68,7 @@ public async Task SendForgotPassword(UserForgotPasswordMessage messageModel) 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 b6ef7a7447a8..6222e01ef243 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 async Task InviteUser(UserInvitationMessage invite) 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();