Для перевірки роботи Ваших проектів на VDS пропонуємо Вам пільговий період, оформити замовлення на 3 дні.
Міні-додаток на Express, який:
Працює на shared-хостингу: слухає порт із process.env.PORT, легко налаштовується через панель ISPmanager.
contact-form-app/ |- package.json |- app.js |- .env # для локального запуску (на хостингу змінні панелі) |- public/ | |- style.css |- views/ |- index.html # форма |- thankyou.html # сторінка "Спасибі" |- error.html # сторінка помилки (опціонально)
{ "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 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, '"'); }
${msg}
Back
Name: ${escapeHtml(name)}
Email: ${escapeHtml(email)}
Phone: ${escapeHtml(phone)}
Message:
${escapeHtml(message).replace(/\n/g, '')}
<!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>
<!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<
<!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>
: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; }
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) задайте ці змінні в панелі, а не зберігайте у файлах.