Add an image
JPG, PNG, or WebP up to 5 MB. The image is hosted publicly for email clients.
uploadImage(event.target.files?.[0])} />
@@ -138,8 +171,16 @@ export default function NewsletterComposer() {
Live preview
QR MASTER
{subject || 'Your subject line will appear here'}
Your existing QR codes will stay active exactly as they are. Unsubscribe from product updates
-
Send a test to setTestEmail(event.target.value)} className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" /> submit('test')}> Send test email
+
Send a test to setTestEmail(event.target.value)} className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" />Preview first name setTestName(event.target.value)} maxLength={80} placeholder="Timo" className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" />Used for the preview and test email only. submit('test')}> Send test email
Live audience setAudience(event.target.value as Audience)} className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-950 outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-100">All account holders — {details?.audiences.all_users ?? '…'} eligible Newsletter subscribers — {details?.audiences.newsletter_subscribers ?? '…'} eligible {audienceCount} recipients will receive this email.
submit('send')}> Send to {audienceCount || '…'} recipients
+
+
+ Remove bounced addresses
+
+ Remove from future campaigns
+
{status &&
{status}
}
diff --git a/src/app/(main)/api/auth/signup/route.ts b/src/app/(main)/api/auth/signup/route.ts
index f1d8398..ae76271 100644
--- a/src/app/(main)/api/auth/signup/route.ts
+++ b/src/app/(main)/api/auth/signup/route.ts
@@ -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) {
diff --git a/src/app/(main)/api/auth/verify-email/route.ts b/src/app/(main)/api/auth/verify-email/route.ts
new file mode 100644
index 0000000..07a9822
--- /dev/null
+++ b/src/app/(main)/api/auth/verify-email/route.ts
@@ -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;
+}
diff --git a/src/app/(main)/api/marketing/newsletter/route.ts b/src/app/(main)/api/marketing/newsletter/route.ts
index cf64907..1a2af25 100644
--- a/src/app/(main)/api/marketing/newsletter/route.ts
+++ b/src/app/(main)/api/marketing/newsletter/route.ts
@@ -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
{
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);
}
}
diff --git a/src/app/(main)/api/marketing/newsletter/suppress/route.ts b/src/app/(main)/api/marketing/newsletter/suppress/route.ts
new file mode 100644
index 0000000..c3a229c
--- /dev/null
+++ b/src/app/(main)/api/marketing/newsletter/suppress/route.ts
@@ -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(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 });
+ }
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index ab10479..64b61d3 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -26,11 +26,22 @@ export const authOptions: NextAuthOptions = {
where: { email: credentials.email },
});
- if (!user || !user.password) {
- return null;
- }
-
- const isPasswordValid = await comparePassword(
+ if (!user || !user.password) {
+ return null;
+ }
+
+ // Existing legacy accounts may not have this field populated. New
+ // password signups have a pending verification token until they
+ // confirm their email, and cannot sign in before doing so.
+ if (!user.emailVerified) {
+ const pendingVerification = await db.verificationToken.findFirst({
+ where: { identifier: user.email, expires: { gt: new Date() } },
+ select: { token: true },
+ });
+ if (pendingVerification) return null;
+ }
+
+ const isPasswordValid = await comparePassword(
credentials.password,
user.password
);
@@ -83,4 +94,4 @@ export const authOptions: NextAuthOptions = {
error: '/login',
},
secret: process.env.NEXTAUTH_SECRET,
-};
\ No newline at end of file
+};
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 83d1946..ee06de8 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -561,6 +561,20 @@ function createSmtpTransport() {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
+export async function sendEmailVerificationEmail(email: string, name: string, verificationUrl: string) {
+ const transport = createSmtpTransport();
+ const firstName = name.trim().split(/\s+/)[0] || 'there';
+
+ await transport.sendMail({
+ from: 'Timo from QR Master ',
+ replyTo: 'support@qrmaster.net',
+ to: email,
+ subject: 'Confirm your QR Master email address',
+ html: `QR MASTER Confirm your email address Hi ${escapeHtml(firstName)},
Click the button below to finish creating your QR Master account.
CONFIRM EMAIL This link expires in 24 hours. If you did not create an account, you can ignore this email.
`,
+ text: `Hi ${firstName},\n\nConfirm your QR Master email address: ${verificationUrl}\n\nThis link expires in 24 hours.`,
+ });
+}
+
/** Marketing announcement with a per-recipient, functional unsubscribe link. */
export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) {
await waitForRateLimit();