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:
parent
a8bb798d03
commit
39d0662197
2
.env.example
Normal file
2
.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,6 +12,10 @@ dist
|
|||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
mailer.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
|
|
|||
10
Dockerfile
Normal file
10
Dockerfile
Normal 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
45
README.md
Normal 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
87
deploy.sh
Executable 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
6
mailer/Dockerfile
Normal 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
82
mailer/mailer.mjs
Normal 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
10
mailer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Imię</label>
|
||||
{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"
|
||||
placeholder="Twoje imię"
|
||||
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
||||
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">{t.contact.labelName}</label>
|
||||
<input
|
||||
type="text"
|
||||
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">{t.contact.labelEmail}</label>
|
||||
<input
|
||||
type="email"
|
||||
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">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="email@firma.pl"
|
||||
className="w-full bg-background border border-white/10 rounded-lg px-4 py-3 focus:outline-none focus:border-primary/50 transition-colors"
|
||||
<label className="text-sm font-medium">{t.contact.labelMessage}</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">W czym możemy pomóc?</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
placeholder="Opisz krótko swój proces lub wyzwanie..."
|
||||
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ę
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{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
16
src/i18n/en.ts
Normal 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
7
src/i18n/index.ts
Normal 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
16
src/i18n/pl.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue