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:
208
src/pages/api/newsletter/subscribe.ts
Normal file
208
src/pages/api/newsletter/subscribe.ts
Normal 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 l’indirizzo email aprendo questo link:',
|
||||
confirmUrl,
|
||||
'',
|
||||
'Se non hai richiesto l’iscrizione, 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);
|
||||
};
|
||||
Reference in New Issue
Block a user