Monday, December 17, 2012

Outlook 2007 addin. Distribution list based on AD look up.

Hello there! Today I'm going to describe my recent small project. The goal was simple: provide colleagues with a flexible way of creation of email distribution lists. Something like outlook groups but with possibility of excluding particular users from the groups. Useful thing if you want to notify the group of people about someone's birthday. Of course you don't want the guy whose name-day is celebrated be in the list.

So I scanned our company Active Directory with ADExporer and found all the details I need to build a small console application. I decided to use builder pattern and keep distribution list building logic in a separate assembly. Here is the class diagram:



And here is the corresponding code:

namespace DLExt.Builder
{
    public class AddressBuilder
    {
        private readonly IList<DirectoryEntry> containersToSearch;
        private readonly IList<Location> locationsToSearch;
        private readonly List<Person> extractedPersons;
        private readonly IList<Person> personsToExclude;
        private readonly List<Person> filteredPersons;

        public string Server { get; private set; }

        public string ResultAddress { get; private set; }

        public AddressBuilder(string server, IEnumerable<Location> locations, IEnumerable<Person> personsToExclude)
        {
            Server = server;
            locationsToSearch = locations.ToList();
            this.personsToExclude = personsToExclude.ToList();

            containersToSearch = new List<DirectoryEntry>();
            extractedPersons = new List<Person>();
            filteredPersons = new List<Person>();
            ResultAddress = string.Empty;
        }

        public void Build()
        {
            try
            {
                LocateContainersToSearch(locationsToSearch);
                GetPersons();
                ExcludePersons();
                BuildDistributionList();
            }
            finally
            {
                FinalizeBuilder();
            }
        }

        protected virtual void LocateContainersToSearch(IList<Location> locations)
        {
            foreach (Location location in locations)
            {
                containersToSearch.Add(new DirectoryEntry(string.Format("LDAP://{0}/{1},{2}", Server, "OU=Users", location.Path)));
            }
        }

        protected virtual void GetPersons()
        {
            foreach (DirectoryEntry container in containersToSearch)
            {
                using (var directorySearcher = new DirectorySearcher(container, "(objectCategory=Person)"))
                {
                    SearchResultCollection results = directorySearcher.FindAll();
                    if (results.Count == 0)
                    {
                        return;
                    }

                    extractedPersons.AddRange((from SearchResult result in results
                                select new Person(
                                    result.Properties["displayName"][0].ToString(), 
                                    result.Properties["mail"][0].ToString())).ToList());
                }
            }
        }

        protected virtual void ExcludePersons()
        {
            filteredPersons.AddRange(extractedPersons.Except(personsToExclude, new PersonEqualityComparer()));
        }

        protected virtual void BuildDistributionList()
        {
            var builder = new StringBuilder();
            foreach (Person person in filteredPersons)
            {
                builder.Append(person.Email).Append(';');
            }

            ResultAddress = builder.ToString();
        }

        protected virtual void FinalizeBuilder()
        {
            foreach (DirectoryEntry container in containersToSearch)
            {
                container.Dispose();
            }
        }
    }
}

namespace DLExt.Builder.Retrievers
{
    public class PersonsRetriever : IRetriever<Person>
    {
        public string Server { get; private set; }

        public PersonsRetriever(string server)
        {
            Server = server;
        }

        public IList<Person> Retrieve(string path)
        {
            var result = new List<Person>();
            try
            {
                using (DirectoryEntry entry = new DirectoryEntry(string.Format("LDAP://{0}/{1}", Server, path)))
                {
                    using (DirectorySearcher searcher = new DirectorySearcher(entry, "(objectCategory=Person)"))
                    {
                        SearchResultCollection locations = searcher.FindAll();
                        if (locations.Count == 0)
                        {
                            return result;
                        }

                        result.AddRange(from SearchResult location in locations
                                        orderby location.Properties["displayName"][0].ToString()
                                        select new Person(
                                            location.Properties["displayName"][0].ToString(),
                                            location.Properties["mail"][0].ToString()));
                    }
                }
            }
            catch
            {
            }

            return result;
        }
    }
}

namespace DLExt.Builder.Retrievers
{
    public class LocationsRetriever :IRetriever<Location>
    {
        public string Server { get; private set; }

        public LocationsRetriever(string server)
        {
            Server = server;
        }

        public virtual IList<Location> Retrieve(string path)
        {
            var result = new List<Location>();
            try
            {
                using (DirectoryEntry entry = new DirectoryEntry(string.Format("LDAP://{0}/{1}", Server, path)))
                {
                    using (DirectorySearcher searcher = new DirectorySearcher(entry, "(objectClass=organizationalUnit)") { SearchScope = SearchScope.OneLevel })
                    {
                        SearchResultCollection locations = searcher.FindAll();
                        if (locations.Count == 0)
                        {
                            return result;
                        }

                        result.AddRange(from SearchResult location in locations 
                                        select new Location(
                                            location.Properties["name"][0].ToString(), 
                                            location.Properties["distinguishedName"][0].ToString()));
                    }
                }
            }
            catch
            {
            }

            return result;
        }
    }
}

After the searching logic was implemented and the console application was finished I decided to move further and build the UI. One option was to create a small tray WPF application. But who wants to run an additional application for such a minor task? I guess nobody. So I decided to build an Outlook 2007 addin. After some searching I found two great articles on MSDN and Codeproject. I was very excited when discovered that I can use WPF for achieving my goal. Here is a code I got:

using System;
using System.Windows.Forms;
using Microsoft.Office.Core;

namespace DLExt.OutlookAddin
{
    public partial class ThisAddIn
    {
        static readonly Timer Timer = new Timer();
        
        private void ThisAddIn_Startup(object sender, EventArgs e)
        {
            // HACK: looks like it is the only way to deal with minimized outlook startup
            Timer.Interval = 100;
            Timer.Tick += (o, args) =>
                              {
                                  var activeExplorer = Application.ActiveExplorer();
                                  if (activeExplorer != null)
                                  {
                                      Timer.Stop();
                                      CreateToolbar();
                                  }
                              };
            Timer.Start();
        }

        private void ThisAddIn_Shutdown(object sender, EventArgs e)
        {
            RemoveToolbar();
        }

        #region VSTO generated code

        /// <summary>
        /// Required method for Designer support - do not modify
        /// the contents of this method with the code editor.
        /// </summary>
        private void InternalStartup()
        {
            this.Startup += new System.EventHandler(ThisAddIn_Startup);
            this.Shutdown += new System.EventHandler(ThisAddIn_Shutdown);
        }
        
        #endregion

        private const string MenuToolbarTag = "Distribution List Addin";
        private CommandBar toolBar;
        private CommandBarButton toolBarButton;

        private CommandBar FindBar()
        {
            var activeExplorer = Application.ActiveExplorer();
            return activeExplorer != null
                       ? (CommandBar) activeExplorer.CommandBars.FindControl(missing, missing, MenuToolbarTag, true)
                       : null;
        }

        private void RemoveToolbar()
        {
            var commandBar = FindBar();
            if (commandBar != null)
            {
                commandBar.Delete();
            }
        }

        private void CreateToolbar()
        {
            try
            {
                toolBar = FindBar() 
                    ?? Application.ActiveExplorer().CommandBars.Add(MenuToolbarTag, MsoBarPosition.msoBarTop, false, true);

                toolBarButton = (CommandBarButton)toolBar.Controls.Add(MsoControlType.msoControlButton, missing, missing, 1, true);
                toolBarButton.Style = MsoButtonStyle.msoButtonIconAndCaption;
                toolBarButton.Caption = "Generate Distribution List";
                toolBarButton.FaceId = 65;
                toolBarButton.Tag = MenuToolbarTag;
                toolBarButton.Click += (CommandBarButton ctrl, ref bool @default) =>
                {
                    MainWindow window = new MainWindow(
                        "controller",
                        "OU=Sites,OU=Company,DC=domain,DC=corp",
                        "OU=Sites,OU=Company,DC=domain,DC=corp");
                    window.Show();
                };
            }
            catch (Exception ex)
            {
                MessageBox.Show("Error: " + ex.Message, "Error Message");
            }
        } 
    }
}

using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows;
using DLExt.Builder;
using DLExt.Builder.Model;
using DLExt.Builder.Retrievers;
using Microsoft.Office.Interop.Outlook;

namespace DLExt.OutlookAddin
{
    public partial class MainWindow : INotifyPropertyChanged
    {
        private readonly BackgroundWorker loadingWorker;
        private readonly BackgroundWorker composeWorker;
        private AddressBuilder builder;
        private IList<Location> locationsList;
        private IList<Person> personsList;

        public event PropertyChangedEventHandler PropertyChanged;

        public string Server { get; private set; }

        public string LocationsRootPath { get; private set; }

        public string PersonsRootPath { get; private set; }

        private bool isProcessing;

        public bool IsProcessing
        {
            get { return isProcessing; }
            set
            {
                isProcessing = value;
                PropertyChanged(this, new PropertyChangedEventArgs("IsProcessing"));
            }
        }

        public MainWindow(string server, string locationsRootPath, string personsRootPath)
        {
            Server = server;
            LocationsRootPath = locationsRootPath;
            PersonsRootPath = personsRootPath;

            loadingWorker = new BackgroundWorker();
            loadingWorker.DoWork += (o, args) =>
            {
                IsProcessing = true;
                locationsList = new LocationsRetriever(Server).Retrieve(LocationsRootPath);
                personsList = new PersonsRetriever(Server).Retrieve(PersonsRootPath);
            };

            loadingWorker.RunWorkerCompleted += (sender, args) =>
            {
                locations.ItemsSource = locationsList;
                persons.ItemsSource = personsList;
                IsProcessing = false;
            };

            composeWorker = new BackgroundWorker();
            composeWorker.DoWork += (sender, args) =>
            {
                IsProcessing = true;
                builder = new AddressBuilder(
                    Server,
                    locations.Items.OfType<Location>().Where(loc => loc.IsSelected),
                    personsToExclude.Items.OfType<Person>());
                builder.Build();
            };

            composeWorker.RunWorkerCompleted += (sender, args) =>
            {
                IsProcessing = false;
                try
                {
                    var app = new Microsoft.Office.Interop.Outlook.Application();
                    var mailItem = (MailItem)app.CreateItem(OlItemType.olMailItem);

                    mailItem.To = builder.ResultAddress;
                    mailItem.Display(true);

                }
                catch (COMException)
                {
                }
            };

            loadingWorker.WorkerSupportsCancellation = true;
            DataContext = this;
            InitializeComponent();
        }

        private void WindowLoaded(object sender, RoutedEventArgs e)
        {
            loadingWorker.RunWorkerAsync();
        }

        private void ComposeEmail(object sender, RoutedEventArgs e)
        {
            composeWorker.RunWorkerAsync();
        }

        private void ExcludePerson(object sender, RoutedEventArgs e)
        {
            if (!personsToExclude.Items.Contains(persons.SelectedItem))
            {
                personsToExclude.Items.Add(persons.SelectedItem);
            }
        }

        private void CloseForm(object sender, RoutedEventArgs e)
        {
            Close();
        }

        private void WindowClosing(object sender, CancelEventArgs e)
        {
            if (loadingWorker.IsBusy)
            {
                loadingWorker.CancelAsync();
            }
        }
    }
}

The UI was completed in a first approximation. The main thing I wasn't satisfied with was execution of LDAP requests in the same thread as UI. So the next step was implementing multithreading with background worker.

public MainWindow(string server, string locationsRootPath, string personsRootPath)
{

 ...            

 loadingWorker = new BackgroundWorker();
 loadingWorker.DoWork += (o, args) =>
 {
  IsProcessing = true;
  locationsList = new LocationsRetriever(Server).Retrieve(LocationsRootPath);
  personsList = new PersonsRetriever(Server).Retrieve(PersonsRootPath);
 };

 loadingWorker.RunWorkerCompleted += (sender, args) =>
 {
  locations.ItemsSource = locationsList;
  persons.ItemsSource = personsList;
  IsProcessing = false;
 };


 loadingWorker.WorkerSupportsCancellation = true;

 ...

 InitializeComponent();
}

 ...

private void WindowLoaded(object sender, RoutedEventArgs e)
{
 loadingWorker.RunWorkerAsync();
}

The application was completed but unfortunately it was crushing during Outlook start up. After some research I found the problem was the absence of ActiveExplorer instance when outlook started minimized. No UI - no ActiceExplorer instance. Sounds fair but how to fix it? I had to recognize that the hack with a timer was the best solution for me.

private void ThisAddIn_Startup(object sender, EventArgs e)
{
 // HACK: looks like it is the only way to deal with minimized outlook startup
 Timer.Interval = 100;
 Timer.Tick += (o, args) =>
  {
    var activeExplorer = Application.ActiveExplorer();
    if (activeExplorer != null)
    {
     Timer.Stop();
     CreateToolbar();
    }
  };
 Timer.Start();
}

The app was completed this time. But I understood that I need to provide a way for easy distribution of the addin. So I decided to build a setup project to handle all prerequisites and registry stuff. The hardest part here was to find this great article for excel plugins distribution. I followed it to create a setup project for my outlook plugin.

Final sources can be found here.