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:
+22
-168
@@ -1,67 +1,27 @@
|
|||||||
/**
|
|
||||||
* Email Utility using Resend
|
|
||||||
* Centralized email sending functionality for the entire package
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Resend } from 'resend';
|
import { Resend } from 'resend';
|
||||||
import { done, fail, info } from '../../shared/lib/logger.js';
|
import { done, fail, info } from '../../shared/lib/logger.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize Resend client
|
|
||||||
*/
|
|
||||||
let resendClient = null;
|
let resendClient = null;
|
||||||
|
|
||||||
function getResendClient() {
|
function getResendClient() {
|
||||||
if (!resendClient) {
|
if (!resendClient) {
|
||||||
const apiKey = process.env.ZEN_EMAIL_RESEND_APIKEY;
|
const apiKey = process.env.ZEN_EMAIL_RESEND_APIKEY;
|
||||||
if (!apiKey) {
|
if (!apiKey) throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
|
||||||
throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
|
|
||||||
}
|
|
||||||
resendClient = new Resend(apiKey);
|
resendClient = new Resend(apiKey);
|
||||||
}
|
}
|
||||||
return resendClient;
|
return resendClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function resolveFrom(from, fromName) {
|
||||||
* Format sender address with name if available
|
const address = from || process.env.ZEN_EMAIL_FROM_ADDRESS;
|
||||||
* @param {string} email - Email address
|
if (!address) throw new Error('ZEN_EMAIL_FROM_ADDRESS environment variable is not set');
|
||||||
* @param {string} name - Sender name (optional)
|
const name = fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||||
* @returns {string} Formatted sender address
|
return name?.trim() ? `${name.trim()} <${address}>` : address;
|
||||||
*/
|
|
||||||
function formatSenderAddress(email, name) {
|
|
||||||
if (name && name.trim()) {
|
|
||||||
return `${name.trim()} <${email}>`;
|
|
||||||
}
|
|
||||||
return email;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function buildPayload({ to, subject, html, text, from, fromName, replyTo, attachments, tags }) {
|
||||||
* Send an email using Resend
|
return {
|
||||||
* @param {Object} options - Email options
|
from: resolveFrom(from, fromName),
|
||||||
* @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 }) {
|
|
||||||
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,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
@@ -70,142 +30,36 @@ async function sendEmail({ to, subject, html, text, from, fromName, replyTo, att
|
|||||||
...(attachments && { attachments }),
|
...(attachments && { attachments }),
|
||||||
...(tags && { tags })
|
...(tags && { tags })
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const response = await resend.emails.send(emailData);
|
async function sendEmail(email) {
|
||||||
|
try {
|
||||||
// Resend returns { data: { id: "..." }, error: null } or { data: null, error: { message: "..." } }
|
const response = await getResendClient().emails.send(buildPayload(email));
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
fail(`Email Resend error: ${response.error.message || response.error}`);
|
fail(`Email Resend error: ${response.error.message || response.error}`);
|
||||||
return {
|
return { success: false, data: null, error: response.error.message || 'Failed to send email' };
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
error: response.error.message || 'Failed to send email'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
info(`Email sent to ${email.to} — ID: ${response.data?.id}`);
|
||||||
const emailId = response.data?.id || response.id;
|
return { success: true, data: response.data, error: null };
|
||||||
info(`Email sent to ${to} — ID: ${emailId}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: response.data || response,
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Email send failed: ${error.message}`);
|
fail(`Email send failed: ${error.message}`);
|
||||||
return {
|
return { success: false, data: null, error: error.message };
|
||||||
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) {
|
async function sendBatchEmails(emails) {
|
||||||
try {
|
try {
|
||||||
const resend = getResendClient();
|
const response = await getResendClient().batch.send(emails.map(buildPayload));
|
||||||
|
|
||||||
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
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
fail(`Email batch Resend error: ${response.error.message || response.error}`);
|
fail(`Email batch Resend error: ${response.error.message || response.error}`);
|
||||||
return {
|
return { success: false, data: null, error: response.error.message || 'Failed to send batch emails' };
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
error: response.error.message || 'Failed to send batch emails'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
done(`Email batch of ${emails.length} sent`);
|
done(`Email batch of ${emails.length} sent`);
|
||||||
|
return { success: true, data: response.data, error: null };
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
data: response.data || response,
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(`Email batch send failed: ${error.message}`);
|
fail(`Email batch send failed: ${error.message}`);
|
||||||
return {
|
return { success: false, data: null, error: error.message };
|
||||||
success: false,
|
|
||||||
data: null,
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { sendEmail, sendBatchEmails };
|
||||||
sendEmail,
|
|
||||||
sendAuthEmail,
|
|
||||||
sendAppEmail,
|
|
||||||
sendBatchEmails
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ export const BaseLayout = ({
|
|||||||
companyName,
|
companyName,
|
||||||
logoURL,
|
logoURL,
|
||||||
supportSection = false,
|
supportSection = false,
|
||||||
supportEmail = 'support@zenya.test'
|
supportEmail
|
||||||
}) => {
|
}) => {
|
||||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||||
const logoSrc = logoURL || process.env.ZEN_EMAIL_LOGO || null;
|
const logoSrc = logoURL || process.env.ZEN_EMAIL_LOGO || null;
|
||||||
const logoHref = process.env.ZEN_EMAIL_LOGO_URL || null;
|
const logoHref = process.env.ZEN_EMAIL_LOGO_URL || null;
|
||||||
|
const resolvedSupportEmail = supportEmail || process.env.ZEN_SUPPORT_EMAIL;
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,11 +69,11 @@ export const BaseLayout = ({
|
|||||||
|
|
||||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||||
© {currentYear} — {appName}. Tous droits réservés.
|
© {currentYear} — {appName}. Tous droits réservés.
|
||||||
{supportSection && (
|
{supportSection && resolvedSupportEmail && (
|
||||||
<>
|
<>
|
||||||
{' · '}
|
{' · '}
|
||||||
<Link href={`mailto:${supportEmail}`} className="text-neutral-400 underline">
|
<Link href={`mailto:${resolvedSupportEmail}`} className="text-neutral-400 underline">
|
||||||
{supportEmail}
|
{resolvedSupportEmail}
|
||||||
</Link>
|
</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';
|
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;
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+21
-117
@@ -1,36 +1,22 @@
|
|||||||
/**
|
|
||||||
* Email Verification and Password Reset
|
|
||||||
* Handles email verification tokens and password reset tokens
|
|
||||||
*/
|
|
||||||
|
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import { render } from '@react-email/components';
|
||||||
import { create, findOne, deleteWhere } from '../../../core/database/crud.js';
|
import { create, findOne, deleteWhere } from '../../../core/database/crud.js';
|
||||||
import { generateToken, generateId } from './password.js';
|
import { generateToken, generateId } from './password.js';
|
||||||
import { fail, info } from '../../../shared/lib/logger.js';
|
import { fail, info } from '../../../shared/lib/logger.js';
|
||||||
import { sendAuthEmail } from '../../../core/email/index.js';
|
import { sendEmail } from '../../../core/email/index.js';
|
||||||
import { renderVerificationEmail, renderPasswordResetEmail, renderPasswordChangedEmail } from '../../../core/email/templates/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) {
|
async function createEmailVerification(email) {
|
||||||
const token = generateToken(32);
|
const token = generateToken(32);
|
||||||
const verificationId = generateId();
|
|
||||||
|
|
||||||
// Token expires in 24 hours
|
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
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', {
|
const verification = await create('zen_auth_verifications', {
|
||||||
id: verificationId,
|
id: generateId(),
|
||||||
identifier: 'email_verification',
|
identifier: 'email_verification',
|
||||||
value: email,
|
value: email,
|
||||||
token,
|
token,
|
||||||
@@ -38,18 +24,9 @@ async function createEmailVerification(email) {
|
|||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { ...verification, token };
|
||||||
...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) {
|
async function verifyEmailToken(email, token) {
|
||||||
const verification = await findOne('zen_auth_verifications', {
|
const verification = await findOne('zen_auth_verifications', {
|
||||||
identifier: 'email_verification',
|
identifier: 'email_verification',
|
||||||
@@ -69,39 +46,24 @@ async function verifyEmailToken(email, token) {
|
|||||||
&& token.length === verification.token.length;
|
&& token.length === verification.token.length;
|
||||||
if (!tokensMatch) return false;
|
if (!tokensMatch) return false;
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
if (new Date(verification.expires_at) < new Date()) {
|
if (new Date(verification.expires_at) < new Date()) {
|
||||||
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the verification token after use
|
|
||||||
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
await deleteWhere('zen_auth_verifications', { id: verification.id });
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a password reset token
|
|
||||||
* @param {string} email - User email
|
|
||||||
* @returns {Promise<Object>} Reset object with token
|
|
||||||
*/
|
|
||||||
async function createPasswordReset(email) {
|
async function createPasswordReset(email) {
|
||||||
const token = generateToken(32);
|
const token = generateToken(32);
|
||||||
const resetId = generateId();
|
|
||||||
|
|
||||||
// Token expires in 1 hour
|
|
||||||
const expiresAt = new Date();
|
const expiresAt = new Date();
|
||||||
expiresAt.setHours(expiresAt.getHours() + 1);
|
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', {
|
const reset = await create('zen_auth_verifications', {
|
||||||
id: resetId,
|
id: generateId(),
|
||||||
identifier: 'password_reset',
|
identifier: 'password_reset',
|
||||||
value: email,
|
value: email,
|
||||||
token,
|
token,
|
||||||
@@ -109,18 +71,9 @@ async function createPasswordReset(email) {
|
|||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return { ...reset, token };
|
||||||
...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) {
|
async function verifyResetToken(email, token) {
|
||||||
const reset = await findOne('zen_auth_verifications', {
|
const reset = await findOne('zen_auth_verifications', {
|
||||||
identifier: 'password_reset',
|
identifier: 'password_reset',
|
||||||
@@ -139,7 +92,6 @@ async function verifyResetToken(email, token) {
|
|||||||
&& token.length === reset.token.length;
|
&& token.length === reset.token.length;
|
||||||
if (!tokensMatch) return false;
|
if (!tokensMatch) return false;
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
if (new Date(reset.expires_at) < new Date()) {
|
if (new Date(reset.expires_at) < new Date()) {
|
||||||
await deleteWhere('zen_auth_verifications', { id: reset.id });
|
await deleteWhere('zen_auth_verifications', { id: reset.id });
|
||||||
return false;
|
return false;
|
||||||
@@ -148,92 +100,44 @@ async function verifyResetToken(email, token) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function deleteResetToken(email) {
|
||||||
* Delete a password reset token
|
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
|
||||||
* @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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
async function sendVerificationEmail(email, token, baseUrl) {
|
||||||
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
|
|
||||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
|
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
|
||||||
const html = await renderVerificationEmail(verificationUrl, email, appName);
|
const html = await render(<VerificationEmail verificationUrl={verificationUrl} companyName={appName} />);
|
||||||
|
const result = await sendEmail({ to: email, subject: `Confirmez votre adresse courriel – ${appName}`, html });
|
||||||
const result = await sendAuthEmail({
|
|
||||||
to: email,
|
|
||||||
subject: `Confirmez votre adresse courriel – ${appName}`,
|
|
||||||
html
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
fail(`Auth: failed to send verification email to ${email}: ${result.error}`);
|
fail(`Auth: failed to send verification email to ${email}: ${result.error}`);
|
||||||
throw new Error('Failed to send verification email');
|
throw new Error('Failed to send verification email');
|
||||||
}
|
}
|
||||||
|
|
||||||
info(`Auth: verification email sent to ${email}`);
|
info(`Auth: verification email sent to ${email}`);
|
||||||
return result;
|
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) {
|
async function sendPasswordResetEmail(email, token, baseUrl) {
|
||||||
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
|
|
||||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
|
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
|
||||||
const html = await renderPasswordResetEmail(resetUrl, email, appName);
|
const html = await render(<PasswordResetEmail resetUrl={resetUrl} companyName={appName} />);
|
||||||
|
const result = await sendEmail({ to: email, subject: `Réinitialisation du mot de passe – ${appName}`, html });
|
||||||
const result = await sendAuthEmail({
|
|
||||||
to: email,
|
|
||||||
subject: `Réinitialisation du mot de passe – ${appName}`,
|
|
||||||
html
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
fail(`Auth: failed to send password reset email to ${email}: ${result.error}`);
|
fail(`Auth: failed to send password reset email to ${email}: ${result.error}`);
|
||||||
throw new Error('Failed to send password reset email');
|
throw new Error('Failed to send password reset email');
|
||||||
}
|
}
|
||||||
|
|
||||||
info(`Auth: password reset email sent to ${email}`);
|
info(`Auth: password reset email sent to ${email}`);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send password changed confirmation email using Resend
|
|
||||||
* @param {string} email - User email
|
|
||||||
*/
|
|
||||||
async function sendPasswordChangedEmail(email) {
|
async function sendPasswordChangedEmail(email) {
|
||||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
|
const html = await render(<PasswordChangedEmail email={email} companyName={appName} />);
|
||||||
const html = await renderPasswordChangedEmail(email, appName);
|
const result = await sendEmail({ to: email, subject: `Mot de passe modifié – ${appName}`, html });
|
||||||
|
|
||||||
const result = await sendAuthEmail({
|
|
||||||
to: email,
|
|
||||||
subject: `Mot de passe modifié – ${appName}`,
|
|
||||||
html
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
fail(`Auth: failed to send password changed email to ${email}: ${result.error}`);
|
fail(`Auth: failed to send password changed email to ${email}: ${result.error}`);
|
||||||
throw new Error('Failed to send password changed email');
|
throw new Error('Failed to send password changed email');
|
||||||
}
|
}
|
||||||
|
|
||||||
info(`Auth: password changed email sent to ${email}`);
|
info(`Auth: password changed email sent to ${email}`);
|
||||||
return result;
|
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