From e9840105a3e575f36a31580d898265c27f4f5186 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Thu, 2 Jul 2026 01:26:49 +0200 Subject: [PATCH] feat: pagina contatti e api email con validazione testata Co-Authored-By: Claude Fable 5 --- 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(+) create mode 100644 src/lib/contact.ts create mode 100644 src/lib/mailer.ts create mode 100644 src/pages/api/contact.ts create mode 100644 src/pages/contact.astro create mode 100644 tests/contact.test.ts 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; + 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 { + 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}`); +--- + + +
+
+
+ +

La mappa viene caricata da Google solo dopo il tuo consenso.

+
+ +
+
+
+

Indirizzo

+

{site.address} {site.cityLine}

+
+
+

Telefono

+

{site.phone}

+
+
+

E-mail

+

{site.email}

+
+
+
+

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.

+
+
+ + + + + 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); + }); +});