Blazor Server: Use reflection to automatically fill email templates
Tags: blazor, dotnetcore
Published: 2024-04-18 08:28:48

# 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:

```csharp
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:

```csharp
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:
```csharp
{
  "Subject": {
    "EntityId": "Id",
    "EntityName": "Name",
  },
  "Body": {
    "Title": "Title",
    "Username": "Username",
    "EntityURL": "@GetEntityUrl"
    "GeneralURL": "@GetGeneralUrl"
  }
}
```
We will parse the **JSON** data using [Newtonsoft.Json](https://www.newtonsoft.com/json)'s `JObject.Parse()` and get the interpolation mappings for both subject and body:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
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:
```csharp
public async Task SendRequestCreationCompletedEmailAsync(string baseUri, EntityType Entity)
{
    await SendTemplatedEmailAsync("specific-template", baseUri, Entity);
}
```
Finally, the URL generating methods: 
```csharp
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;
}
```