After all partial views are rendered and all styles are inlined my RESTful service is sending them via email. I implemented this feature with a MailerMiddleware
.
This middleware dumps the response body and uses it as the body of the email. I pass the recipients and the subject via the HttpContext.Items
property from the controller to the middleware. It uses the <system.net>
element in the app.config
for sending emails.
public class MailerMiddleware
{
private readonly RequestDelegate _next;
private readonly IEmailClient _emailClient;
public MailerMiddleware(RequestDelegate next, IEmailClient emailClient)
{
_next = next;
_emailClient = emailClient;
}
public async Task Invoke(HttpContext context)
{
if (context.Request.Method == "POST")
{
var bodyBackup = context.Response.Body;
using (var memory = new MemoryStream())
{
context.Response.Body = memory;
await _next(context);
memory.Seek(0, SeekOrigin.Begin);
using (var reader = new StreamReader(memory))
{
var recipients = (string)context.Items["Recipients"];
var subject = (string)context.Items["Subject"];
var body = await reader.ReadToEndAsync();
memory.Seek(0, SeekOrigin.Begin);
var restoreBody = memory.CopyToAsync(bodyBackup);
var sendEmail = _emailClient.SendAsync(new Email<EmailSubject, EmailBody>
{
To = recipients,
Subject = new PlainTextSubject(subject),
Body = new ParialViewEmailBody(body),
});
await Task.WhenAll(restoreBody, sendEmail);
}
}
}
else
{
await _next(context);
}
}
}
Inside the action method:
[HttpPost("TestReport")] [ActionName("TestReport")] public IActionResult PostTestReport([FromBody] TestReportBody body) { HttpContext.Items["Recipients"] = "[email protected]"; // todo use body HttpContext.Items["Subject"] = "Test email"; // todo use body return PartialView(body); }
This solution is working great but is there anything that could still be done better?
2 Answers 2
Common practice with custom middleware when manipulating the response body is to replace original stream with one you can rewind as once you start writing to the original stream everything is sent to the client.
You should put the backup body back into the context response so header values like content length can be calculated accurately. A disposed stream was being left in the response.
public async Task Invoke(HttpContext context) {
if (context.Request.Method == "POST") {
// Hold on to original body for downstream calls
var originalBody = context.Response.Body;
// buffer the response stream in order to intercept writes in the pipeline
using (var responseBuffer = new MemoryStream()) {
// replace stream
context.Response.Body = responseBuffer;
// Call the next delegate/middleware in the pipeline
await _next(context);
// buffer now holds the response data
var recipients = (string)context.Items["Recipients"];
var subject = (string)context.Items["Subject"];
var body = string.Empty;
// rewind buffer to read data written while upstream
responseBuffer.Seek(0, SeekOrigin.Begin);
using (var reader = new StreamReader(responseBuffer)) {
body = await reader.ReadToEndAsync();
}
// rewind buffer again to copy data to original stream
responseBuffer.Seek(0, SeekOrigin.Begin);
var restoreBody = responseBuffer.CopyToAsync(originalBody);
var sendEmail = _emailClient.SendAsync(new Email<EmailSubject, EmailBody> {
To = recipients,
Subject = new PlainTextSubject(subject),
Body = new ParialViewEmailBody(body),
});
await Task.WhenAll(restoreBody, sendEmail);
// and finally, reset the stream for the
// previous delegate/middleware in the pipeline
context.Response.Body = originalBody;
}
} else {
await _next(context);
}
}
-
\$\begingroup\$ Now I see my mistake. Making a bakup of the original stream but not restoring it. Also your variable names are much better. Thanks! ;-) \$\endgroup\$t3chb0t– t3chb0t2018年02月27日 05:03:04 +00:00Commented Feb 27, 2018 at 5:03
-
\$\begingroup\$ I've posted an improved version. \$\endgroup\$t3chb0t– t3chb0t2018年03月04日 10:44:16 +00:00Commented Mar 4, 2018 at 10:44
Implementing Nkosi's suggestions was a great step forward. This way the client can also recieve the response. However, there was one more thing that could be greatly improved. While reading about Queued background tasks I realized that this is exactly what I need to speed up my middleware becasue sending emails made it hang for a short time. Queueing the task in a background service allows the middleware to return immediately and take its time for emails.
I adjusted the example code for my needs by not using a logger here. Instead I placed the Debug.Fail
inside the catch
clause (and changed a couple of names).
public interface IWorkItemQueue
{
void Enqueue(Func<CancellationToken, Task> workItem);
Task<Func<CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken);
}
public class WorkItemQueue : IWorkItemQueue
{
private readonly ConcurrentQueue<Func<CancellationToken, Task>> _workItemQueue = new ConcurrentQueue<Func<CancellationToken, Task>>();
private readonly SemaphoreSlim _signal = new SemaphoreSlim(0);
public void Enqueue(Func<CancellationToken, Task> workItem)
{
if (workItem == null) { throw new ArgumentNullException(nameof(workItem)); }
_workItemQueue.Enqueue(workItem);
_signal.Release();
}
public async Task<Func<CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken)
{
await _signal.WaitAsync(cancellationToken);
_workItemQueue.TryDequeue(out var workItem);
return workItem;
}
}
public class WorkItemQueueService : IHostedService
{
private readonly IWorkItemQueue _workItemQueue;
private readonly CancellationTokenSource _shutdown = new CancellationTokenSource();
private Task _backgroundTask;
public WorkItemQueueService(IWorkItemQueue workItemQueue)
{
_workItemQueue = workItemQueue;
}
#region IHostedService
public Task StartAsync(CancellationToken cancellationToken)
{
// ReSharper disable once MethodSupportsCancellation - this task is not supposted to be cancelled until shutdown
_backgroundTask = Task.Run(BackgroundProceessing);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_shutdown.Cancel();
return Task.WhenAny(_backgroundTask, Task.Delay(Timeout.Infinite, cancellationToken));
}
#endregion
public void Enqueue(Func<CancellationToken, Task> workItem)
{
_workItemQueue.Enqueue(workItem);
}
private async Task BackgroundProceessing()
{
while (!_shutdown.IsCancellationRequested)
{
var workItem = await _workItemQueue.DequeueAsync(_shutdown.Token);
try
{
await workItem(_shutdown.Token);
}
catch (Exception)
{
Debug.Fail("Work item should handle its own exceptions.");
}
}
}
}
The updated MailerMiddleware
now enqueues tasks sending emails on that queue so that the service can handle them later and also handles its own exceptions.
public class MailerMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
private readonly IWorkItemQueue _workItemQueue;
private readonly IEmailClient _emailClient;
public MailerMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IWorkItemQueue workItemQueue, IEmailClient emailClient)
{
_next = next;
_logger = loggerFactory.CreateLogger<MailerMiddleware>();
_workItemQueue = workItemQueue;
_emailClient = emailClient;
}
public async Task Invoke(HttpContext context)
{
var bodyBackup = context.Response.Body;
using (var memory = new MemoryStream())
{
context.Response.Body = memory;
await _next(context);
memory.Seek(0, SeekOrigin.Begin);
using (var reader = new StreamReader(memory))
{
var body = await reader.ReadToEndAsync();
var email = context.Email();
if (!(email.To is null || email.Subject is null))
{
_workItemQueue.Enqueue(async cancellationToken =>
{
try
{
await _emailClient.SendAsync(new Email<EmailSubject, EmailBody>
{
To = email.To,
Subject = new PlainTextSubject(email.Subject),
Body = new ParialViewEmailBody(body),
});
}
catch (Exception ex)
{
_logger.Log(Abstraction.Layer.Network().Action().Failed(nameof(IEmailClient.SendAsync)), ex);
}
});
}
// Restore Response.Body
memory.Seek(0, SeekOrigin.Begin);
await memory.CopyToAsync(bodyBackup);
context.Response.Body = bodyBackup;
}
}
}
private class ParialViewEmailBody : EmailBody
{
private readonly string _body;
public ParialViewEmailBody(string body)
{
_body = body;
IsHtml = true;
Encoding = System.Text.Encoding.UTF8;
}
public override string ToString()
{
return _body;
}
}
}
ASP.NET-Core is really cool ;-)
-
\$\begingroup\$ This is a very good improvement over the initial suggestion provided. Queuing the task was well thought out and would definitely improve the response time. I like this code. \$\endgroup\$Nkosi– Nkosi2018年03月04日 12:39:30 +00:00Commented Mar 4, 2018 at 12:39
-
\$\begingroup\$ Curious though. Why use
ContinueWith
for theEnqueue
work item function instead of atry catch
wrapping anawait
?Enqueue
takes a function returning a task which would mean it can be awaited. \$\endgroup\$Nkosi– Nkosi2018年03月04日 12:45:30 +00:00Commented Mar 4, 2018 at 12:45 -
\$\begingroup\$ @Nkosi I guess it's because how
Task
s work (still a little bit confusing to me). I initially had atry/catch
aroundSendAsync
there but this did not work. A test exception was risen in the queue-processor and theDebug.Fail
was executed. The exception got out of thetry/catch
and ended up behing handled byDequeueAsync
. \$\endgroup\$t3chb0t– t3chb0t2018年03月04日 12:49:48 +00:00Commented Mar 4, 2018 at 12:49 -
1\$\begingroup\$ @Nkosi ok, I got it working without
ContinueWith
(and updated the code). My mistake was to return theTask
forSendAsync
instead of awaiting it. \$\endgroup\$t3chb0t– t3chb0t2018年03月04日 15:47:20 +00:00Commented Mar 4, 2018 at 15:47 -
1\$\begingroup\$ Here is a nice recent video on async await that I believe you will find interesting. Youtube - On.Net async/await best practices \$\endgroup\$Nkosi– Nkosi2018年03月04日 15:52:06 +00:00Commented Mar 4, 2018 at 15:52