Automated cold email system with follow-ups and reply detection, built on Google Apps Script + Google Sheets.
- Sends personalized cold emails using
{{firstName}}and{{company}}placeholders - Auto follow-up 3 times (2, 5, and 9 days after initial send) if no reply
- Detects replies via Gmail thread — stops follow-ups automatically
- Email open tracking — 1x1 pixel to detect when emails are viewed
- Link click tracking — track which links are clicked and when
- Bounce detection — auto-detect delivery failures and mark leads as bounced
- All leads, statuses, and engagement tracked in Google Sheets
- Daily time-based trigger for fully automated runs
Create a new Google Sheet with the following columns in order:
| A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| firstName | lastName | company | status | threadIds | lastEmailDate | initialSendDate | notes | opened | lastOpened | clicked | lastClicked | clickedLinks | trackingIds | bounceReason | bounceDate |
- Row 1 should be the header row (exact names don't matter, just the order)
- Add your leads starting from Row 2 (columns A-D only)
- Leave all columns from E onward blank — the script fills them automatically
Note: Columns J-Q are optional tracking columns. If you don't want tracking, you can stop at column I.
- Open your Google Sheet
- Go to Extensions → Apps Script
- Delete the default
Code.gscontent - Create four files and paste the contents:
Config.gs→ pasteConfig.gsEmailTemplates.gs→ pasteEmailTemplates.gsCode.gs→ pasteCode.gsTracking.gs→ pasteTracking.gs
In Config.gs, update:
SHEET_ID: "YOUR_GOOGLE_SHEET_ID_HERE", // From the Sheet URL
SENDER_NAME: "Your Name",
INITIAL_SUBJECT: "Quick question for {{company}}",
// Tracking settings
ENABLE_OPEN_TRACKING: true, // Set false to disable open tracking
ENABLE_CLICK_TRACKING: true, // Set false to disable click tracking
ENABLE_BOUNCE_DETECTION: true, // Set false to disable bounce detectionGet your Sheet ID from the URL:
https://docs.google.com/spreadsheets/d/THIS_IS_YOUR_SHEET_ID/edit
To enable reliable open/click tracking, deploy Tracking.gs as a webhook receiver and the cloudflare-worker/ subfolder as the public tracking endpoint.
- In Apps Script, click Deploy → New deployment
- Click the settings icon ⚙️ and select Web app
- Set Execute as: Me
- Set Who has access: Anyone
- Click Deploy and copy the Web App URL
In Config.gs, set:
TRACKING_BASE_URL: "https://your-worker.your-subdomain.workers.dev",
TRACKING_WEBHOOK_SECRET: "replace-with-a-long-random-secret",- Open the
cloudflare-workerfolder - Create
.dev.varsfrom.dev.vars.example - Set:
APPS_SCRIPT_WEBHOOK_URL=https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec TRACKING_WEBHOOK_SECRET=replace-with-the-same-secret-from-config-gs
- Run:
npm install npx wrangler deploy
- Copy the deployed Worker URL into
Config.gsasTRACKING_BASE_URL
How it works:
- Open tracking: A 1x1 pixel is served by the Worker, and the Worker posts the open event back to Apps Script.
- Click tracking: Links are rewritten to the Worker, which logs the click back to Apps Script and then returns a real
302redirect.
Edit EmailTemplates.gs to write your actual email copy:
getInitialEmailBody()— your cold emailgetFollowUp1Body()— first follow-upgetFollowUp2Body()— second follow-upgetFollowUp3Body()— final follow-up (break-up email)
- In Apps Script, select
runColdEmailerfrom the function dropdown - Click Run — authorize Gmail + Sheets permissions when prompted
- Check View → Logs to see what happened
- Check your Sheet — statuses should be updated
Run setupDailyTrigger once (select it from the dropdown and click Run).
This creates a trigger that runs runColdEmailer every day at 11 PM.
| Status | Meaning |
|---|---|
| (blank) | Not yet contacted — will send initial email |
sent |
Initial email sent |
followed_up_1 |
First follow-up sent |
followed_up_2 |
Second follow-up sent |
followed_up_3 |
Third follow-up sent |
replied |
Reply detected — no more emails |
dead |
Max follow-ups reached, no reply |
skip |
Manually marked to skip this lead |
bounced |
Email bounced (delivery failed) |
| Account Type | Emails/Day |
|---|---|
| Free Gmail | 100 per day |
| Google Workspace | 1,500 per day |
| File | Purpose |
|---|---|
Config.gs |
All settings — sheet ID, intervals, subjects, tracking toggles |
EmailTemplates.gs |
Email body templates for all 4 emails |
Code.gs |
Core logic — send, reply check, follow-up, triggers |
Tracking.gs |
Apps Script webhook receiver for tracking events + bounce detection |
cloudflare-worker/ |
Public tracking endpoints for opens/clicks with real HTTP redirects |
Check the webhook URL format:
- Apps Script Web App URL must end with
/exec - Wrong:
https://script.googleusercontent.com/...(content URL) - Right:
https://script.google.com/a/macros/.../exec(web app URL)
Check Worker logs:
cd cloudflare-worker
npx wrangler tailCommon errors:
401— Web App URL is wrong or deployment deleted405— URL is a content URL, not web app URLMissing worker secrets—.dev.varsnot uploaded to Cloudflare
Verify tracking flow:
- Check column O (TRACKING_IDS) has values like
["abc123..."] - Open pixel URL directly:
https://your-worker.workers.dev/open?id=TRACKING_ID - Check Apps Script Executions tab for
doPostcalls - Check column J (OPENED) incremented to
1
Gmail blocks external images by default. Recipients need to click "Load images" or "Always display images from this sender" for open tracking to work. This is expected behavior — not all opens will be tracked.
If tracked links show a blank page instead of redirecting, the Worker is working but Gmail's iframe sandbox is interfering. The current implementation uses a simple redirect that should work in most cases. If issues persist, open the link in a new tab.