RU
UAH

Контактная форма на Node.js: Express + Nodemailer

Что делает пример

Node.js Express Мини-приложение на Express, которое:

  • Показывает страницу с формой обратной связи
  • Валидирует поля на сервере (имя, email, сообщение)
  • Ограничивает частоту запросов (anti-spam)
  • Отправляет письмо через SMTP (Nodemailer)
  • Возвращает пользователю «Спасибо!» или ошибку

Работает на shared-хостинге: слушает порт из process.env.PORT, легко настраивается через панель ISPmanager.

Структура проекта

contact-form-app/
|- package.json
|- app.js
|- .env         # для локального запуска (на хостинге переменные в панели)
|- public/
| |- style.css
|- views/
|- index.html    # форма
|- thankyou.html # страница "Спасибо"
|- error.html   # страница ошибки (опционально)

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 — сервер

	
// app.js
require('dotenv').config(); // .env для локалки; в проде переменные задаст панель
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;

// Безопасность, логи, парсинг форм
app.use(helmet());
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
app.use(express.urlencoded({ extended: true })); // формы application/x-www-form-urlencoded
app.use(express.json());                         // если будете слать JSON
app.use(express.static(path.join(__dirname, 'public')));

// Лимит запросов на /contact (anti-spam)
const contactLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 минута
  max: 5,              // не более 5 запросов/минуту с одного IP
  standardHeaders: true,
  legacyHeaders: false,
});

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

// Главная (форма)
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'views', 'index.html'));
});

// Спасибо
app.get('/thankyou', (req, res) => {
  res.sendFile(path.join(__dirname, 'views', 'thankyou.html'));
});

// Ошибка (опционально)
app.get('/error', (req, res) => {
  res.sendFile(path.join(__dirname, 'views', 'error.html'));
});

// Настраиваем Nodemailer SMTP-транспорт
// Значения возьмём из окружения, чтобы не хранить пароли в коде.
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 — обработчик формы
app.post(
  '/contact',
  contactLimiter,
  // антибот "honeypot" — скрытое поле, реальный пользователь его не заполнит
  (req, res, next) => {
    if (req.body.website) {
      return res.status(200).redirect('/thankyou'); // тихо игнорируем ботов
    }
    next();
  },
  // Валидация
  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()) {
      // Вариант 1: редирект на /error
      // return res.status(400).redirect('/error');

      // Вариант 2: показать простую HTML-ошибку
      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}`); }); // Простой экранировщик для html function escapeHtml(str = '') { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); }
Node.js
Node.js Хостинг
Запусти проект за пару кликов!
Бесплатный SSLСовременные серверы7 дней теста бесплатно
Перейти к тарифам

views/index.html — форма


<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8" />
  <title>Связаться с нами</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>Заполните форму, и мы ответим в ближайшее время.</p>

    <form method="post" action="/contact" novalidate>
      <!-- Honeypot (скрытое поле для ботов) -->
      <div class="hidden">
        <label>Website <input type="text" name="website" autocomplete="off"></label>
      </div>

      <label>Имя
        <input type="text" name="name" required minlength="2" maxlength="100" placeholder="Ваше имя" />
      </label>

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

      <label>Телефон (необязательно)
        <input type="tel" name="phone" placeholder="+380 ..." />
      </label>

      <label>Сообщение
        <textarea name="message" required minlength="5" maxlength="5000" rows="6" placeholder="Коротко опишите вопрос"></textarea>
      </label>

      <button type="submit">Отправить</button>
    </form>
  </main>
</body>
</html>


	
	

views/thankyou.html


<!doctype html<
<html lang="ru"<
<head<
  <meta charset="utf-8" /<
  <title<Спасибо!</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<Ваше сообщение успешно отправлено. Мы свяжемся с вами в ближайшее время.</p<
    <p<<a href="/"<Вернуться на главную</a<</p<
  </main<
</body<
</html<
	
	

views/error.html


<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8" />
  <title>Ошибка отправки</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>Пожалуйста, попробуйте ещё раз позже или свяжитесь с нами по телефону.</p>
    <p><a href="/">Вернуться к форме</a></p>
  </main>
</body>
</html>	
	

public/style.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 - пример для локального запуска


PORT=3000
NODE_ENV=development

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

# От кого и кому отправлять
MAIL_FROM=no-reply@example.com
MAIL_TO=admin@example.com
	
	

Важно: на продакшене (ISPmanager) задайте эти переменные в панели, а не храните в файлах.

Деплой в ISPmanager

  • Создайте домен/сайт в ISPmanager
  • Загрузите файлы проекта в каталог сайта
  • Установите зависимости: npm install
  • Node.js-приложение в панели:
    • Версия Node.js (LTS)
    • Стартовый файл app.js (или команда npm start)
    • Переменные окружения: SMTP_*, MAIL_FROM, MAIL_TO и т. д.,
    • Привязка к домену/поддомену
  • Включите HTTPS (Let’s Encrypt)
  • Проверьте отправку: заполните форму > должно прийти письмо на MAIL_TO

Частые вопросы

Проверьте лог Mail send error в консоли. Убедитесь в правильности SMTP_HOST/USER/PASS, включите TLS (SMTP_SECURE=true, порт 465) если требуется. Настройте SPF/DKIM/DMARC.
Да, укажите массив to: ['a@ex.com','b@ex.com'] или строку с запятыми.
Да. Добавьте виджет на форму и проверяйте токен на сервере перед отправкой письма. Для минимальной защиты уже есть rate-limit + honeypot.
Параллельно с отправкой письма сохраняйте данные в файл/БД (например, SQLite или MySQL на вашем аккаунте).
Реквизиты: Украина, 61202, Харьков, пр. Людвига Свободы 26/298.
ФО-П Харитинов Олег Сергеевич
IBAN: UA073052990000026001005905889
МФО 305299
ИНН 2961615658
ПАТ КБ "ПриватБанк"
mail:
Документы:
Служба поддержки: телефон + 380 57 7209279
создать тикет

Выберите язык