使用 Google Workspace 插件让电子邮件更具实用价值

1. 概览

在此 Codelab 中,您将使用 Google Apps 脚本编写 Gmail 的 Google Workspace 插件,让用户能直接在 Gmail 中将电子邮件中的收据数据添加到电子表格中。用户通过电子邮件收到收据后,会打开插件,然后自动从电子邮件中获取相关费用信息。用户可以修改费用信息,然后将其提交,以便将费用记录到 Google 表格的电子表格中。

学习内容

  • 使用 Google Apps 脚本创建适用于 Gmail 的 Google Workspace 插件
  • 使用 Google Apps 脚本解析电子邮件
  • 通过 Google Apps 脚本与 Google 表格互动
  • 使用 Google Apps 脚本的属性服务存储用户值

所需条件

  • 访问互联网和网络浏览器
  • Google 账号
  • Gmail 中的部分邮件,最好是电子邮件收据

2. 获取示例代码

在学习此 Codelab 时,不妨参考您将编写的代码的有效版本。GitHub 代码库包含可用作参考的示例代码。

如需获取示例代码,请在命令行中运行以下命令:

git clone https://github.com/googleworkspace/gmail-add-on-codelab.git

3. 创建基本插件

首先,为一个简单的插件版本编写代码,该插件会在电子邮件旁边显示费用表单。

首先,创建一个新的 Apps 脚本项目并打开其清单文件

  1. 前往 script.google.com。在这里,您可以创建、管理和监控您的 Apps 脚本项目。
  2. 如需创建新项目,请点击左上角的新建项目。新项目随即会打开,其中包含名为 Code.gs 的默认文件。暂时不要使用 Code.gs,稍后您会处理它。
  3. 点击 Untitled project,将您的项目命名为 Expense It!,然后点击重命名
  4. 点击左侧的项目设置 图标 项目设置
  5. 选择显示“appscript.json”清单文件”" 复选框中。
  6. 点击编辑器 编辑者
  7. 如需打开清单文件,请点击左侧的 appscript.json

appscript.json 中,指定与插件关联的元数据,例如插件的名称及其所需的权限。将 appsscript.json 的内容替换为以下配置设置:

{
  "timeZone": "GMT",
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute"
  ],
  "gmail": {
    "name": "Expense It!",
    "logoUrl": "https://www.gstatic.com/images/icons/material/system/1x/receipt_black_24dp.png",
    "contextualTriggers": [{
      "unconditional": {
      },
      "onTriggerFunction": "getContextualAddOn"
    }],
    "primaryColor": "#41f470",
    "secondaryColor": "#94f441"
  }
}

请特别注意清单中名为 contextualTriggers 的部分。这部分清单标识首次激活插件时要调用的用户定义函数。在本例中,它会调用 getContextualAddOn,以获取有关所打开电子邮件的详细信息并返回一组要显示给用户的卡片。

如需创建 getContextualAddOn 函数,请按以下步骤操作:

  1. 在左侧,将指针悬停在 Code.gs 上,然后点击“菜单”图标 “更多”菜单 >重命名
  2. 输入 GetContextualAddOn,然后按 Enter 键。Apps 脚本会自动将 .gs 附加到您的文件名,因此您无需输入文件扩展名。如果您输入 GetContextualAddOn.gs,则 Apps 脚本会将您的文件命名为 GetContextualAddOn.gs.gs
  3. GetContextualAddOn.gs 中,将默认代码替换为 getContextualAddOn 函数:
/**
 * Returns the contextual add-on data that should be rendered for
 * the current e-mail thread. This function satisfies the requirements of
 * an 'onTriggerFunction' and is specified in the add-on's manifest.
 *
 * @param {Object} event Event containing the message ID and other context.
 * @returns {Card[]}
 */
function getContextualAddOn(event) {
  var card = CardService.newCardBuilder();
  card.setHeader(CardService.newCardHeader().setTitle('Log Your Expense'));

  var section = CardService.newCardSection();
  section.addWidget(CardService.newTextInput()
    .setFieldName('Date')
    .setTitle('Date'));
  section.addWidget(CardService.newTextInput()
    .setFieldName('Amount')
    .setTitle('Amount'));
  section.addWidget(CardService.newTextInput()
    .setFieldName('Description')
    .setTitle('Description'));
  section.addWidget(CardService.newTextInput()
    .setFieldName('Spreadsheet URL')
    .setTitle('Spreadsheet URL'));

  card.addSection(section);

  return [card.build()];
}

每个 Google Workspace 插件的界面均由拆分为一个或多个部分的卡片组成,每个卡片都包含微件,用于显示和获取用户信息。getContextualAddOn 函数会创建一张卡片,用于获取电子邮件中相关费用的详细信息。该卡片包含一个部分,其中包含相关数据的文本输入字段。该函数会返回该插件的卡片数组。在本例中,返回的数组仅包含一张卡片。

在部署 Expense It!插件,您需要一个 Google Cloud Platform (GCP) 项目,Apps 脚本项目使用该项目来管理授权、高级服务和其他详细信息。如需了解详情,请访问 Google Cloud Platform 项目

如需部署和运行插件,请按以下步骤操作:

  1. 打开您的 GCP 项目,并复制其项目编号
  2. 在 Apps 脚本项目中,点击左侧的项目设置 图标 项目设置
  3. 在“Google Cloud Platform (GCP) 项目”下,点击更改项目
  4. 输入您的 GCP 项目的项目编号,然后点击设置项目
  5. 点击部署 > 测试部署
  6. 确保部署类型为 Google Workspace 插件。如有必要,请点击对话框顶部的“启用部署类型”图标 启用部署类型,然后选择 Google Workspace 插件作为部署类型。
  7. 应用:Gmail 旁边,点击安装
  8. 点击完成

现在,您可以在 Gmail 收件箱中看到该插件。

  1. 在电脑上打开 Gmail
  2. 右侧面板上的“Expense It!”(支出!)系统会显示 花钱!收据图标 插件。您可能需要点击“更多插件”图标 更多插件 才能找到它。
  3. 打开电子邮件,最好是列明相关费用的收据。
  4. 如需打开插件,请在右侧面板中点击 Expense It!花钱!收据图标
  5. 报费!访问您的 Google 账号(点击授予访问权限),然后按照提示操作。

该插件会在打开的 Gmail 邮件旁边显示一个简单的表单。它尚未执行任何其他操作,但您将在下一部分中构建其功能。

要在继续本实验的过程中查看插件的更新,只需保存代码并刷新 Gmail 即可。无需进行额外的部署。

4. 访问电子邮件

添加用于提取电子邮件内容的代码,并对代码进行模块化,以使代码更加井然有序。

在“文件”旁边,点击“添加”图标 添加文件 >脚本,然后创建一个名为 Cards 的文件。创建名为 Helpers 的第二个脚本文件。Cards.gs 会创建卡片,并使用 Helpers.gs 中的函数根据电子邮件的内容填充表单中的字段。

Cards.gs 中的默认代码替换为以下代码:

var FIELDNAMES = ['Date', 'Amount', 'Description', 'Spreadsheet URL'];

/**
 * Creates the main card users see with form inputs to log expenses.
 * Form can be prefilled with values.
 *
 * @param {String[]} opt_prefills Default values for each input field.
 * @param {String} opt_status Optional status displayed at top of card.
 * @returns {Card}
 */
function createExpensesCard(opt_prefills, opt_status) {
  var card = CardService.newCardBuilder();
  card.setHeader(CardService.newCardHeader().setTitle('Log Your Expense'));
  
  if (opt_status) {
    if (opt_status.indexOf('Error: ') == 0) {
      opt_status = '<font color=\'#FF0000\'>' + opt_status + '</font>';
    } else {
      opt_status = '<font color=\'#228B22\'>' + opt_status + '</font>';
    }
    var statusSection = CardService.newCardSection();
    statusSection.addWidget(CardService.newTextParagraph()
      .setText('<b>' + opt_status + '</b>'));
    card.addSection(statusSection);
  }
  
  var formSection = createFormSection(CardService.newCardSection(),
                                      FIELDNAMES, opt_prefills);
  card.addSection(formSection);
  
  return card;
}

/**
 * Creates form section to be displayed on card.
 *
 * @param {CardSection} section The card section to which form items are added.
 * @param {String[]} inputNames Names of titles for each input field.
 * @param {String[]} opt_prefills Default values for each input field.
 * @returns {CardSection}
 */
function createFormSection(section, inputNames, opt_prefills) {
  for (var i = 0; i < inputNames.length; i++) {
    var widget = CardService.newTextInput()
      .setFieldName(inputNames[i])
      .setTitle(inputNames[i]);
    if (opt_prefills && opt_prefills[i]) {
      widget.setValue(opt_prefills[i]);
    }
    section.addWidget(widget);
  }
  return section;
}

createExpensesCard 函数接受一个用于预填充表单的值数组作为可选参数。函数可以显示可选的状态消息,如果状态以“Error:”开头,则显示为红色,否则为绿色。名为 createFormSection 的辅助函数会循环创建文本输入 widget,使用 setValue 设置每个默认值,然后将这些 widget 添加到卡片上的相应部分,而不是手动向表单添加每个字段。

现在,将 Helpers.gs 中的默认代码替换为以下代码:

/**
 * Finds largest dollar amount from email body.
 * Returns null if no dollar amount is found.
 *
 * @param {Message} message An email message.
 * @returns {String}
 */
function getLargestAmount(message) {
  return 'TODO';
}

/**
 * Determines date the email was received.
 *
 * @param {Message} message An email message.
 * @returns {String}
 */
function getReceivedDate(message) {
  return 'TODO';
}

/**
 * Determines expense description by joining sender name and message subject.
 *
 * @param {Message} message An email message.
 * @returns {String}
 */
function getExpenseDescription(message) {
  return 'TODO';
}

/**
 * Determines most recent spreadsheet URL.
 * Returns null if no URL was previously submitted.
 *
 * @returns {String}
 */
function getSheetUrl() {
  return 'TODO';
}

getContextualAddon 会调用 Helpers.gs 中的函数来确定表单的预填充值。目前,这些函数仅返回字符串“TODO”因为您将在后续步骤中实现预填充逻辑。

接下来,更新 GetContextualAddon.gs 中的代码,以便它利用 Cards.gsHelpers.gs 中的代码。将 GetContextualAddon.gs 中的代码替换为以下代码:

/**
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 * Returns the contextual add-on data that should be rendered for
 * the current e-mail thread. This function satisfies the requirements of
 * an 'onTriggerFunction' and is specified in the add-on's manifest.
 *
 * @param {Object} event Event containing the message ID and other context.
 * @returns {Card[]}
 */
function getContextualAddOn(event) {
  var message = getCurrentMessage(event);
  var prefills = [getReceivedDate(message),
                  getLargestAmount(message),
                  getExpenseDescription(message),
                  getSheetUrl()];
  var card = createExpensesCard(prefills);

  return [card.build()];
}

/**
 * Retrieves the current message given an action event object.
 * @param {Event} event Action event object
 * @return {Message}
 */
function getCurrentMessage(event) {
  var accessToken = event.messageMetadata.accessToken;
  var messageId = event.messageMetadata.messageId;
  GmailApp.setCurrentMessageAccessToken(accessToken);
  return GmailApp.getMessageById(messageId);
}

请注意新的 getCurrentMessage 函数,该函数使用 Gmail 提供的事件来读取用户当前打开的邮件。为使此函数正常工作,请在脚本清单中添加一个额外的范围,以允许对 Gmail 邮件进行只读访问。

appscript.json 中,更新 oauthScopes,使其也请求 https://www.googleapis.com/auth/gmail.addons.current.message.readonly 范围。

"oauthScopes": [
  "https://www.googleapis.com/auth/gmail.addons.execute",
   "https://www.googleapis.com/auth/gmail.addons.current.message.readonly"
],

在 Gmail 中,运行插件,并为 Expense It! 授予访问权限查看电子邮件。表单字段现在会预填充“TODO”。

5. 与 Google 表格互动

费用!插件包含一个表单,供用户输入费用详细信息,但这些详细信息无法显示在位置上。我们来添加一个按钮,用于将表单数据发送到 Google 表格。

要添加按钮,我们将使用 ButtonSet 类。为了与 Google 表格进行交互,我们将使用 Google 表格服务

修改 createFormSection 以返回标有“提交”的按钮。请按以下步骤操作:

  1. 使用 CardService.newTextButton() 创建一个文本按钮,将按钮标记为“Submit”使用 CardService.TextButton.setText()
  2. 将按钮设计为在用户点击该按钮时通过 CardService.TextButton.setOnClickAction() 调用以下 submitForm 操作:
/**
 * Logs form inputs into a spreadsheet given by URL from form.
 * Then displays edit card.
 *
 * @param {Event} e An event object containing form inputs and parameters.
 * @returns {Card}
 */
function submitForm(e) {
  var res = e['formInput'];
  try {
    FIELDNAMES.forEach(function(fieldName) {
      if (! res[fieldName]) {
        throw 'incomplete form';
      }
    });
    var sheet = SpreadsheetApp
      .openByUrl((res['Spreadsheet URL']))
      .getActiveSheet();
    sheet.appendRow(objToArray(res, FIELDNAMES.slice(0, FIELDNAMES.length - 1)));
    return createExpensesCard(null, 'Logged expense successfully!').build();
  }
  catch (err) {
    if (err == 'Exception: Invalid argument: url') {
      err = 'Invalid URL';
      res['Spreadsheet URL'] = null;
    }
    return createExpensesCard(objToArray(res, FIELDNAMES), 'Error: ' + err).build();
  }
}

/**
 * Returns an array corresponding to the given object and desired ordering of keys.
 *
 * @param {Object} obj Object whose values will be returned as an array.
 * @param {String[]} keys An array of key names in the desired order.
 * @returns {Object[]}
 */
function objToArray(obj, keys) {
  return keys.map(function(key) {
    return obj[key];
  });
}
  1. 使用 CardService.newButtonSet() 创建一个按钮集 widget,然后将文本按钮添加到使用 CardService.ButtonSet.addButton() 的按钮集中。
  2. 使用 CardService.CardSection.addWidget() 将按钮集 widget 添加到卡片的表单部分。

只需几行代码,我们就能够按网址打开电子表格,然后将一行数据附加到工作表。请注意,表单输入会作为事件 e 的一部分传递给函数,我们会检查用户是否提供了所有字段。假设没有出现错误,我们将创建一张空白费用卡片,并带有理想状态。如果捕获错误,我们会返回最初填充的卡片以及错误消息。借助 objToArray 辅助函数,您可以更轻松地将表单回复转换为数组,然后可将数组回复附加到电子表格中。

最后,更新 appsscript.json 中的 oauthScopes 部分,再次请求范围 https://www.googleapis.com/auth/spreadsheets。获得授权后,该插件会读取和修改用户的 Google 表格。

"oauthScopes": [
  "https://www.googleapis.com/auth/gmail.addons.execute",
  "https://www.googleapis.com/auth/gmail.addons.current.message.readonly",
  "https://www.googleapis.com/auth/spreadsheets"
],

如果您尚未创建新电子表格,请访问 https://docs.google.com/spreadsheets/ 创建一个。

现在,重新运行插件并尝试提交表单。务必在电子表格网址表单字段中输入完整的目标网址。

6. 使用 Properties 服务存储值

用户经常会将许多费用记录到同一个电子表格中,因此在卡片中提供最新的电子表格网址作为默认值会比较方便。为了能够获得最新电子表格的网址,每次使用该插件时,我们都需要存储该信息。

借助 Properties 服务,我们可以存储键值对。在本例中,合理的键是“SPREADSHEET_网址”而该值则是网址本身。要存储这样的值,您需要修改 Cards.gs 中的 submitForm,以便在向工作表附加新行时,电子表格的网址会存储为属性。

请注意,属性可以是以下三个范围之一:脚本、用户或文档文档范围不适用于 Gmail 插件,但在存储特定 Google 文档或表格特有的信息时,此范围与其他类型的插件相关。对于我们的插件,期望个人看到自己(而不是其他人的)最新电子表格作为表单的默认选项。因此,我们选择的是“用户”范围,而不是“脚本”范围。

使用 PropertiesService.getUserProperties().setProperty() 存储电子表格网址。将以下代码添加到 Cards.gs 中的 submitForm

PropertiesService.getUserProperties().setProperty('SPREADSHEET_URL', 
    res['Spreadsheet URL']);

然后,修改 Helpers.gs 中的 getSheetUrl 函数以返回存储的属性,以便用户每次使用该插件时都会看到最新网址。使用 PropertiesService.getUserProperties().getProperty() 获取属性的值。

/**
 * Determines most recent spreadsheet URL.
 * Returns null if no URL was previously submitted.
 *
 * @returns {String}
 */
function getSheetUrl() {
  return PropertiesService.getUserProperties().getProperty('SPREADSHEET_URL');
}

最后,要访问媒体资源服务,脚本还需要获得授权。像以前一样将范围 https://www.googleapis.com/auth/script.storage 添加到清单中,以允许插件读取和写入属性信息。

7. 解析 Gmail 邮件

为了真正地减少我们可以用电子邮件中与费用相关的信息来预填表单。我们已经在 Helpers.gs 中创建了担任此角色的函数,但到目前为止我们仅返回了“TODO”费用的日期、金额和说明。

例如,我们可以获取收到电子邮件的日期,并将其用作费用日期的默认值。

/**
 * Determines date the email was received.
 *
 * @param {Message} message - The message currently open.
 * @returns {String}
 */
function getReceivedDate(message) {
  return message.getDate().toLocaleDateString();
}

实现其余两个函数:

  1. getExpenseDescription 可能需要将发件人的姓名和邮件主题联接起来,但也可以采用更复杂的方法来解析邮件正文并提供更准确的说明。
  2. 对于getLargestAmount,建议查找与货币相关的特定符号。收据通常会列出多个值,例如税费和其他费用。思考如何确定正确的金额。使用正则表达式也很方便。

如果您需要更多灵感,请浏览 GmailMessage 参考文档,或查看您在此 Codelab 开头下载的解决方案代码。为 Helpers.gs 中的所有函数设计好自己的实现后,不妨试用一下您的插件!打开收据并开始在电子表格中记录!

8. 清除包含卡片操作的表单

“花钱”的后果!在打开的电子邮件中误报了一笔费用,并使用错误信息预填了表单?用户清除了表单。我们可以使用 CardAction 类来指定在用户点击操作时调用的函数。让我们使用它来帮助用户快速清除表单。

修改 createExpensesCard,使其返回的卡片具有标记为“Clear form”的卡片操作点击后会调用以下 clearForm 函数,您可以将其粘贴到 Cards.gs 中。您需要将 opt_status 作为名为“Status”的参数传入添加到操作中,确保在表单清除后,状态消息会保留下来。请注意,操作的可选参数必须为 Object.<string, string> 类型,因此如果 opt_status 不可用,则应传递 {'Status' : ''}

/**
 * Recreates the main card without prefilled data.
 *
 * @param {Event} e An event object containing form inputs and parameters.
 * @returns {Card}
 */
function clearForm(e) {
  return createExpensesCard(null, e['parameters']['Status']).build();
}

9. 创建电子表格

除了使用 Google Apps 脚本编辑现有的电子表格外,您还可以通过编程方式创建全新的电子表格。对于我们的插件,您可以允许用户创建费用电子表格。首先,请将以下卡片部分添加到 createExpensesCard 返回的卡片中。

var newSheetSection = CardService.newCardSection();
var sheetName = CardService.newTextInput()
  .setFieldName('Sheet Name')
  .setTitle('Sheet Name');
var createExpensesSheet = CardService.newAction()
  .setFunctionName('createExpensesSheet');
var newSheetButton = CardService.newTextButton()
  .setText('New Sheet')
  .setOnClickAction(createExpensesSheet);
newSheetSection.addWidget(sheetName);
newSheetSection.addWidget(CardService.newButtonSet().addButton(newSheetButton));
card.addSection(newSheetSection);

现在,当用户点击“新工作表”时按钮,插件会生成一个新的电子表格,其格式设置是冻结的,因此标题行始终可见。用户在表单中为新电子表格指定标题,尽管在表单为空的情况下加入默认值或许是不错的选择。在您的 createExpensesSheet 实现中,向现有卡片返回几乎完全相同的卡片,添加适当的状态消息,并使用新电子表格的网址预填充网址字段。

10. 恭喜!

您已成功设计和实施了一个 Gmail 插件,该插件可以查找电子邮件中的费用,并帮助用户在几秒钟内将费用记录到电子表格中。您已使用 Google Apps 脚本与多个 Google API 进行交互,并在多次执行插件之间保留了数据。

可能的改进措施

让你的想象力成为推动“开支”的向导!但你可以参考下面这些想法来打造更实用的产品:

  • 在用户记录费用后链接到电子表格
  • 添加修改/撤消记录费用的功能
  • 集成外部 API 以允许用户付款和请求付款

了解详情