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:
178
src/pages/api/newsletter/admin/send.ts
Normal file
178
src/pages/api/newsletter/admin/send.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user