Checking My E-Mail
Microsoft Developer Network
March 26, 2003
Summary: Duncan Mackenzie describes how to build a tool that uses the System.Net namespace of the Microsoft .NET Framework to check a POP3 e-mail account for unread messages. (15 printed pages)
Microsoft® Visual Basic® .NET
Spouse-Driven Software Design
Coding without any specific goal is like going to the grocery store without a shopping list. Sure, you have a good time, you end up with items—items that might potentially be useful to someone at some point in the future—but you are unlikely to end up with tonight's dinner. Well, that's exactly what I started doing (the coding, not the shopping...) to write this column. I just started coding away, and I wrote some cool stuff (you will just have to take my word for it), but as my deadline got closer and closer, I did not really have anything that was ready for publication. Lucky for me, I received some inspiration after I set up my wife, Laura, with a "new" computer, (okay, it is my old machine), running Microsoft® Windows® XP Home.
Up until now, I had never run a machine with XP Home on it (I generally run Windows XP Pro, connected to a Domain) so I was quite impressed when I saw the Welcome Screen for XP Home (see Figure 1). So was Laura, and she even noticed a feature I hadn't seen; if you are logged in but you've left the system long enough for it to switch back to the logon screen (or you hit Windows Key + L) the screen shows your unread e-mail message count! This was a wonderful feature for her, as she could quickly walk by the computer and see if she had new e-mail messages, without having to enter her password. Ah, the bliss of having provided one's spouse with a geeky computer or electronic solution that they find useful!
Figure 1. Welcome to Windows XP!
Sadly, it always showed the same thing, 7 unread e-mail messages, even though Laura did not have any unread e-mail messages at all. Talk about a setup for disappointment: To be told that you have seven new and potentially exciting messages, only to log on and find that you have none. That blissful feeling was quickly slipping away; I had to do some research, fast.
It turns out those seven unread messages were in her hotmail inbox, but that the value shown on the logon screen was being set using the extremely well-named SHSetUnreadMailCount API call. Now, Laura does not use hotmail for anything beyond MSN® Messenger, so seeing how many unread hotmail messages she has is not very useful. The Microsoft-implanted chip in the back of my neck is beeping like crazy right now at the suggestion, but I decided that this was probably true for many other folks as well. Suddenly I had focus; I could make a small application that would make the logon screen display a more useful value.
Now I should clarify a few details: Microsoft® Outlook® Express also sets this value, so if you are using it for your e-mail you are set, but Outlook does not. What I decided to build would handle connecting to a set of POP3 servers, pulling down the number of available messages, and populating the appropriate registry values. Of course, once I was done with that, I realized that I would likely mess up everything if I ignored Outlook, since that was the mail client Laura was using, so I added Outlook as an additional feature.
So let's break this down, as I like to do, into a set of distinct tasks that I needed to accomplish:
- Connect to a POP3 server and check the current # of messages.
- Allow you to configure a set of POP3 servers with host and user ID/password information.
- Save your server configuration information (including your user ID/password) in a secure fashion.
- Write an application that runs in the background and checks each configured server every n minutes.
- Update the Windows XP logon screen setting whenever the number of messages changes.
- In addition, just for kicks, do all the same stuff with Outlook 2000 or XP.
The full code is available, but I will look at each of these items in order and explain how the code works in each case.
Connecting to the POP3 Servers
I do not write Socket code for a living, and (as someone who uses Microsoft software) you should be happy about that. If my goal were just to make this work, I would go out and find someone else's POP3 component, even if I had to buy it. It would be more efficient and just plain easier. My goal is to show you some Microsoft® .NET code though, so I thought it would be more enjoyable and/or educational for you to watch me wander around in the System.NET namespace and the POP3 spec trying to write my own component.
The first thing I did was to create a little application that did all of the POP3 work in a single Button1_Click routine, and worked in a very procedural fashion (all the code in one big procedure) until I made it work more than once. Before I could use this code in my application though, it needed some tidying. I reorganized it into a proper class and abstracted out some of the specifics around POP3 commands and server information to make it easier for you to change if need be. If I was feeling pretentious, I suppose I would call this the "refactoring" stage, but Microsoft® Word has put a red-squiggle under that term, and I am inclined to agree with its assessment.
If you haven't use the socket classes in .NET, don't be scared—they are quite straightforward. I was able to get a connection working and sending data in only a few minutes. I used an instance of System.Net.Sockets.TcpClient to create a connection to a POP3 server and then grabbed the NetworkStream object once the connection was established.
Public Sub New(ByVal server As String, _ ByVal port As Integer, _ ByVal delay As Integer) Try m_Client = New TcpClient() m_Client.Connect(server, port) m_NS = m_Client.GetStream() m_Delay = delay Dim sResponse As String = GetResponse().Trim If sResponse.Substring(0, 3) <> "+OK" Then Throw New Exception("Connection Failed") End If Catch se As SocketException MsgBox(se.Message & vbCrLf & vbCrLf & se.ToString, _ MsgBoxStyle.Exclamation, "Socket Exception!") Throw New Exception("Connection Failed", se) Catch ex As Exception MsgBox(ex.Message & vbCrLf & vbCrLf & ex.ToString, _ MsgBoxStyle.Exclamation, "Exception!") Throw New Exception("Connection Failed", ex) End Try End Sub
Once I had the open connection and the Stream, everything after that point was just a matter of sending and receiving text. I abstracted the NetworkStream code to read and write byte arrays into a few higher-level functions: SendCommand and GetResponse. The POP3 spec describes two types of server responses, single-line and multi-line, so I included a MultiLine flag in the parameter list of my two functions.
Private Overloads Function GetResponse() As String Return GetResponse(False) End Function Private Overloads Function GetResponse( _ ByVal multiLine As Boolean) As String 'GetResponse wraps the work of 'waiting for a server response to complete 'Single-Line and Multi-Line responses end 'differently, so they need slightly different 'end conditions. Dim sOutput As String = "" Dim input As Integer Dim str(4096) As Byte Dim startTime As Date = Now Dim endCondition As String If multiLine Then endCondition = vbCrLf & vbCrLf & "." Else endCondition = vbCrLf End If Do While m_NS.DataAvailable() startTime = Now input = m_NS.Read(str, 0, 4096) sOutput &= ASCIIEncoding.ASCII.GetChars( _ str, 0, input) End While Loop Until sOutput.IndexOf(endCondition) >= 0 _ Or Now.Subtract(startTime).TotalMilliseconds > Me.m_Delay If sOutput.IndexOf(endCondition) < 0 Then Return sOutput Else Return sOutput End If End Function 'SendCommand abstracts sending a string 'and receiving a response Public Overloads Function SendCommand( _ ByVal command As String) As String Return SendCommand(command, False) End Function Public Overloads Function SendCommand( _ ByVal command As String, _ ByVal multiLine As Boolean) As String Dim user As Byte() user = ASCIIEncoding.ASCII.GetBytes(command) m_NS.Write(user, 0, user.GetLength(0)) Return GetResponse(multiLine) End Function
Note I could have opened a pair of friendlier stream objects, such as StreamReader and StreamWriter on top of the NetworkStream, but I stuck with the byte array. If you want to see some examples of using StreamReader/StreamWriter with a NetworkStream, check out this MSDN Magazine article by Andrew Duthie.
Once I was sending and receiving successfully, I wanted to work with my sockets code from a higher level (as if someone else wrote it and I did not care how it worked), so I decided to abstract that code into a utility class. Then, in my main POP3Server class, I implemented the simple set of POP3 commands that would enable me to retrieve the list of messages. I logged on to the server, using the USER and PASS commands, got the message count (using STAT), and then listed the headers of each message (using TOP). From the headers I parsed out three important pieces of information: the sender, the subject, and the message ID. Grabbing the message ID provided me with a unique identifier for each message, which allowed me to know when a message was "new" to my program. If I found a new message, I raised an event and passed along the subject and sender information. The code snippet below shows the message header retrieval and raising the new e-mail event, but it makes a lot more sense if you look at in the context of the full source code.
Dim msg As POP3.Message If msgCount > 0 Then Dim i As Integer For i = 1 To msgCount 'get the headers for the message 'using the TOP command response = p3.SendCommand( _ String.Format(Me.TopCmd, i), True) msg = ParseResponse(response) Dim msgIndex As Integer = 0 Dim found As Boolean = False 'check if this is a new message Do While msgIndex < Me.m_Messages.Count And Not found If Me.m_Messages(msgIndex).ID = msg.ID Then found = True End If msgIndex += 1 Loop If Not found Then Me.m_Messages.Add(msg) 'if it is new, raise the NewEmail 'event, passing the sender and subject RaiseEvent NewEmail(CObj(Me), _ New NewEmailEventArgs( _ msg.From, msg.Subject, msg.ID)) End If Next End If
That event was caught by my main application, so this is a good time to move on to the central application code.
Writing an Application for the System Tray
I decided to implement this application as a System Tray icon, because it allowed me an easy and relatively unobtrusive way to provide access to my application's options and notification of new mail.
Figure 2. I created a System Tray icon because you can never have enough of these.
I coded almost all of this application as a single Microsoft® Visual Basic® module (which, for you C# types, is equivalent to a class where all members are static), only using a Form for my options dialog. Coming from Visual Basic 6.0, this is exciting; I wrote an application that uses a system tray icon, a timer, and a context menu without requiring an invisible Form. I really hated having to create invisible forms, especially if they are visible for a brief moment at startup. So I was quite happy to find that I did not need one. In this main module, I created an instance of Hans Blomme's wonderful NotifyIconXP, a context menu and some menu items, and a timer. Then I wired up some event handlers for the menu items, the timer, and even one for the BalloonClick event of the notification icon (even though I don't use it, I thought you might want to).
'servers list Dim servers As New POP3ServerCollection() 'timer for polling e-mail servers Dim checkTimer As New Timer() 'A notifyIcon provides the main UI for the app 'this makes is available for popping up balloons, etc. Dim ni As HansBlomme.Windows.Forms.NotifyIcon 'general application preferences Dim appSettings As Settings 'I keep the form available as a module level 'variable so that the ViewOptions menu can avoid 'multiple instances open at once Dim myFrm As frmOptions Sub Main() 'load the server list and settings 'information from serialized xml files Reload() 'loop through all the loaded 'servers and attach a handler for 'the NewE-mail event. Dim srv As POP3Server For Each srv In servers AddHandler srv.NewE-mail, AddressOf NewE-mail Next 'appSettings stores the interval in seconds 'Timers work in milliseconds, so a little conversion 'is necessary to make them work together. checkTimer.Interval = appSettings.CheckInterval * 1000 checkTimer.Enabled = True 'add a handler for the Tick event of the timer AddHandler checkTimer.Tick, AddressOf CheckE-mail_Tick 'Create new Icon, remove HansBlomme section to work with 'the standard NotifyIcon. Other code changes would also 'be required ni = New HansBlomme.Windows.Forms.NotifyIcon() ni.Icon = New Icon(GetType(Main), "mail.ico") ni.Visible = True 'add an event handler for when the user clicks the balloon 'popped up by the NotifyIcon. The standard NotifyIcon does 'not provide this event so you will need to remove this 'line if you are not using the HansBlomme icon. AddHandler ni.BalloonClick, AddressOf NotificationBalloonClicked 'set up the context menu, including event handlers Dim ctxMenu As New ContextMenu() ctxMenu.MenuItems.Add("View Options", AddressOf ViewOptionsForm) ctxMenu.MenuItems.Add("Exit", AddressOf EndApplication) 'and assign it to the NotifyIcon so that it will pop up when 'the icon is right-clicked on. ni.ContextMenu = ctxMenu 'run the application, using Application.Run 'to create a new message loop Application.Run() 'when the app is exiting 'hide the notify icon and ni.Visible = False 'save the server info and settings to xml files Persist() End Sub
At the start of the Main() routine, I called Reload(), and then I called Persist() at the end. These two procedures are how I implemented saving my settings and POP3 server information to disk (in Persist) and loading them up again at startup (in Reload). Serialization is your friend whenever you want to save an object to disk, even a complex structure of objects. The POP3ServerCollection class referenced by this code was generated using the Collection Generator from GotDotNet, and is included in the downloadable source.
Public Sub Persist() 'Save the Settings object and the collection 'of POP3 servers to XML files using serialization 'Note that, unlike the Background Copying article 'these files are not being saved to Isolated Storage. 'They could be, I just thought I would be different 'this time. 'Figure out the paths for the xml files, notice 'the use of IO.Path.Combine... much better than just 'concatenating them together. Dim serverSettingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, _ "servers.xml") Dim settingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, _ "settings.xml") Dim myXMLSerializer As Xml.Serialization.XmlSerializer Dim settingsFile As IO.StreamWriter 'save the servers list myXMLSerializer _ = New Xml.Serialization.XmlSerializer( _ GetType(POP3ServerCollection)) settingsFile _ = New IO.StreamWriter(serverSettingsPath) myXMLSerializer.Serialize(settingsFile, servers) settingsFile.Close() 'save the settings class myXMLSerializer _ = New Xml.Serialization.XmlSerializer( _ GetType(Settings)) settingsFile = New IO.StreamWriter(settingsPath) myXMLSerializer.Serialize( _ settingsFile, appSettings) settingsFile.Close() End Sub Public Sub Reload() 'just the reverse of Persist(), this routine 'loads the two classes from their XML files, or 'creates new instances if the XML files do not exist Dim serverSettingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, "servers.xml") Dim settingsPath As String _ = IO.Path.Combine( _ Application.LocalUserAppDataPath, "settings.xml") If IO.File.Exists(serverSettingsPath) Then 'load servers Dim settingsFile As IO.StreamReader _ = New IO.StreamReader(serverSettingsPath) Dim myXMLSerializer As _ New Xml.Serialization.XmlSerializer( _ GetType(POP3ServerCollection)) servers = DirectCast( _ myXMLSerializer.Deserialize(settingsFile), _ POP3ServerCollection) settingsFile.Close() Else servers = New POP3ServerCollection() End If If IO.File.Exists(settingsPath) Then 'load settings Dim settingsFile As IO.StreamReader _ = New IO.StreamReader(settingsPath) Dim myXMLSerializer As _ New Xml.Serialization.XmlSerializer( _ GetType(Settings)) appSettings = DirectCast( _ myXMLSerializer.Deserialize(settingsFile), _ Settings) settingsFile.Close() Else appSettings = New Settings() End If End Sub
Storing the User ID/Password
As you can see in the Persist/Reload code, I saved your settings into the local application data path. This puts your information into \Documents and Settings\<userid>\Local Settings\Application Data\POP3\POP3\<version>\ as an XML file. That area should be restricted so that only you can access it, but that would not block someone with administrator access, so I decided I had better do a little bit more with the most sensitive parts of your information. To increase the security, I used DPAPI (courtesy of this little sample) to encrypt the User ID and Password before saving them to disk.
Function EncryptText(ByVal source As String) As String 'I use the DPAPI component from GotDotNet 'see the associated article for the link. Dim dp As _ New Dpapi.DataProtector(Dpapi.Store.UserStore) Dim sourceBytes As Byte() = _ System.Text.Encoding.Unicode.GetBytes(source) Dim encryptedBytes As Byte() encryptedBytes = dp.Encrypt(sourceBytes) 'I could store binary values, but since I am using 'XML as a storage mechanism, I prefer to just work 'with a string. ToBase64String handles that for me Return Convert.ToBase64String(encryptedBytes) End Function Function DecryptText(ByVal source As String) As String 'I use the DPAPI component from GotDotNet 'see the associated article for the link. Dim dp As _ New Dpapi.DataProtector(Dpapi.Store.UserStore) Dim sourceBytes As Byte() = _ Convert.FromBase64String(source) Dim decryptedBytes As Byte() decryptedBytes = dp.Decrypt(sourceBytes) Return System.Text.Encoding.Unicode.GetString(decryptedBytes) End Function
Of course, encrypting these strings means that I need to do a little bit of extra work to view and/or edit these values through my Options form.
Changing Settings Through an Options Dialog
I have to admit that when I am writing code for myself, I am usually fine with manually changing values in the code, in a .config file, or in the database. That isn't a good habit to get into, though, because at some point you will want to give this code to someone else, and then you are going to have to explain how to change the settings or just bite the bullet and create an options dialog. In this case, I decided to be a bit more proactive, so I created a little dialog and provided a "View Options" menu option off my notification icon.
Sub ViewOptionsForm( _ ByVal sender As Object, _ ByVal e As EventArgs) 'Pop up the Options dialog Try 'Check if it is already up, 'if so, just set the focus to it If Not myFrm Is Nothing AndAlso myFrm.Visible Then myFrm.Focus() Else 'not already up, create a new copy myFrm = New frmOptions() 'populate the form with data myFrm.servers = servers myFrm.appSettings = appSettings If myFrm.ShowDialog() = DialogResult.OK Then 'I "clone" the appSettings on the form 'so you need to pull a copy back when the form 'closes appSettings = myFrm.appSettings 'hey, you just changed all those settings 'I'd better save them! 'This way, even if the app ends in a bad way 'your changes are still saved. Persist() End If myFrm.Dispose() myFrm = Nothing End If Catch ex As System.Exception Debug.WriteLine(ex.ToString) Debug.WriteLine(ex.StackTrace) End Try End Sub
To allow the servers collection to be edited, I data bound it to a set of controls and provided a New and a Delete button. The little navigational control is something I wrote for my own use that you might find helpful. I could have just provided a couple of buttons that manipulated the CurrencyManager's Position property if I did not want to use this control.
Figure 3. The Option dialog allows you to set up your POP3 server information.
As I mentioned earlier, in the "Storing the User ID/Password" section, since I have encrypted the User ID and Password, I need to do some work to make those values work in a data bound scenario. I added event handlers for the Parse and Format events of the UserID and Password binding objects, so that these values are automatically decrypted for display and encrypted before being stored into the server collection.
'Format and parse routines to decrypt/encrypt values Private Sub encryptedBinding_Format( _ ByVal sender As Object, _ ByVal e As System.Windows.Forms.ConvertEventArgs) If e.Value <> String.Empty Then e.Value = Main.DecryptText(CStr(e.Value)) End If End Sub Private Sub encryptedBinding_Parse( _ ByVal sender As Object, _ ByVal e As System.Windows.Forms.ConvertEventArgs) If e.Value <> String.Empty Then e.Value = Main.EncryptText(CStr(e.Value)) End If End Sub
The general settings are not data bound; I just pulled them from the Settings class and then pushed the new values back in as needed.
'Instead of databinding the general settings 'I just manually push/pull the values to and from 'the controls. Private Sub PopulateControls() If m_appSettings.CheckInterval _ > Me.checkInterval.Maximum Then m_appSettings.CheckInterval _ = Me.checkInterval.Maximum ElseIf m_appSettings.CheckInterval _ < Me.checkInterval.Minimum Then m_appSettings.CheckInterval _ = Me.checkInterval.Minimum End If Me.checkInterval.Value = _ Me.m_appSettings.CheckInterval Me.checkOutlook.Checked = _ Me.m_appSettings.CheckOutlook Me.displayPopups.Checked = _ Me.m_appSettings.DisplayNewMailPopup End Sub Private Sub PullControlValues() Me.m_appSettings.CheckInterval = _ Me.checkInterval.Value Me.m_appSettings.CheckOutlook = _ Me.checkOutlook.Checked Me.m_appSettings.DisplayNewMailPopup = _ Me.displayPopups.Checked End Sub
I almost forgot this one. I had said I would check Outlook's unread messages as well as POP3 accounts. I do not want to reference Outlook's libraries for two reasons:
- I do not want you to need Outlook to compile this code or to run it.
- I did not want to create a dependency on a specific version of Outlook.
Without a reference to Outlook, I had to use late binding, which means no Microsoft® IntelliSense® and everything is treated as an object. On the positive side, though, the resulting code should work on Microsoft® Office 2000, Office XP, and even Office 11.
Public Shared Function GetUnreadMessages() As Integer Dim OutlookApp As Object Try OutlookApp = _ GetObject(, "Outlook.Application") Catch OutlookApp = Nothing End Try 'Uncomment this to automatically 'open Outlook if needed 'If OutlookApp Is Nothing Then ' OutlookApp = _ ' CreateObject("Outlook.Application") 'End If If Not OutlookApp Is Nothing Then '6 is a constant (for the Inbox) 'exposed by the Outlook library 'no reference means no library 'and no constant Dim inbox As Object = _ OutlookApp.Session.GetDefaultFolder(6) Return inbox.UnReadItemCount() Else Return -1 End If End Function
Note that this will not work if Outlook is not open. It could if I used CreateObject, but I did not want this little system tray application to force Outlook to be opened every n seconds. If it is not open, then it cannot read the number of unread items in the Outlook inbox. It is important to leave the unread value alone if you can't connect to Outlook. Setting it to zero would erase the last valid number.
At the end of some of my Coding4Fun columns, I will have a little coding challenge—something for you to work on if you are interested. For this article, the challenge is to create anything e-mail related, and it doesn't have to be POP3. Managed code is preferred (Visual Basic .NET, C#, J#, or Managed C++ please), but an unmanaged component that exposes a COM interface would also be good. Just post whatever you produce to GotDotNet and send me an e-mail message (at email@example.com) with an explanation of what you have done and why you feel it is interesting. You can send me your ideas whenever you like, but please just send me links to code samples, not the samples themselves (my inbox thanks you in advance).
I used four samples from GotDotNet when building the code for this article, and I am always scanning the list of user samples for useful and cool code. Here is a list of selected samples that you might find interesting:
Samples I Used in this Article
- NotifyIcon XP uploaded by hansb.
- Updated Strongly-Typed Collection Generator uploaded by KiwiMagic72.
- Data Protection API (DPAPI) Component uploaded by Macaw.
- Regular Expression Workbench (V1.04) uploaded by Eric Gunnerson.
Other Interesting Samples
- Windows Media Player 9 Series Metadata Sample uploaded by CorySmith.
- Collapsing Panels (XP Style) uploaded by RandyWilliams.
- Photo Properties Library and App uploaded by jgangel.
- SmtpClient Component; uploaded by boss.
That last sample, the SmtpClient, is a great example of the other side of mail—sending messages. Note that System.Web.Mail provides Smtp support right in the .NET Framework but it is dependent on CDONTS, while that sample is pure sockets. I had not noticed before, but boss is so into C# that even his sample name has a semi-colon at the end!
Have your own ideas for hobbyist content? Let me know at firstname.lastname@example.org, and happy coding!