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 10 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: 10 additions & 1 deletion backend/.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,13 @@ PORT=8080
JWT_SECRET="JACKSONCHENNAHEULALLENPENGYU"
JWT_REFRESH_SECRET="JACKSONCHENNAHEULALLENPENGYUREFRESH"
SALT_ROUNDS=123
NODE_ENV="DEV"
NODE_ENV="DEV"


# mail
MAIL_HOST=smtp.example.com
MAIL_USER=user@example.com
MAIL_PASSWORD=topsecret
MAIL_FROM=noreply@example.com
MAIL_PORT=587
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

❓ Verification inconclusive

Replace placeholder email credentials with actual values

The current mail configuration contains example values that must be replaced with actual credentials before deployment.

Issues to address:

  1. Replace smtp.example.com, user@example.com, etc. with actual mail server details
  2. Ensure MAIL_PASSWORD is properly secured and not committed to version control
  3. Update MAIL_DOMAIN to match your actual domain (appears to be "codefox" based on the email template)

This is critical for the email service to function correctly.


Action Required: Update Email Credentials Securely

The configuration in backend/.env (lines 8–14) still uses placeholder values, which must be replaced with the actual mail server settings before deployment. Please address the following:

  • MAIL_HOST, MAIL_USER, MAIL_FROM, MAIL_PORT: Replace smtp.example.com, user@example.com, noreply@example.com, and 587 with your production mail server details.
  • MAIL_PASSWORD: Ensure this password is secured (e.g., via proper secrets management) and not stored in version control.
  • MAIL_DOMAIN: Update from your_net to reflect your actual domain name (e.g., based on the expected value like "codefox" from the email template).

This update is critical to ensure the email service functions correctly and securely in the deployed environment.

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 @@ -30,6 +30,7 @@
"@apollo/server": "^4.11.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 All @@ -56,6 +57,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);
}
}
110 changes: 108 additions & 2 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
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 {
Expand All @@ -29,6 +33,7 @@
private jwtService: JwtService,
private jwtCacheService: JwtCacheService,
private configService: ConfigService,
private mailService: MailService,
@InjectRepository(Menu)
private menuRepository: Repository<Menu>,
@InjectRepository(Role)
Expand All @@ -37,6 +42,98 @@
private refreshTokenRepository: Repository<RefreshToken>,
) {}

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 76 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 @@ -54,9 +151,13 @@
username,
email,
password: hashedPassword,
isEmailConfirmed: false,
});

return this.userRepository.save(newUser);
await this.userRepository.save(newUser);
await this.sendVerificationEmail(newUser);

return newUser;
}

async login(loginUserInput: LoginUserInput): Promise<RefreshTokenResponse> {
Expand All @@ -70,6 +171,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 +218,7 @@
return false;
}
}

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

return true;
} catch (error) {

Check warning on line 234 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,
// },
// });
// }
}

26 changes: 26 additions & 0 deletions backend/src/mail/templates/confirmation.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Confirm Your Email</title>
</head>
<body style="font-family: Arial, sans-serif; background-color: #f4f4f4; padding: 20px; text-align: center;">
<div style="max-width: 600px; background-color: #ffffff; padding: 30px; border-radius: 8px; margin: auto;">
<h2 style="color: #333;">Welcome to CodeFox! 🎉</h2>
<p style="font-size: 16px; color: #555;">
Hi there! Thanks for signing up. Please confirm your email address to activate your account.
</p>
<p>
<a href="{{confirmUrl}}" style="display: inline-block; padding: 12px 24px; font-size: 16px; color: #fff; background-color: #007bff; text-decoration: none; border-radius: 5px;">
Confirm Email
</a>
</p>
<p style="font-size: 14px; color: #888;">
If you didn't create an account, you can ignore this email. The confirmation link will expire in 24 hours.
</p>
<p style="font-size: 14px; color: #888;">
Need help? Contact our support team at <a href="mailto:support@codefox.net">support@codefox.net</a>.
</p>
</div>
</body>
</html>
Loading
Loading