jump to navigation

Robust Daemon Monitoring Inbox with MailKit w/ OAuth2 September 22, 2022

Posted by codinglifestyle in C#, CodeProject, Parallelism.
Tags: , , , , , , , ,
1 comment so far

It all started with an email sent to a daemon.  A windows service hosting two modules, each of which monitors an inbox for automation, dutifully ignored warnings from IT that basic authentication for O365 would be switched off in several months.  Months went by… Even though Microsoft’s announcement was 2 years ago by the time the right people were informed the deadline was just 2 months away.  Sound familiar? Unfortunately the current IMAP API doesn’t support OAuth2 authentication so would have to be replaced.  Even worse, we wasted weeks sorting out access with our Azure admin even though we had step-by-step instructions from day one.

Investigating mainstream IMAP APIs supporting OAuth2 turned up MailKit whose author was reassuringly active on Github and StackOverflow.  We quickly discovered devs everywhere were addressing this issue and there was much debate on how, or even if, it could even be done (some of this doubt from the author himself).  Thankfully after a painful couple of weeks, we were authenticating a daemon without user interaction (aka OAuth2 client credential grant flow).

When it comes to writing an API there is a spectrum in abstracting and obfuscating the inner workings from the user.  On one hand, an API written to be 1:1 with the server is less usable but may give minute control and transparency allowing for better debugging.  This path requires more ramp-up time and leaves more complexity to the user.  On the other end of the spectrum, the API does some of the heavy lifting and aims to provide a usable, easy-to-use interface.  A typical trade-off being the inner workings are a black box which might bite you in the ass down the road.

MailKit is wholeheartedly in the former camp compared with our old API.  The old one connected, there was a new email event, then disconnect when the service shuts down.  It was just overall easier to use from deleting a message to searching for new emails. For example, the email UID was part of the email object.  With MailKit this information must be queried separately because technically that’s how it’s stored on the server.  And this sets the tone for the entire experience of interacting with MailKit.

As mentioned above, even if a bit more difficult to use, it was very reassuring to see how active the author and user community are.  While porting code from the old API required a lot of rewriting there was plenty of documentation, discussion, and examples out there to answer our questions.  What was unexpected was that the server events did not work without building a full IMAP client, which reminds me of implementing a Windows message pump, to idle and process events in its own thread.  Thankfully documentation and examples, although complicated, were available to build upon.

With the preamble out of the way, we can finally talk about the code.  What follows is a C# wrapper for the MailKit API.  We can use it in two different ways.  You can simply instantiate it and execute a command with 2 lines of code.  This will automatically connect, run the IMAP command within the IMAP client thread context, and then disconnect.  Alternatively, you can use it as a long-running connection which will launch the IMAP client as a robust task which will stay connected until stopped.  This allows the use of an event the wrapper exposes to process new messages.  There is also a command queue so that code can be queued to run within the IMAP client thread context.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Security;
using Microsoft.Identity.Client;

namespace Codinglifestyle
{
    /// <summary>
    /// IMAP client instance capable of receiving events and executing IMAP commands
    /// </summary>
    /// <seealso cref="System.IDisposable" />
    public class ImapClientEx : IDisposable
    {
        #region Member variables
        ImapClient                   _imapClient;
        IMailFolder                  _imapFolder;
        int                          _numMessages;
        CancellationTokenSource      _tokenCancel;
        CancellationTokenSource      _tokenDone;

        Queue<OnImapCommand>         _queueCommand;
        bool                         _messagesArrived;

        readonly string              _imapServer;
        readonly string              _imapUser;
        readonly string              _authAppID;
        readonly string              _authAppSecret;
        readonly string              _authTenantID;
        readonly SecureSocketOptions _sslOptions;
        readonly int                 _port;
        readonly FolderAccess        _folderAccess;
        
        protected DateTime           _dtLastConnection;

        readonly object              _lock;
        #endregion

        #region Ctor
        /// <summary>
        /// Initializes a new instance of the <see cref="ImapClientEx"/> class.
        /// </summary>
        /// <param name="userEmail">The user email account.</param>
        public ImapClientEx(string userEmail)
        {
            _queueCommand  = new Queue<OnImapCommand>();
            _numMessages   = 0;
            _lock          = new object();

            Config config  = new Config("O365 Settings");
            _authAppID     = config["App ID"];
            _authAppSecret = config.Decrypt("App Secret");
            _authTenantID  = config["Tenant ID"];
            config         = new Config("Mail Settings");
            _imapServer    = config["IMAP Server"];
            _imapUser      = userEmail;
            _sslOptions    = SecureSocketOptions.Auto;
            _port          = 993;
            _folderAccess  = FolderAccess.ReadWrite;
        }
        #endregion

        #region Public Events
        /// <summary>
        /// IMAP command delegate to be queued and executed by the IMAP thread instance.
        /// </summary>
        /// <param name="imapClient">The IMAP client.</param>
        /// <param name="imapFolder">The IMAP folder.</param>
        public delegate void OnImapCommand(ImapClient imapClient, IMailFolder imapFolder);
        /// <summary>
        /// Event indicates the IMAP client folder has received a new message.
        /// </summary>
        /// <remarks>
        /// The event is called by the IMAP thread instance.
        /// </remarks>
        public event OnImapCommand NewMessage;
        /// <summary>
        /// Fires the new message event.
        /// </summary>
        private void OnNewMessageEvent(ImapClient imapClient, IMailFolder imapFolder)
        {
            if (NewMessage != null)
                NewMessage(_imapClient, _imapFolder);
        }
        #endregion

        #region Public Methods
        /// <summary>
        /// Runs a IMAP client asynchronously.
        /// </summary>
        public async Task RunAsync()
        {
            try
            {
                //
                //Queue first-run event to load new messages since last connection (the consumer must track this)
                //
                QueueCommand(OnNewMessageEvent);
                //
                //Run command in robustness pattern asynchronously to let this thread go...
                //
                await DoCommandAsync((_imapClient, _imapFolder) =>
                {
                    //
                    //Run IMAP client async in IDLE to listen to events until Stop() is called
                    //
                    IdleAsync().Wait();
                });

                Log.Debug(Identifier + "IMAP client exiting normally.");
            }
            catch (OperationCanceledException)
            {
                //Token is cancelled so exit
                Log.Debug(Identifier + "IMAP operation cancelled...");
            }
            catch (Exception ex)
            {
                Log.Err(ex, Identifier + "RunAsync");
            }
            finally
            {
                //
                //Disconnect and close IMAP client
                //
                Dispose();
            }
        }

        /// <summary>
        /// Gets a value indicating whether this IMAP client instance is connected.
        /// </summary>
        public bool IsConnected => _imapClient?.IsConnected == true && _imapFolder?.IsOpen == true;

        /// <summary>
        /// Identifiers this instance for logging.
        /// </summary>
        public string Identifier => string.Format("IMAP {0} [{1}]: ", _imapUser, Thread.CurrentThread.ManagedThreadId);

        /// <summary>
        /// Stops this IMAP client instance.
        /// </summary>
        public void Stop()
        {
            //Cancel the tokens releasing the IMAP client thread
            _tokenDone?.Cancel();
            _tokenCancel?.Cancel();
        }

        /// <summary>
        /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
        /// </summary>
        /// <remarks>This is safe to call and then carry on using this instance as all the resources will be automatically recreated by error handling</remarks>
        public void Dispose()
        {
            //Cancel tokens
            Stop();
            //Release connection
            DisconnectAsync().Wait();
            //Release resources
            if (_imapFolder != null)
            {
                _imapFolder.MessageExpunged -= OnMessageExpunged;
                _imapFolder.CountChanged    -= OnCountChanged;
            }
            _imapFolder  = null;
            _imapClient?.Dispose();
            _imapClient  = null;
            _tokenCancel?.Dispose();
            _tokenCancel = null;
            _tokenDone?.Dispose();
            _tokenDone   = null;
        }
        #endregion

        #region IMAP Connect / Idle
        /// <summary>
        /// Connects IMAP client, authenticated with OAUTH2, and opens the Inbox folder asynchronously.
        /// </summary>
        private async Task ConnectAsync()
        {
            //Dispose of existing instance, if any.
            if (_imapClient != null)
                Dispose();
            //
            //Create IMAP client
            //
            _imapClient  = new ImapClient();
            //
            //Create a new cancellation token
            //
            _tokenCancel = new CancellationTokenSource();
            //
            //Connect to the server
            //
            Log.Debug(Identifier + "Connecting to IMAP server: " + _imapServer);
            if (!_imapClient.IsConnected)
                await _imapClient.ConnectAsync(_imapServer, _port, _sslOptions, _tokenCancel.Token);
            //
            //Authenticate
            //
            if (!_imapClient.IsAuthenticated)
            {
                //
                //Create the client application
                //
                var app       = ConfidentialClientApplicationBuilder
                                    .Create(_authAppID)
                                    .WithClientSecret(_authAppSecret)
                                    .WithAuthority(new System.Uri($"https://login.microsoftonline.com/{_authTenantID}"))
                                    .Build();
                //
                //Get the OAUTH2 token
                //
                var scopes    = new string[] { "https://outlook.office365.com/.default" };
                var authToken = await app.AcquireTokenForClient(scopes).ExecuteAsync();
                Log.Debug(Identifier + "Creating OAUTH2 tokent for {0}: {1}", _imapUser, authToken.AccessToken);
                var oauth2    = new SaslMechanismOAuth2(_imapUser, authToken.AccessToken);
                //
                //Authenticate
                //
                Log.Debug(Identifier + "Authenticating user: " + _imapUser);
                await _imapClient.AuthenticateAsync(oauth2, _tokenCancel.Token);
            }
            //
            //Open inbox
            //
            if (!_imapClient.Inbox.IsOpen)
                await _imapClient.Inbox.OpenAsync(_folderAccess, _tokenCancel.Token);
            // Note: We capture client.Inbox here because cancelling IdleAsync() *may* require
            // disconnecting the IMAP client connection, and, if it does, the `client.Inbox`
            // property will no longer be accessible which means we won't be able to disconnect
            // our event handlers.
            _imapFolder                  = _imapClient.Inbox;
            //
            //Track changes to the number of messages in the folder (this is how we'll tell if new messages have arrived).
            _imapFolder.CountChanged    += OnCountChanged;
            //Track of messages being expunged to track messages removed to work in combination with the above event.
            _imapFolder.MessageExpunged += OnMessageExpunged;
            //Track the message count to determine when we have new messages.
            _numMessages                 = _imapFolder.Count;
        }

        /// <summary>
        /// Closes the folder and disconnects IMAP client asynchronously.
        /// </summary>
        private async Task DisconnectAsync()
        {
            try
            {
                //Disconnect IMAP client
                if (_imapClient?.IsConnected == true)
                    await _imapClient.DisconnectAsync(true);
                Log.Debug(Identifier + "Disconnected.");
            }
            catch (Exception)
            {
            }
        }

        /// <summary>
        /// Idles waiting for events or commands to execute asynchronously.
        /// </summary>
        private async Task IdleAsync()
        {
            do
            {
                try
                {
                    //
                    //Run all queued IMAP commands
                    //
                    await DoCommandsAsync();
                    //
                    //Idle and listen for messages
                    //
                    await WaitForNewMessages();
                    //
                    if (_messagesArrived)
                    {
                        Log.Debug(Identifier + "New message arrived.  Queueing new message event...");
                        //
                        QueueCommand(OnNewMessageEvent);
                        //
                        _messagesArrived = false;
                    }
                }
                catch (OperationCanceledException)
                {
                    //Token is cancelled so exit
                    Log.Debug(Identifier + "IMAP Idle stopping...");
                    break;
                }
            } while (_tokenCancel != null && !_tokenCancel.IsCancellationRequested);
        }

        /// <summary>
        /// Waits for server events or cancellation tokens asynchronously.
        /// </summary>
        private async Task WaitForNewMessages()
        {
            try
            {
                Log.Debug(Identifier + "IMAP idle for 1 minute.  Connection age: {0}", DateTime.Now - _dtLastConnection);
                if (_imapClient.Capabilities.HasFlag(ImapCapabilities.Idle))
                {
                    //Done token will self-desrtruct in specified time (1 min)
                    _tokenDone = new CancellationTokenSource(new TimeSpan(0, 1, 0));
                    //
                    //Idle waiting for new events...
                    //Note: My observation was that the events fired but only after the 1 min token expired
                    //
                    await _imapClient.IdleAsync(_tokenDone.Token, _tokenCancel.Token);
                }
                else
                {
                    //Wait for 1 min
                    await Task.Delay(new TimeSpan(0, 1, 0), _tokenCancel.Token);
                    //Ping the IMAP server to keep the connection alive
                    await _imapClient.NoOpAsync(_tokenCancel.Token);
                }
            }
            catch (OperationCanceledException)
            {
                Log.Debug(Identifier + "WaitForNewMessages Idle cancelled...");
                throw;
            }
            catch (Exception ex)
            {
                Log.Warn(ex, Identifier + "WaitForNewMessages errored out...");
                throw;
            }
            finally
            {
                _tokenDone?.Dispose();
                _tokenDone = null;
            }
        }
        #endregion

        #region Command Queue
        /// <summary>
        /// Connects and performs IMAP command asynchronously.
        /// </summary>
        /// <param name="command">The IMAP comannd to execute.</param>
        /// <param name="retries">The number of times to retry executing the command.</param>
        /// <returns>Return true if the command succesfully updated</returns>
        /// <exception cref="MailKit.ServiceNotConnectedException">Will enter robustness pattern if not connected and retry later</exception>
        public async Task<bool> DoCommandAsync(OnImapCommand command, int retries = -1)
        {
            int attempts                  = 1;
            int errors                    = 0;
            int connections               = 0;
            _dtLastConnection             = DateTime.Now;
            DateTime errorStart           = DateTime.Now;
            bool bReturn                  = false;

            //Enter robustness pattern do/while loop...
            do
            {
                try
                {
                    //
                    //Connect, if not already connected
                    //
                    if (!IsConnected)
                    {
                        Log.Debug(Identifier + "Connection attempt #{0}; retries: {1}; errors: {2}; conns: {3}; total age: {4})",
                                  attempts++,
                                  (retries-- < 0) ? "infinite" : retries.ToString(),
                                  errors,
                                  connections,
                                  DateTime.Now - _dtLastConnection);
                        //
                        //Connect to IMAP
                        //
                        await ConnectAsync();
                        //Test IMAP connection
                        if (!IsConnected)
                            throw new ServiceNotConnectedException();
                        Log.Debug($"{Identifier}Server Connection: {IsConnected}");
                        //Reset connection stats
                        attempts          = 1;
                        errors            = 0; 
                        _dtLastConnection = DateTime.Now;
                        connections++;
                    }
                    //
                    //Perform command
                    //
                    Log.Debug("{0}Run IMAP command: {1}", Identifier, command.Method);
                    await Task.Run(() => command(_imapClient, _imapFolder), _tokenCancel.Token);
                    //
                    //Success: break the do/while loop and exit
                    //
                    Log.Debug(Identifier + "Command completed successfully.");
                    bReturn = true;
                    break;
                }
                catch (OperationCanceledException)
                {
                    //Token is cancelled so break the do/while loop and exit
                    Log.Debug(Identifier + "Command operation cancelled...");
                    break;
                }
                catch (Exception ex)
                {
                    //If no reries left log the error
                    if (retries == 0 && IsConnected)
                        Log.Err(ex, "{0}Error IMAP command: {1}", Identifier, command.Method);
                    //If first error since connected...
                    if (errors++ == 0)
                    {
                        //Track time since first error
                        errorStart = DateTime.Now;
                        //Reset the IMAP connection
                        Log.Debug(Identifier + "Error detected - attempt immediate reconnection.");
                        await DisconnectAsync();
                    }
                    else
                    {
                        TimeSpan errorAge = (DateTime.Now - errorStart);
                        Log.Debug(Identifier + "Connect failure (attempting connection for {0})", errorAge);

                        //Wait and try to reconnect
                        if (errorAge.TotalMinutes < 10)
                        {
                            Log.Debug(Identifier + "Cannot connect.  Retry in 1 minute.");
                            await Task.Delay(new TimeSpan(0, 1, 0), _tokenCancel.Token);
                        }
                        else if (errorAge.TotalMinutes < 60)
                        {
                            Log.Info(Identifier + "Cannot connect.  Retry in 10 minutes.");
                            await Task.Delay(new TimeSpan(0, 10, 0), _tokenCancel.Token);
                        }
                        else
                        {
                            Log.Err(ex, Identifier + "Cannot connect.  Retry in 1 hour (total errors: {0}).", errors);
                            await Task.Delay(new TimeSpan(1, 0, 0), _tokenCancel.Token);
                        }
                    }
                }
            } while (retries != 0 && _tokenCancel != null && !_tokenCancel.IsCancellationRequested);
            //
            //Return true if the command succesfully updated
            //
            return bReturn;
        }

        /// <summary>
        /// Execute the IMAP commands in the queue asynchronously.
        /// </summary>
        /// <param name="retries">The number of times to retry executing the command.</param>
        /// <returns>True if all commands in the queue are executed successfully.</returns>
        /// <remarks>Command retries do not apply to the queue which will run idefinitely until empty or cancelled</remarks>
        public async Task<bool> DoCommandsAsync(int retries = -1)
        {
            while (_queueCommand.Count > 0 && _tokenCancel != null && !_tokenCancel.IsCancellationRequested)
            {
                try
                {
                    //Peek in the command queue for the next command
                    var command = _queueCommand.Peek();
                    //
                    //Execute the Imap command
                    //
                    if (await DoCommandAsync(command, retries))
                    {
                        //If successful, dequeue and discard the command
                        lock (_lock)
                            _queueCommand.Dequeue();
                    }
                    //Reset if the command affects folder state
                    if (_imapClient.IsConnected && !_imapFolder.IsOpen)
                        _imapFolder.Open(_folderAccess);
                }
                catch (Exception ex)
                {
                    //We may be disconnected, throw to try again
                    Log.Warn(ex, Identifier + "DoCommands errored out...");
                    throw;
                }
            }

            return _queueCommand.Count == 0;
        }

        /// <summary>
        /// Queues a command to be executed by the IMAP client instance.
        /// </summary>
        /// <param name="command">The command to execute in the IMAP thread.</param>
        public void QueueCommand(OnImapCommand command)
        {
            lock (_lock)
                _queueCommand.Enqueue(command);
            //If idling, wake up and process the command queue
            _tokenDone?.Cancel();
        }
        #endregion

        #region IMAP Events
        /// <summary>
        /// Called when folder message count changes.
        /// </summary>
        /// <param name="sender">The sender.</param>
        /// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
        /// <remarks>CountChanged event will fire when new messages arrive in the folder and/or when messages are expunged.</remarks>
        private void OnCountChanged(object sender, EventArgs e)
        {
            var folder = (ImapFolder)sender;
            Log.Debug(Identifier + "{0} message count has changed from {1} to {2}.", folder, _numMessages, folder.Count);

            //If the folder count is more than our tracked number of messages flag and cancel IDLE
            if (folder.Count > _numMessages)
            {
                Log.Debug(Identifier + "{0} new messages have arrived.", folder.Count - _numMessages);
                // Note: This event is called by the ImapFolder (the ImapFolder is not re-entrant).
                //       IMAP commands cannot be performed here so instead flag new messages and
                //       cancel the `done` token to handle new messages in IdleAsync.
                _messagesArrived = true;
                _tokenDone?.Cancel();
            }
            //
            //Track the message count to determine when we have new messages.
            //
            _numMessages = folder.Count;
        }

        /// <summary>
        /// Called when a message is expunged (deleted or moved).
        /// </summary>
        /// <param name="sender">The sender.</param>
        /// <param name="e">The <see cref="MessageEventArgs"/> instance containing the event data.</param>
        private void OnMessageExpunged(object sender, MessageEventArgs e)
        {
            var folder = (ImapFolder)sender;
            Log.Debug(Identifier + "{0} message #{1} has been expunged.", folder, e.Index);
            //
            //Track the message count to determine when we have new messages.
            //
            _numMessages = folder.Count;
        }
        #endregion
    }
}

It is worth studying the “robustness pattern” in the DoCommandAsync code. The most likely reason for an exception to be thrown, provided your own code is well written with error handling, is due to a problem with the server connection. This pattern is meant to allow a daemon to reestablish a connection even if it takes hours to do so. The idea is that on the first error it will immediately reconnect and try again. If there is still a connection issue it will wait 1 minute between retries, then 10 minutes, and then ultimately wait an hour before trying to reconnect and run the command. There is also a way of retrying indefinitely or for a specified number of retries.

It should also be noted that, as in the author’s example, there are two cancellation tokens being used. These can be accessed via the wrapper by calling Stop or Disposing the wrapper instance. When a command is queued we’ll wake up if idling. When a server event is received we should do likewise.

First, let’s demonstrate the simple case of connecting and running an IMAP command (such as deleting an email, searching for or fetching details, or moving a message, etc). 

//Connect, perform command, and disconnect synchronously
using (var imapClient = new ImapClientEx(_imapUser))
{
    //The IMAP client will run the command async so we must Wait to ensure the connection does not close before the command is run
    imapClient.DoCommandAsync(MoveEmail, 5).Wait();
}

Notice the using statement for scoping the ImapClientEx wrapper.  This code is being executed by its own thread, when the command is run this is done in the IMAP client thread, and the pointer to the function is shunted over from one thread to the other.  The wrapper will automatically connect before running the command. While async is supported, in this case, we will wait otherwise we will dispose of our IMAP connection too soon.

private void MoveEmail(ImapClient imapClient, IMailFolder imapFolder)
{
    //Perform an action with the connected imapClient or the opened imapFolder
}

The command queue takes a delegate with parameters for the MailKit client and folder arguments. It is run by the IMAP client wrapper thread but it is the instance of your object so you have full access to member variables, etc. Again, this is a simple use case but shows how easily the client can connect and run code.

Now let’s move on to the use case where you want to have a long-standing connection to an inbox to monitor new messages.  This requires asynchronously launching and storing the IMAP client wrapper.  As the client is running it will remain connected and monitor two events as per the author’s example: inbox.CountChanged and inbox.MessageExpunged.  By handling these the wrapper can expose its single event: NewMessage.  With the IMAP client running, all we have to do is keep the instance in a member variable to queue additional IMAP commands, receive the NewMessage event, or stop the client when we are done.

        protected void ImapConnect()
        {
            // Dispose of existing instance, if any.
            if (_imapClient != null)
            {
                _imapClient.NewMessage -= IMAPProcessMessages;
                _imapClient.Stop();
                _imapClient = null;
            }

            _imapClient             = new ImapClientEx(_imapUser);
            _imapClient.NewMessage += IMAPProcessMessages;
            var idleTask            = _imapClient.RunAsync();
            _dtLastConnection       = DateTime.Now;
        }

Now it should be noted that a NewMessage event will fire once the IMAP client connects at startup.  This is because our daemon needs to be capable of shutting down and therefore must track the last processed message.  The best way to do this is to track the last UID processed.  This way, whenever the event is fired, you will just search for new UIDs since the last tracked UID was seen. 

        private void IMAPProcessMessages(ImapClient imapClient, IMailFolder imapFolder)
        {
            LogSvc.Debug(this, "IMAP: Checking emails...");
            _dtLastConnection = DateTime.Now;
            //
            //Retrieve last index from DB
            //
            if (_currentUid  == 0)
                _currentUid  = (uint)TaskEmailData.FetchLastUID(_taskType);
            LogSvc.Debug(this, "IMAP: Last email index from DB: " + _currentUid.ToString());
            //
            //Process messages since last processed UID
            //
            int currentIndex = imapFolder.Count - 1;
            if (currentIndex >= 0)
            {
                //
                //Create range from the current UID to the max
                //
                var range    = new UniqueIdRange(new UniqueId((uint)_currentUid + 1), UniqueId.MaxValue);
                //
                //Get the UIDs newer than the current UID
                //
                var uids     = imapFolder.Search(range, SearchQuery.All);
                //
                if (uids.Count > 0)
                {
                    LogSvc.Info(this, "IMAP: Processing {0} missed emails.", uids.Count);
                    foreach (var uid in uids)
                    {
                        //
                        //Get the email
                        //
                        var email = imapFolder.GetMessage(uid);
                        //
                        //Process and enqueue new message
                        //
                        ImapProcessMessage(imapClient, imapFolder, uid, email);
                    }
                    //
                    //Pulse the lock to process new tasks...
                    //
                    Pulse();
                }
                else
                {
                    LogSvc.Debug(this, "IMAP: No missed emails.");
                }
            }
            else
            {
                LogSvc.Debug(this, "IMAP: No missed emails.");
            }
        }

I won’t show you but, suffice it to say, I have one extra level of redundancy in my daemon where it tracks the connection age and simply recycles the connection after a specified amount of time of inactivity. This was done because, while it was more usable, our old IMAP API became disconnected quite regularly although it falsely reported it was still connected.

Lastly, when the daemon is being shut down for any reason, we need to Stop or Dispose to disconnect and clean up the IMAP connection. The Stop will trigger the cancellation tokens such that the IMAP task thread shuts down in its own time. Calling Dispose directly will synchronously do the same. Also, Dispose can be called repeatedly on the wrapper instance and still be safe to use as it will reconnect as necessary.

_imapClient?.Dispose();

This took a couple of weeks to write and test. My boss was cool about it being shared so I hope to save someone else the pain of writing this from scratch.  While MailKit may be on the basic end of the spectrum we built a solution that is very robust and will no doubt have a better up-time metric than before. Many thanks to the author and the MailKit user community for all the insight and knowledge necessary to have written this.