diff --git a/src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx b/src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx
new file mode 100644
index 0000000..6efcc65
--- /dev/null
+++ b/src/app/(main)/(marketing)/newsletter/NewsletterComposer.tsx
@@ -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
; defaultTestEmail: string };
+
+const defaultText = `Hi {{first_name}},\n\nWrite your update here.\n\nBest,\nTimo`;
+
+export default function NewsletterComposer() {
+ const [details, setDetails] = useState(null);
+ const [subject, setSubject] = useState('');
+ const [content, setContent] = useState(defaultText);
+ const [format, setFormat] = useState('text');
+ const [audience, setAudience] = useState('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(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 `${escaped}`;
+ }, [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 = `
`;
+ setContent((current) => {
+ if (format === 'html') return `${current}\n${imageHtml}`;
+ const escapedText = current.replace(/[&<>]/g, (character) => ({ '&': '&', '<': '<', '>': '>' })[character] || character).replace(/\n/g, '
');
+ return `${escapedText}
\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 (
+
+
+
+
Email studio
+
Create a campaign
+
Compose a plain-text update or paste finished HTML. Test it first, then send only when the preview is right.
+
+
Every live email includes a personal unsubscribe link.
+
+
+
+
+
+
Message setupChoose the audience, then write or paste the message.
+
+
+
+
+
+
+
+
+
+
+
+
Add an image
JPG, PNG, or WebP up to 5 MB. The image is hosted publicly for email clients.
+
uploadImage(event.target.files?.[0])} />
+
+
+ {uploadedImageUrl &&
Image added to the email HTML}
+
+
+
+
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
+
+
{audienceCount} recipients will receive this email.
+ {status &&
{status}
}
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/api/marketing/newsletter/images/route.ts b/src/app/(main)/api/marketing/newsletter/images/route.ts
new file mode 100644
index 0000000..aa700a2
--- /dev/null
+++ b/src/app/(main)/api/marketing/newsletter/images/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/(main)/api/marketing/newsletter/route.ts b/src/app/(main)/api/marketing/newsletter/route.ts
new file mode 100644
index 0000000..cf64907
--- /dev/null
+++ b/src/app/(main)/api/marketing/newsletter/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/(main)/api/newsletter/admin-login/route.ts b/src/app/(main)/api/newsletter/admin-login/route.ts
index cb9ad02..551c294 100644
--- a/src/app/(main)/api/newsletter/admin-login/route.ts
+++ b/src/app/(main)/api/newsletter/admin-login/route.ts
@@ -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 }
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 477b1e9..83d1946 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -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 ',
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
+ : `${escapeHtml(content)}
`;
+
+ const transport = createSmtpTransport();
+
+ await transport.sendMail({
+ from: 'Timo from QR Master ',
+ replyTo: 'support@qrmaster.net',
+ to: email,
+ subject,
+ html: ``,
+ text: format === 'text' ? `${content}\n\nUnsubscribe: ${unsubscribeUrl}` : `View this email in HTML. Unsubscribe: ${unsubscribeUrl}`,
+ });
+}
// ---------------------------------------------------------------------------
// Shared design tokens (email-safe inline styles)