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",
|
"cloudinary": "^2.8.0",
|
||||||
"jose": "3.16.0",
|
"jose": "3.16.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
|
"nodemailer": "^7.0.11",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"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 { applyFileCleanup } from '../prisma/file-cleanup.helper';
|
||||||
import { config } from '../shared/config/config';
|
import { config } from '../shared/config/config';
|
||||||
|
|
||||||
|
import { emailService } from '../shared/services/email.service';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
applyFileCleanup(prisma);
|
applyFileCleanup(prisma);
|
||||||
|
|
||||||
@@ -18,12 +20,12 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
requireEmailVerification: true,
|
requireEmailVerification: true,
|
||||||
sendResetPassword: async ({ user, token }) => {
|
sendResetPassword: async ({ user, token }) => {
|
||||||
console.log(`Send reset password email to ${user.email}: ${token}`);
|
emailService.sendPasswordResetEmail(user.email, token);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
emailVerification: {
|
emailVerification: {
|
||||||
sendVerificationEmail: async ({ user, token }) => {
|
sendVerificationEmail: async ({ user, token }) => {
|
||||||
console.log(`Send verification email to ${user.email}: ${token}`);
|
emailService.sendVerificationEmail(user.email, token);
|
||||||
},
|
},
|
||||||
sendOnSignIn: true,
|
sendOnSignIn: true,
|
||||||
autoSignInAfterVerification: true,
|
autoSignInAfterVerification: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'path';
|
|||||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||||
|
|
||||||
const requiredEnv = [
|
const requiredEnv = [
|
||||||
|
'FRONTEND_URL',
|
||||||
'BETTER_AUTH_SECRET',
|
'BETTER_AUTH_SECRET',
|
||||||
'BETTER_AUTH_URL',
|
'BETTER_AUTH_URL',
|
||||||
'TRUSTED_ORIGINS',
|
'TRUSTED_ORIGINS',
|
||||||
@@ -14,6 +15,10 @@ const requiredEnv = [
|
|||||||
'CLOUDINARY_CLOUD_NAME',
|
'CLOUDINARY_CLOUD_NAME',
|
||||||
'CLOUDINARY_API_KEY',
|
'CLOUDINARY_API_KEY',
|
||||||
'CLOUDINARY_API_SECRET',
|
'CLOUDINARY_API_SECRET',
|
||||||
|
'EMAIL_HOST',
|
||||||
|
'EMAIL_PORT',
|
||||||
|
'EMAIL_USER',
|
||||||
|
'EMAIL_PASSWORD',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
for (const varName of requiredEnv) {
|
for (const varName of requiredEnv) {
|
||||||
@@ -23,6 +28,7 @@ for (const varName of requiredEnv) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
frontendUrl: process.env.FRONTEND_URL!,
|
||||||
betterAuthSecret: process.env.BETTER_AUTH_SECRET!,
|
betterAuthSecret: process.env.BETTER_AUTH_SECRET!,
|
||||||
betterAuthUrl: process.env.BETTER_AUTH_URL!,
|
betterAuthUrl: process.env.BETTER_AUTH_URL!,
|
||||||
trustedOrigins: process.env.TRUSTED_ORIGINS!.split(',').map(origin => origin.trim()).filter(origin => origin.length > 0),
|
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!,
|
apiSecret: process.env.CLOUDINARY_API_SECRET!,
|
||||||
},
|
},
|
||||||
databaseUrl: process.env.DATABASE_URL!,
|
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