feat: Implement email service with welcome, password reset, and verification templates, and add application logo.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
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';
|
||||
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
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 { 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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user