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