@flusys/nestjs-email 6.0.2 → 6.1.1

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 (37) hide show
  1. package/cjs/controllers/email-config.controller.spec.js +85 -0
  2. package/cjs/controllers/email-send.controller.spec.js +139 -0
  3. package/cjs/controllers/email-template.controller.spec.js +89 -0
  4. package/cjs/docs/email-swagger.config.spec.js +62 -0
  5. package/cjs/entities/index.spec.js +18 -0
  6. package/cjs/modules/email.module.spec.js +142 -0
  7. package/cjs/providers/email-factory.service.spec.js +242 -0
  8. package/cjs/providers/email-provider.registry.spec.js +58 -0
  9. package/cjs/providers/mailgun-provider.spec.js +244 -0
  10. package/cjs/providers/sendgrid-provider.spec.js +346 -0
  11. package/cjs/providers/smtp-provider.js +12 -5
  12. package/cjs/providers/smtp-provider.spec.js +327 -0
  13. package/cjs/services/email-config.service.spec.js +69 -0
  14. package/cjs/services/email-datasource.provider.spec.js +135 -0
  15. package/cjs/services/email-provider-config.service.spec.js +247 -0
  16. package/cjs/services/email-send.service.spec.js +546 -0
  17. package/cjs/services/email-template.service.spec.js +257 -0
  18. package/cjs/utils/email-templates.util.spec.js +61 -0
  19. package/fesm/controllers/email-config.controller.spec.js +81 -0
  20. package/fesm/controllers/email-send.controller.spec.js +135 -0
  21. package/fesm/controllers/email-template.controller.spec.js +85 -0
  22. package/fesm/docs/email-swagger.config.spec.js +58 -0
  23. package/fesm/entities/index.spec.js +14 -0
  24. package/fesm/modules/email.module.spec.js +138 -0
  25. package/fesm/providers/email-factory.service.spec.js +238 -0
  26. package/fesm/providers/email-provider.registry.spec.js +54 -0
  27. package/fesm/providers/mailgun-provider.spec.js +240 -0
  28. package/fesm/providers/sendgrid-provider.spec.js +342 -0
  29. package/fesm/providers/smtp-provider.js +12 -5
  30. package/fesm/providers/smtp-provider.spec.js +282 -0
  31. package/fesm/services/email-config.service.spec.js +65 -0
  32. package/fesm/services/email-datasource.provider.spec.js +131 -0
  33. package/fesm/services/email-provider-config.service.spec.js +243 -0
  34. package/fesm/services/email-send.service.spec.js +542 -0
  35. package/fesm/services/email-template.service.spec.js +253 -0
  36. package/fesm/utils/email-templates.util.spec.js +57 -0
  37. package/package.json +3 -3
@@ -0,0 +1,346 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _common = require("@nestjs/common");
6
+ const _sendgridprovider = require("./sendgrid-provider");
7
+ // @sendgrid/mail is not an installed dependency in this workspace, so the real dynamic
8
+ // import() inside `initialize()` fails — exercised directly, no mocking needed.
9
+ // For "happy path" behavior we bypass initialize() and inject a fake sgMail client,
10
+ // matching the private `sgMail`/`apiKey` fields the provider mutates internally.
11
+ function injectSgMail(provider, sgMail, apiKey = 'SG.fake-key') {
12
+ provider.sgMail = sgMail;
13
+ provider.apiKey = apiKey;
14
+ }
15
+ describe('SendGridProvider', ()=>{
16
+ let provider;
17
+ beforeEach(()=>{
18
+ provider = new _sendgridprovider.SendGridProvider();
19
+ });
20
+ describe('initialize', ()=>{
21
+ it('throws InternalServerErrorException with SDK_NOT_INSTALLED when @sendgrid/mail is unavailable', async ()=>{
22
+ await expect(provider.initialize({
23
+ apiKey: 'SG.key'
24
+ })).rejects.toThrow(_common.InternalServerErrorException);
25
+ });
26
+ it('surfaces the sdk name in the exception messageVariables', async ()=>{
27
+ try {
28
+ await provider.initialize({
29
+ apiKey: 'SG.key'
30
+ });
31
+ fail('expected initialize to throw');
32
+ } catch (error) {
33
+ expect(error.getResponse()).toEqual(expect.objectContaining({
34
+ messageKey: 'system.sdk.not.installed',
35
+ messageVariables: {
36
+ sdk: 'sendgrid'
37
+ }
38
+ }));
39
+ }
40
+ });
41
+ });
42
+ describe('sendEmail', ()=>{
43
+ it('returns a failure result when the provider was never initialized', async ()=>{
44
+ const result = await provider.sendEmail({
45
+ to: 'a@example.com',
46
+ subject: 'Hi'
47
+ });
48
+ expect(result).toEqual({
49
+ success: false,
50
+ error: 'SendGrid provider not initialized'
51
+ });
52
+ });
53
+ it('sends via sgMail.send and extracts the message id from response headers', async ()=>{
54
+ const send = jest.fn().mockResolvedValue([
55
+ {
56
+ headers: {
57
+ 'x-message-id': 'sg-msg-1'
58
+ }
59
+ }
60
+ ]);
61
+ injectSgMail(provider, {
62
+ send
63
+ });
64
+ const result = await provider.sendEmail({
65
+ to: [
66
+ 'a@example.com',
67
+ 'b@example.com'
68
+ ],
69
+ subject: 'Hello',
70
+ html: '<p>hi</p>',
71
+ from: 'sender@example.com',
72
+ fromName: 'Sender Name',
73
+ cc: 'c@example.com',
74
+ bcc: [
75
+ 'd@example.com'
76
+ ],
77
+ replyTo: 'reply@example.com'
78
+ });
79
+ expect(result).toEqual({
80
+ success: true,
81
+ messageId: 'sg-msg-1'
82
+ });
83
+ expect(send).toHaveBeenCalledWith(expect.objectContaining({
84
+ to: [
85
+ 'a@example.com',
86
+ 'b@example.com'
87
+ ],
88
+ from: {
89
+ email: 'sender@example.com',
90
+ name: 'Sender Name'
91
+ },
92
+ subject: 'Hello',
93
+ html: '<p>hi</p>',
94
+ cc: [
95
+ 'c@example.com'
96
+ ],
97
+ bcc: [
98
+ 'd@example.com'
99
+ ],
100
+ replyTo: 'reply@example.com'
101
+ }));
102
+ });
103
+ it('falls back to the capitalized X-Message-Id header when lowercase is absent', async ()=>{
104
+ const send = jest.fn().mockResolvedValue([
105
+ {
106
+ headers: {
107
+ 'X-Message-Id': 'sg-msg-2'
108
+ }
109
+ }
110
+ ]);
111
+ injectSgMail(provider, {
112
+ send
113
+ });
114
+ const result = await provider.sendEmail({
115
+ to: 'a@example.com',
116
+ subject: 'Hi',
117
+ from: 'x@example.com'
118
+ });
119
+ expect(result).toEqual({
120
+ success: true,
121
+ messageId: 'sg-msg-2'
122
+ });
123
+ });
124
+ it('base64-encodes buffer attachment content for the SendGrid payload', async ()=>{
125
+ const send = jest.fn().mockResolvedValue([
126
+ {
127
+ headers: {}
128
+ }
129
+ ]);
130
+ injectSgMail(provider, {
131
+ send
132
+ });
133
+ await provider.sendEmail({
134
+ to: 'a@example.com',
135
+ subject: 'Hi',
136
+ from: 'x@example.com',
137
+ attachments: [
138
+ {
139
+ filename: 'doc.txt',
140
+ content: Buffer.from('hello'),
141
+ contentType: 'text/plain'
142
+ }
143
+ ]
144
+ });
145
+ const [msg] = send.mock.calls[0];
146
+ expect(msg.attachments[0]).toEqual({
147
+ filename: 'doc.txt',
148
+ content: Buffer.from('hello').toString('base64'),
149
+ type: 'text/plain',
150
+ disposition: 'attachment'
151
+ });
152
+ });
153
+ it('returns the SendGrid API error message on failure', async ()=>{
154
+ injectSgMail(provider, {
155
+ send: jest.fn().mockRejectedValue({
156
+ response: {
157
+ body: {
158
+ errors: [
159
+ {
160
+ message: 'Invalid recipient'
161
+ }
162
+ ]
163
+ }
164
+ }
165
+ })
166
+ });
167
+ const result = await provider.sendEmail({
168
+ to: 'a@example.com',
169
+ subject: 'Hi'
170
+ });
171
+ expect(result).toEqual({
172
+ success: false,
173
+ error: 'Invalid recipient'
174
+ });
175
+ });
176
+ it('falls back to the generic error message when no SendGrid error body is present', async ()=>{
177
+ injectSgMail(provider, {
178
+ send: jest.fn().mockRejectedValue(new Error('network down'))
179
+ });
180
+ const result = await provider.sendEmail({
181
+ to: 'a@example.com',
182
+ subject: 'Hi'
183
+ });
184
+ expect(result).toEqual({
185
+ success: false,
186
+ error: 'network down'
187
+ });
188
+ });
189
+ it('falls back to "Unknown error" when no message is available at all', async ()=>{
190
+ injectSgMail(provider, {
191
+ send: jest.fn().mockRejectedValue({})
192
+ });
193
+ const result = await provider.sendEmail({
194
+ to: 'a@example.com',
195
+ subject: 'Hi'
196
+ });
197
+ expect(result).toEqual({
198
+ success: false,
199
+ error: 'Unknown error'
200
+ });
201
+ });
202
+ });
203
+ describe('sendBulkEmails', ()=>{
204
+ it('returns a not-initialized failure per message when not initialized', async ()=>{
205
+ const results = await provider.sendBulkEmails([
206
+ {
207
+ to: 'a@example.com',
208
+ subject: 'A'
209
+ },
210
+ {
211
+ to: 'b@example.com',
212
+ subject: 'B'
213
+ }
214
+ ]);
215
+ expect(results).toEqual([
216
+ {
217
+ success: false,
218
+ error: 'SendGrid provider not initialized'
219
+ },
220
+ {
221
+ success: false,
222
+ error: 'SendGrid provider not initialized'
223
+ }
224
+ ]);
225
+ });
226
+ it('sends all messages in a single sgMail.send call and reports success for all', async ()=>{
227
+ const send = jest.fn().mockResolvedValue(undefined);
228
+ injectSgMail(provider, {
229
+ send
230
+ });
231
+ const results = await provider.sendBulkEmails([
232
+ {
233
+ to: 'a@example.com',
234
+ subject: 'A',
235
+ from: 'x@example.com'
236
+ },
237
+ {
238
+ to: 'b@example.com',
239
+ subject: 'B',
240
+ from: 'x@example.com'
241
+ }
242
+ ]);
243
+ expect(send).toHaveBeenCalledTimes(1);
244
+ expect(send).toHaveBeenCalledWith([
245
+ expect.objectContaining({
246
+ to: [
247
+ 'a@example.com'
248
+ ],
249
+ subject: 'A'
250
+ }),
251
+ expect.objectContaining({
252
+ to: [
253
+ 'b@example.com'
254
+ ],
255
+ subject: 'B'
256
+ })
257
+ ]);
258
+ expect(results).toEqual([
259
+ {
260
+ success: true
261
+ },
262
+ {
263
+ success: true
264
+ }
265
+ ]);
266
+ });
267
+ it('excludes cc/bcc/replyTo/attachments from bulk messages (includeExtras=false)', async ()=>{
268
+ const send = jest.fn().mockResolvedValue(undefined);
269
+ injectSgMail(provider, {
270
+ send
271
+ });
272
+ await provider.sendBulkEmails([
273
+ {
274
+ to: 'a@example.com',
275
+ subject: 'A',
276
+ from: 'x@example.com',
277
+ cc: 'c@example.com',
278
+ replyTo: 'r@example.com'
279
+ }
280
+ ]);
281
+ const [messages] = send.mock.calls[0];
282
+ expect(messages[0]).not.toHaveProperty('cc');
283
+ expect(messages[0]).not.toHaveProperty('replyTo');
284
+ });
285
+ it('reports the same failure for every message when the bulk send call rejects', async ()=>{
286
+ injectSgMail(provider, {
287
+ send: jest.fn().mockRejectedValue(new Error('rate limited'))
288
+ });
289
+ const results = await provider.sendBulkEmails([
290
+ {
291
+ to: 'a@example.com',
292
+ subject: 'A',
293
+ from: 'x@example.com'
294
+ },
295
+ {
296
+ to: 'b@example.com',
297
+ subject: 'B',
298
+ from: 'x@example.com'
299
+ }
300
+ ]);
301
+ expect(results).toEqual([
302
+ {
303
+ success: false,
304
+ error: 'rate limited'
305
+ },
306
+ {
307
+ success: false,
308
+ error: 'rate limited'
309
+ }
310
+ ]);
311
+ });
312
+ });
313
+ describe('healthCheck', ()=>{
314
+ it('returns false when not initialized', async ()=>{
315
+ await expect(provider.healthCheck()).resolves.toBe(false);
316
+ });
317
+ it('returns true when sgMail is set and the api key has the SG. prefix', async ()=>{
318
+ injectSgMail(provider, {
319
+ send: jest.fn()
320
+ }, 'SG.validkey');
321
+ await expect(provider.healthCheck()).resolves.toBe(true);
322
+ });
323
+ it('returns false when the api key does not have the SG. prefix', async ()=>{
324
+ injectSgMail(provider, {
325
+ send: jest.fn()
326
+ }, 'invalid-key');
327
+ await expect(provider.healthCheck()).resolves.toBe(false);
328
+ });
329
+ });
330
+ describe('close', ()=>{
331
+ it('clears the sgMail client reference', async ()=>{
332
+ injectSgMail(provider, {
333
+ send: jest.fn()
334
+ });
335
+ await provider.close();
336
+ const result = await provider.sendEmail({
337
+ to: 'a@example.com',
338
+ subject: 'Hi'
339
+ });
340
+ expect(result).toEqual({
341
+ success: false,
342
+ error: 'SendGrid provider not initialized'
343
+ });
344
+ });
345
+ });
346
+ });
@@ -145,11 +145,18 @@ let SmtpProvider = class SmtpProvider {
145
145
  return Array.isArray(addresses) ? addresses.join(', ') : addresses;
146
146
  }
147
147
  async withTimeout(promise, ms, message) {
148
- const timeout = new Promise((_, reject)=>setTimeout(()=>reject(new Error(message)), ms));
149
- return Promise.race([
150
- promise,
151
- timeout
152
- ]);
148
+ let timer;
149
+ const timeout = new Promise((_, reject)=>{
150
+ timer = setTimeout(()=>reject(new Error(message)), ms);
151
+ });
152
+ try {
153
+ return await Promise.race([
154
+ promise,
155
+ timeout
156
+ ]);
157
+ } finally{
158
+ clearTimeout(timer);
159
+ }
153
160
  }
154
161
  extractError(error) {
155
162
  return error instanceof Error ? error.message : 'Unknown error';
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ const _nodemailer = /*#__PURE__*/ _interop_require_wildcard(require("nodemailer"));
6
+ const _smtpprovider = require("./smtp-provider");
7
+ function _getRequireWildcardCache(nodeInterop) {
8
+ if (typeof WeakMap !== "function") return null;
9
+ var cacheBabelInterop = new WeakMap();
10
+ var cacheNodeInterop = new WeakMap();
11
+ return (_getRequireWildcardCache = function(nodeInterop) {
12
+ return nodeInterop ? cacheNodeInterop : cacheBabelInterop;
13
+ })(nodeInterop);
14
+ }
15
+ function _interop_require_wildcard(obj, nodeInterop) {
16
+ if (!nodeInterop && obj && obj.__esModule) {
17
+ return obj;
18
+ }
19
+ if (obj === null || typeof obj !== "object" && typeof obj !== "function") {
20
+ return {
21
+ default: obj
22
+ };
23
+ }
24
+ var cache = _getRequireWildcardCache(nodeInterop);
25
+ if (cache && cache.has(obj)) {
26
+ return cache.get(obj);
27
+ }
28
+ var newObj = {
29
+ __proto__: null
30
+ };
31
+ var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;
32
+ for(var key in obj){
33
+ if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) {
34
+ var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;
35
+ if (desc && (desc.get || desc.set)) {
36
+ Object.defineProperty(newObj, key, desc);
37
+ } else {
38
+ newObj[key] = obj[key];
39
+ }
40
+ }
41
+ }
42
+ newObj.default = obj;
43
+ if (cache) {
44
+ cache.set(obj, newObj);
45
+ }
46
+ return newObj;
47
+ }
48
+ const mockTransporter = {
49
+ verify: jest.fn(),
50
+ sendMail: jest.fn(),
51
+ close: jest.fn()
52
+ };
53
+ jest.mock('nodemailer', ()=>({
54
+ createTransport: jest.fn()
55
+ }));
56
+ const createTransport = _nodemailer.createTransport;
57
+ createTransport.mockReturnValue(mockTransporter);
58
+ function injectTransporter(provider, transporter) {
59
+ provider.transporter = transporter;
60
+ }
61
+ describe('SmtpProvider', ()=>{
62
+ let provider;
63
+ beforeEach(()=>{
64
+ provider = new _smtpprovider.SmtpProvider();
65
+ jest.clearAllMocks();
66
+ });
67
+ describe('initialize', ()=>{
68
+ it('creates a nodemailer transport with the given config and verifies the connection', async ()=>{
69
+ mockTransporter.verify.mockResolvedValue(true);
70
+ await provider.initialize({
71
+ host: 'smtp.example.com',
72
+ port: 587,
73
+ auth: {
74
+ user: 'user',
75
+ pass: 'pass'
76
+ }
77
+ });
78
+ expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({
79
+ host: 'smtp.example.com',
80
+ port: 587,
81
+ secure: false,
82
+ auth: {
83
+ user: 'user',
84
+ pass: 'pass'
85
+ },
86
+ tls: expect.objectContaining({
87
+ rejectUnauthorized: true,
88
+ minVersion: 'TLSv1.2'
89
+ })
90
+ }));
91
+ expect(mockTransporter.verify).toHaveBeenCalled();
92
+ });
93
+ it('defaults secure=true when port is 465 and secure is not explicitly set', async ()=>{
94
+ mockTransporter.verify.mockResolvedValue(true);
95
+ await provider.initialize({
96
+ host: 'smtp.example.com',
97
+ port: 465
98
+ });
99
+ expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({
100
+ secure: true
101
+ }));
102
+ });
103
+ it('honors an explicit secure flag over the port-based default', async ()=>{
104
+ mockTransporter.verify.mockResolvedValue(true);
105
+ await provider.initialize({
106
+ host: 'smtp.example.com',
107
+ port: 587,
108
+ secure: true
109
+ });
110
+ expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({
111
+ secure: true
112
+ }));
113
+ });
114
+ it('allows disabling TLS certificate validation via config', async ()=>{
115
+ mockTransporter.verify.mockResolvedValue(true);
116
+ await provider.initialize({
117
+ host: 'smtp.example.com',
118
+ port: 587,
119
+ tls: {
120
+ rejectUnauthorized: false
121
+ }
122
+ });
123
+ expect(createTransport).toHaveBeenCalledWith(expect.objectContaining({
124
+ tls: expect.objectContaining({
125
+ rejectUnauthorized: false
126
+ })
127
+ }));
128
+ });
129
+ it('propagates a connection verification failure', async ()=>{
130
+ mockTransporter.verify.mockRejectedValue(new Error('ECONNREFUSED'));
131
+ await expect(provider.initialize({
132
+ host: 'smtp.example.com',
133
+ port: 587
134
+ })).rejects.toThrow('ECONNREFUSED');
135
+ });
136
+ });
137
+ describe('sendEmail', ()=>{
138
+ it('returns a failure result when the provider was never initialized', async ()=>{
139
+ const result = await provider.sendEmail({
140
+ to: 'a@example.com',
141
+ subject: 'Hi'
142
+ });
143
+ expect(result).toEqual({
144
+ success: false,
145
+ error: 'SMTP provider not initialized'
146
+ });
147
+ });
148
+ it('sends mail via the transporter and returns the message id', async ()=>{
149
+ injectTransporter(provider, mockTransporter);
150
+ mockTransporter.sendMail.mockResolvedValue({
151
+ messageId: 'msg-1'
152
+ });
153
+ const result = await provider.sendEmail({
154
+ to: [
155
+ 'a@example.com',
156
+ 'b@example.com'
157
+ ],
158
+ cc: 'c@example.com',
159
+ bcc: [
160
+ 'd@example.com'
161
+ ],
162
+ subject: 'Hello',
163
+ html: '<p>hi</p>',
164
+ text: 'hi',
165
+ from: 'sender@example.com',
166
+ fromName: 'Sender Name',
167
+ replyTo: 'reply@example.com'
168
+ });
169
+ expect(result).toEqual({
170
+ success: true,
171
+ messageId: 'msg-1'
172
+ });
173
+ expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
174
+ from: '"Sender Name" <sender@example.com>',
175
+ to: 'a@example.com, b@example.com',
176
+ cc: 'c@example.com',
177
+ bcc: 'd@example.com',
178
+ subject: 'Hello',
179
+ html: '<p>hi</p>',
180
+ text: 'hi',
181
+ replyTo: 'reply@example.com'
182
+ }));
183
+ });
184
+ it('sends the bare "from" address when fromName is not provided', async ()=>{
185
+ injectTransporter(provider, mockTransporter);
186
+ mockTransporter.sendMail.mockResolvedValue({
187
+ messageId: 'msg-1'
188
+ });
189
+ await provider.sendEmail({
190
+ to: 'a@example.com',
191
+ subject: 'Hi',
192
+ from: 'sender@example.com'
193
+ });
194
+ expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
195
+ from: 'sender@example.com'
196
+ }));
197
+ });
198
+ it('maps attachments through to nodemailer mail options', async ()=>{
199
+ injectTransporter(provider, mockTransporter);
200
+ mockTransporter.sendMail.mockResolvedValue({
201
+ messageId: 'msg-1'
202
+ });
203
+ await provider.sendEmail({
204
+ to: 'a@example.com',
205
+ subject: 'Hi',
206
+ attachments: [
207
+ {
208
+ filename: 'doc.txt',
209
+ content: Buffer.from('hi'),
210
+ contentType: 'text/plain'
211
+ }
212
+ ]
213
+ });
214
+ const [mailOptions] = mockTransporter.sendMail.mock.calls[0];
215
+ expect(mailOptions.attachments).toEqual([
216
+ {
217
+ filename: 'doc.txt',
218
+ content: Buffer.from('hi'),
219
+ contentType: 'text/plain',
220
+ encoding: undefined
221
+ }
222
+ ]);
223
+ });
224
+ it('returns the error message when sendMail rejects with an Error', async ()=>{
225
+ injectTransporter(provider, mockTransporter);
226
+ mockTransporter.sendMail.mockRejectedValue(new Error('Connection refused'));
227
+ const result = await provider.sendEmail({
228
+ to: 'a@example.com',
229
+ subject: 'Hi'
230
+ });
231
+ expect(result).toEqual({
232
+ success: false,
233
+ error: 'Connection refused'
234
+ });
235
+ });
236
+ it('returns "Unknown error" when sendMail rejects with a non-Error', async ()=>{
237
+ injectTransporter(provider, mockTransporter);
238
+ mockTransporter.sendMail.mockRejectedValue('raw string failure');
239
+ const result = await provider.sendEmail({
240
+ to: 'a@example.com',
241
+ subject: 'Hi'
242
+ });
243
+ expect(result).toEqual({
244
+ success: false,
245
+ error: 'Unknown error'
246
+ });
247
+ });
248
+ it('surfaces a timeout error if sendMail never settles before the send timeout', async ()=>{
249
+ jest.useFakeTimers();
250
+ injectTransporter(provider, mockTransporter);
251
+ mockTransporter.sendMail.mockReturnValue(new Promise(()=>{}));
252
+ const pending = provider.sendEmail({
253
+ to: 'a@example.com',
254
+ subject: 'Hi'
255
+ });
256
+ jest.advanceTimersByTime(30_000);
257
+ const result = await pending;
258
+ expect(result).toEqual({
259
+ success: false,
260
+ error: 'SMTP send timeout'
261
+ });
262
+ jest.useRealTimers();
263
+ });
264
+ });
265
+ describe('sendBulkEmails', ()=>{
266
+ it('sends each message individually and aggregates the results', async ()=>{
267
+ injectTransporter(provider, mockTransporter);
268
+ mockTransporter.sendMail.mockResolvedValueOnce({
269
+ messageId: 'id-1'
270
+ }).mockRejectedValueOnce(new Error('failed-2'));
271
+ const results = await provider.sendBulkEmails([
272
+ {
273
+ to: 'a@example.com',
274
+ subject: 'A'
275
+ },
276
+ {
277
+ to: 'b@example.com',
278
+ subject: 'B'
279
+ }
280
+ ]);
281
+ expect(results).toEqual([
282
+ {
283
+ success: true,
284
+ messageId: 'id-1'
285
+ },
286
+ {
287
+ success: false,
288
+ error: 'failed-2'
289
+ }
290
+ ]);
291
+ });
292
+ });
293
+ describe('healthCheck', ()=>{
294
+ it('returns false when not initialized', async ()=>{
295
+ await expect(provider.healthCheck()).resolves.toBe(false);
296
+ });
297
+ it('returns true when transporter.verify() resolves', async ()=>{
298
+ injectTransporter(provider, mockTransporter);
299
+ mockTransporter.verify.mockResolvedValue(true);
300
+ await expect(provider.healthCheck()).resolves.toBe(true);
301
+ });
302
+ it('returns false when transporter.verify() rejects', async ()=>{
303
+ injectTransporter(provider, mockTransporter);
304
+ mockTransporter.verify.mockRejectedValue(new Error('down'));
305
+ await expect(provider.healthCheck()).resolves.toBe(false);
306
+ });
307
+ });
308
+ describe('close', ()=>{
309
+ it('closes and clears the transporter', async ()=>{
310
+ injectTransporter(provider, mockTransporter);
311
+ await provider.close();
312
+ expect(mockTransporter.close).toHaveBeenCalled();
313
+ const result = await provider.sendEmail({
314
+ to: 'a@example.com',
315
+ subject: 'Hi'
316
+ });
317
+ expect(result).toEqual({
318
+ success: false,
319
+ error: 'SMTP provider not initialized'
320
+ });
321
+ });
322
+ it('is a no-op when no transporter exists', async ()=>{
323
+ await expect(provider.close()).resolves.toBeUndefined();
324
+ expect(mockTransporter.close).not.toHaveBeenCalled();
325
+ });
326
+ });
327
+ });