Building a Scalable Email Architecture with NestJS, SendGrid and Mailgun (Part 2)


In Part 1 of this series, we meticulously crafted a SOLID email architecture tailored for NestJS applications. Now, we’ll dive deep into the implementation phase, bringing each component to life while adhering to our carefully designed structure. This hands-on approach will demonstrate how our architectural decisions translate into clean, maintainable code.

Project Setup and Dependencies

To kick off our implementation, we’ll start by installing the necessary dependencies. These libraries will form the backbone of our email system, providing essential functionality for database interactions, email sending, and templating:

npm install @nestjs/typeorm typeorm pg @sendgrid/mail mailgun.js liquidjs

With our dependencies in place, let’s outline the structure of our implementation. This organization reflects the separation of concerns we established in our architecture:

src/email/
├── constants/
│   └── injection-tokens.ts
├── entities/
│   └── email-template.entity.ts
├── interfaces/
│   ├── email-provider.interface.ts
│   ├── template-engine.interface.ts
│   └── template-repository.interface.ts
├── providers/
│   ├── sendgrid.provider.ts
│   └── mailgun.provider.ts
├── engines/
│   └── liquid-template.engine.ts
├── repositories/
│   └── email-template.repository.ts
├── services/
│   └── email.service.ts
└── email.module.ts

1. Establishing the Core: Constants and Interfaces

We’ll begin by defining our injection tokens and interfaces. These form the foundation of our dependency injection system and establish clear contracts for our components:

// constants/injection-tokens.ts
export const INJECTION_TOKENS = {
  EMAIL_PROVIDER: 'EMAIL_PROVIDER',
  TEMPLATE_ENGINE: 'TEMPLATE_ENGINE',
  TEMPLATE_REPOSITORY: 'TEMPLATE_REPOSITORY',
} as const;

export type InjectionTokens =
  (typeof INJECTION_TOKENS)[keyof typeof INJECTION_TOKENS];

// interfaces/email-provider.interface.ts
export interface EmailResponse {
  success: boolean;
  messageId?: string;
  error?: string;
}
export interface IEmailProvider {
  sendEmail(
    to: string,
    subject: string,
    content: string,
  ): Promise<EmailResponse>;
}

// interfaces/template-engine.interface.ts
export interface ITemplateEngine {
  render(template: string, data: Record<string, any>): Promise<string>;
  renderSubject(template: string, data: Record<string, any>): Promise<string>;
}

// interfaces/template-repository.interface.ts
export interface ITemplateRepository {
  findById(id: string): Promise<EmailTemplate>;
}

2. Defining the Email Template Entity

Next, we’ll create our EmailTemplate entity, which will represent our email templates in the database:

// entities/email-template.entity.ts
import { Entity, PrimaryColumn, Column } from 'typeorm';

@Entity()
export class EmailTemplate {
  @PrimaryColumn()
  id: string;
  @Column()
  subject: string;
  @Column('text')
  content: string;
  @Column('jsonb', { nullable: true })
  metadata?: Record<string, any>;
}

3. Implementing Email Providers

Now, let’s implement our email providers for SendGrid and Mailgun. These classes will encapsulate the logic for sending emails through their respective services:

// email/providers/sendgrid.provider.ts

import { Injectable } from '@nestjs/common';
import * as SendGrid from '@sendgrid/mail';
import {
  IEmailProvider,
  EmailResponse,
} from '../interfaces/email-provider.interface';

@Injectable()
export class SendGridProvider implements IEmailProvider {
  constructor(
    private readonly apiKey: string,
    private readonly fromEmail: string,
  ) {
    SendGrid.setApiKey(this.apiKey);
  }
  async sendEmail(
    to: string,
    subject: string,
    content: string,
  ): Promise<EmailResponse> {
    try {
      const [response] = await SendGrid.send({
        to,
        from: this.fromEmail,
        subject,
        html: content,
      });
      return {
        success: true,
        messageId: response.headers['x-message-id'],
      };
    } catch (error) {
      return {
        success: false,
        error: error.message,
      };
    }
  }
}

// email/providers/mailgun.provider.ts

import { Injectable } from '@nestjs/common';
import {
  EmailResponse,
  IEmailProvider,
} from '../interfaces/email-provider.interface';
import Mailgun from 'mailgun.js';

@Injectable()
export class MailgunProvider implements IEmailProvider {
  private mailgun;
  constructor(
    private readonly apiKey: string,
    private readonly domain: string,
    private readonly fromEmail: string,
  ) {
    const mailgun = new Mailgun(FormData);
    this.mailgun = mailgun.client({ username: 'api', key: this.apiKey });
  }
  async sendEmail(
    to: string,
    subject: string,
    content: string,
  ): Promise<EmailResponse> {
    try {
      const response = await this.mailgun.messages.create(this.domain, {
        from: this.fromEmail,
        to,
        subject,
        html: content,
      });
      return {
        success: true,
        messageId: response.id,
      };
    } catch (error) {
      return {
        success: false,
        error: error.message,
      };
    }
  }
}

4. Creating the Template Engine

Our template engine will be responsible for rendering email templates. We’ll use the Liquid templating language for this purpose:

// email/engines/liquid-template.engine.ts

import { Injectable } from '@nestjs/common';
import { Liquid } from 'liquidjs';
import { ITemplateEngine } from '../interfaces/template-engine.interface';

@Injectable()
export class LiquidTemplateEngine implements ITemplateEngine {
  private readonly engine: Liquid;
  constructor() {
    this.engine = new Liquid({
      strictVariables: true,
      strictFilters: true,
      cache: true,
    });
  }
  async render(template: string, data: Record<string, any>): Promise<string> {
    try {
      return await this.engine.parseAndRender(template, data);
    } catch (error) {
      throw new Error(`Template rendering failed: ${error.message}`);
    }
  }
  async renderSubject(
    template: string,
    data: Record<string, any>,
  ): Promise<string> {
    return this.render(template, data);
  }
}

5. Implementing the Template Repository

The template repository will handle database operations for our email templates:

// email/repositories/email-template.repository.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { ITemplateRepository } from '../interfaces/template-repository.interface';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EmailTemplate } from '../entities/template-email.entity';

@Injectable()
export class EmailTemplateRepository implements ITemplateRepository {
  constructor(
    @InjectRepository(EmailTemplate)
    private readonly repository: Repository<EmailTemplate>,
  ) {}
  async findById(id: string): Promise<EmailTemplate> {
    const template = await this.repository.findOne({ where: { id } });
    if (!template) {
      throw new NotFoundException(`Template ${id} not found`);
    }
    return template;
  }
}

6. Building the Email Service

Our email service will tie everything together, orchestrating the process of fetching templates, rendering them, and sending emails:

// email/email.service.ts

import { Injectable, Inject } from '@nestjs/common';
import { INJECTION_TOKENS } from './constant/injection-tokens';
import { ITemplateEngine } from './interfaces/template-engine.interface';
import { ITemplateRepository } from './interfaces/template-repository.interface';
import {
  EmailResponse,
  IEmailProvider,
} from './interfaces/email-provider.interface';

export interface EmailData {
  to: string;
  templateId: string;
  templateData: Record<string, any>;
}

@Injectable()
export class EmailService {
  constructor(
    @Inject(INJECTION_TOKENS.EMAIL_PROVIDER)
    private readonly emailProvider: IEmailProvider,
    @Inject(INJECTION_TOKENS.TEMPLATE_ENGINE)
    private readonly templateEngine: ITemplateEngine,
    @Inject(INJECTION_TOKENS.TEMPLATE_REPOSITORY)
    private readonly templateRepository: ITemplateRepository,
  ) {}

  async sendTemplatedEmail(emailData: EmailData): Promise<EmailResponse> {
    try {
      const template = await this.templateRepository.findById(
        emailData.templateId,
      );
      const [renderedSubject, renderedContent] = await Promise.all([
        this.templateEngine.renderSubject(
          template.subject,
          emailData.templateData,
        ),
        this.templateEngine.render(template.content, emailData.templateData),
      ]);
      return await this.emailProvider.sendEmail(
        emailData.to,
        renderedSubject,
        renderedContent,
      );
    } catch (error) {
      return {
        success: false,
        error: error.message,
      };
    }
  }
}

7. Configuring the Email Module

Finally, we’ll create our EmailModule, which will tie all of our components together and make them available to the rest of our application:

// email.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EmailTemplate } from './entities/template-email.entity';
import { INJECTION_TOKENS } from './constant/injection-tokens';
import { EmailService } from './email.service';
import { EmailTemplateRepository } from './repositories/email-template.repostory';
import { LiquidTemplateEngine } from './engines/liquid-template.engine';
import { SendGridProvider } from './providers/sendgrid.provider';
import { MailgunProvider } from './providers/mailgun.provider';
import { ConfigService } from '@nestjs/config';

@Module({})
export class EmailModule {
  static forRoot(): DynamicModule {
    return {
      module: EmailModule,
      imports: [TypeOrmModule.forFeature([EmailTemplate])],
      providers: [
        {
          provide: INJECTION_TOKENS.EMAIL_PROVIDER,
          useFactory: (configService: ConfigService) => {
            const provider = configService.get('EMAIL_PROVIDER');
            const apiKey = configService.get(
              provider === 'sendgrid' ? 'SENDGRID_API_KEY' : 'MAILGUN_API_KEY',
            );
            const fromEmail = configService.get('EMAIL_FROM_ADDRESS');
            const domain =
              provider === 'mailgun'
                ? configService.get('MAILGUN_DOMAIN')
                : undefined;
            return provider === 'sendgrid'
              ? new SendGridProvider(apiKey, fromEmail)
              : new MailgunProvider(apiKey, domain, fromEmail);
          },
          inject: [ConfigService],
        },
        {
          provide: INJECTION_TOKENS.TEMPLATE_ENGINE,
          useClass: LiquidTemplateEngine,
        },
        EmailTemplateRepository,
        {
          provide: INJECTION_TOKENS.TEMPLATE_REPOSITORY,
          useExisting: EmailTemplateRepository,
        },
        EmailService,
      ],
      exports: [EmailService],
    };
  }
}

8. Integrating the Email Module

To use our newly created email system in your application, you’ll need to import the EmailModule and inject the EmailService where needed:

// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { EmailModule } from './email/email.module';
import { DbModule } from './db/db.module';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: ['.env', '.env.development'],
    }),
    DbModule,
    EmailModule.forRoot(),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

// Example usage in a service
@Injectable()
export class UserService {
  constructor(private readonly emailService: EmailService) {}
  async createUser(userData: CreateUserDto) {
    // Create user logic...
    await this.emailService.sendTemplatedEmail({
      to: userData.email,
      templateId: 'welcome-email',
      templateData: {
        name: userData.firstName,
        activationLink: `${process.env.FRONTEND_URL}/activate/${activationToken}`
      }
    });
  }
}

Configuration Setup

To complete our setup, we need to create a .env file with the necessary configuration:

# Email Provider Configuration
EMAIL_PROVIDER=sendgrid  # or 'mailgun'
EMAIL_FROM_ADDRESS=noreply@yourdomain.com

# SendGrid Configuration
SENDGRID_API_KEY=your_sendgrid_api_key

# Mailgun Configuration
MAILGUN_API_KEY=your_mailgun_api_key
MAILGUN_DOMAIN=your_mailgun_domain

Lastly, we’ll create our email template in the database:

INSERT INTO email_template (id, subject, content)
VALUES (
  'welcome-email',
  'Welcome to {{ appName }}, {{ name }}!',
  '<div>
    <h1>Welcome aboard, {{ name }}!</h1>
    <p>We're excited to have you join us. Click below to get started:</p>
    <a href="{{ activationLink }}">Activate Your Account</a>
  </div>'
);

Conclusion: A Robust Email System

Throughout this two-part series, we’ve tackled the complex challenge of email handling in web applications, transforming it into a robust, maintainable system. Let’s recap our journey and the key achievements:

In Part 1, we laid the groundwork by:

  • Identifying and analyzing common email system challenges
  • Designing a SOLID architecture to address these challenges
  • Creating clear interface boundaries to ensure modularity
  • Establishing patterns for provider independence, allowing easy switching between email services

In Part 2, we brought our design to life by:

  • Implementing each component of the system, from providers to services
  • Setting up provider-specific adapters for SendGrid and Mailgun
  • Creating a flexible template engine using Liquid
  • Building a complete NestJS module that ties all components together

The result of our efforts is a sophisticated email system that:

  • Seamlessly switches between SendGrid and Mailgun without affecting the rest of the application
  • Manages email templates efficiently, allowing for easy customization and maintenance
  • Handles errors gracefully, providing clear feedback on the success or failure of email operations
  • Scales effortlessly with your application, thanks to its modular and extensible design

This implementation serves as a prime example of how proper architecture and adherence to SOLID principles can transform a potentially complex and messy feature into a clean, maintainable system that’s not only ready for production use but also prepared for future enhancements and adaptations.

The complete code for this tutorial is available on GitHub. For more in-depth tutorials and professional insights on software architecture and development, visit my website. Feel free to reach out if you have any questions or need consulting on your email system implementation.

Tags:

#NestJS #Email #SendGrid #Mailgun #Implementation #SOLID