nestjs-notifications
Reference

Migrating to NestJS first-party packages

Mail, webhooks, outbox, i18n, Drizzle and resilience now build on the official @nestjs/* packages. What changed, before and after.

Where NestJS ships a first-party package, Aviary integrates it and keeps only what it lacks. These releases are breaking (0.x minors). Configure the official packages as docs.nestjs.com describes; this page lists what changes on the Aviary side.

PackageNew versionNow built on
@dudousxd/nestjs-notifications-core0.11@nestjs/outbox (/outbox), @nestjs/i18n (/i18n), both optional
@dudousxd/nestjs-notifications-mail0.10 (0.11: attachment downloads)@nestjs/mail
@dudousxd/nestjs-notifications-webhook0.7@nestjs/webhooks
@dudousxd/nestjs-notifications-delivery-tracking0.5@nestjs/mail events (/mail, optional)
@dudousxd/nestjs-notifications-database-drizzle0.3@nestjs/drizzle connections (optional)
@dudousxd/nestjs-notifications-resilience0.3@nestjs/resilience

Nest peer floors: core and mail ^11 || ^12; webhook and resilience ^12 (the official peers' floors). The official packages are ESM-only; the CommonJS builds load them with require(esm) (Node ≥ 20.19, which the official packages require anyway).

Mail → @nestjs/mail

Transports, the default sender and retries move to MailModule.

Before
MailChannelModule.forRoot({
  from: 'billing@example.com',
  smtp: { host: 'smtp.example.com', port: 587, auth },
  resolveTransport: (tenant) => tenantTransports.get(tenant) ?? defaultTransport,
});
After
MailModule.forRoot({
  from: 'billing@example.com',
  transport: new TenantRoutingMailTransport(
    (tenant) => tenantTransports.get(tenant),
    new SmtpTransport({ host: 'smtp.example.com', port: 587, auth }),
  ),
}),
MailChannelModule.forRoot(),
RemovedReplacement
NodemailerTransport, smtp, MAIL_SMTP_OPTIONS@nestjs/mail SmtpTransport
SesTransport, composeRawEmail@nestjs/mail SesTransport (SigV4, no AWS SDK); MailMessage.toMime() for raw MIME
LogMailTransport / NullMailTransport@nestjs/mail LogMailTransport / FileMailTransport / InMemoryMailTransport
MailTransport interface, MailTransportPayload, transport, transportInstance, MAIL_TRANSPORT@nestjs/mail MailTransport (abstract class), MailModule.forRoot({ transport })
from optionMailModule.forRoot({ from }) (MailMessage.from() still overrides)
resolveTransport, MAIL_TRANSPORT_RESOLVERTenantRoutingMailTransport (tenant from an AsyncLocalStorage scope)
nodemailer, @aws-sdk/client-sesv2 peers@nestjs/mail peer
attachment path as a URLattach({ filename, url }) (the channel downloads it)

New: toMail() may return { mail: MailClass, data }; MailMessage.replyTo() / .header(); the channel returns MailSendResult (delivery tracking records providerMessageId, so SES bounces match); retry option; the delivery idempotencyKey is passed to the mailer.

URL attachments (0.11)

From 0.11 the channel downloads attach({ filename, url }) files with limits: the URL must be https: and the host must resolve to a public address (checked at connect time), with a 30 s timeout and a 25 MiB cap. An http: or internal-host attachment that worked in 0.10 now fails. To fetch from an internal server you trust:

MailChannelModule.forRoot({
  attachments: { allowPrivateHosts: true, allowHttp: true }, // also: timeoutMs, maxBytes
});

Webhook → @nestjs/webhooks

Before
WebhookChannelModule.forRoot({ url, secret, redirect: 'error', timeoutMs: 10_000 });
// toWebhook: new WebhookMessage().method('PUT').payload(body)
After
WebhookChannelModule.forRoot({ url, secret, timeoutMs: 10_000 }); // redirects are never followed
// toWebhook: new WebhookMessage().payload(body) — always POST
Removed / changedReplacement
fetch-based deliveryHttpWebhookTransport: https only, private/metadata addresses refused, address pinned. delivery: { allowHttp, allowPrivateNetworks, allowedAddresses, ca } for development or internal targets
redirect optionremoved (a 3xx fails the delivery)
WebhookMessage.method() / methodValueremoved (always POST)
request headerssent lower-case
response void{ id, statusCode }

New: signing: 'standard' (Standard Webhooks, verifiable with @VerifyWebhook), transport override, and subscription delivery: return new WebhookEvent(type, data) and configure WebhooksModule + OutboxModule + WebhookChannelModule.forRoot({ transaction }).

Outbox → @nestjs/outbox (new)

NotificationsModule.forRoot({ ...outboxDispatcher({ transaction: (work) => db.transaction(work) }) });

await db.transaction(async (tx) => {
  await notifications.inTransaction(tx).send(user, new OrderPlaced(order));
});

See Outbox. DispatchDriver gains an optional transactional flag; NotificationJob an in-memory transaction; DeliveryContext an idempotencyKey. Custom ScopedNotifier implementations must add inTransaction().

Localization → @nestjs/i18n

Before
NotificationsModule.forRoot({
  localization: { defaultLocale: 'en', catalog: { en: { hi: 'Hi {name}' }, 'pt-BR': { hi: 'Olá {name}' } } },
});
After
I18nModule.forRoot({
  defaultLocale: 'en',
  loader: new InMemoryI18nLoader({ en: { hi: 'Hi {name}' }, 'pt-BR': { hi: 'Olá {name}' } }),
}),
NotificationsModule.forRoot({ providers: [...i18nTranslator()], localization: { defaultLocale: 'en' } }),

InMemoryTranslator, TranslationCatalog and localization.catalog are removed (@nestjs/i18n has catalogs, loaders, plurals and fallbacks). Without a translator, t(key) returns the key (keyTranslator). TranslateParams is now Record<string, unknown>. The recipient's locale resolver (PropertyLocaleResolver, LocaleResolver) is unchanged; deliveries run inside I18nContext.run(locale).

Delivery tracking

New mailDeliveryTracking() (/mail subpath) records mail sent with Mailer directly. See Delivery tracking.

Breaking changes to the inbound controllers:

ChangedNow
createSesNotificationController()topicArns is required (non-empty); messages from other topics get 403
SES verifySignaturedefaults to true (SNS signatures are verified); set false only if something in front already verifies them
guards on the SES and Twilio controllersremoved; passing it throws. Use decorators (see Route protection)

See AWS SES for the full set of SES checks.

Drizzle

New forRoot({ connectionName }) uses a @nestjs/drizzle connection; db still works. forRootAsync takes either a factory that returns { db, tables? }, or { connectionName, useFactory? } where the optional factory returns { tables? }. With connectionName, a factory that also returns db throws. Both apply to DrizzleNotificationStoreModule and DrizzlePendingDigestStoreModule.

Route protection

The inbox, SSE stream, preference center and delivery-tracking controllers no longer take guards. Protect them with @nestjs/authentication / @nestjs/authorization through decorators, or mount the controller in your own module:

OldNow
guards: [G] on createNotificationsController(), createNotificationsStreamController(), createPreferenceCenterController(), createSesNotificationController(), createTwilioStatusController()decorators: [UseGuards(G)]
DatabaseChannelModule.forRoot({ controller: { guards: [G] } })DatabaseChannelModule.forRoot({ decorators: [UseGuards(G)] })
DatabaseChannelModule.forRoot({ controller: false })DatabaseChannelModule.forRoot({ routes: false })

The old options throw at registration instead of being ignored. See Authorization of the mounted routes.

Resilience → @nestjs/resilience

Before
resilientTransport(entries, {
  timeoutMs: 8_000,
  breaker: { threshold: 5, cooldownMs: 30_000 },
  store: new RedisResilienceStore(redis),
});
After
resilientTransport(entries, {
  timeout: '8s',
  breaker: { minimumCalls: 5, slidingWindow: { type: 'count', size: 5 }, failureRateThreshold: 100, openDuration: '30s' },
});

@dudousxd/nestjs-resilience is deprecated. store/ResilienceStore (fleet-wide breaker state), clock and onEvent are removed; breaker state is in-process, events come from the nestjs:resilience:* channels, errors are CircuitOpenError / ResilienceTimeoutError. New: failoverIf, breakers, close(), and any send() signature (fits @nestjs/mail transports).

Not migrated yet: request-context capture still uses @dudousxd/nestjs-context's accessor.

On this page