Email Templating Using the ASP.NET Razor View Engine

Overview

A common design pattern is for an application to send out "form letters" via email using a template. Typically these templates contain include some sort of token, where each token gets replaced with relevant data at the time the email is to be sent. An example of this might be a "Forgot Password" email, where the username, etc. is to be included in the body of the email. The email template may include a token such as "{USERNAME}" which is replaced with the actual username when the email is sent.

While this works fine for simple emails, for more complex emails this token mechanism becomes cumbersome and difficult to maintain. This is especially true when the data to be presented is a collection (e.g., multiple rows of query results) or if the presentation logic involves some decision making. The ASP.NET Razor View Engine was built to handle just such a use case. This article presents code which uses the ASP.NET Razor View Engine outside the context of an ASP.NET website to transform any given .NET object into HTML, which can then be used as the body of an email message.

Sample Usage

private bool SendEmailUsingTemplate<TModel>(string subject, string templateName, string recipients, TModel model)
{
    var body = BuildHtmlUsingTemplate<TModel>(templateName, model);
    // TODO: Write the SendEmail function to create the SmtpClient and MailMessage objects to send the email
    return SendEmail(subject, body, recipients);
}

Building and Testing

I recommend initially creating the Razor view within a web project. The controller method should populate the model with some sample data, so that when you run the website and navigate to the page, you can test the page content, layout, and styling, and adjust it as necessary.

Reusable Code

The following code was adapted from http://vibrantcode.com/blog/2010/11/16/hosting-razor-outside-of-aspnet-revised-for-mvc3-rc.html

BuildHtmlUsingTemplate Method

public string BuildHtmlUsingTemplate<TModel>(string templateName, TModel model)
{
    var templatePath = ConfigurationManager.AppSettings["emailTemplatesFolder"];

    if (templatePath == null)
        templatePath = Path.GetDirectoryName(Assembly.GetEntryAssembly().Location);

    var templateFile = Path.Combine(templatePath, templateName + ".cshtml");

    if (!File.Exists(templateFile))
        throw new FileNotFoundException("Couldn't find template file [" + templateFile + "]");

    var templateContent = File.ReadAllText(templateFile);
    var te = new TemplateEngine<TModel>();
    var asm = Assembly.GetExecutingAssembly();

    // TODO: If necessary, add assemblies here as needed by your Razor views
    var asm2 = typeof(My.Namespace.And.SpecialClass).Assembly;

    var body = te.Execute(templateContent, model, asm, asm2);
    return body;
}

TemplateEngine Class

The TemplateEngine class requires a reference to System.Web.Razor. This assembly is found in the "Add References" dialog under "Assemblies" > "Extensions".

using System;
using System.CodeDom.Compiler;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Web.Razor;
using Microsoft.CSharp;

/* Adapted from http://vibrantcode.com/blog/2010/11/16/hosting-razor-outside-of-aspnet-revised-for-mvc3-rc.html 
 * and https://gist.github.com/ArnoldZokas/2204352
 * and http://www.codemag.com/article/1103081
 * 
 * with acknowledgement to Jeff Polakiewicz for the inspiration behind the idea 
 * and to Andy Hopper for technical assistance.
 */
public class TemplateEngine<TModel>
{
    private TemplateBase<TModel> _currentTemplate;
    private RazorTemplateEngine _razor;

    private void Init()
    {
        RazorEngineHost host = new RazorEngineHost(new CSharpRazorCodeLanguage());
        host.DefaultBaseClass = typeof(TemplateBase<TModel>).FullName;
        host.DefaultNamespace = "RazorOutput";
        host.DefaultClassName = "Template";
        host.NamespaceImports.Add("System");
        _razor = new RazorTemplateEngine(host);
    }

    public string Execute(string templateContent, TModel model, params Assembly[] referenceAssemblies)
    {
        Init();
        _currentTemplate = null;

        // Generate code for the template
        GeneratorResults razorResult = null;

        /* Because the [@model] syntax is ASP.NET-MVC-specific, remove any line starting with "@model" */
        templateContent = string.Join("\n\r", templateContent.Split(new char[] { '\n', '\r' }).Where(o => !o.StartsWith("@model ")));

        using (TextReader rdr = new StringReader(templateContent))
        {
            razorResult = _razor.GenerateCode(rdr);
        }

        // Compile the generated code into an assembly
        var compilerParameters = new CompilerParameters();
        compilerParameters.GenerateInMemory = true;
        compilerParameters.ReferencedAssemblies.Add(typeof(TemplateEngine<TModel>).Assembly.Location);

        if (referenceAssemblies != null)
        {
            foreach (var referenceAssembly in referenceAssemblies)
            {
                compilerParameters.ReferencedAssemblies.Add(referenceAssembly.Location);
            }
        }

        var codeProvider = new CSharpCodeProvider();

        CompilerResults results = codeProvider.CompileAssemblyFromDom(compilerParameters,
            razorResult.GeneratedCode);

        if (results.Errors.HasErrors)
        {
            CompilerError err = results.Errors
                                        .OfType<CompilerError>()
                                        .Where(ce => !ce.IsWarning)
                                        .First();

            throw new Exception(String.Format("Error Compiling Template: ({0}, {1}) {2}",
                                            err.Line, err.Column, err.ErrorText));
        }
        else
        {
            // Load the assembly
            var asm = results.CompiledAssembly;
            if (asm == null)
            {
                throw new Exception("Error loading template assembly");
            }
            else
            {
                // Get the template type
                Type typ = asm.GetType("RazorOutput.Template");
                if (typ == null)
                {
                    throw new Exception(string.Format("Could not find type RazorOutput.Template in assembly {0}", asm.FullName));
                }
                else
                {
                    TemplateBase<TModel> newTemplate = Activator.CreateInstance(typ) as TemplateBase<TModel>;
                    if (newTemplate == null)
                    {
                        throw new Exception("Could not construct RazorOutput.Template or it does not inherit from TemplateBase");
                    }
                    else
                    {
                        _currentTemplate = newTemplate;
                        _currentTemplate.Model = model;
                        _currentTemplate.Execute();
                        var result = _currentTemplate.Buffer.ToString();
                        _currentTemplate.Buffer.Clear();
                        return result;

                    }
                }
            }
        }
    }
}

TemplateBase Class

The TemplateBase class must be in the same namespace as the TemplateEngine class, above.

using System;
using System.ComponentModel;
using System.IO;
using System.Text;

public abstract class TemplateBase<TModel>
{
    [Browsable(false)]
    public StringBuilder Buffer { get; set; }

    [Browsable(false)]
    public StringWriter Writer { get; set; }

    public TemplateBase()
    {
        Buffer = new StringBuilder();
        Writer = new StringWriter(Buffer);
    }

    public abstract void Execute();

    public virtual void Write(object value)
    {
        WriteLiteral(value);
    }

    public virtual void WriteLiteral(object value)
    {
        Buffer.Append(value);
    }

    public TModel Model { get; set; }
}