🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@fluojs/email

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@fluojs/email - npm Package Compare versions

Comparing version
1.0.2
to
2.0.0
+1
-0
dist/queue.d.ts

@@ -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

@@ -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"}

@@ -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"}

@@ -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) {

@@ -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"}

@@ -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

@@ -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": {

@@ -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 @@

@@ -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 @@