TriggerShelf

Recipe

Mail merge from Google Sheets with sent tracking

Trigger: Run from the editor or a time-driven triggerServices: SpreadsheetApp, MailAppMock-tested: Nov 2026

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

  1. 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.
  2. Paste the script into Extensions > Apps Script and save.
  3. 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;
}
Test status. 5 of 5 automated checks pass, run Nov 2026 against simulated Google services. This recipe has not yet been run inside a live Google account. Test it on a copy of your sheet first.

What you should see

EmailNameCompanyStatus
ana@example.comAnaNorthwindSent 2026-11-14 10:15
ben@example.comBenFabrikamSent 2026-11-14 10:15
cy@example.comCyContoso

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.