Search code examples
c#imapmailkit

MailKit IMAP Idle - How to access 'done' CancellationTokenSource in CountChanged Event


I am working with the IMAP Idle sample code found here

The sample requires a Console.ReadKey to cancel the CancellationTokenSource but makes a suggestion that it could be cancelled in the CountChanged event at the time new mail arrives so long as the event has access to the CancellationTokenSource.

How do I get access to the CancellationTokenSource in the CountChanged event?

Here is the snippit of code from the above link...

// Keep track of changes to the number of messages in the folder (this is how we'll tell if new messages have arrived).
client.Inbox.CountChanged += (sender, e) => {
    // Note: the CountChanged event will fire when new messages arrive in the folder and/or when messages are expunged.
    var folder = (ImapFolder)sender;

Console.WriteLine("The number of messages in {0} has changed.", folder);

    // Note: because we are keeping track of the MessageExpunged event and updating our
    // 'messages' list, we know that if we get a CountChanged event and folder.Count is
    // larger than messages.Count, then it means that new messages have arrived.
    if (folder.Count > messages.Count) {
        Console.WriteLine("{0} new messages have arrived.", folder.Count - messages.Count);

        // Note: your first instict may be to fetch these new messages now, but you cannot do
        // that in an event handler (the ImapFolder is not re-entrant).
        //
        // If this code had access to the 'done' CancellationTokenSource (see below), it could
        // cancel that to cause the IDLE loop to end.
                // HOW DO I DO THIS??
    }
};


Console.WriteLine("Hit any key to end the IDLE loop.");
using (var done = new CancellationTokenSource()) {
    // Note: when the 'done' CancellationTokenSource is cancelled, it ends to IDLE loop.
    var thread = new Thread(IdleLoop);

thread.Start(new IdleState(client, done.Token));

    Console.ReadKey();
    done.Cancel();
    thread.Join();
}

Solution

  • All you need to do is rearrange the code a bit so that your event handlers have access to the done token.

    Here's an example of how you could do this:

    using System;
    using System.IO;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Collections.Generic;
    
    using MailKit;
    using MailKit.Net.Imap;
    using MailKit.Security;
    
    namespace ImapIdle
    {
        class Program
        {
            // Connection-related properties
            public const SecureSocketOptions SslOptions = SecureSocketOptions.Auto;
            public const string Host = "imap.gmail.com";
            public const int Port = 993;
    
            // Authentication-related properties
            public const string Username = "[email protected]";
            public const string Password = "password";
    
            public static void Main (string[] args)
            {
                using (var client = new IdleClient ()) {
                    Console.WriteLine ("Hit any key to end the demo.");
    
                    var idleTask = client.RunAsync ();
    
                    Task.Run (() => {
                        Console.ReadKey (true);
                    }).Wait ();
    
                    client.Exit ();
    
                    idleTask.GetAwaiter ().GetResult ();
                }
            }
        }
    
        class IdleClient : IDisposable
        {
            List<IMessageSummary> messages;
            CancellationTokenSource cancel;
            CancellationTokenSource done;
            bool messagesArrived;
            ImapClient client;
    
            public IdleClient ()
            {
                client = new ImapClient (new ProtocolLogger (Console.OpenStandardError ()));
                messages = new List<IMessageSummary> ();
                cancel = new CancellationTokenSource ();
            }
    
            async Task ReconnectAsync ()
            {
                if (!client.IsConnected)
                    await client.ConnectAsync (Program.Host, Program.Port, Program.SslOptions, cancel.Token);
    
                if (!client.IsAuthenticated) {
                    await client.AuthenticateAsync (Program.Username, Program.Password, cancel.Token);
    
                    await client.Inbox.OpenAsync (FolderAccess.ReadOnly, cancel.Token);
                }
            }
    
            async Task FetchMessageSummariesAsync (bool print)
            {
                IList<IMessageSummary> fetched;
    
                do {
                    try {
                        // fetch summary information for messages that we don't already have
                        int startIndex = messages.Count;
    
                        fetched = client.Inbox.Fetch (startIndex, -1, MessageSummaryItems.Full | MessageSummaryItems.UniqueId, cancel.Token);
                        break;
                    } catch (ImapProtocolException) {
                        // protocol exceptions often result in the client getting disconnected
                        await ReconnectAsync ();
                    } catch (IOException) {
                        // I/O exceptions always result in the client getting disconnected
                        await ReconnectAsync ();
                    }
                } while (true);
    
                foreach (var message in fetched) {
                    if (print)
                        Console.WriteLine ("{0}: new message: {1}", client.Inbox, message.Envelope.Subject);
                    messages.Add (message);
                }
            }
    
            async Task WaitForNewMessagesAsync ()
            {
                do {
                    try {
                        if (client.Capabilities.HasFlag (ImapCapabilities.Idle)) {
                            // Note: IMAP servers are only supposed to drop the connection after 30 minutes, so normally
                            // we'd IDLE for a max of, say, ~29 minutes... but GMail seems to drop idle connections after
                            // about 10 minutes, so we'll only idle for 9 minutes.
                            using (done = new CancellationTokenSource (new TimeSpan (0, 9, 0))) {
                                using (var linked = CancellationTokenSource.CreateLinkedTokenSource (cancel.Token, done.Token)) {
                                    await client.IdleAsync (linked.Token);
    
                                    // throw OperationCanceledException if the cancel token has been canceled.
                                    cancel.Token.ThrowIfCancellationRequested ();
                                }
                            }
                        } else {
                            // Note: we don't want to spam the IMAP server with NOOP commands, so lets wait a minute
                            // between each NOOP command.
                            await Task.Delay (new TimeSpan (0, 1, 0), cancel.Token);
                            await client.NoOpAsync (cancel.Token);
                        }
                        break;
                    } catch (ImapProtocolException) {
                        // protocol exceptions often result in the client getting disconnected
                        await ReconnectAsync ();
                    } catch (IOException) {
                        // I/O exceptions always result in the client getting disconnected
                        await ReconnectAsync ();
                    }
                } while (true);
            }
    
            async Task IdleAsync ()
            {
                do {
                    try {
                        await WaitForNewMessagesAsync ();
    
                        if (messagesArrived) {
                            await FetchMessageSummariesAsync (true);
                            messagesArrived = false;
                        }
                    } catch (OperationCanceledException) {
                        break;
                    }
                } while (!cancel.IsCancellationRequested);
            }
    
            public async Task RunAsync ()
            {
                // connect to the IMAP server and get our initial list of messages
                try {
                    await ReconnectAsync ();
                    await FetchMessageSummariesAsync (false);
                } catch (OperationCanceledException) {
                    await client.DisconnectAsync (true);
                    return;
                }
    
                // keep track of changes to the number of messages in the folder (this is how we'll tell if new messages have arrived).
                client.Inbox.CountChanged += OnCountChanged;
    
                // keep track of messages being expunged so that when the CountChanged event fires, we can tell if it's
                // because new messages have arrived vs messages being removed (or some combination of the two).
                client.Inbox.MessageExpunged += OnMessageExpunged;
    
                // keep track of flag changes
                client.Inbox.MessageFlagsChanged += OnMessageFlagsChanged;
    
                await IdleAsync ();
    
                client.Inbox.MessageFlagsChanged -= OnMessageFlagsChanged;
                client.Inbox.MessageExpunged -= OnMessageExpunged;
                client.Inbox.CountChanged -= OnCountChanged;
    
                await client.DisconnectAsync (true);
            }
    
            // Note: the CountChanged event will fire when new messages arrive in the folder and/or when messages are expunged.
            void OnCountChanged (object sender, EventArgs e)
            {
                var folder = (ImapFolder) sender;
    
                // Note: because we are keeping track of the MessageExpunged event and updating our
                // 'messages' list, we know that if we get a CountChanged event and folder.Count is
                // larger than messages.Count, then it means that new messages have arrived.
                if (folder.Count > messages.Count) {
                    int arrived = folder.Count - messages.Count;
    
                    if (arrived > 1)
                        Console.WriteLine ("\t{0} new messages have arrived.", arrived);
                    else
                        Console.WriteLine ("\t1 new message has arrived.");
    
                    // Note: your first instict may be to fetch these new messages now, but you cannot do
                    // that in this event handler (the ImapFolder is not re-entrant).
                    //
                    // Instead, cancel the `done` token and update our state so that we know new messages
                    // have arrived. We'll fetch the summaries for these new messages later...
                    messagesArrived = true;
                    done?.Cancel ();
                }
            }
    
            void OnMessageExpunged (object sender, MessageEventArgs e)
            {
                var folder = (ImapFolder) sender;
    
                if (e.Index < messages.Count) {
                    var message = messages[e.Index];
    
                    Console.WriteLine ("{0}: message #{1} has been expunged: {2}", folder, e.Index, message.Envelope.Subject);
    
                    // Note: If you are keeping a local cache of message information
                    // (e.g. MessageSummary data) for the folder, then you'll need
                    // to remove the message at e.Index.
                    messages.RemoveAt (e.Index);
                } else {
                    Console.WriteLine ("{0}: message #{1} has been expunged.", folder, e.Index);
                }
            }
    
            void OnMessageFlagsChanged (object sender, MessageFlagsChangedEventArgs e)
            {
                var folder = (ImapFolder) sender;
    
                Console.WriteLine ("{0}: flags have changed for message #{1} ({2}).", folder, e.Index, e.Flags);
            }
    
            public void Exit ()
            {
                cancel.Cancel ();
            }
    
            public void Dispose ()
            {
                client.Dispose ();
                cancel.Dispose ();
                done?.Dispose ();
            }
        }
    }