Files
InsanityLab-web/.superpowers/sdd/review-be4d933..e984010.diff
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

215 lines
8.2 KiB
Diff

# Review package: be4d933..HEAD
## Commits
e984010 feat: pagina contatti e api email con validazione testata
## Files changed
src/lib/contact.ts | 31 +++++++++++++++++++++++++++
src/lib/mailer.ts | 25 ++++++++++++++++++++++
src/pages/api/contact.ts | 26 +++++++++++++++++++++++
src/pages/contact.astro | 55 ++++++++++++++++++++++++++++++++++++++++++++++++
tests/contact.test.ts | 33 +++++++++++++++++++++++++++++
5 files changed, 170 insertions(+)
## Diff
diff --git a/src/lib/contact.ts b/src/lib/contact.ts
new file mode 100644
index 0000000..96f9a32
--- /dev/null
+++ b/src/lib/contact.ts
@@ -0,0 +1,31 @@
+export interface ContactData {
+ firstName: string; lastName: string; phone: string;
+ email: string; message: string; website: string;
+}
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+function str(v: unknown, max: number): string {
+ return typeof v === 'string' ? v.trim().slice(0, max + 1) : '';
+}
+
+export function validateContact(data: unknown):
+ | { ok: true; value: ContactData }
+ | { ok: false; error: string } {
+ if (typeof data !== 'object' || data === null) return { ok: false, error: 'Dati non validi.' };
+ const d = data as Record<string, unknown>;
+ const value: ContactData = {
+ firstName: str(d.firstName, 100),
+ lastName: str(d.lastName, 100),
+ phone: str(d.phone, 40),
+ email: str(d.email, 200),
+ message: str(d.message, 5000),
+ website: str(d.website, 200),
+ };
+ if (value.website) return { ok: false, error: 'Richiesta non valida.' };
+ if (!value.firstName) return { ok: false, error: 'Il nome è obbligatorio.' };
+ if (!EMAIL_RE.test(value.email)) return { ok: false, error: 'Inserisci una e-mail valida.' };
+ if (!value.message) return { ok: false, error: 'Il messaggio è obbligatorio.' };
+ if (value.message.length > 5000) return { ok: false, error: 'Messaggio troppo lungo.' };
+ return { ok: true, value };
+}
diff --git a/src/lib/mailer.ts b/src/lib/mailer.ts
new file mode 100644
index 0000000..a2b8b99
--- /dev/null
+++ b/src/lib/mailer.ts
@@ -0,0 +1,25 @@
+import nodemailer from 'nodemailer';
+import { getEnv } from './env';
+import type { ContactData } from './contact';
+
+export async function sendContactEmail(data: ContactData): Promise<void> {
+ const transporter = nodemailer.createTransport({
+ host: getEnv('SMTP_HOST'),
+ port: Number(getEnv('SMTP_PORT', '587')),
+ secure: Number(getEnv('SMTP_PORT', '587')) === 465,
+ auth: { user: getEnv('SMTP_USER'), pass: getEnv('SMTP_PASS') },
+ });
+ await transporter.sendMail({
+ from: getEnv('CONTACT_FROM'),
+ to: getEnv('CONTACT_TO', 'info@insanitylab.it'),
+ replyTo: data.email,
+ subject: `Nuovo contatto dal sito: ${data.firstName} ${data.lastName}`.trim(),
+ text: [
+ `Nome: ${data.firstName} ${data.lastName}`.trim(),
+ data.phone && `Telefono: ${data.phone}`,
+ `E-mail: ${data.email}`,
+ '',
+ data.message,
+ ].filter(Boolean).join('\n'),
+ });
+}
diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts
new file mode 100644
index 0000000..74aba2f
--- /dev/null
+++ b/src/pages/api/contact.ts
@@ -0,0 +1,26 @@
+import type { APIRoute } from 'astro';
+import { validateContact } from '../../lib/contact';
+import { sendContactEmail } from '../../lib/mailer';
+import { rateLimit } from '../../lib/rate-limit';
+
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request, clientAddress }) => {
+ if (!rateLimit(`contact:${clientAddress}`, 5, 60 * 60 * 1000)) {
+ return json(429, { error: 'Troppe richieste, riprova più tardi.' });
+ }
+ let data: unknown;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ const result = validateContact(data);
+ if (!result.ok) return json(400, { error: result.error });
+ try {
+ await sendContactEmail(result.value);
+ } catch (err) {
+ console.error('Invio email fallito:', err);
+ return json(502, { error: 'Invio non riuscito, riprova più tardi o scrivici direttamente.' });
+ }
+ return json(200, { ok: true });
+};
diff --git a/src/pages/contact.astro b/src/pages/contact.astro
new file mode 100644
index 0000000..f3e35dc
--- /dev/null
+++ b/src/pages/contact.astro
@@ -0,0 +1,55 @@
+---
+import Base from '../layouts/Base.astro';
+import PageHero from '../components/PageHero.astro';
+import ContactForm from '../components/ContactForm.astro';
+import { site } from '../data/site';
+export const prerender = true;
+const mapsQuery = encodeURIComponent(`${site.address}, ${site.cityLine}`);
+---
+<Base title="Contatti" description="Contatta InsanityLab: Via Leandro Alberti 76, Bologna. Compila il form o chiamaci per una consulenza iniziale.">
+ <PageHero title="Contattaci" />
+ <section class="section">
+ <div class="container cgrid">
+ <div class="cmap" id="map-box">
+ <button class="btn btn--dark" id="map-load">Carica la mappa (Google Maps)</button>
+ <p class="cmap__note">La mappa viene caricata da Google solo dopo il tuo consenso.</p>
+ </div>
+ <ContactForm variant="full" />
+ </div>
+ <div class="container cinfo">
+ <div>
+ <h3>Indirizzo</h3>
+ <p>{site.address} {site.cityLine}</p>
+ </div>
+ <div>
+ <h3>Telefono</h3>
+ <p>{site.phone}</p>
+ </div>
+ <div>
+ <h3>E-mail</h3>
+ <p><a href={`mailto:${site.email}`}>{site.email}</a></p>
+ </div>
+ </div>
+ <div class="container">
+ <p class="cinvite">Per informazioni o per richiedere una consulenza iniziale, compila il form. Un team specializzato ti accoglierà e ti ricontatterà al più presto, oppure lasciaci il tuo contatto.</p>
+ </div>
+ </section>
+</Base>
+
+<style>
+ .cgrid { display: grid; grid-template-columns: 1.2fr 1fr; gap: 50px; margin-bottom: 50px; }
+ .cmap { background: var(--c-bg-alt); min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; }
+ .cmap iframe { width: 100%; height: 100%; border: 0; min-height: 420px; }
+ .cmap__note { font-size: .8rem; color: var(--c-text-light); }
+ .cinfo { display: grid; grid-template-columns: repeat(3, 1fr); gap: 30px; margin-bottom: 30px; }
+ .cinfo h3 { font-size: .85rem; text-transform: uppercase; letter-spacing: .16em; }
+ .cinvite { max-width: 640px; }
+ @media (max-width: 991px) { .cgrid, .cinfo { grid-template-columns: 1fr; } }
+</style>
+
+<script define:vars={{ mapsQuery }}>
+ document.getElementById('map-load')?.addEventListener('click', () => {
+ const box = document.getElementById('map-box');
+ box.innerHTML = `<iframe src="https://www.google.com/maps?q=${mapsQuery}&output=embed" loading="lazy" title="Mappa InsanityLab" referrerpolicy="no-referrer-when-downgrade"></iframe>`;
+ });
+</script>
diff --git a/tests/contact.test.ts b/tests/contact.test.ts
new file mode 100644
index 0000000..9fdbf67
--- /dev/null
+++ b/tests/contact.test.ts
@@ -0,0 +1,33 @@
+import { describe, it, expect } from 'vitest';
+import { validateContact } from '../src/lib/contact';
+
+describe('validateContact', () => {
+ const good = { firstName: 'Mario', email: 'mario@example.com', message: 'Ciao, vorrei informazioni.' };
+
+ it('accetta payload valido e normalizza i campi opzionali', () => {
+ const r = validateContact(good);
+ expect(r.ok).toBe(true);
+ if (r.ok) {
+ expect(r.value.lastName).toBe('');
+ expect(r.value.email).toBe('mario@example.com');
+ }
+ });
+
+ it('rifiuta email non valida', () => {
+ expect(validateContact({ ...good, email: 'non-email' }).ok).toBe(false);
+ });
+
+ it('rifiuta campi obbligatori mancanti o vuoti', () => {
+ expect(validateContact({ ...good, firstName: ' ' }).ok).toBe(false);
+ expect(validateContact({ ...good, message: '' }).ok).toBe(false);
+ expect(validateContact(null).ok).toBe(false);
+ });
+
+ it('rifiuta honeypot compilato', () => {
+ expect(validateContact({ ...good, website: 'spam.com' }).ok).toBe(false);
+ });
+
+ it('rifiuta messaggi oltre 5000 caratteri', () => {
+ expect(validateContact({ ...good, message: 'x'.repeat(5001) }).ok).toBe(false);
+ });
+});