Dynamic emails with Handlebars.Net

Search for a command to run...

No comments yet. Be the first to comment.
Amazon Simple Email Service (SES) is a cloud-based email service provider that can integrate into any application for high-volume email automation. LocalStack, on the other hand, is a local cloud emulator that lets you build and test cloud applicatio...

Amazon S3, or Amazon Simple Storage Service, is an AWS cloud object storage service. It allows you to scale, secure, and manage your data efficiently. More than that, you can use it even if you host your application elsewhere (Azure, Google Cloud, et...

Heroku is a cheap and simple hosting platform to host your apps. Basic hosting costs around 5$/month for many hours of active app. That means that you can host multiple applications for that cost. Create your app on Heroku Click "New" and choose "Cre...

Heroku is a cheap and simple hosting platform to host your apps. Basic hosting costs around 5$/month for many hours of active app. That means that you can host multiple applications for that cost. Create your app on Heroku Click "New" and choose "Cre...

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.
dotnet add package Handlebars.Net
Services:builder.Services.AddSingleton(Handlebars.Create());
Simply add a .html file and all the possibilities of Handleras are open to you!
<h1>Hi {{UserName}}!</h1>
{{#each Products}}
<div>
{{Name}} - {{Quantity}}x {{Price}} PLN
</div>
{{/each}}
{{#if isActive}}
<img src="star.gif" alt="Active" />
{{/if}}
You will find all the syntax in the documentation.
Creating txt files is similar to HTML files and does not require any additional configuration.
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>
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);
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.
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);
}
}
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);
}
}
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>();
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
};
}
}
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.
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());
The source code is available here :)