ASP.NET Core 3.1 - SMTP EmailSender

Ken Haggerty
Created 04/23/2020 - Updated 09/02/2021 01:47

This article will demonstrate the implementation of a dependency injected SMTP EmailSender. I will assume you have downloaded the FREE ASP.NET Core 3.1 - FIDO Utilities Project or created a new ASP.NET Core 3.1 Razor Pages project. I won't use Identity or Individual User Accounts. See Tutorial: Get started with Razor Pages in ASP.NET Core.

FIDO Utilities Project and Article Series

The ASP.NET Core 3.1 - FIDO Utilities Project (FUP) is a collection of utilities I use in the ASP.NET Core 5.0 - Users Without Passwords Project (UWPP). I have upgraded the FUP with Bootstrap v5 and Bootstrap Native v4. FUP version 2.1 features SingleUser Authentication, Admin Page with Send Email Tests, ExceptionEmailerMiddleware, Bootstrap v5 Detection JavaScript, Copy and Paste Demo, Offcanvas Partial Demo, and Path QR Code Demo. The SMTP Settings Tester is updated and now located in the authorized Pages > Admin folder. The EmailSender and AES Cipher Demo are also updated. Registered users can download the source code for free on KenHaggerty. Com at Manage > Assets. The UWPP is published at Fido. KenHaggerty. Com.

Update 09/01/2021

I developed a new EmailSender for the ASP.NET Core 5.0 - SMTP Settings Tester Project which implements a ServerCertificateValidationCallback delegate, new MailKit. Net. Smtp client asynchronous methods, and a MailKit. Security. SecureSocketOptions. Auto option which allows the MailKit. IMailService decide which SSL or TLS options to use (default). If the server does not support SSL or TLS, then the connection will continue without any encryption. See ASP.NET Core 5.0 - EmailSender Service. I updated the EmailSettings, EmailSender, and the SMTP Settings Tester in the FUP. The article has been updated with the new EmailSettings and EmailSender.

The FIDO processes involve a lot of things that can go wrong and I would like to know when it does. A notice of something going right is also nice. By far, my most popular article is ASP.NET Core 2.2 - SMTP EmailSender Implementation. I have added a SendAdminEmail function by adding an AdminEmail setting and injecting IHttpContextAccessor. This allows access to the current HttpContext properties like UserAgent, Anonymized IP, Path and QueryString inside the service.

Install the MailKit NuGet package. Right click the project then click Manage NuGet Packages. Search for MailKit on the Browse tab.

Install MailKit NuGet Package

The EmailSettings get populated from appsettings. json. The environment and User Secrets features of appsettings. json is beyond the scope of this article. See ASP.NET Core 5.0 - User Secrets. Care should be taken to ensure email credentials and settings are not compromised. The EmailSettings are injected to the EmailSender. Add an Models folder to the project root and add an EmailSettings class to Models.

Models > EmailSettings.cs
public class EmailSettings
{
    public bool Configured { get; set; }
    public int Timeout { get; set; }
    public string MailServer { get; set; }
    public int MailPort { get; set; }
    public string SenderName { get; set; }
    public string SenderEmail { get; set; }
    public string Password { get; set; }
    public bool PasswordEncrypted { get; set; }
    public string BannerBackcolor { get; set; }
    public string BannerColor { get; set; }
    public string BannerText { get; set; }
    public string EmailSignature { get; set; }
    public string SupportName { get; set; }
    public string SupportEmail { get; set; }
    public string AdminEmail { get; set; }
}

Edit appsettings.json, add an EmailSettings section with your email server settings.

Production Example
"EmailSettings": {
  "Configured": false,
  "Timeout": 30000, // default 120000 = 2 minutes
  "MailServer": "mail.YourDomain.com",
  "MailPort": 8889,
  "SenderName": "Your Domain Support",
  "SenderEmail": "support@YourDomain.com",
  "Password": "EncryptedPassword",
  "PasswordEncrypted": true,
  "BannerBackcolor": "#FFFF00",
  "BannerColor": "#FFA500",
  "BannerText": "Your Domain",
  "EmailSignature": "Support",
  "SupportName": "Support",
  "SupportEmail": "support@YourDomain.com",
  "AdminEmail": "admin@YourDomain.com"
}
Gmail Example
"EmailSettings": {
  "Configured": false,
  "Timeout": 30000, // default = 120000 = 2 minutes
  "MailServer": "smtp.gmail.com",
  "MailPort": 465,
  "SenderName": "Sender Name",
  "SenderEmail": "senderemail@gmail.com",
  "Password": "GmailPassword",
  "PasswordEncrypted": false,
  "BannerBackcolor": "#00C89F",
  "BannerColor": "#285F8C",
  "BannerText": "SMTP Settings Tester",
  "EmailSignature": "Email Signature",
  "SupportName": "Support Name",
  "SupportEmail": "supportemail@gmail.com",
  "AdminEmail": "adminemail@gmail.com"
}
If you want to use Gmail for SMTP, you must allow less secure app access to the Google account.

I inject the IHttpContextAccessor to the EmailSender to allow the SendAdminEmail function access to the current HttpContext properties like UserAgent, Anonymized IP, Path and QueryString inside the service. I parse the user's IP address, but I anonymize the IP before storing or emailing. The FIDO Utilities Project implements the SMTP Settings Tester and the SendAdminEmail function on the Error page.

Create a new folder in the project root named Services. Add a new class named AnonymizeIpAddressExtention.cs to Services.

AnonymizeIpAddressExtention.cs
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.

public static class AnonymizeIpAddressExtention
{
    //const string IPV4_NETMASK = "255.255.255.0";
    //const string IPV6_NETMASK = "ffff:ffff:ffff:0000:0000:0000:0000:0000";

    /// <summary>
    /// Removes the unique part of an <see cref="IPAddress" />.
    /// </summary>
    /// <param name="ipAddress"></param>
    /// <returns><see cref="string" /></returns>
    public static string AnonymizeIP(this IPAddress ipAddress)
    {
        string ipAnonymizedString;
        if (ipAddress != null)
        {
            if (ipAddress.AddressFamily == AddressFamily.InterNetwork)
            {
                var ipString = ipAddress.ToString();
                string[] octets = ipString.Split('.');
                octets[3] = "0";
                ipAnonymizedString = string.Join(".", octets);
            }
            else if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6)
            {
                var ipString = ipAddress.ToString();
                string[] hextets = ipString.Split(':');
                var hl = hextets.Length;
                if (hl > 3) { for (var i = 3; i < hl; i++) { if (hextets[i].Length > 0) { hextets[i] = "0"; } } }
                ipAnonymizedString = string.Join(":", hextets);
            }
            else { ipAnonymizedString = $"Not Valid - {ipAddress.ToString()}"; }
        }
        else { ipAnonymizedString = "Is Null"; }

        return ipAnonymizedString;
    }
}

Add a new class named EmailSender.cs to Services.

EmailSender.cs
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.

/// <summary>
/// Interface for the EmailSender service using <paramref name="mailSettings" /> and
/// <paramref name="httpContextAccessor" /> to send emails.
/// </summary>
/// <param name="httpContextAccessor">Injected IHttpContextAccessor.</param>
/// <param name="emailSettings">Injected EmailSettings.</param>
/// <remarks>
/// Uses <see cref="HttpContextAccessor" /> to extract current context properties to include
///  when an email is sent to the <see cref="EmailSettings.AdminEmail" /> address.
/// </remarks>
public interface IEmailSender
{
    /// <summary>
    /// Sends an email to the <paramref name="email" /> address with <paramref name="subject" />,
    /// <paramref name="textMessage" /> and <paramref name="htmlMessage" />.
    /// </summary>
    /// <param name="email">The send to email address.</param>
    /// <param name="subject">The email's subject.</param>
    /// <param name="textMessage">The email's MimeKit.BodyBuilder.TextBody.</param>
    /// <param name="htmlMessage">The email's MimeKit.BodyBuilder.HtmlBody.</param>
    /// <exception cref="ArgumentException"></exception>
    /// <exception cref="ArgumentNullException"></exception>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    /// <exception cref="FormatException"></exception>
    /// <exception cref="InvalidOperationException"></exception>
    /// <exception cref="IOException"></exception>
    /// <exception cref="OverflowException"></exception>
    /// <exception cref="OutOfMemoryException"></exception>
    /// <exception cref="ParseException"></exception>
    Task SendEmailAsync(string email, string subject, string textMessage, string htmlMessage);

    /// <summary>
    /// Sends an email to the address set with the <see cref="EmailSettings.AdminEmail" /> property
    /// with <paramref name="subject" /> and <paramref name="textMessage" /> including HttpContext properties.
    /// </summary>
    /// <param name="subject">The email's subject.</param>
    /// <param name="textMessage">The email's MimeMessage.Body.TextPart.</param>
    /// <remarks>
    /// Uses <see cref="HttpContextAccessor" /> to extract current context properties to include when an email sent
    /// to the <see cref="EmailSettings.AdminEmail" /> address.
    /// </remarks>
    /// <exception cref="ArgumentException"></exception>
    /// <exception cref="ArgumentNullException"></exception>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    /// <exception cref="FormatException"></exception>
    /// <exception cref="IOException"></exception>
    /// <exception cref="InvalidOperationException"></exception>
    /// <exception cref="OverflowException"></exception>
    /// <exception cref="OutOfMemoryException"></exception>
    /// <exception cref="ParseException"></exception>
    Task SendAdminEmailAsync(string subject, string textMessage);
}

public class EmailSender : IEmailSender
{
    private readonly EmailSettings _emailSettings;
    private readonly IHttpContextAccessor _httpContextAccessor;
    public EmailSender(IOptions<EmailSettings> emailSettings, IHttpContextAccessor httpContextAccessor)
    {
        _emailSettings = emailSettings?.Value;
        _httpContextAccessor = httpContextAccessor;
    }

    public async Task SendEmailAsync(string email, string subject, string textMessage, string htmlMessage)
    {
        if (!_emailSettings.Configured)
            throw new InvalidOperationException("The EmailSettings' Configured property is not true.");

        MimeMessage mimeMessage = new MimeMessage();
        mimeMessage.From.Add(new MailboxAddress(_emailSettings.SenderName, _emailSettings.SenderEmail));
        mimeMessage.To.Add(MailboxAddress.Parse(email));

        mimeMessage.Subject = subject;
        BodyBuilder bodyBuilder = new BodyBuilder()
        {
            TextBody = textMessage,
            HtmlBody = GetEmailTemplate(subject, htmlMessage)
        };

        mimeMessage.Body = bodyBuilder.ToMessageBody();

        var password = _emailSettings.Password;
        if (_emailSettings.PasswordEncrypted)
            password = AesCrypto.DecryptString(AesCrypto.CipherKey, AesCrypto.CipherIV, password);

        try
        {
            using SmtpClient client = new SmtpClient();
            client.ServerCertificateValidationCallback = SslCertificateValidationCallback;
            client.Timeout = _emailSettings.Timeout;
            await client.ConnectAsync(_emailSettings.MailServer, _emailSettings.MailPort, SecureSocketOptions.Auto).ConfigureAwait(false);
            await client.AuthenticateAsync(_emailSettings.SenderEmail, password).ConfigureAwait(false);
            await client.SendAsync(mimeMessage).ConfigureAwait(false);
            await client.DisconnectAsync(true).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException(ex.Message, ex);
        }
    }

    public async Task SendAdminEmailAsync(string subject, string textMessage)
    {
        if (!_emailSettings.Configured)
            throw new InvalidOperationException("The EmailSettings' Configured property is not true.");

        var httpContext = _httpContextAccessor.HttpContext;
        var host = httpContext.Request.Host.ToString();
        var uaString = httpContext.Request.Headers["User-Agent"].ToString();
        var ipAnonymizedString = httpContext.Connection.RemoteIpAddress.AnonymizeIP();
        var uid = httpContext.User.Identity.IsAuthenticated
            ? httpContext.User.FindFirst(ClaimTypes.NameIdentifier).Value
            : "Unknown";
        var path = httpContext.Request.Path;
        var queryString = httpContext.Request.QueryString;

        StringBuilder stringBuilder = new StringBuilder($"Host = {host} \r\n");
        stringBuilder.Append($"User Agent = {uaString} \r\n");
        stringBuilder.Append($"Anonymized IP = {ipAnonymizedString} \r\n");
        stringBuilder.Append($"UserId = {uid} \r\n");
        stringBuilder.Append($"Path = {path} \r\n");
        stringBuilder.Append($"QueryString = {queryString} \r\n \r\n");
        stringBuilder.Append(textMessage);

        MimeMessage mimeMessage = new MimeMessage();
        mimeMessage.From.Add(new MailboxAddress(_emailSettings.SenderName, _emailSettings.SenderEmail));
        mimeMessage.To.Add(MailboxAddress.Parse(_emailSettings.AdminEmail));

        mimeMessage.Subject = subject;
        mimeMessage.Body = new TextPart("plain") { Text = stringBuilder.ToString() };

        var password = _emailSettings.Password;
        if (_emailSettings.PasswordEncrypted)
            password = AesCrypto.DecryptString(AesCrypto.CipherKey, AesCrypto.CipherIV, password);

        try
        {
            using var client = new SmtpClient();
            client.ServerCertificateValidationCallback = SslCertificateValidationCallback;
            client.Timeout = _emailSettings.Timeout;
            await client.ConnectAsync(_emailSettings.MailServer, _emailSettings.MailPort, SecureSocketOptions.Auto).ConfigureAwait(false);
            await client.AuthenticateAsync(_emailSettings.SenderEmail, password).ConfigureAwait(false);
            await client.SendAsync(mimeMessage).ConfigureAwait(false);
            await client.DisconnectAsync(true).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException(ex.Message, ex);
        }
    }


    private string GetEmailTemplate(string subject, string message)
    {
        StringBuilder stringBuilder = new StringBuilder(@@$"<!DOCTYPE html>
<html>
<head>
    <meta charset="" utf-8"" />
    <title>{subject}</title>
</head>
<body style="" font-family: Arial, Helvetica, sans-serif;"">
    <table border="" 0"" cellspacing="" 0"" width="" 100%"">
        <tr>
            <td></td>
            <td width="" 700"">
                <div style="" border: 2px solid {_emailSettings.BannerColor}; background-color: {_emailSettings.BannerBackcolor};
                     color: {_emailSettings.BannerColor}; width: 90%; margin: 0 auto;"">
                    <div style="" padding: 5px 0; width: 100%; margin: 0 auto; text-align:center; font-size: 28px;"">
                        {_emailSettings.BannerText}
                    </div>
                </div>
                <div style="" padding: 10px 10px; width: 90%; margin: 0 auto; font-size: 18px;"">
                    <h3>{subject}</h3>
                    <p>
                        {message}
                    </p>
                    <p>
                        Thank you,<br />
                        {_emailSettings.EmailSignature}
                    </p>
                    <p>
                        This is an automated message.
                    </p>
                </div>
                <div style="" background-color: {_emailSettings.BannerBackcolor}; color: {_emailSettings.BannerColor}; padding: 10px 10px; width: 90%; margin: 0 auto; bottom: 10px;"">
                    © {DateTime.Now.Year}
                </div>
            </td>
            <td></td>
        </tr>
    </table>
</body>
</html>");
        return stringBuilder.ToString();
    }

    static bool SslCertificateValidationCallback(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
    {
        // If there are no errors, then everything went smoothly.
        if (sslPolicyErrors == SslPolicyErrors.None)
            return true;

        // Note: MailKit will always pass the host name string as the `sender` argument.
        var host = (string)sender;

        if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNotAvailable) != 0)
        {
            // This means that the remote certificate is unavailable. Notify the user and return false.
            Console.WriteLine("The SSL certificate was not available for {0}", host);
            return false;
        }

        if ((sslPolicyErrors & SslPolicyErrors.RemoteCertificateNameMismatch) != 0)
        {
            // This means that the server's SSL certificate did not match the host name that we are trying to connect to.
            var certificate2 = certificate as X509Certificate2;
            var cn = certificate2 != null ? certificate2.GetNameInfo(X509NameType.SimpleName, false) : certificate.Subject;

            Console.WriteLine("The Common Name for the SSL certificate did not match {0}. Instead, it was {1}.", host, cn);
            return false;
        }

        // The only other errors left are chain errors.
        Console.WriteLine("The SSL certificate for the server could not be validated for the following reasons:");

        // The first element's certificate will be the server's SSL certificate (and will match the `certificate` argument)
        // while the last element in the chain will typically either be the Root Certificate Authority's certificate -or- it
        // will be a non-authoritative self-signed certificate that the server admin created. 
        foreach (var element in chain.ChainElements)
        {
            // Each element in the chain will have its own status list. If the status list is empty, it means that the
            // certificate itself did not contain any errors.
            if (element.ChainElementStatus.Length == 0)
                continue;

            Console.WriteLine("\u2022 {0}", element.Certificate.Subject);
            foreach (var error in element.ChainElementStatus)
            {
                // `error.StatusInformation` contains a human-readable error string while `error.Status` is the corresponding enum value.
                Console.WriteLine("\t\u2022 {0}", error.StatusInformation);
            }
        }
        return false;
    }
}

Resolve using namespaces. Be sure to reference MailKit. Net. Smtp. SmtpClient not System. Net. Mail. SmtpClient. Add the IHttpContextAccessor, EmailSettings and EmailSender to the service container.

Startup.cs > ConfigureServices
services.AddHttpContextAccessor();
services.Configure<EmailSettings>(Configuration.GetSection("EmailSettings"));
services.AddSingleton<IEmailSender, EmailSender>();

To send an email to the admin email address from code, inject the EmailSender to the PageModel then invoke SendAdminEmailAsync.

Index.cshtml.cs
public class IndexModel : PageModel
{
    private readonly IEmailSender _emailSender;
    public IndexModel(IEmailSender emailSender)
    {
        _emailSender = emailSender;
    }

    public async Task<IActionResult> OnGetAsync()
    {
        await _emailSender
            .SendAdminEmailAsync("New Visitor", "New visitor notice OnGetAsync.")
            .ConfigureAwait(false);

        return Page();
    }
}

The free FIDO Utilities Project implements the EmailSender and an SMTP Settings Tester which allows you to test the EmailSettings in the runtime settings, appsettings. json, and appsettings. development. json. See ASP.NET Core 5.0 - SMTP Settings Tester.

SMTP Settings Tester UserSecrets
SMTP Settings Tester Development

Article Tags:

Email FIDO Model

Comment Count = 0

Please log in to comment.

Login Register
Logged in users receive web notifications.
Web Notifications