6 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
6 changed files with 56 additions and 39 deletions

View File

@ -1,6 +1,7 @@
/**
* Outbound email via Cloudflare Email Service (`send_email` binding).
*/
import { env as workerEnv } from 'cloudflare:workers';
export type RuntimeEnv = Record<string, unknown>;
@ -15,8 +16,9 @@ type EmailBinding = {
}) => Promise<{ messageId: string }>;
};
export function getEmailBinding(runtimeEnv: RuntimeEnv): EmailBinding | null {
const binding = runtimeEnv.EMAIL;
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;
}

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 {

View File

@ -1,24 +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 {
@ -69,8 +58,8 @@ 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 fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);

View File

@ -79,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

@ -18,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',
@ -29,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'),
};
}
@ -42,6 +49,9 @@ function confirmEmailCopy(locale: string, confirmUrl: string): { subject: string
confirmUrl,
'',
'หากคุณไม่ได้ขอสมัคร สามารถเพิกเฉยต่อข้อความนี้ได้',
'',
'ยกเลิกการสมัคร:',
unsubscribeUrl,
].join('\n'),
};
}
@ -54,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'),
};
}
@ -83,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(
@ -102,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', {
@ -170,7 +187,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
.run();
}
const copy = confirmEmailCopy(locale, confirmUrl);
const copy = confirmEmailCopy(locale, confirmUrl, unsubscribeUrl);
try {
await sendCloudflareEmail(emailBinding, {
fromEmail,

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>