Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement SMTP email service, adding env also adding email verification #153

Merged
merged 16 commits into from
Mar 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { 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) {

Check warning on line 83 in backend/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / autofix

'error' is defined but never used
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);
}
Comment on lines +111 to +142
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Harmonize return values with the expected GraphQL type.
In resendVerificationEmail, the block that returns { message: 'Email already confirmed!' } lacks a success field while other branches return the success property. This can break the EmailConfirmationResponse shape. Also, consider throwing a NestJS exception instead of a general Error for "User not found."

 if (user.isEmailConfirmed) {
-  return { message: 'Email already confirmed!' };
+  return { message: 'Email already confirmed!', success: true };
 }
 
 if (!user) {
-  throw new Error('User not found');
+  throw new NotFoundException('User not found');
 }

Committable suggestion skipped: line range outside the PR's diff.


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

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 @@
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 @@
return false;
}
}

async logout(token: string): Promise<boolean> {
try {
await this.jwtService.verifyAsync(token);
Expand All @@ -125,7 +252,7 @@
}

return true;
} catch (error) {

Check warning on line 255 in backend/src/auth/auth.service.ts

View workflow job for this annotation

GitHub Actions / autofix

'error' is defined but never used
return false;
}
}
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,
) {}
Comment on lines +10 to +15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Unused user repository dependency.

The userRepository is injected but never used in the service. Either remove it or document why it's needed for future use.

constructor(
- @InjectRepository(User)
- private userRepository: Repository<User>,
  private readonly mailerService: MailerService,
  private configService: ConfigService,
) {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private readonly mailerService: MailerService,
private configService: ConfigService,
) {}
constructor(
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,
// },
// });
// }
}
Comment on lines +1 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for email sending.

The methods don't have error handling for cases where email sending fails. Add try/catch blocks to handle potential errors from the mailer service.

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

+ try {
    await this.mailerService.sendMail({
      to: email,
      subject: 'Confirm Your Email',
      template: './confirmation',
      context: { confirmUrl }, // Data for template
    });
+   return true;
+ } catch (error) {
+   console.error(`Failed to send confirmation email to ${email}:`, error);
+   throw new Error('Failed to send confirmation email. Please try again later.');
+ }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
// },
// });
// }
}
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}`;
try {
await this.mailerService.sendMail({
to: email,
subject: 'Confirm Your Email',
template: './confirmation',
context: { confirmUrl }, // Data for template
});
return true;
} catch (error) {
console.error(`Failed to send confirmation email to ${email}:`, error);
throw new Error('Failed to send confirmation email. Please try again later.');
}
}
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
Loading