Skip to content

Commit bcedc8d

Browse files
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>
1 parent 767894b commit bcedc8d

File tree

12 files changed

+130
-30
lines changed

12 files changed

+130
-30
lines changed

src/Umbraco.Core/Configuration/Models/GlobalSettings.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,11 @@ internal const string
181181
/// </summary>
182182
public bool IsSmtpServerConfigured => !string.IsNullOrWhiteSpace(Smtp?.Host);
183183

184+
/// <summary>
185+
/// Gets a value indicating whether SMTP expiry is configured.
186+
/// </summary>
187+
public bool IsSmtpExpiryConfigured => Smtp?.EmailExpiration != null && Smtp?.EmailExpiration.HasValue == true;
188+
184189
/// <summary>
185190
/// Gets a value indicating whether there is a physical pickup directory configured.
186191
/// </summary>

src/Umbraco.Core/Configuration/Models/SecuritySettings.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public class SecuritySettings
3434
internal const string StaticAuthorizeCallbackLogoutPathName = "/umbraco/logout";
3535
internal const string StaticAuthorizeCallbackErrorPathName = "/umbraco/error";
3636

37+
internal const string StaticPasswordResetEmailExpiry = "01:00:00";
38+
internal const string StaticUserInviteEmailExpiry = "3.00:00:00";
39+
3740
/// <summary>
3841
/// Gets or sets a value indicating whether to keep the user logged in.
3942
/// </summary>
@@ -159,4 +162,16 @@ public class SecuritySettings
159162
/// </summary>
160163
[DefaultValue(StaticAuthorizeCallbackErrorPathName)]
161164
public string AuthorizeCallbackErrorPathName { get; set; } = StaticAuthorizeCallbackErrorPathName;
165+
166+
/// <summary>
167+
/// Gets or sets the expiry time for password reset emails.
168+
/// </summary>
169+
[DefaultValue(StaticPasswordResetEmailExpiry)]
170+
public TimeSpan PasswordResetEmailExpiry { get; set; } = TimeSpan.Parse(StaticPasswordResetEmailExpiry);
171+
172+
/// <summary>
173+
/// Gets or sets the expiry time for user invite emails.
174+
/// </summary>
175+
[DefaultValue(StaticUserInviteEmailExpiry)]
176+
public TimeSpan UserInviteEmailExpiry { get; set; } = TimeSpan.Parse(StaticUserInviteEmailExpiry);
162177
}

src/Umbraco.Core/Configuration/Models/SmtpSettings.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,9 @@ public class SmtpSettings : ValidatableEntryBase
9696
/// Gets or sets a value for the SMTP password.
9797
/// </summary>
9898
public string? Password { get; set; }
99+
100+
/// <summary>
101+
/// Gets or sets a value for the time until an email expires.
102+
/// </summary>
103+
public TimeSpan? EmailExpiration { get; set; }
99104
}

src/Umbraco.Core/HealthChecks/NotificationMethods/EmailNotificationMethod.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public override async Task SendAsync(HealthCheckResults results)
7474
var subject = _textService?.Localize("healthcheck", "scheduledHealthCheckEmailSubject", new[] { host });
7575

7676
EmailMessage mailMessage = CreateMailMessage(subject, message);
77-
Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck);
77+
Task? task = _emailSender?.SendAsync(mailMessage, Constants.Web.EmailTypes.HealthCheck, false, null);
7878
if (task is not null)
7979
{
8080
await task;

src/Umbraco.Core/Mail/IEmailSender.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,28 @@ namespace Umbraco.Cms.Core.Mail;
77
/// </summary>
88
public interface IEmailSender
99
{
10+
/// <summary>
11+
/// Sends a message asynchronously.
12+
/// </summary>
13+
[Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")]
1014
Task SendAsync(EmailMessage message, string emailType);
1115

16+
/// <summary>
17+
/// Sends a message asynchronously.
18+
/// </summary>
19+
[Obsolete("Please use the overload with expires parameter. Scheduled for removal in Umbraco 18.")]
1220
Task SendAsync(EmailMessage message, string emailType, bool enableNotification);
1321

22+
/// <summary>
23+
/// Sends a message asynchronously.
24+
/// </summary>
25+
Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null)
26+
#pragma warning disable CS0618 // Type or member is obsolete
27+
=> SendAsync(message, emailType, enableNotification);
28+
#pragma warning restore CS0618 // Type or member is obsolete
29+
30+
/// <summary>
31+
/// Verifies if the email sender is configured to send emails.
32+
/// </summary>
1433
bool CanSendRequiredEmail();
1534
}

src/Umbraco.Core/Mail/NotImplementedEmailSender.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ public Task SendAsync(EmailMessage message, string emailType, bool enableNotific
1212
throw new NotImplementedException(
1313
"To send an Email ensure IEmailSender is implemented with a custom implementation");
1414

15+
public Task SendAsync(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires) =>
16+
throw new NotImplementedException(
17+
"To send an Email ensure IEmailSender is implemented with a custom implementation");
18+
1519
public bool CanSendRequiredEmail()
1620
=> throw new NotImplementedException(
1721
"To send an Email ensure IEmailSender is implemented with a custom implementation");

src/Umbraco.Core/Services/NotificationService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ private void Process(BlockingCollection<NotificationRequest> notificationRequest
557557
{
558558
ThreadPool.QueueUserWorkItem(state =>
559559
{
560-
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
560+
if (_logger.IsEnabled(LogLevel.Debug))
561561
{
562562
_logger.LogDebug("Begin processing notifications.");
563563
}
@@ -569,9 +569,9 @@ private void Process(BlockingCollection<NotificationRequest> notificationRequest
569569
{
570570
try
571571
{
572-
_emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification).GetAwaiter()
572+
_emailSender.SendAsync(request.Mail, Constants.Web.EmailTypes.Notification, false, null).GetAwaiter()
573573
.GetResult();
574-
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
574+
if (_logger.IsEnabled(LogLevel.Debug))
575575
{
576576
_logger.LogDebug("Notification '{Action}' sent to {Username} ({Email})", request.Action, request.UserName, request.Email);
577577
}

src/Umbraco.Infrastructure/Mail/BasicSmtpEmailSenderClient.cs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,28 +15,44 @@ namespace Umbraco.Cms.Infrastructure.Mail
1515
public class BasicSmtpEmailSenderClient : IEmailSenderClient
1616
{
1717
private readonly GlobalSettings _globalSettings;
18+
19+
/// <inheritdoc />
1820
public BasicSmtpEmailSenderClient(IOptionsMonitor<GlobalSettings> globalSettings)
19-
{
20-
_globalSettings = globalSettings.CurrentValue;
21-
}
21+
=> _globalSettings = globalSettings.CurrentValue;
2222

23+
/// <inheritdoc />
2324
public async Task SendAsync(EmailMessage message)
25+
=> await SendAsync(message, null);
26+
27+
/// <inheritdoc />
28+
public async Task SendAsync(EmailMessage message, TimeSpan? expires)
2429
{
2530
using var client = new SmtpClient();
2631

2732
await client.ConnectAsync(
2833
_globalSettings.Smtp!.Host,
29-
_globalSettings.Smtp.Port,
34+
_globalSettings.Smtp.Port,
3035
(SecureSocketOptions)(int)_globalSettings.Smtp.SecureSocketOptions);
3136

3237
if (!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Username) &&
33-
!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password))
38+
!string.IsNullOrWhiteSpace(_globalSettings.Smtp.Password))
3439
{
3540
await client.AuthenticateAsync(_globalSettings.Smtp.Username, _globalSettings.Smtp.Password);
3641
}
3742

3843
var mimeMessage = message.ToMimeMessage(_globalSettings.Smtp!.From);
3944

45+
if (_globalSettings.IsSmtpExpiryConfigured)
46+
{
47+
expires ??= _globalSettings.Smtp.EmailExpiration;
48+
}
49+
50+
if (expires.HasValue)
51+
{
52+
// `Expires` header needs to be in RFC 1123/2822 compatible format
53+
mimeMessage.Headers.Add("Expires", DateTimeOffset.UtcNow.Add(expires.GetValueOrDefault()).ToString("R"));
54+
}
55+
4056
if (_globalSettings.Smtp.DeliveryMethod == SmtpDeliveryMethod.Network)
4157
{
4258
await client.SendAsync(mimeMessage);

src/Umbraco.Infrastructure/Mail/EmailSender.cs

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public class EmailSender : IEmailSender
3030
private GlobalSettings _globalSettings;
3131
private readonly IEmailSenderClient _emailSenderClient;
3232

33+
/// <summary>
34+
/// Initializes a new instance of the <see cref="EmailSender"/> class.
35+
/// </summary>
3336
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
3437
public EmailSender(
3538
ILogger<EmailSender> logger,
@@ -39,6 +42,9 @@ public EmailSender(
3942
{
4043
}
4144

45+
/// <summary>
46+
/// Initializes a new instance of the <see cref="EmailSender"/> class.
47+
/// </summary>
4248
[Obsolete("Please use the non-obsolete constructor. Will be removed in V17.")]
4349
public EmailSender(
4450
ILogger<EmailSender> logger,
@@ -55,6 +61,9 @@ public EmailSender(
5561
globalSettings.OnChange(x => _globalSettings = x);
5662
}
5763

64+
/// <summary>
65+
/// Initializes a new instance of the <see cref="EmailSender"/> class.
66+
/// </summary>
5867
[ActivatorUtilitiesConstructor]
5968
public EmailSender(
6069
ILogger<EmailSender> logger,
@@ -72,19 +81,19 @@ public EmailSender(
7281
globalSettings.OnChange(x => _globalSettings = x);
7382
}
7483

75-
/// <summary>
76-
/// Sends the message async
77-
/// </summary>
78-
/// <returns></returns>
84+
/// <inheritdoc/>
7985
public async Task SendAsync(EmailMessage message, string emailType) =>
80-
await SendAsyncInternal(message, emailType, false);
86+
await SendAsyncInternal(message, emailType, false, null);
8187

88+
/// <inheritdoc/>
8289
public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification) =>
83-
await SendAsyncInternal(message, emailType, enableNotification);
90+
await SendAsyncInternal(message, emailType, enableNotification, null);
8491

85-
/// <summary>
86-
/// Returns true if the application should be able to send a required application email
87-
/// </summary>
92+
/// <inheritdoc/>
93+
public async Task SendAsync(EmailMessage message, string emailType, bool enableNotification = false, TimeSpan? expires = null) =>
94+
await SendAsyncInternal(message, emailType, enableNotification, expires);
95+
96+
/// <inheritdoc/>
8897
/// <remarks>
8998
/// We assume this is possible if either an event handler is registered or an smtp server is configured
9099
/// or a pickup directory location is configured
@@ -93,7 +102,7 @@ public bool CanSendRequiredEmail() => _globalSettings.IsSmtpServerConfigured
93102
|| _globalSettings.IsPickupDirectoryLocationConfigured
94103
|| _notificationHandlerRegistered;
95104

96-
private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification)
105+
private async Task SendAsyncInternal(EmailMessage message, string emailType, bool enableNotification, TimeSpan? expires)
97106
{
98107
if (enableNotification)
99108
{
@@ -104,7 +113,7 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo
104113
// if a handler handled sending the email then don't continue.
105114
if (notification.IsHandled)
106115
{
107-
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
116+
if (_logger.IsEnabled(LogLevel.Debug))
108117
{
109118
_logger.LogDebug(
110119
"The email sending for {Subject} was handled by a notification handler",
@@ -116,7 +125,7 @@ private async Task SendAsyncInternal(EmailMessage message, string emailType, boo
116125

117126
if (!_globalSettings.IsSmtpServerConfigured && !_globalSettings.IsPickupDirectoryLocationConfigured)
118127
{
119-
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
128+
if (_logger.IsEnabled(LogLevel.Debug))
120129
{
121130
_logger.LogDebug(
122131
"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
173182
while (true);
174183
}
175184

176-
await _emailSenderClient.SendAsync(message);
185+
await _emailSenderClient.SendAsync(message, expires);
177186
}
178-
179187
}

src/Umbraco.Infrastructure/Mail/Interfaces/IEmailSenderClient.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,25 @@
33
namespace Umbraco.Cms.Infrastructure.Mail.Interfaces
44
{
55
/// <summary>
6-
/// Client for sending an email from a MimeMessage
6+
/// Client for sending an email from a MimeMessage.
77
/// </summary>
88
public interface IEmailSenderClient
99
{
1010
/// <summary>
11-
/// Sends the email message
11+
/// Sends the email message.
1212
/// </summary>
13-
/// <param name="message"></param>
14-
/// <returns></returns>
13+
/// <param name="message">The <see cref="EmailMessage"/> to send.</param>
14+
[Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 18.")]
1515
public Task SendAsync(EmailMessage message);
16+
17+
/// <summary>
18+
/// Sends the email message with an expiration date.
19+
/// </summary>
20+
/// <param name="message">The <see cref="EmailMessage"/> to send.</param>
21+
/// <param name="expires">An optional time for expiry.</param>
22+
public Task SendAsync(EmailMessage message, TimeSpan? expires)
23+
#pragma warning disable CS0618 // Type or member is obsolete
24+
=> SendAsync(message);
25+
#pragma warning restore CS0618 // Type or member is obsolete
1626
}
1727
}

0 commit comments

Comments
 (0)