@fluojs/email 1.0.1 → 1.0.2

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
@@ -10,6 +10,7 @@ fluo를 위한 transport-agnostic 이메일 코어 패키지입니다. Nest-like
10
10
  - [사용 시점](#사용-시점)
11
11
  - [빠른 시작](#빠른-시작)
12
12
  - [일반적인 패턴](#일반적인-패턴)
13
+ - [등록 범위와 async factory](#등록-범위와-async-factory)
13
14
  - [`@fluojs/email/node`를 이용한 Node 전용 SMTP](#fluojs-email-node를-이용한-node-전용-smtp)
14
15
  - [`EmailService`를 이용한 standalone 전달](#emailservice를-이용한-standalone-전달)
15
16
  - [`@fluojs/notifications`와의 통합](#fluojs-notifications와의-통합)
@@ -104,6 +105,29 @@ export class WelcomeService {
104
105
 
105
106
  ## 일반적인 패턴
106
107
 
108
+ ### 등록 범위와 async factory
109
+
110
+ `EmailModule.forRoot(...)`와 `EmailModule.forRootAsync(...)`는 기본적으로 global module을 반환합니다. 한 번 import하면 export된 `EmailService`, `EmailChannel`, `EMAIL`, `EMAIL_CHANNEL` provider가 애플리케이션 module graph에 표시됩니다. 이메일 provider를 반환된 module을 명시적으로 import한 module에만 보이게 해야 할 때만 `global: false`를 전달합니다.
111
+
112
+ Async 등록은 의도적으로 fluo의 명시적 factory 형태를 사용합니다:
113
+
114
+ ```typescript
115
+ EmailModule.forRootAsync({
116
+ global: false,
117
+ inject: [ConfigService],
118
+ useFactory: (config) => ({
119
+ defaultFrom: config.mail.from,
120
+ transport: {
121
+ kind: config.mail.transportKind,
122
+ create: () => config.mail.transport,
123
+ ownsResources: false,
124
+ },
125
+ }),
126
+ });
127
+ ```
128
+
129
+ `global`은 factory result가 아니라 `forRootAsync(...)` options object의 최상위에 둡니다. 지원되는 async 등록 형태는 `inject`와 `useFactory`뿐입니다. NestJS dynamic-module 형태인 `imports`, `useClass`, `useExisting`는 `@fluojs/email` 계약에 포함되지 않습니다. 필요한 의존성은 주변 애플리케이션 module graph에 먼저 등록한 뒤, factory가 필요로 하는 token을 `inject`에 나열하세요.
130
+
107
131
  ### `@fluojs/email/node`를 이용한 Node 전용 SMTP
108
132
 
109
133
  런타임 이식 가능한 루트 패키지 계약을 약화시키지 않으면서 1st-party Nodemailer/SMTP 전달이 필요하다면 전용 Node 서브패스를 사용합니다.
@@ -171,9 +195,11 @@ Behavioral contract 메모:
171
195
  - `EmailService.createPlatformStatusSnapshot()`은 diagnostics를 위해 lifecycle, readiness, health, transport ownership details를 노출합니다.
172
196
  - 서비스는 모듈 bootstrap 시 transport를 초기화하며, `verifyOnModuleInit: true`인 경우 bootstrap 검증이 성공적으로 끝날 때까지 delivery가 transport handoff로 진행되지 않습니다.
173
197
  - 거부된 `forRootAsync(...)` 옵션 factory 결과는 영구 memoize되지 않으며, 다음 provider resolution에서 configuration lookup을 다시 시도할 수 있습니다.
174
- - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다린 뒤 닫습니다.
198
+ - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다리고, 활성 transport `verify()` / `send()` 호출을 drain한 소유 transport를 닫습니다.
175
199
  - transport `verify()`와 `close()`에서 발생한 provider error는 diagnostics를 위해 lifecycle failure의 `cause`로 보존됩니다.
176
200
  - 모듈 옵션은 provider wiring 전에 trim 및 normalize됩니다. 여기에는 sender 기본값, notification channel 이름, transport factory 소유권이 포함됩니다.
201
+ - `EmailModule.forRoot(...)`와 `EmailModule.forRootAsync(...)`는 기본적으로 global입니다. module-local visibility가 필요할 때만 `global: false`를 사용합니다.
202
+ - `EmailModule.forRootAsync(...)`는 `inject`와 `useFactory`만 지원합니다. NestJS `imports`, `useClass`, `useExisting` 등록 형태는 factory 호출 전에 애플리케이션 module boundary에서 해석해야 합니다.
177
203
  - 이 패키지는 절대로 `process.env`를 직접 읽지 않습니다. 모든 설정은 명시적인 옵션 또는 DI를 통해 들어와야 합니다.
178
204
 
179
205
  ### `@fluojs/notifications`와의 통합
@@ -214,7 +240,7 @@ export class AppModule {}
214
240
 
215
241
  Behavioral contract 메모:
216
242
 
217
- - `EmailChannel`은 `pending` 또는 `rejected` 수신자가 하나라도 있으면 전달을 성공으로 보고하지 않고 notification dispatch를 실패로 처리합니다.
243
+ - `EmailChannel`은 수락된 수신자가 0명인 경우(`accepted.length === 0`) 또는 `pending`/`rejected` 수신자가 하나라도 있으면 전달을 성공으로 보고하지 않고 notification dispatch를 실패로 처리합니다.
218
244
  - `EmailService.sendNotification(...)`은 렌더링된 template output을 payload 및 notification metadata와 병합합니다. payload 필드는 notification fallback보다 우선합니다.
219
245
  - Template rendering에는 notification `payload`, `metadata`, `locale`, `subject`, `template`이 전달되며, payload `text`, `html`과 notification `subject`가 렌더링된 fallback보다 우선합니다.
220
246
 
@@ -301,9 +327,10 @@ email 패키지는 의도적으로 다음을 **포함하지 않습니다**:
301
327
 
302
328
  ### 계약과 헬퍼
303
329
 
304
- - `Email`: `address`와 선택적 display `name`을 포함하는 정규화된 이메일 주소 값입니다.
330
+ - `Email`: `EMAIL` 호환성 토큰이 노출하는 애플리케이션용 전송 facade이며 address 값이 아닙니다. `EmailService`가 뒷받침하는 `send(...)`, `sendMany(...)`, `sendNotification(...)` 메서드를 제공합니다.
305
331
  - `EmailAddress` / `EmailAddressLike`: `EmailService`가 정규화하기 전에 허용하는 구조화 또는 축약 recipient 값입니다.
306
- - `EmailModuleOptions` / `EmailAsyncModuleOptions`: sender 기본값, renderer, lifecycle 검증, transport factory wiring을 포함하는 동기/비동기 모듈 등록 계약입니다.
332
+ - `EmailAttachment`: `EmailMessage.attachments`에서 허용되고 설정된 transport로 전달되는 file attachment payload입니다. `filename`, `content`, 선택적 `contentType` 필드를 포함합니다.
333
+ - `EmailModuleOptions` / `EmailAsyncModuleOptions`: sender 기본값, renderer, lifecycle 검증, transport factory wiring, 최상위 `global` visibility control, async `inject` + `useFactory` 형태를 포함하는 동기/비동기 모듈 등록 계약입니다.
307
334
  - `EmailMessage`
308
335
  - `EmailNotificationDispatchRequest` / `EmailNotificationPayload`: `EmailChannel`이 소비하는 notification channel payload 계약입니다.
309
336
  - `EmailSendOptions` / `EmailSendManyOptions`: abort signal과 batch failure 수집 같은 per-send 제어 옵션입니다.
package/README.md CHANGED
@@ -10,6 +10,7 @@ Transport-agnostic email delivery core for fluo. It provides a Nest-like module
10
10
  - [When to Use](#when-to-use)
11
11
  - [Quick Start](#quick-start)
12
12
  - [Common Patterns](#common-patterns)
13
+ - [Registration scope and async factories](#registration-scope-and-async-factories)
13
14
  - [Node-only SMTP with `@fluojs/email/node`](#node-only-smtp-with-fluojs-email-node)
14
15
  - [Standalone delivery with `EmailService`](#standalone-delivery-with-emailservice)
15
16
  - [Integration with `@fluojs/notifications`](#integration-with-fluojs-notifications)
@@ -104,6 +105,29 @@ The root `@fluojs/email` surface is intentionally module-first. Register email d
104
105
 
105
106
  ## Common Patterns
106
107
 
108
+ ### Registration scope and async factories
109
+
110
+ `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.
111
+
112
+ Async registration intentionally uses fluo's explicit factory shape:
113
+
114
+ ```typescript
115
+ EmailModule.forRootAsync({
116
+ global: false,
117
+ inject: [ConfigService],
118
+ useFactory: (config) => ({
119
+ defaultFrom: config.mail.from,
120
+ transport: {
121
+ kind: config.mail.transportKind,
122
+ create: () => config.mail.transport,
123
+ ownsResources: false,
124
+ },
125
+ }),
126
+ });
127
+ ```
128
+
129
+ `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
+
107
131
  ### Node-only SMTP with `@fluojs/email/node`
108
132
 
109
133
  Use the dedicated Node subpath when you want first-party Nodemailer/SMTP delivery without weakening the runtime-portable root package contract.
@@ -171,9 +195,11 @@ Behavioral contract notes:
171
195
  - `EmailService.createPlatformStatusSnapshot()` exposes lifecycle, readiness, health, and transport ownership details for diagnostics.
172
196
  - The service initializes the configured transport during module bootstrap and, when `verifyOnModuleInit: true`, delivery waits until bootstrap verification has completed successfully before transport handoff.
173
197
  - Rejected `forRootAsync(...)` option factories are not memoized permanently; the next provider resolution can retry configuration lookup.
174
- - 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 and closed by shutdown.
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.
175
199
  - Transport `verify()` and `close()` provider errors are preserved as the `cause` of lifecycle failures for diagnostics.
176
200
  - Module options are trimmed and normalized before provider wiring, including sender defaults, notification channel names, and transport factory ownership.
201
+ - `EmailModule.forRoot(...)` and `EmailModule.forRootAsync(...)` are global by default. Use `global: false` to opt into module-local visibility.
202
+ - `EmailModule.forRootAsync(...)` supports `inject` plus `useFactory` only; NestJS `imports`, `useClass`, and `useExisting` registration shapes must be resolved at the application module boundary before calling the factory.
177
203
  - The package never reads `process.env` directly. All configuration must enter through explicit options or DI.
178
204
 
179
205
  ### Integration with `@fluojs/notifications`
@@ -214,7 +240,7 @@ Supported notification payload fields:
214
240
 
215
241
  Behavioral contract notes:
216
242
 
217
- - `EmailChannel` treats any `pending` or `rejected` recipients as a failed notification dispatch instead of reporting the delivery as successful.
243
+ - `EmailChannel` treats zero accepted recipients (`accepted.length === 0`) or any `pending`/`rejected` recipients as a failed notification dispatch instead of reporting the delivery as successful.
218
244
  - `EmailService.sendNotification(...)` merges rendered template output with payload and notification metadata; payload fields override notification fallbacks.
219
245
  - Template rendering receives notification `payload`, `metadata`, `locale`, `subject`, and `template`; payload `text`, `html`, and notification `subject` override rendered fallbacks.
220
246
 
@@ -301,9 +327,10 @@ These limitations are part of the package contract so transport selection, templ
301
327
 
302
328
  ### Contracts and helpers
303
329
 
304
- - `Email`: Normalized email address value with an `address` and optional display `name`.
330
+ - `Email`: Application-facing sending facade exposed by the `EMAIL` compatibility token, not an address value; it provides `send(...)`, `sendMany(...)`, and `sendNotification(...)` methods backed by `EmailService`.
305
331
  - `EmailAddress` / `EmailAddressLike`: Structured or shorthand recipient values accepted by `EmailService` before normalization.
306
- - `EmailModuleOptions` / `EmailAsyncModuleOptions`: Synchronous and async module registration contracts, including sender defaults, renderer, lifecycle verification, and transport factory wiring.
332
+ - `EmailAttachment`: File attachment payload accepted on `EmailMessage.attachments` and forwarded to the configured transport with `filename`, `content`, and optional `contentType` fields.
333
+ - `EmailModuleOptions` / `EmailAsyncModuleOptions`: Synchronous and async module registration contracts, including sender defaults, renderer, lifecycle verification, transport factory wiring, top-level `global` visibility control, and the async `inject` + `useFactory` shape.
307
334
  - `EmailMessage`
308
335
  - `EmailNotificationDispatchRequest` / `EmailNotificationPayload`: Notification channel payload contracts consumed by `EmailChannel`.
309
336
  - `EmailSendOptions` / `EmailSendManyOptions`: Per-send controls such as abort signals and batch failure collection.
package/dist/service.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
12
12
  private readonly options;
13
13
  private lifecycleState;
14
14
  private bootstrapPromise;
15
+ private readonly inFlightOperations;
15
16
  private resolvedTransport;
16
17
  private transportPromise;
17
18
  constructor(options: NormalizedEmailModuleOptions);
@@ -78,6 +79,8 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
78
79
  sendNotification(notification: EmailNotificationDispatchRequest, options?: EmailSendOptions): Promise<EmailSendResult>;
79
80
  private ensureTransport;
80
81
  private clearResolvedTransport;
82
+ private drainInFlightOperations;
83
+ private trackInFlightOperation;
81
84
  private ensureReadyForDelivery;
82
85
  private getLifecycleState;
83
86
  private assertCanCreateOrUseTransport;
@@ -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;IAMjE,OAAO,CAAC,QAAQ,CAAC,OAAO;IALpC,OAAO,CAAC,cAAc,CAAyC;IAC/D,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,gBAAgB,CAAsC;gBAEjC,OAAO,EAAE,4BAA4B;IAE5D,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBtC,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,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;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"}
package/dist/service.js CHANGED
@@ -84,6 +84,7 @@ class EmailService {
84
84
  }
85
85
  lifecycleState = 'created';
86
86
  bootstrapPromise;
87
+ inFlightOperations = new Set();
87
88
  resolvedTransport;
88
89
  transportPromise;
89
90
  constructor(options) {
@@ -93,6 +94,7 @@ class EmailService {
93
94
  this.lifecycleState = 'stopping';
94
95
  try {
95
96
  const transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined);
97
+ await this.drainInFlightOperations();
96
98
  if (transport && this.options.transport.ownsResources && transport.close) {
97
99
  await transport.close();
98
100
  }
@@ -126,7 +128,7 @@ class EmailService {
126
128
  return;
127
129
  }
128
130
  if (this.options.verifyOnModuleInit && transport.verify) {
129
- await transport.verify();
131
+ await this.trackInFlightOperation(Promise.resolve(transport.verify()));
130
132
  }
131
133
  if (this.lifecycleState !== 'starting') {
132
134
  return;
@@ -203,7 +205,7 @@ class EmailService {
203
205
  } else {
204
206
  this.assertCanDeliver();
205
207
  }
206
- const result = await transport.send(normalized, options);
208
+ const result = await this.trackInFlightOperation(Promise.resolve(transport.send(normalized, options)));
207
209
  return {
208
210
  accepted: result.accepted ?? [],
209
211
  messageId: result.messageId ?? '',
@@ -313,6 +315,19 @@ class EmailService {
313
315
  this.resolvedTransport = undefined;
314
316
  this.transportPromise = undefined;
315
317
  }
318
+ async drainInFlightOperations() {
319
+ while (this.inFlightOperations.size > 0) {
320
+ await Promise.allSettled(Array.from(this.inFlightOperations));
321
+ }
322
+ }
323
+ async trackInFlightOperation(operation) {
324
+ this.inFlightOperations.add(operation);
325
+ try {
326
+ return await operation;
327
+ } finally {
328
+ this.inFlightOperations.delete(operation);
329
+ }
330
+ }
316
331
  async ensureReadyForDelivery() {
317
332
  this.assertCanDeliver();
318
333
  if (!this.options.verifyOnModuleInit) {
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "queue",
11
11
  "mailer"
12
12
  ],
13
- "version": "1.0.1",
13
+ "version": "1.0.2",
14
14
  "private": false,
15
15
  "license": "MIT",
16
16
  "repository": {
@@ -53,13 +53,13 @@
53
53
  ],
54
54
  "dependencies": {
55
55
  "@fluojs/core": "^1.0.3",
56
- "@fluojs/di": "^1.0.3",
57
- "@fluojs/notifications": "^1.0.1",
58
- "@fluojs/runtime": "^1.1.1"
56
+ "@fluojs/di": "^1.1.0",
57
+ "@fluojs/notifications": "^1.0.2",
58
+ "@fluojs/runtime": "^1.1.8"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "nodemailer": "^6.10.1",
62
- "@fluojs/queue": "^1.0.0"
62
+ "@fluojs/queue": "^1.0.2"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "@fluojs/queue": {
@@ -72,7 +72,7 @@
72
72
  "devDependencies": {
73
73
  "@types/nodemailer": "^8.0.0",
74
74
  "vitest": "^3.2.4",
75
- "@fluojs/queue": "^1.0.0"
75
+ "@fluojs/queue": "^1.0.2"
76
76
  },
77
77
  "scripts": {
78
78
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",