1. 總覽
在本程式碼研究室中,您將使用 Google Apps Script 編寫 Gmail 專用的 Google Workspace 外掛程式,讓使用者直接在 Gmail 中,將電子郵件中的收據資料新增至試算表。使用者收到電子郵件收據後,開啟外掛程式,系統就會自動從電子郵件中取得相關費用資訊。使用者可以編輯費用資訊,然後提交,將費用記錄到 Google 試算表。
課程內容
- 使用 Google Apps Script 建立 Gmail 適用的 Google Workspace 外掛程式
- 使用 Google Apps Script 剖析電子郵件
- 透過 Google Apps Script 與 Google 試算表互動
- 使用 Google Apps Script 的屬性服務儲存使用者值
軟硬體需求
- 連上網際網路並使用網路瀏覽器
- Google 帳戶
- Gmail 中的部分郵件 (最好是電子郵件收據)
2. 取得程式碼範例
在完成本程式碼研究室的過程中,您可能會需要參考所編寫程式碼的運作版本。GitHub 存放區包含可做為參考的範例程式碼。
如要取得範例程式碼,請透過指令列執行:
git clone https://github.com/googleworkspace/gmail-add-on-codelab.git
3. 製作基本外掛程式
首先,請編寫簡單版本的外掛程式程式碼,在電子郵件旁顯示費用表單。
首先,請建立新的 Apps Script 專案,然後開啟資訊清單檔案。
- 前往 script.google.com。您可以在這裡建立、管理及監控 Apps Script 專案。
- 如要建立新專案,請按一下左上方的「新專案」。新專案會開啟,並顯示名為
Code.gs的預設檔案。暫時不要理會Code.gs,稍後會用到。 - 按一下「未命名的專案」,將專案命名為「Expense It!」,然後按一下「重新命名」。
- 按一下左側的「專案設定」圖示
。 - 勾選「在編輯器中顯示『appscript.json』資訊清單檔案」核取方塊。
- 按一下「編輯者」圖示
。 - 如要開啟資訊清單檔案,請按一下左側的
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 函式,請按照下列步驟操作:
- 將指標懸停在左側的
Code.gs上,然後依序點按「選單」圖示
>「重新命名」。 - 輸入
GetContextualAddOn,然後按下Enter鍵。Apps Script 會自動在檔案名稱後方加上.gs,因此您不必輸入副檔名。如果您輸入GetContextualAddOn.gs,Apps Script 會將檔案命名為GetContextualAddOn.gs.gs。 - 在
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 Script 專案會使用這項專案管理授權、進階服務和其他詳細資料。如要瞭解詳情,請參閱 Google Cloud Platform 專案。
如要部署及執行外掛程式,請按照下列步驟操作:
- 開啟 GCP 專案,然後複製專案編號。
- 在 Apps Script 專案中,按一下左側的「專案設定」圖示
。 - 在「Google Cloud Platform (GCP) 專案」下方,按一下「變更專案」。
- 輸入 GCP 專案的專案編號,然後按一下「設定專案」。
- 依序點選「部署」>「測試部署作業」。
- 確認部署類型為「Google Workspace 外掛程式」。如有需要,請按一下對話方塊頂端的「啟用部署類型」圖示
,然後選取「Google Workspace 外掛程式」做為部署類型。 - 在「應用程式:Gmail」旁邊,按一下「安裝」。
- 按一下 [完成]。
現在您可以在 Gmail 收件匣中看到外掛程式。
- 在電腦上開啟 Gmail。
- 右側面板會顯示「Expense It!」畫面上會顯示
外掛程式。你可能需要點按「更多外掛程式」圖示
才能找到。 - 開啟電子郵件,最好是內含費用的收據。
- 如要開啟外掛程式,請在右側面板中按一下「Expense It!」。
。 - 按一下「授權存取」,然後按照提示操作,將 Google 帳戶存取權授予「Expense It!」。
外掛程式會在開啟的 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 的輔助函式,即可完成建立文字輸入小工具的程序、使用 setValue 設定每個預設值,然後將小工具新增至資訊卡的相應區段。
現在,請將 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.gs 和 Helpers.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,傳回標示為「提交」的按鈕,做為資訊卡表單部分。按照下列步驟操作:
- 使用
CardService.newTextButton()建立文字按鈕,並使用CardService.TextButton.setText()將按鈕標示為「提交」。 - 設計按鈕,讓系統在點選按鈕時,透過
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];
});
}
- 使用
CardService.newButtonSet()建立按鈕集小工具,並使用CardService.ButtonSet.addButton()將文字按鈕新增至按鈕集。 - 使用
CardService.CardSection.addWidget()將按鈕集小工具新增至資訊卡的表單區段。
只需幾行程式碼,我們就能透過網址開啟試算表,然後將一列資料附加到該試算表。請注意,表單輸入內容會做為事件 e 的一部分傳遞至函式,我們會檢查使用者是否已提供所有欄位。假設沒有發生任何錯誤,我們會建立空白的費用卡,並顯示有利的狀態。如果我們發現錯誤,會連同錯誤訊息一併退回原始填寫的卡片。objToArray 輔助函式可輕鬆將表單回覆內容轉換為陣列,然後附加至試算表。
最後,請再次要求 https://www.googleapis.com/auth/spreadsheets 範圍,更新 appsscript.json 中的 oauthScopes 區段。授權這個範圍後,外掛程式就能讀取及修改使用者的 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 服務儲存值
使用者通常會將許多費用記錄在同一個試算表中,因此在資訊卡中提供最近使用的試算表網址做為預設值,會很方便。為了取得最新試算表的網址,我們每次使用外掛程式時都必須儲存該資訊。
屬性服務可讓我們儲存鍵/值組合。在本範例中,合理的鍵會是「SPREADSHEET_URL」,值則是網址本身。如要儲存這類值,請修改 Cards.gs 中的 submitForm,以便在將新資料列附加至試算表時,將試算表網址儲存為屬性。
請注意,屬性可以有三種範圍:指令碼、使用者或文件。文件範圍不適用於 Gmail 外掛程式,但如果儲存特定 Google 文件或試算表的專屬資訊,則適用於其他類型外掛程式。就我們的外掛程式而言,我們希望使用者在表單上看到自己最近的試算表,而不是其他人的試算表。因此,我們選取「user」範圍,而非「script」範圍。
使用 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();
}
實作其餘兩個函式:
getExpenseDescription可能會一併加入寄件者名稱和郵件主旨,但也有更精細的方法可剖析郵件內文,並提供更準確的說明。getLargestAmount建議尋找與金錢相關的特定符號。收據通常會列出多個值,例如稅金和其他費用。請思考如何找出正確金額。規則運算式也可能派上用場。
如需更多靈感,請參閱 GmailMessage 的參考說明文件,或查看您在本程式碼研究室一開始下載的解決方案程式碼。為 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 Script 編輯現有試算表,您也可以透過程式輔助方式建立全新的試算表。以我們的外掛程式為例,我們允許使用者建立費用試算表。如要開始使用,請將下列卡片區段新增至 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 Script 與多個 Google API 建立介面,並在多次執行外掛程式時保留資料。
可能的改善做法
請盡情發揮想像力,進一步強化 Expense It!,以下提供一些建議,協助您打造更實用的產品:
- 使用者記錄支出後,系統會提供試算表連結
- 新增編輯/復原費用記錄的功能
- 整合外部 API,讓使用者付款及要求付款