HOWTO: Sample Transport Agent – Add Headers, Categories, MAPI Props, Even Uses a Fork!

Welcome to my first in depth Exchange Transport Agent sample! If you are not familiar with Exchange Transport Agents this blog post is a great place to start. I created a sample Transport Agent for a customer a while ago that I recently added commenting to and cleaned up so it could serve as a general demo suitable for this blog. The focus of this sample is to illustrate a several techniques but no necessarily serve as a template or best practice for writing transport agents in general. The key techniques illustrated are:

  1. Forking a message in the “OnSubmittedMessage” event to process a messages individually for each recipient.
  2. Adding a custom header to a message.
  3. Adding a Keywords (also known as Category) header to a message
  4. Adding a custom named property to a MAPI message

The comments in the code speak for themselves so I won’t walk through it all. There are two very important notes regarding the Keyword header and adding MAPI properties that I want to emphasize though.

The Keywords Header is Removed By Default

If you are you trying to add the Keywords header in a transport agent or even just when sending MIME messages to Exchange 2007 keep in mind that this header is translated into Categories which are removed by default during content conversion. If you really need this functionality though, you can turn this off using the Set-TransportConfig cmdlet and setting ClearCategories to $false. Click here to read more.

If You Don’t Get TNEF, You Don’t Have TNEF

There is no API to convert MIME messages to TNEF within a transport agent. The TNEF body part must be a full representation of the message being submitted to Exchange. You can’t have a complex MIME message with a TNEF body part that simply adds a MAPI property or two. Messages that are submitted from outside the organization will mostly like not have TNEF, there is no mechanism to create a representative TNEF body part for these messages and then append properties.

One final note is that this sample logs information out to a text file on the system. Keep in mind that transport agents run in the context of the Exchange Transport Service which is typically Network Service. In this case, the Network Service account would need write permissions to whatever folder the log file is configured to write to. 

Enjoy…

 using System;
using System.Collections.Generic;
using System.Text;
using System.IO;
using Microsoft.Exchange.Data.Transport;
using Microsoft.Exchange.Data.Transport.Routing;
using Microsoft.Exchange.Data.Common;
using Microsoft.Exchange.Data.Mime;
using Microsoft.Exchange.Data.ContentTypes.Tnef;

namespace MyRoutingAgent
{

    public sealed class MyRoutingAgentFactory : RoutingAgentFactory
    {
        public override RoutingAgent CreateAgent(SmtpServer server)
        {
            return new MyRoutingAgent();
        }
    }

    public class MyRoutingAgent : RoutingAgent
    {
        // Place this text in the subject to test the agent
        public const string TEST_TOKEN = "[TEST]";
        public const string LOG_FILE_PATH = @"C:\Users\Administrator\Desktop\MyRoutingAgent\MyRoutingAgent.log";

        public MyRoutingAgent()
        {
            this.OnRoutedMessage += new RoutedMessageEventHandler(MyRoutingAgent_OnRoutedMessage);
            this.OnSubmittedMessage += new SubmittedMessageEventHandler(MyRoutingAgent_OnSubmittedMessage);
            this.OnResolvedMessage += new ResolvedMessageEventHandler(MyRoutingAgent_OnResolvedMessage);
        }

        void MyRoutingAgent_OnSubmittedMessage(SubmittedMessageEventSource source, QueuedMessageEventArgs e)
        {
            // Don't process EVERY message...
            if (e.MailItem.Message.Subject.Contains(TEST_TOKEN))
            {
                // If we have multiple recipients, fork the message to process each recipient seperately...
                if (e.MailItem.Recipients.Count > 1)
                {
                    LogMessage("OnSubmittedMessage", e.MailItem, "Forking message with " + e.MailItem.Recipients.Count + " recipients.");

                    int count = 0;
                    while (e.MailItem.Recipients.Count > 1)
                    {
                        // Create an individual forked message for each recipient...
                        EnvelopeRecipient recip = e.MailItem.Recipients[e.MailItem.Recipients.Count - 1];
                        List<EnvelopeRecipient> recips = new List<EnvelopeRecipient>();
                        recips.Add(recip);
                        source.Fork(recips);
                        count++;
                    }

                    // This should always be one.
                    LogMessage("OnSubmittedMessage", e.MailItem, "Forked " + count + " messages.");
                }
                else
                {
                    LogMessage("OnSubmittedMessage", e.MailItem, "Single recipient, no forking needed.");
                }
            }
        }

        void MyRoutingAgent_OnResolvedMessage(ResolvedMessageEventSource source, QueuedMessageEventArgs e)
        {
            // Don't process EVERY message...
            if (e.MailItem.Message.Subject.Contains(TEST_TOKEN))
            {
                MimePart tnefPart = e.MailItem.Message.TnefPart;
                if (tnefPart != null)
                {
                    LogMessage("OnResolvedMessage", e.MailItem, "TNEF");
                }
                else
                {
                    LogMessage("OnResolvedMessage", e.MailItem, "No TNEF");
                }
            }
        }
        
        void MyRoutingAgent_OnRoutedMessage(RoutedMessageEventSource source, QueuedMessageEventArgs e)
        {
            // Don't process EVERY message...
            if (e.MailItem.Message.Subject.Contains(TEST_TOKEN))
            {
                if (e.MailItem.Recipients.Count == 1)
                {
                    LogMessage("OnRoutedMessage", e.MailItem, "Processing message sent to " + e.MailItem.Recipients[0].Address.LocalPart + ".");

                    // If we haven't already processed this message...
                    if (e.MailItem.Message.MimeDocument.RootPart.Headers.FindFirst("MyHeader") == null)
                    {
                        ProcessMailItem(e.MailItem);
                    }
                    else
                    {
                        LogMessage("OnRoutedMessage", e.MailItem, "Already processed this message, skipping.");
                    }
                }
                else
                {
                    LogMessage("OnRoutedMessage", e.MailItem, "Unexpected number of recipients, " + e.MailItem.Recipients.Count.ToString() + ".");
                }
            }
        }

        /// <summary>
        /// Add a custom header, keyword header, and MAPI
        /// named property into PS_PUBLIC_STRINGS if there
        /// is a TNEF body part.
        /// </summary>
        private void ProcessMailItem(MailItem item)
        {
            // Modify the subject of this message to ensure the
            // fork worked.  Append the first recipient name to the 
            // subject.  This should match the mailbox name the
            // message is delivered to.
            item.Message.Subject = item.Message.Subject + "| To: " + item.Recipients[0].Address.LocalPart;

            MimeDocument mdMimeDoc = item.Message.MimeDocument;
            HeaderList hlHeaderlist = mdMimeDoc.RootPart.Headers;
            MimeNode lhLasterHeader = hlHeaderlist.LastChild;

            // Add a custom header
            TextHeader nhNewHeader = new TextHeader("MyHeader", "MyHeaderValue");
            hlHeaderlist.InsertBefore(nhNewHeader, lhLasterHeader);

            // Add the Keywords header to set a Category in Outlook/OWA.
            // *** INFO ***
            // Keywords (also known as Categories) are stripped from
            // message by default.  This can be disabled using the 
            // "Set-TransportConfig –ClearCategories $false" cmdlet.
            // (https://technet.microsoft.com/en-us/library/bb124151.aspx)
            TextHeader nhNewKeywords = new TextHeader("Keywords", "1");
            hlHeaderlist.InsertBefore(nhNewKeywords, lhLasterHeader);

            MimePart tnefPart = item.Message.TnefPart;

            // Without a TNEF body part, we can't do this step.
            // There is no way to create a TNEF body part from
            // scratch if Exchange isn't giving us one.  Most
            // mail that comes from the internet won't have TNEF.
            if (tnefPart != null)
            {
                TnefReader reader = new TnefReader(tnefPart.GetContentReadStream());
                TnefWriter writer = new TnefWriter(
                    tnefPart.GetContentWriteStream(tnefPart.ContentTransferEncoding),
                    reader.AttachmentKey,
                    0,
                    TnefWriterFlags.NoStandardAttributes);

                while (reader.ReadNextAttribute())
                {
                    if (reader.AttributeTag == TnefAttributeTag.MapiProperties)
                    {
                        writer.StartAttribute(TnefAttributeTag.MapiProperties, TnefAttributeLevel.Message);
                        writer.WriteAllProperties(reader.PropertyReader);

                        int tag;
                        unchecked
                        {
                            // The first four bytes of the tag is must be at least 0x8000 
                            // when setting a named property, the second determines the type. 
                            // https://www.cdolive.com/cdo10.htm
                            tag = (int)0x8000001E;
                        }

                        Guid PS_PUBLIC_STRINGS = new Guid("00020329-0000-0000-C000-000000000046");

                        writer.StartProperty(
                            new TnefPropertyTag(tag),
                            PS_PUBLIC_STRINGS,
                            "MyProp");
                        writer.WritePropertyValue("Hello!");
                    }
                    else
                    {
                        writer.WriteAttribute(reader);
                    }
                }

                //  Close writer
                if (null != writer)
                {
                    writer.Close();
                }

                LogMessage("ProcessMailItem", item, "Added 'MyProp' to PS_PUBLIC_STRINGS.");
            }
            else
            {
                LogMessage("ProcessMailItem", item, "No TNEF, not adding property");
            }
        }

        private void LogMessage(string eventName, MailItem item, string message)
        {
            TextWriter tw = System.IO.File.AppendText(LOG_FILE_PATH);
            tw.WriteLine(DateTime.Now.Ticks + "\t" + eventName + " - " + item.Message.Subject + " - " + message);
            tw.Close();
        }
    }

}