DEV Community

Ailete619
Ailete619

Posted on

Blazor Server: Use reflection to automatically fill email templates

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; }
    }
}
Enter fullscreen mode Exit fullscreen mode

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! 
Enter fullscreen mode Exit fullscreen mode
Body template
Dear {Title} {Username}

To see more information about this entity please go to:

[Entity] {EntityURL}

Sincerely.

-------------------------------------------------

[General] {GeneralURL}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode
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);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)