Sending Mail in Background with ASP.NET MVC¶

Table of Contents

Let’s start with a simple example: you are building your own blog using ASP.NET MVC and want to receive an email notification about each posted comment. We will use the simple but awesome Postal library to send emails.

Tip

I’ve prepared a simple application that has only comments list, you can download its sources to start work on tutorial.

You already have a controller action that creates a new comment, and want to add the notification feature.

  1. // ~/HomeController.cs
  2.  
  3. [HttpPost]
  4. public ActionResult Create(Comment model)
  5. {
  6. if (ModelState.IsValid)
  7. {
  8. _db.Comments.Add(model);
  9. _db.SaveChanges();
  10. }
  11.  
  12. return RedirectToAction("Index");
  13. }

Installing Postal¶

First, install the Postal NuGet package:

  1. Install-Package Postal.Mvc5

Then, create ~/Models/NewCommentEmail.cs file with the following contents:

  1. using Postal;
  2.  
  3. namespace Hangfire.Mailer.Models
  4. {
  5. public class NewCommentEmail : Email
  6. {
  7. public string To { get; set; }
  8. public string UserName { get; set; }
  9. public string Comment { get; set; }
  10. }
  11. }

Create a corresponding template for this email by adding the ~/Views/Emails/NewComment.cshtml file:

  1. @model Hangfire.Mailer.Models.NewCommentEmail
    To: @Model.To
    From: mailer@example.com
    Subject: New comment posted

  2. Hello,
    There is a new comment from @Model.UserName:

  3. @Model.Comment

  4. <3

And call Postal to sent email notification from the Create controller action:

  1. [HttpPost]
  2. public ActionResult Create(Comment model)
  3. {
  4. if (ModelState.IsValid)
  5. {
  6. _db.Comments.Add(model);
  7. _db.SaveChanges();
  8.  
  9. var email = new NewCommentEmail
  10. {
  11. To = "yourmail@example.com",
  12. UserName = model.UserName,
  13. Comment = model.Text
  14. };
  15.  
  16. email.Send();
  17. }
  18.  
  19. return RedirectToAction("Index");
  20. }

Then configure the delivery method in the web.config file (by default, tutorial source code uses C:\Temp directory to store outgoing mail):

  1. <system.net>
  2. <mailSettings>
  3. <smtp deliveryMethod="SpecifiedPickupDirectory">
  4. <specifiedPickupDirectory pickupDirectoryLocation="C:\Temp\" />
  5. </smtp>
  6. </mailSettings>
  7. </system.net>

That’s all. Try to create some comments and you’ll see notifications in the pickup directory.

Further considerations¶

But why should a user wait until the notification was sent? There should be some way to send emails asynchronously, in the background, and return a response to the user as soon as possible.

Unfortunately, asynchronous controller actions do not help in this scenario, because they do not yield response to the user while waiting for the asynchronous operation to complete. They only solve internal issues related to thread pooling and application capacity.

There are great problems with background threads also. You should use Thread Pool threads or custom ones that are running inside ASP.NET application with care – you can simply lose your emails during the application recycle process (even if you register an implementation of the IRegisteredObject interface in ASP.NET).

And you are unlikely to want to install external Windows Services or use Windows Scheduler with a console application to solve this simple problem (we are building a personal blog, not an e-commerce solution).

Installing Hangfire¶

To be able to put tasks into the background and not lose them during application restarts, we’ll use Hangfire. It can handle background jobs in a reliable way inside ASP.NET application without external Windows Services or Windows Scheduler.

  1. Install-Package Hangfire

Hangfire uses SQL Server or Redis to store information about background jobs. So, let’s configure it. Add a new class Startup into the root of the project:

  1. public class Startup
  2. {
  3. public void Configuration(IAppBuilder app)
  4. {
  5. GlobalConfiguration.Configuration
  6. .UseSqlServerStorage(
  7. "MailerDb",
  8. new SqlServerStorageOptions { QueuePollInterval = TimeSpan.FromSeconds(1) });
  9.  
  10.  
  11. app.UseHangfireDashboard();
  12. app.UseHangfireServer();
  13.  
  14.  
  15. }
  16.  
  17. }

The SqlServerStorage class will install all database tables automatically on application start-up (but you are able to do it manually).

Now we are ready to use Hangfire. It asks us to wrap a piece of code that should be executed in background in a public method.

  1. [HttpPost]
  2. public ActionResult Create(Comment model)
  3. {
  4. if (ModelState.IsValid)
  5. {
  6. _db.Comments.Add(model);
  7. _db.SaveChanges();
  8.  
  9. BackgroundJob.Enqueue(() => NotifyNewComment(model.Id));
  10. }
  11.  
  12. return RedirectToAction("Index");
  13. }

Note, that we are passing a comment identifier instead of a full comment – Hangfire should be able to serialize all method call arguments to string values. The default serializer does not know anything about our Comment class. Furthermore, the integer identifier takes less space in serialized form than the full comment text.

Now, we need to prepare the NotifyNewComment method that will be called in the background. Note that HttpContext.Current is not available in this situation, but Postal library can work even outside of ASP.NET request. But first install another package (that is needed for Postal 0.9.2, see the issue). Let’s update package and bring in the RazorEngine

  1. Update-Package -save
  1. public static void NotifyNewComment(int commentId)
  2. {
  3. // Prepare Postal classes to work outside of ASP.NET request
  4. var viewsPath = Path.GetFullPath(HostingEnvironment.MapPath(@"~/Views/Emails"));
  5. var engines = new ViewEngineCollection();
  6. engines.Add(new FileSystemRazorViewEngine(viewsPath));
  7.  
  8. var emailService = new EmailService(engines);
  9.  
  10. // Get comment and send a notification.
  11. using (var db = new MailerDbContext())
  12. {
  13. var comment = db.Comments.Find(commentId);
  14.  
  15. var email = new NewCommentEmail
  16. {
  17. To = "yourmail@example.com",
  18. UserName = comment.UserName,
  19. Comment = comment.Text
  20. };
  21.  
  22. emailService.Send(email);
  23. }
  24. }

This is a plain C# static method. We are creating an EmailService instance, finding the desired comment and sending a mail with Postal. Simple enough, especially when compared to a custom Windows Service solution.

Warning

Emails now are sent outside of request processing pipeline. As of Postal 1.0.0, there are the following limitations: you can not use layouts for your views, you MUST use Model and not ViewBag, embedding images is not supported either.

That’s all! Try to create some comments and see the C:\Temp path. You also can check your background jobs at http://<your-app>/hangfire. If you have any questions, you are welcome to use the comments form below.

Note

If you experience assembly load exceptions, please, please delete the following sections from the web.config file (I forgot to do this, but don’t want to re-create the repository):

  1. <dependentAssembly>
  2. <assemblyIdentity name="Newtonsoft.Json" publicKeyToken="30ad4fe6b2a6aeed" culture="neutral" />
  3. <bindingRedirect oldVersion="0.0.0.0-6.0.0.0" newVersion="6.0.0.0" />
  4. </dependentAssembly>
  5. <dependentAssembly>
  6. <assemblyIdentity name="Common.Logging" publicKeyToken="af08829b84f0328e" culture="neutral" />
  7. <bindingRedirect oldVersion="0.0.0.0-2.2.0.0" newVersion="2.2.0.0" />
  8. </dependentAssembly>

Automatic retries¶

When the emailService.Send method throws an exception, Hangfire will retry it automatically after a delay (that is increased with each attempt). The retry attempt count is limited (10 by default), but you can increase it. Just apply the AutomaticRetryAttribute to the NotifyNewComment method:

  1. [AutomaticRetry( Attempts = 20 )]
  2. public static void NotifyNewComment(int commentId)
  3. {
  4. /* ... */
  5. }

Logging¶

You can log cases when the maximum number of retry attempts has been exceeded. Try to create the following class:

  1. public class LogFailureAttribute : JobFilterAttribute, IApplyStateFilter
  2. {
  3. private static readonly ILog Logger = LogProvider.GetCurrentClassLogger();
  4.  
  5. public void OnStateApplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
  6. {
  7. var failedState = context.NewState as FailedState;
  8. if (failedState != null)
  9. {
  10. Logger.ErrorException(
  11. String.Format("Background job #{0} was failed with an exception.", context.JobId),
  12. failedState.Exception);
  13. }
  14. }
  15.  
  16. public void OnStateUnapplied(ApplyStateContext context, IWriteOnlyTransaction transaction)
  17. {
  18. }
  19. }

And add it:

Either globally by calling the following method at application start:

  1. public void Configuration(IAppBuilder app)
  2. {
  3. GlobalConfiguration.Configuration
  4. .UseSqlServerStorage(
  5. "MailerDb",
  6. new SqlServerStorageOptions { QueuePollInterval = TimeSpan.FromSeconds(1) })
  7. .UseFilter(new LogFailureAttribute());
  8.  
  9. app.UseHangfireDashboard();
  10. app.UseHangfireServer();
  11. }

Or locally by applying the attribute to a method:

  1. [LogFailure]
  2. public static void NotifyNewComment(int commentId)
  3. {
  4. /* ... */
  5. }

You can see the logging is working when you add a new breakpoint in LogFailureAttribute class inside method OnStateApplied

If you like to use any of common logger and you do not need to do anything. Let’s take NLog as an example.Install NLog (current version: 4.2.3)

  1. Install-Package NLog

Add a new Nlog.config file into the root of the project.

  1.  

<?xml version=”1.0” encoding=”utf-8” ?><nlog xmlns=”http://www.nlog-project.org/schemas/NLog.xsd

xmlns:xsi=”http://www.w3.org/2001/XMLSchema-instance”autoReload=”true”throwExceptions=”false”&gt;
<variable name=”appName” value=”HangFire.Mailer” />
<targets async=”true”>
<target xsi:type=”File”
name=”default”layout=”- {longdate} - - {level:uppercase=true}: - {message}- {onexception:- {newline}EXCEPTION: - {exception:format=ToString}}”fileName=”- {specialfolder:ApplicationData}- {appName}Debug.log”keepFileOpen=”false”archiveFileName=”- {specialfolder:ApplicationData}- {appName}Debug_- {shortdate}.{##}.log”archiveNumbering=”Sequence”archiveEvery=”Day”maxArchiveFiles=”30”/>
<target xsi:type=”EventLog”
name=”eventlog”source=”- {appName}”layout=”- {message}- {newline}- {exception:format=ToString}”/>
</targets><rules>
<logger name=”” writeTo=”default” minlevel=”Info” /><logger name=”” writeTo=”eventlog” minlevel=”Error” />
</rules>

</nlog>

run application and new log file could be find on cd %appdata%HangFire.MailerDebug.log

Fix-deploy-retry¶

If you made a mistake in your NotifyNewComment method, you can fix it and restart the failed background job via the web interface. Try it:

  1. // Break background job by setting null to emailService:
  2. EmailService emailService = null;

Compile a project, add a comment and go to the web interface by typing http://<your-app>/hangfire. Exceed all automatic attempts, then fix the job, restart the application, and click the Retry button on the Failed jobs page.

Preserving current culture¶

If you set a custom culture for your requests, Hangfire will store and set it during the performance of the background job. Try the following:

  1. // HomeController/Create action
  2. Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("es-ES");
  3. BackgroundJob.Enqueue(() => NotifyNewComment(model.Id));

And check it inside the background job:

  1. public static void NotifyNewComment(int commentId)
  2. {
  3. var currentCultureName = Thread.CurrentThread.CurrentCulture.Name;
  4. if (currentCultureName != "es-ES")
  5. {
  6. throw new InvalidOperationException(String.Format("Current culture is {0}", currentCultureName));
  7. }
  8. // ...

原文:
原文:

http://docs.hangfire.io/en/latest/tutorials/send-email.html