Skip to content

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

bash
npm install @poststack.dev/sdk @nestjs/config

2. Register 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 {}

3. 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
@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.

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.

typescript
// 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.

typescript
@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.

typescript
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 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.

Notes

  • Import ConfigModule.forRoot() once in AppModule so POSTSTACK_API_KEY is read from .env
  • The POSTSTACK token lives in its own file so the module and the service never import each other — a circular import would make the token undefined at 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.