The Google Forms approval workflow on the MageSheet blog routes each request through a chain of approvers: a form-submit trigger builds the chain, a web page records the decision, and a daily trigger chases anyone who has not answered. I ran its code in Node against a mock of the Apps Script services, with the kind of data a small team really has, and it did seven things its text does not promise.
The first one: a manager submits a 300 purchase request for herself. The chain is just her, she opens the email, clicks Approve, and the request is APPROVED. Nobody else was asked.
Below are the seven results side by side, then three replacements for the functions involved. All names, addresses and amounts are invented, and the services are mocked, so this shows what the logic does, not what Google's servers do.
| Input (invented data) | Original code | With the fix |
|---|---|---|
| Sales manager files her own 300 request | Chain is her alone, she approves it, APPROVED | Chain goes to the backup person, she is refused |
| Director is also the Sales manager, 9,000 request | Chain is director, finance, director: two approvals, two emails | Chain is director, finance: one approval, one email |
| Manager cell holds the address plus a trailing space | Nobody can approve, the reply names the address and a space | Approves normally |
Decision sent as lowercase reject | Status APPROVED, reply says "Recorded: reject" | Status REJECTED |
Decision banana, or REJECT with no reason | banana approves, the empty-reason REJECT is recorded | Unknown decision. and Please add a reason. |
| "Last updated" cell unreadable, 30 days | 0 mails, no report | Listed in the owner's digest |
| 5 requests waiting 10 days | 40 reminder mails (see below) | 10 reminders and 5 digests |
Why the original does this
Three things in the original flow trust input that nothing has checked.
- The chain is a list of email addresses that nothing compares.
onRequestSubmitmaps each role to an address: the manager from the Rules tab, the others fromROLES. It never asks whether an address is the requester, whether the same address appears twice, or whether a cell has a trailing space. In a small team the manager and the director can be the same person, and a manager is also someone who submits purchase requests. - The decision arrives from the browser as typed.
decide(id, token, decision, comment)is called bygoogle.script.run. The Approve and Reject buttons send'APPROVE'and'REJECT', and the page refuses an empty reason for a rejection. Both are checks in the browser. On the server,decision === 'REJECT'is the only test, so every other string, including'reject'and'banana', takes the approval branch, and the reply even echoes the word you sent. - The reminder writes nothing down.
chaseStalemails the approver when a request is older than 48 hours and then forgets it did. The next run sees the same age and mails again. A request whose "last updated" cell cannot be read is skipped, becauseNaN > 48is false, so it is neither chased nor reported.
None of this needs an attacker. It needs a small org chart, a pasted cell, and one person on holiday.
Block 1: build the chain, then check it
buildChain_ takes the addresses in order, the requester and a backup. It lowercases and trims every address. An entry that does not look like an address fails the whole chain: a blank finance cell must not make finance silently disappear from the route. The requester is removed. A repeated address keeps its first position. If nothing is left, the backup is used, unless the backup is the requester too. In that case the result is ok: false and the request is stored as NEEDS_OWNER, with the script owner mailed instead of an approver.
Add one line to the ROLES object from the original post, backup: 'ops@example.com', a person who never submits requests. The notes that buildChain_ returns are written into the request's history, so the audit trail says why the chain differs from the rules.
// Add one line to ROLES from the original post: // backup: 'ops@example.com' (someone who never submits requests) function normEmail_(s) { return String(s || '').trim().toLowerCase(); } function nowIso_() { return new Date().toISOString(); } function isEmail_(s) { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); } // emails: one address per step, in order. Returns { ok, chain, notes }. function buildChain_(emails, requester, backup) { const me = normEmail_(requester); const seen = {}; const chain = []; const notes = []; for (const raw of emails) { const a = normEmail_(raw); if (!isEmail_(a)) { const why = 'bad approver address: "' + raw + '"'; return { ok: false, chain: [], notes: [why] }; } if (a === me) { notes.push('requester removed: ' + a); continue; } if (seen[a]) { notes.push('duplicate removed: ' + a); continue; } seen[a] = true; chain.push(a); } if (!chain.length) { const b = normEmail_(backup); if (!isEmail_(b) || b === me) { notes.push('no approver left'); return { ok: false, chain: [], notes }; } chain.push(b); notes.push('backup used: ' + b); } return { ok: true, chain, notes }; } function onRequestSubmit(e) { const v = e.namedValues; const amount = Number(String(v['Amount'][0]).replace(/[^\d.]/g, '')); const requester = v['Email Address'][0]; const emails = CHAINS.find(c => amount <= c.max).steps.map(role => role === 'manager' ? managerFor_(v['Department'][0]) : ROLES[role]); const built = buildChain_(emails, requester, ROLES.backup); const rec = { id: Utilities.getUuid(), respRow: e.range.getRow(), requester, amount, chain: built.chain, stage: 0, token: built.ok ? newToken_() : '', status: built.ok ? 'PENDING' : 'NEEDS_OWNER', history: built.notes.map(n => ({ note: n, at: nowIso_() })), }; appendRecord_(rec); if (built.ok) { notifyApprover_(rec); } else { const what = amount + ' from ' + requester; notifyOwner_('Request needs an approver: ' + what, built.notes); } } With the Sales manager also set as director, buildChain_(['director@example.com', 'finance@example.com', 'director@example.com'], 'sam@example.com', 'ops@example.com') returns the chain ['director@example.com', 'finance@example.com'] and the note duplicate removed: director@example.com.
Block 2: the server decides what a decision is
checkDecision_ is pure: it takes the record, the visitor's email, the token, the decision and the comment, and returns either { ok: true, decision, comment } or { msg } with the text to show. The order is closed, expired link, wrong person, own request, unknown decision, missing reason. The own-request check is a second line: the chain builder already removes the requester, but a row written before you deployed this still has the old chain.
decide keeps the lock and the token rotation from the original. The only change is that it acts on chk.decision, the normalised value, and never on the string it was given. The history gets the lowercase address of whoever decided.
function checkDecision_(rec, who, token, decision, comment) { const no = msg => ({ msg }); if (!rec || rec.status !== 'PENDING') { return no('This request is already closed.'); } if (token !== rec.token) { return no('This link has expired: the request has moved on.'); } const approver = normEmail_(rec.chain[rec.stage]); const me = normEmail_(who); if (!me || me !== approver) { return no('This step is assigned to ' + approver + '.'); } if (me === normEmail_(rec.requester)) { return no('You cannot decide your own request.'); } const d = String(decision).trim().toUpperCase(); if (d !== 'APPROVE' && d !== 'REJECT') return no('Unknown decision.'); const c = String(comment || '').trim().slice(0, 500); if (d === 'REJECT' && !c) return no('Please add a reason.'); return { ok: true, decision: d, comment: c }; } function decide(id, token, decision, comment) { const lock = LockService.getScriptLock(); lock.waitLock(10000); // one decision at a time try { const rec = findRecord_(id); const who = Session.getActiveUser().getEmail(); const chk = checkDecision_(rec, who, token, decision, comment); if (!chk.ok) return chk.msg; rec.history.push({ stage: rec.stage, by: normEmail_(who), decision: chk.decision, comment: chk.comment, at: nowIso_() }); if (chk.decision === 'REJECT') { rec.status = 'REJECTED'; } else if (rec.stage + 1 < rec.chain.length) { rec.stage += 1; rec.token = newToken_(); // old links die here } else { rec.status = 'APPROVED'; } if (rec.status !== 'PENDING') rec.token = ''; saveRecord_(rec); if (rec.status === 'PENDING') notifyApprover_(rec); else notifyRequester_(rec); return 'Recorded: ' + chk.decision.toLowerCase() + '. Thank you.'; } finally { lock.releaseLock(); } } The Decide.html page stays as it is. Its empty-reason alert is still worth keeping, because it saves a round trip, but it is no longer the check.
Block 3: remind twice, then tell a person
planReminders_ takes records and a clock value and returns two lists: who to remind and what is stalled. It counts the reminders already sent at the current stage from the history, and it measures the wait from the later of "last updated" and the last reminder. A request gets at most two reminders, 48 hours apart. After the second and another 48 hours, it moves to the stalled list, and the owner gets one digest mail for all of them, whatever their number. NEEDS_OWNER rows and rows whose date cannot be read go straight to that list, so they show up instead of vanishing.
In my test version of saveRecord_, every save stamps "last updated" with the current time, so logging a reminder restarts the clock on its own. The planner does not rely on that. One test replaces the stamp with a copy that keeps the old date and gets the same mail counts.
chaseStale sends the reminder first and records it second. If the mail call throws, for example when the daily quota is gone, nothing is recorded and the next run tries again.
const REMIND_AFTER_H = 48; const MAX_REMINDERS = 2; // recs: records as findRecord_ returns them. Pure: no sheet, no mail. function planReminders_(recs, nowMs) { const plan = { remind: [], stalled: [] }; recs.forEach(r => { if (r.status === 'NEEDS_OWNER') { plan.stalled.push(r.id); return; } if (r.status !== 'PENDING') return; const sent = r.history .filter(h => h.reminder && h.stage === r.stage); const last = Math.max(new Date(r.updated).getTime(), ...sent.map(h => new Date(h.at).getTime())); if (isNaN(last)) { // unreadable date: say so plan.stalled.push(r.id); return; } if ((nowMs - last) / 36e5 < REMIND_AFTER_H) return; const more = sent.length < MAX_REMINDERS; (more ? plan.remind : plan.stalled).push(r.id); }); return plan; } function notifyOwner_(subject, lines) { MailApp.sendEmail({ to: Session.getEffectiveUser().getEmail(), subject, htmlBody: '<ul><li>' + lines.join('</li><li>') + '</li></ul>', }); } function chaseStale() { const rows = SpreadsheetApp.getActive().getSheetByName('Approvals') .getDataRange().getValues().slice(1); const recs = rows .filter(r => r[7] === 'PENDING' || r[7] === 'NEEDS_OWNER') .map(r => findRecord_(r[0])); const plan = planReminders_(recs, Date.now()); const byId = id => recs.find(r => r.id === id); plan.remind.forEach(id => { const rec = byId(id); notifyApprover_(rec, { reminder: true }); // mail first, then log rec.history.push( { reminder: true, stage: rec.stage, at: nowIso_() }); saveRecord_(rec); }); if (plan.stalled.length) { const line = r => r.status + ': ' + r.amount + ' from ' + r.requester + ' (on ' + (r.chain[r.stage] || 'nobody') + ')'; const n = plan.stalled.length; notifyOwner_('Approvals nobody is answering: ' + n, plan.stalled.map(byId).map(line)); } } Nothing here reassigns a stalled request to someone else. That is a decision about who may approve what, and the digest hands it to a person. The original post makes the same call for escalation.
What the reminders cost in mail
The reminder numbers are the ones that touch a limit. Google's quotas page (read 2026-11-12) lists "Email recipients per day (for example, with MailApp)" at 100 for consumer accounts and 1,500 for Google Workspace. In my mock, five requests waiting on one manager for ten days produced these mails:
| Over 10 days, 5 requests waiting on one approver | Original | With the fix |
|---|---|---|
| Mails to the approver | 40 | 10 |
| Mails to the owner | 0 | 5, one digest a day from day 6 |
| First reminder | day 3 | day 2 |
| Stops after | never | two reminders per stage |
With the original, 20 forgotten requests use 20 of a free account's 100 recipients every day, for as long as they stay open. With the fix they cost at most two mails each, then one digest a day.
Pitfalls
- Removing the requester shortens the chain. If the Sales manager submits a 3,000 request, the chain goes from manager, finance to finance alone. The manager's own sign-off is the one thing that had to go. If your policy w...




