非官方中文译本声明:本页为 IETF RFC 8621《The JSON Meta Application Protocol (JMAP) for Mail(JMAP 邮件)》中文译本,由 ztpop.net 整理翻译,仅供学习参考。RFC 文档由 IETF 发布,受 BCP 78 与 IETF 信托法律条款约束;本译本保留原文编号与结构,权威性以英文原文为准。英文原文见 rfc-editor.org/rfc/rfc8621

RFC 8621:JMAP Mail(邮件)

摘要

本文档规定了一种使用 JSON 元数据应用协议(JMAP)与服务器同步邮件数据的数据模型。客户端可借此高效地搜索、访问、组织并发送消息,并在新消息送达或其他客户端做出变更时获取推送通知,从而实现快速重新同步。

本备忘录的状态

本文是一份互联网标准跟踪(Standards Track)文档。

本文是互联网工程任务组(IETF)的成果,代表 IETF 社区的共识,已经过公开评审,并由互联网工程指导组(IESG)批准发布。有关互联网标准的更多信息见 RFC 7841 第 2 节。

关于本文当前状态、任何勘误以及如何提供反馈的信息,可访问 https://www.rfc-editor.org/info/rfc8621

Copyright (c) 2019 IETF 信托及被列为文档作者的个人。保留所有权利。

本文受 BCP 78 以及 IETF 信托的《IETF 文档相关法律规定》(https://trustee.ietf.org/license-info)约束,以本文档发布之日生效的版本为准。请仔细审阅这些文档,因为它们描述了您就本文档所享有的权利与限制。从本文档中提取的代码组件必须包含《简化 BSD 许可证》文本(见信托法律条款第 4.e 节),并按该许可证的描述"不提供任何担保"。

目录

1. 引言

JSON 元数据应用协议(JMAP)[RFC8620] 是一种通用协议,用于在客户端与服务器之间同步诸如邮件、日历或联系人之类的数据。它针对移动与 Web 环境进行了优化,旨在为不同数据类型提供一致的接口。

本规范定义了通过 JMAP 访问邮件存储的数据模型,允许你查询、读取、组织邮件并提交以发送。

该数据模型的设计使得服务器能够通过 IMAP [RFC3501] 以及 JMAP 提供对同一数据的一致访问。与 IMAP 中一样,一封消息必须属于某个邮箱;但在 JMAP 中,若将其在邮箱间移动,其 id 不会改变,并且服务器可允许它同时属于多个邮箱(在用户代理中通常表现为标签而非文件夹)。

与 IMAP 中一样,消息可被赋予零个或多个关键字:即简短的任意字符串。这些主要用于存储告知客户端显示的元数据,例如未读状态,或消息是否已被回复。一个 IANA 注册表使得通用语义能够在客户端之间共享,并便于未来扩展。

一条消息及其回复在服务器上通过共同的 Thread(会话线索)id 关联起来。客户端可获取具有特定 Thread id 的消息列表,以便更轻松地呈现线程化或会话式界面。

消息访问的权限基于每个邮箱进行。服务器可对用户授予受限的邮箱权限,例如,若另一用户的收件箱以只读方式与其共享。

1.1. 符号约定

本文件中的关键词"MUST(必须)"、"MUST NOT(不得)"、"REQUIRED(要求)"、"SHALL(应)"、"SHALL NOT(不应)"、"SHOULD(应该)"、"SHOULD NOT(不应该)"、"RECOMMENDED(推荐)"、"NOT RECOMMENDED(不推荐)"、"MAY(可以)"和"OPTIONAL(可选)",当且仅当它们以如这里所示的全部大写字母出现时,应按照 BCP 14 [RFC2119] [RFC8174] 中的描述进行解释。

本文档中的类型签名、示例与属性描述遵循 [RFC8620] 第 1.1 节所确立的约定。核心规范中定义的数据类型在本文档中同样使用。

服务器必须支持本文档为新数据类型规定的所有属性。

1.2. 术语

本文档使用与核心 JMAP 规范相同的术语。

Mailbox、Thread、Email、SearchSnippet、EmailSubmission 与 VacationResponse(采用该特定大写形式)等术语用于指代本文档中定义的数据类型以及这些数据类型的实例。

术语"message(消息)"指 internet message format(互联网报文格式,见 [RFC5322])中的文档。Email 数据类型表示邮件存储中的消息及关联元数据。

1.3. 对 Capabilities 对象的增补

capabilities 对象作为 JMAP 会话对象的一部分返回,见 [RFC8620] 第 2 节。

本文档定义了三个额外的能力 URI。

1.3.1. urn:ietf:params:jmap:mail

它表示对 Mailbox、Thread、Email 与 SearchSnippet 数据类型及相关 API 方法的支持。在 JMAP 会话的 "capabilities" 属性中,该属性的值为一个空对象。

在某个账户的 "accountCapabilities" 属性中,该属性的值是一个对象,必须包含关于该账户的服务器能力与权限的以下信息:

注意,此限制针对的是未编码附件大小的总和。用户通常不了解编码开销等细节,也不应需要了解,因此营销与帮助材料通常告诉他们的是"最大附件大小"。这是他们在硬盘上看到的未编码大小,因此该能力与之一致,并允许客户端一致地强制执行为用户所理解的限制。

服务器可能另行对消息 [RFC5322] 的总大小(由附件(通常为 base64 编码)与消息头及正文组合而成)设限。例如,假设服务器通告 "maxSizeAttachmentsPerEmail: 50000000"(50 MB)。强制执行的服务器限制可能是针对 70000000 八位组的消息大小。即便有 base64 编码与 2 MB 的 HTML 正文,50 MB 附件也能落在该限制之下。

1.3.2. urn:ietf:params:jmap:submission

它表示对 Identity 与 EmailSubmission 数据类型及相关 API 方法的支持。在 JMAP 会话的 "capabilities" 属性中,该属性的值为一个空对象。

在某个账户的 "accountCapabilities" 属性中,该属性的值是一个对象,必须包含关于该账户的服务器能力与权限的以下信息:

与提交服务器 [RFC6409] 通信的 JMAP 实现应当具有一个配置项,允许管理员修改其可在此属性上暴露的提交 EHLO 能力集合。这使得 JMAP 服务器无需修改代码即可轻松添加对新提交扩展的访问。默认情况下,JMAP 服务器应隐藏与传输机制相关、因而仅与 JMAP 服务器有关的 EHLO 能力(例如 PIPELINING、CHUNKING 或 STARTTLS)。

可包含的提交扩展示例:

即便 JMAP 所使用的提交服务器未实现某扩展,JMAP 服务器也可通告该扩展并在 JMAP 服务器本地实现其语义。

提交扩展的完整 IANA 注册表可在 <https://www.iana.org/assignments/mail-parameters> 找到。

1.3.3. urn:ietf:params:jmap:vacationresponse

它表示对 VacationResponse 数据类型及相关 API 方法的支持。在 JMAP 会话的 "capabilities" 属性与账户的 "accountCapabilities" 属性中,该属性的值均为空对象。

1.4. 不同账户中的数据类型支持

对于任何用户可使用由该 URI 所代表的数据类型的账户,服务器必须在该账户的 "accountCapabilities" 属性中包含相应的能力字符串作为键。受支持的数据类型在用户可访问的不同账户之间可能不同。例如,在用户个人账户中,他们可能可以访问全部三组数据,但在一个共享账户中,他们可能只能访问 "urn:ietf:params:jmap:mail" 的数据。这意味着他们可以在共享账户中访问 Mailbox/Thread/Email 数据,但不被允许以该账户身份发送(因此没有 Identity/EmailSubmission 对象的访问权限),也无法查看/设置其 VacationResponse。

1.5. 推送

服务器必须支持 JMAP 推送机制,如 [RFC8620] 第 7 节所规定,以便在本规范定义的任何类型状态发生变化时接收通知。

此外,实现了 "urn:ietf:params:jmap:mail" 能力的服务器必须支持为名为 "EmailDelivery" 的类型推送状态变化。没有可作用于该类型的方法;它仅作为推送机制的一部分存在。该类型的状态字符串必须在有新 Email 加入存储时改变,但不应在 Email 对象发生任何其他变更时改变,例如当它被标记为已读或被删除时。

处于电量受限环境中的客户端可能希望延迟获取由用户发起的变更,但立即获取新 Email 以便通知用户。为此,它们可以为 EmailDelivery 类型(而非第 4 节定义的 Email 类型)注册推送。

1.5.1. 示例

客户端已(见 [RFC8620])仅针对 EmailDelivery 类型注册了推送通知。用户在另一台设备上将某封 Email 标记为已读,导致 Email 类型的状态字符串改变;但由于存储中未新增任何内容,EmailDelivery 状态未改变,也没有任何内容被推送到客户端。一封新消息到达用户的收件箱,再次导致 Email 状态改变。这一次,EmailDelivery 状态也发生改变,一个 StateChange 对象被推送到客户端,并带有新的状态字符串。客户端随后可立即重新同步以获取新 Email。

1.6. Ids

若某个 JMAP 邮件服务器同时提供数据的 IMAP 接口,并且支持 IMAP 对象标识符扩展 [RFC8474],则 Mailbox、Thread 与 Email 对象在 JMAP 中的 id 应当相同。

2. 邮箱(Mailboxes)

一个 Mailbox 表示一组具名的 Email 对象。这是在账户内组织消息的主要机制,类似于其他系统中的文件夹或标签。一个 Mailbox 可能在系统中扮演某种角色;详见下文。

为与 IMAP 兼容,一封 Email 必须属于一个或多个 Mailbox。若 Email 更改所属 Mailbox,其 Email id 不变。

一个 *Mailbox* 对象具有以下属性:

该值与 IMAP 共享(通过 SPECIAL-USE 扩展 [RFC6154] 在 IMAP 中暴露)。但与 IMAP 不同的是,一个 Mailbox 必须具有单一角色,并且同一账户中不得有两个具有相同角色的 Mailbox。提供 IMAP 访问同一数据的服务器被鼓励在 IMAP 中也强制实施这些额外限制。否则,当通过 JMAP 暴露数据时为确保合规而修改 IMAP 属性,取决于具体实现。

该值必须是 [RFC8457] 确立的 IANA "IMAP Mailbox Name Attributes" 注册表(位于 <https://www.iana.org/assignments/imap-mailbox-name-attributes/>)中列出的某个 Mailbox 属性名称,并转换为小写。未来可能在此处确立新角色。

不要求账户拥有任何具有特定角色的 Mailbox。

排序顺序较小的 Mailbox 应当显示在排序顺序较大(且具有相同父级)的 Mailbox 之前,于客户端 UI 的任何 Mailbox 列表中。排序顺序相等的 Mailbox 应当按名称的字母顺序排序。排序应考虑区域特定的字符顺序约定。

为与现有实现兼容,"未读 Thread"的判定方式本文档不作强制。最简单的实现方案就是:至少有一个 Email 既在本 Mailbox 中、又不带 "$seen" 与 "$draft" 关键字的 Thread 的数量。

然而,一个高质量的实作会返回用户若打开该 Mailbox 将看到的未读项数量。若某个 Thread 包含任何在打开该 Thread 时将被显示的未读 Email,则该 Thread 显示为未读。因此,"unreadThreads" 应为:至少有一个 Email 既在本 Mailbox 中、又不带 "$seen" 与 "$draft" 关键字,并且该 Thread 中至少有一个 Email 位于本 Mailbox 中的 Thread 数量。注意未读 Email 不必是处于本 Mailbox 中的那一个。此外,回收站 Mailbox(即 "role" 为 "trash" 的 Mailbox)需要特殊处理:

  1. 仅为计算其他 Mailbox 的 "unreadThreads" 计数时,那些*仅*在回收站(且不在其他 Mailbox)中的 Email 被忽略。
  2. 仅为计算回收站 Mailbox 的 "unreadThreads" 计数时,不在回收站中的 Email 被忽略。

其结果是,就未读计数而言,回收站中的 Email 被视为处于一个独立的 Thread 中。预期客户端在查看另一 Mailbox 中的某个 Thread 时会隐藏回收站中的 Email,反之亦然。这允许你从某个 Thread 中删除单封 Email 到回收站。

例如,假设你的账户的全部内容是一个包含 2 封 Email 的 Thread:一封在回收站中的未读 Email 与一封在收件箱中的已读 Email。则回收站的 "unreadThreads" 计数为 1,收件箱的计数为 0。

用户可能拥有访问大量共享账户的权限,或拥有一个包含极多 Mailbox 的共享账户,但只对其中少数内容感兴趣。客户端可选择仅显示 "isSubscribed" 属性为 true 的 Mailbox,并提供单独的 UI 让用户查看全部 Mailbox 集合并进行订阅/取消订阅。然而,客户端也可选择忽略此属性,要么为便于实现而整体忽略,要么仅对 "isPersonal" 为 true 的账户忽略(表明这是用户自己的账户而非共享账户)。

此属性对应 IMAP [RFC3501] 的邮箱订阅。

为与 IMAP 兼容,同时属于回收站与另一 Mailbox 的 Email,客户端应将其视为同时存在于两处(即清空回收站时,客户端应仅将其从回收站 Mailbox 中移除,而保留在另一 Mailbox 中)。

支持以下 JMAP 方法。

2.1. Mailbox/get

这是 [RFC8620] 第 5.1 节所述的标准 "/get" 方法。"ids" 参数可以为 "null" 以一次性获取全部。

2.2. Mailbox/changes

这是 [RFC8620] 第 5.2 节所述的标准 "/changes" 方法,但响应中带有一个额外参数:

由于计数频繁变化而其它属性通常很少更改,服务器可通过将 Email/Thread 计数变更与其它状态变更分开记录,来帮助客户端优化数据传输。"updatedProperties" 数组可通过同一请求中的反向引用直接在后续的 "Mailbox/get" 调用中使用,因此若其它内容未变,则仅返回这些属性。

2.3. Mailbox/query

这是 [RFC8620] 第 5.5 节所述的标准 "/query" 方法,但带有以下额外请求参数:

其结果是,Mailbox 按照 parentId 属性作为树排序,每个具有共同父级的子级集合按照标准排序比较器排序。

一个 *FilterCondition* 对象具有以下属性,均可省略:

当且仅当所有给定条件都匹配时,一个 Mailbox 对象才匹配该 FilterCondition。若未指定任何属性,则对所有对象自动为真。

以下 Mailbox 属性必须支持排序:

2.4. Mailbox/queryChanges

这是 [RFC8620] 第 5.6 节所述的标准 "/queryChanges" 方法。

2.5. Mailbox/set

这是 [RFC8620] 第 5.3 节所述的标准 "/set" 方法,但带有以下额外请求参数:

定义了以下额外的 SetError 类型:

对于 "destroy":

2.6. 示例

获取账户中的所有 Mailbox:

                        [[ "Mailbox/get", {
                          "accountId": "u33084183",
                          "ids": null
                        }, "0" ]]

以及响应:

                      [[ "Mailbox/get", {
                        "accountId": "u33084183",
                        "state": "78540",
                        "list": [{
                          "id": "MB23cfa8094c0f41e6",
                          "name": "Inbox",
                          "parentId": null,
                          "role": "inbox",
                          "sortOrder": 10,
                          "totalEmails": 16307,
                          "unreadEmails": 13905,
                          "totalThreads": 5833,
                          "unreadThreads": 5128,
                          "myRights": {
                            "mayAddItems": true,
                            "mayRename": false,
                            "maySubmit": true,
                            "mayDelete": false,
                            "maySetKeywords": true,
                            "mayRemoveItems": true,
                            "mayCreateChild": true,
                            "maySetSeen": true,
                            "mayReadItems": true
                          },
                          "isSubscribed": true
                        }, {
                          "id": "MB674cc24095db49ce",
                          "name": "Important mail",
                          ...
                        }, ... ],
                        "notFound": []
                      }, "0" ]]

现在假设一封 Email 被标记为已读,并且我们收到 Mailbox 状态已变化的推送更新。你可以这样获取更新:

                     [[ "Mailbox/changes", {
                       "accountId": "u33084183",
                       "sinceState": "78540"
                     }, "0" ],
                     [ "Mailbox/get", {
                       "accountId": "u33084183",
                       "#ids": {
                         "resultOf": "0",
                         "name": "Mailbox/changes",
                         "path": "/created"
                       }
                     }, "1" ],
                     [ "Mailbox/get", {
                       "accountId": "u33084183",
                       "#ids": {
                         "resultOf": "0",
                         "name": "Mailbox/changes",
                         "path": "/updated"
                       },
                       "#properties": {
                         "resultOf": "0",
                         "name": "Mailbox/changes",
                         "path": "/updatedProperties"
                       }
                     }, "2" ]]

这先获取已创建/更新/销毁的 Mailbox 的 id 列表,然后利用反向引用,在同一请求中获取仅新建/更新的 Mailbox 的数据。响应可能类似如下:

                   [[ "Mailbox/changes", {
                     "accountId": "u33084183",
                     "oldState": "78541",
                     "newState": "78542",
                     "hasMoreChanges": false,
                     "updatedProperties": [
                       "totalEmails", "unreadEmails",
                       "totalThreads", "unreadThreads"
                     ],
                     "created": [],
                     "updated": ["MB23cfa8094c0f41e6"],
                     "destroyed": []
                   }, "0" ],
                   [ "Mailbox/get", {
                     "accountId": "u33084183",
                     "state": "78542",
                     "list": [],
                     "notFound": []
                   }, "1" ],
                   [ "Mailbox/get", {
                     "accountId": "u33084183",
                     "state": "78542",
                     "list": [{
                       "id": "MB23cfa8094c0f41e6",
                       "totalEmails": 16307,
                       "unreadEmails": 13903,
                       "totalThreads": 5833,
                       "unreadThreads": 5127
                     }],
                     "notFound": []
                   }, "2" ]]

下面是一个尝试重命名一个 Mailbox 并销毁另一个的示例:

                   [[ "Mailbox/set", {
                     "accountId": "u33084183",
                     "ifInState": "78542",
                     "update": {
                       "MB674cc24095db49ce": {
                         "name": "Maybe important mail"
                       }
                     },
                     "destroy": [ "MB23cfa8094c0f41e6" ]
                   }, "0" ]]

假设重命名成功,但我们没有权限销毁试图销毁的那个 Mailbox;我们可能收到:

                     [[ "Mailbox/set", {
                       "accountId": "u33084183",
                       "oldState": "78542",
                       "newState": "78549",
                       "updated": {
                           "MB674cc24095db49ce": null
                       },
                       "notDestroyed": {
                         "MB23cfa8094c0f41e6": {
                           "type": "forbidden"
                         }
                       }
                     }, "0" ]]

3. 会话线索(Threads)

回复与原消息被归并到一起构成一个 Thread。在 JMAP 中,一个 Thread 就是一个按日期排序的 Email 扁平列表。每封 Email 都必须属于某个 Thread,即便它是该 Thread 中唯一的 Email。

判定两封 Email 是否属于同一 Thread 的确切算法,本规范不作强制,以便与不同现有系统兼容。对于新的实现,建议当以下两个条件均满足时,两封消息属于同一 Thread:

  1. 在 Message-Id、In-Reply-To 与 References 头字段中的任意一个里,两封消息出现了相同的 message id [RFC5322]。
  2. 在去除自动添加的前缀(如 "Fwd:"、"Re:"、"[List-Tag]" 等)并忽略空白后,主题相同。这避免了某人回复一封旧消息以方便地找到正确的收件人来发送、却更改主题并开始新对话的情况。

若消息因某种原因乱序投递,用户可能在同一 Thread 中有两封 Email,却缺乏将它们彼此关联的头部。第三封 Email 的到来可能提供缺失的引用,将它们全部合并到单一 Thread 中。由于 Email 的 "threadId" 是不可变的,若服务器希望合并 Thread,它必须通过删除并重新插入(使用新的 Email id)那些改变了 "threadId" 的 Email 来处理。

一个 *Thread* 对象具有以下属性:

支持以下 JMAP 方法。

3.1. Thread/get

这是 [RFC8620] 第 5.1 节所述的标准 "/get" 方法。

3.1.1. 示例

请求:

                       [[ "Thread/get", {
                         "accountId": "acme",
                         "ids": ["f123u4", "f41u44"]
                       }, "#1" ]]

响应:

                 [[ "Thread/get", {
                   "accountId": "acme",
                   "state": "f6a7e214",
                   "list": [
                     {
                       "id": "f123u4",
                       "emailIds": [ "eaa623", "f782cbb"]
                     },
                     {
                       "id": "f41u44",
                       "emailIds": [ "82cf7bb" ]
                     }
                   ],
                   "notFound": []
                 }, "#1" ]]

3.2. Thread/changes

这是 [RFC8620] 第 5.2 节所述的标准 "/changes" 方法。

4. 电子邮件(Emails)

一个 *Email* 对象是对消息 [RFC5322] 的表示,使客户端能够避免 MIME 解析、传输编码与字符编码的复杂性。

4.1. Email 对象的属性

大体上,一个消息由两部分组成:一列头字段,随后是正文。Email 数据类型提供了一种访问完整结构的方式,或在使用简化属性以避免某些复杂性(若对客户端应用已足够)的方式。

虽然可以获取和设置原始头,但绝大多数客户端应为其想要处理的每个头字段使用适当的解析形式,因为这样可使客户端避免有效消息(依 RFC 5322)所需的各种编码的复杂性。

消息的正文通常是一组以树状结构组织的、经过 MIME 编码的文档。它可能是任意嵌套的,但绝大多数邮件客户端呈现的是消息正文的扁平模型(通常为纯文本或 HTML)外加一组附件。展平 MIME 结构以构建此模型可能很困难,并会导致客户端之间的不一致。因此,除了给出完整树的 "bodyStructure" 属性外,Email 对象还包含 3 个带有扁平正文部分列表的替代属性:

"bodyValues" 属性允许客户端直接获取文本部分的值,而无需对 blob 发起第二次请求,并让服务器负责将字符集解码为 unicode。该数据位于独立属性中而非 EmailBodyPart 对象上,是为了避免大量数据的重复,因为若客户端获取了 bodyStructure、textBody 与 htmlBody 中的多个,同一部分可能被包含两次。

在以下小节中,针对内容类型采用了通配符的通用符号约定,因此 "foo/*" 表示任何以 "foo/" 开头的内容类型。

由于涉及的属性众多,Email 属性集在以下四个小节中规定。这纯粹是为了可读性;所有属性都是顶层平级关系。

4.1.1. 元数据

这些属性表示邮件存储中关于消息的元数据,而非通过解析消息本身得到。

关键字与 IMAP 共享。IMAP 的六个系统关键字受到特殊处理。以下四个关键字在 IMAP 中的首字符 "\" 在 JMAP 中改为 "$",并具有特定语义含义:

IMAP 的 "\Recent" 关键字不通过 JMAP 暴露。IMAP 的 "\Deleted" 关键字也不存在:IMAP 使用删除+expunge 模型,而 JMAP 不使用。任何带有 "\Deleted" 关键字的消息都不得通过 JMAP 可见(因此不计入 "totalEmails"、"unreadEmails"、"totalThreads" 与 "unreadThreads" Mailbox 属性)。

用户可向 Email 添加任意关键字。为与 IMAP 兼容,关键字是长度为 1–255 个字符、属于 ASCII 子集 %x21–%x7e(不包括控制字符与空格)的区分大小写字符串,且不得包含以下任何字符:

                              ( ) { ] % * " \

由于 JSON 区分大小写,服务器必须以小写形式返回关键字。

由 [RFC5788] 确立的 IANA "IMAP and JMAP Keywords" 注册表(位于 <https://www.iana.org/assignments/imap-jmap-keywords/>)为一些其他常用关键字赋予语义含义。未来可能在此处确立新关键字。特别需注意:

4.1.2. 头字段解析形式

头字段属性派生自消息头字段 [RFC5322] [RFC6532]。所有头字段都可以原始形式获取。某些头字段也可以解析形式获取。可获取的解析结构取决于具体头字段。这些形式在随后的小节中定义。

4.1.2.1. 原始(Raw)

类型:"String"

头字段值从头字段名结尾冒号之后的第一个八位组开始,直到但不包括头字段终止 CRLF 为止的原始八位组。任何符合标准的消息必须是 ASCII(RFC 5322)或 UTF-8(RFC 6532);但现实中存在其他编码。服务器应将其违反 UTF-8 语法、带有高位设置的所有八位组或八位组串,替换为 Unicode 替换字符(U+FFFD)。任何 NUL 八位组必须被丢弃。

此形式通常带有前导空格,因为大多数生成的消息在终止头字段名的冒号之后插入了一个空格。

4.1.2.2. 文本(Text)

类型:"String"

头字段值经过以下处理:

  1. 空白被展开(如 [RFC5322] 第 2.2.3 节所定义)。
  2. 值末尾终止的 CRLF 被移除。
  3. 值开头的任何 SP 字符被移除。
  4. 任何具有已知字符集、句法正确的编码段 [RFC2047] 被解码。任何按 [RFC2047] 编码的 NUL 八位组或控制字符从解码值中丢弃。任何看似符合 [RFC2047] 语法但违反其放置或空白规则的文本不得被解码。
  5. 所得 unicode 转换为标准等价组合范式 C(NFC)形式。

若任何解码失败,解析器应插入一个 Unicode 替换字符(U+FFFD)并尽可能继续。

为防止明显无意义的、可能导致互操作性问题的行为,此形式只能针对以下头字段获取或设置:

4.1.2.3. 地址(Addresses)

类型:"EmailAddress[]"

头字段被解析为 "address-list" 值(如 [RFC5322] 第 3.4 节所规定),成为 "EmailAddress[]" 类型。从 "address-list" 解析出的每个 "mailbox" 对应一个 EmailAddress 项。组与注释信息被丢弃。

一个 *EmailAddress* 对象具有以下属性:

若没有 "display-name" 但紧随 "addr-spec" 之后有一个 "comment",则应使用该 "comment" 的值。否则,此属性为 null。

任何句法正确的、具有已知编码的编码段 [RFC2047] 必须被解码,遵循与 Text 形式相同的规则(见第 4.1.2.2 节)。

面对无效结构时,解析应尽力而为,以容纳无效消息与半成品草稿。EmailAddress 对象的 "email" 属性可能不符合 "addr-spec" 形式(例如可能不含 @ 符号)。

例如,以下 "address-list" 字符串:

              "  James Smythe" <james@example.com>, Friends:
                jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?=
                <john@example.com>;

将被解析为:

        [
          { "name": "James Smythe", "email": "james@example.com" },
          { "name": null, "email": "jane@example.com" },
          { "name": "John Smith", "email": "john@example.com" }
        ]

为防止明显无意义的、可能导致互操作性问题的行为,此形式只能针对以下头字段获取或设置:

4.1.2.4. 分组地址(GroupedAddresses)

类型:"EmailAddressGroup[]"

这与 Addresses 形式类似,但保留组信息。头字段被解析为 "address-list" 值(如 [RFC5322] 第 3.4 节所规定),成为 "GroupedAddresses[]" 类型。不属于某个组的连续 "mailbox" 值仍被收集到一个 EmailAddressGroup 对象之下,以提供统一类型。

一个 *EmailAddressGroup* 对象具有以下属性:

任何句法正确的、具有已知编码的编码段 [RFC2047] 必须被解码,遵循与 Text 形式相同的规则(见第 4.1.2.2 节)。

面对无效结构时,解析应尽力而为,以容纳无效消息与半成品草稿。

例如,以下 "address-list" 字符串:

              "  James Smythe" <james@example.com>, Friends:
                jane@example.com, =?UTF-8?Q?John_Sm=C3=AEth?=
                <john@example.com>;

将被解析为:

       [
         { "name": null, "addresses": [
           { "name": "James Smythe", "email": "james@example.com" }
         ]},
         { "name": "Friends", "addresses": [
           { "name": null, "email": "jane@example.com" },
           { "name": "John Smith", "email": "john@example.com" }
         ]}
       ]

为防止明显无意义的、可能导致互操作性问题的行为,此形式只能针对与 Addresses 形式相同的头字段获取或设置(见第 4.1.2.3 节)。

4.1.2.5. 消息标识(MessageIds)

类型:"String[]|null"

头字段被解析为 "msg-id" 值列表(如 [RFC5322] 第 3.6.4 节所规定),成为 "String[]" 类型。注释和/或折叠空白(CFWS)以及外围尖括号("<>")被移除。若解析失败,值为 null。

为防止明显无意义的、可能导致互操作性问题的行为,此形式只能针对以下头字段获取或设置:

4.1.2.6. 日期(Date)

类型:"Date|null"

头字段被解析为 "date-time" 值(如 [RFC5322] 第 3.3 节所规定),成为 "Date" 类型。若解析失败,值为 null。

为防止明显无意义的、可能导致互操作性问题的行为,此形式只能针对以下头字段获取或设置:

4.1.2.7. URL

类型:"String[]|null"

头字段被解析为 URL 列表(如 [RFC2369] 所描述),成为 "String[]" 类型。值不包含外围尖括号或头字段中附带的任何注释。若解析失败,值为 null。

为防止明显无意义的、可能导致互操作性问题的行为,此形式只能针对以下头字段获取或设置:

4.1.3. 头字段属性

以下底层 Email 属性为完整访问消息的头数据而规定:

此外,客户端可以请求/发送代表以下形式的各个头字段的属性:

                        header:{header-field-name}

其中 "{header-field-name}" 指任意一个或多个可打印 ASCII 字符的序列(即取值在 33 到 126 之间,含端点)的组合,冒号(:)除外。该属性还可带有以下后缀:

若两个后缀都使用,则必须以上述顺序指定。头字段名以不区分大小写的方式匹配。值的类型取决于所请求的形式,若使用了 :all 则为该类型的数组。若消息中不存在具有所请求名称的头字段,则获取单个实例时值为 null,请求 :all 时为空数组。

作为一个简单示例,若客户端请求名为 "header:subject" 的属性,这意味着找到消息中名为 "subject" 的*最后*一个头字段(不区分大小写匹配),并以原始形式返回值,若未找到该名称的头字段则返回 null。

作为一个更复杂的示例,考虑客户端请求名为 "header:Resent-To:asAddresses:all" 的属性。这意味着:

  1. 找到*所有*名为 Resent-To 的头字段(不区分大小写匹配)。
  2. 对每个实例,以 Addresses 形式解析头字段值。
  3. 结果为 "EmailAddress[][]" 类型——数组中的每一项对应一个 Resent-To 头字段实例的解析值(其本身也是一个数组)。

还为 Email 对象规定了以下便捷属性:

4.1.4. 正文部分(Body Parts)

这些属性派生自消息正文 [RFC5322] 及其 MIME 实体 [RFC2045]。

一个 *EmailBodyPart* 对象具有以下属性:

此外,客户端可以遵循与 Email 对象相同的语法与语义,请求/发送代表各个头字段的 EmailBodyPart 属性,例如 "header:Content-Type"。

为访问消息的正文数据,规定了以下 Email 属性:

有关截断以及启发式确定内容类型与字符集的问题,见安全考量一节。

这些部分都不包含 subParts,包括 "message/*" 类型。附加的消息可使用 "Email/parse" 方法与 "blobId" 获取。

注意,"text/html" 正文部分 [HTML] 可能通过使用 "cid:" 链接引用 Content-Id(如 [RFC2392] 所定义),或通过引用 Content-Location,来引用 attachments 中的图像部分。

该值长度不得超过 256 个字符。

由于这是由服务器从消息内容导出,且导出算法可能随时间改变,第二次获取某 Email 的此值可能返回不同结果。然而,先前的值不被视为错误,且该变化不应导致服务器认为 Email 对象发生了变化。

将 bodyStructure 分解为 textBody、htmlBody 与 attachments 部分列表的确切算法不作强制,因为这是一个服务质量实现问题,且很可能需要在长期内针对发现的畸形内容采用变通方法。然而,基于实际经验,建议以下算法(此处以 JavaScript 表达)作为起点:

  function isInlineMediaType ( type ) {
    return type.startsWith( 'image/' ) ||
           type.startsWith( 'audio/' ) ||
           type.startsWith( 'video/' );
  }

  function parseStructure ( parts, multipartType, inAlternative,
          htmlBody, textBody, attachments ) {

      // For multipartType == alternative
      let textLength = textBody ? textBody.length : -1;
      let htmlLength = htmlBody ? htmlBody.length : -1;

      for ( let i = 0; i < parts.length; i += 1 ) {
          let part = parts[i];
          let isMultipart = part.type.startsWith( 'multipart/' );
          // Is this a body part rather than an attachment
          let isInline = part.disposition != "attachment" &&
              // Must be one of the allowed body types
              ( part.type == "text/plain" ||
                part.type == "text/html" ||
                isInlineMediaType( part.type ) ) &&

              // If multipart/related, only the first part can be inline
              // If a text part with a filename, and not the first item
              // in the multipart, assume it is an attachment
              ( i === 0 ||
                ( multipartType != "related" &&
                  ( isInlineMediaType( part.type ) || !part.name ) ) );

          if ( isMultipart ) {
              let subMultiType = part.type.split( '/' )[1];
              parseStructure( part.subParts, subMultiType,
                  inAlternative || ( subMultiType == 'alternative' ),
                  htmlBody, textBody, attachments );
          } else if ( isInline ) {
              if ( multipartType == 'alternative' ) {
                  switch ( part.type ) {
                  case 'text/plain':
                      textBody.push( part );
                      break;
                  case 'text/html':
                      htmlBody.push( part );
                      break;
                  default:
                      attachments.push( part );
                      break;
                  }
                  continue;
              } else if ( inAlternative ) {
                  if ( part.type == 'text/plain' ) {
                      htmlBody = null;
                  }
                  if ( part.type == 'text/html' ) {
                      textBody = null;
                  }
              }
              if ( textBody ) {
                  textBody.push( part );
              }
              if ( htmlBody ) {
                  htmlBody.push( part );
              }
              if ( ( !textBody || !htmlBody ) &&
                      isInlineMediaType( part.type ) ) {
                  attachments.push( part );
              }
          } else {
              attachments.push( part );
          }
      }

      if ( multipartType == 'alternative' && textBody && htmlBody ) {
          // Found HTML part only
          if ( textLength == textBody.length &&
                  htmlLength != htmlBody.length ) {
              for ( let i = htmlLength; i < htmlBody.length; i += 1 ) {
                  textBody.push( htmlBody[i] );
              }
          }
          // Found plaintext part only
          if ( htmlLength == htmlBody.length &&
                  textLength != textBody.length ) {
              for ( let i = textLength; i < textBody.length; i += 1 ) {
                  htmlBody.push( textBody[i] );
              }
          }
      }
  }

  // Usage:
  let htmlBody = [];
  let textBody = [];
  let attachments = [];

  parseStructure( [ bodyStructure ], 'mixed', false,
      htmlBody, textBody, attachments );

例如,考虑一封同时具有文本与 HTML 版本、且经过列表软件管理器附加了页眉与页脚的消息。它的 MIME 结构可能类似于:

            multipart/mixed
              text/plain, content-disposition=inline - A
              multipart/mixed
                multipart/alternative
                  multipart/mixed
                    text/plain, content-disposition=inline - B
                    image/jpeg, content-disposition=inline - C
                    text/plain, content-disposition=inline - D
                  multipart/related
                    text/html - E
                    image/jpeg - F
                image/jpeg, content-disposition=attachment - G
                application/x-excel - H
                message/rfc822 - J
              text/plain, content-disposition=inline - K

在这种情况下,上述算法会将此分解为:

                     textBody => [ A, B, C, D, K ]
                     htmlBody => [ A, E, K ]
                     attachments => [ C, F, G, H, J ]

4.2. Email/get

这是 [RFC8620] 第 5.1 节所述的标准 "/get" 方法,带有以下额外请求参数:

服务器必须确保截断产生有效的 UTF-8,且不会发生在码点中间。若部分是 "text/html" 类型,服务器不应在 HTML 标签内部截断,例如不应在 "<a href="https://example.com">" 的中间截断。并不要求截断后的形式是平衡的树或有效的 HTML(实际上原始源码很可能既非平衡树也非有效 HTML)。

若标准的 "properties" 参数被省略或为 null,则必须使用以下默认值而非 "all" 属性:

 [ "id", "blobId", "threadId", "mailboxIds", "keywords", "size",
 "receivedAt", "messageId", "inReplyTo", "references", "sender", "from",
 "to", "cc", "bcc", "replyTo", "subject", "sentAt", "hasAttachment",
 "preview", "bodyValues", "textBody", "htmlBody", "attachments" ]

在高质量的实作中,以下属性预期可以快速获取:

客户端在获取任何其他属性时应小心,因为获取与返回这些数据可能存在明显更长的延迟。

如前所述,头的解析形式只能用于适当的头字段。试图获取被禁止的形式(例如 "header:From:asDate")必须导致方法调用被以 "invalidArguments" 错误拒绝。

当作为属性请求了特定头字段时,响应中属性名的大小写必须与请求中使用的大小写完全相同。

4.2.1. 示例

请求:

      [[ "Email/get", {
        "ids": [ "f123u456", "f123u457" ],
        "properties": [ "threadId", "mailboxIds", "from", "subject",
          "receivedAt", "header:List-POST:asURLs",
          "htmlBody", "bodyValues" ],
        "bodyProperties": [ "partId", "blobId", "size", "type" ],
        "fetchHTMLBodyValues": true,
        "maxBodyValueBytes": 256
      }, "#1" ]]

以及响应:

   [[ "Email/get", {
     "accountId": "abc",
     "state": "41234123231",
     "list": [
       {
         "id": "f123u457",
         "threadId": "ef1314a",
         "mailboxIds": { "f123": true },
         "from": [{ "name": "Joe Bloggs", "email": "joe@example.com" }],
         "subject": "Dinner on Thursday?",
         "receivedAt": "2013-10-13T14:12:00Z",
         "header:List-POST:asURLs": [
           "mailto:partytime@lists.example.com"
         ],
         "htmlBody": [{
           "partId": "1",
           "blobId": "B841623871",
           "size": 283331,
           "type": "text/html"
         }, {
           "partId": "2",
           "blobId": "B319437193",
           "size": 10343,
           "type": "text/plain"
         }],
         "bodyValues": {
           "1": {
             "isEncodingProblem": false,
             "isTruncated": true,
             "value": "<html><body><p>Hello ..."
           },
           "2": {
             "isEncodingProblem": false,
             "isTruncated": false,
             "value": "-- Sent by your friendly mailing list ..."
           }
         }
       }
     ],
     "notFound": [ "f123u456" ]
   }, "#1" ]]

4.3. Email/changes

这是 [RFC8620] 第 5.2 节所述的标准 "/changes" 方法。若为大量变更集合生成中间状态,建议先返回较新的变更,因为用户通常对它们更感兴趣。

4.4. Email/query

这是 [RFC8620] 第 5.5 节所述的标准 "/query" 方法,但带有以下额外请求参数:

在高质量的实作中,当过滤器仅由单个 "inMailbox" 属性组成时,查询的 "total" 属性预期可以快速计算,因为它等同于关联 Mailbox 对象的 totalEmails 或 totalThreads 属性(取决于 collapseThreads 是否为 true)。

4.4.1. 过滤

一个 *FilterCondition* 对象具有以下属性,均可省略:

若在 FilterCondition 上未指定任何属性,该条件必须始终求值为真。若指定了多个属性,则必须所有属性都满足该条件才为真(这等价于将对象拆分为单属性条件并使其成为 AND 过滤运算符的所有子项)。

匹配 "String" 字段的确切语义*刻意不予定义*,以便索引实现具有灵活性,但须满足以下约束:

在短语内部,要匹配以下字符之一,必须在其前加反斜杠(\)进行转义:

                                    ' " \

4.4.2. 排序

Comparator 对象上 "property" 字段的以下值必须支持排序:

Comparator 对象上 "property" 字段的以下值应支持排序。当指定 "hasKeyword"、"allInThreadHaveKeyword" 或 "someInThreadHaveKeyword" 排序时,Comparator 对象还必须具有 "keyword" 属性。

服务器也可支持基于其他属性的排序。客户端可通过检查账户的 "capabilities" 对象(见第 1.3 节)来发现支持哪些属性。

排序示例:

                 [{
                   "property": "someInThreadHaveKeyword",
                   "keyword": "$flagged",
                   "isAscending": false
                 }, {
                   "property": "subject",
                   "collation": "i;ascii-casemap"
                 }, {
                   "property": "receivedAt",
                   "isAscending": false
                 }]

这将首先对处于已标记 Thread 中的 Email 排序(若 Thread 内任何 Email 被标记,则该 Thread 被视为已标记),其次按主题顺序,然后对于具有相同主题的消息按从新到旧排序。若两封 Email 在这三个属性上的值完全相同,则顺序取决于服务器但必须稳定。

4.4.3. 会话线索折叠

当 "collapseThreads" 为 true 时,在过滤与排序 Email 列表之后,列表通过移除已经出现过的 Thread id 的 Email(当顺序遍历列表时)被进一步筛选。因此,一个 Thread 在结果中只会*出现一次*,位于属于该 Thread 的第一个 Email 在列表中的位置(给定当前排序/过滤)。

4.5. Email/queryChanges

这是 [RFC8620] 第 5.6 节所述的标准 "/queryChanges" 方法,带有以下额外请求参数:

4.6. Email/set

这是 [RFC8620] 第 5.3 节所述的标准 "/set" 方法。"Email/set" 方法涵盖:

"keywords"/"mailboxIds" 属性的格式意味着,在更新 Email 时,你既可以用属性的完整值替换整个关键字/Mailbox 集合,也可以使用 JMAP 补丁语法添加/移除单个项(规范见 [RFC8620] 第 5.3 节,示例见第 5.7 节)。

由于 Email 对象的格式,在创建 Email 时有多种方式来指定相同信息。为确保所要创建的消息 [RFC5322] 是无歧义的,以下约束适用于为创建而提交的 Email 对象:

违反其中任何一点的创建尝试应被以 "invalidProperties" 错误拒绝;然而,服务器也可选择修改 Email(例如,在冲突的头之间选择、使用不同的内容编码等)以符合其要求来代替。

服务器也可选择设置额外的头。若未包含,服务器必须生成并设置符合 [RFC5322] 第 3.6.4 节的 Message-ID 头字段,以及符合第 3.6.1 节的 Date 头字段。

最终生成的消息可能不符合 RFC 5322。例如,若它是未完成的草稿,To 头字段的值可能不符合该头字段要求的语法。该消息将在提交发送时接受严格一致性检查(见 EmailSubmission 对象描述)。

销毁一封 Email 会将其从其所属的的所有 Mailbox 中移除。要仅仅将 Email 删除到回收站,只需更改 "mailboxIds" 属性,使其现在位于 "role" 属性等于 "trash" 的 Mailbox 中,并移除所有其他 Mailbox id。

清空回收站时,客户端不应销毁同时也位于回收站之外某 Mailbox 中的 Email。对于那些 Email,它们应仅仅移除其回收站 Mailbox。

对于成功创建的 Email 对象,"created" 响应包含该对象的 "id"、"blobId"、"threadId" 与 "size" 属性。

定义了以下额外的 SetError 类型:

对于 "create":

对于 "create" 与 "update":

4.7. Email/copy

这是 [RFC8620] 第 5.4 节所述的标准 "/copy" 方法,但仅在复制期间可设置 "mailboxIds"、"keywords" 与 "receivedAt" 属性。此方法不能修改 Email 所表示的消息。

服务器可禁止两个具有完全相同消息内容 [RFC5322]、甚至仅具有相同 Message-ID [RFC5322] 的 Email 对象共存于一个账户中;若目标账户已有该 Email,复制将以标准的 "alreadyExists" 错误被拒绝。

对于成功复制的 Email 对象,"created" 响应包含新对象的 "id"、"blobId"、"threadId" 与 "size" 属性。

4.8. Email/import

"Email/import" 方法将消息 [RFC5322] 添加到账户中的 Email 集合。服务器必须支持具有邮件地址国际化(EAI)头 [RFC6532] 的消息。消息必须首先使用标准上传机制作为 blob 上传。该方法接受以下参数:

一个 *EmailImport* 对象具有以下属性:

每个要导入的 Email 被视为一个原子单元,可能单独成功或失败。成功导入会从 blobId 所引用的数据创建一个新的 Email 对象,并应用给定的 Mailbox、关键字与 receivedAt 日期。

服务器可禁止两个具有完全相同内容 [RFC5322]、甚至仅具有相同 Message-ID [RFC5322] 的 Email 对象共存于一个账户中。在这种情况下,它必须以 "alreadyExists" SetError 拒绝导入被视为重复的 Email 的尝试。SetError 对象上必须包含一个类型为 "Id" 的 "existingId" 属性,带有现有 Email 的 id。若允许重复,则新创建的 Email 对象必须具有独立于现有对象的 id 以及独立的可变属性。

若 "blobId"、"mailboxIds" 或 "keywords" 属性无效(例如缺失、类型错误、id 未找到),服务器必须以 "invalidProperties" SetError 拒绝导入。

若 Email 因将使账户超出配额而无法导入,导入应以 "overQuota" SetError 被拒绝。

若所引用的 blob 不是有效的消息 [RFC5322],服务器可修改消息以修复错误(例如移除 NUL 八位组或修复无效头)。若如此,响应上的 "blobId" 必须代表新的表示,因此不同于 EmailImport 对象上的 "blobId"。或者,服务器可以以 "invalidEmail" SetError 拒绝导入。

响应具有以下参数:

可能返回以下额外错误以代替 "Email/import" 响应:

"stateMismatch":提供了 "ifInState" 参数,但它与当前状态不匹配。

4.9. Email/parse

此方法允许你将 blob 作为消息 [RFC5322] 解析以获取 Email 对象。服务器必须支持具有 EAI 头 [RFC6532] 的消息。这可用于解析并显示附加的消息,而无需将它们作为顶层 Email 对象导入邮件存储自身。

若被请求,Email 对象上的以下元数据属性将为 null:

若服务器能计算出该 Email 若被导入将被分配到的 Thread,则 Email 的 "threadId" 属性可能呈现。否则,若被获取,此值也为 null。

"Email/parse" 方法接受以下参数:

服务器必须确保截断产生有效的 UTF-8,且不会发生在码点中间。若部分是 "text/html" 类型,服务器不应在 HTML 标签内部截断,例如不应在 "<a href="https://example.com">" 的中间截断。并不要求截断后的形式是平衡的树或有效的 HTML(实际上原始源码很可能既非平衡树也非有效 HTML)。

响应具有以下参数:

如前所述,头的解析形式只能用于适当的头字段。试图获取被禁止的形式(例如 "header:From:asDate")必须导致方法调用被以 "invalidArguments" 错误拒绝。

当作为属性请求了特定头字段时,响应中属性名的大小写必须与请求中使用的大小写完全相同。

4.10. 示例

客户端首次登录。它首先获取 Mailbox 集合。现在它将向用户显示收件箱,我们假定其 Mailbox id 为 "fb666a55"。收件箱可能(非常!)大,但用户的屏幕只有那么大,因此客户端只需加载填满屏幕所需的 Thread,并仅在用户滚动时加载更多。客户端发送此请求:

                      [[ "Email/query",{
                        "accountId": "ue150411c",
                        "filter": {
                          "inMailbox": "fb666a55"
                        },
                        "sort": [{
                          "isAscending": false,
                          "property": "receivedAt"
                        }],
                        "collapseThreads": true,
                        "position": 0,
                        "limit": 30,
                        "calculateTotal": true
                      }, "0" ],
                      [ "Email/get", {
                        "accountId": "ue150411c",
                        "#ids": {
                          "resultOf": "0",
                          "name": "Email/query",
                          "path": "/ids"
                        },
                        "properties": [
                          "threadId"
                        ]
                      }, "1" ],
                      [ "Thread/get", {
                        "accountId": "ue150411c",
                        "#ids": {
                          "resultOf": "1",
                          "name": "Email/get",
                          "path": "/list/*/threadId"
                        }
                      }, "2" ],
                      [ "Email/get", {
                        "accountId": "ue150411c",
                        "#ids": {
                          "resultOf": "2",
                          "name": "Thread/get",
                          "path": "/list/*/emailIds"
                        },
                        "properties": [
                          "threadId",
                          "mailboxIds",
                          "keywords",
                          "hasAttachment",
                          "from",
                          "subject",
                          "receivedAt",
                          "size",
                          "preview"
                        ]
                      }, "3" ]]

让我们拆解这 4 个方法调用,看看它们在做什么:

服务器的响应可能类似于:

    [[ "Email/query", {
      "accountId": "ue150411c",
      "queryState": "09aa9a075588-780599:0",
      "canCalculateChanges": true,
      "position": 0,
      "total": 115,
      "ids": [ "Ma783e5cdf5f2deffbc97930a",
        "M9bd17497e2a99cb345fc1d0a", ... ]
    }, "0" ],
    [ "Email/get", {
      "accountId": "ue150411c",
      "state": "780599",
      "list": [{
        "id": "Ma783e5cdf5f2deffbc97930a",
        "threadId": "T36703c2cfe9bd5ed"
      }, {
        "id": "M9bd17497e2a99cb345fc1d0a",
        "threadId": "T0a22ad76e9c097a1"
      }, ... ],
      "notFound": []
    }, "1" ],
    [ "Thread/get", {
      "accountId": "ue150411c",
      "state": "22a8728b",
      "list": [{
        "id": "T36703c2cfe9bd5ed",
        "emailIds": [ "Ma783e5cdf5f2deffbc97930a" ]
      }, {
        "id": "T0a22ad76e9c097a1",
        "emailIds": [ "M3b568670a63e5d100f518fa5",
          "M9bd17497e2a99cb345fc1d0a" ]
      },  ... ],
      "notFound": []
    }, "2" ],
    [ "Email/get", {
      "accountId": "ue150411c",
      "state": "780599",
      "list": [{
        "id": "Ma783e5cdf5f2deffbc97930a",
        "threadId": "T36703c2cfe9bd5ed",
        "mailboxIds": {
          "fb666a55": true
        },
        "keywords": {
          "$seen": true,
          "$flagged": true
        },
        "hasAttachment": true,
        "from": [{
          "email": "jdoe@example.com",
          "name": "Jane Doe"
        }],
        "subject": "The Big Reveal",
        "receivedAt": "2018-06-27T00:20:35Z",
        "size": 175047,
        "preview": "As you may be aware, we are required to prepare a
          presentation where we wow a panel of 5 random members of the
          public, on or before 30 June each year.  We have drafted..."
      },
      ...
      ],
      "notFound": []
    }, "3" ]]

现在,在另一台设备上,用户将第一封 Email 标记为未读,发送此 API 请求:

                    [[ "Email/set", {
                      "accountId": "ue150411c",
                      "update": {
                        "Ma783e5cdf5f2deffbc97930a": {
                          "keywords/$seen": null
                        }
                      }
                    }, "0" ]]

服务器应用此变更并发送成功响应:

                   [[ "Email/set", {
                     "accountId": "ue150411c",
                     "oldState": "780605",
                     "newState": "780606",
                     "updated": {
                       "Ma783e5cdf5f2deffbc97930a": null
                     },
                     ...
                   }, "0" ]]

用户还删除了几封 Email,随后一封新消息到达。

回到我们的原始机器,我们收到一个推送更新,指出 Email 的状态字符串现在为 "780800"。由于这与客户端当前状态不匹配,它发出请求以获取变更:

               [[ "Email/changes", {
                 "accountId": "ue150411c",
                 "sinceState": "780605",
                 "maxChanges": 50
               }, "3" ],
               [ "Email/queryChanges", {
                 "accountId": "ue150411c",
                 "filter": {
                   "inMailbox": "fb666a55"
                 },
                 "sort": [{
                   "property": "receivedAt",
                   "isAscending": false
                 }],
                 "collapseThreads": true,
                 "sinceQueryState": "09aa9a075588-780599:0",
                 "upToId": "Mc2781d5e856a908d8a35a564",
                 "maxChanges": 25,
                 "calculateTotal": true
               }, "11" ]]

响应:

            [[ "Email/changes", {
              "accountId": "ue150411c",
              "oldState": "780605",
              "newState": "780800",
              "hasMoreChanges": false,
              "created": [ "Me8de6c9f6de198239b982ea2" ],
              "updated": [ "Ma783e5cdf5f2deffbc97930a" ],
              "destroyed": [ "M9bd17497e2a99cb345fc1d0a", ... ]
            }, "3" ],
            [ "Email/queryChanges", {
              "accountId": "ue150411c",
              "oldQueryState": "09aa9a075588-780599:0",
              "newQueryState": "e35e9facf117-780615:0",
              "added": [{
                "id": "Me8de6c9f6de198239b982ea2",
                "index": 0
              }],
              "removed": [ "M9bd17497e2a99cb345fc1d0a" ],
              "total": 115
            }, "11" ]]

客户端可通过移除 "M9bd17497e2a99cb345fc1d0a" 然后将 "Me8de6c9f6de198239b982ea2" 拼接到位置 0,来更新其查询结果本地缓存。由于它没有这封新 Email 的数据,它将随后获取它(也可以在同一个请求中使用反向引用完成此操作)。

它知道 "Ma783e5cdf5f2deffbc97930a" 发生了某些变更,因此也将重新获取该 Email 的 Mailbox id 与关键字(仅有的可变属性)。

用户开始撰写一封新 Email。该邮件为纯文本,且客户端知道其为英文,于是将此项元数据添加到正文部分。用户在撰写仍在进行时保存了一个草稿。客户端发送:

     [[ "Email/set", {
       "accountId": "ue150411c",
       "create": {
         "k192": {
           "mailboxIds": {
             "2ea1ca41b38e": true
           },
           "keywords": {
             "$seen": true,
             "$draft": true
           },
           "from": [{
             "name": "Joe Bloggs",
             "email": "joe@example.com"
           }],
           "subject": "World domination",
           "receivedAt": "2018-07-10T01:03:11Z",
           "sentAt": "2018-07-10T11:03:11+10:00",
           "bodyStructure": {
             "type": "text/plain",
             "partId": "bd48",
             "header:Content-Language": "en"
           },
           "bodyValues": {
             "bd48": {
               "value": "I have the most brilliant plan.  Let me tell
                 you all about it.  What we do is, we",
               "isTruncated": false
             }
           }
         }
       }
     }, "0" ]]

服务器创建该消息并发送成功响应:

       [[ "Email/set", {
         "accountId": "ue150411c",
         "oldState": "780823",
         "newState": "780839",
         "created": {
           "k192": {
             "id": "Mf40b5f831efa7233b9eb1c7f",
             "blobId": "Gf40b5f831efa7233b9eb1c7f8f97d84eeeee64f7",
             "threadId": "Td957e72e89f516dc",
             "size": 359
           }
         },
         ...
       }, "0" ]]

服务器上创建的消息类似于:

 Message-Id: <bbce0ae9-58be-4b24-ac82-deb840d58016@sloti7d1t02>
 User-Agent: Cyrus-JMAP/3.1.6-736-gdfb8e44
 Mime-Version: 1.0
 Date: Tue, 10 Jul 2018 11:03:11 +1000
 From: "Joe Bloggs" <joe@example.com>
 Subject: World domination
 Content-Language: en
 Content-Type: text/plain

 I have the most brilliant plan.  Let me tell you all about it.  What we
 do is, we

用户添加了一个收件人,并将该消息转换为 HTML 以便添加格式,随后保存更新后的草稿:

[[ "Email/set", {
  "accountId": "ue150411c",
  "create": {
    "k1546": {
      "mailboxIds": {
        "2ea1ca41b38e": true
      },
      "keywords": {
        "$seen": true,
        "$draft": true
      },
      "from": [{
        "name": "Joe Bloggs",
        "email": "joe@example.com"
      }],
      "to": [{
        "name": "John",
        "email": "john@example.com"
      }],
      "subject": "World domination",
      "receivedAt": "2018-07-10T01:05:08Z",
      "sentAt": "2018-07-10T11:05:08+10:00",
      "bodyStructure": {
        "type": "multipart/alternative",
        "subParts": [{
          "partId": "a49d",
          "type": "text/html",
          "header:Content-Language": "en"
        }, {
          "partId": "bd48",
          "type": "text/plain",
          "header:Content-Language": "en"
        }]
      },
      "bodyValues": {
        "bd48": {
          "value": "I have the most brilliant plan.  Let me tell
            you all about it.  What we do is, we",
          "isTruncated": false
        },
        "a49d": {
          "value": "<!DOCTYPE html><html><head><title></title>
            <style type=\"text/css\">div{font-size:16px}</style></head>
            <body><div>I have the most <b>brilliant</b> plan.  Let me
            tell you all about it.  What we do is, we</div></body>
            </html>",
          "isTruncated": false
        }
      }
    }
  },
  "destroy": [ "Mf40b5f831efa7233b9eb1c7f" ]
}, "0" ]]

服务器创建新草稿、删除旧草稿,并发送成功响应:

       [[ "Email/set", {
         "accountId": "ue150411c",
         "oldState": "780839",
         "newState": "780842",
         "created": {
           "k1546": {
             "id": "Md45b47b4877521042cec0938",
             "blobId": "Ge8de6c9f6de198239b982ea214e0f3a704e4af74",
             "threadId": "Td957e72e89f516dc",
             "size": 11721
           }
         },
         "destroyed": [ "Mf40b5f831efa7233b9eb1c7f" ],
         ...
       }, "0" ]]

客户端将此草稿移动到另一账户。做到这一点的唯一方式是通过 "Email/copy" 方法。它必须设置一个新的 "mailboxIds" 属性,因为当前值在新目标账户中将不是有效的 Mailbox id:

                 [[ "Email/copy", {
                   "fromAccountId": "ue150411c",
                   "accountId": "u6c6c41ac",
                   "create": {
                     "k45": {
                       "id": "Md45b47b4877521042cec0938",
                       "mailboxIds": {
                         "75a4c956": true
                       }
                     }
                   },
                   "onSuccessDestroyOriginal": true
                 }, "0" ]]

服务器成功复制该 Email 并删除原始 Email。由于隐含调用了 "Email/set",对单一方法调用有两个响应,二者具有相同的方法调用 id:

       [[ "Email/copy", {
         "fromAccountId": "ue150411c",
         "accountId": "u6c6c41ac",
         "oldState": "7ee7e9263a6d",
         "newState": "5a0d2447ed26",
         "created": {
           "k45": {
             "id": "M138f9954a5cd2423daeafa55",
             "blobId": "G6b9fb047cba722c48c611e79233d057c6b0b74e8",
             "threadId": "T2f242ea424a4079a",
             "size": 11721
           }
         },
         "notCreated": null
       }, "0" ],
       [ "Email/set", {
         "accountId": "ue150411c",
         "oldState": "780842",
         "newState": "780871",
         "destroyed": [ "Md45b47b4877521042cec0938" ],
         ...
       }, "0" ]]

5. 搜索摘要(Search Snippets)

当对 "String" 属性进行搜索时,客户端可能希望将正文中与搜索匹配的相关部分作为预览显示,并在该部分以及 Email 的主题中高亮任何匹配词。搜索摘要(Search Snippet)即表示此数据。

一个 *SearchSnippet* 对象具有以下属性:

若主题未匹配过滤器中的文本,此属性为 null。

正文的相关部分用于预览,由服务器定义。若服务器无法确定搜索摘要,它必须为 "subject" 与 "preview" 属性都返回 null。

注意,与大多数数据类型不同,SearchSnippet 没有名为 "id" 的属性。

支持以下 JMAP 方法。

5.1. SearchSnippet/get

要获取搜索摘要,调用 "SearchSnippet/get"。它接受以下参数:

响应具有以下参数:

由于搜索摘要派生于消息内容,且导出算法可能随时间改变,第二次获取相同摘要可能返回不同结果。然而,先前的值不被视为错误,因此不需要状态字符串或更新机制。

可能返回以下额外错误以代替 "SearchSnippet/get" 响应:

"requestTooLarge":客户端请求的 "emailIds" 数量超过了服务器愿意在单次方法调用中处理的最大数量。

"unsupportedFilter":服务器由于任何原因无法处理给定的 "filter"。

5.2. 示例

这里,我们执行了一个 "Email/query" 来搜索账户中包含单词 "foo" 的任何 Email;现在,我们为结果中返回的部分 id 获取搜索摘要:

                     [[ "SearchSnippet/get", {
                       "accountId": "ue150411c",
                       "filter": {
                         "text": "foo"
                       },
                       "emailIds": [
                         "M44200ec123de277c0c1ce69c",
                         "M7bcbcb0b58d7729686e83d99",
                         "M28d12783a0969584b6deaac0",
                         ...
                       ]
                     }, "0" ]]

响应示例:

   [[ "SearchSnippet/get", {
     "accountId": "ue150411c",
     "list": [{
         "emailId": "M44200ec123de277c0c1ce69c",
         "subject": null,
         "preview": null
     }, {
         "emailId": "M7bcbcb0b58d7729686e83d99",
         "subject": "The <mark>Foo</mark>sball competition",
         "preview": "...year the <mark>foo</mark>sball competition will
           be held in the Stadium de ..."
     }, {
         "emailId": "M28d12783a0969584b6deaac0",
         "subject": null,
         "preview": "...the <mark>Foo</mark>/bar method results often
           returns <1 widget rather than the complete..."
     },
     ...
     ],
     "notFound": null
   }, "0" ]]

6. 身份(Identities)

一个 *Identity* 对象存储有关用户可从其发送邮件的电子邮件地址或域的信息。它具有以下属性:

有关 EmailAddress 的定义,见 Email 对象(第 4.1.2.3 节)中的 "Addresses" 头字段形式描述。

允许存在具有相同电子邮件地址的多个 Identity,以便用户可在不同设置之间选择(例如具有不同名称/签名)。

支持以下 JMAP 方法。

6.1. Identity/get

这是 [RFC8620] 第 5.1 节所述的标准 "/get" 方法。"ids" 参数可以为 null 以一次性获取全部。

6.2. Identity/changes

这是 [RFC8620] 第 5.2 节所述的标准 "/changes" 方法。

6.3. Identity/set

这是 [RFC8620] 第 5.3 节所述的标准 "/set" 方法。定义了以下额外的 SetError 类型:

对于 "create":

6.4. 示例

请求:

                           [ "Identity/get", {
                             "accountId": "acme"
                           }, "0" ]

响应:

        [ "Identity/get", {
          "accountId": "acme",
          "state": "99401312ae-11-333",
          "list": [
            {
              "id": "XD-3301-222-11_22AAz",
              "name": "Joe Bloggs",
              "email": "joe@example.com",
              "replyTo": null,
              "bcc": [{
                "name": null,
                "email": "joe+archive@example.com"
              }],
              "textSignature": "-- \nJoe Bloggs\nMaster of Email",
              "htmlSignature": "<div><b>Joe Bloggs</b></div>
                <div>Master of Email</div>",
              "mayDelete": false
            },
            {
              "id": "XD-9911312-11_22AAz",
              "name": "Joe B",
              "email": "*@example.com",
              "replyTo": null,
              "bcc": null,
              "textSignature": "",
              "htmlSignature": "",
              "mayDelete": true
            }
          ],
          "notFound": []
        }, "0" ]

7. 邮件提交(Email Submission)

一个 *EmailSubmission* 对象表示将 Email 提交投递给一个或多个收件人。它具有以下属性:

当 JMAP 服务器执行 SMTP 消息提交时,它可将同一 id 字符串用于 ENVID 参数 [RFC3461] 与 EmailSubmission 对象 id。这样做(将 ENVID 设为服务器提供值)的服务器可替换客户端提供的 ENVID 值。

一个 *Address* 对象具有以下属性:

若 "envelope" 属性在创建时为 null 或省略,服务器必须按如下方式从所引用的 Email 生成:

在不支持撤回的系统上,此属性的值将始终为 "final"。在支持取消提交的系统上,它将以 "pending" 开始,并可能在服务器确定其肯定无法召回消息时转变为 "final",但它也可能仅保持 "pending"。若处于 pending 状态,客户端可通过将此属性设为 "canceled" 来尝试取消提交;若更新成功,则提交被成功取消,且消息未被投递给任何原始收件人。

此值是从每个收件人的电子邮件地址到 DeliveryStatus 对象的映射。

一个 *DeliveryStatus* 对象具有以下属性:

多行 SMTP 响应应按如下方式连接为单个字符串:

例如:

          550-5.7.1 Our system has detected that this message is
          550 5.7.1 likely spam.

将变为:

    550 5.7.1 Our system has detected that this message is likely spam.

对于经由 SMTP 替代方式转发的消息,服务器可生成表示状态的合成字符串。若如此,该字符串必须采用以下形式:

注意,成功转发到外部 SMTP 服务器不应被视为消息已成功到达最终邮件存储的指示。不过在此情况下,若被请求,服务器可能收到 DSN 响应。

若收到收件人的 DSN,且 Action 等于 "delivered"(依 [RFC3464] 第 2.3.3 节),则 "delivered" 属性应设为 "yes";若 Action 等于 "failed",该属性应设为 "no"。收到任何其他 DSN 不应影响此属性。

服务器也可基于其他反馈渠道设置此属性。

若收到此收件人的消息处置通知(MDN),且 Disposition-Type(依 [RFC8098] 第 3.2.6.2 节)等于 "displayed",则此属性应设为 "yes"。

服务器也可基于其他反馈渠道设置此属性。

若 JMAP 服务器关联到某个 EmailSubmission 对象,它可选择不将 DSN 与 MDN 响应作为 Email 对象暴露。它应仅在改为在 "dsnBlobIds" 与 "mdnBlobIds" 字段中暴露它们时才这样做,并且它期望用户使用能够通过 EmailSubmission 对象获取并显示投递状态的客户端。

为求效率,服务器可在消息成功发送后、或完成重试发送后随时销毁 EmailSubmission 对象。对于非常基础的 SMTP 代理,这可能是在创建后立即进行,因为它无法分配真实 id 并在稍后获取时再次返回该信息。

支持以下 JMAP 方法。

7.1. EmailSubmission/get

这是 [RFC8620] 第 5.1 节所述的标准 "/get" 方法。

7.2. EmailSubmission/changes

这是 [RFC8620] 第 5.2 节所述的标准 "/changes" 方法。

7.3. EmailSubmission/query

这是 [RFC8620] 第 5.5 节所述的标准 "/query" 方法。

一个 *FilterCondition* 对象具有以下属性,均可省略:

当且仅当所有给定条件都匹配时,EmailSubmission 对象才匹配该 FilterCondition。若未指定任何属性,则对所有对象自动为真。

以下 EmailSubmission 属性必须支持排序:

7.4. EmailSubmission/queryChanges

这是 [RFC8620] 第 5.6 节所述的标准 "/queryChanges" 方法。

7.5. EmailSubmission/set

这是 [RFC8620] 第 5.3 节所述的标准 "/set" 方法,带有以下两个额外请求参数:

在处理完 "EmailSubmission/set" 调用中的所有 create/update/destroy 项之后,必须进行一次隐式的 "Email/set" 调用来执行这两个参数中请求的任何变更。该调用的响应必须在 "EmailSubmission/set" 响应之后返回。

通过创建 EmailSubmission 对象来发送 Email。在处理每个 create 时,服务器必须检查消息是否有效,且用户拥有充分授权来发送它。若创建成功,消息将被发送到信封 "rcptTo" 参数中给出的收件人。服务器必须在投递期间移除消息上存在的任何 Bcc 头字段。服务器可按照其策略在投递期间添加或移除提交消息的其他头字段,或进行进一步更改。

若在 EmailSubmission 对象创建之后的任何时刻所引用的 Email 被销毁,这不得改变提交的行为(即它不会取消未来的发送)。EmailSubmission 对象的 "emailId" 与 "threadId" 属性保留,但若相应对象已被销毁,尝试获取它们(通过标准 "Email/get" 调用)将返回 "notFound" 错误。

类似地,销毁 EmailSubmission 对象不得影响它所表示的投递。它纯粹移除该提交的记录。服务器可在一段时间后或响应其他触发器时自动销毁 EmailSubmission 对象,并可禁止客户端手动销毁 EmailSubmission 对象。

若待发送的消息大于服务器支持发送的大小,必须返回标准的 "tooLarge" SetError。SetError 上必须存在一个 "maxSize" "UnsignedInt" 属性,指定可发送消息的最大大小(以八位组计)。

若给定的 Email 或 Identity id 无法找到,提交创建以标准的 "invalidProperties" SetError 被拒绝。

定义了以下额外的 SetError 类型:

对于 "create":

对于 "update":

7.5.1. 示例

以下示例假定待发送 Email 的草稿已被保存,且其 Email id 为 "M7f6ed5bcfd7e2604d1753f6c"。此调用随后立即发送该 Email,并在成功时移除 "$draft" 标记,并将其从草稿文件夹(Mailbox id 为 "7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e")移动到已发送文件夹(我们假定其 Mailbox id 为 "73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6")。

      [[ "EmailSubmission/set", {
        "accountId": "ue411d190",
        "create": {
          "k1490": {
            "identityId": "I64588216",
            "emailId": "M7f6ed5bcfd7e2604d1753f6c",
            "envelope": {
              "mailFrom": {
                "email": "john@example.com",
                "parameters": null
              },
              "rcptTo": [{
                "email": "jane@example.com",
                "parameters": null
              },
              ...
              ]
            }
          }
        },
        "onSuccessUpdateEmail": {
          "#k1490": {
            "mailboxIds/7cb4e8ee-df87-4757-b9c4-2ea1ca41b38e": null,
            "mailboxIds/73dbcb4b-bffc-48bd-8c2a-a2e91ca672f6": true,
            "keywords/$draft": null
          }
        }
      }, "0" ]]

成功的响应可能如下所示。注意由于隐式的 "Email/set" 调用,存在两个响应,但二者具有相同的方法调用 id,因为它们源于请求中的同一次调用:

           [[ "EmailSubmission/set", {
             "accountId": "ue411d190",
             "oldState": "012421s6-8nrq-4ps4-n0p4-9330r951ns21",
             "newState": "355421f6-8aed-4cf4-a0c4-7377e951af36",
             "created": {
               "k1490": {
                 "id": "ES-3bab7f9a-623e-4acf-99a5-2e67facb02a0"
               }
             }
           }, "0" ],
           [ "Email/set", {
             "accountId": "ue411d190",
             "oldState": "778193",
             "newState": "778197",
             "updated": {
                 "M7f6ed5bcfd7e2604d1753f6c": null
             }
           }, "0" ]]

假设管理员改为移除了用户的发送权限,因此提交以 "forbiddenToSend" 错误被拒绝。该错误的 description 参数旨在显示给用户,因此应适当本地化。假设请求是带着如下 Accept-Language 头发送的:

                    Accept-Language: de;q=0.9,en;q=0.8

服务器应尝试基于 Accept-Language 头,从其所拥有的本地化中选择最佳的,如 [RFC8620] 第 3.8 节所述。若服务器拥有英语、法语与德语翻译,它将选择德语作为首选语言,并返回如下响应:

[[ "EmailSubmission/set", {
  "accountId": "ue411d190",
  "oldState": "012421s6-8nrq-4ps4-n0p4-9330r951ns21",
  "newState": "012421s6-8nrq-4ps4-n0p4-9330r951ns21",
  "notCreated": {
    "k1490": {
      "type": "forbiddenToSend",
      "description": "Verzeihung, wegen verdaechtiger Aktivitaeten Ihres
       Benutzerkontos haben wir den Versand von Nachrichten gesperrt.
       Bitte wenden Sie sich fuer Hilfe an unser Support Team."
    }
  }
}, "0" ]]

8. 休假回复(Vacation Response)

休假回复在消息投递到邮件存储时发送自动回复,告知原始发件人其消息可能在一段时间内不会被阅读。

自动发送消息可能产生不良行为。为避免此问题,实现者必须遵循 [RFC3834] 中提出的建议。

*VacationResponse* 对象表示账户中与休假回复相关设置的状态。它具有以下属性:

支持以下 JMAP 方法。

8.1. VacationResponse/get

这是 [RFC8620] 第 5.1 节所述的标准 "/get" 方法。

一个账户中必须且只能有一个 VacationResponse 对象。它必须具有 id "singleton"。

8.2. VacationResponse/set

这是 [RFC8620] 第 5.3 节所述的标准 "/set" 方法。

9. 安全考量

JMAP [RFC8620] 的所有安全考量均适用于本规范。针对本文档引入的数据类型与功能的额外考量在以下小节中描述。

9.1. EmailBodyPart 值

服务提供商通常对传入消息执行安全过滤,并且让安全过滤器对内容类型与字符集的检测与 JMAP 服务器所执行的启发式方法保持一致非常重要。对 EmailBodyValue 应用启发式方法来确定内容类型或字符集的服务器应记录这些启发式方法,并在其与特定邮件主机使用的安全过滤器不一致时提供将其关闭的机制。

允许为 ASCII 文本提供隐藏通道的字符集的自动转换(例如 UTF-7),在过去一直对安全过滤器造成问题,因此服务器实现可以通过默认关闭此类转换和/或使其可单独配置来缓解此风险。

为允许客户端限制其可在响应中接收的数据量,可以为文本正文部分返回的数据请求最大长度。然而,截断数据可能改变语义含义,例如截断 URL 会改变其位置。扫描恶意站点链接的服务器应小心确保截断不发生在语义重要点,或在返回之前重新扫描截断值中的恶意内容。

9.2. HTML 邮件显示

HTML 消息正文为消息提供了更丰富的格式,但也带来了一系列安全挑战,尤其是在与界面 HTML 结合嵌入到 webmail 上下文中时。渲染 HTML 消息的客户端应仔细考虑潜在风险,包括:

客户端有多种方式可以缓解这些问题,采用结合多种技术的纵深防御方法将提供最强的安全性。

作为高度复杂的软件组件,HTML 渲染引擎显著增加了客户端的攻击面,尤其是在用于处理不可信、可能恶意的 content 时。

过去曾在图像解码器、JavaScript 引擎与 HTML 解析器中发现了严重缺陷,可能导致整个系统被攻陷。使用某引擎的客户端应确保获取最新版本,并持续并入供应商发布的任何安全补丁。

9.3. 多部分显示

消息可能由多个要按顺序作为正文显示的部分组成。客户端必须隔离渲染每个部分,且不得连接原始文本值来渲染。这样做可能改变消息的整体语义。若客户端或服务器正在解密 Pretty Good Privacy(PGP)或 S/MIME 加密的部分,与其他部分连接可能将解密后的文本泄露给攻击者,如 [EFAIL] 所述。

9.4. 邮件提交

SMTP 提交服务器 [RFC6409] 使用多种机制来缓解受损用户账户与终端系统造成的损害,包括速率限制、反病毒/反垃圾邮件 milter(邮件过滤器)以及其他技术。当这些技术拥有关于客户端连接的更多信息时,它们工作得更好。若 JMAP 邮件提交实现为对 SMTP 提交服务器的代理,则将此信息从 JMAP 代理传达给提交服务器是有用的。事实上的 SMTP XCLIENT 扩展 [XCLIENT] 可用于此,但建议使用经认证的通道,以将该扩展的使用限制于显式授权的代理。

代理到 SMTP 提交服务器的 JMAP 服务器应允许使用提交端口 [RFC8314]。强烈鼓励实现类似 SMTP XCLIENT 的机制。虽然简单认证与安全层(SASL)PLAIN over TLS [RFC4616] 目前是与 SMTP 提交服务器 [RFC4954] 互操作必须实现的机制,但 JMAP 提交代理应针对此用例实现并偏好更强的机制,例如带有 SASL EXTERNAL 的 TLS 客户端证书认证([RFC4422] 附录 A)或加盐质询响应认证机制(SCRAM)[RFC7677]。

若 JMAP 服务器直接将邮件转发到其他管理域的 SMTP 服务器,强烈鼓励实现事实上的 [milter] 协议,以集成第三方产品,处理包括反病毒/反垃圾邮件、声誉保护、合规归档与数据丢失防护在内的安全问题。代理到本地 SMTP 提交服务器可能是提供此类安全服务的更简单方式。

9.5. 部分账户访问

用户可能仅有权访问一个账户中存在的部分数据。为避免泄露未授权信息,在此情况下,服务器必须将用户无权访问的任何数据视为如同其不存在。

例如,假设用户 A 有一个包含两个 Mailbox(inbox 与 sent)的账户,但仅与用户 B 共享 inbox。在此情况下,当用户 B 获取该账户的 Mailbox 时,服务器必须表现得如同 sent Mailbox 不存在。类似地,在查询或获取 Email 对象时,它必须将仅属于 sent Mailbox 的任何消息视为如同其不存在。获取 Thread 对象必须仅返回用户有权访问的 Email 对象的 id;若没有,则 Thread 再次必须被视为如同其不存在。

若服务器禁止单个账户拥有两条相同消息、或两条具有相同 Message-Id 头字段的消息,则具有写权限的用户可利用尝试创建/导入此类消息时返回的错误,来探测其是否已存在于账户中不可访问的部分。

9.6. 从某地址发送的权限

近年来,电子邮件生态系统已转向将信任与消息 [RFC5322] 的 From 地址相关联,尤其是通过基于域的消息认证、报告与一致性(DMARC)[RFC7489] 等方案。

账户中的 Identity 对象集合(见第 6 节)让客户端知道用户有权从中发送的电子邮件地址。每次邮件提交都关联一个 Identity,服务器应拒绝消息的 From 头字段与所关联 Identity 不对应的提交。

服务器可允许例外,将已接收到邮件存储中的现有消息的精确副本发送到另一地址(也称为"重定向"或"退回/bounce"),尽管建议服务器将此限制于用户已验证其也控制的接收目的地。

若用户尝试创建新的 Identity 对象,且用户无权使用该电子邮件地址发送,服务器必须以适当的错误拒绝它。

SMTP MAIL FROM 地址 [RFC5321] 常与 From 消息头字段 [RFC5322] 混淆。用户通常只看到消息头字段中的地址,这也是要强制执行的主要地址。然而,服务器也必须对 MAIL FROM 地址 [RFC5321] 强制执行适当的限制,以阻止用户用退回与非投递通知淹没第三方地址。

JMAP 提交模型为两种上下文中的不可允许地址提供了独立的错误。

10. IANA 考量

10.1. "mail" 的 JMAP 能力注册

IANA 已按如下方式注册 "mail" JMAP 能力:

10.2. "submission" 的 JMAP 能力注册

IANA 已按如下方式注册 "submission" JMAP 能力:

10.3. "vacationresponse" 的 JMAP 能力注册

IANA 已按如下方式注册 "vacationresponse" JMAP 能力:

10.4. IMAP 与 JMAP 关键字注册表

本文档对 [RFC5788] 所定义的 IMAP 关键字注册表做了两处变更。

第一,将该注册表的名称改为"IMAP 与 JMAP 关键字(IMAP and JMAP Keywords)注册表"。

第二,在模板与注册表中新增一个"作用域(scope)"列,用以说明某关键字适用于 "IMAP-only"(仅 IMAP)、"JMAP-only"(仅 JMAP)、"both"(两者)还是 "reserved"(保留)。已经存在于 IMAP 关键字注册表中的所有关键字均被标记为作用域 "both"。"reserved" 状态可用于阻止将来注册某个一旦被注册会造成困扰的名称。作用域为 "reserved" 的关键字的注册可省略注册模板中的大部分字段(参见下文 "$recent" 的注册示例);此类注册应当很少发生。

若协议中出现被标记为 "JMAP-only" 或 "reserved" 的关键字,IMAP 客户端可静默忽略。若协议中出现被标记为 "IMAP-only" 或 "reserved" 的关键字,JMAP 客户端可静默忽略。

新增的 "JMAP-only" 关键字在各小节中注册。这些关键字对应于 IMAP 系统关键字,因此不适合在 IMAP 中使用。除非通过标准行动(standards action),否则它们此后不能再被注册用于 IMAP。

10.4.1. JMAP 关键字 "$draft" 的注册

本小节在"IMAP 与 JMAP 关键字"注册表中注册 "JMAP-only" 关键字 "$draft"。

10.4.2. JMAP 关键字 "$seen" 的注册

本小节在"IMAP 与 JMAP 关键字"注册表中注册 "JMAP-only" 关键字 "$seen"。

10.4.3. JMAP 关键字 "$flagged" 的注册

本小节在"IMAP 与 JMAP 关键字"注册表中注册 "JMAP-only" 关键字 "$flagged"。

10.4.4. JMAP 关键字 "$answered" 的注册

本小节在"IMAP 与 JMAP 关键字"注册表中注册 "JMAP-only" 关键字 "$answered"。

10.4.5. "$recent" 关键字的注册

本小节在"IMAP 与 JMAP 关键字"注册表中注册关键字 "$recent"。

10.5. IMAP 邮箱名称属性注册表

10.5.1. "inbox" 角色的注册

本小节在 [RFC8457] 所建立的"IMAP 邮箱名称属性(IMAP Mailbox Name Attributes)注册表"中注册 "JMAP-only" 属性 "inbox"。

10.6. JMAP 错误代码注册表

以下小节在 [RFC8620] 所定义的"JMAP 错误代码(JMAP Error Codes)注册表"中注册若干新的错误代码。

10.6.1. mailboxHasChild

10.6.2. mailboxHasEmail

10.6.3. blobNotFound

10.6.4. tooManyKeywords

10.6.5. tooManyMailboxes

10.6.6. invalidEmail

10.6.7. tooManyRecipients

10.6.8. noRecipients

10.6.9. invalidRecipients

10.6.10. forbiddenMailFrom

10.6.11. forbiddenFrom

10.6.12. forbiddenToSend

11. 参考文献

11.1. 规范性参考文献

11.2. 资料性参考文献

作者地址

Neil Jenkins
Fastmail
PO Box 234, Collins St. West
Melbourne, VIC 8007
Australia(澳大利亚)

电子邮箱:neilj@fastmailteam.com
URI:https://www.fastmail.com

Chris Newman
Oracle
440 E. Huntington Dr., Suite 400
Arcadia, CA 91006
United States of America(美国)

电子邮箱:chris.newman@oracle.com