Anarchitecture Bricks Docs

Repository documentation hub for packages, guides, and generated references.

@anarchitects/common-nest-mailer

Shared typed mailer configuration, transport setup, and provider wiring for NestJS apps.

Developer + AI Agent Start Here

Features

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:

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

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:

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:

  1. An absolute templateDir is passed through unchanged, regardless of templateBaseDir.
  2. A relative templateDir resolves with path.resolve(templateBaseDir, templateDir) when the base is non-empty.
  3. 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

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.

Source Links