SharePoint Advise One Another (A1A)

I have often been asked about changing the Alerts that are sent from SharePoint when a list item is updated. By far the most frequent is the ability to add a comment or instructions in the email message that is sent. There are many different ways that this request can be accomplished, but I’m including a solution that I consider to be light-weight and easy to understand and customize.

Scenario

If a user wants to advise another, relying on the alert system is not sufficient. The user may not have subscribed to the list. Even if the user is subscribed, the frequency of their subscription may be daily or weekly. Those frequencies may be acceptable, but there is no way to determine the setting, thus making it difficult to set expectations for a response.

The solution I’ve written is designed to send emails to a specific site user with a user-entered message. To avoid confusion with the built-in functionality, I named it “Advise One Another (A1A)” and I’m sure that those of you who know me will know what inspired the name.

Solution

The core of the solution is an ItemUpdating event receiver and columns added to the list. The receiver will inspect the values of the fields and send an email if appropriate. The receiver will alter the fields values before that are saved, resetting them to blank. This will keep the contents of the message private.

List columns

At first glance, it would seem that a content type is appropriate. We are defining columns and behavior for a list, which is what content types are for. However, this solution is not designed to be part of an information management hierarchy. Making the list item use a content type for sending emails will obfuscate the true meaning of the item/document. To avoid this issue, the solution will add the necessary columns directly to the list. This solution has the benefit of making these columns available to all items, regardless of the content type.

The columns added to the list:

Title Data Type
Send Email Yes/No
Mail To User
CC User
Message Note

I am not putting the fields into the Site Column gallery. I am concerned that end users who are not aware of these technical details will add the columns and expect the email functionality to work. But, as a bonus, I have code that provides some good examples for the SPList object in the server API.

Adding the columns

Now that we know the necessary columns, we need to get them added to the list. The obvious place for this is a Feature Activated receiver. But to which list should we add the columns? For now, we are going to add the fields to any list based on the Tasks template. Below is a sample feature event receiver to discover lists based on the Tasks template and add the columns to those lists.

public override void FeatureActivated  
                 (SPFeatureReceiverProperties properties)
{
  logger =
    SharePointServiceLocator.GetCurrent().GetInstance<ILogger>();
  configManager =
    SharePointServiceLocator.GetCurrent().GetInstance<IConfigManager>();

  try
  {
    logger.TraceToDeveloper(
      "enter BindToTasksEventReceiver::FeatureActivated", logCategory);  
    SPWeb web = properties.Feature.Parent as SPWeb;

    configManager.SetWeb(web);
    bag = configManager.GetPropertyBag(ConfigLevel.CurrentSPWeb);

    // ensure that task lists have the required fields
    foreach (SPList list in web.Lists)
    {
      if (list.BaseTemplate == SPListTemplateType.Tasks)
      {
        logger.TraceToDeveloper(
          String.Format("found list {0}", list.Title), logCategory);
        EnsureColumnnsPresent(list);
      }
    }
  }
  catch (Exception ex)
  {
    logger.TraceToDeveloper(
      ex, "An error occurred", 0,
      SandboxTraceSeverity.Unexpected, logCategory);
  }
  finally
  {
    logger.TraceToDeveloper(
      "exit BindToTasksEventReceiver::FeatureActivated", logCategory);
  }
}

The identification of the appropriate lists is performed via the BaseTemplate property of the list. The SPListTemplateType enumeration contains entries for the out of the box templates – if you are targeting a custom list template you will need to specify its template id.

The EnsureColumsPresent () method does the actual work of adding the columns to the list. There is one major design concern here. Users can create columns thru the browser, and it is possible that they will use the field name that we want. (In fact, if a power user tried to solve this problem before engaging a developer, it is likely that the columns will exist.) Typically, you would specify an Internal or Static name for the column in the XML of the field definition. However, we are not using XML. To add a column to a list you must use the Add() method of the SPFieldCollection class. None of the overrides of the Add method provide for the internal name. The internal name is generated based on the display name, and is returned by the method.

So, we need to store the internal name of the fields somewhere. The Event Receiver will need the field name to pull the values. The obvious place to store this is in the property bag of the SPWeb object. I choose; however, to use the Application Settings Manager functionality of the SharePoint Guidance from Microsoft’s patterns and practices group. The guidance also includes helper classes for logging to SharePoint’s unified logging system (ULS). The EnsureColumnsPresent method is shown below.

private void EnsureColumnnsPresent(SPList list)  
{
  string key = default(string);
  string fldInternalName = default(string);

  foreach (FieldCreationInfo fldInfo in Constants.RequiredFields)
  {
    key = list.ID.ToString() + ":" + fldInfo.ConfigurationKey;
    if (!configManager.ContainsKeyInPropertyBag(key, bag))
    {
      fldInternalName =
        FieldCreationHelper.CreateField(list, fldInfo);
      logger.TraceToDeveloper(
        String.Format("added field {0}:{1}",
                      fldInfo.DisplayName, fldInternalName),
        logCategory);
      configManager.SetInPropertyBag(key, fldInternalName, bag);
    }
    else
    {
      fldInternalName =
        configManager.GetFromPropertyBag<string>(key, bag);
      logger.TraceToDeveloper(
        String.Format("field exists {0}:{1}",
                      fldInfo.DisplayName, fldInternalName),
        logCategory);
    }
  }
}

Once the feature is activated, the internal names of the fields are stored in configuration, with the list Id prepended to the configuration key.

Removing the columns

When writing any SharePoint feature, we should handle the case when the feature is deactivated. The general rule that the SharePoint product follows is to remove components, but not content. (Data loss is the biggest no-no.) Our feature has no content, but we should remove the columns from any list to which we added them. The FeatureDeactivating code to do just that:

public override void FeatureDeactivating  
                 (SPFeatureReceiverProperties properties)
{
  logger =
    SharePointServiceLocator.GetCurrent().GetInstance<ILogger>();

  try
  {
    logger.TraceToDeveloper(
      "enter BindToTasksEventReceiver::FeatureDeactivating",
      logCategory);
    SPWeb web = properties.Feature.Parent as SPWeb;

    // ensure that task lists have the required fields
    foreach (SPList list in web.Lists)
    {
      if (list.BaseTemplate == SPListTemplateType.Tasks)
      {
        logger.TraceToDeveloper
          (String.Format("found list {0}", list.Title), logCategory);

        FieldInternalNames fldNames =
          FieldCreationHelper.GetFieldInternalNamesFromConfiguration(list);
        logger.TraceToDeveloper(
          RemoveColumn(list, fldNames.SendEmailFieldname), logCategory);
        logger.TraceToDeveloper(
          RemoveColumn(list, fldNames.MailToFieldname), logCategory);
        logger.TraceToDeveloper(
          RemoveColumn(list, fldNames.CCFieldname), logCategory);
        logger.TraceToDeveloper(
          RemoveColumn(list, fldNames.MessageFieldname), logCategory);
        FieldCreationHelper.RemoveFieldInternalNamesFromConfiguration(list);
      }
    }
  }
  catch (Exception ex)
  {
    logger.TraceToDeveloper(ex, "An error occurred", 0,
      SandboxTraceSeverity.Unexpected, logCategory);
  }
  finally
  {
    logger.TraceToDeveloper(
      "exit BindToTasksEventReceiver::FeatureDeactivating", logCategory);
  }
}

private string RemoveColumn(SPList list, string fieldName)  
{
  string results = string.Empty;
  try
  {
    list.Fields.Delete(fieldName);
    results = String.Format("field '{0}' deleted", fieldName);
  }
  catch (ArgumentException)
  {
    results = String.Format("field '{0}' not found", fieldName);
  }
  catch (Exception)
  {
    results = String.Format("field '{0}' cannot be deleted.");
  }
  return results;
}

Field Creation Helpers

The EnsureColumnsPresent method relies on a helper library named FieldCreationHelpers. The first object in this library is FieldCreationInfo. This is a data-only class used to define the required properties of the created field. The CreateField method will actually create the field, correctly attaching any lookup fields to the appropriate list. (The A1A solution does not use lookup fields.)

Lastly the library contains GetFieldInternalNamesFromConfiguration. This method encapsulates the call to p&p configuration, returning the existing column names our event receiver will need.

Event Receiver

Since we are limiting our functionality to a single list type, we can use the Receivers element to attach our event receiver. Our scenario requires that the mail fields should not be saved, so our receiver hooks into the ItemUpdating event. The sequence number is very low, since we want our receiver to run as soon as possible. Any receiver running before ours will have access to the mail fields. The Elements.xml that defines our receiver:

<Elements xmlns="http://schemas.microsoft.com/sharepoint/">  
  <Receivers ListTemplateId="107">
    <Receiver>
      <Name>TaskItemSendEmailReceiverItemUpdating</Name>
      <Type>ItemUpdating</Type>
      <Assembly>$SharePoint.Project.AssemblyFullName$</Assembly>
      <Class>
        Schaeflein...TaskItemSendEmailReceiver
      </Class>
      <SequenceNumber>10000</SequenceNumber>
    </Receiver>
  </Receivers>
</Elements>  

Again, our example attaches to all task lists, so the ListTemplateId attribute is set to the appropriate ListTemplateId.

The code for the receiver is pretty straightforward. It extracts the values from the fields, and if necessary sends the message using the SPUtility.SendEmail function. It then sets the AfterProperties to blanks, so that the values are not persisted to the list. One other interesting section of code is the building of the list item display url:

string listItemUrl = String.Format("{0}/{1}?ID={2}",  
                       properties.WebUrl,
                       properties.List.Forms[PAGETYPE.PAGE_DISPLAYFORM].Url,
                       properties.ListItem.ID);

Using the Forms property of the list will ensure that the default display form is used – even if the forms have been modified via SharePoint Designer.

Next Steps

There are several enhancements to the solution that I can imagine.

I may tackle these in the future, but for now they are left up to you!

The complete solution is available on CodePlex.