Email-Templating with Blazor

Dev Diary

Creating dynamic and reusable email components can be quite a challenging task. However, with the latest version of .NET this is a breeze to implement. Here are the few steps you need to take to get your generated emails to the next level.

Autor
Paul Hagspiel
Datum
31. Oktober 2024
Lesedauer
7 Minuten

Breaking Through Razor Pages Limitations

With great success, we have been using Razor Pages to render our emails (and even PDF files) for quite some time now. However, we more often than not hit some limitations with it in the past, which we are now able to overcome with the new HtmlRenderer in .NET 8. Just to list a few of the headaches it solved and advantages it brought us:

  • New emails within the same project are mostly composed of pre-existing components, which are quite hard to reuse with the old Razor Pages.
  • Sharing data between the Layout Page, Body Page, Partial View and a Tag Helper were neither safe nor generic.
  • The setup for rendering Blazor Components is far simpler than for Razor Pages. Moreover, it does not require a hard dependency on the ASP.NET Core Framework.
  • Blazor and thus Blazor Components seems to be the current and foreseeable focus of Microsoft for their templating engine.

Just show me the good stuff

Before we dive right into the implementation, let’s first take a look at the experience of using our setup with a sample of a newsletter subscription email.

1. Create localized texts

Obviously most applications are localized and thus the Emails should be as well. For that we will create two resource files NewsletterSubscription.resx and NewsletterSubscription.en.resx in a new Resources directory. The files should represent something like this: 

NameNeutral Value (de)en
SubjectHallo {0}Hello {0}
WelcomeTextWillkommen zum Newsletter!Welcome to the Newsletter!
SubscriptionTextDanke, dass Sie sich angemeldet haben {0}.Thanks for subscribing {0}.

2. Create our Email

Create a NewsletterSubscription.razor file in a new Emails directory that will hold the body of our Email. 

@using Fusonic.Extensions.AspNetCore.Blazor @using Texts = EmailDemo.Resources.NewsletterSubscription <html> <body> <h1>@Texts.WelcomeText</h1> <p>@string.Format(Texts.SubscriptionText, Model.UserFullName)</p> </body> </html> @code { [Parameter] public required NewsletterSubscriptionModel Model { get; init; } public record NewsletterSubscriptionModel(string UserFullName) : IComponentModel<NewsletterSubscription>; }

3. Send our email

We are committed to using CQRS patterns at Fusonic, which is why we send our emails through a CQRS command. It’s not necessary to implement it with CQRS, we just happened to do it this way. 

await mediator.Send(new SendEmail( Recipient: "max@musermann.com", RecipientDisplayName: "Max Mustermann", Culture: CultureInfo.GetCultureInfo("de-AT"), ViewModel: new NewsletterSubscription.NewsletterSubscriptionModel("Max Mustermann"), BccRecipient: "bcc@app.com", Attachments: [new("AGB.pdf", new Uri("https://app.com/assets/agb.pdf"))], SubjectFormatParameters: ["Max Mustermann"], ReplyTo: "support@app.com" ));

Conclusion

With this setup we are able to create new emails in minutes even if they are localized, contain attachments or should be very flexible.

How does it work though?

1. The ComponentModel

We had to introduce something similar to the commonly known Razor ViewModels, this is due to the fact that you should not set the properties of a Blazor Component outside of its component (BL0005).

In theory we could write our own roslyn analyzer that would suppress the warning on the assignment where applicable. Or maybe even worse: disabling the warning altogether (depending on the Project). From our experience, all of these options will do more harm than good in the long run.

Additionally, this makes things a lot easier and safer when we want to serialize this model. We usually run the SendEmail command from a hangfire job so that it doesn’t block the current execution, which serializes the entire SendEmail call to json.

The NewsletterSubscriptionModel holds the data and the type of the component which we need in our email. These will both be required further down the line.

public record NewsletterSubscriptionModel(string UserFullName) :  IComponentModel<NewsletterSubscription>;

Referencing the Component via the IComponentModel<T> directly is a huge improvement compared to what we had to do previously. With Razor Pages, we had to provide each model with the path to its View so that the IViewLocalizer was able to find the View it was looking for (sample seen below). This »reference« was loose, meaning that there was no way to statically validate the reference, it would just throw a runtime exception. Therefore creating them initially and renaming views was quite a pain.

[EmailView("Emails/NewsletterSubscription")] public record NewsletterSubscriptionModel(string Message);

2. Rendering 

At the very heart of this is our BlazorRenderingService which looks something like this:

The RenderComponentBase will do a few things for us right away:

  • It will set the CurrentCulture and the CurrentUICulture so that the component itself will be rendered culture aware. And obviously reset the culture after we are done.
  • It calls an optional beforeRender that can be used to execute code that is culture aware. We will use this later for our localized subjects!
  • It renders our Blazor Component with the new HtmlRenderer and gets the HTML result of it.
public class BlazorRenderingService(HtmlRenderer htmlRenderer) : IBlazorRenderingService { public Task<string> RenderComponent(Type componentType, CultureInfo culture, Dictionary<string, object?>? dictionary = null, Action? beforeRender = null) => RenderComponentBase(componentType, culture, dictionary is null ? ParameterView.Empty : ParameterView.FromDictionary(dictionary), beforeRender); private async Task<string> RenderComponentBase(Type componentType, CultureInfo culture, ParameterView parameters, Action? beforeRender) => await htmlRenderer.Dispatcher.InvokeAsync(async () => { var currentCulture = (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture); try { // The razor renderer takes the culture from the current thread culture. CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = culture; beforeRender?.Invoke(); var output = await htmlRenderer.RenderComponentAsync(componentType, parameters); return output.ToHtmlString(); } finally { (CultureInfo.CurrentCulture, CultureInfo.CurrentUICulture) = currentCulture; } }); }

3. Use the renderer

We purposely split the whole rendering thing into multiple classes, as it allows us to use the core rendering for other things than emails such as PDFs! Our BlazorEmailRenderingService gathers all the necessary data from the Model that has been provided (This would be the NewsletterSubscription.­NewsletterSubscriptionModel from the sample earlier).

The BlazorEmailRenderingService will:

  • Validate that the model provided is actually valid and extract the Blazor Component Type to render.
  • Extract the translation key for the subject of the email. Which is assumed to be »Subject« per default. You either may implement the IProvideEmailSubject or pass the subjectKey to the method to overwrite the default.
  • Gets the localized subject using the provided subjectKey from the resource file. We do this in the 
    beforeRender parameter of the BlazorRenderingService so that we have the correct culture.
  • Get the rendered HTML from the BlazorRenderingService
  • Execute the BlazorContentPostProcessor which was defined in the options. This may be used to post-process the HTML content. By default it will use PreMailer.Net, to inline the css defined. Though, we are currently experimenting with mjml.io, which we might cover in a future blog.
public class BlazorEmailRenderingService(IBlazorRenderingService blazorRenderingService, IStringLocalizerFactory stringLocalizerFactory, EmailOptions emailOptions) : IEmailRenderingService { private const string DefaultSubjectKey = "Subject"; /// <inheritdoc /> public async Task<(string Subject, string Body)> RenderAsync( object model, CultureInfo culture, string? subjectKey, object[]? subjectFormatParameters = null) { var modelType = model.GetType(); if (model is not IComponentModel componentModel) throw new ArgumentException($"The type {modelType.Name} does not implement {nameof(IComponentModel)}.", nameof(model)); var componentType = componentModel.ComponentType; var modelProperty = componentType.GetProperties(BindingFlags.Public | BindingFlags.Instance) .FirstOrDefault(p => p.PropertyType == modelType) ?? throw new ArgumentNullException(nameof(model), $"The Component {componentType.Name} is missing a property of type {modelType.Name}."); subjectKey ??= (componentModel as IProvideEmailSubject)?.SubjectKey ?? DefaultSubjectKey; var subject = subjectKey; var body = await blazorRenderingService.RenderComponent( componentType, culture, new() { [modelProperty.Name] = model }, beforeRender: SetSubject ); if (emailOptions.BlazorContentPostProcessor is not null) { body = await emailOptions.BlazorContentPostProcessor.Invoke(new() { Html = body, ComponentModel = componentModel }); } return (subject, body); void SetSubject() { var subjectLocalization = stringLocalizerFactory.Create(componentType).GetString(subjectKey, subjectFormatParameters ?? []); subject = subjectLocalization.ResourceNotFound ? subjectKey : subjectLocalization.Value; } } }

4. Send the email

With everything we create we are now able to send an email. 

The SendEmail command will:

  • Call the IEmailRenderingService to get the rendered HTML and the localized subject from the provided model.
  • Set an optional prefix for all subjects. We use this to distinguish between review environments. On production this will most likely always be null.
  • Construct the mail message using MailKit, with the recipient, sender, the email body, the subject, email headers, BCC recipients and a reply-to address. The ISmptClient in essence just connects to a SMTP server, sends the email and then disconnects again.
  • Appends all our attachments we provided using a matching IEmailAttachmentResolver. This interface provides consumers a way to specify different attachment resolvers which convert a Uri to a Byte Stream

As this handler is quite verbose and not too interesting, you can take a look at the source code here.

Conclusion

With »Blazor Component email Rendering« one can create an awesome, reliable and maintainable email system, even outside ASP.Net Core. And from that point on you can easily extend the capabilities to your heart’s desire. You can even add support for CSS Isolation, although it’s a bit tricky when you are outside the ASP.Net Core environment. If you are interested to dive deeper into the code from above or to use our extensions yourself, take a look at the Fusonic.Extensions GitHub repository.

Mehr davon?

Kubernetes Dashboard Login über OpenID-Connect_B
Dev Diary
Kubernetes Dashboard Login mit OpenID-Connect
12. September 2024 | 7 Min.
Cron Monitoring with Sentry_B
Dev Diary
Automating Job Monitoring with Sentry and Symfony
7. August 2024 | 3 Min.

Kontaktformular

*Pflichtfeld
*Pflichtfeld
*Pflichtfeld
*Pflichtfeld

Wir schützen deine Daten

Wir bewahren deine persönlichen Daten sicher auf und geben sie nicht an Dritte weiter. Mehr dazu erfährst du in unseren Datenschutzbestimmungen.