Email marketing

This commit is contained in:
2026-07-29 00:02:50 +02:00
parent e1b6d5fcc1
commit 11fdec610f
6 changed files with 379 additions and 19 deletions

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

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

View File

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