feat: Implement email service with welcome, password reset, and verification templates, and add application logo.

This commit is contained in:
pradeepkumar
2025-12-22 11:57:16 +05:30
parent 158a617600
commit 1ab86b488a
17 changed files with 742 additions and 11 deletions

View File

@@ -53,7 +53,7 @@
"@sendgrid/mail": "^8.1.6",
"argon2": "^0.44.0",
"axios": "^1.13.2",
"bull": "^4.16.5",
"bull": "^4.16.5",
"bullmq": "^5.66.1",
"cache-manager": "^6.3.2",
"cacheable": "^1.8.8",
@@ -100,7 +100,7 @@
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/cookie-parser": "^1.4.10",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",

View File

@@ -9,6 +9,7 @@ import { configuration } from './config';
import { PrismaModule } from './prisma';
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
import { UsersModule } from './users';
import { EmailModule } from './email';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@@ -41,6 +42,7 @@ import { AppService } from './app.service';
// Feature Modules
AuthModule,
UsersModule,
EmailModule,
],
controllers: [AppController],
providers: [

View File

@@ -25,6 +25,7 @@ import {
ForgotPasswordDto,
ResetPasswordDto,
RefreshTokenDto,
VerifyEmailDto,
} from './dto';
import { JwtAuthGuard } from './guards';
import { Public, CurrentUser } from './decorators';
@@ -189,6 +190,56 @@ export class AuthController {
return this.authService.resetPassword(dto);
}
// ==========================================
// VERIFY EMAIL
// ==========================================
@Public()
@Post('verify-email')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verify email with token' })
@ApiBody({ type: VerifyEmailDto })
@ApiResponse({
status: 200,
description: 'Email verified successfully',
schema: {
example: {
success: true,
data: {
message: 'Email verified successfully',
},
},
},
})
@ApiResponse({ status: 400, description: 'Invalid or expired token' })
async verifyEmail(@Body() dto: VerifyEmailDto) {
return this.authService.verifyEmail(dto);
}
// ==========================================
// RESEND VERIFICATION EMAIL
// ==========================================
@Post('resend-verification')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Resend email verification' })
@ApiResponse({
status: 200,
description: 'Verification email sent',
schema: {
example: {
success: true,
data: {
message: 'Verification email sent',
},
},
},
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async resendVerification(@CurrentUser('id') userId: string) {
return this.authService.resendVerificationEmail(userId);
}
// ==========================================
// REFRESH TOKEN
// ==========================================

View File

@@ -19,6 +19,7 @@ import {
ForgotPasswordDto,
ResetPasswordDto,
RefreshTokenDto,
VerifyEmailDto,
} from './dto';
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
@@ -88,11 +89,13 @@ export class AuthService {
// Create session
await this.createSession(user.id, tokens.accessToken, tokens.refreshToken);
// Emit event for email verification (can be handled by email service)
// Emit event for email verification (handled by email service)
this.eventEmitter.emit('user.registered', {
userId: user.id,
email: user.email,
role: user.role,
firstName: dto.firstName,
lastName: dto.lastName,
});
return {
@@ -404,6 +407,110 @@ export class AuthService {
}
}
// ==========================================
// VERIFY EMAIL
// ==========================================
async verifyEmail(dto: VerifyEmailDto) {
try {
const payload = this.jwtService.verify(dto.token, {
secret: this.configService.get<string>('jwt.secret'),
});
if (payload.type !== 'email-verification') {
throw new BadRequestException('Invalid verification token');
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
include: {
userProfile: true,
agentProfile: true,
},
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.emailVerified) {
return {
message: 'Email is already verified',
};
}
// Update user as verified
await this.prisma.user.update({
where: { id: user.id },
data: {
emailVerified: true,
emailVerifiedAt: new Date(),
},
});
// Get profile for name
const profile =
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
// Emit event for welcome email
this.eventEmitter.emit('user.email-verified', {
email: user.email,
name: profile?.firstName,
});
return {
message: 'Email verified successfully',
};
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new BadRequestException('Verification token has expired');
}
if (error.name === 'JsonWebTokenError') {
throw new BadRequestException('Invalid verification token');
}
throw error;
}
}
// ==========================================
// RESEND VERIFICATION EMAIL
// ==========================================
async resendVerificationEmail(userId: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
userProfile: true,
agentProfile: true,
},
});
if (!user) {
throw new NotFoundException('User not found');
}
if (user.emailVerified) {
return {
message: 'Email is already verified',
};
}
// Get profile for name
const profile =
user.role === UserRole.AGENT ? user.agentProfile : user.userProfile;
// Emit event for email service to send verification
this.eventEmitter.emit('user.registered', {
userId: user.id,
email: user.email,
role: user.role,
firstName: profile?.firstName,
lastName: profile?.lastName,
});
return {
message: 'Verification email sent',
};
}
// ==========================================
// REFRESH TOKEN
// ==========================================
@@ -597,8 +704,16 @@ export class AuthService {
),
);
await this.prisma.session.create({
data: {
// Use upsert to handle potential duplicate tokens
await this.prisma.session.upsert({
where: { token: accessToken },
update: {
refreshToken,
userAgent,
ipAddress,
expiresAt,
},
create: {
userId,
token: accessToken,
refreshToken,

View File

@@ -4,3 +4,4 @@ export * from './social-auth.dto';
export * from './forgot-password.dto';
export * from './reset-password.dto';
export * from './refresh-token.dto';
export * from './verify-email.dto';

View File

@@ -0,0 +1,12 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VerifyEmailDto {
@ApiProperty({
description: 'Email verification token',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString()
@IsNotEmpty()
token: string;
}

View File

@@ -8,6 +8,17 @@ import {
} from '@nestjs/common';
import { Request, Response } from 'express';
interface ErrorResponse {
success: false;
statusCode: number;
timestamp: string;
path: string;
method: string;
error: string;
message: string | string[];
errors?: Array<{ field: string; errors: string[] }>;
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
@@ -18,19 +29,26 @@ export class AllExceptionsFilter implements ExceptionFilter {
const request = ctx.getRequest<Request>();
let status: number;
let message: string | object;
let message: string | string[];
let error: string;
let validationErrors: Array<{ field: string; errors: string[] }> | undefined;
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object') {
message = (exceptionResponse as any).message || exception.message;
error = (exceptionResponse as any).error || 'Error';
const responseObj = exceptionResponse as any;
message = responseObj.message || exception.message;
error = responseObj.error || this.getErrorNameByStatus(status);
// Include validation errors if present
if (responseObj.errors) {
validationErrors = responseObj.errors;
}
} else {
message = exceptionResponse;
error = 'Error';
error = this.getErrorNameByStatus(status);
}
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
@@ -44,7 +62,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
);
}
const errorResponse = {
const errorResponse: ErrorResponse = {
success: false,
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
@@ -53,6 +72,28 @@ export class AllExceptionsFilter implements ExceptionFilter {
message,
};
if (validationErrors) {
errorResponse.errors = validationErrors;
}
response.status(status).json(errorResponse);
}
private getErrorNameByStatus(status: number): string {
const statusNames: Record<number, string> = {
400: 'Bad Request',
401: 'Unauthorized',
403: 'Forbidden',
404: 'Not Found',
405: 'Method Not Allowed',
409: 'Conflict',
422: 'Unprocessable Entity',
429: 'Too Many Requests',
500: 'Internal Server Error',
502: 'Bad Gateway',
503: 'Service Unavailable',
};
return statusNames[status] || 'Error';
}
}

102
src/email/email.listener.ts Normal file
View File

@@ -0,0 +1,102 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { EmailService } from './email.service';
interface UserRegisteredEvent {
userId: string;
email: string;
role: string;
firstName?: string;
lastName?: string;
}
interface PasswordResetRequestedEvent {
userId: string;
email: string;
resetToken: string;
resetUrl: string;
}
interface PasswordResetCompletedEvent {
userId: string;
email: string;
}
@Injectable()
export class EmailListener {
private readonly logger = new Logger(EmailListener.name);
constructor(
private readonly emailService: EmailService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
@OnEvent('user.registered')
async handleUserRegistered(payload: UserRegisteredEvent) {
this.logger.log(`User registered event received for: ${payload.email}`);
try {
// Generate verification token
const verificationToken = this.jwtService.sign(
{ sub: payload.userId, email: payload.email, type: 'email-verification' },
{
secret: this.configService.get<string>('jwt.secret'),
expiresIn: '24h',
},
);
const name = payload.firstName
? `${payload.firstName} ${payload.lastName || ''}`.trim()
: undefined;
// Send verification email
await this.emailService.sendVerificationEmail(
payload.email,
verificationToken,
name,
);
this.logger.log(`Verification email sent to: ${payload.email}`);
} catch (error) {
this.logger.error(`Failed to send verification email to ${payload.email}:`, error);
}
}
@OnEvent('password.reset-requested')
async handlePasswordResetRequested(payload: PasswordResetRequestedEvent) {
this.logger.log(`Password reset requested for: ${payload.email}`);
try {
await this.emailService.sendPasswordResetEmail(
payload.email,
payload.resetUrl,
);
this.logger.log(`Password reset email sent to: ${payload.email}`);
} catch (error) {
this.logger.error(`Failed to send password reset email to ${payload.email}:`, error);
}
}
@OnEvent('password.reset-completed')
async handlePasswordResetCompleted(payload: PasswordResetCompletedEvent) {
this.logger.log(`Password reset completed for: ${payload.email}`);
// Optionally send a confirmation email that password was changed
}
@OnEvent('user.email-verified')
async handleEmailVerified(payload: { email: string; name?: string }) {
this.logger.log(`Email verified for: ${payload.email}`);
try {
// Send welcome email after verification
await this.emailService.sendWelcomeEmail(payload.email, payload.name);
this.logger.log(`Welcome email sent to: ${payload.email}`);
} catch (error) {
this.logger.error(`Failed to send welcome email to ${payload.email}:`, error);
}
}
}

21
src/email/email.module.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { EmailService } from './email.service';
import { EmailListener } from './email.listener';
@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('jwt.secret'),
}),
inject: [ConfigService],
}),
],
providers: [EmailService, EmailListener],
exports: [EmailService],
})
export class EmailModule {}

136
src/email/email.service.ts Normal file
View File

@@ -0,0 +1,136 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import { Transporter } from 'nodemailer';
import {
getVerificationEmailTemplate,
getPasswordResetTemplate,
getWelcomeEmailTemplate,
} from './templates';
export interface EmailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
private transporter: Transporter;
constructor(private readonly configService: ConfigService) {
this.createTransporter();
}
private createTransporter() {
const host = this.configService.get<string>('SMTP_HOST');
const port = this.configService.get<number>('SMTP_PORT');
const user = this.configService.get<string>('SMTP_USER');
const pass = this.configService.get<string>('SMTP_PASS');
if (!host || !user || !pass) {
this.logger.warn('SMTP configuration incomplete. Emails will be logged only.');
return;
}
this.transporter = nodemailer.createTransport({
host,
port: port || 465,
secure: true, // SSL/TLS
auth: {
user,
pass,
},
});
// Verify connection
this.transporter.verify((error) => {
if (error) {
this.logger.error('SMTP connection failed:', error);
} else {
this.logger.log('SMTP connection established successfully');
}
});
}
async sendEmail(options: EmailOptions): Promise<boolean> {
const from = this.configService.get<string>('SMTP_FROM') ||
this.configService.get<string>('SMTP_USER');
if (!this.transporter) {
this.logger.warn('No SMTP transporter. Logging email instead:');
this.logger.log(`To: ${options.to}`);
this.logger.log(`Subject: ${options.subject}`);
this.logger.log(`Body: ${options.text || 'HTML email'}`);
return false;
}
try {
const info = await this.transporter.sendMail({
from: `"RE-QuestN" <${from}>`,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text,
});
this.logger.log(`Email sent successfully: ${info.messageId}`);
return true;
} catch (error) {
this.logger.error(`Failed to send email to ${options.to}:`, error);
return false;
}
}
// ==========================================
// EMAIL VERIFICATION
// ==========================================
async sendVerificationEmail(email: string, token: string, name?: string): Promise<boolean> {
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
const verificationUrl = `${frontendUrl}/verify-email?token=${token}`;
const html = getVerificationEmailTemplate({ verificationUrl, name });
const text = `Welcome to RE-QuestN! Please verify your email by clicking this link: ${verificationUrl}`;
return this.sendEmail({
to: email,
subject: 'Verify Your Email - RE-QuestN',
html,
text,
});
}
// ==========================================
// PASSWORD RESET
// ==========================================
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<boolean> {
const html = getPasswordResetTemplate({ resetUrl });
const text = `Reset your password by clicking this link: ${resetUrl}. This link will expire in 1 hour.`;
return this.sendEmail({
to: email,
subject: 'Reset Your Password - RE-QuestN',
html,
text,
});
}
// ==========================================
// WELCOME EMAIL
// ==========================================
async sendWelcomeEmail(email: string, name?: string): Promise<boolean> {
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
const html = getWelcomeEmailTemplate({ name, frontendUrl });
const text = `Welcome to RE-QuestN, ${name || 'User'}! Your real estate journey starts now.`;
return this.sendEmail({
to: email,
subject: 'Welcome to RE-QuestN!',
html,
text,
});
}
}

3
src/email/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './email.module';
export * from './email.service';
export * from './email.listener';

View File

@@ -0,0 +1,133 @@
/**
* Base HTML email template with RE-QuestN branding
* All email templates use this as their foundation
*/
export function getBaseTemplate(content: string): string {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RE-QuestN</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #00293d;
margin: 0;
padding: 0;
background-color: #f0f5fc;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.email-wrapper {
background: linear-gradient(to bottom, #c4d9d4, #f0f5fc);
padding: 40px 20px;
}
.email-content {
background-color: #ffffff;
border-radius: 20px;
padding: 40px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.logo {
text-align: center;
margin-bottom: 30px;
}
.logo-text {
font-size: 28px;
font-weight: 600;
}
.logo-re {
color: #00293d;
}
.logo-quest {
color: #5ba4a4;
}
.logo-n {
color: #e58625;
text-decoration: underline;
}
h1 {
color: #00293d;
font-size: 24px;
margin-bottom: 20px;
text-align: center;
}
p {
color: #00293d;
font-size: 16px;
margin-bottom: 20px;
}
.button {
display: inline-block;
background-color: #e58625;
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 20px;
font-weight: bold;
font-size: 16px;
margin: 20px 0;
}
.button:hover {
background-color: #d47a1f;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
color: #666;
font-size: 14px;
}
.footer a {
color: #5ba4a4;
text-decoration: none;
}
.note {
background-color: #f0f5fc;
border-radius: 10px;
padding: 15px;
font-size: 14px;
color: #666;
margin-top: 20px;
}
ul {
color: #00293d;
margin-bottom: 20px;
}
li {
margin-bottom: 8px;
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="container">
<div class="email-content">
<div class="logo">
<span class="logo-text">
<span class="logo-re">RE-</span><span class="logo-quest">Quest</span><span class="logo-n">N</span>
</span>
</div>
${content}
<div class="footer">
<p>&copy; ${new Date().getFullYear()} RE-QuestN. All rights reserved.</p>
<p>Your trusted real estate platform</p>
</div>
</div>
</div>
</div>
</body>
</html>
`;
}

View File

@@ -0,0 +1,7 @@
// Email Templates
// Each template is in its own file for easy maintenance and review
export * from './base.template';
export * from './verification.template';
export * from './password-reset.template';
export * from './welcome.template';

View File

@@ -0,0 +1,29 @@
import { getBaseTemplate } from './base.template';
/**
* Password reset template
* Sent when a user requests a password reset
*/
export interface PasswordResetTemplateData {
resetUrl: string;
}
export function getPasswordResetTemplate(data: PasswordResetTemplateData): string {
const { resetUrl } = data;
const content = `
<h1>Reset Your Password</h1>
<p>Hi,</p>
<p>We received a request to reset your password for your RE-QuestN account. Click the button below to create a new password:</p>
<div class="button-container">
<a href="${resetUrl}" class="button">Reset Password</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; font-size: 14px; color: #5ba4a4;">${resetUrl}</p>
<div class="note">
<strong>Important:</strong> This link will expire in 1 hour. If you didn't request a password reset, please ignore this email or contact support if you're concerned about your account security.
</div>
`;
return getBaseTemplate(content);
}

View File

@@ -0,0 +1,30 @@
import { getBaseTemplate } from './base.template';
/**
* Email verification template
* Sent when a new user registers
*/
export interface VerificationTemplateData {
verificationUrl: string;
name?: string;
}
export function getVerificationEmailTemplate(data: VerificationTemplateData): string {
const { verificationUrl, name } = data;
const content = `
<h1>Verify Your Email</h1>
<p>Hi ${name || 'there'},</p>
<p>Thank you for signing up for RE-QuestN! To complete your registration and start your real estate journey, please verify your email address by clicking the button below:</p>
<div class="button-container">
<a href="${verificationUrl}" class="button">Verify Email</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; font-size: 14px; color: #5ba4a4;">${verificationUrl}</p>
<div class="note">
<strong>Note:</strong> This verification link will expire in 24 hours. If you didn't create an account with RE-QuestN, please ignore this email.
</div>
`;
return getBaseTemplate(content);
}

View File

@@ -0,0 +1,33 @@
import { getBaseTemplate } from './base.template';
/**
* Welcome email template
* Sent after email verification is complete
*/
export interface WelcomeTemplateData {
name?: string;
frontendUrl?: string;
}
export function getWelcomeEmailTemplate(data: WelcomeTemplateData): string {
const { name, frontendUrl } = data;
const content = `
<h1>Welcome to RE-QuestN!</h1>
<p>Hi ${name || 'there'},</p>
<p>We're thrilled to have you join RE-QuestN - your trusted real estate platform!</p>
<p>Here's what you can do:</p>
<ul>
<li>Browse thousands of property listings</li>
<li>Connect with top real estate agents</li>
<li>Save your favorite properties</li>
<li>Get personalized recommendations</li>
</ul>
<div class="button-container">
<a href="${frontendUrl || '#'}" class="button">Start Exploring</a>
</div>
<p>If you have any questions, our support team is always here to help!</p>
`;
return getBaseTemplate(content);
}

View File

@@ -1,5 +1,5 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
@@ -41,6 +41,21 @@ async function bootstrap() {
transformOptions: {
enableImplicitConversion: true,
},
exceptionFactory: (errors) => {
const messages = errors.map((error) => {
const constraints = error.constraints
? Object.values(error.constraints)
: [`${error.property} is invalid`];
return {
field: error.property,
errors: constraints,
};
});
return new BadRequestException({
message: 'Validation failed',
errors: messages,
});
},
}),
);