appscript.dev
Automation Intermediate Gmail Sheets

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 Queue sheet with columns: email, subject, body, and sentAt. An empty sentAt cell means the row hasn’t been sent yet.
  • The merge content already rendered into the subject and body columns — 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

  1. processQueue reads the entire Queue sheet into a values array and bails out early if it is empty. The col map lets the loop reference columns by name.
  2. It calls getRemainingDailyQuota() to read how many recipients are genuinely still available, subtracts a QUOTA_BUFFER so the account is never spent to zero, and caps the result at DAILY_TARGET. The smaller of the two becomes sendBudget. If that is zero or negative, the run stops.
  3. The loop walks the queue, skips any row that already has a sentAt timestamp, and sends each remaining message until sent reaches sendBudget.
  4. Each sent row is stamped with the current time. Because the header was sliced off, row i in rows corresponds to values[i + 1].
  5. After the loop, all the timestamps are written back in a single setValues call, and the run logs how many it sent and how much quota is left. The sentAt stamp 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:

emailsubjectbodysentAt
[email protected]Your renewalHi…
[email protected]Your renewalHi…
(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

  1. In the Apps Script editor, open Triggers and click Add trigger.
  2. Function: processQueue. Event source: time-based. Type: day timer, around 6am.
  3. Save. Re-runs are safe because rows with a sentAt value 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, remaining will 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_TARGET for 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.sendEmail throws mid-loop, the rows already sent are still un-stamped because setValues runs at the end — wrap the send in a try/catch if 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