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 專案。
- 如要建立新專案,請按一下左上方的「New Project」。新專案隨即開啟,並顯示名為
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
函式會建立一張資訊卡,用來取得電子郵件中的支出詳細資料。資訊卡中有一個部分包含相關資料的文字輸入欄位。函式會傳回外掛程式資訊卡的陣列。在本例中,傳回的陣列只包含一張資訊卡。
部署費用之前!外掛程式,則需要有 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!」。
- 快去申辦!請按一下「授權存取權」並按照提示操作,即可存取 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
的輔助函式不會手動在表單中新增每個欄位,而是會循環執行建立文字輸入小工具的程序,透過 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 試算表
開銷!外掛程式能提供使用者輸入費用詳細資料的表單,但這些詳細資料現在不在此處。我們接下來要新增按鈕,以便將表單資料傳送至 Google 試算表。
如要新增按鈕,我們會使用 ButtonSet 類別。如要整合 Google 試算表,請使用 Google 試算表服務。
修改 createFormSection
,傳回標籤為「Submit」的按鈕可顯示在資訊卡的表單部分中按照下列步驟操作:
- 使用
CardService.newTextButton()
建立文字按鈕,將按鈕標示為「提交」使用CardService.TextButton.setText()
。 - 設計按鈕,讓系統在點選以下
submitForm
動作時透過CardService.TextButton.setOnClickAction()
呼叫:
/**
* 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
輔助函式可讓您輕鬆將表單回應轉換為陣列,然後附加至試算表。
最後,更新 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. 使用屬性服務儲存值
使用者通常會將多筆支出記錄在同一份試算表,因此建議您提供最新的試算表網址,做為資訊卡的預設值。為得知最新試算表的網址,每次使用外掛程式時,我們都需要儲存該資訊。
屬性服務可讓我們儲存鍵/值組合。在我們的例子中,合理的金鑰為「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. 清除包含資訊卡動作的表單
如果支出的話會發生什麼事呢!在未結電子郵件中誤認費用,卻在表單中預先填入錯誤資訊?使用者清除表單。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);
現在使用者點選「新工作表」時按鈕會產生新的試算表,其中含有凍結的標題列,方便隨時顯示。使用者會在表單中指定新試算表的標題,不過如果表單為空白,建議您使用預設值。實作 createExpensesSheet
時,請將近乎相同的資訊卡傳回現有的資訊卡,並加入適當的狀態訊息,並預先填入新試算表的網址。
10. 恭喜!
您成功設計並導入 Gmail 外掛程式,可以查詢電子郵件費用,使用者只要短短幾秒,就能將費用記錄到試算表。您已使用 Google Apps Script 處理多個 Google API,並在外掛程式多次執行之間保留資料。
可能的改善項目
不妨發揮想像力,大力提升「成本」!不過,請參考以下幾個點子,瞭解如何打造更實用的產品:
- 在使用者記錄費用後開啟試算表的連結
- 新增編輯/取消支出記錄的功能
- 整合外部 API,讓使用者付款及請款