From 87a04db04b78854e50f3f33e288a1a4644eba8de Mon Sep 17 00:00:00 2001 From: Hyko Date: Mon, 13 Apr 2026 18:37:06 -0400 Subject: [PATCH] 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 --- src/core/email/index.js | 206 +++--------------- src/core/email/templates/BaseLayout.jsx | 17 +- .../email/templates/PasswordChangedEmail.jsx | 41 ---- .../email/templates/PasswordResetEmail.jsx | 49 ----- .../email/templates/VerificationEmail.jsx | 49 ----- src/core/email/templates/index.js | 70 ------ src/features/auth/lib/email.js | 154 +++---------- .../auth/templates/PasswordChangedEmail.jsx | 28 +++ .../auth/templates/PasswordResetEmail.jsx | 35 +++ .../auth/templates/VerificationEmail.jsx | 35 +++ 10 files changed, 166 insertions(+), 518 deletions(-) delete mode 100644 src/core/email/templates/PasswordChangedEmail.jsx delete mode 100644 src/core/email/templates/PasswordResetEmail.jsx delete mode 100644 src/core/email/templates/VerificationEmail.jsx create mode 100644 src/features/auth/templates/PasswordChangedEmail.jsx create mode 100644 src/features/auth/templates/PasswordResetEmail.jsx create mode 100644 src/features/auth/templates/VerificationEmail.jsx diff --git a/src/core/email/index.js b/src/core/email/index.js index 8e7fd71..22ad570 100644 --- a/src/core/email/index.js +++ b/src/core/email/index.js @@ -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} 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} 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} 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} emails - Array of email objects - * @returns {Promise>} 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 }; diff --git a/src/core/email/templates/BaseLayout.jsx b/src/core/email/templates/BaseLayout.jsx index 9bb0022..8026bd8 100644 --- a/src/core/email/templates/BaseLayout.jsx +++ b/src/core/email/templates/BaseLayout.jsx @@ -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 = ({ © {currentYear} — {appName}. Tous droits réservés. - {supportSection && ( + {supportSection && resolvedSupportEmail && ( <> {' · '} - - {supportEmail} + + {resolvedSupportEmail} )} diff --git a/src/core/email/templates/PasswordChangedEmail.jsx b/src/core/email/templates/PasswordChangedEmail.jsx deleted file mode 100644 index 33d944d..0000000 --- a/src/core/email/templates/PasswordChangedEmail.jsx +++ /dev/null @@ -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 ( - - - Ceci confirme que le mot de passe de votre compte {appName} a bien été modifié. - - -
- - Compte - - - {email} - -
- - - Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support. - -
- ); -}; diff --git a/src/core/email/templates/PasswordResetEmail.jsx b/src/core/email/templates/PasswordResetEmail.jsx deleted file mode 100644 index a1a16ce..0000000 --- a/src/core/email/templates/PasswordResetEmail.jsx +++ /dev/null @@ -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 ( - - - Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte {appName}. Cliquez sur le bouton ci-dessous pour en choisir un nouveau. - - -
- -
- - - 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é. - - - - Lien :{' '} - - {resetUrl} - - -
- ); -}; diff --git a/src/core/email/templates/VerificationEmail.jsx b/src/core/email/templates/VerificationEmail.jsx deleted file mode 100644 index 344067d..0000000 --- a/src/core/email/templates/VerificationEmail.jsx +++ /dev/null @@ -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 ( - - - Merci de vous être inscrit sur {appName}. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel. - - -
- -
- - - Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez ce message. - - - - Lien :{' '} - - {verificationUrl} - - -
- ); -}; diff --git a/src/core/email/templates/index.js b/src/core/email/templates/index.js index f416894..29375db 100644 --- a/src/core/email/templates/index.js +++ b/src/core/email/templates/index.js @@ -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} Rendered HTML - */ -export async function renderVerificationEmail(verificationUrl, email, companyName) { - return await render( - - ); -} - -/** - * 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} Rendered HTML - */ -export async function renderPasswordResetEmail(resetUrl, email, companyName) { - return await render( - - ); -} - -/** - * Render password changed email to HTML - * @param {string} email - User's email address - * @param {string} companyName - Company name (optional) - * @returns {Promise} Rendered HTML - */ -export async function renderPasswordChangedEmail(email, companyName) { - return await render( - - ); -} - -// Legacy exports for backward compatibility -export const getVerificationEmailTemplate = renderVerificationEmail; -export const getPasswordResetTemplate = renderPasswordResetEmail; -export const getPasswordChangedTemplate = renderPasswordChangedEmail; - - diff --git a/src/features/auth/lib/email.js b/src/features/auth/lib/email.js index 8e02383..5a0a140 100644 --- a/src/features/auth/lib/email.js +++ b/src/features/auth/lib/email.js @@ -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} 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} 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} 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} 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 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(); + 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(); + 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(); + 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; } diff --git a/src/features/auth/templates/PasswordChangedEmail.jsx b/src/features/auth/templates/PasswordChangedEmail.jsx new file mode 100644 index 0000000..043e557 --- /dev/null +++ b/src/features/auth/templates/PasswordChangedEmail.jsx @@ -0,0 +1,28 @@ +import { Section, Text } from "@react-email/components"; +import { BaseLayout } from "../../core/email/templates/BaseLayout.jsx"; + +export const PasswordChangedEmail = ({ email, companyName }) => ( + + + Ceci confirme que le mot de passe de votre compte {companyName} a bien été modifié. + + +
+ + Compte + + + {email} + +
+ + + Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support. + +
+); diff --git a/src/features/auth/templates/PasswordResetEmail.jsx b/src/features/auth/templates/PasswordResetEmail.jsx new file mode 100644 index 0000000..4c2e329 --- /dev/null +++ b/src/features/auth/templates/PasswordResetEmail.jsx @@ -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 }) => ( + + + Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte {companyName}. Cliquez sur le bouton ci-dessous pour en choisir un nouveau. + + +
+ +
+ + + 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é. + + + + Lien :{' '} + + {resetUrl} + + +
+); diff --git a/src/features/auth/templates/VerificationEmail.jsx b/src/features/auth/templates/VerificationEmail.jsx new file mode 100644 index 0000000..c1c1a88 --- /dev/null +++ b/src/features/auth/templates/VerificationEmail.jsx @@ -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 }) => ( + + + Merci de vous être inscrit sur {companyName}. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel. + + +
+ +
+ + + Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez ce message. + + + + Lien :{' '} + + {verificationUrl} + + +
+);