Python-网络编程基础知识-四-

144 阅读1小时+

Python 网络编程基础知识(四)

原文:Foundations of Python Network Programming

协议:CC BY-NC-SA 4.0

十二、构建和解析电子邮件

这是关于电子邮件这个重要话题的四章中的第一章。这一章不讨论网络通信。相反,它为接下来的三个阶段做好了准备:

  • 本章描述了电子邮件的格式,特别关注多媒体和国际化的正确包含。这为随后三章中概述的协议建立了有效载荷格式。
  • 第十三章解释了简单邮件传输协议(SMTP) ,该协议用于将电子邮件从编写它们的机器传输到保存邮件的服务器,使它们为特定的收件人阅读做好准备。
  • 第十四章描述了陈旧的、设计不良的邮局协议(POP ),通过该协议,准备阅读电子邮件的人可以下载并查看电子邮件服务器上收件箱中等待的新邮件。
  • 第十五章介绍了互联网邮件访问协议(IMAP ),这是一个更好、更现代的本地查看电子邮件服务器上的邮件的选项。IMAP 不仅支持获取和查看,还允许您将邮件标记为已读,并将其存储在服务器本身的不同文件夹中。

如你所见,这四章的顺序暗示了电子邮件的自然寿命。首先,电子邮件由各种文本、多媒体和元数据组成,如发件人和收件人。然后 SMTP 将它从原始位置传送到目的服务器。最后,收件人的电子邮件客户端(通常是 Mozilla Thunderbird 或 Microsoft Outlook)使用 POP 或 IMAP 之类的协议,将邮件的副本拖到他们的台式机、笔记本电脑或平板电脑上进行查看。然而,请注意,最后一步变得越来越不常见:今天许多人通过网络邮件服务阅读他们的电子邮件,这允许他们用网络浏览器登录并查看以 HTML 格式呈现的电子邮件,而无需电子邮件离开电子邮件服务器。Hotmail 曾经非常受欢迎,而 Gmail 可能是当今最大的此类服务。

请记住,无论电子邮件后来发生了什么——无论您使用 SMTP、POP 还是 IMAP——关于电子邮件如何格式化和表示的规则都是完全相同的。这些规则是本章的主题。

电子邮件格式

著名的 1982 年 RFC 822 作为电子邮件的定义统治了近 20 年,直到最后需要更新。此更新由 RFC 2822 在 2001 年提供,随后在 2008 年被 RFC 5322 取代。当您需要编写非常严肃或引人注目的代码来处理电子邮件时,您会希望参考这些标准。出于这里的目的,只有几个关于电子邮件格式的事实需要立即引起注意。

  • 电子邮件表示为普通的 ASCII 文本,使用字符代码 1 到 127。
  • 行尾标记是两个字符的序列——回车加换行(CRLF ),这是用于在老式电传打字机上前进到下一行的同一对代码,并且仍然是今天互联网协议中的标准行尾序列。
  • 一封电子邮件由标题、空行和正文组成。
  • 每个头都被格式化为一个不区分大小写的名称、一个冒号和一个值,如果头的第二行和随后的行用空格缩进,则该值可以扩展到几行。
  • 因为在纯文本中既不允许 Unicode 字符也不允许二进制有效载荷,所以我将在本章后面解释的其他标准提供了编码,通过这些编码可以将更丰富的信息混合到纯 ASCII 文本中进行传输和存储。

在清单 12-1 的中,你可以读到一封真实的电子邮件,当它到达我的收件箱时。

清单 12-1 。交付完成后的真实电子邮件

X-From-Line: rms@gnu.orgSPI_AMP#160; Fri Dec  3 04:00:59 1999
Return-Path: <rms@gnu.org>
Delivered-To: brandon@europa.gtri.gatech.edu
Received: from pele.santafe.edu (pele.santafe.edu [192.12.12.119])
        by europa.gtri.gatech.edu (Postfix) with ESMTP id 6C4774809
        for <brandon@rhodesmill.org>; Fri,  3 Dec 1999 04:00:58 -0500 (EST)
Received: from aztec.santafe.edu (aztec [192.12.12.49])
        by pele.santafe.edu (8.9.1/8.9.1) with ESMTP id CAA27250
        for <brandon@rhodesmill.org>; Fri, 3 Dec 1999 02:00:57 -0700 (MST)
Received: (from rms@localhost)
        by aztec.santafe.edu (8.9.1b+Sun/8.9.1) id CAA29939;
        Fri, 3 Dec 1999 02:00:56 -0700 (MST)
Date: Fri, 3 Dec 1999 02:00:56 -0700 (MST)
Message-Id: <199912030900.CAA29939@aztec.santafe.edu>
X-Authentication-Warning: aztec.santafe.edu: rms set sender to rms@gnu.org using -f
From: Richard Stallman <rms@gnu.org>
To: brandon@rhodesmill.org
In-reply-to: <m3k8my7x1k.fsf@europa.gtri.gatech.edu> (message from Brandon
        Craig Rhodes on 02 Dec 1999 00:04:55 -0500)
Subject: Re: Please proofread this license
Reply-To: rms@gnu.org
References: <199911280547.WAA21685@aztec.santafe.edu> <m3k8my7x1k.fsf@europa.gtri.gatech.edu>
Xref: 38-74.clients.speedfactory.net scrapbook:11
Lines: 1

Thanks.

尽管这条消息实际上只传递了一行正文,但是您可以看到,在通过互联网传输的过程中,它积累了相当多的附加信息。

尽管在撰写电子邮件时,发件人一行以下的所有标题可能都已经存在,但它上面的许多标题可能是在传输历史的不同阶段添加的。处理电子邮件的每个客户端和服务器都保留添加额外邮件头的权利。这意味着每封电子邮件在网络中传播时都会积累一段个人历史,通常可以从最后一个邮件头开始向上阅读,直到到达第一个邮件头。

在这种情况下,电子邮件似乎源自圣达菲一台名为aztec的机器,其作者通过本地主机内部接口直接连接。然后aztec机器使用 SMTP 将消息转发给pele,后者可能为一个部门或整个校园执行电子邮件传输。最后,pele通过 SMTP 连接到我在佐治亚理工学院桌子上的europa机器,它把信息写到磁盘上,以便我以后可以阅读。

现在,我将暂停一下,介绍几个特定的电子邮件标题;完整列表见标准。

  • From 指定电子邮件的作者。和后面的标题一样,它既支持真实姓名,也支持尖括号内的电子邮件地址。
  • 回复到指定回复的目的地,如果不是从头的中列出的作者。
  • 是一个或多个主要收件人的列表。
  • Cc 列出一个或多个收件人,他们应该收到电子邮件的“副本”,但不是通信的直接地址。
  • 密件抄送列出了应该在其他收件人不知情的情况下获得电子邮件秘密副本的收件人。因此,细心的电子邮件客户在真正发送电子邮件之前会先将的密件抄送去掉。
  • 主题是由消息作者编写的消息内容的人类可读摘要。
  • 日期指定发送或接收消息的时间。通常,如果发件人的电子邮件客户端包含日期,则接收电子邮件的服务器和阅读器不会覆盖它。但是如果发件人没有注明日期,那么在收到电子邮件时,为了完整起见,可能会在后面加上日期。
  • Message-Id 是用于识别电子邮件的唯一字符串。
  • 回复至是该消息回复的先前消息的唯一消息 Id。如果要求您构建一个线索显示,将回复消息放在它们所回复的电子邮件的下面,那么这些将非常有用。
  • Received 是在电子邮件通过 SMTP 到达互联网途中的另一个“跳”时添加的。电子邮件服务器管理员经常仔细检查这些树环,以确定邮件被正确传递或未被正确传递的原因。

您可以看到电子邮件的纯文本限制对标题和正文都有影响:在这样一个简单的例子中,它们都被限制为 ASCII。在接下来的小节中,我将解释管理邮件头如何包含国际字符的标准,以及设置电子邮件正文如何包含国际或二进制数据的标准。

构建电子邮件消息

Python 中构建电子邮件消息的主要接口是EmailMessage类,本章列出的每个程序都会用到它。这是 Python 模块大师 r .大卫·穆雷辛勤工作的结果,我要感谢他在我整理本章脚本时给予的指导和建议。最简单的例子如清单 12-2 所示。

清单 12-2 。生成简单的文本电子邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/build_basic_email.py

import email.message, email.policy, email.utils, sys

text = """Hello,
This is a basic message from Chapter 12.
 - Anonymous"""

def main():
    message = email.message.EmailMessage(email.policy.SMTP)
    message['To'] = 'recipient@example.com'
    message['From'] = 'Test Sender <sender@example.com>'
    message['Subject'] = 'Test Message, Chapter 12'
    message['Date'] = email.utils.formatdate(localtime=True)
    message['Message-ID'] = email.utils.make_msgid()
    message.set_content(text)
    sys.stdout.buffer.write(message.as_bytes())

if __name__ == '__main__':
    main()

Image 注意本章中的代码专门针对 Python 3.4 及更高版本,该版本将EmailMessage类引入到旧的电子邮件模块中。如果您需要针对旧版本的 Python 3 并且无法升级,请在https://github.com/brandon-rhodes/fopnp/tree/m/py3/old-chapter12学习旧的脚本。

您可以通过省略此处显示的标题来生成更简单的电子邮件,但这是您在现代互联网上通常应该考虑的最小集合。

EmailMessage的 API 让您的代码非常接近地反映您的电子邮件消息的文本。虽然您可以自由地设置标题,并以最适合您的代码的任何顺序提供内容,但是先设置标题,然后最后设置正文,可以使消息在网络上以及在电子邮件客户端上的显示方式具有令人满意的对称性。

请注意,我在这里设置了两个您应该始终包含的头,但是它们的值不会自动为您设置。我将利用 Python 中内置于标准电子邮件工具集的formatdate()函数,以电子邮件标准所要求的特殊格式提供日期。Message-Id 也是从随机信息中精心构造的,以使它(希望)在所有过去编写的或将来编写的电子邮件中是唯一的。

生成的脚本只是将电子邮件打印到它的标准输出上,这使得试验变得非常容易,并立即显示您所做的任何编辑或修改的结果。

To: recipient@example.com
From: Test Sender <sender@example.com>
Subject: Test Message, Chapter 12
Date: Fri, 28 Mar 2014 16:54:17 -0400
Message-ID: <20140328205417.5927.96806@guinness>
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0

Hello,
This is a basic message from Chapter 12.
 - Anonymous

如果您要使用旧的Message类而不是EmailMessage来构建电子邮件消息,您将会看到其中的几个头将会丢失。老式的电子邮件消息(如清单 12-1 中的消息)没有指定传输编码、多用途互联网邮件扩展(MIME)版本和内容类型,而是简单地省略了这些标题,并相信电子邮件客户端会采用传统的默认值。但是现代的EmailMessage构建器更加小心地指定显式值,以确保与现代工具最高水平的互操作性。

如前所述,标头名称不区分大小写。因此,符合要求的电子邮件客户端将不会区分清单 12-1 中的Message-Id和生成的电子邮件中的Message-ID(改为大写的 D)的含义。

如果不想让函数使用当前日期和时间,可以给函数一个特定的 Python datetime来显示,也可以选择让它使用格林威治标准时间(GMT)而不是本地时区。详见 Python 的文档。

请注意,惟一的Message-ID是由几条信息构成的,如果您处于非常高安全性的情况下,您可能不想公开这些信息:您调用make_msgid()的确切时间、日期和毫秒数,您的 Python 脚本的这次调用的进程 ID,如果您没有提供可选的domain=关键字,甚至是您当前的主机名。如果您想避免泄露任何这些信息,请实现一个替代的唯一 id 解决方案(可能需要一个工业级的通用唯一标识符[UUID] 算法)。

最后,请注意,尽管文本并不正式符合作为电子邮件传输的要求——为了节省脚本中的垂直空间,用三重引号括起来的字符串常量没有结束行——set_content()as_bytes()的组合确保了电子邮件消息以换行符正确结束。

添加 HTML 和多媒体

在早期,人们发明了许多专门的机制来在 7 位 ASCII 世界的电子邮件中传送二进制数据,但正是 MIME 标准为非 ASCII 有效负载建立了一种可互操作和可扩展的机制。MIME 允许内容类型的电子邮件头指定一个边界字符串,每当电子邮件出现在前面有两个连字符的行上时,它就将电子邮件分割成更小的消息部分。每个部分都有自己的头,因此也有自己的内容类型和编码。如果一个部件甚至指定了自己的边界字符串,那么部件甚至可以由更多的子部件组成,从而创建一个层次结构。

Python email模块确实提供了低级支持,可以根据您的需要从任何部分和子部分构建 MIME 消息。简单地构建几个email.message.MIMEPart 对象——每个对象都可以被赋予标题和主体,使用与EmailMessage相同的接口——然后attach()它们到它们的父部分或消息:

my_message.attach(part1)
my_message.attach(part2)
...

但是,如果您要精确地再现某个特定的消息结构(这是您的应用或项目规范所要求的),那么您应该只求助于手动组装。在大多数情况下,您可以简单地创建一个EmailMessage(如清单 12-2 中的所示)并依次调用以下四个方法来构建您的结果:

  • 应该先调用set_content() 来安装主消息体。
  • add_related() 可以被调用零次或更多次,以便用其他需要呈现的资源来补充主要内容。通常,当您的主要内容是 HTML,并且需要图像、CSS 样式表和 JavaScript 文件在支持丰富内容的电子邮件客户端中正确呈现时,您会使用这种方法。每个相关资源都应该有一个 Content-Id ( cid),主 HTML 文档可以通过它在超链接中引用它。
  • 然后可以调用add_alternative() 零次或多次,以提供您的电子邮件消息的其他呈现。例如,如果正文是 HTML,您可以为功能较弱的电子邮件客户端提供纯文本替代呈现。
  • add_attachment() 可以被调用零次或多次,以提供任何附件,如 PDF 文档、图像或电子表格,它们应该伴随消息。传统上,如果收件人要求他们的电子邮件客户端保存附件,则每个附件指定一个默认文件名。

回头看,您可以看到清单 12-2 中的完全遵循了上面的过程——它调用了set_content()作为第一步,然后简单地选择调用其他三个方法中的每一个零次。其结果是最简单的电子邮件结构,呈现了一个没有子部分的统一主体。

但是当事情变得更复杂时,电子邮件看起来如何呢?清单 12-3 旨在给出答案。

清单 12-3 。构建带有 HTML、内嵌图像和附件的 MIME 驱动的电子邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/build_mime_email.py

import argparse, email.message, email.policy, email.utils, mimetypes, sys

plain = """Hello,
This is a MIME message from Chapter 12.
- Anonymous"""

html = """<p>Hello,</p>
<p>This is a <b>test message</b> from Chapter 12.</p>
<p>- <i>Anonymous</i></p>"""

img = """<p>This is the smallest possible blue GIF:</p>
<img src="cid:{}" height="80" width="80">"""

# Tiny example GIF from http://www.perlmonks.org/?node_id=7974
blue_dot = (b'GIF89a1010\x900000\xff000,000010100\x02\x02\x0410;'
            .replace(b'0', b'\x00').replace(b'1', b'\x01'))

def main(args):
    message = email.message.EmailMessage(email.policy.SMTP)
    message['To'] = 'Test Recipient <recipient@example.com>'
    message['From'] = 'Test Sender <sender@example.com>'
    message['Subject'] = 'Foundations of Python Network Programming'
    message['Date'] = email.utils.formatdate(localtime=True)
    message['Message-ID'] = email.utils.make_msgid()

    if not args.i:
        message.set_content(html, subtype='html')
        message.add_alternative(plain)
    else:
        cid = email.utils.make_msgid()  # RFC 2392: must be globally unique!
        message.set_content(html + img.format(cid.strip('<>')), subtype='html')
        message.add_related(blue_dot, 'image', 'gif', cid=cid,
                            filename='blue-dot.gif')
        message.add_alternative(plain)

    for filename in args.filename:
        mime_type, encoding = mimetypes.guess_type(filename)
        if encoding or (mime_type is None):
            mime_type = 'application/octet-stream'
        main, sub = mime_type.split('/')
        if main == 'text':
            with open(filename, encoding='utf-8') as f:
                text = f.read()
            message.add_attachment(text, sub, filename=filename)
        else:
            with open(filename, 'rb') as f:
                data = f.read()
            message.add_attachment(data, main, sub, filename=filename)

    sys.stdout.buffer.write(message.as_bytes())

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Build, print a MIME email')
    parser.add_argument('-i', action='store_true', help='Include GIF image')
    parser.add_argument('filename', nargs='*', help='Attachment filename')
    main(parser.parse_args())

有四种不同的方法可以调用清单 12-3 中的脚本。按照复杂性递增的顺序,它们是:

  • python3 build_mime_email.py
  • python3 build_mime_email.py attachment.txt attachment.gz
  • python3 build_mime_email.py -i
  • python3 build_mime_email.py -i attachment.txt attachment.gz

为了节省空间,我将在这里只显示这四个命令行的第一个和最后一个的输出,但是如果您想了解 MIME 标准如何根据调用者的需要支持逐渐增加的复杂程度,您应该自己下载build_mime_email.py 并尝试其他的。尽管两个示例文件— attachment.txt(纯文本)和attachment.gz(二进制)—包含在本书的源代码库中,就在脚本旁边,但是您可以随意在命令行中列出您想要的任何附件。这样做将让您看到不同的二进制有效载荷是如何被 Python email模块编码的。

不带任何选项或附件调用build_mime_email.py会产生最简单的 MIME 结构,提供电子邮件的两种可选版本:HTML 和纯文本。这里显示了这样做的结果。

To: Test Recipient <recipient@example.com>
From: Test Sender <sender@example.com>
Subject: Foundations of Python Network Programming
Date: Tue, 25 Mar 2014 17:14:01 -0400
Message-ID: <20140325232008.15748.50494@guinness>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="===============1627694678=="

--===============1627694678==
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: 7bit

<p>Hello,</p>
<p>This is a <b>test message</b> from Chapter 12.</p>
<p>- <i>Anonymous</i></p>

--===============1627694678==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0

Hello,
This is a MIME message from Chapter 12.
- Anonymous

--===============1627694678==--

在最高层,上面的电子邮件遵循旧的标准格式:标题、空行和正文。但是现在身体突然更有意思了。为了承载两种有效载荷,纯文本和 HTML,头部指定了一个边界,将主体分成几个更小的部分。每个部分本身都是传统格式:标题、空行和正文。对一个部分的内容只有一个(相当明显的)限制:一个部分不能包含它自己的边界线或任何封闭消息的边界线的副本。

multipart/alternative内容类型是整个multipart/*内容类型家族的一个例子,所有这些内容类型都遵循完全相同的规则,这些规则涉及边界线的建立及其在划定其下的 MIME 子部分时的使用。它的作用是传递信息的几个版本,任何一个版本都可以显示给用户,从而传达信息的全部含义。在这种情况下,可以向用户显示 HTML 或纯文本,但无论以哪种方式,用户都将看到基本相同的电子邮件。如果能够显示的话,大多数客户会选择 HTML。尽管大多数电子邮件客户端会隐藏提供替代版本的事实,但有些确实提供了按钮或下拉菜单,如果用户愿意,可以让他们看到替代版本。

注意到MIME-Version头只在消息的顶层被指定,但是email模块已经处理了这个问题,发送者不需要知道这个标准的细节。

关于multipart段的规则如下:

  • 如果你至少调用了一次add_related(),那么你用set_content()指定的主体将会和所有相关内容一起被分组到一个单独的multipart/related部分中。
  • 如果您至少调用了一次add_alternative(),那么就会创建一个multipart/alternative容器来保存原始主体和您添加的替代部分。
  • 最后,如果您至少调用了一次add_attachment(),那么就会生成一个外部的multipart/mixed容器来保存您添加的所有附件旁边的内容。

通过检查下面的输出,您可以看到所有这些机制一起发挥作用,该输出来自上面给出的四个命令行中最复杂的一个。它要求一个内嵌相关的图像伴随 HTML 和–i,还要求在正文后包含附件。

To: Test Recipient <recipient@example.com>
From: Test Sender <sender@example.com>
Subject: Foundations of Python Network Programming
Date: Tue, 25 Mar 2014 17:14:01 -0400
Message-ID: <20140325232008.15748.50494@guinness>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="===============0086939546=="

--===============0086939546==
Content-Type: multipart/alternative; boundary="===============0903170602=="

--===============0903170602==
Content-Type: multipart/related; boundary="===============1911784257=="

--===============1911784257==
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: 7bit

<p>Hello,</p>
<p>This is a <b>test message</b> from Chapter 12.</p>
<p>- <i>Anonymous</i></p><p>This is the smallest possible blue GIF:</p>
<img src="cid:20140325232008.15748.99346@guinness" height="80" width="80">

--===============1911784257==
Content-Type: image/gif
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="blue-dot.gif"
Content-ID: <20140325232008.15748.99346@guinness>
MIME-Version: 1.0

R0lGODlhAQABAJAAAAAA/wAAACwAAAAAAQABAAACAgQBADs=

--===============1911784257==--

--===============0903170602==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
MIME-Version: 1.0

Hello,
This is a MIME message from Chapter 12.
- Anonymous

--===============0903170602==--

--===============0086939546==
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment; filename="attachment.txt"
MIME-Version: 1.0

This is a test

--===============0086939546==
Content-Type: application/octet-stream
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="attachment.gz"
MIME-Version: 1.0

H4sIAP3o2D8AAwvJyCxWAKJEhZLU4hIuAIwtwPoPAAAA

--===============0086939546==--

这封电子邮件是同心的,有三个层次的多部分内容。和以前一样,您可以看到所有的细节都已经为我们处理好了。每个级别都有自己随机生成的边界,不会与任何其他级别的边界冲突。在每种情况下,都为包含在其中的内容选择了适当的多部分容器。

最后,指定了正确的编码。纯文本已被允许在电子邮件正文中传播,而 Base64 编码已被用于非 7 位安全的二进制数据类图像。请注意,在这两个生成脚本中,电子邮件对象被要求以字节形式显式呈现,而不是要求在保存或传输之前对文本进行编码。

添加内容

用于在清单 12-3 中添加内容的所有四种方法共享相同的调用约定。查阅 Python 文档,了解您正在使用的 Python 3 的特定版本所支持的每种可能的组合。以下是方法set_content()add_related()add_alternative()add_attachment()的一些常见组合:

  • method('string data of type str') method('string data of type str', subtype='html')

    这些创造了一些有文本味道的部分。内容类型将是text/plain,除非您提供一个定制的子类型——例如,第二个示例调用产生了一个内容类型text/html

  • method(b'raw binary payload of type bytes', type='image', subtype='jpeg')

    如果您提供原始的二进制数据,那么 Python 将不会试图猜测应该是什么类型。您必须自己提供 MIME 类型和子类型,它们将在输出中与一个斜杠组合在一起。请注意,清单 12-3 使用了email模块本身之外的一种机制,即mimetypes模块,来尝试猜测您在命令行上指定的每个附件文件的适当类型。

  • method(..., cte='quoted-printable')

    所有这些方法似乎都默认为仅有的两种内容传输编码之一。安全的 7 位信息使用简单可读的 ASCII 编码逐字包含在电子邮件中,而任何更危险的信息都使用 Base64 编码。如果您经常手动检查收到或发出的电子邮件,您可能会发现后一种选择很不幸,例如,这意味着包含一个 Unicode 字符的文本部分将变成完全不可读的 Base64 垃圾。您可以用cte关键字覆盖编码选择。特别是,您可能会发现quoted-printable编码很有吸引力:ASCII 字符在编码的电子邮件中被一字不差地保留下来,转义序列用于第八位被设置的任何字节。

  • add_related(..., cid='<Content ID>')

    通常,您会希望每个相关部分由一个自定义内容 ID 来标识,以便您的 HTML 可以链接到它。在您的调用中,内容 ID 应该总是用尖括号括起来,但是当您在 HTML 中实际形成cid:链接时,应该去掉它们。值得注意的是,内容 ID 应该是全球唯一的——您在文档中包含的每个内容 ID 都应该是整个世界历史上电子邮件中包含的所有内容 ID 中唯一的!清单 12-3 使用make_msgid(),因为email模块没有提供构建唯一内容 id 的特定工具。

  • add_attachment(..., filename='data.csv')

    当添加附件时,大多数电子邮件客户端(以及他们的用户)将期望至少一个建议的文件名,尽管当然当他们选择“保存”时,电子邮件接收者可以覆盖该默认。

同样,您可以在官方 Python 文档中了解这些特殊情况调用的其他更复杂的版本,但是这些版本应该可以帮助您完成构建 MIME 电子邮件的最常见情况。

解析电子邮件消息

使用email模块中的一个函数解析电子邮件后,有两种基本方法可以阅读它。简单的方法是假设消息通过 MIME 的标准和习惯用法提供了正文和附件,并让内置于EmailMessage中的便利方法帮助您找到它们。更复杂的方法是手动访问消息的所有部分和子部分,然后自己决定它们的含义以及如何保存或显示它们。

清单 12-4 展示了这个简单的方法。与保存电子邮件消息一样,重要的是要小心地将输入读取为字节,然后将这些字节传递给email模块,而不要尝试自己的任何解码步骤。

清单 12-4 。请求发送邮件正文和附件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/display_email.py

import argparse, email.policy, sys

def main(binary_file):
    policy = email.policy.SMTP
    message = email.message_from_binary_file(binary_file, policy=policy)
    for header in ['From', 'To', 'Date', 'Subject']:
        print(header + ':', message.get(header, '(none)'))
    print()

    try:
        body = message.get_body(preferencelist=('plain', 'html'))
    except KeyError:
        print('<This message lacks a printable text or HTML body>')
    else:
        print(body.get_content())

    for part in message.walk():
        cd = part['Content-Disposition']
        is_attachment = cd and cd.split(';')[0].lower() == 'attachment'
        if not is_attachment:
            continue
        content = part.get_content()
        print('* {} attachment named {!r}: {} object of length {}'.format(
            part.get_content_type(), part.get_filename(),
            type(content).__name__, len(content)))

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Parse and print an email')
    parser.add_argument('filename', nargs='?', help='File containing an email')
    args = parser.parse_args()
    if args.filename is None:
        main(sys.stdin.buffer)
    else:
        with open(args.filename, 'rb') as f:
            main(f)

一旦脚本的命令行参数被解析,消息本身被读取并转换成EmailMessage,脚本就很自然地分成两部分。因为您希望email模块能够访问消息在磁盘上的精确二进制表示,所以您要么以二进制模式 'rb'打开它的文件,要么使用 Python 的标准输入对象的二进制buffer属性,这将返回原始字节。

第一个关键步骤是对get_body()方法的调用,该方法让 Python 在消息的 MIME 结构中搜索得越来越深,寻找最适合作为主体的部分。您指定的preferencelist 应该以您喜欢的格式排序,优先于您不太可能想要显示的格式。这里 HTML 内容比正文的纯文本版本更受欢迎,但是两者都可以接受。如果找不到合适的身体,那么KeyError被提高。

请注意,如果您没有指定自己的元素,则使用默认的preferencelist,它有三个元素,因为它将multipart/related 放在 HTML 和纯文本之前作为首选项。如果您正在编写一个复杂的电子邮件客户端(可能是一个 webmail 服务或一个带有内置 WebKit 窗格的应用),它不仅可以正确格式化 HTML,还可以显示内嵌图像并支持样式表,那么这个缺省值是合适的。您得到的对象将是相关内容 MIME 部分本身,然后您必须在其中查找 HTML 和它需要的所有多媒体。因为这里的小脚本只是简单地将结果体打印到标准输出,但是,我跳过了这种可能性。

在显示了所能找到的最佳正文之后,就该搜索用户可能希望显示或保存的任何附件了。注意,示例脚本要求 MIME 为附件指定的所有基本信息:内容类型、文件名,然后是数据本身。在实际的应用中,您可能会打开一个文件进行写入并保存这些数据,而不是仅仅将它的长度和类型打印到屏幕上。

注意,由于 Python 3.4 中的一个错误,这个显示脚本被迫自己决定哪些消息部分是附件,哪些不是。在 Python 的未来版本中,您将能够用对消息的iter_attachments()方法的简单调用来代替树的手动迭代并测试每个部分的内容处理。

接下来的脚本将处理由前面的脚本生成的任何 MIME 消息,不管有多复杂。给定最简单的消息,它只显示“有趣的”标题和正文。

$ python3 build_basic_email.py > email.txt
$ python3 display_email.py email.txt
From: Test Sender <sender@example.com>
To: recipient@example.com
Date: Tue, 25 Mar 2014 17:14:01 -0400
Subject: Test Message, Chapter 12

Hello,
This is a basic message from Chapter 12.
 - Anonymous

但即使是最复杂的信息对它来说也不过分。在重新出现电子邮件正文的 HTML 版本之前,get_body()逻辑成功地进入混合的多部分外层,进入可选的多部分中间层,最后甚至深入到消息的相关多部分内部。此外,还会检查包含的每个附件。

$ python3 build_mime_email.py -i attachment.txt attachment.gz > email.txt
$ python3 display_email.py email.txt
From: Test Sender <sender@example.com>
To: Test Recipient <recipient@example.com>
Date: Tue, 25 Mar 2014 17:14:01 -0400
Subject: Foundations of Python Network Programming

Hello,
This is a MIME message from Chapter 12.
- Anonymous

* image/gif attachment named 'blue-dot.gif': bytes object of length 35
* text/plain attachment named 'attachment.txt': str object of length 15
* application/octet-stream attachment named 'attachment.gz': bytes object of length 33

行走的哑剧角色

如果清单 12-4 中的逻辑最终不能满足您的应用——如果它不能找到您的项目需要能够解析的特定电子邮件的正文,或者如果您的客户需要访问的某些指定不当的附件被跳过——那么您将需要退回到自己访问电子邮件消息的每一部分,并实现您自己的算法来决定哪些部分要显示,哪些部分要保存为附件,哪些部分要忽略或丢弃。

当分解一封 MIME 邮件时,有三个基本规则要记住。

  • 在检查一个部分时,您的第一个调用应该是对is_multipart()方法的调用,以确定您正在检查的 MIME 部分是否是其他 MIME 子部分的容器。如果您想要主类型和子类型之间有斜杠的完全限定类型,您也可以调用get_content_type() ,如果您只关心其中的一半,则可以调用get_content_maintype()get_content_subtype()
  • 当遇到一个多部分时,使用iter_parts()方法循环或获取紧接在它下面的部分,这样您就可以依次发现哪些子部分本身是多部分的,哪些只是包含内容。
  • 当检查一个普通部件时,Content-Disposition 头将告诉您它是否是一个附件(在头的值中的任何分号之前寻找单词 attachment )。
  • 调用get_content()方法解码并返回 MIME 部分中的数据本身,作为文本str或二进制bytes对象,这取决于主内容类型是否为text

清单 12-5 中的代码使用递归生成器来访问多部分消息的每一部分。生成器的操作与内置walk()方法的操作类似,除了该生成器会跟上每个子部分的索引,以防以后需要获取。

清单 12-5 。手动访问多部分方法的每个部分

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/display_structure.py

import argparse, email.policy, sys

def walk(part, prefix=''):
    yield prefix, part
    for i, subpart in enumerate(part.iter_parts()):
        yield from walk(subpart, prefix + '.{}'.format(i))

def main(binary_file):
    policy = email.policy.SMTP
    message = email.message_from_binary_file(binary_file, policy=policy)
    for prefix, part in walk(message):
        line = '{} type={}'.format(prefix, part.get_content_type())
        if not part.is_multipart():
            content = part.get_content()
            line += ' {} len={}'.format(type(content).__name__, len(content))
            cd = part['Content-Disposition']
            is_attachment = cd and cd.split(';')[0].lower() == 'attachment'
            if is_attachment:
                line += ' attachment'
            filename = part.get_filename()
            if filename is not None:
                line += ' filename={!r}'.format(filename)
        print(line)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Display MIME structure')
    parser.add_argument('filename', nargs='?', help='File containing an email')
    args = parser.parse_args()
    if args.filename is None:
        main(sys.stdin.buffer)
    else:
        with open(args.filename, 'rb') as f:
            main(f)

您可以针对早期脚本可以生成的任何电子邮件来使用该脚本。(当然,你也可以试着给它发一封你自己的电子邮件。)对使用上述脚本生成的最复杂的消息运行它会产生以下结果。

$ python3 build_mime_email.py -i attachment.txt attachment.gz > email.txt
$ python3 display_structure.py email.txt
 type=multipart/mixed
.0 type=multipart/alternative
.0.0 type=multipart/related
.0.0.0 type=text/html str len=215
.0.0.1 type=image/gif bytes len=35 attachment filename='blue-dot.gif'
.0.1 type=text/plain str len=59
.1 type=text/plain str len=15 attachment filename='attachment.txt'
.2 type=application/octet-stream bytes len=33 attachment filename='attachment.gz'

介绍每行输出的部件号可以在您编写的其他代码中使用,以便通过向get_payload()方法提供每个整数索引来直接进入消息以获取您感兴趣的特定部件。例如,如果您想从该邮件中获取蓝点 GIF 图像,您可以调用:

part = message.get_payload(0).get_payload(0).get_payload(1)

请再次注意,只有多部分部分才允许包含更多的 MIME 子部分。具有非多部分内容类型的每个部分都是上面树中的一个叶节点,包含简单的内容,下面没有进一步的与电子邮件相关的结构。

标题编码

由于有了email模块,上面的解析脚本将正确处理使用 RFC 2047 惯例编码特殊字符的国际化报头,而无需任何修改。清单 12-6 生成这样一封电子邮件,您可以用它来执行测试。注意,因为 Python 3 的源代码默认是 UTF-8 编码的,所以可以包含国际字符,而不需要像 Python 2 那样在顶部声明-*- coding: utf-8 -*-

清单 12-6 。生成一封国际化的电子邮件来测试解析脚本

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter12/build_unicode_email.py

import email.message, email.policy, sys

text = """\
Hwær cwom mearg? Hwær cwom mago?
Hwær cwom maþþumgyfa?
Hwær cwom symbla gesetu?
Hwær sindon seledreamas?"""

def main():
    message = email.message.EmailMessage(email.policy.SMTP)
    message['To'] = 'Böðvarr <recipient@example.com>'
    message['From'] = 'Eardstapa <sender@example.com>'
    message['Subject'] = 'Four lines from The Wanderer'
    message['Date'] = email.utils.formatdate(localtime=True)
    message.set_content(text, cte='quoted-printable')
    sys.stdout.buffer.write(message.as_bytes())

if __name__ == '__main__':
    main()

由于 To:标头中包含特殊字符,因此输出电子邮件对二进制数据使用特殊的 ASCII 编码。此外,根据前面给出的建议,请注意,通过为正文指定一个quoted-printable内容编码,您避免了生成一个 Base64 数据块,而是用它们的直接 ASCII 代码来表示大多数字符,如下面的结果所示。

To: =?utf-8?b?QsO2w7B2YXJy?= <recipient@example.com>
From: Eardstapa <sender@example.com>
Subject: Four lines from The Wanderer
Date: Fri, 28 Mar 2014 22:11:48 -0400
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
MIME-Version: 1.0

Hw=C3=A6r cwom mearg? Hw=C3=A6r cwom mago?
Hw=C3=A6r cwom ma=C3=BE=C3=BEumgyfa?
Hw=C3=A6r cwom symbla gesetu?
Hw=C3=A6r sindon seledreamas?

显示脚本成功地解决了所有这些问题,因为email模块为我们完成了所有的解码和处理。

$ python3 build_unicode_email.py > email.txt
$ python3 display_email.py email.txt
From: Eardstapa <sender@example.com>
To: Böðvarr <recipient@example.com>
Date: Tue, 25 Mar 2014 17:14:01 -0400
Subject: Four lines from The Wanderer

Hwær cwom mearg? Hwær cwom mago?
Hwær cwom maþþumgyfa?
Hwær cwom symbla gesetu?
Hwær sindon seledreamas?

如果您想进一步研究电子邮件头编码,请阅读较低级别的email.header模块的 Python 文档,特别是它的Header类。

解析日期

符合标准的日期通过email.utils中的formatdate()函数在上面的脚本中使用,默认情况下使用当前日期和时间。但是也可以为它们提供一个低级的 Unix 时间戳。如果您正在进行更高级别的日期操作,并且已经生成了一个datetime对象,那么只需使用format_datetime()函数来进行同样的格式化。

当解析一封邮件时,可以通过email.utils中的另外三个方法执行相反的操作。

  • parsedate()parsedate_tz()都通过其time模块返回 Python 在底层支持的时间元组,遵循旧的 C 语言惯例进行日期算术和表示。
  • 现代的parsedate_to_datetime()函数返回一个完整的datetime对象,这可能是您在大多数产品代码中想要进行的调用。

请注意,许多电子邮件程序在编写日期头时没有严格遵循相关标准,尽管这些例程试图宽容一些,但可能会出现它们无法生成有效日期值并返回None的情况。在假设您已经返回了一个日期之前,您需要检查这个值。下面是几个调用示例。

>>> from email import utils
>>> utils.parsedate('Tue, 25 Mar 2014 17:14:01 -0400')
(2014, 3, 25, 17, 14, 1, 0, 1, -1)
>>> utils.parsedate_tz('Tue, 25 Mar 2014 17:14:01 -0400')
(2014, 3, 25, 17, 14, 1, 0, 1, -1, -14400)
>>> utils.parsedate_to_datetime('Tue, 25 Mar 2014 17:14:01 -0400')
datetime.datetime(2014, 3, 25, 17, 14, 1,
                  tzinfo=datetime.timezone(datetime.timedelta(-1, 72000)))

如果您打算对日期进行任何运算,我强烈建议您研究第三方的pytz模块,它已经成为社区中关于日期操作的最佳实践。

摘要

r .大卫·穆雷在 Python 3.4 中引入的强大的email.message.EmailMessage类使得 MIME 消息的生成和消费比以前的 Python 版本方便得多。像往常一样,唯一的警告是密切注意字节和字符串之间的区别。尝试将整个套接字或文件 I/O 作为字节,并让email模块完成所有自己的编码,这样每一步都能正确完成。

电子邮件通常通过实例化EmailMessage然后指定标题和内容来生成。通过将消息视为具有不区分大小写的字符串键的字典来设置头,字符串值存储在字典中,如果字符串值的任何字符是非 ASCII 字符,则在输出时会对其进行正确编码。内容是通过级联的四种方法设置的— set_content()add_related()add_alternative()add_attachment()—在所有情况下都能正确处理文本和字节有效载荷。

通过运行任何一个email模块的解析函数(message_from_binary_file()是本章清单中使用的方法),可以将一个电子邮件消息作为一个EmailMessage对象读回并进行检查,并使用一个策略参数打开EmailMessage类的所有现代特性。每个结果对象要么是一个多部分,其中包含更多的子部分,要么是 Python 以字符串或字节数据形式返回的一段空白内容。

标头在输出和输入时会自动国际化和解码。特殊日期头的格式受email.utils中的方法支持,这些方法允许您的代码使用现代 Python datetime对象的实例来读写它的值。

下一章将专门研究 SMTP 协议在电子邮件传输中的应用。

十三、SMTP

如第十二章开头所述,电子邮件在系统间的实际移动是通过简单的邮件传输协议 SMTP 完成的。它于 1982 年在 RFC 821 中首次定义;SMTP 的最新 RFC 定义是 RFC 5321。该协议通常有两个作用:

  1. 当用户在笔记本电脑或台式机上键入电子邮件时,电子邮件客户端使用 SMTP 将电子邮件提交给服务器,服务器可以将电子邮件发送到目的地。
  2. 电子邮件服务器本身使用 SMTP 来传递消息,通过互联网将每条消息从一个服务器发送到另一个服务器,直到它到达负责收件人电子邮件地址的(电子邮件地址中@符号后的部分)的服务器。

SMTP 用于提交和传递的方式有几个不同之处。然而,在讨论它们之前,我将快速概述使用本地电子邮件客户端查看电子邮件的用户和使用 ?? 网络邮件服务的用户之间的区别。

电子邮件客户端与网络邮件服务

如果我追溯一下用户使用互联网电子邮件的历史,SMTP 在消息提交中的作用可能是最不容易混淆的。

要理解的关键概念是,用户从来没有被要求坐下来等待电子邮件真正送达。在电子邮件真正送达目的地之前,这一过程通常要花费相当多的时间,并且要反复尝试几十次。许多事情都可能导致延迟:一条消息可能必须等待,因为其他消息已经在有限带宽的链路上传输,目标服务器可能停机几个小时,或者其网络可能由于故障而无法访问。如果电子邮件的目的地是一个大的组织,如一所大学,当它到达大的大学服务器时,可能必须经过几个不同的“跳”,然后被定向到大的大学中一个特定学院的较小的电子邮件机器,最后被传送到系里的电子邮件服务器。

因此,理解当用户点击发送时会发生什么,本质上就是理解完成的电子邮件消息是如何被提交到几个电子邮件队列中的第一个的,它可以在队列中等待,直到环境正好适合它的传递发生。(这将在下一节电子邮件传递中讨论。)

一开始是命令行

第一代电子邮件用户的用户名和密码是由他们的公司或大学提供的,这使他们能够通过命令行访问保存用户文件和通用程序的大型主机。每台大型机器通常运行一个电子邮件守护进程,维护一个传出队列;就在同一个盒子上,用户正忙着用小的命令行电子邮件程序输入信息。几个这样的项目各有其鼎盛时期;紧随其后的是更高级的mailx,然后是界面更漂亮、功能更强大的elmpine,最后是mutt

SMTP 协议

目的:向服务器发送电子邮件

标准:RFC 2821

在顶层运行:TCP 或 TLS

端口号:53

库:smtplib

但是对于所有这些早期用户来说,网络甚至没有参与简单的电子邮件提交任务;毕竟,电子邮件客户端和服务器在同一台机器上!弥合这一微小差距和执行电子邮件提交的实际方法仅仅是一个实现细节,通常隐藏在命令行客户端程序之后,该程序与服务器软件一起提供,确切地知道如何与之通信。第一个广泛使用的电子邮件守护进程sendmail,是由一个名为/usr/lib/sendmail的提交电子邮件的程序带来的。

因为第一代用于读写电子邮件的客户端程序是为与sendmail交互而设计的,所以后来流行起来的电子邮件守护程序,如qmailpostfixexim,通常会通过提供自己的sendmail二进制文件(由于最近的文件系统标准,它的正式名称现在是/usr/sbin)来效仿,当用户的电子邮件程序调用该二进制文件时,它会遵循特定的电子邮件守护程序自己的特殊过程来将消息移入队列。

当一封电子邮件到达时,它通常被存放在一个属于邮件收件人的文件中。运行在命令行上的电子邮件客户端可以简单地打开这个文件并解析它,以查看等待用户阅读的消息。本书不涉及这些邮箱格式,因为它的重点是电子邮件如何使用网络。然而,如果您很好奇,您可以查看 Python 标准库中的mailbox包,它支持多年来各种电子邮件程序向磁盘读写消息的所有奇怪和好奇的方式。

客户的崛起

开始使用互联网的下一代用户通常不熟悉命令行的概念。用户熟练使用苹果麦金塔(Apple Macintosh)的图形界面,或者后来出现的微软视窗(Microsoft Windows)操作系统,他们希望通过点击图标和运行图形程序来完成任务。因此,许多不同的电子邮件客户端被编写出来,将这种互联网服务带到了桌面上。Mozilla Thunderbird 和微软 Outlook 是目前仍在使用的最受欢迎的客户端中的两个。

这种方法的问题显而易见。首先,阅读收到的电子邮件从电子邮件程序的简单任务(以前只能打开本地文件并阅读)转变为现在需要网络连接的操作。当你打开你的图形化电子邮件程序时,它不得不通过互联网到达一个全职服务器,当你不在的时候,它代表你接收电子邮件,并把电子邮件带到本地机器。

其次,用户因没有正确备份他们的台式机和笔记本电脑文件系统而臭名昭著,而客户端下载并在本地存储消息,因此当笔记本电脑或台式机硬盘崩溃时,这些消息很容易被删除。相比之下,大学和工业服务器——尽管它们的命令行笨拙——通常有一小群人专门负责保持数据的存档、复制和安全。

第三,笔记本电脑和台式机通常不适合作为电子邮件服务器及其待发邮件队列的环境。毕竟,用户经常在用完电脑后关掉它们,断开网络连接,或者离开网吧,失去无线信号。发出的邮件通常需要在线多花一些时间来完成重试和最终传输,因此完成的电子邮件需要通过某种方式提交回全职服务器进行排队和发送。

但是程序员是聪明人,他们想出了一系列解决这些问题的办法。首先,发明了新的协议——首先是邮局协议(POP) ,我将在第十四章的中讨论,然后是互联网消息访问协议(IMAP) ,这将在第十五章中介绍——它让用户的电子邮件客户端通过密码进行认证,并从存储电子邮件的全职服务器下载电子邮件。密码是必要的,以防止其他人连接到您的互联网服务提供商的服务器和阅读您的电子邮件!这解决了第一个问题。

但是第二个问题呢,坚持;即避免台式机和笔记本电脑硬盘崩溃时丢失电子邮件?这激发了两组进展。首先,使用 POP 的人经常学会关闭其默认模式,在这种模式下,服务器上的电子邮件一旦被下载就被删除,他们学会在服务器上留下重要电子邮件的副本,如果他们不得不重新安装计算机并从头开始,他们可以从服务器上再次获取电子邮件。其次,他们开始转向 IMAP,如果他们的电子邮件服务器确实选择支持这种更高级的协议的话。使用 IMAP 意味着他们不仅可以将收到的电子邮件留在服务器上妥善保管,还可以将邮件整理到服务器上的文件夹中。这使他们能够将他们的电子邮件客户端程序仅仅作为一个浏览电子邮件的窗口,电子邮件本身仍然存储在服务器上,而不必管理他们的笔记本电脑或台式机上的电子邮件存储区。

最后,当用户写完一封电子邮件并单击 Send 时,电子邮件如何返回到服务器?这个任务——再次正式称为电子邮件提交——把我带回了本章的主题;也就是说,电子邮件提交使用 SMTP 协议进行。但是,正如我将要解释的那样,SMTP 通常有两个不同之处,一个是在因特网上的服务器之间使用的,另一个是在客户端提交电子邮件时使用的,这两个不同之处都是由现代对抗垃圾邮件的需要所驱动的。首先,大多数 ISP 会阻止从笔记本电脑和台式机到端口 25 的传出 TCP 连接,这样这些小型机器就不会被病毒劫持并用作电子邮件服务器。相反,电子邮件提交通常被定向到端口 587。第二,为了防止垃圾邮件发送者连接到您的 ISP 并声称他们想要从您这里发送邮件,电子邮件客户端使用包含用户用户名和密码的认证 SMTP

通过这些机制,电子邮件被带到了桌面上——无论是在像大学和企业这样的大型组织中,还是在面向家庭用户的 ISP 中。向每个用户提供说明,告诉他们:

  • 安装一个电子邮件客户端,如雷鸟或 Outlook。
  • 输入可以从中提取电子邮件的主机名和协议。
  • 配置发送服务器的名称和 SMTP 端口号。
  • 分配用户名和密码,使用该用户名和密码可以对两个服务的连接进行身份验证。

尽管电子邮件客户端配置起来很麻烦,服务器也很难维护,但它们最初是使用熟悉的图形界面向盯着彩色大显示器的新一代用户提供电子邮件的唯一方式。如今,他们允许用户有令人羡慕的选择自由:他们的 ISP 只需决定是支持 POP、IMAP,还是两者都支持,而用户(或者至少是非企业用户!)可以自由地尝试各种电子邮件客户端,并选定他们最喜欢的一个。

转移到 Webmail

最后,互联网上又发生了一次世代交替。用户曾经不得不下载并安装大量的客户端来体验互联网所提供的一切。许多有经验的读者会记得,他们在 Windows 或 Mac 机器上最终安装了各种协议的客户机程序,如 Telnet、FTP、Gopher 目录服务、新闻组新闻组,以及万维网。(Unix 用户通常会发现,当他们第一次登录到一台配置良好的机器时,已经安装了每个基本协议的客户端,尽管他们可能会选择安装一些更高级的替代程序,如ncftp来代替笨重的默认 FTP 客户端。)

但是,再也不会了!如今,普通的互联网用户只知道一个客户端:他们的网络浏览器。由于网页现在可以在用户点击键盘时使用 JavaScript 来响应和重绘自己,网络不仅取代了所有传统的互联网协议——用户在网页上浏览和获取文件,而不是通过 FTP 他们阅读留言板,而不是连接到新闻组——但这也消除了对许多传统桌面客户端的需求。如果你的应用是可以通过交互式网页提供的,为什么要说服成千上万的用户下载并安装一个新的电子邮件客户端,点击几个关于你的软件可能如何损害他们的计算机的警告呢?

事实上,网络浏览器已经变得如此卓越,以至于许多互联网用户甚至没有意识到他们已经有了一个网络浏览器。因此,他们交替使用“互联网”和“网络”这两个词,他们认为这两个词都指“所有给我脸书、YouTube 和维基百科的文件和链接”这种对他们正在通过某个特定的有名字和身份的客户端程序——比如说,通过 Internet Explorer 的窗格——来查看 Web 荣耀的事实的忽视,是 Firefox、Google Chrome 和 Opera 等替代产品的传播者的一个持续挫折,他们发现很难说服人们改变他们甚至没有意识到他们正在使用的程序!

显然,如果这样的用户要阅读电子邮件,就必须在网页上呈现给他们,他们在网页上阅读收到的电子邮件,将其分类到文件夹中,并撰写和发送回复。因此有许多网站通过浏览器提供电子邮件服务——Gmail 和 Yahoo!邮件是最受欢迎的,还有服务器软件,如流行的 SquirrelMail,如果系统管理员想为学校或公司的用户提供网络邮件,他们可以安装这些软件。

这种转变对电子邮件协议和网络意味着什么?有趣的是,网络邮件现象本质上把我们从过去的简单日子带回到过去,那时电子邮件提交和电子邮件阅读是私人事务,局限于单个主机服务器,通常根本不涉及使用公共协议。当然,这些现代服务,尤其是由大型互联网服务提供商以及谷歌和雅虎这样的公司运营的服务。必须是庞大的事务,涉及分布在世界各地的数百台服务器;因此,毫无疑问,网络协议涉及到电子邮件存储和检索的每一个层面。

但问题是,这些现在是私人交易,在运行网络邮件服务的组织内部进行。你在网络浏览器中浏览电子邮件;你用同样的界面写电子邮件;当你点击发送时,谁知道谷歌或雅虎用的是什么协议呢?在内部使用将新消息从接收您的 HTTP POST 的 web 服务器传递到邮件队列,并从该队列中进行传递。可能是 SMTP 它可以是内部 RPC 协议;或者它甚至可以是 web 和电子邮件服务器都连接到的公共文件系统上的操作。

就本书的目的而言,重要的是,除非你是在这样一个机构工作的工程师,否则你永远也不会看到在你用来操纵你的信息的网络邮件界面背后,是 POP、IMAP 还是其他什么东西在起作用。

因此,电子邮件的浏览和提交变成了一个黑匣子:您的浏览器与 web API 交互,而在另一端,当电子邮件在各个方向传递时,您将看到普通的旧式 SMTP 连接从大型组织发出并发往该组织。但是在 webmail 的世界中,客户端协议被从等式中删除,将我们带回到纯服务器到服务器的未经认证的 SMTP 的旧时代。

如何使用 SMTP

前面的叙述有望帮助你构建关于互联网电子邮件协议的思维。幸运的话,它还帮助你认识到它们如何在更大的范围内相互配合,从用户那里获取信息。

然而,这一章的主题是一个比较狭窄的主题——简单邮件传输协议。首先,我会用你在本书第一部分学到的术语来陈述基础知识:

  • SMTP 是基于 TCP/IP 的协议。
  • 连接可以经过身份验证,也可以不经过身份验证。
  • 连接可以加密,也可以不加密。

如今,互联网上的大多数电子邮件连接似乎缺乏任何加密尝试,这意味着无论谁拥有互联网主干路由器,理论上都能够读取他人数量惊人的电子邮件。根据上一节的讨论,使用 SMTP 的两种方式是什么?

首先,SMTP 可以用于像 Thunderbird 或 Outlook 这样的客户端电子邮件程序和已经给了用户电子邮件地址的组织的服务器之间的电子邮件提交。这些连接通常使用身份验证,因此垃圾邮件发送者无法在没有密码的情况下代表用户连接和发送数百万条消息。一旦接收到一条消息,服务器就把它放在队列中等待发送,这样电子邮件客户端就可以忘记这条消息,并认为服务器会继续尝试发送这条消息。

第二,SMTP 用于互联网电子邮件服务器之间的*,因为它们将电子邮件从其起点移动到目的地。这通常不涉及身份验证;毕竟,像谷歌、雅虎这样的大公司。,而微软不知道对方用户的密码,所以当雅虎!收到一封来自 Google 的电子邮件,声称它是由一个@gmail.com用户 Yahoo!只要相信他们(或者不信——有时如果太多的垃圾邮件通过他们的服务器,组织会将彼此列入黑名单。当 Hotmail 的电子邮件服务器因为所谓的垃圾邮件问题而停止接受来自 GoDaddy 服务器的电子邮件简讯时,我的一个朋友就遇到了这种情况。*

因此,通常在使用 SMTP 的服务器之间不会进行身份验证——甚至很少使用针对窥探路由器的加密。

由于垃圾邮件发送者连接到电子邮件服务器并声称从另一个组织的用户发送电子邮件的问题,已经尝试锁定哪些特定的服务器可以代表一个组织发送电子邮件。尽管有争议,但一些电子邮件服务器参考 RFC 4408 中定义的发件人策略框架(SPF) ,以查看它们与之对话的服务器是否真的有权传递它正在传输的电子邮件。

让我们转到技术问题,即如何在 Python 程序中实际使用 SMTP。图 13-1 提供了一个 Python 驱动的 SMTP 会话的例子。

9781430258544_Fig13-01.jpg

图 13-1 。Python 驱动的 SMTP 会话示例

发送电子邮件

在分享 SMTP 协议的本质细节之前,有一个警告是适当的:如果您正在编写一个需要发送电子邮件的交互式程序、守护程序或网站,那么您的站点或系统管理员(在不是您的情况下)可能会对您的程序如何发送电子邮件有意见,他们这样做可能会为您节省很多工作!

如前所述,成功发送电子邮件通常需要一个队列,在该队列中,消息可以停留几秒、几分钟甚至几天,直到它可以成功地传输到其目的地。因此,你通常希望你的前端程序使用 Python 的smtplib将电子邮件直接发送到消息的目的地,因为如果你的第一次传输尝试失败了,那么你将不得不写一个完整的邮件传输代理(MTA ),因为 RFC 调用电子邮件服务器,并给它一个完整的符合标准的重试队列。这不仅是一项艰巨的工作,而且也是一项已经做得很好的工作,在尝试自己写东西之前,明智的做法是利用现有的 MTA 之一(看看postfixeximqmail)。

您很少会通过 Python 与外界建立 SMTP 连接。更常见的情况是,您的系统管理员会告诉您两件事情中的一件:

  • 您应该使用属于您的应用的用户名和密码,与您组织中已经存在的电子邮件服务器建立一个经过身份验证的 SMTP 连接。
  • 你应该在系统上运行一个本地二进制程序——比如sendmail程序——系统管理员已经进行了配置,以便本地程序可以发送电子邮件。

Python 库 FAQ 有调用一个sendmail兼容程序的示例代码。看看“如何从 Python 脚本发送邮件”一节。发现于http://docs.python.org/faq/library.html

因为这本书是关于网络的,所以我不会详细讨论这种可能性。然而,记住,当你的机器上没有更简单的发送电子邮件的机制时,你自己只做原始的 SMTP。

标题和信封收件人

SMTP 中的一个关键概念一直让初学者感到困惑,那就是你所熟悉的收件人标题——To、Cc(抄送)和 Bcc(密件抄送)——并没有被 SMTP 协议咨询来决定你的电子邮件的去向。这让许多用户感到惊讶。毕竟,几乎所有现存的电子邮件程序都要求你填写收件人字段,当你点击发送时,邮件就会飞向这些邮箱。还有什么比这更自然的呢?但事实证明,这是电子邮件客户端本身的特性,而不是 SMTP 协议的特性:协议只知道每封邮件周围都有一个“信封”,标明了发件人和一些收件人。SMTP 本身并不关心这些名称是否是它可以在邮件头中找到的名称。

如果你考虑一下密件抄送标题,你会发现电子邮件必须以这种方式工作。与“收件人”和“抄送”标题不同,“密件抄送”标题可以将邮件发送到目的地,并让每个收件人看到其他收件人,而“密件抄送”标题可以在其他收件人不知道的情况下指定您希望接收电子邮件的人。密件可以让您悄悄地将邮件传递给某人,而不会引起其他收件人的注意。

像“密件抄送”这样的邮件头在您撰写邮件时可能会出现,但实际上不会包含在外发邮件中,这种邮件头的存在提出了两点:

  • 您的电子邮件客户端会在发送邮件前编辑邮件标题。除了删除“密件抄送”邮件头以使电子邮件的收件人无法获得其副本之外,客户端通常还会添加邮件头,例如唯一的邮件 ID,可能还有电子邮件客户端本身的名称(例如,我刚刚在桌面上收到的一封电子邮件,将发送它的 X-Mailer 标识为 YahooMailClassic)。
  • 一封电子邮件可以通过 SMTP 到达一个在邮件头或正文中没有提到的目的地址(?? )--------------------------------------------------------------------------------它可以出于最合理的理由这样做。

这种机制也有助于支持电子邮件列表,这样,To 行显示为advocacy@python.org的电子邮件就可以实际发送给订阅该列表的数十或数百人,而不需要向列表的每个读者公开他们的所有电子邮件地址。

因此,当您阅读下面对 SMTP 的描述时,请时刻提醒自己,构成电子邮件本身的标题加正文与协议描述中提到的“信封发送者”和“信封接收者”是分开的。没错,你的电子邮件客户端,不管你用的是/usr/sbin/sendmail还是雷鸟或者谷歌邮箱,很可能只向你要过一次收件人的邮箱地址;但是它接着在两个不同的地方使用了它:一次是在邮件顶部的“收件人”标题中,另一次是在邮件的“外部”,当它使用 SMTP 来发送邮件时。

多跳

曾几何时,电子邮件通常只通过一个 SMTP“跳”在主机和存储收件人收件箱的机器之间传递。如今,信息在到达目的地之前,通常要经过六七台或更多的服务器。这意味着上一节中描述的 SMTP 信封收件人会随着邮件接近其目的地而不断变化。

举个例子应该能说明这一点。以下几个细节是虚构的,但它们应该能让您很好地了解消息实际上是如何在互联网上传输的。

想象一下,佐治亚理工学院中央 IT 部门的一名员工告诉他的朋友他的电子邮件地址是brandon@gatech.edu。当朋友后来给他发消息时,朋友的电子邮件提供商会在域名服务(DNS;参见第四章,接收一系列 MX 记录作为回复,并连接到其中一个 IP 地址来传递消息。很简单,对吧?

但是gatech.edu的服务器服务于整个校园!为了找出brandon在哪里,它查询一个表,找到他的部门,并得知他的官方电子邮件地址实际上是:

brandon.rhodes@oit.gatech.edu

因此,gatech.edu服务器依次对oit.gatech.edu进行 DNS 查询,然后使用 SMTP——消息的第二个 SMTP 跳,如果你算的话——将消息发送到 OIT 的电子邮件服务器,信息技术办公室。

但是 OIT 很久以前就放弃了他们的单服务器解决方案,该方案将所有电子邮件保存在一台 Unix 服务器上。相反,他们现在运行一个复杂的电子邮件解决方案,用户可以通过 webmail、POP 和 IMAP 访问它。到达oit.gatech.edu的电子邮件首先被随机发送到几个垃圾邮件过滤服务器中的一个(第三跳),比如说名为spam3.oit.gatech.edu的服务器。然后,如果它通过了垃圾邮件检查并且没有被丢弃,它会被随机地发送到八个冗余的电子邮件服务器中的一个,这样在第四次跳跃之后,邮件就在mail7.oit.gatech.edu的队列中了。

mail7这样的路由服务器随后可以查询中央目录服务,以确定哪些连接到大型磁盘阵列的后端邮件存储托管哪些用户的邮箱。因此mail7brandon.rhodes进行 LDAP 查找,得出的结论是他的电子邮件保存在anvil.oit.gatech.edu服务器上,在第五次也是最后一次 SMTP 跳跃中,电子邮件被发送到anvil并被写入其冗余磁盘阵列。

这就是为什么电子邮件通常至少需要几秒钟才能通过互联网:大型组织和大型互联网服务提供商往往有几级服务器,邮件在发送前必须经过这些服务器的协商。

你如何调查一封电子邮件的路径?前面已经强调过,SMTP 协议不读取电子邮件标题,但是它自己知道消息应该去往哪里——正如您刚才看到的,它可以随着消息到达目的地的每一跳而改变。但是事实证明,电子邮件服务器被鼓励添加新的标题,精确地跟踪信息从其起点到目的地的迂回路线。

这些标题被称为 Received 标题,对于试图调试电子邮件系统问题的困惑的系统管理员来说,它们是一座金矿。看看任何一封电子邮件,让你的电子邮件客户端显示所有的邮件头。您应该能够看到消息到达目的地的每一步。(垃圾邮件发送者通常在邮件的顶部写几个虚构的 Received 标头,使邮件看起来像是来自一个声誉良好的组织。)最后,当链中的最后一个服务器最终能够成功地将消息写入某人邮箱中的物理存储时,可能会写入一个 Delivered-to 标头。

因为每个服务器倾向于将其接收的报头添加到电子邮件消息的顶部,这节省了时间,并且避免了每个服务器必须搜索到目前已经写入的接收的报头的底部。你应该倒着读:最早收到的邮件头会列在最后,所以当你在屏幕上向上读的时候,你会跟着邮件从起点到终点。试试看:调出您最近收到的一封电子邮件,选择它的“查看所有邮件标题”或“显示原始邮件”选项,并在靠近顶部的位置查找已收到的邮件标题。邮件到达收件箱所需的步骤比您预期的多还是少?

SMTP 库简介

Python 的内置 SMTP 实现在 Python 标准库模块smtplib中,这使得用 SMTP 做简单的任务变得很容易。

在下面的例子中,程序被设计成接受几个命令行参数:SMTP 服务器的名称、发件人地址和一个或多个收件人地址。请谨慎使用;只说出一个你自己运行的或者你知道会乐意接收你的测试信息的 SMTP 服务器,以免你的 IP 地址因为发送垃圾邮件而被禁止!

如果你不知道在哪里可以找到 SMTP 服务器,你可以试着在本地运行一个电子邮件守护进程,比如postfixexim,然后将这些示例程序指向localhost。一些 UNIX、Linux 和 Mac OS X 系统有一个这样的 SMTP 服务器,已经在监听来自本地机器的连接。

否则,请咨询您的网络管理员或互联网提供商以获得正确的主机名和端口。请注意,您通常不能随意选择电子邮件服务器;许多仅存储或转发来自特定授权客户的电子邮件。

解决了这个问题,您就可以继续看清单 13-1 了,它展示了一个非常简单的 SMTP 程序。

清单 13-1 。使用smtplib.sendmail()发送电子邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/simple.py

import sys, smtplib

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the simple.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("usage: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    connection = smtplib.SMTP(server)
    connection.sendmail(fromaddr, toaddrs, message)
    connection.quit()

    s = '' if len(toaddrs) == 1 else 's'
    print("Message sent to {} recipient{}".format(len(toaddrs), s))

if __name__ == '__main__':
    main()

这个程序非常简单,因为它使用了 Python 标准库中一个非常强大的通用函数。它首先从用户的命令行参数生成一条简单的消息(关于生成包含简单纯文本之外的元素的花哨消息的详细信息,参见第十二章)。然后它创建一个连接到指定服务器的smtplib.SMTP对象。最后,所需要的就是调用sendmail()。如果成功返回,那么您就知道电子邮件服务器已经无误地接受了邮件。

正如本章前面提到的,你可以看到谁接收消息的概念——“信封收件人”——在这个层次上,与消息的实际文本是分开的。这个特定的程序写入一个 To 头,这个头恰好包含它发送消息的相同地址;但是 To 头只是一段文本,它可以表示其他任何内容。(收件人的电子邮件客户端是否愿意显示“其他任何内容”,或者是否会导致服务器将该邮件作为垃圾邮件丢弃,这是另一个问题!)

如果你从书的网络操场内部运行程序,它应该能够成功地像这样连接:

$ python3 simple.py mail.example.com sender@example.com recipient@example.com
Message successfully sent to 1 recipient

感谢 Python 标准库的作者为sendmail()方法付出的努力,这可能是您需要的唯一一个 SMTP 调用!但是,为了理解传递消息所采取的步骤,让我们更详细地研究一下 SMTP 是如何工作的。

错误处理和对话调试

使用smtplib编程时,可能会出现几种不同的异常。它们是:

  • socket.gaierror查找地址信息时出现错误
  • socket.error对于一般网络和通信问题
  • socket.herror对于其他寻址错误
  • smtplib.SMTPException或它的一个子类,用于 SMTP 会话问题

前三个错误在第三章中有更详细的介绍;它们在操作系统的 TCP 栈中被提出,被 Python 的网络代码检测并作为异常提出,然后直接通过smtplib模块到达你的程序。然而,只要底层 TCP 套接字工作,所有实际涉及 SMTP 电子邮件会话的问题都会导致一个smtplib.SMTPException

smtplib模块还提供了一种获取一系列关于发送电子邮件步骤的详细信息的方法。要启用该详细级别,您可以调用以下选项:

connection.set_debuglevel(1)

使用这个选项,您应该能够跟踪任何问题。请看一下清单 13-2 中的示例程序,它提供了基本的错误处理和调试。

清单 13-2 。更加谨慎的 SMTP 客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/debug.py

import sys, smtplib, socket

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the debug.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("usage: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    try:
        connection = smtplib.SMTP(server)
        connection.set_debuglevel(1)
        connection.sendmail(fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

if __name__ == '__main__':
    main()

这个程序看起来和上一个类似;但是,输出会很不一样。看一下清单 13-3 中的例子。

清单 13-3 。调试来自smtplib的输出

$ python3 debug.py mail.example.com sender@example.com recipient@example.com
send: 'ehlo [127.0.1.1]\r\n'
reply: b'250-guinness\r\n'
reply: b'250-SIZE 33554432\r\n'
reply: b'250 HELP\r\n'
reply: retcode (250); Msg: b'guinness\nSIZE 33554432\nHELP'
send: 'mail FROM:<sender@example.com> size=212\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
send: 'rcpt TO:<recipient@example.com>\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
send: 'data\r\n'
reply: b'354 End data with <CR><LF>.<CR><LF>\r\n'
reply: retcode (354); Msg: b'End data with <CR><LF>.<CR><LF>'
data: (354, b'End data with <CR><LF>.<CR><LF>')
send: b'To: recipient@example.com\r\nFrom: sender@example.com\r\nSubject: Test Message from simple.py\r\n\r\nHello,\r\n\r\nThis is a test message sent to you from the debug.py program\r\nin Foundations of Python Network Programming.\r\n.\r\n'
reply: b'250 OK\r\n'
reply: retcode (250); Msg: b'OK'
data: (250, b'OK')
send: 'quit\r\n'
reply: b'221 Bye\r\n'
reply: retcode (221); Msg: b'Bye'
Message sent to 1 recipient

从这个例子中,您可以看到smtplib正在通过网络与 SMTP 服务器进行对话。当您实现使用更高级 SMTP 特性的代码时,这里显示的细节将会更重要,所以让我们看看发生了什么。

首先,客户端(smtplib库)发送一个EHLO命令(一个更古老的命令的“扩展”后继命令,更容易理解的名字是HELO),其中包含您的主机名。远程服务器用自己的主机名进行响应,并列出它支持的任何可选 SMTP 功能。

接下来,客户端发送mail from命令,该命令说明了“信封发送者”的电子邮件地址和邮件的大小。这时的服务器有机会拒绝消息(比如因为它认为你是垃圾邮件发送者);但是在这种情况下,它用250 Ok来响应。(注意,在这种情况下,代码250是重要的;剩下的文本只是人们可读的注释,并且因服务器而异。)

然后客户端发送一个带有“信封接收者”的rcpt to命令,我在本章前面已经讨论过了。您最终可以看到,在使用 SMTP 协议时,它确实是与消息本身的文本分开传输的。如果您要将消息发送给多个收件人,他们将分别列在rcpt to行上。

最后,客户机发送一个data命令,传输实际的消息(您会注意到,按照互联网电子邮件标准,使用冗长的回车换行行尾),并结束对话。

在本例中,smtplib模块会自动为您完成所有这些工作。在本章的其余部分,我将解释如何更好地控制这个过程,以利用一些更高级的特性。

Image 注意不要有一种错误的自信感,因为在第一跳中没有检测到错误,所以你确信消息现在保证被传递。在许多情况下,电子邮件服务器可能会接受一封邮件,只是在稍后的时间传递失败。重读“多跳”一节,想象在示例消息到达目的地之前有多少失败的可能性!

EHLO 获取信息

有时,知道远程 SMTP 服务器将接受哪种类型的邮件是一件好事。例如,大多数 SMTP 服务器对它们允许的邮件大小有限制,如果您没有先检查,那么您可能会传输一个非常大的邮件,但当您完成传输时,它会被拒绝。

在最初的 SMTP 版本中,客户端会发送一个HELO命令作为对服务器的初始问候。SMTP 的一组扩展,称为 ESMTP,已经被开发出来以允许更强大的对话。ESMTP 感知的客户端将与EHLO开始对话,这将向 ESMTP 感知的服务器发出信号,它可以用扩展信息进行回复。该扩展信息包括最大邮件大小,以及服务器支持的任何可选 SMTP 功能。

但是,您必须小心检查返回代码。一些服务器不支持 ESMTP。在那些服务器上,EHLO只会返回一个错误。在这种情况下,您必须发送一个HELO命令。

在前面的例子中,我在创建 SMTP 对象后立即使用了sendmail(),因此smtplib自动向服务器发送自己的“hello”消息,为您启动对话。但是如果它看到你试图自己发送EHLOHELO命令,那么 Python 的sendmail()方法将不会试图自己发送 hello 命令。

清单 13-4 显示了一个从服务器获取最大大小的程序,如果消息太大,它会在发送前返回一个错误。

清单 13-4 。检查邮件大小限制

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/ehlo.py

import smtplib, socket, sys

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the ehlo.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("usage: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    try:
        connection = smtplib.SMTP(server)
        report_on_message_size(connection, fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

def report_on_message_size(connection, fromaddr, toaddrs, message):
    code = connection.ehlo()[0]
    uses_esmtp = (200 <= code <= 299)
    if not uses_esmtp:
        code = connection.helo()[0]
        if not (200 <= code <= 299):
            print("Remote server refused HELO; code:", code)
            sys.exit(1)

    if uses_esmtp and connection.has_extn('size'):
        print("Maximum message size is", connection.esmtp_features['size'])
        if len(message) > int(connection.esmtp_features['size']):
            print("Message too large; aborting.")
            sys.exit(1)

    connection.sendmail(fromaddr, toaddrs, message)

if __name__ == '__main__':
    main()

如果您运行此程序,并且远程服务器提供了它的最大消息大小,则该程序将在您的屏幕上显示该大小,并在发送之前验证它的消息没有超过该大小。(对于像这样的小消息,检查是相当愚蠢的,但是清单展示了可以成功用于大得多的消息的模式。)

下面是运行这个程序的样子:

$ python3 ehlo.py mail.example.com sender@example.com recipient@example.com
Maximum message size is 33554432
Message successfully sent to 1 recipient

看一下验证调用ehlo()helo()的结果的代码部分。这两个函数返回一个列表;列表中的第一项是来自远程 SMTP 服务器的数字结果代码。结果在 200 和 299 之间,包括 200 和 299,表示成功;其他一切都表明失败。因此,如果结果在该范围内,您就知道服务器正确地处理了消息。

Image 注意事项与之前相同的注意事项也适用于此。第一个 SMTP 服务器接受邮件的事实并不意味着它将被实际传递;较新的服务器可能有更严格的最大大小限制。

除了消息大小,其他 ESMTP 信息也是可用的。例如,如果提供了8BITMIME功能,一些服务器可能接受原始 8 位模式的数据。其他的可能支持加密,如下一节所述。有关 ESMTP 及其功能(可能因服务器而异)的更多信息,请参考 RFC 1869 或您自己的服务器文档。

使用安全套接字层和传输层实现安全

如前所述,通过 SMTP 以纯文本形式发送的电子邮件可以被任何人阅读,只要他能够访问数据包碰巧经过的互联网网关或路由器,包括咖啡店的无线网络,您的电子邮件客户端可能试图从该网络发送邮件。这个问题的最佳解决方案是用一个公钥加密每封电子邮件,该公钥的私钥只由您要向其发送电子邮件的人拥有;有像 GNU 隐私卫士这样的免费系统可以做到这一点。但是不管消息本身是否受到保护,特定机器对之间的单独 SMTP 对话可以使用 SSL/TLS 进行加密和认证,如第六章中介绍的那样。在本节中,您将了解 SSL/TLS 如何适应 SMTP 对话。

请记住,TLS 只保护选择使用它的 SMTP“跃点”——即使您小心地使用 TLS 向服务器发送电子邮件,如果该服务器必须将您的电子邮件通过另一个跃点转发到其目的地,您也无法控制该服务器是否再次使用 TLS。

在 SMTP 中使用 TLS 的一般过程如下:

  1. 照常创建 SMTP 对象。
  2. 发送EHLO命令。如果远程服务器不支持EHLO,那么它将不会支持 TLS。
  3. 检查s.has_extn()以查看starttls是否存在。如果不支持,则远程服务器不支持 TLS,消息只能以明文形式正常发送。
  4. 构建一个 SSL 上下文对象来验证服务器的身份。
  5. 调用starttls()启动加密通道。
  6. 第二次呼叫ehlo();这次,它被加密了。
  7. 最后,发送您的消息。

使用 TLS 时,您必须问自己的第一个问题是,如果 TLS 不可用,您是否应该返回一个错误。根据您的应用,您可能希望在下列任何情况下引发错误:

  • 远程端不支持 TLS。
  • 远程端无法正确建立 TLS 会话。
  • 远程服务器提供了一个无法验证的证书。

让我们逐一查看这些场景,看看它们何时应该出现错误消息。

首先,有时将缺乏对 TLS 的支持完全视为一种错误是恰当的。如果您正在编写一个只与有限的一组电子邮件服务器通信的应用,可能会出现这种情况,这些服务器可能是由您的公司运行的、您知道应该支持 TLS 的电子邮件服务器,或者是由您知道支持 TLS 的机构运行的电子邮件服务器。

因为今天互联网上只有少数电子邮件服务器支持 TLS,所以一般来说,电子邮件程序不应该将它的缺失视为错误。许多支持 TLS 的 SMTP 客户端将使用 TLS(如果可用的话),但在其他情况下将依靠标准的、不安全的传输。这就是所谓的机会加密,它没有强制加密所有通信安全,但它可以在有能力时保护消息。

其次,有时远程服务器声称知道 TLS,但却无法正确建立 TLS 连接。这通常是由于服务器端的配置错误。为了尽可能地健壮,您可能希望通过一个您甚至没有尝试加密的新连接来重试到这样一个服务器的失败的加密传输。

第三种情况是,您无法完全认证远程服务器。同样,关于对等验证的完整讨论,请参见第六章。如果您的安全策略规定您必须只与受信任的服务器交换电子邮件,那么缺少身份验证显然是一个问题,需要一个错误消息。

清单 13-5 充当一个支持 TLS 的通用客户端。如果可以的话,它将连接到服务器并使用 TLS 否则,它将退回并照常发送消息。如果在与表面上有能力的服务器对话时启动 TLS 的尝试失败,它报错而死。

清单 13-5 。机会主义地使用 TLS

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/tls.py

import sys, smtplib, socket, ssl

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the tls.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("Syntax: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    try:
        connection = smtplib.SMTP(server)
        send_message_securely(connection, fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

def send_message_securely(connection, fromaddr, toaddrs, message):
    code = connection.ehlo()[0]
    uses_esmtp = (200 <= code <= 299)
    if not uses_esmtp:
        code = connection.helo()[0]
        if not (200 <= code <= 299):
            print("Remove server refused HELO; code:", code)
            sys.exit(1)

    if uses_esmtp and connection.has_extn('starttls'):
        print("Negotiating TLS....")
        context = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
        context.set_default_verify_paths()
        context.verify_mode = ssl.CERT_REQUIRED
        connection.starttls(context=context)
        code = connection.ehlo()[0]
        if not (200 <= code <= 299):
            print("Couldn't EHLO after STARTTLS")
            sys.exit(5)
        print("Using TLS connection.")
    else:
        print("Server does not support TLS; using normal connection.")

    connection.sendmail(fromaddr, toaddrs, message)

if __name__ == '__main__':
    main()

注意,不管是否使用了 TLS,最后几个清单中对sendmail()的调用是相同的。一旦启动了 TLS,系统就会对您隐藏这一层复杂性,因此您无需担心。

经过验证的 SMTP

最后,还有认证 SMTP 的话题,在他们允许你发送电子邮件之前,你的 ISP、大学或公司的电子邮件服务器需要你用用户名和密码登录,以证明你不是垃圾邮件发送者。

为了获得最大的安全性,TLS 应该与身份验证结合使用;否则,任何观察连接的人都可以看到您的密码(和用户名)。正确的做法是首先建立 TLS 连接,然后只通过加密的通信信道发送您的身份验证信息。

认证本身很简单;smtplib提供了一个接受用户名和密码的login()函数。清单 13-6 显示了一个例子。为了避免重复前面清单中已经显示过的代码,这个清单没有而不是采纳上一段中提供的建议,它通过一个未经认证的连接发送用户名和密码,这个连接将明文发送它们。

清单 13-6 。通过 SMTP 认证

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter13/login.py

import sys, smtplib, socket
from getpass import getpass

message_template = """To: {}
From: {}
Subject: Test Message from simple.py

Hello,

This is a test message sent to you from the login.py program
in Foundations of Python Network Programming.
"""

def main():
    if len(sys.argv) < 4:
        name = sys.argv[0]
        print("Syntax: {} server fromaddr toaddr [toaddr...]".format(name))
        sys.exit(2)

    server, fromaddr, toaddrs = sys.argv[1], sys.argv[2], sys.argv[3:]
    message = message_template.format(', '.join(toaddrs), fromaddr)

    username = input("Enter username: ")
    password = getpass("Enter password: ")

    try:
        connection = smtplib.SMTP(server)
        try:
            connection.login(username, password)
        except smtplib.SMTPException as e:
            print("Authentication failed:", e)
            sys.exit(1)
        connection.sendmail(fromaddr, toaddrs, message)
    except (socket.gaierror, socket.error, socket.herror,
            smtplib.SMTPException) as e:
        print("Your message may not have been sent!")
        print(e)
        sys.exit(1)
    else:
        s = '' if len(toaddrs) == 1 else 's'
        print("Message sent to {} recipient{}".format(len(toaddrs), s))
        connection.quit()

if __name__ == '__main__':
    main()

Internet 上的大多数发送电子邮件服务器不支持身份验证。如果您使用的服务器不支持认证,您将会收到来自login()尝试的认证失败错误消息。如果远程服务器支持 ESMTP,你可以通过调用connection.ehlo()后检查connection.has_extn('auth')来防止这种情况。

您可以像前面的例子一样运行这个程序。如果在支持身份验证的服务器上运行,系统会提示您输入用户名和密码。如果它们被接受,那么程序将继续传送你的信息。

SMTP 提示

以下是一些帮助您实现 SMTP 客户端的提示:

  • 没有办法保证消息被传递。有时,您会立即知道您的尝试失败了,但是没有错误并不意味着在消息被安全地传递给接收者之前,其他事情不会出错。
  • 如果的任何一个接收者失败了,那么sendmail()函数会引发一个异常,尽管消息可能仍然被发送给了其他接收者。查看您返回的异常以了解更多详细信息。如果知道哪些地址失败的细节对您来说非常重要——比方说,因为您希望稍后尝试重新传输,而不为已经收到该消息的人制作副本——您可能需要为每个收件人单独调用sendmail()。但是,请注意,这种更简单的方法会导致消息体被多次传输,每个收件人一次。
  • 没有证书验证,SSL/TLS 是不安全的:在验证发生之前,您可以与任何临时控制标准服务器 IP 地址的旧服务器进行对话。为了支持证书验证,请记住创建一个 SSL 上下文对象,如 TLS 前面的示例所示,并将其作为唯一的参数提供给starttls()
  • Python 的smtplib并不意味着是一个通用的电子邮件中继。相反,您应该使用它将消息发送到您附近的 SMTP 服务器,该服务器将处理电子邮件的实际传递。

摘要

SMTP 用于将电子邮件传输到电子邮件服务器。Python 提供了smtplib模块供 SMTP 客户端使用。通过调用 SMTP 对象的sendmail()方法,可以传输消息。指定消息的实际接收者的唯一方法是使用sendmail()的参数;邮件正文中的“收件人”、“抄送”和“密件抄送”邮件头与实际的收件人列表是分开的。

SMTP 会话期间可能会引发几种不同的异常。交互程序应该适当地检查和处理它们。

ESMTP 是 SMTP 的扩展。它允许您在传输邮件之前发现远程 SMTP 服务器支持的最大邮件大小。ESMTP 也允许 TLS,这是一种加密你与远程服务器对话的方法。TLS 的基础知识包含在第六章中。

一些 SMTP 服务器需要身份验证。可以用login()方法认证。SMTP】不提供从邮箱下载邮件到自己电脑的功能。为此,您将需要接下来两章中讨论的协议。第十四章中讨论的 POP 是下载信息的一种简单方式。在第十五章中讨论的 IMAP 是一个更强大的协议。

十四、POP

邮局协议 POP ,是一个从服务器下载电子邮件的简单协议。它通常通过电子邮件客户端使用,如 Thunderbird 或 Outlook。如果你想了解电子邮件客户端和像 POP 这样的协议在互联网电子邮件历史中的位置,你可以重读第十三章的前几节。

如果你很想用 POP,那么你应该考虑用 IMAP 来代替;第十五章将解释 IMAP 提供的特性,这些特性使它成为比 POP 支持的原始操作更坚实的远程电子邮件访问基础。

POP 最常见的实现是版本 3,通常称为 POP3。因为版本 3 是如此占主导地位,POP 和 POP3 这两个术语在今天实际上是可以互换的。

POP 的主要优点——也是它最大的缺点——是它的简单。如果您只是需要访问远程邮箱,下载任何新出现的电子邮件,并在下载后选择删除电子邮件,那么 POP 将是您的完美选择。您将能够快速完成这项任务,无需复杂的代码。

但是下载和删除几乎是 POP 的全部功能。它不支持远程端的多个邮箱,也不提供任何可靠、持久的消息标识。这意味着您不能使用 POP 作为电子邮件同步的协议,即您将每封电子邮件的原始副本留在服务器上,同时制作一份副本供本地读取,因为当您稍后返回到服务器时,您无法轻易辨别您已经下载了哪些邮件。如果你需要这个功能,你应该看看 IMAP,这将在第十五章中介绍。

Python 标准库提供了poplib模块,为使用 POP 提供了便捷的接口。本章将解释如何使用poplib连接到 POP 服务器,收集关于邮箱的摘要信息,下载邮件,以及从服务器上删除邮件原件。一旦您知道如何完成这四项任务,您将涵盖所有标准的 POP 功能!

请注意,Python 标准库不提供充当 POP 服务器的功能,而只提供客户端功能。如果您需要实现一个服务器,您将需要找到一个提供 POP 服务器功能的第三方 Python 包。

POP 服务器兼容性

众所周知,POP 服务器不遵守标准。对于一些 POP 行为来说,标准也根本不存在,把细节留给了服务器软件的作者。因此,虽然基本的操作通常可以正常工作,但某些行为确实会因服务器而异。

例如,一些服务器会在您连接到服务器时将您的所有邮件标记为已读——无论您是否下载了任何邮件!其他服务器会在下载邮件时将其标记为只读。有些服务器根本不会将任何邮件标记为已读。标准本身似乎假设了后一种行为,但两者都不清楚。阅读本章时,请记住这些不同之处。

图 14-1 展示了一个由 Python 驱动的非常简单的 POP 对话。

9781430258544_Fig14-01.jpg

图 14-1 。使用 POP 的简单对话

连接和认证

POP 支持多种身份验证方法。最常见的两种是基本用户名-密码验证和 APOP,后者是 POP 的可选扩展,如果您使用的是不支持 SSL 的老式 POP 服务器,它有助于防止密码以纯文本形式发送。

Python 中连接和验证远程服务器的过程如下所示:

  1. 创建一个POP3_SSL或者只是一个普通的POP3对象,并将远程主机名和端口传递给它。
  2. 调用user()pass_()发送用户名和密码。注意pass_()中的下划线!它之所以存在,是因为pass是 Python 中的一个关键字,不能用于方法名。
  3. 如果引发异常poplib.error_proto,则意味着登录失败,异常的字符串值包含服务器发送的错误解释。

POP3POP3_SSL之间的选择取决于你的电子邮件提供商是否提供——或者,在这个时代,甚至要求——你通过加密连接进行连接。查阅第六章以获得更多关于 SSL 的信息,但是一般规则应该是只要可行就使用 SSL。

清单 14-1 使用上述步骤登录到远程 POP 服务器。一旦连接上,它就调用stat(),返回一个简单的元组,给出邮箱中的消息数量和消息的总大小。最后,程序调用quit(),关闭 POP 连接。

POP-3 协议

目的:允许从收件箱下载电子邮件

标准:RFC 1939(1996 年 5 月)

运行于:TCP/IP 之上

默认端口:110(明文),995 (SSL)

`库:弹出式菜单

清单 14-1 。非常简单的流行音乐会

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter14/popconn.py

import getpass, poplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        exit(2)

    hostname, username = sys.argv[1:]
    passwd = getpass.getpass()

    p = poplib.POP3_SSL(hostname)  # or "POP3" if SSL is not supported
    try:
        p.user(username)
        p.pass_(passwd)
    except poplib.error_proto as e:
        print("Login failed:", e)
    else:
        status = p.stat()
        print("You have %d messages totaling %d bytes" % status)
    finally:
        p.quit()

if __name__ == '__main__':
    main()

Image 注意虽然这个程序不会改变任何消息,但是一些 POP 服务器会仅仅因为你连接就改变邮箱标志。对实时邮箱运行本章中的示例可能会导致您丢失有关已读、未读、新邮件或旧邮件的信息。不幸的是,这种行为依赖于服务器,并且不受 POP 客户端的控制。我强烈建议对测试邮箱运行这些示例,而不是对您的真实邮箱!

这个程序有两个命令行参数:POP 服务器的主机名和用户名。如果您不知道此信息,请联系您的互联网提供商或网络管理员。请注意,在某些服务中,您的用户名将是一个普通字符串(如guido),而在其他服务中,它将是您的完整电子邮件地址(guido@example.com)。

然后,程序会提示您输入密码。最后,它将显示邮箱的状态,而不接触或改变你的任何邮件。

下面是你如何在 Mininet playground 中运行程序,你可以从本书的源代码库中下载(见第一章第一节):

$ python3 popconn.py mail.example.com brandon
Password: abc123
You have 3 messages totaling 5675 bytes

如果您看到这样的输出,那么您的第一次 POP 对话就成功了!

当 POP 服务器不支持 SSL 来保护您的连接免受窥探时,它们有时至少支持一种称为 APOP 的替代认证协议,该协议使用挑战-响应方案来确保您的密码不会以明文形式发送。(但是,您的所有电子邮件仍然会被任何看到数据包经过的第三方看到!)Python 标准库使这种尝试变得非常容易:只需调用apop()方法,然后如果您与之对话的 POP 服务器不理解,就退回到基本认证。

要使用 APOP,但回到简单的认证,您可以在您的 POP 程序中使用类似于清单 14-2 中所示的一段代码(如清单 14-1 中的)。

清单 14-2 。尝试 APOP 和后退

print("Attempting APOP authentication...")
try:
p.apop(user, passwd)
except poplib.error_proto:
print("Attempting standard authentication...")
try:
p.user(user)
p.pass_(passwd)
except poplib.error_proto as e:
print("Login failed:", e)
sys.exit(1)

Image 注意无论何种方式只要登录成功,一些较老的 POP 服务器就会锁定邮箱。锁定可能意味着不能对邮箱进行任何更改,甚至意味着在锁定解除之前不能再发送任何电子邮件。问题是一些 POP 服务器不能正确地检测错误,如果你没有调用quit()就挂断了连接,它们会无限期地锁定一个盒子。曾经世界上最流行的 POP 服务器就属于这一类!因此,在结束 POP 会话时,在 Python 程序中始终调用quit()是至关重要的。您会注意到,这里显示的所有程序清单总是小心翼翼地quit()到 Python 保证最后执行的finally块中。

获取邮箱信息

前面的例子向您展示了stat(),它返回邮箱中的邮件数量及其总大小。另一个有用的 POP 命令是list(),它返回每条消息的更多详细信息。

最有趣的部分是消息编号,稍后检索消息时需要用到它。请注意,在消息编号中可能会有间隙:例如,在给定时刻,一个邮箱可能只包含消息编号 1、2、5、6 和 9。此外,在您与 POP 服务器的每次连接中,分配给特定邮件的号码可能会有所不同。

清单 14-3 展示了如何使用list()命令来显示每条消息的信息。

清单 14-3 。使用POP list()命令

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter14/mailbox.py

import getpass, poplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        exit(2)

    hostname, username = sys.argv[1:]
    passwd = getpass.getpass()

    p = poplib.POP3_SSL(hostname)
    try:
        p.user(username)
        p.pass_(passwd)
    except poplib.error_proto as e:
        print("Login failed:", e)
    else:
        response, listings, octet_count = p.list()
        if not listings:
            print("No messages")
        for listing in listings:
            number, size = listing.decode('ascii').split()
            print("Message %s has %s bytes" % (number, size))
    finally:
        p.quit()

if __name__ == '__main__':
    main()

list()函数返回一个包含三项的元组。你一般应该注意第二项。这是目前我的一个 POP 邮箱的原始输出,其中有三条消息:

('+OK 3 messages (5675 bytes)', ['1 2395', '2 1626', '3 1654'], 24)

第二个项目中的三个字符串给出了收件箱中三封邮件的邮件号和大小。清单 14-3 中执行的简单解析让它以更漂亮的格式呈现输出。下面是你如何在部署在书的网络游乐场内部的 POP 服务器上运行它(见第一章):

$ python3 mailbox.py mail.example.com brandon
Password: abc123
Message 1 has 354 bytes
Message 2 has 442 bytes
Message 3 has 1173 bytes

下载和删除邮件

您现在应该已经掌握了 POP 的诀窍:当使用poplib时,您发出一些小的原子命令,这些命令总是返回一个元组,元组内部是各种字符串和字符串列表,向您显示结果。您现在实际上已经准备好处理消息了!三种相关方法都使用由list()返回的相同整数标识符来标识消息,如下所示:

  • retr(num) :该方法下载一条消息,并返回一个包含结果代码和消息本身的元组,以行列表的形式传递。这将导致大多数 POP 服务器将该邮件的“已读”标志设置为“真”,禁止您再次从 POP 中看到该邮件(除非您有其他方法进入您的邮箱,让您将邮件设置回“未读”)。
  • top(num, body_lines) :该方法以与retr()相同的格式返回结果,而不将消息标记为“已看到”但是它并没有返回整个消息,而是只返回标题以及您在body_lines中请求的正文的行数。如果您想让用户决定下载哪些邮件,这对于预览邮件很有用。
  • dele(num) :该方法将消息标记为从 POP 服务器中删除,在您退出 POP 会话时发生。通常,只有当用户直接请求不可撤销地销毁消息时,或者如果您已经将消息存储到冗余存储中(并且可能已经备份了它),并且已经使用类似于fsync()的东西来确保数据确实已经被写入,您才会这样做,因为您将再也无法从服务器中检索消息。

为了把所有的东西放在一起,看一下清单 14-4 ,这是一个功能相当强大的电子邮件客户端,说 POP!它检查你的收件箱,以确定有多少信息,并学习他们的号码;然后它使用top()来提供每一个的预览;而且,根据用户的选择,它可以检索整个邮件并将其从邮箱中删除。

清单 14-4 。一个简单的 POP 电子邮件阅读器

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter14/download-and-delete.py

import email, getpass, poplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        exit(2)

    hostname, username = sys.argv[1:]
    passwd = getpass.getpass()

    p = poplib.POP3_SSL(hostname)
    try:
        p.user(username)
        p.pass_(passwd)
    except poplib.error_proto as e:
        print("Login failed:", e)
    else:
        visit_all_listings(p)
    finally:
        p.quit()

def visit_all_listings(p):
    response, listings, octets = p.list()
    for listing in listings:
        visit_listing(p, listing)

def visit_listing(p, listing):
    number, size = listing.decode('ascii').split()
    print('Message', number, '(size is', size, 'bytes):')
    print()
    response, lines, octets = p.top(number, 0)
    document = '\n'.join(line.decode('ascii') for line in lines)
    message = email.message_from_string(document)
    for header in 'From', 'To', 'Subject', 'Date':
        if header in message:
            print(header + ':', message[header])
    print()
    print('Read this message [ny]?')
    answer = input()
    if answer.lower().startswith('y'):
        response, lines, octets = p.retr(number)
        document = '\n'.join(line.decode('ascii') for line in lines)
        message = email.message_from_string(document)
        print('-'72)
        for part in message.walk():
            if part.get_content_type() == 'text/plain':
                print(part.get_payload())
                print('-'72)
    print()
    print('Delete this message [ny]?')
    answer = input()
    if answer.lower().startswith('y'):
        p.dele(number)
        print('Deleted.')

if __name__ == '__main__':
    main()

你会注意到清单使用了在第十二章的中引入的email模块,这是一个很大的优势,因为即使是带有 HTML 和图像的花哨的现代 MIME 电子邮件通常也有一个text/plain部分,email模块可以代表这样一个简单的程序提取该部分以打印到屏幕上。

如果你在书中的网络游戏中运行这个程序(参见第一章,你会看到类似下面的输出:

$ python3 download-and-delete.py mail.example.com brandon
password: abc123
Message 1 (size is 354 bytes):

From: Administrator <admin@mail.example.com>
To: Brandon <brandon@mail.example.com>
Subject: Welcome to example.com!

Read this message [ny]? y
------------------------------------------------------------------------
We are happy that you have chosen to use example.com's industry-leading
Internet e-mail service and we hope that you experience is a pleasant
one.  If you ever need your password reset, simply contact our staff!

- example.com
------------------------------------------------------------------------

Delete this message [ny]? y
Deleted.

摘要

POP 提供了一种下载存储在远程服务器上的电子邮件的简单方法。使用 Python 的poplib接口,您可以获得关于邮箱中邮件数量和每封邮件大小的信息。您也可以按号码检索或删除单个留言。

连接到 POP 服务器可能会锁定邮箱。因此,尽量缩短 POP 会议,并在会议结束后打电话给quit()是很重要的。

只要有可能,POP 应该与 SSL 一起使用,以保护您的密码和电子邮件的内容。在没有 SSL 的情况下,尝试至少使用 APOP;只有在你迫切需要使用 POP 并且没有更好的选项可用的可怕情况下,才发送你的密码。

尽管 POP 是一种简单且广泛使用的协议,但它有许多缺点,不适合某些应用。例如,它只能访问一个文件夹,并且不提供对单个邮件的持久跟踪。

下一章将讨论 IMAP,这是一个提供 POP 特性和许多新特性的协议。`

十五、IMAP

乍一看,互联网消息访问协议(IMAP)类似于第十四章中描述的 POP 协议。另外,如果你读了《??》第十三章第三部分的第一部分,它提供了电子邮件如何在互联网上传输的全貌,你就会知道这两种协议扮演了一个非常相似的角色:POP 和 IMAP 是笔记本电脑或台式电脑连接到远程互联网服务器查看和处理用户电子邮件的两种方式。

相似之处到此为止。尽管 POP 的功能相当贫乏——用户可以将新邮件下载到他们的个人电脑上——但 IMAP 协议提供了如此全面的功能,许多用户将他们的电子邮件分类并永久存档在服务器上,使其免受笔记本电脑或台式机硬盘崩溃的影响。IMAP 优于 POP 的优势包括:

  • 邮件可以被分类到几个文件夹中,而不是放在一个收件箱里。
  • 每封邮件都支持标志,如“已读”、“已回复”、“已查看”和“已删除”
  • 可以在服务器上搜索邮件中的文本字符串,而不必下载每一个。
  • 本地存储的消息可以直接上传到其中一个远程文件夹。
  • 维护持久的唯一消息编号,使得本地消息存储库和服务器上保存的消息之间的健壮同步成为可能。
  • 文件夹可以与其他用户共享,也可以标记为只读。
  • 一些 IMAP 服务器可以显示非邮件源,如新闻组新闻组,就好像它们是电子邮件文件夹一样。
  • IMAP 客户端可以有选择地下载邮件的一部分,例如,抓取特定的附件或只抓取邮件头,而不必等待下载邮件的其余部分。

综上所述,这些特性意味着 IMAP 可以用于比 POP 支持的简单的下载-删除痉挛更多的操作。许多电子邮件阅读器,如 Thunderbird 和 Outlook,可以显示 IMAP 文件夹,这样它们就可以像本地存储的文件夹一样工作。当用户点击一封邮件时,电子邮件阅读器从 IMAP 服务器下载并显示它,而不必预先下载所有的邮件;读者也可以同时设置消息的“已读”标志。

IMAP 协议

目的:阅读、整理和删除电子邮件文件夹中的电子邮件

标准:RFC 3501 (2003)

运行于:TCP/IP 之上

默认端口:143(明文),993 (SSL)

库:imaplib,IMAPClient

Exceptions : socket.error, socket.gaierror, IMAP4.error, IMAP4.abort, IMAP4.readonly

IMAP 客户端也可以与 IMAP 服务器同步。某个即将出差的人可能会将 IMAP 文件夹下载到笔记本电脑上。然后,在路上,电子邮件可能被阅读、删除或回复,用户的电子邮件程序将记录这些行为。当笔记本电脑最终重新连接到网络时,他们的电子邮件客户端可以用已经在本地设置的相同“已读”或“已回复”标志来标记服务器上的邮件,并且可以继续从服务器上删除已经在本地删除的邮件,以便用户不会看到它们两次。

结果是 IMAP 相对于 POP 的最大优势之一:用户可以从他们所有的笔记本电脑和台式机上看到相同状态的相同电子邮件。POP 用户只能多次看到相同的电子邮件(如果他们告诉他们的电子邮件客户端将电子邮件留在服务器上),或者每封邮件将只下载一次到他们碰巧阅读该邮件的机器上(如果电子邮件客户端删除了该邮件),这意味着他们的电子邮件将分散在他们检查邮件的所有机器上。IMAP 用户避免了这种困境。

当然,IMAP 也可以和 POP 完全一样的方式使用——下载邮件,存储在本地,并立即从服务器上删除邮件——对于那些不想要或不需要它的高级功能的人来说。

有几种版本的 IMAP 协议可用。最近的,也是目前最流行的,被称为 IMAP4rev1 。事实上,术语“IMAP”现在通常与 IMAP4rev1 同义。本章假设所有 IMAP 服务器都是 IMAP4rev1 服务器。非常旧的 IMAP 服务器很少见,可能不支持本章讨论的所有功能。

您还可以通过以下链接访问关于编写 IMAP 客户端的入门教程:

www.dovecot.org/imap-client-coding-howto.html
www.imapwiki.org/ClientImplementation

如果您正在做的事情不仅仅是编写一个小型的、单一用途的客户端来汇总收件箱中的邮件或自动下载附件,那么您应该彻底阅读上述资源中的信息,或者如果您想要更彻底的参考资料,请阅读关于 IMAP 的书籍,以便您可以正确处理在不同的服务器及其 IMAP 实现中可能遇到的所有情况。本章将教授基础知识,重点是如何最好地从 Python 进行连接。

理解 Python 中的 IMAP

Python 标准库包含一个名为imaplib的 IMAP 客户端接口,它提供了对协议的基本访问。不幸的是,它仅限于知道如何发送请求和将它们的响应传递回您的代码。它没有真正尝试实现 IMAP 规范中解析返回数据的详细规则。作为一个从imaplib返回的值通常太原始而不能在程序中使用的例子,看看清单 15-1 。这是一个简单的脚本,它使用imaplib连接到一个 IMAP 帐户,列出服务器公布的“功能”,然后显示由LIST命令返回的状态代码和数据。

清单 15-1 。连接到 IMAP 并列出文件夹

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/open_imaplib.py
# Opening an IMAP connection with the pitiful Python Standard Library

import getpass, imaplib, sys

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        sys.exit(2)

    hostname, username = sys.argv[1:]
    m = imaplib.IMAP4_SSL(hostname)
    m.login(username, getpass.getpass())
    try:
        print('Capabilities:', m.capabilities)
        print('Listing mailboxes ')
        status, data = m.list()
        print('Status:', repr(status))
        print('Data:')
        for datum in data:
            print(repr(datum))
    finally:
        m.logout()

if __name__ == '__main__':
    main()

如果您使用适当的参数运行这个脚本,它将首先询问您的密码;IMAP 身份验证几乎总是通过用户名和密码来完成:

$ python open_imaplib.py imap.example.com brandon@example.com
Password:

如果您的密码是正确的,那么它将显示一个类似于清单 15-2 中所示结果的响应。正如所承诺的,您将首先看到“功能”,它列出了该服务器支持的 IMAP 特性。而且,我必须承认,这个列表的类型非常 Pythonic 化:无论列表在网络上是什么形式,它都被转换成了一个令人愉快的字符串元组。

清单 15-2 。前面清单的输出示例

Capabilities: ('IMAP4REV1', 'UNSELECT', 'IDLE', 'NAMESPACE', 'QUOTA',
 'XLIST', 'CHILDREN', 'XYZZY', 'SASL-IR', 'AUTH=XOAUTH')
Listing mailboxes
Status: 'OK'
Data:
b'(\\HasNoChildren) "/" "INBOX"'
b'(\\HasNoChildren) "/" "Personal"'
b'(\\HasNoChildren) "/" "Receipts"'
b'(\\HasNoChildren) "/" "Travel"'
b'(\\HasNoChildren) "/" "Work"'
b'(\\Noselect \\HasChildren) "/" "[Gmail]"'
b'(\\HasChildren \\HasNoChildren) "/" "[Gmail]/All Mail"'
b'(\\HasNoChildren) "/" "[Gmail]/Drafts"'
b'(\\HasChildren \\HasNoChildren) "/" "[Gmail]/Sent Mail"'
b'(\\HasNoChildren) "/" "[Gmail]/Spam"'
b'(\\HasNoChildren) "/" "[Gmail]/Starred"'
b'(\\HasChildren \\HasNoChildren) "/" "[Gmail]/Trash"'

但是当你转向list()方法的结果时,事情就分崩离析了。首先,您将返回一个普通字符串'OK'形式的状态代码,因此使用imaplib的代码必须不停地检查代码是否为'OK'或者它是否指示一个错误。这并不是可怕的 Python,因为 Python 程序通常可以不做错误检查就运行,并且是安全的,因为它知道如果出现任何错误,就会抛出异常。

其次,imaplib对解释结果没有任何帮助!该 IMAP 帐户中的电子邮件文件夹列表使用各种特定于协议的引号:列表中的每个项目命名每个文件夹上设置的标志,然后指定用于分隔文件夹和子文件夹的字符(在本例中为斜杠字符),最后提供带引号的文件夹名称。但是所有这些都返回到原始数据,需要您解释如下所示的字符串:

(\HasChildren \HasNoChildren) "/" "[Gmail]/Sent Mail"

第三,输出是不同序列的混合:标志仍然是未解释的字节串,而每个分隔符和文件夹名已经被解码为真正的 Unicode 字符串。

因此,除非您想自己实现协议的几个细节,否则您将需要一个更强大的 IMAP 客户端库。

IMAPClient

幸运的是,确实存在一个流行的、经过实战检验的 Python IMAP 库,可以从 Python 包索引中轻松安装。友好的 Python 程序员 Menno Smits 编写了 IMAPClient 包,它实际上在幕后使用 Python 标准库imaplib来完成工作。

如果你想试用 IMAPClient,试着把它安装在一个“virtualenv”中,如第一章所述。一旦安装完毕,你可以在虚拟环境中使用python解释器来运行程序,如清单 15-3 所示。

清单 15-3 。用 IMAPClient 列出 IMAP 文件夹

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/open_imap.py
# Opening an IMAP connection with the powerful IMAPClient

import getpass, sys
from imapclient import IMAPClient

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        sys.exit(2)

    hostname, username = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        print('Capabilities:', c.capabilities())
        print('Listing mailboxes:')
        data = c.list_folders()
        for flags, delimiter, folder_name in data:
            print('  %-30s%s %s' % (' '.join(flags), delimiter, folder_name))
    finally:
        c.logout()

if __name__ == '__main__':
    main()

从代码中您可以立即看到,现在正在代表您处理协议交换的更多细节。例如,您不再得到每次运行命令都必须检查的状态代码;相反,库会替你做检查,如果有任何问题,它会抛出一个异常来阻止你。图 15-1 提供了一个 Python 和 IMAP 服务器之间的对话示例。

9781430258544_Fig15-01.jpg

图 15-1 。Python 和 IMAP 服务器之间的对话示例

其次,您可以看到来自LIST命令的每一个结果——在这个库中是作为list_folders()方法提供的,而不是由imaplib提供的list()方法——已经被解析成 Python 数据类型。每行数据作为一个元组返回,为您提供文件夹标志、文件夹名称分隔符和文件夹名称,标志本身是一个字符串序列。

查看清单 15-4 中的,看看第二个脚本的输出是什么样子。

清单 15-4 。正确解析的标志和文件夹名称

Capabilities: ('IMAP4REV1', 'UNSELECT', 'IDLE', 'NAMESPACE', 'QUOTA', 'XLIST', 'CHILDREN', 'XYZZY', 'SASL-IR', 'AUTH=XOAUTH')
Listing mailboxes:
  \HasNoChildren                / INBOX
  \HasNoChildren                / Personal
  \HasNoChildren                / Receipts
  \HasNoChildren                / Travel
  \HasNoChildren                / Work
  \Noselect \HasChildren        / [Gmail]
  \HasChildren \HasNoChildren   / [Gmail]/All Mail
  \HasNoChildren                / [Gmail]/Drafts
  \HasChildren \HasNoChildren   / [Gmail]/Sent Mail
  \HasNoChildren                / [Gmail]/Spam
  \HasNoChildren                / [Gmail]/Starred
  \HasChildren \HasNoChildren   / [Gmail]/Trash

为每个文件夹列出的标准标志可以是零个或多个以下内容:

  • \Noinferiors:这意味着该文件夹不包含任何子文件夹,并且将来也不可能包含子文件夹。如果 IMAP 客户端试图在该文件夹中创建子文件夹,它将收到一个错误。
  • \Noselect:这意味着不能在该文件夹上运行select_folder();也就是说,该文件夹不包含也不能包含任何邮件。(一种可能是,它的存在只是为了允许它下面有子文件夹。)
  • 这意味着服务器认为这个盒子在某种程度上是有趣的。通常,这表示自上次选择该文件夹后,已有新邮件送达。然而,\Marked的缺失并不能保证不会保证文件夹中不包含新消息;有些服务器根本就没有实现\Marked
  • \Unmarked:这样可以保证文件夹中不包含新邮件。

一些服务器返回标准中没有包含的附加标志。您的代码必须能够接受和忽略这些额外的标志。

检查文件夹

在实际下载、搜索或修改任何邮件之前,您必须“选择”要查看的特定文件夹。这意味着 IMAP 协议是有状态的:它会记住您当前正在查看的文件夹,它的命令会对当前文件夹进行操作,而不会让您一遍又一遍地重复它的名称。只有当你的连接关闭并重新连接时,你才是从一个干净的石板上重新开始。这可以使交互更加愉快,但这也意味着你的程序必须小心,它总是知道什么文件夹被选中,否则它可能会对错误的文件夹做一些事情。

因此,当您选择一个文件夹时,您告诉 IMAP 服务器随后的所有命令(直到您更改文件夹或退出当前文件夹)将应用于所选文件夹。

选择时,通过提供一个readonly=True参数,您可以选择“只读”文件夹,而不是以完全读/写模式选择它。这将导致任何删除或修改消息的操作在您尝试这些操作时返回错误消息。除了防止您在想要保持所有邮件不变时犯任何错误之外,您正在阅读的事实可以被服务器用来优化对文件夹的访问。(例如,当您选中磁盘上的实际文件夹存储时,它可能会读锁定,但不会写锁定。)

消息编号与 uid

IMAP 提供了两种不同的方式来引用文件夹中的特定邮件:通过临时邮件编号(通常为 1、2、3 等)或通过唯一标识符(UID) 。两者的区别在于坚持。当您通过特定连接选择一个文件夹时,就会分配消息编号。这意味着它们可以是漂亮的和连续的,但也意味着如果你以后再次访问同一个文件夹,给定的邮件可能会有不同的号码。对于实时电子邮件阅读器或简单的下载脚本等程序,这种行为(与 POP 相同)没有问题;您不会在意下次连接时号码可能会有所不同。

但是相比之下,UID 被设计为保持不变,即使您关闭与服务器的连接并且不再重新连接。如果一封邮件今天的 UID 为 1053,那么同一封邮件明天的 UID 将为 1053,并且该文件夹中的其他邮件都不会有 UID 1053。如果你正在编写一个同步工具,这个行为是相当有用的!它将允许您 100%确定地验证正在对正确的消息采取行动。这是 IMAP 比 POP 更有趣的地方之一。

请注意,如果您返回到一个 IMAP 帐户,而用户在没有通知您的情况下删除了一个文件夹,然后用相同的名称创建了一个新的文件夹,那么在您的程序看来,可能是同一个文件夹存在,但 UID 号相互冲突,不再一致。即使是文件夹重命名,如果您没有注意到,也可能会让您忘记 IMAP 帐户中的哪些邮件与您已经下载的邮件相对应。但事实证明,IMAP 已经准备好保护您不受这种影响,并且(我很快会解释)提供了一个UIDVALIDITY folder 属性,您可以从一个会话到下一个会话进行比较,以查看该文件夹中的 uid 是否真的与您上次连接时相同消息的 uid 相对应。

大多数处理特定邮件的 IMAP 命令可以采用邮件号或 uid。通常,IMAPClient 总是使用 uid,并忽略 IMAP 分配的临时消息编号。但是如果您想查看临时数字,只需用一个use_uid=False参数实例化 IMAPClient,或者您甚至可以在 IMAP 会话期间动态地将该类的use_uid属性的值设置为FalseTrue

消息范围

大多数处理邮件的 IMAP 命令可以处理一封或多封邮件。如果您需要一整组消息,这可以使处理速度快得多。您可以将一组消息作为一个整体来操作,而不是针对每条单独的消息发出单独的命令并接收单独的响应。这通常会更快,因为您不再需要为每个命令处理网络往返。

在通常需要提供消息编号的地方,您可以提供一个逗号分隔的消息编号列表。此外,如果您想要号码在某个范围内的所有消息,但您不想必须列出它们的所有号码(或者如果您甚至不知道它们的号码—也许您想要“一切从消息一开始”而不必先获取它们的号码),您可以使用冒号来分隔开始和结束消息号码。星号表示“和所有其余的消息”下面是一个规范示例:

2,4:6,20:*

它表示“邮件夹末尾的邮件 2、邮件 4 至 6 和邮件 20”

汇总信息

当您第一次选择一个文件夹时,IMAP 服务器会提供一些关于它的摘要信息—关于文件夹本身以及它的邮件。

摘要由IMAPClient作为字典返回。以下是运行select_folder()时大多数 IMAP 服务器将返回的密钥:

  • EXISTS :给出文件夹中消息数量的整数。
  • FLAGS :该文件夹中的邮件可以设置的标志列表。
  • RECENT :指定自 IMAP 客户端最后一次在文件夹上运行select_folder()以来,服务器对文件夹中出现的邮件数量的估计。
  • PERMANENTFLAGS :指定可以在消息上设置的自定义标志列表;这通常是空的。
  • UIDNEXT :服务器猜测将分配给下一个传入(或上传)消息的 UID。
  • UIDVALIDITY :客户端可以用来验证 UID 编号没有改变的字符串。如果您返回到一个文件夹,并且这是一个不同于上次连接时的值,那么 UID 号已经重新开始,您存储的 UID 值不再有效。
  • UNSEEN :指定文件夹中第一条看不见的消息(没有\Seen标志的消息)的消息号。

在这些标志中,服务器只需要返回FLAGSEXISTSRECENT,尽管大多数也会至少包括UIDVALIDITY。清单 15-5 显示了一个样例程序,它读取并显示我的INBOX电子邮件文件夹的摘要信息。

清单 15-5 。显示文件夹摘要信息

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/folder_info.py
# Opening an IMAP connection with IMAPClient and listing folder information.

import getpass, sys
from imapclient import IMAPClient

def main():
    if len(sys.argv) != 4:
        print('usage: %s hostname username foldername' % sys.argv[0])
        sys.exit(2)

    hostname, username, foldername = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        select_dict = c.select_folder(foldername, readonly=True)
        for k, v in sorted(select_dict.items()):
            print('%s: %r' % (k, v))
    finally:
        c.logout()

if __name__ == '__main__':
    main()

运行时,该程序显示如下结果:

$ ./folder_info.py imap.example.com brandon@example.com
Password:
EXISTS: 3
PERMANENTFLAGS: ('\\Answered', '\\Flagged', '\\Draft', '\\Deleted',
'\\Seen', '\\*')
READ-WRITE: True
UIDNEXT: 2626
FLAGS: ('\\Answered', '\\Flagged', '\\Draft', '\\Deleted', '\\Seen')
UIDVALIDITY: 1
RECENT: 0

这表明我的INBOX文件夹包含三条消息,自从我上次检查以来,没有一条消息到达。如果您的程序对使用它在以前的会话中存储的 uid 感兴趣,记得将UIDVALIDITY与以前的会话中存储的值进行比较。

下载整个邮箱

对于 IMAP,FETCH命令用于下载邮件,IMAPClient 将其公开为其fetch()方法。

最简单的获取方法是一口气下载所有信息。尽管这是最简单的,并且需要的网络流量最少(因为您不必发出重复的命令并接收多个响应),但这确实意味着当您的程序检查它们时,所有返回的消息都需要一起存放在内存中。对于邮件有很多附件的非常大的邮箱来说,这显然是不实际的!

清单 15-6 以 Python 数据结构将INBOX文件夹中的所有消息下载到你计算机的内存中,然后显示每条消息的一些摘要信息。

清单 15-6 。下载文件夹中的所有邮件

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/folder_summary.py
# Opening an IMAP connection with IMAPClient and retrieving mailbox messages.

import email, getpass, sys
from imapclient import IMAPClient

def main():
    if len(sys.argv) != 4:
        print('usage: %s hostname username foldername' % sys.argv[0])
        sys.exit(2)

    hostname, username, foldername = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        print_summary(c, foldername)
    finally:
        c.logout()

def print_summary(c, foldername):
    c.select_folder(foldername, readonly=True)
    msgdict = c.fetch('1:*', ['BODY.PEEK[]'])
    for message_id, message in list(msgdict.items()):
        e = email.message_from_string(message['BODY[]'])
        print(message_id, e['From'])
        payload = e.get_payload()
        if isinstance(payload, list):
            part_content_types = [part.get_content_type() for part in payload]
            print('  Parts:', ' '.join(part_content_types))
        else:
            print('  ', ' '.join(payload[:60].split()), '...')

if __name__ == '__main__':
    main()

请记住,IMAP 是有状态的:首先您使用select_folder()将自己放在给定的文件夹中,然后您可以运行fetch()来询问消息内容。(如果你想离开,不想再呆在给定的文件夹里,你可以稍后运行close_folder()。)范围'1:*'表示“邮件文件夹末尾的第一封邮件”,因为邮件 id——无论是临时的还是 uid——总是正整数。

可能看起来很奇怪的字符串'BODY.PEEK[] '是向 IMAP 请求消息“整体”的方式。字符串'BODY[]'的意思是“整个消息”;正如您将看到的,在方括号内,您可以只要求消息的特定部分。

PEEK表示你只是在查看消息内部以建立一个摘要,并且你不希望服务器自动为你设置所有这些消息的\Seen标志,从而破坏它关于用户已经阅读了哪些消息的记忆。(对我来说,这似乎是一个很好的特性,可以添加到这样一个小脚本中,您可以对一个真实的邮箱运行该脚本——我不想将您的所有邮件都标记为已读!)

返回的字典将消息 uid 映射到给出关于每条消息的信息的字典。当您遍历它的键和值时,您在每个消息字典中查找 IMAP 已经用您所请求的消息的信息填充的'BODY[]'键:它的完整文本,作为一个大字符串返回。

使用我在第十二章的中讨论的email模块,脚本要求 Python 抓取From:行和一点消息内容,并作为摘要打印到屏幕上。当然,如果您想扩展这个脚本,以便将消息保存在文件或数据库中,您可以省略email解析步骤,而是将消息体作为一个单独的字符串存储在存储器中,供以后解析。

以下是运行该脚本时的结果:

$ ./mailbox_summary.py imap.example.com brandon INBOX
Password:
2590 "Amazon.com" <order-update@amazon.com>
  Dear Brandon, Portable Power Systems, Inc. shipped the follo ...
2469 Meetup Reminder <info@meetup.com>
  Parts: text/plain text/html
2470 billing@linode.com
  Thank you. Please note that charges will appear as "Linode.c ...

当然,如果邮件包含很大的附件,仅仅为了打印摘要而下载完整的附件可能是毁灭性的;但是因为这是最简单的消息获取操作,所以我认为从它开始比较合理!

单独下载邮件

电子邮件可能非常大,电子邮件文件夹也可能非常大——许多电子邮件系统允许用户拥有数百或数千封邮件,每封邮件可能有 10MB 或更大。如果一次下载完所有内容,这种邮箱很容易超过客户机上的 RAM,就像前面的例子一样。

为了帮助不想保留每封邮件的本地副本的基于网络的电子邮件客户端,除了前面讨论的“获取整个邮件”命令之外,IMAP 还支持多种操作。

  • 电子邮件的标题可以作为文本块下载,与邮件分开。
  • 可以请求并返回邮件的特定标题,而无需下载所有标题。
  • 可以要求服务器递归地探索并返回消息的 MIME 结构的轮廓。
  • 可以返回消息的特定部分的文本。

这使得 IMAP 客户端可以执行非常高效的查询,只下载需要向用户显示的信息,从而降低 IMAP 服务器和网络的负载,并允许更快地向用户显示结果。

关于一个简单的 IMAP 客户端如何工作的例子,请看清单 15-7 ,它汇集了许多关于浏览 IMAP 账户的想法。如果这些特性在这一章的这一点上分散在六个更短的程序清单中,这将提供更多的上下文。您可以看到客户端由三个同心循环组成,每个循环在用户查看电子邮件文件夹列表、特定电子邮件文件夹中的邮件列表以及特定邮件的各个部分时接受用户的输入。

清单 15-7 。一个简单的 IMAP 客户端

#!/usr/bin/env python3
# Foundations of Python Network Programming, Third Edition
# https://github.com/brandon-rhodes/fopnp/blob/m/py3/chapter15/simple_client.py
# Letting a user browse folders, messages, and message parts.

import getpass, sys
from imapclient import IMAPClient

banner = '-'72

def main():
    if len(sys.argv) != 3:
        print('usage: %s hostname username' % sys.argv[0])
        sys.exit(2)

    hostname, username = sys.argv[1:]
    c = IMAPClient(hostname, ssl=True)
    try:
        c.login(username, getpass.getpass())
    except c.Error as e:
        print('Could not log in:', e)
    else:
        explore_account(c)
    finally:
        c.logout()

def explore_account(c):
    """Display the folders in this IMAP account and let the user choose one."""

    while True:

        print()
        folderflags = {}
        data = c.list_folders()
        for flags, delimiter, name in data:
            folderflags[name] = flags
        for name in sorted(folderflags.keys()):
            print('%-30s %s' % (name, ' '.join(folderflags[name])))
        print()

        reply = input('Type a folder name, or "q" to quit: ').strip()
        if reply.lower().startswith('q'):
            break
        if reply in folderflags:
            explore_folder(c, reply)
        else:
            print('Error: no folder named', repr(reply))

def explore_folder(c, name):
    """List the messages in folder `name` and let the user choose one."""

    while True:
        c.select_folder(name, readonly=True)
        msgdict = c.fetch('1:*', ['BODY.PEEK[HEADER.FIELDS (FROM SUBJECT)]',
                                  'FLAGS', 'INTERNALDATE', 'RFC822.SIZE'])
        print()
        for uid in sorted(msgdict):
            items = msgdict[uid]
            print('%6d  %20s  %6d bytes  %s' % (
                uid, items['INTERNALDATE'], items['RFC822.SIZE'],
                ' '.join(items['FLAGS'])))
            forin items['BODY[HEADER.FIELDS (FROM SUBJECT)]'].splitlines():
                print(' '6, i.strip())

        reply = input('Folder %s - type a message UID, or "q" to quit: '
                          % name).strip()
        if reply.lower().startswith('q'):
            break
        try:
            reply = int(reply)
        except ValueError:
            print('Please type an integer or "q" to quit')
        else:
            if reply in msgdict:
                explore_message(c, reply)

    c.close_folder()

def explore_message(c, uid):
    """Let the user view various parts of a given message."""

    msgdict = c.fetch(uid, ['BODYSTRUCTURE', 'FLAGS'])

    while True:
        print()
        print('Flags:', end=' ')
        flaglist = msgdict[uid]['FLAGS']
        if flaglist:
            print(' '.join(flaglist))
        else:
            print('none')
        print('Structure:')
        display_structure(msgdict[uid]['BODYSTRUCTURE'])
        print()
        reply = input('Message %s - type a part name, or "q" to quit: '
                          % uid).strip()
        print()
        if reply.lower().startswith('q'):
            break
        key = 'BODY[%s]' % reply
        try:
            msgdict2 = c.fetch(uid, [key])
        except c._imap.error:
            print('Error - cannot fetch section %r' % reply)
        else:
            content = msgdict2[uid][key]
            if content:
                print(banner)
                print(content.strip())
                print(banner)
            else:
                print('(No such section)')

def display_structure(structure, parentparts=[]):
    """Attractively display a given message structure."""

    # The whole body of the message is named 'TEXT'.

    if parentparts:
        name = '.'.join(parentparts)
    else:
        print('  HEADER')
        name = 'TEXT'

    # Print a simple, non-multipart MIME part.  Include its disposition,
    # if available.

    is_multipart = not isinstance(structure[0], str)

    if not is_multipart:
        parttype = ('%s/%s' % structure[:2]).lower()
        print('  %-9s' % name, parttype, end=' ')
        if structure[6]:
            print('size=%s' % structure[6], end=' ')
        if structure[9]:
            print('disposition=%s' % structure[9][0],
                  ' '.join('{}={}'.format(k, v) for k, v in structure[9][1:]),
                  end=' ')
        print()
        return

    # For a multipart part, print all of its subordinate parts.

    parttype = 'multipart/%s' % structure[1].lower()
    print('  %-9s' % name, parttype, end=' ')
    print()
    subparts = structure[0]
    forin range(len(subparts)):
        display_structure(subparts[i], parentparts + [str(i + 1)])

if __name__ == '__main__':
    main()

您可以看到,外层函数使用一个简单的list_folders()调用向用户显示电子邮件文件夹列表,就像前面讨论的一些程序清单一样。还会显示每个文件夹的 IMAP 标志。这使得程序可以让用户在文件夹之间进行选择:

INBOX                          \HasNoChildren
Receipts                       \HasNoChildren
Travel                         \HasNoChildren
Work                           \HasNoChildren
Type a folder name, or "q" to quit:

一旦用户选择了一个文件夹,事情就变得更有趣了:必须为每条消息打印一个摘要。不同的电子邮件客户端对显示文件夹中每封邮件的信息做出不同的选择。清单 15-7 中的代码选择了几个标题字段以及消息的日期和大小。请注意,使用BODY.PEEK而不是BODY来获取这些项目是很小心的,因为 IMAP 服务器会将这些消息标记为\Seen,仅仅因为它们显示在摘要中!

一旦选择了电子邮件文件夹,此fetch()调用的结果将打印到屏幕上:

2703   2010-09-28 21:32:13   19129 bytes  \Seen
From: Brandon Craig Rhodes
Subject: Digested Articles

2704   2010-09-28 23:03:45   15354 bytes
Subject: Re: [venv] Building a virtual environment for offline testing
From: "W. Craig Trader"

2705   2010-09-29 08:11:38   10694 bytes
Subject: Re: [venv] Building a virtual environment for offline testing
From: Hugo Lopes Tavares

Folder INBOX - type a message UID, or "q" to quit:

正如您所看到的,可以向 IMAP fetch() 命令提供几个感兴趣的项目,这一事实允许您构建相当复杂的消息摘要,只需与服务器进行一次往返!

一旦用户选择了一条特定的消息,就会使用一种我到目前为止还没有讨论过的技术:fetch()被要求返回消息的BODYSTRUCTURE,这是查看 MIME 消息的各个部分而不必下载整个文本的关键。BODYSTRUCTURE不是让你通过网络传输几兆字节来列出一个大邮件的附件,而是简单地列出它的 MIME 部分作为一个递归数据结构。

简单的 MIME 部分 作为元组返回:

('TEXT', 'PLAIN', ('CHARSET', 'US-ASCII'), None, None, '7BIT', 2279, 48)

在 RFC 3501 的 7.4.2 节中详细描述的该元组的元素如下(当然,从项目索引零开始):

  1. MIME 类型
  2. MIME 子类型
  3. 主体参数,表示为一个元组(name, value, name, value, ...),其中每个参数名称后跟其值
  4. 内容 ID
  5. 内容描述
  6. 内容编码
  7. 内容大小(字节)
  8. 对于文本 MIME 类型,这给出了以行为单位的内容长度

当 IMAP 服务器发现一个消息是多部分的,或者当它检查它发现的消息的一个部分本身是多部分的(参见第十二章了解更多关于 MIME 消息如何在其中嵌套其他 MIME 消息的信息),那么它返回的元组将以一个子结构列表开始,每个子结构都是一个元组,就像外部结构一样。然后,它将以将这些部分绑定在一起的多部分容器的一些信息结束:

([(...), (...)], "MIXED", ('BOUNDARY', '=-=-='), None, None)

"MIXED"准确地指示了所表示的多部分容器的类型——在本例中,完整类型是multipart/mixed。其他常见的“多部分”子类型,除了"MIXED",还有"ALTERNATIVE""DIGEST""PARALLEL"。multipart 类型之外的其余项是可选的,但如果存在,它们会提供一组名称-值参数(此处指示 MIME multipart 边界字符串)、multipart 的部署、语言和位置(通常由 URL 给出)。

给定这些规则,你可以看到像清单 15-7 中的display_structure()这样的递归例程是如何完美地展开和显示消息中各部分的层次结构。当 IMAP 服务器返回一个BODYSTRUCTURE时,例程开始工作并打印出如下内容供用户检查:

Folder INBOX - type a message UID, or "q" to quit: 2701
Flags: \Seen
HEADER
TEXT      multipart/mixed
1         multipart/alternative
1.1       text/plain size=253
1.2       text/html size=508
2         application/octet-stream size=5448 ATTACHMENT FILENAME='test.py'
Message 2701type a part name, or "q" to quit:

您可以看到,这里显示的消息结构是一个非常典型的现代电子邮件,对于在浏览器或现代电子邮件客户端查看它的用户,它有一个精美的富文本 HTML 部分,对于使用更传统的设备或应用的用户,它有一个相同消息的纯文本版本。它还包含一个文件附件,并提供了一个建议的文件名,以防用户希望将其下载到本地文件系统。为了简单和安全起见,这个示例程序并不试图在硬盘上保存任何东西;相反,用户可以选择消息的任何部分——例如特殊部分HEADERTEXT,或者像1.1这样的特定部分之一——其内容将被打印到屏幕上。

如果您检查程序清单,您会发现所有这些都是通过调用 IMAP fetch()方法来支持的。像HEADER1.1这样的部件名只是在调用fetch()时可以指定的更多选项,它们可以和其他值一起使用,比如BODY.PEEKFLAGS。唯一的区别是后面的值适用于所有消息;而类似于2.1.3的部分名称将只存在于其结构包含具有该名称的部分的多部分消息中。

您将注意到的一个奇怪之处是,IMAP 协议实际上并没有而不是为您提供特定消息支持的任何多部分名称!相反,您必须从索引1开始计算BODYSTRUCTURE中列出的零件数量,以确定您应该请求哪个零件号。你可以看到这里的display_structure()例程使用一个简单的循环来完成这个计数。

关于fetch()命令的最后一个注意事项:它不仅可以让您在任何给定的时刻提取您需要的消息部分,而且如果它们很长,并且您只想从头提供一段摘录来吸引用户,它还可以截断它们!要使用此功能,请在任何部分名称后加上尖括号中的切片,以指示所需的字符范围,这与 Python 的切片操作非常相似:

BODY[]<0.100>

这将返回消息体的前 100 个字节,从偏移量 0 到偏移量 100。这可以让您在让用户决定是否选择或下载附件之前,检查附件的文本和开头,以了解更多有关其内容的信息。

标记和删除消息

在试用清单 15-7 中的或阅读其示例输出时,您可能已经注意到,IMAP 用名为 flags 的属性来标记消息,这些属性通常采用以反斜杠为前缀的单词的形式,比如刚刚引用的一条消息中的\Seen。其中有几个是标准的,它们在 RFC 3501 中定义,可以在所有 IMAP 服务器上使用。以下是最重要的几条的含义:

  • \Answered:用户已回复消息。
  • \Draft:用户还没有写完消息。
  • 这条消息不知何故被特别挑了出来;该标志的目的和意义因电子邮件阅读器而异。
  • 以前没有 IMAP 客户端看到过这条消息。该标志是唯一的,因为不能通过普通命令添加或删除该标志;选择邮箱后,它会自动删除。
  • \Seen:消息已被阅读。

如您所见,这些标志大致对应于许多电子邮件读者直观呈现的关于每封邮件的信息。尽管术语可能不同(许多客户谈论“新”邮件而不是“未看到”邮件),但几乎所有的电子邮件阅读器都显示这些标志。特定的服务器也可能支持其他标志,这些标志的代码不一定以反斜杠开头。此外,并非所有服务器都可靠地支持\Recent标志,因此通用 IMAP 客户端最多只能将其视为一个提示。

IMAPClient 库支持几种使用标志的方法。最简单的方法是检索标志,就像你做了一个fetch()请求'FLAGS'一样,但是它继续下去并删除每个答案周围的字典:

>>> c.get_flags(2703)
{2703: ('\\Seen',)}

也有在邮件中添加和移除标志的调用:

c.remove_flags(2703, ['\\Seen'])
c.add_flags(2703, ['\\Answered'])

如果您想完全更改某个特定消息的标志集,而不确定正确的添加和删除序列,您可以单方面使用set_flags()将整个消息标志列表替换为一个新的标志:

c.set_flags(2703, ['\\Seen', '\\Answered'])

这些操作中的任何一个都可以接受消息 UID 的列表,而不是这些示例中显示的单个 UID。

删除消息

标志的最后一个有趣用途是在 IMAP 如何支持邮件删除中发现的。为了安全起见,这个过程分两步:首先,客户端用\Delete标志标记一条或多条消息;然后,它调用expunge()作为单个操作执行挂起的请求删除。

然而,IMAPClient 库不会让您手动这样做(尽管这样做也可以);相反,它隐藏了这样一个事实,即在一个简单的为您标记消息的delete_messages()例程后面包含了标志。但是,如果您真的希望操作生效,它后面还必须跟有expunge():

c.delete_messages([2703, 2704])
c.expunge()

注意,expunge()将对邮箱中消息的临时 id 重新排序,这也是使用 uid 的另一个原因。

正在搜索

搜索是另一个非常重要的功能,对于一个旨在让您将所有电子邮件保存在电子邮件服务器本身上的协议来说:如果没有搜索,电子邮件客户端将不得不在用户第一次想要执行全文搜索来查找电子邮件时下载用户的所有电子邮件。

搜索的本质很简单:在 IMAP 客户机实例上调用search()方法,然后返回符合条件的消息的 uid(当然,假设您接受 IMAP client 缺省值use_uid=True):

>>> c.select_folder('INBOX')
>>> c.search('SINCE 13-Jul-2013 TEXT Apress')
[2590L, 2652L, 2653L, 2654L, 2655L, 2699L]

然后,这些 uid 可以成为一个fetch()命令的主题,该命令检索关于您需要的每条消息的信息,以便向用户呈现搜索结果的摘要。

前面的例子中显示的查询结合了两个标准:一个请求最近的消息(那些自 2013 年 7 月 13 日以来到达的消息,我键入这个消息的日期),另一个请求消息文本在某个地方有单词 Apress,并且结果将只包括满足第一个标准的消息第二个标准——这是用一个空格连接两个标准以形成单个字符串的结果。相反,如果您希望消息至少匹配其中一个标准,但不需要同时匹配两个标准,那么您可以使用一个OR操作符来连接标准:

OR (SINCE 20-Aug-2010) (TEXT Apress)

为了形成一个查询,可以组合许多标准。像 IMAP 的其余部分一样,它们在 RFC 3501 中被指定。有些标准非常简单,指的是二进制属性,如标志:

ALL: Every message in the mailbox
UID (id, ...): Messages with the given UIDs
LARGER n: Messages more than n octets in length
SMALLER m: Messages less than m octets in length
ANSWERED: Have the flag \Answered
DELETED: Have the flag \Deleted
DRAFT: Have the flag \Draft
FLAGGED: Have the flag \Flagged
KEYWORD flag: Have the given keyword flag set
NEW: Have the flag \Recent
OLD: Lack the flag \Recent
UNANSWERED: Lack the flag \Answered
UNDELETED: Lack the flag \Deleted
UNDRAFT: Lack the flag \Draft
UNFLAGGED: Lack the flag \Flagged
UNKEYWORD flag: Lack the given keyword flag
UNSEEN: Lack the flag \Seen

还有许多标志与每个邮件头中的项目相匹配。除了“发送”测试之外,它们都在同名的头中搜索给定的字符串,发送测试查看Date头:

BCC string
CC string
FROM string
HEADER name string
SUBJECT string
TO string

一条 IMAP 消息有两个日期:由发送者指定的内部Date报头,称为其发送日期,以及它实际到达 IMAP 服务器的日期。(前者显然可能是伪造的;后者和 IMAP 服务器及其时钟一样可靠。)因此,根据您要查询的日期,有两组日期条件:

BEFORE 01-Jan-1970
ON 01-Jan-1970
SINCE 01-Jan-1970
SENTBEFORE 01-Jan-1970
SENTON 01-Jan-1970
SENTSINCE 01-Jan-1970

最后,有两种搜索操作涉及到邮件本身的文本,它们是支持全文搜索的主要工具,当用户在电子邮件客户端的搜索字段中键入内容时,他们可能会希望进行全文搜索:

BODY string: The message body must contain the string.
TEXT string: The entire message, either body or header, must contain the string somewhere.

请参阅您正在使用的特定 IMAP 服务器的文档,以了解它是否返回任何“近似”匹配(如现代搜索引擎所支持的匹配),或者只返回与您提供的单词完全匹配的匹配。

如果字符串包含 IMAP 认为特殊的字符,请尝试用双引号将它们括起来,然后用反斜杠将字符串中的任何双引号括起来:

>>> c.search(r'TEXT "Quoth the raven, \"Nevermore.\""')
[2652L]

注意,通过在这里使用一个原始的 Python r'...'字符串,我避免了必须将反斜杠加倍才能将单个反斜杠传递到 IMAP。

操作文件夹和信息

在 IMAP 中创建或删除文件夹非常简单,只需提供文件夹的名称:

c.create_folder('Personal')
c.delete_folder('Work')

某些 IMAP 服务器或配置可能不允许这些操作,或者对命名有限制。在调用它们时,一定要进行错误检查。

除了等待别人给你发送邮件的“正常”方法外,还有两种操作可以在你的 IMAP 帐户中创建新的电子邮件。

首先,您可以将现有邮件从其个人文件夹拷贝到另一个文件夹。首先使用select_folder()访问邮件所在的文件夹,然后像这样运行copy方法:

c.select_folder('INBOX')
c.copy([2653L, 2654L], 'TODO')

最后,可以使用 IMAP 向邮箱添加邮件。您不需要先用 SMTP 发送邮件;IMAP 是所有需要的。添加消息是一个简单的过程,尽管有一些事情你必须知道。

主要关注的是行尾。许多 Unix 机器使用单个 ASCII 换行字符(Python 中的0x0a'\n')来指定一行文本的结尾。Windows 机器使用两个字符:CR-LF,一个手动回车符(0x0D,或者 Python 中的'\r',后跟一个换行符。老款 MAC 电脑只使用手动回位。

像许多互联网协议(HTTP 立即浮现在脑海中)一样,IMAP 在内部使用CR-LF(Python 中的'\r\n')来指定一行的结束。如果您上传的邮件使用任何其他字符作为行尾,某些 IMAP 服务器会出现问题。因此,在翻译上传的邮件时,您必须始终注意正确的行尾。这个问题比你想象的更常见,因为大多数本地邮箱格式只在每行末尾使用'\n'

但是,您还必须小心如何更改行尾,因为一些邮件可能在其中的某个地方使用了'\r\n',尽管在最初的几十行中只使用了'\n',如果邮件使用了两种不同的行尾,IMAP 客户端就会失败!解决方案很简单,这要感谢 Python 强大的splitlines()字符串方法,它可以识别所有三种可能的行尾;只需调用消息中的函数,然后用标准行尾重新加入行:

>>> 'one\rtwo\nthree\r\nfour'.splitlines()
['one', 'two', 'three', 'four']
>>> '\r\n'.join('one\rtwo\nthree\r\nfour'.splitlines())
'one\r\ntwo\r\nthree\r\nfour'

一旦行尾正确,添加消息的实际操作是通过调用 IMAP 客户机上的append()方法来安排的:

c.append('INBOX', my_message)

通过传递一个普通的 Python datetime对象,还可以提供一个列表flags作为关键字参数,以及一个msg_time作为它的到达时间。

异步

最后,需要承认这一章中关于 IMAP 的方法:尽管我把 IMAP 描述成了同步协议,但实际上它支持客户机通过套接字向服务器发送许多请求,然后以服务器能够最有效地从磁盘获取电子邮件并作出响应的任何顺序接收返回的响应。

IMAPClient 库总是发送一个请求,等待响应,然后返回该值,从而隐藏了这种协议灵活性。但是其他库,尤其是 Twisted Python 中提供的 IMAP 功能,可以让您利用它的异步性。

对于大多数需要编写邮箱交互脚本的 Python 程序员来说,本章中采用的同步方法应该可以很好地工作。如果你扩展到异步库,那么你至少已经从本章的描述中了解了所有的 IMAP 命令,你只需要学习如何通过异步库的 API 发送相同的命令。

摘要

IMAP 是访问存储在远程服务器上的电子邮件的可靠协议。Python 有许多 IMAP 库;imaplib内置于 Python 标准库,但是它需要你自己做各种低级的响应解析。更好的选择是 Menno Smits 的 IMAPClient,您可以从 Python 包索引中安装它。

在 IMAP 服务器上,您的电子邮件被分组到文件夹中,其中一些由您的特定 IMAP 提供商预定义,另一些您可以自己创建。IMAP 客户端可以创建文件夹、删除文件夹、将新邮件插入文件夹以及在文件夹之间移动现有邮件。

一旦选择了一个文件夹,这就相当于 IMAP 文件系统上的“更改目录”命令,可以非常灵活地列出和提取邮件。客户端不必下载完整的消息(当然,这也是一个选项),而是可以从消息中请求特定的信息,比如几个标题及其消息结构,以便构建用户可以点击的显示或摘要,按需从服务器下载消息部分和附件。

客户端还可以在每条消息上设置标志——其中一些对服务器也有意义——并且它可以通过设置\Delete标志然后执行 expunge 操作来删除消息。

最后,IMAP 提供了完善的搜索功能,因此无需将电子邮件数据下载到本地机器就可以支持常见的用户操作。

在下一章中,我们将离开电子邮件的主题,考虑一种完全不同的通信类型:向远程服务器发送 shell 命令并接收它们的响应输出。