// Handle CORS preflight for browser fetch requests
if (request.parameters.method === 'OPTIONS') {
return ContentService.createTextOutput('').setMimeType(ContentService.MimeType.JSON);
}
try {
const processor = new FormProcessor(request);
const result = processor.execute();
return ContentService.createTextOutput(JSON.stringify(result))
.setMimeType(ContentService.MimeType.JSON);
} catch (error) {
console.error('Form processing failed:', error);
return ContentService.createTextOutput(JSON.stringify({
status: 'error',
message: error.message
})).setMimeType(ContentService.MimeType.JSON);
}
}
class FormProcessor {
constructor(request) {
this.payload = this.parsePayload(request);
this.sheet = this.getOrCreateSheet();
}
parsePayload(request) {
// Support both JSON body and form-urlencoded data
let data;
if (request.postData) {
try {
data = JSON.parse(request.postData.contents);
} catch {
// Fallback to parameter parsing if JSON fails
data = request.parameter;
}
} else {
data = request.parameter;
}
return data;
}
getOrCreateSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
let sheet = ss.getSheetByName(FORM_CONFIG.sheetName);
if (!sheet) {
sheet = ss.insertSheet(FORM_CONFIG.sheetName);
sheet.appendRow(FORM_CONFIG.headers);
sheet.setFrozenRows(1);
// Optimize column width for message content
sheet.setColumnWidth(4, 300);
}
return sheet;
}
execute() {
this.validate();
this.checkSecurity();
const timestamp = new Date().toISOString();
const rowData = [
timestamp,
this.payload.fullName,
this.payload.contactEmail,
this.payload.inquiry,
this.payload.sourceURL || 'Unknown',
'New'
];
this.sheet.appendRow(rowData);
this.sendNotifications();
return { status: 'success', message: 'Submission recorded' };
}
validate() {
const required = ['fullName', 'contactEmail', 'inquiry'];
const missing = required.filter(field => !this.payload[field]);
if (missing.length > 0) {
throw new Error(`Missing required fields: ${missing.join(', ')}`);
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.payload.contactEmail)) {
throw new Error('Invalid email format');
}
}
checkSecurity() {
// Honeypot check: Bots fill hidden fields; humans do not.
if (this.payload._trap && this.payload._trap.length > 0) {
// Return fake success to confuse bots without alerting them
return { status: 'success', message: 'Submission recorded' };
}
// Keyword filtering
const content = `${this.payload.inquiry} ${this.payload.fullName}`.toLowerCase();
const hasSpam = FORM_CONFIG.spamKeywords.some(keyword => content.includes(keyword));
if (hasSpam) {
throw new Error('Submission rejected: Spam detected');
}
// Rate limiting check (simplified implementation)
// In production, use PropertiesService or a separate sheet for robust rate limiting
const lastSubmissionKey = `last_${this.payload.contactEmail}`;
const userProperties = PropertiesService.getUserProperties();
const lastTime = userProperties.getProperty(lastSubmissionKey);
if (lastTime && (Date.now() - parseInt(lastTime) < FORM_CONFIG.rateLimitMs)) {
throw new Error('Rate limit exceeded. Please wait before resubmitting.');
}
userProperties.setProperty(lastSubmissionKey, Date.now().toString());
}
sendNotifications() {
const { fullName, contactEmail, inquiry } = this.payload;
// Notify Admin
MailApp.sendEmail({
to: FORM_CONFIG.adminEmail,
subject: `New Inquiry from ${fullName}`,
htmlBody: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${fullName}</p>
<p><strong>Email:</strong> ${contactEmail}</p>
<p><strong>Message:</strong></p>
<blockquote>${inquiry}</blockquote>
`
});
// Optional Auto-reply
if (FORM_CONFIG.autoReplyTemplate) {
MailApp.sendEmail({
to: contactEmail,
subject: 'We received your inquiry',
body: FORM_CONFIG.autoReplyTemplate
});
}
}
}
**2. Frontend Integration**
The frontend requires a standard `fetch` call. Note the use of `text/plain` content type to avoid CORS preflight issues common with Google Apps Script deployments.
```typescript
interface FormPayload {
fullName: string;
contactEmail: string;
inquiry: string;
sourceURL: string;
_trap: string; // Honeypot field
}
const SCRIPT_URL = 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec';
async function submitForm(formData: FormData): Promise<void> {
const payload: FormPayload = {
fullName: formData.get('full_name') as string,
contactEmail: formData.get('contact_email') as string,
inquiry: formData.get('inquiry') as string,
sourceURL: window.location.href,
_trap: formData.get('_trap') as string
};
try {
// Use text/plain to bypass CORS preflight requests in GAS
const response = await fetch(SCRIPT_URL, {
method: 'POST',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'text/plain' }
});
const result = await response.json();
if (result.status === 'success') {
console.log('Form submitted successfully');
// Handle UI success state
} else {
throw new Error(result.message);
}
} catch (error) {
console.error('Submission failed:', error);
// Handle UI error state
}
}
3. HTML Structure
Include a hidden honeypot field to trap automated bots.
<form id="contactForm" onsubmit="handleFormSubmit(event)">
<input type="text" name="full_name" required placeholder="Full Name" />
<input type="email" name="contact_email" required placeholder="Email Address" />
<textarea name="inquiry" required placeholder="Your Message"></textarea>
<!-- Honeypot: Hidden from users, visible to bots -->
<input type="text" name="_trap" style="display:none" tabindex="-1" autocomplete="off" />
<button type="submit">Send Inquiry</button>
</form>
Architecture Decisions
text/plain Content Type: Google Apps Script web apps often struggle with CORS when receiving application/json. Sending the body as text/plain with JSON content bypasses the browser's preflight OPTIONS request, simplifying frontend integration.
MailApp vs GmailApp: MailApp is sufficient for most use cases and sends from the script owner's address. If you require higher sending quotas or need to send from a specific Gmail alias, switch to GmailApp, though this requires OAuth authorization during deployment.
- Modular Class Structure: Encapsulating logic in
FormProcessor allows for easier extension. You can add methods for logging errors to a separate sheet or integrating reCAPTCHA tokens without bloating the doPost entry point.
Pitfall Guide
-
CORS Preflight Failures
- Explanation: Browsers send an
OPTIONS request before POST when using application/json. GAS may not handle this gracefully, resulting in blocked requests.
- Fix: Use
text/plain content type in the fetch request, or implement explicit OPTIONS handling in doPost as shown in the solution.
-
Silent Honeypot Traps
- Explanation: If a bot triggers the honeypot, returning an error reveals your defense mechanism, allowing the bot to adapt.
- Fix: Return a fake success response when the honeypot is triggered. The bot believes it succeeded, while the data is discarded.
-
Spreadsheet Performance Degradation
- Explanation: Google Sheets performance degrades significantly as row counts exceed 50,000β100,000 rows, causing slow appends and script timeouts.
- Fix: Implement an archival strategy. Use a time-driven trigger to move rows older than 90 days to an "Archive" sheet or export them to Google BigQuery for long-term storage.
-
Rate Limiting Evasion
- Explanation: The simplified rate limit in the example uses
PropertiesService, which is global and not ideal for per-user limits in high-concurrency scenarios.
- Fix: For production, store rate limit timestamps in a dedicated "RateLimits" sheet indexed by email hash, or use a more robust storage mechanism if volume warrants it.
-
Quota Exhaustion
- Explanation:
MailApp has daily sending limits (e.g., 100 emails/day for free accounts). Exceeding this causes script failures.
- Fix: Monitor usage. If limits are reached, switch to
GmailApp (higher limits) or queue notifications to a sheet and process them via a time-driven trigger that batches emails.
-
Security Misconception
- Explanation: Setting deployment access to "Anyone" exposes the endpoint URL. While honeypots and validation help, the URL can still be abused.
- Fix: Obfuscate the URL in frontend code. Implement strict validation. For sensitive forms, integrate Google reCAPTCHA v3 and verify the token score in the script before processing.
-
Error Handling Gaps
- Explanation: Unhandled exceptions in GAS can result in lost submissions without feedback to the user or developer.
- Fix: Wrap the execution pipeline in a
try/catch block. Log errors to a separate "ErrorLog" sheet with timestamps and payload details for debugging.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Portfolio / Landing Page | Google Apps Script | Zero cost, sufficient quotas, easy maintenance. | $0 |
| High-Volume Lead Gen (>5k/mo) | Dedicated Backend or SaaS | GAS quotas and Sheet limits may be exceeded; need robust scaling. | $20β$100+ |
| File Uploads Required | SaaS or Custom Backend | GAS has limited file upload handling; Sheets cannot store files efficiently. | Varies |
| Enterprise Compliance | Custom Backend with DB | GAS lacks granular access controls and audit logging required by some standards. | Infrastructure Cost |
Configuration Template
Use this template to initialize your Apps Script project.
// config.gs
const CONFIG = {
// Sheet Settings
SHEET_NAME: 'Submissions',
ARCHIVE_SHEET: 'Archive',
ARCHIVE_AFTER_DAYS: 90,
// Email Settings
ADMIN_EMAIL: 'admin@yourdomain.com',
AUTO_REPLY_ENABLED: true,
AUTO_REPLY_BODY: 'Thanks for contacting us. We will reply shortly.',
// Security Settings
HONEYPOT_FIELD: '_trap',
SPAM_KEYWORDS: ['spam_word_1', 'spam_word_2'],
RATE_LIMIT_MS: 60000,
// Logging
ERROR_LOG_SHEET: 'ErrorLog'
};
Quick Start Guide
- Initialize Sheet: Create a Google Sheet. In cell A1, type
Timestamp, B1 Name, C1 Email, D1 Message.
- Add Script: Go to Extensions > Apps Script. Paste the
FormProcessor code provided in the Core Solution.
- Deploy: Click Deploy > New Deployment. Select type "Web app". Set "Execute as" to "Me" and "Who has access" to "Anyone". Click Deploy.
- Copy URL: Copy the Web App URL generated.
- Connect Frontend: Update your frontend
SCRIPT_URL constant with the copied URL. Ensure your form sends a POST request with JSON content.
- Verify: Submit a test entry. Check the Sheet for the new row and your inbox for the notification email.