Add contact form mailer microservice and i18n support

- 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>
This commit is contained in:
oskar 2026-06-18 15:12:05 +02:00
parent a8bb798d03
commit 39d0662197
12 changed files with 389 additions and 29 deletions

2
.env.example Normal file
View file

@ -0,0 +1,2 @@
SMTP_USER=
SMTP_PASS=

4
.gitignore vendored
View file

@ -12,6 +12,10 @@ dist
dist-ssr
*.local
# Secrets
.env
mailer.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json

10
Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

45
README.md Normal file
View file

@ -0,0 +1,45 @@
# humanAI — landing page
React + Vite static site, served by nginx in the `humanai-landing` Docker container,
behind Nginx Proxy Manager on the `nginxproxymanager_default` network.
## Development
```bash
npm install
npm run dev
```
## Deploy
```bash
./deploy.sh
```
The script builds images locally, pushes them to piha.local via the `piha` Docker context,
and runs smoke tests against the mailer endpoint.
## Mailer
The `mailer/` directory contains a standalone Express microservice that handles the contact
form. It runs as `humanai-mailer` on the same Docker network as NPM so it can be proxied
without exposing any port to the host.
### Manual steps required before first deploy
**(a) Create a Fastmail app-password and write credentials to `mailer.env` on the deploy machine:**
```
# mailer.env (never commit this file)
SMTP_USER=youruser@fastmail.com
SMTP_PASS=your-app-password
```
**(b) In Nginx Proxy Manager → Proxy Host for `gethumanai.pl` → Custom Locations:**
| Field | Value |
|---|---|
| location | `/api` |
| scheme | `http` |
| forward hostname | `humanai-mailer` |
| port | `3000` |

87
deploy.sh Executable file
View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
CONTEXT="piha"
REMOTE="ssh://oskar@piha.local"
NETWORK="nginxproxymanager_default"
# Create docker context if missing
docker context inspect "$CONTEXT" &>/dev/null || \
docker context create "$CONTEXT" --docker "host=$REMOTE"
D="docker --context $CONTEXT"
# ── Landing ────────────────────────────────────────────────────────────────────
echo "==> Building humanai-landing"
$D build -t humanai-landing .
echo "==> Deploying humanai-landing"
$D rm -f humanai-landing 2>/dev/null || true
$D run -d \
--name humanai-landing \
--network "$NETWORK" \
--restart unless-stopped \
humanai-landing
# ── Mailer ─────────────────────────────────────────────────────────────────────
echo "==> Building humanai-mailer"
$D build -t humanai-mailer ./mailer
echo "==> Deploying humanai-mailer"
$D rm -f humanai-mailer 2>/dev/null || true
$D run -d \
--name humanai-mailer \
--network "$NETWORK" \
--env-file mailer.env \
--restart unless-stopped \
humanai-mailer
# ── Smoke tests ────────────────────────────────────────────────────────────────
echo "==> Waiting for mailer to start..."
sleep 3
echo "==> Smoke test 1: empty body must return 400"
STATUS=$($D exec humanai-mailer node -e "
const http = require('http');
const body = '{}';
const req = http.request({
host: 'localhost', port: 3000, path: '/api/contact', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, res => { console.log(res.statusCode); });
req.on('error', e => { console.error(e.message); process.exit(1); });
req.write(body);
req.end();
")
if [ "$STATUS" != "400" ]; then
echo "FAIL: expected 400, got $STATUS"
exit 1
fi
echo " OK: got 400"
echo "==> Smoke test 2: honeypot fill must return 200 {ok:true} (no email sent)"
RESP=$($D exec humanai-mailer node -e "
const http = require('http');
const body = JSON.stringify({name:'Bot',email:'bot@example.com',message:'hello world',company:'acme'});
const chunks = [];
const req = http.request({
host: 'localhost', port: 3000, path: '/api/contact', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }
}, res => {
res.on('data', d => chunks.push(d));
res.on('end', () => console.log(res.statusCode + ' ' + Buffer.concat(chunks).toString()));
});
req.on('error', e => { console.error(e.message); process.exit(1); });
req.write(body);
req.end();
")
if [[ "$RESP" != 200* ]]; then
echo "FAIL: expected 200, got $RESP"
exit 1
fi
echo " OK: got $RESP"
echo ""
echo "Deploy complete."
echo "Next steps (manual):"
echo " 1. Set SMTP_USER/SMTP_PASS in mailer.env on this machine"
echo " 2. In NPM: Proxy Host gethumanai.pl → Custom Location /api → http://humanai-mailer:3000"

6
mailer/Dockerfile Normal file
View file

@ -0,0 +1,6 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY mailer.mjs .
CMD ["node", "mailer.mjs"]

82
mailer/mailer.mjs Normal file
View file

@ -0,0 +1,82 @@
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'));

10
mailer/package.json Normal file
View file

@ -0,0 +1,10 @@
{
"name": "humanai-mailer",
"version": "1.0.0",
"type": "module",
"main": "mailer.mjs",
"dependencies": {
"express": "^4.19.2",
"nodemailer": "^6.9.14"
}
}

View file

@ -1,8 +1,34 @@
import React from 'react';
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { Mail, MessageSquare } from 'lucide-react';
import { useT } from '../i18n';
type Status = 'idle' | 'sending' | 'success' | 'error';
const CTASection = () => {
const t = useT();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [company, setCompany] = useState(''); // honeypot
const [status, setStatus] = useState<Status>('idle');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('sending');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message, company }),
});
if (!res.ok) throw new Error(`${res.status}`);
setStatus('success');
} catch {
setStatus('error');
}
};
return (
<section id="contact" className="py-24 relative overflow-hidden">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-full h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
@ -29,42 +55,91 @@ const CTASection = () => {
</a>
</div>
<p className="text-sm text-muted italic">
Na start wystarczy jeden proces, jeden problem albo jeden pomysł.
"Na start wystarczy jeden proces, jeden problem albo jeden pomysł."
</p>
</div>
</div>
<form className="space-y-4" onSubmit={(e) => e.preventDefault()}>
{status === 'success' ? (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col justify-center items-center text-center gap-4"
>
<p className="text-2xl font-bold text-primary">{t.contact.successTitle}</p>
<p className="text-muted">{t.contact.successBody}</p>
</motion.div>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
{/* Honeypot — hidden from real users */}
<div
aria-hidden="true"
style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}
>
<label htmlFor="company">Company</label>
<input
id="company"
type="text"
name="company"
tabIndex={-1}
autoComplete="off"
value={company}
onChange={(e) => setCompany(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Imię</label>
<label className="text-sm font-medium">{t.contact.labelName}</label>
<input
type="text"
placeholder="Twoje imię"
placeholder={t.contact.placeholderName}
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<label className="text-sm font-medium">{t.contact.labelEmail}</label>
<input
type="email"
placeholder="email@firma.pl"
placeholder={t.contact.placeholderEmail}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">W czym możemy pomóc?</label>
<label className="text-sm font-medium">{t.contact.labelMessage}</label>
<textarea
rows={4}
placeholder="Opisz krótko swój proces lub wyzwanie..."
placeholder={t.contact.placeholderMessage}
value={message}
onChange={(e) => setMessage(e.target.value)}
required
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors resize-none"
></textarea>
/>
</div>
<button className="btn-primary w-full py-4 text-lg">
Umów rozmowę
{status === 'error' && (
<p className="text-sm text-red-400">
<span className="font-semibold">{t.contact.errorTitle}</span>{' '}
{t.contact.errorBody}
</p>
)}
<button
type="submit"
disabled={status === 'sending'}
className="btn-primary w-full py-4 text-lg disabled:opacity-60"
>
{status === 'sending' ? t.contact.sending : t.contact.submit}
</button>
</form>
)}
</div>
</div>
</div>

16
src/i18n/en.ts Normal file
View file

@ -0,0 +1,16 @@
export const en = {
contact: {
labelName: 'Name',
placeholderName: 'Your name',
labelEmail: 'Email',
placeholderEmail: 'email@company.com',
labelMessage: 'How can we help?',
placeholderMessage: 'Briefly describe your process or challenge...',
submit: 'Schedule a call',
sending: 'Sending...',
successTitle: 'Message sent!',
successBody: "We'll get back to you shortly.",
errorTitle: 'Something went wrong.',
errorBody: 'Please try again or email us directly at hello@gethumanai.com.',
},
};

7
src/i18n/index.ts Normal file
View file

@ -0,0 +1,7 @@
import { pl } from './pl';
import { en } from './en';
export function useT() {
const lang = navigator.language?.startsWith('pl') ? 'pl' : 'en';
return lang === 'pl' ? pl : en;
}

16
src/i18n/pl.ts Normal file
View file

@ -0,0 +1,16 @@
export const pl = {
contact: {
labelName: 'Imię',
placeholderName: 'Twoje imię',
labelEmail: 'Email',
placeholderEmail: 'email@firma.pl',
labelMessage: 'W czym możemy pomóc?',
placeholderMessage: 'Opisz krótko swój proces lub wyzwanie...',
submit: 'Umów rozmowę',
sending: 'Wysyłam...',
successTitle: 'Wiadomość wysłana!',
successBody: 'Odezwiemy się wkrótce.',
errorTitle: 'Coś poszło nie tak.',
errorBody: 'Spróbuj ponownie lub napisz bezpośrednio na hello@gethumanai.com.',
},
};