Rendering (and Emailing) Embedded Razor Views with .NET Core

Home / Rendering (and Emailing) Embedded Razor Views with .NET Core

In continuing my efforts to process emails in .NET Core, I needed a way to send styled emails with images and such. Previously, I would have manually handled this by replacing text here and there, but using Razor Views seemed like a much better alternative. Considering that the processing would be handled in my domain layer, all of the views would be generated from embedded resources in a class library.


Embedded resources in .NET Core are somewhat different than legacy .NET resources. Basically, you can drop files into a class library and set the build type to “embed.” I created an “EmbeddedViews” folder to mimic that of a typical MVC project’s view structure. I also have one image (email-icon) that I plan to display (embed) within the email. This is created as an embedded resource too.

I may have blogged about this before, but I’m not sure. The intent for rendering is to render the Razor view as a string and then email that with MailKit (aka MimeKit). Rendering a Razor view as a string is pretty straight-forward. I have an IViewRenderService interface which is injected into the domain layer.

public interface IViewRenderService
{
    Task<string> RenderToStringAsync(string viewName);
    Task<string> RenderToStringAsync<TModel>(string viewName, TModel model);
    string RenderToString<TModel>(string viewPath, TModel model);
    string RenderToString(string viewPath);
}

public class ViewRenderService : IViewRenderService
{
    private readonly IRazorViewEngine _viewEngine;
    private readonly ITempDataProvider _tempDataProvider;
    private readonly IServiceProvider _serviceProvider;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public ViewRenderService(IRazorViewEngine viewEngine, IHttpContextAccessor httpContextAccessor,
        ITempDataProvider tempDataProvider,
        IServiceProvider serviceProvider)
    {
        _viewEngine = viewEngine;
        _httpContextAccessor = httpContextAccessor;
        _tempDataProvider = tempDataProvider;
        _serviceProvider = serviceProvider;
    }

    public string RenderToString<TModel>(string viewPath, TModel model)
    {
        try
        {
            var viewEngineResult = _viewEngine.GetView("~/", viewPath, false);

            if (!viewEngineResult.Success)
            {
                throw new InvalidOperationException($"Couldn't find view {viewPath}");
            }

            var view = viewEngineResult.View;

            using (var sw = new StringWriter())
            {
                var viewContext = new ViewContext()
                {
                    HttpContext = _httpContextAccessor.HttpContext ?? new DefaultHttpContext { RequestServices = _serviceProvider },
                    ViewData = new ViewDataDictionary<TModel>(new EmptyModelMetadataProvider(), new ModelStateDictionary()) { Model = model },
                    Writer = sw
                };
                view.RenderAsync(viewContext).GetAwaiter().GetResult();
                return sw.ToString();
            }
        }
        catch (Exception ex)
        {
            throw new Exception("Error ending email.", ex);
        }
    }

    public async Task<string> RenderToStringAsync<TModel>(string viewName, TModel model)
    {
        var httpContext = _httpContextAccessor.HttpContext ?? new DefaultHttpContext { RequestServices = _serviceProvider };
        var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());

        using (var sw = new StringWriter())
        {
            var viewResult = _viewEngine.FindView(actionContext, viewName, false);

            // Fallback - the above seems to consistently return null when using the EmbeddedFileProvider
            if (viewResult.View == null)
            {
                viewResult = _viewEngine.GetView("~/", viewName, false);
            }

            if (viewResult.View == null)
            {
                throw new ArgumentNullException($"{viewName} does not match any available view");
            }

            var viewDictionary = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary())
            {
                Model = model
            };

            var viewContext = new ViewContext(
                actionContext,
                viewResult.View,
                viewDictionary,
                new TempDataDictionary(actionContext.HttpContext, _tempDataProvider),
                sw,
                new HtmlHelperOptions()
            );

            await viewResult.View.RenderAsync(viewContext);
            return sw.ToString();
        }
    }

    public string RenderToString(string viewPath)
    {
        return RenderToString(viewPath, string.Empty);
    }

    public Task<string> RenderToStringAsync(string viewName)
    {
        return RenderToStringAsync<string>(viewName, string.Empty);
    }
}

This renderer works really well. But, there’s no obvious way to retrieve an embedded resource. .NET Core, though, has a built in file provider, through the nuget Package Microsoft.Extensions.FileProviders.Embedded, that will search through an assembly’s embedded resources. We add this in our Startup.cs:

// Add the ViewRenderService
services.AddTransient<IViewRenderService, ViewRenderService>();

// Add the embedded file provider
var viewAssembly = typeof(BaseService).GetTypeInfo().Assembly;
var fileProvider = new EmbeddedFileProvider(viewAssembly);
services.Configure<RazorViewEngineOptions>(options =>
{
    options.FileProviders.Add(fileProvider);
});

Now, if we wanted to render the embedded Razor view, the ViewRenderService is called directly:

var viewRenderSvc= app.ApplicationServices.ServiceProvider.GetService<IViewRenderService>();
var outputSync = viewRenderSvc.RenderToString("~/EmbeddedViews/Mail/OutgoingMail.cshtml");

One note of interest regarding the embedded Razor views. I didn’t get any particular “ViewStart” to be loaded, so I specified the Layout directly in the view that I’m rendering.

@{
    ViewBag.Title = "Outgoing Message";
    Layout = "~/EmbeddedViews/Shared/_EmbeddedLayout.cshtml";
}

Thanks to the EmbeddedFileProvider, both the view and the specified Layout are found without incident. With all of that working, how does one go about embedding images so that emails can be displayed properly? Within my Razor view, I have one image that I plan to embed with a hard-coded content Id:

<img class="fix" src="cid:emailicon" width="70" height="70" border="0" alt="" />

This is where things are interesting in dealing with email in MailKit. If you’ve ever used the lod “AlternateViews” in legacy .NET applications, you will probably appreciate how much simpler it is to embed images with MailKit as well.

In order to load an embedded bitmap in .NET Core, we have to get a handle to the resource’s stream. In order to use that data in MailKit, we have to convert it to a byte array.

var mimeMsg = new MimeMessage();

// We need the bitmap as a byte array
var assembly = typeof(BaseService).GetTypeInfo().Assembly;
// If we can't find the resources... we can always check the name
//var resourceNames = assembly.GetManifestResourceNames();
Stream resource = assembly.GetManifestResourceStream("MyClassLibrary.EmbeddedViews.email-icon.png");
Bitmap bmp = new Bitmap(resource);
byte[] imageBytes = null;

using (var ms = new MemoryStream())
{
    bmp.Save(ms, bmp.RawFormat);
    imageBytes = ms.ToArray();
}

MailKit requires that we specify the content type and add the byte array as a linked resource. After we create the “BodyBuilder” the LinkedResource is added to the builder. The ContentId is set to match the content Id that was specified in the Razor view.

var builder = new BodyBuilder()
{
    HtmlBody = body
};

var contentType = new ContentType("image", $"{GetImageExtension(bmp.RawFormat).Replace(".", string.Empty)}");
var image = builder.LinkedResources.Add("email_icon", imageBytes, contentType);
image.ContentId = "emailicon";
mimeMsg.Body = builder.ToMessageBody();

Here is my helper that gets the file extension (synonymous with the subtype) based on the raw image format.

private string GetImageExtension(ImageFormat format)
{
    var extension = ImageCodecInfo.GetImageEncoders()
    .Where(ie => ie.FormatID == format.Guid)
    .Select(ie => ie.FilenameExtension
        .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries)
        .First()
        .Trim('*')
        .ToLower())
    .FirstOrDefault();
    return extension;
}

Finally, with all of the code in place. Our shiny, styled email gets rendered properly. It displays correctly in Outlook as well!

Leave a Reply