Switch contact form to self-hosted SMTP via nodemailer, remove formsubmit.co
This commit is contained in:
@@ -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, '>')
|
||||
.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: `<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(async (_req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user