How to Track Follow-Ups in Google Sheets and Get a Daily Reminder Email (2026)
Most lost deals, slow hires and forgotten client check-ins come down to the same thing: nobody got back to someone when they said they would. If you keep leads, clients, candidates or vendors in a spreadsheet, it already holds the answer to “who do I need to contact today?” It just doesn't tell you. This guide adds a Next follow-up column that fills itself in from the last contact date, a “due today” view, row colors for anything overdue, and a free Apps Script that emails each owner one short list every morning, with a second nudge for anything still untouched three days later.
It's built for a rolling date that changes every time you reach out. For fixed deadlines like renewals and expirations, see email reminders from a date column; for task lists, see overdue task highlighting.
Step 1: a Follow-ups tab
Create a tab named Follow-ups with these headers in row 1. The script finds columns by name, but the formulas below assume this order (A to J):
| Col | Header | What goes in it |
|---|---|---|
| A | Name | The person you're following up with |
| B | Company | Optional; shown next to the name in the email |
| C | Their address, for your reference. The script never emails them. | |
| D | Stage | e.g. New, Contacted, Proposal sent, Won, Lost (Step 4 adds a dropdown) |
| E | Last contact | The date you last reached out or heard back |
| F | Cadence (days) | How many business days to wait before the next touch, e.g. 3 or 10 |
| G | Next follow-up | A formula (Step 2), or a date you type in |
| H | Owner email | Who should get the reminder; blank sends it to you |
| I | Notes | What you're waiting on (“asked for revised pricing”); the first 120 characters appear in the email |
| J | Sent log | Leave empty; the script records what it sent |
Format E and G as dates (Format › Number › Date). To stamp today's date in Last contact quickly, select the cell and press Ctrl+; (⌘+; on a Mac). Don't use =TODAY() there, because it changes every day.
Step 2: the next follow-up date
In G2, fill down. WORKDAY counts business days and skips weekends, so a 3-day cadence after a Thursday call lands on Tuesday:
=IF(OR(E2="",F2=""),"",WORKDAY(E2,F2))
For calendar days instead, use =IF(OR(E2="",F2=""),"",E2+F2). To skip holidays too, list them on a tab named Holidays and use WORKDAY(E2,F2,Holidays!A:A). When someone says “call me after the 20th,” just type that date over the formula in G.
The loop is simple: after each call or email, update Last contact (and the cadence if it should change), and the next date moves forward on its own.
Step 3: a “who to contact today” view
On a new tab, this lists everyone due today or overdue, oldest first, leaving out closed stages:
=SORT(FILTER({'Follow-ups'!A2:A,'Follow-ups'!B2:B,'Follow-ups'!D2:D,'Follow-ups'!G2:G,'Follow-ups'!I2:I},
'Follow-ups'!G2:G<>"",'Follow-ups'!G2:G<=TODAY(),
NOT(REGEXMATCH('Follow-ups'!D2:D,"(?i)^(won|lost|closed|not interested|do not contact)$"))),4,TRUE)
The tab name needs single quotes because of the hyphen. A FILTER that finds nothing shows #N/A; wrap it in IFERROR(…,"All caught up") if you like. Format the fourth column as a date. For a count on a dashboard: =COUNTIFS('Follow-ups'!G2:G,"<="&TODAY(),'Follow-ups'!D2:D,"<>Won",'Follow-ups'!D2:D,"<>Lost").
Step 4: a stage dropdown and row colors
Select D2:D, choose Insert › Dropdown, and add your stages, including the closed ones (Won, Lost, Closed, Not interested, Do not contact). Consistent spelling matters, because the script and the view above match those words.
Then select A2:J, open Format › Conditional formatting, pick Custom formula is, and add these rules. The $ before G keeps every cell in the row looking at the same date:
| Custom formula | Color | Meaning |
|---|---|---|
=AND(ISNUMBER($G2),$G2<TODAY(),NOT(REGEXMATCH($D2,"(?i)^(won|lost|closed)$"))) | Red | Overdue |
=AND(ISNUMBER($G2),$G2=TODAY()) | Yellow | Due today |
=REGEXMATCH($D2,"(?i)^(won|lost|closed)$") | Gray | Closed |
Step 5: a morning email with today's follow-ups
Sheets can't send an email when a date arrives on its own; its notification rules react to edits, not the calendar. A short Apps Script on a daily trigger does it. Open Extensions › Apps Script, paste this, set DEFAULT_TO (or leave it blank to email yourself), save, run sendFollowUpDigest once to approve permissions, then add a trigger: Triggers › Add trigger › sendFollowUpDigest › Time-driven › Day timer, and pick an early hour like 7 to 8 am.
/**
* Daily follow-up digest from a "Follow-ups" tab.
* Headers used (any order): Name, Next follow-up, Sent log.
* Optional: Company, Owner email, Stage, Notes.
* Each owner gets one email listing follow-ups due today, plus one
* nudge for any still not updated 3 days later. Blank owner = you.
* The people you follow up with are never emailed by this script.
*/
const SHEET_NAME = 'Follow-ups';
const DEFAULT_TO = ''; // e.g. 'sales@yourcompany.com' (blank = you)
const NUDGE_AFTER = 3; // days overdue before a second reminder
const STOP_STAGE = /^(won|lost|closed|not interested|do not contact)$/i;
const MAX_EMAILS = 20; // safety cap per run
function sendFollowUpDigest(now) {
const sh = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
const rows = sh.getDataRange().getValues();
const head = rows[0].map((h) => String(h).trim());
const col = (name) => head.indexOf(name);
const c = {
name: col('Name'), company: col('Company'), next: col('Next follow-up'),
owner: col('Owner email'), stage: col('Stage'), notes: col('Notes'),
log: col('Sent log'),
};
if (c.name === -1 || c.next === -1 || c.log === -1) {
throw new Error('Need Name, Next follow-up and Sent log columns.');
}
const tz = Session.getScriptTimeZone();
// A time-driven trigger passes an event object, so only use a real Date.
const today = now instanceof Date ? new Date(now) : new Date();
today.setHours(0, 0, 0, 0);
const get = (row, i) => (i === -1 ? '' : row[i]);
const isDate = (v) => v instanceof Date && !isNaN(v);
const ymd = (d) => Utilities.formatDate(d, tz, 'yyyy-MM-dd');
const nice = (d) => Utilities.formatDate(d, tz, 'EEE, MMM d');
const me = Session.getEffectiveUser().getEmail();
const byOwner = {};
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
const next = row[c.next];
if (!row[c.name] || !isDate(next)) continue;
if (STOP_STAGE.test(String(get(row, c.stage)).trim())) continue;
const d = new Date(next); d.setHours(0, 0, 0, 0);
const late = Math.round((today - d) / 86400000); // days overdue
if (late < 0) continue; // not due yet
// One reminder on the due day, one nudge if still overdue later.
// A late start sends only the one that fits, never both.
const step = late >= NUDGE_AFTER ? NUDGE_AFTER : 0;
const sent = String(row[c.log] || '').split(';').filter(String);
const key = `F${ymd(d)}+${step}`;
if (sent.includes(key)) continue;
if (step === 0 && sent.includes(`F${ymd(d)}+${NUDGE_AFTER}`)) continue;
const who = [row[c.name], get(row, c.company)].filter(String).join(', ');
const note = String(get(row, c.notes)).trim();
const due = late === 0 ? 'due today' : `overdue ${late} day${late > 1 ? 's' : ''} (was ${nice(d)})`;
const to = String(get(row, c.owner)).trim() || DEFAULT_TO || me;
(byOwner[to] = byOwner[to] || []).push({
late, text: `${who}: ${due}${note ? ` - ${note.slice(0, 120)}` : ''}`,
r: r + 1, log: sent.concat(key).slice(-9).join(';'),
});
}
let emails = 0;
for (const to of Object.keys(byOwner)) {
if (emails >= MAX_EMAILS || MailApp.getRemainingDailyQuota() < 1) break;
const items = byOwner[to].sort((a, b) => b.late - a.late);
MailApp.sendEmail(to, `Follow-ups for ${nice(today)} (${items.length})`,
'Who to get back to (oldest first). After you reach out, set a new ' +
'Next follow-up date or change the Stage:\n\n- ' +
items.map((x) => x.text).join('\n- ') + '\n\nSent from the Follow-ups sheet.');
// Log only after the email for this owner went out.
items.forEach((x) => sh.getRange(x.r, c.log + 1).setValue(x.log));
emails++;
}
}
How it behaves:
- One email per owner, only when something's due. Rows are grouped by Owner email, so each rep or recruiter gets their own list, oldest first, with the note about what you're waiting on. Rows with a blank owner go to
DEFAULT_TOor you. - A reminder on the day, then one nudge. A row shows up the morning its Next follow-up date arrives. If the date still hasn't moved three days later, it shows up once more as overdue (change
NUDGE_AFTERto adjust). After that it stays red in the sheet but stops emailing, so the list doesn't fill up with stale rows. - No catch-up pile. If the trigger starts late or misses days, a row gets only the reminder that fits, not both.
- New date, new reminders. Sent log entries include the date they were for (like
F2026-11-13+0), so updating Last contact or typing a new date in G gives the next follow-up its own reminders. Entries are written only after that owner's email has gone out. - Stops when the conversation is over. Set Stage to Won, Lost, Closed, Not interested or Do not contact and the row is skipped.
- Only emails your team. The Email column is never used, so nobody on your list gets an automated message from this script.
We mock-tested this logic before publishing: the first run sent one list per owner in the right order with notes, a rerun the same day and the next day sent nothing, a row still untouched three days later got exactly one nudge, a trigger that started five days late sent one nudge instead of two emails, a new follow-up date got its own reminder, closed stages and future dates were skipped, and nothing was logged when the email quota was used up.
Tips
- Log the touch, not just the date. A History tab with Date, Name and what happened (or a running note in column I) helps when someone else picks up the account.
- Pick cadences per stage. A common pattern is short gaps right after first contact (2 to 3 business days), then longer ones (1 to 2 weeks) once a proposal is out. You can set F with a lookup from the stage instead of typing it.
- Form leads in, follow-ups out. If leads arrive through a Google Form, keep the response tab untouched and pull rows into Follow-ups with a formula or by copying, so your added columns don't shift when new responses come in.
- Match time zones. Set the script's time zone (Project Settings in Apps Script) to the same zone as the spreadsheet (File › Settings), or a date can land a day off.
- Ownership and quotas. The trigger runs as whoever created it, and the emails come from that account. Sends count against your Apps Script email quota; one list per owner per day is far below it, and
MAX_EMAILScaps each run anyway. - Chasing payments? For unpaid invoices with due dates and polite escalating reminders, see invoice payment reminders.
See SheetReminders
Related Google Sheets guides
- How to generate a PDF or Google Doc for each row in Google Sheets
- Best Autocrat alternatives for mail merge to PDF and Docs
- Make invoices or certificates from Google Sheets rows
- Create sales quotes or estimates from Google Sheets
- Generate receipts from Google Sheets rows
- Create purchase orders from Google Sheets
- Make packing slips from Google Sheets orders
- Generate a PDF certificate from a Google Form response
- Issue numbered training completion certificates with expiry dates
- Make a PDF progress report for each student
- Generate offer letters from a Google Sheets hiring tracker
- Email each row’s PDF as an attachment
- Automatic email reminders from a date column
- Contract renewal and expiration alerts from Google Sheets
- Track certificate of insurance (COI) expirations in Google Sheets
- Track employee license and certification expirations
- Track equipment and vehicle maintenance with reminders
- Track warranty expiration dates with reminders
- Birthday and work anniversary reminders from Google Sheets
- Rent due and lease renewal reminders from Google Sheets
- Send invoice payment reminders from Google Sheets
- Highlight overdue tasks and send email reminders
- Follow-up reminders for leads, clients and candidates
- Membership renewal reminders for clubs and associations
- SheetWeave vs Autocrat
- SheetWeave vs Document Studio
- SheetWeave vs RowDocs
Instructions refer to Google Sheets and Apps Script as they worked on November 13, 2026. Not affiliated with Google or any other vendor named here. Google Sheets and Google Drive are trademarks of Google LLC. Features change, so verify in your own account. Questions or corrections: hello@loomwise.app.