Email marketing V2
This commit is contained in:
@@ -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) {
|
||||
|
||||
39
src/app/(main)/api/auth/verify-email/route.ts
Normal file
39
src/app/(main)/api/auth/verify-email/route.ts
Normal 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;
|
||||
}
|
||||
@@ -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