另一个电子邮件客户端(LINQ to IMAP)






4.93/5 (22投票s)
Equinox 是一个运行在 .NET Framework 和 Mono 上的 SMTP/IMAP 客户端。
前导码
自从我最初写这篇文章来传播消息并激发大众的热情已经过去了四个月。嗯,至少部分达到了目的。四个月前,这只是一个原型,功能有限,远非可靠。我决定更新这篇文章,以反映最近的变化并重新点燃热情(咳)。对于第一次阅读这篇文章的人来说,这不是一篇“如何创建炫酷邮件库”的文章,而更像是一篇“展示和讲述”。内容实在太多了,我甚至不知道从哪里开始解释,但是……代码是开源的,你可以随时查看,如果你有问题,我很乐意尽力回答,因为我是一个好人 ;) 我不会写手册,而是主要关注独特的部分,尤其是 IMAP 客户端及其内置的 LINQ 引擎,并向你展示如何用它来完成其他库无法完成的所有工作。
这是什么?
Equinox 项目是一个针对 .NET 4.0 和 Mono 2.8 的消息传递库。该库完整实现了 IMAP、SMTP,以及最终的 POP3 协议。此外,该库为 IMAP 协议提供了一个独特且完全集成的 LINQ 提供程序。该项目使用 C# 编写,完全托管,并根据 Ms-PL 获得许可。
有什么新功能?
这是一次更新,所以显然有些东西发生了变化。确实如此……
- 首先,存在 bug,尽管我非常有信心我们还没有发现所有 bug,但已经修复了大量 bug,超过 100 个。
- 此外,该库还获得了 **POP3** 支持。与 IMAP 和 SMTP 的对应项一样,POP3 也支持 TLS/SSL 和多种 SASL 身份验证,下载进度事件……所有功能一应俱全。
- 我们增加了对发送嵌入式内容的支持,而不仅仅是接收。
- 我们为 IMAP 客户端实现了 IDLE 命令,因为推送邮件比不推送要好 ;)
- 我们提高了 body struct 解析器的鲁棒性,是的,它确实存在,并且现在支持最奇怪和最复杂的服务器响应。
- 我们优化了 LINQ 查询生成,以避免与服务器进行不必要的往返,使其速度更快。
- 也许最重要的一部分是,我们几乎完成了 CodePlex 网站上该项目的文档。
列表还在继续,但为了不让你们厌烦长篇大论,我们设置了一个页面来展示所有功能,并且公平地说,也列出了该库不支持的功能。你可以在 内容与功能 文档中找到该列表。
为什么存在?
我创建这个消息传递库的原因是,令人遗憾的是,没有一个现有的开源库达到我的期望。现实狠狠地打击了我。我尝试了几款库,它们都能处理大约 70% 到 80% 的邮件,但最终,它们都在某些方面失败了。
有些库在编码方面有问题,甚至根本不编码,有些在解析更复杂的 MIME 结构方面性能较差,许多只支持协议功能的很小一部分,有些似乎就是错误的,没有一个库有一个可用的 body structure 解析器……而最常被忽视的功能是 fetch 命令的充分实现。fetch 命令是 IMAP 协议最 **重要** 和 **复杂** 的部分,因为它是唯一一个根据查询和服务器实现产生动态响应的命令,这可能是大多数实现只提供简单 fetch-headers 或 fetch-all 解决方案的原因。乍一看,这似乎足够了,但仔细观察,你会发现还有更多东西。事实是,所有电子邮件客户端都可以显示和处理邮件,无论是 Thunderbird、Outlook、Lotus Notes、Apple Mail、K9 Mail,还是你喜欢的任何其他客户端,虽然我对 Lotus Notes 不太确定。但是,如果一个客户端可以在 2 分钟内处理 20 封邮件,因为它是一个智能客户端,只抓取所需内容,显示对你重要的内容,并省略其余内容,或者在 2 小时内处理,因为你的客户端必须通过你的 UMTS 设备下载 20MB 的数据,这还是有区别的。乍一看这似乎微不足道,但相信我,累积起来的差别很大,而且随着你可用的带宽越来越低,情况只会变得更糟。我之所以提到这一点,是因为你可能已经注意到桌面市场正在萎缩,而移动计算市场正在迅速增长。在台式机上,我们通常通过宽带互联网连接,下载 10KB、100KB 甚至 1MB 的数据几乎无关紧要,但在某些情况下,这确实很重要。当然,这个库并不打算在手机上运行,虽然牺牲一些功能可以实现,但那是另一回事。为了更清楚地说明,它可能运行在平板电脑或笔记本电脑上,使用 Linux、OS X 或 Windows。在这些移动计算机上,我们不总是有高速宽带连接或流量套餐的奢侈,可以让我们说……什么 5MB 的附件?
当然,我检查过的每一个库都允许用户发送所谓的“原始命令”到服务器。本质上,你可以手动创建这个手术般高效的查询并发送到服务器。不幸的是,你收到的响应也将非常“原始”,它很可能是一部分 MIME 编码的消息,而问题就出在这里。
下面的命令是 LIST 命令,它显然会列出给定邮箱中包含的所有邮箱。
Send("LIST #news.comp.mail.misc \"\");
这是一行代码。我不需要一个库来为我做这个。我需要一个库来处理复杂的事情,比如解析 MIME 结构、识别 body 部分、编码和解码以进行传输,但是,当涉及到抓取除“全部”以外的任何东西时,如果一个库在大多数或所有这些部分都带有“自己动手”的政策,那么它的意义何在?尽管我测试过的一些库设计得非常好且易于使用,但当涉及到抓取除“全部”以外的任何东西时,我总是发现自己陷入了正则表达式和字符串比较的泥潭,不得不自己动手。考虑到这一切,我将分享这个项目,并会尝试解释它与大多数现有库有何不同。
库的结构
该库分为五个主要程序集
- Crystalbyte.Equinox.Core
- Crystalbyte.Equinox.Mime
- Crystalbyte.Equinox.Imap
- Crystalbyte.Equinox.Smtp
- Crystalbyte.Equinox.Pop3
这样用户就可以选择他们需要哪些库并忽略其余的。这无关乎大小,因为所有程序集加起来大约是 220 KB;如果你只需要发送邮件,删除 IMAP 和 POP3 程序集,它将降至约 90 KB。分离迫使你正确地设计你的应用程序。MIME 程序集与其他程序集没有任何依赖关系,因此可以作为独立的 MIME 解析器使用。Core 程序集包含共享类,这些类在三个客户端程序集之间共享,以消除冗余。
使用 SMTP 客户端
与 IMAP 客户端相比,SMTP 客户端是一个简单的类。实际上只有一个值得关注的方法,那就是 `Send(...)`。Core 程序集包含所有模型实现,包括 `Message` 类。一旦创建,我们将一个实例传递到 `Send(...)` 方法中,然后就完成了,这真的没什么令人兴奋的。
using Crystalbyte.Equinox.Security;
using Crystalbyte.Equinox.Smtp;
var message = new Message();
// fill the message object
using(var client = new SmtpClient()) {
// Connect
// Login
client.Send(message);
}
使用 POP3 客户端
与 SMTP 协议一样,POP3 协议也相当简单,我们成功实现了并测试了以下命令:**LIST**、**RSET**、**TOP**、**QUIT**、**DELE**、**RETR**、**STAT**、**USER**、**PASS**、**QUIT**、**NOOP**、**UIDL**。我希望你不会生气,但我将跳过对 POP3 的详细介绍,因为整个协议完全是静态的,并且所有方法都在网上得到了详细解释。然而,下面的代码将展示其用法与其他客户端非常相似。POP3 客户端利用了与 SMTP 和 IMAP 客户端相同的类,这使得输入输出与其他客户端 100% 兼容。事实上,由于所有客户端都共享许多这些类型,并且协议相对简单,因此从头开始实现 `Pop3Client` 只用了不到五个小时。
using Crystalbyte.Equinox.Security;
using Crystalbyte.Equinox.Pop3;
var message = new Message();
// fill the message object
using(var client = new Pop3Client()) {
// Connect
// Login
{
// get headers and the first 20 lines for the first message.
var response = client.Top(1, 20);
}
{
// fetch the entire fourth message
var response = client.Retr(4);
}
}
使用 IMAP 客户端
与 SMTP 和 POP3 客户端不同,IMAP 客户端是复杂且庞大的。由于 IMAP 协议自 1986 年以来一直存在,并且变化很小,我不会用琐碎的细节来烦扰你。无需多言,所有基本命令都已实现,但我在此不详述。同样,网上有很多页面,甚至 CodeProject 上的文章,涵盖了与该库类似,甚至相同的基本知识。我知道……我知道我省略了很多,但你真的想让我告诉你调用 *Delete("Foo")* 实际上会删除一个名为“Foo”的文件夹吗?得了吧!相反,我将重点介绍该库独有的部分,这些部分涵盖了我当初实现这个库的原因。
使用 LINQ to IMAP 进行动态查询生成
尽管该客户端具有常规的 `Search()` 和 `Fetch()` 方法,但我不会推荐它们用于任何比最简单请求更复杂的情况。我之前谈到了解决我之前批评的一些问题,其中之一就是 fetch 命令的灵活度不足或实现不完整。
为了解决这个问题,我实现了一个 LINQ 提供程序,它使我们能够直接从服务器抓取消息或消息的部分。这带来了两个好处……
首先,无论查询有多复杂或我们请求多少项,都将通过一个 fetch 命令一次性完成。这可以节省网络流量和多次往返;尤其是在较慢的连接上,这可以节省时间。大多数客户端需要对每条消息进行 4-6 次请求才能在抓取消息之前获取所有相关数据,而我们只需要一次……始终如此!
然而,更重要的一点是,我们不再需要手动解析或映射任何响应,因为这都将由 LINQ 提供程序处理。
让我们快速看一个简单的例子。以下代码将抓取过去一周所有未读邮件中的项目。我们要抓取的文件是
- Envelope
- Uid
- 标志
- 大小
var query = client.Messages.Where(x => x.Date > DateTime.Today.AddDays(-7)
&& !x.Flags.HasFlag(MessageFlags.Read)).Select(x => new MyContainer
{
Envelope = x.Envelope,
Uid = x.Uid,
Flags = x.Flags,
Size = x.Size
});
如果我们想改变场景,只需更改查询。我们可以抓取更少或更多的内容,而无需更改或编写解析器。
var query = client.Messages.Where( ... ).Select(x => x.Envelope);
var query = client.Messages.Where( ... ).Select(x => new SomeClass
{
Subject = x.Subject,
Uid = x.Uid,
Flags = x.Flags,
Size = x.Size,
Internal = x.InternalDate,
BodyStructure = x.BodyStructure
});
我们可以通过遍历结果来解析查询。
foreach(var container in query) {
Debug.WriteLine(container.Envelope.Subject);
}
正如 LINQ to SQL 一样,我们不再需要担心从 SQL Server 中解析数据,我们只需将响应映射到我们的对象。如果没有 LINQ 的参与,我们需要为每种情况创建一个不同的解析器,或者创建一个能够处理不同但仍然有限数量响应的单一解析器,一旦我们更改了查询,我们也将被迫更改解析器。
与许多 LINQ 提供程序一样,由于我们必须在 IMAP 协议的限制范围内工作,因此存在限制和约束。不允许使用多个或嵌套的 `Where`/`Select` 语句;更准确地说,我们需要正好一个 `Where` 子句和一个 `Select` 子句。除少数例外,其他扩展方法如 `Any()`、`Single()` 或 `SelectMany()` 均不受支持。
老派抓取
尽管我多次提到简单地抓取所有内容并不总是有利于用户,但它仍然是可能的。
var message = client.FetchMessageByUid(187);
var message = client.FetchMessageBySequenceNumber(10);
新派抓取
那么,如果一切都很糟糕,什么才是好的呢?嗯,我赞成“抓取所需内容,忽略其余内容”的原则。如果用户不想看到 HTML 内容,只需下载纯文本,何必两者都要?如果彼得不想看奶奶的度假照片,这些照片包含专业未压缩的 30 张文件,每张 1MB,那就不要下载它们。这仅仅是用户体验的问题。IMAP 提供了使您能够做到这一点的功能,它们未包含在原始草案中,但现在随着 IMAP4rev1,几乎所有常用服务器都支持它们。我正在谈论的项目是 `BODYSTRUCTURE` 命令。通过抓取消息的 body structure,我们可以获得消息中所有实体的结构化对象模型。
var structure = client.Messages.Where(...).Select(x => x.BodyStructure).ToList().First();
Body structure 包含所有相关类型的信息对象,如附件、视图和嵌套消息。使用这些信息对象,我们可以将消息的结构显示给用户,因为有关内容的所有重要数据都可用,例如文件名、类型、大小等……一旦用户选择了一个项目进行打开、保存或其他操作,我们就可以通过调用客户端上的相应方法并将信息对象作为参数来单独抓取该项目。我们甚至可以过滤信息集合,并且只对 *非常* 特定的项目执行查询,例如,只加载图片而将 PDF 文件保留在服务器上,举个例子……
var bodyStructure = ...
// fetching only the html view
var htmlViewInfo = bodyStructure.Views.Where(x => x.MediaType == "text/html");
var htmlView = client.FetchView(htmlViewInfo);
// fetching the third attachment
var thirdAttachmentInfo = bodyStructure.Attachments.ElementAt(2);
var thirdAttachment = client.FetchAttachment(thirdAttachmentInfo);
// fetching only images
var imageInfos = bodyStructure.Attachments.Where(x => x.MediaType.StartsWith("image"));
var images = imageInfos.Select(client.FetchAttachment).ToList();
// fetching only images with a size smaller than 100k (encoded)
var imageInfos = bodyStructure.Attachments.Where(x =>
x.MediaType.StartsWith("image") && x.SizeEncoded.MegaBytes < 0.1);
var images = imageInfos.Select(client.FetchAttachment).ToList();
嵌套消息和视图也有类似的方法。您看,如果库支持,这并不难;)
如果我们查看其中一个 fetch 方法的内部,我们会发现它们利用了集成 LINQ 提供程序。事实上,IMAP 客户端中实现的每一个 fetch 操作都使用了 LINQ 提供程序。除了 LINQ 解析器本身,我无需在该库中的其他任何地方编写任何解析器。
public Message FetchMessageBySequenceNumber(int sn)
{
var query = Messages
.Where(x => x.SequenceNumber == sn)
.Select(x => new MessageContainer
{
Uid = x.Uid,
SequenceNumber = x.SequenceNumber,
Text = (string) x.Parts(string.Empty)
});
var container = query.ToList().FirstOrDefault();
if (container == null) {
return null;
}
var entity = new Entity();
entity.Deserialize(container.Text);
var message = entity.ToMessage();
message.Uid = container.Uid;
message.SequenceNumber = container.SequenceNumber;
return message;
}
如您所见,这里没有任何黑魔法,LINQ 提供程序可以用来抓取消息的任何部分,关键是 `x.Parts()` 方法。该方法以结构化的 MIME 标识符作为参数。第一个嵌套实体具有标识符“1”,第二个是“2”。如果第一个实体有两个子级,我们可以使用 ID “1.1” 和 “1.2” 来访问它们,依此类推,这很简单。上面,我们传递了 `string.Empty`,这实际上意味着“全部给我”,就这样。
精细搜索
非常特殊,但很少用于过滤的是 TEXT、KEYWORDS、HEADERS、FROM/TO/BCC/CC 搜索键。你可能不会使用它们,但由于它们在 IMAP 协议中定义,所以它们也被实现了。这些键可以用来创建更精细化的查询。
关键词
Keywords 类似于 flags,可以使用 STORE 命令将其应用于消息。使用 `Keywords` 属性,我们可以搜索带有特殊关键字的消息。普通 flags 和 keywords 的区别在于,如果服务器允许,用户有机会为消息标记任意值。以下代码显示了如何访问标记有关键字“MyTag”的消息。
var query = client.Messages(x => x.Keywords.Contains("MyTag")).Select(x => x.Envelope)
Headers
`Headers` 属性可用于查询特定的标题及其值。以下查询将返回所有名称为“Priority”且值为“high”的标题的消息。
var query = client.Messages.Where(x => x.Headers.Any(y =>
y.Name.Contains("Priority") && y.Value.Contains("high"))).Select(x => x.Envelope);
From, To, Bcc, Cc
所有这四个集合属性都可以用来对消息的联系人列表进行全文搜索。以下查询将返回所有从“Peter”发送给“Mary”的消息。
var query = client.Messages.Where(x =>
x.From.Contains("Peter") && x.To.Contains("Mary")).Select(x => x.Envelope);
文本
使用 text 属性,我们可以对消息正文进行全文搜索。以下查询将返回所有内容中包含字符串“blue whale”的消息。
var query = client.Messages.Where(x => x.Text.Contains("blue whale")).Select(x => x.Envelope);
结论
好了,现在就到这里。希望您能抽出一些时间留下评论。最后,我想感谢所有通过指出 bug 和提出改进建议来帮助我们的人。