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,178 @@
import type { APIRoute } from 'astro';
import {
absoluteUrl,
getNewsletterDb,
getRuntimeEnv,
readEnv,
sendWithResend,
siteOrigin,
type NewsletterSubscriber,
} from '../../../../lib/newsletter';
export const prerender = false;
type SendBody = {
subject?: string;
text?: string;
html?: string;
dryRun?: boolean;
};
function unauthorized(): Response {
return new Response('Unauthorized', { status: 401 });
}
function authorize(request: Request, runtimeEnv: ReturnType<typeof getRuntimeEnv>): boolean {
const expected = readEnv('NEWSLETTER_ADMIN_TOKEN', runtimeEnv);
if (!expected) return false;
const header = request.headers.get('authorization') || '';
const bearer = header.toLowerCase().startsWith('bearer ')
? header.slice(7).trim()
: '';
return bearer === expected;
}
export const POST: APIRoute = async ({ request, locals, url }) => {
const runtimeEnv = getRuntimeEnv(locals);
if (!authorize(request, runtimeEnv)) return unauthorized();
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
let body: SendBody;
try {
body = (await request.json()) as SendBody;
} catch {
return new Response('Invalid JSON body.', { status: 400 });
}
const subject = (body.subject || '').trim();
const text = (body.text || '').trim();
const html = (body.html || '').trim();
if (!subject || !text) {
return new Response('Fields subject and text are required.', { status: 400 });
}
if (mode !== 'live') {
console.info('[newsletter:dev] Campaign send requested', {
subject,
dryRun: Boolean(body.dryRun),
textPreview: text.slice(0, 120),
});
return Response.json({
mode,
accepted: true,
sent: 0,
failed: 0,
note: 'Dev mode: campaign not sent.',
});
}
const db = getNewsletterDb(runtimeEnv);
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);
if (!resendApiKey || !fromEmail) {
return new Response('Missing Resend API key or from email for newsletter send.', {
status: 500,
});
}
const subscribersResult = await db
.prepare(
`SELECT * FROM newsletter_subscribers
WHERE status = 'active'
ORDER BY id ASC`,
)
.all<NewsletterSubscriber>();
const subscribers = subscribersResult.results ?? [];
const campaignInsert = await db
.prepare(
`INSERT INTO newsletter_campaigns (subject, body_text, body_html, status, created_by)
VALUES (?, ?, ?, ?, ?)`,
)
.bind(subject, text, html || null, body.dryRun ? 'draft' : 'sending', 'admin-api')
.run();
const campaignId = Number(campaignInsert.meta.last_row_id);
const origin = siteOrigin(url, runtimeEnv);
if (body.dryRun) {
return Response.json({
dryRun: true,
campaignId,
recipients: subscribers.length,
sample: subscribers.slice(0, 5).map((s) => s.email),
});
}
let sent = 0;
let failed = 0;
for (const subscriber of subscribers) {
const unsubUrl = absoluteUrl(
origin,
`/api/newsletter/unsubscribe?token=${subscriber.unsubscribe_token}`,
);
const personalizedText = `${text}\n\n---\nDisiscrizione: ${unsubUrl}\n`;
const personalizedHtml = html
? `${html}<hr /><p style="font-size:12px;color:#666">Disiscrizione: <a href="${unsubUrl}">${unsubUrl}</a></p>`
: undefined;
try {
await sendWithResend({
apiKey: resendApiKey,
fromEmail,
toEmail: subscriber.email,
subject,
text: personalizedText,
html: personalizedHtml,
});
await db
.prepare(
`INSERT INTO newsletter_campaign_sends
(campaign_id, subscriber_id, email, status, sent_at)
VALUES (?, ?, ?, 'sent', datetime('now'))`,
)
.bind(campaignId, subscriber.id, subscriber.email)
.run();
sent += 1;
} catch (error) {
failed += 1;
const message = error instanceof Error ? error.message : String(error);
await db
.prepare(
`INSERT INTO newsletter_campaign_sends
(campaign_id, subscriber_id, email, status, error, sent_at)
VALUES (?, ?, ?, 'failed', ?, datetime('now'))`,
)
.bind(campaignId, subscriber.id, subscriber.email, message.slice(0, 500))
.run();
}
}
await db
.prepare(
`UPDATE newsletter_campaigns
SET status = ?, sent_at = datetime('now')
WHERE id = ?`,
)
.bind(failed > 0 && sent === 0 ? 'failed' : 'sent', campaignId)
.run();
return Response.json({
campaignId,
recipients: subscribers.length,
sent,
failed,
});
};

View File

@ -0,0 +1,74 @@
import type { APIRoute } from 'astro';
import {
getNewsletterDb,
getRuntimeEnv,
readEnv,
type NewsletterSubscriber,
} from '../../../../lib/newsletter';
export const prerender = false;
function unauthorized(): Response {
return new Response('Unauthorized', { status: 401 });
}
function authorize(request: Request, runtimeEnv: ReturnType<typeof getRuntimeEnv>): boolean {
const expected = readEnv('NEWSLETTER_ADMIN_TOKEN', runtimeEnv);
if (!expected) return false;
const header = request.headers.get('authorization') || '';
const bearer = header.toLowerCase().startsWith('bearer ')
? header.slice(7).trim()
: '';
const queryToken = new URL(request.url).searchParams.get('token') || '';
return bearer === expected || queryToken === expected;
}
export const GET: APIRoute = async ({ request, locals }) => {
const runtimeEnv = getRuntimeEnv(locals);
if (!authorize(request, runtimeEnv)) return unauthorized();
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
if (mode !== 'live') {
return Response.json({
mode,
subscribers: [],
note: 'Dev mode: no database query performed.',
});
}
const db = getNewsletterDb(runtimeEnv);
if (!db) {
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
status: 500,
});
}
const status = new URL(request.url).searchParams.get('status');
let rows: NewsletterSubscriber[];
if (status === 'pending' || status === 'active' || status === 'unsubscribed') {
const result = await db
.prepare(
`SELECT id, email, status, locale, source, created_at, confirmed_at, unsubscribed_at
FROM newsletter_subscribers
WHERE status = ?
ORDER BY created_at DESC`,
)
.bind(status)
.all<NewsletterSubscriber>();
rows = result.results ?? [];
} else {
const result = await db
.prepare(
`SELECT id, email, status, locale, source, created_at, confirmed_at, unsubscribed_at
FROM newsletter_subscribers
ORDER BY created_at DESC`,
)
.all<NewsletterSubscriber>();
rows = result.results ?? [];
}
return Response.json({
count: rows.length,
subscribers: rows,
});
};

View File

@ -0,0 +1,85 @@
import type { APIRoute } from 'astro';
import {
getNewsletterDb,
getRuntimeEnv,
readEnv,
required,
sendWithResend,
type NewsletterSubscriber,
} from '../../../lib/newsletter';
export const prerender = false;
export const GET: APIRoute = async ({ request, locals, url }) => {
const runtimeEnv = getRuntimeEnv(locals);
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
const token = required(url.searchParams.get('token'));
const okPath = '/newsletter/confermata';
const failPath = '/newsletter/conferma-errore';
if (!token) {
return Response.redirect(new URL(failPath, url).toString(), 303);
}
if (mode !== 'live') {
console.info('[newsletter:dev] Confirm token received', { token: token.slice(0, 8) + '…' });
return Response.redirect(new URL(okPath, url).toString(), 303);
}
const db = getNewsletterDb(runtimeEnv);
if (!db) {
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
status: 500,
});
}
const row = await db
.prepare(
`SELECT * FROM newsletter_subscribers
WHERE confirm_token = ? AND status = 'pending'
LIMIT 1`,
)
.bind(token)
.first<NewsletterSubscriber>();
if (!row) {
return Response.redirect(new URL(failPath, url).toString(), 303);
}
await db
.prepare(
`UPDATE newsletter_subscribers
SET status = 'active',
confirmed_at = datetime('now'),
confirm_token = NULL
WHERE id = ?`,
)
.bind(row.id)
.run();
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 && notifyTo) {
try {
await sendWithResend({
apiKey: resendApiKey,
fromEmail,
toEmail: notifyTo,
subject: 'Iscrizione newsletter confermata — NexStudio',
text: `Lindirizzo ${row.email} ha confermato liscrizione alla newsletter.`,
});
} catch (error) {
console.warn('[newsletter] confirm notify failed', error);
}
}
return Response.redirect(new URL(okPath, request.url).toString(), 303);
};

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);
};

View File

@ -0,0 +1,85 @@
import type { APIRoute } from 'astro';
import {
getNewsletterDb,
getRuntimeEnv,
readEnv,
required,
type NewsletterSubscriber,
} from '../../../lib/newsletter';
export const prerender = false;
async function unsubscribeByToken(
locals: unknown,
token: string,
): Promise<'ok' | 'missing' | 'not_found' | 'no_db'> {
if (!token) return 'missing';
const runtimeEnv = getRuntimeEnv(locals);
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
if (mode !== 'live') {
console.info('[newsletter:dev] Unsubscribe token received', { token: token.slice(0, 8) + '…' });
return 'ok';
}
const db = getNewsletterDb(runtimeEnv);
if (!db) return 'no_db';
const row = await db
.prepare(
`SELECT * FROM newsletter_subscribers
WHERE unsubscribe_token = ?
LIMIT 1`,
)
.bind(token)
.first<NewsletterSubscriber>();
if (!row) return 'not_found';
if (row.status !== 'unsubscribed') {
await db
.prepare(
`UPDATE newsletter_subscribers
SET status = 'unsubscribed',
unsubscribed_at = datetime('now'),
confirm_token = NULL
WHERE id = ?`,
)
.bind(row.id)
.run();
}
return 'ok';
}
export const GET: APIRoute = async ({ locals, url }) => {
const token = required(url.searchParams.get('token'));
const result = await unsubscribeByToken(locals, token);
if (result === 'no_db') {
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
status: 500,
});
}
const path =
result === 'ok' ? '/newsletter/disiscrizione' : '/newsletter/disiscrizione-errore';
return Response.redirect(new URL(path, url).toString(), 303);
};
export const POST: APIRoute = async ({ request, locals, url }) => {
const form = await request.formData();
const token = required(form.get('token')) || required(url.searchParams.get('token'));
const result = await unsubscribeByToken(locals, token);
if (result === 'no_db') {
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
status: 500,
});
}
const path =
result === 'ok' ? '/newsletter/disiscrizione' : '/newsletter/disiscrizione-errore';
return Response.redirect(new URL(path, url).toString(), 303);
};