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:
+30
-176
@@ -1,211 +1,65 @@
|
||||
/**
|
||||
* Email Utility using Resend
|
||||
* Centralized email sending functionality for the entire package
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
import { done, fail, info } from '../../shared/lib/logger.js';
|
||||
|
||||
/**
|
||||
* Initialize Resend client
|
||||
*/
|
||||
let resendClient = null;
|
||||
|
||||
function getResendClient() {
|
||||
if (!resendClient) {
|
||||
const apiKey = process.env.ZEN_EMAIL_RESEND_APIKEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
|
||||
}
|
||||
if (!apiKey) throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
|
||||
resendClient = new Resend(apiKey);
|
||||
}
|
||||
return resendClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sender address with name if available
|
||||
* @param {string} email - Email address
|
||||
* @param {string} name - Sender name (optional)
|
||||
* @returns {string} Formatted sender address
|
||||
*/
|
||||
function formatSenderAddress(email, name) {
|
||||
if (name && name.trim()) {
|
||||
return `${name.trim()} <${email}>`;
|
||||
}
|
||||
return email;
|
||||
function resolveFrom(from, fromName) {
|
||||
const address = from || process.env.ZEN_EMAIL_FROM_ADDRESS;
|
||||
if (!address) throw new Error('ZEN_EMAIL_FROM_ADDRESS environment variable is not set');
|
||||
const name = fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||
return name?.trim() ? `${name.trim()} <${address}>` : address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Resend
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.from - Sender email address (optional, defaults to ZEN_EMAIL_FROM_ADDRESS)
|
||||
* @param {string} options.fromName - Sender name (optional, defaults to ZEN_EMAIL_FROM_NAME)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @param {Array} options.attachments - Email attachments (optional)
|
||||
* @param {Object} options.tags - Email tags for tracking (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendEmail({ to, subject, html, text, from, fromName, replyTo, attachments, tags }) {
|
||||
function buildPayload({ to, subject, html, text, from, fromName, replyTo, attachments, tags }) {
|
||||
return {
|
||||
from: resolveFrom(from, fromName),
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
...(text && { text }),
|
||||
...(replyTo && { reply_to: replyTo }),
|
||||
...(attachments && { attachments }),
|
||||
...(tags && { tags })
|
||||
};
|
||||
}
|
||||
|
||||
async function sendEmail(email) {
|
||||
try {
|
||||
const resend = getResendClient();
|
||||
|
||||
// Default from address and name
|
||||
const fromAddress = from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
|
||||
const senderName = fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||
|
||||
// Format sender with name if available
|
||||
const formattedFrom = formatSenderAddress(fromAddress, senderName);
|
||||
|
||||
const emailData = {
|
||||
from: formattedFrom,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
...(text && { text }),
|
||||
...(replyTo && { reply_to: replyTo }),
|
||||
...(attachments && { attachments }),
|
||||
...(tags && { tags })
|
||||
};
|
||||
|
||||
const response = await resend.emails.send(emailData);
|
||||
|
||||
// Resend returns { data: { id: "..." }, error: null } or { data: null, error: { message: "..." } }
|
||||
const response = await getResendClient().emails.send(buildPayload(email));
|
||||
if (response.error) {
|
||||
fail(`Email Resend error: ${response.error.message || response.error}`);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: response.error.message || 'Failed to send email'
|
||||
};
|
||||
return { success: false, data: null, error: response.error.message || 'Failed to send email' };
|
||||
}
|
||||
|
||||
const emailId = response.data?.id || response.id;
|
||||
info(`Email sent to ${to} — ID: ${emailId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
error: null
|
||||
};
|
||||
info(`Email sent to ${email.to} — ID: ${response.data?.id}`);
|
||||
return { success: true, data: response.data, error: null };
|
||||
} catch (error) {
|
||||
fail(`Email send failed: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message
|
||||
};
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an authentication-related email
|
||||
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendAuthEmail({ to, subject, html, text, replyTo }) {
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an application-related email
|
||||
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @param {Array} options.attachments - Email attachments (optional)
|
||||
* @param {Object} options.tags - Email tags for tracking (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendAppEmail({ to, subject, html, text, replyTo, attachments, tags }) {
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo,
|
||||
attachments,
|
||||
tags
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of emails
|
||||
* @param {Array<Object>} emails - Array of email objects
|
||||
* @returns {Promise<Array<Object>>} Array of Resend responses
|
||||
*/
|
||||
async function sendBatchEmails(emails) {
|
||||
try {
|
||||
const resend = getResendClient();
|
||||
|
||||
const emailsData = emails.map(email => {
|
||||
const fromAddress = email.from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
|
||||
const fromName = email.fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||
const formattedFrom = formatSenderAddress(fromAddress, fromName);
|
||||
|
||||
return {
|
||||
from: formattedFrom,
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
...(email.text && { text: email.text }),
|
||||
...(email.replyTo && { reply_to: email.replyTo }),
|
||||
...(email.attachments && { attachments: email.attachments }),
|
||||
...(email.tags && { tags: email.tags })
|
||||
};
|
||||
});
|
||||
|
||||
const response = await resend.batch.send(emailsData);
|
||||
|
||||
// Handle Resend error response
|
||||
const response = await getResendClient().batch.send(emails.map(buildPayload));
|
||||
if (response.error) {
|
||||
fail(`Email batch Resend error: ${response.error.message || response.error}`);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: response.error.message || 'Failed to send batch emails'
|
||||
};
|
||||
return { success: false, data: null, error: response.error.message || 'Failed to send batch emails' };
|
||||
}
|
||||
|
||||
done(`Email batch of ${emails.length} sent`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
error: null
|
||||
};
|
||||
return { success: true, data: response.data, error: null };
|
||||
} catch (error) {
|
||||
fail(`Email batch send failed: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message
|
||||
};
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
sendEmail,
|
||||
sendAuthEmail,
|
||||
sendAppEmail,
|
||||
sendBatchEmails
|
||||
};
|
||||
|
||||
export { sendEmail, sendBatchEmails };
|
||||
|
||||
@@ -18,18 +18,19 @@ import {
|
||||
Link,
|
||||
} from "@react-email/components";
|
||||
|
||||
export const BaseLayout = ({
|
||||
preview,
|
||||
title,
|
||||
children,
|
||||
export const BaseLayout = ({
|
||||
preview,
|
||||
title,
|
||||
children,
|
||||
companyName,
|
||||
logoURL,
|
||||
supportSection = false,
|
||||
supportEmail = 'support@zenya.test'
|
||||
supportEmail
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const logoSrc = logoURL || process.env.ZEN_EMAIL_LOGO || null;
|
||||
const logoHref = process.env.ZEN_EMAIL_LOGO_URL || null;
|
||||
const resolvedSupportEmail = supportEmail || process.env.ZEN_SUPPORT_EMAIL;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
@@ -68,11 +69,11 @@ export const BaseLayout = ({
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
© {currentYear} — {appName}. Tous droits réservés.
|
||||
{supportSection && (
|
||||
{supportSection && resolvedSupportEmail && (
|
||||
<>
|
||||
{' · '}
|
||||
<Link href={`mailto:${supportEmail}`} className="text-neutral-400 underline">
|
||||
{supportEmail}
|
||||
<Link href={`mailto:${resolvedSupportEmail}`} className="text-neutral-400 underline">
|
||||
{resolvedSupportEmail}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Password Changed Confirmation Email Template
|
||||
*/
|
||||
|
||||
import { Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const PasswordChangedEmail = ({
|
||||
email,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Votre mot de passe a été modifié – ${appName}`}
|
||||
title="Mot de passe modifié"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<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">{appName}</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>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Password Reset Email Template
|
||||
*/
|
||||
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const PasswordResetEmail = ({
|
||||
email,
|
||||
resetUrl,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Réinitialisez votre mot de passe pour ${appName}`}
|
||||
title="Réinitialisation du mot de passe"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<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">{appName}</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>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Email Verification Template
|
||||
*/
|
||||
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const VerificationEmail = ({
|
||||
email,
|
||||
verificationUrl,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Confirmez votre adresse courriel pour ${appName}`}
|
||||
title="Confirmez votre adresse courriel"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<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">{appName}</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>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +1 @@
|
||||
/**
|
||||
* Email Templates
|
||||
* Export all email templates and render functions
|
||||
*/
|
||||
|
||||
import { render } from '@react-email/components';
|
||||
import { VerificationEmail } from './VerificationEmail.jsx';
|
||||
import { PasswordResetEmail } from './PasswordResetEmail.jsx';
|
||||
import { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
|
||||
|
||||
// Export JSX components
|
||||
export { VerificationEmail } from './VerificationEmail.jsx';
|
||||
export { PasswordResetEmail } from './PasswordResetEmail.jsx';
|
||||
export { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
|
||||
export { BaseLayout } from './BaseLayout.jsx';
|
||||
|
||||
/**
|
||||
* Render verification email to HTML
|
||||
* @param {string} verificationUrl - The verification URL
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderVerificationEmail(verificationUrl, email, companyName) {
|
||||
return await render(
|
||||
<VerificationEmail
|
||||
email={email}
|
||||
verificationUrl={verificationUrl}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render password reset email to HTML
|
||||
* @param {string} resetUrl - The password reset URL
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderPasswordResetEmail(resetUrl, email, companyName) {
|
||||
return await render(
|
||||
<PasswordResetEmail
|
||||
email={email}
|
||||
resetUrl={resetUrl}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render password changed email to HTML
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderPasswordChangedEmail(email, companyName) {
|
||||
return await render(
|
||||
<PasswordChangedEmail
|
||||
email={email}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy exports for backward compatibility
|
||||
export const getVerificationEmailTemplate = renderVerificationEmail;
|
||||
export const getPasswordResetTemplate = renderPasswordResetEmail;
|
||||
export const getPasswordChangedTemplate = renderPasswordChangedEmail;
|
||||
|
||||
|
||||
|
||||
+29
-125
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user