Update 2008-05-21: Mark Deraeve left a comment about a much better solution… changing the group permissions to let SPD send the email natively. Thanks Mark! I’ve kept the rest of the post intact as it does give a walkthrough of creating custom designer actions.
As you might be able to tell from my some of my recent posts, I have been mucking around with a SharePoint Designer (SPD) workflow. This post serves as a complete example, building on some of the information previously posted:
- Emailing form links using SharePoint Designer
- Custom SharePoint Designer Actions using VS 2005, which links to two examples of creating an action to send emails to multiple recipients
- SharePoint aware workflow activities by passing WorkflowContext to a custom activity
- Referencing the SharePoint DLLs (WSS 3.0 / MOSS 2007)
- Using an XSD for .ACTIONS file Intellisense, as well as another custom activity example, this time writing a parameter to the event log.
- In addition to that research, a fair amount of the “bludgeoning myself into a semi-comatose state using my laptop” technique was employed to dull the pain :-)
Background
We had managed to create a complete document approval workflow using only MOSS 2007 and SPD (part of project manager’s aim for the project was to see how much could be done without coding). The workflow assigned tasks to a single user or a site group by looking up a custom list that mapped a field from the document list to the required approver/approvers. The aim was then to create a very simple notification workflow to email the user or group that was assigned the task to let them know they had work to do. The Send Email task worked great for single users, but failed horribly when the task’s AssignedTo property was a group.
After a bit of research, talking to partners, and laptop bludgeoning, it was finally decided to implement this tiny bit of functionality using a custom task.
False start
The first attempt was to create a custom email task that would send to multiple recipients. This took a lot of mucking around. It sort of worked. It would send the appropriate email, but as Todd Baginski noted it would not replace the lookup fields in the email body with the correct values. So I decided to try making a custom activity to lookup the user or group matching the AssignedTo property, and return the relevant email addresses to a workflow variable using an output parameter.
The GetEmailAddressActivity
The first step in coding our activity is to expose the required properties, which is what theGetEmailAddressActivity
class does. It delegates the real work to the NameToEmailResolver
class (covered below).using System;
using System.Workflow.Activities;
using System.Workflow.ComponentModel;
using System.Workflow.ComponentModel.Compiler;
using Microsoft.SharePoint.WorkflowActions;
namespace MyCustomActivityLibrary {
public partial class GetEmailAddressActivity : SequenceActivity {
public GetEmailAddressActivity() {
InitializeComponent();
}
public static DependencyProperty NameToLookupProperty =
DependencyProperty.Register("NameToLookup", typeof(String), typeof(GetEmailAddressActivity));
public static DependencyProperty TargetProperty =
DependencyProperty.Register("Target", typeof(String), typeof(GetEmailAddressActivity));
public static DependencyProperty __ContextProperty =
DependencyProperty.Register("__Context", typeof(WorkflowContext), typeof(GetEmailAddressActivity));
[ValidationOption(ValidationOption.Required)]
public string NameToLookup {
get { return (string) base.GetValue(NameToLookupProperty); }
set { base.SetValue(NameToLookupProperty, value); }
}
[ValidationOption(ValidationOption.Required)]
public string Target {
get { return (string) base.GetValue(TargetProperty); }
set { base.SetValue(TargetProperty, value); }
}
[ValidationOption(ValidationOption.Required)]
public WorkflowContext __Context {
get {
return (WorkflowContext) base.GetValue(__ContextProperty);
}
set { base.SetValue(__ContextProperty, value); }
}
protected override ActivityExecutionStatus Execute(ActivityExecutionContext executionContext) {
NameToEmailResolver resolver = new NameToEmailResolver(__Context);
Target = resolver.GetEmailAddressesFromName(NameToLookup);
return ActivityExecutionStatus.Closed;
}
}
}
NameToEmailResolver
Here is where the real work is done. It takes the a name, which could be a user’s login name or a site group name, and puts it into a semi-colon delimited string of email addresses. If you wanted to provide a wrapper around the WorkflowContext this would be a great place to insert some unit tests.
using System;
using System.Collections.Generic;
using System.Text;
using Microsoft.SharePoint;
using Microsoft.SharePoint.WorkflowActions;
namespace MyCustomActivityLibrary {
public class NameToEmailResolver {
private WorkflowContext context;
public NameToEmailResolver(WorkflowContext context) {
this.context = context;
}
public String GetEmailAddressesFromName(String name) {
StringBuilder resolvedAddresses = new StringBuilder();
IEnumerableaddresses = getEmailAddressesFromName(name);
foreach (string address in addresses) {
if (!String.IsNullOrEmpty(address)) {
resolvedAddresses.AppendFormat("{0};", address);
}
}
return resolvedAddresses.ToString();
}
private IEnumerable<string> getEmailAddressesFromName(string name) {
SPGroup group = getGroup(name);
if (group != null) {
return getEmailAddressesFromGroup(group);
}
SPUser user = getUser(name);
if (user != null) {
return new string[] {user.Email};
}
return new string[0];
}
private IEnumerable<string> getEmailAddressesFromGroup(SPGroup group) {
List<string> addresses = new List();
foreach (SPUser user in group.Users) {
addresses.Add(user.Email);
}
return addresses;
}
private SPGroup getGroup(string name) {
foreach (SPGroup group in context.Web.SiteGroups) {
if (String.Equals(group.Name, name, StringComparison.InvariantCultureIgnoreCase)) {
return group;
}
}
return null;
}
private SPUser getUser(string name) {
foreach (SPUser user in context.Web.SiteUsers) {
if (String.Equals(user.LoginName, name, StringComparison.InvariantCultureIgnoreCase)) {
return user;
}
}
return null;
}
}
}
Create .ACTIONS file
To get SPD to recognise your activity you need an .ACTIONS file.
<?xml version="1.0"?>The tricky parts (well, for me) was the DesignerType and OperatorTypeFrom values. The current values give you the lookup for the NameToLookup value, and the workflow variables UI selector for the Target parameter. The final parameter is the WorkflowContext.
<workflowinfo language="en-us">
<!--
.ACTIONS files live here:
C:\Program Files\Common Files\Microsoft Shared\web server extensions\12\TEMPLATE\1033\Workflow
Recognised after iisreset.
-->
<actions>
<action name="Store Email Address" classname="MyCustomActivityLibrary.GetEmailAddressActivity" assembly="MyCustomActivityLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=<strong>(your token here)</strong>" appliesto="all" category="My Custom Activities">
<ruledesigner sentence="Store email address for %1 in %2.">
<fieldbind id="1" field="NameToLookup" text="this user or group" designertype="TextArea" operatortypefrom="FieldName">
<fieldbind id="2" field="Target" text="variable" designertype="ParameterNames" operatortypefrom="Variable">
</ruledesigner>
<parameters>
<parameter name="NameToLookup" type="System.String, mscorlib" direction="In">
<parameter name="Target" type="System.String, mscorlib" direction="Out">
<parameter name="__Context" type="Microsoft.SharePoint.WorkflowActions.WorkflowContext, Microsoft.SharePoint.WorkflowActions" direction="In">
</parameters>
</action>
</actions>
</workflowinfo>
Deployment
You need to install the DLL containing your activity into the GAC on your WSS/MOSS server, which means your assembly needs to be signed/strongly named. Step 7 of Lee’s post has a good run down of how to do this if you are unfamiliar with it (in fact he covers the whole deplyoment thing really well). You can then drag your DLL into the GAC (C:\WINNT\ASSEMBLY). You also need to copy your .ACTIONS file to the …12\TEMPLATE\1033\Workflow directory. Finally you need to tell SPD to trust the DLL in the application’s web.config file. Here is the XML fragment:
<?xml version="1.0"?>
<!-- Add authorised type to relevant web.config:
C:\Inetpub\wwwroot\wss\VirtualDirectories\(dir for app)\web.config
-->
<configuration>
<System.Workflow.ComponentModel.WorkflowCompiler>
<authorizedTypes>
<authorizedType Assembly="MyCustomActivityLibrary, Version=1.0.0.0, Culture=neutral, PublicKeyToken=(your public key)"
Namespace="MyCustomActivityLibrary" TypeName="*" Authorized="True" />
</authorizedTypes>
</System.Workflow.ComponentModel.WorkflowCompiler>
</configuration>
After an iisreset your activity is ready to go.
Setting up the workflow in SPD
The final step is the SPD workflow itself. Create a new workflow and attach it to your task list, to be started whenever a new task is created.
The first task is to initialise the relevant variables. In the screen shot below you can see the custom task highlighted / hovered over. Our custom task will map the name in Tasks:AssignedTo to a single email (when assigned to a single user), or a semi-colon delimited list of emails (when assigned to a group), and will then store this value in a variable.
The final step in the workflow is to use the standard email task, but set the To: field to our workflow variable initialised in the previous step.
Finished!
The final result? You can assign a task to a single user or a site group in a different workflow, then ensure all users get notified when a task is assigned to them or a group they are in. A few disclaimers: there is no error handling in the code; the code will almost definitely break when a multi-valued field is used for the name (although this should be straight forward to support with a few modifications); the code is not particularly pretty and probably violates several laws of nature; the example is meant to be illustrative only and not fit for any specific purpose (i.e. you’re crazy if you use it); and my head has a laptop-shaped bruise in it.
Hope this helps someone!