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.

Quickstart#

Before you start

You need an API key (sk_live_…, or sk_test_… to try it without delivering anything) and a sending domain you have verified in PostStack. See Authentication and Domains.

  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
    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);
      }
    }
  4. 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());

Good to know

  • 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

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

Get started free

Next steps

Was this page helpful?