diff --git a/.env.example b/.env.example index 8ac0af7..72998d5 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index 4f80c1b..f4a223c 100644 --- a/README.md +++ b/README.md @@ -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 `): - `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 diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..47c2652 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,45 @@ +/** + * Outbound email via Cloudflare Email Service (`send_email` binding). + */ + +export type RuntimeEnv = Record; + +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 binding = runtimeEnv.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 { + 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 } : {}), + }); +} diff --git a/src/lib/newsletter.ts b/src/lib/newsletter.ts index 26d33cc..2d3294a 100644 --- a/src/lib/newsletter.ts +++ b/src/lib/newsletter.ts @@ -100,34 +100,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 { - 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); diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 2d86cf7..9e856a7 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -1,4 +1,5 @@ import type { APIRoute } from 'astro'; +import { getEmailBinding, sendCloudflareEmail } from '../../lib/email'; export const prerender = false; @@ -58,27 +59,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 = { @@ -92,7 +72,6 @@ function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: st export const POST: APIRoute = async ({ request, locals, url }) => { const runtimeEnv = getRuntimeEnv(locals); 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 +120,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, diff --git a/src/pages/api/newsletter/admin/send.ts b/src/pages/api/newsletter/admin/send.ts index 9501801..7e5c169 100644 --- a/src/pages/api/newsletter/admin/send.ts +++ b/src/pages/api/newsletter/admin/send.ts @@ -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, diff --git a/src/pages/api/newsletter/confirm.ts b/src/pages/api/newsletter/confirm.ts index ce14d22..852abd9 100644 --- a/src/pages/api/newsletter/confirm.ts +++ b/src/pages/api/newsletter/confirm.ts @@ -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', diff --git a/src/pages/api/newsletter/subscribe.ts b/src/pages/api/newsletter/subscribe.ts index cbadbfa..3f22919 100644 --- a/src/pages/api/newsletter/subscribe.ts +++ b/src/pages/api/newsletter/subscribe.ts @@ -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, @@ -118,9 +119,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 +127,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 }, ); } @@ -173,8 +172,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => { const copy = confirmEmailCopy(locale, confirmUrl); try { - await sendWithResend({ - apiKey: resendApiKey, + await sendCloudflareEmail(emailBinding, { fromEmail, toEmail: email, subject: copy.subject, @@ -187,8 +185,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', diff --git a/wrangler.jsonc b/wrangler.jsonc index 6148489..6bb523c 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -17,5 +17,10 @@ "database_id": "f2b30e34-6e63-4df8-8f92-f0a0dad08cda", "migrations_dir": "migrations" } + ], + "send_email": [ + { + "name": "EMAIL" + } ] }