Implementing Email Validation with NestJS and Agenda.js: A Step-by-Step Guide

Cover Image for Implementing Email Validation with NestJS and Agenda.js: A Step-by-Step Guide
Rishav Thapliyal
Rishav Thapliyal

Introduction:

Email validation is a crucial part of any modern web application, ensuring that users are who they claim to be and preventing the creation of fake accounts. In this blog, we'll walk through the process of implementing a robust email validation system using NestJS, a popular Node.js framework, combined with Agenda.js for handling the email scheduling. By the end of this guide, you'll have a fully working email validation setup ready for your application.

Prerequisites:

Before diving into setting up email validation using NestJS and Agenda.js, make sure you have the following:

  • MongoDB is installed locally or available as a cloud service.As agenda uses mongodb for storing jobs

  • Access to an SMTP server or a third-party email provider (eg:- Gmail).

  • Node and Npm installed

  • A basic NestJS application up and running and also knowledge of basic nestjs concepts. You can follow the official NestJS documentation to get hang of it

Installing Necessary Dependencies:

Before implementing email validation, let's first install necessary dependencies in our NestJS project.

npm install @nestjs/jwt @nestjs/mongoose mongoose agenda nodemailer 

Here’s a breakdown of the packages:

  • @nestjs/jwt :For handling JSON Web Tokens (JWT) for email validation.

  • @nestjs/mongoose : For using MongoDB in NestJS.

  • mongoose: MongoDB ODM (Object Data Modeling) library.

  • agenda: Job scheduling library.

  • nodemailer: For sending emails through SMTP.

Setting Up Nestjs:

After installing necessary dependencies we will set up basic foundational pieces

Step 1: Configure .env file:

MONGO_URL="your mongodb uri"
DB_NAME="your db name"
SECRET="secret"
GMAIL="your gmail configured for sending mail"
GMAIL_PASSWORD="password for the above mail"

Step 2: Configure MongoDB Connection:

In your app.module.ts, set up the MongoDB connection to ensure the app can store scheduled jobs and user data.

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    MongooseModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        uri: configService.get<string>('MONGO_URI'),
        dbName: configService.get<string>('DB_NAME')
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }

Flow of the Registration and Email Verification Process

Before we begin implementing following is the flow we will take

  1. User Registration:

    • The user submits their email, name, and password.

    • In the registerUser service, the application checks if the user already exists.

    • If the user doesn’t exist, the password is hashed, and a new user is created.

    • A JWT is generated and signed using the user’s email and a secret key. This token will serve as the verification link.

    • The token is stored in the database (MongoDB or Redis) to track the verification process.

    • A verification email is sent to the user containing the link with the token as a query parameter.

  2. Email Verification:

    • The user clicks the verification link, which calls the activateAccount method.

    • The token is verified by checking it against the JWT secret key and expiration.

    • If the token is valid and not expired, the corresponding user is retrieved.

    • The user’s isActivated field is set to true, marking the user as verified.

    • The token is deleted from the tokens table

    • User is redirected to the login page with a success message

Creating the Mailer Service:

Now that we have our basic setup, lets implement the mailer service which will handle the sending of mails to the user for verficiation

Step 1: Setting up mailer service:

In the root dir inside src add a mailer folder and inside it add a mailer.service.ts file

Step 2: Implementing Mailer Service:

For this tutorial we will be using nodemailer and gmail for sending mail
import { Injectable, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Agenda } from 'agenda';
import * as nodemailer from 'nodemailer';

@Injectable()
export class MailerService implements OnModuleInit {
  private transporter: nodemailer.Transporter;
  private agenda: Agenda;


  constructor(private configService: ConfigService) {
    this.agenda = new Agenda({
      db: { address: this.configService.get<string>("MONGO_URI"), collection: "agendaJobs" },
      processEvery: '30 seconds'
    })

    this.defineEmailJob();
    this.transporter = nodemailer.createTransport({
      host: "smtp.gmail.com",
      port: 587,
      secure: false,
      auth: {
        user: this.configService.get<string>("GMAIL"),
        pass: this.configService.get<string>("GMAIL_PASSWORD"),
      },
    });
  }



  private defineEmailJob() {
    this.agenda.define('send email', async (job: any) => {
      const { to, subject, text, html } = job.attrs.data;
      await this.sendMail(to, subject, text, html);
    });
  }

  async scheduleEmail(to: string, subject: string, text?: string, html?: string) {
    await this.agenda.now('send email', { to, subject, text, html });
  }

  async onModuleInit() {
    await this.agenda.start();
  }

  async sendMail(to: string, subject: string, text?: string, html?: string) {
    const mailOptions = {
      from: '"your name" gmail configured for sending mail',
      to,
      subject,
      html,
    };

    try {
      const info = await this.transporter.sendMail(mailOptions);
      console.log('Email sent: %s', info.messageId);
    } catch (error) {
      console.error('Error sending email:', error);
      throw new Error('Failed to send email');
    }
  }
}

Creating the User Module:

Now that we have our mailer service setup with the agenda for scheduling mail we can start implementing users modules which will be responsible for registration,sending links and activating the account

Step 1: Setting Up User Module:

nest generate module user 
nest generate service user
nest generate controller user 

Step 2: Define User Schema (Mongoose):

Inside the user folder make a user.schema.ts file

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
import { randomUUID } from 'crypto';
import * as bcrypt from 'bcrypt';
import { Exclude } from 'class-transformer';

export type UserDocument = HydratedDocument<User>;

@Schema({ versionKey: false, timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt' } })
export class User {
  @Prop({ default: () => randomUUID(), unique: true, required: true })
  user_id: string;

  @Prop({ required: true, unique: true, lowercase: true })
  email: string;

  @Prop({ required: true })
  password: string;

  @Prop({ required: true })
  name: string;

  @Prop({ default: false })
  isActivated: boolean;

  @Prop({ default: Date.now })
  createdAt: Date;

  @Prop({ default: Date.now })
  updatedAt: Date;
}
export const UserSchema = SchemaFactory.createForClass(User);

Note: You can adjust the schema according to you business logic

Step 2: Define Token Schema (Mongoose):

import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Schema as MongooseSchema } from 'mongoose';

export type TokenDocument = HydratedDocument<Token>;

@Schema({ versionKey: false, timestamps: true })
export class Token {
  @Prop({ type: MongooseSchema.Types.ObjectId, ref: 'User', required: true })
  userId: string;

  @Prop({ required: true })
  token: string;

  @Prop({ required: true, enum: ['verification', 'passwordReset', 'invite'] })
  type: string;

}

export const TokenSchema = SchemaFactory.createForClass(Token);

Note: Adjust the schema as required,you can also use redis for this case

Step 2: Set Up User services:

In user.service.ts, we will implement the logic to register users and generate email verification tokens using JWT as well as activating the account using the JWT

import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { InjectModel } from "@nestjs/mongoose";
import { User, UserDocument } from "./users.schema";
import { Model } from "mongoose";
import { RegisterUserDto } from "./dto/RegisterUser.dto";
import { LoginUserDto } from "./dto/LoginUser.dto";
import { JwtService } from "@nestjs/jwt";
import { Token } from "src/schemas/tokens.schema";
import { MailerService } from "src/mailer/mailer.service";
import { ConfigService } from "@nestjs/config";
import { Request, Response } from "express";

@Injectable()
export class UserService {
  constructor(
    @InjectModel(User.name) private userModel: Model<User>,
    @InjectModel(Token.name) private tokenModel: Model<Token>,
    private jwtService: JwtService,
    private configService: ConfigService,
    private readonly mailerService: MailerService
  ) { }

  async activateAccount(token: string): Promise<UserDocument | null> {
    try {
      const tokenDoc = await this.tokenModel.findOne({ token }).exec();

      if (!tokenDoc) {
        throw new UnauthorizedException('Invalid or expired activation link.');
      }
      const decodedToken = await this.jwtService.verifyAsync(
        token,
        {
          secret: this.configService.get<string>("ACCESS_SECRET_TOKEN"),
        }

      );
      if (!decodedToken || !decodedToken.email) {
        throw new BadRequestException('Invalid activation token payload.');
      }
      const user = await this.userModel.findOne({ email: decodedToken.email }).exec();
      if (!user) {
        throw new NotFoundException('User not found.');
      }
      if (user.isActivated) {
        throw new BadRequestException('User is already activated.');
      }
      user.isActivated = true;
      await user.save();
      await this.tokenModel.deleteOne({ _id: tokenDoc._id }).exec();
      return user;
    } catch (error) {
      throw new UnauthorizedException('Invalid or expired activation link.');
    }

  }

  async registerUser(registerUserDto: RegisterUserDto): Promise<UserDocument> {
   try {
    const existingUser = await this.userModel.findOne({ email: registerUserDto.email }).exec();
    if (existingUser) {
      throw new ConflictException('User already exists');
    }
    const hashedPassword = await bcrypt.hash(password, 10);      
    const newUser = new this.userModel({
      ...registerUserDto,
      password:hashedPassword
    });

    const payload = { username: newUser.name, email: newUser.email };
    const verificationToken = await this.jwtService.signAsync(payload, { expiresIn: "1h" });
    const link= `your-backend-url/activate-account?token=${token}`;
    await this.mailerService.scheduleEmail(
        registerUserDto.email,
        'Registration Confirmation',
        'Please confirm your registration.',
       `Click <a href="${link}">here</a> to verify your email.` 
      );

      const user = await newUser.save()

      const newToken = new this.tokenModel({
        userId: user._id,
        token: verificationToken,
        type: "verification"
      })

      await newToken.save();

      return user;
    } catch (error) {
      console.log(error);
      throw new BadRequestException('Error registering user');
    }

  }
}

Step 3:Setting Up User Controllers :

After we are done setting up the services we will set up the controllers (routes) that will implement these services

import { Body, Controller, Get, Post, Query, Req, Res } from "@nestjs/common";
import { UserService } from "./users.service";
import { RegisterUserDto } from "./dto/RegisterUser.dto";
import { ResponseHelper } from "src/utils/response.helper";
import { Request, Response } from "express"
import { LoginUserDto } from "./dto/LoginUser.dto";


@Controller()
export class UsersController {
  constructor(private usersService: UserService) { }

  @Post("/api/register")
  async registerUser(@Body() registerUserDto: RegisterUserDto, @Res() res: Response) {
    try {
      const user = await this.usersService.registerUser(registerUserDto);
      console.log(user);
      
    } catch (error) {
      console.log(error)
    }
  }

  @Get("/api/activate-account")
  async activateAccount(@Query('token') token: string, @Res() res: Response) {
    try {
      const user = await this.usersService.activateAccount(token);
      return res.redirect('your-frontend-url')
    } catch (error) {
       console.log(error)
    }
  }
}

Step 4: Setting Up User Module:

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { User, UserSchema } from './users.schema';
import { UserService } from './users.service';
import { UsersController } from './users.controller';
import { JwtModule } from '@nestjs/jwt';
import { Token, TokenSchema } from 'src/schemas/tokens.schema';
import { MailerService } from 'src/mailer/mailer.service';

@Module({
  imports: [
    MongooseModule.forFeature([
      { name: User.name, schema: UserSchema },
      { name: Token.name, schema: TokenSchema }
    ]),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>("ACCESS_SECRET_TOKEN"),
      }),
      inject: [ConfigService],
      global: true
    }),
  ],
  providers: [UserService, MailerService],
  controllers: [UsersController]
})
export class UserModule { }

Note: We need to inject the Mailer service in the user module before we can import it inside our user service

References:

https://docs.nestjs.com/

https://github.com/agenda/agenda

Conclusion:

In this guide, we successfully set up a complete email validation flow using NestJS and Agenda.js. By following the steps, you now have a system that allows new users to register, receive email verification links, and activate their accounts securely. This approach ensures that only legitimate users can fully access your application, while also providing a robust job scheduling mechanism through Agenda.js for handling email dispatch. Implementing features like these is essential for improving both the security and user experience of any modern web application