From 7aff1abcfbd7735cbd3d7eb4b976c061ccfbee20 Mon Sep 17 00:00:00 2001 From: Javaxman Date: Tue, 28 Jul 2026 23:23:19 +0200 Subject: [PATCH] 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 --- .env.example | 16 +- README.md | 36 +++ migrations/0001_newsletter.sql | 49 +++++ package.json | 4 +- src/components/SiteFooter.astro | 2 + src/data/home/footer.ts | 12 +- src/lib/newsletter.ts | 146 ++++++++++++ src/pages/api/newsletter/admin/send.ts | 178 +++++++++++++++ src/pages/api/newsletter/admin/subscribers.ts | 74 +++++++ src/pages/api/newsletter/confirm.ts | 85 +++++++ src/pages/api/newsletter/subscribe.ts | 208 ++++++++++++++++++ src/pages/api/newsletter/unsubscribe.ts | 85 +++++++ src/pages/en/newsletter/conferma-errore.astro | 4 + src/pages/en/newsletter/confermata.astro | 4 + .../en/newsletter/disiscrizione-errore.astro | 4 + src/pages/en/newsletter/disiscrizione.astro | 4 + src/pages/news.astro | 2 +- src/pages/newsletter/conferma-errore.astro | 18 ++ src/pages/newsletter/confermata.astro | 18 ++ .../newsletter/disiscrizione-errore.astro | 20 ++ src/pages/newsletter/disiscrizione.astro | 18 ++ src/pages/newsletter/grazie.astro | 11 +- src/pages/th/newsletter/conferma-errore.astro | 4 + src/pages/th/newsletter/confermata.astro | 4 + .../th/newsletter/disiscrizione-errore.astro | 4 + src/pages/th/newsletter/disiscrizione.astro | 4 + wrangler.jsonc | 12 +- 27 files changed, 1008 insertions(+), 18 deletions(-) create mode 100644 migrations/0001_newsletter.sql create mode 100644 src/lib/newsletter.ts create mode 100644 src/pages/api/newsletter/admin/send.ts create mode 100644 src/pages/api/newsletter/admin/subscribers.ts create mode 100644 src/pages/api/newsletter/confirm.ts create mode 100644 src/pages/api/newsletter/subscribe.ts create mode 100644 src/pages/api/newsletter/unsubscribe.ts create mode 100644 src/pages/en/newsletter/conferma-errore.astro create mode 100644 src/pages/en/newsletter/confermata.astro create mode 100644 src/pages/en/newsletter/disiscrizione-errore.astro create mode 100644 src/pages/en/newsletter/disiscrizione.astro create mode 100644 src/pages/newsletter/conferma-errore.astro create mode 100644 src/pages/newsletter/confermata.astro create mode 100644 src/pages/newsletter/disiscrizione-errore.astro create mode 100644 src/pages/newsletter/disiscrizione.astro create mode 100644 src/pages/th/newsletter/conferma-errore.astro create mode 100644 src/pages/th/newsletter/confermata.astro create mode 100644 src/pages/th/newsletter/disiscrizione-errore.astro create mode 100644 src/pages/th/newsletter/disiscrizione.astro diff --git a/.env.example b/.env.example index e3ce163..8ac0af7 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,22 @@ PUBLIC_CONTACT_FORM_ACTION_URL=/api/contact PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT=http://localhost:4321/#contatti PUBLIC_CONTACT_FORM_MAILTO= PUBLIC_CONTACT_TURNSTILE_SITE_KEY= +PUBLIC_SITE_URL=http://localhost:4321 CONTACT_FORM_MODE=dev CONTACT_FORM_RESEND_API_KEY= -CONTACT_FORM_FROM_EMAIL=no-reply@nexstudio.com -CONTACT_FORM_TO_EMAIL=info@nexstudio.com -CONTACT_FORM_TO_EMAIL_INFO=info@nexstudio.com +CONTACT_FORM_FROM_EMAIL=no-reply@nexstudio.ai +CONTACT_FORM_TO_EMAIL=info@nexstudio.ai +CONTACT_FORM_TO_EMAIL_INFO=info@nexstudio.ai CONTACT_FORM_TO_EMAIL_PRIVACY= CONTACT_FORM_TO_EMAIL_CAREERS= CONTACT_TURNSTILE_SECRET_KEY= + +# Newsletter (custom D1 + Resend, double opt-in) +# In produzione: NEWSLETTER_MODE=live (oppure CONTACT_FORM_MODE=live come fallback) +NEWSLETTER_MODE=dev +NEWSLETTER_RESEND_API_KEY= +NEWSLETTER_FROM_EMAIL=no-reply@nexstudio.ai +NEWSLETTER_NOTIFY_TO_EMAIL=info@nexstudio.ai +NEWSLETTER_ADMIN_TOKEN= +NEWSLETTER_TURNSTILE_SECRET_KEY= diff --git a/README.md b/README.md index 3a175f8..4f80c1b 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,42 @@ Indirizzi consigliati portale: - `careers@nexstudio.ai` - `no-reply@nexstudio.ai` (solo invio comunicazioni/newsletter, non destinazione inbound) +## Configurazione newsletter + +Sistema custom su Cloudflare D1 + Resend (double opt-in). + +Variabili server: + +- `NEWSLETTER_MODE` (`dev` oppure `live`; se assente usa `CONTACT_FORM_MODE`) +- `NEWSLETTER_RESEND_API_KEY` (fallback: `CONTACT_FORM_RESEND_API_KEY`) +- `NEWSLETTER_FROM_EMAIL` (fallback: `CONTACT_FORM_FROM_EMAIL`, consigliato `no-reply@nexstudio.ai`) +- `NEWSLETTER_NOTIFY_TO_EMAIL` (notifiche admin; fallback: `CONTACT_FORM_TO_EMAIL`) +- `NEWSLETTER_ADMIN_TOKEN` (Bearer token per endpoint admin) +- `NEWSLETTER_TURNSTILE_SECRET_KEY` (opzionale; fallback: `CONTACT_TURNSTILE_SECRET_KEY`) +- `PUBLIC_SITE_URL` (origine usata nei link di conferma/disiscrizione) + +Endpoint pubblici: + +- `POST /api/newsletter/subscribe` — iscrizione (form footer) +- `GET /api/newsletter/confirm?token=…` — conferma double opt-in +- `GET|POST /api/newsletter/unsubscribe?token=…` — disiscrizione + +Endpoint admin (header `Authorization: Bearer `): + +- `GET /api/newsletter/admin/subscribers?status=active|pending|unsubscribed` +- `POST /api/newsletter/admin/send` — body JSON `{ "subject", "text", "html?", "dryRun?" }` + +Database: + +- D1 `nexstudio-newsletter` (binding `NEWSLETTER_DB`) +- Migrazioni in `migrations/` +- `npm run db:migrate:local` / `npm run db:migrate:remote` + +Comportamento: + +- `dev`: accetta submit e logga (senza D1/Resend obbligatori) +- `live`: scrive su D1, invia email di conferma via Resend, richiede binding D1 + ## Deploy Cloudflare Impostazioni consigliate: diff --git a/migrations/0001_newsletter.sql b/migrations/0001_newsletter.sql new file mode 100644 index 0000000..dcf7eab --- /dev/null +++ b/migrations/0001_newsletter.sql @@ -0,0 +1,49 @@ +-- Newsletter subscribers and campaign send log +CREATE TABLE IF NOT EXISTS newsletter_subscribers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL COLLATE NOCASE, + status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'unsubscribed')), + locale TEXT NOT NULL DEFAULT 'it', + confirm_token TEXT, + unsubscribe_token TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'footer', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + confirmed_at TEXT, + unsubscribed_at TEXT, + UNIQUE (email) +); + +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_status + ON newsletter_subscribers (status); + +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_confirm_token + ON newsletter_subscribers (confirm_token); + +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_unsubscribe_token + ON newsletter_subscribers (unsubscribe_token); + +CREATE TABLE IF NOT EXISTS newsletter_campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subject TEXT NOT NULL, + body_text TEXT NOT NULL, + body_html TEXT, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'sending', 'sent', 'failed')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + sent_at TEXT, + created_by TEXT +); + +CREATE TABLE IF NOT EXISTS newsletter_campaign_sends ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + campaign_id INTEGER NOT NULL, + subscriber_id INTEGER NOT NULL, + email TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('queued', 'sent', 'failed', 'skipped')), + error TEXT, + sent_at TEXT, + FOREIGN KEY (campaign_id) REFERENCES newsletter_campaigns (id), + FOREIGN KEY (subscriber_id) REFERENCES newsletter_subscribers (id) +); + +CREATE INDEX IF NOT EXISTS idx_newsletter_campaign_sends_campaign + ON newsletter_campaign_sends (campaign_id); diff --git a/package.json b/package.json index efd7209..cd92666 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "deploy:cf": "npm run build && wrangler deploy --config dist/server/wrangler.json", "preview": "astro preview", "astro": "astro", - "generate-types": "wrangler types" + "generate-types": "wrangler types", + "db:migrate:local": "wrangler d1 migrations apply nexstudio-newsletter --local", + "db:migrate:remote": "wrangler d1 migrations apply nexstudio-newsletter --remote" }, "dependencies": { "@astrojs/alpinejs": "^0.5.0", diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro index 0df5cf8..06577b6 100644 --- a/src/components/SiteFooter.astro +++ b/src/components/SiteFooter.astro @@ -166,6 +166,8 @@ const withLocale = (href: string) => { {notifySubject ? ( ) : null} + + ; + +export type SubscriberStatus = 'pending' | 'active' | 'unsubscribed'; + +export type NewsletterSubscriber = { + id: number; + email: string; + status: SubscriberStatus; + locale: string; + confirm_token: string | null; + unsubscribe_token: string; + source: string; + created_at: string; + confirmed_at: string | null; + unsubscribed_at: string | null; +}; + +export function getRuntimeEnv(locals: unknown): RuntimeEnv { + if ( + locals && + typeof locals === 'object' && + 'runtime' in locals && + locals.runtime && + typeof locals.runtime === 'object' && + 'env' in locals.runtime && + locals.runtime.env && + typeof locals.runtime.env === 'object' + ) { + return locals.runtime.env as RuntimeEnv; + } + return {}; +} + +export function readEnv(name: string, runtimeEnv: RuntimeEnv): string { + const runtimeValue = runtimeEnv[name]; + if (typeof runtimeValue === 'string' && runtimeValue.trim().length > 0) { + return runtimeValue.trim(); + } + const staticValue = import.meta.env[name]; + return typeof staticValue === 'string' ? staticValue.trim() : ''; +} + +export function getNewsletterDb(runtimeEnv: RuntimeEnv): D1Database | null { + const db = runtimeEnv.NEWSLETTER_DB; + if (db && typeof db === 'object' && 'prepare' in db) { + return db as D1Database; + } + return null; +} + +export function sanitizeRedirect(candidate: string, requestUrl: URL): string { + if (!candidate) return ''; + try { + const u = new URL(candidate, requestUrl); + if (u.origin !== requestUrl.origin) return ''; + return u.pathname + u.search + u.hash; + } catch { + return ''; + } +} + +export function required(value: FormDataEntryValue | null): string { + return typeof value === 'string' ? value.trim() : ''; +} + +export function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254; +} + +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +export function createToken(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +export async function verifyTurnstile( + token: string, + secret: string, + ip?: string | null, +): Promise { + const body = new URLSearchParams(); + body.set('secret', secret); + body.set('response', token); + if (ip) body.set('remoteip', ip); + + const response = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + body, + }); + if (!response.ok) return false; + const payload = (await response.json()) as { success?: boolean }; + return payload.success === true; +} + +export async function sendWithResend(options: { + apiKey: string; + fromEmail: string; + toEmail: string; + subject: string; + text: string; + html?: string; +}): Promise { + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${options.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: options.fromEmail, + to: [options.toEmail], + subject: options.subject, + text: options.text, + ...(options.html ? { html: options.html } : {}), + }), + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Resend error ${response.status}: ${detail}`); + } +} + +export function siteOrigin(requestUrl: URL, runtimeEnv: RuntimeEnv): string { + const configured = readEnv('PUBLIC_SITE_URL', runtimeEnv) || readEnv('SITE', runtimeEnv); + if (configured) { + try { + return new URL(configured).origin; + } catch { + /* fall through */ + } + } + return requestUrl.origin; +} + +export function absoluteUrl(origin: string, path: string): string { + return new URL(path, origin).toString(); +} diff --git a/src/pages/api/newsletter/admin/send.ts b/src/pages/api/newsletter/admin/send.ts new file mode 100644 index 0000000..9501801 --- /dev/null +++ b/src/pages/api/newsletter/admin/send.ts @@ -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): 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(); + 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}

Disiscrizione: ${unsubUrl}

` + : 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, + }); +}; diff --git a/src/pages/api/newsletter/admin/subscribers.ts b/src/pages/api/newsletter/admin/subscribers.ts new file mode 100644 index 0000000..d8c2c8b --- /dev/null +++ b/src/pages/api/newsletter/admin/subscribers.ts @@ -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): 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(); + 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(); + rows = result.results ?? []; + } + + return Response.json({ + count: rows.length, + subscribers: rows, + }); +}; diff --git a/src/pages/api/newsletter/confirm.ts b/src/pages/api/newsletter/confirm.ts new file mode 100644 index 0000000..ce14d22 --- /dev/null +++ b/src/pages/api/newsletter/confirm.ts @@ -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(); + + 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); +}; diff --git a/src/pages/api/newsletter/subscribe.ts b/src/pages/api/newsletter/subscribe.ts new file mode 100644 index 0000000..cbadbfa --- /dev/null +++ b/src/pages/api/newsletter/subscribe.ts @@ -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(); + + 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); +}; diff --git a/src/pages/api/newsletter/unsubscribe.ts b/src/pages/api/newsletter/unsubscribe.ts new file mode 100644 index 0000000..91eeb99 --- /dev/null +++ b/src/pages/api/newsletter/unsubscribe.ts @@ -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(); + + 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); +}; diff --git a/src/pages/en/newsletter/conferma-errore.astro b/src/pages/en/newsletter/conferma-errore.astro new file mode 100644 index 0000000..e5bb033 --- /dev/null +++ b/src/pages/en/newsletter/conferma-errore.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/conferma-errore.astro'; +--- + diff --git a/src/pages/en/newsletter/confermata.astro b/src/pages/en/newsletter/confermata.astro new file mode 100644 index 0000000..8434851 --- /dev/null +++ b/src/pages/en/newsletter/confermata.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/confermata.astro'; +--- + diff --git a/src/pages/en/newsletter/disiscrizione-errore.astro b/src/pages/en/newsletter/disiscrizione-errore.astro new file mode 100644 index 0000000..4110732 --- /dev/null +++ b/src/pages/en/newsletter/disiscrizione-errore.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/disiscrizione-errore.astro'; +--- + diff --git a/src/pages/en/newsletter/disiscrizione.astro b/src/pages/en/newsletter/disiscrizione.astro new file mode 100644 index 0000000..e26bd23 --- /dev/null +++ b/src/pages/en/newsletter/disiscrizione.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/disiscrizione.astro'; +--- + diff --git a/src/pages/news.astro b/src/pages/news.astro index b01f530..a107b30 100644 --- a/src/pages/news.astro +++ b/src/pages/news.astro @@ -22,7 +22,7 @@ import SubpageLayout from '../layouts/SubpageLayout.astro';

Canali di distribuzione

  • Questa pagina resta il punto di riferimento ufficiale per annunci e changelog sintetici.
  • -
  • La newsletter verra attivata appena disponibile il canale comunicazioni dedicato.
  • +
  • La newsletter è attiva dal footer del sito (iscrizione con conferma email).
  • I contenuti long-form potranno essere pubblicati anche su canali esterni, con rimando qui.
diff --git a/src/pages/newsletter/conferma-errore.astro b/src/pages/newsletter/conferma-errore.astro new file mode 100644 index 0000000..08434cd --- /dev/null +++ b/src/pages/newsletter/conferma-errore.astro @@ -0,0 +1,18 @@ +--- +import SubpageLayout from '../../layouts/SubpageLayout.astro'; +--- + + +

+ Puoi riprovare iscrivendoti di nuovo dal form newsletter nel footer del + sito: riceverai un nuovo messaggio di conferma. +

+

+ Torna alla home +

+
diff --git a/src/pages/newsletter/confermata.astro b/src/pages/newsletter/confermata.astro new file mode 100644 index 0000000..0e129f7 --- /dev/null +++ b/src/pages/newsletter/confermata.astro @@ -0,0 +1,18 @@ +--- +import SubpageLayout from '../../layouts/SubpageLayout.astro'; +--- + + +

+ Puoi disiscriverti in qualsiasi momento dal link presente in fondo a ogni + messaggio. +

+

+ Torna alla home +

+
diff --git a/src/pages/newsletter/disiscrizione-errore.astro b/src/pages/newsletter/disiscrizione-errore.astro new file mode 100644 index 0000000..68d4898 --- /dev/null +++ b/src/pages/newsletter/disiscrizione-errore.astro @@ -0,0 +1,20 @@ +--- +import SubpageLayout from '../../layouts/SubpageLayout.astro'; +--- + + +

+ Se continui a ricevere messaggi indesiderati, scrivi a + privacy@nexstudio.com. +

+

+ Torna alla home +

+
diff --git a/src/pages/newsletter/disiscrizione.astro b/src/pages/newsletter/disiscrizione.astro new file mode 100644 index 0000000..834324b --- /dev/null +++ b/src/pages/newsletter/disiscrizione.astro @@ -0,0 +1,18 @@ +--- +import SubpageLayout from '../../layouts/SubpageLayout.astro'; +--- + + +

+ Se ti sei disiscritto per errore, puoi iscriverti di nuovo dal footer del + sito e confermare l’email. +

+

+ Torna alla home +

+
diff --git a/src/pages/newsletter/grazie.astro b/src/pages/newsletter/grazie.astro index 650a31c..3bcda85 100644 --- a/src/pages/newsletter/grazie.astro +++ b/src/pages/newsletter/grazie.astro @@ -4,13 +4,14 @@ import SubpageLayout from '../../layouts/SubpageLayout.astro';

- 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.

Torna alla home diff --git a/src/pages/th/newsletter/conferma-errore.astro b/src/pages/th/newsletter/conferma-errore.astro new file mode 100644 index 0000000..e5bb033 --- /dev/null +++ b/src/pages/th/newsletter/conferma-errore.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/conferma-errore.astro'; +--- + diff --git a/src/pages/th/newsletter/confermata.astro b/src/pages/th/newsletter/confermata.astro new file mode 100644 index 0000000..8434851 --- /dev/null +++ b/src/pages/th/newsletter/confermata.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/confermata.astro'; +--- + diff --git a/src/pages/th/newsletter/disiscrizione-errore.astro b/src/pages/th/newsletter/disiscrizione-errore.astro new file mode 100644 index 0000000..4110732 --- /dev/null +++ b/src/pages/th/newsletter/disiscrizione-errore.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/disiscrizione-errore.astro'; +--- + diff --git a/src/pages/th/newsletter/disiscrizione.astro b/src/pages/th/newsletter/disiscrizione.astro new file mode 100644 index 0000000..e26bd23 --- /dev/null +++ b/src/pages/th/newsletter/disiscrizione.astro @@ -0,0 +1,4 @@ +--- +import Page from '../../newsletter/disiscrizione.astro'; +--- + diff --git a/wrangler.jsonc b/wrangler.jsonc index 7f3e273..6148489 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,5 +9,13 @@ }, "observability": { "enabled": true - } -} \ No newline at end of file + }, + "d1_databases": [ + { + "binding": "NEWSLETTER_DB", + "database_name": "nexstudio-newsletter", + "database_id": "f2b30e34-6e63-4df8-8f92-f0a0dad08cda", + "migrations_dir": "migrations" + } + ] +} -- 2.49.0