Last updated: 2026-09-22 · Working Apps Script, no add-on required
This is the whole job in one page: read rows from a sheet, send one personalised email per row, and survive the two limits that break naive versions of this script — Google's daily recipient quota and the 6-minute cap on a single Apps Script execution.
Copy the final script at the bottom if you are in a hurry. It is the one that does not send anyone the same email twice.
One recipient per row, with a header row. The script finds columns by name, so the order does not matter and you can add your own columns freely.
| Name | Amount | |
|---|---|---|
| Dana Okonkwo | dana@example.com | $420.00 |
| Rin Takahashi | rin@example.com | $118.50 |
From inside the spreadsheet: Extensions → Apps Script. That creates a script bound to this one spreadsheet, which is what you want — it can reach this sheet without you granting access to the rest of your Drive.
Paste this, then press Run. The first run asks you to authorise it.
function sendBulkEmail() {
const sheet = SpreadsheetApp.getActiveSheet();
const values = sheet.getDataRange().getValues();
const header = values.shift();
const col = (name) => {
const i = header.indexOf(name);
if (i === -1) throw new Error('Missing column: ' + name);
return i;
};
const emailCol = col('Email');
const nameCol = col('Name');
const amountCol = col('Amount');
values.forEach((row) => {
const address = String(row[emailCol] || '').trim();
if (!address) return;
MailApp.sendEmail({
to: address,
subject: 'Your statement',
body: 'Hi ' + row[nameCol] + ',\n\n'
+ 'Your balance is ' + row[amountCol] + '.\n\n'
+ 'Thanks.'
});
});
}
That is a working mail merge. It is also the version that will hurt you on a real list, for the two reasons below.
Google caps how many recipients one account can email per day from Apps Script. From Google's published quota table for Apps Script services:
| Account type | Email recipients per day |
|---|---|
| Consumer (gmail.com) | 100 |
| Google Workspace | 1,500 |
The attachment ceiling is 25 MB per message. Quotas reset 24 hours after the first request, and Google states they may change without notice — so read the number at runtime rather than trusting any figure written down, including this one:
Logger.log(MailApp.getRemainingDailyQuota());
No tool can raise this. Any product claiming to send more than your account's daily allowance either uses a different sender (its own mail servers, so mail no longer comes from you) or is describing something other than what Apps Script does.
A single Apps Script execution is stopped at 6 minutes. Sending is slow enough that a few hundred rows can reach that ceiling — and when it hits, the script is killed mid-loop.
The damage is not the interruption. It is that a naive script has no record of where it stopped, so running it again starts from row 1 and emails everyone who already received the message a second time.
Three changes fix it:
Status value into each row as it is sent, and skip rows already marked.const STATUS_HEADER = 'Status';
const RUN_BUDGET_MS = 4 * 60 * 1000; // stop before the 6-minute ceiling
function sendBulkEmail() {
const started = Date.now();
const sheet = SpreadsheetApp.getActiveSheet();
const values = sheet.getDataRange().getValues();
const header = values[0];
const col = (name) => {
const i = header.indexOf(name);
if (i === -1) throw new Error('Missing column: ' + name);
return i;
};
const emailCol = col('Email');
const nameCol = col('Name');
const amountCol = col('Amount');
// Create the Status column if it is not there yet.
let statusCol = header.indexOf(STATUS_HEADER);
if (statusCol === -1) {
statusCol = header.length;
sheet.getRange(1, statusCol + 1).setValue(STATUS_HEADER);
}
let quota = MailApp.getRemainingDailyQuota();
let sent = 0, skipped = 0, failed = 0;
for (let r = 1; r < values.length; r++) {
if (values[r][statusCol] === 'SENT') { skipped++; continue; }
if (quota <= 0) {
Logger.log('Daily quota exhausted. Re-run tomorrow; sent rows are marked.');
break;
}
if (Date.now() - started > RUN_BUDGET_MS) {
Logger.log('Time budget reached. Re-run to continue from row ' + (r + 1) + '.');
break;
}
const address = String(values[r][emailCol] || '').trim();
if (!address) {
sheet.getRange(r + 1, statusCol + 1).setValue('SKIP: no address');
skipped++;
continue;
}
try {
MailApp.sendEmail({
to: address,
subject: 'Your statement',
body: 'Hi ' + values[r][nameCol] + ',\n\n'
+ 'Your balance is ' + values[r][amountCol] + '.\n\n'
+ 'Thanks.'
});
sheet.getRange(r + 1, statusCol + 1).setValue('SENT');
quota--;
sent++;
} catch (err) {
sheet.getRange(r + 1, statusCol + 1).setValue('ERR: ' + err.message);
failed++;
}
SpreadsheetApp.flush(); // make the status durable before any timeout
}
Logger.log('sent=' + sent + ' skipped=' + skipped + ' failed=' + failed);
}
SpreadsheetApp.flush() is the line that makes this safe. Apps
Script batches writes to the sheet. Without the flush, a run killed at the 6-minute mark can lose the
status of rows whose mail has already gone out — and those people get a second copy on the
next run. Flushing costs speed and buys correctness.
Before a real run, point every address at yourself and check the count. Or comment out the
MailApp.sendEmail call and log what it would have done — a dry run that touches
nothing:
// MailApp.sendEmail({ ... });
Logger.log('WOULD SEND to ' + address);
String(...).trim() above is what catches it.getValues() returns the underlying
value, not what you see. If you need the formatted string, use
getDisplayValues().try/catch keeps a single
malformed address from killing the run — the row is marked ERR: and the loop
continues.SENT are
skipped. To deliberately re-send a row, clear its Status cell.Sending plain text is the easy half. The common version of this job attaches a document that differs per row — an invoice, a certificate, a statement. That is the same loop with a template step in front of it: how to mail merge to PDF from Google Sheets.