Switch contact form to self-hosted SMTP via nodemailer, remove formsubmit.co
This commit is contained in:
@@ -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
|
||||||
Generated
+21
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
@@ -939,6 +941,16 @@
|
|||||||
"undici-types": "~7.16.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -4129,6 +4141,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"nodemailer": "^8.0.5",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.0",
|
"@vitejs/plugin-react": "^6.0.0",
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import express from 'express'
|
|||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
function htmlEsc(str) {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
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: `<p><strong>Name:</strong> ${htmlEsc(name.trim())}</p><p><strong>Email:</strong> ${htmlEsc(email.trim())}</p><hr/><pre style="font-family:sans-serif">${htmlEsc(message.trim())}</pre>`,
|
||||||
|
})
|
||||||
|
|
||||||
|
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(express.static(DIST_DIR))
|
||||||
|
|
||||||
app.use(async (_req, res) => {
|
app.use(async (_req, res) => {
|
||||||
|
|||||||
+14
@@ -572,6 +572,20 @@
|
|||||||
margin-top: 0.25rem;
|
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 {
|
.contact-honeypot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -9999px;
|
left: -9999px;
|
||||||
|
|||||||
+66
-20
@@ -13,8 +13,6 @@ const YOUTUBE_URL = 'https://www.youtube.com/@blackzebraem5558'
|
|||||||
const AMAZON_MUSIC_URL =
|
const AMAZON_MUSIC_URL =
|
||||||
'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate'
|
'https://music.amazon.com/podcasts/202322bf-db86-4e7d-9a6b-4db7cbccbccf/verse-by-verse-with-nate'
|
||||||
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
|
const FACEBOOK_URL = 'https://facebook.com/versebyversewithnate'
|
||||||
const CONTACT_FORM_ACTION = 'https://formsubmit.co/vbvwithnate@outlook.com'
|
|
||||||
const CONTACT_FORM_NEXT = '/thanks'
|
|
||||||
|
|
||||||
function FacebookIcon() {
|
function FacebookIcon() {
|
||||||
return (
|
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<HTMLInputElement | HTMLTextAreaElement>) {
|
||||||
|
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 (
|
||||||
|
<form className="contact-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="contact-honeypot"
|
||||||
|
tabIndex={-1}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-hidden="true"
|
||||||
|
value={honey}
|
||||||
|
onChange={e => setHoney(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" name="name" required autoComplete="name" value={fields.name} onChange={handleChange} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" required autoComplete="email" value={fields.email} onChange={handleChange} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Message
|
||||||
|
<textarea name="message" rows={6} required value={fields.message} onChange={handleChange} />
|
||||||
|
</label>
|
||||||
|
{status === 'error' && <p className="contact-error">{errorMsg}</p>}
|
||||||
|
<button type="submit" className="btn-primary" disabled={status === 'submitting'}>
|
||||||
|
{status === 'submitting' ? 'Sending…' : 'Send Message'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LandingPage({ content }: { content: SiteContent }) {
|
function LandingPage({ content }: { content: SiteContent }) {
|
||||||
return (
|
return (
|
||||||
<div className="site">
|
<div className="site">
|
||||||
@@ -336,24 +399,7 @@ function LandingPage({ content }: { content: SiteContent }) {
|
|||||||
Have a question, testimony, or topic request? Send a message and we will get back to you.
|
Have a question, testimony, or topic request? Send a message and we will get back to you.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="contact-form" action={CONTACT_FORM_ACTION} method="POST">
|
<ContactForm />
|
||||||
<input type="hidden" name="_subject" value="New message from Verse by Verse website" />
|
|
||||||
<input type="hidden" name="_next" value={CONTACT_FORM_NEXT} />
|
|
||||||
<input type="text" name="_honey" className="contact-honeypot" tabIndex={-1} autoComplete="off" />
|
|
||||||
<label>
|
|
||||||
Name
|
|
||||||
<input type="text" name="name" required autoComplete="name" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Email
|
|
||||||
<input type="email" name="email" required autoComplete="email" />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Message
|
|
||||||
<textarea name="message" rows={6} required />
|
|
||||||
</label>
|
|
||||||
<button type="submit" className="btn-primary">Send Message</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user