@fluojs/email 1.0.0-beta.3 → 1.0.0-beta.5

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
@@ -86,8 +86,9 @@ export class AppModule {}
86
86
  import { Inject } from '@fluojs/core';
87
87
  import { EmailService } from '@fluojs/email';
88
88
 
89
+ @Inject(EmailService)
89
90
  export class WelcomeService {
90
- constructor(@Inject(EmailService) private readonly email: EmailService) {}
91
+ constructor(private readonly email: EmailService) {}
91
92
 
92
93
  async sendWelcome(address: string) {
93
94
  await this.email.send({
@@ -139,6 +140,7 @@ Behavioral contract 메모:
139
140
  - `createNodemailerEmailTransportFactory(...)`는 Node 전용이며 `@fluojs/email/node`에서만 export됩니다.
140
141
  - 이 factory는 자신이 생성한 Nodemailer transporter 리소스를 소유하므로 `EmailService`가 bootstrap 시 검증하고 shutdown 시 닫을 수 있습니다.
141
142
  - `createNodemailerEmailTransport(...)`는 이미 존재하는 Nodemailer transporter를 감싸지만 리소스 소유권은 호출자에게 남깁니다.
143
+ - Nodemailer display-name 주소는 구조화된 address object로 전달되며, newline 문자가 포함되면 provider handoff 전에 거부됩니다.
142
144
  - SMTP 자격 증명은 여전히 명시적인 옵션 또는 DI를 통해 들어와야 합니다. 루트 패키지와 Node 서브패스 모두 `process.env`를 직접 읽지 않습니다.
143
145
 
144
146
  ### `EmailService`를 이용한 standalone 전달
@@ -162,10 +164,15 @@ EmailModule.forRootAsync({
162
164
  Behavioral contract 메모:
163
165
 
164
166
  - `EmailService.send(...)`는 전달 전에 `defaultFrom`과 `defaultReplyTo`를 해석합니다.
167
+ - `EmailService.send(...)`는 빈 `to` 수신자를 transport handoff 전에 거부하므로 transport가 빈 전달 대상을 받지 않습니다.
168
+ - `EmailService.send(...)`와 `EmailService.sendNotification(...)`은 이미 abort된 `AbortSignal`을 템플릿 렌더링 또는 transport handoff 전에 반영합니다.
165
169
  - `EmailService.send(...)`는 `accepted`, `pending`, `rejected` 수신자를 분리해 보존하므로 provider의 부분 실패가 호출자에게 그대로 보입니다.
166
170
  - `EmailService.sendMany(...)`는 기본적으로 fail-fast입니다. 실패를 batch result에 수집하려면 `continueOnError: true`를 전달합니다.
167
171
  - `EmailService.createPlatformStatusSnapshot()`은 diagnostics를 위해 lifecycle, readiness, health, transport ownership details를 노출합니다.
168
- - 서비스는 모듈 bootstrap 시 transport를 초기화하고, factory가 소유한 리소스만 애플리케이션 shutdown 닫습니다.
172
+ - 서비스는 모듈 bootstrap 시 transport를 초기화하며, `verifyOnModuleInit: true`인 경우 bootstrap 검증이 성공적으로 끝날 때까지 delivery가 transport handoff로 진행되지 않습니다.
173
+ - 거부된 `forRootAsync(...)` 옵션 factory 결과는 영구 memoize되지 않으며, 다음 provider resolution에서 configuration lookup을 다시 시도할 수 있습니다.
174
+ - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다린 뒤 닫습니다.
175
+ - transport `verify()`와 `close()`에서 발생한 provider error는 diagnostics를 위해 lifecycle failure의 `cause`로 보존됩니다.
169
176
  - 모듈 옵션은 provider wiring 전에 trim 및 normalize됩니다. 여기에는 sender 기본값, notification channel 이름, transport factory 소유권이 포함됩니다.
170
177
  - 이 패키지는 절대로 `process.env`를 직접 읽지 않습니다. 모든 설정은 명시적인 옵션 또는 DI를 통해 들어와야 합니다.
171
178
 
@@ -209,6 +216,7 @@ Behavioral contract 메모:
209
216
 
210
217
  - `EmailChannel`은 `pending` 또는 `rejected` 수신자가 하나라도 있으면 전달을 성공으로 보고하지 않고 notification dispatch를 실패로 처리합니다.
211
218
  - `EmailService.sendNotification(...)`은 렌더링된 template output을 payload 및 notification metadata와 병합합니다. payload 필드는 notification fallback보다 우선합니다.
219
+ - Template rendering에는 notification `payload`, `metadata`, `locale`, `subject`, `template`이 전달되며, payload `text`, `html`과 notification `subject`가 렌더링된 fallback보다 우선합니다.
212
220
 
213
221
  ### 큐 기반 대량 전달
214
222
 
@@ -306,6 +314,7 @@ email 패키지는 의도적으로 다음을 **포함하지 않습니다**:
306
314
 
307
315
  - `createEmailPlatformStatusSnapshot(...)`
308
316
  - `EmailConfigurationError`
317
+ - `EmailLifecycleError`: lifecycle로 차단된 전달, transport 초기화 또는 검증, 소유 리소스 shutdown 실패에서 발생합니다. 애플리케이션 teardown과 전송이 경합할 수 있다면 이 에러를 catch하세요.
309
318
  - `EmailMessageValidationError`
310
319
 
311
320
  ### Node 전용 서브패스
package/README.md CHANGED
@@ -86,8 +86,9 @@ export class AppModule {}
86
86
  import { Inject } from '@fluojs/core';
87
87
  import { EmailService } from '@fluojs/email';
88
88
 
89
+ @Inject(EmailService)
89
90
  export class WelcomeService {
90
- constructor(@Inject(EmailService) private readonly email: EmailService) {}
91
+ constructor(private readonly email: EmailService) {}
91
92
 
92
93
  async sendWelcome(address: string) {
93
94
  await this.email.send({
@@ -139,6 +140,7 @@ Behavioral contract notes:
139
140
  - `createNodemailerEmailTransportFactory(...)` is Node-only and is exported exclusively from `@fluojs/email/node`.
140
141
  - The factory owns the Nodemailer transporter it creates, so `EmailService` can verify it on bootstrap and close it during shutdown.
141
142
  - `createNodemailerEmailTransport(...)` wraps an existing Nodemailer transporter without transferring resource ownership.
143
+ - Nodemailer display-name addresses are forwarded as structured address objects and reject newline characters before provider handoff.
142
144
  - SMTP credentials still enter through explicit options or DI. Neither the root package nor the Node subpath reads `process.env` directly.
143
145
 
144
146
  ### Standalone delivery with `EmailService`
@@ -162,10 +164,15 @@ EmailModule.forRootAsync({
162
164
  Behavioral contract notes:
163
165
 
164
166
  - `EmailService.send(...)` resolves `defaultFrom` and `defaultReplyTo` before delivery.
167
+ - `EmailService.send(...)` rejects blank `to` recipients before handoff so transports never receive an empty delivery target.
168
+ - `EmailService.send(...)` and `EmailService.sendNotification(...)` honor an already-aborted `AbortSignal` before template rendering or transport handoff.
165
169
  - `EmailService.send(...)` preserves `accepted`, `pending`, and `rejected` recipients separately so partial provider failures stay caller-visible.
166
170
  - `EmailService.sendMany(...)` is fail-fast by default; pass `continueOnError: true` to collect failures in a batch result.
167
171
  - `EmailService.createPlatformStatusSnapshot()` exposes lifecycle, readiness, health, and transport ownership details for diagnostics.
168
- - The service initializes the configured transport during module bootstrap and closes factory-owned resources during application shutdown.
172
+ - 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
+ - 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.
175
+ - Transport `verify()` and `close()` provider errors are preserved as the `cause` of lifecycle failures for diagnostics.
169
176
  - Module options are trimmed and normalized before provider wiring, including sender defaults, notification channel names, and transport factory ownership.
170
177
  - The package never reads `process.env` directly. All configuration must enter through explicit options or DI.
171
178
 
@@ -209,6 +216,7 @@ Behavioral contract notes:
209
216
 
210
217
  - `EmailChannel` treats any `pending` or `rejected` recipients as a failed notification dispatch instead of reporting the delivery as successful.
211
218
  - `EmailService.sendNotification(...)` merges rendered template output with payload and notification metadata; payload fields override notification fallbacks.
219
+ - Template rendering receives notification `payload`, `metadata`, `locale`, `subject`, and `template`; payload `text`, `html`, and notification `subject` override rendered fallbacks.
212
220
 
213
221
  ### Queue-backed bulk delivery
214
222
 
@@ -306,6 +314,7 @@ These limitations are part of the package contract so transport selection, templ
306
314
 
307
315
  - `createEmailPlatformStatusSnapshot(...)`
308
316
  - `EmailConfigurationError`
317
+ - `EmailLifecycleError`: thrown by lifecycle-gated delivery, transport initialization or verification, and owned-resource shutdown failures. Catch this error when sends can race with application teardown.
309
318
  - `EmailMessageValidationError`
310
319
 
311
320
  ### Node-only subpath
package/dist/errors.d.ts CHANGED
@@ -10,4 +10,10 @@ export declare class EmailConfigurationError extends Error {
10
10
  export declare class EmailMessageValidationError extends Error {
11
11
  constructor(message: string);
12
12
  }
13
+ /**
14
+ * Thrown when email delivery is requested after the service lifecycle has started shutting down.
15
+ */
16
+ export declare class EmailLifecycleError extends Error {
17
+ constructor(message: string, options?: ErrorOptions);
18
+ }
13
19
  //# sourceMappingURL=errors.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B;AAED;;GAEG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI5B"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,KAAK;gBACpC,OAAO,EAAE,MAAM;CAI5B;AAED;;GAEG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;gBACxC,OAAO,EAAE,MAAM;CAI5B;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;gBAChC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY;CAIpD"}
package/dist/errors.js CHANGED
@@ -16,4 +16,14 @@ export class EmailMessageValidationError extends Error {
16
16
  super(message);
17
17
  this.name = 'EmailMessageValidationError';
18
18
  }
19
+ }
20
+
21
+ /**
22
+ * Thrown when email delivery is requested after the service lifecycle has started shutting down.
23
+ */
24
+ export class EmailLifecycleError extends Error {
25
+ constructor(message, options) {
26
+ super(message, options);
27
+ this.name = 'EmailLifecycleError';
28
+ }
19
29
  }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { EmailConfigurationError, EmailMessageValidationError, } from './errors.js';
1
+ export { EmailConfigurationError, EmailLifecycleError, EmailMessageValidationError, } from './errors.js';
2
2
  export { EmailChannel } from './channel.js';
3
3
  export { EmailModule } from './module.js';
4
4
  export { EmailService } from './service.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uBAAuB,EACvB,2BAA2B,GAC5B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,iCAAiC,EAAE,MAAM,aAAa,CAAC;AAChE,YAAY,EAAE,mBAAmB,EAAE,2BAA2B,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAC7G,OAAO,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACnD,YAAY,EACV,KAAK,EACL,YAAY,EACZ,gBAAgB,EAChB,uBAAuB,EACvB,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,gCAAgC,EAChC,wBAAwB,EACxB,oBAAoB,EACpB,gBAAgB,EAChB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EACf,0BAA0B,EAC1B,sBAAsB,EACtB,wBAAwB,EACxB,qBAAqB,EACrB,yBAAyB,EACzB,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uBAAuB,EACvB,mBAAmB,EACnB,2BAA2B,GAC5B,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,iCAAiC,EAAE,MAAM,aAAa,CAAC;AAChE,YAAY,EAAE,mBAAmB,EAAE,2BAA2B,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;AAC7G,OAAO,EAAE,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACnD,YAAY,EACV,KAAK,EACL,YAAY,EACZ,gBAAgB,EAChB,uBAAuB,EACvB,eAAe,EACf,YAAY,EACZ,kBAAkB,EAClB,gCAAgC,EAChC,wBAAwB,EACxB,oBAAoB,EACpB,gBAAgB,EAChB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EACf,0BAA0B,EAC1B,sBAAsB,EACtB,wBAAwB,EACxB,qBAAqB,EACrB,yBAAyB,EACzB,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- export { EmailConfigurationError, EmailMessageValidationError } from './errors.js';
1
+ export { EmailConfigurationError, EmailLifecycleError, EmailMessageValidationError } from './errors.js';
2
2
  export { EmailChannel } from './channel.js';
3
3
  export { EmailModule } from './module.js';
4
4
  export { EmailService } from './service.js';
@@ -1 +1 @@
1
- {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAMhE,OAAO,KAAK,EAGV,uBAAuB,EAGvB,kBAAkB,EAEnB,MAAM,YAAY,CAAC;AAyHpB,kFAAkF;AAClF,qBAAa,WAAW;IACtB;;;;;;;;;;;;;;;;QAgBI;IACJ,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,kBAAkB,GAAG,UAAU;IAIvD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,MAAM,CAAC,YAAY,CAAC,OAAO,EAAE,uBAAuB,GAAG,UAAU;CAGlE"}
1
+ {"version":3,"file":"module.d.ts","sourceRoot":"","sources":["../src/module.ts"],"names":[],"mappings":"AAEA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAMhE,OAAO,KAAK,EAGV,uBAAuB,EAGvB,kBAAkB,EAEnB,MAAM,YAAY,CAAC;AA8HpB,kFAAkF;AAClF,qBAAa,WAAW;IACtB;;;;;;;;;;;;;;;;QAgBI;IACJ,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,kBAAkB,GAAG,UAAU;IAIvD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,MAAM,CAAC,YAAY,CAAC,OAAO,EAAE,uBAAuB,GAAG,UAAU;CAGlE"}
package/dist/module.js CHANGED
@@ -85,7 +85,10 @@ function buildEmailModuleAsync(options) {
85
85
  let cachedResult;
86
86
  const memoizedFactory = (...deps) => {
87
87
  if (!cachedResult) {
88
- cachedResult = Promise.resolve(factory(...deps)).then(resolved => normalizeEmailModuleOptions(resolved));
88
+ cachedResult = Promise.resolve(factory(...deps)).then(resolved => normalizeEmailModuleOptions(resolved)).catch(error => {
89
+ cachedResult = undefined;
90
+ throw error;
91
+ });
89
92
  }
90
93
  return cachedResult;
91
94
  };
@@ -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;AAErB,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;AAoDD;;;;;;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;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,5 +1,6 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import nodemailer from 'nodemailer';
3
+ import { EmailMessageValidationError } from '../errors.js';
3
4
 
4
5
  /** Node-only Nodemailer transporter type used by the explicit `@fluojs/email/node` seam. */
5
6
 
@@ -21,8 +22,21 @@ import nodemailer from 'nodemailer';
21
22
  * isolated to the Node-only subpath.
22
23
  */
23
24
 
25
+ function assertSafeAddressPart(value, label) {
26
+ if (/\r|\n/.test(value)) {
27
+ throw new EmailMessageValidationError(`Nodemailer ${label} must not contain newline characters.`);
28
+ }
29
+ }
24
30
  function createAddress(address) {
25
- return address.name ? `${address.name} <${address.address}>` : address.address;
31
+ assertSafeAddressPart(address.address, 'address');
32
+ if (address.name) {
33
+ assertSafeAddressPart(address.name, 'display name');
34
+ return {
35
+ address: address.address,
36
+ name: address.name
37
+ };
38
+ }
39
+ return address.address;
26
40
  }
27
41
  function createAddressList(addresses) {
28
42
  if (addresses.length === 0) {
package/dist/service.d.ts CHANGED
@@ -11,11 +11,13 @@ import type { Email, EmailMessage, EmailNotificationDispatchRequest, EmailSendBa
11
11
  export declare class EmailService implements Email, OnModuleInit, OnApplicationShutdown {
12
12
  private readonly options;
13
13
  private lifecycleState;
14
+ private bootstrapPromise;
14
15
  private resolvedTransport;
15
16
  private transportPromise;
16
17
  constructor(options: NormalizedEmailModuleOptions);
17
18
  onApplicationShutdown(): Promise<void>;
18
19
  onModuleInit(): Promise<void>;
20
+ private startTransport;
19
21
  /**
20
22
  * Creates a platform status snapshot for the active email transport wiring.
21
23
  *
@@ -75,6 +77,10 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
75
77
  */
76
78
  sendNotification(notification: EmailNotificationDispatchRequest, options?: EmailSendOptions): Promise<EmailSendResult>;
77
79
  private ensureTransport;
80
+ private ensureReadyForDelivery;
81
+ private getLifecycleState;
82
+ private assertCanCreateOrUseTransport;
83
+ private assertCanDeliver;
78
84
  private normalizeMessage;
79
85
  private renderNotification;
80
86
  }
@@ -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,EAC9B,oBAAoB,EAEpB,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EAKf,4BAA4B,EAC7B,MAAM,YAAY,CAAC;AA2CtB;;;;;;;GAOG;AACH,qBACa,YAAa,YAAW,KAAK,EAAE,YAAY,EAAE,qBAAqB;IAKjE,OAAO,CAAC,QAAQ,CAAC,OAAO;IAJpC,OAAO,CAAC,cAAc,CAAmF;IACzG,OAAO,CAAC,iBAAiB,CAA6B;IACtD,OAAO,CAAC,gBAAgB,CAAsC;gBAEjC,OAAO,EAAE,4BAA4B;IAE5D,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAetC,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBnC;;;;OAIG;IACH,4BAA4B;IAY5B;;;;;;;;;;;;;;;;;OAiBG;IACG,IAAI,CAAC,OAAO,EAAE,YAAY,EAAE,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC;IAoB3F;;;;;;;;;;;;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;YA0Bb,eAAe;IAe7B,OAAO,CAAC,gBAAgB;YAuBV,kBAAkB;CAejC"}
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;AAqEpB;;;;;;;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;IAiC5B;;;;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;YAiBf,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
@@ -6,7 +6,7 @@ function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.descrip
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
8
  import { DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS } from './constants.js';
9
- import { EmailMessageValidationError } from './errors.js';
9
+ import { EmailLifecycleError, EmailMessageValidationError } from './errors.js';
10
10
  import { createEmailPlatformStatusSnapshot } from './status.js';
11
11
  import { EMAIL_OPTIONS } from './tokens.js';
12
12
  function normalizeAddress(address) {
@@ -35,10 +35,29 @@ function createAbortError() {
35
35
  error.name = 'AbortError';
36
36
  return error;
37
37
  }
38
+ function assertNotAborted(signal) {
39
+ if (signal?.aborted) {
40
+ throw createAbortError();
41
+ }
42
+ }
43
+ function createLifecycleError(message, cause) {
44
+ return new EmailLifecycleError(message, {
45
+ cause
46
+ });
47
+ }
48
+ function createDeliveryLifecycleError(state) {
49
+ return new EmailLifecycleError(`Email delivery cannot start while the service lifecycle is ${state}.`);
50
+ }
51
+ function isShutdownLifecycleState(state) {
52
+ return state === 'stopping' || state === 'stopped';
53
+ }
38
54
  function assertMessageContent(message) {
39
55
  if (message.to.length === 0) {
40
56
  throw new EmailMessageValidationError('Email messages require at least one recipient in `to`.');
41
57
  }
58
+ if (message.to.some(entry => entry.address.length === 0)) {
59
+ throw new EmailMessageValidationError('Email messages require non-empty recipients in `to`.');
60
+ }
42
61
  if (!message.from.address) {
43
62
  throw new EmailMessageValidationError('Email messages require a resolved `from` address.');
44
63
  }
@@ -61,6 +80,7 @@ class EmailService {
61
80
  [_EmailService, _initClass] = _applyDecs(this, [Inject(EMAIL_OPTIONS)], []).c;
62
81
  }
63
82
  lifecycleState = 'created';
83
+ bootstrapPromise;
64
84
  resolvedTransport;
65
85
  transportPromise;
66
86
  constructor(options) {
@@ -69,26 +89,52 @@ class EmailService {
69
89
  async onApplicationShutdown() {
70
90
  this.lifecycleState = 'stopping';
71
91
  try {
72
- if (this.resolvedTransport && this.options.transport.ownsResources && this.resolvedTransport.close) {
73
- await this.resolvedTransport.close();
92
+ const transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined);
93
+ if (transport && this.options.transport.ownsResources && transport.close) {
94
+ await transport.close();
74
95
  }
75
96
  this.lifecycleState = 'stopped';
76
- } catch {
97
+ } catch (error) {
77
98
  this.lifecycleState = 'failed';
78
- throw new Error('Email transport failed to close cleanly.');
99
+ throw createLifecycleError('Email transport failed to close cleanly.', error);
79
100
  }
80
101
  }
81
102
  async onModuleInit() {
103
+ if (this.bootstrapPromise) {
104
+ return this.bootstrapPromise;
105
+ }
106
+ this.bootstrapPromise = this.startTransport();
107
+ try {
108
+ await this.bootstrapPromise;
109
+ } finally {
110
+ if (this.lifecycleState === 'failed') {
111
+ this.bootstrapPromise = undefined;
112
+ }
113
+ }
114
+ }
115
+ async startTransport() {
116
+ if (this.lifecycleState === 'stopping' || this.lifecycleState === 'stopped') {
117
+ return;
118
+ }
82
119
  this.lifecycleState = 'starting';
83
120
  try {
84
121
  const transport = await this.ensureTransport();
122
+ if (this.lifecycleState !== 'starting') {
123
+ return;
124
+ }
85
125
  if (this.options.verifyOnModuleInit && transport.verify) {
86
126
  await transport.verify();
87
127
  }
128
+ if (this.lifecycleState !== 'starting') {
129
+ return;
130
+ }
88
131
  this.lifecycleState = 'ready';
89
- } catch {
132
+ } catch (error) {
133
+ if (isShutdownLifecycleState(this.lifecycleState)) {
134
+ throw error;
135
+ }
90
136
  this.lifecycleState = 'failed';
91
- throw new Error('Email transport failed to initialize.');
137
+ throw createLifecycleError('Email transport failed to initialize.', error);
92
138
  }
93
139
  }
94
140
 
@@ -128,12 +174,21 @@ class EmailService {
128
174
  * ```
129
175
  */
130
176
  async send(message, options = {}) {
131
- if (options.signal?.aborted) {
132
- throw createAbortError();
177
+ assertNotAborted(options.signal);
178
+ if (this.options.verifyOnModuleInit) {
179
+ await this.ensureReadyForDelivery();
180
+ } else {
181
+ this.assertCanDeliver();
133
182
  }
134
183
  const transport = await this.ensureTransport();
135
184
  const normalized = this.normalizeMessage(message);
136
185
  assertMessageContent(normalized);
186
+ assertNotAborted(options.signal);
187
+ if (this.options.verifyOnModuleInit) {
188
+ await this.ensureReadyForDelivery();
189
+ } else {
190
+ this.assertCanDeliver();
191
+ }
137
192
  const result = await transport.send(normalized, options);
138
193
  return {
139
194
  accepted: result.accepted ?? [],
@@ -202,8 +257,11 @@ class EmailService {
202
257
  * ```
203
258
  */
204
259
  async sendNotification(notification, options = {}) {
260
+ assertNotAborted(options.signal);
261
+ this.assertCanDeliver();
205
262
  const payload = notification.payload;
206
- const rendered = await this.renderNotification(notification);
263
+ const rendered = await this.renderNotification(notification, options.signal);
264
+ assertNotAborted(options.signal);
207
265
  return this.send({
208
266
  attachments: payload.attachments,
209
267
  bcc: payload.bcc,
@@ -225,6 +283,7 @@ class EmailService {
225
283
  }, options);
226
284
  }
227
285
  async ensureTransport() {
286
+ this.assertCanCreateOrUseTransport();
228
287
  if (this.resolvedTransport) {
229
288
  return this.resolvedTransport;
230
289
  }
@@ -236,6 +295,34 @@ class EmailService {
236
295
  }
237
296
  return this.transportPromise;
238
297
  }
298
+ async ensureReadyForDelivery() {
299
+ this.assertCanDeliver();
300
+ if (!this.options.verifyOnModuleInit) {
301
+ return;
302
+ }
303
+ if (this.lifecycleState === 'ready') {
304
+ return;
305
+ }
306
+ if (this.lifecycleState === 'created' || this.lifecycleState === 'starting') {
307
+ await this.onModuleInit();
308
+ }
309
+ this.assertCanDeliver();
310
+ const state = this.getLifecycleState();
311
+ if (state !== 'ready') {
312
+ throw createDeliveryLifecycleError(state);
313
+ }
314
+ }
315
+ getLifecycleState() {
316
+ return this.lifecycleState;
317
+ }
318
+ assertCanCreateOrUseTransport() {
319
+ if (this.lifecycleState === 'stopping' || this.lifecycleState === 'stopped' || this.lifecycleState === 'failed') {
320
+ throw createDeliveryLifecycleError(this.lifecycleState);
321
+ }
322
+ }
323
+ assertCanDeliver() {
324
+ this.assertCanCreateOrUseTransport();
325
+ }
239
326
  normalizeMessage(message) {
240
327
  const from = message.from ? normalizeAddress(message.from) : this.options.defaultFrom;
241
328
  const replyTo = normalizeAddressList(message.replyTo);
@@ -256,10 +343,11 @@ class EmailService {
256
343
  to: normalizeAddressList(message.to)
257
344
  };
258
345
  }
259
- async renderNotification(notification) {
346
+ async renderNotification(notification, signal) {
260
347
  if (!notification.template || !this.options.renderer) {
261
348
  return undefined;
262
349
  }
350
+ assertNotAborted(signal);
263
351
  return this.options.renderer.render({
264
352
  locale: notification.locale,
265
353
  metadata: notification.metadata,
package/package.json CHANGED
@@ -10,7 +10,7 @@
10
10
  "queue",
11
11
  "mailer"
12
12
  ],
13
- "version": "1.0.0-beta.3",
13
+ "version": "1.0.0-beta.5",
14
14
  "private": false,
15
15
  "license": "MIT",
16
16
  "repository": {
@@ -52,14 +52,14 @@
52
52
  "dist"
53
53
  ],
54
54
  "dependencies": {
55
- "@fluojs/core": "^1.0.0-beta.4",
56
- "@fluojs/di": "^1.0.0-beta.6",
57
- "@fluojs/notifications": "^1.0.0-beta.3",
58
- "@fluojs/runtime": "^1.0.0-beta.11"
55
+ "@fluojs/core": "^1.0.0-beta.6",
56
+ "@fluojs/notifications": "^1.0.0-beta.5",
57
+ "@fluojs/di": "^1.0.0-beta.8",
58
+ "@fluojs/runtime": "^1.0.0-beta.12"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "nodemailer": "^6.10.1",
62
- "@fluojs/queue": "^1.0.0-beta.4"
62
+ "@fluojs/queue": "^1.0.0-beta.5"
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-beta.4"
75
+ "@fluojs/queue": "^1.0.0-beta.5"
76
76
  },
77
77
  "scripts": {
78
78
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",