2017 年 8 月

第 32 卷,第 8 期

Microsoft Office - Outlook 的可操作邮件

作者 Woon Kiat Kiat | 2017 年 8 月

我喜欢电子邮件。工作时,我都是通过电子邮件,及时了解最新动态以及需要完成的工作。在电子邮件中,我可以看到自己团队提交新费用报表的通知、我的推文的新回复、拉取请求的新评论等。但电子邮件可以有更好的表现。我为什么需要先单击电子邮件中的链接,并等待财务系统网站在浏览器中加载后,才能审批费用报表呢? 我又为什么必须在头脑里更改上下文呢? 我应当能够在电子邮件客户端上下文中直接审批费用报表。

听起来是不是很熟悉? Outlook 旨在改善用户生活、节省用户时间并提高用户工作效率。

可操作邮件简介

使用可操作邮件,用户可以在电子邮件本身中完成任务。通过它,用户可以在 Outlook 桌面客户端和 Outlook Web Access (OWA) 中获得本机体验。在本文中,我将使用 Outlook 一词表示 Outlook 桌面客户端或 OWA。

在我要用的示例中,虚构公司 Contoso 有一个内部费用审批系统。每当有员工提交费用报表,经理就会收到供审批的电子邮件。我将逐步介绍如何在 Outlook 中使用可操作邮件。通过此类邮件,经理可以在电子邮件本身中批准请求。

我的首个可操作邮件

图 1 展示了可操作邮件的 HTML。虽然看起来可能很复杂,但请相信我,它并不复杂。我将在下面各部分中详细介绍标记。第一步是使用图 1**** 中的标记,将电子邮件发送到 Office 365 电子邮件帐户。

图1:Outlook 可操作邮件的 HTML

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf8">
    <script type="application/ld+json">{
      "@context": "http://schema.org/extensions",
      "@type": "MessageCard",
      "hideOriginalBody": "true",
      "title": "Expense report is pending your approval",
      "sections": [{
        "text": "Please review the expense report below.",
        "facts": [{
          "name": "ID",
          "value": "98432019"
        }, {
          "name": "Amount",
          "value": "83.27 USD"
        }, {
          "name": "Submitter",
          "value": "Kathrine Joseph"
        }, {
          "name": "Description",
          "value": "Dinner with client"
        }]
      }],
      "potentialAction": [{
        "@type": "HttpPost",
        "name": "Approve",
        "target": ""
      }, {
        "@type": "OpenUri",
        "name": "View Expense",
        "targets": [ { "os": "default", 
        "uri": "https://expense.contoso.com/view?id=98432019"} ]
      }]
    }
    </script>
  </head>
  <body>
    <p>Please <a href="https://expense.contoso.com/view?id=98432019">approve</a> 
      expense report #98432019 for $83.27.</p>
  </body>
</html>

如图 2 所示,邮件本身中有一个邮件卡,其中包含两个可交互的按钮。如果单击“批准”按钮,暂时会生成错误,因为还没有指定操作 URL。稍后将添加 URL。如果单击“查看费用”按钮,将打开浏览器,并转到“费用审批”网站。

Outlook Web Access 中的可操作邮件
图 2:Outlook Web Access 中的可操作邮件****

MessageCard 标记

电子邮件本身就是典型的 HTML 标记。若要在 Outlook 中实现可操作邮件,可以在 <script> 元素中插入 MessageCard 标记。这种方法的一大优点是,电子邮件可以继续照常在无法识别 MessageCard 标记的客户端上呈现。此标记的格式称为“JSON-LD”。这是一种标准格式,用于在 Internet 上创建计算机可读数据。现在,我们将详细探讨此标记。下面这两行代码在每个标记中都是必需的:

"@context": "http://schema.org/extensions",
"@type": "MessageCard",

将上下文设置为“http://schema.org/extensions”,并将类型设置为“MessageCard”。 MessageCard 类型表明此电子邮件为可操作邮件。

接下来是属性“hideOriginalBody”。 如果值设置为“True”,将隐藏电子邮件正文,而只显示邮件卡,如图 2 所示。如果邮件卡本身包含用户所需的全部信息,或邮件卡内容导致电子邮件正文内容变得多余,此设置尤为实用。如果在无法识别邮件卡的电子邮件客户端中查看邮件,将看到原始正文,而看不到邮件卡,无论“hideOriginalBody”值如何。 属性“title”的值是 MessageCard 的标题:

"hideOriginalBody": "true",
"title": "Expense report is pending your approval",

接下来是“section”。 可以将分区看作是“活动”。 如果邮件卡有多个活动,绝对应使用多个分区,每个分区对应一个活动。图 3 展示了包含一个分区的标记。使用分区的 facts 属性(一组名称/值对)显示费用报表的详细信息。

图 3:包含一个分区的邮件卡

"sections": [{
  "text": "Please review the expense report below.",
  "facts": [{
    "name": "ID",
    "value": "98432019"
  }, {
    "name": "Amount",
    "value": "83.27 USD"
  }, {
    "name": "Submitter",
    "value": "Jonathan Kiev"
  }, {
    "name": "Description",
    "value": "Dinner with client"
  }]
}],

接下来是“potentialAction”。 这是可以对此邮件卡调用的一系列操作。目前,支持操作 OpenUri 和 HttpPOST:

"potentialAction": [{
  "@type": "HttpPost",
  "name": "Approve",
  "target": ""
}, {
  "@type": "OpenUri",
  "name": "View Expense",
  "targets": [ { "os": "default",
  "uri": "https://expense.contoso.com/view?id=98432019"} ]
}]

OpenUri 操作打开浏览器,并转到 target 属性中指定的 URL。target 属性是一个数组,可用于指定平台专属 URL。例如,可能希望 iOS 和 Android 上的用户转到不同的 URL。在此示例中,将 OS 设置为默认值。也就是说,所有平台的 URL 都是相同的。

HttpPOST 操作向 target 属性中指定的外部 Web 服务发出 HTTP POST 请求。值暂为空。正因如此,单击“批准”按钮时会看到错误消息。

MessageCard 样本应用

如果在创建标记时能够呈现邮件卡的外观,将会带来极大的帮助。Microsoft 提供具有此用途的 Web 应用。这就是 MessageCard 样本应用 (bit.ly/2s274S9)。

一律应先在此应用中设计邮件卡。对邮件卡的布局感到满意后,便可以对电子邮件使用标记了。

通过 HttpPOST 操作调用外部 Web 服务

至此,已有一张包含两个操作的邮件卡。OpenUri 打开浏览器,并转到操作中指定的 URL。对于 HttpPOST 操作,希望它调用可批准批费用报表的 REST API。可以将 HttpPOST 操作替换为下列代码:

{
  "@type": "HttpPost",
  "name": "Approve",
  "target": "https://api.contoso.com/expense/approve",
  "body": "{ \"id\": \"98432019\" }"
}

当用户单击“批准”按钮时,Microsoft 服务器会发出如下 HTTP POST 请求:

POST api.contoso.com/expense/approve
Content-Type: application/json

{ "id": "98432019" }

target 是 Microsoft 服务器向其发出 POST 请求的 URL,而 body 是请求内容。body 内容始终假定为 JSON。

现在,将使用新标记向自己发送电子邮件。单击“批准”按钮后,操作成功完成。

ActionCard 操作

现在,让我们添加“拒绝”按钮,以便用户能够拒绝费用报表。若要提供“拒绝”按钮,需要另外获取用户输入,以说明费用报表遭拒原因。

ActionCard 操作专为此而设计。它包含一个或多个输入以及相关操作(可以是 OpenUri,也可以是 HttpPost)。在 HttpPOST 和 OpenUri 之间插入 ActionCard 操作,如图 4**** 所示。

图 4:ActionCard 操作

"potentialAction": [{
  "@type": "HttpPost",
  ...
}, {
  "@type": "ActionCard",
  "name": "Reject",
  "inputs": [{
    "@type": "TextInput",
    "id": "comment",
    "isMultiline": true,
    "title": "Explain why the expense report is rejected"
  }],
  "actions": [{
    "@type": "HttpPOST",
    "name": "Reject",
    "target": "https://api.contoso.com/expense/reject",
    "body": "{ \"id\": \"98432019\", \"comment\": \"{{rejectComment.value}}\" }"
  }]
},{
  "@type": "OpenUri",
  ...
}]

如果向自己发送更新后的标记,就会看到“批准”、“拒绝”和“查看费用”按钮。如果单击“拒绝”按钮,现在可以先输入注释,然后再拒绝费用报表。

让我们来看看如何实际使用 ActionCard 操作标记。除了类型和名称属性之外,它还包含一系列输入和操作。在此示例中,使用多行 TextInput,方便用户输入文本。支持的其他输入包括 DateInput 和 Multichoice­Input。有关详细信息,请访问 bit.ly/2t3bLJN

使用 HttpPOST 操作,以便调用外部 Web 服务来拒绝费用报表。这类似于用于批准操作的 HttpPOST 操作。一个主要区别是,要将用户输入的注释传递给 Web 服务调用。可以使用 {{rejectComment.value}} 引用文本输入值,其中 rejectComment 是文本输入 ID。

可操作邮件的 Web 服务

至此,已了解 Outlook 可操作邮件的标记及其工作原理。在本文的剩余部分中,我将介绍 Web 服务应如何处理 Outlook 可操作邮件发出的请求。

可操作邮件与可以处理 HTTP POST 请求的任何 Web 服务协同工作。在此示例中,Web 服务是 ASP.NET MVC 中的 API 控制器。图 5 展示了 API 控制器。

图 5:费用 API 控制器

[RoutePrefix("expense")]
public class ExpenseController : ApiController
{
  [HttpPost]
  [Route("approve")]
  public HttpResponseMessage Approve([FromBody]JObject jBody)
  {
    string expenseId = jBody["id"].ToString();

    // Process and approve the expense report.
    HttpResponseMessage response = this.Request.CreateResponse(HttpStatusCode.OK);
    response.Headers.Add("CARD-ACTION-STATUS", "The expense was approved.");

      return response;    
  }

  [HttpPost]
  [Route("reject")]
  public HttpResponseMessage Reject([FromBody]JObject jBody)
  {
    string expenseId = jBody["id"].ToString();
    string comment = jBody["comment"].ToString();

    // Process and reject the expense report.
    HttpResponseMessage response = this.Request.CreateResponse(HttpStatusCode.OK);
    response.Headers.Add("CARD-ACTION-STATUS", "The expense was rejected.");

    return response;    
  }
}

此 API 控制器有两种方法,一种用于批准,另一种用于拒绝。若要指明操作成功,Web 服务必须返回 HTTP 状态代码 2xx。Web 服务还可以在响应中添加 CARD-ACTION-STATUS 标头。此标头的值在邮件卡保留区域中向用户显示。如果将 Web 服务部署到 https://api.contoso.com,并单击“批准”按钮,将会收到指明操作已成功完成的通知,如图 6 所示。

图 6:成功批准费用报表的通知

现在,可以端到端运行可操作邮件了。可以发送可操作邮件,当用户单击“批准”按钮时,向 Web 服务发送 HTTP POST 请求。Web 服务将处理请求,并返回“200 OK”。然后,Outlook 将操作标记为“完成”。接下来,我将介绍如何保护 Web 服务。

专用令牌

因为费用 ID 通常遵循一定的格式,所以存在一定风险,即攻击者可以使用不同的费用 ID 发布大量请求,从而进行攻击。如果攻击者成功猜出费用 ID,就可以批准或拒绝费用报表。Microsoft 建议开发者在操作目标 URL 或请求正文中使用“专用令牌”。攻击者应该很难猜出专用令牌。例如,我使用 128 位数字的 GUID 作为专用令牌。此令牌可用于将服务 URL 与特定的请求和用户相关联。还可用于保护 Web 服务免受重播攻击 (bit.ly/2sBQmdn)。将标记更新为在正文中添加 GUID:

{
  "@type": "HttpPost",
  "name": "Approve",
  "target": "https://api.contoso.com/expense/approve",
  "body": "{ \"id\": \"98432019\", \"token\": \
  "d8a0bf4f-ae70-4df6-b129-5999b41f4b7f\" }"
}

持有者令牌

虽然专用令牌加大了攻击者伪造请求的难度,但仍并非十全十美。理想情况下,Web 服务应该能够判断 HTTP POST 请求是否来自 Microsoft 服务器,而不是一些未经授权的潜在恶意服务器。

为解决此问题,Microsoft 在发送给 Web 服务的每个 HTTP POST 请求中添加持有者令牌。持有者令牌是 JSON Web 令牌 (JWT),包含在请求的授权标头中。在用户单击“批准”按钮后,Web 服务会收到如下请求:

POST https://api.contoso.com/expenses/approve

Content-Type: application/json
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJ­SUzI1NiIsIng1dCI6I­jhx­Z3A4­VER­CbDJINkp5­RkU0WjM0­ZDJoYS1rR­SIsImtpZCI6I­jhxZ3A4V­ERCbDJINkp5RkU0WjM0ZD­JoYS1rRSJ9.eyJpYXQiOjE0ODQwODkyNzksInZlciI6IlNUSS5FeHRlcm­5hbEFjY2Vzc1Rva2­VuLlYxIiwiYXBw aWQiOiI0OGFmMD­hkYy1mN­mQyLTQzNWYtYjJhNy0wN­jlhYmQ5OWMwODYiLCJzd­WIiOiJk­YXZpZEBj­ b250b3NvLmN­vbSIsImFwcGlk­YWNyIjoiMiIsIm­FjciI6IjAi­LCJzZW5kZ­XIiOiJleHB­lbnNlYXBw... (truncated for brevity)

{
  "id": "98432019",
  "token": "d8a0bf4f-ae70-4df6-b129-5999b41f4b7f"
}

在授权标头中,“Bearer”后面是一个采用 Base64 编码的长字符串,即 JSON Web 令牌 (JWT)。可以在 jwt.calebb.net 中解码 JWT。图 7**** 展示了解码后的示例令牌。

图 7:示例持有者 JSON Web 令牌

{
  typ: "JWT",
  alg: "RS256",
  x5t: "8qgp8TDBl2H6JyFE4Z34d2ha-kE",
  kid: "8qgp8TDBl2H6JyFE4Z34d2ha-kE"
}.
{
  iat: 1484089279,
  ver: "STI.ExternalAccessToken.V1",
  appid: "48af08dc-f6d2-435f-b2a7-069abd99c086",
  sub: "david@contoso.com",
  appidacr: "2",
  acr: "0",
  sender: "expenseapproval@contoso.com",
  iss: "https://substrate.office.com/sts/",
  aud: "https://api.contoso.com",
  exp: 1484090179,
  nbf: 1484089279
}.
[signature]

每个 JWT 都分为三段,分别用点号 (.) 隔开。第一段是标头,描述了应用于 JWT 的加密操作。在此示例中,用于执行令牌签名的算法 (alg) 是 RS256。也就是说,RSA 使用 SHA-256 哈希算法。x5t 值指定用于执行令牌签名的密钥指纹。

第二段是有效负载本身。其中包含令牌断言的声明列表。Web 服务应使用这些声明来验证请求。图 8 中的表格介绍了这些声明。

图 8:有关有效负载中声明的介绍****

声明 说明
iss 令牌颁发者。值应始终为 https://substrate.office.om/sts/。如果值不一致,Web 服务应拒绝令牌和请求。
appid 颁发令牌的应用程序 ID。值应始终为 48af08dc-f6d2-435f-b2a7-069abd99c086。如果值不一致,Web 服务应拒绝令牌和请求。
aud 令牌受众。应与 Web 服务 URL 的主机名一致。如果值不一致,Web 服务应拒绝令牌和请求。
sub 执行过操作的使用者。值为执行过操作的用户的电子邮件地址,前提是“收件人:”行中有此电子邮件地址或任何代理电子邮件地址。如果没有匹配的电子邮件地址,那么值为使用者的用户主体名称 (UPN) 哈希值。保证同一 UPN 的哈希值相同。
sender 原始邮件发件人的电子邮件地址。
tid 令牌颁发者的租户 ID。

第三段是令牌的数字签名。通过验证签名,Web 服务可以确信令牌是由 Microsoft 发送,并信任令牌中的声明。

验证数字签名是一项非常复杂的任务。幸运的是,可以借助 NuGet 库轻松执行验证任务。可从 bit.ly/2stq90c 获取 Microsoft 创建的库。Microsoft 还发布了有关如何验证令牌的其他语言代码示例。本文末尾收录了这些代码示例的链接。

在 Web 服务项目中添加 NuGet 包之后,可以使用 VerifyBearerToken 方法(如图 9**** 所示),验证请求中的持有者令牌。

图 9:VerifyBearerToken 方法

private async Task<HttpStatusCode> VerifyBearerToken(
  HttpRequestMessage request, string serviceBaseUrl, string expectedSender)
{
  if (request.Headers.Authorization == null ||
    !string.Equals(request.Headers.Authorization.Scheme, "bearer", 
      StringComparison.OrdinalIgnoreCase) ||
      string.IsNullOrEmpty(request.Headers.Authorization.Parameter))
  {
    return HttpStatusCode.Unauthorized ;
  }

  string bearerToken = request.Headers.Authorization.Parameter;
  ActionableMessageTokenValidator validator = 
    new ActionableMessageTokenValidator();
  ActionableMessageTokenValidationResult result = 
    await validator.ValidateTokenAsync(bearerToken, serviceBaseUrl);

  if (!result.ValidationSucceeded)
  {
    return HttpStatusCode.Unauthorized;
  }

  if (!string.Equals(result.Sender, expectedSender, 
    StringComparison.OrdinalIgnoreCase) ||
      !result.ActionPerformer.EndsWith("@contoso.com", 
        StringComparison.OrdinalIgnoreCase))
  {
    return HttpStatusCode.Forbidden;
  }

  return HttpStatusCode.OK;
}

[HttpPost]
[Route("approve")]
public async Task<HttpResponseMessage> Approve([FromBody]JObject jBody)
{
  HttpRequestMessage request = this.ActionContext.Request;
  HttpStatusCode result = await VerifyBearerToken(
    request, "https://api.contoso.com", 
    "expenseapproval@contoso.com");

  switch (result)
  {
    case HttpStatusCode.Unauthorized:
      return request.CreateErrorResponse(
        HttpStatusCode.Unauthorized, new HttpError());

    case HttpStatusCode.Forbidden:
      HttpResponseMessage errorResponse = 
        this.Request.CreateErrorResponse(HttpStatusCode.Forbidden, new HttpError());
      errorResponse.Headers.Add("CARD-ACTION-STATUS", 
        "Invalid sender or the action performer is not allowed.");
      return errorResponse;

    default:
      break;
  }

  string expenseId = jBody["id"].ToString();

  // Process and approve the expense report.

  HttpResponseMessage response = this.Request.CreateResponse(HttpStatusCode.OK);
  response.Headers.Add("CARD-ACTION-STATUS", "The expense was approved.");

  return response;
}

首先,此方法验证授权标头中是否有持有者令牌。然后,它会初始化 ActionableMessageTokenValidator 的新实例,并调用 ValidateToken­Async 方法。此方法需要使用两个参数。第一个参数是持有者令牌本身。第二个参数是 Web 服务基 URL。如果查看解码后的 JWT,这就是 aud(受众)声明值。基本上是说,令牌是针对目标受众(用户的 Web 服务,而不是其他任何 Web 服务)颁发。在此示例中,要调用的 API 是 http://api.contoso.com/expense/approve。声明中的值为基 URL,即 https://api.contoso.com。

此方法返回 ActionableMessage­TokenValidationResult 实例。首先,检查属性 ValidationSucceeded。如果验证成功,则值为 True;否则,值为 False。

结果还包括另外两个对第三方有用的属性。第一个是 Sender。此为令牌中的发件人声明值。这是发送可操作邮件的帐户的电子邮件地址。第二个是 ActionPerformer,也就是子声明值。这是执行过操作的用户的电子邮件地址。在此示例中,只有电子邮件地址包含 <@contoso.com> 的用户,才能批准或拒绝费用报表。可以将代码替换为自己编写的更复杂验证代码。

刷新操作卡

目前,向用户提供反馈的唯一方法是通过 CARD-ACTION-STATUS 标头。此标头的值在邮件卡保留区域中向用户显示。另一种方法是向用户返回刷新操作卡。我们的想法是将当前操作卡替换为其他操作卡。需要这样做的原因有一些。例如,不希望用户再次批准或拒绝已获准的费用报表。相反,应告诉用户,费用报表已获准。 图 10 展示了返回的标记。

图 10:返回的包含刷新操作卡的费用报表标记

{ "@context": "http://schema.org/extensions", "@type":"MessageCard", "hideOriginalBody": "true", "title":"Expense report #98432019 was approved", "sections": [{ "facts": [{ "name":"ID", "value":"98432019" }, { "name":"Amount", "value":"83.27 USD" }, { "name":"Submitter", "value":"Kathrine Joseph" }, { "name":"Description", "value":"Dinner with client" }] }] }

需要将标头 CARD-UPDATE-IN-BODY 的值设置为 True,让 Microsoft 服务器知道响应中包含刷新操作卡。 图 11 展示了 Approve 方法返回的刷新操作卡。

图 11:Approve 方法返回刷新操作卡

private HttpResponseMessage CreateRefreshCard(
  HttpRequestMessage request, string actionStatus, 
  string expenseID, string amount, string submitter, string description)
{
  string refreshCardFormatString = "{\"@context\": \"http://schema.org/extensions\",\"@type\": \"MessageCard\",\"hideOriginalBody\": \"true\",\"title\": \"Expense report #{0} was approved\",\"sections\": [{\"facts\": [{\"name\": \"ID\",\"value\": \"{0}\"},{\"name\": \"Amount\",\"value\": \"{1}\"},{\"name\": \"Submitter\",\"value\": \"{2}\"},{\"name\": \"Description\",\"value\": \"{3}\"}]}]}";
  string refreshCardMarkup = string.Format(
    refreshCardFormatString,
    expenseID,
    amount,
    submitter,
    description);

HttpResponseMessage response = request.CreateResponse(HttpStatusCode.OK);
Response.Headers.Add("CARD-ACTION-STATUS", actionStatus);
  response.Headers.Add("CARD-UPDATE-IN-BODY", "true");
  response.Content = new StringContent(refreshCardMarkup);

  return response;
}

[HttpPost]
[Route("approve")]
public async Task<HttpResponseMessage> Approve([FromBody]JObject jBody)
{
  HttpRequestMessage request = this.ActionContext.Request;
  HttpStatusCode result = await VerifyBearerToken(
    request, "https://api.contoso.com", 
    "expenseapproval@contoso.com");

  switch (result)
  {
    case HttpStatusCode.Unauthorized:
      return request.CreateErrorResponse(
        HttpStatusCode.Unauthorized, new HttpError());

    case HttpStatusCode.Forbidden:
      HttpResponseMessage errorResponse = 
        this.Request.CreateErrorResponse(
          HttpStatusCode.Forbidden, new HttpError());
      errorResponse.Headers.Add("CARD-ACTION-STATUS", 
        "Invalid sender or the action performer is not allowed.");
      return errorResponse;

    default:
      break;
  }

  string expenseId = jBody["id"].ToString();

  // Process and approve the expense report.

  return CreateRefreshCard(
    request,
    "The expense was approved.",
    "98432019",
    "83.27 USD",
    "Jonathan Kiev",
    "Dinner with client");
}

总结

使用可操作邮件,用户可以安全地在 Outlook 中完成任务。目前,桌面版 Outlook 和 Outlook Web Access 都支持此功能,并且很快将在 Outlook for Mac 和 Outlook Mobile 中推出此功能。实现可操作邮件非常简单。首先,需要将相应标记添加到要发送的电子邮件中。其次,需要在 Web 服务中验证 Microsoft 发送的持有者令牌。可操作邮件将提高用户的满意度和工作效率。对于可操作邮件,除了本文介绍的内容外,还有很多需要了解的地方。请访问 bit.ly/2rAD6AZ,获取完整引用和代码示例链接。

我要感谢 Sohail Zafar、Edaena Salinas Jasso、Vasant Kumar Tiwari、Mark Encarnacion 和 Miaosen Wang,他们帮助审阅了本文的语法、拼写和流程。


Woon Kiat Wong 是 Microsoft Research 知识技术小组的一名软件工程师。他与 Outlook 团队紧密合作,以提供可操作邮件。可通过 wowong@microsoft.com 与他联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Pretish Abraham、David Claux、Mark Encarnacion 和 Patrick Pantel


在 MSDN 杂志论坛讨论这篇文章