Securing Email Synchronization in NestJS: Implementing Email Service

Abdullah Irfan
5 min readMar 12, 2024

--

This is the fifteenth story of series Building a Robust Backend: A Comprehensive Guide Using NestJS, TypeORM, and Microservices. Our purpose is to build an email sync system for Gmail, since we have built the main functionality, now let’s proceed with implementing authentication and authorization. Today we be creating an email service to send emails.

We will start by adding the required email package for the elailjs service by npm i @emailjs/nodejs. Further we will be using emailjs for easy email template setup. Follow the official docs to setup and link Gmail account with it in order to use emailjs with email, the below image shows the template I have to set for registration:

EmailJS template

Now let’s define type interface for template params, the code is:

// src\shared\utils\types.d.ts
import { Request } from '@nestjs/common';
import { subscriptionStatus, subscriptionType } from '../enums/stripe.enum';


export interface EmailTemplateParams {
to_name: string;
to_email: string;
link: string;
[key: string]: unknown;
}

Now, we need an email service, we will be defining an email service class that will initialize with EmailJS private and public keys. Since in free EmailJS account we can create only two templates, so we will create a registration and a forgot password templates. The code of email service is below:

// src\services\email-service.ts
import emailjs, { EmailJSResponseStatus } from '@emailjs/nodejs';
import { Injectable } from '@nestjs/common';
import { EmailTemplateParams } from '../shared/utils/types';

@Injectable()
export class EmailService {
constructor() {
emailjs.init({
publicKey: process.env.EMAIL_JS_PUBLIC_KEY,
privateKey: process.env.EMAIL_JS_PRIVATE_KEY,
});
}

async sendRegisterationEmail(params: EmailTemplateParams) {
try {
const response = await emailjs.send(
process.env.EMAIL_JS_SERVICE_KEY,
process.env.EMAIL_JS_REGISTRATION_TEMPLATE_ID,
params,
);
console.log('Email sent successfully:', response);
return true;
} catch (err) {
if (err instanceof EmailJSResponseStatus) {
console.log('EMAILJS FAILED...', err);
return false;
}
console.log('ERROR', err);
return false;
}
}

async sendForgotPasswordEmail(params: EmailTemplateParams) {
try {
const response = await emailjs.send(
process.env.EMAIL_JS_SERVICE_KEY,
process.env.EMAIL_JS_FORGOT_PASSWORD_TEMPLATE_ID,
params,
);
console.log('Email sent successfully:', response);
return true;
} catch (err) {
if (err instanceof EmailJSResponseStatus) {
console.log('EMAILJS FAILED...', err);
return false;
}
console.log('ERROR', err);
return false;
}
}
}

With this change, we will update code to send registration code in users.service.ts with constructor to include email service and jwt service:

constructor(
@InjectRepository(Users) private readonly userRepository: Repository<Users>,
private readonly emailService: EmailService,
private readonly jwtService: JwtService,
) {}

And update the try part of create method of service with below code:

    try {
const user = await this.userRepository.save(newUser);
const payload = { id: user.id, email: user.email };
const token = this.jwtService.sign(payload, {
secret: process.env.SECRET_NON_AUTH,
});
const link = `${process.env.BASE_URL}login/${user.id}/${token}`;
const mailData: EmailTemplateParams = {
to_name: user.full_name,
to_email: user.email,
link: link,
};
if (await this.emailService.sendRegisterationEmail(mailData)) {
return customMessage(
HttpStatus.OK,
'you will shortly receive registration email',
);
}
throw new ServiceUnavailableException(
customMessage(HttpStatus.SERVICE_UNAVAILABLE, 'Service is unavailable'),
);
}

Now we need to add a column for email_verified in order to mark accounts whose emails are confirmed to be marked as true. Apart from this we also need to add foreign key to old table named gmail-accounts to link users with their repective gmail accounts. So we will create migrations with npm run migration:create — name=add-email-verified-in-users-table and npm run migration:create — name=add-email-verified-in-users-table. Thus the migrations code will be:

import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"

export class AddEmailVerifiedInUsersTable1710131043703 implements MigrationInterface {

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'users',
new TableColumn({
name: 'email_verified',
type: 'boolean',
default: false,
})
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('users', 'email_verified');
}

}
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from "typeorm"

export class AddUserIdInGmailAccounts1710131174592 implements MigrationInterface {

public async up(queryRunner: QueryRunner): Promise<void> {
// Add 'user_id' column to 'gmail_accounts'
await queryRunner.addColumn(
'gmail_accounts',
new TableColumn({
name: 'user_id',
type: 'int',
isNullable: true
})
);
await queryRunner.createForeignKey(
'gmail_accounts',
new TableForeignKey({
columnNames: ['user_id'],
referencedColumnNames: ['id'],
referencedTableName: 'users',
onDelete: 'CASCADE'
})
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropForeignKey('gmail_accounts', 'FK_user_id');
await queryRunner.dropColumn('gmail_accounts', 'user_id');
}

}

In order to verify the user user, we will add end point to verify user:

  @Get('verify-user/:id/:token')
verifyUser(@Param('id') id: string, @Param('token') token: string) {
return this.userService.verifyUser(id, token);
}

And the service method will be:

  async verifyUser(id: string, token: string) {
const user = await this.userRepository.findOneBy({ id });

try {
this.jwtService.verify(token, {
secret: process.env.SECRET_NON_AUTH,
});
} catch (err) {
throw new ForbiddenException(
customMessage(HttpStatus.FORBIDDEN, 'expired or invalid token'),
);
}
if (!user)
throw new NotFoundException(
customMessage(HttpStatus.NOT_FOUND, "user doesn't exist"),
);
const newUser = this.userRepository.create({
email_verified: true,
});
if (await this.userRepository.update(id, newUser)) {
return customMessage(HttpStatus.OK, 'user verified successful');
}
throw new ServiceUnavailableException(
customMessage(
HttpStatus.SERVICE_UNAVAILABLE,
'something went wrong, please try again later',
),
);
}

Just to clarify, we will setup the link according to the frontend and frontend will hit the backend with updated endpoint. Lastly we need to setup local.env variables with data:

BASE_URL=http://localhost:5000/
SECRET_NON_AUTH=secret_non_auth
EMAIL_JS_SERVICE_KEY=
EMAIL_JS_REGISTRATION_TEMPLATE_ID=
EMAIL_JS_PUBLIC_KEY=
EMAIL_JS_PRIVATE_KEY=

With everything set, now let’s hit register API again (use new email or remove old record from DB), this time the response will be below with email received:

user registration API
Registration email received with verification

Since normally in verification process, we use frontend webpage link that will be redirecting the user to login screen while also verifying the user from server, so the link contains /login however since our endpoint is /api/v1/users/verify-user, replace it and send hit with postman, the user will be verified

User email confirmation thorough

Now we have email service configured, next, we will setup auth module. This story code (the story doesn’t cover all aspects of code, review branch code from GitHub) is available on GitHub in feature/add-mail-service. If you appreciate this work, please show your support by clapping for the story and starring the repository.

--

--