appscript.dev
Automation Advanced Gmail Sheets

Personalize cold outreach at scale safely

Merge from a prospect sheet with per-row custom intro lines, paced to stay clear of spam filters.

Published Jul 1, 2025

Cold outreach lives or dies on two things: whether the email reads like it was written for one person, and whether the sending pattern looks human. Blast forty identical messages in two minutes and Gmail’s filters notice — your account gets throttled and your domain reputation takes a hit that lingers for weeks.

Northwind’s sales team runs outreach from a Prospects sheet where each row carries a hand-written customIntro line. This script merges that intro into a templated body, sends one prospect at a time, and waits a randomised 25 to 90 seconds between sends so the run paces like a person working through a list. A daily cap and a sentAt timestamp keep the whole thing safe to re-run.

What you’ll need

  • A Prospects sheet with columns: email, firstName, company, customIntro, sentAt. An empty sentAt cell means “not yet sent”.
  • A genuine per-row customIntro for every prospect — the script personalises the merge, but the intro line itself is the part that has to be real.
  • A sending account you are willing to pace slowly. With the default cap and delays, a full batch takes the better part of an hour to drain.

The script

// The Prospects sheet to read from.
const PROSPECTS_SHEET = '1abcProspectsSheetId';

// How many messages to send in a single run. Stays well under Gmail's
// daily recipient limit and keeps the pattern modest.
const DAILY_CAP = 40;

// Randomised pause between sends, in milliseconds — a human cadence,
// not a machine-gun burst.
const MIN_DELAY_MS = 25_000;
const MAX_DELAY_MS = 90_000;

/**
 * Sends up to DAILY_CAP personalised outreach emails to prospects who
 * have no sentAt timestamp yet, pacing each send with a random delay.
 */
function sendOutreachBatch() {
  // 1. Read the whole Prospects sheet into memory.
  const sheet = SpreadsheetApp.openById(PROSPECTS_SHEET).getSheets()[0];
  const values = sheet.getDataRange().getValues();
  const [header, ...rows] = values;

  if (!rows.length) {
    Logger.log('No prospect rows found — nothing to send.');
    return;
  }

  // Map header names to column indexes for readable lookups.
  const col = Object.fromEntries(header.map((h, i) => [h, i]));

  let sent = 0;
  for (let i = 0; i < rows.length && sent < DAILY_CAP; i++) {
    const row = rows[i];

    // 2. Skip rows already sent, or rows missing an email address.
    if (row[col.sentAt] || !row[col.email]) continue;

    // 3. Build the merged body from the template and this row's fields.
    const body = template({
      firstName: row[col.firstName],
      company: row[col.company],
      customIntro: row[col.customIntro],
    });

    // 4. Send a single, personalised message.
    GmailApp.sendEmail(row[col.email],
      `${row[col.firstName]}, a quick thought on ${row[col.company]}`, body, {
        name: 'Northwind Studios',
      });

    // 5. Stamp sentAt immediately so a re-run never sends twice.
    //    values is offset by one row because header was sliced off.
    values[i + 1][col.sentAt] = new Date();
    sent++;

    // 6. Pause a random interval before the next send.
    const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
    Utilities.sleep(delay);
  }

  // 7. Write the sentAt timestamps back to the sheet in one operation.
  sheet.getDataRange().setValues(values);
  Logger.log(`Sent ${sent} outreach email(s).`);
}

/**
 * Builds the plain-text email body. The per-row customIntro carries the
 * personalisation; the rest is a fixed Northwind sign-off.
 */
function template({ firstName, company, customIntro }) {
  return [
    `Hi ${firstName},`,
    '',
    customIntro,
    '',
    `If brand and product design at ${company} is on your mind this quarter, ` +
    `we'd love to compare notes — 20 minutes, no pitch.`,
    '',
    '— Awadesh, Northwind Studios',
  ].join('\n');
}

How it works

  1. sendOutreachBatch reads the entire Prospects sheet into a values array and bails out early if there are no rows. The col map lets the loop reference columns by name.
  2. The loop runs until it has either reached the end of the sheet or hit DAILY_CAP. It skips rows that already carry a sentAt timestamp and rows with no email address.
  3. For each remaining prospect it calls template to merge the row’s firstName, company, and customIntro into the email body.
  4. It sends one message under the Northwind sender name, with a subject line that names the prospect and their company.
  5. It writes the sentAt timestamp into the in-memory values array straight away. Because the header row was sliced off into a separate variable, the row at loop index i lives at values[i + 1].
  6. Utilities.sleep pauses for a random interval between 25 and 90 seconds. The randomness is the point — a fixed delay is still a recognisable machine pattern.
  7. After the loop, every sentAt change is written back to the sheet in a single setValues call. The timestamp is the source of truth, so the next run picks up exactly where this one stopped.

Example run

Suppose the Prospects sheet starts like this:

emailfirstNamecompanycustomIntrosentAt
[email protected]SamAcmeLoved your new packaging refresh.
[email protected]JoBelltowerSaw Belltower at the design fair.
(37 more)

A run sends the first 40 unsent rows. Sam receives an email titled “Sam, a quick thought on Acme” whose body opens with the merged intro “Loved your new packaging refresh.” Each send is followed by a pause, so the batch takes roughly 25 to 60 minutes to finish. Afterwards every processed row has a sentAt timestamp, and the log reads Sent 40 outreach email(s). The next run skips all 40 and continues from row 41.

Run it

This is a paced, on-demand job rather than a fire-and-forget trigger — you generally want a person deciding when a batch goes out:

  1. In the Apps Script editor, select sendOutreachBatch and click Run.
  2. Approve the authorisation prompt the first time.
  3. Leave the execution running — it will pause between sends and finish on its own. Re-run it the next day to send the following 40.

If you do schedule it, use a daily trigger and keep DAILY_CAP modest. Note that a single execution can run long because of the sleeps — Apps Script caps execution time at six minutes on consumer accounts, so a high cap with long delays may time out before the batch finishes.

Watch out for

  • Cold outreach is regulated, and the rules differ by region — CAN-SPAM in the US, GDPR in the EU, CASL in Canada. Always include a clear opt-out line in every customIntro, and honour removals promptly.
  • Hard bounces tank sender reputation fast. Pair this with Detect bounced emails and clean your list so dead addresses leave the sheet before they cost you deliverability.
  • The six-minute execution limit caps how long one run can sleep. With the default delays, a cap of around 40 is realistic; push the cap higher and the run may be killed mid-batch. The sentAt stamp makes that recoverable, but it is still worth keeping batches small.
  • The customIntro line is what makes the email land — a generic placeholder reads worse than no personalisation at all. If the intro is blank for a row, consider skipping that prospect rather than sending a hollow merge.
  • A randomised delay helps, but volume still matters. If the account has never sent outreach before, start with a cap well below 40 and ramp up over a week so the pattern builds gradually.

Related