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!

17 thoughts on “Rendering (and Emailing) Embedded Razor Views with .NET Core”

  1. “var viewRenderSvc= app.ApplicationServices.ServiceProvider.GetService();”

    this piece of code seems odd in the context of DI.

    Tried to add this service via DI into controller but seems it isn’t work

    here is my piece of Startup.cs
    public void ConfigureServices(IServiceCollection services)
    {
    services.AddOptions();
    services.Configure(Configuration.GetSection(nameof(HRVAppSettings)));
    services.AddSingleton(Configuration);
    // Add the ViewRenderService
    services.AddSingleton();

    //// Add the embedded file provider
    var embeddedProvider = new EmbeddedFileProvider(Assembly.GetEntryAssembly());
    var compositeProvider = new CompositeFileProvider(embeddedProvider);
    services.AddSingleton(compositeProvider);
    services.AddMvc();

    services.Configure(options =>
    {
    options.AutomaticAuthentication = true;
    });

    }

    1. The line that you quote as seeming odd is purely illustrative. I tossed it into the code sample to show how one could retrieve/executing the rendering directly.

      One problem I see with your DI setup is that you’re not specifying a type for the service to inject.

      In the original code sample, for example, I am injecting the IViewRenderService with the specified implementation:

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

      Another issue is that you’re not setting the RazorView options to use the providers that you’ve defined. Of course, if you’re running your process within an MVC context, which I was not, you don’t really need the embedded file provider at all. If you’re not planning to use embedded views, I would remove that section of code entirely.

      Let me know if that helps.

  2. Hey awesome blog post! I’ve got it working, except for partial views.

    Getting this exception:

    Message: System.ArgumentNullException : Value cannot be null.
    Parameter name: member

    On this line of code: https://github.com/aspnet/Mvc/blob/d278d6eedfafb339e0bccd35393e19a75032ac0d/src/Microsoft.AspNetCore.Mvc.Razor/Internal/DefaultRazorPageFactoryProvider.cs#L55

    Got this in my main template:

    @Html.Partial($”~/Views/Footer.cshtml”, Model)

    Any suggestions? 🙂

    1. I keep coming back and looking at that line of code… nothing is jumping out at me unless, someone, the partial path can’t be resolved. I thought I was using views with partials, but it’s possible that I wasn’t. I’ll see if I can reproduce and/or render partials without issue and let you know.

      1. Hello, I love the post! It has been super useful! Have you been able to make any progress using partial views? I’ve tried many different ways to implement them with your setup, but I have been unsuccesful.

        1. Hey James – glad it was useful for you. I haven’t spent any extra time figuring out how to get partial views to work. I presume you mean in the scenario of rendering the partial view within a common layout/master view. Sounds like I need to spend some time one weekend soon and see how to deal with the partial view scenario.

          1. Thanks, I ended up getting it to work with a Layout (thanks your additional pointers) which actually better suited my scenario. I think part of my issue was not making the Partial view embedded. Will post back later if I run into this again and get it to work.

  3. Very interesting article. Using Razor Views looks much better indeed.

    But I tried it on a Console Application project and I got this error. What am I missing?

    InvalidOperationException: Unable to resolve service for type ‘Microsoft.AspNetCore.Mvc.Razor.IRazorViewEngine’ while attempting to activate ‘TwistofLime.FocusWords.Services.ViewRenderService’.

    If I add this line to my Startup:
    services.AddSingleton();

    It gives this error, and I am out of ideas 🙁
    InvalidOperationException: Unable to resolve service for type ‘Microsoft.AspNetCore.Mvc.Razor.IRazorPageFactoryProvider’ while attempting to activate ‘Microsoft.AspNetCore.Mvc.Razor.RazorViewEngine’.

    1. Try using the “AddMvc” extension that adds all of the rendering (like IRazorPageFactoryProvider) services to the service collection.

      services.AddMvc();

  4. I have used the code here to create a service in my Web app.

    All works fine – I can get the string value for the individual view – until I try to include the Layout page. I have tried your suggestion of explicitly declaring the layout page in the view but I keep getting a System.NullReferenceException with the following stack:

    System.NullReferenceException: Object reference not set to an instance of an object.
    at Microsoft.AspNetCore.Mvc.Routing.UrlHelper.get_AmbientValues()
    at Microsoft.AspNetCore.Mvc.Routing.UrlHelper.GetVirtualPathData(String routeName, RouteValueDictionary values)
    at Microsoft.AspNetCore.Mvc.Routing.UrlHelper.Action(UrlActionContext actionContext)
    at Microsoft.AspNetCore.Mvc.UrlHelperExtensions.Action(IUrlHelper helper, String action, String controller, Object values, String protocol, String host, String fragment)
    at Microsoft.AspNetCore.Mvc.ViewFeatures.DefaultHtmlGenerator.GenerateActionLink(ViewContext viewContext, String linkText, String actionName, String controllerName, String protocol, String hostname, String fragment, Object routeValues, Object htmlAttributes)
    at Microsoft.AspNetCore.Mvc.TagHelpers.AnchorTagHelper.Process(TagHelperContext context, TagHelperOutput output)
    at Microsoft.AspNetCore.Razor.TagHelpers.TagHelper.ProcessAsync(TagHelperContext context, TagHelperOutput output)
    at Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperRunner.RunAsync(TagHelperExecutionContext executionContext)
    at AspNetCore.Views_Shared__Layout.b__45_1()
    at Microsoft.AspNetCore.Razor.Runtime.TagHelpers.TagHelperExecutionContext.SetOutputContentAsync()
    at AspNetCore.Views_Shared__Layout.ExecuteAsync()
    at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderPageCoreAsync(IRazorPage page, ViewContext context)
    at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderPageAsync(IRazorPage page, ViewContext context, Boolean invokeViewStarts)
    at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderLayoutAsync(ViewContext context, ViewBufferTextWriter bodyWriter)
    at Microsoft.AspNetCore.Mvc.Razor.RazorView.RenderAsync(ViewContext context)
    at ViewToStringService.ViewRenderService.RenderToString[TModel](String viewPath, TModel model) in D:\_Websites\DEServe\deserve_mvc\ViewToStringService\Service.cs:line 61
    — End of inner exception stack trace —
    at ViewToStringService.ViewRenderService.RenderToString[TModel](String viewPath, TModel model) in D:\_Websites\DEServe\deserve_mvc\ViewToStringService\Service.cs:line 67
    at GeneratePdfService.HtmlToPdfService.SaveToArray(PdfDetails details) in D:\_Websites\DEServe\deserve_mvc\GeneratePdfService\Service.cs:line 173

    Any ideas what I’m getting wrong?

    1. Are you sure the relative path to the layout is being specified correctly? Also, it could be that you may need to set PreserveCompilationContext to true in the project that contains your Razor views. Other than that, I’m not entirely sure what the problem might be.

  5. Hi,

    I tried the above approach to send an email, in a queued background item as a background task, every time i send a request, it works for the first time but for the second request it throws an exception
    Cannot access a disposed object. Object name: ‘IServiceProvider’.

    Is there anyone who faced this issue and resolved it?

    Thanks

  6. Hello another question for you. Have you been able to get this working using Endpoint Routing? I run into issues with this because RouteData is null when Razor calls into the UrlHelperFactory and causes an exception during rendering.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.