@lenne.tech/nest-server 11.31.2 → 11.32.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/.claude/rules/architecture.md +2 -0
- package/.claude/rules/configurable-features.md +2 -0
- package/.claude/rules/versioning.md +5 -8
- package/CLAUDE.md +31 -4
- package/FRAMEWORK-API.md +7 -2
- package/README.md +1 -0
- package/dist/config.env.d.ts +4 -0
- package/dist/config.env.js +32 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/logging.helper.d.ts +1 -0
- package/dist/core/common/helpers/logging.helper.js +12 -0
- package/dist/core/common/helpers/logging.helper.js.map +1 -1
- package/dist/core/common/helpers/meta.helper.d.ts +1 -0
- package/dist/core/common/helpers/meta.helper.js +32 -1
- package/dist/core/common/helpers/meta.helper.js.map +1 -1
- package/dist/core/common/helpers/process-diagnostics.helper.d.ts +18 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js +88 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +12 -0
- package/dist/core/common/services/brevo.service.d.ts +7 -1
- package/dist/core/common/services/brevo.service.js +37 -16
- package/dist/core/common/services/brevo.service.js.map +1 -1
- package/dist/core/common/services/email.service.d.ts +3 -1
- package/dist/core/common/services/email.service.js +33 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/template.service.js +9 -4
- package/dist/core/common/services/template.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +9 -4
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/hub/core-hub-actions.controller.d.ts +22 -0
- package/dist/core/modules/hub/core-hub-actions.controller.js +141 -0
- package/dist/core/modules/hub/core-hub-actions.controller.js.map +1 -0
- package/dist/core/modules/hub/core-hub-html.service.d.ts +8 -0
- package/dist/core/modules/hub/core-hub-html.service.js +123 -0
- package/dist/core/modules/hub/core-hub-html.service.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +56 -0
- package/dist/core/modules/hub/core-hub.controller.js +398 -0
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -0
- package/dist/core/modules/hub/core-hub.module.d.ts +17 -0
- package/dist/core/modules/hub/core-hub.module.js +109 -0
- package/dist/core/modules/hub/core-hub.module.js.map +1 -0
- package/dist/core/modules/hub/core-hub.service.d.ts +18 -0
- package/dist/core/modules/hub/core-hub.service.js +153 -0
- package/dist/core/modules/hub/core-hub.service.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-client-js.helper.d.ts +1 -0
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js +755 -0
- package/dist/core/modules/hub/helpers/hub-client-js.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-command-shape.helper.d.ts +1 -0
- package/dist/core/modules/hub/helpers/hub-command-shape.helper.js +33 -0
- package/dist/core/modules/hub/helpers/hub-command-shape.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-mask.helper.d.ts +1 -0
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +46 -0
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-mermaid.helper.d.ts +10 -0
- package/dist/core/modules/hub/helpers/hub-mermaid.helper.js +35 -0
- package/dist/core/modules/hub/helpers/hub-mermaid.helper.js.map +1 -0
- package/dist/core/modules/hub/helpers/hub-shell.helper.d.ts +9 -0
- package/dist/core/modules/hub/helpers/hub-shell.helper.js +62 -0
- package/dist/core/modules/hub/helpers/hub-shell.helper.js.map +1 -0
- package/dist/core/modules/hub/hub-action-messages.d.ts +17 -0
- package/dist/core/modules/hub/hub-action-messages.js +21 -0
- package/dist/core/modules/hub/hub-action-messages.js.map +1 -0
- package/dist/core/modules/hub/hub-config.helper.d.ts +11 -0
- package/dist/core/modules/hub/hub-config.helper.js +164 -0
- package/dist/core/modules/hub/hub-config.helper.js.map +1 -0
- package/dist/core/modules/hub/hub-nav.d.ts +12 -0
- package/dist/core/modules/hub/hub-nav.js +43 -0
- package/dist/core/modules/hub/hub-nav.js.map +1 -0
- package/dist/core/modules/hub/hub-ring-buffer.d.ts +22 -0
- package/dist/core/modules/hub/hub-ring-buffer.js +59 -0
- package/dist/core/modules/hub/hub-ring-buffer.js.map +1 -0
- package/dist/core/modules/hub/hub.constants.d.ts +18 -0
- package/dist/core/modules/hub/hub.constants.js +22 -0
- package/dist/core/modules/hub/hub.constants.js.map +1 -0
- package/dist/core/modules/hub/index.d.ts +25 -0
- package/dist/core/modules/hub/index.js +42 -0
- package/dist/core/modules/hub/index.js.map +1 -0
- package/dist/core/modules/hub/interfaces/hub-config.interface.d.ts +115 -0
- package/dist/core/modules/hub/interfaces/hub-config.interface.js +3 -0
- package/dist/core/modules/hub/interfaces/hub-config.interface.js.map +1 -0
- package/dist/core/modules/hub/interfaces/hub-panels.interface.d.ts +231 -0
- package/dist/core/modules/hub/interfaces/hub-panels.interface.js +3 -0
- package/dist/core/modules/hub/interfaces/hub-panels.interface.js.map +1 -0
- package/dist/core/modules/hub/middleware/hub-trace.middleware.d.ts +8 -0
- package/dist/core/modules/hub/middleware/hub-trace.middleware.js +50 -0
- package/dist/core/modules/hub/middleware/hub-trace.middleware.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.d.ts +48 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js +136 -0
- package/dist/core/modules/hub/services/core-hub-actions.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-db.service.d.ts +19 -0
- package/dist/core/modules/hub/services/core-hub-db.service.js +180 -0
- package/dist/core/modules/hub/services/core-hub-db.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-email.service.d.ts +24 -0
- package/dist/core/modules/hub/services/core-hub-email.service.js +148 -0
- package/dist/core/modules/hub/services/core-hub-email.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +20 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +103 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-migrations.service.d.ts +25 -0
- package/dist/core/modules/hub/services/core-hub-migrations.service.js +115 -0
- package/dist/core/modules/hub/services/core-hub-migrations.service.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-sources.service.d.ts +28 -0
- package/dist/core/modules/hub/services/core-hub-sources.service.js +187 -0
- package/dist/core/modules/hub/services/core-hub-sources.service.js.map +1 -0
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +24 -0
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +210 -0
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -0
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +38 -0
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +235 -0
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -0
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +18 -0
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +123 -0
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -0
- package/dist/core.module.js +42 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +5 -2
- package/dist/main.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +2 -0
- package/docs/brevo-manual-test.md +166 -0
- package/docs/security-overrides.md +90 -0
- package/migration-guides/11.31.2-to-11.31.3.md +135 -0
- package/migration-guides/11.31.3-to-11.32.0.md +254 -0
- package/package.json +16 -14
- package/src/config.env.ts +75 -2
- package/src/core/common/helpers/logging.helper.spec.ts +61 -0
- package/src/core/common/helpers/logging.helper.ts +48 -0
- package/src/core/common/helpers/meta.helper.ts +46 -1
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +310 -0
- package/src/core/common/helpers/process-diagnostics.helper.ts +321 -0
- package/src/core/common/interfaces/server-options.interface.ts +78 -0
- package/src/core/common/services/brevo.service.spec.ts +266 -0
- package/src/core/common/services/brevo.service.ts +100 -17
- package/src/core/common/services/email.service.ts +33 -1
- package/src/core/common/services/template.service.ts +21 -16
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
- package/src/core/modules/hub/INTEGRATION-CHECKLIST.md +64 -0
- package/src/core/modules/hub/README.md +159 -0
- package/src/core/modules/hub/core-hub-actions.controller.ts +137 -0
- package/src/core/modules/hub/core-hub-html.service.ts +135 -0
- package/src/core/modules/hub/core-hub.controller.ts +286 -0
- package/src/core/modules/hub/core-hub.module.spec.ts +108 -0
- package/src/core/modules/hub/core-hub.module.ts +159 -0
- package/src/core/modules/hub/core-hub.service.ts +169 -0
- package/src/core/modules/hub/helpers/hub-client-js.helper.ts +768 -0
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +48 -0
- package/src/core/modules/hub/helpers/hub-command-shape.helper.ts +47 -0
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +67 -0
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +78 -0
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +54 -0
- package/src/core/modules/hub/helpers/hub-mermaid.helper.ts +62 -0
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +106 -0
- package/src/core/modules/hub/helpers/hub-shell.helper.ts +90 -0
- package/src/core/modules/hub/hub-action-messages.ts +47 -0
- package/src/core/modules/hub/hub-config.helper.spec.ts +108 -0
- package/src/core/modules/hub/hub-config.helper.ts +233 -0
- package/src/core/modules/hub/hub-nav.ts +66 -0
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +95 -0
- package/src/core/modules/hub/hub-ring-buffer.ts +101 -0
- package/src/core/modules/hub/hub.constants.ts +84 -0
- package/src/core/modules/hub/index.ts +25 -0
- package/src/core/modules/hub/interfaces/hub-config.interface.ts +265 -0
- package/src/core/modules/hub/interfaces/hub-panels.interface.ts +186 -0
- package/src/core/modules/hub/middleware/hub-trace.middleware.ts +45 -0
- package/src/core/modules/hub/services/core-hub-actions.service.ts +133 -0
- package/src/core/modules/hub/services/core-hub-db.service.ts +185 -0
- package/src/core/modules/hub/services/core-hub-email.service.ts +158 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +116 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +121 -0
- package/src/core/modules/hub/services/core-hub-migrations.service.ts +112 -0
- package/src/core/modules/hub/services/core-hub-sources.service.ts +194 -0
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +252 -0
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +274 -0
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +112 -0
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +134 -0
- package/src/core.module.ts +61 -1
- package/src/index.ts +7 -0
- package/src/main.ts +22 -3
|
@@ -18,6 +18,7 @@ import * as SMTPTransport from 'nodemailer/lib/smtp-transport';
|
|
|
18
18
|
import type * as StreamTransport from 'nodemailer/lib/stream-transport';
|
|
19
19
|
|
|
20
20
|
import { Falsy } from '../types/falsy.type';
|
|
21
|
+
import { IHubConfig } from '../../modules/hub/interfaces/hub-config.interface';
|
|
21
22
|
import { IPermissions } from '../../modules/permissions/interfaces/permissions.interface';
|
|
22
23
|
import { CronJobConfigWithTimeZone } from './cron-job-config-with-time-zone.interface';
|
|
23
24
|
import { CronJobConfigWithUtcOffset } from './cron-job-config-with-utc-offset.interface';
|
|
@@ -1519,6 +1520,19 @@ export interface IServerOptions {
|
|
|
1519
1520
|
*/
|
|
1520
1521
|
exclude?: RegExp;
|
|
1521
1522
|
|
|
1523
|
+
/**
|
|
1524
|
+
* How often the SDK retries a failed send (408 / 429 / 5xx).
|
|
1525
|
+
*
|
|
1526
|
+
* The Brevo SDK defaults to 2 retries and honours `Retry-After` with a cap of 60 SECONDS per
|
|
1527
|
+
* attempt. `BrevoService.sendMail()` is awaited inside request handlers (e.g. the BetterAuth
|
|
1528
|
+
* email-verification hook), so a rate-limited Brevo can otherwise park a user-facing HTTP
|
|
1529
|
+
* request for roughly two minutes. The framework default of `0` keeps the request fast and
|
|
1530
|
+
* leaves retrying to the caller.
|
|
1531
|
+
*
|
|
1532
|
+
* @default 0
|
|
1533
|
+
*/
|
|
1534
|
+
maxRetries?: number;
|
|
1535
|
+
|
|
1522
1536
|
/**
|
|
1523
1537
|
* Default sender for Brevo
|
|
1524
1538
|
*/
|
|
@@ -1526,6 +1540,25 @@ export interface IServerOptions {
|
|
|
1526
1540
|
email: string;
|
|
1527
1541
|
name: string;
|
|
1528
1542
|
};
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Whether a failed send should throw instead of resolving to `null`.
|
|
1546
|
+
*
|
|
1547
|
+
* `false` (default) preserves the historical contract: errors are logged and the method
|
|
1548
|
+
* resolves to `null`. Set `true` when the caller must not proceed on a silent failure — the
|
|
1549
|
+
* framework's own email-verification path checks the `null` either way.
|
|
1550
|
+
*
|
|
1551
|
+
* @default false
|
|
1552
|
+
*/
|
|
1553
|
+
throwOnError?: boolean;
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Per-request timeout in seconds. Unset means no timeout — a hung connection is then bounded
|
|
1557
|
+
* only by the HTTP agent's own (300 s) limits, multiplied by `maxRetries`.
|
|
1558
|
+
*
|
|
1559
|
+
* @default 10
|
|
1560
|
+
*/
|
|
1561
|
+
timeoutInSeconds?: number;
|
|
1529
1562
|
};
|
|
1530
1563
|
|
|
1531
1564
|
/**
|
|
@@ -1847,6 +1880,24 @@ export interface IServerOptions {
|
|
|
1847
1880
|
*/
|
|
1848
1881
|
hostname?: string;
|
|
1849
1882
|
|
|
1883
|
+
/**
|
|
1884
|
+
* Hub admin area (operator cockpit).
|
|
1885
|
+
*
|
|
1886
|
+
* A build-free, ADMIN-gated dashboard of runtime information and admin tools (dashboard,
|
|
1887
|
+
* diagnostics, logs, request traces, query profiler, migrations, DB stats, models/ERD, files,
|
|
1888
|
+
* cron jobs, error codes, masked config, auth-migration progress, email preview, built-in mailbox,
|
|
1889
|
+
* AI panel).
|
|
1890
|
+
*
|
|
1891
|
+
* Follows "presence implies enabled" — but is NEVER enabled implicitly. It must be switched on per
|
|
1892
|
+
* environment (here or via `NSC__HUB__*`); no environment inherits it.
|
|
1893
|
+
* - `true` / `{}`: enabled at `/hub`, admin-only, default collectors (logs + traces on, queries off)
|
|
1894
|
+
* - `{ path: 'admin/hub', collectors: { queries: true } }`: custom path + query profiling
|
|
1895
|
+
* - `{ enabled: false }` / `undefined`: disabled — every Hub route answers 404
|
|
1896
|
+
*
|
|
1897
|
+
* @default undefined (disabled)
|
|
1898
|
+
*/
|
|
1899
|
+
hub?: boolean | IHubConfig;
|
|
1900
|
+
|
|
1850
1901
|
/**
|
|
1851
1902
|
* Ignore selections in fieldSelection
|
|
1852
1903
|
* [ConfigService must be integrated in ModuleService]
|
|
@@ -3312,4 +3363,31 @@ export interface ICoreModuleOverrides {
|
|
|
3312
3363
|
controller?: Type<any>;
|
|
3313
3364
|
service?: Type<any>;
|
|
3314
3365
|
};
|
|
3366
|
+
|
|
3367
|
+
/**
|
|
3368
|
+
* Override Hub controllers and/or services with project-specific subclasses.
|
|
3369
|
+
*
|
|
3370
|
+
* - `actionsController` must extend `CoreHubActionsController`
|
|
3371
|
+
* - `actionsService` must extend `CoreHubActionsService`
|
|
3372
|
+
* - `controller` must extend `CoreHubController`
|
|
3373
|
+
* - `htmlService` must extend `CoreHubHtmlService`
|
|
3374
|
+
* - `service` must extend `CoreHubService`
|
|
3375
|
+
*
|
|
3376
|
+
* @example
|
|
3377
|
+
* ```typescript
|
|
3378
|
+
* {
|
|
3379
|
+
* hub: {
|
|
3380
|
+
* service: MyHubService,
|
|
3381
|
+
* htmlService: MyHubHtmlService,
|
|
3382
|
+
* },
|
|
3383
|
+
* }
|
|
3384
|
+
* ```
|
|
3385
|
+
*/
|
|
3386
|
+
hub?: {
|
|
3387
|
+
actionsController?: Type<any>;
|
|
3388
|
+
actionsService?: Type<any>;
|
|
3389
|
+
controller?: Type<any>;
|
|
3390
|
+
htmlService?: Type<any>;
|
|
3391
|
+
service?: Type<any>;
|
|
3392
|
+
};
|
|
3315
3393
|
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import type { Brevo, BrevoClient as RealBrevoClient } from '@getbrevo/brevo';
|
|
5
|
+
|
|
6
|
+
import { BrevoService } from './brevo.service';
|
|
7
|
+
import type { ConfigService } from './config.service';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Type-level contract against the REAL SDK.
|
|
11
|
+
*
|
|
12
|
+
* Everything below runs against a hand-written mock, so the mock alone could keep passing after a
|
|
13
|
+
* breaking SDK upgrade. `sendMail()` is declared `Promise<unknown>`, which gives tsc nothing to
|
|
14
|
+
* check either. These assertions close that gap: they compile against the installed
|
|
15
|
+
* `@getbrevo/brevo`, and `pnpm run typecheck:tests` covers `src/**\/*.spec.ts`, so a v7 that
|
|
16
|
+
* re-introduces a `.body` envelope or renames the request type fails the build here rather than
|
|
17
|
+
* silently at runtime.
|
|
18
|
+
*/
|
|
19
|
+
type SendFn = RealBrevoClient['transactionalEmails']['sendTransacEmail'];
|
|
20
|
+
/** Awaiting the call must yield the payload itself — v3's `{ response, body }` envelope is gone. */
|
|
21
|
+
type AssertNoEnvelope = Awaited<ReturnType<SendFn>> extends { body: unknown } ? never : true;
|
|
22
|
+
/** The request type the service builds must still be assignable to the SDK's parameter. */
|
|
23
|
+
type AssertRequestType = Brevo.SendTransacEmailRequest extends NonNullable<Parameters<SendFn>[0]> ? true : never;
|
|
24
|
+
// Consumed by an assertion below so the contract cannot be dead-code-eliminated or linted away.
|
|
25
|
+
const sdkContract: [AssertNoEnvelope, AssertRequestType] = [true, true];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Shared handles into the mocked SDK. `vi.hoisted` runs before the `vi.mock` factory below, so the
|
|
29
|
+
* spies exist by the time the module graph is wired up.
|
|
30
|
+
*/
|
|
31
|
+
const brevoMock = vi.hoisted(() => ({
|
|
32
|
+
/** Every options object the client was constructed with, in call order. */
|
|
33
|
+
clientOptions: [] as unknown[],
|
|
34
|
+
sendTransacEmail: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock('@getbrevo/brevo', () => ({
|
|
38
|
+
BrevoClient: class {
|
|
39
|
+
transactionalEmails = { sendTransacEmail: brevoMock.sendTransacEmail };
|
|
40
|
+
|
|
41
|
+
constructor(options: unknown) {
|
|
42
|
+
brevoMock.clientOptions.push(options);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const API_KEY = 'test-api-key';
|
|
48
|
+
const SENDER = { email: 'noreply@test.com', name: 'Test Sender' };
|
|
49
|
+
|
|
50
|
+
/** Matches the per-send `Idempotency-Key` header without pinning the random UUID. */
|
|
51
|
+
const anyIdempotencyHeaders = { 'Idempotency-Key': expect.any(String) as unknown as string };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Minimal ConfigService double.
|
|
55
|
+
*
|
|
56
|
+
* `exclude` can be set independently on the mutable (`config`) and the frozen
|
|
57
|
+
* (`configFastButReadOnly`) side so the tests can prove which one the service reads.
|
|
58
|
+
*/
|
|
59
|
+
function makeConfigService(
|
|
60
|
+
options: {
|
|
61
|
+
exclude?: RegExp;
|
|
62
|
+
frozenExclude?: RegExp;
|
|
63
|
+
maxRetries?: number;
|
|
64
|
+
throwOnError?: boolean;
|
|
65
|
+
timeoutInSeconds?: number;
|
|
66
|
+
withBrevo?: boolean;
|
|
67
|
+
} = {},
|
|
68
|
+
): ConfigService {
|
|
69
|
+
const { exclude, frozenExclude = exclude, maxRetries, throwOnError, timeoutInSeconds, withBrevo = true } = options;
|
|
70
|
+
const base = { apiKey: API_KEY, maxRetries, sender: SENDER, throwOnError, timeoutInSeconds };
|
|
71
|
+
return {
|
|
72
|
+
config: withBrevo ? { brevo: { ...base, exclude } } : {},
|
|
73
|
+
configFastButReadOnly: withBrevo ? { brevo: { ...base, exclude: frozenExclude } } : {},
|
|
74
|
+
} as unknown as ConfigService;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('@getbrevo/brevo SDK contract', () => {
|
|
78
|
+
it('still resolves the payload directly and accepts the request type we build', () => {
|
|
79
|
+
// The assertion is the COMPILATION of `sdkContract` above, which `pnpm run typecheck:tests`
|
|
80
|
+
// performs against the really installed SDK. This test body exists so the contract is also
|
|
81
|
+
// referenced at runtime — otherwise it reads as dead code and invites deletion.
|
|
82
|
+
expect(sdkContract).toEqual([true, true]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('BrevoService', () => {
|
|
87
|
+
let loggerError: ReturnType<typeof vi.spyOn>;
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
brevoMock.sendTransacEmail.mockReset();
|
|
91
|
+
brevoMock.clientOptions.length = 0;
|
|
92
|
+
loggerError = vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterEach(() => {
|
|
96
|
+
// The spies above are never restored otherwise. File-level isolation contains it today, but a
|
|
97
|
+
// leaked Logger spy is the kind of thing that only surfaces as an unrelated flake later.
|
|
98
|
+
vi.restoreAllMocks();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('constructor', () => {
|
|
102
|
+
it('throws when the Brevo configuration is missing', () => {
|
|
103
|
+
expect(() => new BrevoService(makeConfigService({ withBrevo: false }))).toThrow('Brevo configuration not set!');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does NOT construct the SDK client eagerly', async () => {
|
|
107
|
+
// The SDK is ~580 CommonJS modules and BrevoService is re-exported from the package barrel,
|
|
108
|
+
// so a static import would put that cost on every consumer's cold start — including the
|
|
109
|
+
// majority that never configure Brevo.
|
|
110
|
+
const service = new BrevoService(makeConfigService());
|
|
111
|
+
expect(brevoMock.clientOptions).toEqual([]);
|
|
112
|
+
|
|
113
|
+
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
114
|
+
await service.sendMail('user@example.com', 42);
|
|
115
|
+
expect(brevoMock.clientOptions).toHaveLength(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('creates the client with the configured API key and safe request limits', async () => {
|
|
119
|
+
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
120
|
+
const service = new BrevoService(makeConfigService());
|
|
121
|
+
await service.sendMail('user@example.com', 42);
|
|
122
|
+
|
|
123
|
+
// The SDK defaults to 2 retries honouring `Retry-After` with a 60 s cap PER attempt and to
|
|
124
|
+
// no timeout at all. Both send methods are awaited inside request handlers, so those
|
|
125
|
+
// defaults would let a rate-limited Brevo park a user-facing request for ~2 minutes.
|
|
126
|
+
expect(brevoMock.clientOptions).toEqual([{ apiKey: API_KEY, maxRetries: 0, timeoutInSeconds: 10 }]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('honours configured retry and timeout overrides', async () => {
|
|
130
|
+
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
131
|
+
const service = new BrevoService(makeConfigService({ maxRetries: 3, timeoutInSeconds: 30 }));
|
|
132
|
+
await service.sendMail('user@example.com', 42);
|
|
133
|
+
expect(brevoMock.clientOptions).toEqual([{ apiKey: API_KEY, maxRetries: 3, timeoutInSeconds: 30 }]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('reuses the client across sends', async () => {
|
|
137
|
+
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
138
|
+
const service = new BrevoService(makeConfigService());
|
|
139
|
+
await service.sendMail('user@example.com', 42);
|
|
140
|
+
await service.sendMail('other@example.com', 42);
|
|
141
|
+
expect(brevoMock.clientOptions).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('sendMail', () => {
|
|
146
|
+
it('rejects incomplete input without calling the API', async () => {
|
|
147
|
+
const service = new BrevoService(makeConfigService());
|
|
148
|
+
await expect(service.sendMail('', 42)).resolves.toBe(false);
|
|
149
|
+
await expect(service.sendMail('user@example.com', 0)).resolves.toBe(false);
|
|
150
|
+
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('sends template, recipient and params, and returns the response unwrapped', async () => {
|
|
154
|
+
const response = { messageId: '<mail-1@brevo>' };
|
|
155
|
+
brevoMock.sendTransacEmail.mockResolvedValue(response);
|
|
156
|
+
const service = new BrevoService(makeConfigService());
|
|
157
|
+
|
|
158
|
+
// Identity check: v6 resolves the payload directly, there is no `.body` envelope any more
|
|
159
|
+
await expect(service.sendMail('user@example.com', 42, { name: 'Test' })).resolves.toBe(response);
|
|
160
|
+
expect(brevoMock.sendTransacEmail).toHaveBeenCalledWith({
|
|
161
|
+
headers: anyIdempotencyHeaders,
|
|
162
|
+
params: { name: 'Test' },
|
|
163
|
+
templateId: 42,
|
|
164
|
+
to: [{ email: 'user@example.com' }],
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('sends a unique Idempotency-Key per call', async () => {
|
|
169
|
+
// The SDK retries POSTs on 408/429/5xx. Without a key, a retry issued after a delivered
|
|
170
|
+
// response whose reply was lost sends the mail twice.
|
|
171
|
+
brevoMock.sendTransacEmail.mockResolvedValue({ messageId: '<x@brevo>' });
|
|
172
|
+
const service = new BrevoService(makeConfigService());
|
|
173
|
+
await service.sendMail('user@example.com', 42);
|
|
174
|
+
await service.sendMail('user@example.com', 42);
|
|
175
|
+
|
|
176
|
+
const keys = brevoMock.sendTransacEmail.mock.calls.map(
|
|
177
|
+
([request]) => (request as Brevo.SendTransacEmailRequest).headers?.['Idempotency-Key'],
|
|
178
|
+
);
|
|
179
|
+
expect(keys[0]).toBeTypeOf('string');
|
|
180
|
+
expect(keys[0]).not.toBe(keys[1]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('skips excluded (test) recipients', async () => {
|
|
184
|
+
const service = new BrevoService(makeConfigService({ exclude: /@test\.com$/i }));
|
|
185
|
+
await expect(service.sendMail('user@test.com', 42)).resolves.toBe('TEST_USER!');
|
|
186
|
+
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('reads the exclude pattern from the mutable config, not the frozen one', async () => {
|
|
190
|
+
// A frozen RegExp carrying the `g` flag throws on `.test()` (it assigns `lastIndex`).
|
|
191
|
+
// Reading `exclude` off `configFastButReadOnly` would therefore fail instead of excluding.
|
|
192
|
+
const service = new BrevoService(
|
|
193
|
+
makeConfigService({ exclude: /@test\.com$/i, frozenExclude: Object.freeze(/@test\.com$/gi) }),
|
|
194
|
+
);
|
|
195
|
+
await expect(service.sendMail('user@test.com', 42)).resolves.toBe('TEST_USER!');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns null when the API call fails', async () => {
|
|
199
|
+
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
200
|
+
const service = new BrevoService(makeConfigService());
|
|
201
|
+
await expect(service.sendMail('user@example.com', 42)).resolves.toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('logs the failure through the Nest logger', async () => {
|
|
205
|
+
// The whole point of the sibling diagnostics work is that silent failures cost debugging
|
|
206
|
+
// sessions. Asserting only the `null` return would let the observability half regress.
|
|
207
|
+
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
208
|
+
const service = new BrevoService(makeConfigService());
|
|
209
|
+
await service.sendMail('user@example.com', 42);
|
|
210
|
+
expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('Brevo down'));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rethrows when throwOnError is enabled', async () => {
|
|
214
|
+
const service = new BrevoService(makeConfigService({ throwOnError: true }));
|
|
215
|
+
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
216
|
+
await expect(service.sendMail('user@example.com', 42)).rejects.toThrow('Brevo down');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('sendHtmlMail', () => {
|
|
221
|
+
it('rejects incomplete input without calling the API', async () => {
|
|
222
|
+
const service = new BrevoService(makeConfigService());
|
|
223
|
+
await expect(service.sendHtmlMail('', 'Subject', '<p>Hi</p>')).resolves.toBe(false);
|
|
224
|
+
await expect(service.sendHtmlMail('user@example.com', '', '<p>Hi</p>')).resolves.toBe(false);
|
|
225
|
+
await expect(service.sendHtmlMail('user@example.com', 'Subject', '')).resolves.toBe(false);
|
|
226
|
+
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('sends html, subject and the configured sender, and returns the response unwrapped', async () => {
|
|
230
|
+
const response = { messageId: '<mail-2@brevo>' };
|
|
231
|
+
brevoMock.sendTransacEmail.mockResolvedValue(response);
|
|
232
|
+
const service = new BrevoService(makeConfigService());
|
|
233
|
+
|
|
234
|
+
await expect(
|
|
235
|
+
service.sendHtmlMail('user@example.com', 'Subject', '<p>Hi</p>', { params: { code: '123' } }),
|
|
236
|
+
).resolves.toBe(response);
|
|
237
|
+
expect(brevoMock.sendTransacEmail).toHaveBeenCalledWith({
|
|
238
|
+
headers: anyIdempotencyHeaders,
|
|
239
|
+
htmlContent: '<p>Hi</p>',
|
|
240
|
+
params: { code: '123' },
|
|
241
|
+
sender: SENDER,
|
|
242
|
+
subject: 'Subject',
|
|
243
|
+
to: [{ email: 'user@example.com' }],
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('skips excluded (test) recipients', async () => {
|
|
248
|
+
const service = new BrevoService(makeConfigService({ exclude: /@test\.com$/i }));
|
|
249
|
+
await expect(service.sendHtmlMail('user@test.com', 'Subject', '<p>Hi</p>')).resolves.toBe('TEST_USER!');
|
|
250
|
+
expect(brevoMock.sendTransacEmail).not.toHaveBeenCalled();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('returns null when the API call fails', async () => {
|
|
254
|
+
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
255
|
+
const service = new BrevoService(makeConfigService());
|
|
256
|
+
await expect(service.sendHtmlMail('user@example.com', 'Subject', '<p>Hi</p>')).resolves.toBeNull();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('logs the failure through the Nest logger', async () => {
|
|
260
|
+
brevoMock.sendTransacEmail.mockRejectedValue(new Error('Brevo down'));
|
|
261
|
+
const service = new BrevoService(makeConfigService());
|
|
262
|
+
await service.sendHtmlMail('user@example.com', 'Subject', '<p>Hi</p>');
|
|
263
|
+
expect(loggerError).toHaveBeenCalledWith(expect.stringContaining('Brevo down'));
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -1,27 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import type { Brevo, BrevoClient } from '@getbrevo/brevo';
|
|
3
5
|
|
|
4
6
|
import { ConfigService } from './config.service';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* Brevo service to send transactional emails
|
|
10
|
+
*
|
|
11
|
+
* ## Return contract
|
|
12
|
+
*
|
|
13
|
+
* Both send methods resolve to one of four things, and callers on security-critical paths
|
|
14
|
+
* (verification, password reset, magic link) MUST distinguish them:
|
|
15
|
+
*
|
|
16
|
+
* | Value | Meaning |
|
|
17
|
+
* |-------|---------|
|
|
18
|
+
* | `false` | Rejected before sending — a required argument was missing |
|
|
19
|
+
* | `'TEST_USER!'` | Recipient matched `brevo.exclude`, nothing was sent (by design) |
|
|
20
|
+
* | `null` | The send FAILED. The error was logged, not thrown |
|
|
21
|
+
* | otherwise | The Brevo `SendTransacEmailResponse` (`{ messageId?, messageIds? }`) |
|
|
22
|
+
*
|
|
23
|
+
* A `null` is the one that bites: treating "did not throw" as "was delivered" silently drops mail.
|
|
24
|
+
* Set `brevo.throwOnError: true` if you would rather have the exception propagate.
|
|
8
25
|
*/
|
|
9
26
|
@Injectable()
|
|
10
27
|
export class BrevoService {
|
|
11
28
|
brevoConfig: ConfigService['configFastButReadOnly']['brevo'];
|
|
12
|
-
|
|
29
|
+
protected readonly logger = new Logger(BrevoService.name);
|
|
30
|
+
private client: BrevoClient | undefined;
|
|
13
31
|
|
|
14
32
|
constructor(protected configService: ConfigService) {
|
|
15
33
|
this.brevoConfig = configService.configFastButReadOnly.brevo;
|
|
16
34
|
if (!this.brevoConfig) {
|
|
17
35
|
throw new Error('Brevo configuration not set!');
|
|
18
36
|
}
|
|
19
|
-
this.apiInstance = new TransactionalEmailsApi();
|
|
20
|
-
this.apiInstance.setApiKey(TransactionalEmailsApiApiKeys.apiKey, this.brevoConfig.apiKey);
|
|
21
37
|
}
|
|
22
38
|
|
|
23
39
|
/**
|
|
24
40
|
* Send a transactional email via Brevo
|
|
41
|
+
*
|
|
42
|
+
* @param to - Recipient email address
|
|
43
|
+
* @param templateId - Brevo template id
|
|
44
|
+
* @param params - Template parameters. These are rendered SERVER-SIDE by the Brevo template
|
|
45
|
+
* engine, so any user-controlled value placed here is content-injection surface owned by
|
|
46
|
+
* whoever wrote the template — escape it there, or do not pass it.
|
|
47
|
+
* @returns The Brevo response, `false` on missing input, `'TEST_USER!'` when excluded, `null` on failure
|
|
25
48
|
*/
|
|
26
49
|
async sendMail(to: string, templateId: number, params?: object): Promise<unknown> {
|
|
27
50
|
try {
|
|
@@ -37,25 +60,31 @@ export class BrevoService {
|
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
// Prepare data
|
|
40
|
-
const
|
|
41
|
-
|
|
63
|
+
const request: Brevo.SendTransacEmailRequest = {
|
|
64
|
+
headers: this.buildIdempotencyHeaders(),
|
|
65
|
+
// The public signature keeps the wider `object` so existing callers stay source-compatible;
|
|
66
|
+
// the SDK narrowed its own field to an index-signature type in v6.
|
|
67
|
+
params: params as Record<string, unknown>,
|
|
42
68
|
templateId,
|
|
43
69
|
to: [{ email: to }],
|
|
44
70
|
};
|
|
45
71
|
|
|
46
72
|
// Send email
|
|
47
|
-
const
|
|
48
|
-
return
|
|
73
|
+
const client = await this.getClient();
|
|
74
|
+
return await client.transactionalEmails.sendTransacEmail(request);
|
|
49
75
|
} catch (error) {
|
|
50
|
-
|
|
76
|
+
return this.handleSendError(error, to);
|
|
51
77
|
}
|
|
52
|
-
|
|
53
|
-
// Return null if error
|
|
54
|
-
return null;
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
/**
|
|
58
81
|
* Send HTML mail
|
|
82
|
+
*
|
|
83
|
+
* @param to - Recipient email address
|
|
84
|
+
* @param subject - Email subject
|
|
85
|
+
* @param html - HTML body
|
|
86
|
+
* @param options - Optional template parameters
|
|
87
|
+
* @returns The Brevo response, `false` on missing input, `'TEST_USER!'` when excluded, `null` on failure
|
|
59
88
|
*/
|
|
60
89
|
async sendHtmlMail(
|
|
61
90
|
to: string,
|
|
@@ -76,7 +105,8 @@ export class BrevoService {
|
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
// Prepare data
|
|
79
|
-
const
|
|
108
|
+
const request: Brevo.SendTransacEmailRequest = {
|
|
109
|
+
headers: this.buildIdempotencyHeaders(),
|
|
80
110
|
htmlContent: html,
|
|
81
111
|
params: options?.params,
|
|
82
112
|
sender: this.brevoConfig.sender,
|
|
@@ -85,12 +115,65 @@ export class BrevoService {
|
|
|
85
115
|
};
|
|
86
116
|
|
|
87
117
|
// Send email
|
|
88
|
-
const
|
|
89
|
-
return
|
|
118
|
+
const client = await this.getClient();
|
|
119
|
+
return await client.transactionalEmails.sendTransacEmail(request);
|
|
90
120
|
} catch (error) {
|
|
91
|
-
|
|
121
|
+
return this.handleSendError(error, to);
|
|
92
122
|
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Builds the per-send idempotency header.
|
|
127
|
+
*
|
|
128
|
+
* The SDK retries POSTs on 408/429/5xx. Without a key, a retry issued after a response that was
|
|
129
|
+
* actually delivered (but whose reply was lost) sends the mail twice. Brevo deduplicates on
|
|
130
|
+
* `Idempotency-Key`.
|
|
131
|
+
*
|
|
132
|
+
* @returns Custom headers for the send request
|
|
133
|
+
*/
|
|
134
|
+
protected buildIdempotencyHeaders(): Record<string, unknown> {
|
|
135
|
+
return { 'Idempotency-Key': randomUUID() };
|
|
136
|
+
}
|
|
93
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Lazily constructs (and memoises) the Brevo SDK client.
|
|
140
|
+
*
|
|
141
|
+
* The import is dynamic on purpose: `@getbrevo/brevo` pulls in ~580 CommonJS modules, and
|
|
142
|
+
* `BrevoService` is re-exported from the package barrel. A static import would put that cost on
|
|
143
|
+
* every consumer's cold start, including the majority that never configure Brevo at all.
|
|
144
|
+
*
|
|
145
|
+
* @returns The memoised SDK client
|
|
146
|
+
*/
|
|
147
|
+
protected async getClient(): Promise<BrevoClient> {
|
|
148
|
+
if (!this.client) {
|
|
149
|
+
const { BrevoClient: BrevoClientCtor } = await import('@getbrevo/brevo');
|
|
150
|
+
this.client = new BrevoClientCtor({
|
|
151
|
+
apiKey: this.brevoConfig.apiKey,
|
|
152
|
+
// The SDK defaults to 2 retries honouring `Retry-After` with a 60 s cap PER attempt, and to
|
|
153
|
+
// no timeout at all. Both send methods are awaited inside request handlers, so those
|
|
154
|
+
// defaults let a rate-limited Brevo park a user-facing request for roughly two minutes.
|
|
155
|
+
maxRetries: this.brevoConfig.maxRetries ?? 0,
|
|
156
|
+
timeoutInSeconds: this.brevoConfig.timeoutInSeconds ?? 10,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return this.client;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Logs a failed send through the Nest logger and applies the configured failure policy.
|
|
164
|
+
*
|
|
165
|
+
* @param error - The thrown SDK error
|
|
166
|
+
* @param to - Recipient, for correlation
|
|
167
|
+
* @returns `null` (the historical contract) unless `brevo.throwOnError` is set
|
|
168
|
+
* @throws The original error when `brevo.throwOnError` is `true`
|
|
169
|
+
*/
|
|
170
|
+
protected handleSendError(error: unknown, to: string): null {
|
|
171
|
+
this.logger.error(
|
|
172
|
+
`Brevo sendTransacEmail failed for ${to}: ${error instanceof Error ? error.message : String(error)}`,
|
|
173
|
+
);
|
|
174
|
+
if (this.brevoConfig.throwOnError) {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
94
177
|
// Return null if error
|
|
95
178
|
return null;
|
|
96
179
|
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
|
2
|
+
import { Inject, Injectable, OnModuleDestroy, Optional } from '@nestjs/common';
|
|
3
3
|
import nodemailer = require('nodemailer');
|
|
4
4
|
import { Attachment } from 'nodemailer/lib/mailer';
|
|
5
5
|
|
|
6
6
|
import { isNonEmptyString, isTrue, returnFalse } from '../helpers/input.helper';
|
|
7
7
|
import { MailTransportOptions } from '../interfaces/server-options.interface';
|
|
8
|
+
import { HUB_EMAIL_CAPTURE } from '../../modules/hub/hub.constants';
|
|
8
9
|
import { ConfigService } from './config.service';
|
|
9
10
|
import { TemplateService } from './template.service';
|
|
11
|
+
import type { IHubEmailCapture } from '../../modules/hub/interfaces/hub-config.interface';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Email service
|
|
@@ -26,6 +28,9 @@ export class EmailService implements OnModuleDestroy {
|
|
|
26
28
|
constructor(
|
|
27
29
|
protected configService: ConfigService,
|
|
28
30
|
protected templateService: TemplateService,
|
|
31
|
+
// Optional Hub mailbox hook. When the Hub mailbox is enabled it captures outgoing mail (and, in
|
|
32
|
+
// capture mode, suppresses the actual send). Undefined otherwise — zero cost.
|
|
33
|
+
@Optional() @Inject(HUB_EMAIL_CAPTURE) protected readonly emailCapture?: IHubEmailCapture,
|
|
29
34
|
) {}
|
|
30
35
|
|
|
31
36
|
onModuleDestroy(): void {
|
|
@@ -104,6 +109,33 @@ export class EmailService implements OnModuleDestroy {
|
|
|
104
109
|
}
|
|
105
110
|
}
|
|
106
111
|
|
|
112
|
+
// Hub mailbox capture (Mailpit-style). Runs after templates are rendered, before the transport.
|
|
113
|
+
// In capture mode it records the mail and suppresses the send (returns a jsonTransport-like ack).
|
|
114
|
+
// Fully guarded: a broken mailbox hook must never break (or crash) the mail path.
|
|
115
|
+
if (this.emailCapture) {
|
|
116
|
+
let skipTransport = false;
|
|
117
|
+
try {
|
|
118
|
+
skipTransport = this.emailCapture.capture({
|
|
119
|
+
from: `"${senderName}" <${senderEmail}>`,
|
|
120
|
+
html,
|
|
121
|
+
subject,
|
|
122
|
+
templateName: htmlTemplate ?? textTemplate,
|
|
123
|
+
text,
|
|
124
|
+
to: Array.isArray(recipients) ? recipients.join(', ') : recipients,
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
/* mailbox capture must never break mail sending */
|
|
128
|
+
}
|
|
129
|
+
if (skipTransport) {
|
|
130
|
+
return {
|
|
131
|
+
accepted: Array.isArray(recipients) ? recipients : [recipients],
|
|
132
|
+
captured: true,
|
|
133
|
+
messageId: 'hub-mailbox',
|
|
134
|
+
rejected: [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
// Reuse transporter if SMTP config hasn't changed (avoids creating new connections per email)
|
|
108
140
|
// Use hash instead of raw JSON to avoid keeping credentials as a string in memory
|
|
109
141
|
const smtpKey = createHash('sha256').update(JSON.stringify(smtp)).digest('hex');
|
|
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|
|
2
2
|
import { TemplateFunction } from 'ejs';
|
|
3
3
|
import ejs = require('ejs');
|
|
4
4
|
import fs = require('fs');
|
|
5
|
-
import {
|
|
5
|
+
import { resolve, sep } from 'path';
|
|
6
6
|
|
|
7
7
|
import { ConfigService } from './config.service';
|
|
8
8
|
|
|
@@ -36,27 +36,32 @@ export class TemplateService {
|
|
|
36
36
|
* @param filePath Directory names (separated via '/' if template is in subdirectory) + name of the template file without extension
|
|
37
37
|
*/
|
|
38
38
|
protected async getTemplate(filePath: string): Promise<TemplateFunction> {
|
|
39
|
-
|
|
39
|
+
// Resolve the template file to an absolute path and enforce that it stays within the configured
|
|
40
|
+
// templates directory. Legitimate template names never contain '..'; this guard is a defense in
|
|
41
|
+
// depth against path traversal for any caller that forwards user-influenced template names.
|
|
42
|
+
const baseDir = resolve(this.configService.getFastButReadOnly('templates.path'));
|
|
43
|
+
const fullPath = resolve(baseDir, `${filePath}.ejs`);
|
|
44
|
+
if (fullPath !== baseDir && !fullPath.startsWith(baseDir + sep)) {
|
|
45
|
+
throw new Error(`Invalid template path "${filePath}".`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return new Promise<TemplateFunction>((res, reject) => {
|
|
40
49
|
// Get template from cache
|
|
41
50
|
if (this.templates[filePath]) {
|
|
42
|
-
|
|
51
|
+
res(this.templates[filePath]);
|
|
43
52
|
return;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
// Get template file
|
|
47
|
-
fs.readFile(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
resolve(this.templates[filePath]);
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
);
|
|
56
|
+
fs.readFile(fullPath, { encoding: 'utf8' }, (err, data) => {
|
|
57
|
+
if (err) {
|
|
58
|
+
reject(err);
|
|
59
|
+
} else {
|
|
60
|
+
// Compile and return template
|
|
61
|
+
this.templates[filePath] = ejs.compile(data);
|
|
62
|
+
res(this.templates[filePath]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
60
65
|
});
|
|
61
66
|
}
|
|
62
67
|
}
|