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:
Javaxman
2026-07-28 23:23:19 +02:00
parent 9a8566a3f5
commit 7aff1abcfb
27 changed files with 1008 additions and 18 deletions

146
src/lib/newsletter.ts Normal file
View File

@ -0,0 +1,146 @@
/**
* Shared helpers for newsletter API routes (Cloudflare Worker / Astro).
*/
export type RuntimeEnv = Record<string, unknown>;
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<boolean> {
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<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 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();
}