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

@@ -1,13 +1,12 @@
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import { NextRequest, NextResponse } from 'next/server';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { db } from '@/lib/db';
import { z } from 'zod';
import { csrfProtection } from '@/lib/csrf';
import { rateLimit, getClientIdentifier, RateLimits } from '@/lib/rateLimit';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import { signupSchema, validateRequest } from '@/lib/validationSchemas';
import { sendWelcomeEmail } from '@/lib/email';
import { sendEmailVerificationEmail } from '@/lib/email';
import { sendConversionEvent } from '@/lib/metaConversions';
import {
ATTRIBUTION_COOKIE_NAME,
@@ -95,13 +94,29 @@ export async function POST(request: NextRequest) {
},
});
triggerLifecycleScoring(user.id, 'signup');
// Send welcome email (fire-and-forget - never block signup)
try {
await sendWelcomeEmail(user.email, user.name ?? 'there');
} catch (emailError) {
console.error('Welcome email failed:', emailError);
triggerLifecycleScoring(user.id, 'signup');
const verificationToken = crypto.randomBytes(32).toString('base64url');
const verificationUrl = new URL('/api/auth/verify-email', process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net');
verificationUrl.searchParams.set('token', verificationToken);
await db.verificationToken.deleteMany({ where: { identifier: user.email } });
await db.verificationToken.create({
data: {
identifier: user.email,
token: verificationToken,
expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
// A confirmed address is required before the account can be used or receive campaigns.
try {
await sendEmailVerificationEmail(user.email, user.name ?? 'there', verificationUrl.toString());
} catch (emailError) {
console.error('Email verification message failed:', emailError);
await db.verificationToken.deleteMany({ where: { token: verificationToken } });
await db.user.delete({ where: { id: user.id } });
return NextResponse.json({ error: 'We could not send the confirmation email. Please try again.' }, { status: 503 });
}
// Meta Conversions API - CompleteRegistration event
@@ -119,19 +134,12 @@ export async function POST(request: NextRequest) {
// Create response
const response = NextResponse.json({
success: true,
needsOnboarding: true,
user: {
id: user.id,
name: user.name,
email: user.email,
plan: 'FREE',
},
success: true,
requiresEmailVerification: true,
email: user.email,
});
// Set cookie for auto-login after signup
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
response.cookies.delete(ATTRIBUTION_COOKIE_NAME);
return response;
} catch (error) {

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { getAuthCookieOptions } from '@/lib/cookieConfig';
import { signUserId } from '@/lib/session';
import { sendWelcomeEmail } from '@/lib/email';
export async function GET(request: NextRequest) {
const token = new URL(request.url).searchParams.get('token');
const expiredUrl = new URL('/verify-email?status=expired', request.url);
if (!token) return NextResponse.redirect(expiredUrl);
const verification = await db.verificationToken.findUnique({ where: { token } });
if (!verification || verification.expires <= new Date()) {
if (verification) await db.verificationToken.deleteMany({ where: { token } });
return NextResponse.redirect(expiredUrl);
}
const user = await db.user.findUnique({ where: { email: verification.identifier } });
if (!user) {
await db.verificationToken.deleteMany({ where: { token } });
return NextResponse.redirect(expiredUrl);
}
await db.$transaction([
db.user.update({ where: { id: user.id }, data: { emailVerified: new Date() } }),
db.verificationToken.deleteMany({ where: { identifier: user.email } }),
]);
try {
await sendWelcomeEmail(user.email, user.name ?? 'there');
} catch (error) {
console.error('Welcome email after verification failed:', error);
}
const response = NextResponse.redirect(new URL('/onboarding?email_verified=1', request.url));
response.cookies.set('userId', signUserId(user.id), getAuthCookieOptions());
return response;
}

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 });
}
}