Recipe
Mail merge from Google Sheets with sent tracking
Sends one personalised email per row, replaces {{Name}} style fields from your column headers, marks each row as sent, and stops before the daily mail quota runs out.
Set it up
- Put recipients in Sheet1 (A Email, B Name, C Company, D Status). Add a Settings tab with the subject in B1 and the body in B2.
- Paste the script into Extensions > Apps Script and save.
- Run sendMerge and approve the prompts. Rerun any time: rows with a Status are skipped.
The script
/**
* Mail merge from a sheet with sent tracking.
* Columns: A Email, B Name, C Company, D Status (blank = not sent yet).
* Template cells: Settings!B1 = subject, Settings!B2 = body. Use {{Name}} and {{Company}}.
* Run sendMerge from the menu or a trigger. Only rows with a blank Status are sent.
*/
var MM = { dataSheet: 'Sheet1', settingsSheet: 'Settings', statusCol: 4, headerRow: 1, reserve: 0 };
function fill_(template, record) {
return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, function (m, key) {
return record[key] === undefined ? m : String(record[key]);
});
}
function sendMerge() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(MM.dataSheet);
var settings = ss.getSheetByName(MM.settingsSheet);
var subject = settings.getRange(1, 2).getValue();
var body = settings.getRange(2, 2).getValue();
var values = sheet.getDataRange().getValues();
var headers = values[0];
var sent = 0;
for (var i = MM.headerRow; i < values.length; i++) {
if (MailApp.getRemainingDailyQuota() <= MM.reserve) break;
var row = values[i];
if (row[MM.statusCol - 1]) continue;
var to = String(row[0]).trim();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(to)) {
sheet.getRange(i + 1, MM.statusCol).setValue('Skipped: bad email');
continue;
}
var record = {};
headers.forEach(function (h, c) { record[h] = row[c]; });
MailApp.sendEmail(to, fill_(subject, record), fill_(body, record));
sheet.getRange(i + 1, MM.statusCol).setValue('Sent ' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'));
sent++;
}
return sent;
}What you should see
| Name | Company | Status | |
|---|---|---|---|
| ana@example.com | Ana | Northwind | Sent 2026-11-14 10:15 |
| ben@example.com | Ben | Fabrikam | Sent 2026-11-14 10:15 |
| cy@example.com | Cy | Contoso |
After one run: sent rows are stamped. Sample values, not output from a real account.
Common errors and fixes
- Service invoked too many times for one day: email
- The daily quota is used up. Rows without a Status are sent on the next run.
- {{Name}} appears in the email
- The header in row 1 must be spelled the same as the field.
- Exception: Invalid email
- Fix the address. Bad rows are marked Skipped: bad email.
Permissions. Running this script asks you to grant Google permissions for the services listed above, to your own script. TriggerShelf never receives your Google data. See the permissions table. Google will show "Google hasn't verified this app"; that is expected for a script you paste yourself.
TriggerShelf is independent and not affiliated with or endorsed by Google LLC. Google Sheets, Gmail, Google Drive and Apps Script are trademarks of Google LLC. Quota figures come from Google's Apps Script quotas page. Test on a copy first.