Outlook Webmail Add-in for Windows Home Server
- Posted: Aug 10, 2007 at 1:33 AM
- 3,072 Views
- 20 Comments
Loading User Information from Channel 9
Something went wrong getting user information from Channel 9
Loading User Information from MSDN
Something went wrong getting user information from MSDN
Loading Visual Studio Achievements
Something went wrong getting the Visual Studio Achievements
|
In this article, Brian Peek will demonstrate how to create an add-in for Windows Home Server that will allow users to view their Outlook mail from a web browser. |
|
Difficulty: Intermediate
Time Required: 2-3 hours
Cost: Free
Software: Windows Home Server, Microsoft
Outlook 2000/XP/2003/2007 (not Outlook Express/Windows Mail), Visual Basic or Visual C# Express Editions,
Visual Web Developer Express Edition,
Microsoft .NET Framework 3.0 runtime, Outlook/Office Primary Interop Assemblies (more on this below), Windows SDK
(Vista or later, which includes the .NET 3.0 bits. Note that this installs and works just fine under Windows XP. The SDK can build applications for Windows XP and greater.)
Hardware: None
Download: CodePlex Project
|
|
Windows Home Server is a new product from Microsoft which allows home users to manage and share data, including photos, documents, videos, music, etc. It also provides a very easy way to backup all computers on your home network to a central storage server.
Windows Home Server can also be extended via add-ins to enhance the experience and provide new and interesting functionality other than what comes in the box.
One feature not present in WHS that I would find useful is the ability to view my Outlook mail box from the web at any time. I have 6 or 7 email accounts that are all setup to retrieve via POP3 to Outlook. Most of these accounts do not support IMAP or have a web-based interface. Therefore, Outlook is generally open all day and checking messages. When I'm away from home for work or pleasure, it's very often inconvenient to have to remote desktop into the machine with Outlook running to read my email, so it would be nice to have a web-based version of my current Outlook folders so I can view all email (old and new) at any time simply by browsing to a web server at home. Windows Home Server comes with Internet Information Services 6 (IIS6) and one can easily add a new web application to IIS on the server.
So, this article will attempt to show how to build a new web site using ASP.NET that can be added to your Windows Home Server installation that will allow one to view the Outlook folders running on whatever computer contains your current Outlook installation and message store.
If you wish to just use the application, download the sample from above and skip down to the deployment section for installation instructions.
NOTE: This application will only work with Microsoft Outlook. It will not work with Outlook Express, Windows Mail, or any other mail client.
Setup can be a bit tricky. Office/Outlook will need to be installed on your development machine. It does not need to be the same machine which contains your store at this point, but that too would help. Once Office/Outlook is installed, the Primary Interop Assemblies for the version of Office you are using need to be installed. For Office 2003/2007, this can be done choosing .NET Programming Support from the list of sub-items in the Microsoft Office Outlook section of the setup program.
Unfortunately I do not have an earlier copy of Office with which to check, but the procedure should be the same. If anyone happens to try this with Office XP/2000, please let me know if/how it works.
If you will be developing on an OS earlier than Vista, install the .NET Framework 3.0 runtime.
Finally, install the Windows SDK linked above accepting all defaults.
The architecture we will be using is very similar that to an N-tier application. The machine running Outlook with the message store to be viewed is, in essence, the server machine. That machine will run a host process that we will develop which will expose several methods via Windows Communication Foundation. These methods will be consumed by an ASP.NET application running on the Windows Home Server.
Let's start by building the host application. This will run on the computer where Outlook is installed and the messages are stored. The application will be written to run in the notification area next to the Windows system clock.
To start, create a new Windows Application project named WHSMailHost. Rename the default Form1.cs/.vb file to frmMain.cs/.vb. Double-click on the file in the Solution Explorer to bring up the design surface.
In order to get the application to run in the notification area, drag and drop a NotifyIcon control from the Toolbox to the design surface, name it niIcon. Also, drag over a ContextMenuStrip to the design surface and name it cmsMenu. This will be used to pop up a context menu when the icon in the notification area is clicked. Finally, set the following properties on the niIcon control:
| Text | WHS Mail Host |
| ContextMenuStrip | cmsMenu |
| Visible | True |
Select the cmsMenu control and add a single menu item named Exit to the list. Double-click on that menu item to create a default Click event. In the code for the Click event, simply close the form as follows:
C#
private void mnuExit_Click(object sender, EventArgs e) { // exit the application this.Close(); }
VB
Private Sub mnuExit_Click(ByVal sender As Object, ByVal e As EventArgs) Handles mnuExit.Click ' exit the application Me.Close() End Sub
Finally, add the following events to frmMain by selecting them from the Event Property window and implementing them with the following code:
C#
private void frmMain_Resize(object sender, EventArgs e) { // hide the form this.Hide(); } private void frmMain_Load(object sender, EventArgs e) { // start the WCF service MyServiceHost.StartService(); } private void frmMain_FormClosing(object sender, FormClosingEventArgs e) { // stop the WCF service MyServiceHost.StopService(); }
VB
Private Sub frmMain_Resize(ByVal sender As Object, ByVal e As EventArgs) Handles MyBase.Resize ' hide the form Me.Hide() End Sub Private Sub frmMain_Load(ByVal sender As Object, ByVal e As EventArgs) Handles MyBase.Load ' start the WCF service MyServiceHost.StartService() End Sub Private Sub frmMain_FormClosing(ByVal sender As Object, ByVal e As FormClosingEventArgs) Handles MyBase.FormClosing ' stop the WCF service MyServiceHost.StopService() End Sub
The code above references a class named MyServiceHost. This is what starts the WCF host and will be discussed next.
Windows Communication Foundation (formerly known as Indigo) is a feature of the .NET Framework 3.0 that allows one to build and run connected systems. The simplest definition that fits with what we will be doing here is that it allows an application running on one machine (the client, in this case, the ASP.NET application) to execute a method on another machine (the host, in this case, the application we're currently building).
First, add a reference to the System.ServiceModel assembly. Then, add a class to the project named WHSMailService. Open the class and add the following code beneath the generated WHSMailService class implementation:
C#
internal class MyServiceHost { internal static ServiceHost myServiceHost = null; internal static void StartService() { // Instantiate new ServiceHost myServiceHost = new ServiceHost(typeof(WHSMailService)); myServiceHost.Open(); } internal static void StopService() { // Call StopService from your shutdown logic (i.e. dispose method) if (myServiceHost.State != CommunicationState.Closed) myServiceHost.Close(); } }
VB
Friend Class MyServiceHost Friend Shared myServiceHost As ServiceHost = Nothing Friend Shared Sub StartService() ' Instantiate new ServiceHost myServiceHost = New ServiceHost(GetType(WHSMailService)) myServiceHost.Open() End Sub Friend Shared Sub StopService() ' Call StopService from your shutdown logic (i.e. dispose method) If myServiceHost.State <> CommunicationState.Closed Then myServiceHost.Close() End If End Sub End Class
This code simply creates a new WCF ServiceHost of the type WHSMailService (which we will implement next) and then opens that host so that it may receive incoming connections. We will look at how this connections are configured later in the article.
Remember that the code in the form above above called the StartService and StopService methods located here when the main form loads and closes. This very easily allows to immediately start the service host when the application starts and closes the service host when the application exits.
A contract is an interface that defines which methods are exposed by the service host that can be consumed by the client application. Both the client application and the server application will need to know what is in this interface, so we will need to create a second project that will contain the interface definition. Additionally, we will need a way to pass folder and email information to and from each application, so we will define some custom classes to encapsulate those objects.
Create a new Class Library project in the current solution named WHSMailCommon. In this new project, create a directory named Contracts and a directory named Entities.
Inside the Entities directory, create a new class named Folder. This will represent an Outlook message folder. The class should look like the following:
C#
using System; using System.Collections.Generic; namespace WHSMailCommon.Entities { // entity object representing an Folder [Serializable] public class Folder : IComparable { private string _entryID; private string _name; private List<Folder> _folders; private int _unreadMessages; private int _totalMessages; public Folder(string entryID, string name, int unreadMessages, int totalMessages) { _entryID = entryID; _name = name; _unreadMessages = unreadMessages; _totalMessages = totalMessages; } // MAPI unique identifier public string EntryID { get { return _entryID; } set { _entryID = value; } } // subfolders of this folder public List<Folder> Folders { get { return _folders; } set { _folders = value; } } public string Name { get { return _name; } set { _name = value; } } public int UnreadMessages { get { return this._unreadMessages; } set { this._unreadMessages = value; } } public int TotalMessages { get { return this._totalMessages; } set { this._totalMessages = value; } } // used so we can sort the folders alphabetically later on public int CompareTo(object obj) { return string.Compare(this.Name, ((Folder)obj).Name); } } }
VB
Imports Microsoft.VisualBasic Imports System Imports System.Collections.Generic Namespace WHSMailCommon.Entities ' entity object representing an Folder <Serializable> _ Public Class Folder Implements IComparable Private _entryID As String Private _name As String Private _folders As List(Of Folder) Private _unreadMessages As Integer Private _totalMessages As Integer Public Sub New(ByVal entryID As String, ByVal name As String, ByVal unreadMessages As Integer, ByVal totalMessages As Integer) _entryID = entryID _name = name _unreadMessages = unreadMessages _totalMessages = totalMessages End Sub ' MAPI unique identifier Public Property EntryID() As String Get Return _entryID End Get Set(ByVal value As String) _entryID = value End Set End Property ' subfolders of this folder Public Property Folders() As List(Of Folder) Get Return _folders End Get Set(ByVal value As List(Of Folder)) _folders = value End Set End Property Public Property Name() As String Get Return _name End Get Set(ByVal value As String) _name = value End Set End Property Public Property UnreadMessages() As Integer Get Return Me._unreadMessages End Get Set(ByVal value As Integer) Me._unreadMessages = value End Set End Property Public Property TotalMessages() As Integer Get Return Me._totalMessages End Get Set(ByVal value As Integer) Me._totalMessages = value End Set End Property ' used so we can sort the folders alphabetically later on Public Function CompareTo(ByVal obj As Object) As Integer Implements IComparable.CompareTo Return String.Compare(Me.Name, (CType(obj, Folder)).Name) End Function End Class End Namespace
This class defines several properties to describe the folder (EntryID, Name, etc.) and additionally implements the IComparable interface's CompareTo method so that we can easily sort the folders alphabetically later on.
Next, create a class named Email. The code for this class looks like the following:
C#
using System; using System.Collections.Generic; using System.Text; namespace WHSMailCommon.Entities { // entity object representing an Email [Serializable] public class Email { private string _entryID; private string _from; private string _fromName; private string _subject; private DateTime _received; private int _size; private string _body; public Email(string entryID, string from, string fromName, string subject, DateTime received, int size) { _entryID = entryID; _from = from; _fromName = fromName; _subject = string.IsNullOrEmpty(subject) ? "(no subject)" : subject; _received = received; _size = size; } public Email(string entryID, string from, string fromName, string subject, DateTime received, int size, string body) : this(entryID, from, fromName, subject, received, size) { _body = body; } // MAPI unique ID public string EntryID { get { return _entryID; } set { _entryID = value; } } // email address of sender public string From { get { return _from; } set { _from = value; } } // name of sender public string FromName { get { return _fromName; } set { _fromName = value; } } public string Subject { get { return _subject; } set { _subject = value; } } public string Body { get { return _body; } set { _body = value; } } public DateTime Received { get { return _received; } set { _received = value; } } public int Size { get { return _size; } set { _size = value; } } } }
VB
Imports Microsoft.VisualBasic Imports System Imports System.Collections.Generic Imports System.Text Namespace WHSMailCommon.Entities ' entity object representing an Email <Serializable> _ Public Class Email Private _entryID As String Private _from As String Private _fromName As String Private _subject As String Private _received As DateTime Private _size As Integer Private _body As String Public Sub New(ByVal entryID As String, ByVal From As String, ByVal fromName As String, ByVal subject As String, ByVal received As DateTime, ByVal size As Integer) _entryID = entryID _from = From _fromName = fromName If String.IsNullOrEmpty(subject) Then _subject = "(no subject)" Else _subject = subject End If _received = received _size = size End Sub Public Sub New(ByVal entryID As String, ByVal From As String, ByVal fromName As String, ByVal subject As String, ByVal received As DateTime, ByVal size As Integer, ByVal body As String) Me.New(entryID, From, fromName, subject, received, size) _body = body End Sub ' MAPI unique ID Public Property EntryID() As String Get Return _entryID End Get Set(ByVal value As String) _entryID = value End Set End Property ' email address of sender Public Property From() As String Get Return _from End Get Set(ByVal value As String) _from = value End Set End Property ' name of sender Public Property FromName() As String Get Return _fromName End Get Set(ByVal value As String) _fromName = value End Set End Property Public Property Subject() As String Get Return _subject End Get Set(ByVal value As String) _subject = value End Set End Property Public Property Body() As String Get Return _body End Get Set(ByVal value As String) _body = value End Set End Property Public Property Received() As DateTime Get Return _received End Get Set(ByVal value As DateTime) _received = value End Set End Property Public Property Size() As Integer Get Return _size End Get Set(ByVal value As Integer) _size = value End Set End Property End Class End Namespace
This class simply contains properties to describe an email message.
You will note that both of these classes have the [Serializable] attribute attached to them. When objects are passed through WCF, they are serialized at the source and deserialized at the destination. By marking the objects with the [Serializable] attribute, the .NET CLR can do this for us automatically since we are not using any complex data types in our entities.
With our entities out of the way, we can define our contract. Inside the Contracts directory, create a new Interface file named IWHSMailService.
This interface/contract will define three methods: one method to return a tree of objects which represent the folder tree in Outlook (GetFolders), one method to return a list of messages inside that folder (GetMessages), and one method to return the contents of a specific message (GetMessages). The interface will look like the following:
C#
using System.Collections.Generic; using System.ServiceModel; using WHSMailCommon.Entities; namespace WHSMailCommon.Contracts { // list of methods of the WHSMailService service [ServiceContract()] public interface IWHSMailService { [OperationContract] List<Folder> GetFolders(); [OperationContract] List<Email> GetMessages(string entryID, int numPerPage, int pageNum); [OperationContract] Email GetMessage(string entryID); } }
VB
Imports Microsoft.VisualBasic Imports System.Collections.Generic Imports System.ServiceModel Imports WHSMailCommon.Entities Namespace WHSMailCommon.Contracts ' list of methods of the WHSMailService service <ServiceContract()> _ Public Interface IWHSMailService <OperationContract> _ Function GetFolders() As List(Of Folder) <OperationContract> _ Function GetMessages(ByVal entryID As String, ByVal numPerPage As Integer, ByVal pageNum As Integer) As List(Of Email) <OperationContract> _ Function GetMessage(ByVal entryID As String) As Email End Interface End Namespace
As with the entities, this interface is also decorated with several attributes. First, any WCF contract interface must be tagged with the [ServiceContract()] attribute to define it as a contract to WCF. Additionally, all methods which will be exposed for consumption by a client must be marked with the [OperationContract] attribute.
The implementation and description of these methods will come later when we write the contract implementation.
The final thing to setup on the WCF server is the configuration file. The service can very easily be configured using an application configuration file. Add an Application Configuration file to the project named App.config. Set the contents of the file to the following:
<?xml version="1.0" encoding="utf-8" ?> <configuration> <system.serviceModel> <bindings> <netTcpBinding> <binding name="NewBinding0"> <security mode="None" /> </binding> </netTcpBinding> </bindings> <services> <service name="WHSMailHost.WHSMailService"> <endpoint address="net.tcp://localhost:12345/IWHSMailService" binding="netTcpBinding" bindingConfiguration="NewBinding0" contract="WHSMailCommon.Contracts.IWHSMailService" /> </service> </services> </system.serviceModel> </configuration>
The <services> section defines the services that the WCF host will enable. This creates a single <service> named WHSMailHost.WHSMailService, which is the full name of the service class that we will implement shortly. Inside the <service> tag an endpoint is defined. And endpoint is essentially the "port" on which the client connects. The endpoint defined here is using the net.tcp protocol and is set to listen on the localhost on port 12345 at the name IWHSMailService. The next thing defined is a binding. This sets protocol by which the service will communicate, its configuration, and the contract it implements. The binding configuration named NewBinding0 (a default name) can be found above inside the <bindings> tag. The only configuration item that is specified is that security will be turned off for communication between the client and server. Given that these two machines will be connecting to each other on your own local network, security is not a great concern. If you were to use this over a real internet connection where the client and server were open to the outside world, you would definitely not want to do this.
There are around several billion other options, protocols, bindings, etc. etc. etc. that can be configured here. At the end of the article you will find several links to WCF documentation that you can explore to learn more about the WCF internals. Also note that you can configure WCF with a windows UI by selecting the Service Configuration Editor application installed with the Windows SDK. You'll find this in the Microsoft Windows SDK program group off your Start menu.
MAPI (Messaging Application Programming Interface) is what allows one to develop applications and plugins for Microsoft Outlook and other messaging applications which support it (Exchange, Windows Messaging, etc.). We will be using MAPI to get the data we require from Outlook for our application.
Earlier we created a contract named IWHSMailService. This contract will be implemented by the WHSMailService class that was also created earlier. To do this, set a reference to the project's WHSMailCommon assembly. Then, bring the Contracts and Entities namespaces into the WHSMailService class with the following:
C#
using WHSMailCommon.Contracts; using WHSMailCommon.Entities;
VB
Imports WHSMailCommon.Contracts Imports WHSMailCommon.Entities
Then, setup the class to implement the interface as follows:
C#
public class WHSMailService : IWHSMailService
VB
Public Class WHSMailService Implements IWHSMailService
Visual Studio will then create the 3 methods that must be implemented according to the interface (GetFolders, GetMessages, GetMessage). We will fill those in shortly. But first, we must implement our constructor. When WCF calls the service, a new instance of the object will be created on each call. Therefore, it is easy to setup any initialization code for the service in the default constructor method. In this case, we will initialize the MAPI layer and logon to the default instance.
First, set a reference to the Microsoft Outlook XX.0 Object Library which you will find under the COM tab assuming Outlook is installed as per the instructions above. Note that the version will depend on what version of Outlook you have installed on your local machine. I'm using Outlook 2007, so version 12 is what the sample code above is referencing. If you are using a different version, reference the appropriate version before continuing.
Once the reference is set, bring the namespace into the WHSMailService class with the following line which will import the namespace and setup an alias named Outlook to save some typing:
C#
using Outlook = Microsoft.Office.Interop.Outlook;
VB
Imports Outlook = Microsoft.Office.Interop.Outlook
Now the constructor can be implemented. We need to get an instance of the Outlook ApplicationClass. From there we can get an instance of the MAPI namespace. All methods that we will be using hang off that namespace object. The code for the constructor follows:
C#
private readonly Outlook.NameSpace _nameSpace = null; public WHSMailService() { // get an instance of the MAPI namespace and login Outlook.Application app = new Outlook.ApplicationClass(); _nameSpace = app.GetNamespace("MAPI"); _nameSpace.Logon(null, null, false, false); }
VB
Private ReadOnly _nameSpace As Outlook.NameSpace = Nothing Public Sub New() ' get an instance of the MAPI namespace and login Dim app As Outlook.Application = New Outlook.ApplicationClass() _nameSpace = app.GetNamespace("MAPI") _nameSpace.Logon(Nothing, Nothing, False, False) End Sub
With a handle to the namespace, we can write our GetFolders method. This method will get the default Inbox folder in the default Outlook message store, look at the parent node, recursively enumerate from there, pulling out only folders that contain mail items, and finally sort them alphabetically (recall the CompareTo method of the IComparable interface we implemented earlier).
C#
public List<Folder> GetFolders() { List<Folder> list = new List<Folder>(); // get the inbox and then go up one level...that *should* be the root of the default store Outlook.MAPIFolder root = (Outlook.MAPIFolder)_nameSpace.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox).Parent; // add root folder Folder folder = new Folder(root.EntryID, root.Name, root.UnReadItemCount, root.Items.Count); list.Add(folder); // Enumerate the sub-folders EnumerateFolders(root.Folders, folder); return list; } private void EnumerateFolders(Outlook.Folders folders, Folder rootFolder) { foreach(Outlook.MAPIFolder f in folders) { // ensure it's a folder that contains mail messages (i.e. no contacts, appointments, etc.) if(f.DefaultItemType == Outlook.OlItemType.olMailItem) { if(rootFolder.Folders == null) rootFolder.Folders = new List<Folder>(); // add the current folder and enumerate all sub-folders Folder subFolder = new Folder(f.EntryID, f.Name, f.UnReadItemCount, f.Items.Count); rootFolder.Folders.Add(subFolder); if(f.Folders.Count > 0) this.EnumerateFolders(f.Folders, subFolder); } } // alphabetize the list (Folder implements IComparable) rootFolder.Folders.Sort(); }
VB
Public Function GetFolders() As List(Of Folder) Implements IWHSMailService.GetFolders Dim list As List(Of Folder) = New List(Of Folder)() ' get the inbox and then go up one level...that *should* be the root of the default store Dim root As Outlook.MAPIFolder = CType(_nameSpace.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox).Parent, Outlook.MAPIFolder) ' add root folder Dim folder As Folder = New Folder(root.EntryID, root.Name, root.UnReadItemCount, root.Items.Count) list.Add(folder) ' Enumerate the sub-folders EnumerateFolders(root.Folders, folder) Return list End Function Private Sub EnumerateFolders(ByVal folders As Outlook.Folders, ByVal rootFolder As Folder) For Each f As Outlook.MAPIFolder In folders ' ensure it's a folder that contains mail messages (i.e. no contacts, appointments, etc.) If f.DefaultItemType = Outlook.OlItemType.olMailItem Then If rootFolder.Folders Is Nothing Then rootFolder.Folders = New List(Of Folder)() End If ' add the current folder and enumerate all sub-folders Dim subFolder As Folder = New Folder(f.EntryID, f.Name, f.UnReadItemCount, f.Items.Count) rootFolder.Folders.Add(subFolder) If f.Folders.Count > 0 Then Me.EnumerateFolders(f.Folders, subFolder) End If End If Next f ' alphabetize the list (Folder implements IComparable) rootFolder.Folders.Sort() End Sub
This code will return a generic hierarchal list of our Folder entity objects which will be displayed in the web application. Note that one of the items assigned to the Folder entity is the EntryID property from the MAPIFolder object. All MAPI items, be they email messages, folders, appointments, etc. have a unique identifier which is stored in the EntryID field. We will need this unique value later on to retrieve messages from that folder.
Next, let's implement the GetMessages method. This method will return a list of messages (minus the bodies) from the folder specified using the GetFolderFromID method, sorted by the received date with the most current first. It will also handle paging so that the entire folder is not returned at once.
C#
public List<Email> GetMessages(string entryID, int numPerPage, int pageNum) { List<Email> list = new List<Email>(); Outlook.MAPIFolder f; // if no ID specified, open the inbox if(string.IsNullOrEmpty(entryID)) f = _nameSpace.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); else f = _nameSpace.GetFolderFromID(entryID, ""); // to handle the sorting, one needs to cache their own instance of the items object Outlook.Items items = f.Items; // sort descending by received time items.Sort("[ReceivedTime]", true); // pull in the correct number of items based on number of items per page and current page number for(int i = (numPerPage*pageNum)+1; i <= (numPerPage*pageNum)+numPerPage && i <= items.Count; i++) { // ensure it's a mail message Outlook.MailItem mi = (items[i] as Outlook.MailItem); if(mi != null) list.Add(new Email(mi.EntryID, mi.SenderEmailAddress, mi.SenderName, mi.Subject, mi.ReceivedTime, mi.Size)); } return list; }
VB
Public Function GetMessages(ByVal entryID As String, ByVal numPerPage As Integer, ByVal pageNum As Integer) As List(Of Email) Implements IWHSMailService.GetMessages Dim list As List(Of Email) = New List(Of Email)() Dim f As Outlook.MAPIFolder ' if no ID specified, open the inbox If String.IsNullOrEmpty(entryID) Then f = _nameSpace.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox) Else f = _nameSpace.GetFolderFromID(entryID, "") End If ' to handle the sorting, one needs to cache their own instance of the items object Dim items As Outlook.Items = f.Items ' sort descending by received time items.Sort("[ReceivedTime]", True) ' pull in the correct number of items based on number of items per page and current page number Dim i As Integer = (numPerPage*pageNum)+1 Do While i <= (numPerPage*pageNum)+numPerPage AndAlso i <= items.Count ' ensure it's a mail message Dim mi As Outlook.MailItem = (TryCast(items(i), Outlook.MailItem)) If Not mi Is Nothing Then list.Add(New Email(mi.EntryID, mi.SenderEmailAddress, mi.SenderName, mi.Subject, mi.ReceivedTime, mi.Size)) End If i += 1 Loop Return list End Function
The method takes a string parameter named entryID which is the unique identifier of the folder (from the code above) from which to return the messages. If no ID is specified, messages are returned from the Inbox. This code also handles paging by indexing into the array of Items of the MAPIFolder object at the specified position and counting out numPerPage records to be returned. A list of Email entity objects is returned from this method to be displayed on the web interface.
Finally, let's implement the GetMessage method. This will return a specific message based on the MAPI EntryID using the GetItemFromID method and format the body for plain text or HTML display.
C#
public Email GetMessage(string entryID) { // pull the message Outlook.MailItem mi = (_nameSpace.GetItemFromID(entryID, "") as Outlook.MailItem); if (mi != null) { string body; // if it's a plain format message, wrap it in <pre> tags for nice output if(mi.BodyFormat == Outlook.OlBodyFormat.olFormatPlain) body = "<pre>" + mi.Body + "</pre>"; else body = mi.HTMLBody; return new Email(mi.EntryID, mi.SenderEmailAddress, mi.SenderName, mi.Subject, mi.ReceivedTime, mi.Size, body); } else return null; }
VB
Public Function GetMessage(ByVal entryID As String) As Email Implements IWHSMailService.GetMessage ' pull the message Dim mi As Outlook.MailItem = (TryCast(_nameSpace.GetItemFromID(entryID, ""), Outlook.MailItem)) If Not mi Is Nothing Then Dim body As String ' if it's a plain format message, wrap it in <pre> tags for nice output If mi.BodyFormat = Outlook.OlBodyFormat.olFormatPlain Then body = "<pre>" & mi.Body & "</pre>" Else body = mi.HTMLBody End If Return New Email(mi.EntryID, mi.SenderEmailAddress, mi.SenderName, mi.Subject, mi.ReceivedTime, mi.Size, body) Else Return Nothing End If End Function
With these three methods in place, we can get our folders, get messages in those folders, and get a specific message from a folder. Now we can switch our attention to the web-based "client" application which will plug into WHS.
The client we are going to produce is going to be a very simple 3-paned screen much like Outlook with folders on the left, messages on the top right, and message text on the bottom right.
Create a new ASP.NET Web Application named WHSMailWeb (if you are using the Express editions of Visual Studio, you will need to do this in Visual Web Developer Express). Set a reference to the WHSMailCommon assembly along with the System.ServiceModel assembly. Again, if you are in Express, you will have to set the reference to WHSMailCommon in the other project's bin directory.
First, let's configure the ASP.NET application so it can properly call our WCF service running on the host machine. Open the web.config file and add the following after the end </system.web> tag and before the end </configuration> tab:
<system.serviceModel> <bindings> <netTcpBinding> <binding name="NewBinding0" maxReceivedMessageSize="1048576"> <readerQuotas maxStringContentLength="1048576" /> <security mode="None" /> </binding> </netTcpBinding> </bindings> <client> <endpoint address="net.tcp://SERVERNAME:12345/IWHSMailService" binding="netTcpBinding" bindingConfiguration="NewBinding0" contract="WHSMailCommon.Contracts.IWHSMailService" name="WHSMailService" /> </client> </system.serviceModel>
This looks very similar to the configuration information from our host service. An endpoint is defined which points to the host server. YOU WILL NEED TO CHANGE THE "SERVERNAME" HOST TO THE NAME OR IP ADDRESS OF THE MACHINE THAT WILL RUN THE HOST SERVICE WE CREATED ABOVE!
You will see the same setup for the binding and contract. The name parameter will be used by the code a bit later so WCF knows how to contact the host to instantiate the remote object.
The one thing that is different here is the netTcpBinding configuration. The security mode is set to none as it was before, but we have the addition of the maxReceivedMessageSize and maxStringContentLength. Both of these are set at 1 megabyte to allow that much data to be transferred from the host to the client. The default is only 64K which is not enough to return most of the data we need to serialize and transmit between the two machines.
Next, add a page to the project named BasePage. This page will be inherited by all pages in the project to easily startup and shutdown the WCF channel required to retrieve the message data.
Open the code-behind file for BasePage and add the following code:
C#
using System; using System.ServiceModel; using WHSMailCommon.Contracts; namespace WHSMailWeb { public partial class BasePage : System.Web.UI.Page { ChannelFactory<IWHSMailService> _factory = null; private IWHSMailService _channel = null; protected void Page_Init(object sender, EventArgs e) { // create a channel factory and then instantiate a proxy channel _factory = new ChannelFactory<IWHSMailService>("WHSMailService"); _channel = _factory.CreateChannel(); } protected void Page_Unload(object sender, EventArgs e) { try { _factory.Close(); } catch { // for the moment, we don't really care what happens here...if it fails, so be it } } public IWHSMailService WHSMailService { get { return _channel; } } } }
VB
Imports Microsoft.VisualBasic Imports System Imports System.ServiceModel Imports WHSMailCommon.Contracts Namespace WHSMailWeb Public Partial Class BasePage Inherits System.Web.UI.Page Private _factory As ChannelFactory(Of IWHSMailService) = Nothing Private _channel As IWHSMailService = Nothing Protected Sub Page_Init(ByVal sender As Object, ByVal e As EventArgs) ' create a channel factory and then instantiate a proxy channel _factory = New ChannelFactory(Of IWHSMailService)("WHSMailService") _channel = _factory.CreateChannel() End Sub Protected Sub Page_Unload(ByVal sender As Object, ByVal e As EventArgs) Try _factory.Close() Catch ' for the moment, we don't really care what happens here...if it fails, so be it End Try End Sub Public ReadOnly Property WHSMailService() As IWHSMailService Get Return _channel End Get End Property End Class End Namespace
This code overrides the page's Init and Unload methods. Page_Init is called at the beginning of a page request and Page_Unload is called just before the request completes.
The Page_Init method here uses WCF to create a ChannelFactory object factory that will return proxy objects of type IWHSMailService. Recall that this is the interface which defines the contract of our service. The name passed as a parameter to the ChannelFactory constructor must match the name of the service in the configuration file, as discussed earlier.
Next, a channel object is created by calling CreateChannel from the ChannelFactory. This is what returns the fake, proxy object defined by our contract. A property named WHSMailService at the bottom of the class exposes this so that the inherited pages can easily use the object to call the host service. We'll see this a bit later.
The Page_Unload method simply closes the factory object and very poorly handles any exceptions that may occur when doing so. An error on close isn't critical to this application, but that is certainly not always the case.
Now we will implement the default page. I'm not going to repeat the entire HTML of the page itself here, and I'm certainly not a HTML/CSS designer, so I wouldn't recommend learning from that anyhow. It does, however, get the job done.
What you do need to know is that the page contains 4 <DIV> tags: one contains an ASP.NET TreeView object (the left-most pane), one contains an ASP.NET GridView object (the top-right pane), one contains 2 ASP.NET LinkButton's which implement a Next/Back paging scheme (the middle-right pane), and the last contains a table to display some header information, and a <DIV> to display the message contents.
After that poor description, here's what the application looks like in an instance of Internet Explorer with my instance of Outlook (with some blurring over the (not really) sensitive data):
Now let's implement the actual logic for the page. Open the Default.aspx page's code behind file. Bring in the WHSMailCommon.Entities namespace as follows:
C#
using WHSMailCommon.Entities;
VB
Imports WHSMailCommon.Entities
Next, change the _Default class to inherit from BasePage instead of System.Web.UI.Page:
C#
public partial class _Default : BasePage
VB
Public Partial Class _Default Inherits BasePage
Then, implement the Page_Load method. This method will be called after the Page_Init method which is implemented in our parent object and will be called automatically.
C#
private string _folderEntryID = string.Empty; private int _pageNum = 0; protected void Page_Load(object sender, EventArgs e) { // first time in (tvFolders has viewstate enabled if(tvFolders.Nodes.Count == 0) { // get the folder tree List<Folder> folderList = this.WHSMailService.GetFolders(); // add the root node and expand it TreeNode node = new TreeNode(folderList[0].Name, folderList[0].EntryID); node.Expanded = true; tvFolders.Nodes.Add(node); // load up the sub-folders LoadNode(folderList[0].Folders, node); // get the inbox list and bind it to the grid List<Email> list = GetMessages(); BindMessages(list); // save off the default page number and folder ID ViewState["PageNum"] = _pageNum; ViewState["FolderID"] = _folderEntryID; } _pageNum = int.Parse(ViewState["PageNum"].ToString()); _folderEntryID = ViewState["FolderID"].ToString(); } private void LoadNode(List<Folder> folders, TreeNode node) { foreach(Folder f in folders) { // add the node with the format of Folder (Unread/Total) TreeNode subNode = new TreeNode(f.Name + " (" + f.UnreadMessages + "/" + f.TotalMessages + ")", f.EntryID); // expand and select the inbox subNode.Expanded = subNode.Selected = (f.Name == "Inbox"); node.ChildNodes.Add(subNode); // load the subfolders if(f.Folders != null) LoadNode(f.Folders, subNode); } } private List<Email> GetMessages() { // get a group of messages based on the current page number and size return this.WHSMailService.GetMessages(_folderEntryID, GridView1.PageSize, _pageNum); } private void BindMessages(List<Email> list) { // load the grid GridView1.DataSource = list; GridView1.DataBind(); // save off the new page number ViewState["PageNum"] = _pageNum; }
VB
Private _folderEntryID As String = String.Empty Private _pageNum As Integer = 0 Protected Sub Page_Load(ByVal sender As Object, ByVal e As EventArgs) ' first time in (tvFolders has viewstate enabled If tvFolders.Nodes.Count = 0 Then ' get the folder tree Dim folderList As List(Of Folder) = Me.WHSMailService.GetFolders() ' add the root node and expand it Dim node As TreeNode = New TreeNode(folderList(0).Name, folderList(0).EntryID) node.Expanded = True tvFolders.Nodes.Add(node) ' load up the sub-folders LoadNode(folderList(0).Folders, node) ' get the inbox list and bind it to the grid Dim list As List(Of Email) = GetMessages() BindMessages(list) ' save off the default page number and folder ID ViewState("PageNum") = _pageNum ViewState("FolderID") = _folderEntryID End If _pageNum = Integer.Parse(ViewState("PageNum").ToString()) _folderEntryID = ViewState("FolderID").ToString() End Sub Private Sub LoadNode(ByVal folders As List(Of Folder), ByVal node As TreeNode) For Each f As Folder In folders ' add the node with the format of Folder (Unread/Total) Dim subNode As TreeNode = New TreeNode(f.Name & " (" & f.UnreadMessages & "/" & f.TotalMessages & ")", f.EntryID) ' expand and select the inbox subNode.Selected = (f.Name = "Inbox") subNode.Expanded = subNode.Selected node.ChildNodes.Add(subNode) ' load the subfolders If Not f.Folders Is Nothing Then LoadNode(f.Folders, subNode) End If Next f End Sub Private Function GetMessages() As List(Of Email) ' get a group of messages based on the current page number and size Return Me.WHSMailService.GetMessages(_folderEntryID, GridView1.PageSize, _pageNum) End Function Private Sub BindMessages(ByVal list As List(Of Email)) ' load the grid GridView1.DataSource = list GridView1.DataBind() ' save off the new page number ViewState("PageNum") = _pageNum End Sub
If the tvFolders tree-view has not been filled in, we call our host service's GetFolders method from the WHSMailService property as defined above. The method then enumerates through the hierarchical list of folders returned, building the same tree structure that exists in Outlook. The Text property of each TreeNode is set to the folder name with a count of messages unread and total count of messages. The Value property of the node is set to the unique MAPI-defined EntryID so that we can later grab that value to return the list of messages from that specific folder.
Next, we call our service's GetMessages method to return messages from the Inbox. We pass in the value from the grid view's PageSize property and a member variable named _pageNum. This will handle our paging scheme as we discussed earlier. Once that list of messages is returned, is it bound to the data grid for display.
Finally, the default page number and folder entry ID (0 and "" respectively) are stored in the ViewState so they can be assigned back to our member variables on each page request.
If you were to run the application right now, you would see the same page that you do above, minus the message text at the bottom.
Next, we will implement what occurs when a user clicks on a folder in the tree view. We simply pull out the MAPI unique EntryID which is stored in the Value field of the selected node, assign it to the local member variable and view state, and then call the GetMessages method from our service to return a list of messages from that folder, just as we did with the inbox above. The code below shows the process:
C#
protected void tvFolders_SelectedNodeChanged(object sender, EventArgs e) { // new folder, so reset the view _pageNum = 0; _folderEntryID = this.tvFolders.SelectedNode.Value; ViewState["FolderID"] = _folderEntryID; List<Email> list = GetMessages(); BindMessages(list); }
VB
Protected Sub tvFolders_SelectedNodeChanged(ByVal sender As Object, ByVal e As EventArgs) ' new folder, so reset the view _pageNum = 0 _folderEntryID = Me.tvFolders.SelectedNode.Value ViewState("FolderID") = _folderEntryID Dim list As List(Of Email) = GetMessages() BindMessages(list) End Sub
Next, we can implement the Next/Back link buttons. These methods simply increment or decrement the current page number, assign it to the view state for use on the next request, and then, just as before, gets the list of messages specific to the currently selected folder, using the unique ID stored in the view state.
C#
protected void btnPrev_Click(object sender, EventArgs e) { // first page if(_pageNum > 0) _pageNum--; List<Email> list = GetMessages(); BindMessages(list); } protected void btnNext_Click(object sender, EventArgs e) { // next page _pageNum++; List<Email> list = GetMessages(); // if we're out of messages, go back to the previous page if(list == null || list.Count == 0) { _pageNum--; list = GetMessages(); } BindMessages(list); }
VB
Protected Sub btnPrev_Click(ByVal sender As Object, ByVal e As EventArgs) ' first page If _pageNum > 0 Then _pageNum -= 1 End If Dim list As List(Of Email) = GetMessages() BindMessages(list) End Sub Protected Sub btnNext_Click(ByVal sender As Object, ByVal e As EventArgs) ' next page _pageNum += 1 Dim list As List(Of Email) = GetMessages() ' if we're out of messages, go back to the previous page If list Is Nothing OrElse list.Count = 0 Then _pageNum -= 1 list = GetMessages() End If BindMessages(list) End Sub
And finally, we need to implement what happens when the user selects a message from the top pane. If you look at the HTML for the Default page, you will see that the Subject field is bound to a LinkButton control in the grid view via a TemplateField (the rest of the fields are standard BoundFields. The LinkButton sets the CommandArgument property to the unique EntryID of that message as defined by MAPI. So, all we need to do is listen on the server for the LinkButton's Command event, grab the unique ID and call our service's GetMessage method with that ID to return the message itself.
C#
protected void btnLink_Command(object sender, CommandEventArgs e) { Email email = this.WHSMailService.GetMessage(e.CommandArgument.ToString()); // fill out the header with some basic info lblFrom.Text = email.FromName + " (" + email.From + ")"; lblSubject.Text = email.Subject; lblReceived.Text = email.Received.ToString(); // when a message is selected, write out the content msgContent.InnerHtml = email.Body; }
VB
Protected Sub btnLink_Command(ByVal sender As Object, ByVal e As CommandEventArgs) Dim email As Email = Me.WHSMailService.GetMessage(e.CommandArgument.ToString()) ' fill out the header with some basic info lblFrom.Text = email.FromName & " (" & email.From & ")" lblSubject.Text = email.Subject lblReceived.Text = email.Received.ToString() ' when a message is selected, write out the content msgContent.InnerHtml = email.Body End Sub
The Email entity is retrieved, its properties are set to the labels in the header, and the message content is assigned to the InnerHtml property of the content holding <DIV>.
Voila! A very simple mail reader.
That's it for code. Now we need to deploy the applications and configure Windows Home Server.
If you are deploying to a machine that is not your development machine, install the .NET Framework 3.0 runtime on the machine to which you will be running the host service. Then, copy the WHSMailHost.exe, WHSMailHost.exe.config, and WHSMailCommon.dll files to the machine running Outlook that will be connected to by the web application. You may want to create a shortcut in the Startup group so it will launch when you log into Windows.
Next, open Outlook and select Trust Center from the Tools menu. Choose Programmatic Access and set the option to Never warn me about suspicious activity. Unfortunately this must be disabled, otherwise Outlook will prompt you every time the host process attempts to retrieve any data. There is no way to bypass this on an application-by-application basis, so we have to turn it off for all applications.
If you are running firewall software on the PC, ensure that port 12345 will allow traffic to pass through. You may also change port 12345 to a different port, but you must change it in the configuration files for both applications.
Finally, note that Outlook does not need to remain open to use this application. You do, however, need to be logged into the machine with the host running.
On your Windows Home Server machine, install the .NET Framework 3.0 Runtime. This is needed to use WCF. Next, create a new directory named mail (or anything you wish, but it is assumed it is named mail) in the c:\inetpub directory. Copy all .aspx pages, web.config, icon.png and the bin directory to the mail directory. You could also share out the mail directory and use Visual Studio's Publish command to automatically copy the files to that share. Then, open the Internet Information Services (IIS) Manager application from the Administrative Tools group on the Start menu. Open Web Sites and then Default Web Site in the left pane. Right-click on Default Web Site and select New -> Virtual Directory... from the context menu. When prompted, use the following values:
| Alias | |
| Path | c:\inetpub\mail |
| Permissions | Read, Run Scripts |
The next part is a bit tricky. We want to integrate with the security already provided by Windows Home Server. The WHS remote website uses Forms security from ASP.NET. Ideally, you should be able to log into the main page of the WHS remote website and then open the webmail link without having to log in again. Additionally, if the mail URL is used directly, you should be prompted for your login credentials.
To achieve this, open the web.config file from c:\inetpub\remote in Notepad. In the XML, between the start and end <system.web> tags you will find the configuration for the Forms-based authentication used by WHS. In order to use the cookie that is created by this authentication method, we need to have the same key values in our web.config file. The easiest way to achieve this is to copy all of the the text between the two <system.web> tags. Next, open the web.config file from the mail directory that you just copied over and paste the text between the two lines informing you to do so. Finally, we need to update the loginUrl and defaultRedirect paths in the forms and customErrors keys. Add /remote/ to the beginning of each to make them /remote/login.aspx and /remote/error.aspx. Be sure that the SERVERNAME item in the endpoint configuration is updated to the name/IP address of the machine running the host process.
With the files copied and edited, ensure that permissions on the file are propagated from the root mail directory to the files inside.
Finally, open the websites.xml file in the /remote directory and add the following XML before the end </WebSites> tag:
<WebSite name="Outlook Webmail" uri="/mail" imageUrl="/mail/icon.png" absolute="false"></WebSite>
With the configuration done, we need to enable web site connectivity in WHS. Double-click the Windows Home Server Console icon on the desktop. When it loads, click the Settings button in the top right corner. When the Settings dialog appears, select Remote Access in the left pane and then click the Turn On button in the right pane, assuming web site connectivity is off. If it already enabled, skip this step.
With all that done, create a user account for yourself in WHS so that you can login remotely. That's it!
Ensure the WHSMailHost application is running on the PC with Outlook installed. Then, browse to your Windows Home Server's default website. After logging in, you should see a link on the right side of the screen named Outlook Webmail.
Click that to start our application and if everything is working, you should see the web page as shown above.
If the web page displays an error message, open the web.config file in the mail directory on your Windows Home Server. Look for the <customErrors> tag and change the mode parameter to Off. This should allow you to see the actual error that is occurring and provide more information as to the status of the application.
Phew! Two applications and one shared assembly later, we have a web-based add-in for Windows Home Server that allows one to remotely access their Outlook message store. Currently the application is read-only and provides only minimal functionality. It's a great starting point to create a full-fledged webmail client interface. The project is hosted on CodePlex so I'm hoping to see you readers implement additional features and functionality that you find useful.
Here are a few links with additional information on the items discussed here:
Brian is a Microsoft C# MVP and a recognized .NET expert with over 6 years experience developing .NET solutions, and over 9 years of professional experience architecting and developing solutions using Microsoft technologies and platforms, although he has been "coding for fun" for as long as he can remember. Outside the world of .NET and business applications, Brian enjoys developing both hardware and software projects in the areas of gaming, robotics, and whatever else strikes his fancy for the next ten minutes. He rarely passes up an opportunity to dive into a C/C++ or assembly language project. You can reach Brian via his blog at http://www.brianpeek.com/.
Comments have been closed since this content was published more than 30 days ago, but if you'd like to continue the conversation,
please create a new thread in our Forums,
or
Contact Us and let us know.
Follow the Discussion
Oops, something didn't work.
What does this mean?
Following an item on Channel 9 allows you to watch for new content and comments that you are interested in. You need to be signed in to Channel 9 to use this feature.What does this mean?
Following an item on Channel 9 allows you to watch for new content and comments that you are interested in and view them all on your notifications page.sign up for email notifications?
You stated at the start of the article, that the project would work fine on XP, is this the case for home edition as well.
Thanks Brian, great idea for a project.
I'm struggling to get it to work though, I have everything deployed (using your code rather than coding myself at this stage) but i continue to get the message.
"No connection could be made because the target machine actively refused it"
I have ensured that the firewall is open to port 12345
I can see norton listening to TCP port 12345.
Can anyone suggest what might be stopping this from working.
thanks
Mark
In case you missed it, Brian Peek created a Windows Home Server add-in for that lets people view their
In case you missed it, Brian Peek created a Windows Home Server add-in for that lets people view their
Just a quick not for Vista Users.
You must run outlook as an administrator to be able to change the security setting. Once the setting is changed, you can go back to running it normally.
Very good solution, easy to understand. In this way learning and understanding Win Servers is fine!
Greetz from http://www.techdummie.de
Thanks for this very helpful and interesting post.
This is a great article, thanks!
[FaultException: The server was unable to process the request due to an internal error. For more information about the error, either turn on IncludeExceptionDetailInFaults (either from ServiceBehaviorAttribute or from the <serviceDebug> configuration behavior) on the server in order to send the exception information back to the client, or turn on tracing as per the Microsoft .NET Framework 3.0 SDK documentation and inspect the server trace logs.]
System.Runtime.Remoting.Proxies.RealProxy.HandleReturnMessage(IMessage reqMsg, IMessage retMsg) +2668969
System.Runtime.Remoting.Proxies.RealProxy.PrivateInvoke(MessageData& msgData, Int32 type) +717
WHSMailCommon.Contracts.IWHSMailService.GetFolders() +0
WHSMailWeb._Default.Page_Load(Object sender, EventArgs e) in C:\Projects\Personal\WHSMail\WHSMailWeb\Default.aspx.cs:19
System.Web.Util.CalliHelper.EventArgFunctionCaller(IntPtr fp, Object o, Object t, EventArgs e) +15
System.Web.Util.CalliEventHandlerDelegateProxy.Callback(Object sender, EventArgs e) +33
System.Web.UI.Control.OnLoad(EventArgs e) +99
System.Web.UI.Control.LoadRecursive() +47
System.Web.UI.Page.ProcessRequestMain(Boolean includeStagesBeforeAsyncPoint, Boolean includeStagesAfterAsyncPoint) +1436
@Mark, please contact me directly through my blog at:
http://www.brianpeek.com/blog/contact.aspx
That will get an email direct to me (Brian) and we can figure out what's going on.
it all works perfectly. to bad it still has limited functionality. I was hoping It would look more like exchange.
is there already someone out there who created additional functionality ? i would shurely love to use it
really interesting, thank you!
Is it also possible to creat pushmail on home server?
great article by the way
There are many useful informations in this great article…I really enjoy reading the whole blog that you write...Thanks!
great article.
@Richard Knight: thanks for the tip.
A truly wonderful article. Thanks to the author
Are there any example out there of how to perform the WHS installation via an MSI, with all the web.config tweaks etc performed automatically?
I keep getting a refused connection to port 808.
This is confusing because the active port is 12345.
Hi all,
It runs fine here but other users at my WHS who have acces to remote login can also connect to the mailbox??
Is there any posebillity to avoid that?
Could you not just create a download version of a working version, instead of having to create it yourself.
I know the point is that your coding for fun, but....
Remove this comment
Remove this thread
close