8 Commits

Author SHA1 Message Date
770db3067d Mostra il link di disiscrizione dopo la conferma newsletter.
Passa il token unsub alla pagina di conferma e lo include anche nell'email di double opt-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:45:54 +02:00
6ba58c3a86 Merge pull request #16 from javaxman/fix/newsletter-turnstile-optional
Fix Turnstile sul form newsletter
2026-07-29 14:40:21 +02:00
8a99290209 Non richiedere Turnstile contatti sul form newsletter.
Il footer non ha il widget Turnstile: evita il fallimento usando solo NEWSLETTER_TURNSTILE_SECRET_KEY.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:39:52 +02:00
c04d43d9d7 Merge pull request #15 from javaxman/fix/astro6-cloudflare-workers-env
Fix env Cloudflare per Astro 6 (contatti e newsletter)
2026-07-29 14:08:25 +02:00
c4adc116d6 Corregge l'accesso all'env Worker per Astro 6.
Sostituisce locals.runtime.env con import da cloudflare:workers, ripristinando contatti e newsletter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:06:16 +02:00
44007f99e1 Merge pull request #14 from javaxman/feat/cloudflare-email-sending
Sostituisce Resend con Cloudflare Email Service
2026-07-29 09:55:05 +02:00
0743521d48 Sostituisce Resend con Cloudflare Email Service.
Usa il binding EMAIL per contatti e newsletter, eliminando la dipendenza da Resend e dalle relative API key.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:54:12 +02:00
2b470c778a Merge pull request #13 from javaxman/feat/newsletter-custom
Aggiunge newsletter custom con D1 e double opt-in
2026-07-28 23:27:00 +02:00
10 changed files with 151 additions and 130 deletions

View File

@ -5,7 +5,6 @@ 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.ai
CONTACT_FORM_TO_EMAIL=info@nexstudio.ai
CONTACT_FORM_TO_EMAIL_INFO=info@nexstudio.ai
@ -13,10 +12,9 @@ CONTACT_FORM_TO_EMAIL_PRIVACY=
CONTACT_FORM_TO_EMAIL_CAREERS=
CONTACT_TURNSTILE_SECRET_KEY=
# Newsletter (custom D1 + Resend, double opt-in)
# Newsletter (custom D1 + Cloudflare Email Service, 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=

View File

@ -15,7 +15,7 @@ Sito istituzionale NexStudio basato su Astro, con deploy su Cloudflare e form co
- Adapter Cloudflare (`@astrojs/cloudflare`)
- Sitemap (`@astrojs/sitemap`)
- Form backend su endpoint `/api/contact`
- Turnstile + Resend per anti-spam e invio email
- Turnstile + Cloudflare Email Service per anti-spam e invio email
## Struttura essenziale
@ -29,8 +29,7 @@ Sito istituzionale NexStudio basato su Astro, con deploy su Cloudflare e form co
- Node.js 20+
- npm 10+
- Account Cloudflare (Pages/Workers + Turnstile)
- Account Resend (API key + sender verificato)
- Account Cloudflare (Workers + Turnstile + Email Service con dominio onboardato, es. `nexstudio.ai`)
## Avvio locale
@ -63,8 +62,7 @@ Variabili pubbliche (frontend):
Variabili server (Cloudflare/local):
- `CONTACT_FORM_MODE` (`dev` oppure `live`)
- `CONTACT_FORM_RESEND_API_KEY`
- `CONTACT_FORM_FROM_EMAIL` (consigliato: `no-reply@nexstudio.ai`)
- `CONTACT_FORM_FROM_EMAIL` (consigliato: `no-reply@nexstudio.ai`, dominio verificato su Email Service)
- `CONTACT_FORM_TO_EMAIL` (default destinatario, es. `info@nexstudio.ai`)
- `CONTACT_FORM_TO_EMAIL_INFO` (opzionale, override esplicito per area "info")
- `CONTACT_FORM_TO_EMAIL_PRIVACY`
@ -74,7 +72,7 @@ Variabili server (Cloudflare/local):
Comportamento:
- `dev`: accetta submit e scrive payload nei log (senza invio reale)
- `live`: valida Turnstile e invia email via Resend
- `live`: valida Turnstile e invia email via binding `EMAIL` (Cloudflare Email Service)
Routing pubblico consigliato (portale vetrina):
@ -91,12 +89,13 @@ Indirizzi consigliati portale:
## Configurazione newsletter
Sistema custom su Cloudflare D1 + Resend (double opt-in).
Sistema custom su Cloudflare D1 + Cloudflare Email Service (double opt-in).
Prerequisito: dominio (es. `nexstudio.ai`) onboardato in **Email Service** con SPF/DKIM, e binding `send_email``EMAIL` nel Worker.
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)
@ -114,6 +113,8 @@ Endpoint admin (header `Authorization: Bearer <NEWSLETTER_ADMIN_TOKEN>`):
- `GET /api/newsletter/admin/subscribers?status=active|pending|unsubscribed`
- `POST /api/newsletter/admin/send` — body JSON `{ "subject", "text", "html?", "dryRun?" }`
Nota: Email Service è pensato per email **transazionali** (conferme, contatti, notifiche). Le campagne marketing di massa non sono ancora il target ufficiale del servizio.
Database:
- D1 `nexstudio-newsletter` (binding `NEWSLETTER_DB`)
@ -122,8 +123,8 @@ Database:
Comportamento:
- `dev`: accetta submit e logga (senza D1/Resend obbligatori)
- `live`: scrive su D1, invia email di conferma via Resend, richiede binding D1
- `dev`: accetta submit e logga (senza D1/Email obbligatori)
- `live`: scrive su D1, invia email di conferma via Cloudflare Email Service, richiede binding D1 + EMAIL
## Deploy Cloudflare

47
src/lib/email.ts Normal file
View File

@ -0,0 +1,47 @@
/**
* Outbound email via Cloudflare Email Service (`send_email` binding).
*/
import { env as workerEnv } from 'cloudflare:workers';
export type RuntimeEnv = Record<string, unknown>;
type EmailBinding = {
send: (message: {
to: string;
from: string;
subject: string;
text?: string;
html?: string;
replyTo?: string;
}) => Promise<{ messageId: string }>;
};
export function getEmailBinding(runtimeEnv?: RuntimeEnv): EmailBinding | null {
const env = (runtimeEnv ?? (workerEnv as unknown as RuntimeEnv)) as RuntimeEnv;
const binding = env.EMAIL;
if (binding && typeof binding === 'object' && 'send' in binding) {
return binding as EmailBinding;
}
return null;
}
export async function sendCloudflareEmail(
email: EmailBinding,
options: {
fromEmail: string;
toEmail: string;
subject: string;
text: string;
html?: string;
replyTo?: string;
},
): Promise<void> {
await email.send({
from: options.fromEmail,
to: options.toEmail,
subject: options.subject,
text: options.text,
...(options.html ? { html: options.html } : {}),
...(options.replyTo ? { replyTo: options.replyTo } : {}),
});
}

View File

@ -1,6 +1,7 @@
/**
* Shared helpers for newsletter API routes (Cloudflare Worker / Astro).
*/
import { env as workerEnv } from 'cloudflare:workers';
export type RuntimeEnv = Record<string, unknown>;
@ -19,20 +20,9 @@ export type NewsletterSubscriber = {
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 {};
/** Astro v6+: use `cloudflare:workers` env (locals.runtime.env was removed). */
export function getRuntimeEnv(_locals?: unknown): RuntimeEnv {
return workerEnv as unknown as RuntimeEnv;
}
export function readEnv(name: string, runtimeEnv: RuntimeEnv): string {
@ -100,34 +90,7 @@ export async function verifyTurnstile(
return payload.success === true;
}
export async function sendWithResend(options: {
apiKey: string;
fromEmail: string;
toEmail: string;
subject: string;
text: string;
html?: string;
}): Promise<void> {
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 { getEmailBinding, sendCloudflareEmail } from './email';
export function siteOrigin(requestUrl: URL, runtimeEnv: RuntimeEnv): string {
const configured = readEnv('PUBLIC_SITE_URL', runtimeEnv) || readEnv('SITE', runtimeEnv);

View File

@ -1,23 +1,13 @@
import type { APIRoute } from 'astro';
import { env as workerEnv } from 'cloudflare:workers';
import { getEmailBinding, sendCloudflareEmail } from '../../lib/email';
export const prerender = false;
type RuntimeEnv = Record<string, unknown>;
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 {};
function getRuntimeEnv(): RuntimeEnv {
return workerEnv as unknown as RuntimeEnv;
}
function readEnv(name: string, runtimeEnv: RuntimeEnv): string {
@ -58,27 +48,6 @@ async function verifyTurnstile(token: string, secret: string, ip?: string | null
return payload.success === true;
}
async function sendWithResend(apiKey: string, fromEmail: string, toEmail: string, subject: string, text: string) {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: fromEmail,
to: [toEmail],
subject,
text,
}),
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`Resend error ${response.status}: ${detail}`);
}
}
function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: string, department: string): string {
const normalized = department.trim().toLowerCase();
const map: Record<string, string> = {
@ -89,10 +58,9 @@ function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: st
return map[normalized] || defaultRecipient;
}
export const POST: APIRoute = async ({ request, locals, url }) => {
const runtimeEnv = getRuntimeEnv(locals);
export const POST: APIRoute = async ({ request, url }) => {
const runtimeEnv = getRuntimeEnv();
const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev';
const resendApiKey = readEnv('CONTACT_FORM_RESEND_API_KEY', runtimeEnv);
const fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
@ -141,13 +109,23 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
.join('\n');
if (mode === 'live') {
if (!resendApiKey || !fromEmail || !toEmail) {
return new Response('Live mode requires CONTACT_FORM_RESEND_API_KEY, CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.', {
status: 500,
});
const emailBinding = getEmailBinding(runtimeEnv);
if (!emailBinding || !fromEmail || !toEmail) {
return new Response(
'Live mode requires EMAIL binding (Cloudflare Email Service), CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.',
{
status: 500,
},
);
}
const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department);
await sendWithResend(resendApiKey, fromEmail, destination, subject, text);
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: destination,
subject,
text,
replyTo: email,
});
} else {
console.info('[contact:dev] Contact payload received', {
name,

View File

@ -1,10 +1,11 @@
import type { APIRoute } from 'astro';
import {
absoluteUrl,
getEmailBinding,
getNewsletterDb,
getRuntimeEnv,
readEnv,
sendWithResend,
sendCloudflareEmail,
siteOrigin,
type NewsletterSubscriber,
} from '../../../../lib/newsletter';
@ -73,15 +74,13 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
});
}
const resendApiKey =
readEnv('NEWSLETTER_RESEND_API_KEY', runtimeEnv) ||
readEnv('CONTACT_FORM_RESEND_API_KEY', runtimeEnv);
const emailBinding = getEmailBinding(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.', {
if (!emailBinding || !fromEmail) {
return new Response('Missing EMAIL binding or from email for newsletter send.', {
status: 500,
});
}
@ -129,8 +128,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
: undefined;
try {
await sendWithResend({
apiKey: resendApiKey,
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: subscriber.email,
subject,

View File

@ -1,10 +1,11 @@
import type { APIRoute } from 'astro';
import {
getEmailBinding,
getNewsletterDb,
getRuntimeEnv,
readEnv,
required,
sendWithResend,
sendCloudflareEmail,
type NewsletterSubscriber,
} from '../../../lib/newsletter';
@ -57,9 +58,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
.bind(row.id)
.run();
const resendApiKey =
readEnv('NEWSLETTER_RESEND_API_KEY', runtimeEnv) ||
readEnv('CONTACT_FORM_RESEND_API_KEY', runtimeEnv);
const emailBinding = getEmailBinding(runtimeEnv);
const fromEmail =
readEnv('NEWSLETTER_FROM_EMAIL', runtimeEnv) ||
readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
@ -67,10 +66,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
readEnv('NEWSLETTER_NOTIFY_TO_EMAIL', runtimeEnv) ||
readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
if (resendApiKey && fromEmail && notifyTo) {
if (emailBinding && fromEmail && notifyTo) {
try {
await sendWithResend({
apiKey: resendApiKey,
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: notifyTo,
subject: 'Iscrizione newsletter confermata — NexStudio',
@ -81,5 +79,8 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
}
}
return Response.redirect(new URL(okPath, request.url).toString(), 303);
return Response.redirect(
new URL(`${okPath}?unsub=${encodeURIComponent(row.unsubscribe_token)}`, request.url).toString(),
303,
);
};

View File

@ -2,6 +2,7 @@ import type { APIRoute } from 'astro';
import {
absoluteUrl,
createToken,
getEmailBinding,
getNewsletterDb,
getRuntimeEnv,
isValidEmail,
@ -9,7 +10,7 @@ import {
readEnv,
required,
sanitizeRedirect,
sendWithResend,
sendCloudflareEmail,
siteOrigin,
verifyTurnstile,
type NewsletterSubscriber,
@ -17,7 +18,11 @@ import {
export const prerender = false;
function confirmEmailCopy(locale: string, confirmUrl: string): { subject: string; text: string } {
function confirmEmailCopy(
locale: string,
confirmUrl: string,
unsubscribeUrl: string,
): { subject: string; text: string } {
if (locale === 'en') {
return {
subject: 'Confirm your NexStudio newsletter subscription',
@ -28,6 +33,9 @@ function confirmEmailCopy(locale: string, confirmUrl: string): { subject: string
confirmUrl,
'',
'If you did not request this, you can ignore this message.',
'',
'Unsubscribe:',
unsubscribeUrl,
].join('\n'),
};
}
@ -41,6 +49,9 @@ function confirmEmailCopy(locale: string, confirmUrl: string): { subject: string
confirmUrl,
'',
'หากคุณไม่ได้ขอสมัคร สามารถเพิกเฉยต่อข้อความนี้ได้',
'',
'ยกเลิกการสมัคร:',
unsubscribeUrl,
].join('\n'),
};
}
@ -53,6 +64,9 @@ function confirmEmailCopy(locale: string, confirmUrl: string): { subject: string
confirmUrl,
'',
'Se non hai richiesto liscrizione, puoi ignorare questo messaggio.',
'',
'Per disiscriverti:',
unsubscribeUrl,
].join('\n'),
};
}
@ -82,9 +96,9 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
}
const email = normalizeEmail(emailRaw);
const turnstileSecret =
readEnv('NEWSLETTER_TURNSTILE_SECRET_KEY', runtimeEnv) ||
readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
// Turnstile solo se configurato esplicitamente per la newsletter.
// Non riusare CONTACT_TURNSTILE_SECRET_KEY: il form footer non ha il widget.
const turnstileSecret = readEnv('NEWSLETTER_TURNSTILE_SECRET_KEY', runtimeEnv);
if (turnstileSecret) {
const turnstileToken = required(form.get('cf-turnstile-response'));
const isHuman = await verifyTurnstile(
@ -101,6 +115,10 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
const unsubscribeToken = createToken();
const origin = siteOrigin(url, runtimeEnv);
const confirmUrl = absoluteUrl(origin, `/api/newsletter/confirm?token=${confirmToken}`);
const unsubscribeUrl = absoluteUrl(
origin,
`/api/newsletter/unsubscribe?token=${unsubscribeToken}`,
);
if (mode !== 'live') {
console.info('[newsletter:dev] Subscription request', {
@ -118,9 +136,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
});
}
const resendApiKey =
readEnv('NEWSLETTER_RESEND_API_KEY', runtimeEnv) ||
readEnv('CONTACT_FORM_RESEND_API_KEY', runtimeEnv);
const emailBinding = getEmailBinding(runtimeEnv);
const fromEmail =
readEnv('NEWSLETTER_FROM_EMAIL', runtimeEnv) ||
readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
@ -128,9 +144,9 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
readEnv('NEWSLETTER_NOTIFY_TO_EMAIL', runtimeEnv) ||
readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
if (!resendApiKey || !fromEmail) {
if (!emailBinding || !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).',
'Live mode requires EMAIL binding (Cloudflare Email Service) and NEWSLETTER_FROM_EMAIL (or CONTACT_FORM_FROM_EMAIL).',
{ status: 500 },
);
}
@ -171,10 +187,9 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
.run();
}
const copy = confirmEmailCopy(locale, confirmUrl);
const copy = confirmEmailCopy(locale, confirmUrl, unsubscribeUrl);
try {
await sendWithResend({
apiKey: resendApiKey,
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: email,
subject: copy.subject,
@ -187,8 +202,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
if (notifyTo) {
try {
await sendWithResend({
apiKey: resendApiKey,
await sendCloudflareEmail(emailBinding, {
fromEmail,
toEmail: notifyTo,
subject: required(form.get('_subject')) || 'Nuova richiesta newsletter — NexStudio',

View File

@ -1,5 +1,10 @@
---
import SubpageLayout from '../../layouts/SubpageLayout.astro';
const unsubToken = Astro.url.searchParams.get('unsub')?.trim() ?? '';
const unsubHref = unsubToken
? `/api/newsletter/unsubscribe?token=${encodeURIComponent(unsubToken)}`
: '';
---
<SubpageLayout
@ -10,8 +15,19 @@ import SubpageLayout from '../../layouts/SubpageLayout.astro';
>
<p>
Puoi disiscriverti in qualsiasi momento dal link presente in fondo a ogni
messaggio.
messaggio della newsletter.
</p>
{
unsubHref ? (
<p>
Oppure disiscriviti subito:{' '}
<a class="text-sky-400/90 hover:text-sky-300" href={unsubHref}>
Annulla iscrizione
</a>
.
</p>
) : null
}
<p>
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
</p>

View File

@ -17,5 +17,10 @@
"database_id": "f2b30e34-6e63-4df8-8f92-f0a0dad08cda",
"migrations_dir": "migrations"
}
],
"send_email": [
{
"name": "EMAIL"
}
]
}