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

View File

@ -0,0 +1,178 @@
import type { APIRoute } from 'astro';
import {
absoluteUrl,
getNewsletterDb,
getRuntimeEnv,
readEnv,
sendWithResend,
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 resendApiKey =
readEnv('NEWSLETTER_RESEND_API_KEY', runtimeEnv) ||
readEnv('CONTACT_FORM_RESEND_API_KEY', 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.', {
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 sendWithResend({
apiKey: resendApiKey,
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,
});
};