@fluojs/email 1.0.2 → 2.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
@@ -14,6 +14,7 @@ fluo를 위한 transport-agnostic 이메일 코어 패키지입니다. Nest-like
14
14
  - [`@fluojs/email/node`를 이용한 Node 전용 SMTP](#fluojs-email-node를-이용한-node-전용-smtp)
15
15
  - [`EmailService`를 이용한 standalone 전달](#emailservice를-이용한-standalone-전달)
16
16
  - [`@fluojs/notifications`와의 통합](#fluojs-notifications와의-통합)
17
+ - [Template renderer 설정](#template-renderer-설정)
17
18
  - [큐 기반 대량 전달](#큐-기반-대량-전달)
18
19
  - [의도적인 제한 사항](#의도적인-제한-사항)
19
20
  - [공개 API 개요](#공개-api-개요)
@@ -195,7 +196,7 @@ Behavioral contract 메모:
195
196
  - `EmailService.createPlatformStatusSnapshot()`은 diagnostics를 위해 lifecycle, readiness, health, transport ownership details를 노출합니다.
196
197
  - 서비스는 모듈 bootstrap 시 transport를 초기화하며, `verifyOnModuleInit: true`인 경우 bootstrap 검증이 성공적으로 끝날 때까지 delivery가 transport handoff로 진행되지 않습니다.
197
198
  - 거부된 `forRootAsync(...)` 옵션 factory 결과는 영구 memoize되지 않으며, 다음 provider resolution에서 configuration lookup을 다시 시도할 수 있습니다.
198
- - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다리고, 활성 transport `verify()` / `send()` 호출을 drain한 뒤 소유 transport를 닫습니다.
199
+ - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다리고, 활성 transport `verify()` / `send()` 호출을 drain한 뒤 소유 transport를 닫습니다. 동시에 또는 반복해서 shutdown을 호출하면 동일한 완료 Promise를 공유하므로 소유 transport는 최대 한 번만 닫힙니다.
199
200
  - transport `verify()`와 `close()`에서 발생한 provider error는 diagnostics를 위해 lifecycle failure의 `cause`로 보존됩니다.
200
201
  - 모듈 옵션은 provider wiring 전에 trim 및 normalize됩니다. 여기에는 sender 기본값, notification channel 이름, transport factory 소유권이 포함됩니다.
201
202
  - `EmailModule.forRoot(...)`와 `EmailModule.forRootAsync(...)`는 기본적으로 global입니다. module-local visibility가 필요할 때만 `global: false`를 사용합니다.
@@ -235,7 +236,7 @@ export class AppModule {}
235
236
  지원하는 notification payload 필드:
236
237
 
237
238
  - `to`, `cc`, `bcc`, `from`, `replyTo`
238
- - `text`, `html`, `attachments`, `headers`
239
+ - `text`, `html`, `attachments`, `headers`, `metadata`
239
240
  - 모듈에 renderer가 구성된 경우 `templateData`
240
241
 
241
242
  Behavioral contract 메모:
@@ -244,6 +245,90 @@ Behavioral contract 메모:
244
245
  - `EmailService.sendNotification(...)`은 렌더링된 template output을 payload 및 notification metadata와 병합합니다. payload 필드는 notification fallback보다 우선합니다.
245
246
  - Template rendering에는 notification `payload`, `metadata`, `locale`, `subject`, `template`이 전달되며, payload `text`, `html`과 notification `subject`가 렌더링된 fallback보다 우선합니다.
246
247
 
248
+ ### Template renderer 설정
249
+
250
+ `EmailModule`을 등록할 때 필수 transport와 함께 `EmailTemplateRenderer`를 전달합니다. 아래의 완전한 Node.js 예제는 1st-party SMTP factory를 사용합니다. 다른 런타임에서는 renderer를 그대로 두고 `transport`만 애플리케이션이 소유한 `EmailTransport` 또는 `EmailTransportFactory`로 교체하세요.
251
+
252
+ ```typescript
253
+ import { Module } from '@fluojs/core';
254
+ import { EmailModule, type EmailTemplateRenderer } from '@fluojs/email';
255
+ import { createNodemailerEmailTransportFactory } from '@fluojs/email/node';
256
+
257
+ const renderer: EmailTemplateRenderer = {
258
+ render({ payload, template }) {
259
+ const name = payload.templateData?.name;
260
+ const displayName = typeof name === 'string' ? name : 'customer';
261
+
262
+ return {
263
+ html: '<h1>Welcome</h1>',
264
+ subject: template === 'welcome' ? `Welcome, ${displayName}` : template,
265
+ text: `Hello, ${displayName}`,
266
+ };
267
+ },
268
+ };
269
+
270
+ @Module({
271
+ imports: [
272
+ EmailModule.forRoot({
273
+ defaultFrom: 'noreply@example.com',
274
+ renderer,
275
+ transport: createNodemailerEmailTransportFactory({
276
+ smtp: {
277
+ auth: {
278
+ pass: 'smtp-password',
279
+ user: 'smtp-user',
280
+ },
281
+ host: 'smtp.example.com',
282
+ port: 587,
283
+ secure: false,
284
+ },
285
+ }),
286
+ verifyOnModuleInit: true,
287
+ }),
288
+ ],
289
+ })
290
+ export class AppModule {}
291
+ ```
292
+
293
+ `EmailService.sendNotification(...)`을 호출할 때 template key를 전달하고 renderer 전용 값은 `payload.templateData` 아래에 둡니다. 위에서 설명한 대로 `EMAIL_CHANNEL`을 등록한 뒤에는 같은 request shape를 `NotificationsService.dispatch(...)`에서도 사용할 수 있습니다.
294
+
295
+ ```typescript
296
+ import { Inject } from '@fluojs/core';
297
+ import { EmailService } from '@fluojs/email';
298
+
299
+ @Inject(EmailService)
300
+ export class WelcomeEmailService {
301
+ constructor(private readonly email: EmailService) {}
302
+
303
+ async sendRenderedWelcome(address: string, name: string) {
304
+ await this.email.sendNotification({
305
+ channel: 'email',
306
+ recipients: [address],
307
+ template: 'welcome',
308
+ payload: {
309
+ templateData: { name },
310
+ },
311
+ });
312
+ }
313
+
314
+ async sendWelcomeWithOverrides(address: string, name: string) {
315
+ await this.email.sendNotification({
316
+ channel: 'email',
317
+ recipients: [address],
318
+ subject: 'Your account is ready',
319
+ template: 'welcome',
320
+ payload: {
321
+ html: '<p>Your account is ready.</p>',
322
+ templateData: { name },
323
+ text: 'Use this exact welcome message.',
324
+ },
325
+ });
326
+ }
327
+ }
328
+ ```
329
+
330
+ `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를 해석하지 않습니다.
331
+
247
332
  ### 큐 기반 대량 전달
248
333
 
249
334
  `@fluojs/notifications`가 대량 이메일 전달을 백그라운드로 넘겨야 한다면 `QueueModule`을 import하고, `QueueLifecycleService`를 주입해 `createEmailNotificationsQueueAdapter(queue)`를 만든 뒤, `EmailNotificationsQueueWorker`를 애플리케이션 provider로 등록합니다. 루트 `EmailModule`은 worker를 자동 등록하지 않으므로 `@fluojs/email/queue`를 import하지 않는 애플리케이션은 런타임에서 `@fluojs/queue`를 필요로 하지 않습니다.
@@ -299,6 +384,7 @@ Behavioral contract 메모:
299
384
 
300
385
  - Queue 지원은 opt-in입니다. 루트 `@fluojs/email` 엔트리포인트와 `EmailModule`은 `@fluojs/queue`를 import하거나 `EmailNotificationsQueueWorker`를 등록하거나 queue peer 설치를 요구하지 않습니다.
301
386
  - `EmailNotificationsQueueWorker`는 `@fluojs/email/queue`에서 export되며, queue 기반 전달을 활성화하는 애플리케이션이 직접 등록해야 합니다.
387
+ - worker는 transport handoff 전에 queued notification channel이 구성된 `EmailChannel.channel`과 정확히 일치하는지 확인합니다. 일치하지 않으면 `EmailMessageValidationError`로 실패하므로 non-email 작업이 email transport에 도달하지 않습니다.
302
388
  - worker는 `EmailChannel` 전달 semantics를 재사용하므로 transport가 수락된 수신자 0명 또는 `pending`/`rejected` 수신자를 보고하면 queued job이 실패합니다. 따라서 incomplete delivery는 성공한 job으로 승인되지 않고 `@fluojs/queue`의 retry/dead-letter 흐름으로 넘어갑니다.
303
389
 
304
390
  ### 의도적인 제한 사항
package/README.md CHANGED
@@ -14,6 +14,7 @@ Transport-agnostic email delivery core for fluo. It provides a Nest-like module
14
14
  - [Node-only SMTP with `@fluojs/email/node`](#node-only-smtp-with-fluojs-email-node)
15
15
  - [Standalone delivery with `EmailService`](#standalone-delivery-with-emailservice)
16
16
  - [Integration with `@fluojs/notifications`](#integration-with-fluojs-notifications)
17
+ - [Template renderer setup](#template-renderer-setup)
17
18
  - [Queue-backed bulk delivery](#queue-backed-bulk-delivery)
18
19
  - [Intentional limitations](#intentional-limitations)
19
20
  - [Public API Overview](#public-api-overview)
@@ -195,7 +196,7 @@ Behavioral contract notes:
195
196
  - `EmailService.createPlatformStatusSnapshot()` exposes lifecycle, readiness, health, and transport ownership details for diagnostics.
196
197
  - 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
198
  - 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.
199
+ - 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
200
  - Transport `verify()` and `close()` provider errors are preserved as the `cause` of lifecycle failures for diagnostics.
200
201
  - Module options are trimmed and normalized before provider wiring, including sender defaults, notification channel names, and transport factory ownership.
201
202
  - `EmailModule.forRoot(...)` and `EmailModule.forRootAsync(...)` are global by default. Use `global: false` to opt into module-local visibility.
@@ -235,7 +236,7 @@ export class AppModule {}
235
236
  Supported notification payload fields:
236
237
 
237
238
  - `to`, `cc`, `bcc`, `from`, `replyTo`
238
- - `text`, `html`, `attachments`, `headers`
239
+ - `text`, `html`, `attachments`, `headers`, `metadata`
239
240
  - `templateData` when a renderer is configured on the module
240
241
 
241
242
  Behavioral contract notes:
@@ -244,6 +245,90 @@ Behavioral contract notes:
244
245
  - `EmailService.sendNotification(...)` merges rendered template output with payload and notification metadata; payload fields override notification fallbacks.
245
246
  - Template rendering receives notification `payload`, `metadata`, `locale`, `subject`, and `template`; payload `text`, `html`, and notification `subject` override rendered fallbacks.
246
247
 
248
+ ### Template renderer setup
249
+
250
+ 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`.
251
+
252
+ ```typescript
253
+ import { Module } from '@fluojs/core';
254
+ import { EmailModule, type EmailTemplateRenderer } from '@fluojs/email';
255
+ import { createNodemailerEmailTransportFactory } from '@fluojs/email/node';
256
+
257
+ const renderer: EmailTemplateRenderer = {
258
+ render({ payload, template }) {
259
+ const name = payload.templateData?.name;
260
+ const displayName = typeof name === 'string' ? name : 'customer';
261
+
262
+ return {
263
+ html: '<h1>Welcome</h1>',
264
+ subject: template === 'welcome' ? `Welcome, ${displayName}` : template,
265
+ text: `Hello, ${displayName}`,
266
+ };
267
+ },
268
+ };
269
+
270
+ @Module({
271
+ imports: [
272
+ EmailModule.forRoot({
273
+ defaultFrom: 'noreply@example.com',
274
+ renderer,
275
+ transport: createNodemailerEmailTransportFactory({
276
+ smtp: {
277
+ auth: {
278
+ pass: 'smtp-password',
279
+ user: 'smtp-user',
280
+ },
281
+ host: 'smtp.example.com',
282
+ port: 587,
283
+ secure: false,
284
+ },
285
+ }),
286
+ verifyOnModuleInit: true,
287
+ }),
288
+ ],
289
+ })
290
+ export class AppModule {}
291
+ ```
292
+
293
+ 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.
294
+
295
+ ```typescript
296
+ import { Inject } from '@fluojs/core';
297
+ import { EmailService } from '@fluojs/email';
298
+
299
+ @Inject(EmailService)
300
+ export class WelcomeEmailService {
301
+ constructor(private readonly email: EmailService) {}
302
+
303
+ async sendRenderedWelcome(address: string, name: string) {
304
+ await this.email.sendNotification({
305
+ channel: 'email',
306
+ recipients: [address],
307
+ template: 'welcome',
308
+ payload: {
309
+ templateData: { name },
310
+ },
311
+ });
312
+ }
313
+
314
+ async sendWelcomeWithOverrides(address: string, name: string) {
315
+ await this.email.sendNotification({
316
+ channel: 'email',
317
+ recipients: [address],
318
+ subject: 'Your account is ready',
319
+ template: 'welcome',
320
+ payload: {
321
+ html: '<p>Your account is ready.</p>',
322
+ templateData: { name },
323
+ text: 'Use this exact welcome message.',
324
+ },
325
+ });
326
+ }
327
+ }
328
+ ```
329
+
330
+ 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.
331
+
247
332
  ### Queue-backed bulk delivery
248
333
 
249
334
  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 +384,7 @@ Behavioral contract notes:
299
384
 
300
385
  - 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
386
  - `EmailNotificationsQueueWorker` is exported from `@fluojs/email/queue` and must be registered by applications that enable queue-backed delivery.
387
+ - 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
388
  - 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
389
 
304
390
  ### Intentional limitations
package/dist/queue.d.ts CHANGED
@@ -46,6 +46,7 @@ export declare class EmailNotificationsQueueWorker {
46
46
  *
47
47
  * @param job Queued notification payload created by `createEmailNotificationsQueueAdapter(...)`.
48
48
  * @returns A promise that resolves only when email delivery is accepted by the channel contract.
49
+ * @throws {EmailMessageValidationError} When the queued notification targets another configured channel.
49
50
  */
50
51
  handle(job: EmailNotificationQueueJob): Promise<void>;
51
52
  }
@@ -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,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"}
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
 
@@ -71,8 +72,13 @@ class EmailNotificationsQueueWorker {
71
72
  *
72
73
  * @param job Queued notification payload created by `createEmailNotificationsQueueAdapter(...)`.
73
74
  * @returns A promise that resolves only when email delivery is accepted by the channel contract.
75
+ * @throws {EmailMessageValidationError} When the queued notification targets another configured channel.
74
76
  */
75
77
  async handle(job) {
78
+ const expectedChannel = this.channel.channel;
79
+ if (job.notification.channel !== expectedChannel) {
80
+ throw new EmailMessageValidationError(`Queued notification channel "${job.notification.channel}" does not match configured email channel "${expectedChannel}".`);
81
+ }
76
82
  await this.channel.send(job.notification, {});
77
83
  }
78
84
  static {
package/dist/service.d.ts CHANGED
@@ -12,11 +12,13 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
12
12
  private readonly options;
13
13
  private lifecycleState;
14
14
  private bootstrapPromise;
15
+ private shutdownPromise;
15
16
  private readonly inFlightOperations;
16
17
  private resolvedTransport;
17
18
  private transportPromise;
18
19
  constructor(options: NormalizedEmailModuleOptions);
19
20
  onApplicationShutdown(): Promise<void>;
21
+ private shutdown;
20
22
  onModuleInit(): Promise<void>;
21
23
  private startTransport;
22
24
  /**
@@ -79,6 +81,7 @@ export declare class EmailService implements Email, OnModuleInit, OnApplicationS
79
81
  sendNotification(notification: EmailNotificationDispatchRequest, options?: EmailSendOptions): Promise<EmailSendResult>;
80
82
  private ensureTransport;
81
83
  private clearResolvedTransport;
84
+ private handleTransportInitializationFailure;
82
85
  private drainInFlightOperations;
83
86
  private trackInFlightOperation;
84
87
  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;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"}
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,16 +86,26 @@ class EmailService {
84
86
  }
85
87
  lifecycleState = 'created';
86
88
  bootstrapPromise;
89
+ shutdownPromise;
87
90
  inFlightOperations = new Set();
88
91
  resolvedTransport;
89
92
  transportPromise;
90
93
  constructor(options) {
91
94
  this.options = options;
92
95
  }
93
- async onApplicationShutdown() {
96
+ onApplicationShutdown() {
97
+ this.shutdownPromise ??= this.shutdown();
98
+ return this.shutdownPromise;
99
+ }
100
+ async shutdown() {
94
101
  this.lifecycleState = 'stopping';
102
+ let transport;
103
+ try {
104
+ transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined);
105
+ } catch (error) {
106
+ await this.handleTransportInitializationFailure(error);
107
+ }
95
108
  try {
96
- const transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined);
97
109
  await this.drainInFlightOperations();
98
110
  if (transport && this.options.transport.ownsResources && transport.close) {
99
111
  await transport.close();
@@ -135,22 +147,9 @@ class EmailService {
135
147
  }
136
148
  this.lifecycleState = 'ready';
137
149
  } 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);
150
+ await this.handleTransportInitializationFailure(error, {
151
+ preserveShutdownState: true
152
+ });
154
153
  }
155
154
  }
156
155
 
@@ -165,7 +164,6 @@ class EmailService {
165
164
  defaultFromConfigured: this.options.defaultFrom !== undefined,
166
165
  lifecycleState: this.lifecycleState,
167
166
  ownsTransportResources: this.options.transport.ownsResources,
168
- queueWorkerJobName: DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS.jobName,
169
167
  transportKind: this.options.transport.kind,
170
168
  verifiedOnModuleInit: this.options.verifyOnModuleInit
171
169
  });
@@ -309,12 +307,36 @@ 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
+ async handleTransportInitializationFailure(error, options = {}) {
321
+ if (isTransportInitializationLifecycleError(error)) {
322
+ throw error;
323
+ }
324
+ if (options.preserveShutdownState && isShutdownLifecycleState(this.lifecycleState)) {
325
+ throw error;
326
+ }
327
+ this.lifecycleState = 'failed';
328
+ let cause = error;
329
+ const transport = this.resolvedTransport;
330
+ if (transport && this.options.transport.ownsResources && transport.close) {
331
+ try {
332
+ await transport.close();
333
+ } catch (cleanupError) {
334
+ cause = createCleanupFailureCause(error, cleanupError);
335
+ }
336
+ }
337
+ this.clearResolvedTransport();
338
+ throw createLifecycleError('Email transport failed to initialize.', cause);
339
+ }
318
340
  async drainInFlightOperations() {
319
341
  while (this.inFlightOperations.size > 0) {
320
342
  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": "2.0.0",
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.3",
56
- "@fluojs/di": "^1.1.0",
57
- "@fluojs/notifications": "^1.0.2",
58
- "@fluojs/runtime": "^1.1.8"
55
+ "@fluojs/core": "^1.1.0",
56
+ "@fluojs/di": "^2.0.0",
57
+ "@fluojs/notifications": "^1.0.3",
58
+ "@fluojs/runtime": "^2.0.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "nodemailer": "^6.10.1",
62
- "@fluojs/queue": "^1.0.2"
62
+ "@fluojs/queue": "^2.0.0"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "@fluojs/queue": {
@@ -72,7 +72,8 @@
72
72
  "devDependencies": {
73
73
  "@types/nodemailer": "^8.0.0",
74
74
  "vitest": "^3.2.4",
75
- "@fluojs/queue": "^1.0.2"
75
+ "@fluojs/queue": "^2.0.0",
76
+ "@fluojs/testing": "^2.0.0"
76
77
  },
77
78
  "scripts": {
78
79
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",