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

@@ -48,10 +48,15 @@ export default function SignupClient() {
body: JSON.stringify({ name, email, password }),
});
const data = await response.json();
if (response.ok && data.success) {
// Store user in localStorage for client-side
const data = await response.json();
if (response.ok && data.success) {
if (data.requiresEmailVerification) {
router.push(`/verify-email?email=${encodeURIComponent(data.email)}`);
return;
}
// Store user in localStorage for client-side
localStorage.setItem('user', JSON.stringify(data.user));
// Track successful signup with PostHog

View File

@@ -0,0 +1,28 @@
import Link from 'next/link';
export const metadata = {
title: 'Check your email | QR Master',
robots: { index: false, follow: false },
};
export default function VerifyEmailPage({ searchParams }: { searchParams: { email?: string; status?: string } }) {
const expired = searchParams.status === 'expired';
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary-50 to-white p-4">
<section className="w-full max-w-md rounded-xl bg-white p-8 shadow-[0_22px_42px_-30px_rgba(50,50,93,0.38)]">
<Link href="/" className="text-sm font-semibold text-primary-700">QR MASTER</Link>
<h1 className="mt-8 text-3xl font-semibold tracking-tight text-slate-950">
{expired ? 'This confirmation link has expired.' : 'Check your inbox.'}
</h1>
<p className="mt-3 text-sm leading-6 text-slate-600">
{expired
? 'Please create your account again to receive a new confirmation email.'
: <>We sent a confirmation link{searchParams.email ? <> to <strong className="font-medium text-slate-800">{searchParams.email}</strong></> : ''}. Open it to finish creating your account.</>}
</p>
<p className="mt-6 text-sm leading-6 text-slate-500">The link expires in 24 hours. Check your spam folder if it does not arrive shortly.</p>
<Link href="/login" className="mt-8 inline-flex text-sm font-medium text-primary-700 hover:text-primary-800">Back to sign in</Link>
</section>
</main>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Code2, Eye, FileText, ImagePlus, Loader2, MailCheck, Send, ShieldCheck, Users } from 'lucide-react';
import { Ban, Code2, Eye, FileText, ImagePlus, MailCheck, Send, ShieldCheck, Users } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
@@ -18,9 +18,12 @@ export default function NewsletterComposer() {
const [format, setFormat] = useState<Format>('text');
const [audience, setAudience] = useState<Audience>('all_users');
const [testEmail, setTestEmail] = useState('knuth.timo@gmail.com');
const [testName, setTestName] = useState('Timo');
const [bounceEmails, setBounceEmails] = useState('');
const [status, setStatus] = useState('');
const [isTesting, setIsTesting] = useState(false);
const [isSending, setIsSending] = useState(false);
const [isSuppressing, setIsSuppressing] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [uploadedImageUrl, setUploadedImageUrl] = useState('');
const imageInput = useRef<HTMLInputElement>(null);
@@ -40,10 +43,12 @@ export default function NewsletterComposer() {
const audienceCount = details?.audiences[audience] ?? 0;
const preview = useMemo(() => {
if (format === 'html') return content;
const escaped = content.replace(/[&<>]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[character] || character);
const firstName = testName.trim().split(/\s+/)[0] || 'there';
const previewContent = content.replace(/\{\{\s*first_name\s*\}\}/gi, firstName);
if (format === 'html') return previewContent;
const escaped = previewContent.replace(/[&<>]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[character] || character);
return `<pre style="margin:0;font:16px/1.65 Arial,sans-serif;white-space:pre-wrap;color:#1b1c19;">${escaped}</pre>`;
}, [content, format]);
}, [content, format, testName]);
async function submit(action: 'test' | 'send') {
if (!subject.trim() || !content.trim()) {
@@ -60,10 +65,12 @@ export default function NewsletterComposer() {
const response = await fetch('/api/marketing/newsletter', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, confirm: action === 'send', subject, content, format, audience, testEmail }),
body: JSON.stringify({ action, confirm: action === 'send', subject, content, format, audience, testEmail, testName }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Sending failed.');
const payload = response.headers.get('content-type')?.includes('application/json')
? await response.json()
: null;
if (!response.ok) throw new Error(payload?.error || 'The server did not return a sending result. Do not send again until the delivery log has been checked.');
setStatus(action === 'test' ? `Test email sent to ${testEmail}.` : `Delivery complete: ${payload.sent} sent, ${payload.failed} failed.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Sending failed.');
@@ -73,6 +80,32 @@ export default function NewsletterComposer() {
}
}
async function suppressBounces() {
const emails = bounceEmails.split(/[\s,;]+/).filter(Boolean);
if (emails.length === 0) {
setStatus('Paste one or more bounced email addresses first.');
return;
}
setIsSuppressing(true);
setStatus('');
try {
const response = await fetch('/api/marketing/newsletter/suppress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ emails }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Could not suppress these addresses.');
setBounceEmails('');
setStatus(`${payload.suppressed} bounced address${payload.suppressed === 1 ? '' : 'es'} removed from future campaigns.`);
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Could not suppress these addresses.');
} finally {
setIsSuppressing(false);
}
}
async function uploadImage(file: File | undefined) {
if (!file) return;
@@ -126,7 +159,7 @@ export default function NewsletterComposer() {
<CardContent className="grid gap-8 pt-6 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.85fr)]">
<div className="space-y-5">
<label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Subject line</span><input value={subject} onChange={(event) => setSubject(event.target.value)} maxLength={150} placeholder="What should people see in their inbox?" 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" /><span className="mt-1 block text-right text-xs tabular-nums text-slate-400">{subject.length}/150</span></label>
<label className="block"><span className="mb-2 flex items-center justify-between text-sm font-medium text-slate-800"><span>{format === 'html' ? 'HTML email' : 'Email text'}</span><span className="font-normal text-slate-500">Use the preview to check spacing and line breaks</span></span><textarea value={content} onChange={(event) => setContent(event.target.value)} spellCheck={format === 'text'} className={`min-h-[390px] w-full resize-y rounded-lg border border-slate-300 bg-white p-4 text-sm leading-6 text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100 ${format === 'html' ? 'font-mono text-[13px]' : ''}`} placeholder={format === 'html' ? '<h1>Your update</h1>' : 'Write your email...'} /></label>
<label className="block"><span className="mb-2 flex items-center justify-between text-sm font-medium text-slate-800"><span>{format === 'html' ? 'HTML email' : 'Email text'}</span><span className="font-normal text-slate-500">Use the preview to check spacing and line breaks</span></span><textarea value={content} onChange={(event) => setContent(event.target.value)} spellCheck={format === 'text'} className={`min-h-[390px] w-full resize-y rounded-lg border border-slate-300 bg-white p-4 text-sm leading-6 text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100 ${format === 'html' ? 'font-mono text-[13px]' : ''}`} placeholder={format === 'html' ? '<h1>Your update</h1>' : 'Write your email...'} /><span className="mt-2 block text-xs leading-5 text-slate-500">Use <code className="rounded bg-slate-100 px-1 py-0.5 text-slate-700">{'{{first_name}}'}</code> for a first-name greeting. Accounts without a name receive there.</span></label>
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/60 p-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-sm font-medium text-slate-800">Add an image</p><p className="mt-1 text-xs leading-5 text-slate-500">JPG, PNG, or WebP up to 5 MB. The image is hosted publicly for email clients.</p></div>
<input ref={imageInput} type="file" accept="image/jpeg,image/png,image/webp" className="sr-only" onChange={(event) => uploadImage(event.target.files?.[0])} />
@@ -138,8 +171,16 @@ export default function NewsletterComposer() {
<div className="space-y-5 xl:border-l xl:border-slate-100 xl:pl-8">
<div className="flex items-center gap-2 text-sm font-medium text-slate-800"><Eye className="h-4 w-4 text-primary-600" /> Live preview</div>
<div className="overflow-hidden rounded-lg border border-slate-200 bg-slate-100 p-3"><div className="mx-auto max-w-[600px] overflow-hidden rounded bg-white shadow-sm"><div className="border-b border-slate-100 px-5 py-3 text-[11px] font-semibold tracking-[0.16em] text-slate-700">QR MASTER</div><div className="border-b border-slate-100 px-5 py-4 text-sm font-semibold text-slate-950">{subject || 'Your subject line will appear here'}</div><iframe title="Email preview" sandbox="" srcDoc={preview} className="h-[280px] w-full border-0 bg-white" /><div className="border-t border-slate-100 px-5 py-4 text-[11px] leading-5 text-slate-500">Your existing QR codes will stay active exactly as they are.<br />Unsubscribe from product updates</div></div></div>
<div className="border-t border-slate-100 pt-5"><label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Send a test to</span><input type="email" value={testEmail} onChange={(event) => 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" /></label><Button type="button" variant="outline" className="mt-3 w-full" loading={isTesting} onClick={() => submit('test')}><MailCheck className="mr-2 h-4 w-4" /> Send test email</Button></div>
<div className="border-t border-slate-100 pt-5"><label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Send a test to</span><input type="email" value={testEmail} onChange={(event) => 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" /></label><label className="mt-3 block"><span className="mb-2 block text-sm font-medium text-slate-800">Preview first name</span><input value={testName} onChange={(event) => 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" /><span className="mt-1 block text-xs leading-5 text-slate-500">Used for the preview and test email only.</span></label><Button type="button" variant="outline" className="mt-3 w-full" loading={isTesting} onClick={() => submit('test')}><MailCheck className="mr-2 h-4 w-4" /> Send test email</Button></div>
<div className="border-t border-slate-100 pt-5"><label className="block"><span className="mb-2 block text-sm font-medium text-slate-800">Live audience</span><select value={audience} onChange={(event) => 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"><option value="all_users">All account holders {details?.audiences.all_users ?? '…'} eligible</option><option value="newsletter_subscribers">Newsletter subscribers {details?.audiences.newsletter_subscribers ?? '…'} eligible</option></select></label><div className="mt-3 flex items-center gap-2 text-sm text-slate-600"><Users className="h-4 w-4 text-slate-400" /> {audienceCount} recipients will receive this email.</div><Button type="button" className="mt-4 w-full" loading={isSending} disabled={!details || audienceCount === 0} onClick={() => submit('send')}><Send className="mr-2 h-4 w-4" /> Send to {audienceCount || '…'} recipients</Button></div>
<div className="border-t border-slate-100 pt-5">
<label className="block">
<span className="mb-2 block text-sm font-medium text-slate-800">Remove bounced addresses</span>
<textarea value={bounceEmails} onChange={(event) => setBounceEmails(event.target.value)} rows={3} placeholder={'one@email.com\nanother@email.com'} className="w-full resize-y rounded-lg border border-slate-300 bg-white p-3 text-sm leading-6 text-slate-950 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-100" />
<span className="mt-1 block text-xs leading-5 text-slate-500">Paste addresses from SMTP delivery-status messages. One per line, comma, or space.</span>
</label>
<Button type="button" variant="outline" className="mt-3 w-full" loading={isSuppressing} onClick={suppressBounces}><Ban className="mr-2 h-4 w-4" /> Remove from future campaigns</Button>
</div>
{status && <p role="status" className="rounded-lg bg-slate-50 px-3 py-3 text-sm leading-5 text-slate-700">{status}</p>}
</div>
</CardContent>

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

View File

@@ -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,
};
};

View File

@@ -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 <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: 'Confirm your QR Master email address',
html: `<!doctype html><html><body style="margin:0;background:#f5f4ef;color:#1b1c19;font-family:Arial,sans-serif;"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td align="center" style="padding:32px 12px;"><table role="presentation" width="600" cellspacing="0" cellpadding="0" style="width:100%;max-width:600px;background:#fff;"><tr><td style="padding:20px 32px;border-bottom:1px solid #e3e3de;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER</td></tr><tr><td style="padding:36px 32px;"><h1 style="margin:0 0 18px;font-family:Georgia,serif;font-size:30px;font-weight:normal;line-height:1.2;">Confirm your email address</h1><p style="margin:0;font-size:16px;line-height:1.65;">Hi ${escapeHtml(firstName)},</p><p style="font-size:16px;line-height:1.65;">Click the button below to finish creating your QR Master account.</p><a href="${verificationUrl}" style="display:inline-block;margin:10px 0 22px;background:#0047ff;color:#fff;padding:14px 22px;text-decoration:none;font-size:14px;font-weight:bold;">CONFIRM EMAIL</a><p style="margin:0;color:#747878;font-size:13px;line-height:1.6;">This link expires in 24 hours. If you did not create an account, you can ignore this email.</p></td></tr></table></td></tr></table></body></html>`,
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();