Skip to content

Commit

Permalink
feat: implement SMTP email service, adding env also adding email veri…
Browse files Browse the repository at this point in the history
…fication (#153)

This creates backend email service support.
1, send verification email
2, confirm token for User email
3, resend email
4, template for verification email

Frontend:
1, register to tell user check email
2, after user click confirm email link. Frontend should send request to
backend to do check

Vedio:
https://jam.dev/c/1e63139a-e24f-4baf-968c-a9d2e771fef0

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Introduced a robust email verification workflow that requires users to
confirm their email addresses upon registration.
- Added the ability to resend confirmation emails and a dedicated
confirmation page that provides real-time success or error feedback.
- **Chores**
- Expanded configuration options to support customizable email settings
via environment variables.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
  • Loading branch information
2 people authored and pengyu committed Mar 5, 2025
1 parent 4706c48 commit dc3a023
Show file tree
Hide file tree
Showing 18 changed files with 2,149 additions and 103 deletions.
11 changes: 11 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,14 @@ S3_ENDPOINT="https://<account_id>.r2.cloudflarestorage.com" # Cloudflare R2 e
S3_ACCOUNT_ID="your_cloudflare_account_id" # Your Cloudflare account ID
S3_PUBLIC_URL="https://pub-xxx.r2.dev" # Your R2 public bucket URL

# mail
# Set to false to disable all email functionality
MAIL_ENABLED=false

MAIL_HOST=smtp.example.com
MAIL_USER=user@example.com
MAIL_PASSWORD=topsecret
MAIL_FROM=noreply@example.com
MAIL_PORT=587
MAIL_DOMAIN=your_net

2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@aws-sdk/client-s3": "^3.758.0",
"@huggingface/hub": "latest",
"@huggingface/transformers": "latest",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/apollo": "^12.2.0",
"@nestjs/axios": "^3.0.3",
"@nestjs/common": "^10.0.0",
Expand Down Expand Up @@ -59,6 +60,7 @@
"graphql-ws": "^5.16.0",
"lodash": "^4.17.21",
"markdown-to-txt": "^2.0.1",
"nodemailer": "^6.10.0",
"normalize-path": "^3.0.0",
"openai": "^4.77.0",
"p-queue-es5": "^6.0.2",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AppResolver } from './app.resolver';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { LoggingInterceptor } from 'src/interceptor/LoggingInterceptor';
import { PromptToolModule } from './prompt-tool/prompt-tool.module';
import { MailModule } from './mail/mail.module';

// TODO(Sma1lboy): move to a separate file
function isProduction(): boolean {
Expand Down Expand Up @@ -47,6 +48,7 @@ function isProduction(): boolean {
TokenModule,
ChatModule,
PromptToolModule,
MailModule,
TypeOrmModule.forFeature([User]),
],
providers: [
Expand Down
2 changes: 2 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { User } from 'src/user/user.model';
import { AuthResolver } from './auth.resolver';
import { RefreshToken } from './refresh-token/refresh-token.model';
import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module';
import { MailModule } from 'src/mail/mail.module';

@Module({
imports: [
Expand All @@ -23,6 +24,7 @@ import { JwtCacheModule } from 'src/jwt-cache/jwt-cache.module';
inject: [ConfigService],
}),
JwtCacheModule,
MailModule,
],
providers: [AuthService, AuthResolver],
exports: [AuthService, JwtModule],
Expand Down
16 changes: 16 additions & 0 deletions backend/src/auth/auth.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ export class RefreshTokenResponse {
refreshToken: string;
}

@ObjectType()
export class EmailConfirmationResponse {
@Field()
message: string;

@Field({ nullable: true })
success?: boolean;
}

@Resolver()
export class AuthResolver {
constructor(private readonly authService: AuthService) {}
Expand All @@ -33,4 +42,11 @@ export class AuthResolver {
): Promise<RefreshTokenResponse> {
return this.authService.refreshToken(refreshToken);
}

@Mutation(() => EmailConfirmationResponse)
async confirmEmail(
@Args('token') token: string,
): Promise<EmailConfirmationResponse> {
return this.authService.confirmEmail(token);
}
}
143 changes: 135 additions & 8 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,127 @@ import { Role } from './role/role.model';
import { RefreshToken } from './refresh-token/refresh-token.model';
import { randomUUID } from 'crypto';
import { compare, hash } from 'bcrypt';
import { RefreshTokenResponse } from './auth.resolver';
import {
EmailConfirmationResponse,
RefreshTokenResponse,
} from './auth.resolver';
import { MailService } from 'src/mail/mail.service';

@Injectable()
export class AuthService {
private readonly isMailEnabled: boolean;

constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private jwtService: JwtService,
private jwtCacheService: JwtCacheService,
private configService: ConfigService,
private mailService: MailService,
@InjectRepository(Menu)
private menuRepository: Repository<Menu>,
@InjectRepository(Role)
private roleRepository: Repository<Role>,
@InjectRepository(RefreshToken)
private refreshTokenRepository: Repository<RefreshToken>,
) {}
) {
// Read the MAIL_ENABLED environment variable, default to 'true'
this.isMailEnabled =
this.configService.get<string>('MAIL_ENABLED', 'true').toLowerCase() ===
'true';
}

async confirmEmail(token: string): Promise<EmailConfirmationResponse> {
try {
const payload = await this.jwtService.verifyAsync(token);

// Check if payload has the required email field
if (!payload || !payload.email) {
return {
message: 'Invalid token format',
success: false,
};
}

// Find user and update
const user = await this.userRepository.findOne({
where: { email: payload.email },
});

if (user && !user.isEmailConfirmed) {
user.isEmailConfirmed = true;
await this.userRepository.save(user);

return {
message: 'Email confirmed successfully!',
success: true,
};
}

return {
message: 'Email already confirmed or user not found.',
success: false,
};
} catch (error) {
return {
message: 'Invalid or expired token',
success: false,
};
}
}

async sendVerificationEmail(user: User): Promise<EmailConfirmationResponse> {
// Generate confirmation token
const verifyToken = this.jwtService.sign(
{ email: user.email },
{ expiresIn: '30m' },
);

// Send confirmation email
await this.mailService.sendConfirmationEmail(user.email, verifyToken);

// update user last time send email time
user.lastEmailSendTime = new Date();
await this.userRepository.save(user);

return {
message: 'Verification email sent successfully!',
success: true,
};
}

async resendVerificationEmail(email: string) {
const user = await this.userRepository.findOne({
where: { email },
});

if (!user) {
throw new Error('User not found');
}

if (user.isEmailConfirmed) {
return { message: 'Email already confirmed!' };
}

// Check if a cooldown period has passed (e.g., 1 minute)
const cooldownPeriod = 1 * 60 * 1000; // 1 minute in milliseconds
if (
user.lastEmailSendTime &&
new Date().getTime() - user.lastEmailSendTime.getTime() < cooldownPeriod
) {
const timeLeft = Math.ceil(
(cooldownPeriod -
(new Date().getTime() - user.lastEmailSendTime.getTime())) /
1000,
);
return {
message: `Please wait ${timeLeft} seconds before requesting another email`,
success: false,
};
}

return this.sendVerificationEmail(user);
}

async register(registerUserInput: RegisterUserInput): Promise<User> {
const { username, email, password } = registerUserInput;
Expand All @@ -50,13 +154,31 @@ export class AuthService {
}

const hashedPassword = await hash(password, 10);
const newUser = this.userRepository.create({
username,
email,
password: hashedPassword,
});

return this.userRepository.save(newUser);
let newUser;
if (this.isMailEnabled) {
newUser = this.userRepository.create({
username,
email,
password: hashedPassword,
isEmailConfirmed: false,
});
} else {
newUser = this.userRepository.create({
username,
email,
password: hashedPassword,
isEmailConfirmed: true,
});
}

await this.userRepository.save(newUser);

if (this.isMailEnabled) {
await this.sendVerificationEmail(newUser);
}

return newUser;
}

async login(loginUserInput: LoginUserInput): Promise<RefreshTokenResponse> {
Expand All @@ -70,6 +192,10 @@ export class AuthService {
throw new UnauthorizedException('Invalid credentials');
}

if (!user.isEmailConfirmed) {
throw new Error('Email not confirmed. Please check your inbox.');
}

const isPasswordValid = await compare(password, user.password);

if (!isPasswordValid) {
Expand Down Expand Up @@ -113,6 +239,7 @@ export class AuthService {
return false;
}
}

async logout(token: string): Promise<boolean> {
try {
await this.jwtService.verifyAsync(token);
Expand Down
47 changes: 47 additions & 0 deletions backend/src/mail/mail.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { join } from 'path';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/user/user.model';
import { JwtModule } from '@nestjs/jwt';

@Module({
imports: [
ConfigModule,
TypeOrmModule.forFeature([User]),
MailerModule.forRootAsync({
// imports: [ConfigModule], // import module if not enabled globally
useFactory: async (config: ConfigService) => ({
// transport: config.get("MAIL_TRANSPORT"),
// or
transport: {
host: config.get('MAIL_HOST'),
port: config.get<number>('MAIL_PORT'),
secure: false,
auth: {
user: config.get('MAIL_USER'),
pass: config.get('MAIL_PASSWORD'),
},
},
defaults: {
from: `"Your App" <${config.get<string>('MAIL_FROM')}>`,
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
}),
inject: [ConfigService],
}),
JwtModule,
],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
59 changes: 59 additions & 0 deletions backend/src/mail/mail.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { MailerService } from '@nestjs-modules/mailer';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from 'src/user/user.model';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class MailService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private readonly mailerService: MailerService,
private configService: ConfigService,
) {}

async sendConfirmationEmail(email: string, token: string) {
const confirmUrl = `https://${this.configService.get('MAIL_DOMAIN')}/auth/confirm?token=${token}`;

await this.mailerService.sendMail({
to: email,
subject: 'Confirm Your Email',
template: './confirmation',
context: { confirmUrl }, // Data for template
});
}

async sendPasswordResetEmail(user: User, token: string) {
const frontendUrl = this.configService.get('FRONTEND_URL');
const url = `${frontendUrl}/reset-password?token=${token}`;

await this.mailerService.sendMail({
to: user.email,
subject: 'Password Reset Request',
template: './passwordReset',
context: {
name: user.username,
firstName: user.username,
url,
},
});
}

// async sendConfirmationEmail(user: User, token: string) {
// const frontendUrl = this.configService.get('FRONTEND_URL');
// const url = `${frontendUrl}/confirm-email?token=${token}`;

// await this.mailerService.sendMail({
// to: user.email,
// subject: 'Welcome! Confirm Your Email',
// template: './confirmation', // This will use the confirmation.hbs template
// context: { // Data to be sent to the template
// name: user.username,
// firstName: user.firstName || user.username,
// url,
// },
// });
// }
}
Loading

0 comments on commit dc3a023

Please sign in to comment.