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:
178
src/pages/api/newsletter/admin/send.ts
Normal file
178
src/pages/api/newsletter/admin/send.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
74
src/pages/api/newsletter/admin/subscribers.ts
Normal file
74
src/pages/api/newsletter/admin/subscribers.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
85
src/pages/api/newsletter/confirm.ts
Normal file
85
src/pages/api/newsletter/confirm.ts
Normal 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: `L’indirizzo ${row.email} ha confermato l’iscrizione alla newsletter.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[newsletter] confirm notify failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.redirect(new URL(okPath, request.url).toString(), 303);
|
||||
};
|
||||
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);
|
||||
};
|
||||
85
src/pages/api/newsletter/unsubscribe.ts
Normal file
85
src/pages/api/newsletter/unsubscribe.ts
Normal 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);
|
||||
};
|
||||
4
src/pages/en/newsletter/conferma-errore.astro
Normal file
4
src/pages/en/newsletter/conferma-errore.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/conferma-errore.astro';
|
||||
---
|
||||
<Page />
|
||||
4
src/pages/en/newsletter/confermata.astro
Normal file
4
src/pages/en/newsletter/confermata.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/confermata.astro';
|
||||
---
|
||||
<Page />
|
||||
4
src/pages/en/newsletter/disiscrizione-errore.astro
Normal file
4
src/pages/en/newsletter/disiscrizione-errore.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/disiscrizione-errore.astro';
|
||||
---
|
||||
<Page />
|
||||
4
src/pages/en/newsletter/disiscrizione.astro
Normal file
4
src/pages/en/newsletter/disiscrizione.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/disiscrizione.astro';
|
||||
---
|
||||
<Page />
|
||||
@ -22,7 +22,7 @@ import SubpageLayout from '../layouts/SubpageLayout.astro';
|
||||
<h2>Canali di distribuzione</h2>
|
||||
<ul>
|
||||
<li>Questa pagina resta il punto di riferimento ufficiale per annunci e changelog sintetici.</li>
|
||||
<li>La newsletter verra attivata appena disponibile il canale comunicazioni dedicato.</li>
|
||||
<li>La newsletter è attiva dal footer del sito (iscrizione con conferma email).</li>
|
||||
<li>I contenuti long-form potranno essere pubblicati anche su canali esterni, con rimando qui.</li>
|
||||
</ul>
|
||||
|
||||
|
||||
18
src/pages/newsletter/conferma-errore.astro
Normal file
18
src/pages/newsletter/conferma-errore.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
||||
---
|
||||
|
||||
<SubpageLayout
|
||||
title="Conferma non riuscita — Newsletter NexStudio"
|
||||
description="Il link di conferma newsletter non è valido o è già stato usato."
|
||||
heading="Conferma non riuscita"
|
||||
lead="Il link di conferma non è valido, è scaduto o è già stato utilizzato."
|
||||
>
|
||||
<p>
|
||||
Puoi riprovare iscrivendoti di nuovo dal form newsletter nel footer del
|
||||
sito: riceverai un nuovo messaggio di conferma.
|
||||
</p>
|
||||
<p>
|
||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
||||
</p>
|
||||
</SubpageLayout>
|
||||
18
src/pages/newsletter/confermata.astro
Normal file
18
src/pages/newsletter/confermata.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
||||
---
|
||||
|
||||
<SubpageLayout
|
||||
title="Iscrizione confermata — Newsletter NexStudio"
|
||||
description="La tua iscrizione alla newsletter NexStudio è confermata."
|
||||
heading="Iscrizione confermata"
|
||||
lead="Il tuo indirizzo email è ora attivo per ricevere aggiornamenti su prodotti e roadmap NexStudio."
|
||||
>
|
||||
<p>
|
||||
Puoi disiscriverti in qualsiasi momento dal link presente in fondo a ogni
|
||||
messaggio.
|
||||
</p>
|
||||
<p>
|
||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
||||
</p>
|
||||
</SubpageLayout>
|
||||
20
src/pages/newsletter/disiscrizione-errore.astro
Normal file
20
src/pages/newsletter/disiscrizione-errore.astro
Normal file
@ -0,0 +1,20 @@
|
||||
---
|
||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
||||
---
|
||||
|
||||
<SubpageLayout
|
||||
title="Disiscrizione non riuscita — Newsletter NexStudio"
|
||||
description="Il link di disiscrizione newsletter non è valido."
|
||||
heading="Disiscrizione non riuscita"
|
||||
lead="Il link di disiscrizione non è valido o è incompleto."
|
||||
>
|
||||
<p>
|
||||
Se continui a ricevere messaggi indesiderati, scrivi a
|
||||
<a class="text-sky-400/90 hover:text-sky-300" href="mailto:privacy@nexstudio.com"
|
||||
>privacy@nexstudio.com</a
|
||||
>.
|
||||
</p>
|
||||
<p>
|
||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
||||
</p>
|
||||
</SubpageLayout>
|
||||
18
src/pages/newsletter/disiscrizione.astro
Normal file
18
src/pages/newsletter/disiscrizione.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
||||
---
|
||||
|
||||
<SubpageLayout
|
||||
title="Disiscrizione — Newsletter NexStudio"
|
||||
description="Disiscrizione dalla newsletter NexStudio completata."
|
||||
heading="Disiscrizione completata"
|
||||
lead="Non riceverai più la newsletter di NexStudio su questo indirizzo."
|
||||
>
|
||||
<p>
|
||||
Se ti sei disiscritto per errore, puoi iscriverti di nuovo dal footer del
|
||||
sito e confermare l’email.
|
||||
</p>
|
||||
<p>
|
||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
||||
</p>
|
||||
</SubpageLayout>
|
||||
@ -4,13 +4,14 @@ import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
||||
|
||||
<SubpageLayout
|
||||
title="Grazie — Newsletter NexStudio"
|
||||
description="Iscrizione alla newsletter ricevuta."
|
||||
heading="Grazie"
|
||||
lead="La richiesta di iscrizione è stata inviata. Controlla la posta se è prevista una conferma (double opt-in)."
|
||||
description="Richiesta di iscrizione alla newsletter ricevuta."
|
||||
heading="Controlla la tua email"
|
||||
lead="Abbiamo ricevuto la richiesta di iscrizione. Per completarla, apri il messaggio di conferma e clicca sul link (double opt-in)."
|
||||
>
|
||||
<p>
|
||||
Se usi un servizio con conferma via email, apri il messaggio e segui il
|
||||
link per completare l’iscrizione.
|
||||
Se non trovi l’email, controlla anche la cartella spam o promozioni. Il link
|
||||
di conferma scade solo se viene generata una nuova richiesta per lo stesso
|
||||
indirizzo.
|
||||
</p>
|
||||
<p>
|
||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
||||
|
||||
4
src/pages/th/newsletter/conferma-errore.astro
Normal file
4
src/pages/th/newsletter/conferma-errore.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/conferma-errore.astro';
|
||||
---
|
||||
<Page />
|
||||
4
src/pages/th/newsletter/confermata.astro
Normal file
4
src/pages/th/newsletter/confermata.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/confermata.astro';
|
||||
---
|
||||
<Page />
|
||||
4
src/pages/th/newsletter/disiscrizione-errore.astro
Normal file
4
src/pages/th/newsletter/disiscrizione-errore.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/disiscrizione-errore.astro';
|
||||
---
|
||||
<Page />
|
||||
4
src/pages/th/newsletter/disiscrizione.astro
Normal file
4
src/pages/th/newsletter/disiscrizione.astro
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
import Page from '../../newsletter/disiscrizione.astro';
|
||||
---
|
||||
<Page />
|
||||
Reference in New Issue
Block a user