Email marketing V2

This commit is contained in:
2026-07-29 10:42:21 +02:00
parent 11fdec610f
commit 6fd0ed8522
9 changed files with 256 additions and 53 deletions

View File

@@ -8,6 +8,7 @@ export const maxDuration = 300;
type Audience = 'all_users' | 'newsletter_subscribers';
type Format = 'text' | 'html';
type Recipient = { email: string; name: string | null };
function isAdmin() {
return cookies().get('newsletter-admin')?.value === 'authenticated';
@@ -17,27 +18,44 @@ function isEmail(value: unknown): value is string {
return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
async function getRecipients(audience: Audience) {
function escapeHtml(value: string) {
return value.replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[character] as string);
}
function personalizeContent(content: string, name: string | null | undefined, format: Format) {
const firstName = name?.trim().split(/\s+/)[0] || 'there';
const replacement = format === 'html' ? escapeHtml(firstName) : firstName;
return content.replace(/\{\{\s*first_name\s*\}\}/gi, replacement);
}
async function getRecipients(audience: Audience): Promise<Recipient[]> {
if (audience === 'newsletter_subscribers') {
const subscriptions = await db.newsletterSubscription.findMany({
where: { status: 'subscribed' },
select: { email: true },
orderBy: { createdAt: 'asc' },
});
return subscriptions.map((subscription) => subscription.email);
return subscriptions.map((subscription) => ({ email: subscription.email, name: null }));
}
const suppressions = await db.newsletterSubscription.findMany({
where: { status: 'unsubscribed' },
where: { status: { in: ['unsubscribed', 'bounced'] } },
select: { email: true },
});
const suppressed = new Set(suppressions.map((entry) => entry.email.toLowerCase()));
const users = await db.user.findMany({
select: { email: true },
where: { emailVerified: { not: null } },
select: { email: true, name: true },
orderBy: { createdAt: 'asc' },
});
return users.map((user) => user.email).filter((email) => !suppressed.has(email.toLowerCase()));
return users.filter((user) => !suppressed.has(user.email.toLowerCase()));
}
export async function GET() {
@@ -86,7 +104,7 @@ export async function POST(request: NextRequest) {
await sendNewsletterEmail({
email: body.testEmail,
subject: `[Test] ${subject}`,
content,
content: personalizeContent(content, typeof body.testName === 'string' ? body.testName : null, format),
format,
unsubscribeUrl: createMarketingUnsubscribeUrl(body.testEmail),
});
@@ -102,18 +120,18 @@ export async function POST(request: NextRequest) {
let sent = 0;
const failed: string[] = [];
for (const email of recipients) {
for (const recipient of recipients) {
try {
await sendNewsletterEmail({
email,
email: recipient.email,
subject,
content,
content: personalizeContent(content, recipient.name, format),
format,
unsubscribeUrl: createMarketingUnsubscribeUrl(email),
unsubscribeUrl: createMarketingUnsubscribeUrl(recipient.email),
});
sent++;
} catch (error) {
failed.push(email);
failed.push(recipient.email);
console.error('Newsletter send failed:', error);
}
}

View File

@@ -0,0 +1,39 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
function isAdmin() {
return cookies().get('newsletter-admin')?.value === 'authenticated';
}
function isEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
export async function POST(request: NextRequest) {
if (!isAdmin()) return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 });
try {
const body = await request.json();
const rawEmails: unknown[] = Array.isArray(body.emails) ? body.emails : [];
const emails = Array.from(new Set<string>(rawEmails
.filter((email: unknown): email is string => typeof email === 'string')
.map((email: string) => email.trim().toLowerCase())
.filter(isEmail)));
if (emails.length === 0) {
return NextResponse.json({ error: 'Paste at least one valid email address.' }, { status: 400 });
}
await Promise.all(emails.map((email) => db.newsletterSubscription.upsert({
where: { email },
create: { email, source: 'smtp-bounce', status: 'bounced' },
update: { status: 'bounced', source: 'smtp-bounce' },
})));
return NextResponse.json({ success: true, suppressed: emails.length });
} catch (error) {
console.error('Newsletter bounce suppression error:', error);
return NextResponse.json({ error: 'Unable to suppress these addresses.' }, { status: 500 });
}
}