diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8df7e7 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and fill in your real values. +# Never commit .env to version control. + +# Outlook SMTP credentials for the contact form +# Use an app password (not your main password): +# 1. Go to account.microsoft.com → Security → Advanced security options +# 2. Enable 2-step verification if not already on +# 3. Create an App password and paste it here +SMTP_USER=vbvwithnate@outlook.com +SMTP_PASS=your_app_password_here diff --git a/package-lock.json b/package-lock.json index a8a5956..0e207cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "express": "^5.2.1", + "nodemailer": "^8.0.5", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", @@ -18,6 +19,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", + "@types/nodemailer": "^8.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", @@ -939,6 +941,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -4129,6 +4141,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.5.tgz", + "integrity": "sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index 041a04b..53c4eb0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "express": "^5.2.1", + "nodemailer": "^8.0.5", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", @@ -23,6 +24,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", + "@types/nodemailer": "^8.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.0", diff --git a/server.js b/server.js index 91c3f88..86e9f96 100644 --- a/server.js +++ b/server.js @@ -2,6 +2,15 @@ import express from 'express' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' +import nodemailer from 'nodemailer' + +function htmlEsc(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -45,6 +54,78 @@ app.put('/api/admin-content', async (req, res) => { } }) +// Rate-limit contact submissions: max 5 per IP per 10 minutes +const contactHits = new Map() +function contactRateLimit(req, res, next) { + const ip = req.ip ?? 'unknown' + const now = Date.now() + const windowMs = 10 * 60 * 1000 + const entry = contactHits.get(ip) ?? { count: 0, start: now } + if (now - entry.start > windowMs) { + entry.count = 0 + entry.start = now + } + entry.count += 1 + contactHits.set(ip, entry) + if (entry.count > 5) { + res.status(429).json({ message: 'Too many messages. Please wait a few minutes.' }) + return + } + next() +} + +app.post('/api/contact', contactRateLimit, async (req, res) => { + try { + const { name, email, message, _honey } = req.body ?? {} + + // Honeypot — silently discard if filled by a bot + if (_honey) { + res.json({ ok: true }) + return + } + + if (!name || typeof name !== 'string' || name.trim().length < 1 || name.trim().length > 200) { + res.status(400).json({ message: 'Name is required.' }) + return + } + if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) { + res.status(400).json({ message: 'A valid email address is required.' }) + return + } + if (!message || typeof message !== 'string' || message.trim().length < 5 || message.trim().length > 3000) { + res.status(400).json({ message: 'Message must be between 5 and 3000 characters.' }) + return + } + + if (!process.env.SMTP_USER || !process.env.SMTP_PASS) { + console.error('[contact] SMTP_USER / SMTP_PASS env vars not set') + res.status(503).json({ message: 'The contact form is not yet configured on the server.' }) + return + } + + const transporter = nodemailer.createTransport({ + host: 'smtp-mail.outlook.com', + port: 587, + secure: false, + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + }) + + await transporter.sendMail({ + from: process.env.SMTP_USER, + to: process.env.SMTP_USER, + replyTo: `"${name.trim()}" <${email.trim()}>`, + subject: `New message from Verse by Verse website — ${name.trim()}`, + text: `Name: ${name.trim()}\nEmail: ${email.trim()}\n\nMessage:\n${message.trim()}`, + html: `

Name: ${htmlEsc(name.trim())}

Email: ${htmlEsc(email.trim())}


${htmlEsc(message.trim())}
`, + }) + + res.json({ ok: true }) + } catch (err) { + console.error('[contact] send error:', err) + res.status(500).json({ message: 'Failed to send your message. Please try again or email us directly.' }) + } +}) + app.use(express.static(DIST_DIR)) app.use(async (_req, res) => { diff --git a/src/App.css b/src/App.css index 0e9ec1e..6800955 100644 --- a/src/App.css +++ b/src/App.css @@ -572,6 +572,20 @@ margin-top: 0.25rem; } +.contact-form .btn-primary:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + +.contact-error { + font-family: 'Barlow Condensed', sans-serif; + font-size: 0.95rem; + color: #e05c5c; + margin: 0; + padding: 0; +} + .contact-honeypot { position: absolute; left: -9999px; diff --git a/src/App.tsx b/src/App.tsx index af49d08..8808717 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,8 +13,6 @@ const YOUTUBE_URL = 'https://www.youtube.com/@blackzebraem5558' const AMAZON_MUSIC_URL = 'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate' const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate' -const CONTACT_FORM_ACTION = 'https://formsubmit.co/vbvwithnate@outlook.com' -const CONTACT_FORM_NEXT = '/thanks' function FacebookIcon() { return ( @@ -104,6 +102,71 @@ function AmazonMusicIcon() { ) } +function ContactForm() { + const navigate = useNavigate() + const [fields, setFields] = useState({ name: '', email: '', message: '' }) + const [honey, setHoney] = useState('') + const [status, setStatus] = useState<'idle' | 'submitting' | 'error'>('idle') + const [errorMsg, setErrorMsg] = useState('') + + function handleChange(e: React.ChangeEvent) { + setFields(f => ({ ...f, [e.target.name]: e.target.value })) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setStatus('submitting') + setErrorMsg('') + try { + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...fields, _honey: honey }), + }) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setErrorMsg((data as { message?: string }).message ?? 'Something went wrong. Please try again.') + setStatus('error') + return + } + navigate('/thanks') + } catch { + setErrorMsg('Could not connect. Please try again later.') + setStatus('error') + } + } + + return ( +
+ setHoney(e.target.value)} + /> + + +