使用 MailKit 和 OAuth2 实现强大的守护进程监控收件箱





5.00/5 (1投票)
一切都始于发送给一个守护进程的邮件。一个运行两个模块的 Windows 服务,每个模块都监控一个收件箱以实现自动化,但它却忠实地忽略了 IT 部门关于基本身份验证在几个月后将被关闭的警告。这就是我们解决这个问题的方法。
一切都始于发送给一个守护进程的邮件。一个运行两个模块的 Windows 服务,每个模块都监控一个收件箱以实现自动化,但它却忠实地忽略了 IT 部门关于 O365 的基本身份验证将在几个月后被关闭的警告。几个月过去了……尽管微软的公告已经过去 2 年了,但当相关人员得知此消息时,距离截止日期只有 2 个月了。听起来是否很熟悉?不幸的是,当前的 IMAP API 不支持 OAuth2 身份验证,因此必须替换。更糟糕的是,尽管我们从一开始就获得了 分步说明,但我们仍然在与 Azure 管理员处理访问权限上浪费了几周时间。
调查支持 OAuth2 的主流 IMAP API,发现了 MailKit,其作者在 Github 和 StackOverflow 上非常活跃,令人安心。我们很快发现,开发者们都在解决这个问题,并且关于如何解决,甚至是否能解决,存在很多争论(其中一些疑虑来自 作者本人)。幸运的是,经过几周的痛苦磨合,我们终于实现了无需用户交互即可对守护进程进行身份验证(即 OAuth2 客户端凭据授予流)。
在编写 API 时,在抽象和隐藏内部工作机制的程度上,有一个用户光谱。一方面,一个与服务器一对一的 API 可用性较低,但可能提供细致的控制和透明度,从而实现更好的调试。这条路需要更多的学习时间,并将更多的复杂性留给用户。在光谱的另一端,API 完成了一些繁重的工作,并致力于提供可用、易于使用的界面。典型的权衡是内部工作机制是一个黑箱,这可能会在以后给你带来麻烦。
与我们的旧 API 相比,MailKit 完全属于前者。旧的 API 连接、收到新邮件事件、服务关闭时断开连接。从删除邮件到搜索新邮件,总体而言它都更容易使用。例如,邮件 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;
}
现在应该注意的是,`NewMessage` 事件将在 IMAP 客户端启动连接后触发。这是因为我们的守护进程需要能够关闭,因此必须跟踪最后处理的消息。最好的方法是跟踪最后处理的 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 用户社区提供了编写此解决方案所需的所有见解和知识。