myjobb
Cold Email24 Oct 202620 min read

How to Automate Cold Emails to Recruiters (2026): Free Gmail + Sheets Script, n8n Workflow, or an AI Agent

Three ways to automate recruiter outreach: a free Gmail and Google Sheets Apps Script with threaded follow-ups that stop on reply, the same workflow in n8n, or an AI agent that also finds contacts and applies.

shreyansh
Co-Founder / CTO
How to automate cold emails to recruiters: an Apps Script that skips replied threads and sends from Gmail, with Sheets, Gmail and n8n

Disclosure: myjobb.ai publishes this guide and includes its own product as one of three options. We describe every tool from its own website and documentation (checked October 2026) and say where myjobb is not the best fit.

Quick answer: How to automate cold emails to recruiters: keep one Google Sheet row per job and contact, then pick a sender. Option 1 is a free Gmail + Apps Script mail merge with follow-ups that stop on reply. Option 2 is an n8n workflow. Option 3 is an AI agent such as myjobb.ai, which also finds contacts and applies.

Key takeaways

  • A free Gmail account can send to 100 recipients a day from Apps Script and 500 emails a day from Gmail itself; Google Workspace allows 1,500 and 2,000 (Google documentation, checked October 2026).
  • The script below sends a first email, two follow-ups in the same Gmail thread, and stops automatically on a reply or bounce. It defaults to 20 emails a day, on weekdays, 10:00 to 17:00 IST.
  • n8n can do the same with Gmail and Google Sheets nodes plus two Gmail API calls; it suits people already running n8n.
  • No automation fixes a weak list. Find the person who owns the role, verify their work email, and write one line that is true only for that company.

Prefer no setup at all? Start free with myjobb.ai: your first 20 applications are on us, no card, no auto-renewal. Recruiter outreach is included from Pro at ₹999/month.

How to automate cold emails to recruiters: 3 options compared

Automating cold emails to recruiters means a system sends each personalised first email, schedules follow-ups, and stops when the person answers, while you spend your time on research and replies. Here are three ways to do it, from free and hands-on to fully done for you.

  • Cost. Option 1: Gmail + Sheets + Apps Script: Free · Option 2: n8n workflow: Free if self-hosted; n8n Cloud is paid (see n8n's pricing page) · Option 3: AI agent (myjobb.ai): Pro ₹999/month, Max ₹1,999/month (GST incl.)
  • Setup effort. Option 1: Gmail + Sheets + Apps Script: Medium: paste a script, authorise once · Option 2: n8n workflow: High: credentials, nodes, testing · Option 3: AI agent (myjobb.ai): Low: sign up, connect Gmail or Outlook
  • Finds recruiter emails. Option 1: Gmail + Sheets + Apps Script: No, you research them · Option 2: n8n workflow: No, you research them · Option 3: AI agent (myjobb.ai): Yes: top 10 people per target company
  • Personalisation. Option 1: Gmail + Sheets + Apps Script: A "Personal line" column per row · Option 2: n8n workflow: Same, or add an AI node · Option 3: AI agent (myjobb.ai): Playbooks and tones; written from your resume and the job
  • Follow-ups in the same thread. Option 1: Gmail + Sheets + Apps Script: Yes, 2 · Option 2: n8n workflow: Yes, 2 · Option 3: AI agent (myjobb.ai): Yes, 3–5 steps, 1–3 days apart
  • Stops on reply. Option 1: Gmail + Sheets + Apps Script: Yes (thread + inbox search) · Option 2: n8n workflow: Yes (inbox search) · Option 3: AI agent (myjobb.ai): Yes
  • Daily volume. Option 1: Gmail + Sheets + Apps Script: Your cap (default 20), Google allows 100 via script · Option 2: n8n workflow: Your cap, within Gmail limits · Option 3: AI agent (myjobb.ai): Up to 50 per mailbox per day by default
  • Applies to the job too. Option 1: Gmail + Sheets + Apps Script: No · Option 2: n8n workflow: No · Option 3: AI agent (myjobb.ai): Yes: Naukri, Foundit, Instahyre, Hirist, IIM Jobs, Greenhouse, Ashby, Workday
  • Best for. Option 1: Gmail + Sheets + Apps Script: Technical job seekers who want control for ₹0 · Option 2: n8n workflow: People already using n8n · Option 3: AI agent (myjobb.ai): Job seekers in India who want the whole loop handled
Short on time? myjobb.ai does the research, the emails and the follow-ups, and applies for the same jobs. Start free: your first 20 applications are on us. No card, no auto-renewal.

Gmail sending limits: personal Gmail vs Google Workspace

Know the ceilings first. These come from Google's own pages, checked 24 October 2026; Google says limits can change without notice.

  • Emails per day from Gmail. Personal Gmail (gmail.com): 500, or one email to more than 500 recipients · Google Workspace: 2,000 per user; 500 on trial accounts · Source: Gmail Help; Workspace Admin Help
  • Gmail's built-in mail merge. Personal Gmail (gmail.com): not listed · Google Workspace: 1,500 per day · Source: Workspace Admin Help
  • Apps Script email recipients per day (MailApp, GmailApp). Personal Gmail (gmail.com): 100 · Google Workspace: 1,500 · Source: Apps Script quotas
  • Recipients per message from Apps Script. Personal Gmail (gmail.com): 50 · Google Workspace: 50 · Source: Apps Script quotas
  • Unique external recipients per day. Personal Gmail (gmail.com): not listed · Google Workspace: 2,000 (500 on trial) · Source: Workspace Admin Help
  • Script runtime. Personal Gmail (gmail.com): 6 min per execution · Google Workspace: 6 min per execution · Source: Apps Script quotas
  • Triggers total runtime. Personal Gmail (gmail.com): 90 min per day · Google Workspace: 6 hr per day · Source: Apps Script quotas
  • Email body size from Apps Script. Personal Gmail (gmail.com): 200 KB · Google Workspace: 400 KB · Source: Apps Script quotas
  • When quotas reset. Personal Gmail (gmail.com): 24 hours after the first request · Google Workspace: same · Source: Apps Script quotas
  • If you go over. Personal Gmail (gmail.com): Send again in about 1 to 24 hours · Google Workspace: Sending blocked for up to 24 hours (rolling window) · Source: Gmail Help; Workspace Admin Help

The practical rule for a job search: stay in the tens per day. The script's default of 20 a day is a fifth of the Apps Script quota on a free account and 4% of Gmail's 500-a-day limit.

Before you automate: find recruiter emails ethically

Automation multiplies whatever is in your sheet, so the list matters more than the code. Use only work addresses meant for professional contact, verified before sending.

  1. Start from the job post. Note the job ID, the hiring team and any recruiter or HR contact the posting names on Naukri, Instahyre, Hirist or the company's Greenhouse, Lever or Workday page.
  2. Find the owner of the role. Look up the recruiter or hiring manager on the company's careers page and their public profiles. Do this by hand: LinkedIn's User Agreement prohibits automation, so never scrape it.
  3. Find and verify the address. Hunter's free plan gives 50 credits a month; Apollo's free plan and ReachOut's free tier help too. Verify every address, because bounces can stop your Gmail sending for up to 24 hours.
  4. Never buy or scrape lists, and never guess a pattern and send to every variant.
  5. Keep only what you need. Store name, work email and role; delete anyone who asks. India's Digital Personal Data Protection Act, 2023 (and GDPR, if you write to people in Europe) governs personal data, so treat contacts with care. This is not legal advice.

Option 1: free Gmail + Google Sheets mail merge with follow-ups (Apps Script)

This is a Google Apps Script mail merge built for recruiter outreach: one row per job, a personal line per row, a first email plus two threaded follow-ups, and an automatic stop on reply or bounce. It runs inside your own Google account, so emails come from your Gmail and replies land in your inbox.

Step 1: Create the tracker Sheet

Create a new Google Sheet, then open Extensions → Apps Script. In Step 4 you run setupSheet, which builds these columns, the Status dropdown and the colours for you.

  • A. Header: Company · What goes in it: Company name · Filled by: You
  • B. Header: Role · What goes in it: Exact job title from the posting · Filled by: You
  • C. Header: Job link · What goes in it: URL or job ID · Filled by: You
  • D. Header: Recruiter name · What goes in it: Full name, for your reference · Filled by: You
  • E. Header: First name · What goes in it: Used in "Hi …"; blank becomes "Hi there" · Filled by: You
  • F. Header: Email · What goes in it: Verified work email · Filled by: You
  • G. Header: Personal line · What goes in it: One sentence that is true only for this company (required) · Filled by: You
  • H. Header: Status · What goes in it: Dropdown; set "To send" when the row is ready · Filled by: You, then the script
  • I. Header: Touches · What goes in it: 1, 2 or 3 · Filled by: Script
  • J. Header: First sent · What goes in it: Timestamp of email 1 · Filled by: Script
  • K. Header: Last sent · What goes in it: Timestamp of the latest email · Filled by: Script
  • L. Header: Next follow-up · What goes in it: When the next touch is due · Filled by: Script
  • M. Header: Thread ID · What goes in it: Gmail thread, used to follow up and detect replies · Filled by: Script
  • N. Header: Message ID · What goes in it: Email header that keeps follow-ups threaded · Filled by: Script
  • O. Header: Notes · What goes in it: Why a row was skipped or failed · Filled by: Script

The Status column is colour-coded so you can scan the sheet in seconds:

  • To send. Colour: Yellow · Meaning: Ready for the next run · Your move: Nothing
  • Sent. Colour: Light blue · Meaning: Email 1 delivered; follow-up scheduled · Your move: Wait
  • Follow-up 1 sent / Follow-up 2 sent. Colour: Blue / darker blue · Meaning: Sequence in progress · Your move: Wait
  • Replied. Colour: Green · Meaning: A reply (or auto-reply) arrived; sequence stopped · Your move: Answer within a day
  • Bounced. Colour: Red · Meaning: Delivery failed; sequence stopped · Your move: Fix the address, set "To send" again
  • No reply - closed. Colour: Grey · Meaning: Three touches, no answer · Your move: Move on
  • Do not contact. Colour: Dark grey · Meaning: They, or you, said stop · Your move: Never email
  • Error. Colour: Pink · Meaning: Something failed; see Notes · Your move: Fix, then set "To send"

Step 2: Paste the script

In the Apps Script editor, delete the sample code in Code.gs, paste the script below and save. It is original code written for this guide; you are free to copy and change it.

/** * Recruiter cold-email automation: Google Sheets + Gmail + Apps Script. * From the myjobb.ai tutorial "How to Automate Cold Emails to Recruiters" (October 2026). * Free to copy and change. * * What it does: *  - Sends one personalised first email per row marked "To send". *  - Sends up to 2 follow-ups in the same Gmail thread, on weekdays, inside your sending window. *  - Before every run it checks for a reply or a bounce and stops that sequence. *  - Caps sending far below Google's limits (default 20 emails a day). * * Setup: in the Apps Script editor, open Services (+), add "Gmail API", click Add. */const CONFIG = {  SHEET_NAME: 'Outreach',  YOUR_NAME: 'Your Name',  YOUR_PITCH: '[One sentence: your most relevant result, with a number you can prove]',  YOUR_LINKS: 'Resume: [Drive link] | LinkedIn: [profile link] | Phone: [number]',  TIME_ZONE: 'Asia/Kolkata',  SEND_DAYS: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'],  SEND_START_HOUR: 10,          // first send at 10:00 IST  SEND_END_HOUR: 17,            // no new sends from 17:00 IST  MAX_EMAILS_PER_DAY: 20,       // first emails + follow-ups combined  MAX_EMAILS_PER_RUN: 5,  PAUSE_MS: 15000,              // 15 seconds between sends  FOLLOW_UP_DAYS: [4, 7],       // follow-up 1 after 4 days, follow-up 2 after 7 more  CLOSE_AFTER_DAYS: 7,          // mark "No reply - closed" 7 days after follow-up 2  REQUIRED: ['Company', 'Role', 'Job link', 'Email', 'Personal line'],  TEST_EMAIL: ''                // blank = send tests to your own address};const HEADERS = ['Company', 'Role', 'Job link', 'Recruiter name', 'First name', 'Email',  'Personal line', 'Status', 'Touches', 'First sent', 'Last sent', 'Next follow-up',  'Thread ID', 'Message ID', 'Notes'];const STATUS = {  TO_SEND: 'To send',  SENT: 'Sent',  FOLLOW_UP_1: 'Follow-up 1 sent',  FOLLOW_UP_2: 'Follow-up 2 sent',  REPLIED: 'Replied',  BOUNCED: 'Bounced',  NO_REPLY: 'No reply - closed',  DO_NOT_CONTACT: 'Do not contact',  ERROR: 'Error'};// Touch 1 has the subject. Touches 2 and 3 go in the same thread as "Re: <subject>".const TEMPLATES = [  {    subject: '{{Role}} at {{Company}} - {{YourName}}',    body:      'Hi {{FirstName}},\n\n' +      '{{PersonalLine}}\n\n' +      'I applied for the {{Role}} role ({{JobLink}}) and wanted to write to you directly. ' +      '{{YourPitch}}\n\n' +      'Would you be open to a short call this week, or could you point me to the right person?\n\n' +      'Thanks,\n{{YourName}}\n{{YourLinks}}\n\n' +      'If this is not relevant, reply "no thanks" and I will not follow up.'  },  {    body:      'Hi {{FirstName}},\n\n' +      'Following up on my note about the {{Role}} role at {{Company}}. In one line: {{YourPitch}}\n\n' +      'Happy to share work samples or answer anything that helps.\n\n' +      'Thanks,\n{{YourName}}'  },  {    body:      'Hi {{FirstName}},\n\n' +      'I will close the loop here so I do not crowd your inbox. If the {{Role}} role is filled, ' +      'or someone else handles it, a name would help a lot.\n\n' +      'Thank you for your time,\n{{YourName}}'  }];/* ---------- Menu ---------- */function onOpen() {  SpreadsheetApp.getUi().createMenu('Recruiter outreach')    .addItem('1. Set up sheet', 'setupSheet')    .addItem('2. Send test emails to myself', 'sendTestToMyself')    .addItem('3. Check replies now', 'checkRepliesNow')    .addItem('4. Run once now', 'runOutreach')    .addSeparator()    .addItem('Start hourly automation', 'startAutomation')    .addItem('Stop automation', 'stopAutomation')    .addToUi();}function setupSheet() {  const ss = SpreadsheetApp.getActiveSpreadsheet();  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME) || ss.insertSheet(CONFIG.SHEET_NAME);  sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]).setFontWeight('bold');  sheet.setFrozenRows(1);  const rows = sheet.getMaxRows() - 1;  const statusRange = sheet.getRange(2, HEADERS.indexOf('Status') + 1, rows, 1);  const statusList = Object.keys(STATUS).map(function (k) { return STATUS[k]; });  statusRange.setDataValidation(SpreadsheetApp.newDataValidation()    .requireValueInList(statusList, true).setAllowInvalid(false).build());  const colours = {};  colours[STATUS.TO_SEND] = '#fff2cc';        // yellow: ready  colours[STATUS.SENT] = '#cfe2f3';           // light blue: waiting  colours[STATUS.FOLLOW_UP_1] = '#9fc5e8';  colours[STATUS.FOLLOW_UP_2] = '#6fa8dc';  colours[STATUS.REPLIED] = '#b6d7a8';        // green: act now  colours[STATUS.BOUNCED] = '#ea9999';        // red: fix the address  colours[STATUS.NO_REPLY] = '#d9d9d9';       // grey: closed  colours[STATUS.DO_NOT_CONTACT] = '#b7b7b7';  colours[STATUS.ERROR] = '#f4cccc';  const rules = Object.keys(colours).map(function (s) {    return SpreadsheetApp.newConditionalFormatRule().whenTextEqualTo(s)      .setBackground(colours[s]).setRanges([statusRange]).build();  });  sheet.setConditionalFormatRules(rules);  ['First sent', 'Last sent', 'Next follow-up'].forEach(function (h) {    sheet.getRange(2, HEADERS.indexOf(h) + 1, rows, 1).setNumberFormat('dd mmm yyyy hh:mm');  });}function startAutomation() {  stopAutomation();  ScriptApp.newTrigger('runOutreach').timeBased().everyHours(1).create();}function stopAutomation() {  ScriptApp.getProjectTriggers().forEach(function (t) {    if (t.getHandlerFunction() === 'runOutreach') ScriptApp.deleteTrigger(t);  });}/* ---------- Main jobs ---------- */function runOutreach() {  if (!inSendingWindow_()) return;            // outside weekdays 10:00-17:00 IST: do nothing  withLock_(function () {    const ctx = load_();    updateStatuses_(ctx);                     // stop-on-reply always runs before any send    sendDue_(ctx);  });}function checkRepliesNow() {  withLock_(function () { updateStatuses_(load_()); });}function sendTestToMyself() {  checkConfig_();  const ctx = load_();  const to = CONFIG.TEST_EMAIL || ctx.me;  for (let r = 1; r < ctx.data.length; r++) {    const row = ctx.data[r];    if (row[ctx.col['Status']] !== STATUS.TO_SEND) continue;    const subject = fill_(TEMPLATES[0].subject, row, ctx.col);    TEMPLATES.forEach(function (t, i) {      GmailApp.sendEmail(to,        '[TEST ' + (i + 1) + '/' + TEMPLATES.length + ' for ' + row[ctx.col['Email']] + '] ' +        (i === 0 ? subject : 'Re: ' + subject),        fill_(t.body, row, ctx.col), { name: CONFIG.YOUR_NAME });    });    SpreadsheetApp.getActiveSpreadsheet().toast('Sent ' + TEMPLATES.length + ' test emails to ' + to + ' using row ' + (r + 1));    return;  }  SpreadsheetApp.getActiveSpreadsheet().toast('No row with Status "To send" found.');}/* ---------- Stop on reply, bounce or end of sequence ---------- */function updateStatuses_(ctx) {  const now = new Date();  for (let r = 1; r < ctx.data.length; r++) {    const row = ctx.data[r];    const status = row[ctx.col['Status']];    if ([STATUS.SENT, STATUS.FOLLOW_UP_1, STATUS.FOLLOW_UP_2].indexOf(status) === -1) continue;    const outcome = checkForReply_(row, ctx.col, ctx.me);    if (outcome) {      setCells_(ctx.sheet, r, ctx.col, { 'Status': outcome, 'Next follow-up': '' });      row[ctx.col['Status']] = outcome;      continue;    }    const due = row[ctx.col['Next follow-up']];    if (status === STATUS.FOLLOW_UP_2 && due instanceof Date && now >= due) {      setCells_(ctx.sheet, r, ctx.col, { 'Status': STATUS.NO_REPLY, 'Next follow-up': '' });      row[ctx.col['Status']] = STATUS.NO_REPLY;    }  }}function checkForReply_(row, col, me) {  const threadId = row[col['Thread ID']];  if (threadId) {    let thread = null;    try { thread = GmailApp.getThreadById(threadId); } catch (e) { thread = null; }    if (thread) {      const messages = thread.getMessages();      for (let i = 0; i < messages.length; i++) {        const from = messages[i].getFrom().toLowerCase();        if (from.indexOf('mailer-daemon') !== -1 || from.indexOf('postmaster') !== -1) return STATUS.BOUNCED;        if (from.indexOf(me) === -1) return STATUS.REPLIED;      }    }  }  // Some people reply in a new thread, so also search for any mail from them since the first send.  const email = String(row[col['Email']]).trim().toLowerCase();  const firstSent = row[col['First sent']];  if (email && firstSent instanceof Date) {    const after = Utilities.formatDate(firstSent, CONFIG.TIME_ZONE, 'yyyy/MM/dd');    if (GmailApp.search('from:' + email + ' after:' + after, 0, 1).length > 0) return STATUS.REPLIED;  }  return null;}/* ---------- Sending ---------- */function sendDue_(ctx) {  const now = new Date();  let budget = Math.min(CONFIG.MAX_EMAILS_PER_RUN,    CONFIG.MAX_EMAILS_PER_DAY - sentToday_(),    MailApp.getRemainingDailyQuota());  // Follow-ups first: these people already know your name.  for (let r = 1; r < ctx.data.length && budget > 0; r++) {    const row = ctx.data[r];    const status = row[ctx.col['Status']];    const due = row[ctx.col['Next follow-up']];    if ((status === STATUS.SENT || status === STATUS.FOLLOW_UP_1) && due instanceof Date && now >= due) {      budget -= trySend_(ctx, r, status === STATUS.SENT ? 1 : 2);    }  }  // Then new first emails.  const contacted = {};  ctx.data.slice(1).forEach(function (row) {    const s = row[ctx.col['Status']];    if (s && s !== STATUS.TO_SEND) contacted[String(row[ctx.col['Email']]).trim().toLowerCase()] = true;  });  for (let r = 1; r < ctx.data.length && budget > 0; r++) {    const row = ctx.data[r];    if (row[ctx.col['Status']] !== STATUS.TO_SEND) continue;    const email = String(row[ctx.col['Email']]).trim().toLowerCase();    const problem = rowProblem_(row, ctx.col, contacted);    if (problem) {      if (row[ctx.col['Notes']] !== problem) setCells_(ctx.sheet, r, ctx.col, { 'Notes': problem });      continue;    }    budget -= trySend_(ctx, r, 0);    contacted[email] = true;  }}function trySend_(ctx, r, touch) {  try {    if (touch === 0) sendFirst_(ctx, r);    else sendFollowUp_(ctx, r, touch);    countSend_();    Utilities.sleep(CONFIG.PAUSE_MS);  } catch (e) {    setCells_(ctx.sheet, r, ctx.col, { 'Status': STATUS.ERROR, 'Notes': String(e && e.message ? e.message : e) });    ctx.data[r][ctx.col['Status']] = STATUS.ERROR;  }  return 1; // every attempt uses budget, so one bad row cannot loop through your quota}function sendFirst_(ctx, r) {  const row = ctx.data[r];  const subject = fill_(TEMPLATES[0].subject, row, ctx.col);  const body = fill_(TEMPLATES[0].body, row, ctx.col);  const draft = GmailApp.createDraft(String(row[ctx.col['Email']]).trim(), subject, body, { name: CONFIG.YOUR_NAME });  const message = draft.send();  const now = new Date();  const values = {    'Status': STATUS.SENT,    'Touches': 1,    'First sent': now,    'Last sent': now,    'Next follow-up': nextSendDate_(now, CONFIG.FOLLOW_UP_DAYS[0]),    'Thread ID': message.getThread().getId(),    'Message ID': message.getHeader('Message-ID'),    'Notes': ''  };  setCells_(ctx.sheet, r, ctx.col, values);  Object.keys(values).forEach(function (k) { row[ctx.col[k]] = values[k]; });}function sendFollowUp_(ctx, r, touch) {  if (typeof Gmail === 'undefined') throw new Error('Add the Gmail API under Services (+) in the Apps Script editor.');  const row = ctx.data[r];  const threadId = row[ctx.col['Thread ID']];  const messageId = row[ctx.col['Message ID']];  if (!threadId || !messageId) throw new Error('Missing Thread ID or Message ID, so the follow-up cannot be threaded.');  const subject = 'Re: ' + fill_(TEMPLATES[0].subject, row, ctx.col);  const body = fill_(TEMPLATES[touch].body, row, ctx.col);  const encodedBody = Utilities.base64Encode(body, Utilities.Charset.UTF_8).match(/.{1,76}/g).join('\r\n');  const raw = [    'From: ' + encodeHeader_(CONFIG.YOUR_NAME) + ' <' + ctx.me + '>',    'To: ' + String(row[ctx.col['Email']]).trim(),    'Subject: ' + encodeHeader_(subject),    'In-Reply-To: ' + messageId,    'References: ' + messageId,    'MIME-Version: 1.0',    'Content-Type: text/plain; charset=UTF-8',    'Content-Transfer-Encoding: base64',    '',    encodedBody  ].join('\r\n');  Gmail.Users.Messages.send({ raw: Utilities.base64EncodeWebSafe(raw), threadId: threadId }, 'me');  const now = new Date();  const isLast = touch === TEMPLATES.length - 1;  const values = {    'Status': touch === 1 ? STATUS.FOLLOW_UP_1 : STATUS.FOLLOW_UP_2,    'Touches': touch + 1,    'Last sent': now,    'Next follow-up': nextSendDate_(now, isLast ? CONFIG.CLOSE_AFTER_DAYS : CONFIG.FOLLOW_UP_DAYS[touch])  };  setCells_(ctx.sheet, r, ctx.col, values);  Object.keys(values).forEach(function (k) { row[ctx.col[k]] = values[k]; });}/* ---------- Helpers ---------- */function load_() {  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.SHEET_NAME);  if (!sheet) throw new Error('No sheet named "' + CONFIG.SHEET_NAME + '". Run "Set up sheet" first.');  const data = sheet.getDataRange().getValues();  const col = {};  data[0].forEach(function (h, i) { col[String(h).trim()] = i; });  HEADERS.forEach(function (h) {    if (col[h] === undefined) throw new Error('Missing column "' + h + '". Run "Set up sheet" first.');  });  return { sheet: sheet, data: data, col: col, me: Session.getEffectiveUser().getEmail().toLowerCase() };}function withLock_(fn) {  const lock = LockService.getScriptLock();  if (!lock.tryLock(10000)) return;           // another run is still going  try { checkConfig_(); fn(); } finally { lock.releaseLock(); }}function checkConfig_() {  if (CONFIG.YOUR_NAME === 'Your Name' || /\[/.test(CONFIG.YOUR_PITCH + CONFIG.YOUR_LINKS)) {    throw new Error('Edit YOUR_NAME, YOUR_PITCH and YOUR_LINKS in CONFIG before sending.');  }}function rowProblem_(row, col, contacted) {  for (let i = 0; i < CONFIG.REQUIRED.length; i++) {    if (!String(row[col[CONFIG.REQUIRED[i]]]).trim()) return 'Skipped: "' + CONFIG.REQUIRED[i] + '" is empty.';  }  const email = String(row[col['Email']]).trim().toLowerCase();  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Skipped: email address looks invalid.';  if (contacted[email]) return 'Skipped: this address is already in an active or finished sequence.';  return '';}function fill_(template, row, col) {  const first = String(row[col['First name']] || '').trim();  const values = {    FirstName: first || 'there',    Company: row[col['Company']],    Role: row[col['Role']],    JobLink: row[col['Job link']],    PersonalLine: row[col['Personal line']],    YourName: CONFIG.YOUR_NAME,    YourPitch: CONFIG.YOUR_PITCH,    YourLinks: CONFIG.YOUR_LINKS  };  const out = template.replace(/{{(\w+)}}/g, function (m, key) {    return values[key] !== undefined ? String(values[key]).trim() : m;  });  if (/{{\w+}}/.test(out)) throw new Error('Unknown placeholder in template: ' + out.match(/{{\w+}}/)[0]);  return out;}function encodeHeader_(text) {  return /^[\x20-\x7E]*$/.test(text) ? text    : '=?UTF-8?B?' + Utilities.base64Encode(text, Utilities.Charset.UTF_8) + '?=';}function inSendingWindow_() {  const now = new Date();  const day = Utilities.formatDate(now, CONFIG.TIME_ZONE, 'EEE');  const hour = Number(Utilities.formatDate(now, CONFIG.TIME_ZONE, 'H'));  return CONFIG.SEND_DAYS.indexOf(day) !== -1 && hour >= CONFIG.SEND_START_HOUR && hour < CONFIG.SEND_END_HOUR;}function nextSendDate_(from, days) {  const d = new Date(from.getTime() + days * 86400000);  while (CONFIG.SEND_DAYS.indexOf(Utilities.formatDate(d, CONFIG.TIME_ZONE, 'EEE')) === -1) {    d.setTime(d.getTime() + 86400000);        // push weekend dates to Monday  }  return d;}function todayKey_() {  return 'sent_' + Utilities.formatDate(new Date(), CONFIG.TIME_ZONE, 'yyyy-MM-dd');}function sentToday_() {  return Number(PropertiesService.getScriptProperties().getProperty(todayKey_()) || 0);}function countSend_() {  PropertiesService.getScriptProperties().setProperty(todayKey_(), String(sentToday_() + 1));}function setCells_(sheet, r, col, values) {  Object.keys(values).forEach(function (name) {    sheet.getRange(r + 1, col[name] + 1).setValue(values[name]);  });}

Step 3: Edit CONFIG and the templates

At the top of the script, set YOUR_NAME, YOUR_PITCH (one sentence with a result you can prove) and YOUR_LINKS (your resume as a Google Drive "anyone with the link can view" link, your LinkedIn and your phone). The script refuses to send while the placeholders in square brackets are still there.

Rewrite the three TEMPLATES in your own words. Keep the placeholders in double curly brackets, such as {{FirstName}} and {{Role}}; the script fills them from each row and stops with an error if one is misspelt.

Step 4: Turn on the Gmail API and authorise

In the editor's left panel, click Services (+), choose Gmail API and click Add. Follow-ups use it to reply inside the same thread. Then run setupSheet once from the toolbar. Google will ask you to authorise the script; because it is your own unpublished script, you may see an "unverified app" screen, and you continue through Advanced. Reload the Sheet to see the Recruiter outreach menu.

Also set the Sheet's time zone to India Standard Time under File → Settings, so timestamps display in IST.

Step 5: Send test emails to yourself

Fill one row, set its Status to "To send" and choose Recruiter outreach → Send test emails to myself. All three touches arrive in your inbox marked "[TEST 1/3]" and so on. Read them as the recruiter would and fix anything generic.

Step 6: Run once, then start the hourly automation

Choose Run once now on a weekday between 10:00 and 17:00 IST and check that the row turns light blue with a Thread ID. Then choose Start hourly automation; each run checks for replies before sending anything due. Stop automation removes the trigger.

How the follow-up and stop-on-reply logic works

  • Before every send, the script reads each active thread. Any message from someone other than you marks the row "Replied"; a message from a mail-delivery daemon marks it "Bounced". It also searches your inbox for mail from that address since the first send, in case they replied in a new thread.
  • Follow-ups go out 4 days after email 1 and 7 days after follow-up 1, moved to Monday if they fall on a weekend. They are sent through the Gmail API with the original thread ID and Message-ID headers, so they appear as replies in the same conversation.
  • Seven days after follow-up 2, a silent row becomes "No reply - closed".
  • Caps: at most 5 emails per run and 20 per day, 15 seconds apart, weekdays only. Hourly runs from 10:00 to 16:59 IST make 7 runs a day; 5 sends × 15 seconds is about 75 seconds of pausing per run, inside the 6-minute execution limit and the 90-minute daily trigger budget.
  • Guards: rows missing a company, role, job link, email or personal line are skipped with a note, as are invalid or already-contacted addresses.

Ethical use: use this script only for one-to-one job outreach to people who handle hiring, with honest content about a real role. Do not use it for marketing, bought lists or anyone who has asked you to stop. An out-of-office reply also counts as a reply, which is deliberate: the script errs on the side of stopping.

Option 2: build the same workflow in n8n (Gmail + Google Sheets)

n8n is a workflow automation tool with Gmail and Google Sheets nodes. Use it if you already run n8n or want extra steps, such as an AI node that drafts the personal line. It uses Option 1's Sheet layout; format the date columns as plain text so timestamps come back unchanged, and use one engine per sheet, not both.

Credentials first: create a Google OAuth2 credential for Gmail and Google Sheets. n8n's docs recommend OAuth2 for Gmail rather than a service account, and n8n Cloud offers managed OAuth for supported Google nodes.

  • 1. Node: Schedule Trigger · Key settings: Trigger interval: Custom (Cron), 0 10-16 * * 1-5; workflow Settings → Timezone: Asia/Kolkata · Why: Hourly on weekdays, 10:00–16:00 IST
  • 2. Node: Google Sheets: Get Row(s) · Key settings: Document: your tracker; Sheet: Outreach · Why: Reads every row
  • 3. Node: Code: "Plan this run" · Key settings: Mode: Run Once for All Items; code below · Why: Picks closes, due follow-ups and new emails; caps 5 per run, 20 per day
  • 4. Node: Loop Over Items · Key settings: Batch size 1 · Why: One email at a time
  • 5. Node: Switch · Key settings: Rules on {{ $json.action }}: first, follow_up, close · Why: Routes each row
  • 6a. Node: Gmail: Send a message · Key settings: To {{ $json.Email }}; Subject {{ $json.subject }}; Email Type: Text; Message {{ $json.body }}; Options: Append n8n attribution off, Sender Name · Why: Email 1 from your Gmail
  • 7a. Node: HTTP Request · Key settings: GET https://gmail.googleapis.com/gmail/v1/users/me/messages/{{ $json.id }}?format=metadata&metadataHeaders=Message-ID; Authentication: Predefined credential type → Gmail OAuth2 API · Why: Reads the Message-ID header for threading
  • 8a. Node: Google Sheets: Update Row · Key settings: Column to match on: Email; Status "Sent", Touches 1, First sent and Last sent {{ $now.toISO() }}, Next follow-up from the plan, Thread ID from node 6a, Message ID {{ $json.payload.headers[0].value }} · Why: Saves the thread details
  • 6b. Node: Gmail: Get Many messages · Key settings: Limit 1; Filters → Search {{ $json.search }}; node Settings → Always Output Data on · Why: Has the recruiter written back?
  • 7b. Node: If · Key settings: {{ $json.id }} is not empty · Why: True: update Status to "Replied". False: send the follow-up
  • 8b. Node: HTTP Request · Key settings: POST https://gmail.googleapis.com/gmail/v1/users/me/messages/send; same credential; JSON body raw and threadId from the Loop item · Why: Follow-up inside the same thread
  • 9b. Node: Google Sheets: Update Row · Key settings: Status "Follow-up 1 sent" or "Follow-up 2 sent", Touches, Last sent, Next follow-up · Why: Records the touch
  • 6c. Node: Google Sheets: Update Row · Key settings: Status "No reply - closed" · Why: Ends silent sequences
  • 10. Node: Wait · Key settings: 15 seconds, then back to Loop Over Items · Why: Spacing between sends

Inside the loop, reference the current row as {{ $('Loop Over Items').item.json.Email }}, because Gmail and HTTP nodes replace $json with their own output. To catch bounces, add a second Get Many searching from:mailer-daemon plus the address, and mark matches "Bounced".

The "Plan this run" Code node:

// n8n Code node "Plan this run" (Mode: Run Once for All Items)const MY_NAME = 'Your Name';                              // editconst PITCH = 'One sentence on a result you can prove.';  // editconst MAX_PER_RUN = 5, MAX_PER_DAY = 20;const FOLLOW_UP_DAYS = [4, 7], CLOSE_AFTER_DAYS = 7;const zone = 'Asia/Kolkata';const now = $now.setZone(zone);const rows = $input.all().map(item => item.json);const due = r => !!r['Next follow-up'] && DateTime.fromISO(r['Next follow-up']) <= now;const subject = r => `${r['Role']} at ${r['Company']} - ${MY_NAME}`;const hi = r => `Hi ${r['First name'] || 'there'},`;const sentToday = rows.filter(r => r['Last sent'] &&  DateTime.fromISO(r['Last sent']).setZone(zone).hasSame(now, 'day')).length;let budget = Math.min(MAX_PER_RUN, MAX_PER_DAY - sentToday);const out = [];for (const r of rows) {  if (r.Status === 'Follow-up 2 sent' && due(r)) out.push({ ...r, action: 'close' });}for (const r of rows) {  if (budget <= 0) break;  if ((r.Status === 'Sent' || r.Status === 'Follow-up 1 sent') && due(r)) {    const touch = r.Status === 'Sent' ? 2 : 3;    const body = touch === 2      ? `${hi(r)}\n\nFollowing up on my note about the ${r['Role']} role at ${r['Company']}. In one line: ${PITCH}\n\nThanks,\n${MY_NAME}`      : `${hi(r)}\n\nI will close the loop here so I do not crowd your inbox. If someone else handles the ${r['Role']} role, a name would help a lot.\n\nThank you,\n${MY_NAME}`;    const mime = [`To: ${r.Email}`, `Subject: Re: ${subject(r)}`,      `In-Reply-To: ${r['Message ID']}`, `References: ${r['Message ID']}`,      'MIME-Version: 1.0', 'Content-Type: text/plain; charset=UTF-8', '', body].join('\r\n');    out.push({ ...r, action: 'follow_up', touch,      newStatus: touch === 2 ? 'Follow-up 1 sent' : 'Follow-up 2 sent',      nextFollowUp: now.plus({ days: touch === 2 ? FOLLOW_UP_DAYS[1] : CLOSE_AFTER_DAYS }).toISO(),      search: `from:${r.Email} after:${DateTime.fromISO(r['First sent']).toFormat('yyyy/MM/dd')}`,      threadId: r['Thread ID'], raw: Buffer.from(mime).toString('base64url') });    budget--;  }}const seen = new Set(rows.filter(r => r.Status && r.Status !== 'To send')  .map(r => String(r.Email).trim().toLowerCase()));for (const r of rows) {  if (budget <= 0) break;  const email = String(r.Email || '').trim().toLowerCase();  if (r.Status !== 'To send' || !r['Personal line'] || seen.has(email) ||      !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) continue;  out.push({ ...r, action: 'first', subject: subject(r),    body: `${hi(r)}\n\n${r['Personal line']}\n\nI applied for the ${r['Role']} role (${r['Job link']}) and wanted to write to you directly. ${PITCH}\n\nWould you be open to a short call this week, or could you point me to the right person?\n\nThanks,\n${MY_NAME}\n\nIf this is not relevant, reply "no thanks" and I will not follow up.`,    nextFollowUp: now.plus({ days: FOLLOW_UP_DAYS[0] }).toISO() });  seen.add(email);  budget--;}return out.map(json => ({ json }));

The trigger runs only on weekdays, so weekend follow-ups go out on Monday. Test with two rows pointing at a second address you own before adding real recruiters.

Option 3: let an AI agent find, write, send and follow up

An AI job agent automates the parts the scripts leave to you: finding the right people, writing to them and applying for the job itself. myjobb.ai is an AI job agent for job seekers in India; it finds jobs that match your resume, tailors your resume to any job description you pick, applies for you on Indian job portals and company career sites, finds people who can refer you, emails recruiters and hiring managers from your own inbox, and follows up, and you see everything it does on WhatsApp and in an activity log.

How its cold email outreach works, step by step:

  1. Connect your inbox. Gmail or Outlook through the official sign-in, or any SMTP mailbox. Emails go out from your own address.
  2. Pick target companies. For each one, the referral finder runs up to 7 searches, reads the top 30 public profiles and returns the 10 people most likely to refer you, usually including 2–3 recruiters and, at companies under 250 people, up to 2 founders. It can look up their work email.
  3. Choose a playbook and tone. There are 10 playbooks and 6 tones, with A/B testing.
  4. Let the sequence run. 3–5 steps, 1–3 days apart, as threaded follow-ups that stop automatically when someone replies. By default it sends up to 50 emails per mailbox per day, on weekdays, within a sending window, with an unsubscribe link and suppression.
  5. Handle replies. It labels replies and drafts 3 possible responses.

Alongside outreach, it applies on Naukri, Foundit, Instahyre, Hirist, IIM Jobs and Greenhouse, Ashby and Workday career sites with your default resume, and asks you on WhatsApp or by email when unsure of a screening answer such as CTC. 1M+ job seekers have used myjobb, and it has processed 400K+ applications.

Honest trade-offs: the 20 free applications cover auto-apply only; cold email and the referral finder need Pro (₹999/month) or Max (₹1,999/month). It does not send LinkedIn messages, automate LinkedIn Easy Apply, apply on Lever career sites or guarantee replies. If you enjoy building and need ten emails a week, Option 1 is free and fine.

Cost against time, as an example: if researching and personalising one email takes you 10 minutes, 20 emails a day is 200 minutes. Pro costs ₹999 for the month and also includes 350 applications.

Start free: your first 20 applications are on us. No card, no auto-renewal.

The 3-touch sequence: a cold email to a recruiter that gets a reply

A cold email to recruiter that gets a reply is specific, short and easy to answer, and the follow-ups add something new instead of repeating "just checking in". This is the sequence the script and workflow above send; rewrite the wording in your voice.

  • 1. First email. When: Day 0, weekday morning IST · Goal: Make the role and your fit obvious · What to write: Subject "Role at Company - Your Name"; one line only true for them; the role and link; one proof point; a small ask
  • 2. Follow-up 1. When: About 4 days later, same thread · Goal: Add one new piece of value · What to write: Your strongest result in one line, or a relevant work sample
  • 3. Follow-up 2. When: About 7 days later, same thread · Goal: Close the loop politely · What to write: Offer to step back; ask who else handles the role
  • Stop. When: On any reply, bounce or "no" · Goal: Respect their time · What to write: The script or agent stops automatically

Tips for Indian recruiters: put your notice period and current city in the first email if they are relevant, mention the job ID from Naukri or the career page, and keep it short enough to read on a phone without scrolling. For copy-paste versions for freshers, experienced hires and referral asks, see our cold email to recruiter templates for India.

We have not published reply-rate data for these templates yet. Track your own in the Sheet: sent, bounced, replied and interviews per template.

How to automate cold emails to recruiters without getting flagged: deliverability checklist

To automate cold emails to recruiters without getting flagged, send few, personal, plain-text emails from your real address, verify every recipient, and stop the moment anyone replies or bounces.

  • Stay in the tens per day, far below 100 (Apps Script) and 500 (Gmail).
  • One recipient per email; never BCC a list.
  • Verify every address; Google may stop your sending for up to 24 hours after many bounces.
  • Every email has a line written for that company.
  • Plain text, one or two links, resume as a link.
  • Weekdays, working hours IST, with a pause between sends.
  • Follow up in the same thread, at most twice.
  • Stop on reply, bounce or any "no"; mark them "Do not contact".
  • Send from your real name and one consistent address.
  • On your own domain, follow Google's Email sender guidelines: authenticate with SPF and DKIM and keep spam complaints low.

Risks, limits and ethical use

  • Gmail limits. Going over a limit can block sending for up to 24 hours. Mitigation: the caps above, and MailApp.getRemainingDailyQuota() in the script.
  • Annoying the person you need. Generic copy or too many nudges burns the contact. Mitigation: required personal line, two follow-ups at most, stop on reply.
  • Broken threading. Some mail apps may show a follow-up as a new email. Mitigation: the script sets the thread ID plus In-Reply-To and References headers; test with your own second address first.
  • Auto-replies stop the sequence. That is deliberate; check "Replied" rows by hand.
  • Privacy and consent. Use work addresses meant for professional contact, store the minimum, delete on request. Never use these tools for bulk marketing.
  • No outcome guarantees. Automation saves time; it does not promise replies, referrals or interviews.

FAQ

How to automate cold emails to recruiters without coding?

Use an AI job agent or a Gmail mail-merge add-on. myjobb.ai finds people at your target companies, emails them from your Gmail or Outlook and follows up, stopping when they reply, from ₹999/month. Mailmeteor and GMass add mail merge to Gmail, with follow-up sequences on their higher plans. You still research contacts yourself with add-ons.

How to automate cold emails to recruiters on a free Gmail account?

Use the Apps Script in Option 1. It runs free inside your Google account and sends from your Gmail, with two threaded follow-ups and a stop on reply. On a free account, Apps Script can email 100 recipients a day, and Gmail allows 500 emails a day, so keep your own cap far lower.

What is the best tool to cold email recruiters?

The best tool to cold email recruiters finds the right person, sends from your own inbox, follows up and stops on reply. For India, myjobb.ai also applies to the job. Apply Ops, Dear Hiring Manager and ReachOut cover parts of the job. See our comparison of the best tools to cold email recruiters.

How many emails can Google Apps Script send per day?

Apps Script can email 100 recipients a day on a personal Gmail account and 1,500 on Google Workspace, per Google's quotas page. Quotas reset 24 hours after the first request. Call MailApp.getRemainingDailyQuota() to see what is left today; the script checks it before every run.

Will automated cold emails get my Gmail account suspended?

Not if you stay well under Google's limits and send genuine one-to-one emails. Google can block sending for up to 24 hours when you exceed a limit or many messages bounce. Keep volume in the tens, verify addresses, avoid BCC blasts and stop on replies.

How does the script know a recruiter has replied?

Before each run, it reads the Gmail thread of every active row. A message from anyone other than you marks the row "Replied"; a delivery-failure message marks it "Bounced". It also searches your inbox for mail from that address since the first email, in case the reply started a new thread.

How many follow-ups should an automated sequence send?

Two follow-ups, so three touches in total, a few days apart, then stop. Each follow-up should add something new. For sales email, Guideflow cites Belkins data linking follow-ups to 49% more replies; there is no comparable public data for job seekers yet.

Can n8n send follow-ups in the same Gmail thread?

Yes. Save the thread ID and the Message-ID header from the first email, then send the follow-up through the Gmail API's send endpoint with that thread ID and matching In-Reply-To and References headers, using n8n's HTTP Request node with your Gmail OAuth2 credential.

Should I attach my resume to an automated cold email?

Link it instead. A Google Drive link set to "anyone with the link can view" keeps the email light, works in plain text and lets you update the file without resending. Attach a PDF only when the recruiter asks for one or the posting says so.

Can I automate cold emails to recruiters from Outlook instead of Gmail?

Yes, but not with this script, which works only with Gmail. n8n has a Microsoft Outlook node, so you can rebuild the workflow with it. myjobb.ai connects Outlook through the official sign-in, or any SMTP mailbox, and sends its follow-up sequences from that address.

Bottom line: choose your level of automation

How to automate cold emails to recruiters comes down to how much you want to build. For ₹0 and full control, copy the Apps Script and the Sheet. If you already live in n8n, rebuild it there. If you want the research, the emails, the follow-ups and the applications handled together, use an agent.

Start free: your first 20 applications are on us. No card, no auto-renewal. See how myjobb applies on Naukri, Foundit, Instahyre, IIM Jobs, Hirist and company career sites.

Sources

Two hands reaching for each other over a meadow: a job seeker and the myjobb AI job agent working together

no more solo job hunting.

Apply to your first job