A contact form in Node.js that actually sends email: Express 5, express-validator, Mustache and Nodemailer
A contact form looks like the simplest thing a server can do: show a form, accept a POST, send an email. The details are where it goes wrong: what the visitor typed disappears on a validation error, a refresh sends the message twice, bots fill it with spam, or a down mail server loses the message entirely.
This version uses Express 5.2.1, express-validator 7.3.2, Mustache 4.2.0 and Nodemailer 10.0.13 on node:24-slim. It was tested end to end with fetch and with Chromium (via the Playwright Docker image) against Mailpit 1.31.3, a local SMTP server that catches outgoing mail and exposes it through an API.
Setup
npm init -y
npm pkg set type=module
npm install express express-validator mustache nodemailerThe original version of this post used mustache-express, which hasn't been released since 2022. You don't need it: an Express view engine is just a function that takes a file path and data and calls back with HTML, so a few lines with the mustache package do the same job.
The server
import fs from 'node:fs/promises';
import express from 'express';
import Mustache from 'mustache';
import nodemailer from 'nodemailer';
import { body, validationResult, matchedData } from 'express-validator';
const app = express();
// Mustache as an Express view engine: render views/<name>.mustache
app.engine('mustache', async (file, data, callback) => {
try {
callback(null, Mustache.render(await fs.readFile(file, 'utf8'), data));
} catch (err) {
callback(err);
}
});
app.set('view engine', 'mustache');
app.set('views', './views');
app.use(express.urlencoded({ extended: false, limit: '20kb' }));
const mailer = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT ?? 587),
auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } : undefined,
});
const rules = [
body('name').trim().isLength({ min: 2, max: 100 }).withMessage('Please enter your name'),
body('email').trim().isEmail().withMessage('Please enter a valid email address'),
body('message').trim().isLength({ min: 10, max: 5000 }).withMessage('Your message should be 10 to 5000 characters'),
];
app.get('/', (req, res) => res.render('contact'));
app.post('/', rules, async (req, res) => {
// Honeypot: people never see this field, many bots fill in every field
if (req.body.website) return res.redirect(303, '/thanks');
const result = validationResult(req);
if (!result.isEmpty()) {
return res.status(400).render('contact', {
errors: result.array({ onlyFirstError: true }),
// show what they typed again (Mustache escapes it)
name: req.body.name,
email: req.body.email,
message: req.body.message,
});
}
const { name, email, message } = matchedData(req);
try {
await mailer.sendMail({
from: '"Website contact form" <[email protected]>', // your own domain
to: '[email protected]',
replyTo: { name, address: email }, // "Reply" goes to the visitor
subject: `Contact form: ${name}`,
text: message,
});
} catch (err) {
console.error('Sending failed:', err.message);
return res.status(503).render('contact', {
errors: [{ msg: 'Sorry, your message could not be sent. Please try again in a minute.' }],
name, email, message,
});
}
res.redirect(303, '/thanks'); // POST/Redirect/GET: a refresh won't resend the form
});
app.get('/thanks', (req, res) => res.render('thanks'));
app.listen(3130, () => console.log('Contact form on http://localhost:3130'));- Validation runs on the server, always. Browser checks help, but they aren't enough: Chromium's
type="email"acceptedada@example(no top-level domain), and onlyisEmail()rejected it. Bots don't run your HTML checks at all. matchedData()returns only the validated, trimmed fields, so nothing else fromreq.bodymakes it into the email.- From vs. Reply-To: the email is from your own address, which passes your domain's SPF/DKIM checks, and the visitor goes in
Reply-To, so pressing Reply in your mail client answers them. Putting the visitor's address inFromis a common reason contact form mail lands in spam or gets rejected. - 303 redirect after a successful POST (POST/Redirect/GET): the browser ends up on
GET /thanks, so reloading the page doesn't send the form again. limit: '20kb'on the body parser rejects oversized posts before any of your code runs.
Configure SMTP with environment variables, for example SMTP_HOST=smtp.example.com SMTP_PORT=587 SMTP_USER=… SMTP_PASS=… node server.mjs. Most transactional email providers (Postmark, SES, Mailgun and others) give you SMTP credentials that work here unchanged.
The template
views/contact.mustache:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contact us</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 3rem auto; padding: 0 1rem; }
label { display: block; margin-top: 1rem; font-weight: 600; }
input, textarea { width: 100%; box-sizing: border-box; padding: 0.5rem; font: inherit; }
.errors { background: #fee2e2; color: #991b1b; padding: 0.75rem 1rem 0.75rem 2rem; border-radius: 6px; }
.hp { position: absolute; left: -10000px; }
button { margin-top: 1rem; padding: 0.5rem 1.25rem; font: inherit; }
</style>
</head>
<body>
<h1>Contact us</h1>
{{#errors.length}}
<ul class="errors" role="alert">
{{#errors}}<li>{{msg}}</li>{{/errors}}
</ul>
{{/errors.length}}
<form method="post" action="/">
<label for="name">Name</label>
<input id="name" name="name" value="{{name}}" autocomplete="name" required>
<label for="email">Email</label>
<input id="email" name="email" type="email" value="{{email}}" autocomplete="email" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="6" required>{{message}}</textarea>
<div class="hp" aria-hidden="true">
<label for="website">Leave this empty</label>
<input id="website" name="website" tabindex="-1" autocomplete="off">
</div>
<button>Send</button>
</form>
</body>
</html>views/thanks.mustache is a plain HTML page that says "Thank you!".
Two bugs from the original template are fixed here:
- The message was lost on every validation error. The original wrote
<textarea value="{{ message }}"></textarea>, but<textarea>has novalueattribute: its content goes between the tags. Chromium showed the textarea empty (""), so a visitor who mistyped their email lost their whole message. - The message label pointed at the name field (
for="name"), so clicking "Message" focused the wrong input, and screen readers couldn't connect the label to the textarea.
{{#errors.length}} renders the error box only when there are errors, and role="alert" makes screen readers announce it after the page reloads. The honeypot website field is moved off-screen, hidden from assistive technology with aria-hidden, and removed from the tab order. People never fill it in, but many bots fill in every field they find.
Submitting with an invalid email in Chromium:

after submit: http://contact:3130/ | errors: [ 'Please enter a valid email address' ] | message kept: "Hello! I would like to know more about your workshops."
fixed + submit: /thanks | Thank you!
reload thanks page: /thanks (no resubmit prompt: it was a GET)Testing the edge cases
A script posted a series of forms to the server and then read what Mailpit had received:
GET / 200
empty form → 400 [
'Please enter your name',
'Please enter a valid email address',
'Your message should be 10 to 5000 characters'
]
bad email → 400 [ 'Please enter a valid email address' ]
name kept: true | textarea: Hi </textarea><script>alert(1)</script> & thanks
<script> in page: false
valid → 303 /thanks
CRLF in name → 303 /thanks
honeypot filled → 303 /thanks
30 kB body → 413The message Hi </textarea><script>alert(1)</script> & thanks tried to close the textarea and inject a script. Mustache's {{…}} escaped it, so it went back into the textarea as text, and the page contained no <script>. Use triple braces {{{…}}} (no escaping) only for HTML you produced yourself. The honeypot submission got the same 303 a real visitor gets, so the bot can't tell it was dropped, but no email was sent. Mailpit received exactly two messages:
{
From: 'Website contact form <[email protected]>',
'Reply-To': 'Ada Lovelace <[email protected]>',
To: '[email protected]',
Subject: 'Contact form: Ada Lovelace',
Bcc: 0,
recipients: [ '[email protected]' ],
text: 'Hello! I would like to know more.'
}
{
From: 'Website contact form <[email protected]>',
'Reply-To': '=?UTF-8?Q?Eve=0D=0ABcc=3A_victim=40example=2Enet?= <[email protected]>',
To: '[email protected]',
Subject: 'Contact form: Eve Bcc: [email protected]',
Bcc: 0,
recipients: [ '[email protected]' ],
text: 'Header injection attempt here.'
}The second message is an email header injection attempt: the name was "Eve\r\nBcc: [email protected]", hoping the line break would start a new Bcc header and turn the form into a spam relay. Nodemailer neutralised it: in the subject the line break became a space, and in Reply-To the whole name was encoded as a single display name. The only recipient was still [email protected]. If you ever build email headers by hand, this is exactly what you'd have to get right yourself.
When the mail server is down
With Mailpit stopped, sendMail() rejected. Without the try/catch, Express 5 would turn that into a 500 error page, and the visitor's message would be gone. With it:
503 [ 'Sorry, your message could not be sent. Please try again in a minute.' ] | message kept: true
Sending failed: getaddrinfo ENOTFOUND mailpitThe form comes back with everything still filled in, and the failure is logged. For important forms, also save each submission (to a database or a queue) before sending, so that nothing depends on the mail server being up at that moment.
What else to add before going live
- Rate limiting per IP, so one client can't send hundreds of messages. If the app runs behind a proxy, read the client IP correctly first, see Blocking users by IP in Node.js.
- A CAPTCHA or challenge such as Cloudflare Turnstile or hCaptcha if the honeypot isn't enough. Many bots are smarter than "fill every field".
- Styling: the template keeps its CSS inline so it's self-contained. To use Tailwind instead, see How to set up Tailwind CSS v4. The original version of this post generated a full, multi-megabyte Tailwind v2 build, which v4 no longer needs.
Sources & further reading
- express-validator docs (validation chains,
validationResult(),matchedData()) - Express: using template engines and developing template engines
- mustache.js (escaping with
{{}}vs{{{}}}) - Nodemailer: SMTP transport and message options (
replyTo) - Mailpit (a local SMTP server for testing email)
- MDN: <textarea>
- MDN: 303 See Other (POST/Redirect/GET)
- OWASP: cross-site scripting