Connect contact form to Google Sheets via Apps Script#43
Connect contact form to Google Sheets via Apps Script#43
Conversation
Agent-Logs-Url: https://github.com/clearlineKris/clearlinekris.github.io/sessions/b3e766c8-213e-4fa7-8365-63d103201cb6 Co-authored-by: clearlineKris <[email protected]>
There was a problem hiding this comment.
Pull request overview
Replaces the existing Formspree contact form submission with a Google Apps Script Web App endpoint that appends lead + address details into a managed Google Sheet.
Changes:
- Updates
index.htmlcontact form markup (new lead/address fields, status message element) and loads a new JS handler. - Adds
form-handler.jsto intercept submit and POST URL-encoded form data + timestamp to Apps Script. - Adds
google-apps-script/Code.gsto create/maintain aLeadstab and append each submission as a new row; updates styling and setup docs accordingly.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| styles.css | Widened form and added layout/status styles for new fields and submission feedback. |
| README.md | Documents one-time deployment/setup steps for the Apps Script + front-end wiring. |
| index.html | Replaces Formspree wiring with JS hook, adds new lead/address fields, and adds a status area. |
| google-apps-script/Code.gs | Implements the Apps Script doPost handler that creates/appends to the Leads sheet. |
| form-handler.js | Implements fetch-based form submission to Apps Script with optimistic success UX. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // ── Replace with your deployed Google Apps Script Web App URL ──────────── | ||
| var SCRIPT_URL = 'YOUR_GOOGLE_APPS_SCRIPT_URL'; | ||
| // ───────────────────────────────────────────────────────────────────────── |
There was a problem hiding this comment.
SCRIPT_URL is left as the placeholder string; as merged, submissions will always fail (and only show a generic error) until someone edits and re-deploys the site. Add a guard that detects an unconfigured/invalid SCRIPT_URL and shows an explicit “form not configured” message (and skips fetch), or load the URL from a config/data attribute so production builds aren’t committed in a broken state.
google-apps-script/Code.gs
Outdated
| var p = e.parameter; | ||
| sheet.appendRow([ | ||
| // timestamp is always provided by form-handler.js; the fallback here | ||
| // guards against future clients that may not include it | ||
| p.timestamp || new Date().toISOString(), |
There was a problem hiding this comment.
User-submitted fields are written into the sheet without sanitization, which enables spreadsheet formula injection (values starting with '=', '+', '-', or '@'). Sanitize/escape strings before appendRow so all submissions are stored as plain text.
google-apps-script/Code.gs
Outdated
| /** | ||
| * doPost — called each time the contact form submits. | ||
| * Parameters are read from e.parameter (URL-encoded form body). | ||
| */ | ||
| function doPost(e) { | ||
| try { | ||
| var ss = SpreadsheetApp.getActiveSpreadsheet(); | ||
| var sheet = ss.getSheetByName(SHEET_NAME); |
There was a problem hiding this comment.
This Web App endpoint accepts writes from anyone and appends directly to the Leads sheet, which makes it easy to spam/poison the spreadsheet. Add basic abuse controls (e.g., a honeypot field, rate limiting via CacheService, and/or rejecting requests that don’t include expected fields) so the sheet can’t be filled by arbitrary third parties.
There was a problem hiding this comment.
Can you facilitate this for me @copilot? I'm not well versed with this kind of risk.
There was a problem hiding this comment.
Done — commit 6730bb7 adds the following protections directly into Code.gs:
| Control | What it does |
|---|---|
| Honeypot | A hidden hp field is added to the form (invisible to real users via off-screen CSS). Bots that blindly fill every field get silently discarded server-side. |
| Required-field guard | Submissions missing name or email are rejected before anything is written to the sheet. |
| Rate limiting | Each email address (normalized to collapse + aliases) is capped at 5 submissions per hour using CacheService. Requests over the cap get an error response without touching the sheet. |
| Formula injection protection | sanitizeCell() prefixes any value starting with =, +, -, @, |, or % with a tab character so Google Sheets stores it as plain text instead of executing it as a formula. |
| Concurrent write safety | LockService.getScriptLock() serializes simultaneous POSTs so sheet creation and row appends are race-condition-free. If the lock times out, the user gets a friendly "try again" message. |
The README also has a full breakdown of each measure under "Anti-abuse measures built into the Apps Script".
google-apps-script/Code.gs
Outdated
| // Create the sheet and add headers if it doesn't exist yet | ||
| if (!sheet) { | ||
| sheet = ss.insertSheet(SHEET_NAME); | ||
| sheet.appendRow([ | ||
| 'Timestamp', |
There was a problem hiding this comment.
doPost can be invoked concurrently; without a LockService lock around sheet creation/appendRow, simultaneous submissions can intermittently fail with spreadsheet service errors or create race conditions on first-run sheet creation. Consider using LockService.getScriptLock() to serialize writes (especially around insertSheet/appendRow).
There was a problem hiding this comment.
Is there a way to create a spreadsheet to output entries onto @copilot?
There was a problem hiding this comment.
Yes — the setup is already fully automated after a one-time manual step. Here's how it works:
- You create the Google Sheet (one time, takes 30 seconds) — just go to sheets.google.com, click +, and name it "ClearLine Leads".
- Paste
Code.gsinto that sheet's Apps Script editor and deploy it as a Web App (step-by-step instructions are in the README under "One-time setup"). - That's it — on the very first form submission, the script automatically creates a Leads tab with the correct column headers (Timestamp, Name, Email, Company, Address, City, State, Zip, Message) and freezes the header row. Every subsequent submission appends a new row. You never need to touch the spreadsheet structure manually.
The README's "What gets recorded" table shows exactly which form field maps to which column.
form-handler.js
Outdated
| // The response is opaque — success is optimistic (the data still reaches | ||
| // the sheet; we cannot distinguish server-side errors from success). |
There was a problem hiding this comment.
The inline comment says “the data still reaches the sheet” under no-cors, but that isn’t guaranteed—server-side errors can occur and fetch will still resolve with an opaque response. Reword to avoid implying delivery is assured when you can’t observe the HTTP status/body.
| // The response is opaque — success is optimistic (the data still reaches | |
| // the sheet; we cannot distinguish server-side errors from success). | |
| // The response is opaque — success is optimistic (the request is sent, | |
| // but we cannot verify delivery or distinguish server-side errors from success). |
index.html
Outdated
| <textarea id="message" name="message" rows="4"></textarea> | ||
| </div> | ||
| <button type="submit" class="btn btn-primary btn-full">Send Message</button> | ||
| <div id="form-status" class="form-status" hidden></div> |
There was a problem hiding this comment.
The status message container is visually updated but not announced to assistive technologies. Add accessibility attributes (e.g., role="status" and aria-live="polite" / aria-atomic="true") to #form-status so screen reader users get submission feedback.
| <div id="form-status" class="form-status" hidden></div> | |
| <div id="form-status" class="form-status" role="status" aria-live="polite" aria-atomic="true" hidden></div> |
There was a problem hiding this comment.
I'm honestly lost as I'm doing this from a hospital bed @copilot
README.md
Outdated
| - Click **Deploy → New deployment**. | ||
| - Under *Select type*, choose **Web app**. | ||
| - Set **Execute as** → *Me*. | ||
| - Set **Who has access** → *Anyone*. |
There was a problem hiding this comment.
The setup instructs deploying the Web App with access set to “Anyone”, which makes the endpoint publicly writable and susceptible to spam. Consider documenting a minimal anti-abuse measure (honeypot/rate-limit) or recommending “Anyone with the link” / another restriction if feasible for the use case.
| - Set **Who has access** → *Anyone*. | |
| - Set **Who has access** → *Anyone with the link* (recommended). | |
| - Using *Anyone with the link* reduces the chance of spam and automated abuse. If you must choose *Anyone*, add basic anti-abuse measures (e.g. a hidden honeypot field in the form and simple server-side validation/rate limiting in your Apps Script). |
There was a problem hiding this comment.
Please assist me with protecting this form thusly @copilot
There was a problem hiding this comment.
Already taken care of in commit 6730bb7. The form and Apps Script now have five layers of protection baked in — all documented in the README under "Anti-abuse measures built into the Apps Script":
- Honeypot: hidden
hpfield catches bots that fill every input - Required-field guard: rejects submissions missing name or email
- Rate limiting: 5 submissions per email per hour via
CacheService - Formula injection protection: values that start with
=,+, etc. are neutralized before being written to the Sheet - Concurrent write safety:
LockServiceprevents race conditions
No extra work needed on your end — it's all live when you deploy Code.gs.
index.html
Outdated
| <p class="section-intro">Whether it's a question about compliance, a project idea, or just connecting — I'd like to hear from you.</p> | ||
|
|
||
| <form class="contact-form" action="https://formspree.io/f/maqnryyz" method="POST"> | ||
| <form id="contact-form" class="contact-form"> |
There was a problem hiding this comment.
Removing the form’s action/method makes submission depend entirely on JS; if the script fails to load (or JS is disabled), the form will POST/GET back to the same page and nothing reaches the sheet. Consider keeping a real action="…/exec" + method="POST" as a progressive-enhancement fallback (JS can still intercept to keep the in-page UX).
| <form id="contact-form" class="contact-form"> | |
| <form id="contact-form" class="contact-form" method="post" action="https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec"> |
There was a problem hiding this comment.
Already handled in commit 6730bb7. The <form> tag now has method="post" action="YOUR_GOOGLE_APPS_SCRIPT_URL" — once you drop your deployed Web App URL in there, the form will work even if JavaScript is disabled or fails to load. The JS handler still intercepts when it's available for the better in-page experience.
…IPT_URL guard, a11y, progressive enhancement Agent-Logs-Url: https://github.com/clearlineKris/clearlinekris.github.io/sessions/b9e94d7b-9d0f-40ba-9e01-9403074be032 Co-authored-by: clearlineKris <[email protected]>
Replaces the Formspree integration with a direct Google Apps Script Web App POST, and expands the form with lead/address fields that land in a managed Google Sheet. Includes anti-abuse hardening and accessibility improvements.
Changes
index.htmlmethod="post" action="YOUR_GOOGLE_APPS_SCRIPT_URL"as a progressive-enhancement fallback for no-JS environmentspattern="[A-Za-z]{2}"), Zip#form-statusdiv withrole="status" aria-live="polite" aria-atomic="true"for screen-reader announcementshp) positioned off-screen via.form-honeypotCSS;aria-hidden,tabindex="-1",autocomplete="off"form-handler.js(new)fetchwithmode: 'no-cors'no-cors; network failures still surface an error messagegoogle-apps-script/Code.gs(new)doPost(e)handler: auto-creates a frozen-header Leads tab on first run, appends one row per submission:Timestamp | Name | Email | Company | Address | City | State | Zip | Messagehpfield is non-emptynameoremailCacheService; normalises+aliases so variants share one bucketsanitizeCell()prefixes values starting with=,+,-,@,|, or%with a tab so Sheets stores them as plain textLockService.getScriptLock()serialises simultaneous POSTs; timeout returns a user-friendly errorstyles.css.form-row--thirdsfor the 3-column City/State/Zip row.form-optionallabel variant;.form-status--success/.form-status--errorstates.form-honeypotoff-screen hide (position absolute, opacity 0, pointer-events none)README.mdSCRIPT_URLinform-handler.jsandactioninindex.htmlActivation
Two places to update after deploying
Code.gsas a Web App (Execute as: Me / Anyone with the link):Preview
Original prompt