import prisma from '@/lib/prisma' import { sendEmail } from '@/lib/mail' import { getSystemConfig } from '@/lib/config' import { getEmailTemplate } from '@/lib/email-template' const VERIFY_PREFIX = 'email-verify:' const TOKEN_TTL_MS = 24 * 60 * 60 * 1000 // 24h export function generateVerificationToken(): string { const array = new Uint8Array(32) globalThis.crypto.getRandomValues(array) return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('') } function identifierForEmail(email: string): string { return `${VERIFY_PREFIX}${email.toLowerCase()}` } export async function createEmailVerificationToken(email: string): Promise { const normalized = email.toLowerCase() const identifier = identifierForEmail(normalized) const token = generateVerificationToken() const expires = new Date(Date.now() + TOKEN_TTL_MS) // Replace any pending tokens for this email await prisma.verificationToken.deleteMany({ where: { identifier } }) await prisma.verificationToken.create({ data: { identifier, token, expires }, }) return token } export async function sendVerificationEmail(opts: { email: string name?: string | null locale?: string }): Promise<{ success: boolean; error?: string }> { const token = await createEmailVerificationToken(opts.email) const baseUrl = (process.env.NEXTAUTH_URL || '').replace(/\/$/, '') const verifyLink = `${baseUrl}/verify-email?token=${token}` const isFr = (opts.locale ?? '').toLowerCase().startsWith('fr') const greet = opts.name?.trim() ? opts.name.trim() : isFr ? 'Bonjour' : 'Hi' const title = isFr ? 'Confirmez votre adresse e-mail' : 'Confirm your email address' const body = isFr ? `

${greet},

Merci de vous ĂȘtre inscrit sur Memento. Cliquez sur le bouton ci-dessous pour activer votre compte. Ce lien est valable 24 heures.

` : `

${greet},

Thanks for signing up for Memento. Click the button below to activate your account. This link is valid for 24 hours.

` const cta = isFr ? 'Confirmer mon e-mail' : 'Confirm my email' const subject = isFr ? 'Confirmez votre compte Memento' : 'Confirm your Memento account' const html = getEmailTemplate(title, body, verifyLink, cta) const sysConfig = await getSystemConfig() const emailProvider = (sysConfig.EMAIL_PROVIDER || 'auto') as 'resend' | 'smtp' | 'auto' return sendEmail({ to: opts.email.toLowerCase(), subject, html }, emailProvider) } export async function verifyEmailToken( token: string, ): Promise<{ success: true } | { success: false; error: 'invalid' | 'expired' }> { if (!token) return { success: false, error: 'invalid' } const record = await prisma.verificationToken.findFirst({ where: { token }, }) if (!record || !record.identifier.startsWith(VERIFY_PREFIX)) { return { success: false, error: 'invalid' } } if (record.expires < new Date()) { await prisma.verificationToken.deleteMany({ where: { identifier: record.identifier }, }) return { success: false, error: 'expired' } } const email = record.identifier.slice(VERIFY_PREFIX.length) const user = await prisma.user.findUnique({ where: { email } }) if (!user) { return { success: false, error: 'invalid' } } await prisma.$transaction([ prisma.user.update({ where: { id: user.id }, data: { emailVerified: new Date() }, }), prisma.verificationToken.deleteMany({ where: { identifier: record.identifier }, }), ]) return { success: true } } /** * Resend verification for an unverified password account. * Always returns success to avoid email enumeration when the address is unknown. */ export async function resendVerificationEmail( email: string, locale?: string, ): Promise<{ success: boolean; error?: string }> { const normalized = email.toLowerCase().trim() if (!normalized) return { error: 'missing_email', success: false } const user = await prisma.user.findUnique({ where: { email: normalized } }) if (!user || !user.password) { return { success: true } } if (user.emailVerified) { return { success: true } } const result = await sendVerificationEmail({ email: user.email, name: user.name, locale, }) if (!result.success) { return { success: false, error: 'send_failed' } } return { success: true } }