@lenne.tech/nest-server 11.31.3 → 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.
Files changed (32) hide show
  1. package/.claude/rules/versioning.md +5 -8
  2. package/CLAUDE.md +3 -3
  3. package/FRAMEWORK-API.md +4 -2
  4. package/README.md +1 -0
  5. package/dist/core/common/helpers/process-diagnostics.helper.d.ts +18 -0
  6. package/dist/core/common/helpers/process-diagnostics.helper.js +88 -0
  7. package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -0
  8. package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
  9. package/dist/core/common/services/brevo.service.d.ts +7 -1
  10. package/dist/core/common/services/brevo.service.js +37 -16
  11. package/dist/core/common/services/brevo.service.js.map +1 -1
  12. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +9 -4
  13. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/main.js +5 -2
  18. package/dist/main.js.map +1 -1
  19. package/dist/tsconfig.build.tsbuildinfo +1 -1
  20. package/docs/REQUEST-LIFECYCLE.md +1 -0
  21. package/docs/brevo-manual-test.md +166 -0
  22. package/docs/security-overrides.md +90 -0
  23. package/migration-guides/11.31.3-to-11.32.0.md +254 -0
  24. package/package.json +15 -14
  25. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +310 -0
  26. package/src/core/common/helpers/process-diagnostics.helper.ts +321 -0
  27. package/src/core/common/interfaces/server-options.interface.ts +32 -0
  28. package/src/core/common/services/brevo.service.spec.ts +266 -0
  29. package/src/core/common/services/brevo.service.ts +100 -17
  30. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
  31. package/src/index.ts +1 -0
  32. package/src/main.ts +22 -3
@@ -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 { SendSmtpEmail, TransactionalEmailsApi, TransactionalEmailsApiApiKeys } from '@getbrevo/brevo';
2
- import { Injectable } from '@nestjs/common';
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
- private apiInstance: TransactionalEmailsApi;
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 sendSmtpEmail: SendSmtpEmail = {
41
- params,
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 result = await this.apiInstance.sendTransacEmail(sendSmtpEmail);
48
- return result.body;
73
+ const client = await this.getClient();
74
+ return await client.transactionalEmails.sendTransacEmail(request);
49
75
  } catch (error) {
50
- console.error(error);
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 sendSmtpEmail: SendSmtpEmail = {
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 result = await this.apiInstance.sendTransacEmail(sendSmtpEmail);
89
- return result.body;
118
+ const client = await this.getClient();
119
+ return await client.transactionalEmails.sendTransacEmail(request);
90
120
  } catch (error) {
91
- console.error(error);
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
  }
@@ -180,15 +180,25 @@ export class CoreBetterAuthEmailVerificationService {
180
180
  if (this.config.brevoTemplateId && this.brevoService) {
181
181
  try {
182
182
  const appName = this.getAppName();
183
- await this.brevoService.sendMail(user.email, this.config.brevoTemplateId, {
183
+ const result = await this.brevoService.sendMail(user.email, this.config.brevoTemplateId, {
184
184
  appName,
185
185
  expiresIn: this.formatExpiresIn(this.config.expiresIn),
186
186
  link: url,
187
187
  name: user.name || user.email.split('@')[0],
188
188
  });
189
- this.trackSend(user.email);
190
- this.logger.debug(`Verification email sent via Brevo to ${this.maskEmail(user.email)}`);
191
- return;
189
+
190
+ // `sendMail()` swallows SDK errors and resolves to `null` (unless `brevo.throwOnError` is
191
+ // set), so "did not throw" is NOT "was delivered". Recording a send here on a null would
192
+ // mark the address as mailed, log success, and skip the SMTP fallback below — leaving the
193
+ // user with no verification email at all on a Brevo outage or a revoked key.
194
+ if (result === null) {
195
+ this.logger.error(`Brevo verification send failed for ${this.maskEmail(user.email)} — falling back to SMTP`);
196
+ // Deliberately no `return`: fall through to the EmailService path.
197
+ } else {
198
+ this.trackSend(user.email);
199
+ this.logger.debug(`Verification email sent via Brevo to ${this.maskEmail(user.email)}`);
200
+ return;
201
+ }
192
202
  } catch (error) {
193
203
  this.logger.error(
194
204
  `Failed to send verification email via Brevo to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export * from './core/common/helpers/input.helper';
43
43
  export * from './core/common/helpers/logging.helper';
44
44
  export * from './core/common/helpers/meta.helper';
45
45
  export * from './core/common/helpers/model.helper';
46
+ export * from './core/common/helpers/process-diagnostics.helper';
46
47
  export * from './core/common/helpers/register-enum.helper';
47
48
  export * from './core/common/helpers/scim.helper';
48
49
  export * from './core/common/helpers/service.helper';
package/src/main.ts CHANGED
@@ -9,6 +9,7 @@ import envConfig from './config.env';
9
9
  import { FilterArgs } from './core/common/args/filter.args';
10
10
  import { buildCorsConfig, isCookiesEnabled, isCorsDisabled } from './core/common/helpers/cookies.helper';
11
11
  import { HttpExceptionLogFilter } from './core/common/filters/http-exception-log.filter';
12
+ import { handleFatalBootstrapError, installProcessDiagnostics } from './core/common/helpers/process-diagnostics.helper';
12
13
  import { CorePersistenceModel } from './core/common/models/core-persistence.model';
13
14
  import { CoreAuthModel } from './core/modules/auth/core-auth.model';
14
15
  import { CoreUserModel } from './core/modules/user/core-user.model';
@@ -21,6 +22,11 @@ import { ServerModule } from './server/server.module';
21
22
  * Preparations for server start
22
23
  */
23
24
  async function bootstrap() {
25
+ // Make the exit reason diagnosable: log unhandled rejections without crashing, log uncaught
26
+ // exceptions before the restart, and label external termination signals so a silent
27
+ // "app crashed" always has a reason. See process-diagnostics.helper.ts for the rationale.
28
+ installProcessDiagnostics();
29
+
24
30
  // Create a new server based on express
25
31
  const server = await NestFactory.create<NestExpressApplication>(
26
32
  // Include server module, with all necessary modules for the project
@@ -106,9 +112,20 @@ async function bootstrap() {
106
112
  jsonDocumentUrl: '/api-docs-json',
107
113
  });
108
114
 
115
+ // Drain the event loop on SIGTERM/SIGINT so the process actually exits.
116
+ //
117
+ // This is load-bearing in a container, where `docker-entrypoint.sh` runs node under `exec` and it
118
+ // therefore becomes PID 1. A PID-namespace init is SIGNAL_UNKILLABLE: a userspace signal whose
119
+ // disposition is the default is silently discarded by the kernel, so re-raising is a no-op there.
120
+ // Meanwhile the listening HTTP server keeps the event loop non-empty, so nothing exits on its own
121
+ // and `docker stop` waits out its full grace period before SIGKILL — dropping in-flight requests
122
+ // and skipping every onModuleDestroy(). enableShutdownHooks() is what closes the app and drains
123
+ // the loop; installProcessDiagnostics() then correctly defers to it instead of re-raising.
124
+ server.enableShutdownHooks();
125
+
109
126
  // Start server on configured port
110
127
  await server.listen(envConfig.port, envConfig.hostname);
111
- console.debug(`Server startet at ${await server.getUrl()}`);
128
+ console.debug(`Server started at ${await server.getUrl()}`);
112
129
 
113
130
  // Run command after server init
114
131
  if (envConfig.execAfterInit) {
@@ -126,5 +143,7 @@ async function bootstrap() {
126
143
  }
127
144
  }
128
145
 
129
- // Start server
130
- bootstrap();
146
+ // Start server. A rejection here is a fatal startup failure (e.g. port already in use, DB
147
+ // unreachable) — surface it and exit rather than let it become a silent unhandledRejection
148
+ // that leaves a zombie process "alive" but listening on nothing.
149
+ bootstrap().catch(handleFatalBootstrapError);