@fluojs/email 1.0.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -11,9 +11,11 @@ fluo를 위한 transport-agnostic 이메일 코어 패키지입니다. Nest-like
11
11
  - [빠른 시작](#빠른-시작)
12
12
  - [일반적인 패턴](#일반적인-패턴)
13
13
  - [등록 범위와 async factory](#등록-범위와-async-factory)
14
+ - [NestJS mailer 마이그레이션](#nestjs-mailer-마이그레이션)
14
15
  - [`@fluojs/email/node`를 이용한 Node 전용 SMTP](#fluojs-email-node를-이용한-node-전용-smtp)
15
16
  - [`EmailService`를 이용한 standalone 전달](#emailservice를-이용한-standalone-전달)
16
17
  - [`@fluojs/notifications`와의 통합](#fluojs-notifications와의-통합)
18
+ - [Template renderer 설정](#template-renderer-설정)
17
19
  - [큐 기반 대량 전달](#큐-기반-대량-전달)
18
20
  - [의도적인 제한 사항](#의도적인-제한-사항)
19
21
  - [공개 API 개요](#공개-api-개요)
@@ -33,14 +35,16 @@ npm install @fluojs/email
33
35
  npm install @fluojs/notifications @fluojs/queue
34
36
  ```
35
37
 
36
- 명시적인 `@fluojs/email/node` 서브패스로 Node 전용 SMTP 전달을 사용할 때만 `nodemailer`를 설치하면 됩니다.
38
+ 명시적인 `@fluojs/email/node` 서브패스로 Node 전용 SMTP 전달을 사용할 때만 `nodemailer`와 `@types/nodemailer` 선언을 설치하면 됩니다.
37
39
 
38
40
  ```bash
39
- npm install @fluojs/email nodemailer
41
+ npm install @fluojs/email nodemailer@^9.0.1 @types/nodemailer@^8.0.0
40
42
  ```
41
43
 
42
44
  Node 전용 SMTP 전달은 명시적인 `@fluojs/email/node` 서브패스에서 사용할 수 있습니다. queue 기반 notifications 통합은 `@fluojs/email/queue` 서브패스에 있으며, 이 서브패스용 `@fluojs/queue`는 루트 설치 필수가 아닌 optional peer로 선언됩니다. 루트 `@fluojs/email` 엔트리포인트는 계속 transport-agnostic 상태를 유지하므로 Bun, Deno, Cloudflare, 커스텀 HTTP transport가 Node 전용 또는 queue 전용 동작을 함께 끌어오지 않습니다.
43
45
 
46
+ `@fluojs/email/node`는 Nodemailer `^9.0.1`을 요구합니다. Nodemailer 6, 7, 8을 사용하던 consumer는 이 major `@fluojs/email` release를 적용하기 전에 peer를 업그레이드하고 lockfile을 갱신해야 합니다. fluo transport factory API는 그대로지만, migration 시 provider별 SMTP option이 Nodemailer 9와 호환되는지 확인하세요.
47
+
44
48
  ## 사용 시점
45
49
 
46
50
  - 이메일을 직접 보내는 기능과 `@fluojs/notifications` 채널 연동을 한 패키지에서 처리하고 싶을 때.
@@ -128,6 +132,16 @@ EmailModule.forRootAsync({
128
132
 
129
133
  `global`은 factory result가 아니라 `forRootAsync(...)` options object의 최상위에 둡니다. 지원되는 async 등록 형태는 `inject`와 `useFactory`뿐입니다. NestJS dynamic-module 형태인 `imports`, `useClass`, `useExisting`는 `@fluojs/email` 계약에 포함되지 않습니다. 필요한 의존성은 주변 애플리케이션 module graph에 먼저 등록한 뒤, factory가 필요로 하는 token을 `inject`에 나열하세요.
130
134
 
135
+ ### NestJS mailer 마이그레이션
136
+
137
+ <!-- fluo-email-nestjs-migration: async=injected-factory->supported;async-negative=imports->unsupported,useClass->unsupported,useExisting->unsupported;ownership=portable->application,node-factory->email-module,nodemailer->caller;delivery=direct->pre-rendered,template->rendered;precedence=notification.subject->rendered.subject,payload.text->rendered.text,payload.html->rendered.html,payload.to->notification.recipients;api=EmailModule.forRootAsync,inject,useFactory,global: false,EmailTransport,createNodemailerEmailTransportFactory,createNodemailerEmailTransport,EmailService.send(...),EmailService.sendNotification(...),payload.templateData -->
138
+
139
+ 완전한 NestJS 마이그레이션 경로는 [마이그레이션 맵](../../docs/getting-started/migrate-from-nestjs.ko.md#이메일-transport-ownership-delivery-마이그레이션)에서 시작하세요. 명시적인 transport 경계 하나를 선택합니다. 애플리케이션이 소유한 이식 가능한 `EmailTransport` / `EmailTransportFactory`, `createNodemailerEmailTransportFactory(...)`로 만든 factory 소유 Node SMTP transport, 또는 기존 호출자 소유 Nodemailer transporter를 감싼 `createNodemailerEmailTransport({ transporter })`입니다. 기존 transporter wrapper는 shutdown ownership을 `EmailService`로 넘기지 않습니다.
140
+
141
+ Pre-rendered `MailerService.sendMail(...)` 호출은 `EmailService.send(...)`로 대체합니다. 이때 `EmailMessage`에는 delivery field만 넣고 template field는 넣지 않습니다. Template-backed delivery에는 template key와 renderer 전용 `payload.templateData`를 포함한 `EmailService.sendNotification(...)`을 호출하세요. Template과 module renderer가 모두 있을 때만 renderer가 실행되며, 결과는 fallback content이므로 notification `subject`와 payload `text` / `html`이 계속 우선합니다.
142
+
143
+ NestJS `imports`, `useClass`, `useExisting`, `MailerService` 호환, implicit transport discovery를 옮기지 마세요. 애플리케이션 module graph에서 의존성을 해석한 다음 module-local visibility가 필요할 때 `EmailModule.forRootAsync({ inject, useFactory, global: false })`를 사용하세요.
144
+
131
145
  ### `@fluojs/email/node`를 이용한 Node 전용 SMTP
132
146
 
133
147
  런타임 이식 가능한 루트 패키지 계약을 약화시키지 않으면서 1st-party Nodemailer/SMTP 전달이 필요하다면 전용 Node 서브패스를 사용합니다.
@@ -189,13 +203,14 @@ Behavioral contract 메모:
189
203
 
190
204
  - `EmailService.send(...)`는 전달 전에 `defaultFrom`과 `defaultReplyTo`를 해석합니다.
191
205
  - `EmailService.send(...)`는 빈 `to` 수신자를 transport handoff 전에 거부하므로 transport가 빈 전달 대상을 받지 않습니다.
206
+ - `EmailService.send(...)`는 lazy transport를 획득하기 전에 메시지를 normalize하고 검증하므로, 잘못된 입력이 transport 리소스를 초기화하지 않습니다.
192
207
  - `EmailService.send(...)`와 `EmailService.sendNotification(...)`은 이미 abort된 `AbortSignal`을 템플릿 렌더링 또는 transport handoff 전에 반영합니다.
193
208
  - `EmailService.send(...)`는 `accepted`, `pending`, `rejected` 수신자를 분리해 보존하므로 provider의 부분 실패가 호출자에게 그대로 보입니다.
194
209
  - `EmailService.sendMany(...)`는 기본적으로 fail-fast입니다. 실패를 batch result에 수집하려면 `continueOnError: true`를 전달합니다.
195
210
  - `EmailService.createPlatformStatusSnapshot()`은 diagnostics를 위해 lifecycle, readiness, health, transport ownership details를 노출합니다.
196
211
  - 서비스는 모듈 bootstrap 시 transport를 초기화하며, `verifyOnModuleInit: true`인 경우 bootstrap 검증이 성공적으로 끝날 때까지 delivery가 transport handoff로 진행되지 않습니다.
197
212
  - 거부된 `forRootAsync(...)` 옵션 factory 결과는 영구 memoize되지 않으며, 다음 provider resolution에서 configuration lookup을 다시 시도할 수 있습니다.
198
- - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다리고, 활성 transport `verify()` / `send()` 호출을 drain한 뒤 소유 transport를 닫습니다.
213
+ - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다리고, 활성 transport `verify()` / `send()` 호출을 drain한 뒤 소유 transport를 닫습니다. 동시에 또는 반복해서 shutdown을 호출하면 동일한 완료 Promise를 공유하므로 소유 transport는 최대 한 번만 닫힙니다.
199
214
  - transport `verify()`와 `close()`에서 발생한 provider error는 diagnostics를 위해 lifecycle failure의 `cause`로 보존됩니다.
200
215
  - 모듈 옵션은 provider wiring 전에 trim 및 normalize됩니다. 여기에는 sender 기본값, notification channel 이름, transport factory 소유권이 포함됩니다.
201
216
  - `EmailModule.forRoot(...)`와 `EmailModule.forRootAsync(...)`는 기본적으로 global입니다. module-local visibility가 필요할 때만 `global: false`를 사용합니다.
@@ -235,7 +250,7 @@ export class AppModule {}
235
250
  지원하는 notification payload 필드:
236
251
 
237
252
  - `to`, `cc`, `bcc`, `from`, `replyTo`
238
- - `text`, `html`, `attachments`, `headers`
253
+ - `text`, `html`, `attachments`, `headers`, `metadata`
239
254
  - 모듈에 renderer가 구성된 경우 `templateData`
240
255
 
241
256
  Behavioral contract 메모:
@@ -244,6 +259,90 @@ Behavioral contract 메모:
244
259
  - `EmailService.sendNotification(...)`은 렌더링된 template output을 payload 및 notification metadata와 병합합니다. payload 필드는 notification fallback보다 우선합니다.
245
260
  - Template rendering에는 notification `payload`, `metadata`, `locale`, `subject`, `template`이 전달되며, payload `text`, `html`과 notification `subject`가 렌더링된 fallback보다 우선합니다.
246
261
 
262
+ ### Template renderer 설정
263
+
264
+ `EmailModule`을 등록할 때 필수 transport와 함께 `EmailTemplateRenderer`를 전달합니다. 아래의 완전한 Node.js 예제는 1st-party SMTP factory를 사용합니다. 다른 런타임에서는 renderer를 그대로 두고 `transport`만 애플리케이션이 소유한 `EmailTransport` 또는 `EmailTransportFactory`로 교체하세요.
265
+
266
+ ```typescript
267
+ import { Module } from '@fluojs/core';
268
+ import { EmailModule, type EmailTemplateRenderer } from '@fluojs/email';
269
+ import { createNodemailerEmailTransportFactory } from '@fluojs/email/node';
270
+
271
+ const renderer: EmailTemplateRenderer = {
272
+ render({ payload, template }) {
273
+ const name = payload.templateData?.name;
274
+ const displayName = typeof name === 'string' ? name : 'customer';
275
+
276
+ return {
277
+ html: '<h1>Welcome</h1>',
278
+ subject: template === 'welcome' ? `Welcome, ${displayName}` : template,
279
+ text: `Hello, ${displayName}`,
280
+ };
281
+ },
282
+ };
283
+
284
+ @Module({
285
+ imports: [
286
+ EmailModule.forRoot({
287
+ defaultFrom: 'noreply@example.com',
288
+ renderer,
289
+ transport: createNodemailerEmailTransportFactory({
290
+ smtp: {
291
+ auth: {
292
+ pass: 'smtp-password',
293
+ user: 'smtp-user',
294
+ },
295
+ host: 'smtp.example.com',
296
+ port: 587,
297
+ secure: false,
298
+ },
299
+ }),
300
+ verifyOnModuleInit: true,
301
+ }),
302
+ ],
303
+ })
304
+ export class AppModule {}
305
+ ```
306
+
307
+ `EmailService.sendNotification(...)`을 호출할 때 template key를 전달하고 renderer 전용 값은 `payload.templateData` 아래에 둡니다. 위에서 설명한 대로 `EMAIL_CHANNEL`을 등록한 뒤에는 같은 request shape를 `NotificationsService.dispatch(...)`에서도 사용할 수 있습니다.
308
+
309
+ ```typescript
310
+ import { Inject } from '@fluojs/core';
311
+ import { EmailService } from '@fluojs/email';
312
+
313
+ @Inject(EmailService)
314
+ export class WelcomeEmailService {
315
+ constructor(private readonly email: EmailService) {}
316
+
317
+ async sendRenderedWelcome(address: string, name: string) {
318
+ await this.email.sendNotification({
319
+ channel: 'email',
320
+ recipients: [address],
321
+ template: 'welcome',
322
+ payload: {
323
+ templateData: { name },
324
+ },
325
+ });
326
+ }
327
+
328
+ async sendWelcomeWithOverrides(address: string, name: string) {
329
+ await this.email.sendNotification({
330
+ channel: 'email',
331
+ recipients: [address],
332
+ subject: 'Your account is ready',
333
+ template: 'welcome',
334
+ payload: {
335
+ html: '<p>Your account is ready.</p>',
336
+ templateData: { name },
337
+ text: 'Use this exact welcome message.',
338
+ },
339
+ });
340
+ }
341
+ }
342
+ ```
343
+
344
+ `template`과 `renderer`가 모두 있을 때만 renderer가 실행됩니다. Renderer가 반환하는 `subject`, `text`, `html`은 fallback입니다. 명시적인 notification `subject`는 렌더링된 subject보다 우선하고, 명시적인 `payload.text`와 `payload.html`은 렌더링된 body보다 우선합니다. `payload.to`도 notification `recipients` fallback보다 우선합니다. `templateData`는 opaque payload 안에 그대로 남아 renderer에서 `payload.templateData`로 사용할 수 있으며, email 패키지는 내부 key를 해석하지 않습니다.
345
+
247
346
  ### 큐 기반 대량 전달
248
347
 
249
348
  `@fluojs/notifications`가 대량 이메일 전달을 백그라운드로 넘겨야 한다면 `QueueModule`을 import하고, `QueueLifecycleService`를 주입해 `createEmailNotificationsQueueAdapter(queue)`를 만든 뒤, `EmailNotificationsQueueWorker`를 애플리케이션 provider로 등록합니다. 루트 `EmailModule`은 worker를 자동 등록하지 않으므로 `@fluojs/email/queue`를 import하지 않는 애플리케이션은 런타임에서 `@fluojs/queue`를 필요로 하지 않습니다.
@@ -299,6 +398,9 @@ Behavioral contract 메모:
299
398
 
300
399
  - Queue 지원은 opt-in입니다. 루트 `@fluojs/email` 엔트리포인트와 `EmailModule`은 `@fluojs/queue`를 import하거나 `EmailNotificationsQueueWorker`를 등록하거나 queue peer 설치를 요구하지 않습니다.
301
400
  - `EmailNotificationsQueueWorker`는 `@fluojs/email/queue`에서 export되며, queue 기반 전달을 활성화하는 애플리케이션이 직접 등록해야 합니다.
401
+ - 내장 adapter는 결정적인 `NotificationsQueueJob.id`를 queued email payload에 보존하고 Queue의 `deduplicationKey`로 전달합니다. Queue가 이를 BullMQ에 유효한 job id로 매핑하므로 BullMQ가 반복 notification dispatch 시도를 deduplicate할 수 있습니다.
402
+ - Notification bulk adapter는 parallel single-job enqueue 대신 Queue의 atomic `enqueueMany(...)` seam에 위임하고, 각 notification ID를 해당 entry의 `deduplicationKey`로 유지합니다.
403
+ - worker는 transport handoff 전에 queued notification channel이 구성된 `EmailChannel.channel`과 정확히 일치하는지 확인합니다. 일치하지 않으면 `EmailMessageValidationError`로 실패하므로 non-email 작업이 email transport에 도달하지 않습니다.
302
404
  - worker는 `EmailChannel` 전달 semantics를 재사용하므로 transport가 수락된 수신자 0명 또는 `pending`/`rejected` 수신자를 보고하면 queued job이 실패합니다. 따라서 incomplete delivery는 성공한 job으로 승인되지 않고 `@fluojs/queue`의 retry/dead-letter 흐름으로 넘어갑니다.
303
405
 
304
406
  ### 의도적인 제한 사항
@@ -382,7 +484,7 @@ email 패키지는 의도적으로 다음을 **포함하지 않습니다**:
382
484
  - `@fluojs/notifications`: `EMAIL_CHANNEL`을 소비하는 공통 오케스트레이션 계층입니다.
383
485
  - `@fluojs/queue`: 대량 이메일 전달을 백그라운드에서 처리하려는 경우 권장됩니다.
384
486
  - `@fluojs/config`: 환경 직접 접근 없이 transport 자격 증명과 sender 기본값을 해석하려는 경우 권장됩니다.
385
- - `nodemailer`: `@fluojs/email/node`가 소비하는 Node 전용 SMTP 구현체입니다.
487
+ - `nodemailer`와 `@types/nodemailer`: `@fluojs/email/node`가 소비하는 Node 전용 SMTP 구현체와 선언입니다.
386
488
 
387
489
  ## 예제 소스
388
490
 
package/README.md CHANGED
@@ -11,9 +11,11 @@ Transport-agnostic email delivery core for fluo. It provides a Nest-like module
11
11
  - [Quick Start](#quick-start)
12
12
  - [Common Patterns](#common-patterns)
13
13
  - [Registration scope and async factories](#registration-scope-and-async-factories)
14
+ - [NestJS mailer migration](#nestjs-mailer-migration)
14
15
  - [Node-only SMTP with `@fluojs/email/node`](#node-only-smtp-with-fluojs-email-node)
15
16
  - [Standalone delivery with `EmailService`](#standalone-delivery-with-emailservice)
16
17
  - [Integration with `@fluojs/notifications`](#integration-with-fluojs-notifications)
18
+ - [Template renderer setup](#template-renderer-setup)
17
19
  - [Queue-backed bulk delivery](#queue-backed-bulk-delivery)
18
20
  - [Intentional limitations](#intentional-limitations)
19
21
  - [Public API Overview](#public-api-overview)
@@ -33,14 +35,16 @@ Install `@fluojs/notifications` and `@fluojs/queue` only when you want the built
33
35
  npm install @fluojs/notifications @fluojs/queue
34
36
  ```
35
37
 
36
- Install `nodemailer` only when you use the explicit `@fluojs/email/node` subpath for Node-only SMTP delivery.
38
+ Install `nodemailer` and its `@types/nodemailer` declarations only when you use the explicit `@fluojs/email/node` subpath for Node-only SMTP delivery.
37
39
 
38
40
  ```bash
39
- npm install @fluojs/email nodemailer
41
+ npm install @fluojs/email nodemailer@^9.0.1 @types/nodemailer@^8.0.0
40
42
  ```
41
43
 
42
44
  Node-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.
43
45
 
46
+ `@fluojs/email/node` requires Nodemailer `^9.0.1`. Consumers upgrading from Nodemailer 6, 7, or 8 must upgrade the peer and refresh their lockfile before adopting this major `@fluojs/email` release. The fluo transport factory API is unchanged, but applications should validate provider-specific SMTP options against Nodemailer 9 when migrating.
47
+
44
48
  ## When to Use
45
49
 
46
50
  - When you want one package that can send email directly and also plug into `@fluojs/notifications`.
@@ -128,6 +132,16 @@ EmailModule.forRootAsync({
128
132
 
129
133
  `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`.
130
134
 
135
+ ### NestJS mailer migration
136
+
137
+ <!-- fluo-email-nestjs-migration: async=injected-factory->supported;async-negative=imports->unsupported,useClass->unsupported,useExisting->unsupported;ownership=portable->application,node-factory->email-module,nodemailer->caller;delivery=direct->pre-rendered,template->rendered;precedence=notification.subject->rendered.subject,payload.text->rendered.text,payload.html->rendered.html,payload.to->notification.recipients;api=EmailModule.forRootAsync,inject,useFactory,global: false,EmailTransport,createNodemailerEmailTransportFactory,createNodemailerEmailTransport,EmailService.send(...),EmailService.sendNotification(...),payload.templateData -->
138
+
139
+ For the complete NestJS migration path, start with the [migration map](../../docs/getting-started/migrate-from-nestjs.md#email-transport-ownership-and-delivery-migration). Choose one explicit transport boundary: an application-owned portable `EmailTransport` / `EmailTransportFactory`, the factory-owned Node SMTP transport created by `createNodemailerEmailTransportFactory(...)`, or `createNodemailerEmailTransport({ transporter })` around an existing caller-owned Nodemailer transporter. The existing-transporter wrapper does not transfer shutdown ownership to `EmailService`.
140
+
141
+ Replace a pre-rendered `MailerService.sendMail(...)` call with `EmailService.send(...)`; its `EmailMessage` carries delivery fields, not template fields. For template-backed delivery, call `EmailService.sendNotification(...)` with a template key and renderer-specific `payload.templateData`. The renderer runs only when both `template` and a module renderer are present; its output is fallback content, so notification `subject` and payload `text` / `html` remain authoritative.
142
+
143
+ Do not migrate NestJS `imports`, `useClass`, `useExisting`, `MailerService` compatibility, or implicit transport discovery. Resolve dependencies in the application module graph, then use `EmailModule.forRootAsync({ inject, useFactory, global: false })` when opting into module-local visibility.
144
+
131
145
  ### Node-only SMTP with `@fluojs/email/node`
132
146
 
133
147
  Use the dedicated Node subpath when you want first-party Nodemailer/SMTP delivery without weakening the runtime-portable root package contract.
@@ -189,13 +203,14 @@ Behavioral contract notes:
189
203
 
190
204
  - `EmailService.send(...)` resolves `defaultFrom` and `defaultReplyTo` before delivery.
191
205
  - `EmailService.send(...)` rejects blank `to` recipients before handoff so transports never receive an empty delivery target.
206
+ - `EmailService.send(...)` normalizes and validates messages before acquiring a lazy transport, so invalid input does not initialize transport resources.
192
207
  - `EmailService.send(...)` and `EmailService.sendNotification(...)` honor an already-aborted `AbortSignal` before template rendering or transport handoff.
193
208
  - `EmailService.send(...)` preserves `accepted`, `pending`, and `rejected` recipients separately so partial provider failures stay caller-visible.
194
209
  - `EmailService.sendMany(...)` is fail-fast by default; pass `continueOnError: true` to collect failures in a batch result.
195
210
  - `EmailService.createPlatformStatusSnapshot()` exposes lifecycle, readiness, health, and transport ownership details for diagnostics.
196
211
  - The service initializes the configured transport during module bootstrap and, when `verifyOnModuleInit: true`, delivery waits until bootstrap verification has completed successfully before transport handoff.
197
212
  - Rejected `forRootAsync(...)` option factories are not memoized permanently; the next provider resolution can retry configuration lookup.
198
- - Once shutdown starts, `EmailService.send(...)` and `EmailService.sendNotification(...)` fail with `EmailLifecycleError` instead of reusing or lazily creating transports; any in-flight factory-owned transport creation is awaited, active transport `verify()` / `send()` calls are drained, and then owned transports are closed by shutdown.
213
+ - Once shutdown starts, `EmailService.send(...)` and `EmailService.sendNotification(...)` fail with `EmailLifecycleError` instead of reusing or lazily creating transports; any in-flight factory-owned transport creation is awaited, active transport `verify()` / `send()` calls are drained, and then owned transports are closed by shutdown. Concurrent and repeated shutdown calls share the same completion promise, so an owned transport is closed at most once.
199
214
  - Transport `verify()` and `close()` provider errors are preserved as the `cause` of lifecycle failures for diagnostics.
200
215
  - Module options are trimmed and normalized before provider wiring, including sender defaults, notification channel names, and transport factory ownership.
201
216
  - `EmailModule.forRoot(...)` and `EmailModule.forRootAsync(...)` are global by default. Use `global: false` to opt into module-local visibility.
@@ -235,7 +250,7 @@ export class AppModule {}
235
250
  Supported notification payload fields:
236
251
 
237
252
  - `to`, `cc`, `bcc`, `from`, `replyTo`
238
- - `text`, `html`, `attachments`, `headers`
253
+ - `text`, `html`, `attachments`, `headers`, `metadata`
239
254
  - `templateData` when a renderer is configured on the module
240
255
 
241
256
  Behavioral contract notes:
@@ -244,6 +259,90 @@ Behavioral contract notes:
244
259
  - `EmailService.sendNotification(...)` merges rendered template output with payload and notification metadata; payload fields override notification fallbacks.
245
260
  - Template rendering receives notification `payload`, `metadata`, `locale`, `subject`, and `template`; payload `text`, `html`, and notification `subject` override rendered fallbacks.
246
261
 
262
+ ### Template renderer setup
263
+
264
+ Pass an `EmailTemplateRenderer` together with the required transport when registering `EmailModule`. This complete Node.js example uses the first-party SMTP factory; on another runtime, keep the renderer and replace only `transport` with an application-owned `EmailTransport` or `EmailTransportFactory`.
265
+
266
+ ```typescript
267
+ import { Module } from '@fluojs/core';
268
+ import { EmailModule, type EmailTemplateRenderer } from '@fluojs/email';
269
+ import { createNodemailerEmailTransportFactory } from '@fluojs/email/node';
270
+
271
+ const renderer: EmailTemplateRenderer = {
272
+ render({ payload, template }) {
273
+ const name = payload.templateData?.name;
274
+ const displayName = typeof name === 'string' ? name : 'customer';
275
+
276
+ return {
277
+ html: '<h1>Welcome</h1>',
278
+ subject: template === 'welcome' ? `Welcome, ${displayName}` : template,
279
+ text: `Hello, ${displayName}`,
280
+ };
281
+ },
282
+ };
283
+
284
+ @Module({
285
+ imports: [
286
+ EmailModule.forRoot({
287
+ defaultFrom: 'noreply@example.com',
288
+ renderer,
289
+ transport: createNodemailerEmailTransportFactory({
290
+ smtp: {
291
+ auth: {
292
+ pass: 'smtp-password',
293
+ user: 'smtp-user',
294
+ },
295
+ host: 'smtp.example.com',
296
+ port: 587,
297
+ secure: false,
298
+ },
299
+ }),
300
+ verifyOnModuleInit: true,
301
+ }),
302
+ ],
303
+ })
304
+ export class AppModule {}
305
+ ```
306
+
307
+ Call `EmailService.sendNotification(...)` with a template key and put renderer-specific values under `payload.templateData`. The same request shape works through `NotificationsService.dispatch(...)` after registering `EMAIL_CHANNEL` as shown above.
308
+
309
+ ```typescript
310
+ import { Inject } from '@fluojs/core';
311
+ import { EmailService } from '@fluojs/email';
312
+
313
+ @Inject(EmailService)
314
+ export class WelcomeEmailService {
315
+ constructor(private readonly email: EmailService) {}
316
+
317
+ async sendRenderedWelcome(address: string, name: string) {
318
+ await this.email.sendNotification({
319
+ channel: 'email',
320
+ recipients: [address],
321
+ template: 'welcome',
322
+ payload: {
323
+ templateData: { name },
324
+ },
325
+ });
326
+ }
327
+
328
+ async sendWelcomeWithOverrides(address: string, name: string) {
329
+ await this.email.sendNotification({
330
+ channel: 'email',
331
+ recipients: [address],
332
+ subject: 'Your account is ready',
333
+ template: 'welcome',
334
+ payload: {
335
+ html: '<p>Your account is ready.</p>',
336
+ templateData: { name },
337
+ text: 'Use this exact welcome message.',
338
+ },
339
+ });
340
+ }
341
+ }
342
+ ```
343
+
344
+ The renderer runs only when both `template` and `renderer` are present. Its `subject`, `text`, and `html` are fallbacks: an explicit notification `subject` overrides the rendered subject, while explicit `payload.text` and `payload.html` override rendered bodies. `payload.to` also overrides the notification `recipients` fallback. `templateData` remains inside the opaque payload and is available to the renderer as `payload.templateData`; the email package does not interpret its keys.
345
+
247
346
  ### Queue-backed bulk delivery
248
347
 
249
348
  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.
@@ -299,6 +398,9 @@ Behavioral contract notes:
299
398
 
300
399
  - Queue support is opt-in. The root `@fluojs/email` entrypoint and `EmailModule` do not import `@fluojs/queue`, register `EmailNotificationsQueueWorker`, or require queue peer installation.
301
400
  - `EmailNotificationsQueueWorker` is exported from `@fluojs/email/queue` and must be registered by applications that enable queue-backed delivery.
401
+ - The built-in adapter preserves each deterministic `NotificationsQueueJob.id` in the queued email payload and passes it to Queue as `deduplicationKey`; Queue maps it to a BullMQ-safe job id so BullMQ can deduplicate repeated notification dispatch attempts.
402
+ - Its notification bulk adapter delegates to Queue's atomic `enqueueMany(...)` seam rather than issuing parallel single-job enqueues, retaining each notification ID as its corresponding entry's `deduplicationKey`.
403
+ - Before transport handoff, the worker requires the queued notification channel to exactly match the configured `EmailChannel.channel`. A mismatch fails with `EmailMessageValidationError`, so non-email work cannot reach the email transport.
302
404
  - The worker reuses `EmailChannel` delivery semantics, so a queued job fails when the underlying transport reports zero accepted recipients or any `pending`/`rejected` recipients. This lets `@fluojs/queue` retry and dead-letter incomplete deliveries instead of acknowledging them as successful jobs.
303
405
 
304
406
  ### Intentional limitations
@@ -382,7 +484,7 @@ These limitations are part of the package contract so transport selection, templ
382
484
  - `@fluojs/notifications`: Shared orchestration layer that consumes `EMAIL_CHANNEL`.
383
485
  - `@fluojs/queue`: Recommended when bulk email delivery should run in the background.
384
486
  - `@fluojs/config`: Recommended for resolving transport credentials and sender defaults without direct environment access.
385
- - `nodemailer`: The Node-only SMTP implementation consumed by `@fluojs/email/node`.
487
+ - `nodemailer` and `@types/nodemailer`: The Node-only SMTP implementation and declarations consumed by `@fluojs/email/node`.
386
488
 
387
489
  ## Example Sources
388
490
 
package/dist/errors.d.ts CHANGED
@@ -11,7 +11,7 @@ export declare class EmailMessageValidationError extends Error {
11
11
  constructor(message: string);
12
12
  }
13
13
  /**
14
- * Thrown when email delivery is requested after the service lifecycle has started shutting down.
14
+ * Thrown when email transport initialization or shutdown fails, or delivery is requested outside an active service lifecycle.
15
15
  */
16
16
  export declare class EmailLifecycleError extends Error {
17
17
  constructor(message: string, options?: ErrorOptions);
package/dist/errors.js CHANGED
@@ -19,7 +19,7 @@ export class EmailMessageValidationError extends Error {
19
19
  }
20
20
 
21
21
  /**
22
- * Thrown when email delivery is requested after the service lifecycle has started shutting down.
22
+ * Thrown when email transport initialization or shutdown fails, or delivery is requested outside an active service lifecycle.
23
23
  */
24
24
  export class EmailLifecycleError extends Error {
25
25
  constructor(message, options) {
package/dist/module.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare class EmailModule {
6
6
  * Registers email providers using static options.
7
7
  *
8
8
  * @param options Static email module options including transport wiring and optional template rendering behavior.
9
- * @returns A global module definition that exports {@link EmailService}, {@link EmailChannel}, and email facade tokens.
9
+ * @returns A module definition that exports {@link EmailService}, {@link EmailChannel}, and email facade tokens globally by default or only to explicit importers when `options.global` is `false`.
10
10
  *
11
11
  * @example
12
12
  * ```ts
@@ -24,7 +24,7 @@ export declare class EmailModule {
24
24
  * Registers email providers from an async DI factory.
25
25
  *
26
26
  * @param options Async module options that resolve email transport and renderer configuration through DI.
27
- * @returns A global module definition that memoizes async option resolution per module instance.
27
+ * @returns A module definition that memoizes async option resolution per module instance and exports its providers globally by default or only to explicit importers when `options.global` is `false`.
28
28
  *
29
29
  * @example
30
30
  * ```ts
package/dist/module.js CHANGED
@@ -110,7 +110,7 @@ export class EmailModule {
110
110
  * Registers email providers using static options.
111
111
  *
112
112
  * @param options Static email module options including transport wiring and optional template rendering behavior.
113
- * @returns A global module definition that exports {@link EmailService}, {@link EmailChannel}, and email facade tokens.
113
+ * @returns A module definition that exports {@link EmailService}, {@link EmailChannel}, and email facade tokens globally by default or only to explicit importers when `options.global` is `false`.
114
114
  *
115
115
  * @example
116
116
  * ```ts
@@ -131,7 +131,7 @@ export class EmailModule {
131
131
  * Registers email providers from an async DI factory.
132
132
  *
133
133
  * @param options Async module options that resolve email transport and renderer configuration through DI.
134
- * @returns A global module definition that memoizes async option resolution per module instance.
134
+ * @returns A module definition that memoizes async option resolution per module instance and exports its providers globally by default or only to explicit importers when `options.global` is `false`.
135
135
  *
136
136
  * @example
137
137
  * ```ts
@@ -1 +1 @@
1
- {"version":3,"file":"nodemailer.d.ts","sourceRoot":"","sources":["../../src/node/nodemailer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,IAAI,MAAM,uBAAuB,CAAC;AAC9C,OAAO,KAAK,aAAa,MAAM,+BAA+B,CAAC;AAE/D,OAAO,KAAK,EAEV,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AAGrB,4FAA4F;AAC5F,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,+BAA+B;IAC9C,iFAAiF;IACjF,WAAW,EAAE,qBAAqB,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,sCAAsC;IACrD,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd,iGAAiG;IACjG,IAAI,EAAE,aAAa,CAAC,OAAO,GAAG,MAAM,CAAC;CACtC;AAiED;;;;;;GAMG;AACH,qBAAa,wBAAyB,YAAW,cAAc;IACjD,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAAX,WAAW,EAAE,qBAAqB;IAE/D;;;;;;;;;;;;OAYG;IACG,IAAI,CAAC,OAAO,EAAE,sBAAsB,EAAE,QAAQ,EAAE,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAa5G;;;;OAIG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B;;;;OAIG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,+BAA+B,GAAG,cAAc,CAEvG;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,qCAAqC,CACnD,OAAO,EAAE,sCAAsC,GAC9C,qBAAqB,CAQvB"}
1
+ {"version":3,"file":"nodemailer.d.ts","sourceRoot":"","sources":["../../src/node/nodemailer.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,IAAI,MAAM,uBAAuB,CAAC;AAC9C,OAAO,KAAK,aAAa,MAAM,+BAA+B,CAAC;AAE/D,OAAO,KAAK,EAEV,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AAGrB,4FAA4F;AAC5F,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,aAAa,CAAC,eAAe,CAAC,CAAC;AAExE;;;;;;;GAOG;AACH,MAAM,WAAW,+BAA+B;IAC9C,iFAAiF;IACjF,WAAW,EAAE,qBAAqB,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,sCAAsC;IACrD,iFAAiF;IACjF,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd,iGAAiG;IACjG,IAAI,EAAE,aAAa,CAAC,OAAO,GAAG,MAAM,CAAC;CACtC;AA6DD;;;;;;GAMG;AACH,qBAAa,wBAAyB,YAAW,cAAc;IACjD,OAAO,CAAC,QAAQ,CAAC,WAAW;gBAAX,WAAW,EAAE,qBAAqB;IAE/D;;;;;;;;;;;;OAYG;IACG,IAAI,CAAC,OAAO,EAAE,sBAAsB,EAAE,QAAQ,EAAE,qBAAqB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAa5G;;;;OAIG;IACG,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B;;;;OAIG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,+BAA+B,GAAG,cAAc,CAEvG;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,qCAAqC,CACnD,OAAO,EAAE,sCAAsC,GAC9C,qBAAqB,CAQvB"}
@@ -69,10 +69,7 @@ function createSendMailOptions(message) {
69
69
  };
70
70
  }
71
71
  function normalizeAddressList(value) {
72
- if (!Array.isArray(value)) {
73
- return [];
74
- }
75
- return value.map(entry => String(entry));
72
+ return value?.map(entry => typeof entry === 'string' ? entry : entry.address) ?? [];
76
73
  }
77
74
 
78
75
  /**
package/dist/queue.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { NotificationsQueueAdapter } from '@fluojs/notifications';
2
- import { type QueueLifecycleService, type QueueWorkerOptions } from '@fluojs/queue';
2
+ import { type Queue, type QueueWorkerOptions } from '@fluojs/queue';
3
3
  import { EmailChannel } from './channel.js';
4
4
  import type { EmailNotificationDispatchRequest } from './types.js';
5
5
  /** Queue worker execution defaults used by the built-in notifications queue integration. */
@@ -8,18 +8,20 @@ export type EmailQueueWorkerOptions = QueueWorkerOptions;
8
8
  export declare class EmailNotificationQueueJob {
9
9
  readonly notification: EmailNotificationDispatchRequest;
10
10
  readonly queuedAt: string;
11
+ readonly id?: string | undefined;
11
12
  /**
12
13
  * Creates one queued email notification job payload.
13
14
  *
14
15
  * @param notification Notification envelope that will be delivered by the email channel worker.
15
16
  * @param queuedAt ISO timestamp captured when the notifications foundation delegated the job.
17
+ * @param id Deterministic notification identity preserved for queue deduplication.
16
18
  */
17
- constructor(notification: EmailNotificationDispatchRequest, queuedAt: string);
19
+ constructor(notification: EmailNotificationDispatchRequest, queuedAt: string, id?: string | undefined);
18
20
  }
19
21
  /**
20
- * Creates a notifications queue adapter backed by {@link QueueLifecycleService}.
22
+ * Creates a notifications queue adapter backed by the public {@link Queue} facade.
21
23
  *
22
- * @param queue Queue lifecycle service used to enqueue email notification jobs.
24
+ * @param queue Queue facade used to enqueue email notification jobs.
23
25
  * @returns A queue adapter compatible with `NotificationsModule.forRoot(...)` queue wiring.
24
26
  *
25
27
  * @example
@@ -36,7 +38,7 @@ export declare class EmailNotificationQueueJob {
36
38
  * });
37
39
  * ```
38
40
  */
39
- export declare function createEmailNotificationsQueueAdapter(queue: QueueLifecycleService): NotificationsQueueAdapter;
41
+ export declare function createEmailNotificationsQueueAdapter(queue: Queue): NotificationsQueueAdapter;
40
42
  /** Queue worker that converts queued notification jobs back into email delivery. */
41
43
  export declare class EmailNotificationsQueueWorker {
42
44
  private readonly channel;
@@ -46,6 +48,7 @@ export declare class EmailNotificationsQueueWorker {
46
48
  *
47
49
  * @param job Queued notification payload created by `createEmailNotificationsQueueAdapter(...)`.
48
50
  * @returns A promise that resolves only when email delivery is accepted by the channel contract.
51
+ * @throws {EmailMessageValidationError} When the queued notification targets another configured channel.
49
52
  */
50
53
  handle(job: EmailNotificationQueueJob): Promise<void>;
51
54
  }
@@ -1 +1 @@
1
- {"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,yBAAyB,EAAyB,MAAM,uBAAuB,CAAC;AAC9F,OAAO,EAAe,KAAK,qBAAqB,EAAE,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGjG,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,gCAAgC,EAAE,MAAM,YAAY,CAAC;AAEnE,4FAA4F;AAC5F,MAAM,MAAM,uBAAuB,GAAG,kBAAkB,CAAC;AAEzD,iFAAiF;AACjF,qBAAa,yBAAyB;aAQlB,YAAY,EAAE,gCAAgC;aAC9C,QAAQ,EAAE,MAAM;IARlC;;;;;OAKG;gBAEe,YAAY,EAAE,gCAAgC,EAC9C,QAAQ,EAAE,MAAM;CAEnC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,oCAAoC,CAAC,KAAK,EAAE,qBAAqB,GAAG,yBAAyB,CAW5G;AAED,oFAAoF;AACpF,qBAEa,6BAA6B;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,YAAY;IAElD;;;;;OAKG;IACG,MAAM,CAAC,GAAG,EAAE,yBAAyB,GAAG,OAAO,CAAC,IAAI,CAAC;CAG5D"}
1
+ {"version":3,"file":"queue.d.ts","sourceRoot":"","sources":["../src/queue.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,yBAAyB,EAAyB,MAAM,uBAAuB,CAAC;AAC9F,OAAO,EAAe,KAAK,KAAK,EAAE,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAGjF,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,OAAO,KAAK,EAAE,gCAAgC,EAAE,MAAM,YAAY,CAAC;AAEnE,4FAA4F;AAC5F,MAAM,MAAM,uBAAuB,GAAG,kBAAkB,CAAC;AAEzD,iFAAiF;AACjF,qBAAa,yBAAyB;aASlB,YAAY,EAAE,gCAAgC;aAC9C,QAAQ,EAAE,MAAM;aAChB,EAAE,CAAC,EAAE,MAAM;IAV7B;;;;;;OAMG;gBAEe,YAAY,EAAE,gCAAgC,EAC9C,QAAQ,EAAE,MAAM,EAChB,EAAE,CAAC,EAAE,MAAM,YAAA;CAE9B;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,oCAAoC,CAAC,KAAK,EAAE,KAAK,GAAG,yBAAyB,CAkB5F;AAED,oFAAoF;AACpF,qBAEa,6BAA6B;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,YAAY;IAElD;;;;;;OAMG;IACG,MAAM,CAAC,GAAG,EAAE,yBAAyB,GAAG,OAAO,CAAC,IAAI,CAAC;CAW5D"}
package/dist/queue.js CHANGED
@@ -8,6 +8,7 @@ import { Inject } from '@fluojs/core';
8
8
  import { QueueWorker } from '@fluojs/queue';
9
9
  import { DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS } from './constants.js';
10
10
  import { EmailChannel } from './channel.js';
11
+ import { EmailMessageValidationError } from './errors.js';
11
12
 
12
13
  /** Queue worker execution defaults used by the built-in notifications queue integration. */
13
14
 
@@ -18,17 +19,19 @@ export class EmailNotificationQueueJob {
18
19
  *
19
20
  * @param notification Notification envelope that will be delivered by the email channel worker.
20
21
  * @param queuedAt ISO timestamp captured when the notifications foundation delegated the job.
22
+ * @param id Deterministic notification identity preserved for queue deduplication.
21
23
  */
22
- constructor(notification, queuedAt) {
24
+ constructor(notification, queuedAt, id) {
23
25
  this.notification = notification;
24
26
  this.queuedAt = queuedAt;
27
+ this.id = id;
25
28
  }
26
29
  }
27
30
 
28
31
  /**
29
- * Creates a notifications queue adapter backed by {@link QueueLifecycleService}.
32
+ * Creates a notifications queue adapter backed by the public {@link Queue} facade.
30
33
  *
31
- * @param queue Queue lifecycle service used to enqueue email notification jobs.
34
+ * @param queue Queue facade used to enqueue email notification jobs.
32
35
  * @returns A queue adapter compatible with `NotificationsModule.forRoot(...)` queue wiring.
33
36
  *
34
37
  * @example
@@ -48,10 +51,17 @@ export class EmailNotificationQueueJob {
48
51
  export function createEmailNotificationsQueueAdapter(queue) {
49
52
  return {
50
53
  enqueue(job) {
51
- return queue.enqueue(new EmailNotificationQueueJob(job.notification, job.queuedAt));
54
+ return queue.enqueue(new EmailNotificationQueueJob(job.notification, job.queuedAt, job.id), {
55
+ deduplicationKey: job.id
56
+ });
52
57
  },
53
58
  enqueueMany(jobs) {
54
- return Promise.all(jobs.map(job => queue.enqueue(new EmailNotificationQueueJob(job.notification, job.queuedAt))));
59
+ return queue.enqueueMany(jobs.map(job => ({
60
+ job: new EmailNotificationQueueJob(job.notification, job.queuedAt, job.id),
61
+ options: {
62
+ deduplicationKey: job.id
63
+ }
64
+ })));
55
65
  }
56
66
  };
57
67
  }
@@ -71,8 +81,13 @@ class EmailNotificationsQueueWorker {
71
81
  *
72
82
  * @param job Queued notification payload created by `createEmailNotificationsQueueAdapter(...)`.
73
83
  * @returns A promise that resolves only when email delivery is accepted by the channel contract.
84
+ * @throws {EmailMessageValidationError} When the queued notification targets another configured channel.
74
85
  */
75
86
  async handle(job) {
87
+ const expectedChannel = this.channel.channel;
88
+ if (job.notification.channel !== expectedChannel) {
89
+ throw new EmailMessageValidationError(`Queued notification channel "${job.notification.channel}" does not match configured email channel "${expectedChannel}".`);
90
+ }
76
91
  await this.channel.send(job.notification, {});
77
92
  }
78
93
  static {
package/dist/service.d.ts CHANGED
@@ -12,11 +12,14 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
12
12
  private readonly options;
13
13
  private lifecycleState;
14
14
  private bootstrapPromise;
15
+ private shutdownPromise;
16
+ private readonly ownedTransportCleanupPromises;
15
17
  private readonly inFlightOperations;
16
18
  private resolvedTransport;
17
19
  private transportPromise;
18
20
  constructor(options: NormalizedEmailModuleOptions);
19
21
  onApplicationShutdown(): Promise<void>;
22
+ private shutdown;
20
23
  onModuleInit(): Promise<void>;
21
24
  private startTransport;
22
25
  /**
@@ -79,6 +82,8 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
79
82
  sendNotification(notification: EmailNotificationDispatchRequest, options?: EmailSendOptions): Promise<EmailSendResult>;
80
83
  private ensureTransport;
81
84
  private clearResolvedTransport;
85
+ private closeOwnedTransport;
86
+ private handleTransportInitializationFailure;
82
87
  private drainInFlightOperations;
83
88
  private trackInFlightOperation;
84
89
  private ensureReadyForDelivery;
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAM3E,OAAO,KAAK,EACV,KAAK,EAGL,YAAY,EACZ,gCAAgC,EAChC,oBAAoB,EAEpB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EAKf,4BAA4B,EAC7B,MAAM,YAAY,CAAC;AA4EpB;;;;;;;GAOG;AACH,qBACa,YAAa,YAAW,KAAK,EAAE,YAAY,EAAE,qBAAqB;IAOjE,OAAO,CAAC,QAAQ,CAAC,OAAO;IANpC,OAAO,CAAC,cAAc,CAAyC;IAC/D,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA+B;IAClE,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,gBAAgB,CAAsC;gBAEjC,OAAO,EAAE,4BAA4B;IAE5D,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAmBtC,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;YAgBrB,cAAc;IA+C5B;;;;OAIG;IACH,4BAA4B;IAY5B;;;;;;;;;;;;;;;;;OAiBG;IACG,IAAI,CAAC,OAAO,EAAE,YAAY,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IA6B3F;;;;;;;;;;;;OAYG;IACG,QAAQ,CAAC,QAAQ,EAAE,SAAS,YAAY,EAAE,EAAE,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,oBAAoB,CAAC;IA6BpH;;;;;;;;;;;;;;;;;OAiBG;IACG,gBAAgB,CACpB,YAAY,EAAE,gCAAgC,EAC9C,OAAO,GAAE,gBAAqB,GAC7B,OAAO,CAAC,eAAe,CAAC;YA+Bb,eAAe;IAiB7B,OAAO,CAAC,sBAAsB;YAKhB,uBAAuB;YAMvB,sBAAsB;YAUtB,sBAAsB;IAwBpC,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,6BAA6B;IAMrC,OAAO,CAAC,gBAAgB;IAIxB,OAAO,CAAC,gBAAgB;YAuBV,kBAAkB;CAkBjC"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAK3E,OAAO,KAAK,EACV,KAAK,EAGL,YAAY,EACZ,gCAAgC,EAChC,oBAAoB,EAEpB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EAKf,4BAA4B,EAC7B,MAAM,YAAY,CAAC;AAgFpB;;;;;;;GAOG;AACH,qBACa,YAAa,YAAW,KAAK,EAAE,YAAY,EAAE,qBAAqB;IASjE,OAAO,CAAC,QAAQ,CAAC,OAAO;IARpC,OAAO,CAAC,cAAc,CAAyC;IAC/D,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,6BAA6B,CAAgD;IAC9F,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA+B;IAClE,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,gBAAgB,CAAsC;gBAEjC,OAAO,EAAE,4BAA4B;IAElE,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;YAKxB,QAAQ;IAyBhB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;YAgBrB,cAAc;IA4B5B;;;;OAIG;IACH,4BAA4B;IAW5B;;;;;;;;;;;;;;;;;OAiBG;IACG,IAAI,CAAC,OAAO,EAAE,YAAY,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IA8B3F;;;;;;;;;;;;OAYG;IACG,QAAQ,CAAC,QAAQ,EAAE,SAAS,YAAY,EAAE,EAAE,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,oBAAoB,CAAC;IA6BpH;;;;;;;;;;;;;;;;;OAiBG;IACG,gBAAgB,CACpB,YAAY,EAAE,gCAAgC,EAC9C,OAAO,GAAE,gBAAqB,GAC7B,OAAO,CAAC,eAAe,CAAC;YA+Bb,eAAe;IAqB7B,OAAO,CAAC,sBAAsB;IAK9B,OAAO,CAAC,mBAAmB;YAkBb,oCAAoC;YA8BpC,uBAAuB;YAMvB,sBAAsB;YAUtB,sBAAsB;IAwBpC,OAAO,CAAC,iBAAiB;IAIzB,OAAO,CAAC,6BAA6B;IAMrC,OAAO,CAAC,gBAAgB;IAIxB,OAAO,CAAC,gBAAgB;YAuBV,kBAAkB;CAkBjC"}
package/dist/service.js CHANGED
@@ -5,7 +5,6 @@ function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e =
5
5
  function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.description) ? "[" + t + "]" : ""); try { Object.defineProperty(e, "name", { configurable: !0, value: n ? n + " " + t : t }); } catch (e) {} return e; }
6
6
  function _checkInRHS(e) { if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); return e; }
7
7
  import { Inject } from '@fluojs/core';
8
- import { DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS } from './constants.js';
9
8
  import { EmailLifecycleError, EmailMessageValidationError } from './errors.js';
10
9
  import { createEmailPlatformStatusSnapshot } from './status.js';
11
10
  import { EMAIL_OPTIONS } from './tokens.js';
@@ -45,6 +44,9 @@ function createLifecycleError(message, cause) {
45
44
  cause
46
45
  });
47
46
  }
47
+ function isTransportInitializationLifecycleError(error) {
48
+ return error instanceof EmailLifecycleError && error.message === 'Email transport failed to initialize.';
49
+ }
48
50
  function createDeliveryLifecycleError(state) {
49
51
  return new EmailLifecycleError(`Email delivery cannot start while the service lifecycle is ${state}.`);
50
52
  }
@@ -84,19 +86,30 @@ class EmailService {
84
86
  }
85
87
  lifecycleState = 'created';
86
88
  bootstrapPromise;
89
+ shutdownPromise;
90
+ ownedTransportCleanupPromises = new WeakMap();
87
91
  inFlightOperations = new Set();
88
92
  resolvedTransport;
89
93
  transportPromise;
90
94
  constructor(options) {
91
95
  this.options = options;
92
96
  }
93
- async onApplicationShutdown() {
97
+ onApplicationShutdown() {
98
+ this.shutdownPromise ??= this.shutdown();
99
+ return this.shutdownPromise;
100
+ }
101
+ async shutdown() {
94
102
  this.lifecycleState = 'stopping';
103
+ let transport;
104
+ try {
105
+ transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined);
106
+ } catch (error) {
107
+ await this.handleTransportInitializationFailure(error);
108
+ }
95
109
  try {
96
- const transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined);
97
110
  await this.drainInFlightOperations();
98
- if (transport && this.options.transport.ownsResources && transport.close) {
99
- await transport.close();
111
+ if (transport) {
112
+ await this.closeOwnedTransport(transport);
100
113
  }
101
114
  this.lifecycleState = 'stopped';
102
115
  } catch (error) {
@@ -135,22 +148,9 @@ class EmailService {
135
148
  }
136
149
  this.lifecycleState = 'ready';
137
150
  } catch (error) {
138
- if (isShutdownLifecycleState(this.lifecycleState)) {
139
- throw error;
140
- }
141
- this.lifecycleState = 'failed';
142
- let cause = error;
143
- const transport = this.resolvedTransport;
144
- if (transport && this.options.transport.ownsResources && transport.close) {
145
- try {
146
- await transport.close();
147
- } catch (cleanupError) {
148
- cause = createCleanupFailureCause(error, cleanupError);
149
- } finally {
150
- this.clearResolvedTransport();
151
- }
152
- }
153
- throw createLifecycleError('Email transport failed to initialize.', cause);
151
+ await this.handleTransportInitializationFailure(error, {
152
+ preserveShutdownState: true
153
+ });
154
154
  }
155
155
  }
156
156
 
@@ -165,7 +165,6 @@ class EmailService {
165
165
  defaultFromConfigured: this.options.defaultFrom !== undefined,
166
166
  lifecycleState: this.lifecycleState,
167
167
  ownsTransportResources: this.options.transport.ownsResources,
168
- queueWorkerJobName: DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS.jobName,
169
168
  transportKind: this.options.transport.kind,
170
169
  verifiedOnModuleInit: this.options.verifyOnModuleInit
171
170
  });
@@ -191,14 +190,13 @@ class EmailService {
191
190
  */
192
191
  async send(message, options = {}) {
193
192
  assertNotAborted(options.signal);
193
+ this.assertCanDeliver();
194
+ const normalized = this.normalizeMessage(message);
195
+ assertMessageContent(normalized);
194
196
  if (this.options.verifyOnModuleInit) {
195
197
  await this.ensureReadyForDelivery();
196
- } else {
197
- this.assertCanDeliver();
198
198
  }
199
199
  const transport = await this.ensureTransport();
200
- const normalized = this.normalizeMessage(message);
201
- assertMessageContent(normalized);
202
200
  assertNotAborted(options.signal);
203
201
  if (this.options.verifyOnModuleInit) {
204
202
  await this.ensureReadyForDelivery();
@@ -309,12 +307,49 @@ class EmailService {
309
307
  return transport;
310
308
  });
311
309
  }
312
- return this.transportPromise;
310
+ try {
311
+ return await this.transportPromise;
312
+ } catch (error) {
313
+ return await this.handleTransportInitializationFailure(error);
314
+ }
313
315
  }
314
316
  clearResolvedTransport() {
315
317
  this.resolvedTransport = undefined;
316
318
  this.transportPromise = undefined;
317
319
  }
320
+ closeOwnedTransport(transport) {
321
+ if (!this.options.transport.ownsResources || !transport.close) {
322
+ return Promise.resolve();
323
+ }
324
+ const existingCleanup = this.ownedTransportCleanupPromises.get(transport);
325
+ if (existingCleanup) {
326
+ return existingCleanup;
327
+ }
328
+ const close = transport.close;
329
+ const cleanup = Promise.resolve().then(() => close.call(transport));
330
+ this.ownedTransportCleanupPromises.set(transport, cleanup);
331
+ return cleanup;
332
+ }
333
+ async handleTransportInitializationFailure(error, options = {}) {
334
+ if (isTransportInitializationLifecycleError(error)) {
335
+ throw error;
336
+ }
337
+ if (options.preserveShutdownState && isShutdownLifecycleState(this.lifecycleState)) {
338
+ throw error;
339
+ }
340
+ this.lifecycleState = 'failed';
341
+ let cause = error;
342
+ const transport = this.resolvedTransport;
343
+ if (transport) {
344
+ try {
345
+ await this.closeOwnedTransport(transport);
346
+ } catch (cleanupError) {
347
+ cause = createCleanupFailureCause(error, cleanupError);
348
+ }
349
+ }
350
+ this.clearResolvedTransport();
351
+ throw createLifecycleError('Email transport failed to initialize.', cause);
352
+ }
318
353
  async drainInFlightOperations() {
319
354
  while (this.inFlightOperations.size > 0) {
320
355
  await Promise.allSettled(Array.from(this.inFlightOperations));
package/dist/status.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface EmailStatusAdapterInput {
7
7
  defaultFromConfigured: boolean;
8
8
  lifecycleState: EmailLifecycleState;
9
9
  ownsTransportResources: boolean;
10
- queueWorkerJobName: string;
10
+ queueWorkerJobName?: string;
11
11
  transportKind: string;
12
12
  verifiedOnModuleInit: boolean;
13
13
  }
@@ -1 +1 @@
1
- {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEvG,6EAA6E;AAC7E,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEvG,wEAAwE;AACxE,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,qBAAqB,EAAE,OAAO,CAAC;IAC/B,cAAc,EAAE,mBAAmB,CAAC;IACpC,sBAAsB,EAAE,OAAO,CAAC;IAChC,kBAAkB,EAAE,MAAM,CAAC;IAC3B,aAAa,EAAE,MAAM,CAAC;IACtB,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED,iFAAiF;AACjF,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,MAAM,EAAE,oBAAoB,CAAC;IAC7B,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAC;IACzC,SAAS,EAAE,uBAAuB,CAAC;CACpC;AA6DD;;;;;GAKG;AACH,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,uBAAuB,GAAG,2BAA2B,CAkB7G"}
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,uBAAuB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEvG,6EAA6E;AAC7E,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEvG,wEAAwE;AACxE,MAAM,WAAW,uBAAuB;IACtC,WAAW,EAAE,MAAM,CAAC;IACpB,qBAAqB,EAAE,OAAO,CAAC;IAC/B,cAAc,EAAE,mBAAmB,CAAC;IACpC,sBAAsB,EAAE,OAAO,CAAC;IAChC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,EAAE,MAAM,CAAC;IACtB,oBAAoB,EAAE,OAAO,CAAC;CAC/B;AAED,iFAAiF;AACjF,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,MAAM,EAAE,oBAAoB,CAAC;IAC7B,SAAS,EAAE,gBAAgB,CAAC,WAAW,CAAC,CAAC;IACzC,SAAS,EAAE,uBAAuB,CAAC;CACpC;AA6DD;;;;;GAKG;AACH,wBAAgB,iCAAiC,CAAC,KAAK,EAAE,uBAAuB,GAAG,2BAA2B,CAkB7G"}
package/dist/status.js CHANGED
@@ -69,7 +69,9 @@ export function createEmailPlatformStatusSnapshot(input) {
69
69
  defaultFromConfigured: input.defaultFromConfigured,
70
70
  dependencies: ['notifications.channel', 'email.transport'],
71
71
  lifecycleState: input.lifecycleState,
72
- queueWorkerJobName: input.queueWorkerJobName,
72
+ ...(input.queueWorkerJobName !== undefined ? {
73
+ queueWorkerJobName: input.queueWorkerJobName
74
+ } : {}),
73
75
  transportKind: input.transportKind,
74
76
  verifiedOnModuleInit: input.verifiedOnModuleInit
75
77
  },
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "queue",
11
11
  "mailer"
12
12
  ],
13
- "version": "1.0.2",
13
+ "version": "3.0.0",
14
14
  "private": false,
15
15
  "license": "MIT",
16
16
  "repository": {
@@ -52,31 +52,36 @@
52
52
  "dist"
53
53
  ],
54
54
  "dependencies": {
55
- "@fluojs/core": "^1.0.3",
56
- "@fluojs/di": "^1.1.0",
57
- "@fluojs/notifications": "^1.0.2",
58
- "@fluojs/runtime": "^1.1.8"
55
+ "@fluojs/core": "^2.0.0",
56
+ "@fluojs/di": "^3.0.0",
57
+ "@fluojs/notifications": "^2.0.0",
58
+ "@fluojs/runtime": "^3.0.0"
59
59
  },
60
60
  "peerDependencies": {
61
- "nodemailer": "^6.10.1",
62
- "@fluojs/queue": "^1.0.2"
61
+ "@types/nodemailer": "^8.0.0",
62
+ "nodemailer": "^9.0.1",
63
+ "@fluojs/queue": "^3.0.0"
63
64
  },
64
65
  "peerDependenciesMeta": {
65
66
  "@fluojs/queue": {
66
67
  "optional": true
67
68
  },
69
+ "@types/nodemailer": {
70
+ "optional": true
71
+ },
68
72
  "nodemailer": {
69
73
  "optional": true
70
74
  }
71
75
  },
72
76
  "devDependencies": {
73
77
  "@types/nodemailer": "^8.0.0",
74
- "vitest": "^3.2.4",
75
- "@fluojs/queue": "^1.0.2"
78
+ "vitest": "^4.1.11",
79
+ "@fluojs/queue": "^3.0.0",
80
+ "@fluojs/testing": "^3.0.0"
76
81
  },
77
82
  "scripts": {
78
83
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",
79
- "build": "pnpm exec babel src --extensions .ts --ignore 'src/**/*.test.ts' --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
84
+ "build": "pnpm exec babel src --extensions .ts --ignore 'src/**/*.test.ts' --ignore 'src/**/*.fixture.ts' --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
80
85
  "typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
81
86
  "test": "pnpm exec vitest run -c vitest.config.ts",
82
87
  "test:watch": "pnpm exec vitest -c vitest.config.ts"