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

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