emailverifier.dev
Posts

NestJS Email Validation Tutorial: Block Disposable Signups

| 5 min read | Usama Ejaz
NestJS email check cover with one message moving through stacked application modules.

The useful NestJS test is not “the controller returned 200.” It is “one server-side request contained only the submitted email, used the project key, and stopped the signup when the result said block.”

This guide builds that boundary as a Nest provider, calls it from a registration controller, and proves the outgoing request with Jest before making a live check.

Start with the boundary test

Create a strict Nest project and install the configuration and validation packages:

npx @nestjs/cli@latest new nest-email-check \
  --package-manager npm \
  --strict
cd nest-email-check
npm install @nestjs/config class-validator class-transformer

Nest currently requires Node.js 20 or later. The official first-steps guide documents the CLI project and generated test setup.

Create src/email-verification.service.spec.ts:

import { ConfigService } from '@nestjs/config'
import { EmailVerificationService } from './email-verification.service'

describe('EmailVerificationService', () => {
  afterEach(() => {
    jest.restoreAllMocks()
  })

  it('sends one server-side request with only the email', async () => {
    const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(
      new Response(
        JSON.stringify({
          email: 'person@example.com',
          status: 'risky',
          action: 'block',
          flagged: true,
          signals: ['disposable_address'],
          suggestion: null,
        }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      ),
    )

    const config = {
      getOrThrow: jest.fn().mockReturnValue('test-project-key'),
    } as unknown as ConfigService

    const service = new EmailVerificationService(config)
    const result = await service.check('person@example.com')

    expect(fetchMock).toHaveBeenCalledTimes(1)
    expect(fetchMock).toHaveBeenCalledWith(
      'https://emailverifier.dev/api/v1/verify',
      expect.objectContaining({
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': 'test-project-key',
        },
        body: JSON.stringify({ email: 'person@example.com' }),
      }),
    )
    expect(result.action).toBe('block')
  })
})

The test does not spend a credit or depend on the network. Nest's testing documentation recommends keeping component tests beside the class and provides Jest in the generated project.

Implement the provider that makes the request

Create src/email-verification.service.ts:

import {
  Injectable,
  ServiceUnavailableException,
} from '@nestjs/common'
import { ConfigService } from '@nestjs/config'

type VerificationStatus =
  | 'deliverable'
  | 'risky'
  | 'undeliverable'
  | 'unknown'

type VerificationAction = 'allow' | 'review' | 'block'

export type VerificationResult = {
  email: string
  status: VerificationStatus
  action: VerificationAction
  flagged: boolean
  signals: string[]
  suggestion: string | null
}

function isVerificationResult(
  value: unknown,
): value is VerificationResult {
  if (!value || typeof value !== 'object') return false

  const result = value as Record<string, unknown>
  return (
    typeof result.email === 'string' &&
    ['deliverable', 'risky', 'undeliverable', 'unknown'].includes(
      String(result.status),
    ) &&
    ['allow', 'review', 'block'].includes(String(result.action)) &&
    typeof result.flagged === 'boolean' &&
    Array.isArray(result.signals) &&
    (result.suggestion === null ||
      typeof result.suggestion === 'string')
  )
}

@Injectable()
export class EmailVerificationService {
  constructor(private readonly config: ConfigService) {}

  async check(email: string): Promise<VerificationResult> {
    const apiKey = this.config.getOrThrow<string>(
      'EMAILVERIFIER_API_KEY',
    )

    let response: Response
    try {
      response = await fetch(
        'https://emailverifier.dev/api/v1/verify',
        {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-API-Key': apiKey,
          },
          body: JSON.stringify({ email }),
          signal: AbortSignal.timeout(4000),
        },
      )
    } catch {
      throw new ServiceUnavailableException(
        'Email verification is temporarily unavailable.',
      )
    }

    if (!response.ok) {
      throw new ServiceUnavailableException(
        'Email verification is temporarily unavailable.',
      )
    }

    const result: unknown = await response.json()
    if (!isVerificationResult(result)) {
      throw new ServiceUnavailableException(
        'Email verification returned an unexpected response.',
      )
    }

    return result
  }
}

The provider uses the Node.js fetch available in the supported runtime and stops the request after four seconds. It also validates the response shape instead of trusting arbitrary JSON.

Put the provider in front of registration

Create src/signup.dto.ts:

import { IsEmail, IsString, Length } from 'class-validator'

export class SignupDto {
  @IsEmail()
  email!: string

  @IsString()
  @Length(8, 128)
  password!: string
}

Create src/signup.controller.ts:

import {
  Body,
  Controller,
  HttpCode,
  Post,
  UnprocessableEntityException,
} from '@nestjs/common'
import { EmailVerificationService } from './email-verification.service'
import { SignupDto } from './signup.dto'

@Controller('signup')
export class SignupController {
  constructor(
    private readonly emailVerification: EmailVerificationService,
  ) {}

  @Post()
  @HttpCode(200)
  async signup(@Body() input: SignupDto) {
    const result = await this.emailVerification.check(input.email)

    if (result.action === 'block') {
      throw new UnprocessableEntityException({
        message: result.signals.includes('disposable_address')
          ? 'Use a permanent email address.'
          : 'Check the email address and try again.',
        status: result.status,
        signals: result.signals,
        suggestion: result.suggestion,
      })
    }

    if (result.action === 'review') {
      return {
        decision: 'review',
        email: result.email,
        status: result.status,
        signals: result.signals,
        suggestion: result.suggestion,
      }
    }

    // Call your existing user repository here. Keep input.password
    // inside the application; the verifier receives only the email.
    return {
      decision: 'allow',
      email: result.email,
      status: result.status,
    }
  }
}

The DTO catches malformed input before the controller calls the provider. Nest documents this class-based boundary in its current ValidationPipe guide.

Replace src/app.module.ts:

import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { EmailVerificationService } from './email-verification.service'
import { SignupController } from './signup.controller'

@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true })],
  controllers: [SignupController],
  providers: [EmailVerificationService],
})
export class AppModule {}

Replace src/main.ts:

import { ValidationPipe } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'

async function bootstrap() {
  const app = await NestFactory.create(AppModule)
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
    }),
  )
  await app.listen(process.env.PORT ?? 3000)
}

void bootstrap()

ConfigModule.forRoot() loads a project-root .env file, while real environment variables take precedence. That behavior is defined in the official Nest configuration guide.

Run the test and then the live route

Run the provider test first:

npm test -- email-verification.service.spec.ts

Create an emailverifier.dev project and copy its API key. Put it in a local .env file that is excluded from version control:

EMAILVERIFIER_API_KEY=your-project-key

Start Nest and submit a signup:

npm run start:dev

curl http://localhost:3000/signup \
  -H "Content-Type: application/json" \
  -d '{
    "email": "person@example.com",
    "password": "correct-horse"
  }'

The emailverifier.dev response can report deliverable, risky, undeliverable, or unknown. This controller follows the accompanying action: allow continues, review keeps the account behind confirmation or restricted access, and block returns 422. A timeout, non-success response, or malformed upstream payload returns 503 instead of pretending the email failed.

See the REST API reference for every field. If trial credit is valuable, define the blocking rule with the disposable-email signup policy and grant the credit only after the accepted registration reaches the qualifying state.

Continue reading