Compare commits
6 Commits
fix/astro6
...
chore/test
| Author | SHA1 | Date | |
|---|---|---|---|
| 02adea3e75 | |||
| 7befcf7804 | |||
| d33258c41a | |||
| 35f7d207dc | |||
| 73a9e6aa20 | |||
| e097d3fe7f |
16
.env.example
16
.env.example
@ -2,20 +2,12 @@ PUBLIC_CONTACT_FORM_ACTION_URL=/api/contact
|
|||||||
PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT=http://localhost:4321/#contatti
|
PUBLIC_CONTACT_FORM_SUCCESS_REDIRECT=http://localhost:4321/#contatti
|
||||||
PUBLIC_CONTACT_FORM_MAILTO=
|
PUBLIC_CONTACT_FORM_MAILTO=
|
||||||
PUBLIC_CONTACT_TURNSTILE_SITE_KEY=
|
PUBLIC_CONTACT_TURNSTILE_SITE_KEY=
|
||||||
PUBLIC_SITE_URL=http://localhost:4321
|
|
||||||
|
|
||||||
CONTACT_FORM_MODE=dev
|
CONTACT_FORM_MODE=dev
|
||||||
CONTACT_FORM_FROM_EMAIL=no-reply@nexstudio.ai
|
CONTACT_FORM_RESEND_API_KEY=
|
||||||
CONTACT_FORM_TO_EMAIL=info@nexstudio.ai
|
CONTACT_FORM_FROM_EMAIL=no-reply@nexstudio.com
|
||||||
CONTACT_FORM_TO_EMAIL_INFO=info@nexstudio.ai
|
CONTACT_FORM_TO_EMAIL=info@nexstudio.com
|
||||||
|
CONTACT_FORM_TO_EMAIL_INFO=info@nexstudio.com
|
||||||
CONTACT_FORM_TO_EMAIL_PRIVACY=
|
CONTACT_FORM_TO_EMAIL_PRIVACY=
|
||||||
CONTACT_FORM_TO_EMAIL_CAREERS=
|
CONTACT_FORM_TO_EMAIL_CAREERS=
|
||||||
CONTACT_TURNSTILE_SECRET_KEY=
|
CONTACT_TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
# 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_FROM_EMAIL=no-reply@nexstudio.ai
|
|
||||||
NEWSLETTER_NOTIFY_TO_EMAIL=info@nexstudio.ai
|
|
||||||
NEWSLETTER_ADMIN_TOKEN=
|
|
||||||
NEWSLETTER_TURNSTILE_SECRET_KEY=
|
|
||||||
|
|||||||
49
README.md
49
README.md
@ -15,7 +15,7 @@ Sito istituzionale NexStudio basato su Astro, con deploy su Cloudflare e form co
|
|||||||
- Adapter Cloudflare (`@astrojs/cloudflare`)
|
- Adapter Cloudflare (`@astrojs/cloudflare`)
|
||||||
- Sitemap (`@astrojs/sitemap`)
|
- Sitemap (`@astrojs/sitemap`)
|
||||||
- Form backend su endpoint `/api/contact`
|
- Form backend su endpoint `/api/contact`
|
||||||
- Turnstile + Cloudflare Email Service per anti-spam e invio email
|
- Turnstile + Resend per anti-spam e invio email
|
||||||
|
|
||||||
## Struttura essenziale
|
## Struttura essenziale
|
||||||
|
|
||||||
@ -29,7 +29,8 @@ Sito istituzionale NexStudio basato su Astro, con deploy su Cloudflare e form co
|
|||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- npm 10+
|
- npm 10+
|
||||||
- Account Cloudflare (Workers + Turnstile + Email Service con dominio onboardato, es. `nexstudio.ai`)
|
- Account Cloudflare (Pages/Workers + Turnstile)
|
||||||
|
- Account Resend (API key + sender verificato)
|
||||||
|
|
||||||
## Avvio locale
|
## Avvio locale
|
||||||
|
|
||||||
@ -62,7 +63,8 @@ Variabili pubbliche (frontend):
|
|||||||
Variabili server (Cloudflare/local):
|
Variabili server (Cloudflare/local):
|
||||||
|
|
||||||
- `CONTACT_FORM_MODE` (`dev` oppure `live`)
|
- `CONTACT_FORM_MODE` (`dev` oppure `live`)
|
||||||
- `CONTACT_FORM_FROM_EMAIL` (consigliato: `no-reply@nexstudio.ai`, dominio verificato su Email Service)
|
- `CONTACT_FORM_RESEND_API_KEY`
|
||||||
|
- `CONTACT_FORM_FROM_EMAIL` (consigliato: `no-reply@nexstudio.ai`)
|
||||||
- `CONTACT_FORM_TO_EMAIL` (default destinatario, es. `info@nexstudio.ai`)
|
- `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_INFO` (opzionale, override esplicito per area "info")
|
||||||
- `CONTACT_FORM_TO_EMAIL_PRIVACY`
|
- `CONTACT_FORM_TO_EMAIL_PRIVACY`
|
||||||
@ -72,7 +74,7 @@ Variabili server (Cloudflare/local):
|
|||||||
Comportamento:
|
Comportamento:
|
||||||
|
|
||||||
- `dev`: accetta submit e scrive payload nei log (senza invio reale)
|
- `dev`: accetta submit e scrive payload nei log (senza invio reale)
|
||||||
- `live`: valida Turnstile e invia email via binding `EMAIL` (Cloudflare Email Service)
|
- `live`: valida Turnstile e invia email via Resend
|
||||||
|
|
||||||
Routing pubblico consigliato (portale vetrina):
|
Routing pubblico consigliato (portale vetrina):
|
||||||
|
|
||||||
@ -87,45 +89,6 @@ Indirizzi consigliati portale:
|
|||||||
- `careers@nexstudio.ai`
|
- `careers@nexstudio.ai`
|
||||||
- `no-reply@nexstudio.ai` (solo invio comunicazioni/newsletter, non destinazione inbound)
|
- `no-reply@nexstudio.ai` (solo invio comunicazioni/newsletter, non destinazione inbound)
|
||||||
|
|
||||||
## Configurazione newsletter
|
|
||||||
|
|
||||||
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_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)
|
|
||||||
- `NEWSLETTER_TURNSTILE_SECRET_KEY` (opzionale; fallback: `CONTACT_TURNSTILE_SECRET_KEY`)
|
|
||||||
- `PUBLIC_SITE_URL` (origine usata nei link di conferma/disiscrizione)
|
|
||||||
|
|
||||||
Endpoint pubblici:
|
|
||||||
|
|
||||||
- `POST /api/newsletter/subscribe` — iscrizione (form footer)
|
|
||||||
- `GET /api/newsletter/confirm?token=…` — conferma double opt-in
|
|
||||||
- `GET|POST /api/newsletter/unsubscribe?token=…` — disiscrizione
|
|
||||||
|
|
||||||
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`)
|
|
||||||
- Migrazioni in `migrations/`
|
|
||||||
- `npm run db:migrate:local` / `npm run db:migrate:remote`
|
|
||||||
|
|
||||||
Comportamento:
|
|
||||||
|
|
||||||
- `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
|
## Deploy Cloudflare
|
||||||
|
|
||||||
Impostazioni consigliate:
|
Impostazioni consigliate:
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
-- Newsletter subscribers and campaign send log
|
|
||||||
CREATE TABLE IF NOT EXISTS newsletter_subscribers (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
email TEXT NOT NULL COLLATE NOCASE,
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('pending', 'active', 'unsubscribed')),
|
|
||||||
locale TEXT NOT NULL DEFAULT 'it',
|
|
||||||
confirm_token TEXT,
|
|
||||||
unsubscribe_token TEXT NOT NULL,
|
|
||||||
source TEXT NOT NULL DEFAULT 'footer',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
confirmed_at TEXT,
|
|
||||||
unsubscribed_at TEXT,
|
|
||||||
UNIQUE (email)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_status
|
|
||||||
ON newsletter_subscribers (status);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_confirm_token
|
|
||||||
ON newsletter_subscribers (confirm_token);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_unsubscribe_token
|
|
||||||
ON newsletter_subscribers (unsubscribe_token);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS newsletter_campaigns (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
subject TEXT NOT NULL,
|
|
||||||
body_text TEXT NOT NULL,
|
|
||||||
body_html TEXT,
|
|
||||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'sending', 'sent', 'failed')),
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
sent_at TEXT,
|
|
||||||
created_by TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS newsletter_campaign_sends (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
campaign_id INTEGER NOT NULL,
|
|
||||||
subscriber_id INTEGER NOT NULL,
|
|
||||||
email TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL CHECK (status IN ('queued', 'sent', 'failed', 'skipped')),
|
|
||||||
error TEXT,
|
|
||||||
sent_at TEXT,
|
|
||||||
FOREIGN KEY (campaign_id) REFERENCES newsletter_campaigns (id),
|
|
||||||
FOREIGN KEY (subscriber_id) REFERENCES newsletter_subscribers (id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_newsletter_campaign_sends_campaign
|
|
||||||
ON newsletter_campaign_sends (campaign_id);
|
|
||||||
@ -12,9 +12,7 @@
|
|||||||
"deploy:cf": "npm run build && wrangler deploy --config dist/server/wrangler.json",
|
"deploy:cf": "npm run build && wrangler deploy --config dist/server/wrangler.json",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"generate-types": "wrangler types",
|
"generate-types": "wrangler types"
|
||||||
"db:migrate:local": "wrangler d1 migrations apply nexstudio-newsletter --local",
|
|
||||||
"db:migrate:remote": "wrangler d1 migrations apply nexstudio-newsletter --remote"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/alpinejs": "^0.5.0",
|
"@astrojs/alpinejs": "^0.5.0",
|
||||||
|
|||||||
@ -166,8 +166,6 @@ const withLocale = (href: string) => {
|
|||||||
{notifySubject ? (
|
{notifySubject ? (
|
||||||
<input type="hidden" name="_subject" value={notifySubject} />
|
<input type="hidden" name="_subject" value={notifySubject} />
|
||||||
) : null}
|
) : null}
|
||||||
<input type="hidden" name="locale" value={currentLocale} />
|
|
||||||
<input type="hidden" name="source" value="footer" />
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="_gotcha"
|
name="_gotcha"
|
||||||
|
|||||||
@ -45,9 +45,9 @@ const footerByLocale = {
|
|||||||
description: 'Aggiornamenti su prodotti e roadmap.',
|
description: 'Aggiornamenti su prodotti e roadmap.',
|
||||||
placeholder: 'La tua email',
|
placeholder: 'La tua email',
|
||||||
buttonLabel: 'Iscriviti',
|
buttonLabel: 'Iscriviti',
|
||||||
actionUrl: '/api/newsletter/subscribe',
|
actionUrl: '',
|
||||||
fieldName: 'email',
|
fieldName: 'email',
|
||||||
successRedirect: '/newsletter/grazie',
|
successRedirect: '',
|
||||||
notifySubject: 'Nuova iscrizione newsletter — NexStudio',
|
notifySubject: 'Nuova iscrizione newsletter — NexStudio',
|
||||||
},
|
},
|
||||||
labels: {
|
labels: {
|
||||||
@ -101,9 +101,9 @@ const footerByLocale = {
|
|||||||
description: 'Updates on products and roadmap.',
|
description: 'Updates on products and roadmap.',
|
||||||
placeholder: 'Your email',
|
placeholder: 'Your email',
|
||||||
buttonLabel: 'Subscribe',
|
buttonLabel: 'Subscribe',
|
||||||
actionUrl: '/api/newsletter/subscribe',
|
actionUrl: '',
|
||||||
fieldName: 'email',
|
fieldName: 'email',
|
||||||
successRedirect: '/en/newsletter/grazie',
|
successRedirect: '',
|
||||||
notifySubject: 'New newsletter signup — NexStudio',
|
notifySubject: 'New newsletter signup — NexStudio',
|
||||||
},
|
},
|
||||||
labels: {
|
labels: {
|
||||||
@ -157,9 +157,9 @@ const footerByLocale = {
|
|||||||
description: 'อัปเดตเกี่ยวกับผลิตภัณฑ์และโรดแมป',
|
description: 'อัปเดตเกี่ยวกับผลิตภัณฑ์และโรดแมป',
|
||||||
placeholder: 'อีเมลของคุณ',
|
placeholder: 'อีเมลของคุณ',
|
||||||
buttonLabel: 'สมัครรับข่าวสาร',
|
buttonLabel: 'สมัครรับข่าวสาร',
|
||||||
actionUrl: '/api/newsletter/subscribe',
|
actionUrl: '',
|
||||||
fieldName: 'email',
|
fieldName: 'email',
|
||||||
successRedirect: '/th/newsletter/grazie',
|
successRedirect: '',
|
||||||
notifySubject: 'มีผู้สมัครจดหมายข่าวใหม่ — NexStudio',
|
notifySubject: 'มีผู้สมัครจดหมายข่าวใหม่ — NexStudio',
|
||||||
},
|
},
|
||||||
labels: {
|
labels: {
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared helpers for newsletter API routes (Cloudflare Worker / Astro).
|
|
||||||
*/
|
|
||||||
import { env as workerEnv } from 'cloudflare:workers';
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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 {
|
|
||||||
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 { getEmailBinding, sendCloudflareEmail } from './email';
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,13 +1,23 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { env as workerEnv } from 'cloudflare:workers';
|
|
||||||
import { getEmailBinding, sendCloudflareEmail } from '../../lib/email';
|
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
type RuntimeEnv = Record<string, unknown>;
|
type RuntimeEnv = Record<string, unknown>;
|
||||||
|
|
||||||
function getRuntimeEnv(): RuntimeEnv {
|
function getRuntimeEnv(locals: unknown): RuntimeEnv {
|
||||||
return workerEnv as unknown as 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 readEnv(name: string, runtimeEnv: RuntimeEnv): string {
|
function readEnv(name: string, runtimeEnv: RuntimeEnv): string {
|
||||||
@ -48,6 +58,27 @@ async function verifyTurnstile(token: string, secret: string, ip?: string | null
|
|||||||
return payload.success === true;
|
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 {
|
function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: string, department: string): string {
|
||||||
const normalized = department.trim().toLowerCase();
|
const normalized = department.trim().toLowerCase();
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@ -58,9 +89,10 @@ function resolveDepartmentRecipient(runtimeEnv: RuntimeEnv, defaultRecipient: st
|
|||||||
return map[normalized] || defaultRecipient;
|
return map[normalized] || defaultRecipient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, url }) => {
|
export const POST: APIRoute = async ({ request, locals, url }) => {
|
||||||
const runtimeEnv = getRuntimeEnv();
|
const runtimeEnv = getRuntimeEnv(locals);
|
||||||
const mode = readEnv('CONTACT_FORM_MODE', runtimeEnv).toLowerCase() || 'dev';
|
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 fromEmail = readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
|
||||||
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
|
const toEmail = readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
|
||||||
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
|
const turnstileSecret = readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
|
||||||
@ -109,23 +141,13 @@ export const POST: APIRoute = async ({ request, url }) => {
|
|||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
if (mode === 'live') {
|
if (mode === 'live') {
|
||||||
const emailBinding = getEmailBinding(runtimeEnv);
|
if (!resendApiKey || !fromEmail || !toEmail) {
|
||||||
if (!emailBinding || !fromEmail || !toEmail) {
|
return new Response('Live mode requires CONTACT_FORM_RESEND_API_KEY, CONTACT_FORM_FROM_EMAIL and CONTACT_FORM_TO_EMAIL.', {
|
||||||
return new Response(
|
status: 500,
|
||||||
'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);
|
const destination = resolveDepartmentRecipient(runtimeEnv, toEmail, department);
|
||||||
await sendCloudflareEmail(emailBinding, {
|
await sendWithResend(resendApiKey, fromEmail, destination, subject, text);
|
||||||
fromEmail,
|
|
||||||
toEmail: destination,
|
|
||||||
subject,
|
|
||||||
text,
|
|
||||||
replyTo: email,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.info('[contact:dev] Contact payload received', {
|
console.info('[contact:dev] Contact payload received', {
|
||||||
name,
|
name,
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import {
|
|
||||||
absoluteUrl,
|
|
||||||
getEmailBinding,
|
|
||||||
getNewsletterDb,
|
|
||||||
getRuntimeEnv,
|
|
||||||
readEnv,
|
|
||||||
sendCloudflareEmail,
|
|
||||||
siteOrigin,
|
|
||||||
type NewsletterSubscriber,
|
|
||||||
} from '../../../../lib/newsletter';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
type SendBody = {
|
|
||||||
subject?: string;
|
|
||||||
text?: string;
|
|
||||||
html?: string;
|
|
||||||
dryRun?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function unauthorized(): Response {
|
|
||||||
return new Response('Unauthorized', { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorize(request: Request, runtimeEnv: ReturnType<typeof getRuntimeEnv>): boolean {
|
|
||||||
const expected = readEnv('NEWSLETTER_ADMIN_TOKEN', runtimeEnv);
|
|
||||||
if (!expected) return false;
|
|
||||||
const header = request.headers.get('authorization') || '';
|
|
||||||
const bearer = header.toLowerCase().startsWith('bearer ')
|
|
||||||
? header.slice(7).trim()
|
|
||||||
: '';
|
|
||||||
return bearer === expected;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
||||||
const runtimeEnv = getRuntimeEnv(locals);
|
|
||||||
if (!authorize(request, runtimeEnv)) return unauthorized();
|
|
||||||
|
|
||||||
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
|
|
||||||
let body: SendBody;
|
|
||||||
try {
|
|
||||||
body = (await request.json()) as SendBody;
|
|
||||||
} catch {
|
|
||||||
return new Response('Invalid JSON body.', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = (body.subject || '').trim();
|
|
||||||
const text = (body.text || '').trim();
|
|
||||||
const html = (body.html || '').trim();
|
|
||||||
if (!subject || !text) {
|
|
||||||
return new Response('Fields subject and text are required.', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode !== 'live') {
|
|
||||||
console.info('[newsletter:dev] Campaign send requested', {
|
|
||||||
subject,
|
|
||||||
dryRun: Boolean(body.dryRun),
|
|
||||||
textPreview: text.slice(0, 120),
|
|
||||||
});
|
|
||||||
return Response.json({
|
|
||||||
mode,
|
|
||||||
accepted: true,
|
|
||||||
sent: 0,
|
|
||||||
failed: 0,
|
|
||||||
note: 'Dev mode: campaign not sent.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getNewsletterDb(runtimeEnv);
|
|
||||||
if (!db) {
|
|
||||||
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailBinding = getEmailBinding(runtimeEnv);
|
|
||||||
const fromEmail =
|
|
||||||
readEnv('NEWSLETTER_FROM_EMAIL', runtimeEnv) ||
|
|
||||||
readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
|
|
||||||
|
|
||||||
if (!emailBinding || !fromEmail) {
|
|
||||||
return new Response('Missing EMAIL binding or from email for newsletter send.', {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const subscribersResult = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM newsletter_subscribers
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY id ASC`,
|
|
||||||
)
|
|
||||||
.all<NewsletterSubscriber>();
|
|
||||||
const subscribers = subscribersResult.results ?? [];
|
|
||||||
|
|
||||||
const campaignInsert = await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO newsletter_campaigns (subject, body_text, body_html, status, created_by)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
.bind(subject, text, html || null, body.dryRun ? 'draft' : 'sending', 'admin-api')
|
|
||||||
.run();
|
|
||||||
|
|
||||||
const campaignId = Number(campaignInsert.meta.last_row_id);
|
|
||||||
const origin = siteOrigin(url, runtimeEnv);
|
|
||||||
|
|
||||||
if (body.dryRun) {
|
|
||||||
return Response.json({
|
|
||||||
dryRun: true,
|
|
||||||
campaignId,
|
|
||||||
recipients: subscribers.length,
|
|
||||||
sample: subscribers.slice(0, 5).map((s) => s.email),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let sent = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const subscriber of subscribers) {
|
|
||||||
const unsubUrl = absoluteUrl(
|
|
||||||
origin,
|
|
||||||
`/api/newsletter/unsubscribe?token=${subscriber.unsubscribe_token}`,
|
|
||||||
);
|
|
||||||
const personalizedText = `${text}\n\n---\nDisiscrizione: ${unsubUrl}\n`;
|
|
||||||
const personalizedHtml = html
|
|
||||||
? `${html}<hr /><p style="font-size:12px;color:#666">Disiscrizione: <a href="${unsubUrl}">${unsubUrl}</a></p>`
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendCloudflareEmail(emailBinding, {
|
|
||||||
fromEmail,
|
|
||||||
toEmail: subscriber.email,
|
|
||||||
subject,
|
|
||||||
text: personalizedText,
|
|
||||||
html: personalizedHtml,
|
|
||||||
});
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO newsletter_campaign_sends
|
|
||||||
(campaign_id, subscriber_id, email, status, sent_at)
|
|
||||||
VALUES (?, ?, ?, 'sent', datetime('now'))`,
|
|
||||||
)
|
|
||||||
.bind(campaignId, subscriber.id, subscriber.email)
|
|
||||||
.run();
|
|
||||||
sent += 1;
|
|
||||||
} catch (error) {
|
|
||||||
failed += 1;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO newsletter_campaign_sends
|
|
||||||
(campaign_id, subscriber_id, email, status, error, sent_at)
|
|
||||||
VALUES (?, ?, ?, 'failed', ?, datetime('now'))`,
|
|
||||||
)
|
|
||||||
.bind(campaignId, subscriber.id, subscriber.email, message.slice(0, 500))
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE newsletter_campaigns
|
|
||||||
SET status = ?, sent_at = datetime('now')
|
|
||||||
WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.bind(failed > 0 && sent === 0 ? 'failed' : 'sent', campaignId)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
campaignId,
|
|
||||||
recipients: subscribers.length,
|
|
||||||
sent,
|
|
||||||
failed,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import {
|
|
||||||
getNewsletterDb,
|
|
||||||
getRuntimeEnv,
|
|
||||||
readEnv,
|
|
||||||
type NewsletterSubscriber,
|
|
||||||
} from '../../../../lib/newsletter';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
function unauthorized(): Response {
|
|
||||||
return new Response('Unauthorized', { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorize(request: Request, runtimeEnv: ReturnType<typeof getRuntimeEnv>): boolean {
|
|
||||||
const expected = readEnv('NEWSLETTER_ADMIN_TOKEN', runtimeEnv);
|
|
||||||
if (!expected) return false;
|
|
||||||
const header = request.headers.get('authorization') || '';
|
|
||||||
const bearer = header.toLowerCase().startsWith('bearer ')
|
|
||||||
? header.slice(7).trim()
|
|
||||||
: '';
|
|
||||||
const queryToken = new URL(request.url).searchParams.get('token') || '';
|
|
||||||
return bearer === expected || queryToken === expected;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request, locals }) => {
|
|
||||||
const runtimeEnv = getRuntimeEnv(locals);
|
|
||||||
if (!authorize(request, runtimeEnv)) return unauthorized();
|
|
||||||
|
|
||||||
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
|
|
||||||
if (mode !== 'live') {
|
|
||||||
return Response.json({
|
|
||||||
mode,
|
|
||||||
subscribers: [],
|
|
||||||
note: 'Dev mode: no database query performed.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getNewsletterDb(runtimeEnv);
|
|
||||||
if (!db) {
|
|
||||||
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = new URL(request.url).searchParams.get('status');
|
|
||||||
let rows: NewsletterSubscriber[];
|
|
||||||
if (status === 'pending' || status === 'active' || status === 'unsubscribed') {
|
|
||||||
const result = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT id, email, status, locale, source, created_at, confirmed_at, unsubscribed_at
|
|
||||||
FROM newsletter_subscribers
|
|
||||||
WHERE status = ?
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
)
|
|
||||||
.bind(status)
|
|
||||||
.all<NewsletterSubscriber>();
|
|
||||||
rows = result.results ?? [];
|
|
||||||
} else {
|
|
||||||
const result = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT id, email, status, locale, source, created_at, confirmed_at, unsubscribed_at
|
|
||||||
FROM newsletter_subscribers
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
)
|
|
||||||
.all<NewsletterSubscriber>();
|
|
||||||
rows = result.results ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
count: rows.length,
|
|
||||||
subscribers: rows,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import {
|
|
||||||
getEmailBinding,
|
|
||||||
getNewsletterDb,
|
|
||||||
getRuntimeEnv,
|
|
||||||
readEnv,
|
|
||||||
required,
|
|
||||||
sendCloudflareEmail,
|
|
||||||
type NewsletterSubscriber,
|
|
||||||
} from '../../../lib/newsletter';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request, locals, url }) => {
|
|
||||||
const runtimeEnv = getRuntimeEnv(locals);
|
|
||||||
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
|
|
||||||
const token = required(url.searchParams.get('token'));
|
|
||||||
const okPath = '/newsletter/confermata';
|
|
||||||
const failPath = '/newsletter/conferma-errore';
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return Response.redirect(new URL(failPath, url).toString(), 303);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode !== 'live') {
|
|
||||||
console.info('[newsletter:dev] Confirm token received', { token: token.slice(0, 8) + '…' });
|
|
||||||
return Response.redirect(new URL(okPath, url).toString(), 303);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getNewsletterDb(runtimeEnv);
|
|
||||||
if (!db) {
|
|
||||||
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM newsletter_subscribers
|
|
||||||
WHERE confirm_token = ? AND status = 'pending'
|
|
||||||
LIMIT 1`,
|
|
||||||
)
|
|
||||||
.bind(token)
|
|
||||||
.first<NewsletterSubscriber>();
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
return Response.redirect(new URL(failPath, url).toString(), 303);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE newsletter_subscribers
|
|
||||||
SET status = 'active',
|
|
||||||
confirmed_at = datetime('now'),
|
|
||||||
confirm_token = NULL
|
|
||||||
WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.bind(row.id)
|
|
||||||
.run();
|
|
||||||
|
|
||||||
const emailBinding = getEmailBinding(runtimeEnv);
|
|
||||||
const fromEmail =
|
|
||||||
readEnv('NEWSLETTER_FROM_EMAIL', runtimeEnv) ||
|
|
||||||
readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
|
|
||||||
const notifyTo =
|
|
||||||
readEnv('NEWSLETTER_NOTIFY_TO_EMAIL', runtimeEnv) ||
|
|
||||||
readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
|
|
||||||
|
|
||||||
if (emailBinding && fromEmail && notifyTo) {
|
|
||||||
try {
|
|
||||||
await sendCloudflareEmail(emailBinding, {
|
|
||||||
fromEmail,
|
|
||||||
toEmail: notifyTo,
|
|
||||||
subject: 'Iscrizione newsletter confermata — NexStudio',
|
|
||||||
text: `L’indirizzo ${row.email} ha confermato l’iscrizione alla newsletter.`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[newsletter] confirm notify failed', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.redirect(new URL(okPath, request.url).toString(), 303);
|
|
||||||
};
|
|
||||||
@ -1,205 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import {
|
|
||||||
absoluteUrl,
|
|
||||||
createToken,
|
|
||||||
getEmailBinding,
|
|
||||||
getNewsletterDb,
|
|
||||||
getRuntimeEnv,
|
|
||||||
isValidEmail,
|
|
||||||
normalizeEmail,
|
|
||||||
readEnv,
|
|
||||||
required,
|
|
||||||
sanitizeRedirect,
|
|
||||||
sendCloudflareEmail,
|
|
||||||
siteOrigin,
|
|
||||||
verifyTurnstile,
|
|
||||||
type NewsletterSubscriber,
|
|
||||||
} from '../../../lib/newsletter';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
function confirmEmailCopy(locale: string, confirmUrl: string): { subject: string; text: string } {
|
|
||||||
if (locale === 'en') {
|
|
||||||
return {
|
|
||||||
subject: 'Confirm your NexStudio newsletter subscription',
|
|
||||||
text: [
|
|
||||||
'Thanks for subscribing to the NexStudio newsletter.',
|
|
||||||
'',
|
|
||||||
'Confirm your email by opening this link:',
|
|
||||||
confirmUrl,
|
|
||||||
'',
|
|
||||||
'If you did not request this, you can ignore this message.',
|
|
||||||
].join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (locale === 'th') {
|
|
||||||
return {
|
|
||||||
subject: 'ยืนยันการสมัครรับจดหมายข่าว NexStudio',
|
|
||||||
text: [
|
|
||||||
'ขอบคุณที่สมัครรับจดหมายข่าว NexStudio',
|
|
||||||
'',
|
|
||||||
'ยืนยันอีเมลของคุณโดยเปิดลิงก์นี้:',
|
|
||||||
confirmUrl,
|
|
||||||
'',
|
|
||||||
'หากคุณไม่ได้ขอสมัคร สามารถเพิกเฉยต่อข้อความนี้ได้',
|
|
||||||
].join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
subject: 'Conferma iscrizione newsletter NexStudio',
|
|
||||||
text: [
|
|
||||||
'Grazie per esserti iscritto alla newsletter di NexStudio.',
|
|
||||||
'',
|
|
||||||
'Conferma l’indirizzo email aprendo questo link:',
|
|
||||||
confirmUrl,
|
|
||||||
'',
|
|
||||||
'Se non hai richiesto l’iscrizione, puoi ignorare questo messaggio.',
|
|
||||||
].join('\n'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
||||||
const runtimeEnv = getRuntimeEnv(locals);
|
|
||||||
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
|
|
||||||
const db = getNewsletterDb(runtimeEnv);
|
|
||||||
const form = await request.formData();
|
|
||||||
|
|
||||||
const redirectOk =
|
|
||||||
sanitizeRedirect(required(form.get('_next')), url) ||
|
|
||||||
'/newsletter/grazie';
|
|
||||||
const redirectErr = sanitizeRedirect(required(form.get('_error')), url) || redirectOk;
|
|
||||||
|
|
||||||
// Honeypot
|
|
||||||
if (required(form.get('_gotcha'))) {
|
|
||||||
return Response.redirect(new URL(redirectOk, url).toString(), 303);
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailRaw = required(form.get('email'));
|
|
||||||
const locale = required(form.get('locale')) || 'it';
|
|
||||||
const source = required(form.get('source')) || 'footer';
|
|
||||||
|
|
||||||
if (!emailRaw || !isValidEmail(emailRaw)) {
|
|
||||||
return new Response('Invalid email.', { status: 400 });
|
|
||||||
}
|
|
||||||
const email = normalizeEmail(emailRaw);
|
|
||||||
|
|
||||||
const turnstileSecret =
|
|
||||||
readEnv('NEWSLETTER_TURNSTILE_SECRET_KEY', runtimeEnv) ||
|
|
||||||
readEnv('CONTACT_TURNSTILE_SECRET_KEY', runtimeEnv);
|
|
||||||
if (turnstileSecret) {
|
|
||||||
const turnstileToken = required(form.get('cf-turnstile-response'));
|
|
||||||
const isHuman = await verifyTurnstile(
|
|
||||||
turnstileToken,
|
|
||||||
turnstileSecret,
|
|
||||||
request.headers.get('CF-Connecting-IP'),
|
|
||||||
);
|
|
||||||
if (!isHuman) {
|
|
||||||
return new Response('Turnstile verification failed.', { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmToken = createToken();
|
|
||||||
const unsubscribeToken = createToken();
|
|
||||||
const origin = siteOrigin(url, runtimeEnv);
|
|
||||||
const confirmUrl = absoluteUrl(origin, `/api/newsletter/confirm?token=${confirmToken}`);
|
|
||||||
|
|
||||||
if (mode !== 'live') {
|
|
||||||
console.info('[newsletter:dev] Subscription request', {
|
|
||||||
email,
|
|
||||||
locale,
|
|
||||||
source,
|
|
||||||
confirmUrl,
|
|
||||||
});
|
|
||||||
return Response.redirect(new URL(redirectOk, url).toString(), 303);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!db) {
|
|
||||||
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailBinding = getEmailBinding(runtimeEnv);
|
|
||||||
const fromEmail =
|
|
||||||
readEnv('NEWSLETTER_FROM_EMAIL', runtimeEnv) ||
|
|
||||||
readEnv('CONTACT_FORM_FROM_EMAIL', runtimeEnv);
|
|
||||||
const notifyTo =
|
|
||||||
readEnv('NEWSLETTER_NOTIFY_TO_EMAIL', runtimeEnv) ||
|
|
||||||
readEnv('CONTACT_FORM_TO_EMAIL', runtimeEnv);
|
|
||||||
|
|
||||||
if (!emailBinding || !fromEmail) {
|
|
||||||
return new Response(
|
|
||||||
'Live mode requires EMAIL binding (Cloudflare Email Service) and NEWSLETTER_FROM_EMAIL (or CONTACT_FORM_FROM_EMAIL).',
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await db
|
|
||||||
.prepare('SELECT * FROM newsletter_subscribers WHERE email = ? LIMIT 1')
|
|
||||||
.bind(email)
|
|
||||||
.first<NewsletterSubscriber>();
|
|
||||||
|
|
||||||
if (existing?.status === 'active') {
|
|
||||||
// Already subscribed: redirect without revealing status too loudly.
|
|
||||||
return Response.redirect(new URL(redirectOk, url).toString(), 303);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existing?.status === 'unsubscribed' || existing?.status === 'pending') {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE newsletter_subscribers
|
|
||||||
SET status = 'pending',
|
|
||||||
locale = ?,
|
|
||||||
source = ?,
|
|
||||||
confirm_token = ?,
|
|
||||||
unsubscribe_token = ?,
|
|
||||||
confirmed_at = NULL,
|
|
||||||
unsubscribed_at = NULL
|
|
||||||
WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.bind(locale, source, confirmToken, unsubscribeToken, existing.id)
|
|
||||||
.run();
|
|
||||||
} else {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO newsletter_subscribers
|
|
||||||
(email, status, locale, confirm_token, unsubscribe_token, source)
|
|
||||||
VALUES (?, 'pending', ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
.bind(email, locale, confirmToken, unsubscribeToken, source)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
const copy = confirmEmailCopy(locale, confirmUrl);
|
|
||||||
try {
|
|
||||||
await sendCloudflareEmail(emailBinding, {
|
|
||||||
fromEmail,
|
|
||||||
toEmail: email,
|
|
||||||
subject: copy.subject,
|
|
||||||
text: copy.text,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[newsletter] confirm email failed', error);
|
|
||||||
return Response.redirect(new URL(redirectErr, url).toString(), 303);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notifyTo) {
|
|
||||||
try {
|
|
||||||
await sendCloudflareEmail(emailBinding, {
|
|
||||||
fromEmail,
|
|
||||||
toEmail: notifyTo,
|
|
||||||
subject: required(form.get('_subject')) || 'Nuova richiesta newsletter — NexStudio',
|
|
||||||
text: [
|
|
||||||
`Nuova richiesta di iscrizione (pending conferma).`,
|
|
||||||
`Email: ${email}`,
|
|
||||||
`Locale: ${locale}`,
|
|
||||||
`Source: ${source}`,
|
|
||||||
].join('\n'),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[newsletter] admin notify failed', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.redirect(new URL(redirectOk, url).toString(), 303);
|
|
||||||
};
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
import type { APIRoute } from 'astro';
|
|
||||||
import {
|
|
||||||
getNewsletterDb,
|
|
||||||
getRuntimeEnv,
|
|
||||||
readEnv,
|
|
||||||
required,
|
|
||||||
type NewsletterSubscriber,
|
|
||||||
} from '../../../lib/newsletter';
|
|
||||||
|
|
||||||
export const prerender = false;
|
|
||||||
|
|
||||||
async function unsubscribeByToken(
|
|
||||||
locals: unknown,
|
|
||||||
token: string,
|
|
||||||
): Promise<'ok' | 'missing' | 'not_found' | 'no_db'> {
|
|
||||||
if (!token) return 'missing';
|
|
||||||
|
|
||||||
const runtimeEnv = getRuntimeEnv(locals);
|
|
||||||
const mode = (readEnv('NEWSLETTER_MODE', runtimeEnv) || readEnv('CONTACT_FORM_MODE', runtimeEnv) || 'dev').toLowerCase();
|
|
||||||
|
|
||||||
if (mode !== 'live') {
|
|
||||||
console.info('[newsletter:dev] Unsubscribe token received', { token: token.slice(0, 8) + '…' });
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getNewsletterDb(runtimeEnv);
|
|
||||||
if (!db) return 'no_db';
|
|
||||||
|
|
||||||
const row = await db
|
|
||||||
.prepare(
|
|
||||||
`SELECT * FROM newsletter_subscribers
|
|
||||||
WHERE unsubscribe_token = ?
|
|
||||||
LIMIT 1`,
|
|
||||||
)
|
|
||||||
.bind(token)
|
|
||||||
.first<NewsletterSubscriber>();
|
|
||||||
|
|
||||||
if (!row) return 'not_found';
|
|
||||||
|
|
||||||
if (row.status !== 'unsubscribed') {
|
|
||||||
await db
|
|
||||||
.prepare(
|
|
||||||
`UPDATE newsletter_subscribers
|
|
||||||
SET status = 'unsubscribed',
|
|
||||||
unsubscribed_at = datetime('now'),
|
|
||||||
confirm_token = NULL
|
|
||||||
WHERE id = ?`,
|
|
||||||
)
|
|
||||||
.bind(row.id)
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ locals, url }) => {
|
|
||||||
const token = required(url.searchParams.get('token'));
|
|
||||||
const result = await unsubscribeByToken(locals, token);
|
|
||||||
|
|
||||||
if (result === 'no_db') {
|
|
||||||
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const path =
|
|
||||||
result === 'ok' ? '/newsletter/disiscrizione' : '/newsletter/disiscrizione-errore';
|
|
||||||
return Response.redirect(new URL(path, url).toString(), 303);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, url }) => {
|
|
||||||
const form = await request.formData();
|
|
||||||
const token = required(form.get('token')) || required(url.searchParams.get('token'));
|
|
||||||
const result = await unsubscribeByToken(locals, token);
|
|
||||||
|
|
||||||
if (result === 'no_db') {
|
|
||||||
return new Response('Newsletter database binding NEWSLETTER_DB is not configured.', {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const path =
|
|
||||||
result === 'ok' ? '/newsletter/disiscrizione' : '/newsletter/disiscrizione-errore';
|
|
||||||
return Response.redirect(new URL(path, url).toString(), 303);
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/conferma-errore.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/confermata.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/disiscrizione-errore.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/disiscrizione.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -22,7 +22,7 @@ import SubpageLayout from '../layouts/SubpageLayout.astro';
|
|||||||
<h2>Canali di distribuzione</h2>
|
<h2>Canali di distribuzione</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Questa pagina resta il punto di riferimento ufficiale per annunci e changelog sintetici.</li>
|
<li>Questa pagina resta il punto di riferimento ufficiale per annunci e changelog sintetici.</li>
|
||||||
<li>La newsletter è attiva dal footer del sito (iscrizione con conferma email).</li>
|
<li>La newsletter verra attivata appena disponibile il canale comunicazioni dedicato.</li>
|
||||||
<li>I contenuti long-form potranno essere pubblicati anche su canali esterni, con rimando qui.</li>
|
<li>I contenuti long-form potranno essere pubblicati anche su canali esterni, con rimando qui.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
|
||||||
---
|
|
||||||
|
|
||||||
<SubpageLayout
|
|
||||||
title="Conferma non riuscita — Newsletter NexStudio"
|
|
||||||
description="Il link di conferma newsletter non è valido o è già stato usato."
|
|
||||||
heading="Conferma non riuscita"
|
|
||||||
lead="Il link di conferma non è valido, è scaduto o è già stato utilizzato."
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Puoi riprovare iscrivendoti di nuovo dal form newsletter nel footer del
|
|
||||||
sito: riceverai un nuovo messaggio di conferma.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
|
||||||
</p>
|
|
||||||
</SubpageLayout>
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
|
||||||
---
|
|
||||||
|
|
||||||
<SubpageLayout
|
|
||||||
title="Iscrizione confermata — Newsletter NexStudio"
|
|
||||||
description="La tua iscrizione alla newsletter NexStudio è confermata."
|
|
||||||
heading="Iscrizione confermata"
|
|
||||||
lead="Il tuo indirizzo email è ora attivo per ricevere aggiornamenti su prodotti e roadmap NexStudio."
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Puoi disiscriverti in qualsiasi momento dal link presente in fondo a ogni
|
|
||||||
messaggio.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
|
||||||
</p>
|
|
||||||
</SubpageLayout>
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
|
||||||
---
|
|
||||||
|
|
||||||
<SubpageLayout
|
|
||||||
title="Disiscrizione non riuscita — Newsletter NexStudio"
|
|
||||||
description="Il link di disiscrizione newsletter non è valido."
|
|
||||||
heading="Disiscrizione non riuscita"
|
|
||||||
lead="Il link di disiscrizione non è valido o è incompleto."
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Se continui a ricevere messaggi indesiderati, scrivi a
|
|
||||||
<a class="text-sky-400/90 hover:text-sky-300" href="mailto:privacy@nexstudio.com"
|
|
||||||
>privacy@nexstudio.com</a
|
|
||||||
>.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
|
||||||
</p>
|
|
||||||
</SubpageLayout>
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
|
||||||
---
|
|
||||||
|
|
||||||
<SubpageLayout
|
|
||||||
title="Disiscrizione — Newsletter NexStudio"
|
|
||||||
description="Disiscrizione dalla newsletter NexStudio completata."
|
|
||||||
heading="Disiscrizione completata"
|
|
||||||
lead="Non riceverai più la newsletter di NexStudio su questo indirizzo."
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Se ti sei disiscritto per errore, puoi iscriverti di nuovo dal footer del
|
|
||||||
sito e confermare l’email.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
|
||||||
</p>
|
|
||||||
</SubpageLayout>
|
|
||||||
@ -4,14 +4,13 @@ import SubpageLayout from '../../layouts/SubpageLayout.astro';
|
|||||||
|
|
||||||
<SubpageLayout
|
<SubpageLayout
|
||||||
title="Grazie — Newsletter NexStudio"
|
title="Grazie — Newsletter NexStudio"
|
||||||
description="Richiesta di iscrizione alla newsletter ricevuta."
|
description="Iscrizione alla newsletter ricevuta."
|
||||||
heading="Controlla la tua email"
|
heading="Grazie"
|
||||||
lead="Abbiamo ricevuto la richiesta di iscrizione. Per completarla, apri il messaggio di conferma e clicca sul link (double opt-in)."
|
lead="La richiesta di iscrizione è stata inviata. Controlla la posta se è prevista una conferma (double opt-in)."
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
Se non trovi l’email, controlla anche la cartella spam o promozioni. Il link
|
Se usi un servizio con conferma via email, apri il messaggio e segui il
|
||||||
di conferma scade solo se viene generata una nuova richiesta per lo stesso
|
link per completare l’iscrizione.
|
||||||
indirizzo.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
<a class="text-sky-400/90 hover:text-sky-300" href="/">Torna alla home</a>
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/conferma-errore.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/confermata.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/disiscrizione-errore.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
---
|
|
||||||
import Page from '../../newsletter/disiscrizione.astro';
|
|
||||||
---
|
|
||||||
<Page />
|
|
||||||
@ -9,18 +9,5 @@
|
|||||||
},
|
},
|
||||||
"observability": {
|
"observability": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
}
|
||||||
"d1_databases": [
|
|
||||||
{
|
|
||||||
"binding": "NEWSLETTER_DB",
|
|
||||||
"database_name": "nexstudio-newsletter",
|
|
||||||
"database_id": "f2b30e34-6e63-4df8-8f92-f0a0dad08cda",
|
|
||||||
"migrations_dir": "migrations"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"send_email": [
|
|
||||||
{
|
|
||||||
"name": "EMAIL"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user