EN
UAH

Node.js contact form: Express + Nodemailer.

What the example does

Node.js Express A small Express app that:

  • Serves a contact form page
  • Server-side validation of fields (name, email, message)
  • Limits request rate (anti-spam)
  • Sends an email via SMTP (Nodemailer)
  • Returns a “Thank you!” response or an error

Shared-hosting ready: uses process.env.PORT and is simple to set up in ISPmanager.

Project structure

contact-form-app/
|- package.json
|- app.js
|- .env         # for local run (on hosting, set env vars via the panel)
|- public/
| |- style.css
|- views/
|- index.html    # form
|- thankyou.html # "Thank you" page
|- error.html   # error page (optional)

package.json


{
  "name": "contact-form-app",
  "version": "1.0.0",
  "description": "Contact form on Express + Nodemailer for shared hosting (ISPmanager).",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "start:prod": "NODE_ENV=production node app.js"
  },
  "engines": {
    "node": ">=18.x"
  },
  "dependencies": {
    "dotenv": "^16.4.5",
    "express": "^4.19.2",
    "express-rate-limit": "^7.4.0",
    "express-validator": "^7.2.0",
    "helmet": "^7.1.0",
    "morgan": "^1.10.0",
    "nodemailer": "^6.9.14"
  }
}
	

app.js — server

	
// app.js
require('dotenv').config(); // .env for local development; in production the panel sets the environment variables
const express = require('express');
const path = require('path');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { body, validationResult } = require('express-validator');
const nodemailer = require('nodemailer');

const app = express();
const PORT = process.env.PORT || 3000;

// Security, logging, and form data parsing
app.use(helmet());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use(express.urlencoded({ extended: true })); // application/x-www-form-urlencoded forms
app.use(express.json());                         // if sending JSON
app.use(express.static(path.join(__dirname, 'public')));

// Rate limiting for /contact (anti-spam)
const contactLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 5,              // up to 5 requests per minute from one IP
  standardHeaders: true,
  legacyHeaders: false,
});

// Health-check
app.get('/health', (req, res) => res.json({ status: 'ok', uptime: process.uptime() }));

// Home page (form)
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'views', 'index.html'));
});

// Thank you
app.get('/thankyou', (req, res) => {
  res.sendFile(path.join(__dirname, 'views', 'thankyou.html'));
});

// Error (optional)
app.get('/error', (req, res) => {
  res.sendFile(path.join(__dirname, 'views', 'error.html'));
});

// Configure Nodemailer SMTP transport
// Read values from environment variables so credentials aren’t hard-coded.

function createTransport() {
  const {
    SMTP_HOST,
    SMTP_PORT,
    SMTP_SECURE, // "true"/"false"
    SMTP_USER,
    SMTP_PASS
  } = process.env;

  return nodemailer.createTransport({
    host: SMTP_HOST,
    port: Number(SMTP_PORT || 587),
    secure: String(SMTP_SECURE || 'false') === 'true',
    auth: SMTP_USER && SMTP_PASS ? { user: SMTP_USER, pass: SMTP_PASS } : undefined
  });
}

// POST /contact — form submission handler
app.post(
  '/contact',
  contactLimiter,
  // Anti-bot "honeypot": hidden field; real users won’t fill it
  (req, res, next) => {
    if (req.body.website) {
      return res.status(200).redirect('/thankyou'); // silently ignore bots
    }
    next();
  },
  // Validation
  body('name').trim().isLength({ min: 2, max: 100 }).withMessage('Enter your name'),
  body('email').isEmail().normalizeEmail().withMessage('Enter a valid email'),
  body('message').trim().isLength({ min: 5, max: 5000 }).withMessage('Message is too short'),
  async (req, res) => {
    const errors = validationResult(req);
    const { name, email, message, phone = '' } = req.body;

    if (!errors.isEmpty()) {
      // Option 1: redirect to /error
      // return res.status(400).redirect('/error');

      // Option 2: show a simple HTML error
      const msg = errors.array().map(e => e.msg).join(', ');
      return res.status(400).send(`

Form error

${msg}

Back

`); } try { const transporter = createTransport(); const mailFrom = process.env.MAIL_FROM || process.env.SMTP_USER || 'no-reply@example.com'; const mailTo = process.env.MAIL_TO || 'admin@example.com'; await transporter.sendMail({ from: `"Website Contact" <${mailFrom}>`, to: mailTo, subject: `New contact form message from ${name}`, replyTo: email, text: [ `Name: ${name}`, `Email: ${email}`, phone ? `Phone: ${phone}` : '', '', 'Message:', message ].join('\n'), html: `

New contact form submission

Name: ${escapeHtml(name)}

Email: ${escapeHtml(email)}

${phone ? `

Phone: ${escapeHtml(phone)}

` : ''}

Message:

${escapeHtml(message).replace(/\n/g, '
')}

` }); return res.redirect('/thankyou'); } catch (err) { console.error('Mail send error:', err); return res.status(500).redirect('/error'); } } ); // 404 app.use((req, res) => { res.status(404).send('404 Not Found'); }); // 500 app.use((err, req, res, next) => { console.error('Unhandled error:', err); res.status(500).send('500 Internal Server Error'); }); app.listen(PORT, () => { console.log(`Contact form app is running on port ${PORT}`); }); // Simple HTML escaping helper function escapeHtml(str = '') { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }
Node.js
Fast & Reliable Node.js Hosting
Start Your Project with Just a Few Clicks!
Free SSL CertificatPowered by Modern Servers7-Day Free Trial
View Plans

views/index.html — form page


<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Contact</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link href="/style.css" rel="stylesheet" />
</head>
<body>
  <main class="card">
    <h1>Contact us</h1>
    <p>Please fill out the form and we’ll reply soon.</p>

    <form method="post" action="/contact" novalidate>
      <!-- Honeypot (hidden field for bots) -->
      <div class="hidden">
        <label>Website <input type="text" name="website" autocomplete="off"></label>
      </div>

      <label>Name
        <input type="text" name="name" required minlength="2" maxlength="100" placeholder="Your name" />
      </label>

      <label>Email
        <input type="email" name="email" required placeholder="name@example.com" />
      </label>

      <label>Phone (optional)
        <input type="tel" name="phone" placeholder="+380 ..." />
      </label>

      <label>Message
        <textarea name="message" required minlength="5" maxlength="5000" rows="6" placeholder="Briefly describe your issue"></textarea>
      </label>

      <button type="submit">Submit</button>
    </form>
  </main>
</body>
</html>


	
	

views/thankyou.html


<!doctype html<
<html lang="en"<
<head<
  <meta charset="utf-8" /<
  <title<Thank you!</title<
  <meta name="viewport" content="width=device-width, initial-scale=1" /<
  <link href="/style.css" rel="stylesheet" /<
</head<
<body<
  <main class="card"<
    <h1<Спасибо!</h1<
    <p<Your message has been sent. We’ll contact you soon.</p<
    <p<<a href="/"<Back to homepage</a<</p<
  </main<
</body<
</html<
	
	

views/error.html


<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Failed to send</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link href="/style.css" rel="stylesheet" />
</head>
<body>
  <main class="card">
    <h1>Your message could not be sent</h1>
    <p>Please try again later or contact us by phone.</p>
    <p><a href="/">Back to the form</a></p>
  </main>
</body>
</html>	
	

public/style.css — minimal CSS


:root {
  --bg: #0f172a;
  --card: #111827;
  --text: #e2e8f0;
  --muted: #94a3b8;
  --primary: #22c55e;
  --primary-hover: #16a34a;
}

* { box-sizing: border-box; }
html, body {
  margin: 0;
  font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
  background: var(--bg);
  color: var(--text);
}
main.card {
  max-width: 720px;
  margin: 6vh auto;
  background: var(--card);
  border-radius: 16px;
  padding: 24px;
  box-shadow: 0 10px 30px rgba(0,0,0,.25);
}
h1 { margin-top: 0; }
p { color: var(--muted); }

form {
  display: grid;
  gap: 14px;
  margin-top: 16px;
}
label {
  display: grid;
  gap: 6px;
  font-weight: 600;
}
input, textarea {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #1f2937;
  border-radius: 10px;
  background: #0b1220;
  color: var(--text);
}
button {
  padding: 12px 16px;
  border: 0;
  border-radius: 10px;
  background: var(--primary);
  color: #052e16;
  font-weight: 700;
  cursor: pointer;
}
button:hover { background: var(--primary-hover); }

.hidden { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
	
	

.env — example for local development


PORT=3000
NODE_ENV=development

# SMTP (example)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=no-reply@example.com
SMTP_PASS=super-secret-password

# From/To
MAIL_FROM=no-reply@example.com
MAIL_TO=admin@example.com
	
	

Note: In production (ISPmanager), set these env vars in the panel, not in files.

Deploying to ISPmanager

  • Add a domain or website in ISPmanager
  • Upload the project files to the site directory
  • Install the dependencies: npm install
  • Node.js app in the control panel:
    • Node.js version (LTS)
    • Entry file: app.js (or run npm start)
    • Env vars: SMTP_*, MAIL_FROM, MAIL_TO, etc.
    • Domain/subdomain binding
  • Enable HTTPS (Let’s Encrypt)
  • Verify delivery: fill out the form — an email should arrive at MAIL_TO

FAQ (Frequently Asked Questions)

Check the console for “Mail send error” logs. Verify your SMTP_HOST/SMTP_USER/SMTP_PASS; enable TLS (SMTP_SECURE=true, port 465) if required. Configure SPF/DKIM/DMARC.
Yes — set to as an array (e.g., ['a@ex.com','b@ex.com']) or as a comma-separated string.
Yes. Add the widget to the form and verify the token on the server before sending the email. For basic protection, rate limiting and a honeypot are already in place.
Save the data to a file/DB (e.g., SQLite or MySQL) alongside sending the email.
Contact details: Ukraine, 61202, Kharkiv, Ludviga Svobody st. 26-298.
FO-P Kharitinov Oleg Sergeevich
IBAN UA073052990000026001005905889
tax.number 2961615658
PrivatBank
mail:
Documentation:
Support service: телефон + 380 57 7209279
Create a ticket