Messaging, Channels, Rules and Tokens - Part 3

<-- Read Part 2


In this part, we will be implementing the following:

  • A custom IActionProvider;
  • A custom IMessageEventHandler;
  • A custom IFormProvider;

In the previous part, we implemented a messaging part to hold our message templates and some parsers (SimpleText and Razor).
But having templates won't doing us any good if we can't use them to actually send messages. So how do we trigger them?

Rules

In Orchard, users can define rules, which consists of one or more events and one or more actions. When an event is triggered, the specified actions will be executed.

Example events are content items being published, versioned or removed. Example actions are displaying a notification or closing a comment.

Out of the box, the Orchard.Email module exposes an action that enables us to specify an email message to be sent to a configurable user.

What we need is a similar action. The primary difference will be that our version will allow the user to select which template to use and through which channel the message is to be sent.

Tokens

The Rules engine is another great example of how powerful Tokens are. Whenever code triggers an event, it can pass through a dictionary of objects, which are used as context for any tokens specified in any action.

For this module we won't have to implement custom tokens, but it is worth mentioning that Tokens are used not only by Autoroute, but by Rules as well (as well as with the new Workflow module coming in 1.7).

Messaging

Orchard comes with a messaging API where code can send messages without being tied to a specific channel. That means messages could be sent not only via SMTP, but HTTP just as easy to, for example, send messages via an SMS gateway, or an ESP such as BlueHornet.

The Messaging service class exposes a Send method, accepting a message type and a messaging channel, amongst a few other arguments (recipient and a dictionary).

When a message is sent, the Messaging service triggers an event which IMessageEventHandler implementations can act upon. This is where we will setup the message to be sent using the configured template parser.

Let's start with implementing a Rules action provider called TemplatedMessageActions.

TemplatedMessageActions

Our goal is to let the user define rules, select an event and an action that will send a message using a message template. To create a custom action, we need to implement IActionProvider, which lives somewhere in Orchard.Rules.

An IActionProvider has a single method: Describe.

When implementing this method, we need to provide a couple of things:

  • The name of the category for the actions provided
  • A category description
  • A list of "elements". Each element represents an action. In our case, we will provide one: the "SendTemplatedMessage" action.

An action element describes the following:

  • The action type (or technical name)
  • The action name (or friendly name)
  • The action description
  • The actual method to execute when the action is executed
  • A descriptive text to show to the user when the action is render in context of the rule
  • Optional: the name of the form to render when editing the action

Add a reference to Orchard.Rules, create a Rules folder and create a class called TemplatedMessageActions:

Rules/TemplatedMessageActions.cs:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Orchard.Environment.Extensions;
using Orchard.Localization;
using Orchard.Messaging.Services;
using Orchard.Rules.Models;
using Orchard.Rules.Services;
using Skywalker.Messaging.Helpers;
using Skywalker.Messaging.Models;
using Skywalker.Messaging.Services;
 
namespace Skywalker.Messaging.Rules {
    [OrchardFeature("Skywalker.Messaging.Rules")]
    public class TemplatedMessageActions : IActionProvider {
        private readonly IMessageManager _messageManager;
        private readonly IMessageTemplateService _messageTemplateService;
        public const string MessageType = "ActionTemplatedMessage";
 
        public TemplatedMessageActions(
            IMessageManager messageManagerIMessageTemplateService messageTemplateService) {
            _messageManager = messageManager;
            _messageTemplateService = messageTemplateService;
            T = NullLocalizer.Instance;
        }
 
        public Localizer T { getset; }
 
        public void Describe(DescribeActionContext describe) {
 
            describe.For("Messaging"T("Messaging"), T("Send Templated Messages"))
                .Element(
                    "SendTemplatedMessage"T("Send templated message"), T("Sends a templated message using a selected layout through the selected channel."), Send,
                    DislayAction"ActionTemplatedMessage");
        }
 
        private LocalizedString DislayAction(ActionContext context) {
            var layoutId = context.Properties.GetValue("TemplateId").AsInt32();
            var channel = context.Properties.GetValue("Channel"?? "unknown";
            var layout = layoutId != null ? _messageTemplateService.GetTemplate(layoutId.Value) : default(MessageTemplatePart);
            return T("Send a templated message using the [{0}] template and the [{1}] channel"layout != null ? layout.Title : "unkown"channel);
        }
 
        private bool Send(ActionContext context) {
            var channel = context.Properties.GetValue("Channel");
            var recipient = context.Properties.GetValue("Recipient");
            var dataTokens = ParseDataTokens(context.Properties);
 
            if (channel == null)
                throw new InvalidOperationException("No channel has been specified");
 
            if (recipient == null)
                throw new InvalidOperationException("No recipient has been specified");
 
            var properties = dataTokens.ToDictionary(x => x.Keyx => x.Value);
 
            foreach (var token in context.Tokens) {
                properties[token.Key= token.Value.ToString();
            }
 
            properties["Recipient"= recipient;
            properties["Channel"= context.Properties.GetValue("Channel");
            properties["TemplateId"= context.Properties.GetValue("TemplateId");
 
            _messageManager.Send(new[] { recipient }, MessageTypechannelproperties);
            return true;
        }
 
        private static IEnumerable<KeyValuePair<stringstring>> ParseDataTokens(IDictionary<stringstring> source) {
            var dictionary = new Dictionary<stringstring>();
            var text = source["DataTokens"];
 
            if (string.IsNullOrWhiteSpace(text))
                return dictionary;
 
            var pairs = Regex.Split(text"\\n"RegexOptions.Multiline);
            foreach (var pair in pairs) {
                var items = pair.Split(new[] { ':' }, 2);
 
                if (items.Length == 2) {
                    var key = items[0];
                    var value = items[1];
 
                    dictionary[key= value;
                }
            }
 
            return dictionary;
        }
    }
}

Notice that we introduced a new feature called "Skywalker.Messaging.Rules", which is defined as:

Module.txt (snippet):

Skywalker.Messaging.Rules:
        Name: Skywalker Messaging Rules
        Description: Provides a templated message action.
        Category: Rules
        Dependencies: Orchard.Rules, Skywalker.Messaging

Let's analyze the code a bit:

public void Describe(DescribeActionContext describe) {
 
    describe.For("Messaging"T("Messaging"), T("Send Templated Messages"))
        .Element(
            "SendTemplatedMessage"T("Send templated message"), T("Sends a templated message using a selected layout through the selected channel."), Send,
            DislayAction"ActionTemplatedMessage");
}

As mentioned, the Describe method is the only member to be implemented. We define a category and an action, for which we specify the address of a method called Send. We also specify a form named "ActionTemplateMessage", which we'll implement in a bit.

Let's look at DisplayAction next:

private LocalizedString DislayAction(ActionContext context) {
    var layoutId = context.Properties.GetValue("TemplateId").AsInt32();
    var channel = context.Properties.GetValue("Channel"?? "unknown";
    var layout = layoutId != null ? _messageTemplateService.GetTemplate(layoutId.Value) : default(MessageTemplatePart);
    return T("Send a templated message using the [{0}] template and the [{1}] channel"layout != null ? layout.Title : "unkown"channel);
}

DisplayAction is a private method we defined to help rendering the description of the action when it is displayed n the context of a rule. The context provides us with form values specified (which we'll implement in a minute). We're using these values to retrieve information about which template was selected and which channel to build the description string.

The meat of the action is the Send method, which looks like this:

private bool Send(ActionContext context) {
    var channel = context.Properties.GetValue("Channel");
    var recipient = context.Properties.GetValue("Recipient");
    var dataTokens = ParseDataTokens(context.Properties);
 
    if (channel == null)
        throw new InvalidOperationException("No channel has been specified");
 
    if (recipient == null)
        throw new InvalidOperationException("No recipient has been specified");
 
    var properties = dataTokens.ToDictionary(x => x.Keyx => x.Value);
 
    foreach (var token in context.Tokens) {
        properties[token.Key= token.Value.ToString();
    }
 
    properties["Recipient"= recipient;
    properties["Channel"= context.Properties.GetValue("Channel");
    properties["TemplateId"= context.Properties.GetValue("TemplateId");
 
    _messageManager.Send(new[] { recipient }, MessageTypechannelproperties);
    return true;
}

Again we're getting some form values, a few of which we copy into a dictionary (properties), and invoke the Send method of the message manager.

The ParseDataTokens is a private method that helps us parse a string of tokens (separated by new lines), which looks like this:

private static IEnumerable<KeyValuePair<stringstring>> ParseDataTokens(IDictionary<stringstring> source) {
    var dictionary = new Dictionary<stringstring>();
    var text = source["DataTokens"];
 
    if (string.IsNullOrWhiteSpace(text))
        return dictionary;
 
    var pairs = Regex.Split(text"\\n"RegexOptions.Multiline);
    foreach (var pair in pairs) {
        var items = pair.Split(new[] { ':' }, 2);
 
        if (items.Length == 2) {
            var key = items[0];
            var value = items[1];
 
            dictionary[key= value;
        }
    }
 
    return dictionary;
}

This enables users to include tokenized values with the message. These values can be used inside of the template. The way these values are accessed depends on the specific template parser. For the Razor Parser, we'll add these values to the ViewData dictionary.

IFormProvider

Orchard Rules takes advantage of the Forms API. This API let's us define a form using descriptions, without having to write markup. To implement a form, simply implement IFormProvider, which lives in Orchard.Forms.

The semantics are similar to IActionProvider in that it has one member to implement: Describe.

This method takes a context argument, which we use to describe our form, which includes defining what controls to render.

Let's see how that works and implement the editor for our action by implementing IFormProvider. Add a reference to Orchard.Forms and create a class called TemplatedMessageForms.

Rules/TemplatedMessageForms.cs:

using System;
using System.Linq;
using System.Web.Mvc;
using Orchard.DisplayManagement;
using Orchard.Environment.Extensions;
using Orchard.Forms.Services;
using Orchard.Localization;
using Orchard.Messaging.Services;
using Skywalker.Messaging.Services;
 
namespace Skywalker.Messaging.Rules {
    [OrchardFeature("Skywalker.Messaging.Rules")]
    public class TemplatedMessageForms : IFormProvider {
        private readonly IMessageTemplateService _messageTemplateService;
        private readonly IMessageManager _messageManager;
        protected dynamic Shape { getset; }
        public Localizer T { getset; }
 
        public TemplatedMessageForms(IShapeFactory shapeFactoryIMessageTemplateService messageTemplateServiceIMessageManager messageManager) {
            Shape = shapeFactory;
            _messageTemplateService = messageTemplateService;
            _messageManager = messageManager;
            T = NullLocalizer.Instance;
        }
 
        public void Describe(DescribeContext context) {
            Func<IShapeFactorydynamic> form =
                shape => Shape.Form(
                Id"ActionTemplatedMessage",
                _RecipientShape.TextBox(
                    Id"Recipient"Name"Recipient",
                    TitleT("Recipient"),
                    Description"The recipient's address"),
                    Classesnew[] { "textMedium""tokenized" },
                _TemplateIdShape.SelectList(
                    Id"TemplateId"Name"TemplateId",
                    TitleT("Template"),
                    DescriptionT("The template of the e-mail."),
                    Items_messageTemplateService.GetTemplates().Select(x => new SelectListItem { Text = x.TitleValue = x.Id.ToString() })
                    ),
                _ChannelShape.SelectList(
                    Id"Channel"Name"Channel",
                    TitleT("Channel"),
                    DescriptionT("The channel through which to send the message."),
                    Items_messageManager.GetAvailableChannelServices().Select(x => new SelectListItem { Text = xValue = x })
                    ),
                _DataTokensShape.TextArea(
                    Id"DataTokens"Name"DataTokens",
                    TitleT("Data Tokens"),
                    DescriptionT("Enter a key:value pair per line. Each key will become available as a property on the ViewBag of the Razor template. E.g. \"Order:{Order.OrderNumber}\" will create a \"Order\" property on the ViewBag"),
                    Classesnew[] { "textMedium" }
                    )
                );
 
            context.Form("ActionTemplatedMessage"form);
        }
    }
}

Essentially, all we're doing is defining a form factory method assigned to a local variable called form, where we create a Form shape. This shape contains child shapes, representing the various form elements to be rendered. To find out which elements are available and what properties they take, have a look at the EditorShapes class in Orchard.Forms/Shapes.

At the end of the method, we invoke the Form method on the context argument, specifying the name of the form (which we use to reference the form from our action provider), and the form factory method.

Let's see how our action and form look by creating a test rule called Test, using a Published event and our SendTemplatedMessage action. Be sure to enable the Skywalker.Messaging.Rules feature.

1. Enable Skywalker Messaging Rules

2. Create a new rule called Test:

3. Add the "Content Published" event for the "Page" type:

4. Add our new "Send Templated Message" action (be sure to enable Orchard.Email first so we can use the "email" channel:

And that's all it takes to implement a custom action provider and form provider.

Next, we'll implement IMessageEventHandler so that the selected template is processed and the output used as the message's body, which will be used by the selected channel ("email" in this example).

IMessageEventHandler

Whenever the Send method of IMessageManager is invoked, two methods on all IMessageEventHandler implementations are invoked: Sending and Sent.

The beauty of this loosly coupled design is that it makes it easy to completely control what is being sent. IMessageManager doesn't care; all it does is invoking said methods, and invoking the SendMessage on available channels.

In our case, we want to send a message from our custom action provider using the selected message template. We will be leveraging the Tokenizer to support tokenized message subjects, so let's add a reference to Orchard.Tokens.

NExt, create a new TemplatedMessageHandler class in the Services folder with the following code:

Services/TemplatedMessageHandler.cs:

using System.Web;
using Orchard;
using Orchard.Environment.Extensions;
using Orchard.Messaging.Events;
using Orchard.Messaging.Models;
using Orchard.Tokens;
using Skywalker.Messaging.Models;
using Skywalker.Messaging.Rules;
 
namespace Skywalker.Messaging.Services {
    [OrchardFeature("Skywalker.Messaging")]
    public class TemplatedMessageHandler : IMessageEventHandler {
        private readonly IMessageTemplateService _messageTemplateService;
        private readonly ITokenizer _tokenizer;
        private readonly IOrchardServices _services;
 
        public TemplatedMessageHandler(IMessageTemplateService messageTemplateServiceITokenizer tokenizerIOrchardServices services) {
            _messageTemplateService = messageTemplateService;
            _tokenizer = tokenizer;
            _services = services;
        }
 
        public void Sending(MessageContext context) {
            if (context.MessagePrepared || context.Type != TemplatedMessageActions.MessageType)
                return;
 
            var templateId = int.Parse(context.Properties["TemplateId"]);
            var template = _messageTemplateService.GetTemplate(templateId);
            var body = _messageTemplateService.ParseTemplate(templatenew ParseTemplateContext {
                ViewBag = context.Properties
            });
 
            context.MailMessage.Subject = _tokenizer.Replace(template.Subjectcontext.PropertiesReplaceOptions.Default);
            context.MailMessage.Body = body;
            context.MessagePrepared = true;
            context.Properties["BaseUrl"= VirtualPathUtility.RemoveTrailingSlash(_services.WorkContext.CurrentSite.BaseUrl);
        }
 
        public void Sent(MessageContext context) {
        }
    }
}

Essentially, we are doing the following things:

  • First we check if the message was already marked as prepared by another message handler, in which case we exit early. We also exit early of the specified message type does not match the mesage type we want to handle.
  • Retrieving the TemplateId which was passed as part of the properties dictionary when Send was invoked on the IMessageManager instance from our custom action provider.
  • Using this template, we ask the IMessageTemplateService to please parse that template. Notice how we are setting the properties dictionary to be the ViewBag for the template. This is key to providing data to the template.
  • Using the tokenizer, we are processing the specified Subject using the Properties dictionary as the context. This way we can configure subjects like: "{Owner} published some content". NOTE: Unfortunately, MessageContext currently supports a string dictionary only, which means we can't pass through objects (for which token providers exist). There is a work item to change this, but for the time being you will ether have to apply the patch (attached to the work item), or don't use the tokenizer, but instead, replace keys with dictionary values (the Data Tokens field configured with the custom rules action will have been processed at this point).
  • We're adding a value to the Properties dictionary called "BaseUrl", which is generally useful in any emai ltemplate when you want to render fully qualified urls.

And the messaging channel takes care of the rest. In case of the email channel, the message gets sent via SMTP.

Before you try out and trigger an email, be sure to enable the Test rule we created earlier:

To trigger that rule, simply go and edit a Page content item and publish it.

However, before you do that, we still need to implement the ParseTemplate method of the MessageTemplateService class:

Services/MessageTemplateService.cs (snippet):

public string ParseTemplate(MessageTemplatePart templateParseTemplateContext context) {
    throw new System.NotImplementedException();
}

Replace that code with:

public string ParseTemplate(MessageTemplatePart templateParseTemplateContext context) {
    var parser = SelectParser(template);
    return parser.ParseTemplate(templatecontext);
}

And that's it for coding! Now let's try it out.

Trying it out: The Master Template

Let's create a new Email Template called "Master Layout". This will act as the master page for all of our email templates (we'll only create one for this demo though).

We'll be using Razor syntax, so be sure to configure Email Template to use that parser, or set the Razor Parser to be the default via the Messaging site settings.

Trying it out: The "Content Published" Notification Email Template

Now that we have a master page, let's create a child page called "Content Published":

Notice that we're accessing two properties of the ViewBag: Author and ContentUrl. These values come from the Data Tokens field of our Send Templated Message action, which we'll configure next as part of the "Content Published" rule.

Trying it out: Setting Up The "Content Published" Rule

  1. Go to Rules and click the "Add a new Rule" button. For the new rule, enter the name "Content Published" and hit "Save".
  2. Click the "Add a new Event" button and select the "Content Published" event. For the Content Type, select "Page" and hit "Save".
  3. Click the "Add a new Action" button and select the "Send templated message" action.
    For "Recipient", enter "{Content.Author.Email}".
    For "Template", select "Content Published" (this is the email template we just created).
    For "Channel", select "email" (the only available channel at this point).
    For "Data Tokens", add the following two lines:
        Author: {Content.Author}
        ContentUrl: {Content.DisplayUrl.Absolute}
    Notice that we are referencing these two keys in our Email Template via the ViewBag property.
  4. Hit save.
  5. Finally, hit "Save and Enable"

Trying it out: Publishing a Page And Receiving An Email Notification

To trigger an email, let's go and edit the Homepage and publish it:

Now, as soon as you hit publish (and have setup your SMTP server correctly), you will receive an email:

Note: please ensure that the user you're logged in with has an email address specified, or else you will not receive an email.

I'm using the smtp4dev SMTP server, which is great for testing sending email messages via SMTP.

Conslusion

And that's it! We now have a nice, flexible way to setup email templates, enabling us to use Razor syntax and use Tokens to provide data to that template.

When 1.7 comes out, we'll implement a workflow activity replacing our custom rules actions provider. Rules are cool, but Workflows are hot.

Download the source code: Skywalker.Messaging.Part3.zip.

6 comments

  • ... Posted by JP Tissot Posted 11/07/2014 07:35 AM

    Hi Spike, Great article as always.

    Did you ever get around to implemeting the workflows ? If not, I will have to do it for a project and would like to give you the source. Let me know.

  • ... Posted by Italo Posted 11/07/2014 07:36 AM

    Great Article! The link to the zip file is broken. Thanks!

  • ... Posted by Sipke Schoorstra Posted 11/07/2014 07:36 AM [http://www.ideliverable.com]

    Sorry about that. It's fixed. Thanks for reporting!

  • ... Posted by Jeremy Posted 11/07/2014 07:36 AM

    No access to the zip file :(. Good example though, there is really a lot you can do with this type of system in terms of templated e-mails

  • ... Posted by Michael Posted 11/07/2014 07:37 AM

    This is great stuff! Thank you for providing such useful examples for Orchard. Can you use the IFormProvider for the front-end? If so, and as a future request for another series of great posts from you that I think might help a lot of people, it would be great if we could see examples of each type of form input, including checkbox lists and radio button lists. I think uniformity in usage helps a lot. Thanks, again!

  • ... Posted by Sipke Schoorstra Posted 11/07/2014 07:37 AM [http://www.ideliverable.com]

    You certainly could! I have been wanting to write some documentation and samples on the Forms API, and your question about using it on the front-end would make for an interesting new post. Thanks!

Leave a comment