email marketing

This commit is contained in:
2026-07-28 13:29:35 +02:00
parent ab63d4b916
commit e1b6d5fcc1
13 changed files with 1124 additions and 3 deletions

View File

@@ -559,7 +559,48 @@ function createSmtpTransport() {
});
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
/** Marketing announcement with a per-recipient, functional unsubscribe link. */
export async function sendDesignerAnnouncementEmail(email: string, unsubscribeUrl: string) {
await waitForRateLimit();
const createUrl = `${appUrl}/create`;
await resend.emails.send({
from: 'Timo from QR Master <timo@qrmaster.net>',
replyTo: 'support@qrmaster.net',
to: email,
subject: 'Your QR codes can now look like your brand',
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" style="background:#f5f4ef;"><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 #c4c7c7;font-size:11px;font-weight:bold;letter-spacing:2px;">QR MASTER / DESIGNER UPDATE</td></tr>
<tr><td style="padding:42px 48px;">
<div style="width:42px;height:1px;background:#1b1c19;margin-bottom:24px;"></div>
<h1 style="margin:0 0 28px;font-family:Georgia,serif;font-size:38px;font-weight:normal;line-height:1.15;">Your QR codes can now look like your brand.</h1>
<p style="font-size:16px;line-height:1.65;">Your QR codes do not have to look generic. The new QR Master Designer gives you more control over how every code looks while keeping it easy to create QR codes that scan reliably.</p>
<div style="border-top:1px solid #e3e3de;border-bottom:1px solid #e3e3de;padding:22px 0;margin:28px 0;">
<p style="font-size:11px;font-weight:bold;letter-spacing:2px;">DESIGNER TIERS</p>
<p style="font-size:14px;line-height:1.6;"><strong>Free:</strong> custom colours, plus SVG and PNG downloads.<br><strong>Pro (EUR 9/mo):</strong> 4 module shapes, custom eye styles, and your logo.<br><strong>Business (EUR 29/mo):</strong> 11 module shapes, colour gradients, and saved presets for bulk uploads.</p>
</div>
<p style="font-size:16px;line-height:1.65;">For packaging, flyers, menus, labels, or campaigns, a consistent design makes every scan feel like part of your brand. With Business, you can save a design once and apply it across an entire bulk upload.</p>
<a href="${createUrl}" style="display:inline-block;background:#0047ff;color:#fff;padding:15px 24px;text-decoration:none;font-size:11px;font-weight:bold;letter-spacing:1.5px;">DESIGN YOUR QR CODE</a>
<p style="margin:30px 0 0;font-family:Georgia,serif;font-size:17px;font-style:italic;">Best, Timo</p>
</td></tr>
<tr><td style="padding:20px 48px 36px;border-top:1px solid #e3e3de;text-align:center;color:#747878;font-size:11px;line-height:1.6;">
Your existing QR codes will stay active exactly as they are.<br>
<a href="${unsubscribeUrl}" style="color:#747878;text-decoration:underline;">Unsubscribe from product updates</a>
</td></tr>
</table>
</td></tr></table>
</body></html>
`,
text: `Your QR codes can now look like your brand. Design your QR code: ${createUrl}\n\nUnsubscribe from product updates: ${unsubscribeUrl}`,
});
}
// ---------------------------------------------------------------------------
// Shared design tokens (email-safe inline styles)

72
src/lib/marketingEmail.ts Normal file
View File

@@ -0,0 +1,72 @@
import 'server-only';
import crypto from 'crypto';
const TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 365;
function getSigningSecret(): string {
const secret = process.env.EMAIL_UNSUBSCRIBE_SECRET || process.env.NEXTAUTH_SECRET;
if (!secret) {
throw new Error('Set EMAIL_UNSUBSCRIBE_SECRET before sending marketing email.');
}
return secret;
}
function sign(payload: string): string {
return crypto.createHmac('sha256', getSigningSecret()).update(payload).digest('base64url');
}
function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
export function createMarketingUnsubscribeUrl(email: string): string {
const payload = Buffer.from(
JSON.stringify({ email: normalizeEmail(email), expiresAt: Date.now() + TOKEN_TTL_MS })
).toString('base64url');
const token = `${payload}.${sign(payload)}`;
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://www.qrmaster.net';
return `${appUrl}/unsubscribe?token=${encodeURIComponent(token)}`;
}
export function getUnsubscribeEmail(token: string | null | undefined): string | null {
if (!token) return null;
const separator = token.lastIndexOf('.');
if (separator <= 0 || separator === token.length - 1) return null;
const payload = token.slice(0, separator);
const providedSignature = token.slice(separator + 1);
const expectedSignature = sign(payload);
const providedBuffer = Buffer.from(providedSignature);
const expectedBuffer = Buffer.from(expectedSignature);
if (
providedBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(providedBuffer, expectedBuffer)
) {
return null;
}
try {
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
email?: unknown;
expiresAt?: unknown;
};
if (
typeof decoded.email !== 'string' ||
typeof decoded.expiresAt !== 'number' ||
decoded.expiresAt < Date.now()
) {
return null;
}
return normalizeEmail(decoded.email);
} catch {
return null;
}
}