diff --git a/.env.development b/.env.development index 6ecfb1a..3a75460 100644 --- a/.env.development +++ b/.env.development @@ -1,2 +1,10 @@ # Feature Flags API Configuration (Development) VITE_FEATURE_FLAGS_API_URL=http://localhost:8787/api/flags + +# Contact Form API Configuration (Development) +VITE_CONTACT_FORM_API_URL=http://localhost:8788/api/contact + +# Cloudflare Turnstile Configuration (Development) +# Use "1x00000000000000000000AA" for always pass in development +# Use "2x00000000000000000000AB" for always block in development +VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA diff --git a/.env.production b/.env.production index ba95296..3c0e3f7 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,9 @@ # Feature Flags API Configuration (Production) VITE_FEATURE_FLAGS_API_URL=https://portfolio-feature-flags.tyler-a-earls.workers.dev/api/flags + +# Contact Form API Configuration (Production) +VITE_CONTACT_FORM_API_URL=https://portfolio-contact-form.tyler-a-earls.workers.dev/api/contact + +# Cloudflare Turnstile Configuration (Production) +# Replace with your actual Turnstile site key from Cloudflare Dashboard +VITE_TURNSTILE_SITE_KEY=YOUR_PRODUCTION_SITE_KEY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfbe1a5..f1fdfb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,8 @@ jobs: run: npm run test:integration env: VITE_FEATURE_FLAGS_API_URL: http://localhost:8787/api/flags + VITE_CONTACT_FORM_API_URL: http://localhost:8788/api/contact + VITE_TURNSTILE_SITE_KEY: 1x00000000000000000000AA build: name: Build diff --git a/ROADMAP.md b/ROADMAP.md index 8badf41..ab2cf02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,9 +2,9 @@ ## Executive Summary -This roadmap outlines the development plan for Tyler Earls' portfolio website, focusing on performance optimization, modern React tooling, and enhanced user experience. The project has **completed Phase 5 (React Compiler Integration)** and **Phase 6 (CI/CD setup)**, and is now working through **Phase 7 (UI/UX Enhancements)** with 9 open issues. +This roadmap outlines the development plan for Tyler Earls' portfolio website, focusing on performance optimization, modern React tooling, and enhanced user experience. The project has **completed Phase 5 (React Compiler Integration)** and **Phase 6 (CI/CD setup)**, and is now working through **Phase 7 (UI/UX Enhancements)** with 7 open issues. -**Current Focus**: Feature flag infrastructure (#72) complete! Performance optimization sprint done with #61, #62, #63, #11, and #27. Next: Contact form (#14). +**Current Focus**: Contact form (#14) complete! Feature flag infrastructure (#72) and performance optimization sprint done with #61, #62, #63, #11, and #27. Phase 7 core features complete! --- @@ -21,7 +21,7 @@ This roadmap outlines the development plan for Tyler Earls' portfolio website, f ## Open Issues Summary -### Priority Breakdown (8 Total - 6 Completed) +### Priority Breakdown (7 Total - 7 Completed) #### 🔴 Critical Priority (0 issues) @@ -37,14 +37,13 @@ This roadmap outlines the development plan for Tyler Earls' portfolio website, f ✅ **#63** - Fix Cumulative Layout Shift (CLS) on Mobile - **COMPLETED Nov 23, 2025** -#### 🟢 Medium Priority (1 issue) - Effort: ~8-16 hours +#### 🟢 Medium Priority (0 issues) - Effort: ~8-16 hours ✅ **#11** - Preload Sprite SVG in development and production - **COMPLETED Nov 24, 2025** ✅ **#27** - UI - Lazy Load Routes with React Router - **COMPLETED Nov 24, 2025** -- **#14** - Add Working Email Contact Form - _~1-2 days_ - - Impact: User engagement and professional contact method +✅ **#14** - Add Working Email Contact Form - **COMPLETED Dec 7, 2025** #### 🔵 Low Priority (7 issues) - Effort: ~2-3 weeks @@ -199,13 +198,12 @@ _Successfully migrated to TailwindCSS v4 with modern config_ - Changes: Complete feature flag system with Cloudflare Workers + KV + React Context - Result: Runtime feature toggling without redeployment, ready for #14 -**Next Up**: - -7. **#14** - Add Working Email Contact Form - **START NEXT** +7. ✅ **#14** - Add Working Email Contact Form - **COMPLETED** - Priority: 🟢 MEDIUM - - Effort: ~1-2 days - - Impact: User engagement - professional contact method - - Prerequisites: ✅ #72 (feature flag infrastructure complete) + - Status: Completed Dec 7, 2025 + - Effort: ~1-2 days (actual) + - Changes: Complete email contact form with Postmark API, Cloudflare Turnstile CAPTCHA, honeypot, rate limiting + - Result: Professional contact method with triple-layer spam protection **Recent Sprint Completed (Oct 30 - Nov 13, 2025)**: @@ -315,11 +313,11 @@ Phase 7: Accessibility & Core Web Vitals ✅ COMPLETE ├── 🔵 #64 WCAG AAA contrast (2-4h) - ENHANCEMENT └── 🔵 #65 Font size readability (1-2h) - ENHANCEMENT -Phase 7: Performance & UX (CURRENT FOCUS) +Phase 7: Performance & UX ✅ CORE COMPLETE ├── ✅ #11 SVG Preloading (30min) - COMPLETED ├── ✅ #27 Lazy Routes (2h) - COMPLETED ├── ✅ #72 Feature Flags (8-12h) - COMPLETED - Runtime toggling infrastructure -└── #14 Contact Form (1-2 days) - User engagement - NEXT +└── ✅ #14 Contact Form (1-2 days) - COMPLETED - Postmark + Turnstile + spam protection Phase 8: Research (Anytime) ├── #33 Graphite spike (1-2h) @@ -393,9 +391,9 @@ _None - All prerequisites for #43 are complete. Ready to implement._ | ----------- | ----- | ----------- | -------------------- | ---------- | | 🔴 Critical | 0 | 0 | 1 | 0 | | 🟡 High | 0 | 0 | 10 | 0 | -| 🟢 Medium | 1 | 0 | 4 | 1 | +| 🟢 Medium | 0 | 0 | 5 | 0 | | 🔵 Low | 7 | 0 | 0 | 7 | -| **TOTAL** | **8** | **0** | **15** | **8** | +| **TOTAL** | **7** | **0** | **16** | **7** | ### Issues by Category @@ -404,18 +402,18 @@ _None - All prerequisites for #43 are complete. Ready to implement._ **Infrastructure** (0 open, 1 closed): Closed: #72 (feature flags) **CI/CD** (0 open, 2 closed): Closed: #18 (GitHub Actions pipeline), #72 (feature flags) **Accessibility** (2 open, 3 closed): Open: #64, #65 | Closed: #61 (navigation contrast), #62 (touch targets), #63 (CLS mobile) -**UI/UX** (4 open, 5 closed): Open: #10, #13, #14, #15 | Closed: #58 (left-align text), #28 (React 19 Meta), #11 (SVG preload), #27 (lazy routes), #72 (feature flags) +**UI/UX** (3 open, 6 closed): Open: #10, #13, #15 | Closed: #58 (left-align text), #28 (React 19 Meta), #11 (SVG preload), #27 (lazy routes), #72 (feature flags), #14 (contact form) **Research** (2 open): #33, #34 ### Effort Distribution (Open Issues Only) -| Effort Level | Count | Issues | -| ------------- | ----- | ------------------------------ | -| Small (< 2h) | 4 | #13, #33, #34, #65 | -| Medium (2-8h) | 2 | #64, #10 | -| Large (> 8h) | 2 | #14 (1-2 days), #15 (1-2 days) | +| Effort Level | Count | Issues | +| ------------- | ----- | ------------------ | +| Small (< 2h) | 4 | #13, #33, #34, #65 | +| Medium (2-8h) | 2 | #64, #10 | +| Large (> 8h) | 1 | #15 (1-2 days) | -**Note**: Issue #14 categorized as Large based on 1-2 days estimate (~8-16 hours total effort). +**Note**: All medium and high priority issues complete. Only low priority and enhancements remain. --- @@ -500,7 +498,88 @@ _None - All prerequisites for #43 are complete. Ready to implement._ - ✅ Production build successful - ✅ No breaking changes (minor version update within React 19) -**Next Actions**: Continue with #14 (Add Working Email Contact Form) +--- + +### 2025-12-07 - Issue #14 Completed: Add Working Email Contact Form + +- **Completed**: #14 - Add Working Email Contact Form +- **Priority**: 🟢 MEDIUM (GitHub labels: `type: feature`, `area: ui`, `priority: medium`, `effort: large`) +- **Status**: Completed Dec 7, 2025 +- **Effort**: ~1-2 days (actual) +- **Impact**: Professional contact method with enterprise-grade spam protection + +**Implementation Summary**: + +Delivered a complete, production-ready contact form with: + +1. **Cloudflare Worker Backend** (`packages/contact-form/`) + - POST `/api/contact` endpoint for form submission + - Cloudflare Turnstile verification (privacy-friendly CAPTCHA) + - Honeypot field for bot detection + - Rate limiting: 5 requests/hour/IP using Cloudflare KV + - Postmark API integration for email delivery + - Input validation with proper error messages + - CORS configuration for allowed origins + - Full TypeScript implementation + +2. **React Frontend Enhancement** (`src/components/ContactEmailForm.tsx`) + - Complete form with name, email, message fields + - Cloudflare Turnstile widget integration (@marsidev/react-turnstile) + - Hidden honeypot field for additional bot protection + - Client-side email validation with inline errors + - Loading, success, and error states + - Character counter for message field (5000 char limit) + - Accessibility: ARIA attributes, screen reader announcements, role="alert" + - Form clears and resets Turnstile on successful submission + +3. **Testing Suite** + - Unit tests with Vitest (22 tests for ContactEmailForm) + - Integration tests with Cypress (form display, validation, accessibility) + - Tests cover: rendering, validation, submission, error handling, Turnstile integration + +**Files Created**: + +- `packages/contact-form/src/index.ts` - Main Worker with full contact form logic +- `packages/contact-form/package.json` - Package configuration +- `packages/contact-form/tsconfig.json` - TypeScript config +- `packages/contact-form/wrangler.toml` - Cloudflare Worker config +- `tests/unit/components/ContactEmailForm.test.tsx` - Unit tests (22 tests) +- `tests/integration/contact-form.cy.ts` - Integration tests + +**Files Modified**: + +- `src/components/ContactEmailForm.tsx` - Complete rewrite with full functionality +- `src/util/constants.ts` - Added API_URIS.CONTACT, TURNSTILE_SITE_KEY +- `src/vite-env.d.ts` - Added new environment variable types +- `.env.development` - Added contact form and Turnstile URLs +- `.env.production` - Added contact form and Turnstile URLs +- `package.json` (root) - Added workspace, scripts, @marsidev/react-turnstile dependency + +**Technical Highlights**: + +- **Security**: Triple-layer spam protection (Turnstile + honeypot + rate limit) +- **Privacy**: Cloudflare Turnstile is privacy-friendly (no tracking cookies) +- **Performance**: Edge-deployed Worker for low latency +- **Reliability**: Graceful error handling, form validation, retry support +- **Email Delivery**: Postmark API with verified sender domain (tylerearls.com) +- **Feature Flag Ready**: Works with existing feature flag infrastructure (#72) + +**Deployment Requirements**: + +1. Create Cloudflare Turnstile site (get Site Key and Secret Key) +2. Create KV namespace for rate limiting +3. Set Worker secrets: `POSTMARK_SERVER_TOKEN`, `TURNSTILE_SECRET_KEY` +4. Deploy Worker: `npm run deploy:contact` +5. Enable feature flag: `email-contact-form.enabled = true` + +**Impact on Roadmap**: + +- Completes: Phase 7 core features (contact form was the last major feature) +- Reduces open issues: 8 → 7 +- All medium and high priority issues now complete! +- Only low priority and enhancement issues remain + +**Next Actions**: Low priority features (#10, #13, #15) or accessibility enhancements (#64, #65) as time permits --- @@ -1187,6 +1266,6 @@ All high-priority accessibility issues (#61, #62, #63) have been resolved. Mediu --- -**Last Updated**: December 13, 2025 (Issue #77 - React 19.2.3 Update) +**Last Updated**: December 13, 2025 (Issue #14 Complete - Contact Form with Postmark + Turnstile) **Maintained By**: Tyler Earls **Generated With**: Claude Code diff --git a/eslint.config.mts b/eslint.config.mts index edbe148..6784d12 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -6,11 +6,10 @@ import { flatConfigs as eslintPluginImportFlatConfigs } from "eslint-plugin-impo import eslintPluginReact from "eslint-plugin-react"; import eslintPluginReactHooks from "eslint-plugin-react-hooks"; import eslintPluginReactRefresh from "eslint-plugin-react-refresh"; -import { defineConfig } from "eslint/config"; import globals from "globals"; import typescriptEslint from "typescript-eslint"; -const config = defineConfig([ +const config = typescriptEslint.config( { files: ["**/*.{js,mjs,cjs,ts,mts,jsx,tsx}"] }, { ignores: [ @@ -19,7 +18,6 @@ const config = defineConfig([ "node_modules", "prettier.config.mjs", "**/.wrangler/**", - "packages/feature-flags/.wrangler/**", ], }, { @@ -149,6 +147,6 @@ const config = defineConfig([ ], }, }, -]); +); export default config; diff --git a/package-lock.json b/package-lock.json index 61fb43a..4eb0568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ ], "dependencies": { "@cloudinary/url-gen": "^1.22.0", + "@marsidev/react-turnstile": "^1.3.1", "@xstate/react": "^6.0.0", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -28,6 +29,7 @@ "@testing-library/cypress": "^10.1.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@tsconfig/node22": "^22.0.2", "@types/mocha": "^10.0.10", "@types/node": "^22.10.4", @@ -2292,6 +2294,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@marsidev/react-turnstile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.3.1.tgz", + "integrity": "sha512-h2THG/75k4Y049hgjSGPIcajxXnh+IZAiXVbryQyVmagkboN7pJtBgR16g8akjwUBSfRrg6jw6KvPDjscQflog==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@mjackson/node-fetch-server": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", @@ -2741,6 +2753,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@portfolio/contact-form": { + "resolved": "packages/contact-form", + "link": true + }, "node_modules/@portfolio/feature-flags": { "resolved": "packages/feature-flags", "link": true @@ -4093,6 +4109,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tsconfig/node22": { "version": "22.0.2", "resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.2.tgz", @@ -14354,6 +14384,18 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "packages/contact-form": { + "name": "@portfolio/contact-form", + "version": "1.0.0", + "dependencies": { + "@portfolio/shared-types": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "typescript": "^5.3.3", + "wrangler": "^4.51.0" + } + }, "packages/feature-flags": { "name": "@portfolio/feature-flags", "version": "1.0.0", diff --git a/package.json b/package.json index c865fcc..0081b78 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,14 @@ "preview": "vite preview --open", "ci": "npm run lint:check && npm run oxlint:check && npm run format:check && npm run test:all && npm run build", "dev:flags": "npm run dev -w @portfolio/feature-flags", - "dev:all": "concurrently \"npm run dev\" \"npm run dev:flags\"", - "deploy:flags": "npm run deploy -w @portfolio/feature-flags" + "dev:contact": "npm run dev -w @portfolio/contact-form", + "dev:all": "concurrently \"npm run dev\" \"npm run dev:flags\" \"npm run dev:contact\"", + "deploy:flags": "npm run deploy -w @portfolio/feature-flags", + "deploy:contact": "npm run deploy -w @portfolio/contact-form" }, "dependencies": { "@cloudinary/url-gen": "^1.22.0", + "@marsidev/react-turnstile": "^1.3.1", "@xstate/react": "^6.0.0", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -46,6 +49,7 @@ "@testing-library/cypress": "^10.1.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@tsconfig/node22": "^22.0.2", "@types/mocha": "^10.0.10", "@types/node": "^22.10.4", diff --git a/packages/contact-form/.gitignore b/packages/contact-form/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/packages/contact-form/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/packages/contact-form/package.json b/packages/contact-form/package.json new file mode 100644 index 0000000..067ba4f --- /dev/null +++ b/packages/contact-form/package.json @@ -0,0 +1,19 @@ +{ + "name": "@portfolio/contact-form", + "version": "1.0.0", + "private": true, + "main": "src/index.ts", + "scripts": { + "dev": "wrangler dev --port 8788", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@portfolio/shared-types": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20240117.0", + "typescript": "^5.3.3", + "wrangler": "^4.51.0" + } +} diff --git a/packages/contact-form/src/index.ts b/packages/contact-form/src/index.ts new file mode 100644 index 0000000..d7b4641 --- /dev/null +++ b/packages/contact-form/src/index.ts @@ -0,0 +1,488 @@ +/** + * Contact Form Worker + * + * Handles contact form submissions with: + * - Cloudflare Turnstile verification + * - Honeypot spam detection + * - Rate limiting + * - Postmark email delivery + */ + +import { EMAIL_REGEX } from "@portfolio/shared-types"; + +interface Env { + RATE_LIMIT?: KVNamespace; + ALLOWED_ORIGINS: string; + RECIPIENT_EMAIL: string; + POSTMARK_SERVER_TOKEN: string; + TURNSTILE_SECRET_KEY: string; + RATE_LIMIT_MAX: string; + RATE_LIMIT_WINDOW_SECONDS: string; +} + +interface ContactRequest { + name: string; + email: string; + message: string; + website?: string; // Honeypot field + turnstileToken: string; +} + +interface ContactResponse { + success: boolean; + message?: string; + error?: string; + details?: Array; + retryAfter?: number; +} + +interface RateLimitEntry { + count: number; + windowStart: number; +} + +interface TurnstileVerifyResponse { + success: boolean; + "error-codes"?: Array; + challenge_ts?: string; + hostname?: string; +} + +/** + * Validate contact request body + */ +function validateContactRequest(body: unknown): { + success: boolean; + data?: ContactRequest; + errors?: Array; +} { + const errors: Array = []; + + if (!body || typeof body !== "object") { + return { success: false, errors: ["Request body must be a JSON object"] }; + } + + const { name, email, message, website, turnstileToken } = body as Record< + string, + unknown + >; + + // Name validation + if (typeof name !== "string" || name.trim().length === 0) { + errors.push("Name is required"); + } else if (name.trim().length > 100) { + errors.push("Name must be 100 characters or less"); + } + + // Email validation + if (typeof email !== "string" || email.trim().length === 0) { + errors.push("Email is required"); + } else if (!EMAIL_REGEX.test(email.trim())) { + errors.push("Invalid email format"); + } + + // Message validation + if (typeof message !== "string" || message.trim().length === 0) { + errors.push("Message is required"); + } else if (message.trim().length > 5000) { + errors.push("Message must be 5000 characters or less"); + } + + // Turnstile token validation + if ( + typeof turnstileToken !== "string" || + turnstileToken.trim().length === 0 + ) { + errors.push("Turnstile verification is required"); + } + + if (errors.length > 0) { + return { success: false, errors }; + } + + return { + success: true, + data: { + name: (name as string).trim(), + email: (email as string).trim(), + message: (message as string).trim(), + website: typeof website === "string" ? website : undefined, + turnstileToken: (turnstileToken as string).trim(), + }, + }; +} + +/** + * Verify Cloudflare Turnstile token + */ +async function verifyTurnstileToken( + token: string, + secretKey: string, + clientIP: string, +): Promise<{ success: boolean; error?: string }> { + try { + const formData = new FormData(); + formData.append("secret", secretKey); + formData.append("response", token); + formData.append("remoteip", clientIP); + + const response = await fetch( + "https://challenges.cloudflare.com/turnstile/v0/siteverify", + { + method: "POST", + body: formData, + }, + ); + + const result = (await response.json()) as TurnstileVerifyResponse; + + if (!result.success) { + console.error("Turnstile verification failed:", result["error-codes"]); + return { success: false, error: "Turnstile verification failed" }; + } + + return { success: true }; + } catch (error) { + console.error("Turnstile verification error:", error); + return { success: false, error: "Failed to verify Turnstile token" }; + } +} + +/** + * Check rate limit using KV storage + */ +async function checkRateLimit( + kv: KVNamespace | undefined, + clientIP: string, + maxRequests: number, + windowSeconds: number, +): Promise<{ allowed: boolean; remaining: number; retryAfter?: number }> { + // If KV is not configured, allow all requests (development mode) + if (!kv) { + return { allowed: true, remaining: maxRequests }; + } + + const key = `rate-limit:${clientIP}`; + const now = Date.now(); + const windowMs = windowSeconds * 1000; + + try { + const entry = await kv.get(key, "json"); + + if (!entry) { + return { allowed: true, remaining: maxRequests - 1 }; + } + + // Check if window has expired + if (now - entry.windowStart > windowMs) { + return { allowed: true, remaining: maxRequests - 1 }; + } + + // Check if rate limit exceeded + if (entry.count >= maxRequests) { + const retryAfter = Math.ceil((entry.windowStart + windowMs - now) / 1000); + return { allowed: false, remaining: 0, retryAfter }; + } + + return { allowed: true, remaining: maxRequests - entry.count - 1 }; + } catch (error) { + console.error("Rate limit check error:", error); + // Allow request if rate limit check fails + return { allowed: true, remaining: maxRequests }; + } +} + +/** + * Record request for rate limiting + */ +async function recordRequest( + kv: KVNamespace | undefined, + clientIP: string, + windowSeconds: number, +): Promise { + if (!kv) return; + + const key = `rate-limit:${clientIP}`; + const now = Date.now(); + const windowMs = windowSeconds * 1000; + + try { + const entry = await kv.get(key, "json"); + + let newEntry: RateLimitEntry; + + if (!entry || now - entry.windowStart > windowMs) { + // Start new window + newEntry = { count: 1, windowStart: now }; + } else { + // Increment count in current window + newEntry = { count: entry.count + 1, windowStart: entry.windowStart }; + } + + await kv.put(key, JSON.stringify(newEntry), { + expirationTtl: windowSeconds * 2, + }); + } catch (error) { + console.error("Rate limit record error:", error); + } +} + +/** + * Escape HTML characters to prevent XSS + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Send email via Postmark API + */ +async function sendEmail(params: { + serverToken: string; + to: string; + replyTo: string; + name: string; + message: string; +}): Promise<{ success: boolean; error?: string }> { + const { serverToken, to, replyTo, name, message } = params; + + // Use a verified sender domain address + const fromAddress = "Portfolio Contact Form "; + + try { + const response = await fetch("https://api.postmarkapp.com/email", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "X-Postmark-Server-Token": serverToken, + }, + body: JSON.stringify({ + From: fromAddress, + To: to, + ReplyTo: replyTo, + Subject: `Portfolio Contact: ${name}`, + TextBody: ` +New contact form submission: + +Name: ${name} +Email: ${replyTo} + +Message: +${message} + `.trim(), + HtmlBody: ` +

New Portfolio Contact Form Submission

+

Name: ${escapeHtml(name)}

+

Email: ${escapeHtml(replyTo)}

+
+

Message:

+

${escapeHtml(message).replace(/\n/g, "
")}

+ `.trim(), + MessageStream: "outbound", + }), + }); + + if (!response.ok) { + const errorData = (await response.json()) as { Message?: string }; + console.error("Postmark API error:", errorData); + return { + success: false, + error: errorData.Message ?? "Failed to send email", + }; + } + + return { success: true }; + } catch (error) { + console.error("Email send error:", error); + return { success: false, error: String(error) }; + } +} + +/** + * Handle contact form submission + */ +async function handleContactSubmission( + request: Request, + env: Env, + corsHeaders: Record, +): Promise { + const clientIP = request.headers.get("CF-Connecting-IP") ?? "unknown"; + + try { + // Rate limit check + const rateLimitResult = await checkRateLimit( + env.RATE_LIMIT, + clientIP, + parseInt(env.RATE_LIMIT_MAX, 10), + parseInt(env.RATE_LIMIT_WINDOW_SECONDS, 10), + ); + + if (!rateLimitResult.allowed) { + const response: ContactResponse = { + success: false, + error: "Too many requests. Please try again later.", + retryAfter: rateLimitResult.retryAfter, + }; + return new Response(JSON.stringify(response), { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(rateLimitResult.retryAfter), + ...corsHeaders, + }, + }); + } + + // Parse and validate request body + let body: unknown; + try { + body = await request.json(); + } catch { + const response: ContactResponse = { + success: false, + error: "Invalid JSON in request body", + }; + return new Response(JSON.stringify(response), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + const validation = validateContactRequest(body); + + if (!validation.success || !validation.data) { + const response: ContactResponse = { + success: false, + error: "Invalid request", + details: validation.errors, + }; + return new Response(JSON.stringify(response), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + // Check honeypot (spam detection) + if (validation.data.website) { + // Honeypot triggered - silently succeed to not reveal detection + console.log("Honeypot triggered for IP:", clientIP); + const response: ContactResponse = { + success: true, + message: "Message sent successfully", + }; + return new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + // Verify Turnstile token + const turnstileResult = await verifyTurnstileToken( + validation.data.turnstileToken, + env.TURNSTILE_SECRET_KEY, + clientIP, + ); + + if (!turnstileResult.success) { + const response: ContactResponse = { + success: false, + error: + turnstileResult.error ?? "Verification failed. Please try again.", + }; + return new Response(JSON.stringify(response), { + status: 400, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + // Send email via Postmark + const emailResult = await sendEmail({ + serverToken: env.POSTMARK_SERVER_TOKEN, + to: env.RECIPIENT_EMAIL, + replyTo: validation.data.email, + name: validation.data.name, + message: validation.data.message, + }); + + if (!emailResult.success) { + console.error("Email send failed:", emailResult.error); + const response: ContactResponse = { + success: false, + error: "Failed to send message. Please try again.", + }; + return new Response(JSON.stringify(response), { + status: 500, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + // Record successful request for rate limiting + await recordRequest( + env.RATE_LIMIT, + clientIP, + parseInt(env.RATE_LIMIT_WINDOW_SECONDS, 10), + ); + + const response: ContactResponse = { + success: true, + message: "Message sent successfully", + }; + return new Response(JSON.stringify(response), { + status: 200, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } catch (error) { + console.error("Contact form error:", error); + const response: ContactResponse = { + success: false, + error: "An unexpected error occurred. Please try again.", + }; + return new Response(JSON.stringify(response), { + status: 500, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const origin = request.headers.get("Origin") ?? ""; + const allowedOrigins = env.ALLOWED_ORIGINS.split(","); + + const corsOrigin = allowedOrigins.includes(origin) + ? origin + : allowedOrigins[0]; + + const corsHeaders: Record = { + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Origin": corsOrigin, + }; + + // Handle preflight + if (request.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + // Health check endpoint + if (url.pathname === "/health" && request.method === "GET") { + return new Response(JSON.stringify({ status: "ok" }), { + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + + // Contact form endpoint + if (url.pathname === "/api/contact" && request.method === "POST") { + return handleContactSubmission(request, env, corsHeaders); + } + + return new Response("Not Found", { status: 404 }); + }, +}; diff --git a/packages/contact-form/tsconfig.json b/packages/contact-form/tsconfig.json new file mode 100644 index 0000000..b498a62 --- /dev/null +++ b/packages/contact-form/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "lib": ["ES2021"], + "types": ["@cloudflare/workers-types"], + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "references": [{ "path": "../shared-types" }] +} diff --git a/packages/contact-form/wrangler.toml b/packages/contact-form/wrangler.toml new file mode 100644 index 0000000..ab1c332 --- /dev/null +++ b/packages/contact-form/wrangler.toml @@ -0,0 +1,22 @@ +name = "portfolio-contact-form" +main = "src/index.ts" +compatibility_date = "2024-11-28" + +[dev] +port = 8788 + +[vars] +ALLOWED_ORIGINS = "https://tylerearls.com,http://localhost:5173,http://localhost:3000,http://localhost:4173" +RECIPIENT_EMAIL = "tyler.a.earls@gmail.com" +RATE_LIMIT_MAX = "5" +RATE_LIMIT_WINDOW_SECONDS = "3600" + +# Secrets (set via wrangler secret put): +# - POSTMARK_SERVER_TOKEN +# - TURNSTILE_SECRET_KEY + +# KV namespace for rate limiting (create with wrangler kv:namespace create "RATE_LIMIT") +# [[kv_namespaces]] +# binding = "RATE_LIMIT" +# id = "" +# preview_id = "" diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 1f5070b..c1dcbcc 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -12,3 +12,10 @@ export const DEFAULT_FLAGS: FeatureFlags = { enabled: false, // Safe default - disabled until explicitly enabled via Worker }, }; + +/** + * Email validation regex + * RFC 5322 compliant pattern for validating email addresses + */ +export const EMAIL_REGEX = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; diff --git a/src/components/ContactEmailForm.tsx b/src/components/ContactEmailForm.tsx index 8a92f18..11b2806 100644 --- a/src/components/ContactEmailForm.tsx +++ b/src/components/ContactEmailForm.tsx @@ -1,26 +1,148 @@ +import type { TurnstileInstance } from "@marsidev/react-turnstile"; import type { FormEvent } from "react"; -import { useState } from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import { useRef, useState } from "react"; -import { PORTFOLIO_EMAIL } from "@/util/constants.ts"; +import { + API_URIS, + EMAIL_REGEX, + PORTFOLIO_EMAIL, + TURNSTILE_SITE_KEY, +} from "@/util/constants.ts"; + +type FormStatus = "idle" | "submitting" | "success" | "error"; + +interface FormError { + message: string; + fields?: Array; +} + +interface ContactResponse { + success: boolean; + message?: string; + error?: string; + details?: Array; + retryAfter?: number; +} /** - * ContactEmailForm - Email contact form for portfolio + * ContactEmailForm - Email contact form for portfolio with Postmark integration * - * This is a placeholder component that will be enhanced with full functionality. - * Currently displays a basic form structure. + * Features: + * - Cloudflare Turnstile CAPTCHA for spam protection + * - Honeypot field for additional bot detection + * - Server-side rate limiting (5 requests/hour/IP) + * - Client-side email validation + * - Loading, success, and error states + * - Accessibility: ARIA attributes, screen reader announcements */ const ContactEmailForm = () => { const [formData, setFormData] = useState({ email: "", message: "", name: "", + website: "", // Honeypot field }); + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [emailError, setEmailError] = useState(null); + const [turnstileToken, setTurnstileToken] = useState(null); + + const turnstileRef = useRef(undefined); + + const validateEmail = (email: string): boolean => { + const regex = new RegExp(EMAIL_REGEX.source, "i"); + return regex.test(email); + }; + + const handleEmailBlur = () => { + if (formData.email && !validateEmail(formData.email)) { + setEmailError("Please enter a valid email address"); + } else { + setEmailError(null); + } + }; - const handleSubmit = (e: FormEvent) => { + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - // TODO: Implement form submission logic - console.log("Form submitted:", formData); + setError(null); + setEmailError(null); + + // Client-side email validation + if (!validateEmail(formData.email)) { + setEmailError("Please enter a valid email address"); + return; + } + + // Ensure Turnstile token is present + if (!turnstileToken) { + setError({ message: "Please complete the verification challenge" }); + return; + } + + setStatus("submitting"); + + try { + const response = await fetch(API_URIS.CONTACT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + name: formData.name, + email: formData.email, + message: formData.message, + website: formData.website, // Honeypot + turnstileToken, + }), + }); + + const data = (await response.json()) as ContactResponse; + + if (!response.ok) { + if (response.status === 429) { + setError({ + message: `Too many requests. Please try again in ${data.retryAfter ?? 60} seconds.`, + }); + } else if (response.status === 400 && data.details) { + setError({ + message: "Please fix the following errors:", + fields: data.details, + }); + } else { + setError({ + message: data.error ?? "Failed to send message. Please try again.", + }); + } + setStatus("error"); + // Reset Turnstile on error + turnstileRef.current?.reset(); + setTurnstileToken(null); + return; + } + + setStatus("success"); + setFormData({ email: "", message: "", name: "", website: "" }); + // Reset Turnstile on success + turnstileRef.current?.reset(); + setTurnstileToken(null); + + // Reset to idle after 5 seconds so user can submit again if needed + setTimeout(() => { + setStatus("idle"); + }, 5000); + } catch (err) { + console.error("Form submission error:", err); + setError({ + message: "Network error. Please check your connection and try again.", + }); + setStatus("error"); + // Reset Turnstile on error + turnstileRef.current?.reset(); + setTurnstileToken(null); + } }; const handleChange = ( @@ -28,12 +150,34 @@ const ContactEmailForm = () => { ) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); + + // Clear email error when user starts typing + if (name === "email" && emailError) { + setEmailError(null); + } + }; + + const handleTurnstileSuccess = (token: string) => { + setTurnstileToken(token); + }; + + const handleTurnstileError = () => { + setTurnstileToken(null); + setError({ message: "Verification failed. Please try again." }); + }; + + const handleTurnstileExpire = () => { + setTurnstileToken(null); }; const isFormValid = formData.name.trim() !== "" && formData.email.trim() !== "" && - formData.message.trim() !== ""; + formData.message.trim() !== "" && + !emailError && + turnstileToken !== null; + + const isSubmitting = status === "submitting"; return (
{ className="form-boxshadow mx-auto my-8 w-full max-w-sm rounded-md bg-gray-200 px-4 py-6 dark:bg-gray-900" method="POST" onSubmit={handleSubmit} + aria-describedby={error ? "form-error" : undefined} > -
+
Contact Form + {/* Success Message */} + {status === "success" && ( +
+

Message sent successfully!

+

+ Thank you for reaching out. I'll get back to you soon. +

+
+ )} + + {/* Error Message */} + {error && ( + + )} + {/* Name Field */}
{/* Email Field */}
+ {emailError && ( +

+ {emailError} +

+ )}
+ {/* Honeypot Field (hidden from users, visible to bots) */} + + {/* Message Field */}