@anarchitects/common-nest-mailer
Shared typed mailer configuration, transport setup, and provider wiring for NestJS apps.
Developer + AI Agent Start Here
- Read this README before generating mailer integration code with
@anarchitects/common-nest-mailer. - Configure transport once at app root (
CommonMailerModule.forRootFromConfig()), then use provider wiring for domain adapters. - Treat
MailerPortas the cross-domain contract; keep domain modules decoupled from concrete mailer implementations. - Use only public exports from this package and keep provider selection explicit (
nodeornoop).
Features
- Shared mailer config namespace + typed injection helpers
- Adapter wiring (
nodeandnoop) behind a commonMailerPortcontract - Root transport setup helpers for deterministic app-level configuration
- Template directory resolution against a deployment base, with startup validation
Installation
Common 0.5.0 is published. The security-maintenance candidate for #512 adds support for Nodemailer 10.2.1 and updates Handlebars to 4.7.10. It is not yet a published patch. Follow the security decision, upgrade/rollback guide and release gates before adopting it. Existing Nodemailer 8 installations remain vulnerable even though their peer compatibility is retained.
After the reviewed patch is published, install its exact version in place of REVIEWED_PATCH below:
npm install @anarchitects/common-nest-mailer@REVIEWED_PATCH @nestjs/common@11.1.6 @nestjs/core@11.1.6 @nestjs/config@4.0.2 @nestjs-modules/mailer@^3.0.2 nodemailer@10.2.1
# or
yarn add @anarchitects/common-nest-mailer@REVIEWED_PATCH @nestjs/common@11.1.6 @nestjs/core@11.1.6 @nestjs/config@4.0.2 @nestjs-modules/mailer@^3.0.2 nodemailer@10.2.1
Peer requirements:
@nestjs/common:^11.1.6 || ^12.1.2@nestjs/config:^4.0.2 || ^12.0.1(Config 4 is Nest 11 only)@nestjs-modules/mailer:^3.0.2nodemailer:^8.0.5 || ^10.2.1(use 10.2.1+ for remediation; 8.x is compatibility-only)
Keep the host's Nest common/core/platform/testing packages on the same major.
The recipe above retains Nest 11. For Nest 12, use aligned common/core ^12.1.2 and Config ^12.0.1.
Config's own peers reject Nest 12 with Config 4. Handlebars and the Nodemailer
declarations are direct package dependencies, so the built-in template adapter
does not depend on optional packages being hoisted by the consuming workspace.
Compatibility and migration
For the current security candidate and separate old-line mitigation, see the #512 record. The following dependency table describes the candidate hosts, not a published security patch.
The same CommonJS artifact supports these tested dependency combinations:
| Nest common/core/testing | Config | Mailer | Nodemailer | Handlebars |
|---|---|---|---|---|
| 11.1.6 | 4.0.2 | 3.0.2 | 10.2.1 | 4.7.10 |
| 11.1.6 | 12.0.1 | 3.0.2 | 10.2.1 | 4.7.10 |
| 12.1.2 | 12.0.1 | 3.0.2 | 10.2.1 | 4.7.10 |
Breaking dependency change from 0.4.x: the 0.5 line requires Mailer 3
and at least the declared Nest patch floor; its security candidate admits
Nodemailer 10.2.1. Update those host
dependencies together. The facade methods, config namespace, injection tokens,
node/noop selection and MailerPort API are unchanged by #465. Existing Nest 11
hosts on 0.4.x need the old-line containment/backport procedure in the security
guide or an explicitly reviewed migration; the old line is not a secure fallback.
Auth, Forms and Newsletter now declare Common 0.5 and have their own passing packed hosts. Their releases must be coordinated with Common 0.5 availability.
Mailer 2.3.4 and 2.3.10 publish a declaration importing
@nestjs/common/interfaces, which is not exposed by Nest 12 under NodeNext
resolution. Mailer 3.0.2
imports those types from the public Nest root and requires Nodemailer >=8.0.5.
It also stops automatically installing optional template engines. This package
therefore declares Handlebars directly. No skipLibCheck, patched upstream
declarations, ESM conversion or application API workaround is required.
Linux packed CI passed Nest 11.1.6 on Node 20.19.0, 22.13.0, 24.11.0 and 26.11.1, and Nest 12.1.2 on Node 22.13.0, 24.11.0 and 26.11.1, with TypeScript 6.0.3, strict NodeNext and skipLibCheck: false. No standalone engine is declared for this package; use the tested host/dependency combinations. Nest 12 on Node 20 is excluded by its locked dependencies. That historical matrix used Nodemailer 8.0.5; fresh security-candidate CI is required under #512.
Entry points and exports
mailerConfig:registerAs(...)config namespace for@nestjs/configMailerConfig: public config type, includingtemplateDir: stringand optionaltemplateBaseDir?: stringInjectMailerConfig(): decorator helper for injecting config valuesCommonMailerProvider: provider mode union ('node' | 'noop')CommonMailerModuleOptions: provider wiring options (provider?: CommonMailerProvider)CommonMailerModule.forRoot(options?): provider wiring (MailerPort -> NodeMailerAdapter|NoopMailerAdapter)CommonMailerModule.forProviderFromConfig(overrides?): config-driven provider wiring fromMAILER_PROVIDERCommonMailerModule.forRootFromConfig(): config-driven root mail transport setupCommonMailerModule.forRootAsync(...): pass-through setup for custom transportsMailerPort: shared mailer port token/contract for domain adapters, includingsendMessage,send, andsendTemplateMailerMessage: rendered HTML/text message contract with optional sender, reply-to, and headersNodeMailerAdapter: shared concrete adapter using NestMailerServiceNoopMailerAdapter: shared no-op implementation
Environment Variables
MAILER_PROVIDER=node
MAILER_HOST=smtp.example.com
MAILER_PORT=587
MAILER_SECURE=false
MAILER_USER=user@example.com
MAILER_PASS=super-secret
MAILER_DEFAULT=noreply@example.com
MAILER_IGNORE_TLS=false
MAILER_TEMPLATE_DIR=templates
# Optional; leave empty for cwd-relative behavior.
MAILER_TEMPLATE_BASE_DIR=
Configuration
Configure mailerConfig in ConfigModule.forRoot({ load: [mailerConfig] }) and then choose provider wiring with either:
CommonMailerModule.forProviderFromConfig()for env-driven provider selectionCommonMailerModule.forRoot({ provider: 'node' | 'noop' })for explicit provider selection
Template Directory Configuration
CommonMailerModule.forRootFromConfig() resolves the template directory before passing it to the Handlebars adapter. The fields are part of MailerConfig:
| Config field | Environment variable | Default |
|---|---|---|
templateDir: string |
MAILER_TEMPLATE_DIR |
templates when unset |
templateBaseDir?: string |
MAILER_TEMPLATE_BASE_DIR |
Unset; resolution falls back to process.cwd() |
Resolution follows these rules:
- An absolute
templateDiris passed through unchanged, regardless oftemplateBaseDir. - A relative
templateDirresolves withpath.resolve(templateBaseDir, templateDir)when the base is non-empty. - An absent or empty base resolves with
path.resolve(process.cwd(), templateDir)for compatibility with existing cwd-relative configurations.
A relative base also depends on process.cwd() through Node's path.resolve. Use an absolute deployment base or derive one from the running application to make resolution independent of the launch directory. Empty means ''; whitespace is a directory name, not an omitted value. An empty templateDir resolves to the base directory rather than the default templates directory.
Existing cwd-relative configuration
MAILER_TEMPLATE_DIR=templates
MAILER_TEMPLATE_BASE_DIR=
With process.cwd() equal to /srv/app, the resolved directory is /srv/app/templates. Omitting MAILER_TEMPLATE_BASE_DIR has the same effect. Launching from another working directory changes this result.
Explicit deployment base
For a built application shipped with this layout:
/srv/app/dist/
main.js
templates/
contact.hbs
configure:
MAILER_TEMPLATE_BASE_DIR=/srv/app/dist
MAILER_TEMPLATE_DIR=templates
The resolved directory is /srv/app/dist/templates, even when PM2, systemd, or a container starts the process with cwd / or /var/run. A fixed absolute base must still match the deployment location; it does not automatically follow a moved directory.
Absolute template directory
MAILER_TEMPLATE_BASE_DIR=/srv/app/dist
MAILER_TEMPLATE_DIR=/srv/shared/mail-templates
The resolved directory remains /srv/shared/mail-templates; the base is ignored.
Choosing the base in application code
forRootFromConfig() takes no options argument and reads the registered mailerConfig. forRoot() accepts provider selection only. To derive the template location in application code while using the config-driven setup, populate its environment inputs before loading the application module:
import { NestFactory } from '@nestjs/core';
import type { MailerConfig } from '@anarchitects/common-nest-mailer';
async function bootstrap() {
const templateLocation = {
templateDir: 'templates',
templateBaseDir: __dirname,
} satisfies Pick<MailerConfig, 'templateDir' | 'templateBaseDir'>;
process.env['MAILER_TEMPLATE_DIR'] = templateLocation.templateDir;
process.env['MAILER_TEMPLATE_BASE_DIR'] = templateLocation.templateBaseDir;
// AppModule imports CommonMailerModule.forRootFromConfig().
const { AppModule } = await import('./app.module');
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
void bootstrap();
This CommonJS example deliberately makes the application own both path settings. With the layout above, __dirname is /srv/app/dist. Moving the artifact to /srv/releases/42/dist makes the base /srv/releases/42/dist without editing the configuration. For an ESM entry point, derive the directory with fileURLToPath(new URL('.', import.meta.url)) from node:url. In either case, the actual output layout must place templates at the configured relative path.
Startup validation and ownership
During Nest configuration, forRootFromConfig() checks that the resolved path exists, is a directory, and passes Node's read/search access checks where supported by the OS. A missing path, a file, or an inaccessible directory rejects bootstrap before the mailer becomes usable. Errors identify the resolved path, for example:
Invalid mailer template directory "/srv/app/dist/templates": path does not exist or cannot be accessed.
Startup compatibility change: configurations that previously booted with invalid template paths now fail immediately, even if no email is sent. This validation runs whenever forRootFromConfig() is imported; MAILER_PROVIDER=noop only controls provider wiring and does not disable that transport setup.
The check covers the directory at startup. It does not compile every template, guarantee individual files exist, monitor later filesystem changes, or verify SMTP connectivity. Missing templates or later permission changes can still fail when rendering a message.
The consuming application owns, copies, and ships its templates. Include them in the build artifact or mount them before bootstrap, with access for the runtime user. This package resolves and validates their location; it does not bundle or copy host templates.
Migrating a checkout-specific absolute path
Replace a configuration such as:
MAILER_TEMPLATE_DIR=/home/developer/old-checkout/templates
with a base matching the deployed artifact and a relative template directory:
MAILER_TEMPLATE_BASE_DIR=/srv/app/dist
MAILER_TEMPLATE_DIR=templates
Ship templates/ alongside main.js in that layout. If the deployment root itself moves, derive the base from the application as above, or have the deployment configuration supply the new base. Verify startup from a different cwd before sending a real message. Existing absolute paths remain supported when they intentionally refer to a stable shared location.
Usage (Preferred)
Configure mail transport once at app root, then let domain mailer modules consume MailerPort.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { CommonMailerModule, mailerConfig } from '@anarchitects/common-nest-mailer';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [mailerConfig],
}),
CommonMailerModule.forRootFromConfig(),
CommonMailerModule.forProviderFromConfig(),
],
})
export class AppModule {}
Explicit Provider Wiring
import { Module } from '@nestjs/common';
import { CommonMailerModule } from '@anarchitects/common-nest-mailer';
@Module({
imports: [CommonMailerModule.forRoot({ provider: 'noop' })],
})
export class AppModule {}
Custom Transport Setup
forRootAsync() accepts upstream @nestjs-modules/mailer options unchanged. It does not apply MailerConfig.templateBaseDir, resolve relative template.dir values, or perform this package's directory validation. For custom template setup, the host must resolve and validate its directory before returning the options. The package's resolver and validator are internal helpers, not public exports.
For an in-memory transport without templates:
import { Module } from '@nestjs/common';
import { CommonMailerModule } from '@anarchitects/common-nest-mailer';
@Module({
imports: [
CommonMailerModule.forRootAsync({
useFactory: () => ({
transport: { jsonTransport: true },
defaults: { from: 'noreply@example.com' },
}),
}),
],
})
export class AppModule {}
Injecting Typed Config
import { Injectable } from '@nestjs/common';
import { InjectMailerConfig, MailerConfig } from '@anarchitects/common-nest-mailer';
@Injectable()
export class MailerSetupService {
constructor(@InjectMailerConfig() private readonly config: MailerConfig) {}
}
Rendered messages with alternatives and metadata
MailerPort.sendMessage(message) sends a transport-neutral MailerMessage with to, subject, at least one of html/text, and optional from, replyTo, and string-valued headers. Supply both bodies for multipart alternative messages. Omitted sender metadata uses the transport's configured defaults. These are message headers, not a custom SMTP envelope. Domains own rendering and business semantics; Common does not interpret the content.
await mailer.sendMessage({
to: 'reader@example.com',
subject: 'Your requested message',
html: '<p>Hello</p>',
text: 'Hello',
from: 'Example <noreply@example.com>',
replyTo: 'help@example.com',
headers: { 'X-Application': 'example' },
});
Existing node/noop provider selection applies to this method. Node forwards the supported fields to the configured transport and propagates rejection; success returns void, not provider metadata. Noop deliberately resolves without delivery. Callers decide failure/retry policy. Configure provider timeouts at the transport layer. Metadata and rendered bodies are trusted application input; do not forward untrusted request headers or arbitrary transport options.
Custom adapter compatibility: send and sendTemplate keep their existing signatures and behavior. Implementations of MailerPort must now implement sendMessage, including test doubles. Forward both requested alternatives and metadata, or reject unsupported messages explicitly; never silently discard requested capabilities. The built-in node and noop adapters implement the new contract. This is a source-breaking contract extension for custom implementers and must be released accordingly through CI.
Development notes
- Configure transport once at app root and keep domain modules adapter-focused.
- Use
MailerPortas the cross-domain contract to avoid tight coupling to concrete providers. - Keep shared module defaults safe for local/dev environments.
Isolated packed consumers
yarn nx run common-nest-mailer:test-nest-compatibility:nest11
yarn nx run common-nest-mailer:test-nest-compatibility:nest11-config12
yarn nx run common-nest-mailer:test-nest-compatibility:nest12
# Compatibility only; deliberately retains vulnerable Nodemailer 8:
yarn nx run common-nest-mailer:test-nest-compatibility:nest11-legacy
The uncached target builds and packs the actual library, checks source/build/pack
manifest equality, then installs it outside the repository with npm ci, strict
peer/engine checks and lifecycle scripts disabled. Registry dependencies are
integrity-locked in tests/hosts/*/*.template; only the newly built local tarball
has no stored integrity. Its dependency and peer contracts must match the lock,
and its version is taken from the current source manifest. There are no workspace
links or rewritten package peers. The host uses the development Node range from
#463 (^22.22.3 || ^24.15.0 || >=26.0.0) and needs npm registry access.
Both CommonJS and ESM decorated consumers compile and run. They verify token identity, DI, explicit/config-driven provider selection and precedence, noop without a transport, real Nodemailer dispatch through an in-memory transport, sender defaults/overrides, message metadata, error propagation, shutdown, legacy send/template methods, config injection, deployment-relative template rendering and startup rejection of an invalid template directory. No external email is sent.
To refresh a host lock after an intentional dependency change, copy its manifest
template to a temporary directory as package.json, pack the built library there
as common-mailer.tgz, and run npm install --package-lock-only --ignore-scripts --strict-peer-deps --legacy-peer-deps=false --force=false --engine-strict.
Copy the resulting lock back to the template, removing only the local Common
Mailer's integrity field. Retain every registry integrity field. Run all three
host targets and yarn nx run release-tools:test before accepting the update.
The external peer normalizer deliberately preserves this package's independently
verified contract instead of copying the Nest 11 root ranges.
License
Released under the Apache License 2.0.