Skip to content

Instantly share code, notes, and snippets.

@kurrik
Last active August 24, 2021 00:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save kurrik/30719823b9e28e952b2e3b7835803cfd to your computer and use it in GitHub Desktop.
Save kurrik/30719823b9e28e952b2e3b7835803cfd to your computer and use it in GitHub Desktop.
Sending nicely-formatted change emails from Google Apps Forms
/**
* 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();
};
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