@fluojs/email
Advanced tools
+1
-0
@@ -49,2 +49,3 @@ import type { NotificationsQueueAdapter } from '@fluojs/notifications'; | ||
| * @returns A promise that resolves only when email delivery is accepted by the channel contract. | ||
| * @throws {EmailMessageValidationError} When the queued notification targets another configured channel. | ||
| */ | ||
@@ -51,0 +52,0 @@ handle(job: EmailNotificationQueueJob): Promise<void>; |
@@ -1,1 +0,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"} | ||
| {"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"} |
+6
-0
@@ -11,2 +11,3 @@ let _initClass; | ||
| import { EmailChannel } from './channel.js'; | ||
| import { EmailMessageValidationError } from './errors.js'; | ||
@@ -75,4 +76,9 @@ /** Queue worker execution defaults used by the built-in notifications queue integration. */ | ||
| * @returns A promise that resolves only when email delivery is accepted by the channel contract. | ||
| * @throws {EmailMessageValidationError} When the queued notification targets another configured channel. | ||
| */ | ||
| async handle(job) { | ||
| const expectedChannel = this.channel.channel; | ||
| if (job.notification.channel !== expectedChannel) { | ||
| throw new EmailMessageValidationError(`Queued notification channel "${job.notification.channel}" does not match configured email channel "${expectedChannel}".`); | ||
| } | ||
| await this.channel.send(job.notification, {}); | ||
@@ -79,0 +85,0 @@ } |
@@ -15,2 +15,3 @@ import type { OnApplicationShutdown, OnModuleInit } from '@fluojs/runtime'; | ||
| private bootstrapPromise; | ||
| private shutdownPromise; | ||
| private readonly inFlightOperations; | ||
@@ -21,2 +22,3 @@ private resolvedTransport; | ||
| onApplicationShutdown(): Promise<void>; | ||
| private shutdown; | ||
| onModuleInit(): Promise<void>; | ||
@@ -84,2 +86,3 @@ private startTransport; | ||
| private clearResolvedTransport; | ||
| private handleTransportInitializationFailure; | ||
| private drainInFlightOperations; | ||
@@ -86,0 +89,0 @@ private trackInFlightOperation; |
@@ -1,1 +0,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"} | ||
| {"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"} |
+43
-21
@@ -8,3 +8,2 @@ let _initClass; | ||
| import { Inject } from '@fluojs/core'; | ||
| import { DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS } from './constants.js'; | ||
| import { EmailLifecycleError, EmailMessageValidationError } from './errors.js'; | ||
@@ -49,2 +48,5 @@ import { createEmailPlatformStatusSnapshot } from './status.js'; | ||
| } | ||
| function isTransportInitializationLifecycleError(error) { | ||
| return error instanceof EmailLifecycleError && error.message === 'Email transport failed to initialize.'; | ||
| } | ||
| function createDeliveryLifecycleError(state) { | ||
@@ -89,2 +91,3 @@ return new EmailLifecycleError(`Email delivery cannot start while the service lifecycle is ${state}.`); | ||
| bootstrapPromise; | ||
| shutdownPromise; | ||
| inFlightOperations = new Set(); | ||
@@ -96,6 +99,15 @@ resolvedTransport; | ||
| } | ||
| async onApplicationShutdown() { | ||
| onApplicationShutdown() { | ||
| this.shutdownPromise ??= this.shutdown(); | ||
| return this.shutdownPromise; | ||
| } | ||
| async shutdown() { | ||
| this.lifecycleState = 'stopping'; | ||
| let transport; | ||
| try { | ||
| const transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined); | ||
| transport = this.resolvedTransport ?? (this.transportPromise ? await this.transportPromise : undefined); | ||
| } catch (error) { | ||
| await this.handleTransportInitializationFailure(error); | ||
| } | ||
| try { | ||
| await this.drainInFlightOperations(); | ||
@@ -142,18 +154,5 @@ if (transport && this.options.transport.ownsResources && transport.close) { | ||
| } catch (error) { | ||
| if (isShutdownLifecycleState(this.lifecycleState)) { | ||
| throw error; | ||
| } | ||
| this.lifecycleState = 'failed'; | ||
| let cause = error; | ||
| const transport = this.resolvedTransport; | ||
| if (transport && this.options.transport.ownsResources && transport.close) { | ||
| try { | ||
| await transport.close(); | ||
| } catch (cleanupError) { | ||
| cause = createCleanupFailureCause(error, cleanupError); | ||
| } finally { | ||
| this.clearResolvedTransport(); | ||
| } | ||
| } | ||
| throw createLifecycleError('Email transport failed to initialize.', cause); | ||
| await this.handleTransportInitializationFailure(error, { | ||
| preserveShutdownState: true | ||
| }); | ||
| } | ||
@@ -173,3 +172,2 @@ } | ||
| ownsTransportResources: this.options.transport.ownsResources, | ||
| queueWorkerJobName: DEFAULT_EMAIL_QUEUE_WORKER_OPTIONS.jobName, | ||
| transportKind: this.options.transport.kind, | ||
@@ -318,3 +316,7 @@ verifiedOnModuleInit: this.options.verifyOnModuleInit | ||
| } | ||
| return this.transportPromise; | ||
| try { | ||
| return await this.transportPromise; | ||
| } catch (error) { | ||
| return await this.handleTransportInitializationFailure(error); | ||
| } | ||
| } | ||
@@ -325,2 +327,22 @@ clearResolvedTransport() { | ||
| } | ||
| async handleTransportInitializationFailure(error, options = {}) { | ||
| if (isTransportInitializationLifecycleError(error)) { | ||
| throw error; | ||
| } | ||
| if (options.preserveShutdownState && isShutdownLifecycleState(this.lifecycleState)) { | ||
| throw error; | ||
| } | ||
| this.lifecycleState = 'failed'; | ||
| let cause = error; | ||
| const transport = this.resolvedTransport; | ||
| if (transport && this.options.transport.ownsResources && transport.close) { | ||
| try { | ||
| await transport.close(); | ||
| } catch (cleanupError) { | ||
| cause = createCleanupFailureCause(error, cleanupError); | ||
| } | ||
| } | ||
| this.clearResolvedTransport(); | ||
| throw createLifecycleError('Email transport failed to initialize.', cause); | ||
| } | ||
| async drainInFlightOperations() { | ||
@@ -327,0 +349,0 @@ while (this.inFlightOperations.size > 0) { |
+1
-1
@@ -10,3 +10,3 @@ import type { PlatformHealthReport, PlatformReadinessReport, PlatformSnapshot } from '@fluojs/runtime'; | ||
| ownsTransportResources: boolean; | ||
| queueWorkerJobName: string; | ||
| queueWorkerJobName?: string; | ||
| transportKind: string; | ||
@@ -13,0 +13,0 @@ verifiedOnModuleInit: boolean; |
@@ -1,1 +0,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"} | ||
| {"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"} |
+3
-1
@@ -72,3 +72,5 @@ /** Resolved email lifecycle state used for diagnostics and health checks. */ | ||
| lifecycleState: input.lifecycleState, | ||
| queueWorkerJobName: input.queueWorkerJobName, | ||
| ...(input.queueWorkerJobName !== undefined ? { | ||
| queueWorkerJobName: input.queueWorkerJobName | ||
| } : {}), | ||
| transportKind: input.transportKind, | ||
@@ -75,0 +77,0 @@ verifiedOnModuleInit: input.verifiedOnModuleInit |
+8
-7
@@ -13,3 +13,3 @@ { | ||
| ], | ||
| "version": "1.0.2", | ||
| "version": "2.0.0", | ||
| "private": false, | ||
@@ -56,10 +56,10 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@fluojs/core": "^1.0.3", | ||
| "@fluojs/di": "^1.1.0", | ||
| "@fluojs/notifications": "^1.0.2", | ||
| "@fluojs/runtime": "^1.1.8" | ||
| "@fluojs/core": "^1.1.0", | ||
| "@fluojs/di": "^2.0.0", | ||
| "@fluojs/notifications": "^1.0.3", | ||
| "@fluojs/runtime": "^2.0.1" | ||
| }, | ||
| "peerDependencies": { | ||
| "nodemailer": "^6.10.1", | ||
| "@fluojs/queue": "^1.0.2" | ||
| "@fluojs/queue": "^2.0.0" | ||
| }, | ||
@@ -77,3 +77,4 @@ "peerDependenciesMeta": { | ||
| "vitest": "^3.2.4", | ||
| "@fluojs/queue": "^1.0.2" | ||
| "@fluojs/queue": "^2.0.0", | ||
| "@fluojs/testing": "^2.0.0" | ||
| }, | ||
@@ -80,0 +81,0 @@ "scripts": { |
+88
-2
@@ -17,2 +17,3 @@ # @fluojs/email | ||
| - [`@fluojs/notifications`와의 통합](#fluojs-notifications와의-통합) | ||
| - [Template renderer 설정](#template-renderer-설정) | ||
| - [큐 기반 대량 전달](#큐-기반-대량-전달) | ||
@@ -199,3 +200,3 @@ - [의도적인 제한 사항](#의도적인-제한-사항) | ||
| - 거부된 `forRootAsync(...)` 옵션 factory 결과는 영구 memoize되지 않으며, 다음 provider resolution에서 configuration lookup을 다시 시도할 수 있습니다. | ||
| - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다리고, 활성 transport `verify()` / `send()` 호출을 drain한 뒤 소유 transport를 닫습니다. | ||
| - shutdown이 시작된 뒤에는 `EmailService.send(...)`와 `EmailService.sendNotification(...)`이 transport를 재사용하거나 lazy 생성하지 않고 `EmailLifecycleError`로 실패합니다. 진행 중인 factory 소유 transport 생성은 shutdown이 기다리고, 활성 transport `verify()` / `send()` 호출을 drain한 뒤 소유 transport를 닫습니다. 동시에 또는 반복해서 shutdown을 호출하면 동일한 완료 Promise를 공유하므로 소유 transport는 최대 한 번만 닫힙니다. | ||
| - transport `verify()`와 `close()`에서 발생한 provider error는 diagnostics를 위해 lifecycle failure의 `cause`로 보존됩니다. | ||
@@ -240,3 +241,3 @@ - 모듈 옵션은 provider wiring 전에 trim 및 normalize됩니다. 여기에는 sender 기본값, notification channel 이름, transport factory 소유권이 포함됩니다. | ||
| - `to`, `cc`, `bcc`, `from`, `replyTo` | ||
| - `text`, `html`, `attachments`, `headers` | ||
| - `text`, `html`, `attachments`, `headers`, `metadata` | ||
| - 모듈에 renderer가 구성된 경우 `templateData` | ||
@@ -250,2 +251,86 @@ | ||
| ### Template renderer 설정 | ||
| `EmailModule`을 등록할 때 필수 transport와 함께 `EmailTemplateRenderer`를 전달합니다. 아래의 완전한 Node.js 예제는 1st-party SMTP factory를 사용합니다. 다른 런타임에서는 renderer를 그대로 두고 `transport`만 애플리케이션이 소유한 `EmailTransport` 또는 `EmailTransportFactory`로 교체하세요. | ||
| ```typescript | ||
| import { Module } from '@fluojs/core'; | ||
| import { EmailModule, type EmailTemplateRenderer } from '@fluojs/email'; | ||
| import { createNodemailerEmailTransportFactory } from '@fluojs/email/node'; | ||
| const renderer: EmailTemplateRenderer = { | ||
| render({ payload, template }) { | ||
| const name = payload.templateData?.name; | ||
| const displayName = typeof name === 'string' ? name : 'customer'; | ||
| return { | ||
| html: '<h1>Welcome</h1>', | ||
| subject: template === 'welcome' ? `Welcome, ${displayName}` : template, | ||
| text: `Hello, ${displayName}`, | ||
| }; | ||
| }, | ||
| }; | ||
| @Module({ | ||
| imports: [ | ||
| EmailModule.forRoot({ | ||
| defaultFrom: 'noreply@example.com', | ||
| renderer, | ||
| transport: createNodemailerEmailTransportFactory({ | ||
| smtp: { | ||
| auth: { | ||
| pass: 'smtp-password', | ||
| user: 'smtp-user', | ||
| }, | ||
| host: 'smtp.example.com', | ||
| port: 587, | ||
| secure: false, | ||
| }, | ||
| }), | ||
| verifyOnModuleInit: true, | ||
| }), | ||
| ], | ||
| }) | ||
| export class AppModule {} | ||
| ``` | ||
| `EmailService.sendNotification(...)`을 호출할 때 template key를 전달하고 renderer 전용 값은 `payload.templateData` 아래에 둡니다. 위에서 설명한 대로 `EMAIL_CHANNEL`을 등록한 뒤에는 같은 request shape를 `NotificationsService.dispatch(...)`에서도 사용할 수 있습니다. | ||
| ```typescript | ||
| import { Inject } from '@fluojs/core'; | ||
| import { EmailService } from '@fluojs/email'; | ||
| @Inject(EmailService) | ||
| export class WelcomeEmailService { | ||
| constructor(private readonly email: EmailService) {} | ||
| async sendRenderedWelcome(address: string, name: string) { | ||
| await this.email.sendNotification({ | ||
| channel: 'email', | ||
| recipients: [address], | ||
| template: 'welcome', | ||
| payload: { | ||
| templateData: { name }, | ||
| }, | ||
| }); | ||
| } | ||
| async sendWelcomeWithOverrides(address: string, name: string) { | ||
| await this.email.sendNotification({ | ||
| channel: 'email', | ||
| recipients: [address], | ||
| subject: 'Your account is ready', | ||
| template: 'welcome', | ||
| payload: { | ||
| html: '<p>Your account is ready.</p>', | ||
| templateData: { name }, | ||
| text: 'Use this exact welcome message.', | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| ``` | ||
| `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를 해석하지 않습니다. | ||
| ### 큐 기반 대량 전달 | ||
@@ -306,2 +391,3 @@ | ||
| - `EmailNotificationsQueueWorker`는 `@fluojs/email/queue`에서 export되며, queue 기반 전달을 활성화하는 애플리케이션이 직접 등록해야 합니다. | ||
| - worker는 transport handoff 전에 queued notification channel이 구성된 `EmailChannel.channel`과 정확히 일치하는지 확인합니다. 일치하지 않으면 `EmailMessageValidationError`로 실패하므로 non-email 작업이 email transport에 도달하지 않습니다. | ||
| - worker는 `EmailChannel` 전달 semantics를 재사용하므로 transport가 수락된 수신자 0명 또는 `pending`/`rejected` 수신자를 보고하면 queued job이 실패합니다. 따라서 incomplete delivery는 성공한 job으로 승인되지 않고 `@fluojs/queue`의 retry/dead-letter 흐름으로 넘어갑니다. | ||
@@ -308,0 +394,0 @@ |
+88
-2
@@ -17,2 +17,3 @@ # @fluojs/email | ||
| - [Integration with `@fluojs/notifications`](#integration-with-fluojs-notifications) | ||
| - [Template renderer setup](#template-renderer-setup) | ||
| - [Queue-backed bulk delivery](#queue-backed-bulk-delivery) | ||
@@ -199,3 +200,3 @@ - [Intentional limitations](#intentional-limitations) | ||
| - Rejected `forRootAsync(...)` option factories are not memoized permanently; the next provider resolution can retry configuration lookup. | ||
| - 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. | ||
| - 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. | ||
| - Transport `verify()` and `close()` provider errors are preserved as the `cause` of lifecycle failures for diagnostics. | ||
@@ -240,3 +241,3 @@ - Module options are trimmed and normalized before provider wiring, including sender defaults, notification channel names, and transport factory ownership. | ||
| - `to`, `cc`, `bcc`, `from`, `replyTo` | ||
| - `text`, `html`, `attachments`, `headers` | ||
| - `text`, `html`, `attachments`, `headers`, `metadata` | ||
| - `templateData` when a renderer is configured on the module | ||
@@ -250,2 +251,86 @@ | ||
| ### Template renderer setup | ||
| 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`. | ||
| ```typescript | ||
| import { Module } from '@fluojs/core'; | ||
| import { EmailModule, type EmailTemplateRenderer } from '@fluojs/email'; | ||
| import { createNodemailerEmailTransportFactory } from '@fluojs/email/node'; | ||
| const renderer: EmailTemplateRenderer = { | ||
| render({ payload, template }) { | ||
| const name = payload.templateData?.name; | ||
| const displayName = typeof name === 'string' ? name : 'customer'; | ||
| return { | ||
| html: '<h1>Welcome</h1>', | ||
| subject: template === 'welcome' ? `Welcome, ${displayName}` : template, | ||
| text: `Hello, ${displayName}`, | ||
| }; | ||
| }, | ||
| }; | ||
| @Module({ | ||
| imports: [ | ||
| EmailModule.forRoot({ | ||
| defaultFrom: 'noreply@example.com', | ||
| renderer, | ||
| transport: createNodemailerEmailTransportFactory({ | ||
| smtp: { | ||
| auth: { | ||
| pass: 'smtp-password', | ||
| user: 'smtp-user', | ||
| }, | ||
| host: 'smtp.example.com', | ||
| port: 587, | ||
| secure: false, | ||
| }, | ||
| }), | ||
| verifyOnModuleInit: true, | ||
| }), | ||
| ], | ||
| }) | ||
| export class AppModule {} | ||
| ``` | ||
| 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. | ||
| ```typescript | ||
| import { Inject } from '@fluojs/core'; | ||
| import { EmailService } from '@fluojs/email'; | ||
| @Inject(EmailService) | ||
| export class WelcomeEmailService { | ||
| constructor(private readonly email: EmailService) {} | ||
| async sendRenderedWelcome(address: string, name: string) { | ||
| await this.email.sendNotification({ | ||
| channel: 'email', | ||
| recipients: [address], | ||
| template: 'welcome', | ||
| payload: { | ||
| templateData: { name }, | ||
| }, | ||
| }); | ||
| } | ||
| async sendWelcomeWithOverrides(address: string, name: string) { | ||
| await this.email.sendNotification({ | ||
| channel: 'email', | ||
| recipients: [address], | ||
| subject: 'Your account is ready', | ||
| template: 'welcome', | ||
| payload: { | ||
| html: '<p>Your account is ready.</p>', | ||
| templateData: { name }, | ||
| text: 'Use this exact welcome message.', | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| ``` | ||
| 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. | ||
| ### Queue-backed bulk delivery | ||
@@ -306,2 +391,3 @@ | ||
| - `EmailNotificationsQueueWorker` is exported from `@fluojs/email/queue` and must be registered by applications that enable queue-backed delivery. | ||
| - 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. | ||
| - 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. | ||
@@ -308,0 +394,0 @@ |
137302
6.61%1672
2.08%479
21.88%4
33.33%+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated
Updated
Updated