feat(email): refactor email module and improve config handling

- Simplify `sendEmail` by extracting `resolveFrom` and `buildPayload` helpers
- Remove `sendAuthEmail` and `sendAppEmail` exports, keeping only `sendEmail` and `sendBatchEmails`
- Replace hardcoded fallback sender with env-based validation (throws if missing)
- Update `BaseLayout` to resolve `supportEmail` from `ZEN_SUPPORT_EMAIL` env var instead of hardcoded default
- Conditionally render support section only when a support email is available
- Remove verbose JSDoc comments and reduce overall code verbosity
This commit is contained in:
2026-04-13 18:37:06 -04:00
parent 59fce3cd91
commit 87a04db04b
10 changed files with 166 additions and 518 deletions
+29 -125
View File
@@ -1,61 +1,38 @@
/**
* Email Verification and Password Reset
* Handles email verification tokens and password reset tokens
*/
import crypto from 'crypto';
import { render } from '@react-email/components';
import { create, findOne, deleteWhere } from '../../../core/database/crud.js';
import { generateToken, generateId } from './password.js';
import { fail, info } from '../../../shared/lib/logger.js';
import { sendAuthEmail } from '../../../core/email/index.js';
import { renderVerificationEmail, renderPasswordResetEmail, renderPasswordChangedEmail } from '../../../core/email/templates/index.js';
import { sendEmail } from '../../../core/email/index.js';
import { VerificationEmail } from '../templates/VerificationEmail.jsx';
import { PasswordResetEmail } from '../templates/PasswordResetEmail.jsx';
import { PasswordChangedEmail } from '../templates/PasswordChangedEmail.jsx';
/**
* Create an email verification token
* @param {string} email - User email
* @returns {Promise<Object>} Verification object with token
*/
async function createEmailVerification(email) {
const token = generateToken(32);
const verificationId = generateId();
// Token expires in 24 hours
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
// Delete any existing verification tokens for this email
await deleteWhere('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
await deleteWhere('zen_auth_verifications', { identifier: 'email_verification', value: email });
const verification = await create('zen_auth_verifications', {
id: verificationId,
id: generateId(),
identifier: 'email_verification',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return {
...verification,
token
};
return { ...verification, token };
}
/**
* Verify an email token
* @param {string} email - User email
* @param {string} token - Verification token
* @returns {Promise<boolean>} True if valid, false otherwise
*/
async function verifyEmailToken(email, token) {
const verification = await findOne('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
if (!verification) return false;
// Timing-safe comparison — always operate on same-length buffers so that a
@@ -69,64 +46,40 @@ async function verifyEmailToken(email, token) {
&& token.length === verification.token.length;
if (!tokensMatch) return false;
// Check if token is expired
if (new Date(verification.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: verification.id });
return false;
}
// Delete the verification token after use
await deleteWhere('zen_auth_verifications', { id: verification.id });
return true;
}
/**
* Create a password reset token
* @param {string} email - User email
* @returns {Promise<Object>} Reset object with token
*/
async function createPasswordReset(email) {
const token = generateToken(32);
const resetId = generateId();
// Token expires in 1 hour
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
// Delete any existing reset tokens for this email
await deleteWhere('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
await deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
const reset = await create('zen_auth_verifications', {
id: resetId,
id: generateId(),
identifier: 'password_reset',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return {
...reset,
token
};
return { ...reset, token };
}
/**
* Verify a password reset token
* @param {string} email - User email
* @param {string} token - Reset token
* @returns {Promise<boolean>} True if valid, false otherwise
*/
async function verifyResetToken(email, token) {
const reset = await findOne('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
if (!reset) return false;
// Timing-safe comparison — same rationale as verifyEmailToken above.
@@ -139,7 +92,6 @@ async function verifyResetToken(email, token) {
&& token.length === reset.token.length;
if (!tokensMatch) return false;
// Check if token is expired
if (new Date(reset.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: reset.id });
return false;
@@ -148,92 +100,44 @@ async function verifyResetToken(email, token) {
return true;
}
/**
* Delete a password reset token
* @param {string} email - User email
* @returns {Promise<number>} Number of deleted tokens
*/
async function deleteResetToken(email) {
return await deleteWhere('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
}
/**
* Send verification email using Resend
* @param {string} email - User email
* @param {string} token - Verification token
* @param {string} baseUrl - Base URL of the application
*/
async function sendVerificationEmail(email, token, baseUrl) {
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderVerificationEmail(verificationUrl, email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Confirmez votre adresse courriel ${appName}`,
html
});
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
const html = await render(<VerificationEmail verificationUrl={verificationUrl} companyName={appName} />);
const result = await sendEmail({ to: email, subject: `Confirmez votre adresse courriel ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send verification email to ${email}: ${result.error}`);
throw new Error('Failed to send verification email');
}
info(`Auth: verification email sent to ${email}`);
return result;
}
/**
* Send password reset email using Resend
* @param {string} email - User email
* @param {string} token - Reset token
* @param {string} baseUrl - Base URL of the application
*/
async function sendPasswordResetEmail(email, token, baseUrl) {
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderPasswordResetEmail(resetUrl, email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Réinitialisation du mot de passe ${appName}`,
html
});
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
const html = await render(<PasswordResetEmail resetUrl={resetUrl} companyName={appName} />);
const result = await sendEmail({ to: email, subject: `Réinitialisation du mot de passe ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send password reset email to ${email}: ${result.error}`);
throw new Error('Failed to send password reset email');
}
info(`Auth: password reset email sent to ${email}`);
return result;
}
/**
* Send password changed confirmation email using Resend
* @param {string} email - User email
*/
async function sendPasswordChangedEmail(email) {
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderPasswordChangedEmail(email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Mot de passe modifié ${appName}`,
html
});
const html = await render(<PasswordChangedEmail email={email} companyName={appName} />);
const result = await sendEmail({ to: email, subject: `Mot de passe modifié ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send password changed email to ${email}: ${result.error}`);
throw new Error('Failed to send password changed email');
}
info(`Auth: password changed email sent to ${email}`);
return result;
}
@@ -0,0 +1,28 @@
import { Section, Text } from "@react-email/components";
import { BaseLayout } from "../../core/email/templates/BaseLayout.jsx";
export const PasswordChangedEmail = ({ email, companyName }) => (
<BaseLayout
preview={`Votre mot de passe a été modifié ${companyName}`}
title="Mot de passe modifié"
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Ceci confirme que le mot de passe de votre compte <span className="font-medium text-neutral-900">{companyName}</span> a bien été modifié.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
Compte
</Text>
<Text className="text-[14px] font-medium text-neutral-900 m-0">
{email}
</Text>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.
</Text>
</BaseLayout>
);
@@ -0,0 +1,35 @@
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "../../core/email/templates/BaseLayout.jsx";
export const PasswordResetEmail = ({ resetUrl, companyName }) => (
<BaseLayout
preview={`Réinitialisez votre mot de passe pour ${companyName}`}
title="Réinitialisation du mot de passe"
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.
</Text>
<Section className="mt-[28px] mb-[32px]">
<Button
href={resetUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Réinitialiser le mot de passe
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message votre mot de passe ne sera pas modifié.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={resetUrl} className="text-neutral-400 underline break-all">
{resetUrl}
</Link>
</Text>
</BaseLayout>
);
@@ -0,0 +1,35 @@
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "../../core/email/templates/BaseLayout.jsx";
export const VerificationEmail = ({ verificationUrl, companyName }) => (
<BaseLayout
preview={`Confirmez votre adresse courriel pour ${companyName}`}
title="Confirmez votre adresse courriel"
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Merci de vous être inscrit sur <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel.
</Text>
<Section className="mt-[28px] mb-[32px]">
<Button
href={verificationUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Confirmer mon courriel
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez ce message.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={verificationUrl} className="text-neutral-400 underline break-all">
{verificationUrl}
</Link>
</Text>
</BaseLayout>
);