Search code examples
azureoauth-2.0exchange-serverimapmailkit

How to use MailKit with IMAP for Exchange with OAuth2 for daemon / non-interactive apps


I have a daemon that reads the inbox of an email address and performs actions to the emails. I'm using MailKit to connect to the exchange server with IMAP but Microsoft has shut down basic authentication for us (at 4am, without warning...). So I need a new way to connect to my mailbox.

Using graph would require a major rewrite of my application. I plan to do that, but in the mean time, I need an intermediary solution that would keep MailKit.


Solution

  • This is using ROPC.

    First, register an Azure Active Directory app:

    • single tenant (I haven't tried the other options)
    • Authentication / Allow public client flows (not sure that's required but that's what I have)
    • create a secret
    • API permissions: use delegated permissions and have an admin grant consent for them
      • email
      • offline_access
      • openid
      • IMAP.AccessAsUser.All
      • SMTP.Send
      • User.Read (not sure that's needed)

    Even though this is a daemon-like application, we're using delegated permissions because we're using the ROPC grant.

    Then you can use this code which uses the following nuget packages:

    • MailKit
    • Newtonsoft.Json
    using MailKit;
    using MailKit.Net.Imap;
    using MailKit.Net.Smtp;
    using MailKit.Search;
    using MailKit.Security;
    using MimeKit;
    using Newtonsoft.Json.Linq;
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Net.Http;
    using System.Threading.Tasks;
    
    namespace MailKitExchangeDaemon
    {
        class Program
        {
            const string ScopeEmail = "email";
            const string ScopeOpenId = "openid";
            const string ScopeOfflineAccess = "offline_access";
            const string ScopeImap = "https://outlook.office.com/IMAP.AccessAsUser.All";
            const string ScopeSmtp = "https://outlook.office.com/SMTP.Send";
    
            const string SmtpHost = "smtp.office365.com";
            const string ImapHost = "outlook.office365.com";
    
            const string TenantId = "<GUID>";
            const string AppId = "<GUID>";
            const string AppSecret = "<secret value>";
            const string Username = "<email address>";
            const string Password = "<password>";
    
            static async Task Main(string[] args)
            {
                Console.WriteLine($"Sending an email to {Username}...");
                await sendEmail();
                System.Threading.Thread.Sleep(2000);
                Console.WriteLine($"Printing {Username} inbox...");
                await printInbox();
    
                Console.Write("Press ENTER to end this program");
                Console.ReadLine();
            }
    
            static async Task printInbox()
            {
                var accessToken = await getAccessToken(ScopeEmail, ScopeOpenId, ScopeOfflineAccess, ScopeImap);
                using (var client = new ImapClient(/*new MailKit.ProtocolLogger(Console.OpenStandardOutput())*/))
                {
                    try
                    {
                        await client.ConnectAsync(ImapHost, 993, true);
                        await client.AuthenticateAsync(accessToken);
    
                        client.Inbox.Open(FolderAccess.ReadOnly);
                        var emailUIDs = client.Inbox.Search(SearchQuery.New);
                        Console.WriteLine($"Found {emailUIDs.Count} new emails in the {Username} inbox");
                        foreach (var emailUID in emailUIDs)
                        {
                            var email = client.Inbox.GetMessage(emailUID);
                            Console.WriteLine($"Got email from {email.From[0]} on {email.Date}: {email.Subject}");
                        }
                    }
                    catch (Exception e)
                    {
                        Console.Error.WriteLine($"Error in 'print inbox': {e.GetType().Name} {e.Message}");
                    }
                }
            }
    
            static async Task sendEmail()
            {
                var accessToken = await getAccessToken(ScopeEmail, ScopeOpenId, ScopeOfflineAccess, ScopeSmtp);
                using (var client = new SmtpClient(/*new MailKit.ProtocolLogger(Console.OpenStandardOutput())*/))
                {
                    try
                    {
                        client.Connect(SmtpHost, 587, SecureSocketOptions.Auto);
                        client.Authenticate(accessToken);
    
                        var email = new MimeMessage();
                        email.From.Add(MailboxAddress.Parse(Username));
                        email.To.Add(MailboxAddress.Parse(Username));
                        email.Subject = "SMTP Test";
                        email.Body = new TextPart("plain") { Text = "This is a test" };
                        client.Send(email);
                    }
                    catch (Exception e)
                    {
                        Console.Error.WriteLine($"Error in 'send email': {e.GetType().Name} {e.Message}");
                    }
                }
            }
    
            /// <summary>
            /// Get the access token using the ROPC grant (<see cref="https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth-ropc"/>).
            /// </summary>
            /// <param name="scopes">The scopes/permissions the app requires</param>
            /// <returns>An access token that can be used to authenticate using MailKit.</returns>
            private static async Task<SaslMechanismOAuth2> getAccessToken(params string[] scopes)
            {
                if (scopes == null || scopes.Length == 0) throw new ArgumentException("At least one scope is required", nameof(scopes));
    
                var scopesStr = String.Join(" ", scopes.Select(x => x?.Trim()).Where(x => !String.IsNullOrEmpty(x)));
                var content = new FormUrlEncodedContent(new List<KeyValuePair<string, string>>
                {
                    new KeyValuePair<string, string>("grant_type", "password"),
                    new KeyValuePair<string, string>("username", Username),
                    new KeyValuePair<string, string>("password", Password),
                    new KeyValuePair<string, string>("client_id", AppId),
                    new KeyValuePair<string, string>("client_secret", AppSecret),
                    new KeyValuePair<string, string>("scope", scopesStr),
                });
                using (var client = new HttpClient())
                {
                    var response = await client.PostAsync($"https://login.microsoftonline.com/{TenantId}/oauth2/v2.0/token", content).ConfigureAwait(continueOnCapturedContext: false);
                    var responseString = await response.Content.ReadAsStringAsync();
                    var json = JObject.Parse(responseString);
                    var token = json["access_token"];
                    return token != null
                        ? new SaslMechanismOAuth2(Username, token.ToString())
                        : null;
                }
            }
    
        }
    }