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

1. 概览

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

学习内容

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

所需条件

  • 能够访问互联网和网络浏览器
  • 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. 点击未命名项目,将项目命名为 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 加载项的用户界面都由 卡片组成,这些卡片分为一个或多个部分,每个部分都包含可显示信息并从用户处获取信息的 widgetgetContextualAddOn 函数会创建一个卡片,用于获取电子邮件中找到的费用的详细信息。该卡片包含一个部分,其中包含用于输入相关数据的文本输入字段。该函数会返回插件的卡片数组。在这种情况下,返回的数组仅包含一张卡片。

在部署“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! Expense It! 收据图标 加购项随即显示。您可能需要点击“更多插件”图标 更多插件 才能找到它。
  3. 打开一封电子邮件,最好是包含费用的收据。
  4. 如需打开该插件,请在右侧边栏中点击“Expense It!”。Expense It! 收据图标
  5. 点击授权访问,然后按照提示操作,以向 Expense It! 授予对您 Google 账号的访问权限。

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

如需在继续完成本实验的过程中查看插件的更新,您只需保存代码并刷新 Gmail。无需进行其他部署。

4. 访问电子邮件

添加用于提取电子邮件内容的代码,并对代码进行模块化处理,以提高组织性。

点击“文件”旁边的“添加”图标 添加文件 > 脚本,然后创建一个名为 Cards 的文件。创建第二个脚本文件,名为 HelpersCards.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';
}

Helpers.gs 中的函数由 getContextualAddon 调用,以确定表单上的预填充值。目前,这些函数只会返回字符串“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 表格互动

Expense It! 加载项提供了一个表单,供用户输入有关费用的详细信息,但这些详细信息无处可去。我们来添加一个按钮,用于将表单数据发送到 Google 表格。

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

修改 createFormSection 以返回一个标记为“提交”的按钮,作为卡片表单部分的一部分。请按以下步骤操作:

  1. 使用 CardService.newTextButton() 创建一个文字按钮,并使用 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/ 创建一个。

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

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');
}

最后,如需访问 Property 服务,脚本还需要获得授权。像之前一样,将范围 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. 通过卡片操作清除表单

如果“Expense It!”错误地识别了打开的电子邮件中的费用,并使用错误的信息预填充了表单,会怎么样?用户清除表单。借助 CardAction 类,我们可以指定在点击操作时调用的函数。我们来使用它,为用户提供一种快速清除表单的方式。

修改 createExpensesCard,使其返回的卡片具有标记为“清除表单”的卡片操作,并在点击时调用以下 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);

现在,当用户点击“New Sheet”按钮时,该插件会生成一个新工作表,其中包含一个冻结的标题行,以便始终可见。用户可以在表单中指定新电子表格的标题,不过最好包含一个默认值,以防表单为空。在 createExpensesSheet 的实现中,返回与现有卡片几乎相同的卡片,但要添加适当的状态消息,并使用新电子表格的网址预填充网址字段。

10. 恭喜!

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

可能的改进

在增强 Expense It! 的功能时,请充分发挥想象力,但以下是一些可让产品变得更加实用的想法:

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

了解详情