Switch contact form to self-hosted SMTP via nodemailer, remove formsubmit.co

This commit is contained in:
nmemmert
2026-04-09 14:38:28 -04:00
parent e93840c25f
commit 4b01d01133
6 changed files with 194 additions and 20 deletions
+81
View File
@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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) => {