Exchange 2010 Custom Transport Agent

This is a wrap-up of an older post that had originally been published on my former website.

Even though that this post focusses on Exchange 2010 transport agents, you will get an understand on what is required to create an Exchange 2013/2016 aka Version 15 transport agent.

Visual Studio Project

Writing your own transport agent for Exchange 2010 is not really complicated. With a Visual Studio C# Class project you are ready to go.

The follow picture shows the Visual Studio Solution as it has been used for the Message Modifier Solution.

Visual Studio Solution

Besides the C# class the solution contains the following Powershell script to simplify development and deployment:

  • Add-TransportAgent.ps1
    Installs the transport agent on the productive Exchange Server
  • Remove-TransportAgent.ps1
    Uninstalls the transport agent on the productive Exchange Server
    See Technet Gallery https://gallery.technet.microsoft.com/Remove-a-custom-Exchange-1cd30e92
  • Build-DeploymentPackage.ps1
    Copy all required DLLs, Powershell scripts and the deployment configuration file to a dedicated folder
  • install.ps1
    Installs the transport agent on the development Exchange Server
  • uninstall.ps1
    Uninstalls the transport agent on the development Exchange Server

The transport agent intercepts a message from a given sender address and performs the following actions:

  • If the message has attachments with file names starting with „WORKBOOK_“ the attachments are renamed to the following format:

    [yyyyMMdd] [EMAIL SUBJECT]-[NUMBER].[ORIGINAL EXTENSION]
     

  • The subject is rewritten from the format

    [dd.MM.yyyy] [SUBJECT TEXT]
    to
    [yyyyMMdd] [SUBJECT TEXT]

 

Links

Code Sample

// AttachmentModify  // ----------------------------------------------------------  // Example for intercepting email messages in an Exchange 2010 transport queue  //   // The example intercepts messages sent from a configurable email address(es)  // and checks the mail message for attachments have filename in to format  //   //      WORKBOOK_{GUID}  //  // Changing the filename of the attachments makes it easier for the information worker  // to identify the reports in the emails and in the file system as well.  // Copyright (c) Thomas Stensitzki// ----------------------------------------------------------    using System;  using System.Collections.Generic;  using System.Diagnostics;  using System.Globalization;  using System.IO;  using System.Reflection;  using System.Text;  using System.Text.RegularExpressions;  using System.Threading;  using System.Xml;    // the lovely Exchange   using Microsoft.Exchange.Data.Transport;  using Microsoft.Exchange.Data.Transport.Smtp;  using Microsoft.Exchange.Data.Transport.Email;  using Microsoft.Exchange.Data.Transport.Routing;    namespace SFTools.Messaging.AttachmentModify  {      #region Message Modifier Factory        ///       /// Message Modifier Factory      ///       public class MessageModifierFactory : RoutingAgentFactory      {          ///           /// Instance of our transport agent configuration          /// This is for a later implementation          ///           private MessageModifierConfig messageModifierConfig = new MessageModifierConfig();            ///           /// Returns an instance of the agent          ///           /// The SMTP Server          /// The Transport Agent          public override RoutingAgent CreateAgent(SmtpServer server)          {              return new MessageModifier(messageModifierConfig);          }      }       #endregion       #region Message Modifier Routing Agent            ///       /// The Message Modifier Routing Agent for modifying an email message      ///       public class MessageModifier : RoutingAgent      {          // The agent uses the fileLock object to synchronize access to the log file          private object fileLock = new object();            ///           /// The current MailItem the transport agent is handling          ///           private MailItem mailItem;            ///           /// This context to allow Exchange to continue processing a message          ///           private AgentAsyncContext agentAsyncContext;            ///           /// Transport agent configuration          ///           private MessageModifierConfig messageModifierConfig;            ///           /// Constructor for the MessageModifier class          ///           /// Transport Agent configuration          public MessageModifier(MessageModifierConfig messageModifierConfig)          {              // Set configuration              this.messageModifierConfig = messageModifierConfig;                // Register an OnRoutedMessage event handler              this.OnRoutedMessage += OnRoutedMessageHandler;          }            ///           /// Event handler for OnRoutedMessage event          ///           /// Routed Message Event Source          /// Queued Message Event Arguments          void OnRoutedMessageHandler(RoutedMessageEventSource source, QueuedMessageEventArgs args)          {              lock (fileLock) {                  try {                      this.mailItem = args.MailItem;                      this.agentAsyncContext = this.GetAgentAsyncContext();                        // Get the folder for accessing the config file                      string dllDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);                        // Fetch the from address from the current mail item                      RoutingAddress fromAddress = this.mailItem.FromAddress;                        Boolean boWorkbookFound = false;    // We just want to modifiy subjects when we modified an attachement first                       #region External Receive Connector Example                        // CHeck first, if the mail item does have a ReceiveConnectorName property first to prevent ugly things to happen                      if (mailItem.Properties.ContainsKey("Microsoft.Exchange.Transport.ReceiveConnectorName")) {                          // This is just an example, if you want to do something with a mail item which has been received via a named external receive connector                          if (mailItem.Properties["Microsoft.Exchange.Transport.ReceiveConnectorName"].ToString().ToLower() == "externalreceiveconnectorname")                          {                              // do something fancy with the email                          }                      }                       #endregion                        RoutingAddress catchAddress;                        // Check, if we have any email addresses configured to look for                      if (this.messageModifierConfig.AddressMap.Count > 0) {                          // Now lets check, if the sender address can be found in the dictionary                          if (this.messageModifierConfig.AddressMap.TryGetValue(fromAddress.ToString().ToLower(), out catchAddress)) {                              // Sender address found, now check if we have attachments to handle                              if (this.mailItem.Message.Attachments.Count != 0) {                                  // Get all attachments                                  AttachmentCollection attachments = this.mailItem.Message.Attachments;                                    // Modify each attachment                                  for (int count = 0; count < this.mailItem.Message.Attachments.Count; count++) {                                      // Get attachment                                      Attachment attachment = this.mailItem.Message.Attachments[count];                                        // We will only transform attachments which start with "WORKBOOK_"                                      if (attachment.FileName.StartsWith("WORKBOOK_")) {                                          // Create a new filename for the attachment                                          // [MODIFIED SUBJECT]-[NUMBER].[FILEEXTENSION]                                          String newFileName = MakeValidFileName(string.Format("{0}-{1}{2}", ModifiySubject(this.mailItem.Message.Subject.Trim()), count + 1, Path.GetExtension(attachment.FileName)));                                            // Change the filename of the attachment                                          this.mailItem.Message.Attachments[count].FileName = newFileName;                                            // Yes we have changed the attachment. Therefore we want to change the subject as well.                                          boWorkbookFound = true;                                      }                                  }                                    // Have changed any attachments?                                  if (boWorkbookFound) {                                      // Then let's change the subject as well                                      this.mailItem.Message.Subject = ModifiySubject(this.mailItem.Message.Subject);                                  }                              }                          }                      }                  }                  catch (System.IO.IOException ex) {                      // oops                      Debug.WriteLine(ex.ToString());                      this.agentAsyncContext.Complete();                  }                  finally {                      // We are done                      this.agentAsyncContext.Complete();                  }              }                // Return to pipeline              return;          }            ///           /// Build a new subject, if the first 10 chars of the original subject are a valid date.          /// We muste transform the de-DE format dd.MM.yyyy to yyyyMMdd for better sortability in the email client.          ///           /// The original subject string          /// The modified subject string, if modification was possible          private static string ModifiySubject(string MessageSubject)          {              string newSubject = String.Empty;                if (MessageSubject.Length >= 10) {                  string dateCheck = MessageSubject.Substring(0, 10);                  DateTime dt = new DateTime();                  try {                      // Check if we can parse the datetime                      if (DateTime.TryParse(dateCheck, out dt)) {                          // lets fetch the subject starting at the 10th character                          string subjectRight = MessageSubject.Substring(10).Trim();                          // build a new subject                          newSubject = string.Format("{0:yyyyMMdd} {1}", dt, subjectRight);                      }                  }                  finally {                      // do nothing                  }              }                return newSubject;          }              ///           /// Replace invalid filename chars with an underscore          ///           /// The filename to be checked          /// The sanitized filename          private static string MakeValidFileName(string name)          {              string invalidChars = Regex.Escape(new string(Path.GetInvalidFileNameChars()));              string invalidRegExStr = string.Format(@"[{0}]+", invalidChars);              return Regex.Replace(name, invalidRegExStr, "_");          }        }       #endregion       #region Message Modifier Configuration        ///       /// Message Modifier Configuration class      ///       public class MessageModifierConfig      {          ///           ///  The name of the configuration file.          ///           private static readonly string configFileName = "SFTools.MessageModify.Config.xml";            ///           /// Point out the directory with the configuration file (= assembly location)          ///           private string configDirectory;            ///           /// The filesystem watcher to monitor configuration file updates.          ///           private FileSystemWatcher configFileWatcher;            ///           /// The from address          ///           private Dictionary addressMap;            ///           /// Whether reloading is ongoing          ///           private int reLoading = 0;            ///           /// The mapping between domain to catchall address.          ///           public Dictionary AddressMap          {              get { return this.addressMap; }          }            ///           /// Constructor          ///           public MessageModifierConfig()          {              // Setup a file system watcher to monitor the configuration file              this.configDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);              this.configFileWatcher = new FileSystemWatcher(this.configDirectory);              this.configFileWatcher.NotifyFilter = NotifyFilters.LastWrite;              this.configFileWatcher.Filter = configFileName;              this.configFileWatcher.Changed += new FileSystemEventHandler(this.OnChanged);                // Create an initially empty map              this.addressMap = new Dictionary();                // Load the configuration              this.Load();                // Now start monitoring              this.configFileWatcher.EnableRaisingEvents = true;          }            ///           /// Configuration changed handler.          ///           /// Event source.          /// Event arguments.          private void OnChanged(object source, FileSystemEventArgs e)          {              // Ignore if load ongoing              if (Interlocked.CompareExchange(ref this.reLoading, 1, 0) != 0) {                  Trace.WriteLine("load ongoing: ignore");                  return;              }                // (Re) Load the configuration              this.Load();                // Reset the reload indicator              this.reLoading = 0;          }            ///           /// Load the configuration file. If any errors occur, does nothing.          ///           private void Load()          {              // Load the configuration              XmlDocument doc = new XmlDocument();              bool docLoaded = false;              string fileName = Path.Combine(this.configDirectory, MessageModifierConfig.configFileName);                try {                  doc.Load(fileName);                  docLoaded = true;              }              catch (FileNotFoundException) {                  Trace.WriteLine("Configuration file not found: {0}", fileName);              }              catch (XmlException e) {                  Trace.WriteLine("XML error: {0}", e.Message);              }              catch (IOException e) {                  Trace.WriteLine("IO error: {0}", e.Message);              }                // If a failure occured, ignore and simply return              if (!docLoaded || doc.FirstChild == null) {                  Trace.WriteLine("Configuration error: either no file or an XML error");                  return;              }                // Create a dictionary to hold the mappings              Dictionary map = new Dictionary(100);                // Track whether there are invalid entries              bool invalidEntries = false;                // Validate all entries and load into a dictionary              foreach (XmlNode node in doc.FirstChild.ChildNodes) {                  if (string.Compare(node.Name, "domain", true, CultureInfo.InvariantCulture) != 0) {                      continue;                  }                    XmlAttribute domain = node.Attributes["name"];                  XmlAttribute address = node.Attributes["address"];                    // Validate the data                  if (domain == null || address == null) {                      invalidEntries = true;                      Trace.WriteLine("Reject configuration due to an incomplete entry. (Either or both domain and address missing.)");                      break;                  }                    if (!RoutingAddress.IsValidAddress(address.Value)) {                      invalidEntries = true;                      Trace.WriteLine(String.Format("Reject configuration due to an invalid address ({0}).", address));                      break;                  }                    // Add the new entry                  string lowerDomain = domain.Value.ToLower();                  map[lowerDomain] = new RoutingAddress(address.Value);                    Trace.WriteLine(String.Format("Added entry ({0} -> {1})", lowerDomain, address.Value));              }                // If there are no invalid entries, swap in the map              if (!invalidEntries) {                  Interlocked.Exchange>(ref this.addressMap, map);                  Trace.WriteLine("Accepted configuration");              }          }      }       #endregion  } 

 

 

%d Bloggern gefällt das: