Skip to content
This repository was archived by the owner on Jul 30, 2024. It is now read-only.

Commit 899603f

Browse files
authored
Fix wording for expiring API keys email notification (#105)
* Fix sql query to get apikey v2 expiring api keys * Fix email wording and aggregate the emails by users * format mail * Fix resend email logic * add app host * format messaging * Nit fixes * Nit fixes * address feedback * Fix exception logging * undo app host changes
1 parent 5405ad4 commit 899603f

8 files changed

Lines changed: 160 additions & 48 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ nuget.exe
77
*.suo
88
*.user
99
*.sln.docstates
10+
.vs/config/applicationhost.config
1011

1112
# Build results
1213
[Dd]ebug/

src/Gallery.CredentialExpiration/Gallery.CredentialExpiration.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
<Reference Include="System.Xml" />
9797
</ItemGroup>
9898
<ItemGroup>
99+
<Compile Include="LogEvents.cs" />
99100
<Compile Include="MyJobArgumentNames.cs" />
100101
<Compile Include="Models\ExpiredCredentialData.cs" />
101102
<Compile Include="Job.cs" />

src/Gallery.CredentialExpiration/Job.cs

Lines changed: 82 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ public class Job : JobBase
3535
private string _mailFrom;
3636
private SmtpClient _smtpClient;
3737

38+
private int _allowEmailResendAfterDays = 7;
3839
private int _warnDaysBeforeExpiration = 10;
39-
40+
4041
private ILogger _logger;
4142

4243
public override bool Init(IDictionary<string, string> jobArgsDictionary)
@@ -64,15 +65,15 @@ public override bool Init(IDictionary<string, string> jobArgsDictionary)
6465
var smtpUri = new SmtpUri(new Uri(smtpConnectionString));
6566
_smtpClient = CreateSmtpClient(smtpUri);
6667

67-
var temp = JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, MyJobArgumentNames.WarnDaysBeforeExpiration);
68-
if (temp.HasValue)
69-
{
70-
_warnDaysBeforeExpiration = temp.Value;
71-
}
68+
_warnDaysBeforeExpiration = JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, MyJobArgumentNames.WarnDaysBeforeExpiration)
69+
?? _warnDaysBeforeExpiration;
70+
71+
_allowEmailResendAfterDays = JobConfigurationManager.TryGetIntArgument(jobArgsDictionary, MyJobArgumentNames.AllowEmailResendAfterDays)
72+
?? _allowEmailResendAfterDays;
7273
}
7374
catch (Exception exception)
7475
{
75-
_logger.LogCritical("Failed to initialize job! {Exception}", exception);
76+
_logger.LogCritical(LogEvents.JobInitFailed, exception, "Failed to initialize job!");
7677

7778
return false;
7879
}
@@ -93,8 +94,8 @@ public override async Task<bool> Run()
9394
var contactedUsers = JsonConvert.DeserializeObject<Dictionary<string, DateTimeOffset>>(
9495
File.ReadAllText(_cursorFile));
9596

96-
// Clean older entries (contacted in last _warnDaysBeforeExpiration * 2 days)
97-
var referenceDate = DateTimeOffset.UtcNow.AddDays(-2 * _warnDaysBeforeExpiration);
97+
// Clean older entries (contacted in last _allowEmailResendAfterDays)
98+
var referenceDate = DateTimeOffset.UtcNow.AddDays(-1 * _allowEmailResendAfterDays);
9899
foreach (var kvp in contactedUsers.Where(kvp => kvp.Value >= referenceDate))
99100
{
100101
_contactedUsers.AddOrUpdate(kvp.Key, kvp.Value, (s, offset) => kvp.Value);
@@ -118,24 +119,51 @@ public override async Task<bool> Run()
118119
expiredCredentials.Count);
119120
}
120121

122+
// Add default description for non-scoped API keys
123+
expiredCredentials
124+
.Where(cred => string.IsNullOrEmpty(cred.Description))
125+
.ToList()
126+
.ForEach(ecd => ecd.Description = Constants.NonScopedApiKeyDescription);
127+
128+
// Group credentials for each user
129+
var userToExpiredCredsMapping = expiredCredentials
130+
.GroupBy(x => x.Username)
131+
.ToDictionary(user => user.Key, value => value.ToList());
132+
121133
// Handle expiring credentials
122134
var jobRunTime = DateTimeOffset.UtcNow;
123-
foreach (var expiredCredential in expiredCredentials)
135+
foreach (var userCredMapping in userToExpiredCredsMapping)
124136
{
125-
if (!_contactedUsers.ContainsKey(expiredCredential.Username))
137+
var username = userCredMapping.Key;
138+
var credentialList = userCredMapping.Value;
139+
140+
// Split credentials into two lists: Expired and Expiring to aggregate messages
141+
var expiringCredentialList = credentialList
142+
.Where(x => (x.Expires - jobRunTime).TotalDays > 0)
143+
.ToList();
144+
var expiredCredentialList = credentialList
145+
.Where(x => (x.Expires - jobRunTime).TotalDays <= 0)
146+
.ToList();
147+
148+
DateTimeOffset userContactTime;
149+
if (!_contactedUsers.TryGetValue(username, out userContactTime))
126150
{
127-
await HandleExpiredCredentialEmail(expiredCredential, jobRunTime);
151+
// send expiring API keys email notification
152+
await HandleExpiredCredentialEmail(username, expiringCredentialList, jobRunTime, expired: false);
153+
154+
// send expired API keys email notification
155+
await HandleExpiredCredentialEmail(username, expiredCredentialList, jobRunTime, expired: true);
128156
}
129157
else
130158
{
131-
_logger.LogDebug("Skipping expired credential for user {Username} - already handled today.",
132-
expiredCredential.Username);
159+
_logger.LogDebug("Skipping expired credential for user {Username} - already handled at {JobRuntime}.",
160+
username, userContactTime);
133161
}
134162
}
135163
}
136164
catch (Exception ex)
137165
{
138-
_logger.LogCritical("Job run failed! {Exception}", ex);
166+
_logger.LogCritical(LogEvents.JobRunFailed, ex, "Job run failed!");
139167

140168
return false;
141169
}
@@ -149,24 +177,37 @@ public override async Task<bool> Run()
149177
return true;
150178
}
151179

152-
private async Task HandleExpiredCredentialEmail(ExpiredCredentialData expiredCredential, DateTimeOffset jobRunTime)
180+
private async Task HandleExpiredCredentialEmail(string username, List<ExpiredCredentialData> credentialList, DateTimeOffset jobRunTime, bool expired)
153181
{
154-
_logger.LogInformation("Handling expired credential for user {Username} (expires: {Expires})...", expiredCredential.Username, expiredCredential.Expires);
182+
if (credentialList == null || credentialList.Count == 0)
183+
{
184+
return;
185+
}
186+
187+
_logger.LogInformation("Handling {Expired} credential(s) for user {Username} (Keys: {Descriptions})...",
188+
expired ? "expired" : "expiring",
189+
username,
190+
string.Join(", ", credentialList.Select(x => x.Description).ToList()));
155191

156192
// Build message
157-
var mailMessage = new MailMessage(_mailFrom, expiredCredential.EmailAddress);
193+
var userEmail = credentialList.FirstOrDefault().EmailAddress;
194+
var mailMessage = new MailMessage(_mailFrom, userEmail);
158195

196+
var apiKeyExpiryMessageList = credentialList
197+
.Select(x => BuildApiKeyExpiryMessage(x.Description, x.Expires, jobRunTime))
198+
.ToList();
199+
200+
var apiKeyExpiryMessage = string.Join(Environment.NewLine, apiKeyExpiryMessageList);
159201
// Build email body
160-
var expiresInDays = expiredCredential.Expires.UtcDateTime - DateTime.UtcNow;
161-
if (expiresInDays.TotalDays <= 0)
202+
if (expired)
162203
{
163204
mailMessage.Subject = string.Format(Strings.ExpiredEmailSubject, _galleryBrand);
164-
mailMessage.Body = string.Format(Strings.ExpiredEmailBody, _galleryBrand, _galleryAccountUrl);
205+
mailMessage.Body = string.Format(Strings.ExpiredEmailBody, username, _galleryBrand, apiKeyExpiryMessage, _galleryAccountUrl);
165206
}
166207
else
167208
{
168209
mailMessage.Subject = string.Format(Strings.ExpiringEmailSubject, _galleryBrand);
169-
mailMessage.Body = string.Format(Strings.ExpiringEmailBody, _galleryBrand, _galleryAccountUrl, (int)expiresInDays.TotalDays);
210+
mailMessage.Body = string.Format(Strings.ExpiringEmailBody, username, _galleryBrand, apiKeyExpiryMessage, _galleryAccountUrl);
170211
}
171212

172213
// Send email
@@ -177,22 +218,37 @@ private async Task HandleExpiredCredentialEmail(ExpiredCredentialData expiredCre
177218
await _smtpClient.SendMailAsync(mailMessage);
178219
}
179220

180-
_logger.LogInformation("Handled expired credential for user {Username}.", expiredCredential.Username);
221+
_logger.LogInformation("Handled {Expired} credential for user {Username}.",
222+
expired ? "expired" : "expiring",
223+
username);
181224

182-
_contactedUsers.AddOrUpdate(expiredCredential.Username, jobRunTime, (s, offset) => jobRunTime);
225+
_contactedUsers.AddOrUpdate(username, jobRunTime, (s, offset) => jobRunTime);
183226
}
184227
catch (SmtpFailedRecipientException ex)
185228
{
186-
_logger.LogWarning("Failed to handle expired credential for user {Username} - recipient failed.", expiredCredential.Username, ex);
229+
var logMessage = $"Failed to handle credential for user {username} - recipient failed!";
230+
_logger.LogWarning(LogEvents.FailedToSendMail, ex, logMessage);
187231
}
188232
catch (Exception ex)
189233
{
190-
_logger.LogCritical("Failed to handle expired credential for user {Username}.", expiredCredential.Username, ex);
234+
var logMessage = $"Failed to handle credential for user {username}.";
235+
_logger.LogCritical(LogEvents.FailedToHandleExpiredCredential, ex, logMessage);
191236

192237
throw;
193238
}
194239
}
195240

241+
private static string BuildApiKeyExpiryMessage(string description, DateTimeOffset expiry, DateTimeOffset currentTime)
242+
{
243+
var expiryInDays = (expiry - currentTime).TotalDays;
244+
var message = expiryInDays < 0
245+
? string.Format(Strings.ApiKeyExpired, description)
246+
: string.Format(Strings.ApiKeyExpiring, description, (int)expiryInDays);
247+
248+
// \u2022 - Unicode for bullet point.
249+
return "\u2022 "+ message + Environment.NewLine;
250+
}
251+
196252
private SmtpClient CreateSmtpClient(SmtpUri smtpUri)
197253
{
198254
var smtpClient = new SmtpClient(smtpUri.Host, smtpUri.Port)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace Gallery.CredentialExpiration
7+
{
8+
public class LogEvents
9+
{
10+
public static EventId FailedToHandleExpiredCredential = new EventId(600, "Failed to handle expired credential");
11+
public static EventId FailedToSendMail = new EventId(601, "Failed to deliver email");
12+
public static EventId JobRunFailed = new EventId(650, "Job run failed");
13+
public static EventId JobInitFailed = new EventId(651, "Job initialization failed");
14+
}
15+
}

src/Gallery.CredentialExpiration/Models/ExpiredCredentialData.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@
55

66
namespace Gallery.CredentialExpiration.Models
77
{
8+
public static class Constants
9+
{
10+
public const string NonScopedApiKeyDescription = "Full access API key";
11+
}
12+
813
public class ExpiredCredentialData
914
{
1015
public string Type { get; set; }
1116
public string Username { get; set; }
1217
public string EmailAddress { get; set; }
18+
public string Description { get; set; }
1319
public DateTimeOffset Created { get; set; }
1420
public DateTimeOffset Expires { get; set; }
1521
}

src/Gallery.CredentialExpiration/MyJobArgumentNames.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ public class MyJobArgumentNames
88
public const string GalleryBrand = "GalleryBrand";
99
public const string GalleryAccountUrl = "GalleryAccountUrl";
1010
public const string WarnDaysBeforeExpiration = "WarnDaysBeforeExpiration";
11+
public const string AllowEmailResendAfterDays = "AllowEmailResendAfterDays";
1112
}
1213
}

src/Gallery.CredentialExpiration/Strings.Designer.cs

Lines changed: 33 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Gallery.CredentialExpiration/Strings.resx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -118,36 +118,40 @@
118118
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
119119
</resheader>
120120
<data name="ExpiredEmailBody" xml:space="preserve">
121-
<value>Hi,
121+
<value>Hi {0},
122122

123-
We wanted to inform you that your API key on {0} has expired.
123+
We wanted to inform you that the following API key(s) on {1} have expired.
124124

125-
Visit {1} to generate a new API key so that you can continue pushing packages.
125+
{2}
126+
127+
Visit {3} to generate a new API key(s) so that you can continue pushing packages.
126128

127129
Best regards,
128130

129-
{0}</value>
131+
{1}</value>
130132
</data>
131133
<data name="ExpiringEmailBody" xml:space="preserve">
132-
<value>Hi,
134+
<value>Hi {0},
135+
136+
We wanted to inform you that the following API key(s) on {1} will expire soon:
133137

134-
We wanted to inform you that your API key on {0} will expire in {2} days.
138+
{2}
135139

136-
Visit {1} to generate a new API key so that you can continue pushing packages.
140+
Visit {3} to generate a new API key(s) so that you can continue pushing packages using them.
137141

138142
Best regards,
139143

140-
{0}</value>
144+
{1}</value>
141145
</data>
142146
<data name="GetExpiredCredentialsQuery" xml:space="preserve">
143-
<value>SELECT cr.[Type], cr.[Created], cr.[Expires], u.[EmailAddress], u.[Username]
147+
<value>SELECT cr.[Type], cr.[Created], cr.[Expires], cr.[Description], u.[EmailAddress], u.[Username]
144148
FROM [Credentials] AS cr
145149
INNER JOIN Users AS u ON u.[Key] = cr.[UserKey]
146150
WHERE u.[EmailAllowed] = 1
147151
AND u.[EmailAddress] &lt;&gt; ''
148152
AND cr.[Expires] &lt;= DATEADD(day,{0},GETUTCDATE())
149-
AND cr.[Expires] &gt; DATEADD(day,-1,GETUTCDATE())
150-
AND cr.[Type] = 'apikey.v1'
153+
AND cr.[Expires] &gt; DATEADD(day,-1 * {0},GETUTCDATE())
154+
AND (cr.[Type] = 'apikey.v1' or cr.[Type] = 'apikey.v2')
151155
ORDER BY u.[Username]</value>
152156
</data>
153157
<data name="ExpiredEmailSubject" xml:space="preserve">
@@ -156,4 +160,10 @@ ORDER BY u.[Username]</value>
156160
<data name="ExpiringEmailSubject" xml:space="preserve">
157161
<value>[{0}] Your API key is about to expire</value>
158162
</data>
163+
<data name="ApiKeyExpiring" xml:space="preserve">
164+
<value>{0} - expires in {1} day(s).</value>
165+
</data>
166+
<data name="ApiKeyExpired" xml:space="preserve">
167+
<value>{0} - has expired.</value>
168+
</data>
159169
</root>

0 commit comments

Comments
 (0)