Email marketing V2
This commit is contained in:
@@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[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);
|
||||
}
|
||||
}
|
||||
|
||||
39
src/app/(main)/api/marketing/newsletter/suppress/route.ts
Normal file
39
src/app/(main)/api/marketing/newsletter/suppress/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user