Rendering Emails with RazorViewEngine in .NET Core 2.0

PUBLISHED ON SEP 8, 2017 — .NET, .NET CORE

In this post I’m going to cover how to use the RazorViewEngine to render Views, and get the string content so it can be used as an email template.

As always, the code for this example can be found on Github here.

This has been done in .NET Core 2.0, and I’ve created this on my Mac - though being .NET Core it works equally well on Windows (tested on my Surface Pro), and in theory should on Linux too.

To start with I created a Web API and Console project using the dotnet cli. And given this was done through the cli, I then created a solution and added the two projects to it the same way.

In the code for the API I modified the Program class to set a specific port - 5020. This is just so that it launches on the same port when I run the code, so my Console app knows where to send requests.

It’s worth noting here that if you’re running this through Visual Studio you will have to set the port on the project setting as Visual Studio overrides the setting.

The first thing to do is set up a controller for us to talk to. By default the API comes with a Values controller, so I’ve renamed that to Email, removed the boilerplate code, and added a simple Get method that, for now, just returns a hard coded string:

Route("api/[controller]")]
public class EmailController : Controller
{
    // GET api/email
    [HttpGet]
    public string Get()
    {
        return "hello";
    }
}

I’ve then set up the Console to send a request to this endpoint, printing out the result. This is just so we can easily see what’s being output:

class Program
{
    private static HttpClient _client = new HttpClient();

    static void Main(string[] args)
    {
        while (true)
        {
            Console.WriteLine("Press [enter] to send a request");
            Console.WriteLine("Enter 'exit' to close the app");

            var input = Console.ReadLine();
            if (input.ToLower() == "exit")
            {
                break;
            }

            var test = GetEmail().Result;
            Console.WriteLine(test);
        }
    }

    static async Task<string> GetEmail()
    {
        _client.DefaultRequestHeaders.Accept.Clear();
        HttpResponseMessage response = await _client.GetAsync("http://localhost:5020/api/email");
        return await response.Content.ReadAsStringAsync();
    }
}

Now if you run both applications, you can see simply that hello gets printed to the console whenever we press enter.

The next step is to start setting up our API for the rendering. I’ve created a Templating folder in the API, and a subfolder called Emails. In the Emails folder I’ve added one .cshtml file called HelloEmail.cshtml which is blank for now.

Create a class under the Templating folder called RazorViewToStringRenderer. The purpose of this class is going to be to find an IView through the RazorViewEngine, and render that by calling RenderAsync.

The RenderAsync method takes in a ViewContext parameter. So the first thing we need to do is create the ViewContext - which means we need the parameters.

The first parameter is an ActionContext. I’ve added a private method to generate this which simply sets up a HttpContext, and uses that to create the ActionContext:

private ActionContext GetActionContext()
{
    var httpContext = new DefaultHttpContext
    {
        RequestServices = _serviceProvider
    };

    return new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
}

As you can see I’ve used a field called _serviceProvider, which is an implementation of IServiceProvider, that we don’t have yet, so let’s add that at the top of the class. We’re also going to make use of two other fields - ITempDataProvider and IRazorViewEngine:

private readonly IRazorViewEngine _viewEngine;
private readonly ITempDataProvider _tempDataProvider;
private readonly IServiceProvider _serviceProvider;

The TempDataProvider is needed for the ViewContext later, and the RazorViewEngine is what will help find and render our Views.

These fields will need setting, so let’s create a constructor for this class passing in the three interfaces, and setting the fields:

public RazorViewToStringRenderer(IRazorViewEngine viewEngine, 
                                    ITempDataProvider tempDataProvider, 
                                    IServiceProvider serviceProvider)
{
    _viewEngine = viewEngine;
    _tempDataProvider = tempDataProvider;
    _serviceProvider = serviceProvider;
}

Now we need a method that’s going to make use of GetActionContext, as well as handling the rest of the operation, so add an async method (RenderAsync, remember!) that takes in the name of the View we want, and the type of the model for the View - TModel so this can be used for any and all Views.

In here we can call to create our ActionContext, and make use of the defined RazorViewEngine, and the view name parameter, to locate our View. After the call to locate the View, I’ve checked the Success property to check the View was actually found. The method should look like this for now:

public async Task<string> RenderViewToString<TModel>(string name, TModel model)
{
    var actionContext = GetActionContext();

    var viewEngineResult = _viewEngine.FindView(actionContext, name, false);

    if (!viewEngineResult.Success)
    {
        throw new InvalidOperationException(string.Format("Couldn't find view '{0}'", name));
    }

    var view = viewEngineResult.View;

    return "hello";
}

As you can see we get our ActionContext, and use that and the passed in name variable to locate the View through the RazorViewEngine. If successful we get the View from the result, otherwise we throw an exception - obviously what you do in this scenario is up to you!

Now we have the View, we need to create the ViewContext. As mentioned, the first parameter is the ActionContext that we already have. The remaining parameters are the View, a ViewDataDictionary, a TempDataDictionary, a TextWriter, and HtmlHelperOptions.

So to create the ViewContext we pass in the ActionContext and View objets we already have, then we can create the remaining parameters - we instantiate a ViewDataDictionary of type TModel, with new instances of both parameters, and set the Model property to our passed in model.

Next is the TempDataDictionary which we create by passing in the HttpContext of our ActionContext, and our _tempDataProvider field.

For the TextWriter, I’ve added a using statement for a StringWriter, and placed the creation of the ViewContext in there - then I can used the defined StringWriter for the parameter.

Lastly, pass in a new instance of HtmlHelperOptions:

using (var output = new StringWriter())
{
    var viewContext = new ViewContext(
        actionContext,
        view,
        new ViewDataDictionary<TModel>(new EmptyModelMetadataProvider(), new ModelStateDictionary()) {
            Model = model
        },
        new TempDataDictionary(actionContext.HttpContext, _tempDataProvider),
        output,
        new HtmlHelperOptions()
    );
}

Now that we have both our View, and the ViewContext, we can simply call the RenderAsync method, and get the string output. Inside the using statement, after the creation of the ViewContext, we want to add the following lines:

await view.RenderAsync(viewContext);

return output.ToString();

This method will now render our View with the provided model, and return us the string representation of that!

We’ve passed in three parameters to this class, and we don’t want to worry about handling the setup of those objects, so we can use dependency injection to do the heavy lifting.

We want to add an interface for this class, so I created an IViewToStrinRenderer interface, and had the class implement that. The interface simply defines the one method we’ve already implemented:

public interface IViewToStringRenderer
{
    Task<string> RenderViewToString<TModel>(string name, TModel model);
}

Now we can configure the startup to register this interface and implementation in the AddServices method:

services.AddTransient<IViewToStringRenderer, RazorViewToStringRenderer>();

.NET will do the rest for us!

Now we need the controller we previously set up to make use of this class. I’ve added a constructor to the EmailController class passing in IViewToStringRenderer, and setting this to a private field on the class. Once again, .NET handles injecting this into the controller, which should now start with this:

private readonly IViewToStringRenderer _viewToStringRenderer;

public EmailController(IViewToStringRenderer viewToStringRenderer) 
{
    _viewToStringRenderer = viewToStringRenderer;
}

In order to test this, we’re going to need our templates to do something.

I’ve created a simple HelloEmailModel class, which just contains a Name property so we can see our model binding working. Then for the HelloEmail view, I’ve just set the model, and added a binding to the Name property:

@model RazorViewEngineEmailTemplates.Templating.Models.HelloEmailModel
@{
    Layout = "_Layout";
}

<h1>
  Hello @Model.Name.
</h1>

Back in the EmailController, I’m going to hard code in some values, as it’s not important for this demo.

I’ve created an instance of the Model, and set my name. Then I’ve added a try-catch block, which makes use of the RazorViewToStringRenderer to try and make use of our HelloEmail template.

If an exception is thrown (which I added earlier if the view wasn’t found) we return an error message instead:

[HttpGet]
public string Get()
{
    var emailModel = new HelloEmailModel {
        Name = "Ian Rufus"
    };
    try
    {
        var result = _viewToStringRenderer.RenderViewToString("HelloEmail", emailModel).Result;
        return result;
    }
    catch (Exception ex)
    {
        return $"Error: {ex.Message}";
    }
}

Great, let’s give it a run and see what happens!

Oh no! We’ve got an error instead.

Error: One or more errors occurred. (Couldn't find view 'HelloEmail')

So RazorViewEngine can’t find the correct .cshtml file, let’s put a breakpoint in and see what’s happened.

We can see that the result’s Success indicator is false. Because of this, there is another property available to us - SearchedLocations. This tells us where the engine has tried to locate our view:

[0] [string]:"/Views//HelloEmail.cshtml"
[1] [string]:"/Views/Shared/HelloEmail.cshtml"

We can see that it’s searched the conventional folders for the templates, but that’s not where I’ve placed them. You could place the template there, but I like to keep things separated more - especially if you intend for your API to actually have some Views.

So now we need to tell the engine to look in the correct location. To do this, we need to add a ViewLocationExpander.

Under the Templating folder create a new class called ViewLocationExpander. This class will implement the IViewLocationExpander.

Implementing the interface gives us two methods we need to populate - ExpandViewLocations and PopulateValues.

I’ve added a list of strings which gets populated in the constructor - this is populated by getting the current directory, and finding all Emails folders in there.

Then in the ExpandViewLocations we Union our list, with the passed in list of ViewLocations - if you don’t want to search the default locations, just return your own list.

For PopulateValues we’re just adding a customviewlocation value to the context, for the name of our expander.

public class ViewLocationExpander : IViewLocationExpander
{
    private IEnumerable<string> _directoryLocations;

    public ViewLocationExpander()
    {
        var root = Directory.GetCurrentDirectory();

        _directoryLocations = Directory.GetDirectories(root, "Emails", SearchOption.AllDirectories);
    }

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        return _directoryLocations.Union(viewLocations); 
    }

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        context.Values["customviewlocation"] = nameof(ViewLocationExpander);
    }
}

In order to make use of the expander, we need to configure the RazorViewEngine to make use of it.

In the Startup, we need to configure the RazorViewEngineOptions to add our expander to the ViewLocationExpanders on the options.

services.Configure<RazorViewEngineOptions>(options =>
{
    options.ViewLocationExpanders.Add(new ViewLocationExpander());
});

If you run it now you’ll see…. An error still!

Putting a break point in again will show that we have indeed searched an extra location, but it doesn’t look quite right:

[string]:"/Users/ianrufus/Personal/BlogPosts/RazorViewEngineEmailTemplates/RazorViewEngineEmailTemplates/Templating/Emails"

For one, that’s an absolute path, which we don’t want as RazorViewEngine works with absolute paths. And you’ll also notice that it’s searched the folder, but not for the view we’re after. We can make a minor change to the ViewLocationExpander to fix this.

We want to remove the root path from each found location, and also append the file name, which can be done with a couple of select statements:

public ViewLocationExpander()
{
    var root = Directory.GetCurrentDirectory();

    _directoryLocations = Directory.GetDirectories(root, "Emails", SearchOption.AllDirectories);

    _directoryLocations = _directoryLocations.Select(s => s.Replace(root, ""))
                                            .Select(s => s.Insert(s.Length, "/{0}.cshtml"));

}

If we run this now, we’ll see that the email has been rendered with our model, hooray!

Let’s make a useful addition - if you have multiple different emails you’ll be managing, you don’t want to be maintaining all the layouts separately. It’s nice to have a consistent theme applied for you - so let’s add a layout file.

Under the Emails folder, add a _Layout.cshtml file.

I’m not going into the details of formatting etc, just enough to show working with layouts works.

In the layout I’m just setting the structure of the page, adding a title, and rendering our body content:

<html>
    <head>
        <title>Email Layout Title</title>
    </head>
    <body>
        This is from the layout
        @RenderBody()
    </body>
</html>

Add the layout use to the email template file under the model:

@model RazorViewEngineEmailTemplates.Templating.Models.HelloEmailModel
@{
    Layout = "_Layout";
}

<h1>Hello @Model.Name.</h1>

Now if you run the app, you can see our layout is rendered as well as our View. The same works for rendering sections for scripts, content etc - just be careful about what your email client will allow!

There are two small things we want to do now - to make sure things run as expected when we deploy. The first, is to ensure that the email templates are copied to the output directory - they can be found in the source folder now, but they need to be present when you publish and deploy. Add the following to your project file:

<ItemGroup>
  <Content Update="Templating\Emails\HelloEmail.cshtml">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </Content>
  <Content Update="Templating\Emails\_Layout.cshtml">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </Content>
</ItemGroup>

The second is to improve how we find the templates. The current idea works well enough, but what if you run the project from outside of it’s root folder? If you do so, and have another project containing views present, you can run into rendering issues due to confusion between views with the same name, or with different layout files being found.
To fix this, we pass in the content root path of the hosting environment, and filter out files that don’t match. Inject IHostingEnvironment into your startup, and store the content root in a field, then pass this to the expander. In the expander constructor, we want to only find files that contain that path:

public ViewLocationExpander(string contentRootPath)
{
    var root = Directory.GetCurrentDirectory();
    // Find all 'Emails' folders in the directory
    _directoryLocations = Directory.GetDirectories(root, "Emails", SearchOption.AllDirectories);
    // Only include the ones in the running directory, remove the root path (the view engine uses relative paths)
    // and append the file name
    _directoryLocations = _directoryLocations.Where(s => s.Contains(contentRootPath))
                                            .Select(s => s.Replace(root, ""))
                                            .Select(s => s.Insert(s.Length, "/{0}.cshtml"));

}

Now you can use the RazorViewEngine to render a View, and get the string content to be used for an email.

One last thing to mention is a potential issue when running integration tests. If you were to add a test using the renderer, you may see a lot of confusing errors about missing references in the View files. A workaround that worked for me is mentioned in this Github issue: https://github.com/aspnet/Razor/issues/1212 It’s just a case of creating one file, and adding a few lines to your project file. Making those changes and the errors went away for me 🙂

As always, please comment, raise an issue on Github, or otherwise get in touch if you see any problems or improvements!

comments powered by Disqus