Extending the EmailSender to Include Send Admin Email
Before we get to the EmailSender let’s talk about the who, what, when and where an admin email should consider. In the process of creating this article I discovered and corrected a couple of violations with my own admin emails.
Who
The who is usually a user name if a user is logged in or unknown if not. If you use a user name you risk violating the GDPR personal data rules. If you use the id of the user record and the record is deleted when the user requests to be forgotten it is no longer personal data. If you are using Identity in ASP.NET Core you can easily get the user record id with the UserManager.
var uid = _userManager.GetUserId(User); if (uid == null) { uid = "Unknown"; }
Or you can use System.Security.Claims.
var uid = "Unknown"; if (User.Identity.IsAuthenticated) { uid = User.FindFirst(ClaimTypes.NameIdentifier).Value; }
The browser’s User-Agent is not personal data.
string uaString = HttpContext.Request.Headers["User-Agent"].ToString();
The IP address is considered personal data. There are plenty of articles about IP anonymization for Google Analytics but not many for IP logging. Google Analytics anonymizes IP addresses by zeroing the last octant for IPv4 and last 5 hextets or doublets (80 bits) for IPv6. I have not seen many IPv6 addresses but we need to deal with it.
Section references:
We can get the IP address from HttpContext.Connection.RemoteIpAddress and I have created an extension to anonymize it.
What and When
Depends on the action the admin wants to be notified about. It could be as simple as a notice of a new registered user. The most important notice is when an error occurs. In a new ASP.NET Core web application the Startup.cs has app.UseExceptionHandler("/Error"); which redirects unhandled exceptions to an error page in the main Pages folder. You can get status codes from the error page if you add app.UseStatusCodePagesWithReExecute("/error/{0}"); to Startup.cs, the (int? statusCode = null) parameter to OnGet method in Error.cshtml.cs and "{statusCode?}" to the page directive in Error.cshtml. I thought I was sending an email with the exception details using var exception = HttpContext.Features.Get<IExceptionHandlerFeature>(); until I had a data foreign key issue which was throwing an exception and displaying the Error page but not sending the admin email. To capture exception details I had to send the admin email from exception handling middleware which will be subject of my next article.
Where
For a simple notice you can use Request.Path. To get the path where an error occurred you can use var feature = HttpContext.Features.Get<IStatusCodeReExecuteFeature>(); then feature?.OriginalPath in the OnGet of Error.cshtml.cs.
EmailSender
This article extends my previous article ASP.NET Core 2.2 - SMTP EmailSender Implementation. I use plain text for my admin email, but you can modify the method if you want html. The new method uses a new SysAdminEmail in EmailSettings.
Edit appsettings.json and appsettings.Development.json, add the new SysAdminEmail EmailSetting.
"EmailSettings": { "MailServer": "smtp.some_server.com", "MailPort": 587, "SenderName": "some name", "Sender": "some_email@some_server.com", "Password": "some_password", "BannerBackcolor": "yellow", "BannerColor": "orange", "BannerText": "MyWebsite.Com", "EmailSignature": "Support", "SysAdminEmail": "admin@some_server.com" }
Add the new SysAdminEmail property to the EmailSettings class in the Entities folder.
public class EmailSettings { public string MailServer { get; set; } public int MailPort { get; set; } public string SenderName { get; set; } public string Sender { get; set; } public string Password { get; set; } public string BannerBackcolor { get; set; } public string BannerColor { get; set; } public string BannerText { get; set; } public string EmailSignature { get; set; } public string SysAdminEmail { get; set; } }
Add the AnonymizeIpAddressExtention to the Services folder.
public static class AnonymizeIpAddressExtention { //IPV4_NETMASK = "255.255.255.0"; //IPV6_NETMASK = "ffff:ffff:ffff:0000:0000:0000:0000:0000"; 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; } }
Edit EmailSender.cs, add the signature to IEmailSender.
Task SendAdminEmailAsync(string subject, string textMessage, string uaString, string ipAnonymizedString, string userId);
Add the SendAdminEmailAsync method to EmailSender.
public async Task SendAdminEmailAsync(string subject, string textMessage, string uaString, string ipAnonymizedString, string userId) { StringBuilder sb = new StringBuilder(); sb.Append($"User Agent = {uaString} \r\n"); sb.Append($"Anonymized IP = {ipAnonymizedString} \r\n"); sb.Append($"User Id = {userId} \r\n \r\n"); sb.Append(textMessage); try { var mimeMessage = new MimeMessage(); mimeMessage.From.Add(new MailboxAddress(_emailSettings.Sender)); mimeMessage.To.Add(new MailboxAddress(_emailSettings.SysAdminEmail)); mimeMessage.Subject = subject; mimeMessage.Body = new TextPart("plain") { Text = sb.ToString() }; using (var client = new SmtpClient()) { // For demo-purposes, accept all SSL certificates (in case the server supports STARTTLS) client.ServerCertificateValidationCallback = (s, c, h, e) => true; if (_env.IsDevelopment()) { await client.ConnectAsync(_emailSettings.MailServer, _emailSettings.MailPort, true); } else { await client.ConnectAsync(_emailSettings.MailServer); } // Note: only needed if the SMTP server requires authentication await client.AuthenticateAsync(_emailSettings.Sender, _emailSettings.Password); await client.SendAsync(mimeMessage); await client.DisconnectAsync(true); } } catch (Exception ex) { // TODO: handle exception throw new InvalidOperationException(ex.Message); } }
In the ASP.NET Core 2.2 - SMTP EmailSender Implementation article we setup a test from a razor page and then updated the test for text and html messages. I removed the Email input and model property because we get the To email from EmailSettings.
Add this label and form to Index.cshtml.
<div class="row"> <div class="col-6"> <label class="alert alert-success">@Model.EmailStatusMessage</label> </div> </div> <div class="row"> <div class="col-6"> <form method="post"> <button type="submit" class="btn btn-primary">Email Test</button> </form> </div> </div>
Edit Index.cshtml.cs.
public class IndexModel : PageModel { private readonly IEmailSender _emailSender; public IndexModel(IEmailSender emailSender) { _emailSender = emailSender; } public string EmailStatusMessage { get; set; } public void OnGet() { } public async TaskOnPostAsync() { if (!ModelState.IsValid) { return Page(); } string uaString = HttpContext.Request.Headers["User-Agent"].ToString(); string ipAnonymizedString = HttpContext.Connection.RemoteIpAddress.AnonymizeIP(); var uid = "Unknown"; if (User.Identity.IsAuthenticated) { uid = User.FindFirst(ClaimTypes.NameIdentifier).Value; } var subject = "Admin Email Test"; var textMessage = "This is the text message."; await _emailSender.SendAdminEmailAsync(subject, textMessage, uaString, ipAnonymizedString, uid); EmailStatusMessage = "Send new admin email was successful."; return Page(); } }
The result of my test.
User Agent = Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.109 Safari/537.36 Anonymized IP = ::1 User Id = Unknown This is the text message.
Notice the AnonymizeIpAddressExtention handled the IPv6 loopback IP (::1).
Comments(0)