@fluojs/email 2.0.0 → 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,6 +11,7 @@ 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와의-통합)
@@ -34,14 +35,16 @@ npm install @fluojs/email
34
35
  npm install @fluojs/notifications @fluojs/queue
35
36
  ```
36
37
 
37
- 명시적인 `@fluojs/email/node` 서브패스로 Node 전용 SMTP 전달을 사용할 때만 `nodemailer`를 설치하면 됩니다.
38
+ 명시적인 `@fluojs/email/node` 서브패스로 Node 전용 SMTP 전달을 사용할 때만 `nodemailer`와 `@types/nodemailer` 선언을 설치하면 됩니다.
38
39
 
39
40
  ```bash
40
- npm install @fluojs/email nodemailer
41
+ npm install @fluojs/email nodemailer@^9.0.1 @types/nodemailer@^8.0.0
41
42
  ```
42
43
 
43
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 전용 동작을 함께 끌어오지 않습니다.
44
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
+
45
48
  ## 사용 시점
46
49
 
47
50
  - 이메일을 직접 보내는 기능과 `@fluojs/notifications` 채널 연동을 한 패키지에서 처리하고 싶을 때.
@@ -129,6 +132,16 @@ EmailModule.forRootAsync({
129
132
 
130
133
  `global`은 factory result가 아니라 `forRootAsync(...)` options object의 최상위에 둡니다. 지원되는 async 등록 형태는 `inject`와 `useFactory`뿐입니다. NestJS dynamic-module 형태인 `imports`, `useClass`, `useExisting`는 `@fluojs/email` 계약에 포함되지 않습니다. 필요한 의존성은 주변 애플리케이션 module graph에 먼저 등록한 뒤, factory가 필요로 하는 token을 `inject`에 나열하세요.
131
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
+
132
145
  ### `@fluojs/email/node`를 이용한 Node 전용 SMTP
133
146
 
134
147
  런타임 이식 가능한 루트 패키지 계약을 약화시키지 않으면서 1st-party Nodemailer/SMTP 전달이 필요하다면 전용 Node 서브패스를 사용합니다.
@@ -190,6 +203,7 @@ Behavioral contract 메모:
190
203
 
191
204
  - `EmailService.send(...)`는 전달 전에 `defaultFrom`과 `defaultReplyTo`를 해석합니다.
192
205
  - `EmailService.send(...)`는 빈 `to` 수신자를 transport handoff 전에 거부하므로 transport가 빈 전달 대상을 받지 않습니다.
206
+ - `EmailService.send(...)`는 lazy transport를 획득하기 전에 메시지를 normalize하고 검증하므로, 잘못된 입력이 transport 리소스를 초기화하지 않습니다.
193
207
  - `EmailService.send(...)`와 `EmailService.sendNotification(...)`은 이미 abort된 `AbortSignal`을 템플릿 렌더링 또는 transport handoff 전에 반영합니다.
194
208
  - `EmailService.send(...)`는 `accepted`, `pending`, `rejected` 수신자를 분리해 보존하므로 provider의 부분 실패가 호출자에게 그대로 보입니다.
195
209
  - `EmailService.sendMany(...)`는 기본적으로 fail-fast입니다. 실패를 batch result에 수집하려면 `continueOnError: true`를 전달합니다.
@@ -384,6 +398,8 @@ Behavioral contract 메모:
384
398
 
385
399
  - Queue 지원은 opt-in입니다. 루트 `@fluojs/email` 엔트리포인트와 `EmailModule`은 `@fluojs/queue`를 import하거나 `EmailNotificationsQueueWorker`를 등록하거나 queue peer 설치를 요구하지 않습니다.
386
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`로 유지합니다.
387
403
  - worker는 transport handoff 전에 queued notification channel이 구성된 `EmailChannel.channel`과 정확히 일치하는지 확인합니다. 일치하지 않으면 `EmailMessageValidationError`로 실패하므로 non-email 작업이 email transport에 도달하지 않습니다.
388
404
  - worker는 `EmailChannel` 전달 semantics를 재사용하므로 transport가 수락된 수신자 0명 또는 `pending`/`rejected` 수신자를 보고하면 queued job이 실패합니다. 따라서 incomplete delivery는 성공한 job으로 승인되지 않고 `@fluojs/queue`의 retry/dead-letter 흐름으로 넘어갑니다.
389
405
 
@@ -468,7 +484,7 @@ email 패키지는 의도적으로 다음을 **포함하지 않습니다**:
468
484
  - `@fluojs/notifications`: `EMAIL_CHANNEL`을 소비하는 공통 오케스트레이션 계층입니다.
469
485
  - `@fluojs/queue`: 대량 이메일 전달을 백그라운드에서 처리하려는 경우 권장됩니다.
470
486
  - `@fluojs/config`: 환경 직접 접근 없이 transport 자격 증명과 sender 기본값을 해석하려는 경우 권장됩니다.
471
- - `nodemailer`: `@fluojs/email/node`가 소비하는 Node 전용 SMTP 구현체입니다.
487
+ - `nodemailer`와 `@types/nodemailer`: `@fluojs/email/node`가 소비하는 Node 전용 SMTP 구현체와 선언입니다.
472
488
 
473
489
  ## 예제 소스
474
490
 
package/README.md CHANGED
@@ -11,6 +11,7 @@ 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)
@@ -34,14 +35,16 @@ Install `@fluojs/notifications` and `@fluojs/queue` only when you want the built
34
35
  npm install @fluojs/notifications @fluojs/queue
35
36
  ```
36
37
 
37
- 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.
38
39
 
39
40
  ```bash
40
- npm install @fluojs/email nodemailer
41
+ npm install @fluojs/email nodemailer@^9.0.1 @types/nodemailer@^8.0.0
41
42
  ```
42
43
 
43
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.
44
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
+
45
48
  ## When to Use
46
49
 
47
50
  - When you want one package that can send email directly and also plug into `@fluojs/notifications`.
@@ -129,6 +132,16 @@ EmailModule.forRootAsync({
129
132
 
130
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`.
131
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
+
132
145
  ### Node-only SMTP with `@fluojs/email/node`
133
146
 
134
147
  Use the dedicated Node subpath when you want first-party Nodemailer/SMTP delivery without weakening the runtime-portable root package contract.
@@ -190,6 +203,7 @@ Behavioral contract notes:
190
203
 
191
204
  - `EmailService.send(...)` resolves `defaultFrom` and `defaultReplyTo` before delivery.
192
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.
193
207
  - `EmailService.send(...)` and `EmailService.sendNotification(...)` honor an already-aborted `AbortSignal` before template rendering or transport handoff.
194
208
  - `EmailService.send(...)` preserves `accepted`, `pending`, and `rejected` recipients separately so partial provider failures stay caller-visible.
195
209
  - `EmailService.sendMany(...)` is fail-fast by default; pass `continueOnError: true` to collect failures in a batch result.
@@ -384,6 +398,8 @@ Behavioral contract notes:
384
398
 
385
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.
386
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`.
387
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.
388
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.
389
405
 
@@ -468,7 +484,7 @@ These limitations are part of the package contract so transport selection, templ
468
484
  - `@fluojs/notifications`: Shared orchestration layer that consumes `EMAIL_CHANNEL`.
469
485
  - `@fluojs/queue`: Recommended when bulk email delivery should run in the background.
470
486
  - `@fluojs/config`: Recommended for resolving transport credentials and sender defaults without direct environment access.
471
- - `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`.
472
488
 
473
489
  ## Example Sources
474
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;
@@ -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;AAE5C,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,CAS5G;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"}
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
@@ -19,17 +19,19 @@ export class EmailNotificationQueueJob {
19
19
  *
20
20
  * @param notification Notification envelope that will be delivered by the email channel worker.
21
21
  * @param queuedAt ISO timestamp captured when the notifications foundation delegated the job.
22
+ * @param id Deterministic notification identity preserved for queue deduplication.
22
23
  */
23
- constructor(notification, queuedAt) {
24
+ constructor(notification, queuedAt, id) {
24
25
  this.notification = notification;
25
26
  this.queuedAt = queuedAt;
27
+ this.id = id;
26
28
  }
27
29
  }
28
30
 
29
31
  /**
30
- * Creates a notifications queue adapter backed by {@link QueueLifecycleService}.
32
+ * Creates a notifications queue adapter backed by the public {@link Queue} facade.
31
33
  *
32
- * @param queue Queue lifecycle service used to enqueue email notification jobs.
34
+ * @param queue Queue facade used to enqueue email notification jobs.
33
35
  * @returns A queue adapter compatible with `NotificationsModule.forRoot(...)` queue wiring.
34
36
  *
35
37
  * @example
@@ -49,10 +51,17 @@ export class EmailNotificationQueueJob {
49
51
  export function createEmailNotificationsQueueAdapter(queue) {
50
52
  return {
51
53
  enqueue(job) {
52
- 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
+ });
53
57
  },
54
58
  enqueueMany(jobs) {
55
- 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
+ })));
56
65
  }
57
66
  };
58
67
  }
package/dist/service.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
13
13
  private lifecycleState;
14
14
  private bootstrapPromise;
15
15
  private shutdownPromise;
16
+ private readonly ownedTransportCleanupPromises;
16
17
  private readonly inFlightOperations;
17
18
  private resolvedTransport;
18
19
  private transportPromise;
@@ -81,6 +82,7 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
81
82
  sendNotification(notification: EmailNotificationDispatchRequest, options?: EmailSendOptions): Promise<EmailSendResult>;
82
83
  private ensureTransport;
83
84
  private clearResolvedTransport;
85
+ private closeOwnedTransport;
84
86
  private handleTransportInitializationFailure;
85
87
  private drainInFlightOperations;
86
88
  private trackInFlightOperation;
@@ -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;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;IAQjE,OAAO,CAAC,QAAQ,CAAC,OAAO;IAPpC,OAAO,CAAC,cAAc,CAAyC;IAC/D,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,eAAe,CAA4B;IACnD,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;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;IAqB7B,OAAO,CAAC,sBAAsB;YAKhB,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"}
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
@@ -87,6 +87,7 @@ class EmailService {
87
87
  lifecycleState = 'created';
88
88
  bootstrapPromise;
89
89
  shutdownPromise;
90
+ ownedTransportCleanupPromises = new WeakMap();
90
91
  inFlightOperations = new Set();
91
92
  resolvedTransport;
92
93
  transportPromise;
@@ -107,8 +108,8 @@ class EmailService {
107
108
  }
108
109
  try {
109
110
  await this.drainInFlightOperations();
110
- if (transport && this.options.transport.ownsResources && transport.close) {
111
- await transport.close();
111
+ if (transport) {
112
+ await this.closeOwnedTransport(transport);
112
113
  }
113
114
  this.lifecycleState = 'stopped';
114
115
  } catch (error) {
@@ -189,14 +190,13 @@ class EmailService {
189
190
  */
190
191
  async send(message, options = {}) {
191
192
  assertNotAborted(options.signal);
193
+ this.assertCanDeliver();
194
+ const normalized = this.normalizeMessage(message);
195
+ assertMessageContent(normalized);
192
196
  if (this.options.verifyOnModuleInit) {
193
197
  await this.ensureReadyForDelivery();
194
- } else {
195
- this.assertCanDeliver();
196
198
  }
197
199
  const transport = await this.ensureTransport();
198
- const normalized = this.normalizeMessage(message);
199
- assertMessageContent(normalized);
200
200
  assertNotAborted(options.signal);
201
201
  if (this.options.verifyOnModuleInit) {
202
202
  await this.ensureReadyForDelivery();
@@ -317,6 +317,19 @@ class EmailService {
317
317
  this.resolvedTransport = undefined;
318
318
  this.transportPromise = undefined;
319
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
+ }
320
333
  async handleTransportInitializationFailure(error, options = {}) {
321
334
  if (isTransportInitializationLifecycleError(error)) {
322
335
  throw error;
@@ -327,9 +340,9 @@ class EmailService {
327
340
  this.lifecycleState = 'failed';
328
341
  let cause = error;
329
342
  const transport = this.resolvedTransport;
330
- if (transport && this.options.transport.ownsResources && transport.close) {
343
+ if (transport) {
331
344
  try {
332
- await transport.close();
345
+ await this.closeOwnedTransport(transport);
333
346
  } catch (cleanupError) {
334
347
  cause = createCleanupFailureCause(error, cleanupError);
335
348
  }
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "queue",
11
11
  "mailer"
12
12
  ],
13
- "version": "2.0.0",
13
+ "version": "3.0.0",
14
14
  "private": false,
15
15
  "license": "MIT",
16
16
  "repository": {
@@ -52,32 +52,36 @@
52
52
  "dist"
53
53
  ],
54
54
  "dependencies": {
55
- "@fluojs/core": "^1.1.0",
56
- "@fluojs/di": "^2.0.0",
57
- "@fluojs/notifications": "^1.0.3",
58
- "@fluojs/runtime": "^2.0.1"
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": "^2.0.0"
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": "^2.0.0",
76
- "@fluojs/testing": "^2.0.0"
78
+ "vitest": "^4.1.11",
79
+ "@fluojs/queue": "^3.0.0",
80
+ "@fluojs/testing": "^3.0.0"
77
81
  },
78
82
  "scripts": {
79
83
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",
80
- "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",
81
85
  "typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
82
86
  "test": "pnpm exec vitest run -c vitest.config.ts",
83
87
  "test:watch": "pnpm exec vitest -c vitest.config.ts"