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:
146
src/lib/newsletter.ts
Normal file
146
src/lib/newsletter.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user