Throttle bulk sends to stay under Gmail quotas
Batch and pace large merges across days using script properties and a daily-cap counter.
Published Jul 8, 2025
Gmail will not let you send unlimited mail in a day. Free accounts top out at 100 recipients, Workspace accounts at 1,500. When Northwind’s annual contract renewal goes out to 800 clients, a single run blows straight through the limit — the early emails send, the later ones throw quota errors, and you are left guessing which clients actually heard from you.
The fix is to treat the send as a queue rather than an event. This script reads
a Queue sheet, checks how much quota is genuinely left right now, sends as
many messages as the budget allows, and stamps each one as done. Run it on a
daily trigger and an 800-client send simply drains over a few days — you fill
the queue once and leave it.
Daily quotas
- Free Gmail: 100 recipients per day.
- Workspace: 1,500 per day.
MailApp.getRemainingDailyQuota()returns the live number of recipients still available — the script reads this rather than assuming the full limit, because other automations may already have spent some of it.
What you’ll need
- A
Queuesheet with columns:email,subject,body, andsentAt. An emptysentAtcell means the row hasn’t been sent yet. - The merge content already rendered into the
subjectandbodycolumns — this script paces sends, it does not build the messages.
The script
// The sheet holding the queued, pre-rendered messages.
const QUEUE_SHEET = '1abcRenewalQueueId';
// The most messages to send in a single day, even if quota allows more.
const DAILY_TARGET = 1400;
// Recipients held back as a safety buffer, so the run never spends the
// account's quota right down to zero.
const QUOTA_BUFFER = 50;
/**
* Sends as many queued messages as the remaining daily quota and the
* DAILY_TARGET allow, stamping each sent row so re-runs are safe.
*/
function processQueue() {
// 1. Read the whole queue into memory.
const sheet = SpreadsheetApp.openById(QUEUE_SHEET).getSheets()[0];
const values = sheet.getDataRange().getValues();
const [header, ...rows] = values;
if (!rows.length) {
console.log('Queue is empty — nothing to send.');
return;
}
// Map header names to column indexes.
const col = Object.fromEntries(header.map((h, i) => [h, i]));
// 2. Work out how many messages we may send right now: the smaller of
// the live quota (minus a buffer) and the daily target.
const remaining = MailApp.getRemainingDailyQuota();
const sendBudget = Math.min(remaining - QUOTA_BUFFER, DAILY_TARGET);
if (sendBudget <= 0) {
console.log(`No budget left (quota remaining: ${remaining}).`);
return;
}
// 3. Send queued rows until the budget runs out.
let sent = 0;
for (let i = 0; i < rows.length && sent < sendBudget; i++) {
// Skip rows already sent.
if (rows[i][col.sentAt]) continue;
GmailApp.sendEmail(rows[i][col.email], rows[i][col.subject], rows[i][col.body]);
// 4. Stamp sentAt straight away (row i lives at values[i + 1]).
values[i + 1][col.sentAt] = new Date();
sent++;
}
// 5. Write the sentAt stamps back to the sheet in one operation.
sheet.getDataRange().setValues(values);
console.log(`Sent ${sent}, quota left: ${MailApp.getRemainingDailyQuota()}`);
}
How it works
processQueuereads the entireQueuesheet into avaluesarray and bails out early if it is empty. Thecolmap lets the loop reference columns by name.- It calls
getRemainingDailyQuota()to read how many recipients are genuinely still available, subtracts aQUOTA_BUFFERso the account is never spent to zero, and caps the result atDAILY_TARGET. The smaller of the two becomessendBudget. If that is zero or negative, the run stops. - The loop walks the queue, skips any row that already has a
sentAttimestamp, and sends each remaining message untilsentreachessendBudget. - Each sent row is stamped with the current time. Because the header was
sliced off, row
iinrowscorresponds tovalues[i + 1]. - After the loop, all the timestamps are written back in a single
setValuescall, and the run logs how many it sent and how much quota is left. ThesentAtstamp is the source of truth, so the next day’s run picks up exactly where this one stopped.
Example run
Suppose the Queue sheet holds 800 unsent rows and the script runs on a free
Gmail account with the full 100-recipient quota available:
| subject | body | sentAt | |
|---|---|---|---|
| [email protected] | Your renewal | Hi… | |
| [email protected] | Your renewal | Hi… | |
| … | … | … | (798 more) |
sendBudget works out to min(100 - 50, 1400) = 50. The first run sends 50
messages, stamps those rows, and logs Sent 50, quota left: 50. The next
day’s run sends the following 50, and so on — the 800-row queue fully drains in
16 days with no further input. On a Workspace account the budget would be
min(1500 - 50, 1400) = 1400, draining the same queue in a single run.
Schedule it
- In the Apps Script editor, open Triggers and click Add trigger.
- Function:
processQueue. Event source: time-based. Type: day timer, around 6am. - Save. Re-runs are safe because rows with a
sentAtvalue are skipped, so the queue drains across days automatically — fill it once and let it run.
Watch out for
getRemainingDailyQuota()counts recipients, not messages. A message with five people in CC burns five units of quota, so a queue with multiple recipients per row drains faster than the row count suggests.- The quota figure is shared across every script and trigger on the account.
If another automation has already sent that day,
remainingwill be lower than the headline limit — which is exactly why the script reads it live. - Sudden volume spikes can flag an account that has never sent in bulk. If this
is a first large send, lower
DAILY_TARGETfor the first week and ease the volume up gradually. - The buffer protects against a near-zero quota, but it doesn’t catch a single
failed send. If
GmailApp.sendEmailthrows mid-loop, the rows already sent are still un-stamped becausesetValuesruns at the end — wrap the send in atry/catchif you need each row stamped the instant it succeeds. - A daily trigger that misses a day simply means the queue drains one day later. Nothing is lost, but if a send is time-sensitive, check the execution log to confirm the trigger is firing.
Related
Build a newsletter sender with open tracking
Send to a Subscribers sheet and log opens with a 1x1 tracking pixel served from a web app.
Updated Jul 15, 2025
Personalize cold outreach at scale safely
Merge from a prospect sheet with per-row custom intro lines, paced to stay clear of spam filters.
Updated Jul 1, 2025
Send a personalized renewal reminder series
Drip three escalating emails to a client before a retainer renewal date.
Updated Jun 22, 2025
Mail merge with personalized PDF attachments
Send personalised emails from a Clients sheet, each with a custom-generated PDF attached.
Updated Jun 15, 2025
Parse bank-alert emails into an expense ledger
Convert transaction alerts from Northwind's bank into categorised spend rows automatically.
Updated Apr 28, 2026