# 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:** 1. Client submits form → `POST /api/contact` (JSON body) 2. Rate limit check (5 requests / IP / hour, sliding window) 3. Honeypot field check (silent success if bot detected) 4. Server-side field validation 5. Email delivery via SMTP (nodemailer) — logs to console if SMTP not configured 6. JSON response returned **Fields validated:** - `name` — required, 2–100 characters - `email` — required, valid format, max 254 chars - `phone` — optional; if present, must match phone regex - `subject` — 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 characters - `website` — honeypot, must be empty **Rate limit:** 5 requests per IP per hour --- ### Newsletter (`/api/newsletter`) **Flow:** 1. Client submits email → `POST /api/newsletter` (JSON body) 2. Rate limit check (3 requests / IP / hour) 3. Email format validation 4. Subscribe via configured provider (Mailchimp → ConvertKit → SMTP notification → log-only) 5. 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 ```env 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):** ```env MAILCHIMP_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-us1 MAILCHIMP_LIST_ID=abc123def456 MAILCHIMP_DC=us1 ``` Uses double opt-in (`status: "pending"`). Handles already-subscribed gracefully. **ConvertKit:** ```env 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: ```bash 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**: `website` input 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 = 5` per hour - Newsletter: `RATE_LIMIT_MAX_REQUESTS = 3` per hour ### 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 `OPTIONS` handlers 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):** ```json { "success": true, "message": "Human-readable success message" } ``` **Validation error (422):** ```json { "success": false, "error": "Specific validation error" } ``` **Rate limited (429):** ```json { "success": false, "error": "Too many requests. Please try again later." } ``` **Server error (500):** ```json { "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: ```bash # 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 API - `tests/newsletter-subscription.spec.ts` — newsletter UI and API - `tests/api-integration.spec.ts` — API endpoint integration tests - `tests/e2e-form-interactions.spec.ts` — form interaction flows Run all form tests: ```bash 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: 1. Sign up at https://formspree.io 2. Create a form and get your form ID (e.g. `xpwzgkab`) 3. Replace the fetch URL in `contact.astro`: ```javascript 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