feat: Implement email service with welcome, password reset, and verification templates, and add application logo.
This commit is contained in:
@@ -9,6 +9,7 @@ import { configuration } from './config';
|
|||||||
import { PrismaModule } from './prisma';
|
import { PrismaModule } from './prisma';
|
||||||
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
|
import { AuthModule, JwtAuthGuard, RolesGuard } from './auth';
|
||||||
import { UsersModule } from './users';
|
import { UsersModule } from './users';
|
||||||
|
import { EmailModule } from './email';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ import { AppService } from './app.service';
|
|||||||
// Feature Modules
|
// Feature Modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
|
EmailModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
ForgotPasswordDto,
|
ForgotPasswordDto,
|
||||||
ResetPasswordDto,
|
ResetPasswordDto,
|
||||||
RefreshTokenDto,
|
RefreshTokenDto,
|
||||||
|
VerifyEmailDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { JwtAuthGuard } from './guards';
|
import { JwtAuthGuard } from './guards';
|
||||||
import { Public, CurrentUser } from './decorators';
|
import { Public, CurrentUser } from './decorators';
|
||||||
@@ -189,6 +190,56 @@ export class AuthController {
|
|||||||
return this.authService.resetPassword(dto);
|
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
|
// REFRESH TOKEN
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ForgotPasswordDto,
|
ForgotPasswordDto,
|
||||||
ResetPasswordDto,
|
ResetPasswordDto,
|
||||||
RefreshTokenDto,
|
RefreshTokenDto,
|
||||||
|
VerifyEmailDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
import { UserRole, AuthProvider, UserStatus } from '@prisma/client';
|
||||||
|
|
||||||
@@ -88,11 +89,13 @@ export class AuthService {
|
|||||||
// Create session
|
// Create session
|
||||||
await this.createSession(user.id, tokens.accessToken, tokens.refreshToken);
|
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', {
|
this.eventEmitter.emit('user.registered', {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
firstName: dto.firstName,
|
||||||
|
lastName: dto.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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
|
// REFRESH TOKEN
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -597,8 +704,16 @@ export class AuthService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prisma.session.create({
|
// Use upsert to handle potential duplicate tokens
|
||||||
data: {
|
await this.prisma.session.upsert({
|
||||||
|
where: { token: accessToken },
|
||||||
|
update: {
|
||||||
|
refreshToken,
|
||||||
|
userAgent,
|
||||||
|
ipAddress,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
userId,
|
userId,
|
||||||
token: accessToken,
|
token: accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './social-auth.dto';
|
|||||||
export * from './forgot-password.dto';
|
export * from './forgot-password.dto';
|
||||||
export * from './reset-password.dto';
|
export * from './reset-password.dto';
|
||||||
export * from './refresh-token.dto';
|
export * from './refresh-token.dto';
|
||||||
|
export * from './verify-email.dto';
|
||||||
|
|||||||
12
src/auth/dto/verify-email.dto.ts
Normal file
12
src/auth/dto/verify-email.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,17 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Request, Response } from 'express';
|
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()
|
@Catch()
|
||||||
export class AllExceptionsFilter implements ExceptionFilter {
|
export class AllExceptionsFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||||
@@ -18,19 +29,26 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
let status: number;
|
let status: number;
|
||||||
let message: string | object;
|
let message: string | string[];
|
||||||
let error: string;
|
let error: string;
|
||||||
|
let validationErrors: Array<{ field: string; errors: string[] }> | undefined;
|
||||||
|
|
||||||
if (exception instanceof HttpException) {
|
if (exception instanceof HttpException) {
|
||||||
status = exception.getStatus();
|
status = exception.getStatus();
|
||||||
const exceptionResponse = exception.getResponse();
|
const exceptionResponse = exception.getResponse();
|
||||||
|
|
||||||
if (typeof exceptionResponse === 'object') {
|
if (typeof exceptionResponse === 'object') {
|
||||||
message = (exceptionResponse as any).message || exception.message;
|
const responseObj = exceptionResponse as any;
|
||||||
error = (exceptionResponse as any).error || 'Error';
|
message = responseObj.message || exception.message;
|
||||||
|
error = responseObj.error || this.getErrorNameByStatus(status);
|
||||||
|
|
||||||
|
// Include validation errors if present
|
||||||
|
if (responseObj.errors) {
|
||||||
|
validationErrors = responseObj.errors;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
message = exceptionResponse;
|
message = exceptionResponse;
|
||||||
error = 'Error';
|
error = this.getErrorNameByStatus(status);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
@@ -44,7 +62,8 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorResponse = {
|
const errorResponse: ErrorResponse = {
|
||||||
|
success: false,
|
||||||
statusCode: status,
|
statusCode: status,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
path: request.url,
|
path: request.url,
|
||||||
@@ -53,6 +72,28 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
|||||||
message,
|
message,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (validationErrors) {
|
||||||
|
errorResponse.errors = validationErrors;
|
||||||
|
}
|
||||||
|
|
||||||
response.status(status).json(errorResponse);
|
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
102
src/email/email.listener.ts
Normal 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
21
src/email/email.module.ts
Normal 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
136
src/email/email.service.ts
Normal 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
3
src/email/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './email.module';
|
||||||
|
export * from './email.service';
|
||||||
|
export * from './email.listener';
|
||||||
133
src/email/templates/base.template.ts
Normal file
133
src/email/templates/base.template.ts
Normal 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>© ${new Date().getFullYear()} RE-QuestN. All rights reserved.</p>
|
||||||
|
<p>Your trusted real estate platform</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
7
src/email/templates/index.ts
Normal file
7
src/email/templates/index.ts
Normal 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';
|
||||||
29
src/email/templates/password-reset.template.ts
Normal file
29
src/email/templates/password-reset.template.ts
Normal 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);
|
||||||
|
}
|
||||||
30
src/email/templates/verification.template.ts
Normal file
30
src/email/templates/verification.template.ts
Normal 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);
|
||||||
|
}
|
||||||
33
src/email/templates/welcome.template.ts
Normal file
33
src/email/templates/welcome.template.ts
Normal 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);
|
||||||
|
}
|
||||||
17
src/main.ts
17
src/main.ts
@@ -1,5 +1,5 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
import { ValidationPipe, Logger, BadRequestException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
@@ -41,6 +41,21 @@ async function bootstrap() {
|
|||||||
transformOptions: {
|
transformOptions: {
|
||||||
enableImplicitConversion: true,
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user