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