Gestisce errori email del form contatti senza 500 opaco.
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:
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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,83 +112,115 @@ function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, url }) => {
|
export const POST: APIRoute = async ({ request, url }) => {
|
||||||
const runtimeEnv = getRuntimeEnv();
|
try {
|
||||||
const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev';
|
const runtimeEnv = getRuntimeEnv();
|
||||||
const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
|
const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev';
|
||||||
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
|
const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
|
||||||
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
|
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
|
||||||
|
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const redirectTarget =
|
if (required(form.get('_gotcha'))) {
|
||||||
sanitizeRedirect(required(form.get('_next')), url) ||
|
const redirectTarget =
|
||||||
sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) ||
|
sanitizeRedirect(required(form.get('_next')), url) ||
|
||||||
sanitizeRedirect(request.headers.get('referer') ?? '/', url) ||
|
sanitizeRedirect(request.headers.get('referer') ?? '/', url) ||
|
||||||
'/';
|
new URL('/#contatti', url).toString();
|
||||||
|
return succeed(request, redirectTarget);
|
||||||
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 subject = required(form.get('_subject')) || 'Richiesta contatto - sito NexStudio';
|
const redirectTarget =
|
||||||
const text = [
|
sanitizeRedirect(required(form.get('_next')), url) ||
|
||||||
`Nome: ${name}`,
|
sanitizeRedirect(readEnv('PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT', runtimeEnv), url) ||
|
||||||
company ? `Azienda: ${company}` : '',
|
sanitizeRedirect(request.headers.get('referer') ?? '/', url) ||
|
||||||
`Email: ${email}`,
|
new URL('/#contatti', url).toString();
|
||||||
`Paese: ${country}`,
|
|
||||||
`Reparto: ${department}`,
|
|
||||||
'',
|
|
||||||
message,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
if (mode === 'live') {
|
const name = required(form.get('name'));
|
||||||
const emailBinding = getEmailBinding(runtimeEnv);
|
const company = required(form.get('company'));
|
||||||
if (!emailBinding || !fromEmail || !toEmail) {
|
const email = required(form.get('email'));
|
||||||
return new Response(
|
const country = required(form.get('country'));
|
||||||
'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.',
|
const department = required(form.get('department')) || 'info';
|
||||||
{
|
const message = required(form.get('message'));
|
||||||
status: 500,
|
|
||||||
},
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
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;
|
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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user