@bymax-one/nest-notification
v1.4.0
Published
Multi-channel notification library for NestJS — email, OTP, SMS, push — with pluggable providers and storage, multi-tenant by design, zero runtime dependencies.
Readme
✨ Overview
@bymax-one/nest-notification is a transactional notification library for NestJS 11 that ships two channels — email and OTP (one-time passwords) — behind a single dynamic module, plus a React subpath for the OTP input box.
Everything that touches the outside world — the email transport, the OTP store, the template renderer, the audit sink — is an interface you implement or pick from the bundled reference adapters. The same module runs on Resend or SendGrid, on Redis or an in-memory map, with a Prisma audit log or none at all, without changing a single call site.
Why nest-notification?
- 🔌 Your database, your provider — The library defines the contracts (
IEmailProvider,IOtpStorage,IEmailTemplateRenderer,INotificationLogRepository). You supply the implementations. It never imports@prisma/clientor any other ORM — a CI gate fails the build if one appears — so a cross-cutting concern never hard-wires your schema. - 🏢 Multi-tenant from the first line — Every operation is scoped by
tenantId, store keys aresha256(sha256(tenantId):sha256(recipient)), and the audit interceptor resolves the trusted tenant from the request rather than the request body when you configure a resolver. - 🔒 Native crypto only — Codes come from
crypto.randomIntand are compared withcrypto.timingSafeEqual. Nocrypto-js, nootpauth, nouuid, nonanoid— the most security-sensitive path carries no third-party supply-chain risk. - ⚛️ Atomic by construction — The attempt counter and the resend cooldown are mutated inside the storage (Redis Lua /
SET NX EX), never by a service-side read-then-write. Aget+updatepair races, and the race is exactly how a max-attempts ceiling gets bypassed under concurrent requests. - 🪶 Pay for what you use —
"dependencies": {}. NestJS, your email SDK, your Redis client, and React are all peer dependencies, and only the channels you configure are registered in the container.
pnpm add @bymax-one/nest-notification🔥 Features
- ✅ Pluggable transport —
IEmailProviderwith a bundledResendEmailProvider, anSmtpEmailProviderthat speaks the protocol every relay and mail-capture server understands, and aNoOpEmailProviderfor dev/test that logs subject and recipient only, never the body - ✅ Template rendering —
IEmailTemplateRendererwith a bundled{{var}}renderer that HTML-escapes interpolated values in the HTML body - ✅ Canonical template names —
CANONICAL_EMAIL_TEMPLATESso providers and templates agree on the wire - ✅ Attachment guard — a configurable total size ceiling (10 MiB by default) rejected before the provider is called
🔢 OTP
- ✅ CSPRNG codes —
numeric/alpha/alphanumeric, built character by character fromcrypto.randomIntso every position is uniform - ✅ TTL + max attempts — with the counter spent atomically inside the storage
- ✅ Atomic resend cooldown — acquired with
SET NX EXand released only on delivery failure, so two concurrent resends cannot both win - ✅ Constant-time verification —
crypto.timingSafeEqual, never=== - ✅ Per-purpose overrides — length, code type, TTL, attempts, and cooldown tuned per purpose (a password reset is not an email verification)
- ✅ Optional email delivery — hand the code to the email channel, or take it and deliver it yourself
🏢 Multi-Tenant & Audit
- ✅ SHA-256 storage keys —
sha256(sha256(tenantId):sha256(recipient)): no recipient PII in a key, and the encoding adds no ambiguity of its own, so two pairs never share one by construction - ✅
tenantIdResolver— the audited tenant comes from a trusted source (a JWT claim, a subdomain, a gateway-checked header), not the payload - ✅ Opt-in audit log — a fire-and-forget
INotificationLogRepositoryplus aNotificationAuditInterceptor; audit failures never break delivery - ✅ This library never logs a code — not to a logger, not to an audit entry, not into an error message it authors, enforced by a regression test. Text a provider authors is a separate problem with a documented ceiling: an error quoting the body in another encoding survives every guard, so control whether provider error text reaches your logs at all
🧩 Developer Experience
- ✅ Three subpaths — server, zero-dependency shared types, and React hooks
- ✅ Stable error catalog — 23 namespaced codes shared byte-for-byte between server and frontend, so you localize on the
code - ✅ Dual-format output — ESM + CJS + declarations for both, verified against the packed tarball on every PR
- ✅ Typed end to end — TypeScript
strictwithexactOptionalPropertyTypesandnoUncheckedIndexedAccess; zeroany
📦 Subpath Exports
One package, four entry points — import only what your app needs:
| Subpath | Import | Purpose | Peer deps |
| ----------- | -------------------------------------- | ------------------------------------------------------ | :------------------------------------: |
| Server | @bymax-one/nest-notification | NestJS module, services, providers, errors, tokens | NestJS 11 (+ your provider/store SDKs) |
| Shared | @bymax-one/nest-notification/shared | Types + constants (error codes, TTLs) | None |
| React | @bymax-one/nest-notification/react | useOtpInput + useOtpCountdown (UX/state only) | React 19 |
| Testing | @bymax-one/nest-notification/testing | Executable IOtpStorage contract for your own storage | None |
shared (zero deps)
↗ ↖
server reactshared is independent, react depends only on react, and server is independent — importing one never drags in another's peers. Every subpath ships ESM (.mjs), CJS (.cjs), and declarations for both formats (.d.ts + .d.cts).
[!TIP] Prefer to learn from a working app? See the nest-notification-example — a full NestJS project wired with this library.
🚀 Quick Start
1. Install
# Using pnpm (recommended)
pnpm add @bymax-one/nest-notification
# Using npm
npm install @bymax-one/nest-notification
# Using yarn
yarn add @bymax-one/nest-notification[!IMPORTANT] You must also install the peer dependencies for the subpaths and channels you use:
# Server subpath (required)
pnpm add @nestjs/common @nestjs/core reflect-metadata rxjs
# Production email + OTP over Redis (optional — pick your own provider/store)
pnpm add resend ioredis
# …or SMTP instead of Resend (any relay, plus Mailpit/MailHog in tests)
pnpm add nodemailer ioredis
# React subpath (optional)
pnpm add react[!NOTE] Requires Node.js 24+ and NestJS 11. Every provider and storage SDK is an optional peer dependency: install only the ones your configuration actually names.
2. Development — NoOpEmailProvider + InMemoryOtpStorage
No external services. Emails are logged (subject and recipient only, never the body), and OTP state lives in process memory. Ideal for local dev and tests.
import { Module } from '@nestjs/common'
import {
BymaxNotificationModule,
NoOpEmailProvider,
InMemoryOtpStorage
} from '@bymax-one/nest-notification'
@Module({
imports: [
BymaxNotificationModule.forRoot({
email: {
provider: new NoOpEmailProvider(),
defaultFrom: '[email protected]'
},
otp: {
storage: new InMemoryOtpStorage(),
defaultLength: 6,
defaultTtlSeconds: 600,
defaultMaxAttempts: 5,
resendCooldownSeconds: 60
}
})
]
})
export class AppModule {}Inject OtpService anywhere and the two-step flow is complete:
import { Injectable } from '@nestjs/common'
import { OtpService } from '@bymax-one/nest-notification'
@Injectable()
export class VerificationService {
constructor(private readonly otp: OtpService) {}
/** Generate + deliver an email-verification OTP. */
async start(tenantId: string, email: string): Promise<{ expiresAt: number }> {
const { expiresAt } = await this.otp.generate({
tenantId,
recipient: email,
purpose: 'email_verification',
deliverVia: 'email'
})
return { expiresAt }
}
/** Verify a submitted code. */
async confirm(tenantId: string, email: string, code: string): Promise<boolean> {
const result = await this.otp.verify({
tenantId,
recipient: email,
purpose: 'email_verification',
code
})
return result.valid
}
}[!NOTE]
generate()returns onlyexpiresAtandcooldownSeconds— never the code. Resolve thetenantIdin your controller from a trusted source and pass it down; the service takes it as an explicit argument, so nothing is silently overridden.
3. Production — Resend + Redis
Real email via Resend and OTP state in Redis (keys are SHA-256 hashed). Wire your ioredis client however your app already does.
import { Module } from '@nestjs/common'
import Redis from 'ioredis'
import {
BymaxNotificationModule,
ResendEmailProvider,
RedisOtpStorage,
DefaultTemplateRenderer
} from '@bymax-one/nest-notification'
const redis = new Redis(process.env.REDIS_URL!)
@Module({
imports: [
BymaxNotificationModule.forRoot({
global: {
redisNamespace: 'notification',
defaultLocale: 'en',
// Trust the tenant from a gateway-verified header, not the request body.
tenantIdResolver: (req) => String(req.headers['x-tenant-id'] ?? 'default')
},
email: {
provider: new ResendEmailProvider({ apiKey: process.env.RESEND_API_KEY! }),
defaultFrom: '[email protected]',
defaultFromName: 'Acme',
templateRenderer: new DefaultTemplateRenderer({
templates: {
'otp_code::en': {
subject: 'Your Acme verification code',
html: '<p>Your code is <strong>{{code}}</strong>. It expires in {{expiresInMinutes}} minutes.</p>',
text: 'Your code is {{code}}. It expires in {{expiresInMinutes}} minutes.'
}
}
})
},
otp: {
storage: new RedisOtpStorage({ redisClient: redis }),
defaultLength: 6,
defaultCodeType: 'numeric',
defaultTtlSeconds: 600,
defaultMaxAttempts: 5,
resendCooldownSeconds: 60,
perPurpose: {
password_reset: {
length: 8,
codeType: 'alphanumeric',
ttlSeconds: 900,
maxAttempts: 5,
resendCooldownSeconds: 60
}
}
}
})
]
})
export class AppModule {}For async configuration (e.g. reading secrets from ConfigService), use forRootAsync:
import { ConfigModule, ConfigService } from '@nestjs/config'
BymaxNotificationModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
email: {
provider: new ResendEmailProvider({ apiKey: config.getOrThrow('RESEND_API_KEY') }),
defaultFrom: config.getOrThrow('MAIL_FROM')
},
otp: { storage: new RedisOtpStorage({ redisClient: redis }) }
})
})[!IMPORTANT]
forRootAsyncsupports theuseFactorypattern.useClass/useExistingare not implemented and are rejected at startup.
4. SMTP — any relay, and end-to-end tests
SmtpEmailProvider speaks the protocol rather than one vendor's HTTP API, so the same adapter drives a corporate relay, Postfix, SES-via-SMTP, and the mail-capture servers (Mailpit, MailHog) that make email flows testable end to end. Requires the optional nodemailer peer dependency.
import { BymaxNotificationModule, SmtpEmailProvider } from '@bymax-one/nest-notification'
// Local capture server — no credentials, no TLS. Read the captured mail at :8025.
const mailpit = new SmtpEmailProvider({ host: 'localhost', port: 1025, secure: false })
// Authenticated production relay. STARTTLS is already mandatory by default here.
const relay = new SmtpEmailProvider({
host: process.env.SMTP_HOST,
port: 587,
credentials: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
})
BymaxNotificationModule.forRoot({
email: {
provider: process.env.NODE_ENV === 'production' ? relay : mailpit,
defaultFrom: '[email protected]'
}
})| Option | Default | Notes |
| --------------------------------------------------------- | --------------------- | ---------------------------------------------------------------------------- |
| host | — | Required. Without it isConfigured() is false and send() throws. |
| port | 587 | Submission port. |
| secure | true iff port 465 | Implicit TLS from the first byte. |
| requireTls | true off-loopback | Makes the STARTTLS upgrade mandatory. See the TLS note below. |
| credentials | — (open relay) | { user, pass }. Supplying it declares the deployment logs in. |
| tls | — | rejectUnauthorized / servername / ca, for a relay behind a private CA. |
| connectionTimeout / greetingTimeout / socketTimeout | 10s / 10s / 20s | Bounded on purpose — Nodemailer's own defaults run into minutes. |
[!IMPORTANT]
isConfigured()answers on the real configuration, not on "a transport object exists". Omittingcredentialsmarks the relay as open and is fine; supplying them half-loaded — anSMTP_PASSthat never made it into the environment — reports not configured and fails the send closed, rather than silently attempting an anonymous delivery a permissive relay might accept.
[!WARNING] STARTTLS is mandatory by default for any non-loopback host. On a connection that does not start out encrypted, whether TLS happens at all is decided by the plaintext EHLO banner: an attacker with network position strips the
250-STARTTLSline, the transport never upgrades, and the credentials and the message body — which carries the OTP code — cross the network in the clear. SorequireTlsdefaults totrueunless the host islocalhost/127.0.0.1/::1orsecureis already on. If your relay genuinely cannot upgrade, opt out explicitly withrequireTls: false; a Mailpit or MailHog container reached by its compose service name rather than over loopback is the common case.
Three further behaviours worth knowing:
- The returned
messageIdis the RFC-5322Message-IDwith its angle brackets, so an audit entry correlates with the header the recipient received. tagsare not forwarded, because SMTP has no tag facility — they still reach the audit log throughEmailService.- A line break in
from,replyTo, a recipient, or a custom header name/value is rejected before the send. Nodemailer already neutralizes those, so this is defence in depth — but header injection is the one place where trusting a peer dependency's current behaviour would be the whole security boundary. The subject is deliberately exempt: a stray trailing newline from a template is plausible there, and Nodemailer folds it away.
5. Bring Your Own Provider
Every external boundary is an interface — implement it and pass the instance (or class) to forRoot. The bundled ResendEmailProvider and RedisOtpStorage are reference implementations, not requirements.
import type {
IEmailProvider,
EmailSendOptions,
EmailSendResult
} from '@bymax-one/nest-notification'
export class SendGridEmailProvider implements IEmailProvider {
readonly name = 'sendgrid'
isConfigured(): boolean {
return Boolean(this.apiKey)
}
async send(options: EmailSendOptions): Promise<EmailSendResult> {
// Call SendGrid; throw on failure — EmailService maps it to a NotificationException.
return { messageId: '…' }
}
}import type { IOtpStorage } from '@bymax-one/nest-notification'
// Implement set / get / consumeAttempt / update / delete /
// tryAcquireCooldown / getCooldown / clearCooldown.[!IMPORTANT]
consumeAttemptandtryAcquireCooldownmust be atomic — one indivisible read-modify-write (a Redis Lua script, or a synchronous operation for an in-memory store). Implemented as agetfollowed by anupdate, two concurrent requests both read the same attempt count and themaxAttemptsceiling stops being a ceiling.
Adapter examples for several providers and stores live under docs/templates/ and docs/schemas/:
| Email provider | Adapter |
| ----------------------- | ----------------------------------------- |
| Resend | bundled — ResendEmailProvider |
| SMTP (any relay) | bundled — SmtpEmailProvider |
| Mailpit / MailHog | bundled — SmtpEmailProvider |
| AWS SES (SMTP endpoint) | bundled — SmtpEmailProvider |
| SendGrid | implement IEmailProvider (sketch above) |
| AWS SES (HTTP API) | implement IEmailProvider |
| Mailgun | implement IEmailProvider |
| Template engine | Adapter |
| --------------- | ---------------------------------------------------------------------------------------------------- |
| Default | bundled — DefaultTemplateRenderer ({{var}} interpolation, HTML-escaped) |
| Handlebars | docs/templates/handlebars-renderer.example.md |
| MJML | docs/templates/mjml-renderer.example.md |
| React Email | docs/templates/react-email-renderer.example.md |
6. Audit Log
The library never imports Prisma. You implement INotificationLogRepository against your own client; the module calls it fire-and-forget, so the audit sink can never crash the notification flow. A copy-pasteable Prisma schema fragment lives in docs/schemas/notification-log.prisma and a full repository in docs/schemas/prisma-repository.example.md.
import { Injectable } from '@nestjs/common'
import type { INotificationLogRepository, NotificationLogEntry } from '@bymax-one/nest-notification'
import { PrismaClient } from '@prisma/client' // your app's dependency, not the library's
@Injectable()
export class PrismaNotificationLogRepository implements INotificationLogRepository {
readonly name = 'prisma'
constructor(private readonly prisma: PrismaClient) {}
async create(entry: NotificationLogEntry): Promise<void> {
await this.prisma.notificationLog.create({
data: {
timestamp: new Date(entry.timestamp),
tenantId: entry.tenantId,
channel: entry.channel,
verb: entry.verb,
recipient: entry.recipient, // already masked if you set `audit.maskRecipient`
purpose: entry.purpose ?? null,
providerName: entry.providerName,
messageId: entry.messageId ?? null,
errorMessage: entry.errorMessage ?? null,
userId: entry.userId ?? null
}
})
}
}BymaxNotificationModule.forRoot({
email: { provider: new ResendEmailProvider({ apiKey }), defaultFrom: '[email protected]' },
otp: { storage: new RedisOtpStorage({ redisClient: redis }) },
audit: {
repository: new PrismaNotificationLogRepository(prisma),
swallowErrors: true, // default — audit failures never break delivery
maskRecipient: (r) => r.replace(/^(.).*(@.*)$/, '$1***$2') // [email protected] -> j***@acme.com
}
})To capture HTTP-level sent / failed entries automatically, apply the interceptor:
import { NotificationAuditInterceptor } from '@bymax-one/nest-notification'
// @UseInterceptors(NotificationAuditInterceptor) on a controller/handler, or wire it globally.7. Frontend Integration (React)
The ./react subpath is browser-only state and UX — it drives the OTP-input box and a countdown. Verifying the code is your app's job: the hooks carry no HTTP client and no Node builtins, so nothing about your API shape is assumed.
import { useOtpInput, useOtpCountdown } from '@bymax-one/nest-notification/react'
function OtpForm({ expiresAt }: { expiresAt: number }) {
const { values, onChange, onKeyDown, onPaste, refs, isComplete } = useOtpInput({
length: 6,
type: 'numeric',
onComplete: (full) => void submitToBackend(full)
})
const { formatted, expired } = useOtpCountdown({ expiresAt })
return (
<form>
{values.map((v, i) => (
<input
key={i}
ref={refs[i]}
value={v}
onChange={onChange(i)}
onKeyDown={onKeyDown(i)}
onPaste={i === 0 ? onPaste : undefined}
inputMode="numeric"
maxLength={1}
/>
))}
<p>{expired ? 'Code expired' : `Expires in ${formatted}`}</p>
{/* Never render the code itself — `onComplete` hands it to your submit path.
Echoing it into a label leaks it to screen readers, screenshots and
session-replay tools, which is exactly what the server side avoids. */}
<button disabled={!isComplete}>Verify</button>
</form>
)
}Pair it with the ./shared subpath to branch on server errors without duplicating string literals:
import { NOTIFICATION_ERROR_CODES } from '@bymax-one/nest-notification/shared'
if (error.code === NOTIFICATION_ERROR_CODES.OTP_MAX_ATTEMPTS_EXCEEDED) {
// Show your own localized copy — the library never ships translations.
}⚙️ Configuration
Configure via forRoot(options) or forRootAsync({ useFactory }). At least one channel must be configured, and configuring a channel the library does not implement throws at startup rather than failing on the first send. The full reference is in docs/technical_specification.md §4; the most-used options:
| Section | Option | Default | Notes |
| -------- | ----------------------- | ---------------- | ------------------------------------------------ |
| global | redisNamespace | 'notification' | Prefix for store keys. |
| global | defaultLocale | 'en' | Template locale fallback. |
| global | tenantIdResolver | — | (req) => tenantId; the audit source of truth. |
| email | provider | — (required) | Instance or class implementing IEmailProvider. |
| email | defaultFrom | — (required) | Must look like an email address. |
| email | templateRenderer | default renderer | Any IEmailTemplateRenderer. |
| email | maxAttachmentBytes | 10485760 | 10 MiB attachment guard. |
| otp | storage | — (required) | Instance or class implementing IOtpStorage. |
| otp | defaultLength | 6 | 1–32. |
| otp | defaultCodeType | 'numeric' | numeric | alpha | alphanumeric. |
| otp | defaultTtlSeconds | 600 | Code lifetime. |
| otp | defaultMaxAttempts | 5 | Verify attempts before lock-out. |
| otp | resendCooldownSeconds | 60 | Anti-resend window (atomic SET NX EX). |
| otp | perPurpose | {} | Per-purpose overrides of the above. |
| audit | repository | — (required) | Any INotificationLogRepository. |
| audit | swallowErrors | true | Keep audit failures out of the delivery path. |
| audit | maskRecipient | identity | Minimize recipient PII before persisting. |
🎨 Templates
Email rendering goes through IEmailTemplateRenderer. The bundled DefaultTemplateRenderer does {{var}} interpolation with automatic HTML escaping in the HTML body — subject and plaintext are left raw, since neither is an HTML context. That closes a stored-XSS vector: a display name containing markup renders as text, not as an element, in the recipient's mail client.
Register named templates per name::locale; the renderer falls back to the en locale when a locale-specific template is missing.
CANONICAL_EMAIL_TEMPLATES exports stable names for common transactional emails (otp_code, otp_password_reset, otp_resent, welcome, password_reset_success, trial_expiring, …) so providers and templates agree on the wire. For richer output, plug in Handlebars, MJML, or React Email — examples under docs/templates/.
🏗️ Architecture
The package runs inside your NestJS application as a dynamic module — not as a separate service:
┌──────────────────────────────────────────────────┐
│ Your NestJS Application │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ @bymax-one/nest-notification │ │
│ │ │ │
│ │ NotificationService │ │
│ │ ├── EmailService │ │
│ │ └── OtpService ←→ Crypto (node:crypto)│ │
│ │ NotificationAuditInterceptor │ │
│ └───┬────────┬─────────┬──────────┬──────────┘ │
│ │ │ │ │ │
│ ┌───▼───┐ ┌──▼─────┐ ┌─▼───────┐ ┌▼──────────┐ │
│ │IEmail │ │IOtp │ │IEmail │ │INotifi… │ │
│ │Provi… │ │Storage │ │Template │ │LogRepo │ │
│ │(yours)│ │(yours) │ │Renderer │ │(yours) │ │
│ └───────┘ └────────┘ └─────────┘ └───────────┘ │
└──────────────────────────────────────────────────┘NotificationService is the façade: it dispatches to the channel services and exposes getEnabledChannels(), getEmail(), and getOtp() for direct access. Only the channels present in your configuration are registered, so an unconfigured channel is a startup error, not a runtime surprise.
Design Principles
| Principle | Description |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🔌 Interface-Driven | Define contracts, inject implementations — works with any email provider, any store, any ORM (or none) |
| 🔒 Secure by Default | CSPRNG codes, constant-time comparison, hashed keys, and a never-log-codes invariant — all on by default, nothing to opt into |
| ⚛️ Atomic Where It Counts | The attempt counter and the resend cooldown are mutated by the storage in one indivisible step; a service-side read-then-write is a bug, not a style choice |
| 🪶 Zero Runtime Deps | "dependencies": {} — adds nothing of its own; crypto is native node:crypto. Required peers (NestJS…) come from your app, optional ones only when you use them |
| 🌳 Tree-Shakeable | sideEffects: false, subpath exports, ESM + CJS dual output with declarations for both |
| ⚡ Conditional Loading | Unconfigured channels don't register — no wasted memory or startup time, and no half-wired channel that fails on the first send |
🔐 Security Model
OTP codes are bearer secrets with a short life and a wide blast radius: whoever holds one is the account. The model below is built around keeping them unreadable, unguessable, and unresendable.
SHA-256 storage keys
OTP entries and resend cooldowns are stored under a key derived from sha256(sha256(tenantId):sha256(recipient)) — never the plaintext recipient or tenant id:
notification:otp:email_verification:7f3d8c91… (64 hex chars)- Privacy. An operator with
KEYS notification:otp:*access to Redis — or anyone holding a leaked backup — cannot enumerate which addresses have a pending OTP. The recipient never appears in a key. - Isolation. Each component is hashed to a fixed length before the two are joined, so distinct
(tenantId, recipient)pairs cannot produce the same key. This is a property of the encoding rather than of the digest — joining the raw values around a delimiter would map('acme:bob', 'x')and('acme', 'bob:x')onto one key while SHA-256's preimage and collision resistance held perfectly. Both components must be non-empty; an empty one is refused rather than hashed, because it would place every caller that omits it into a single shared scope.
The trade-off — opaque keys you cannot read back to a recipient — is intentional: keys are an index, not a data source. The recipient lives only inside the TTL-bound value and, optionally masked, in the audit log.
tenantIdResolver prevents tenant spoofing
When you expose notification endpoints over HTTP, a caller could forge another tenant's id in the request body — POST { "tenantId": "tenant_a", … } sent from tenant_b to verify someone else's OTP. Configure a tenantIdResolver that reads the tenant from a trusted source: a verified JWT claim, a subdomain, or a gateway-checked header.
import type { NotificationRequest } from '@bymax-one/nest-notification'
BymaxNotificationModule.forRoot({
global: {
// Subdomain-based: `acme.app.com` -> `acme`.
tenantIdResolver: (req: NotificationRequest) => req.hostname?.split('.')[0] ?? 'default'
}
// …channels
})
// JWT-claim based (the request is augmented by your auth middleware):
const tenantIdResolver = (req: NotificationRequest): string =>
String(req.headers['x-tenant-id'] ?? 'default')NotificationRequest is a minimal, framework-agnostic request shape declared by the library itself, so a public signature never drags in a framework's types. When a resolver is set, the NotificationAuditInterceptor uses it as the source of truth for the audited tenant id — any tenantId in the payload becomes a suggestion the resolver overrides.
[!NOTE] The resolver governs what the audit interceptor trusts. Service methods still take an explicit
tenantId: resolve the tenant in your controller and pass it down — there is no hidden override of a method argument.
The library never writes a code anywhere readable
Everything below is about text this library authors. Text a provider authors is a separate problem with a documented ceiling — see Errors — and the two must not be read as one guarantee.
The library never writes a code to a sink that can be read back:
- Not to the audit log — an entry is
{ verb, tenantId, recipient, purpose, providerName, … }, nevercode. - Not to a console or logger line.
- Not inside an
errorMessage, which carries the message only — never a stack trace. - Not inside a rethrown delivery error: the renderer and the provider both receive the code (template
data, rendered body), so on a failed delivery every literal occurrence of the code — the form it was issued in, which is the form this library's own text carries — is scrubbed to[redacted]across the outgoing error chain — message and stack at every link, cycle-safe with no depth limit — before the audit write and the rethrow, and a non-Errorrejection is flattened to a redacted string. The email channel's ownfailedaudit entry goes further than redaction: OTP delivery setspublishProviderText: false, so that entry records the fixed label[provider text withheld]plus the SMTP reply codes a fixed grammar can express — never the provider's own words. It also declares the code viaauditRedactValues, which stays as precision for the paths that do publish text. Default V8 stack frames carry only function names and source locations, never argument values; the header line (Error: <message>) is where a code could ride a stack, and the scrub covers it.
Within this library, codes exist only inside the OTP store, under a TTL, and in process memory for the duration of the request — a provider that quotes the body back is outside that boundary, and its ceiling is documented under Errors. audit.maskRecipient minimizes the recipient before it is persisted ([email protected] → j***@acme.com). A regression test asserts the invariant directly rather than trusting review: JSON.stringify(auditEntry).includes(code) === false.
Attempt ceilings and cooldowns are atomic
consumeAttempt is the only writer of the attempt counter, and tryAcquireCooldown (SET NX EX) is the only acquirer of the resend window — both inside the storage, in one indivisible step. A service-side get followed by an update looks equivalent and passes every sequential test, but two concurrent verifies read the same count and each writes back the same increment: five attempts become unbounded. The same shape lets two concurrent resends both pass the cooldown check.
Security Checklist
When integrating @bymax-one/nest-notification in production, verify each of the following:
tenantIdResolverreads from a verified source (JWT claim, subdomain, gateway-signed header) — never the request bodyaudit.maskRecipientis configured if audit rows are retained beyond the operational minimum- A custom
IOtpStorageimplementsconsumeAttemptandtryAcquireCooldownatomically - The recipient passed to the service is canonical (
email.trim().toLowerCase()) — the library does not normalize it, so[email protected]and[email protected]are distinct keys resendCooldownSecondsanddefaultMaxAttemptsare tuned per purpose, not left at the defaults for high-value flows- Your own controller rate-limits the OTP endpoints — the library bounds attempts per code, not requests per IP
🛡️ Security Table
| Layer | Implementation |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Code Generation | crypto.randomInt per character over the configured alphabet — uniform at every position, no modulo bias |
| Code Comparison | crypto.timingSafeEqual (constant-time), never === |
| Storage Keys | sha256(sha256(tenantId):sha256(recipient)) — no recipient PII; the encoding adds no ambiguity, so no two pairs share a key by construction |
| Attempt Ceiling | Counter spent atomically inside the storage (Redis Lua) — never a service-side read-then-write |
| Resend Cooldown | SET NX EX acquire, released only on delivery failure — two concurrent resends cannot both win |
| Code Lifetime | TTL-bound in the store; expiry and absence are reported identically so neither leaks the other |
| Code Exposure | Never logged, audited, or placed in an error message or stack trace by this library — asserted by a regression test. Provider-authored text has a documented ceiling: see Errors |
| Recipient PII | Absent from keys; optionally masked before it reaches the audit sink |
| Provider Secrets | The Resend API key, the SMTP password and the Redis client live in private fields; serializing a provider omits them, and a failing SMTP session has its password scrubbed from the error |
| Tenant Isolation | tenantId scopes every operation and is resolved from a trusted source, not the payload |
| Template Injection | HTML body escaped on interpolation by the bundled renderer — closes stored XSS through a display name |
| Attachment DoS | Total attachment size rejected against a budget before the provider is called |
| Audit Failures | Fire-and-forget with swallowErrors — an audit outage never becomes a delivery outage |
| Supply Chain | "dependencies": {}; published with npm provenance (OIDC), CodeQL and OpenSSF Scorecard on every push |
[!IMPORTANT] This package uses zero external cryptographic dependencies. All operations use Node.js native
node:crypto, eliminating supply chain attack vectors for critical security code.
🧱 Tech Stack
🧪 Testing & Quality
A one-time password is a credential, so the suite is held to a bar beyond "it runs" — every behavior is pinned so that a regression fails a test.
- ✅ 100% line coverage — statements, branches, functions, and lines, enforced per file as a release gate across unit + e2e
- ✅ 100% mutation score — verified with Stryker: every viable seeded fault killed, with no survivors, against a
breakthreshold of 100. The equivalents that no test can kill carry their reason on the line they apply to, so the number is an accounting rather than a target - ✅ 680 tests — unit and end-to-end, spanning all four subpaths
- ✅ Invariants asserted, not assumed — the never-log-codes rule is a test (
JSON.stringify(entry).includes(code) === falseon every audit entry, and the thrown exception is serialized recursively — message, stack, response body, every nestedcause— asserting the code appears at no depth), not a review convention - ✅ Published shape verified —
attwresolves every entrypoint against the packed tarball, and a dogfood smoke test installs the package into a scratch consumer before any tag is cut - ✅ Every equivalent mutant documented — the ones no test can kill carry an inline
// Stryker disablewith the reason, so the score is an accounting rather than a number
| Gate | Standard |
| -------------- | ---------------------------------------------------------------------- |
| Type safety | TypeScript strict, zero any |
| Coverage | 100% line/branch/function/statement per file (pnpm test:cov:all) |
| Mutation | Stryker score 100% (break: 100), no survivors |
| Lint | ESLint flat config + eslint-plugin-security, zero warnings |
| Bundle budgets | server < 30 KB · shared < 4 KB · react < 8 KB brotli (pnpm size) |
| Prisma-free | pnpm check:no-prisma — the library never imports @prisma/client |
| Export map | pnpm check:exports — attw against the packed tarball, ESM and CJS |
| Supply chain | published with npm provenance (OIDC), CodeQL + OpenSSF Scorecard |
pnpm test # unit suite
pnpm test:cov:all # unit + e2e, 100% coverage gate
pnpm mutation # Stryker mutation testing[!NOTE] Line coverage proves a line executed under test; mutation testing proves a test would fail if that line were wrong. The full methodology and per-area breakdown are in docs/mutation_testing_results.md.
📖 API Reference
Module
| Member | Returns | Purpose |
| ---------------------------------------- | --------------- | ------------------------------------------------------------ |
| BymaxNotificationModule.forRoot() | DynamicModule | Synchronous registration from a literal options object |
| BymaxNotificationModule.forRootAsync() | DynamicModule | Async registration via useFactory (+ imports / inject) |
Services
| Service | Method | Returns | Purpose |
| --------------------- | ------------------------- | -------------------------------- | --------------------------------------------------------------------- |
| OtpService | generate() | { expiresAt, cooldownSeconds } | Mint a code, store it, optionally deliver it — never returns the code |
| OtpService | verify() | OtpVerifyResult | Spend one attempt and compare in constant time |
| OtpService | consume() | void | Invalidate a verified code so it cannot be replayed |
| OtpService | resend() | { expiresAt, cooldownSeconds } | Re-issue under the atomic cooldown |
| OtpService | getStatus() | OtpStatusResult | Read-only view (existence, expiry, attempts) — never the code |
| EmailService | send() | { messageId } | Send a literal subject + body |
| EmailService | sendTemplate() | { messageId } | Render a named template for a locale, then send |
| NotificationService | dispatch() | DispatchResult | Channel-agnostic façade over the configured channels |
| NotificationService | getEnabledChannels() | NotificationChannel[] | Which channels this instance actually registered |
| NotificationService | getEmail() / getOtp() | the channel service | Direct access when you already know the channel |
Interceptor
| Member | Purpose |
| ------------------------------ | ------------------------------------------------------------------------------------------------ |
| NotificationAuditInterceptor | Records sent / failed audit entries at the HTTP layer, tenant resolved by tenantIdResolver |
Errors
| Member | Purpose |
| -------------------------------- | -------------------------------------------------------------------------- |
| NotificationException | HttpException carrying a stable code, a status, and optional details |
| NotificationExceptionOptions | Constructor options bag: { status?, message?, cause? } |
| NOTIFICATION_ERROR_CODES | The 23 stable notification.* codes — branch on these, not on messages |
| NOTIFICATION_ERROR_DEFINITIONS | Server-side code → HTTP status + default English message |
Error codes are namespaced (notification.otp_invalid_code, notification.otp_cooldown_active, notification.email_send_failed, …) and never change once published. Default messages are English; localize on the code.
Failures raised by the library carry the underlying error as the native Error.cause — a provider's connect ECONNREFUSED sits on exception.cause, where cause-walking log serializers (e.g. pino's err) print it alongside the stable code. The cause is stored as a log-safe copy: name, message, stack, and the nested cause chain survive (depth-bounded); every other property is dropped, because SDK errors routinely retain the request payload (an axios-style config.data) and for an OTP email that payload contains the code. The cause never enters the HTTP response body either: details stays reserved for the structured, client-safe context shown above. To attach a cause in your own code, pass the options bag as the third constructor argument — new NotificationException('EMAIL_SEND_FAILED', { providerName }, { cause: error }); the positional (key, details, status, message) form keeps working.
If you send a secret through EmailService yourself — declare it, and treat the provider's error text as untrusted anyway. A relay or DLP filter that quotes the rejected content puts the rendered body (and any secret inside it) into the provider error, which rides the cause into your logs. Pass auditRedactValues: [code] on the send input: the value is scrubbed from the attached cause, from the failed-audit errorMessage, and — forwarded as EmailSendOptions.redactValues — from the provider's own error logging. As defense-in-depth for the undeclared case, any run of 16+ characters of the rendered body detected inside a provider error is redacted automatically.
Withholding provider text — publishProviderText: false. For a message whose body carries a credential, set it on the send input (EmailService.send or sendTemplate); the built-in OTP delivery already does. The failure then carries no byte the provider wrote: no cause, and the audit entry's errorMessage is the fixed label [provider text withheld].
What it publishes instead is what a fixed grammar can express — the basic SMTP reply code and, when present, the RFC 3463 enhanced code — as structured fields. The two surfaces name them differently, so read the right keys: the audit entry carries deliveryStatus and deliveryEnhancedStatus, while the exception's details carries status and enhanced.
await emailService.send({
tenantId,
to: user.email,
subject: 'Your code',
html: rendered,
publishProviderText: false // this body carries a credential
})
// On failure: NotificationException('notification.email_send_failed')
// details → { providerName: 'smtp', status: 550, enhanced: '5.7.1' }
// cause → absent
// Audit entry: { verb: 'failed', errorMessage: '[provider text withheld]',
// deliveryStatus: 550, deliveryEnhancedStatus: '5.7.1', … }Three properties worth knowing before you rely on it. The published codes are independent of the secret — the same reply publishes 550 5.7.1 whether the code was 550571, 123456 or absent — which is why they are safe to log while the prose is not. Ambiguity resolves to silence: a text carrying two different replies publishes neither, so a cause chain cannot have 424 and 242 read as one value. And a code that would carry a declared secret is dropped, since this library's invariant is that a code's characters never reach an audit entry.
The default is true, and deliberately so: for a receipt or a marketing send the provider's message is often the only diagnosis available. The flag is per send, never global — one setting cannot serve an OTP path and a marketing path in the same application.
Know the ceiling of both guards. They remove the shapes they can predict, and a relay quotes what it transmitted, not what you handed it — a MIME body travels raw, quoted-printable or base64. A declared 123456 cannot match MTIzNDU2, and echo detection compares the error against the rendered body it holds, not against the encoding the relay chose; measured, an error quoting the base64 of the body survives both. No list of values closes an encoding gap. So control whether provider error text reaches your logs at all for secret-bearing sends — pass publishProviderText: false on the send input, which withholds every provider-authored byte and publishes only the SMTP reply codes a fixed grammar can express — and use declared values as precision on top of that rather than as the barrier. If you consume @bymax-one/nest-auth and bind its bundled DefaultAuthEmailProvider, that provider is the call site, so the declaration belongs there rather than in your application code; if you implement nest-auth's email port yourself, your adapter is the call site and the declaration is yours to make.
Reference adapters
| Export | Implements | Use for |
| ------------------------------- | ---------------------------- | -------------------------------------------- |
| ResendEmailProvider | IEmailProvider | Production email via Resend |
| SmtpEmailProvider | IEmailProvider | Any SMTP relay, and Mailpit/MailHog in tests |
| NoOpEmailProvider | IEmailProvider | Dev/test — logs subject and recipient only |
| RedisOtpStorage | IOtpStorage | Production OTP state (atomic via Lua) |
| InMemoryOtpStorage | IOtpStorage | Dev/test — single-process only |
| DefaultTemplateRenderer | IEmailTemplateRenderer | {{var}} interpolation with HTML escaping |
| NoOpNotificationLogRepository | INotificationLogRepository | Audit configured but intentionally discarded |
Utilities
| Export | Purpose |
| ----------------------- | ----------------------------------------------------------------------- |
| hashTenantRecipient() | The key derivation, exposed so a custom storage produces identical keys |
| generateOtpCode() | The CSPRNG generator, for a storage or provider that mints its own |
| safeCompare() | Constant-time string comparison over crypto.timingSafeEqual |
| toRetryAfterHeader() | Format a cooldown as a Retry-After header value |
| cooldownExpiresAt() | Absolute expiry timestamp for a remaining cooldown |
| formatCooldown() | Human-readable cooldown, for a UI countdown |
React hooks (./react)
| Hook | Returns | Purpose |
| ------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------- |
| useOtpInput() | { values, setValue, onChange, onKeyDown, onPaste, refs, code, isComplete, reset } | Multi-slot OTP input with paste and arrow navigation |
| useOtpCountdown() | { remainingSeconds, formatted, expired } | Expiry countdown for the resend button |
Constants
| Export | Subpath | Purpose |
| --------------------------- | --------------------------- | -------------------------------------------------------------- |
| NOTIFICATION_ERROR_CODES | ./shared (and the server) | The 22 stable codes, with zero dependencies on the shared side |
| DEFAULT_TTLS | ./shared (and the server) | Default lifetimes, so a frontend countdown matches the backend |
| NOTIFICATION_PURPOSES | server | Canonical purposes (email_verification, password_reset, …) |
| CANONICAL_EMAIL_TEMPLATES | server | Canonical template names (otp_code, welcome, …) |
[!IMPORTANT] Only
NOTIFICATION_ERROR_CODESandDEFAULT_TTLSare re-exported from./shared.NOTIFICATION_PURPOSESandCANONICAL_EMAIL_TEMPLATESlive on the server subpath — importing them into a browser bundle would pull NestJS in with them. Pass the purpose to your frontend as a plain string.
🤝 Contributing
Contributions are welcome! Please read our contributing guidelines before submitting a pull request.
# Clone the repository
git clone https://github.com/bymaxone/nest-notification.git
cd nest-notification
# Install dependencies
pnpm install
# Run tests
pnpm test
# Build
pnpm build
# Type check
pnpm typecheck🔒 Security Policy
If you discover a security vulnerability, please do not open a public issue. Instead, email us at [email protected] with details. We take security seriously and will respond promptly. The full policy is in SECURITY.md.
