NestJS Email Validation Tutorial: Block Disposable Signups
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.