feat: Add email service for authentication flows, including email verification and password reset.
This commit is contained in:
Generated
+1426
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@
|
||||
"cloudinary": "^2.8.0",
|
||||
"jose": "3.16.0",
|
||||
"multer": "^2.0.2",
|
||||
"nodemailer": "^7.0.11",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
@@ -43,6 +44,7 @@
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
|
||||
+4
-2
@@ -6,6 +6,8 @@ import { admin } from 'better-auth/plugins';
|
||||
import { applyFileCleanup } from '../prisma/file-cleanup.helper';
|
||||
import { config } from '../shared/config/config';
|
||||
|
||||
import { emailService } from '../shared/services/email.service';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
applyFileCleanup(prisma);
|
||||
|
||||
@@ -18,12 +20,12 @@ export const auth = betterAuth({
|
||||
enabled: true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ user, token }) => {
|
||||
console.log(`Send reset password email to ${user.email}: ${token}`);
|
||||
emailService.sendPasswordResetEmail(user.email, token);
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, token }) => {
|
||||
console.log(`Send verification email to ${user.email}: ${token}`);
|
||||
emailService.sendVerificationEmail(user.email, token);
|
||||
},
|
||||
sendOnSignIn: true,
|
||||
autoSignInAfterVerification: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'path';
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
const requiredEnv = [
|
||||
'FRONTEND_URL',
|
||||
'BETTER_AUTH_SECRET',
|
||||
'BETTER_AUTH_URL',
|
||||
'TRUSTED_ORIGINS',
|
||||
@@ -14,6 +15,10 @@ const requiredEnv = [
|
||||
'CLOUDINARY_CLOUD_NAME',
|
||||
'CLOUDINARY_API_KEY',
|
||||
'CLOUDINARY_API_SECRET',
|
||||
'EMAIL_HOST',
|
||||
'EMAIL_PORT',
|
||||
'EMAIL_USER',
|
||||
'EMAIL_PASSWORD',
|
||||
] as const;
|
||||
|
||||
for (const varName of requiredEnv) {
|
||||
@@ -23,6 +28,7 @@ for (const varName of requiredEnv) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
frontendUrl: process.env.FRONTEND_URL!,
|
||||
betterAuthSecret: process.env.BETTER_AUTH_SECRET!,
|
||||
betterAuthUrl: process.env.BETTER_AUTH_URL!,
|
||||
trustedOrigins: process.env.TRUSTED_ORIGINS!.split(',').map(origin => origin.trim()).filter(origin => origin.length > 0),
|
||||
@@ -35,4 +41,11 @@ export const config = {
|
||||
apiSecret: process.env.CLOUDINARY_API_SECRET!,
|
||||
},
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
email: {
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: parseInt(process.env.EMAIL_PORT || '587'),
|
||||
user: process.env.EMAIL_USER,
|
||||
password: process.env.EMAIL_PASSWORD,
|
||||
from: process.env.EMAIL_FROM || '"Zemenu" <no-reply@zemenu.com>',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { config } from '../config/config';
|
||||
|
||||
export class EmailService {
|
||||
private transporter: nodemailer.Transporter;
|
||||
|
||||
constructor() {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: config.email.host,
|
||||
port: config.email.port,
|
||||
secure: config.email.port === 465,
|
||||
auth: {
|
||||
user: config.email.user,
|
||||
pass: config.email.password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async sendVerificationEmail(to: string, token: string) {
|
||||
const verificationLink = `${config.frontendUrl}/verify/${token}`;
|
||||
|
||||
const content = `
|
||||
<p>Please click the button below to verify your email address:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${verificationLink}" style="background-color: #4CAF50; color: white; padding: 14px 24px; text-align: center; text-decoration: none; display: inline-block; border-radius: 4px; font-weight: bold; font-size: 16px;">Verify Email</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666;">${verificationLink}</p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
`;
|
||||
|
||||
const html = this.getHtmlTemplate('Verify your email address', content);
|
||||
|
||||
await this.sendEmail(to, 'Verify your email', html);
|
||||
}
|
||||
|
||||
async sendPasswordResetEmail(to: string, token: string) {
|
||||
const resetLink = `${config.frontendUrl}/reset-password/${token}`;
|
||||
|
||||
const content = `
|
||||
<p>You requested a password reset. Click the button below to reset your password:</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${resetLink}" style="background-color: #008CBA; color: white; padding: 14px 24px; text-align: center; text-decoration: none; display: inline-block; border-radius: 4px; font-weight: bold; font-size: 16px;">Reset Password</a>
|
||||
</div>
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="word-break: break-all; color: #666;">${resetLink}</p>
|
||||
<p>If you didn't request this, please ignore this email.</p>
|
||||
`;
|
||||
|
||||
const html = this.getHtmlTemplate('Reset your password', content);
|
||||
|
||||
await this.sendEmail(to, 'Reset your password', html);
|
||||
}
|
||||
|
||||
private getHtmlTemplate(title: string, content: string): string {
|
||||
const logoUrl = 'https://res.cloudinary.com/dmxoohiwo/image/upload/v1764315105/logo_fpczsd.png';
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title}</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; background-color: #f4f4f4; color: #333; line-height: 1.6;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color: #f4f4f4; padding: 20px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="600" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 30px 0; background-color: #ffffff; border-bottom: 1px solid #eee;">
|
||||
<img src="${logoUrl}" alt="Zemenu Logo" width="150" style="display: block; border: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 40px 30px;">
|
||||
<h1 style="margin-top: 0; margin-bottom: 20px; font-size: 24px; color: #333; text-align: center;">${title}</h1>
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color: #f8f9fa; padding: 20px 30px; text-align: center; border-top: 1px solid #eee;">
|
||||
<p style="margin: 0; font-size: 14px; color: #888;">© ${currentYear} Zemenu. All rights reserved.</p>
|
||||
<p style="margin: 10px 0 0; font-size: 12px; color: #aaa;">
|
||||
This is an automated message, please do not reply to this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
private async sendEmail(to: string, subject: string, html: string) {
|
||||
if (!config.email.user || !config.email.password) {
|
||||
console.warn('Email credentials not provided. Skipping email sending.');
|
||||
console.log(`To: ${to}, Subject: ${subject}`);
|
||||
// For development, we might want to log the link
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.transporter.sendMail({
|
||||
from: config.email.from,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
console.log(`Email sent to ${to}`);
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const emailService = new EmailService();
|
||||
Reference in New Issue
Block a user