Addresses contact-page audit points 22–27: - Confirmed no '200+ companies' credibility claim; canonical stat set retained - Service dropdown now matches real services, led by ERP/Enterprise + Government IT; removed Cloud Services and AI & ML, added UI/UX Design - Backend allowlist + labels updated to accept 'ui-ux' and reflect real service names - H1 paired with value prop: free 1-page proposal in 24 hours - '< 24h response' promise badge moved above the form - Clarified contradictory hours to 'We reply within 24 business hours, Mon–Fri' - Reduced message textarea height (rows 5→4) and max length (5000→2000) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.0 KiB
Form Backend Integration
Agent: backend-specialist Date: 2026-03-21 Status: Complete ✓
Overview
Two API endpoints handle form submissions for the WorkRoot site:
| Endpoint | File | Purpose |
|---|---|---|
POST /api/contact |
src/pages/api/contact.ts |
Contact form submissions |
POST /api/newsletter |
src/pages/api/newsletter.ts |
Newsletter subscriptions |
Both endpoints include server-side validation, rate limiting, spam protection, and graceful degradation when optional dependencies (SMTP, newsletter providers) are not configured.
Architecture
Contact Form (/api/contact)
Flow:
- Client submits form →
POST /api/contact(JSON body) - Rate limit check (5 requests / IP / hour, sliding window)
- Honeypot field check (silent success if bot detected)
- Server-side field validation
- Email delivery via SMTP (nodemailer) — logs to console if SMTP not configured
- JSON response returned
Fields validated:
name— required, 2–100 charactersemail— required, valid format, max 254 charsphone— optional; if present, must match phone regexsubject— required, allowlist enum (erp-solutions, government-systems, web-development, mobile-development, ui-ux, consulting, other; legacy values cloud-services, ai-ml, support still accepted by the API but no longer offered in the contact-page dropdown)message— required, 10–2000 characterswebsite— honeypot, must be empty
Rate limit: 5 requests per IP per hour
Newsletter (/api/newsletter)
Flow:
- Client submits email →
POST /api/newsletter(JSON body) - Rate limit check (3 requests / IP / hour)
- Email format validation
- Subscribe via configured provider (Mailchimp → ConvertKit → SMTP notification → log-only)
- JSON response returned
Rate limit: 3 requests per IP per hour
Configuration
Set these environment variables (see .env.example):
Email (SMTP) — for contact form delivery and newsletter fallback
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your_email@example.com
SMTP_PASS=your_app_password
CONTACT_EMAIL=hello@workroot.in
Gmail tip: Enable 2FA, then create an App Password at https://myaccount.google.com/apppasswords
Newsletter — pick ONE provider
Mailchimp (recommended):
MAILCHIMP_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-us1
MAILCHIMP_LIST_ID=abc123def456
MAILCHIMP_DC=us1
Uses double opt-in (status: "pending"). Handles already-subscribed gracefully.
ConvertKit:
CONVERTKIT_API_KEY=your_api_key
CONVERTKIT_FORM_ID=1234567
If no provider is configured, submissions are logged to server console. Safe for development and staging.
Dependencies
nodemailer (installed)
The SMTP functionality requires nodemailer. It is installed as a production dependency:
npm install nodemailer
npm install --save-dev @types/nodemailer
Both are now in package.json. The dynamic import (await import('nodemailer')) in the API routes handles the case where the module is unavailable with graceful degradation.
Graceful Degradation Matrix
| Config state | Contact form | Newsletter |
|---|---|---|
| SMTP not configured | Logs submission to console, returns 200 | Falls through to next provider |
| SMTP configured, nodemailer unavailable | Warns and logs only, returns 200 | Warns and logs only, returns 200 |
| No newsletter provider | — | Logs to console, returns 200 |
| SMTP set, no newsletter provider | Sends email | Falls back to SMTP notification |
| All configured | Sends HTML email | Sends to Mailchimp/ConvertKit |
This prevents the site from breaking in any environment, including when secrets aren't yet configured.
Security
Spam Protection
- Honeypot field:
websiteinput hidden from users via CSS (position: absolute; left: -9999px). Bots that fill all fields are detected server-side and receive a silent 200 response. - Rate limiting: In-memory sliding window per IP address. Configurable constants at the top of each API file:
- Contact:
RATE_LIMIT_MAX_REQUESTS = 5per hour - Newsletter:
RATE_LIMIT_MAX_REQUESTS = 3per hour
- Contact:
Input Validation
- All input validated server-side regardless of client-side checks
- String lengths enforced to prevent DoS via large payloads
- Subject field uses an allowlist (enum) to prevent injection
- HTML in email templates uses
escapeHtml()to prevent XSS in email clients
CORS
- Production:
Access-Control-Allow-Origin: https://workroot.in - Development:
Access-Control-Allow-Origin: * - Preflight
OPTIONShandlers implemented on both endpoints
Rate Limit Response Headers
Both endpoints return standard rate limit headers on every response:
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 4
X-RateLimit-Reset: 1742000000
Response Format
All responses follow a consistent structure:
Success (200):
{ "success": true, "message": "Human-readable success message" }
Validation error (422):
{ "success": false, "error": "Specific validation error" }
Rate limited (429):
{ "success": false, "error": "Too many requests. Please try again later." }
Server error (500):
{ "success": false, "error": "Failed to send message. Please try again or email us directly." }
Client Integration
Contact form (src/pages/contact.astro)
Posts JSON to /api/contact. Handles all response states:
- Success → toast notification + form reset
- Validation error → toast with specific message
- Rate limit (429) → error toast
- Network error → error toast
Newsletter form (src/components/Footer.astro)
Posts JSON to /api/newsletter. Handles all response states:
- Success → inline green message + form reset
- Error → inline red message
- Network error → inline red message
- Message auto-hides after 5 seconds
Logging & Monitoring
Both endpoints use src/utils/logger.ts for structured logging:
- Development: colored console output
- Production: JSON output for log aggregators (Logtail, Datadog, etc.)
Errors are captured via src/utils/sentry.ts (optional — only active when SENTRY_DSN env var is set).
Each API request is logged with:
- Method, path, status code
- IP address
- Duration in milliseconds
Testing
Test the endpoints manually with curl:
# Contact form - success case
curl -X POST http://localhost:4321/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"Test User","email":"test@example.com","subject":"consulting","message":"Hello, I need help with a project."}'
# Newsletter - success case
curl -X POST http://localhost:4321/api/newsletter \
-H "Content-Type: application/json" \
-d '{"email":"subscriber@example.com"}'
# Test honeypot (should return 200 silently)
curl -X POST http://localhost:4321/api/contact \
-H "Content-Type: application/json" \
-d '{"name":"Bot","email":"bot@spam.com","subject":"other","message":"Buy cheap stuff now!","website":"http://spam.com"}'
# Test rate limiting (run 4+ times to trigger 429 on newsletter)
for i in 1 2 3 4; do
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:4321/api/newsletter \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com"}'
done
Playwright tests are located at:
tests/contact-form.spec.ts— contact form UI and APItests/newsletter-subscription.spec.ts— newsletter UI and APItests/api-integration.spec.ts— API endpoint integration teststests/e2e-form-interactions.spec.ts— form interaction flows
Run all form tests:
npm run test:forms
In-Memory Rate Limiter Notes
The current rate limiter is in-memory (per process). This means:
- ✅ Works correctly for single-instance deployments (VPS, single container)
- ⚠️ Resets on server restart
- ⚠️ Not shared across multiple instances (e.g., PM2 cluster mode)
For multi-instance deployments, upgrade to Redis-backed rate limiting (see Future Improvements).
Alternative: FormSpree (No-code option)
If you prefer a managed third-party service without SMTP configuration:
- Sign up at https://formspree.io
- Create a form and get your form ID (e.g.
xpwzgkab) - Replace the fetch URL in
contact.astro:const response = await fetch('https://formspree.io/f/xpwzgkab', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(formData), });
The existing custom API endpoint can remain for future migration back to self-hosted.
Future Improvements
- Persist rate limit state in Redis for multi-instance PM2 cluster deployments
- Add CAPTCHA (hCaptcha or Cloudflare Turnstile) as additional spam layer
- Store contact submissions in a database for CRM integration
- Add confirmation/welcome email for newsletter signups (double opt-in flow)
- Webhook notifications to Slack/Discord on new contact submissions
- Admin dashboard to view contact form submissions