Send email with NestJS
Wrap the PostStack SDK in a NestJS module: an injectable client, a MailService, error mapping to HTTP exceptions, and queued sends with BullMQ.
NestJS is built around dependency injection, so the clean way to send email is to register one PostStack client as a provider and inject a MailService wherever you need it. The PostStack TypeScript SDK (@poststack.dev/sdk) is fully typed, already retries 408/429/5xx with an idempotency key, and works on both the Express and Fastify platforms. Below: the module and service, an exception filter that turns PostStack errors into HTTP responses, background sends with BullMQ, attachments, and how to replace the client in tests.
Quickstart#
Install the SDK
bashnpm install @poststack.dev/sdk @nestjs/configRegister the client in a module
typescript// src/mail/mail.constants.ts export const POSTSTACK = Symbol('POSTSTACK'); // src/mail/mail.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { PostStack } from '@poststack.dev/sdk'; import { POSTSTACK } from './mail.constants'; import { MailService } from './mail.service'; @Module({ imports: [ConfigModule], providers: [ { provide: POSTSTACK, inject: [ConfigService], useFactory: (config: ConfigService) => new PostStack(config.getOrThrow<string>('POSTSTACK_API_KEY')), }, MailService, ], exports: [MailService], }) export class MailModule {}Send from a service
typescript// src/mail/mail.service.ts import { Inject, Injectable } from '@nestjs/common'; import { PostStack } from '@poststack.dev/sdk'; import { POSTSTACK } from './mail.constants'; @Injectable() export class MailService { constructor(@Inject(POSTSTACK) private readonly poststack: PostStack) {} async sendWelcome(to: string, name: string) { const { id } = await this.poststack.emails.send({ from: 'Acme <hello@yourdomain.com>', // must be on a verified domain to: [to], // always an array subject: `Welcome, ${name}`, html: `<h1>Welcome, ${name}!</h1>`, text: `Welcome, ${name}!`, tags: ['welcome'], }); return id; // "em_..." } } // src/users/users.service.ts — inject it anywhere MailModule is imported import { Injectable } from '@nestjs/common'; import { MailService } from '../mail/mail.service'; @Injectable() export class UsersService { constructor(private readonly mail: MailService) {} async register(email: string, name: string) { // ...create the user await this.mail.sendWelcome(email, name); } }Handle errors
typescript// src/mail/poststack-exception.filter.ts import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common'; import { PostStackError } from '@poststack.dev/sdk'; import type { Response } from 'express'; @Catch(PostStackError) export class PostStackExceptionFilter implements ExceptionFilter { private readonly logger = new Logger('PostStack'); catch(err: PostStackError, host: ArgumentsHost) { this.logger.error(`${err.statusCode} ${err.message} (request ${err.requestId})`); // 400/422 are caused by the input (bad payload, unverified domain, // suppressed recipient); everything else is our problem, not the caller's. const status = err.statusCode === 400 || err.statusCode === 422 ? err.statusCode : HttpStatus.BAD_GATEWAY; host.switchToHttp().getResponse<Response>().status(status).json({ statusCode: status, message: status === HttpStatus.BAD_GATEWAY ? 'Email could not be sent' : err.message, }); } } // main.ts app.useGlobalFilters(new PostStackExceptionFilter());
Send in the background with BullMQ#
Move sends out of the request with @nestjs/bullmq. Pass an idempotency_key derived from the job so a retried job never delivers twice — PostStack returns the original email instead.
// npm install @nestjs/bullmq bullmq
// mail.module.ts: imports: [BullModule.registerQueue({ name: 'mail' })]
// src/mail/mail.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Inject } from '@nestjs/common';
import { Job } from 'bullmq';
import { PostStack, PostStackError } from '@poststack.dev/sdk';
import { POSTSTACK } from './mail.constants';
@Processor('mail')
export class MailProcessor extends WorkerHost {
constructor(@Inject(POSTSTACK) private readonly poststack: PostStack) {
super();
}
async process(job: Job<{ to: string; name: string }>) {
try {
const { id } = await this.poststack.emails.send({
from: 'Acme <hello@yourdomain.com>',
to: [job.data.to],
subject: `Welcome, ${job.data.name}`,
html: `<h1>Welcome, ${job.data.name}!</h1>`,
idempotency_key: `welcome-${job.id}`,
});
return { id };
} catch (err) {
// 400/422 will fail the same way on every attempt — don't retry them
if (err instanceof PostStackError && [400, 422].includes(err.statusCode)) {
await job.discard();
}
throw err;
}
}
}
// enqueue: await this.mailQueue.add('welcome', { to, name }, { attempts: 5, backoff: { type: 'exponential', delay: 30_000 } });Attachments#
Attachments are base64 strings. With FileInterceptor the upload is already a Buffer. Limits: 10 attachments, 10 MB per file, 25 MB in total.
@Post('report')
@UseInterceptors(FileInterceptor('file'))
async sendReport(@UploadedFile() file: Express.Multer.File) {
const { id } = await this.poststack.emails.send({
from: 'reports@yourdomain.com',
to: ['team@yourdomain.com'],
subject: `Report: ${file.originalname}`,
text: 'Report attached.',
attachments: [
{
filename: file.originalname,
content: file.buffer.toString('base64'),
content_type: file.mimetype,
},
],
});
return { id };
}Replace the client in tests#
Override the POSTSTACK provider with a stub so unit tests never call the API.
const send = jest.fn().mockResolvedValue({ id: 'em_test' });
const moduleRef = await Test.createTestingModule({ imports: [MailModule] })
.overrideProvider(POSTSTACK)
.useValue({ emails: { send } })
.compile();
await moduleRef.get(MailService).sendWelcome('ada@example.com', 'Ada');
expect(send).toHaveBeenCalledWith(expect.objectContaining({ to: ['ada@example.com'] }));Framework integrations#
Global module
Mark MailModule with @Global() if many feature modules send email, so you do not have to import it everywhere. Keep one client per process — it is safe to share.
Fastify platform
The SDK does not depend on the HTTP platform. Only the exception filter’s response object changes: use FastifyReply and reply.status(status).send(...).
SMTP with @nestjs-modules/mailer
If you already use the mailer module with Handlebars or Pug templates, set its transport to smtp.poststack.dev (465 with secure: true, or 587 with STARTTLS) and your API key as the password. The SDK path returns the email id; SMTP does not.
Webhooks
Receive delivery, bounce and open events in a controller and verify them with await PostStack.Webhooks.verify(req.rawBody.toString("utf8"), signatureHeader, secret) — it takes the body as a string and resolves to a boolean. Enable rawBody: true in NestFactory.create so the signature is checked against the exact bytes that were signed.
Common pitfalls#
Creating a client per request
Register the client once as a provider. Building new PostStack(...) in a request-scoped service creates an object per request for no benefit, and makes the client impossible to override in tests.
Nest can’t resolve dependencies of the MailService
The module that injects MailService must import MailModule (or MailModule must be @Global()), and ConfigModule must be available where the factory runs.
to must be an array
to: "user@example.com" fails validation with a 400. Use to: ["user@example.com"]; the same applies to cc and bcc.
Retrying permanent failures
A 422 for an unverified domain or a suppressed recipient will fail on every attempt. Discard those jobs instead of letting the queue retry them.
FAQ#
How do I send email in NestJS?
Install @poststack.dev/sdk, register new PostStack(apiKey) as a provider with useFactory, inject it into a MailService, and call this.poststack.emails.send({ from, to: [...], subject, html }).
Should I use the SDK or SMTP in NestJS?
The SDK for new code: it is typed, retries for you and returns the email id you need to match webhook events. SMTP makes sense if you already use @nestjs-modules/mailer templates.
How do I queue emails in NestJS?
Use @nestjs/bullmq: enqueue a job in the request and send from a @Processor. Pass an idempotency_key based on the job id so retries never deliver twice.
How do I test code that sends email?
Override the client provider with a stub in Test.createTestingModule, or use an sk_test_... key in e2e tests — sends are validated and logged but never delivered.
Ready to send from NestJS?
Create a free account and an API key — no card needed.