Aggiunge newsletter custom con D1, double opt-in e invio campagne.

Attiva il form footer, API subscribe/confirm/unsubscribe e endpoint admin protetti, riusando Resend per le email.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Javaxman
2026-07-28 23:23:19 +02:00
parent 9a8566a3f5
commit 7aff1abcfb
27 changed files with 1008 additions and 18 deletions

View File

@ -0,0 +1,208 @@
import type { APIRoute } from 'astro';
import {
absoluteUrl,
createToken,
getNewsletterDb,
getRuntimeEnv,
isValidEmail,
normalizeEmail,
readEnv,
required,
sanitizeRedirect,
sendWithResend,
siteOrigin,
verifyTurnstile,
type NewsletterSubscriber,
} from '../../../lib/newsletter';
export const prerender = false;
function confirmEmailCopy(locale: string, confirmUrl: string): { subject: string; text: string } {
if (locale === 'en') {
return {
subject: 'Confirm your NexStudio newsletter subscription',
text: [
'Thanks for subscribing to the NexStudio newsletter.',
'',
'Confirm your email by opening this link:',
confirmUrl,
'',
'If you did not request this, you can ignore this message.',
].join('\n'),
};
}
if (locale === 'th') {
return {
subject: 'ยืนยันการสมัครรับจดหมายข่าว NexStudio',
text: [
'ขอบคุณที่สมัครรับจดหมายข่าว NexStudio',
'',
'ยืนยันอีเมลของคุณโดยเปิดลิงก์นี้:',
confirmUrl,
'',
'หากคุณไม่ได้ขอสมัคร สามารถเพิกเฉยต่อข้อความนี้ได้',
].join('\n'),
};
}
return {
subject: 'Conferma iscrizione newsletter NexStudio',
text: [
'Grazie per esserti iscritto alla newsletter di NexStudio.',
'',
'Conferma lindirizzo email aprendo questo link:',
confirmUrl,
'',
'Se non hai richiesto liscrizione, puoi ignorare questo messaggio.',
].join('\n'),
};
}
export const POST: APIRoute = async ({ request, locals, url }) => {
const runtimeEnv = getRuntimeEnv(locals);
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
const db = getNewsletterDb(runtimeEnv);
const form = await request.formData();
const redirectOk =
sanitizeRedirect(required(form.get('_next')), url) ||
'/newsletter/grazie';
const redirectErr = sanitizeRedirect(required(form.get('_error')), url) || redirectOk;
// Honeypot
if (required(form.get('_gotcha'))) {
return Response.redirect(new URL(redirectOk, url).toString(), 303);
}
const emailRaw = required(form.get('email'));
const locale = required(form.get('locale')) || 'it';
const source = required(form.get('source')) || 'footer';
if (!emailRaw || !isValidEmail(emailRaw)) {
return new Response('Invalid email.', { status: 400 });
}
const email = normalizeEmail(emailRaw);
const turnstileSecret =
readEnv('NEWSLETTER_TURNSTILE_SECRET_KEY', runtimeEnv) ||
readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
if (turnstileSecret) {
const turnstileToken = required(form.get('cf-turnstile-response'));
const isHuman = await verifyTurnstile(
turnstileToken,
turnstileSecret,
request.headers.get('CF-Connecting-IP'),
);
if (!isHuman) {
return new Response('Turnstile verification failed.', { status: 400 });
}
}
const confirmToken = createToken();
const unsubscribeToken = createToken();
const origin = siteOrigin(url, runtimeEnv);
const confirmUrl = absoluteUrl(origin, `/api/newsletter/confirm?token=${confirmToken}`);
if (mode !== 'live') {
console.info('[newsletter:dev] Subscription request', {
email,
locale,
source,
confirmUrl,
});
return Response.redirect(new URL(redirectOk, url).toString(), 303);
}
if (!db) {
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
status: 500,
});
}
const resendApiKey =
readEnv('NEWSLETTER_RESEND_API_KEY', runtimeEnv) ||
readEnv('CONTACT_FORM_RESEND_API_KEY', runtimeEnv);
const fromEmail =
readEnv('NEWSLETTER_FROM_EMAIL', runtimeEnv) ||
readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
const notifyTo =
readEnv('NEWSLETTER_NOTIFY_TO_EMAIL', runtimeEnv) ||
readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
if (!resendApiKey || !fromEmail) {
return new Response(
'Live mode requires NEWSLETTER_RESEND_API_KEY (or CONTACT_FORM_RESEND_API_KEY) and NEWSLETTER_FROM_EMAIL (or CONTACT_FORM_FROM_EMAIL).',
{ status: 500 },
);
}
const existing = await db
.prepare('SELECT * FROM newsletter_subscribers WHERE email = ? LIMIT 1')
.bind(email)
.first<NewsletterSubscriber>();
if (existing?.status === 'active') {
// Already subscribed: redirect without revealing status too loudly.
return Response.redirect(new URL(redirectOk, url).toString(), 303);
}
if (existing?.status === 'unsubscribed' || existing?.status === 'pending') {
await db
.prepare(
`UPDATE newsletter_subscribers
SET status = 'pending',
locale = ?,
source = ?,
confirm_token = ?,
unsubscribe_token = ?,
confirmed_at = NULL,
unsubscribed_at = NULL
WHERE id = ?`,
)
.bind(locale, source, confirmToken, unsubscribeToken, existing.id)
.run();
} else {
await db
.prepare(
`INSERT INTO newsletter_subscribers
(email, status, locale, confirm_token, unsubscribe_token, source)
VALUES (?, 'pending', ?, ?, ?, ?)`,
)
.bind(email, locale, confirmToken, unsubscribeToken, source)
.run();
}
const copy = confirmEmailCopy(locale, confirmUrl);
try {
await sendWithResend({
apiKey: resendApiKey,
fromEmail,
toEmail: email,
subject: copy.subject,
text: copy.text,
});
} catch (error) {
console.error('[newsletter] confirm email failed', error);
return Response.redirect(new URL(redirectErr, url).toString(), 303);
}
if (notifyTo) {
try {
await sendWithResend({
apiKey: resendApiKey,
fromEmail,
toEmail: notifyTo,
subject: required(form.get('_subject')) || 'Nuova richiesta newsletter — NexStudio',
text: [
`Nuova richiesta di iscrizione (pending conferma).`,
`Email: ${email}`,
`Locale: ${locale}`,
`Source: ${source}`,
].join('\n'),
});
} catch (error) {
console.warn('[newsletter] admin notify failed', error);
}
}
return Response.redirect(new URL(redirectOk, url).toString(), 303);
};