Email marketing
This commit is contained in:
@@ -28,13 +28,14 @@ import {
|
||||
TrendingUp,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getGoalLabel,
|
||||
import {
|
||||
getGoalLabel,
|
||||
getRoleLabel,
|
||||
getSourceLabel,
|
||||
getTeamSizeLabel,
|
||||
getUseCaseLabel,
|
||||
} from '@/lib/revops';
|
||||
getUseCaseLabel,
|
||||
} from '@/lib/revops';
|
||||
import NewsletterComposer from './NewsletterComposer';
|
||||
|
||||
type SegmentRow = {
|
||||
id: string;
|
||||
@@ -453,7 +454,7 @@ export default function NewsletterClient() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="mx-auto max-w-[1600px] px-4 py-8">
|
||||
<div className="mx-auto max-w-[1600px] px-4 py-8">
|
||||
<div className="mb-8 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-slate-900">Ops Cockpit</h1>
|
||||
@@ -465,9 +466,11 @@ export default function NewsletterClient() {
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
</div>
|
||||
|
||||
<NewsletterComposer />
|
||||
|
||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
|
||||
149
src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx
Normal file
149
src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Code2, Eye, FileText, ImagePlus, Loader2, MailCheck, Send, ShieldCheck, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
|
||||
type Format = 'text' | 'html';
|
||||
type Audience = 'all_users' | 'newsletter_subscribers';
|
||||
type ComposerData = { audiences: Record<Audience, number>; defaultTestEmail: string };
|
||||
|
||||
const defaultText = `Hi {{first_name}},\n\nWrite your update here.\n\nBest,\nTimo`;
|
||||
|
||||
export default function NewsletterComposer() {
|
||||
const [details, setDetails] = useState<ComposerData | null>(null);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [content, setContent] = useState(defaultText);
|
||||
const [format, setFormat] = useState<Format>('text');
|
||||
const [audience, setAudience] = useState<Audience>('all_users');
|
||||
const [testEmail, setTestEmail] = useState('knuth.timo@gmail.com');
|
||||
const [status, setStatus] = useState('');
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [uploadedImageUrl, setUploadedImageUrl] = useState('');
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/marketing/newsletter')
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error('Unable to load recipients.');
|
||||
return response.json();
|
||||
})
|
||||
.then((payload: ComposerData) => {
|
||||
setDetails(payload);
|
||||
if (payload.defaultTestEmail) setTestEmail(payload.defaultTestEmail);
|
||||
})
|
||||
.catch(() => setStatus('Recipient counts could not be loaded. Refresh the page and try again.'));
|
||||
}, []);
|
||||
|
||||
const audienceCount = details?.audiences[audience] ?? 0;
|
||||
const preview = useMemo(() => {
|
||||
if (format === 'html') return content;
|
||||
const escaped = content.replace(/[&<>]/g, (character) => ({ '&': '&', '<': '<', '>': '>' })[character] || character);
|
||||
return `<pre style="margin:0;font:16px/1.65 Arial,sans-serif;white-space:pre-wrap;color:#1b1c19;">${escaped}</pre>`;
|
||||
}, [content, format]);
|
||||
|
||||
async function submit(action: 'test' | 'send') {
|
||||
if (!subject.trim() || !content.trim()) {
|
||||
setStatus('Add a subject and email content first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'send' && !window.confirm(`Send this email to ${audienceCount} eligible recipients? This cannot be undone.`)) return;
|
||||
|
||||
action === 'test' ? setIsTesting(true) : setIsSending(true);
|
||||
setStatus('');
|
||||
|
||||
try {
|
||||
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 }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || 'Sending failed.');
|
||||
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.');
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadImage(file: File | undefined) {
|
||||
if (!file) return;
|
||||
|
||||
setIsUploadingImage(true);
|
||||
setStatus('');
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
const response = await fetch('/api/marketing/newsletter/images', { method: 'POST', body: formData });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || 'Image upload failed.');
|
||||
|
||||
const imageHtml = `<img src="${payload.url}" alt="${file.name.replace(/[&<>"']/g, '')}" width="600" style="display:block;width:100%;max-width:600px;height:auto;border:0;">`;
|
||||
setContent((current) => {
|
||||
if (format === 'html') return `${current}\n${imageHtml}`;
|
||||
const escapedText = current.replace(/[&<>]/g, (character) => ({ '&': '&', '<': '<', '>': '>' })[character] || character).replace(/\n/g, '<br>');
|
||||
return `<p>${escapedText}</p>\n${imageHtml}`;
|
||||
});
|
||||
setFormat('html');
|
||||
setUploadedImageUrl(payload.url);
|
||||
setStatus('Image uploaded and added to your HTML email.');
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : 'Image upload failed.');
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
if (imageInput.current) imageInput.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mb-10">
|
||||
<div className="mb-5 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary-700">Email studio</p>
|
||||
<h2 className="mt-1 text-2xl font-semibold tracking-tight text-slate-950">Create a campaign</h2>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-6 text-slate-600">Compose a plain-text update or paste finished HTML. Test it first, then send only when the preview is right.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600"><ShieldCheck className="h-4 w-4 text-emerald-600" /> Every live email includes a personal unsubscribe link.</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-200 shadow-[0_22px_42px_-30px_rgba(50,50,93,0.38)]">
|
||||
<CardHeader className="border-b border-slate-100 pb-5">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div><CardTitle className="text-xl text-slate-950">Message setup</CardTitle><p className="mt-1 text-sm text-slate-600">Choose the audience, then write or paste the message.</p></div>
|
||||
<div className="inline-flex w-fit rounded-lg border border-slate-200 bg-slate-50 p-1" role="group" aria-label="Email format">
|
||||
<button type="button" onClick={() => setFormat('text')} className={`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition ${format === 'text' ? 'bg-white text-slate-950 shadow-sm' : 'text-slate-500 hover:text-slate-800'}`}><FileText className="h-4 w-4" /> Plain text</button>
|
||||
<button type="button" onClick={() => setFormat('html')} className={`inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition ${format === 'html' ? 'bg-white text-slate-950 shadow-sm' : 'text-slate-500 hover:text-slate-800'}`}><Code2 className="h-4 w-4" /> HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<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>
|
||||
<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])} />
|
||||
<Button type="button" variant="outline" loading={isUploadingImage} onClick={() => imageInput.current?.click()}><ImagePlus className="mr-2 h-4 w-4" /> Upload image</Button>
|
||||
</div>
|
||||
{uploadedImageUrl && <div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white p-3"><img src={uploadedImageUrl} alt="Uploaded email asset" className="h-12 w-12 rounded object-cover" /><span className="min-w-0 truncate text-xs text-slate-500">Image added to the email HTML</span></div>}
|
||||
</div>
|
||||
|
||||
<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">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>
|
||||
{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>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
src/app/(main)/api/marketing/newsletter/images/route.ts
Normal file
38
src/app/(main)/api/marketing/newsletter/images/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { uploadFileToR2 } from '@/lib/r2';
|
||||
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (cookies().get('newsletter-admin')?.value !== 'authenticated') {
|
||||
return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('image');
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: 'Choose an image to upload.' }, { status: 400 });
|
||||
}
|
||||
if (!ALLOWED_IMAGE_TYPES.has(file.type)) {
|
||||
return NextResponse.json({ error: 'Use a JPG, PNG, or WebP image.' }, { status: 400 });
|
||||
}
|
||||
if (!file.size || file.size > MAX_IMAGE_BYTES) {
|
||||
return NextResponse.json({ error: 'Images must be smaller than 5 MB.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const url = await uploadFileToR2(
|
||||
Buffer.from(await file.arrayBuffer()),
|
||||
`newsletter-${file.name}`,
|
||||
file.type
|
||||
);
|
||||
|
||||
return NextResponse.json({ url, filename: file.name });
|
||||
} catch (error) {
|
||||
console.error('Newsletter image upload error:', error);
|
||||
return NextResponse.json({ error: 'Unable to upload image.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
126
src/app/(main)/api/marketing/newsletter/route.ts
Normal file
126
src/app/(main)/api/marketing/newsletter/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '@/lib/db';
|
||||
import { sendNewsletterEmail } from '@/lib/email';
|
||||
import { createMarketingUnsubscribeUrl } from '@/lib/marketingEmail';
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
type Audience = 'all_users' | 'newsletter_subscribers';
|
||||
type Format = 'text' | 'html';
|
||||
|
||||
function isAdmin() {
|
||||
return cookies().get('newsletter-admin')?.value === 'authenticated';
|
||||
}
|
||||
|
||||
function isEmail(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
|
||||
async function getRecipients(audience: Audience) {
|
||||
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);
|
||||
}
|
||||
|
||||
const suppressions = await db.newsletterSubscription.findMany({
|
||||
where: { status: 'unsubscribed' },
|
||||
select: { email: true },
|
||||
});
|
||||
const suppressed = new Set(suppressions.map((entry) => entry.email.toLowerCase()));
|
||||
const users = await db.user.findMany({
|
||||
select: { email: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
return users.map((user) => user.email).filter((email) => !suppressed.has(email.toLowerCase()));
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!isAdmin()) return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const [allUsers, newsletterSubscribers] = await Promise.all([
|
||||
getRecipients('all_users'),
|
||||
getRecipients('newsletter_subscribers'),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
audiences: {
|
||||
all_users: allUsers.length,
|
||||
newsletter_subscribers: newsletterSubscribers.length,
|
||||
},
|
||||
defaultTestEmail: process.env.NEWSLETTER_TEST_EMAIL || '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Newsletter composer audience error:', error);
|
||||
return NextResponse.json({ error: 'Unable to load eligible recipients.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isAdmin()) return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 });
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const subject = typeof body.subject === 'string' ? body.subject.trim() : '';
|
||||
const content = typeof body.content === 'string' ? body.content : '';
|
||||
const format: Format = body.format === 'html' ? 'html' : 'text';
|
||||
const audience: Audience = body.audience === 'newsletter_subscribers'
|
||||
? 'newsletter_subscribers'
|
||||
: 'all_users';
|
||||
|
||||
if (!subject || subject.length > 150 || !content.trim()) {
|
||||
return NextResponse.json({ error: 'Add a subject and email content before sending.' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.action === 'test') {
|
||||
if (!isEmail(body.testEmail)) {
|
||||
return NextResponse.json({ error: 'Enter a valid test email address.' }, { status: 400 });
|
||||
}
|
||||
|
||||
await sendNewsletterEmail({
|
||||
email: body.testEmail,
|
||||
subject: `[Test] ${subject}`,
|
||||
content,
|
||||
format,
|
||||
unsubscribeUrl: createMarketingUnsubscribeUrl(body.testEmail),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, sent: 1 });
|
||||
}
|
||||
|
||||
if (body.action !== 'send' || body.confirm !== true) {
|
||||
return NextResponse.json({ error: 'Confirm the live send before continuing.' }, { status: 400 });
|
||||
}
|
||||
|
||||
const recipients = await getRecipients(audience);
|
||||
let sent = 0;
|
||||
const failed: string[] = [];
|
||||
|
||||
for (const email of recipients) {
|
||||
try {
|
||||
await sendNewsletterEmail({
|
||||
email,
|
||||
subject,
|
||||
content,
|
||||
format,
|
||||
unsubscribeUrl: createMarketingUnsubscribeUrl(email),
|
||||
});
|
||||
sent++;
|
||||
} catch (error) {
|
||||
failed.push(email);
|
||||
console.error('Newsletter send failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, sent, failed: failed.length, total: recipients.length });
|
||||
} catch (error) {
|
||||
console.error('Newsletter composer send error:', error);
|
||||
return NextResponse.json({ error: 'Unable to send newsletter.' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '@/lib/db';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
import { getAuthCookieOptions } from '@/lib/cookieConfig';
|
||||
|
||||
/**
|
||||
* POST /api/newsletter/admin-login
|
||||
@@ -21,19 +19,22 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// SECURITY: Only allow support@qrmaster.net to access newsletter admin
|
||||
const ALLOWED_ADMIN_EMAIL = 'support@qrmaster.net';
|
||||
const ALLOWED_ADMIN_PASSWORD = 'Timo.16092005';
|
||||
|
||||
if (email.toLowerCase() !== ALLOWED_ADMIN_EMAIL) {
|
||||
const allowedAdminEmail = process.env.NEWSLETTER_ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const allowedAdminPassword = process.env.NEWSLETTER_ADMIN_PASSWORD;
|
||||
|
||||
if (!allowedAdminEmail || !allowedAdminPassword) {
|
||||
console.error('Newsletter admin credentials are not configured.');
|
||||
return NextResponse.json({ error: 'Newsletter admin is not configured.' }, { status: 503 });
|
||||
}
|
||||
|
||||
if (email.toLowerCase() !== allowedAdminEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied. Only authorized accounts can access this area.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password with hardcoded value
|
||||
if (password !== ALLOWED_ADMIN_PASSWORD) {
|
||||
if (password !== allowedAdminPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid credentials' },
|
||||
{ status: 401 }
|
||||
|
||||
@@ -567,7 +567,9 @@ export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUr
|
||||
|
||||
const createUrl = `${appUrl}/create`;
|
||||
|
||||
await resend.emails.send({
|
||||
const transport = createSmtpTransport();
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
to: email,
|
||||
@@ -601,6 +603,47 @@ export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUr
|
||||
text: `Your QR codes can now look like your brand. Design your QR code: ${createUrl}\n\nUnsubscribe from product updates: ${unsubscribeUrl}`,
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value.replace(/[&<>"']/g, (character) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[character] as string);
|
||||
}
|
||||
|
||||
export async function sendNewsletterEmail({
|
||||
email,
|
||||
subject,
|
||||
content,
|
||||
format,
|
||||
unsubscribeUrl,
|
||||
}: {
|
||||
email: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
format: 'text' | 'html';
|
||||
unsubscribeUrl: string;
|
||||
}) {
|
||||
await waitForRateLimit();
|
||||
|
||||
const body = format === 'html'
|
||||
? content
|
||||
: `<div style="white-space:pre-wrap;">${escapeHtml(content)}</div>`;
|
||||
|
||||
const transport = createSmtpTransport();
|
||||
|
||||
await transport.sendMail({
|
||||
from: 'Timo from QR Master <timo@qrmaster.net>',
|
||||
replyTo: 'support@qrmaster.net',
|
||||
to: email,
|
||||
subject,
|
||||
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;">${body}</td></tr><tr><td style="padding:20px 32px;border-top:1px solid #e3e3de;color:#747878;font-size:11px;line-height:1.6;">You are receiving this email from QR Master.<br><a href="${unsubscribeUrl}" style="color:#747878;">Unsubscribe from product updates</a></td></tr></table></td></tr></table></body></html>`,
|
||||
text: format === 'text' ? `${content}\n\nUnsubscribe: ${unsubscribeUrl}` : `View this email in HTML. Unsubscribe: ${unsubscribeUrl}`,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared design tokens (email-safe inline styles)
|
||||
|
||||
Reference in New Issue
Block a user