Introduction
I'm currently developing a web application that uses email templates at work. The email templates are stored in the database.
All templates require two types of information to fill in the blanks:
- Information about specific database entities
- URL generated from the current domain, information from the above entities, etc.
However, not each template requires exactly the same information.
I started by writing a method for each template to extract the required information and interpolate it in the template, but 2ใproblems arose:
- as the number of template grew, the number of method also grew.
- the templates can be edited but only information extracted by the method associated with the template can be filled in.
The solution
Use reflection with a dictionary structure to map template fields to entity properties or URL generation methods.
Implementation
Email Template
The email template class stores the templates of the subject of the email and its body separately, but the mapping info for both is stored in one JSON dictionary:
using System.ComponentModel.DataAnnotations;
namespace MyProject.Data.Models
{
public class EmailTemplate
{
[Key]
public string Id { get; set; }
[Required]
public string Subject{ get; set; }
[Required]
public string Body{ get; set; }
[Required]
public string InterpolationInfo { get; set; }
}
}
Templates
The "blanks" in the email template are strings enclosed in curly brackets like {EntityId}
. To fill in the "blanks", we need to replace them in both the subject and body interpolation strings.
Subject template
[{EntityId}] New information concerning {EntityName} is available!
Body template
Dear {Title} {Username}
To see more information about this entity please go to:
[Entity] {EntityURL}
Sincerely.
-------------------------------------------------
[General] {GeneralURL}
Dedicated Method
This is the way the method associated to the email template was written before the refactoring:
using MailKit.Net.Smtp;
using MimeKit;
public async Task SendSpecificTemplateEmailAsync(string baseUri, EntityType Entity)
{
using (var client = new SmtpClient())
{
try
{
EmailTemplate template = _context.EmailTemplates.Where(template => template.Id == "specific-template").First();
string? Subject = template.Subject;
string? Body = template.Body;
if (Subject != null && Body != null)
{
if (baseUri.EndsWith("/"))
{
baseUri = baseUri.TrimEnd('/');
}
var emailMessage = new MimeMessage();
AuthenticationState authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
ClaimsPrincipal authUser = authState.User;
ApplicationUser user = this._context.Users.Where(user => user.Email == authUser.Identity.Name).First();
emailMessage.From.Add(new MailboxAddress(user.UserName, user.Email));
emailMessage.To.Add(new MailboxAddress(Entity.RecipientName, Entity.RecipientEmail));
Subject= Subject.Replace('{EntityId}', Entity.Id.ToString());
Subject= Subject.Replace('{EntityName}', Entity.Name);
emailMessage.Subject = Subject;
Body = Body.Replace('{Title}', Entity.Title);
Body = Body.Replace('{Username}', Entity.Username);
Body = Body.Replace('{EntityURL}', $"{baseUri}entity/{Entity.Id.ToString()}");
Body = Body.Replace('{GeneralURL}', $"{baseUri}");
emailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Text) { Text = Body };
client.Connect(host: _host, port: _port, useSsl: false);
client.Send(emailMessage);
}
else
{
throw new ArgumentNullException();
}
}
catch (Exception e)
{
System.Diagnostics.Debug.Print(e.ToString());
throw e;
}
finally
{
client.Disconnect(true);
client.Dispose();
}
}
}
Mapping Dictionary
The mapping information used for interpolation is stored as a JSON dictionary:
{
"Subject": {
"EntityId": "Id",
"EntityName": "Name",
},
"Body": {
"Title": "Title",
"Username": "Username",
"EntityURL": "@GetEntityUrl"
"GeneralURL": "@GetGeneralUrl"
}
}
We will parse the JSON data using Newtonsoft.Json's JObject.Parse()
and get the interpolation mappings for both subject and body:
using Newtonsoft.Json.Linq;
dynamic jsonDict = JObject.Parse(template.InterpolationInfo);
dynamic subjectDict = jsonDict.Subject;
dynamic bodyDict = jsonDict.Body;
Then we use loop on the (key,value)
pairs to extract the string to replace and the value to replace it with:
foreach (JProperty pair in subjectDict)
{
string placeholder = $"{{{pair.Name.Trim()}}}";
if (string.IsNullOrEmpty(placeholder))
{
throw new ArgumentNullException();
}
else
{
string propertyName = pair.Value.ToString();
}
}
Properties
For properties of the entity, reflection is required to retrieve property values using a string. Then a simple call to Replace()
on the template string does the trick:
var property = typeof(entityType).GetProperty(propertyName);
var replacement = property.GetValue(Entity).ToString();
Subject = Subject.Replace(placeholder, replacement);
Generated URLs
For generated URLs, I decided to use methods added on my email sending service and to put a @
at the start of method names to differentiate them from the entity properties:
string memberName = propertyName.Substring(1);
var replacement = typeof(EmailService).InvokeMember(memberName, BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.NonPublic, null, this, [baseUri, Entity]).ToString();
Subject = Subject.Replace(placeholder, replacement);
All these URL generating methods need to have the same parameters like below:
private string GetSpecificEntityURL(string baseUri, EntityType Entity)
{
Uri entityUri;
bool created = Uri.TryCreate(new Uri(baseUri), $"entity/{Entity.Id.ToString()}", out entityUri);
if (created == false || entityUri == null)
{
throw new Exception("URL NotGenerated");
}
return entityUri.AbsoluteUri;
}
Refactored Method
Putting the new method all together:
public async Task SendTemplatedEmailAsync(string templateId, string baseUri, EntityType Entity)
{
using (var client = new SmtpClient())
{
try
{
EmailTemplate template = _context.EmailTemplates.Where(template => template.Id == templateId).First();
string? Subject = template.Subject;
string? Body = template.Body;
if (Subject != null && Body != null)
{
if (baseUri.EndsWith("/"))
{
baseUri = baseUri.TrimEnd('/');
}
var emailMessage = new MimeMessage();
AuthenticationState authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
ClaimsPrincipal authUser = authState.User;
ApplicationUser user = this._context.Users.Where(user => user.Email == authUser.Identity.Name).First();
emailMessage.From.Add(new MailboxAddress(user.UserName, user.Email));
emailMessage.To.Add(new MailboxAddress(Entity.Manager, Entity.ManagerEmail));
dynamic jsonDict = JObject.Parse(template.InterpolationInfo);
// Subject
dynamic subjectDict = jsonDict.Subject;
foreach (JProperty pair in subjectDict)
{
string placeholder = $"{{{pair.Name.Trim()}}}";
if (string.IsNullOrEmpty(placeholder))
{
throw new ArgumentNullException();
}
else
{
string propertyName = pair.Value.ToString();
if (propertyName.StartsWith('@'))
{
string memberName = propertyName.Substring(1);
var replacement = typeof(EmailService).InvokeMember(memberName, BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.NonPublic, null, this, [baseUri, Entity]).ToString();
Subject = Subject.Replace(placeholder, replacement);
}
else
{
var property = typeof(EntityType).GetProperty(propertyName);
var replacement = property.GetValue(Entity).ToString();
Subject = Subject.Replace(placeholder, replacement);
}
}
}
emailMessage.Subject = Subject;
// Body
dynamic bodyDict = jsonDict.Body;
foreach (JProperty pair in bodyDict)
{
string placeholder = $"{{{pair.Name.Trim()}}}";
if (string.IsNullOrEmpty(placeholder))
{
throw new ArgumentNullException();
}
else
{
string propertyName = pair.Value.ToString();
if (propertyName.StartsWith('@'))
{
string memberName = propertyName.Substring(1);
var replacement = typeof(EmailService).InvokeMember(memberName, BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.NonPublic, null, this, [baseUri, Entity]).ToString();
Body = Body.Replace(placeholder, replacement);
}
else
{
var property = typeof(EntityType).GetProperty(propertyName);
var replacement = property.GetValue(Entity).ToString();
Body = Body.Replace(placeholder, replacement);
}
}
}
emailMessage.Body = new TextPart(MimeKit.Text.TextFormat.Text) { Text = Body };
client.Connect(host: _host, port: _port, useSsl: false);
client.Send(emailMessage);
}
else
{
throw new ArgumentNullException();
}
}
catch (Exception e)
{
System.Diagnostics.Debug.Print(e.ToString());
throw e;
}
finally
{
client.Disconnect(true);
client.Dispose();
}
}
}
The dedicated method can be summed up to one call of our new method:
public async Task SendRequestCreationCompletedEmailAsync(string baseUri, EntityType Entity)
{
await SendTemplatedEmailAsync("specific-template", baseUri, Entity);
}
Finally, the URL generating methods:
private string GetEntityUrl(string baseUri, EntityType Entity)
{
Uri entityUri;
bool created = Uri.TryCreate(new Uri(baseUri), $"entity/{Entity.Id.ToString()}", out entityUri);
if (created == false || entityUri == null)
{
throw new Exception("URL NotGenerated");
}
return entityUri.AbsoluteUri;
}
private string GetGeneralUrl(string baseUri, EntityType Entity)
{
Uri generalUri = new Uri(baseUri);
return generalUri.AbsoluteUri;
}
Top comments (0)