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.
1. Install the SDK
npm install @poststack.dev/sdk @nestjs/config2. Register the client in a module
// 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 {}3. Send from a service
// 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
@Injectable()
export class UsersService {
constructor(private readonly mail: MailService) {}
async register(email: string, name: string) {
// ...create the user
await this.mail.sendWelcome(email, name);
}
}4. Handle errors
NestJS idioms for error handling, retries, and structured logging when sending through PostStack.
// 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 PostStack.Webhooks.verify(rawBody, signatureHeader, secret). Enable rawBody: true in NestFactory.create so the signature can be checked against the exact bytes.
Common pitfalls
Creating a client per request
Register the client once as a provider.
new PostStack(...)inside a request-scoped service discards connection reuse and adds latency.Nest can’t resolve dependencies of the MailServiceThe module that injects
MailServicemust importMailModule(orMailModulemust be@Global()), andConfigModulemust be available where the factory runs.tomust be an arrayto: "user@example.com"fails validation with a 400. Useto: ["user@example.com"]; the same applies toccandbcc.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.
Notes
- Import
ConfigModule.forRoot()once inAppModulesoPOSTSTACK_API_KEYis read from.env - The
POSTSTACKtoken lives in its own file so the module and the service never import each other — a circular import would make the tokenundefinedat decoration time - An
sk_test_...key validates and logs sends without delivering them — use it in e2e tests
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.
Related guides
Ready to send emails with NestJS?
Create a free account and get your API key in under a minute.