Skip to content

Mail

Transactional email sending with pluggable transport backends. The contract provides a concrete MailService that applies config defaults (from, replyTo) and delegates to an abstract MailTransport sub-contract — the same pattern as webhook (WebhookService + WebhookDelivery).

Contract (@modularityjs/mail)

typescript
class MailService {
  send(message: MailMessage): Promise<MailSendResult>;
}

abstract class MailTransport {
  abstract send(message: MailMessage): Promise<MailSendResult>;
}

MailService is concrete — it composes config defaults and delegates to MailTransport. Drivers implement MailTransport.

Setup

typescript
import { MailModule } from '@modularityjs/mail';
import { MailNodemailerModule } from '@modularityjs/mail-nodemailer';

const modules = [
  MailModule.forRoot({ from: 'noreply@example.com' }),
  MailNodemailerModule.forRoot({
    host: 'smtp.example.com',
    port: 587,
    auth: { user: 'app@example.com', pass: 'secret' },
  }),
];

Config

FieldTypeDefaultDescription
fromstringDefault sender address
replyTostringDefault reply-to address

Per-message from and replyTo override the config defaults. A send whose resolved from is empty (no message.from and no MailConfig.from) throws a ValidationException before reaching the transport — the same failure against every driver.

Drivers

Nodemailer (@modularityjs/mail-nodemailer)

SMTP and other transports via nodemailer. Covers SMTP, SES, sendmail, and other backends through nodemailer's transport system.

typescript
MailNodemailerModule.forRoot({
  host: 'smtp.example.com',
  port: 587,
  secure: false,
  auth: { user: 'app@example.com', pass: 'secret' },
});
FieldTypeDefaultDescription
hoststringSMTP server hostname
portnumber587SMTP port
securebooleanfalseUse TLS
auth{ user: string; pass: string }SMTP authentication

Memory (@modularityjs/mail-memory)

In-memory transport for testing. Stores sent messages in an array with getSent() and clear().

typescript
import {
  MailMemoryModule,
  MemoryMailTransport,
} from '@modularityjs/mail-memory';

const modules = [
  MailModule.forRoot({ from: 'test@example.com' }),
  MailMemoryModule,
];

// In tests — get the transport from the container
const transport = container.get(MemoryMailTransport);
expect(transport.getSent()).toHaveLength(1);
transport.clear();

Browsing captured mail (@modularityjs/mail-memory-ui)

getSent() is right for assertions and wrong for development — during a signup or password-reset flow you want to read the mail, with its HTML rendered. MailMemoryUiModule mounts a letter-opener-style inbox at /dev/mail over the in-memory transport.

typescript
const modules = [
  MailModule.forRoot({ from: 'dev@example.com' }),
  MailMemoryModule,
  MailMemoryUiModule,
];
OptionDefaultDescription
enabledprocess.env.NODE_ENV !== 'production'Route 404s when false

It defaults to off in production, so a forgotten dependency cannot expose the mailbox. It builds on the abstract http contract (a plain @Controller), not on Fastify — any HTTP driver serves it.

Sending Mail

Plain text / HTML

typescript
await mailService.send({
  to: 'user@example.com',
  subject: 'Order confirmed',
  html: '<h1>Thank you for your order!</h1>',
  text: 'Thank you for your order!',
});

With template engine

Compose TemplateEngine.render() + MailService.send() — no extension package needed:

typescript
@Injectable()
class OrderMailer {
  constructor(
    @Inject(MailService) private readonly mail: MailService,
    @Inject(TemplateEngine) private readonly templates: TemplateEngine,
  ) {}

  async sendConfirmation(order: Order): Promise<void> {
    const html = await this.templates.render('order-confirmation', {
      orderNumber: order.number,
      total: order.total,
    });
    await this.mail.send({
      to: order.customerEmail,
      subject: `Order ${order.number} confirmed`,
      html,
    });
  }
}

Multiple recipients, CC, BCC

typescript
await mailService.send({
  to: ['alice@example.com', 'bob@example.com'],
  cc: { name: 'Manager', address: 'manager@example.com' },
  bcc: 'audit@example.com',
  subject: 'Team update',
  text: 'Weekly report attached.',
});

Attachments

typescript
await mailService.send({
  to: 'user@example.com',
  subject: 'Your invoice',
  html: '<p>Please find your invoice attached.</p>',
  attachments: [
    {
      filename: 'invoice.pdf',
      content: pdfBuffer,
      contentType: 'application/pdf',
    },
  ],
});

Named addresses

Recipients accept both strings and MailAddress objects:

typescript
await mailService.send({
  to: { name: 'Alice', address: 'alice@example.com' },
  from: { name: 'MyApp', address: 'noreply@myapp.com' },
  subject: 'Hello',
  text: 'Hi Alice!',
});

Templated Mail (@modularityjs/mail-template)

TemplatedMailService renders mail bodies through the app's TemplateEngine so mailers pass template names + data instead of pre-built strings. It coexists with the plain MailService — inject TemplatedMailService for templated mail, MailService for pre-built bodies.

typescript
import { Inject, Injectable } from '@modularityjs/di';
import { TemplatedMailService } from '@modularityjs/mail-template';

@Injectable()
class OrderMailer {
  constructor(
    @Inject(TemplatedMailService) private readonly mail: TemplatedMailService,
  ) {}

  async sendConfirmation(order: Order): Promise<void> {
    await this.mail.send({
      to: order.customerEmail,
      subject: `Order ${order.number} confirmed`,
      htmlTemplate: 'order-confirmation', // rendered to the html body
      textTemplate: 'order-confirmation-text', // rendered to the text body
      layout: 'mail-layout', // wraps the html render (not the text)
      data: { orderNumber: order.number, total: order.total },
    });
  }
}

At least one of htmlTemplate / textTemplate is required — omitting both throws a ValidationException. The same data object is shared by the html, text, and layout renders.

Layouts

layout names a template (or an array of templates, outermost-first) that wraps the rendered html body. Each layout is rendered with the input data plus body set to the inner render — so the layout template emits the body value where the content goes (a triple-stash expression in Handlebars, so the html is not re-escaped). An array reads like nested HTML: layout: ['layout', 'newsletter'] produces layout( newsletter( inner ) ). Layouts apply to the html body only; the text render is never wrapped.

Wiring

MailTemplateModule imports MailModule and TemplateModule, so the app supplies a mail transport and a template-engine driver:

typescript
import { MailModule } from '@modularityjs/mail';
import { MailNodemailerModule } from '@modularityjs/mail-nodemailer';
import { MailTemplateModule } from '@modularityjs/mail-template';
import { TemplateModule } from '@modularityjs/template';
import { TemplateHandlebarsModule } from '@modularityjs/template-handlebars';

const modules = [
  MailModule.forRoot({ from: 'noreply@example.com' }),
  MailNodemailerModule.forRoot({ host: 'smtp.example.com', port: 587 }),
  TemplateModule,
  TemplateHandlebarsModule.forRoot({ directory: 'templates' }),
  MailTemplateModule,
];

Queue Extension (@modularityjs/mail-queue)

Async mail delivery via the queue system. Publishes MailMessage to a queue topic instead of sending synchronously. Follows the same pattern as @modularityjs/webhook-queue.

typescript
import { MailQueueModule, MAIL_SEND_TOPIC } from '@modularityjs/mail-queue';

Web process — enqueues mail

typescript
const modules = [
  RedisModule.forRoot({ url: 'redis://localhost:6379' }),
  MailModule.forRoot({ from: 'noreply@example.com' }),
  MailQueueModule, // overrides MailTransport with queue publisher
  QueueModule,
  QueueRedisModule,
];

Worker process — sends via SMTP

typescript
const modules = [
  RedisModule.forRoot({ url: 'redis://localhost:6379' }),
  MailModule.forRoot({ from: 'noreply@example.com' }),
  MailNodemailerModule.forRoot({ host: 'smtp.example.com', ... }),
  QueueModule,
  QueueRedisModule,
  AppMailConsumerModule, // your @Consume({ topic: 'mail.send', name: 'mail-send-handler' }) handler
];

The consumer listens to MAIL_SEND_TOPIC ('mail.send') and sends via the real MailService (which resolves to nodemailer in the worker process).

Custom Transport

Implement MailTransport for any backend:

typescript
@Injectable()
class SesMailTransport extends MailTransport {
  async send(message: MailMessage): Promise<MailSendResult> {
    // Use AWS SES SDK directly
    const result = await ses.sendEmail({ ... });
    return { messageId: result.MessageId, accepted: [...], rejected: [] };
  }
}

@Module({
  name: 'mail-ses',
  imports: [MailModule],
  providers: [SesMailTransport],
  preferences: [{ provide: MailTransport, useClass: SesMailTransport }],
})
class MailSesModule {}