Improving your ASP.NET Core site's e-mailing capabilities
Many websites depend heavily on e-mail: they send account confirmation e-mails, password reset e-mails, order confirmations, back-in-stock notifications and much more. Despite its importance, I often see that sending e-mail is an overlooked area when writing well maintainable and stable code. It's also often overlooked when monitoring sites and lots of code I have seen just assumes the mail server is up and running. But problems will occur; mail servers will go down, passwords do expire or get changed without updating the web site and more.
In a preceding article you saw how to monitor your site's SMTP server using an ASP.NET Core health check. While it's great to be notified when your SMTP server is unavailable, it would be even better if your site has an alternative way to deliver the messages when the primary SMTP is not available.
In this article, I'll show you a couple of ways to improve the way you send e-mails from your ASP.NET Core applications. In particular, I'll discuss:
- Hiding send functionality behind an interface
- Providing multiple concrete implementations to send e-mails in different ways and configure the best one at runtime
- Implementing a fallback solution for cases where your primary mail server is not available
- How to use MailKit to replace the built-in SmtpClient which is now considered obsolete by Microsoft
- How to use the SendGrid API to send e-mails from your application
You'll find the full code for the article as a download at the end of this article, as well as in the Github repository for this article.
Abstracting e-mail functionality out to an interface
Very often I see code like the following directly in, for example, an action method of an MVC or Web API project:
var message = new MailMessage { Subject = "Your account confirmation", Body = BuildBody(), From = new MailAddress("sender@example.com") }; message.To.Add("recipient@example.com"); var smtpClient = new SmtpClient(mailServer, port) { UseDefaultCredentials = false, Credentials = new NetworkCredential(userName, password), EnableSsl = true }; try { smtpClient.Send(message); return Ok(); } catch (Exception ex) { // Todo log error return StatusCode(500); }
While this code works, it has a number of drawbacks:
- It's impossible or at least very difficult to unit test as it will always attempt to send a real e-mail message
- The server and credential information and usage are spread across the app
- There's no easy way to implement a fallback solution in case the primary mail server is down
- You can't move away easily from the SmtpClient to use, say, a cloud-based e-mailing tool
Sometimes the code appears to be a little better and looks like this:
MailHelper.Send("sender@example.com", "recipient@example.com", "Your account confirmation", BuildBody());
But most likely, this is just using the same code inside the Send method with the same drawbacks.
A better solution is to hide the e-mail functionality behind an interface and then inject a concrete instance of it at runtime into your controller. Here's a very simple implementation of that idea:
public interface IMailSender { void SendMessage(string from, string to, string subject, string body); void SendMessage(string from, string to, string subject, string body, bool isBodyHtml); void SendMessage(MailMessage mailMessage); } public class MailSender : IMailSender { public MailSender() { } public void SendMessage(string from, string to, string subject, string body) { SendMessage(from, to, subject, body, false); } public void SendMessage(string from, string to, string subject, string body, bool isBodyHtml) { var mailMessage = new MailMessage { From = new MailAddress(from), Subject = subject, Body = body, IsBodyHtml = isBodyHtml }; mailMessage.To.Add(new MailAddress(to)); SendMessage(mailMessage); } public void SendMessage(MailMessage mailMessage) { mailMessage.BodyEncoding = System.Text.Encoding.UTF8; var client = new SmtpClient(MailServer, Port) { UseDefaultCredentials = false, Credentials = new NetworkCredential(UserName, Password), EnableSsl = true }; client.Send(mailMessage); } }
With the e-mail functionality now behind an interface you can register its concrete implementation in Startup like follows:
services.AddSingleton<IMailSender, MailSender>();
And then use it in your controllers like this:
public class HomeController : ApiController { private readonly IMailSender _mailSender; public HomeController(IMailSender mailSender) { _mailSender = mailSender; } public IHttpActionResult SendIt() { try { _mailSender.SendMessage("sender@example.com", "recipient@example.com", "Your account confirmation", BuildBody()); return Ok(); } catch (Exception ex) { // Todo log error return StatusCode(500); } } }
Providing alternative implementations of IMailSender
Although the actual implementation of sending e-mail hasn't changed yet (I'll do that later in this article), you now have a nice opportunity to replace the MailSender with a completely different implementation. For example, during unit testing you can now the mock IMailSender so you can easily test the surrounding SendIt action method. But also, during local development where you may still want to see the actual e-mail without sending it over the wire you can create a new class that implements the IMailSender interface that, instead of sending out the e-mail over the network, writes your messages as .eml files on disk. Here's what that could look like:
public class AlwaysDropMailOnLocalDiskMailSender : IMailSender
{
private readonly string _rootFolder;
public AlwaysDropMailOnLocalDiskMailSender(string rootFolder)
{
if (!Directory.Exists(rootFolder))
{
Directory.CreateDirectory(rootFolder);
}
_rootFolder = rootFolder;
}
public void SendMessage(string fromAddress, string toAddress, string subject, string body)
{
SendMessage(fromAddress, toAddress, subject, body, false);
}
public void SendMessage(string fromAddress, string toAddress, string subject, string body, bool isBodyHtml)
{
var message = new MailMessage
{
From = new MailAddress(fromAddress),
Subject = subject,
Body = body,
IsBodyHtml = isBodyHtml
};
message.To.Add(new MailAddress(toAddress));
SendMessage(message);
}
public void SendMessage(MailMessage message)
{
using var smtpClient = new SmtpClient
{
DeliveryMethod = SmtpDeliveryMethod.SpecifiedPickupDirectory,
PickupDirectoryLocation = _rootFolder
};
message.BodyEncoding = System.Text.Encoding.UTF8;
smtpClient.Send(message);
}
}
The important bits of this code are in the last SendMessage method. Instead of connecting to a remote server, the SmtpClient now simply writes files to your local disk, in the folder identified by _rootFolder. You can simply open those files in an email client like Outlook by double-clicking them.
Since the AlwaysDropMailOnLocalDiskMailSender class implements the IMailSender interface, you can inject it anywhere the code expects one. For example, in an integration or unit test where you want to see and review the actual e-mail, you can inject one as follows:
var controller = new HomeController(new AlwaysDropMailOnLocalDiskMailSender("c:\\TempMail")); controller.SendIt();
But you can also use this IMailSender in your application directly, for example in all environments except production:
if (_environment.IsProduction()) { services.AddSingleton<IMailSender, MailSender>(); } else { services.AddSingleton<IMailSender, AlwaysDropMailOnLocalDiskMailSender>( x => new AlwaysDropMailOnLocalDiskMailSender(Configuration.GetValue<string>("TempMailFolder"))); }
This way your local development, testing and staging environments always write mail to disk, preventing a real e-mail from ever being sent out accidentally. And in production the application then uses the real mail sender.
In order to access the environment in ConfigureServices, you need to inject an instance of IWebHostEnvironment in Startup, and save it in a field so you can use it inside ConfigureServices:
public class Startup { private readonly IWebHostEnvironment _environment; public Startup(IConfiguration configuration, IWebHostEnvironment environment) { _environment = environment; Configuration = configuration; } public IConfiguration Configuration { get; } // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { // Use _environment here as in the example above. // Other code here } // Other code here }
And with this setup in place, you can now build other classes that implement IMailSender and plug them in as needed. For example, I often have an InternalMailSender implementation that sends messages for real (so we can perform proper end-to-end integration tests) but before it sends the mail, it changes the recipients to a fixed set of (internal) users and appends the addresses of the original recipients to the mail body so you can see whom this e-mail would have been sent to. Here's an example of how the SendMessage method of that class could look:
public void SendMessage(MailMessage mailMessage) { var sb = new StringBuilder(); var lineBreak = mailMessage.IsBodyHtml ? "<br />" : "\r\n"; foreach (var mailAddress in mailMessage.To) { sb.Append(BuildLine(nameof(mailMessage.To), mailAddress, lineBreak)); } foreach (var mailAddress in mailMessage.CC) { sb.Append(BuildLine(nameof(mailMessage.CC), mailAddress, lineBreak)); } foreach (var mailAddress in mailMessage.Bcc) { sb.Append(BuildLine(nameof(mailMessage.Bcc), mailAddress, lineBreak)); } sb.Append(mailMessage.Body); mailMessage.Body = sb.ToString(); mailMessage.To.Clear(); mailMessage.CC.Clear(); mailMessage.Bcc.Clear(); mailMessage.To.Add(_debugAddress); mailMessage.BodyEncoding = Encoding.UTF8; _mailSender.SendMessage(mailMessage); } private string BuildLine(string collectionName, MailAddress address, string lineBreak) { var result = $"Message ({collectionName}) addressed to {address.Address}"; if (!string.IsNullOrWhiteSpace(address.DisplayName)) { result += $" ({address.DisplayName})"; } return result + lineBreak; }
What's nice is that you can now mix and match. The InternalMailSender accepts an instance of IMailSender itself so you can decide to actually send those messages or just drop them on disk. Here's how you would define the class:
public class InternalMailSender : IMailSender { private readonly string _debugAddress; private readonly IMailSender _mailSender; public InternalMailSender(string debugAddress, IMailSender mailSender) { _debugAddress = debugAddress; _mailSender = mailSender; } ... Rest of the code you saw before here }
And here's how you could register it using the AlwaysDropMailOnLocalDiskMailSender:
services.AddSingleton<IMailSender, InternalMailSender>( x => new InternalMailSender( Configuration.GetValue<string>("DebugEmailAddress"), new AlwaysDropMailOnLocalDiskMailSender(Configuration.GetValue<string>("TempMailFolder"))));
Now you can test your InternalMailSender and still keep your messages local to your server!
To make registration of these mail senders a little easier it's recommended to create extension methods for IServiceCollection like this:
public static IServiceCollection AddSmtpServer(this IServiceCollection services) { services.AddSingleton<IMailSender, MailSender>(); return services; } public static IServiceCollection AddDropLocalSmtpServer(this IServiceCollection services, string folder) { services.AddSingleton<IMailSender, AlwaysDropMailOnLocalDiskMailSender>( x => new AlwaysDropMailOnLocalDiskMailSender(folder)); return services; }
And then you can simply call them like this in Startup:
if (_environment.IsProduction()) { services.AddSmtpServer(); } else { services.AddDropLocalSmtpServer(Configuration.GetValue<string>("TempMailFolder")); }
Next up in the list of improvements is the ability to define a fallback SMTP server in case the primary one cannot be used. You'll see how to do this next
Implementing a fallback server
So far, the code you've seen uses a single SMTP server to send the message to. If the mail server is up: great; everything works as expected. But what if it's down or what if your password expired? In an earlier article I showed you how to implement a health check to notify you when the mail server becomes unavailable somehow. But it would be even better if your code can handle this situation more gracefully and would attempt to reach another configured mail server to try sending the message. The overall steps are simple:
- Define configuration for multiple SMTP servers in the site's settings file
- Make the list of settings available in the MailSender
- Inside SendMessage attempt to send the mail with the first SMTP server. When it fails for certain error types, try the next one in the list
The sample application that comes with this article shows an implementation of this. The gist of it is explained in the next few sections:
private readonly SmtpSettings[] _emailSettings; public MailSender(IOptions<List<SmtpSettings>> emailSettings) { if (!emailSettings.Value.Any()) { throw new ArgumentException("Must specify at least one SMTP option."); } _emailSettings = emailSettings.Value.OrderBy(x => x.Priority).ToArray(); }
The class constructor accepts an array of SmtpSettings which contain the details of the available SMTP servers along with a priority:
public class SmtpSettings { public string MailServer { get; private set; } public int Port { get; private set; } public string UserName { get; private set; } public string Password { get; private set; } public bool UseSsl { get; private set; } public int Priority { get; private set; } }
Then inside the mail sender you can attempt to deliver the message and try the next mail server in the list when sending fails:
public void SendMessage(MailMessage mailMessage) { mailMessage.BodyEncoding = System.Text.Encoding.UTF8; bool shouldTrySendEmail; var count = 0; do { shouldTrySendEmail = !SendMail(_emailSettings[count], mailMessage); count++; } while (shouldTrySendEmail && count < _emailSettings.Length); } private bool SendMail(SmtpSettings smtpSettings, MailMessage mailMessage) { var client = BuildClient(smtpSettings); try { client.Send(mailMessage); return true; } catch (SmtpException smtpEx) { switch (smtpEx.StatusCode) { case SmtpStatusCode.MailboxBusy: // Sleep for a bit and then retry. // If it fails again, try the alternative server. return Retry(mailMessage, client); // Ignore the status codes below. Resending using an alternate e-mail server won't help in these cases. case SmtpStatusCode.InsufficientStorage: case SmtpStatusCode.MailboxNameNotAllowed: case SmtpStatusCode.ExceededStorageAllocation: case SmtpStatusCode.CannotVerifyUserWillAttemptDelivery: case SmtpStatusCode.UserNotLocalWillForward: return true; default: return false; // Attempt to resend with an alternative server on other status codes. } } catch (Exception) { return true; // Don't retry on other exceptions } } private static bool Retry(MailMessage mailMessage, SmtpClient client) { System.Threading.Thread.Sleep(2000); try { client.Send(mailMessage); return true; } catch (SmtpException) { // Failed twice now; let's retry the next server return false; } } private static SmtpClient BuildClient(SmtpSettings smtpSettings) { return new SmtpClient(smtpSettings.MailServer, smtpSettings.Port) { UseDefaultCredentials = false, Credentials = new NetworkCredential(smtpSettings.UserName, smtpSettings.Password), EnableSsl = smtpSettings.UseSsl; }; }
When sending the e-mail fails, you can catch an SmtpException which exposes a status code. Depending on the status code you can attempt to retry the e-mail with a different mail server. For example, when you get SmtpStatusCode.ClientNotPermitted the current user is not allowed to use the specified mail server, so it makes sense to try a different server. But SmtpStatusCode.InsufficientStorage is a receiver's account issue, so resending using an alternate server is likely to fail again. The sample app has explicit support for a few status codes from the SmtpStatusCode enum that I encountered in real-world apps, but you're encouraged to review and test as many others as possible and see whether a retry is in order or not.
To define the various mail servers, the sample app has the following app settings:
"SmtpSettings": [ { "MailServer": "YourMailServer", "Port": 587, "UserName": "you@example.com", "Password": "Passw0rd", "UseSsl": true, "Priority": 1 }, { "MailServer": "YourMailServer2", "Port": 587, "UserName": "you@example.com", "Password": "Passw0rd", "UseSsl": true, "Priority": 2 } ]
which can be pulled out with code like this:
services.Configure<List<SmtpSettings>>(options => Configuration.GetSection("SmtpSettings") .Bind(options, c => c.BindNonPublicProperties = true));
I use Configure and Bind since I want to bind to an immutable class with read-only properties. More information on this can be found in my article on standard health checks in ASP.NET Core.
With the retry/fallback mechanism in place, the next optimization you could implement is replace SmtpClient with an external library. I'll discuss that next.
Replacing SmtpClient with MailKit
The official documentation of the System.Net.Mail.SmtpClient class states that:
The SmtpClient type is now obsolete.
Important
We don't recommend that you use the SmtpClient class for new development because SmtpClient doesn't support many modern protocols. Use MailKit or other libraries instead. For more information, see SmtpClient shouldn't be used on GitHub.
SmtpClient is still present in .NET Core, but mostly for backwards compatibility reasons. It's also still a viable solution for one-off e-mails in simple scenarios, but for anything that needs to scale or has more requirements than what SmtpClient supports, you should look elsewhere. Fortunately, there are good alternatives. In this section I'll show you how to implement MailKit and a later section describes how to use the SendGrid API.
MailKit is an open-source project available at https://github.com/jstedfast/MailKit. It's available as a NuGet package (Install-Package MailKit) and has great documentation and samples. Using its API is pretty straightforward and looks similar to using System.Net.Mail.SmtpClient. The following code snippet shows a (modified) code example from the Github repo that demonstrates sending an e-mail:
var message = new MimeMessage (); message.From.Add (new MailboxAddress ("Me", "me@example.com")); message.To.Add (new MailboxAddress ("You", "you@example.com")); message.Subject = "Subject here"; message.Body = new TextPart ("plain") { Text = @"Body here" }; using (var client = new SmtpClient ()) { client.Connect ("smtp.example.com", 587, false); // Note: only needed if the SMTP server requires authentication client.Authenticate ("UserName", "Pa$$w0rd"); // Hope you use better passwords in your real apps. I do ;-) client.Send (message); client.Disconnect (true); }
MailKit also has support for async methods allowing you implement all your e-mail functionality with async code. The current IMailSender is not async, so now is a good time to correct that. When you want to support async in your code base, you can either add async methods to a synchronous interface, or replace them fully with async ones. In my solution I usually opt for the latter and provide an async version only. For IMailSender that could look like this:
public interface IMailSender { Task SendMessageAsync(string from, string to, string subject, string body); Task SendMessageAsync(string from, string to, string subject, string body, bool isBodyHtml); Task SendMessageAsync(MailMessage mailMessage); }
Concrete implementations can then implement these async methods as you'll see in a bit. In the sample project you'll find two IMailSender interfaces: IMailSender and IMailSenderV2 where V2 is the async version. I wouldn't recommend naming them like this in your own projects. I would pick one and call that IMailSender. I just called the second one IMailSenderV2 so they can live side-by-side in my sample project. I have also supplied a sample version of MailSender that implements the IMailSenderV2 interface and called it MailSenderAsync. Note: if you want to try out the different versions, you'll also need to change the code in the HomeController to either accept the synchronous or the async version and update the SendIt method to use the async version of the SendMessage method.
One of the differences between standard System.Net.Mail and MailKit you may have spotted is that the code uses a MimeMessage (from the MimeKit namespace) instead of a MailMessage (from the System.Net.Mail namespace). This means that if you introduce MailKit into an existing application you may end up with some incompatibilities. You can overcome that with any of the following:
- Update your IMailSender to accept a MimeMessage instead and update any calling code. This then ties your code to the MailKit library which may cause issues down the road when switching to a different sender (like the ones that drop mail on your local machine, but also the SendGrid implementation discussed later.)
- Introduce your own DTO representing an e-mail message and use that in your IMailSender interface and implementations. Then for the MailKit implementation, you would map from your message to MimeMessage. However, that could be quite a lot of work as the MailMessage and MimeMessage have a ton of different properties you need to support. For new applications this may still be a good option though.
- Keep using MailMessage and convert that to a MimeMessage in your MailKit implementation. I like this solution as System.Net.Mail.MailMessage depends only on .NET Core and nothing else, which makes it a light dependency. And mapping it to a MimeMessage turns out to be super easy with a cast as you'll see shortly. Note: According to the developer of MailKit the option to cast is there mostly as a temporary solution for developers porting to MailKit, not intended as a real solution for production so use this with care. In my tests so far, it worked just fine.
Before I show you my MailKit implementation of IMailSender, let's look at one more optimization we can make. Currently, the IMailSender interface defines three overloads of SendMessage. The first two do nothing but forward data to the final one that does the actual work by sending the e-mail. Instead of implementing that logic in every concrete class, it's a good idea to introduce a base that handles this. Your mail senders then inherit this class and override only a single method. Here's the code for the base class:
public abstract class MailSenderBase : IMailSenderV2 // Remember, only V2 in the sample project { public async Task SendMessageAsync(string from, string to, string subject, string body) { await SendMessageAsync(from, to, subject, body, true); } public async Task SendMessageAsync(string from, string to, string subject, string body, bool isBodyHtml) { var mailMessage = new MailMessage { From = new MailAddress(from), Subject = subject, Body = body, IsBodyHtml = isBodyHtml }; mailMessage.To.Add(new MailAddress(to)); await SendMessageAsync(mailMessage); } public abstract Task SendMessageAsync(MailMessage mailMessage); }
You can extend on this idea by introducing other overloads (for example for accepting a collection of recipients, attachments, a separate message text body, and more. These overloads would then assign that data to the MailMessage instance and send that one to the abstract SendMessageAsync which is then implemented by your concrete mail sender(s).
The MailKit implementation can now inherit this base class, convert the MailMessage to a MimeMessage and send it off. Just like MailSender you saw before, the class can accept one or more sets of settings to connect to an SMTP server. In the sample app, MailKitMailSender accepts a collection of SmtpSettings but it only uses the first option and doesn't perform a retry. You can update the code with the alternative server logic I showed earlier if you like.
To send the message with MailKit, you can cast it to a MimeMessage which is a very convenient way as it requires no manual mapping. Then you can send that message using MailKit's SmtpClient as you saw earlier. If you don't like the cast, you could manually map from the incoming message to the expected MimeMessage. Later in this article you find an extension method called GetSendGridMessage that converts a MailMessage to a SendGridMessage; the implementation for the MimeMessage would look very similar. Here's the full code for the MailKitMailSender using the cast variation:
public class MailKitMailSender : MailSenderBase { private readonly SmtpSettings _emailSettings; public MailKitMailSender(IOptions<List<SmtpSettings>> emailSettings) { if (!emailSettings.Value.Any()) { throw new ArgumentException("Must specify at least one SMTP option."); } _emailSettings = emailSettings.Value.OrderBy(x => x.Priority).First(); } public override async Task SendMessageAsync(System.Net.Mail.MailMessage mailMessage) { var message = (MimeMessage)mailMessage; // Cast the MailMessage to a MimeMessage // which also changes all child objects using var client = new SmtpClient(); await client.ConnectAsync(_emailSettings.MailServer, _emailSettings.Port, _emailSettings.UseSsl); await client.AuthenticateAsync(_emailSettings.UserName, _emailSettings.Password); await client.SendAsync(message); await client.DisconnectAsync(true); } }
You can register the MailKitMailSender directly in Startup:
services.AddSingleton<IMailSenderV2, MailKitMailSender>();
Or register it with a nice extension method:
public static IServiceCollection AddMailKitSmtpServer(this IServiceCollection services) { services.AddSingleton<IMailSenderV2, MailKitMailSender>(); return services; }
With the MailKit implementation I can now fully replace SmtpClient in an existing code base. Depending on whether I previously used the synchronous version of IMailSender, I may have to update existing code to work with the async versions instead but that's normally relatively straightforward.
In the next and final section of this article I'll show yet another way to send e-mail: using SendGrid.
Sending e-mail with SendGrid
So far in this article I have presented solutions to send e-mail using an SMTP connection (and drop them on your local disk but that doesn't really qualify as sending ;-)). That works well in many cases, especially with low volume e-mail. However, in many cases it's advisable to use a third-party product to deliver the mail for you. The companies providing those products are specialized in handling the complexities of large volumes of e-mail, know how to deal with anti-spam measures like DMARC, SPF and DKIM, they can handle unsubscribes, offer bounce management and a lot more. There are many external e-mail services available that you can connect with including SendGrid, MailChimp, MailerLite, Mailgun and many others. In this article I'll discuss SendGrid as I've worked with it successfully many times in my applications. It has a nice API, great feature set and best of all: it offers a free account that you can use for development and testing as well as small production scenarios. But you should also take a look at the alternatives and pick the one that you like best.
Getting started with SendGrid is easy. Start by going to sendgrid.com and click Start for Free. That takes you to https://signup.sendgrid.com/. Follow the instructions to create an account. Once your e-mail address is confirmed and the account is created you'll be logged in to the SendGrid backend where you can manage your account, see statistics, bounces, your API keys and a lot more. For this article you only need to look at the API keys section and sender identity registration, but it's a good idea to explore their backend settings to see what else is there.
SendGrid has a great API that is documented very well. It allows you to do stuff like send e-mails, manage suppressions (such as bounces and blocks), and a lot more. It also comes with a very rich SDK that wraps the API which means you don't have to deal with the underlying API too much but instead use a .NET library. Sending an e-mail with the SendGrid API can be as simple as this:
public override async Task SendMessageAsync(MailMessage mailMessage) { var client = new SendGridClient(_apiKey); var msg = mailMessage.GetSendGridMessage(); await client.SendEmailAsync(msg); }
I'll show you how I configured my SendGrid account first, and then provide a quick tour of the code in the sample project:
- First, I logged in to the SendGrid backend at https://app.sendgrid.com/
- I then clicked Settings | API Keys | Create API Key
- Next, I configured a new API key with full Mail Send permissions and nothing else
- I then copied the key I was given and stored it in a safe place. You won't be able to request the key anymore after this initial step and you'll need to create a new one if you lose it
- Finally, under Settings | Sender Authentication I created a sender identity. For production scenarios you want to authenticate an entire domain, but for this demo it's enough to authorize a single e-mail address that can be used as the sender of your e-mails. Under Single Sender Verification I clicked Create address and filled in the required information. Once that was done, I received an e-mail on the address I specified which I then confirmed by clicking a link.
With the account set up correctly, I then implemented SendGrid in my project as follows:
-
Install the SendGrid package using the command:
Install-Package SendGrid
-
Create a new class to hold the settings for the SendGridMailSender:
public class SendGridSettings { public string ApiKey { get; private set; } }
For now, this class has an ApiKey property only so I could have skipped the step of a separate class and use the api key directly in the constructor as a string. However, by making it a class I can more easily add other configuration to SendGrid (such as how to log, headers to append to each method and so on).
-
I added an extension method called GetSendGridMessage to the project. This method converts an incoming MailMessage to a SendGridMessage, similar to what you saw earlier with MailKit (except that this code doesn't use a cast, but just creates a new instance of the required type.) This method came from this Github issue: https://github.com/sendgrid/sendgrid-csharp/issues/266 and I have changed it only slightly.
-
Next, I implemented SendGridMailSender as follows:
public class SendGridMailSender : MailSenderBase { private readonly string _apiKey; public SendGridMailSender(IOptions<SendGridSettings> sendGridSettings) { _apiKey = sendGridSettings.Value.ApiKey; } public override async Task SendMessageAsync(MailMessage mailMessage) { var client = new SendGridClient(_apiKey); var msg = mailMessage.GetSendGridMessage(); await client.SendEmailAsync(msg); } }
With this class inheriting from MailSenderBase and the extension method taking care of copying the MailMessage to a type that SendGrid understands, this implementation is now really straightforward. The constructor takes in an instance of IOptions<SendGridSettings>, grabs the API key and stores it in a private field. The SendMessageAsync method then uses the key to authenticate with SendGrid and deliver the message.
-
To wire this up with the DI container, I have another extension method:
public static IServiceCollection AddSendGridSmtpServer( this IServiceCollection services, IConfiguration configuration) { services.Configure<SendGridSettings>(options => configuration.GetSection("SendGridSettings") .Bind(options, c => c.BindNonPublicProperties = true)); services.AddSingleton<IMailSenderV2, SendGridMailSender>(); return services; }
Notice how this method combines the configuration using Configure and Bind as you saw earlier and then registers the SendGridMailSender as an IMailSenderV2 implementation. For that to work, I had to add the package Microsoft.Extensions.Configuration.Binder to my project.
Using the extension method in Startup is now as simple as this:
services.AddSendGridSmtpServer(Configuration);
-
The final step is adding the SendGrid API key to the settings file, like this:
"SendGridSettings": { "ApiKey": "YourKeyHere" },
My project is now ready to send mail using SendGrid. As soon as I hit the SendIt endpoint, I received an e-mail on the e-mail address specified. The e-mail also showed up in SendGrid:
Extending SendGrid with WebHooks
One feature of SendGrid that I like in particular is its Web Hooks API. This API can make HTTP calls into your APIs whenever an event that you are watching is triggered. This, for example, allows your application to be notified when messages are sent, when they bounce, when a user opens them and more. Here's an example of the data that is posted to your API whenever a message is sent:
{ "email": "imar@example.com", "event": "delivered", "ip": "137.69.1.128", "response": "250 2.0.0 OK 160414435 co16si6726edb.465 - gsmtp", "sg_event_id": "ZGVsaXZlcmVkLTAtMTDA5MjAtUN2Qm5amdZjZSDFDLXBTVENudy0w", "sg_message_id": "QSvBlyjgRf6IH1C-pSTCnw.filterdrecv-p3i2-64c98cc-jcjwt-20-5F9BA1-3C.0", "smtp-id": "<QSvBlyjf6H1C-pSCnw@ismtpd0lon1.sendgrid.net>", "timestamp": 1604144035, "tls": 1 }
What's cool about this is that you can tag additional information on the e-mail which then roundtrips and comes back to you in the web hook notification. Here's an example that adds an OrderId to the e-mail going out
var client = new SendGridClient(_apiKey);
var msg = mailMessage.GetSendGridMessage();
msg.CustomArgs = new Dictionary<string, string> { { "OrderId", "1234" } };
await client.SendEmailAsync(msg);
Note: Of course you would never hard code this in your MailSender but instead make it a parameter of SendMessageAsync so you can send it a dictionary that allows for multiple keys with whatever extra data you want to add. Then when you send the e-mail, you can, say, add the customer's ID and the ID of the order to a dictionary and pass that to SendMessageAsync:
var tags = new Dictionary<string, string> { { "CustomerId", customer.Id }, { "OrderId", order.Id } }; await _mailSender.SendMessageAsync("sender@example.com", "imar@example.com", "Your order confirmation", BuildBody(), tags);
Once you set this up, any event hook triggered for this e-mail message then includes the information you added:
{
"CustomerId": "34167",
"OrderId": "PO-493452",
"email": "imar@example.com",
"event": "delivered",
"ip": "50.11.29.22",
"response": "250 2.0.0 OK 1604144473 a61s9940edf.418 - gsmtp",
"sg_event_id": "ZGVsaXZlcmVkLTAtMTkxNDA5MjQS2Raa1VUa0NtUmxrbC15WEdJZy0w",
"sg_message_id": "-FPKdZkRlkl-yXGIg.filterdrecv-p3s1-59c9db66-pv78x-20-5F9D7-36.0",
"smtp-id": "<-FPKdZkURlkl-yXGIg@ismtpd00lon1.sendgrid.net>",
"timestamp": 1604144474,
"tls": 1
}
Then in your code that handles the web hook calls you can pull out the customer and order IDs (or whatever data you sent along with the e-mail message) and use that in your application to figure out what to do next.
The SendGrid web hooks API comes with a handy tool to configure and test your hooks. More information can be found here: here
Other tools
In addition to the option to drop your e-mails on the local disk using AlwaysDropMailOnLocalDiskMailSender or redirect them to a different e-mail address using InternalMailSender there are other solutions out there that can help manage your e-mail and prevent them from accidentally being sent to end-users. Two tools I worked with in the past I want to call out it:
- Smtp4Dev - A dummy SMTP server for Windows, Linux, Mac OS-X. It act as an SMTP server and intercepts your outgoing emails which can then be viewed and inspected.
- Mailtrap - Similar to Smtp4dev in that it is a fake SMTP server for development teams to test, view and share emails sent from the development and staging environments without spamming real customers. This one runs in the cloud instead of on someone's local development machine.
Wrapping Up
With the SendGrid implementation and its web hooks feature, I've come to the end of this article on improving your e-mail capabilities in your ASP.NET Core applications. You saw how to change some "fire and forget" code directly in a controller that is hard to configure, maintain and test to a far more maintainable solution by hiding the functionality behind an interface. You then saw some concrete implementations to send e-mail such as with SmtpClient (which Microsoft now considers obsolete), MailKit (the new recommended solution) and SendGrid (a scalable, cloud-based web API to send e-mails.)
Happy e-mailing!
Downloads
Where to Next?
Wonder where to go next? You can post a comment on this article.
Links in this Document
Doc ID | 614 |
Full URL | https://imar.spaanjaars.com/614/improving-your-aspnet-core-sites-e-mailing-capabilities |
Short cut | https://imar.spaanjaars.com/614/ |
Written by | Imar Spaanjaars |
Date Posted | 11/24/2020 08:43 |
Comments
Talk Back! Comment on Imar.Spaanjaars.Com
I am interested in what you have to say about this article. Feel free to post any comments, remarks or questions you may have about this article. The Talk Back feature is not meant for technical questions that are not directly related to this article. So, a post like "Hey, can you tell me how I can upload files to a MySQL database in PHP?" is likely to be removed. Also spam and unrealistic job offers will be deleted immediately.
When you post a comment, you have to provide your name and the comment. Your e-mail address is optional and you only need to provide it if you want me to contact you. It will not be displayed along with your comment. I got sick and tired of the comment spam I was receiving, so I have protected this page with a simple calculation exercise. This means that if you want to leave a comment, you'll need to complete the calculation before you hit the Post Comment button.
If you want to object to a comment made by another visitor, be sure to contact me and I'll look into it ASAP. Don't forget to mention the page link, or the Doc ID of the document.