Skip to content

Commit

Permalink
Merge pull request #1 from ttsukagoshi/dev202004
Browse files Browse the repository at this point in the history
Added mergeMail.gs
  • Loading branch information
ttsukagoshi authored Apr 22, 2020
2 parents 5104bb9 + b23b8d2 commit ca209a0
Showing 1 changed file with 196 additions and 0 deletions.
196 changes: 196 additions & 0 deletions src/mailMerge.gs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Add spreadsheet menu
function onOpen() {
let ui = SpreadsheetApp.getUi();
ui.createMenu('Mail Merge')
.addItem('Send Emails', 'sendEmails')
.addSeparator()
.addItem('Create Draft', 'createDraftEmails')
.addToUi();
}

/**
* Send personalized email(s) to recipient address listed in sheet 'SHEET_NAME_DATA'
*/
function sendEmails() {
const draftMode = false;
const config = getConfig_('Config');
sendPersonalizedEmails_(draftMode, config);
}

/**
* Send test emails to myself the content of the first row in sheet 'List'
*/
function createDraftEmails() {
const draftMode = true;
const config = getConfig_('Config');
sendPersonalizedEmails_(draftMode, config);
}

/**
* Bulk send personalized emails based on a designated Gmail draft.
* The email(s) can be sent to multiple recipients, which will serve as an alternative for using BCC.
* @param {boolean} draftMode Creates Gmail draft(s) instead of sending email. Defaults to true.
* @param {Object} config Object returned by getConfig_()
*/
function sendPersonalizedEmails_(draftMode = true, config) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var ui = SpreadsheetApp.getUi();
var myEmail = Session.getActiveUser().getEmail();

// Get data of field(s) to merge
//// Range of data
const dataSheet = ss.getSheetByName(config.DATA_SHEET_NAME);
var mergeDataRange = dataSheet.getDataRange().setNumberFormat('@'); // Convert all formatted dates and numbers into texts
//// Get data
var mergeData = mergeDataRange.getValues();
//// Define first row of mergeData as header
var header = mergeData.shift();
//// Convert 2d array of mergeData into object array
var mergeDataObj = mergeData.map(function (values) {
return header.reduce(function (object, key, index) {
object[key] = values[index];
return object;
}, {});
})

try {
// Confirmation before sending email
let confirmAccount = (draftMode === true
? `Are you sure you want to create draft email(s) as ${myEmail}?`
: `Are you sure you want to send email(s) as ${myEmail}?`);
let answer = ui.alert(confirmAccount, ui.ButtonSet.OK_CANCEL);
if (answer !== ui.Button.OK) {
throw new Error('Canceled.');
}

// Email Template
//// Prompt to enter the subject of the Gmail draft to use as template
let promptMessage = 'Enter the subject text of Gmail draft to use as template.';
let promptResult = ui.prompt(promptMessage, ui.ButtonSet.OK_CANCEL);
let [selectedButton, subjectText] = [promptResult.getSelectedButton(), promptResult.getResponseText()];
if (selectedButton !== ui.Button.OK) {
throw new Error('Canceled.');
} else if (subjectText == null) {
throw new Error('No text entered.');
}
//// Get template
let draftMessage = getDraftBySubject_(subjectText);
if (draftMessage.length > 1) {
throw new Error('There are 2 or more Gmail drafts with the subject you entered. Enter a unique subject text.');
}
var template = {
'subject': subjectText,
'plainBody': draftMessage[0].getPlainBody(),
'htmlBody': draftMessage[0].getBody()
};

// Send or create draft of personalized email
mergeDataObj.forEach((element, i) => {
var messageData = fillInTemplateFromObject_(template, element, config.MERGE_FIELD_MARKER, config.REPLACE_VALUE);
var options = {
'htmlBody': messageData.htmlBody,
'bcc': (config.BCC_TO_MYSELF === true ? myEmail : null)
};
draftMode === true
? GmailApp.createDraft(element[config.RECIPIENT_COL_NAME], messageData.subject, messageData.plainBody, options)
: GmailApp.sendEmail(element[config.RECIPIENT_COL_NAME], messageData.subject, messageData.plainBody, options);
});
let completeMessage = (draftMode === true
? 'Complete: All draft(s) created.'
: 'Complete: All mails sent.');
ui.alert(completeMessage)
} catch (e) {
let message = errorMessage_(e);
ui.alert(message);
}
}

/**
* Returns an object of configurations from spreadsheet.
* The sheet should have a first row of headers, and its first column should include the following properties:
* DATA_SHEET_NAME: {string} Name of sheet in which field(s) to merge in email are stored
* RECIPIENT_COL_NAME: {string} Name of column in sheet 'DATA_SHEET_NAME' that designates the email address of the recipient
* BCC_TO_MYSELF: {string} String boolean. When true, will send (or create draft) email with the sender's address set to BCC.
* REPLACE_VALUE: {string} Text that will replace empty data of marker.
* MERGE_FIELD_MARKER: {string} Text to be processed in RegExp() constructor to define merge field marker. Note that the backslash itself does not need to be escaped, i.e., does not need to be repeated.
*
* @param {string} configSheetName Name of sheet with configurations. Defaults to 'Config'.
* @return {Object}
*/
function getConfig_(configSheetName = 'Config') {
// Get values from spreadsheet
let configValues = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(configSheetName).getDataRange().getValues();
configValues.shift();

// Convert the 2d array values into a Javascript object
let configObj = {};
configValues.forEach(element => configObj[element[0]] = element[1]);

// Convert data types
configObj.BCC_TO_MYSELF = toBoolean_(configObj.BCC_TO_MYSELF);
configObj.MERGE_FIELD_MARKER = new RegExp(configObj.MERGE_FIELD_MARKER, 'g');

return configObj;
}

/**
* Convert string booleans into boolean data
* @param {string} stringBoolean
* @return {boolean}
*/
function toBoolean_(stringBoolean) {
return stringBoolean.toLowerCase === 'true';
}

/**
* Get an array of Gmail message(s) with the designated subject
* @param {string} subject Subject text of Gmail draft
* @return {array} Array of GmailMessage class objects. https://developers.google.com/apps-script/reference/gmail/gmail-message
*/
function getDraftBySubject_(subject) {
let draftMessages = GmailApp.getDraftMessages();
let targetDrafts = draftMessages.filter(element => element.getSubject() == subject);
return targetDrafts;
}

/**
* Replaces markers in a template object with values defined in a JavaScript data object.
* @param {Object} template Template object containing markers, as designated in regular expression in MERGE_FIELD_MARKER
* @param {Object} mergeData Object with values to replace markers.
* @param {string} mergeFieldMarker Regular expression for the merge field marker. Defaults to /\{\{[^\}]+\}\}/g e.g., {{field name}}
* @param {string} replaceValue String to replace empty data of a marker. Defaults to 'NA' for Not Available.
* @return {Object} Returns object with markers replaced.
*/
function fillInTemplateFromObject_(template, mergeData, mergeFieldMarker = /\{\{[^\}]+\}\}/g, replaceValue = 'NA') {
let messageData = {};
for (let k in template) {
let text = '';
text = template[k];
// Search for all the variables to be replaced
let textVars = text.match(mergeFieldMarker);
if (!textVars) {
messageData[k] = text; // return text itself if no marker is found
} else {
// Get the text inside markers. e.g., {{field name}} => field name
// assuming that the text length for opening and closing markers are 2 and 2, respectively
let markerText = textVars.map(value => value.substring(2, value.length - 2));
// Replace variables in textVars with the actual values from the data object.
// If no value is available, replace with the string with replaceValue.
textVars.forEach(
(variable, i) => text = text.replace(variable, mergeData[markerText[i]] || replaceValue)
);
messageData[k] = text;
}
}
return messageData;
}

/**
* Standarized error message
* @param {Object} e Error object returned by try-catch
* @return {string} Standarized error message
*/
function errorMessage_(e) {
let message = `Error: line - ${e.lineNumber}\n[${e.name}] ${e.message}\n${e.stack}`
return message;
}

0 comments on commit ca209a0

Please sign in to comment.