@fluojs/email
v1.0.2
Published
Transport-agnostic email delivery core for Fluo with notifications and queue integration seams.
Maintainers
Readme
@fluojs/email
Transport-agnostic email delivery core for fluo. It provides a Nest-like module API, an injectable EmailService for standalone usage, and a first-party channel/queue adapter pair for @fluojs/notifications integration without hard-coding any runtime-specific transport.
Table of Contents
- Installation
- When to Use
- Quick Start
- Common Patterns
- Public API Overview
- Runtime-Specific and Integration Subpaths
- Related Packages
- Example Sources
Installation
npm install @fluojs/emailInstall @fluojs/notifications and @fluojs/queue only when you want the built-in notifications channel and queue worker integration.
npm install @fluojs/notifications @fluojs/queueInstall nodemailer only when you use the explicit @fluojs/email/node subpath for Node-only SMTP delivery.
npm install @fluojs/email nodemailerNode-specific SMTP delivery is available from the explicit @fluojs/email/node subpath. Queue-backed notifications integration is available from @fluojs/email/queue, and @fluojs/queue is declared as an optional peer for that subpath. The root @fluojs/email entrypoint stays transport-agnostic so Bun, Deno, Cloudflare, and custom HTTP transports do not inherit Node-only or queue-specific behavior.
When to Use
- When you want one package that can send email directly and also plug into
@fluojs/notifications. - When transport choice must stay explicit and portable across Node, Bun, Deno, and Cloudflare-compatible application boundaries.
- When email transport resources must participate in application bootstrap/shutdown without the core package assuming a specific runtime.
- When bulk notification delivery should enqueue email work through
@fluojs/queueinstead of blocking request paths.
Quick Start
Register the module
import { Module } from '@fluojs/core';
import { EmailModule, type EmailTransport } from '@fluojs/email';
class ExampleTransport implements EmailTransport {
async send(message) {
return {
accepted: message.to.map((entry) => entry.address),
messageId: crypto.randomUUID(),
pending: [],
rejected: [],
};
}
}
@Module({
imports: [
EmailModule.forRoot({
defaultFrom: '[email protected]',
transport: {
kind: 'example-http-transport',
create: async () => new ExampleTransport(),
},
}),
],
})
export class AppModule {}Send mail directly
import { Inject } from '@fluojs/core';
import { EmailService } from '@fluojs/email';
@Inject(EmailService)
export class WelcomeService {
constructor(private readonly email: EmailService) {}
async sendWelcome(address: string) {
await this.email.send({
to: [address],
subject: 'Welcome to fluo',
text: 'Your account is ready.',
});
}
}The root @fluojs/email surface is intentionally module-first. Register email delivery through EmailModule.forRoot(...) or EmailModule.forRootAsync(...).
Common Patterns
Registration scope and async factories
EmailModule.forRoot(...) and EmailModule.forRootAsync(...) return a global module by default. After one import, the exported EmailService, EmailChannel, EMAIL, and EMAIL_CHANNEL providers are visible to the application module graph. Pass global: false only when email providers should stay visible to modules that explicitly import the returned module.
Async registration intentionally uses fluo's explicit factory shape:
EmailModule.forRootAsync({
global: false,
inject: [ConfigService],
useFactory: (config) => ({
defaultFrom: config.mail.from,
transport: {
kind: config.mail.transportKind,
create: () => config.mail.transport,
ownsResources: false,
},
}),
});global belongs on the top-level forRootAsync(...) options object, not in the factory result. The supported async registration shape is inject plus useFactory; NestJS dynamic-module forms such as imports, useClass, and useExisting are not part of the @fluojs/email contract. Register dependencies in the surrounding application module graph first, then list the tokens the factory needs in inject.
Node-only SMTP with @fluojs/email/node
Use the dedicated Node subpath when you want first-party Nodemailer/SMTP delivery without weakening the runtime-portable root package contract.
import { Module } from '@fluojs/core';
import { EmailModule } from '@fluojs/email';
import { createNodemailerEmailTransportFactory } from '@fluojs/email/node';
@Module({
imports: [
EmailModule.forRoot({
defaultFrom: '[email protected]',
transport: createNodemailerEmailTransportFactory({
smtp: {
auth: {
pass: 'smtp-password',
user: 'smtp-user',
},
host: 'smtp.example.com',
port: 587,
secure: false,
},
}),
verifyOnModuleInit: true,
}),
],
})
export class AppModule {}Behavioral contract notes:
createNodemailerEmailTransportFactory(...)is Node-only and is exported exclusively from@fluojs/email/node.- The factory owns the Nodemailer transporter it creates, so
EmailServicecan verify it on bootstrap and close it during shutdown. createNodemailerEmailTransport(...)wraps an existing Nodemailer transporter without transferring resource ownership.- Nodemailer display-name addresses are forwarded as structured address objects and reject newline characters before provider handoff.
- SMTP credentials still enter through explicit options or DI. Neither the root package nor the Node subpath reads
process.envdirectly.
Standalone delivery with EmailService
Use EmailService when your application wants direct email delivery without going through the notifications foundation.
EmailModule.forRootAsync({
inject: [ConfigService],
useFactory: (config) => ({
defaultFrom: config.mail.from,
transport: {
kind: config.mail.transportKind,
create: () => config.mail.transport,
ownsResources: false,
},
}),
});Behavioral contract notes:
EmailService.send(...)resolvesdefaultFromanddefaultReplyTobefore delivery.EmailService.send(...)rejects blanktorecipients before handoff so transports never receive an empty delivery target.EmailService.send(...)andEmailService.sendNotification(...)honor an already-abortedAbortSignalbefore template rendering or transport handoff.EmailService.send(...)preservesaccepted,pending, andrejectedrecipients separately so partial provider failures stay caller-visible.EmailService.sendMany(...)is fail-fast by default; passcontinueOnError: trueto collect failures in a batch result.EmailService.createPlatformStatusSnapshot()exposes lifecycle, readiness, health, and transport ownership details for diagnostics.- The service initializes the configured transport during module bootstrap and, when
verifyOnModuleInit: true, delivery waits until bootstrap verification has completed successfully before transport handoff. - Rejected
forRootAsync(...)option factories are not memoized permanently; the next provider resolution can retry configuration lookup. - Once shutdown starts,
EmailService.send(...)andEmailService.sendNotification(...)fail withEmailLifecycleErrorinstead of reusing or lazily creating transports; any in-flight factory-owned transport creation is awaited, active transportverify()/send()calls are drained, and then owned transports are closed by shutdown. - Transport
verify()andclose()provider errors are preserved as thecauseof lifecycle failures for diagnostics. - Module options are trimmed and normalized before provider wiring, including sender defaults, notification channel names, and transport factory ownership.
EmailModule.forRoot(...)andEmailModule.forRootAsync(...)are global by default. Useglobal: falseto opt into module-local visibility.EmailModule.forRootAsync(...)supportsinjectplususeFactoryonly; NestJSimports,useClass, anduseExistingregistration shapes must be resolved at the application module boundary before calling the factory.- The package never reads
process.envdirectly. All configuration must enter through explicit options or DI.
Integration with @fluojs/notifications
Inject EMAIL_CHANNEL into NotificationsModule.forRootAsync(...) so the email package remains the only place that understands email-specific payload fields and template rendering.
import { Module } from '@fluojs/core';
import { EmailModule, EMAIL_CHANNEL } from '@fluojs/email';
import { NotificationsModule } from '@fluojs/notifications';
@Module({
imports: [
EmailModule.forRoot({
defaultFrom: '[email protected]',
transport: {
kind: 'transactional-http',
create: () => transactionalTransport,
ownsResources: false,
},
}),
NotificationsModule.forRootAsync({
inject: [EMAIL_CHANNEL],
useFactory: (channel) => ({
channels: [channel],
}),
}),
],
})
export class AppModule {}Supported notification payload fields:
to,cc,bcc,from,replyTotext,html,attachments,headerstemplateDatawhen a renderer is configured on the module
Behavioral contract notes:
EmailChanneltreats zero accepted recipients (accepted.length === 0) or anypending/rejectedrecipients as a failed notification dispatch instead of reporting the delivery as successful.EmailService.sendNotification(...)merges rendered template output with payload and notification metadata; payload fields override notification fallbacks.- Template rendering receives notification
payload,metadata,locale,subject, andtemplate; payloadtext,html, and notificationsubjectoverride rendered fallbacks.
Queue-backed bulk delivery
When @fluojs/notifications should offload bulk email delivery to the background, import QueueModule, inject QueueLifecycleService, call createEmailNotificationsQueueAdapter(queue), and register EmailNotificationsQueueWorker as an application provider. The root EmailModule does not register the worker automatically, so applications that never import @fluojs/email/queue do not need @fluojs/queue at runtime.
import { Module } from '@fluojs/core';
import {
EmailModule,
EMAIL_CHANNEL,
} from '@fluojs/email';
import { createEmailNotificationsQueueAdapter, EmailNotificationsQueueWorker } from '@fluojs/email/queue';
import { NotificationsModule } from '@fluojs/notifications';
import { QueueLifecycleService, QueueModule } from '@fluojs/queue';
@Module({
imports: [
QueueModule.forRoot(),
EmailModule.forRoot({
defaultFrom: '[email protected]',
transport: {
kind: 'bulk-email-api',
create: () => bulkEmailTransport,
ownsResources: false,
},
}),
NotificationsModule.forRootAsync({
inject: [EMAIL_CHANNEL, QueueLifecycleService],
useFactory: (channel, queue) => ({
channels: [channel],
queue: {
adapter: createEmailNotificationsQueueAdapter(queue),
bulkThreshold: 25,
},
}),
}),
],
providers: [EmailNotificationsQueueWorker],
})
export class AppModule {}The built-in queue worker contract uses these defaults:
attempts: 3backoff: { type: 'exponential', delayMs: 1000 }concurrency: 5rateLimiter: { max: 50, duration: 1000 }jobName: 'fluo.email.notification'
These defaults are exported from @fluojs/email/queue as DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS so callers can document or mirror them when they build custom queue adapters/workers.
Behavioral contract notes:
- Queue support is opt-in. The root
@fluojs/emailentrypoint andEmailModuledo not import@fluojs/queue, registerEmailNotificationsQueueWorker, or require queue peer installation. EmailNotificationsQueueWorkeris exported from@fluojs/email/queueand must be registered by applications that enable queue-backed delivery.- The worker reuses
EmailChanneldelivery semantics, so a queued job fails when the underlying transport reports zero accepted recipients or anypending/rejectedrecipients. This lets@fluojs/queueretry and dead-letter incomplete deliveries instead of acknowledging them as successful jobs.
Intentional limitations
The email package intentionally does not:
- read transport credentials from
process.env - ship a built-in SMTP or Nodemailer transport in the shared root package
- configure
QueueModuleor register queue workers automatically - leak provider-specific option types into
@fluojs/notifications
These limitations are part of the package contract so transport selection, template strategy, and queue rollout stay explicit at the application boundary.
Public API Overview
Core
EmailModule.forRoot(options)/EmailModule.forRootAsync(options)EmailServiceEmailService.sendMany(messages, options)EmailService.sendNotification(notification, options)EmailService.createPlatformStatusSnapshot()EmailChannelEMAILEMAIL_CHANNEL
Contracts and helpers
Email: Application-facing sending facade exposed by theEMAILcompatibility token, not an address value; it providessend(...),sendMany(...), andsendNotification(...)methods backed byEmailService.EmailAddress/EmailAddressLike: Structured or shorthand recipient values accepted byEmailServicebefore normalization.EmailAttachment: File attachment payload accepted onEmailMessage.attachmentsand forwarded to the configured transport withfilename,content, and optionalcontentTypefields.EmailModuleOptions/EmailAsyncModuleOptions: Synchronous and async module registration contracts, including sender defaults, renderer, lifecycle verification, transport factory wiring, top-levelglobalvisibility control, and the asyncinject+useFactoryshape.EmailMessageEmailNotificationDispatchRequest/EmailNotificationPayload: Notification channel payload contracts consumed byEmailChannel.EmailSendOptions/EmailSendManyOptions: Per-send controls such as abort signals and batch failure collection.EmailSendResult/EmailSendBatchResult/EmailSendFailure: Direct and batch delivery result contracts that preserve accepted, pending, rejected, and failed messages.EmailTransportReceipt: Transport-level provider receipt preserved byEmailSendResult.EmailTransportEmailTransportContextEmailTransportFactoryEmailTemplateRenderInputEmailTemplateRendererEmailTemplateRenderResultNormalizedEmailAddressList/NormalizedEmailMessage: Internal-normalized message shapes exposed for typed integrations and tests.
Integration subpaths
@fluojs/email/queue:createEmailNotificationsQueueAdapter(queue),EmailNotificationQueueJob,EmailNotificationsQueueWorker,DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS,EmailQueueWorkerOptions
Status and errors
createEmailPlatformStatusSnapshot(...)EmailLifecycleStateEmailPlatformStatusSnapshotEmailStatusAdapterInputEmailConfigurationErrorEmailLifecycleError: thrown by lifecycle-gated delivery, transport initialization or verification, and owned-resource shutdown failures. Catch this error when sends can race with application teardown.EmailMessageValidationError
Node-only subpath
createNodemailerEmailTransport(...)createNodemailerEmailTransportFactory(...)NodemailerEmailTransportNodemailerTransporterNodemailerEmailTransportOptionsNodemailerEmailTransportFactoryOptions
Runtime-Specific and Integration Subpaths
| Runtime | Subpath | Exports |
| --- | --- | --- |
| Node.js | @fluojs/email/node | createNodemailerEmailTransport(...), createNodemailerEmailTransportFactory(...), NodemailerEmailTransport, NodemailerTransporter, NodemailerEmailTransportOptions, NodemailerEmailTransportFactoryOptions |
| Concern | Subpath | Exports |
| --- | --- | --- |
| Queue-backed notifications integration | @fluojs/email/queue | createEmailNotificationsQueueAdapter(queue), EmailNotificationQueueJob, EmailNotificationsQueueWorker, DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS, EmailQueueWorkerOptions |
Related Packages
@fluojs/notifications: Shared orchestration layer that consumesEMAIL_CHANNEL.@fluojs/queue: Recommended when bulk email delivery should run in the background.@fluojs/config: Recommended for resolving transport credentials and sender defaults without direct environment access.nodemailer: The Node-only SMTP implementation consumed by@fluojs/email/node.
Example Sources
packages/email/src/module.test.ts: Module registration, option normalization, async wiring, lifecycle, and queue-backed notifications examples.packages/email/src/public-surface.test.ts: Public export and TypeScript contract verification.packages/email/src/node/node.test.ts: Node-only Nodemailer adapter mapping and lifecycle examples.packages/email/src/status.test.ts: Health/readiness contract examples.
