Google Docs Mail Merge
One of the clubs I am involved in needed to contact the leaders of many student organizations through a personalized form e-mail. They were sending these e-mails out one at a time, consuming a lot of manpower/time. While MS Office has a very robust mail merge feature, all of the club’s documents are stored in the cloud, on Google Drive and GMail. I have found and modified a Google Apps Script for mail merge. The original version was found at http://www.labnol.org/internet/personalized-mail-merge-in-gmail/20981/. My modified version, attached to the bottom this post, allows the user to select from all available GMail “send-as” addresses, instead of only sending from the default address.
Like the original, this code is free to use and modify. To mail-merge functionality to your own documents, follow the following basic steps:
In Google Docs, create a basic spreadsheet that includes columns with each of your desired mail-merge variables (for example: e-mail address, name, position, salutation, etc…)
In your GDocs spreadsheet, go to Tools -> Script Editor and copy in the code attached to this post
In GMail, compose a draft message in which your mail-merge variables are written as $%variableName% (for example: $%name%, $%position%. Ensure that these variable names match your column names in your GDoc spreadsheet)
In your GDocs spreadsheet, go to Mail Merge -> Start Mail Merge
/*
* Mail Merge HD with GUI, Notifications and better Gmail integration
* @labnol 03/06/2012
*/
// Updated 2012/04/18 - Fixed the BCC issue
// Updated 2013/06/29 - Fixed the Sent Status issue
// Updated 2013/07/01 - Fixed the Inline Images Issue
// Updated 2013/08/15 - Fixed the UI
// Updated 2013/10/02 - Tailored this script to USAS, added email alias selection - Marc Khouri
function onOpen () {
//create menu items in google docs
var ss = SpreadsheetApp . getActiveSpreadsheet ();
var menu = [
{ name : " Clear Canvas (Reset) " , functionName : " labnolReset " },
{ name : " Start Mail Merge " , functionName : " fnMailMerge " }
];
ss . addMenu ( " Mail Merge " , menu );
ss . toast ( " Click the Mail Merge menu above to begin sending out auto-emails. Do this from the CLUBS TO EMAIL sheet. See instructions in the HOW TO USE sheet " , "" , 10 );
}
function labnolReset () {
//reset the current canvas, except for column headers
var mySheet = SpreadsheetApp . getActiveSpreadsheet (). getActiveSheet ();
mySheet . getRange ( 2 , 1 , mySheet . getMaxRows () - 1 , mySheet . getMaxColumns ()). clearContent ();
}
/*
* Forked from gist: 1838132 by ligthyear
* https://gist.github.com/1907310
* Re-forked by Marc Khouri
*/
function fnMailMerge () {
//find names of aliases and drafts
var threads = GmailApp . search ( ' in:draft ' , 0 , 10 );
if ( threads . length === 0 ) {
Browser . msgBox ( " We found no templates in Gmail. Please save a template as a draft message in your Gmail mailbox and re-run the Mail Merge program. " );
return ;
}
var aliases = GmailApp . getAliases ();
// build prompt and prompt for mail merge details
var myapp = UiApp . createApplication (). setTitle ( ' Mail Merge HD - USAS Edition ' ). setHeight ( 250 ). setWidth ( 300 );
var top_panel = myapp . createFlowPanel ();
top_panel . add ( myapp . createLabel ( " Please select your Mail Merge template " ));
var lb = myapp . createListBox ( false ). setWidth ( 250 ). setName ( ' templates ' ). addItem ( " Select template... " ). setVisibleItemCount ( 1 );
for ( var i = 0 ; i < threads . length ; i ++ ) {
lb . addItem (( i + 1 ) + ' - ' + threads [ i ]. getFirstMessageSubject (). substr ( 0 , 40 ));
}
top_panel . add ( lb );
top_panel . add ( myapp . createLabel ( "" ). setHeight ( 10 ));
top_panel . add ( myapp . createLabel ( " Please select your outgoing e-mail " ));
var lb1 = myapp . createListBox ( false ). setWidth ( 250 ). setName ( ' emailSendAddress ' ). addItem ( " Send from which address? " ). setVisibleItemCount ( 1 );
for ( var i = 0 ; i < aliases . length ; i ++ ) {
lb1 . addItem ( aliases [ i ]. substr ( 0 , 40 ));
}
top_panel . add ( lb1 );
top_panel . add ( myapp . createLabel ( "" ). setHeight ( 10 ));
top_panel . add ( myapp . createLabel ( " Please write the sender's full name " ));
var name_box = myapp . createTextBox (). setName ( " name " ). setWidth ( 250 );
top_panel . add ( name_box );
top_panel . add ( myapp . createLabel ( "" ). setHeight ( 10 ));
var bcc_box = myapp . createCheckBox (). setName ( " bcc " ). setText ( " BCC yourself? " ). setWidth ( 250 );
top_panel . add ( bcc_box );
top_panel . add ( myapp . createLabel ( "" ). setHeight ( 5 ));
var ok_btn = myapp . createButton ( " Start Mail Merge " );
top_panel . add ( ok_btn );
myapp . add ( top_panel );
// send data to startMailMerge function
var handler = myapp . createServerClickHandler ( ' startMailMerge ' ). addCallbackElement ( lb ). addCallbackElement ( lb1 ). addCallbackElement ( name_box ). addCallbackElement ( bcc_box );
ok_btn . addClickHandler ( handler );
SpreadsheetApp . getActiveSpreadsheet (). show ( myapp );
}
/*
* The code is written by Romain Vialard - Yet Another Mail Merge
* https://docs.google.com/document/d/1fsjHYL8TeHS2eiG217hqTgtGWI1RhRXcIvpfZFmIa3A/edit
*/
function startMailMerge ( e ) {
var ss = SpreadsheetApp . getActiveSpreadsheet ();
var dataSheet = ss . getActiveSheet ();
if ( dataSheet . getRange ( 1 , dataSheet . getLastColumn ()). getValue () != ' Mail Merge Status ' ){
dataSheet . getRange ( 1 , dataSheet . getLastColumn () + 1 ). setValue ( ' Mail Merge Status ' );
}
var headers = dataSheet . getRange ( 1 , 1 , 1 , dataSheet . getLastColumn ()). getValues ();
var emailColumnFound = false ;
for ( i in headers [ 0 ]){
if ( headers [ 0 ][ i ] == " Email Address " ){
emailColumnFound = true ;
}
}
if ( ! emailColumnFound ){
var emailColumn = Browser . inputBox ( " Which column contains the recipient's email address ? (A, B,...) " );
dataSheet . getRange ( emailColumn + '' + 1 ). setValue ( " Email Address " );
}
var dataRange = dataSheet . getRange ( 2 , 1 , dataSheet . getLastRow () - 1 , dataSheet . getLastColumn ());
ss . toast ( ' Starting mail merge, please wait... ' , ' Mail Merge ' , - 1 );
var selectedTemplate = GmailApp . search ( " in:drafts " )[( parseInt ( e . parameter . templates . substr ( 0 , 2 )) - 1 )]. getMessages ()[ 0 ];
var emailTemplate = selectedTemplate . getBody ();
var attachments = selectedTemplate . getAttachments ();
var cc = selectedTemplate . getCc ();
var bcc = "" ;
if ( e . parameter . bcc == " true " ) {
bcc = selectedTemplate . getFrom ();
}
var regMessageId = new RegExp ( selectedTemplate . getId (), " g " );
if ( emailTemplate . match ( regMessageId ) != null ) {
var inlineImages = {};
var nbrOfImg = emailTemplate . match ( regMessageId ). length ;
var imgVars = emailTemplate . match ( /<img [^ > ] +>/g );
var imgToReplace = [];
for ( var i = 0 ; i < imgVars . length ; i ++ ) {
if ( imgVars [ i ]. search ( regMessageId ) != - 1 ) {
var id = imgVars [ i ]. match ( /Inline \s images ?\s(\d) / );
imgToReplace . push ([ parseInt ( id [ 1 ]), imgVars [ i ]]);
}
}
imgToReplace . sort ( function ( a , b ) {
return a [ 0 ] - b [ 0 ];
});
for ( var i = 0 ; i < imgToReplace . length ; i ++ ) {
var attId = ( attachments . length - nbrOfImg ) + i ;
var title = ' inlineImages ' + i ;
inlineImages [ title ] = attachments [ attId ]. copyBlob (). setName ( title );
attachments . splice ( attId , 1 );
var newImg = imgToReplace [ i ][ 1 ]. replace ( /src=" [^\"] + \" / , " src= \" cid: " + title + " \" " );
emailTemplate = emailTemplate . replace ( imgToReplace [ i ][ 1 ], newImg );
}
}
objects = getRowsData ( dataSheet , dataRange );
for ( var i = 0 ; i < objects . length ; ++ i ) {
var rowData = objects [ i ];
if ( rowData . mailMergeStatus != " EMAIL_SENT " ){
// Replace markers (for instance ${"First Name"}) with the
// corresponding value in a row object (for instance rowData.firstName).
var emailText = fillInTemplateFromObject ( emailTemplate , rowData );
var emailSubject = fillInTemplateFromObject ( selectedTemplate . getSubject (), rowData );
GmailApp . sendEmail ( rowData . emailAddress , emailSubject , emailText ,
{ from : e . parameter . emailSendAddress , name : e . parameter . name , attachments : attachments , htmlBody : emailText , cc : cc , bcc : bcc , inlineImages : inlineImages });
dataSheet . getRange ( i + 2 , dataSheet . getLastColumn ()). setValue ( " EMAIL_SENT " );
}
}
ss . toast ( ' Shoot me feedback at [email protected] . ' , ' Mail Merge Complete ' , - 1 );
//print out logger output for debug
//ss.toast(Logger.getLog(),'Debug log',-1);
var app = UiApp . getActiveApplication ();
app . close ();
return app ;
}
// Replaces markers in a template string with values define in a JavaScript data object.
// Arguments:
// - template: string containing markers, for instance ${"Column name"}
// - data: JavaScript object with values to that will replace markers. For instance
// data.columnName will replace marker ${"Column name"}
// Returns a string without markers. If no data is found to replace a marker, it is
// simply removed.
function fillInTemplateFromObject ( template , data ) {
var email = template ;
// Search for all the variables to be replaced, for instance ${"Column name"}
var templateVars = template . match ( / \$\%[^\%] + \% /g );
if ( templateVars != null ){
// Replace variables from the template with the actual values from the data object.
// If no value is available, replace with the empty string.
for ( var i = 0 ; i < templateVars . length ; ++ i ) {
// normalizeHeader ignores ${"} so we can call it directly here.
var variableData = data [ normalizeHeader ( templateVars [ i ])];
email = email . replace ( templateVars [ i ], variableData || "" );
}
}
return email ;
}
/* This code is reused from the 'Reading Spreadsheet data using JavaScript Objects' tutorial */
function getRowsData ( sheet , range , columnHeadersRowIndex ) {
columnHeadersRowIndex = columnHeadersRowIndex || range . getRowIndex () - 1 ;
var numColumns = range . getEndColumn () - range . getColumn () + 1 ;
var headersRange = sheet . getRange ( columnHeadersRowIndex , range . getColumn (), 1 , numColumns );
var headers = headersRange . getValues ()[ 0 ];
return getObjects ( range . getValues (), normalizeHeaders ( headers ));
}
function getObjects ( data , keys ) {
var objects = [];
for ( var i = 0 ; i < data . length ; ++ i ) {
var object = {};
var hasData = false ;
for ( var j = 0 ; j < data [ i ]. length ; ++ j ) {
var cellData = data [ i ][ j ];
if ( isCellEmpty ( cellData )) {
continue ;
}
object [ keys [ j ]] = cellData ;
hasData = true ;
}
if ( hasData ) {
objects . push ( object );
}
}
return objects ;
}
function normalizeHeaders ( headers ) {
var keys = [];
for ( var i = 0 ; i < headers . length ; ++ i ) {
var key = normalizeHeader ( headers [ i ]);
if ( key . length > 0 ) {
keys . push ( key );
}
}
return keys ;
}
function normalizeHeader ( header ) {
var key = "" ;
var upperCase = false ;
for ( var i = 0 ; i < header . length ; ++ i ) {
var letter = header [ i ];
if ( letter == " " && key . length > 0 ) {
upperCase = true ;
continue ;
}
if ( ! isAlnum ( letter )) {
continue ;
}
if ( key . length == 0 && isDigit ( letter )) {
continue ; // first character must be a letter
}
if ( upperCase ) {
upperCase = false ;
key += letter . toUpperCase ();
} else {
key += letter . toLowerCase ();
}
}
return key ;
}
function isCellEmpty ( cellData ) {
return typeof ( cellData ) == " string " && cellData == "" ;
}
function isAlnum ( char ) {
return char >= ' A ' && char <= ' Z ' ||
char >= ' a ' && char <= ' z ' ||
isDigit ( char );
}
function isDigit ( char ) {
return char >= ' 0 ' && char <= ' 9 ' ;
}