// Sheets · Gmail

Send bulk emails from a spreadsheet in Google Sheets.

Read a list of recipients from a Sheet, render a templated message, and ship the lot through Gmail — without leaving Apps Script. One trigger, no third-party tools.

I have a column of email addresses. I want to email each one without copying them into BCC.

The script

copy · paste · trigger
sendBulkFromSheet.gs
Apps Script
// Send a templated email to every row in the active sheet.
// Columns expected: A=email, B=name, C=status. Skips rows where C === 'sent'.
function sendBulkFromSheet() {
  const sheet = SpreadsheetApp.getActiveSheet();
  const rows = sheet.getDataRange().getValues();

  // Drop header row.
  for (let i = 1; i < rows.length; i++) {
    const [email, name, status] = rows[i];
    if (!email || status === 'sent') continue;

    GmailApp.sendEmail(email, 'Your weekly digest', `Hi ${name},

Here's the update you asked for. Reply to this thread if anything's off.

— The bulldo.gs crew`);

    // Mark the row sent so a re-run is idempotent.
    sheet.getRange(i + 1, 3).setValue('sent');
  }
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

How it works

Apps Script reads the active sheet via SpreadsheetApp.getActiveSheet() and pulls every row in one call to getValues() — far cheaper than reading row by row.

For each non-empty row that hasn't already been marked "sent", GmailApp.sendEmail dispatches the message. The function is a normal Gmail send, so it counts against your daily quota (1,500/day for Workspace, 100/day for free Gmail).

After every successful send, the script writes "sent" back to column C. That makes the function safely idempotent: re-running it picks up where it left off, instead of double-sending.

How to schedule it

Open Triggers in the Apps Script editor (clock icon in the left rail). Add a time-based trigger that fires sendBulkFromSheet() on the cadence you want — daily 9am is the usual choice for digests.

If you need to send only once when a specific row is added, use an installable onChange trigger on the sheet instead. The same function body works.

Common pitfalls

Quota: free Gmail caps at 100 messages per day. If your sheet has more rows, batch across multiple days or upgrade to a paid Workspace tier.

Authorization: the first run prompts for Gmail and Sheets scopes. Approve once, then triggers run silently afterward.

Encoding: sendEmail accepts plain text by default. For HTML, pass an options object with htmlBody — the bulldo.gs Pro generator can swap the function shape for you.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Will this work in the free Gmail tier?
Yes, but with a 100-message daily cap. Workspace tiers raise it to 1,500/day. The script itself is identical in both environments.
How do I add a delay between sends?
Insert Utilities.sleep(milliseconds) inside the loop. A 250ms delay is enough to avoid Gmail throttling while keeping a 100-row run under 30 seconds.
Can I attach files from Drive?
Yes — pass options.attachments to sendEmail with an array of DriveApp blobs. Use DriveApp.getFileById(id).getBlob() to grab them.
What if a row's email is malformed?
GmailApp throws on invalid addresses. Wrap the send in try/catch and write the error to a fourth column for review.