i18n, Mail & File Storage
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2, published 2026-09-30), Node.js 22 and 24 LTS, TypeScript 6 (6.0.x), Express 5.2 and Fastify 5.12, all as of 2026-10-02. The three packages it covers, @nestjs/i18n, @nestjs/mail and @nestjs/storage, are first-party but still pre-1.0: on that date npm listed each of them at 0.0.1 (published between 2026-09-25 and 2026-09-29), so their APIs can change in any release and you should pin exact versions. The examples use a Nest 12 project (ESM template, Vitest, supertest) with the real packages, local disks, an in-memory mail transport and a file transport rather than a real mail server or an S3 bucket. The running scenario is the Bookshelf application that grows across this section: it answers readers in their own language, mails them a loan receipt and stores book covers.
Until recently, a Nest application reached for community packages for these three jobs. The official documentation now ships its own: @nestjs/i18n is the official counterpart of the community nestjs-i18n package (still published, version 10.8.5 at the time of writing), @nestjs/mail covers what @nestjs-modules/mailer on top of Nodemailer did, and @nestjs/storage gives uploads a destination, which multer alone never did. The first-party packages share a design: a global dynamic module with forRoot() and forRootAsync() (see Dynamic Modules), plain classes you extend for your own loaders, resolvers, transports and disks, and testing doubles that replace the part that talks to the outside world.
The three packages at a glance
| Package | Registers | Replace in tests with |
|---|---|---|
|
|
|
|
|
|
|
|
|
None of them is part of Nest core, and none of them is required: nothing here changes how modules, providers or the request lifecycle work (see Request Lifecycle). Install them one by one:
npm i --save @nestjs/i18n @nestjs/mail @nestjs/storage
@nestjs/i18n declares class-validator as an optional peer dependency pinned to ^0.14.0, while a current Nest project installs class-validator 0.15.1. npm i stops with an ERESOLVE error unless --legacy-peer-deps is added, and the localised validation then works against 0.15.1. If you do not need localised class-validator messages you can leave class-validator out; otherwise expect that peer conflict until the package widens its range. The mail package has no dependencies beyond Nest, and @nestjs/storage depends only on @nestjs/common and @nestjs/core, so neither needs the AWS SDK or Nodemailer.
Internationalization with @nestjs/i18n
The package resolves the locale once per request, keeps it in an AsyncLocalStorage store, and lets services, exceptions, pipes and GraphQL field resolvers translate without ever seeing the request object. That is the property that matters in Nest: a singleton BooksService can answer each request in its own language without becoming request-scoped (see Injection Scopes).
Catalogs and registration
Translations live in JSON files, one directory per locale. Each file is a namespace named after the file, so notFound in books.json has the key books.notFound. Words in braces are placeholders filled from the arguments you pass. A message that depends on a number is an object keyed by plural category, and each language needs the categories its grammar has: English has one and other, Polish has four.
{
"copies": {
"one": "{count} egzemplarz",
"few": "{count} egzemplarze",
"many": "{count} egzemplarzy",
"other": "{count} egzemplarza"
},
"added": "Dodano \"{title}\" na półkę: {copies}.",
"notFound": "Nie znaleziono książki #{id}.",
"invalidId": "Numer książki musi być liczbą całkowitą.",
"tooManyCopies": {
"one": "{path}: najwyżej {count} egzemplarz",
"few": "{path}: najwyżej {count} egzemplarze",
"many": "{path}: najwyżej {count} egzemplarzy",
"other": "{path}: najwyżej {count} egzemplarza"
},
"status": { "available": "Dostępna", "borrowed": "Wypożyczona" }
}
The TypeScript compiler does not copy JSON files to dist, so add them to the assets in nest-cli.json ("assets": ["i18n/*/.json"], with "watchAssets": true for development). Then register the module in the root module:
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import {
AcceptLanguageLocaleResolver,
HeaderLocaleResolver,
I18nModule,
I18nValidationPipe,
JsonI18nLoader,
QueryLocaleResolver,
} from '@nestjs/i18n';
import { join } from 'node:path';
const env = process.env.NODE_ENV ?? 'development';
@Module({
imports: [
I18nModule.forRoot({
defaultLocale: 'en',
supportedLocales: ['en', 'pl'],
fallbacks: { 'en-GB': 'en' },
loader: new JsonI18nLoader({
path: join(import.meta.dirname, 'i18n'),
watch: env === 'development',
}),
resolvers: [
new QueryLocaleResolver('lang'),
new HeaderLocaleResolver('x-lang'),
new AcceptLanguageLocaleResolver(),
],
missingKey: env === 'production' ? 'fallback' : 'throw',
}),
],
controllers: [BooksController],
providers: [
BooksService,
{ provide: APP_PIPE, useValue: new I18nValidationPipe({ whitelist: true, transform: true }) },
],
})
export class I18nBooksModule {}
import.meta.dirname is the ESM template’s way to build a path next to the compiled file (the CommonJS template uses __dirname). The module is global by default (isGlobal: true), and it mounts a middleware on every route that resolves the locale before guards, interceptors, pipes and handlers run. supportedLocales is optional and defaults to the directories the loader finds; pinning it means a stray directory never becomes a locale.
Finally, give the keys a type. The English catalog is the source of truth: a type-only import reads its shape at compile time, and a declare module block registers it with the package.
import type books from './en/books.json';
import type mail from './en/mail.json';
import type validation from './en/validation.json';
export interface Translations {
books: typeof books;
mail: typeof mail;
validation: typeof validation;
}
declare module '@nestjs/i18n' {
interface I18nTypes {
translations: Translations;
}
}
From then on I18nService.t(), t(), i18nValidationMessage() and i18nIssueMessage() accept only keys that exist in the English catalog. A typo such as books.notFund fails to compile, and so does books.status, because it is a namespace and not a message. This needs resolveJsonModule in tsconfig.json.
Locale resolvers
The resolvers list is tried in order, and the first candidate that matches a supported locale wins. In the example, ?lang=pl beats an x-lang header, which beats Accept-Language. The built-in resolvers are QueryLocaleResolver (default name lang), HeaderLocaleResolver (x-lang), AcceptLanguageLocaleResolver and CookieLocaleResolver (cookie lang). Their behaviour:
-
Accept-Languageis parsed properly: languages are ordered by theirqvalues and unsupported ones are skipped, sofr-CH, pl-PL;q=0.9, en;q=0.8resolved topl. -
A region matches its base language:
pl-PLbecomespl. -
fallbackskeeps a region as a locale of its own. With{ 'en-GB': 'en' }, a request foren-GBresolved toen-GB, its messages come fromen, and number and date formatting follows British conventions. A region that is not listed resolves to the plain language. -
When nothing matches, or there is no request at all (a cron job, a queue consumer), the
defaultLocaleis used. -
Every HTTP response says which locale it was written in: the middleware sets
Content-Language(herepl) and lists the headers the resolvers read inVary(herex-lang, Accept-Language), so caches keep one copy per language.responseHeaders: falseturns this off.
Translating and formatting
Inject I18nService and call t(). You do not pass a locale: it reads the one resolved for the current request, even though the service is a singleton. Set the locale option only to override it, for example when a background job writes to a user in the language saved on their profile (the option is matched like a request locale, so pl-PL becomes pl and an unsupported value becomes the default locale).
@Injectable()
export class BooksService {
constructor(private readonly i18nService: I18nService) {}
create(dto: CreateBookDto) {
const book: Book = { id: this.nextId++, ...dto, status: 'available', addedAt: new Date() };
this.books.push(book);
return {
id: book.id,
message: this.i18nService.t('books.added', {
args: { title: book.title, copies: this.describeCopies(book.copies) },
}),
};
}
describeCopies(count: number): string {
// A numeric `count` argument selects the plural form with Intl.PluralRules
return this.i18nService.t('books.copies', { args: { count } });
}
summary(id: number) {
const book = this.findOne(id);
return {
id: book.id,
status: this.i18nService.t(`books.status.${book.status}`),
copies: this.describeCopies(book.copies),
addedAt: this.i18nService.formatDate(book.addedAt, { dateStyle: 'long', timeZone: 'UTC' }),
price: this.i18nService.formatNumber(12.5, { style: 'currency', currency: 'EUR' }),
};
}
}
POST /books?lang=pl with five copies answers Dodano "Solaris" na półkę: 5 egzemplarzy., while the same body without a language answers "Solaris" was added to the shelf: 1 copy. for one copy. Only an argument named count selects a plural form, so name it count in every plural message. Placeholders cannot nest, which is why the sentence is composed from two messages (the plural phrase first, then the sentence). A placeholder you pass no argument for stays in the text as written.
formatNumber() and formatDate() wrap Intl.NumberFormat and Intl.DateTimeFormat with the request’s locale. Reading the same book three ways gives:
{ "id": 1, "status": "Wypożyczona", "copies": "5 egzemplarzy", "addedAt": "14 września 2026", "price": "12,50 €" }
{ "id": 1, "status": "Borrowed", "copies": "5 copies", "addedAt": "14 September 2026", "price": "€12.50" }
{ "id": 1, "status": "Borrowed", "copies": "5 copies", "addedAt": "September 14, 2026", "price": "€12.50" }
The first line is ?lang=pl, the second Accept-Language: en-GB (hence 14 September) and the third the default. Two things the locale does not decide: the currency (it controls how an amount is written, not what it is worth, so take the currency from the data) and the time zone (without timeZone the server’s zone is used, which can shift the day).
Localised exceptions
An exception carries finished text, so there is nothing to configure: translate where you throw, and Nest’s default exception filter, your own filters and GraphQL error formatting (see Exception Filters & Error Handling) all receive the translated message.
findOne(id: number): Book {
const book = this.books.find((book) => book.id === id);
if (!book) {
throw new NotFoundException(this.i18nService.t('books.notFound', { args: { id } }));
}
return book;
}
GET /books/999 with the header x-lang: pl returns:
{ "message": "Nie znaleziono książki #999.", "error": "Not Found", "statusCode": 404 }
Code with no I18nService at hand, such as a pipe created outside dependency injection, uses the standalone t() function, which translates for the current request in the same way (outside a request it returns the key). Built-in pipes such as ParseIntPipe produce English messages, so give them an exception factory:
import { t } from '@nestjs/i18n';
// Created once, outside dependency injection, yet answers each request in its own language
const bookIdPipe = new ParseIntPipe({
exceptionFactory: () => new BadRequestException(t('books.invalidId')),
});
@Get(':id')
summary(@Param('id', bookIdPipe) id: number) {
return this.booksService.summary(id);
}
GET /books/abc?lang=pl returns Numer książki musi być liczbą całkowitą. with status 400. Pipes are covered in Pipes & Validation.
Localised validation messages
I18nValidationPipe is a drop-in replacement for the built-in ValidationPipe: it accepts the same options and only translates the messages. Bind it where ValidationPipe was, as the APP_PIPE provider above. A class-validator constraint without a message is looked up in the validation namespace by constraint name (@IsInt() reads validation.isInt, @MinLength() reads validation.minLength); i18nValidationMessage() points one constraint at a key of your choice.
import { i18nValidationMessage } from '@nestjs/i18n';
import { IsInt, IsString, Max, Min, MinLength } from 'class-validator';
export class CreateBookDto {
@IsString()
@MinLength(2)
title: string;
@IsInt()
@Min(1)
@Max(10, { message: i18nValidationMessage('books.tooManyCopies') })
copies: number;
}
A message can use the placeholders property, path (the dotted path, such as items.1.quantity), value, constraint1, constraint2 and so on (the decorator’s arguments), and count, the first numeric argument, so a plural message agrees with it. Posting { "title": "A", "copies": 12 } to POST /books?lang=pl returned:
{
"message": ["title musi mieć co najmniej 2 znaki", "copies: najwyżej 10 egzemplarzy"],
"error": "Bad Request",
"statusCode": 400
}
In English, { "title": "A", "copies": 0 } produces title must have at least 2 characters and copies must be at least 1. A constraint with no translation keeps the class-validator message, so you can translate constraints one at a time, and messages written as plain strings are left alone.
For an application that validates with Zod, Valibot, ArkType or another Standard Schema library (the same schema option on @Body() that Nest 12 added, see Pipes & Validation), bind I18nStandardSchemaValidationPipe instead of I18nValidationPipe; an application binds one of the two. Library messages are plain strings, so i18nIssueMessage() encodes a translation key in the message:
import { i18nIssueMessage } from '@nestjs/i18n';
import { z } from 'zod';
export const createBookSchema = z.object({
title: z.string().min(2),
copies: z.number().int().min(1).max(10, i18nIssueMessage('books.tooManyCopies')),
});
export type CreateBookInput = z.infer<typeof createBookSchema>;
// app module: providers: [{ provide: APP_PIPE, useValue: new I18nStandardSchemaValidationPipe() }]
// controller: create(@Body({ schema: createBookSchema }) dto: CreateBookInput) { ... }
An issue without an explicit key is looked up by the code the library assigns, from most to least specific: a format first (validation.invalid_format.regex), then the origin (validation.too_small.string for a short string), then the code alone. With a catalog that defines validation.too_small.string for Polish, the same invalid body returns title jest za krótkie and copies: najwyżej 10 egzemplarzy. Issues without a translation keep Nest’s usual format.
The same translation machinery covers GraphQL (field resolvers inject I18nService; the locale middleware wraps Apollo on Express, but a global prefix needs useGlobalPrefix: true on GraphQLModule, and subscriptions fall back to a global interceptor), microservice message handlers and WebSocket gateways (there the locale is resolved in a global interceptor, resolvers receive the executionContext, and a custom resolver can read a locale the producer put into the message). The behaviour on those paths is as the official page describes it in Localize GraphQL fields and Microservices and WebSocket gateways.
The signed-in user’s saved language
Resolvers run in the locale middleware, before guards, when nobody is signed in yet. A resolver that sets afterGuards runs after guards instead and receives the executionContext, so it can read the user an authentication guard put on the request (see Guards). Nest instantiates a resolver class, so it can inject providers; list the module that provides them in imports.
@Injectable()
export class ReaderLocaleResolver extends LocaleResolver {
// Runs after guards, when the authenticated user is on the request
readonly afterGuards = true;
constructor(private readonly readersService: ReadersService) {
super();
}
resolve({ executionContext }: LocaleResolverInput) {
const request = executionContext?.switchToHttp().getRequest<{ user?: { id: string } }>();
return request?.user ? this.readersService.localeOf(request.user.id) : undefined;
}
}
Put the class in resolvers at the rank it should have, for instance after the query and header resolvers and before AcceptLanguageLocaleResolver, and import the module that provides ReadersService. With a guard that sets request.user, a signed-in reader whose saved locale is pl reads Polish despite Accept-Language: en, an anonymous request falls through to Accept-Language, and ?lang=en still wins because earlier resolvers win. A response that depends on who asks is usually private anyway, but if a shared cache stores it, list the identifying header in the resolver’s varyHeaders. I18nContext.setLocale() changes the locale for the rest of the request, for an endpoint that lets users switch language and answers in the new one.
ICU message syntax
The built-in syntax has placeholders and plural objects. Translation platforms often export ICU MessageFormat instead, with exact matches such as =0, select and selectordinal. The formatter option plugs in another syntax: extend I18nMessageFormatter and implement format(), which receives the message, the arguments and the locale. This one hands the message to the intl-messageformat library, which your application installs (npm i intl-messageformat):
import { I18nMessageFormatter } from '@nestjs/i18n';
import { IntlMessageFormat } from 'intl-messageformat';
export class IcuMessageFormatter extends I18nMessageFormatter {
// Parsing a message costs far more than formatting it, and the catalogs hold a fixed set
private readonly compiled = new Map<string, IntlMessageFormat>();
format(message: string, args: Record<string, unknown>, locale: string): string {
const key = `${locale}\u0000${message}`;
let compiled = this.compiled.get(key);
if (!compiled) {
compiled = new IntlMessageFormat(message, locale, undefined, { ignoreTag: true });
this.compiled.set(key, compiled);
}
return String(compiled.format<unknown>(args));
}
}
// I18nModule.forRoot({ ..., formatter: new IcuMessageFormatter() })
With it registered, the Polish message {count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktu}} formatted 5 as 5 produktów and 22 as 22 produkty, a selectordinal message formatted 2 as 2nd, and =0 {Empty shelf} matched an exact zero. The catalogs written for the built-in syntax keep working unchanged, because a placeholder is a simple ICU argument. One caveat from the docs: the compiler requires a numeric count for a plural object but treats an ICU plural as a string, so pass its argument yourself.
Missing keys, configuration and hot reload
missingKey decides what happens when a key is not found for the requested locale. With 'throw', t() throws an I18nMissingKeyError and the request fails with a 500; with 'fallback' the message is taken from the default locale and a warning names the key once. The other policies are 'key', 'empty' and a function of the key and the locale. The example uses 'throw' outside production, which makes tests fail loudly (Vitest sets NODE_ENV to test), and 'fallback' in production. translate('nope.key') throws under the default environment. Fallback works per key, so a Polish customer can receive an English sentence around a Polish plural; the catalog test below keeps that from shipping.
With watch: true, the JSON loader watches the catalog directory and reloads it atomically when a file changes; a file that fails to parse is ignored and logged. Enable it in development only. Read the environment through ConfigService (see Configuration) with forRootAsync(); classes that Nest instantiates, such as a resolver class, stay at the top level next to imports, because a factory can return instances but not classes:
I18nModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
defaultLocale: config.getOrThrow<string>('i18n.default'),
supportedLocales: ['en', 'pl'],
loader: new JsonI18nLoader({ path: join(import.meta.dirname, 'i18n') }),
}),
// A class, which Nest instantiates, so it stays next to useFactory
resolvers: [new QueryLocaleResolver('lang'), ReaderLocaleResolver, new AcceptLanguageLocaleResolver()],
imports: [ReadersModule],
}),
ConfigModule has to be global (or imported by I18nModule through imports) for the factory to inject ConfigService; without isGlobal: true (or the import) the factory fails to resolve it. A custom I18nLoader (extend it, implement load() and optionally watch()) can read catalogs from a translation platform or a database; see A custom loader.
Testing
Outside a request, I18nService uses the default locale. To test a service in another language without HTTP, run the call inside I18nContext.run(). A catalog test catches missing translations on paths the tests do not exercise: every locale must have the same keys as English and every plural message the forms its language needs, which Intl.PluralRules knows.
it('describes copies in Polish and falls back outside a request', () => {
const books = app.get(BooksService);
const i18nContext = app.get(I18nContext);
expect(books.describeCopies(5)).toBe('5 copies'); // no request: default locale
expect(i18nContext.run('pl', () => books.describeCopies(22))).toBe('22 egzemplarze');
});
describe.each(Object.keys(catalogs))('the %s catalog', (lang) => {
it('has every plural form the language needs', () => {
const forms = new Intl.PluralRules(lang).resolvedOptions().pluralCategories;
for (const [key, message] of messages(catalogs[lang])) {
if (typeof message === 'object') {
expect(Object.keys(message), key).toEqual(expect.arrayContaining(forms));
}
}
});
});
To run the application with other catalogs, replace the loader on the testing module with overrideProvider(I18nLoader).useValue(new InMemoryI18nLoader(catalogs)). See Testing.
Mail with @nestjs/mail
Sending an email well takes more than a call to an SMTP library: the HTML must be escaped, the mail must survive failures, and tests must see what was sent. @nestjs/mail covers these. Mail classes are injectable providers that render a subject and a body from typed data, so they can inject other providers. The body comes from an HTML template rendered by a small built-in engine that escapes every value it inserts, or by Handlebars or any other engine you plug in. Transports deliver the result: SMTP (written on node:net and node:tls) and Resend, Postmark, SendGrid and Amazon SES over their HTTP APIs, with no SDK. Tests swap the transport for an in-memory one and read what it received.
Registering the module and choosing a transport
Every transport extends the abstract MailTransport class, which is also its injection token. Development writes each mail to an .eml file with FileMailTransport; production picks SMTP or Resend from the environment, so the same build runs everywhere. A bad value stops the application at startup instead of failing the first mail:
import { FileMailTransport, ResendTransport, SmtpTransport, type MailTransport } from '@nestjs/mail';
export function mailTransport(env: NodeJS.ProcessEnv = process.env): MailTransport {
switch (env.MAIL_TRANSPORT ?? 'file') {
case 'file':
return new FileMailTransport({ directory: env.MAIL_DIRECTORY ?? 'var/mail' });
case 'smtp':
return new SmtpTransport({ url: required(env, 'SMTP_URL'), pool: true });
case 'resend':
return new ResendTransport({ apiKey: required(env, 'RESEND_API_KEY') });
default:
throw new Error(`MAIL_TRANSPORT must be "file", "smtp" or "resend" (got "${env.MAIL_TRANSPORT}")`);
}
}
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name];
if (!value) {
throw new Error(`${name} is required when MAIL_TRANSPORT is "${env.MAIL_TRANSPORT}"`);
}
return value;
}
The factory lives in forRootAsync(), which receives ConfigService:
MailModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
transport: mailTransport(),
templates: new FileTemplateEngine({
dir: join(import.meta.dirname, 'templates'),
layout: 'layout',
// Development reads the files for every mail, so edits show up without a restart
cache: config.get('NODE_ENV') === 'production',
}),
from: config.get<string>('MAIL_FROM', 'Bookshelf <books@example.com>'),
}),
}),
MailModule is global and provides Mailer (sends mail) and MailEvents (reports what was sent and what failed). A missing transport or an invalid from stops the application at startup. The transports hold their credentials in private fields and error messages never contain them; keep the values in the environment or a secret store, as the factory does with SMTP_URL and RESEND_API_KEY. SMTP details worth knowing: on port 587 it upgrades with STARTTLS and refuses a server that does not offer it, port 465 uses TLS from the first byte, certificates are verified against the host name (tls.ca adds a private CA), pool: true reuses up to five connections, and dkim signs mail when your SMTP server does not. The HTTP providers accept a fetch option to go through a proxy or to stub the API in a test. Of the four, only Resend supports idempotency keys, which matters below.
Templates, mail classes and the layout
The template is logic-less: values between double braces, #if, #unless and #each blocks, partials and comments, but no helpers and no expressions. What needs code, such as formatting a date or building a link, happens in the mail class, where it is typed and tested. The syntax is a subset of Handlebars, so templates move to another engine as they are. Double braces escape the value (a book titled Dune <1965> is rendered as Dune <1965>); triple braces insert HTML you produced yourself, which only the layout’s body needs.
<p>Hi {{ reader.name }},</p>
<p>you borrowed these books. Please return them by {{ dueDate }}.</p>
<table>
<tr><th>Title</th><th>Author</th></tr>
{{#each books}}
<tr><td>{{ title }}</td><td>{{ author }}</td></tr>
{{/each}}
</table>
<p><a href="{{ loanUrl }}">View your loan</a></p>
<p>Your receipt is attached.</p>
Every mail is wrapped in the layout, which places the rendered mail with triple braces around body, includes the signature partial from templates/partials/signature.html, and references the logo by its content id:
<!DOCTYPE html>
<html>
<body>
<p><img src="cid:logo@bookshelf.example" alt="Bookshelf" width="120" height="32"></p>
{{{ body }}}
{{> signature}}
</body>
</html>
A mail class implements Mailable<TData>. Its render() method gets the data and a render context (the locale passed to send() and the parsed recipients to) and returns the subject, the template’s name and its context, and optionally attachments. An attachment’s content is a Buffer, a string or a stream, or it is read from a local path when the mail is sent; a cid makes it an inline image that the HTML references as cid:….
@Injectable()
export class LoanReceiptMail implements Mailable<LoanReceipt> {
constructor(
private readonly i18nService: I18nService,
private readonly receiptPdf: ReceiptPdf,
) {}
render({ loan, reader }: LoanReceipt, { locale }: MailRenderContext) {
// Mail goes out of band, so the locale is passed explicitly instead of read from a request
const t = (key: 'subject' | 'receiptFile') =>
this.i18nService.t(`mail.loanReceipt.${key}`, { locale, args: { id: loan.id } });
return {
subject: t('subject'),
// loan-receipt.pl.html for Polish, loan-receipt.html otherwise, both wrapped in the layout
template: 'loan-receipt',
context: {
reader,
books: loan.books,
dueDate: this.i18nService.formatDate(new Date(loan.dueDate), { locale, dateStyle: 'long', timeZone: 'UTC' }),
loanUrl: `${SHOP_URL}/loans/${loan.id}`,
},
attachments: [
// Inline: the layout references it as cid:logo@bookshelf.example
{ cid: 'logo@bookshelf.example', filename: 'logo.png', content: LOGO, contentType: 'image/png' },
{ filename: t('receiptFile'), content: this.receiptPdf.render(loan) },
],
};
}
}
Register LoanReceiptMail as a provider like any other injectable. mailer.send(LoanReceiptMail, { to, data, locale }) then takes the recipient and the data, whose type comes from the class’s render(). The recipient is an object with a name and an address, so a display name with a comma or quotes stays one name. A string works too but must hold exactly one address (a comma-separated list throws), so a name typed by a user can never add a recipient.
For a Polish reader the engine renders loan-receipt.pl.html and the Polish signature partial; for any other locale, or none, it renders loan-receipt.html. A regional locale tries itself first and then its language. The plain-text part is derived from the HTML (links become View your loan (https://…;)), or you can add a .txt file next to the HTML. Rendering the mail with mailer.render() without sending it produced, for locale pl:
<p><img src="cid:logo@bookshelf.example" alt="Bookshelf" width="120" height="32"></p>
<p>Cześć Ada,</p>
<p>wypożyczyłeś(-aś) te książki. Oddaj je do 30 października 2026.</p>
<table>
<tr><th>Tytuł</th><th>Autor</th></tr>
<tr><td>Dune <1965></td><td>Frank Herbert</td></tr>
</table>
<p><a href="https://bookshelf.example.com/loans/1001">Zobacz wypożyczenie</a></p>
<p>Potwierdzenie znajdziesz w załączniku.</p>
<p>Zespół Bookshelf</p>
render() is the basis for a development-only preview route: render the mail with sample data, replace the cid: references with data: URLs (browsers do not resolve cid:), and return the HTML. Register such a controller only outside production. Never build an attachment path from user input: the file at that path is attached, whatever it is (URLs are refused, not fetched).
A mail small enough to write in TypeScript does not need a template file. render() (or send()) can take html built with the html tagged template, which escapes every interpolated value. Interpolating the string <b>Ada</b> stores <b>Ada</b> in the HTML:
import { html } from '@nestjs/mail';
await this.mailer.send({
to: 'ada@example.com',
subject: 'Hi',
html: html`<p>Hello ${name}</p>`, // escaped; unsafeHtml() inserts HTML you produced yourself
});
Escaping only protects element content and quoted attribute values, so those are the only places a double-braced value may go: a value inside a tag anywhere else is a compile error, and a template that does not compile fails the mail with a permanent MailTemplateError that names the file and the position.
Sending after the transaction commits
Sending the mail from the request handler, after the commit, still has three failure modes: a crash between the commit and the send loses the mail, a mail-server outage fails a request that already succeeded, and the customer waits for the mail server. This is the dual-write problem that the transactional outbox solves (see The Transactional Outbox Pattern for the pattern, and Outbox & Durable Workflows for the pre-1.0 @nestjs/outbox package in depth). The business row and an loan.created message commit in one transaction; a relay hands the message to a handler that sends the mail after the commit, retries with backoff, and moves messages that never succeed to a dead-letter table.
The service adds the message through the transaction handle, so it commits or rolls back with the loan:
async lend(readerId: string, books: Loan['books']): Promise<Loan> {
const loan = await this.db.transaction(async (tx) => {
const loan: Loan = { id: String(1000 + this.saved.length + 1), readerId, books, dueDate: '2026-10-30' };
this.saved.push(loan); // stands in for the INSERT through `tx`
// Same transaction: the receipt mail is queued only if the loan commits
await this.outbox.add(tx, { topic: 'loan.created', payload: loan });
return loan;
});
this.outbox.notify(); // send now instead of at the next poll
return loan;
}
The handler sends the mail. Each option of send() has a job:
@Injectable()
export class LoanMailsHandler {
constructor(
private readonly mailer: Mailer,
private readonly readers: ReadersDirectory,
) {}
// Runs after the loan committed; the inbox skips messages this consumer already handled
@OnOutboxMessage('loan.created', { consumer: 'loan-receipt-mail' })
async sendReceipt(loan: Loan, { message, signal }: OutboxHandlerContext) {
const reader = this.readers.find(loan.readerId);
if (!reader) {
throw new NonRetryableMessageError(`Reader ${loan.readerId} not found`);
}
try {
await this.mailer.send(LoanReceiptMail, {
to: { name: reader.name, address: reader.email },
data: { loan, reader },
locale: reader.locale,
idempotencyKey: message.id,
signal,
retry: false, // the outbox retries, with its own backoff
});
} catch (error) {
// A refused address won't be accepted later: dead-letter it now
if (error instanceof MailError && error.permanent) {
throw new NonRetryableMessageError(error.message, { cause: error });
}
throw error;
}
}
}
-
idempotencyKeyidentifies the mail across redeliveries, because the outbox delivers at least once. TheMessage-IDheader is derived from it, so over SMTP a duplicate carries the same id, which receivers use to drop it;ResendTransportalso sends it as Resend’sIdempotency-Key. Postmark, SendGrid and SES have no idempotency keys, so with them a redelivery can reach the reader twice. -
signalis aborted by the relay when the handler outlives its publish timeout, and the mailer stops the SMTP exchange or the HTTP request. -
retry: falsemakes one attempt per delivery. The mailer retries transient failures three times by default; with an outbox that already retries over minutes, retries at both layers would multiply. -
Every error the package throws extends
MailError, and itspermanentflag says whether sending the same message again can succeed. A 4xx SMTP reply (greylisting, a full mailbox), a 429 from an HTTP provider, a timeout or a dropped connection is transient; a 5xx reply such as550 5.1.1 User unknownis permanent, so the handler dead-letters it at once.
A minimal module registration uses the default in-memory outbox store (OutboxModule.forRoot({ relay: { enabled: false }, retry: { attempts: 10, backoff: { delay: '10s', maxDelay: '10m' } } })), with a test-only Database whose transaction() runs on that store. That is enough to test the flow, but an in-memory store is lost on restart and cannot join your database’s transaction: with NODE_ENV=production the outbox refuses to start without a store of the application’s own. In production, register PostgresOutboxStore (from @nestjs/outbox/postgres) or MySqlOutboxStore on the same database client as your ORM, as the official page shows for Drizzle and TypeORM. The package is pre-1.0, like the mail package, so confirm both against their pages before relying on them.
Another template engine and your own transport
FileTemplateEngine implements MailTemplateEngine, an abstract class with one render() method that takes the template name, the context and the render options (the locale) and returns the HTML, or an object with html and text. Extend it to render with Handlebars, EJS or anything else. The mailer sends the HTML as the engine returns it, so escaping is the engine’s job; Handlebars escapes double-braced values as the built-in engine does.
export class HandlebarsTemplateEngine extends MailTemplateEngine {
private readonly templates = new Map<string, ReturnType<typeof Handlebars.compile> | undefined>();
async render(name: string, context: object, { locale }: MailTemplateRenderOptions) {
// Only a language code goes into a file name, never whatever the locale holds
const localized = locale && /^[a-z]{2}$/.test(locale) ? [`${name}.${locale}.hbs`] : [];
for (const file of [...localized, `${name}.hbs`]) {
const template = await this.load(file);
if (template) {
return template(context);
}
}
throw new MailTemplateError(`No template "${name}" in ${TEMPLATES}`, { template: name });
}
private async load(file: string) {
if (!this.templates.has(file)) {
const source = await readFile(join(TEMPLATES, file), 'utf8').catch(() => undefined);
this.templates.set(file, source === undefined ? undefined : Handlebars.compile(source));
}
return this.templates.get(file);
}
}
With handlebars installed, send({ template: 'loan-receipt', context }) rendered <p>Hi Ada <3,</p> and the book list. Pass the instance, or the class (which Nest instantiates, so it can inject configuration), as templates. Throw MailTemplateError for a missing or broken template: it is permanent, so an outbox dead-letters the mail instead of retrying it. Handlebars does not know the partials/ folder or the layout: register partials with Handlebars.registerPartial() and render the layout yourself.
For a provider the package does not ship a transport for, extend MailTransport. Its send() gets the finished, frozen MailMessage (with envelope for the SMTP recipients, Bcc included, and toMime()) and resolves once the provider has accepted it. What it throws decides whether the mailer retries:
export class JsonApiTransport extends MailTransport {
readonly #token: string;
// ...
async send(message: MailMessage, { signal, idempotencyKey }: MailTransportSendOptions): Promise<MailTransportResult> {
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: {
authorization: `Bearer ${this.#token}`,
'content-type': 'application/json',
...(idempotencyKey && { 'idempotency-key': idempotencyKey }),
},
// The envelope names every recipient; the MIME message leaves Bcc out
body: JSON.stringify({ to: message.envelope.to, raw: message.toMime().toString('base64') }),
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
});
if (!response.ok) {
// Permanent for 4xx except 408, 409 and 429; those and 5xx are retried
throw new MailProviderError({ provider: 'json-api', status: response.status, detail: await response.text() });
}
const { id } = (await response.json()) as { id?: string };
return { accepted: message.envelope.to, providerMessageId: id };
}
}
Against a stubbed fetch, a 200 resolved with the provider’s id and forwarded the idempotency key, a 422 raised a MailProviderError with permanent: true, and a 503 raised one with permanent: false. The token is read from configuration and held in a private field, never written in code. A transport that holds connections implements close(), which runs on application shutdown after the sends in flight have finished; call app.enableShutdownHooks() in main.ts (see Lifecycle Events & Shutdown) so a deploy lets in-flight mail finish.
Testing mail
Replace the transport with InMemoryMailTransport. It keeps every mail it receives and has helpers that find one and read it. Everything except delivery is real: the mail class renders its templates with the catalogs, the outbox and its inbox run, and the message is fully built, headers and attachments included.
const mailbox = new InMemoryMailTransport();
const mod = await Test.createTestingModule({ imports: [MailBooksModule] })
.overrideProvider(MailTransport)
.useValue(mailbox)
.compile();
it('sends after the commit, in the reader language', async () => {
await loans.lend('ola', [{ title: 'Solaris', author: 'Stanisław Lem' }]);
mailbox.assertNotSent(); // nothing until the relay publishes the committed loan
await relay.runOnce();
const mail = mailbox.assertSent({ to: 'ola@example.com', mail: LoanReceiptMail });
expect(mail.subject).toBe('Twoje wypożyczenie #1001');
expect(mail.attachment('potwierdzenie-1001.pdf').contentType).toBe('application/pdf');
expect(mail.link('/loans/').pathname).toBe('/loans/1001');
});
it('dead-letters a mail the server refuses', async () => {
mailbox.failNext(new MailSmtpError('RCPT TO', { code: 550, text: 'User unknown' }));
await loans.lend('ada', [{ title: 'Dune', author: 'Frank Herbert' }]);
await relay.runOnce();
mailbox.assertNotSent();
const [deadLetter] = await mod.get(OutboxDeadLetters).list();
expect(deadLetter).toMatchObject({ topic: 'loan.created', reason: 'rejected' });
});
assertSent() returns the most recent mail that matches (by recipient, subject, mail class or template, or a predicate) and throws an error that lists what was sent when nothing matches; assertNotSent(), filter(), find() and mails cover the rest. link() returns the first link that contains a string, as a URL with entities decoded, which is the whole test for a password-reset or magic-link mail. A rolled-back transaction produced no mail, because the outbox message rolled back with it. FileMailTransport wrote the .eml with the Polish name and subject as encoded-words (To: =?UTF-8?Q?Ola_W=C3=B3jcik?= <ola@example.com>), because headers are ASCII and the mailer encodes everything else as RFC 2047.
File storage with @nestjs/storage
A Nest application typically keeps public files (book covers), private files (receipts) and large uploads that should not pass through the API. In development all of it should work from a folder on the laptop; in production it goes to S3 or Cloudflare R2, because the API runs on several instances and none of them has a disk that lasts. @nestjs/storage gives each place files live a name, a disk, and one API for all of them: put(), get(), list(), delete(), public URLs and signed URLs. It ships LocalDisk, S3Disk (AWS S3 and S3-compatible stores such as Cloudflare R2, MinIO and Backblaze B2, talked to over fetch with no AWS SDK) and InMemoryDisk for tests.
It does not parse uploads. multer on Express and @nestjs/platform-fastify/multipart on Fastify keep doing that through FileInterceptor() (see File Upload & Streaming); this package is where the files end up, and it provides a storage engine that streams each upload straight onto a disk.
Configuring disks
The app needs two disks: covers, public with a URL, and private, reachable only through signed URLs. Keeping them on separate disks (separate buckets in production) means a file never becomes public by accident. The configuration comes from @nestjs/config, and STORAGE_DRIVER picks local disks or S3:
export function createStorageOptions(config: ConfigService): StorageModuleOptions {
if (config.get('storage.driver') === 's3') {
const s3 = {
endpoint: config.get<string>('storage.s3.endpoint'),
region: config.getOrThrow<string>('storage.s3.region'),
};
return {
default: 'private',
disks: {
// Credentials come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, never from code
covers: new S3Disk({
...s3,
bucket: config.getOrThrow<string>('storage.s3.coversBucket'),
publicUrl: config.getOrThrow<string>('storage.s3.coversUrl'),
}),
private: new S3Disk({ ...s3, bucket: config.getOrThrow<string>('storage.s3.privateBucket') }),
},
};
}
const appUrl = config.getOrThrow<string>('appUrl');
const root = config.getOrThrow<string>('storage.root');
return {
default: 'private',
disks: {
// Book covers: public, served by the app in development
covers: new LocalDisk({ root: `${root}/covers`, publicUrl: `${appUrl}/covers` }),
// Receipts and uploads in progress: only reachable through signed URLs
private: new LocalDisk({
root: `${root}/private`,
signedUrls: { baseUrl: `${appUrl}/files`, keys: [config.getOrThrow<string>('storage.signingKey')] },
}),
},
};
}
A LocalDisk keeps files in a directory it creates at startup. A local directory cannot produce signed URLs the way S3 does, so a local disk with signedUrls signs its own, with HMAC-SHA256 under baseUrl, and the app verifies and serves them. keys takes one or more keys of at least 32 characters; the first signs and all verify, so you rotate by putting the new key first. The signing key must come from a secret (openssl rand -hex 32), never from code. Register the module with forRootAsync():
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, load: [configuration] }),
StorageModule.forRootAsync({ inject: [ConfigService], useFactory: createStorageOptions }),
],
controllers: [CoversController, FilesController, ReceiptsController],
providers: [CoversService, ReceiptsService],
})
export class StorageBooksModule {}
StorageModule is global. A service injects the default disk as StorageDisk, a named disk with @InjectDisk('covers'), or Storage to pick a disk at runtime with storage.disk('covers'). Configuration mistakes stop the application at startup: a default that names no disk, a signing key shorter than 32 characters, and a class that injects a disk name nobody configured (the error lists the configured disks).
Streaming uploads onto a disk
The upload endpoint uses FileInterceptor() as any upload does. The difference is the storage option: uploadToDisk() is a multer storage engine that streams the file onto a disk instead of into memory or a temp folder.
@Post('books/:id/cover')
@UseInterceptors(
FileInterceptor('cover', {
storage: uploadToDisk({ disk: 'covers', contentTypes: COVER_TYPES, cacheControl: COVER_CACHE_CONTROL }),
limits: { fileSize: MAX_COVER_SIZE, files: 1 },
}),
)
upload(@Param('id') id: string, @UploadedFile() file: StoredUpload | undefined) {
if (!file) {
throw new BadRequestException('Attach the image as the "cover" field');
}
return this.coversService.replace(id, file.key);
}
// Development only: in production a CDN serves the covers bucket at `publicUrl`
@Get('covers/:file')
serve(@Param('file') file: string, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
return serveFile(this.covers, file, { req, res, disposition: 'inline' });
}
By the time the handler runs, the cover is stored, and the engine has done three things on the way. It checked what the file is, not what the client says: it reads the first bytes of the stream, detects the type, and refuses anything not in contentTypes with a 415 before a byte is written (the stored content type is the detected one). It enforced the size limit without leaving a partial file: multer stops at limits.fileSize, and the request fails with 413 with nothing stored. And it picked the key: by default a random UUID plus the extension of the detected type, so an upload never overwrites another and a cached cover never goes stale. @UploadedFile() returns a StoredUpload with multer’s usual fields plus key, disk, contentType and etag; mimetype is still the client’s claim, so use contentType. ParseFilePipe and its validators run after the interceptor, when a streamed file is already stored, so check type and size in the engine instead.
Uploading a PNG answers {"id":"dune","coverUrl":"http://localhost:3000/covers/4b4308db-….png"}; fetching it returns image/png, Cache-Control: public, max-age=31536000, immutable, Content-Disposition: inline and X-Content-Type-Options: nosniff; an HTML file declared as image/png is refused:
{ "message": "File type not allowed. Allowed types: image/jpeg, image/png, image/webp", "error": "Unsupported Media Type", "statusCode": 415 }
A body over 5 MiB answers 413 File too large, and uploading a second cover for the same book leaves exactly one file on disk because the service deletes the previous key. serveFile() streams a file from a disk with Content-Type, Content-Length, ETag, Last-Modified, the stored Cache-Control (private when none is stored, so a shared cache never keeps a file that may be one user’s) and nosniff; for types that could run script in your origin, such as HTML or SVG, it sends Content-Disposition: attachment whatever you ask. It answers Range with 206, If-None-Match with 304 and a missing file with 404. On Fastify, import FileInterceptor from @nestjs/platform-fastify/multipart and register @fastify/multipart; uploadToDisk() works with both. The Fastify variant is as the official page states it.
Private files and signed URLs
A receipt is generated the first time a reader asks for it and stored on the private disk. put() takes a Buffer, a string, a Uint8Array, a Node stream, a web ReadableStream or any async iterable of bytes; on every disk a write is atomic (a reader sees the old file or the new one, never half of one), and metadata stores string values of your own with the file.
@Injectable()
export class ReceiptsService {
// No name: the default disk, `private`
constructor(private readonly storageDisk: StorageDisk) {}
async downloadUrl(loanId: string) {
const key = `receipts/${loanId}.pdf`;
if (!(await this.storageDisk.exists(key))) {
await this.storageDisk.put(key, Buffer.from(`%PDF-1.7\n% Receipt ${loanId}\n%%EOF\n`), {
contentType: 'application/pdf',
metadata: { loanId },
});
}
const url = await this.storageDisk.signedUrl(key, { expiresIn: '5m', filename: `receipt-${loanId}.pdf` });
return { url };
}
}
signedUrl() returns a link that works without other credentials until it expires. expiresIn is a duration ('5m', '1h', or milliseconds), 15 minutes by default and at most 7 days, S3’s limit; filename sets the name the browser saves the file under. The endpoint returns the link rather than the file; check that the receipt belongs to the signed-in reader before handing it out (see Authorization), because the link itself needs no check: whoever holds it may download that one file until it expires, and it cannot be revoked. With a local disk, the link points at the app, so a route verifies and serves it:
@Controller('files')
export class FilesController {
constructor(private readonly storageDisk: StorageDisk) {}
@Get()
download(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
return serveSignedUrl(this.storageDisk, { req, res });
}
@Put()
upload(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
return receiveSignedUpload(this.storageDisk, { req, res });
}
}
serveSignedUrl() checks the signature, the expiry and the method, then answers like serveFile(). The signature covers the route (baseUrl), the key, the expiry and the file name, so changing any of them is a 403 Invalid signed URL, and a link past its expiry is a 403 too. The route returns {"url":"http://localhost:3000/files?key=receipts%2F1001.pdf&expires=…&disposition=attachment%3B+filename%3D%22receipt-1001.pdf%22&signature=…"}; the link downloads as application/pdf with Content-Disposition: attachment; filename="receipt-1001.pdf", and answers {"message":"Invalid signed URL","error":"Forbidden","statusCode":403} after changing 1001 to 1002 in the same link. With an S3 private disk the signed URLs point at the bucket and this route answers 404.
Direct uploads from a client
Large files on slow connections should not pass through the API. The client asks the API for an upload URL, sends the file straight to storage with PUT, and then tells the API it is done. The upload lands on the private disk under incoming/, because the API has not looked at it yet; only after checking it does the API copy it to the public disk.
async createUpload(bookId: string, contentType: string, size: number) {
if (!COVER_TYPES.includes(contentType)) {
throw new UnsupportedMediaTypeException(`A cover must be one of: ${COVER_TYPES.join(', ')}`);
}
if (!Number.isSafeInteger(size) || size < 1 || size > MAX_COVER_SIZE) {
throw new BadRequestException(`size must be between 1 and ${MAX_COVER_SIZE} bytes`);
}
// Private until checked; the book id in the key ties the upload to this book
const key = `incoming/${bookId}/${randomUUID()}`;
const upload = await this.uploads.signedUpload(key, { contentType, contentLength: size, expiresIn: '10m' });
return { key, ...upload };
}
async completeUpload(bookId: string, key: string) {
// Only keys this service handed out for this book: never copy an arbitrary private file
const prefix = `incoming/${bookId}/`;
if (typeof key !== 'string' || !key.startsWith(prefix) || !/^[0-9a-f-]{36}$/.test(key.slice(prefix.length))) {
throw new BadRequestException('Unknown upload');
}
const file = await this.uploads.stat(key).catch((error: unknown) => {
if (error instanceof StorageFileNotFoundError) {
throw new BadRequestException('Upload the file first');
}
throw error;
});
if (file.size > MAX_COVER_SIZE) {
await this.uploads.delete(key);
throw new PayloadTooLargeException('File too large');
}
// The client chose the Content-Type: check what the bytes are
const head = await this.uploads.get(key, { range: { start: 0, end: 15 } });
const type = detectContentType(await buffer(head.body));
if (!type || !COVER_TYPES.includes(type)) {
await this.uploads.delete(key);
throw new UnsupportedMediaTypeException(`A cover must be one of: ${COVER_TYPES.join(', ')}`);
}
// Stream it from the private disk to the public one
const coverKey = `${randomUUID()}${EXTENSIONS[type]}`;
const { body } = await this.uploads.get(key);
await this.covers.put(coverKey, body, { contentType: type, contentLength: file.size, cacheControl: COVER_CACHE_CONTROL });
await this.uploads.delete(key);
return this.replace(bookId, coverKey);
}
signedUpload() returns the URL, the method (PUT), the headers the client must send and when the URL expires. The URL is signed for exactly the declared content type and length: a PUT with Content-Type: text/html against a URL signed for image/png is refused with 403, and the matching PUT answers {"key":"incoming/dune/…","size":46,"contentType":"image/png","etag":"…"}. The declared type is still the client’s claim, so completeUpload() treats the upload as untrusted: the key must be one it handed out for this book (otherwise a client could submit receipts/1001.pdf and have the API copy someone’s receipt to the public disk, which is refused with 400), the size is checked again with stat() (metadata without the body), and the bytes are checked by reading only the first 16 with a ranged get() and passing them to detectContentType(), which recognises JPEG, PNG, GIF, WebP, AVIF, HEIC and PDF. An HTML body uploaded under image/png is refused at completion with 415. On Fastify the body of the signed PUT must reach the handler unread, so register a catch-all content type parser that leaves it alone (addContentTypeParser('*', (req, payload, done) ⇒ done(null)) on the Fastify instance), as the official page notes. Uploads that are never completed stay under incoming/; on S3 a lifecycle rule deletes them after a day.
S3 and R2 in production
Production switches both disks to S3Disk with configuration alone (the s3 branch of the factory above); every service, controller and signed URL keeps working. A local disk cannot serve there: a container’s file system is discarded when the task is replaced, and other instances cannot read it. S3Disk reads credentials from the standard AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN variables unless you pass credentials (an object, or a function called before every request for credentials that rotate); without credentials the app does not start. The same class covers several services:
| Service | Settings |
|---|---|
AWS S3 |
|
Cloudflare R2 |
|
MinIO |
|
Backblaze B2 |
|
Google Cloud Storage |
|
With S3, signedUrl() and signedUpload() return presigned URLs signed with Signature Version 4 (implemented on node:crypto), which the bucket verifies itself, so the app’s /files route is not involved; url() returns URLs under publicUrl, typically a CDN or R2’s public domain in front of the covers bucket. Uploads stream: a file up to 8 MiB goes up in one request, a longer one (or a stream of unknown length) as a multipart upload that is aborted if anything fails, and network errors, timeouts, throttling and 5xx answers are retried with backoff up to three attempts. Presigning is offline: with the S3 branch of the factory pointed at a placeholder R2 endpoint and test values in the two environment variables, the private disk’s signedUrl() returns an X-Amz-Algorithm=AWS4-HMAC-SHA256 URL on https://bookshelf-private.<account>.r2.cloudflarestorage.com/receipts/1.pdf with X-Amz-Expires=300, signedUpload() returns method PUT with content-type and content-length headers, and url() on the covers disk returns the CDN URL.
Testing and production checklist
InMemoryDisk behaves like the other disks (the same key rules, ranges, listings, metadata and signed URLs). In an end-to-end test, override STORAGE_MODULE_OPTIONS, the options the factory would have returned, so the test needs no signing key and touches no file system:
const covers = new InMemoryDisk({ publicUrl: 'https://covers.example.com' });
const files = new InMemoryDisk({
signedUrls: { baseUrl: 'https://api.example.com/files', keys: [randomBytes(32).toString('hex')] },
});
const moduleRef = await Test.createTestingModule({ imports: [StorageBooksModule] })
.overrideProvider(STORAGE_MODULE_OPTIONS)
.useValue({ default: 'private', disks: { covers, private: files } })
.compile();
it('stores the cover and returns its public URL', async () => {
const res = await request(app.getHttpServer())
.post('/books/dune/cover')
.attach('cover', PNG, 'cover.png')
.expect(201);
const [key] = covers.keys();
expect(res.body.coverUrl).toBe(`https://covers.example.com/${key}`);
expect(await covers.getBuffer(key)).toEqual(PNG);
});
The upload goes through multer and uploadToDisk() as in production; only the place the bytes land differs, and a non-image body answers 415 with the disk still empty. Use a LocalDisk on a temporary directory to test what reaches a real file system, and point an S3Disk at MinIO to test S3-specific behaviour. What to check before shipping:
-
Keep public and private files in separate buckets. Put a CDN in front of the public one and point
publicUrlat it; keep the private bucket fully private. There is no per-file public flag, because new S3 buckets have object ACLs disabled. -
Add a lifecycle rule that deletes objects under
incoming/and incomplete multipart uploads after a day, and allowPUTfrom your site’s origin with CORS if a web page uploads to presigned URLs. -
Grant the app only
s3:GetObject,s3:PutObject,s3:DeleteObjectands3:ListBucketon its two buckets. Withouts3:ListBucket, S3 answers a request for a missing file with403instead of404, andexists()fails instead of returningfalse. -
Encrypt at rest (S3 does by default; set
serverSideEncryption: 'aws:kms'andkmsKeyIdfor a key of your own). -
Keep signed links short-lived, set a signing key per environment from a secret, and never use a local disk in production: the module logs a warning at startup when it finds one with
NODE_ENV=production. -
Build keys from ids you control. Keys are validated on every disk (no
.., no absolute paths, no control characters), so user input cannot escape a disk, but it can still name any file on it.
Pitfalls
-
Pre-1.0 means pin and re-read. All three packages (and
@nestjs/outbox) were0.xwhen this page was checked. Pin exact versions, and re-read the official pages when you upgrade. -
Mail is usually sent outside the recipient’s request.
I18nServicefalls back to the current request’s locale, which for an outbox handler, a cron job or an admin’s request is the wrong one. Passlocaletosend()and tot()explicitly, as the mail class above does. -
The
class-validatorpeer range.@nestjs/i18nasks for^0.14.0; with 0.15.1 installed, npm needs--legacy-peer-deps(as of 2026-10-02). -
A global
ConfigModulefor async factories.forRootAsync()of each package injectsConfigServicefrom the importing module graph; makeConfigModuleglobal or list it inimports. -
Escape what goes into HTML. Placeholder arguments in i18n messages are inserted as they are, which is right for JSON responses; an HTML email must escape them, which the mail template engine does and a hand-built string does not.
-
Do not trust the client’s
Content-Type. Both upload paths detect the type from the bytes; a direct upload must be checked again at completion. -
class-validatormessages andParseFilePipe. Localise validators withI18nValidationPipe, but check file type and size in the storage engine, not inParseFilePipe, which runs after a streamed file is already stored. -
Signed URLs cannot be revoked. Keep them short-lived and authorise the request that hands one out.
-
Local disks and the file transport are for development.
FileMailTransportandLocalDiskwrite to a disk a container loses;LogMailTransportonly logs. The mail module warns at startup when one of them runs withNODE_ENV=production.
Express and Fastify
The locale middleware, I18nService and the translated exceptions behave the same on both platforms, and the mail package does not touch the HTTP layer at all. Storage is where the platform shows: the helpers serveFile(), serveSignedUrl() and receiveSignedUpload() take the request and response of the adapter in use, the Express example above imports FileInterceptor from @nestjs/platform-express, and on Fastify it comes from @nestjs/platform-fastify/multipart (which needs @fastify/multipart) and the signed PUT needs the catch-all content type parser. Nest 12.1 added Fastify upload interceptors backed by @fastify/multipart (see File Upload & Streaming). The Fastify differences above are taken from the official documentation.
What changed recently
-
The September 2026 documentation restructure moved these chapters to
/application/i18n,/application/mailand/application/file-storage. They are new in the official documentation and describe packages that were first published in the last weeks of September 2026, all at0.0.1. -
@nestjs/i18nresolves the locale once per request into anAsyncLocalStoragestore, ships a Standard Schema validation pipe next to theclass-validatorone, and exposesmissingKeyas a policy ('throw','fallback','key','empty'or a function). -
@nestjs/mailhas no dependency on Nodemailer: SMTP is implemented in the package, the HTTP providers usefetch, andInMemoryMailTransportis a first-party testing double withassertSent()andlink(). -
@nestjs/storageimplements S3 requests withfetchandnode:cryptorather than the AWS SDK, and its Fastify path builds on the@nestjs/platform-fastify/multipartentry point that Nest 12.1 introduced. -
@nestjs/outbox(0.1.0 on 2026-10-01) is the companion that makes mail after commit reliable; it is pre-1.0 too.
Where the book is outdated
The reference book, published in January 2025 (see the section bibliography), does not cover internationalization, mail or file storage with first-party packages: it predates them. Older tutorials, and anything written before late September 2026, use the community nestjs-i18n package, @nestjs-modules/mailer over Nodemailer, and hand-written wrappers around the AWS SDK or multer’s diskStorage(). Those still work and are still published, so there is no deprecation to chase, but code that follows them does not get the behaviour described above: per-request locale without request scoping, an escaped-by-default template engine, a mail transport you can swap for an in-memory one, and an upload engine that detects the real file type from the bytes and refuses a partial file. When you move such code over, keep the migration to one package at a time and keep the tests that read what was sent or stored.