- mailer/: standalone Express+nodemailer service on port 3000 with
validation, honeypot (company field), per-IP rate limiting (5/min),
Fastmail SMTP. Returns 400 on bad input, 429 on rate limit, 502 on
SMTP failure, 200 {ok:true} on success or honeypot hit.
- Dockerfile: multi-stage nginx build for humanai-landing container.
- mailer/Dockerfile: node:22-alpine, no exposed host ports.
- deploy.sh: idempotent deploy of both containers to piha.local via
Docker context 'piha', with smoke tests (400 on empty body, 200 on
honeypot fill).
- src/i18n/: minimal pl/en translation files; useT() hook picks
language from navigator.language.
- CTASection: replaced dead onSubmit with fetch POST /api/contact,
added hidden honeypot field, success/error states using i18n strings.
- README: Mailer section with two manual steps (credentials + NPM proxy).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
83 lines
2.2 KiB
JavaScript
83 lines
2.2 KiB
JavaScript
import express from 'express';
|
|
import nodemailer from 'nodemailer';
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// Rate limiter: max 5 requests/min per IP
|
|
const rateLimiter = new Map();
|
|
function checkRateLimit(ip) {
|
|
const now = Date.now();
|
|
const window = 60_000;
|
|
const max = 5;
|
|
const entry = rateLimiter.get(ip) ?? { count: 0, start: now };
|
|
if (now - entry.start > window) {
|
|
entry.count = 0;
|
|
entry.start = now;
|
|
}
|
|
entry.count++;
|
|
rateLimiter.set(ip, entry);
|
|
return entry.count <= max;
|
|
}
|
|
|
|
// Prune rate limiter every 5 minutes
|
|
setInterval(() => {
|
|
const cutoff = Date.now() - 60_000;
|
|
for (const [ip, entry] of rateLimiter) {
|
|
if (entry.start < cutoff) rateLimiter.delete(ip);
|
|
}
|
|
}, 300_000);
|
|
|
|
const transporter = nodemailer.createTransport({
|
|
host: 'smtp.fastmail.com',
|
|
port: 465,
|
|
secure: true,
|
|
auth: {
|
|
user: process.env.SMTP_USER,
|
|
pass: process.env.SMTP_PASS,
|
|
},
|
|
});
|
|
|
|
app.post('/api/contact', async (req, res) => {
|
|
const ip =
|
|
(req.headers['x-forwarded-for'] ?? '').split(',')[0].trim() ||
|
|
req.socket.remoteAddress;
|
|
|
|
if (!checkRateLimit(ip)) {
|
|
return res.status(429).json({ error: 'Too many requests' });
|
|
}
|
|
|
|
const { name, email, message, company } = req.body ?? {};
|
|
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
return res.status(400).json({ error: 'name required' });
|
|
}
|
|
if (!email || typeof email !== 'string' || !email.includes('@')) {
|
|
return res.status(400).json({ error: 'valid email required' });
|
|
}
|
|
if (!message || typeof message !== 'string' || message.trim().length < 5) {
|
|
return res.status(400).json({ error: 'message too short' });
|
|
}
|
|
|
|
// Honeypot
|
|
if (company) {
|
|
return res.json({ ok: true });
|
|
}
|
|
|
|
try {
|
|
await transporter.sendMail({
|
|
from: 'oskar@gethumanai.com',
|
|
to: 'oskar@gethumanai.com',
|
|
replyTo: email,
|
|
subject: `Kontakt od ${name.trim()}`,
|
|
text: `Imię: ${name.trim()}\nEmail: ${email}\n\n${message.trim()}`,
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('SMTP error:', err);
|
|
res.status(502).json({ error: 'mail delivery failed' });
|
|
}
|
|
});
|
|
|
|
app.listen(3000, () => console.log('mailer listening on :3000'));
|