Fix 500 form contatti: errori email visibili #18

Merged
javaxman merged 1 commits from fix/contact-form-500 into main 2026-07-29 15:24:28 +02:00
3 changed files with 201 additions and 83 deletions
Showing only changes of commit 0b2a7f94ec - Show all commits

View File

@ -1,7 +1,9 @@
/** Call-to-action (blocco contatti / chiusura pagina) */ /** Call-to-action (blocco contatti / chiusura pagina) */
const env = import.meta.env; const env = import.meta.env;
const contactActionUrl = (env.PUBLIC_CONTACT_FORM_ACTION_URL ?? '/api/contact').trim(); const contactActionUrl = (env.PUBLIC_CONTACT_FORM_ACTION_URL ?? '/api/contact').trim();
const contactSuccessRedirect = (env.PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT ?? '').trim(); const contactSuccessRedirect = (
env.PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT ?? '/#contatti'
).trim();
const contactMailto = (env.PUBLIC_CONTACT_FORM_MAILTO ?? '').trim(); const contactMailto = (env.PUBLIC_CONTACT_FORM_MAILTO ?? '').trim();
const contactTurnstileSiteKey = (env.PUBLIC_CONTACT_TURNSTILE_SITE_KEY ?? '').trim(); const contactTurnstileSiteKey = (env.PUBLIC_CONTACT_TURNSTILE_SITE_KEY ?? '').trim();

View File

@ -33,6 +33,59 @@ function required(value: FormDataEntryValue | null): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
} }
function wantsJson(request: Request): boolean {
return (request.headers.get('Accept') || '').includes('application/json');
}
function fail(request: Request, message: string, status: number): Response {
if (wantsJson(request)) {
return Response.json({ ok: false, error: message }, { status });
}
return new Response(message, { status });
}
function succeed(request: Request, redirectTarget: string): Response {
if (wantsJson(request)) {
return Response.json({ ok: true, redirect: redirectTarget });
}
return Response.redirect(redirectTarget, 303);
}
function emailErrorMessage(error: unknown): string {
const code =
error && typeof error === 'object' && 'code' in error
? String((error as { code?: unknown }).code || '')
: '';
const message =
error instanceof Error
? error.message
: error && typeof error === 'object' && 'message' in error
? String((error as { message?: unknown }).message || '')
: '';
switch (code) {
case 'E_SENDER_NOT_VERIFIED':
case 'E_SENDER_DOMAIN_NOT_AVAILABLE':
return 'Dominio mittente non verificato su Cloudflare Email Service.';
case 'E_RECIPIENT_NOT_ALLOWED':
return 'Destinatario non autorizzato dal binding email.';
case 'E_RECIPIENT_SUPPRESSED':
return 'Destinatario in lista di soppressione (bounce/spam).';
case 'E_RATE_LIMIT_EXCEEDED':
case 'E_DAILY_LIMIT_EXCEEDED':
return 'Limite di invio email raggiunto. Riprova più tardi.';
case 'E_VALIDATION_ERROR':
case 'E_FIELD_MISSING':
return message
? `Dati email non validi: ${message}`
: 'Dati email non validi.';
default:
return code
? `Invio email non riuscito (${code}). Riprova più tardi.`
: 'Invio email non riuscito. Riprova più tardi.';
}
}
async function verifyTurnstile(token: string, secret: string, ip?: string | null): Promise<boolean> { async function verifyTurnstile(token: string, secret: string, ip?: string | null): Promise<boolean> {
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set('secret', secret); body.set('secret', secret);
@ -59,6 +112,7 @@ function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: st
} }
export const POST: APIRoute = async ({ request, url }) => { export const POST: APIRoute = async ({ request, url }) => {
try {
const runtimeEnv = getRuntimeEnv(); const runtimeEnv = getRuntimeEnv();
const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev'; const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev';
const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv); const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
@ -66,11 +120,19 @@ export const POST: APIRoute = async ({ request, url }) => {
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv); const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
const form = await request.formData(); const form = await request.formData();
if (required(form.get('_gotcha'))) {
const redirectTarget =
sanitizeRedirect(required(form.get('_next')), url) ||
sanitizeRedirect(request.headers.get('referer') ?? '/', url) ||
new URL('/#contatti', url).toString();
return succeed(request, redirectTarget);
}
const redirectTarget = const redirectTarget =
sanitizeRedirect(required(form.get('_next')), url) || sanitizeRedirect(required(form.get('_next')), url) ||
sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) || sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) ||
sanitizeRedirect(request.headers.get('referer') ?? '/', url) || sanitizeRedirect(request.headers.get('referer') ?? '/', url) ||
'/'; new URL('/#contatti', url).toString();
const name = required(form.get('name')); const name = required(form.get('name'));
const company = required(form.get('company')); const company = required(form.get('company'));
@ -80,7 +142,7 @@ export const POST: APIRoute = async ({ request, url }) => {
const message = required(form.get('message')); const message = required(form.get('message'));
if (!name || !email || !country || !message) { if (!name || !email || !country || !message) {
return new Response('Missing required contact fields.', { status: 400 }); return fail(request, 'Missing required contact fields.', 400);
} }
const turnstileToken = required(form.get('cf-turnstile-response')); const turnstileToken = required(form.get('cf-turnstile-response'));
@ -91,7 +153,7 @@ export const POST: APIRoute = async ({ request, url }) => {
request.headers.get('CF-Connecting-IP'), request.headers.get('CF-Connecting-IP'),
); );
if (!isHuman) { if (!isHuman) {
return new Response('Turnstile verification failed.', { status: 400 }); return fail(request, 'Turnstile verification failed.', 400);
} }
} }
@ -111,14 +173,18 @@ export const POST: APIRoute = async ({ request, url }) => {
if (mode === 'live') { if (mode === 'live') {
const emailBinding = getEmailBinding(runtimeEnv); const emailBinding = getEmailBinding(runtimeEnv);
if (!emailBinding || !fromEmail || !toEmail) { if (!emailBinding || !fromEmail || !toEmail) {
return new Response( return fail(
request,
'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.', 'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.',
{ 500,
status: 500,
},
); );
} }
const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department); const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department);
if (!destination) {
return fail(request, 'Nessun destinatario configurato per il reparto selezionato.', 500);
}
try {
await sendCloudflareEmail(emailBinding, { await sendCloudflareEmail(emailBinding, {
fromEmail, fromEmail,
toEmail: destination, toEmail: destination,
@ -126,6 +192,21 @@ export const POST: APIRoute = async ({ request, url }) => {
text, text,
replyTo: email, replyTo: email,
}); });
} catch (error) {
console.error('[contact] email failed', error);
// Retry without replyTo: some payloads reject Reply-To even when From/To are valid.
try {
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: destination,
subject,
text,
});
} catch (retryError) {
console.error('[contact] email retry failed', retryError);
return fail(request, emailErrorMessage(retryError), 502);
}
}
} else { } else {
console.info('[contact:dev] Contact payload received', { console.info('[contact:dev] Contact payload received', {
name, name,
@ -137,5 +218,9 @@ export const POST: APIRoute = async ({ request, url }) => {
}); });
} }
return Response.redirect(redirectTarget, 303); return succeed(request, redirectTarget);
} catch (error) {
console.error('[contact] unexpected error', error);
return fail(request, 'Errore interno. Riprova più tardi.', 500);
}
}; };

View File

@ -1,4 +1,4 @@
/** Modulo contatti in pagina (#contatti): mailto o POST Formspree. */ /** Modulo contatti in pagina (#contatti): mailto o POST /api/contact. */
interface ContactFormCfg { interface ContactFormCfg {
actionUrl: string; actionUrl: string;
mailto: string; mailto: string;
@ -52,11 +52,18 @@ function readCfg(): ContactFormCfg {
return JSON.parse(raw) as ContactFormCfg; return JSON.parse(raw) as ContactFormCfg;
} }
function showError(message: string): void {
const err = document.getElementById('nx-contact-form-error');
if (!err) return;
err.textContent = message;
err.classList.remove('hidden');
}
function bind(): void { function bind(): void {
const form = document.getElementById('nx-contact-form') as HTMLFormElement | null; const form = document.getElementById('nx-contact-form') as HTMLFormElement | null;
if (!form) return; if (!form) return;
form.addEventListener('submit', (e) => { form.addEventListener('submit', async (e) => {
const cfg = readCfg(); const cfg = readCfg();
const err = document.getElementById('nx-contact-form-error'); const err = document.getElementById('nx-contact-form-error');
if (err) err.classList.add('hidden'); if (err) err.classList.add('hidden');
@ -69,17 +76,44 @@ function bind(): void {
)?.value; )?.value;
if (!token) { if (!token) {
e.preventDefault(); e.preventDefault();
if (err) { showError(cfg.turnstileError);
err.textContent = cfg.turnstileError;
err.classList.remove('hidden');
}
return; return;
} }
} }
if (cfg.actionUrl) { if (cfg.actionUrl) {
e.preventDefault();
const submitBtn = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (submitBtn) submitBtn.disabled = true;
try {
const res = await fetch(cfg.actionUrl, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
const data = (await res.json().catch(() => null)) as
| { ok?: boolean; error?: string; redirect?: string }
| null;
if (!res.ok) {
showError(
(data && typeof data.error === 'string' && data.error) ||
'Invio non riuscito. Riprova tra poco.',
);
return; return;
} }
const target =
(data && typeof data.redirect === 'string' && data.redirect) ||
cfg.successRedirect ||
`${window.location.pathname}${window.location.search}#contatti`;
window.location.assign(target);
} catch {
showError('Errore di rete. Controlla la connessione e riprova.');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
return;
}
e.preventDefault(); e.preventDefault();
const fd = new FormData(form); const fd = new FormData(form);
const name = String(fd.get(cfg.fieldName) ?? '').trim(); const name = String(fd.get(cfg.fieldName) ?? '').trim();
@ -101,10 +135,7 @@ function bind(): void {
window.location.href = `mailto:${cfg.mailto}?subject=${subject}&body=${body}`; window.location.href = `mailto:${cfg.mailto}?subject=${subject}&body=${body}`;
return; return;
} }
if (err) { showError(cfg.configError);
err.textContent = cfg.configError;
err.classList.remove('hidden');
}
}); });
} }