这一切都始于发送给守护程序的电子邮件。托管两个模块(每个模块监视收件箱的自动化)的Windows服务尽职尽责地忽略了IT部门的警告,即 O365的基本身份验证将在几个月内关闭。几个月过去了...尽管微软的公告是在2年前,但当合适的人被告知截止日期只有2个月时。听起来很耳熟?不幸的是,当前的IMAP API不支持OAuth2身份验证,因此必须替换。更糟糕的是,我们浪费了数周时间与Azure管理员一起整理访问权限,尽管我们从第一天起就有分步说明。
在调查支持OAuth2的主流IMAPAP时,发现了MailKit,其作者在Github和StackOverflow上非常活跃。我们很快发现各地的开发人员都在解决这个问题,并且关于如何甚至是否可以做到这一点有很多争论(作者本人对此表示怀疑)。值得庆幸的是,经过几周的痛苦,我们在没有用户交互的情况下对守护程序进行身份验证(也称为OAuth2客户端凭据授予流)。
在编写API时,有一个范围从用户抽象和混淆内部工作原理。一方面,编写为与服务器1:1的API不太可用,但可以提供细微的控制和透明度,从而实现更好的调试。此路径需要更多的启动时间,并给用户留下更多的复杂性。另一方面,API承担了一些繁重的工作,旨在提供一个可用、易于使用的界面。一个典型的权衡是内部工作原理是一个黑匣子,可能会在路上咬你的屁股。
与我们的旧API相比,MailKit全心全意地处于前阵营。旧的连接,有一个新的电子邮件事件,然后在服务关闭时断开连接。从删除邮件到搜索新电子邮件,总体上更容易使用。例如,电子邮件UID是电子邮件对象的一部分。使用MailKit时,必须单独查询此信息,因为从技术上讲,这是它在服务器上的存储方式。这为与MailKit交互的整个体验定下了基调。
如上所述,即使使用起来有点困难,看到作者和用户社区的活跃程度也非常令人放心。虽然从旧API移植代码需要大量重写,但有大量的文档、讨论和示例可以回答我们的问题。出乎意料的是,如果不构建完整的IMAP客户端,服务器事件就无法工作,这让我想起了实现Windows消息泵,以在其自己的线程中空闲和处理事件。值得庆幸的是,文档和示例虽然复杂,但可以在此基础上进行构建。
随着序言的结束,我们终于可以谈谈代码了。下面是MailKit API的C#包装器。我们可以以两种不同的方式使用它。您可以简单地实例化它并使用两行代码执行命令。这将自动连接,在IMAP客户端线程上下文中运行IMAP命令,然后断开连接。或者,您可以将其用作长时间运行的连接,它将启动IMAP客户端作为强大的任务,该任务将保持连接直到停止。这允许使用包装器公开的事件来处理新消息。还有一个命令队列,以便代码可以排队以在IMAP客户端线程上下文中运行。
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 an 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
}
}
DoCommandAsync代码中的“健壮性模式”值得研究。如果你自己的代码写得很好,并且使用错误处理,引发异常的最可能原因是由于服务器连接的问题。此模式旨在允许守护程序重新建立连接,即使这样做需要数小时。这个想法是,在第一个错误时,它将立即重新连接并重试。如果仍然存在连接问题,它将在两次重试之间等待1分钟,然后等待10分钟,然后最终等待一个小时,然后再尝试重新连接并运行命令。还有一种方法可以无限期重试或重试指定的重试次数。
还应该注意的是,与作者的示例一样,使用了两个取消令牌。可以通过调用包装器Stop或Dispose包装器实例访问这些内容。当命令排队时,如果空闲,我们将唤醒。当收到服务器事件时,我们也应该这样做。
首先,让我们演示连接和运行IMAP命令的简单案例(例如删除电子邮件、搜索或获取详细信息或移动邮件等)。
//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();
}
请注意用于限定ImapClientEx包装器范围的using语句。此代码由其自己的线程执行,当命令运行时,此操作在另一个线程中完成,要运行的函数的指针从线程分流到IMAP线程并由IMAP客户端执行。它将在运行命令之前自动连接。虽然在这种情况下支持异步,但我们将等待,否则我们将过早处理我们的IMAP连接。
private void MoveEmail(ImapClient imapClient, IMailFolder imapFolder)
{
//Perform an action with the connected imapClient or the opened imapFolder
}
命令队列采用具有MailKit客户端和文件夹参数参数的委托。它由IMAP客户端包装器线程运行,但它是对象的实例,因此您可以完全访问成员变量等。同样,这是一个简单的用例,但显示了客户端连接和运行代码的难易程度。
现在,让我们转到您希望与收件箱建立长期连接以监视新邮件的用例。这需要异步启动和存储IMAP客户端包装器。当客户端运行时,它将保持连接并按照作者的示例监视两个事件:inbox.CountChanged和inbox.MessageExpunged。通过监视这一点,我们可以在包装器中公开我们的单个事件:NewMessage。在IMAP客户端运行时,我们所要做的就是将实例保留在成员变量中,以对其他IMAP命令进行排队、接收NewMessage事件或在完成后停止客户端。
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;
}
现在应该注意的是,一旦IMAP客户端在启动时连接,就会触发NewMessage事件。这是因为我们的守护程序需要能够关闭,因此必须跟踪最后处理的消息。执行此操作的最佳方法是跟踪上次处理的UID。这样,每当触发事件时,您只需搜索自上次跟踪的UID以来的新UID。
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.");
}
}
我不会向您展示,但是,可以说,我的守护程序中有一个额外的冗余级别,它跟踪连接年龄并在指定的非活动时间后简单地回收连接。这样做是因为,虽然它更有用,但我们旧的IMAP API经常断开连接,尽管它错误地报告它仍然处于连接状态。
最后,当守护程序因任何原因被关闭时,我们需要Stop或Dispose断开并清理IMAP连接。Stop将触发取消令牌,以便IMAP任务线程在其自己的时间内关闭。直接调用Dispose将同步执行相同的操作。此外,可以在包装器实例上重复调用Dispose,并且仍然可以安全使用,因为它将根据需要重新连接。
_imapClient?.Dispose();
这花了几周的时间来编写和测试。我的老板对它被分享很酷,所以我希望省去其他人从头开始写这篇文章的痛苦。虽然MailKit可能处于频谱的基本端,但我们构建了一个非常强大的解决方案,并且无疑将具有比以前更好的正常运行时间指标。非常感谢作者和MailKit用户社区提供撰写本文所需的所有见解和知识。
本文最初发表于 Robust Daemon Monitoring Inbox with MailKit w/ OAuth2 | Coding Lifestyle
https://www.codeproject.com/Articles/5342831/Robust-Daemon-Monitoring-Inbox-with-MailKit-w-OAut
3372

被折叠的 条评论
为什么被折叠?



