Gestisce errori email del form contatti senza 500 opaco. (#18)

Invia via fetch/JSON, cattura fallimenti Cloudflare Email (con retry senza Reply-To) e reindirizza a #contatti.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Javaxman
2026-07-29 15:24:28 +02:00
committed by GitHub
parent 2c3df8371d
commit 4b70c940db
3 changed files with 201 additions and 83 deletions

View File

@ -33,6 +33,59 @@ function required(value: FormDataEntryValue | null): string {
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> {
const body = new URLSearchParams();
body.set('secret', secret);
@ -59,83 +112,115 @@ function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: st
}
export const POST: APIRoute = async ({ request, url }) => {
const runtimeEnv = getRuntimeEnv();
const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev';
const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
try {
const runtimeEnv = getRuntimeEnv();
const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev';
const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
const form = await request.formData();
const redirectTarget =
sanitizeRedirect(required(form.get('_next')), url) ||
sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) ||
sanitizeRedirect(request.headers.get('referer') ?? '/', url) ||
'/';
const name = required(form.get('name'));
const company = required(form.get('company'));
const email = required(form.get('email'));
const country = required(form.get('country'));
const department = required(form.get('department')) || 'info';
const message = required(form.get('message'));
if (!name || !email || !country || !message) {
return new Response('Missing required contact fields.', { status: 400 });
}
const turnstileToken = required(form.get('cf-turnstile-response'));
if (turnstileSecret) {
const isHuman = await verifyTurnstile(
turnstileToken,
turnstileSecret,
request.headers.get('CF-Connecting-IP'),
);
if (!isHuman) {
return new Response('Turnstile verification failed.', { status: 400 });
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 subject = required(form.get('_subject')) || 'Richiesta contatto - sito NexStudio';
const text = [
`Nome: ${name}`,
company ? `Azienda: ${company}` : '',
`Email: ${email}`,
`Paese: ${country}`,
`Reparto: ${department}`,
'',
message,
]
.filter(Boolean)
.join('\n');
const redirectTarget =
sanitizeRedirect(required(form.get('_next')), url) ||
sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) ||
sanitizeRedirect(request.headers.get('referer') ?? '/', url) ||
new URL('/#contatti', url).toString();
if (mode === 'live') {
const emailBinding = getEmailBinding(runtimeEnv);
if (!emailBinding || !fromEmail || !toEmail) {
return new Response(
'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.',
{
status: 500,
},
const name = required(form.get('name'));
const company = required(form.get('company'));
const email = required(form.get('email'));
const country = required(form.get('country'));
const department = required(form.get('department')) || 'info';
const message = required(form.get('message'));
if (!name || !email || !country || !message) {
return fail(request, 'Missing required contact fields.', 400);
}
const turnstileToken = required(form.get('cf-turnstile-response'));
if (turnstileSecret) {
const isHuman = await verifyTurnstile(
turnstileToken,
turnstileSecret,
request.headers.get('CF-Connecting-IP'),
);
if (!isHuman) {
return fail(request, 'Turnstile verification failed.', 400);
}
}
const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department);
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: destination,
subject,
text,
replyTo: email,
});
} else {
console.info('[contact:dev] Contact payload received', {
name,
company,
email,
country,
department,
subject,
});
}
return Response.redirect(redirectTarget, 303);
const subject = required(form.get('_subject')) || 'Richiesta contatto - sito NexStudio';
const text = [
`Nome: ${name}`,
company ? `Azienda: ${company}` : '',
`Email: ${email}`,
`Paese: ${country}`,
`Reparto: ${department}`,
'',
message,
]
.filter(Boolean)
.join('\n');
if (mode === 'live') {
const emailBinding = getEmailBinding(runtimeEnv);
if (!emailBinding || !fromEmail || !toEmail) {
return fail(
request,
'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.',
500,
);
}
const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department);
if (!destination) {
return fail(request, 'Nessun destinatario configurato per il reparto selezionato.', 500);
}
try {
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: destination,
subject,
text,
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 {
console.info('[contact:dev] Contact payload received', {
name,
company,
email,
country,
department,
subject,
});
}
return succeed(request, redirectTarget);
} catch (error) {
console.error('[contact] unexpected error', error);
return fail(request, 'Errore interno. Riprova più tardi.', 500);
}
};