Skip to main content

Command Palette

Search for a command to run...

Dynamic emails with Handlebars.Net

Updated
6 min read
Dynamic emails with Handlebars.Net

Handlebars.Net is a port of the Handlebars.js library that compiles Handlebars templates directly into IL bytecode. It enables quick and easy creation of dynamic email templates, including variables, iterations, conditional blocks, and much, much more.

string source =
@"<div class=""entry"">
  <h1>{{title}}</h1>
  <div class=""body"">
    {{body}}
  </div>
</div>";

var template = Handlebars.Compile(source);

var data = new {
    title = "My new post",
    body = "This is my first post!"
};

var result = template(data);

/* Would render:
<div class="entry">
  <h1>My New Post</h1>
  <div class="body">
    This is my first post!
  </div>
</div>
*/

In this article, I will present an example of implementing Handlebars into a project.

Installation

  1. Add Handlebars.Net from Nuget.
dotnet add package Handlebars.Net
  1. Register Handlebars in Services:
builder.Services.AddSingleton(Handlebars.Create());

Creating HTML template

Simply add a .html file and all the possibilities of Handleras are open to you!

Variables

<h1>Hi {{UserName}}!</h1>

Iteration

{{#each Products}}
    <div>
        {{Name}} - {{Quantity}}x {{Price}} PLN
    </div>
{{/each}}

Conditional statements

{{#if isActive}}
    <img src="star.gif" alt="Active" />
{{/if}}

You will find all the syntax in the documentation.

Creating TXT template

Creating txt files is similar to HTML files and does not require any additional configuration.

💡
Remember to set files property Copy to output directory to Copy always. Otherwise, files will not appear with the build.
    <ItemGroup>
      <None Update="Features\Emails\Templates\Order\OrderEmail.txt">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
      <None Update="Features\Emails\Templates\Order\OrderEmail.html">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
    </ItemGroup>

Template model

We already have html and txt templates. In my case, these were templates for order emails. Now we need to collect the variables we use into a single, consistent data model. This is how it looked for me:

public record OrderTemplateModel(
    string UserName,
    string OrderNumber,
    string OrderDate,
    List<OrderItem> Products,
    decimal ShippingPrice,
    string PaymentLink,
    decimal TotalPrice,
    string OrderUrl);

public record OrderItem(string Name, int Quantity, decimal Price);

Templates provider

Now we need to create a method that will render our templates.

public class TemplatesProvider(IHandlebars handlebars)
{
    public (string Html, string Text) RenderTemplate(object model, string htmlPath, string txtPath)
    {
        var templateHtml = handlebars.Compile(File.ReadAllText(htmlPath));
        var html = templateHtml(model);    

        var templateText = handlebars.Compile(File.ReadAllText(txtPath));
        var text = templateText(model);

        return (html,text);
    }
}

Our method compiles the files indicated by the paths and then fills in the templates with values from the passed model. However, it has two problems. First, the object is too broad a type and does not secure our method in any way. Second, specifying the paths each time will be inconvenient and prone to errors. Let us solve these problems one by one.

ITemplateModel

Let's start by getting rid of the object. Create an interface that we will use to mark template models:

public interface ITemplateModel;

Next, add it to our created model:

public record OrderTemplateModel(
    string UserName,
    string OrderNumber,
    string OrderDate,
    List<OrderItem> Products,
    decimal ShippingPrice,
    string PaymentLink,
    decimal TotalPrice,
    string OrderUrl) : ITemplateModel;

public record OrderItem(string Name, int Quantity, decimal Price);

Now we can refactor TemplatesProvider:

public class TemplatesProvider(IHandlebars handlebars)
{
    public (string Html, string Text) RenderTemplate(ITemplateModel model, string htmlPath, string txtPath)
    {
        var templateHtml = handlebars.Compile(File.ReadAllText(htmlPath));
        var html = templateHtml(model);    

        var templateText = handlebars.Compile(File.ReadAllText(txtPath));
        var text = templateText(model);

        return (html,text);
    }
}

Paths attribute

File paths can be organised in many ways, e.g. through a static class with constants. I decided to use attributes so that later, when adding new templates, I could limit the number of places where I would have to make changes.

[AttributeUsage(AttributeTargets.Class)]
public class PathsAttribute(string htmlPath, string textPath) : Attribute
{
    public string HtmlPath { get; } = htmlPath;
    public string TextPath { get; } = textPath;
}

Return to our template's model and add an attribute:

[Paths(htmlPath:"Features/Emails/Templates/Order/OrderEmail.html", textPath:"Features/Emails/Templates/Order/OrderEmail.txt")]
public record OrderTemplateModel(
    string UserName,
    string OrderNumber,
    string OrderDate,
    List<OrderItem> Products,
    decimal ShippingPrice,
    string PaymentLink,
    decimal TotalPrice,
    string OrderUrl) : ITemplateModel;

public record OrderItem(string Name, int Quantity, decimal Price);

Now we can remove the arguments from the method in TemplatesProvider:

public class TemplatesProvider(IHandlebars handlebars)
{
    public (string Html, string Text) RenderTemplate(ITemplateModel model)
    {
        var pathsAttribute = model.GetType().GetCustomAttribute<PathsAttribute>();
        if (pathsAttribute == null) throw new InvalidOperationException($"{nameof(PathsAttribute)} not found in {model.GetType().Name}. {nameof(PathsAttribute)} is required for templates");

        var templateHtml = handlebars.Compile(File.ReadAllText(pathsAttribute.HtmlPath));
        var html = templateHtml(model);    

        var templateText = handlebars.Compile(File.ReadAllText(pathsAttribute.TextPath));
        var text = templateText(model);

        return (html,text);
    }
}

TemplatesProvider - final touches

To enable the injection of TemplatesProvider, add its interface:

public interface ITemplatesProvider
{
    (string Html, string Text) RenderTemplate(ITemplateModel model);
}

Make TemplatesProvider inherits from it:

public class TemplatesProvider(IHandlebars handlebars) : ITemplatesProvider
{
    public (string Html, string Text) RenderTemplate(ITemplateModel model)
    {
        var pathsAttribute = model.GetType().GetCustomAttribute<PathsAttribute>();
        if (pathsAttribute == null) throw new InvalidOperationException($"{nameof(PathsAttribute)} not found in {model.GetType().Name}. {nameof(PathsAttribute)} is required for templates");

        var templateHtml = handlebars.Compile(File.ReadAllText(pathsAttribute.HtmlPath));
        var html = templateHtml(model);    

        var templateText = handlebars.Compile(File.ReadAllText(pathsAttribute.TextPath));
        var text = templateText(model);

        return (html,text);
    }
}

And register it in the DI container:

builder.Services.AddSingleton<ITemplatesProvider, TemplatesProvider>();

Usage

The templates are ready to use! It looks like this:

[ApiController]
[Route("[controller]")]
public class EmailsController(IEmailService emailService, ITemplatesProvider templatesProvider) : ControllerBase
{
    [HttpPost("order")]
    public async Task<SendOrderEmailResult> SendOrderEmail(SendOrderEmailRequest request)
    {
        var data = new OrderTemplateModel(
            UserName: "Hubert",
            OrderNumber: "12345",
            OrderDate: DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
            Products: [
                new OrderItem("Test product", 1, 249.90m)
            ],
            ShippingPrice: 14.90m,
            PaymentLink: "testUrl",
            TotalPrice: 264.80m,
            OrderUrl: "testUrl"
        );

        var (html, text) = templatesProvider.RenderTemplate(data);

        var email = new Email
        {
            BodyHtml = html,
            BodyText = text,
            Subject = "Order",
            SenderAddress = "noreply@poc.com",
            ToAddresses = ["test@test.com"]
        };

        var sendEmailResult = await emailService.SendEmailAsync(email, HttpContext.RequestAborted);

        return new SendOrderEmailResult
        {
            IsSuccess = sendEmailResult.IsSent
        };
    }
}

Reuse of templates

Handlebars allows for template reuse through partials. Partials are normal Handlebars templates that may be called directly by other templates. You can read more about it here.

We can use partials to separate the header and footer of the email.

Let's start by creating the appropriate html and txt files. Using my header as an example:

<table style="background-color: black; padding: 10px; height: 100%; width: 100%;">
    <tr>
        <td style="background-color: black;">
            <img src="myLogo.jpg" alt="Logo" style="width: 100%;">
        </td>
    </tr>
</table>

Partials, just like regular templates, can accept model templates and use all the features offered by Handlebars.

Use a partial in your templates:

<div>
    {{> Header}}
</div>

For partials to work, they must be registered in your Handlebars instance:

builder.Services.AddSingleton<IHandlebars>(_ =>
{
    var handlebars = Handlebars.Create();
    handlebars.RegisterTemplate("Footer", File.ReadAllText(Path.Combine("Features", "Emails", "Templates", "Footer", "Footer.html")));
    handlebars.RegisterTemplate("Header", File.ReadAllText(Path.Combine("Features", "Emails", "Templates", "Header", "Header.html")));

    return handlebars;
});

If we use more parties, we will have more code here. That is why I decided to separate Handlebars configuration into a separate class - HandlebarsFactory.

Handlebars factory

public static class HandlebarsFactory
{
    public static IHandlebars Create()
    {
        var handlebars = Handlebars.Create();
        handlebars.Configuration.TextEncoder = new HtmlEncoder();
        handlebars.RegisterTemplate("Footer", File.ReadAllText(Path.Combine("Features", "Emails", "Templates", "Footer", "Footer.html")));
        handlebars.RegisterTemplate("Header", File.ReadAllText(Path.Combine("Features", "Emails", "Templates", "Header", "Header.html")));

        return handlebars;
    }
}

In the factor, you can register partials and add other configurations, such as TextEncoder, which supports special characters in templates.

Usage:

builder.Services.AddSingleton<IHandlebars>(_ => HandlebarsFactory.Create());

Bonus

The source code is available here :)

More from this blog

C

Chihuahua Coder

8 posts

I love learning and trying new things. Through the blog I want to deepen my understanding and share my reaserch with the community. Join me on my journey into the wilderness of programming!