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)
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
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
| Field | Type | Default | Description |
|---|---|---|---|
from | string | — | Default sender address |
replyTo | string | — | Default 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.
MailNodemailerModule.forRoot({
host: 'smtp.example.com',
port: 587,
secure: false,
auth: { user: 'app@example.com', pass: 'secret' },
});| Field | Type | Default | Description |
|---|---|---|---|
host | string | — | SMTP server hostname |
port | number | 587 | SMTP port |
secure | boolean | false | Use 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().
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.
const modules = [
MailModule.forRoot({ from: 'dev@example.com' }),
MailMemoryModule,
MailMemoryUiModule,
];| Option | Default | Description |
|---|---|---|
enabled | process.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
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:
@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
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
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:
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.
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:
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.
import { MailQueueModule, MAIL_SEND_TOPIC } from '@modularityjs/mail-queue';Web process — enqueues mail
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
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:
@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 {}