Envello
Tutorial

Sending transactional email from NestJS, the right way

Envello Team·2026-07-17·8 min read

NestJS's module system is a good fit for isolating an external API call like this: a dedicated EmailService, injected wherever a send is needed, keeps the HTTP details out of your controllers and business logic.

The service

A minimal EmailService using NestJS's built-in HttpModule (which wraps Axios) or plain fetch, either works fine:

  • @Injectable() class EmailService { async send(payload: SendEmailPayload) { const res = await fetch('https://api.envello.dev/emails', { method: 'POST', headers: { Authorization: `Bearer ${this.configService.get('ENVELLO_API_KEY')}`, 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new EmailSendError(res.status); } }
  • Use NestJS's ConfigService for the API key rather than reading process.env directly, so it's validated and testable the same way the rest of your config is

Config validation

Use class-validator with NestJS's ConfigModule to fail fast if the API key is missing, rather than discovering it on the first send attempt in production.

  • Create an EnvelloConfig class with @IsString() @IsNotEmpty() apiKey: string
  • Register ConfigModule.forRoot({ validate: validateConfig }) in your AppModule, where validateConfig runs class-validator against process.env
  • This surfaces a clear startup error ('ENVELLO_API_KEY is required') instead of a runtime failure the first time someone triggers a password reset

Defining the payload shape

Use a DTO with class-validator decorators for the email payload, consistent with how NestJS validates incoming request bodies.

  • class SendEmailDto { @IsEmail() to: string; @IsString() @MaxLength(200) subject: string; @IsString() html: string; @IsOptional() @IsString() text?: string; }
  • Validate before calling the API, not just at the controller boundary, since EmailService might be called from places other than an HTTP request (a queue consumer, a scheduled job)

Error handling

Map Envello's response codes to typed exceptions your application can handle differently at each layer.

  • Define custom exceptions: EmailValidationError, EmailRateLimitError, EmailServerError, each extending a base EmailSendError
  • 400 responses throw EmailValidationError with the parsed error body attached
  • 429 responses throw EmailRateLimitError; catch this at the queue consumer level (below) to requeue with delay
  • 5xx responses throw EmailServerError after one retry with a short delay
  • In a controller, catch these and map to appropriate HTTP responses with NestJS's exception filters

Async sending with BullMQ

For transactional sends triggered by HTTP requests, queue the send with BullMQ (NestJS's recommended queue library, built on Redis) so the API response doesn't wait on Envello's round-trip.

  • Install and register: @nestjs/bullmq and BullModule.registerQueue({ name: 'email' })
  • Inject the queue: @InjectQueue('email') private emailQueue: Queue
  • In your controller: await this.emailQueue.add('send', { to, subject, html }, { attempts: 3, backoff: { type: 'exponential', delay: 5000 } })
  • Create a processor: @Processor('email') class EmailProcessor { async process(job: Job) { await this.emailService.send(job.data); } }
  • BullMQ handles retry, backoff, and dead-letter behavior (failed jobs) automatically

Where dependency injection helps

Injecting EmailService into whatever module needs it (an AuthModule for password resets, an OrdersModule for confirmations) means you can mock it cleanly in tests, without needing to stub fetch globally or hit a real API in your test suite.

Define an EMAIL_SERVICE injection token and provide a mock implementation in test modules via Test.createTestingModule({ providers: [{ provide: EmailService, useValue: mockEmailService }] })

Webhook handling

Create a controller to receive Envello's delivery event webhooks.

  • @Controller('webhooks') class WebhookController { @Post('envello') async handleEnvello(@Req() req: RawBodyRequest<Request>) { ... } }
  • Enable raw body access in main.ts: NestFactory.create(AppModule, { rawBody: true })
  • Read req.rawBody and the x-envello-signature header
  • Verify HMAC-SHA256 with Node's crypto module: crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
  • Use crypto.timingSafeEqual for the comparison to prevent timing attacks
  • Handle events: delivered (log), bounced (call SuppressionService.suppress(email)), complained (suppress)
  • Return { received: true } with a 200 status

If you want the Node SDK instead

A Node/TypeScript SDK exists (npm install envello, source at github.com/Aktai-ltd/envello-node). Wrapping the SDK client inside the same EmailService pattern above works identically, you'd just swap the fetch call for the SDK's client.emails.send() method.

Testing

Use Nest's testing module and mock the underlying HTTP call rather than the whole EmailService when you want to test error handling and retry logic specifically.

For controller and service tests, provide a mock EmailService via Test.createTestingModule and verify it was called with the expected arguments (toHaveBeenCalledWith).

For queue processor tests, use BullMQ's testing utilities or directly instantiate the processor with a mocked EmailService and call process() with a sample job.

For integration tests, use Envello's test mode to validate the full HTTP call without delivering to real inboxes.

Free tool

Check your domain's SPF, DKIM, and DMARC records

Paste in a domain and see what's missing, plus the exact DNS records to fix it. Free, no account needed.

Check your domain →
Envello

EU-hosted transactional email, done right by default.