Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve email sending #6154

Merged
merged 11 commits into from
Aug 6, 2018
76 changes: 51 additions & 25 deletions src/NuGetGallery.Core/Services/CoreMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Net.Mail;
using System.Text;
using System.Threading;
using AnglicanGeek.MarkdownMailer;
using NuGet.Services.Validation;
using NuGet.Services.Validation.Issues;
Expand All @@ -14,9 +16,11 @@ namespace NuGetGallery.Services
{
public class CoreMessageService : ICoreMessageService
{
protected CoreMessageService()
{
}
private static readonly ReadOnlyCollection<TimeSpan> RetryDelays = Array.AsReadOnly(new[] {
TimeSpan.FromSeconds(0.1),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(10)
});

public CoreMessageService(IMailSender mailSender, ICoreMessageServiceConfiguration coreConfiguration)
{
Expand Down Expand Up @@ -48,7 +52,7 @@ [change your email notification settings]({emailSettingsUrl}).

if (mailMessage.To.Any())
{
SendMessage(mailMessage, copySender: false);
SendMessage(mailMessage);
}
}
}
Expand Down Expand Up @@ -94,7 +98,7 @@ public void SendPackageValidationFailedNotice(Package package, PackageValidation

if (mailMessage.To.Any())
{
SendMessage(mailMessage, copySender: false);
SendMessage(mailMessage);
}
}
}
Expand Down Expand Up @@ -162,7 +166,7 @@ public void SendValidationTakingTooLongNotice(Package package, string packageUrl

if (mailMessage.To.Any())
{
SendMessage(mailMessage, copySender: false);
SendMessage(mailMessage);
}
}
}
Expand Down Expand Up @@ -192,31 +196,53 @@ protected static void AddOwnersSubscribedToPackagePushedNotification(PackageRegi
}
}

protected void SendMessage(MailMessage mailMessage)
protected virtual void SendMessage(MailMessage mailMessage)
{
SendMessage(mailMessage, copySender: false);
int attempt = 0;
bool success = false;
while (!success)
{
try
{
AttemptSendMessage(mailMessage);
success = true;
}
catch (SmtpException)
{
if (attempt < RetryDelays.Count)
{
Thread.Sleep(RetryDelays[attempt]);
attempt++;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably need to track the amount of retries we do.

}
else
{
throw;
}
}
}
}

virtual protected void SendMessage(MailMessage mailMessage, bool copySender)
protected virtual void AttemptSendMessage(MailMessage mailMessage)
{
MailSender.Send(mailMessage);
if (copySender)
}

protected void SendMessageToSender(MailMessage mailMessage)
{
var senderCopy = new MailMessage(
CoreConfiguration.GalleryOwner,
mailMessage.ReplyToList.First())
{
var senderCopy = new MailMessage(
CoreConfiguration.GalleryOwner,
mailMessage.ReplyToList.First())
{
Subject = mailMessage.Subject + " [Sender Copy]",
Body = string.Format(
CultureInfo.CurrentCulture,
"You sent the following message via {0}: {1}{1}{2}",
CoreConfiguration.GalleryOwner.DisplayName,
Environment.NewLine,
mailMessage.Body),
};
senderCopy.ReplyToList.Add(mailMessage.ReplyToList.First());
MailSender.Send(senderCopy);
}
Subject = mailMessage.Subject + " [Sender Copy]",
Body = string.Format(
CultureInfo.CurrentCulture,
"You sent the following message via {0}: {1}{1}{2}",
CoreConfiguration.GalleryOwner.DisplayName,
Environment.NewLine,
mailMessage.Body),
};
senderCopy.ReplyToList.Add(mailMessage.ReplyToList.First());
SendMessage(senderCopy);
}
}
}
3 changes: 2 additions & 1 deletion src/NuGetGallery/App_Start/DefaultDependenciesModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
using NuGetGallery.Infrastructure.Authentication;
using NuGetGallery.Infrastructure.Lucene;
using NuGetGallery.Security;
using NuGetGallery.Services;
using SecretReaderFactory = NuGetGallery.Configuration.SecretReader.SecretReaderFactory;

namespace NuGetGallery
Expand Down Expand Up @@ -338,7 +339,7 @@ protected override void Load(ContainerBuilder builder)
.As<IMailSender>()
.InstancePerLifetimeScope();

builder.RegisterType<MessageService>()
builder.RegisterType<BackgroundMessageService>()
.AsSelf()
.As<IMessageService>()
.InstancePerLifetimeScope();
Expand Down
1 change: 1 addition & 0 deletions src/NuGetGallery/NuGetGallery.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@
<Compile Include="Services\ActionRequiringReservedNamespacePermissions.cs" />
<Compile Include="Services\ActionsRequiringPermissions.cs" />
<Compile Include="Services\AsynchronousPackageValidationInitiator.cs" />
<Compile Include="Services\BackgroundMessageService.cs" />
<Compile Include="Services\CertificatesConfiguration.cs" />
<Compile Include="Services\CertificateService.cs" />
<Compile Include="Services\CertificateValidator.cs" />
Expand Down
37 changes: 37 additions & 0 deletions src/NuGetGallery/Services/BackgroundMessageService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Net.Mail;
using System.Threading.Tasks;
using AnglicanGeek.MarkdownMailer;
using NuGetGallery.Configuration;

namespace NuGetGallery.Services
{
public class BackgroundMessageService : MessageService
{
public BackgroundMessageService(IMailSender mailSender, IAppConfiguration config, ITelemetryClient telemetryClient)
:base(mailSender, config, telemetryClient)
{
}

protected override void SendMessage(MailMessage mailMessage)
{
// Send email as background task, as we don't want to delay the HTTP response.
// Particularly when sending email fails and needs to be retried with a delay.
Task.Run(() =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a concerth that we might run into thread pool depletion if we try to send a lot of email while SMTP server is unresponsive and causes retries. base.SendMessage uses Thread.Sleep which blocks thread preventing other tasks from running on it (including processing the web requests? not sure if asp.net uses the same thread pool as Task.Run for request processing).

{
try
{
base.SendMessage(mailMessage);
}
catch (Exception ex)
{
// Log but swallow the exception.
QuietLog.LogHandledException(ex);
}
});
}
}
}
9 changes: 9 additions & 0 deletions src/NuGetGallery/Services/ITelemetryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,14 @@ public interface ITelemetryClient
void TrackMetric(string metricName, double value, IDictionary<string, string> properties = null);

void TrackException(Exception exception, IDictionary<string, string> properties = null, IDictionary<string, double> metrics = null);

void TrackDependency(string dependencyTypeName,
string target,
string dependencyName,
string data,
DateTimeOffset startTime,
TimeSpan duration,
string resultCode,
bool success);
}
}
38 changes: 22 additions & 16 deletions src/NuGetGallery/Services/MessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net.Mail;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using AnglicanGeek.MarkdownMailer;
using NuGetGallery.Configuration;
Expand All @@ -16,15 +18,16 @@ namespace NuGetGallery
{
public class MessageService : CoreMessageService, IMessageService
{
protected MessageService()
{
}

public MessageService(IMailSender mailSender, IAppConfiguration config)
public MessageService(IMailSender mailSender, IAppConfiguration config, ITelemetryClient telemetryClient)
: base(mailSender, config)
{
this.telemetryClient = telemetryClient ?? throw new ArgumentNullException(nameof(telemetryClient));
smtpUri = config.SmtpUri?.Host ?? throw new ArgumentNullException(nameof(config) + "." + nameof(config.SmtpUri));
}

private readonly ITelemetryClient telemetryClient;
private readonly string smtpUri;

public IAppConfiguration Config
{
get { return (IAppConfiguration)CoreConfiguration; }
Expand Down Expand Up @@ -168,7 +171,11 @@ [change your email notification settings]({7}).

if (mailMessage.To.Any())
{
SendMessage(mailMessage, copySender);
SendMessage(mailMessage);
if (copySender)
{
SendMessageToSender(mailMessage);
}
}
}
}
Expand Down Expand Up @@ -1014,21 +1021,20 @@ private bool AddAddressesForAccountManagementToEmail(MailMessage mailMessage, Us
return AddAddressesWithPermissionToEmail(mailMessage, user, ActionsRequiringPermissions.ManageAccount);
}

protected override void SendMessage(MailMessage mailMessage, bool copySender)
protected override void AttemptSendMessage(MailMessage mailMessage)
{
bool success = false;
DateTimeOffset now = DateTimeOffset.UtcNow;
Stopwatch sw = Stopwatch.StartNew();
try
{
base.SendMessage(mailMessage, copySender);
}
catch (InvalidOperationException ex)
{
// Log but swallow the exception
QuietLog.LogHandledException(ex);
base.AttemptSendMessage(mailMessage);
success = true;
}
catch (SmtpException ex)
finally
{
// Log but swallow the exception
QuietLog.LogHandledException(ex);
sw.Stop();
telemetryClient.TrackDependency("SMTP", smtpUri, "SendMessage", null, now, sw.Elapsed, null, success);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pattern we use for this kind of thing is a TrackMetric, through some sort of descriptive wrapper method. For example: https://github.com/NuGet/NuGet.Jobs/blob/master/src/Validation.PackageSigning.ProcessSignature/Telemetry/TelemetryService.cs#L61

Also, this out of scope for this PR, but we really should merge the Gallery's and ServerCommon's telemetry APIs...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it may be worth tracking whether the operation was a success or not so that we measure how good/bad our mail system is.


In reply to: 201454393 [](ancestors = 201454393)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to use TrackDependency for a few reasons.

  • I wanted to have an event which told us if it was successful or failed. Keeping the duration is just a side-effect of using the dependency event type
  • AI's Smart Monitoring can automatically alert us when failed dependencies increase. Using metrics we would have to create custom alerts, assuming we use App Insights for alerting. Perhaps if we export App Insights logs to something else and do alerting there, using metrics doesn't lose this advantage.
  • Dependencies are shown in AI's Application Map tab. If all dependencies are recorded in AI, then the application map is an auto-generated, self-updating, service architecture diagram, which is great for newbies like me 😄 (side note, we must be using an old App Insights SDK, because newer ones automatically capture outgoing HTTP calls, including Blob Storage, but ours does not)
  • Sending emails to a SMTP server really is a service dependency, and not an application specific metric. Therefore, using the dependency event type, rather than metric, seems more semantically correct.

I'm happy to change it to a metric, but those are the reasons I added TrackDependency to ITelemetryClient. Let me know what you think.

Copy link
Contributor

@loic-sharma loic-sharma Jul 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those are very good points! It sounds like TrackDependency is indeed the right pattern here.

}

Expand Down
19 changes: 19 additions & 0 deletions src/NuGetGallery/Services/TelemetryClientWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,24 @@ public void TrackMetric(string metricName, double value, IDictionary<string, str
// logging failed, don't allow exception to escape
}
}

public void TrackDependency(string dependencyTypeName,
string target,
string dependencyName,
string data,
DateTimeOffset startTime,
TimeSpan duration,
string resultCode,
bool success)
{
try
{
UnderlyingClient.TrackDependency(dependencyTypeName, target, dependencyName, data, startTime, duration, resultCode, success);
}
catch
{
// logging failed, don't allow exception to escape
}
}
}
}
5 changes: 3 additions & 2 deletions tests/NuGetGallery.Facts/Services/MessageServiceFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2005,19 +2005,20 @@ public class TestableMessageService
: MessageService
{
private TestableMessageService(IGalleryConfigurationService configurationService)
: base(new TestMailSender(), configurationService.Current, new Mock<ITelemetryClient>().Object)
{
configurationService.Current.GalleryOwner = TestGalleryOwner;
configurationService.Current.GalleryNoReplyAddress = TestGalleryNoReplyAddress;

Config = configurationService.Current;
MailSender = MockMailSender = new TestMailSender();
MockMailSender = (TestMailSender)MailSender;
}

public Mock<AuthenticationService> MockAuthService { get; protected set; }
public TestMailSender MockMailSender { get; protected set; }

public static TestableMessageService Create(IGalleryConfigurationService configurationService)
{
configurationService.Current.SmtpUri = new Uri("smtp://fake.mail.server");
return new TestableMessageService(configurationService);
}
}
Expand Down