Sends a personalised email to each row of a Google Sheet, records every send, and never emails the same person twice.

UsesGmailGoogle Sheets

Mail merge a personalised email to every row of a spreadsheet

Give it a spreadsheet of recipients and a message written once, with merge fields where each row's first name and company should go. Each time you run it, it sends one personalised email per row from your own Gmail account.

Nothing in your recipient tab is ever changed. A separate 'Sent log' tab is added alongside it and one line is appended there for every email sent. Every address is checked against that log before sending, so running it again skips everyone already written to and catches an address that appears twice. A row with no usable email, or a blank cell where the message needs a name or company, is reported and skipped rather than sent a half-finished greeting. If an email goes out but the log cannot be written, the run stops at once rather than carrying on blind.

Your sheet needs a header row with an 'Email' column, plus 'First Name' and 'Company' if your message uses them. Each run sends only as many emails as you allow, so your first run can be a single test.

ValuesSet sheetUrl to blank.Set sheetUrl to blank.
ValuesSet sheetTabName to blank.Set sheetTabName to blank.
ValuesSet emailSubject to blank.Set emailSubject to blank.
ValuesSet emailBody to blank.Set emailBody to blank.
ValuesSet sentLogTabName to Sent log.Set sentLogTabName to Sent log.
ValuesSet maxEmailsThisRun to 1.Set maxEmailsThisRun to 1.
ValuesSet usesFirstName to emailSubject contains {{First Name}} or emailBody contains {{First Name}}.Set usesFirstName to emailSubject contains {{First Name}} or emailBody contains {{First Name}}.
ValuesSet usesCompany to emailSubject contains {{Company}} or emailBody contains {{Company}}.Set usesCompany to emailSubject contains {{Company}} or emailBody contains {{Company}}.
ValuesSet sentLogIsNew to true.Set sentLogIsNew to true.
Decisions & LoopsIf a step below fails, save the error as createLogTabErrorIf a step below fails, save the error as createLogTabError
Try
Google SheetsGoogle SheetsCreate the tab sentLogTabName in sheetUrl.Create the tab sentLogTabName in sheetUrl.
On error
ValuesChange sentLogIsNew to false.Change sentLogIsNew to false.
Decisions & LoopsIf sentLogIsNew is yesIf sentLogIsNew is yes
Yes
Google SheetsGoogle SheetsAdd a row to sentLogTabName in sheetUrl.Add a row to sentLogTabName in sheetUrl.
No
No steps
Google SheetsGoogle SheetsRead column 'A' of sentLogTabName in sheetUrl. Save it as sentLog.Read column 'A' of sentLogTabName in sheetUrl. Save it as sentLog.
ValuesSet alreadySent to an empty list.Set alreadySent to an empty list.
Decisions & LoopsFor each loggedAddress in sentLog.values:For each loggedAddress in sentLog.values:
Decisions & LoopsIf a step below fails, save the error as loggedAddressErrorIf a step below fails, save the error as loggedAddressError
Try
ListsAdd loggedAddress in lowercase to the end of alreadySent and save it as alreadySent.Add loggedAddress in lowercase to the end of alreadySent and save it as alreadySent.
On error
ValuesShow in the output: Ignored a cell in the '@sentLogTabName' tab that is not text.Show in the output: Ignored a cell in the '@sentLogTabName' tab that is not text.
ValuesSet sentThisRun to 0.Set sentThisRun to 0.
ValuesSet skippedRows to 0.Set skippedRows to 0.
ValuesSet alreadySentRows to 0.Set alreadySentRows to 0.
Google SheetsGoogle SheetsRead the rows of sheetUrl. Save it as recipientSheet.Read the rows of sheetUrl. Save it as recipientSheet.
Decisions & LoopsFor each row in recipientSheet.rows:For each row in recipientSheet.rows:
Decisions & LoopsIf sentThisRun is greater than or equal to maxEmailsThisRunIf sentThisRun is greater than or equal to maxEmailsThisRun
Yes
Decisions & LoopsStop repeating.Stop repeating.
No
No steps
ValuesSet rowLabel to row row.row_number as text.Set rowLabel to row row.row_number as text.
ValuesSet recipient to blank.Set recipient to blank.
Decisions & LoopsIf a step below fails, save the error as recipientErrorIf a step below fails, save the error as recipientError
Try
TextMake row.Email lowercase and save it as recipient.Make row.Email lowercase and save it as recipient.
On error
ValuesChange recipient to blank.Change recipient to blank.
Decisions & LoopsIf recipient is empty or recipient doesn't contain @If recipient is empty or recipient doesn't contain @
Yes
ValuesShow in the output: Skipped rowLabel: the Email column is empty or does not hold an email address.Show in the output: Skipped rowLabel: the Email column is empty or does not hold an email address.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
Decisions & LoopsIf alreadySent contains recipientIf alreadySent contains recipient
Yes
ValuesShow in the output: Skipped rowLabel: recipient is already listed in the '@sentLogTabName' tab.Show in the output: Skipped rowLabel: recipient is already listed in the '@sentLogTabName' tab.
ValuesAdd 1 to alreadySentRows.Add 1 to alreadySentRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
ValuesSet firstName to blank.Set firstName to blank.
Decisions & LoopsIf a step below fails, save the error as firstNameErrorIf a step below fails, save the error as firstNameError
Try
TextTrim row["First Name"] and save it as firstName.Trim row["First Name"] and save it as firstName.
On error
ValuesChange firstName to blank.Change firstName to blank.
ValuesSet company to blank.Set company to blank.
Decisions & LoopsIf a step below fails, save the error as companyErrorIf a step below fails, save the error as companyError
Try
TextTrim row.Company and save it as company.Trim row.Company and save it as company.
On error
ValuesChange company to blank.Change company to blank.
Decisions & LoopsIf usesFirstName is yes and firstName is emptyIf usesFirstName is yes and firstName is empty
Yes
ValuesShow in the output: Skipped rowLabel: your message uses {{First Name}} but that cell is empty.Show in the output: Skipped rowLabel: your message uses {{First Name}} but that cell is empty.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
Decisions & LoopsIf usesCompany is yes and company is emptyIf usesCompany is yes and company is empty
Yes
ValuesShow in the output: Skipped rowLabel: your message uses {{Company}} but that cell is empty.Show in the output: Skipped rowLabel: your message uses {{Company}} but that cell is empty.
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
TextReplace {{Email}} with recipient in emailSubject with {{First Name}} replaced by firstName with {{Company}} replaced by company and save it as mergedSubject.Replace {{Email}} with recipient in emailSubject with {{First Name}} replaced by firstName with {{Company}} replaced by company and save it as mergedSubject.
TextReplace {{Email}} with recipient in emailBody with {{First Name}} replaced by firstName with {{Company}} replaced by company and save it as mergedBody.Replace {{Email}} with recipient in emailBody with {{First Name}} replaced by firstName with {{Company}} replaced by company and save it as mergedBody.
ValuesSet emailSent to true.Set emailSent to true.
Decisions & LoopsIf a step below fails, save the error as sendErrorIf a step below fails, save the error as sendError
Try
GmailGmailSend an email to recipient.Send an email to recipient. Subject: mergedSubjectmergedBody
On error
ValuesChange emailSent to false.Change emailSent to false.
ValuesShow in the output: Could not send to recipient: sendError as text.Show in the output: Could not send to recipient: sendError as text.
Decisions & LoopsIf emailSent is noIf emailSent is no
Yes
ValuesAdd 1 to skippedRows.Add 1 to skippedRows.
Decisions & LoopsSkip to the next item.Skip to the next item.
No
No steps
Decisions & LoopsIf a step below fails, save the error as logWriteErrorIf a step below fails, save the error as logWriteError
Try
Google SheetsGoogle SheetsAdd a row to sentLogTabName in sheetUrl.Add a row to sentLogTabName in sheetUrl.
On error
Decisions & LoopsStop and report a problem: Sent the email to recipient but could not add it to the '@sentLogTabName' tab. The run stopped here so that nobody is emailed twice next time - fix the spreadsheet, then run it again.Stop and report a problem: Sent the email to recipient but could not add it to the '@sentLogTabName' tab. The run stopped here so that nobody is emailed twice next time - fix the spreadsheet, then run it again.
ListsAdd recipient to the end of alreadySent and save it as alreadySent.Add recipient to the end of alreadySent and save it as alreadySent.
ValuesAdd 1 to sentThisRun.Add 1 to sentThisRun.
ValuesShow in the output: Sent sentThisRun as text email(s) this run. alreadySentRows as text row(s) had already been emailed and skippedRows as text row(s) were skipped. Raise 'maxEmailsThisRun' and run it again to carry on down the list.Show in the output: Sent sentThisRun as text email(s) this run. alreadySentRows as text row(s) had already been emailed and skippedRows as text row(s) were skipped. Raise 'maxEmailsThisRun' and run it again to carry on down the list.