Last active
August 24, 2021 00:56
-
-
Save kurrik/30719823b9e28e952b2e3b7835803cfd to your computer and use it in GitHub Desktop.
Sending nicely-formatted change emails from Google Apps Forms
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* This script sends an email in response to form submissions. | |
* It formats metadata specified in the form and may be configured | |
* to send to different audiences depending on which options are | |
* chosen. The email is nicely formatted, suitable for ingestion | |
* into Slack. | |
**/ | |
/** CHANGE THE FOLLOWING TO MATCH YOUR FORM **/ | |
var SLACK_EMAIL_ALIAS = 'example+slack@example.com'; | |
var VISIBLE_EMAIL_ALIAS = 'example+visible@example.com'; | |
var ALL_EMAIL_ALIAS = 'example+all@example.com'; | |
var FORM_URL = "http://example.com/form"; | |
var QUESTION_MAP = [ | |
{ | |
// This question determines who the email is sent to. | |
// Configured to match a multiple choice input: | |
// Audience of the change? | |
// - Developer facing (changes to the API) | |
// - User facing (any other user facing change, e.g. emails) | |
// - Internal only change (no user impact, e.g. a team process change) | |
startsWith: 'AUDIENCE', | |
key: 'audience', | |
responseMap: [ | |
{ | |
startsWith: 'DEVELOPER', | |
key: 'developer-facing', | |
emails: [ ALL_EMAIL_ALIAS, VISIBLE_EMAIL_ALIAS ], | |
bcc: [ SLACK_EMAIL_ALIAS ], | |
}, | |
{ | |
startsWith: 'USER', | |
key: 'user-facing', | |
emails: [ ALL_EMAIL_ALIAS, VISIBLE_EMAIL_ALIAS ], | |
bcc: [ SLACK_EMAIL_ALIAS ], | |
}, | |
{ | |
startsWith: 'INTERNAL', | |
key: 'team-only', | |
emails: [ ALL_EMAIL_ALIAS ], | |
bcc: [ SLACK_EMAIL_ALIAS ], | |
}, | |
], | |
}, | |
{ | |
// This question adds extra data to the email. | |
// Configured to match a multiple choice input: | |
// Timeline of the change? | |
// - Live (retroactive reporting) | |
// - Imminent (turning on today) | |
// - Soon (turning on in the next few days) | |
// - Testing (our first beta user was signed up) | |
// - Later (planning stage, looking for feedback) | |
startsWith: 'TIMELINE', | |
key: 'timeline', | |
responseMap: [ | |
{ | |
startsWith: 'LIVE', | |
key: 'shipped', | |
}, | |
{ | |
startsWith: 'IMMINENT', | |
key: 'shipping', | |
}, | |
{ | |
startsWith: 'SOON', | |
key: 'soon', | |
}, | |
{ | |
startsWith: 'TESTING', | |
key: 'beta', | |
}, | |
{ | |
startsWith: 'LATER', | |
key: 'design', | |
}, | |
], | |
}, | |
{ | |
// This question defines a one-line summary of the change. | |
// The summary is transformed to have punctuation at the end. | |
startsWith: 'SUMMARY', | |
key: 'summary', | |
responseTransform: makeSentence, | |
}, | |
{ | |
// This question defines a multi-line description of the change. | |
// No transformation of the input is done. | |
startsWith: 'DESCRIPTION', | |
key: 'description', | |
}, | |
]; | |
/** YOU CAN CUSTOMIZE THE EMAIL BY CHANGING THE FOLLOWING METHODS **/ | |
//This generates the subject of the email. Change this to customize the email. | |
// The argument passed to this method is the output of `parseResponse`. | |
function generateSubject(data) { | |
return Utilities.formatString( | |
'[%s][%s] %s', | |
data['audience']['key'], | |
data['timeline']['key'], | |
data['summary']); | |
}; | |
// This generates the HTML body of the email. Change this to customize the email. | |
// The argument passed to this method is the output of `parseResponse`. | |
function generateHtmlBody(data) { | |
return Utilities.formatString( | |
[ | |
'<b>Submitted by</b> %s', | |
'<b>Audience</b> %s', | |
'<b>Timeline</b> %s', | |
'', | |
'%s', | |
'', | |
'%s', | |
'', | |
'<i>This change log was submitted via <a href="%s">this form</a>.</i>', | |
'', | |
].join('<br>'), | |
data['submitter'], | |
data['audience_raw'], | |
data['timeline_raw'], | |
data['summary'], | |
data['description'].split('\n').join('<br>'), // Convert newlines to HTML line breaks. | |
FORM_URL); | |
}; | |
/** YOU PROBABLY DON'T NEED TO CHANGE ANYTHING BELOW HERE **/ | |
// Generates a list of recipients for the form submission. | |
// This is based off of the config above. | |
// Also adds the submitter to the recipients. | |
function generateRecipient(data) { | |
var emails = data['audience']['emails'] || []; | |
return emails.concat(data['submitter']).join(','); | |
}; | |
// Generates a list of BCC recipients for the form submission. | |
// This is based off of the config above. | |
function generateBcc(data) { | |
var emails = data['audience']['bcc'] || []; | |
return emails.join(','); | |
}; | |
// This generates an object suitable for passing to `MailApp.sendEmail`. | |
// See https://developers.google.com/apps-script/reference/mail/mail-app#sendemailmessage | |
// for all options. | |
// The argument passed to this method is the output of `parseResponse`. | |
function generateEmail(data) { | |
return { | |
'to': generateRecipient(data), | |
'bcc': generateBcc(data), | |
'replyTo': data['submitter'], | |
'name': data['submitter'], | |
'noReply': true, // Note that this only works with G Suite accounts. | |
'subject': generateSubject(data), | |
'htmlBody': generateHtmlBody(data), | |
}; | |
}; | |
// Given a piece of text and an array of mapping items, return the first item | |
// which matches the input text. Items should minimally be of the form: | |
// { | |
// key: 'some-value', | |
// startsWith: 'text', | |
// } | |
// Text will match if it begins with the value of `startsWith`, ignoring case. | |
function transform(text, mapping) { | |
const textUpper = text.toUpperCase(); | |
for (var i = 0; i < mapping.length; i++) { | |
var startsWithMatcher = mapping[i]['startsWith']; | |
if (startsWithMatcher) { | |
if (textUpper.indexOf(startsWithMatcher.toUpperCase()) === 0) { | |
return mapping[i]; | |
} | |
} | |
} | |
return { | |
key: 'unknown', | |
}; | |
}; | |
// If the supplied piece of text doesn't end in punctuation, add a period. | |
function makeSentence(text) { | |
text = text.trim(); | |
switch (text[text.length - 1]) { | |
case ':': | |
case '?': | |
case '!': | |
case '.': | |
return text; | |
default: | |
return text + '.'; | |
} | |
}; | |
// Takes the form submission and runs it through QUESTION_MAP. | |
// Depending on how the map is configured, the output is a data map | |
// with template data under a set of keys. With the default configuration | |
// of this example, the output might be: | |
// | |
// { | |
// audience: { | |
// startsWith: 'DEVELOPER', | |
// key: 'developer-facing', | |
// emails: [ ... ], | |
// bcc: [ ... ], | |
// }, | |
// audience_raw: "User facing (any other user facing change, e.g. emails)" | |
// timeline: { | |
// startsWith: 'IMMINENT', | |
// key: 'shipping', | |
// }, | |
// timeline_raw: "Imminent (turning on today)", | |
// summary: "Changing the dashboard to be purple.", | |
// summary_raw: "Changing the dashboard to be purple" | |
// description: "...", | |
// description_raw: "..." | |
// } | |
// | |
// This data is passed to email generation methods as template data. | |
function parseResponse(submitter, itemResponses) { | |
const data = { | |
'submitter': submitter, | |
}; | |
// Iterate over all responses in the form. | |
for (var j = 0; j < itemResponses.length; j++) { | |
var itemResponse = itemResponses[j]; | |
// This is the raw title text of the question which was answered. | |
var questionTitle = itemResponse.getItem().getTitle(); | |
// This is the raw response text submitted. | |
var responseText = itemResponse.getResponse().toString(); | |
// Use QUESTION_MAP to get the appropriate block for the question. | |
var questionData = transform(questionTitle, QUESTION_MAP); | |
var questionKey = questionData['key']; | |
var responseData = responseText; | |
// If the item block contains `responseMap`, map the title of the question | |
// through the `responseMap` items array. | |
if (questionData['responseMap']) { | |
responseData = transform(responseText, questionData['responseMap']); | |
// If the item block contains `responseTransform`, map the response text | |
// through the supplied function. | |
} else if (questionData['responseTransform']) { | |
responseData = questionData['responseTransform'](responseText); | |
} | |
// Output from this question is stored under the key corresponding with | |
// the question. So if the key is "foo" then: | |
// data["foo"] contains the result of all mapping or transforms, otherwise | |
// just the raw response text. | |
// data["foo_raw"] contains the raw response text. | |
data[questionKey] = responseData; | |
data[questionKey + '_raw'] = responseText; | |
} | |
Logger.log('Template data %s', JSON.stringify(data)); | |
return data; | |
}; | |
// This runs every time the form is submitted. | |
// It loops through all available responses (should only ever be 1) | |
// and sends an email for each response. Then it deletes the response from | |
// the form. | |
function onFormSubmit() { | |
const form = FormApp.getActiveForm(); | |
const formResponses = form.getResponses(); | |
for (var i = 0; i < formResponses.length; i++) { | |
var response = formResponses[i]; | |
var responseId = response.getId(); | |
var emailData = parseResponse(response.getRespondentEmail(), response.getItemResponses()); | |
var email = generateEmail(emailData); | |
MailApp.sendEmail(email); | |
Logger.log('Sent email for response %s: %s', responseId, JSON.stringify(email)); | |
form.deleteResponse(responseId); | |
Logger.log('Deleted response %s', responseId); | |
} | |
}; | |
// You only need to run this one time, right before you want to first test the form. | |
// Run it directly from the Script Editor by choosing `installTriggers` in the | |
// function dropdown above and pressing the play icon. | |
function installTriggers() { | |
const form = FormApp.getActiveForm(); | |
ScriptApp.newTrigger('onFormSubmit').forForm(form).onFormSubmit().create(); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var EMAIL_TO = ['kurrik@gmail.com']; | |
var EMAIL_BCC = []; | |
var GO_LINK = 'bit.ly/foo'; | |
function isArray(ob) { | |
var type = typeof ob; | |
var name = ob.constructor && ob.constructor.name.toLowerCase(); | |
return type === 'object' && name === 'array'; | |
} | |
// Reference: https://developers.google.com/apps-script/reference/forms/form-response | |
function parseResponse(submitter, itemResponses) { | |
const data = { | |
'submitter': submitter, | |
'form': [], | |
'emailTo': EMAIL_TO, | |
'emailBcc': EMAIL_BCC, | |
'user': '', | |
}; | |
for (var j = 0; j < itemResponses.length; j++) { | |
var itemResponse = itemResponses[j]; | |
var title = itemResponse.getItem().getTitle(); | |
var response = itemResponse.getResponse(); | |
var responseText = ''; | |
if (isArray(response)) { | |
var responseItems = []; | |
for (var i = 0; i < response.length; i++) { | |
responseItems.push(' - ' + response[i].toString()); | |
responseText = responseItems.join('\n'); | |
} | |
} else { | |
responseText = response.toString(); | |
} | |
data['form'].push({ | |
'title': title, | |
'response': responseText, | |
}); | |
if (title.toLowerCase().indexOf('who is the user?') === 0) { | |
data['user'] = responseText; | |
} | |
} | |
return data; | |
}; | |
function generateEmailRecipients(emailArray) { | |
return (emailArray || []).join(','); | |
}; | |
function generateSubject(data) { | |
return Utilities.formatString( | |
'[request] %s', | |
data['user']); | |
}; | |
function generateHtmlBody(data) { | |
var answers = []; | |
for (var i = 0; i < data['form'].length; i++) { | |
answers.push(Utilities.formatString( | |
[ | |
'<b>%s</b>', | |
'%s', | |
'', | |
].join('<br>'), | |
data['form'][i]['title'], | |
data['form'][i]['response'].split('\n').join('<br>') | |
)); | |
} | |
return Utilities.formatString( | |
[ | |
'<b>Submitted by</b> %s', | |
'', | |
'%s', | |
'<i>This request was submitted via ' + | |
'<a href="http://%s">%s</a>.</i>', | |
'', | |
].join('<br>'), | |
data['submitter'], | |
answers.join('<br>'), | |
GO_LINK, | |
GO_LINK | |
); | |
}; | |
function generateEmail(data) { | |
return { | |
'to': generateEmailRecipients(data['emailTo']), | |
'bcc': generateEmailRecipients(data['emailBcc']), | |
'replyTo': data['submitter'], | |
'name': data['submitter'], | |
'noReply': true, | |
'subject': generateSubject(data), | |
'htmlBody': generateHtmlBody(data), | |
}; | |
}; | |
function onFormSubmit() { | |
const form = FormApp.getActiveForm(); | |
const formResponses = form.getResponses(); | |
for (var i = 0; i < formResponses.length; i++) { | |
var response = formResponses[i]; | |
var responseId = response.getId(); | |
var email = generateEmail(parseResponse( | |
response.getRespondentEmail(), | |
response.getItemResponses())); | |
MailApp.sendEmail(email); | |
Logger.log('Sent email for response %s: %s', responseId, JSON.stringify(email)); | |
form.deleteResponse(responseId); | |
Logger.log('Deleted response %s', responseId); | |
} | |
}; | |
function installTriggers() { | |
const form = FormApp.getActiveForm(); | |
ScriptApp.newTrigger('onFormSubmit').forForm(form).onFormSubmit().create(); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment