Blazor サーバー：リフレクションを使用してメールのテンプレートを自動的に入力

# 問題
現在、仕事でメールのテンプレートを使用する Web アプリケーションを開発してます。そのメールのテンプレートをデータベースに保存されています。

すべてのテンプレートでは、空白を埋めるために 2 種類の情報が必要です：
- 特定のデータベース エンティティの情報
- 現在のドメイン、上記エンティティ等からの情報から生成された URL

ただし、各テンプレートがまったく同じ情報を必要とするわけではありません。

まずはテンプレートごとに必要な情報を抽出してテンプレートに補間するメソッドを書くことから始めましたが、2 問題が発生しました：
- テンプレートの数が増えると、メソッドの数も増えます
- テンプレートは編集できますが、埋められる情報のは、テンプレートに関連付けられたメソッドによって抽出された情報のみです

## 解決策
ディクショナリ構造とリフレクションを使用して、テンプレート フィールドをエンティティ プロパティまたは URL 生成メソッドにマップします。
# 実装

## メールのテンプレート
メールのテンプレートのクラスは、メールの件名と本文のテンプレートを個別に保存しますが、両方のマッピング情報は 1 つの JSON 辞書に保存されます：
```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; }
    }
}
```
### テンプレート
電子メール テンプレート内の「空白」は、`{EntityId}` のように中括弧で囲まれた文字列です。 「空白」を埋めるには、件名と本文の両方の補間文字列を置換する必要があります。
##### 件名のテンプレート
```
[{EntityId}] New information concerning {EntityName} is available! 
```
##### 本文のテンプレート
```
Dear {Title} {Username}

To see more information about this entity please go to:

[Entity] {EntityURL}

Sincerely.

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

[General] {GeneralURL}
```
### 専用メソッド
リファクタリング前のメールのテンプレートに関連付けられたメソッドです：
```csharp
using MailKit.Net.Smtp;
using MimeKit;
using Newtonsoft.Json.Linq;

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();
        }
    }
}
```
### マッピング ディクショナリー
補間に使用されるマッピング情報は **JSON** ディクショナリーとして保存されてます：
```csharp
{
  "Subject": {
    "EntityId": "Id",
    "EntityName": "Name",
  },
  "Body": {
    "Title": "Title",
    "Username": "Username",
    "EntityURL": "@GetEntityUrl"
    "GeneralURL": "@GetGeneralUrl"
  }
}
```

[Newtonsoft.Json](https://www.newtonsoft.com/json) の `JObject.Parse()` を使用して **JSON** データを解析し、件名と本文の両方の補間マッピングを取得します：
```csharp
using Newtonsoft.Json.Linq;

dynamic jsonDict = JObject.Parse(template.InterpolationInfo);
dynamic subjectDict = jsonDict.Subject;
dynamic bodyDict = jsonDict.Body;
```
次に、`(key,value)` ペアでループを使用して、置換する文字列とそれに置換する値を抽出します：
```csharp
foreach (JProperty pair in subjectDict)
{
    string placeholder = $"{{{pair.Name.Trim()}}}";
    if (string.IsNullOrEmpty(placeholder))
    {
        throw new ArgumentNullException();
    }
    else
    {
        string propertyName = pair.Value.ToString();
    }
}
```
##### プロパーティ
エンティティのプロパティの場合、文字列を使用してプロパティ値を取得するにはリフレクションが必要です。 次に、テンプレート文字列で `Replace()` を単純に呼び出す：
```csharp
var property = typeof(entityType).GetProperty(propertyName);
var replacement = property.GetValue(Entity).ToString();
Subject = Subject.Replace(placeholder, replacement);
```
##### URL 生成
生成された URL の場合は、メール送信サービスに追加されたメソッドを使用し、エンティティのプロパティと区別するためにメソッド名の先頭に「@」を付けることにしました：
```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);
``` 
これらすべての URL 生成メソッドには、以下のような同じパラメータが必要です：
```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;
}
```
### リファクタリングされたメソッド
新しいメソッドは次のようにまとめられる：
```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();
        }
    }
}
```
専用メソッドは、新しいメソッドへ呼び出しにまとめられる：
```csharp
public async Task SendRequestCreationCompletedEmailAsync(string baseUri, EntityType Entity)
{
    await SendTemplatedEmailAsync("specific-template", baseUri, Entity);
}
```
最後に、URL 生成メソッドは： 
```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;
}
```