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
Prospectssheet with columns:email,firstName,company,customIntro,sentAt. An emptysentAtcell means “not yet sent”. - A genuine per-row
customIntrofor 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
sendOutreachBatchreads the entireProspectssheet into avaluesarray and bails out early if there are no rows. Thecolmap lets the loop reference columns by name.- The loop runs until it has either reached the end of the sheet or hit
DAILY_CAP. It skips rows that already carry asentAttimestamp and rows with no email address. - For each remaining prospect it calls
templateto merge the row’sfirstName,company, andcustomIntrointo the email body. - It sends one message under the Northwind sender name, with a subject line that names the prospect and their company.
- It writes the
sentAttimestamp into the in-memoryvaluesarray straight away. Because the header row was sliced off into a separate variable, the row at loop indexilives atvalues[i + 1]. Utilities.sleeppauses for a random interval between 25 and 90 seconds. The randomness is the point — a fixed delay is still a recognisable machine pattern.- After the loop, every
sentAtchange is written back to the sheet in a singlesetValuescall. 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:
| firstName | company | customIntro | sentAt | |
|---|---|---|---|---|
| [email protected] | Sam | Acme | Loved your new packaging refresh. | |
| [email protected] | Jo | Belltower | Saw 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:
- In the Apps Script editor, select
sendOutreachBatchand click Run. - Approve the authorisation prompt the first time.
- 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
sentAtstamp makes that recoverable, but it is still worth keeping batches small. - The
customIntroline 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
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
Throttle bulk sends to stay under Gmail quotas
Batch and pace large merges across days using script properties and a daily-cap counter.
Updated Jul 8, 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