@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,542 @@
|
|
|
1
|
+
import { Test } from '@nestjs/testing';
|
|
2
|
+
import { BadRequestException } from '@nestjs/common';
|
|
3
|
+
import { buildMockUser } from '@test-utils/mocks/logged-user.mock';
|
|
4
|
+
import { EmailFactoryService } from '../providers';
|
|
5
|
+
import { EmailConfigService } from './email-config.service';
|
|
6
|
+
import { EmailProviderConfigService } from './email-provider-config.service';
|
|
7
|
+
import { EmailSendService } from './email-send.service';
|
|
8
|
+
import { EmailTemplateService } from './email-template.service';
|
|
9
|
+
function buildConfig(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
id: 'config-uuid-1',
|
|
12
|
+
name: 'Primary SMTP',
|
|
13
|
+
provider: 'smtp',
|
|
14
|
+
config: {},
|
|
15
|
+
fromEmail: 'noreply@example.com',
|
|
16
|
+
fromName: 'FLUSYS',
|
|
17
|
+
isActive: true,
|
|
18
|
+
isDefault: true,
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
updatedAt: new Date(),
|
|
21
|
+
deletedAt: null,
|
|
22
|
+
createdById: null,
|
|
23
|
+
updatedById: null,
|
|
24
|
+
deletedById: null,
|
|
25
|
+
...overrides
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function buildTemplate(overrides = {}) {
|
|
29
|
+
return {
|
|
30
|
+
id: 'template-uuid-1',
|
|
31
|
+
name: 'Welcome Email',
|
|
32
|
+
slug: 'welcome-email',
|
|
33
|
+
description: null,
|
|
34
|
+
subject: 'Welcome {{firstName}}!',
|
|
35
|
+
schema: {},
|
|
36
|
+
htmlContent: '<h1>Welcome {{firstName}}!</h1>',
|
|
37
|
+
textContent: 'Welcome {{firstName}}!',
|
|
38
|
+
schemaVersion: 1,
|
|
39
|
+
isActive: true,
|
|
40
|
+
isHtml: true,
|
|
41
|
+
createdAt: new Date(),
|
|
42
|
+
updatedAt: new Date(),
|
|
43
|
+
deletedAt: null,
|
|
44
|
+
createdById: null,
|
|
45
|
+
updatedById: null,
|
|
46
|
+
deletedById: null,
|
|
47
|
+
...overrides
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
describe('EmailSendService', ()=>{
|
|
51
|
+
let service;
|
|
52
|
+
let mockEmailFactory;
|
|
53
|
+
let mockEmailConfigService;
|
|
54
|
+
let mockEmailProviderConfigService;
|
|
55
|
+
let mockEmailTemplateService;
|
|
56
|
+
let mockProvider;
|
|
57
|
+
beforeEach(async ()=>{
|
|
58
|
+
mockProvider = {
|
|
59
|
+
sendEmail: jest.fn().mockResolvedValue({
|
|
60
|
+
success: true,
|
|
61
|
+
messageId: 'msg-1'
|
|
62
|
+
}),
|
|
63
|
+
sendBulkEmails: jest.fn(),
|
|
64
|
+
healthCheck: jest.fn()
|
|
65
|
+
};
|
|
66
|
+
mockEmailFactory = {
|
|
67
|
+
createProvider: jest.fn().mockResolvedValue(mockProvider)
|
|
68
|
+
};
|
|
69
|
+
mockEmailConfigService = {
|
|
70
|
+
isCompanyFeatureEnabled: jest.fn().mockReturnValue(false),
|
|
71
|
+
getDefaultFromName: jest.fn().mockReturnValue('FLUSYS')
|
|
72
|
+
};
|
|
73
|
+
mockEmailProviderConfigService = {
|
|
74
|
+
findByIdDirect: jest.fn(),
|
|
75
|
+
getDefaultConfig: jest.fn()
|
|
76
|
+
};
|
|
77
|
+
mockEmailTemplateService = {
|
|
78
|
+
findByIdDirect: jest.fn(),
|
|
79
|
+
findBySlug: jest.fn()
|
|
80
|
+
};
|
|
81
|
+
const module = await Test.createTestingModule({
|
|
82
|
+
providers: [
|
|
83
|
+
EmailSendService,
|
|
84
|
+
{
|
|
85
|
+
provide: EmailFactoryService,
|
|
86
|
+
useValue: mockEmailFactory
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
provide: EmailConfigService,
|
|
90
|
+
useValue: mockEmailConfigService
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
provide: EmailProviderConfigService,
|
|
94
|
+
useValue: mockEmailProviderConfigService
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
provide: EmailTemplateService,
|
|
98
|
+
useValue: mockEmailTemplateService
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}).compile();
|
|
102
|
+
service = await module.resolve(EmailSendService);
|
|
103
|
+
});
|
|
104
|
+
describe('sendEmail', ()=>{
|
|
105
|
+
it('resolves the config by id and forwards the payload to the provider', async ()=>{
|
|
106
|
+
const config = buildConfig();
|
|
107
|
+
mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
|
|
108
|
+
const dto = {
|
|
109
|
+
to: 'user@example.com',
|
|
110
|
+
subject: 'Hello',
|
|
111
|
+
html: '<p>Hi</p>',
|
|
112
|
+
emailConfigId: 'config-uuid-1'
|
|
113
|
+
};
|
|
114
|
+
const result = await service.sendEmail(dto, buildMockUser());
|
|
115
|
+
expect(result).toEqual({
|
|
116
|
+
success: true,
|
|
117
|
+
messageId: 'msg-1'
|
|
118
|
+
});
|
|
119
|
+
expect(mockEmailFactory.createProvider).toHaveBeenCalledWith({
|
|
120
|
+
provider: config.provider,
|
|
121
|
+
config: config.config
|
|
122
|
+
});
|
|
123
|
+
expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
|
|
124
|
+
to: 'user@example.com',
|
|
125
|
+
subject: 'Hello',
|
|
126
|
+
html: '<p>Hi</p>',
|
|
127
|
+
from: 'noreply@example.com',
|
|
128
|
+
fromName: 'FLUSYS'
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
it('falls back to the default config when no emailConfigId is supplied', async ()=>{
|
|
132
|
+
const config = buildConfig();
|
|
133
|
+
mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(config);
|
|
134
|
+
await service.sendEmail({
|
|
135
|
+
to: 'a@example.com',
|
|
136
|
+
subject: 'Hi',
|
|
137
|
+
html: '<p/>'
|
|
138
|
+
}, buildMockUser());
|
|
139
|
+
expect(mockEmailProviderConfigService.getDefaultConfig).toHaveBeenCalledWith(buildMockUser());
|
|
140
|
+
expect(mockEmailProviderConfigService.findByIdDirect).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
it('prefers dto.from/fromName over the config defaults', async ()=>{
|
|
143
|
+
const config = buildConfig({
|
|
144
|
+
fromEmail: 'config@example.com',
|
|
145
|
+
fromName: 'Config Name'
|
|
146
|
+
});
|
|
147
|
+
mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(config);
|
|
148
|
+
await service.sendEmail({
|
|
149
|
+
to: 'a@example.com',
|
|
150
|
+
subject: 'Hi',
|
|
151
|
+
html: '<p/>',
|
|
152
|
+
from: 'override@example.com',
|
|
153
|
+
fromName: 'Override Name'
|
|
154
|
+
}, buildMockUser());
|
|
155
|
+
expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
|
|
156
|
+
from: 'override@example.com',
|
|
157
|
+
fromName: 'Override Name'
|
|
158
|
+
}));
|
|
159
|
+
});
|
|
160
|
+
it('base64-decodes attachment content before forwarding to the provider', async ()=>{
|
|
161
|
+
mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
|
|
162
|
+
await service.sendEmail({
|
|
163
|
+
to: 'a@example.com',
|
|
164
|
+
subject: 'Hi',
|
|
165
|
+
html: '<p/>',
|
|
166
|
+
attachments: [
|
|
167
|
+
{
|
|
168
|
+
filename: 'a.txt',
|
|
169
|
+
content: Buffer.from('hello').toString('base64')
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}, buildMockUser());
|
|
173
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
174
|
+
expect(payload.attachments?.[0].content.toString('utf8')).toBe('hello');
|
|
175
|
+
});
|
|
176
|
+
it('throws NotFoundException with CONFIG_NOT_FOUND when the referenced config does not exist', async ()=>{
|
|
177
|
+
mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(null);
|
|
178
|
+
await expect(service.sendEmail({
|
|
179
|
+
to: 'a@example.com',
|
|
180
|
+
subject: 'Hi',
|
|
181
|
+
html: '<p/>',
|
|
182
|
+
emailConfigId: 'missing'
|
|
183
|
+
}, buildMockUser())).rejects.toMatchObject({
|
|
184
|
+
response: expect.objectContaining({
|
|
185
|
+
messageKey: 'email.send.config.not.found'
|
|
186
|
+
})
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
it('throws BadRequestException with CONFIG_INACTIVE when the config is inactive', async ()=>{
|
|
190
|
+
mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(buildConfig({
|
|
191
|
+
isActive: false
|
|
192
|
+
}));
|
|
193
|
+
await expect(service.sendEmail({
|
|
194
|
+
to: 'a@example.com',
|
|
195
|
+
subject: 'Hi',
|
|
196
|
+
html: '<p/>',
|
|
197
|
+
emailConfigId: 'c-1'
|
|
198
|
+
}, buildMockUser())).rejects.toMatchObject({
|
|
199
|
+
response: expect.objectContaining({
|
|
200
|
+
messageKey: 'email.send.config.inactive'
|
|
201
|
+
})
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
it('rejects a config belonging to another company when the company feature is enabled', async ()=>{
|
|
205
|
+
mockEmailConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
206
|
+
mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(buildConfig({
|
|
207
|
+
companyId: 'other-company'
|
|
208
|
+
}));
|
|
209
|
+
await expect(service.sendEmail({
|
|
210
|
+
to: 'a@example.com',
|
|
211
|
+
subject: 'Hi',
|
|
212
|
+
html: '<p/>',
|
|
213
|
+
emailConfigId: 'c-1'
|
|
214
|
+
}, buildMockUser({
|
|
215
|
+
companyId: 'my-company'
|
|
216
|
+
}))).rejects.toThrow(BadRequestException);
|
|
217
|
+
});
|
|
218
|
+
it('throws NotFoundException with CONFIG_DEFAULT_NOT_FOUND when no default config exists', async ()=>{
|
|
219
|
+
mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(null);
|
|
220
|
+
await expect(service.sendEmail({
|
|
221
|
+
to: 'a@example.com',
|
|
222
|
+
subject: 'Hi',
|
|
223
|
+
html: '<p/>'
|
|
224
|
+
}, buildMockUser())).rejects.toMatchObject({
|
|
225
|
+
response: expect.objectContaining({
|
|
226
|
+
messageKey: 'email.send.config.default.not.found'
|
|
227
|
+
})
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('sendTemplateEmail — template resolution', ()=>{
|
|
232
|
+
beforeEach(()=>{
|
|
233
|
+
mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
|
|
234
|
+
});
|
|
235
|
+
it('resolves the template by templateId when provided', async ()=>{
|
|
236
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate());
|
|
237
|
+
await service.sendTemplateEmail({
|
|
238
|
+
to: 'a@example.com',
|
|
239
|
+
templateId: 'template-uuid-1'
|
|
240
|
+
}, buildMockUser());
|
|
241
|
+
expect(mockEmailTemplateService.findByIdDirect).toHaveBeenCalledWith('template-uuid-1');
|
|
242
|
+
expect(mockEmailTemplateService.findBySlug).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
it('resolves the template by templateSlug when templateId is absent', async ()=>{
|
|
245
|
+
mockEmailTemplateService.findBySlug.mockResolvedValue(buildTemplate());
|
|
246
|
+
const user = buildMockUser();
|
|
247
|
+
await service.sendTemplateEmail({
|
|
248
|
+
to: 'a@example.com',
|
|
249
|
+
templateSlug: 'welcome-email'
|
|
250
|
+
}, user);
|
|
251
|
+
expect(mockEmailTemplateService.findBySlug).toHaveBeenCalledWith('welcome-email', user);
|
|
252
|
+
});
|
|
253
|
+
it('throws BadRequestException with TEMPLATE_ID_OR_SLUG_REQUIRED when neither is provided', async ()=>{
|
|
254
|
+
await expect(service.sendTemplateEmail({
|
|
255
|
+
to: 'a@example.com'
|
|
256
|
+
}, buildMockUser())).rejects.toMatchObject({
|
|
257
|
+
response: expect.objectContaining({
|
|
258
|
+
messageKey: 'email.send.template.id.or.slug.required'
|
|
259
|
+
})
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
it('throws NotFoundException with TEMPLATE_NOT_FOUND when the template does not exist', async ()=>{
|
|
263
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(null);
|
|
264
|
+
await expect(service.sendTemplateEmail({
|
|
265
|
+
to: 'a@example.com',
|
|
266
|
+
templateId: 'missing'
|
|
267
|
+
}, buildMockUser())).rejects.toMatchObject({
|
|
268
|
+
response: expect.objectContaining({
|
|
269
|
+
messageKey: 'email.send.template.not.found'
|
|
270
|
+
})
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
it('throws BadRequestException with TEMPLATE_INACTIVE when the template is inactive', async ()=>{
|
|
274
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
275
|
+
isActive: false
|
|
276
|
+
}));
|
|
277
|
+
await expect(service.sendTemplateEmail({
|
|
278
|
+
to: 'a@example.com',
|
|
279
|
+
templateId: 'template-uuid-1'
|
|
280
|
+
}, buildMockUser())).rejects.toMatchObject({
|
|
281
|
+
response: expect.objectContaining({
|
|
282
|
+
messageKey: 'email.send.template.inactive'
|
|
283
|
+
})
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
it('rejects a template belonging to another company when the company feature is enabled', async ()=>{
|
|
287
|
+
mockEmailConfigService.isCompanyFeatureEnabled.mockReturnValue(true);
|
|
288
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
289
|
+
companyId: 'other-company'
|
|
290
|
+
}));
|
|
291
|
+
await expect(service.sendTemplateEmail({
|
|
292
|
+
to: 'a@example.com',
|
|
293
|
+
templateId: 'template-uuid-1'
|
|
294
|
+
}, buildMockUser({
|
|
295
|
+
companyId: 'my-company'
|
|
296
|
+
}))).rejects.toThrow(BadRequestException);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
describe('sendTemplateEmail — variable interpolation & XSS safety', ()=>{
|
|
300
|
+
beforeEach(()=>{
|
|
301
|
+
mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
|
|
302
|
+
});
|
|
303
|
+
it('HTML-escapes untrusted variables in the html body', async ()=>{
|
|
304
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
305
|
+
subject: 'Hi {{name}}',
|
|
306
|
+
htmlContent: '<p>Hi {{name}}</p>',
|
|
307
|
+
isHtml: true
|
|
308
|
+
}));
|
|
309
|
+
await service.sendTemplateEmail({
|
|
310
|
+
to: 'a@example.com',
|
|
311
|
+
templateId: 'template-uuid-1',
|
|
312
|
+
variables: {
|
|
313
|
+
name: '<script>alert(1)</script>'
|
|
314
|
+
}
|
|
315
|
+
}, buildMockUser());
|
|
316
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
317
|
+
expect(payload.html).toBe('<p>Hi <script>alert(1)</script></p>');
|
|
318
|
+
expect(payload.html).not.toContain('<script>');
|
|
319
|
+
});
|
|
320
|
+
it('does NOT escape variables interpolated into the subject line', async ()=>{
|
|
321
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
322
|
+
subject: 'Hi {{name}}',
|
|
323
|
+
isHtml: true
|
|
324
|
+
}));
|
|
325
|
+
await service.sendTemplateEmail({
|
|
326
|
+
to: 'a@example.com',
|
|
327
|
+
templateId: 'template-uuid-1',
|
|
328
|
+
variables: {
|
|
329
|
+
name: '<b>Bob</b>'
|
|
330
|
+
}
|
|
331
|
+
}, buildMockUser());
|
|
332
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
333
|
+
expect(payload.subject).toBe('Hi <b>Bob</b>');
|
|
334
|
+
});
|
|
335
|
+
it('does not escape variables interpolated into plain-text content', async ()=>{
|
|
336
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
337
|
+
isHtml: false,
|
|
338
|
+
textContent: 'Hi {{name}}'
|
|
339
|
+
}));
|
|
340
|
+
await service.sendTemplateEmail({
|
|
341
|
+
to: 'a@example.com',
|
|
342
|
+
templateId: 'template-uuid-1',
|
|
343
|
+
variables: {
|
|
344
|
+
name: '<script>x</script>'
|
|
345
|
+
}
|
|
346
|
+
}, buildMockUser());
|
|
347
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
348
|
+
expect(payload.text).toBe('Hi <script>x</script>');
|
|
349
|
+
expect(payload.html).toBeUndefined();
|
|
350
|
+
});
|
|
351
|
+
it('falls back to htmlContent as text when isHtml is false and textContent is empty', async ()=>{
|
|
352
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
353
|
+
isHtml: false,
|
|
354
|
+
textContent: null,
|
|
355
|
+
htmlContent: 'Plain {{name}}'
|
|
356
|
+
}));
|
|
357
|
+
await service.sendTemplateEmail({
|
|
358
|
+
to: 'a@example.com',
|
|
359
|
+
templateId: 'template-uuid-1',
|
|
360
|
+
variables: {
|
|
361
|
+
name: 'Bob'
|
|
362
|
+
}
|
|
363
|
+
}, buildMockUser());
|
|
364
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
365
|
+
expect(payload.text).toBe('Plain Bob');
|
|
366
|
+
});
|
|
367
|
+
it('leaves unresolved {{variable}} tokens untouched when the variable is missing', async ()=>{
|
|
368
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
369
|
+
htmlContent: '<p>Hi {{missing}}</p>',
|
|
370
|
+
isHtml: true
|
|
371
|
+
}));
|
|
372
|
+
await service.sendTemplateEmail({
|
|
373
|
+
to: 'a@example.com',
|
|
374
|
+
templateId: 'template-uuid-1',
|
|
375
|
+
variables: {
|
|
376
|
+
unrelated: 'value'
|
|
377
|
+
}
|
|
378
|
+
}, buildMockUser());
|
|
379
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
380
|
+
expect(payload.html).toBe('<p>Hi {{missing}}</p>');
|
|
381
|
+
});
|
|
382
|
+
it('substitutes a repeated variable token every time it appears', async ()=>{
|
|
383
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
384
|
+
htmlContent: '<p>{{name}} and {{name}} again</p>',
|
|
385
|
+
isHtml: true
|
|
386
|
+
}));
|
|
387
|
+
await service.sendTemplateEmail({
|
|
388
|
+
to: 'a@example.com',
|
|
389
|
+
templateId: 'template-uuid-1',
|
|
390
|
+
variables: {
|
|
391
|
+
name: 'Bob'
|
|
392
|
+
}
|
|
393
|
+
}, buildMockUser());
|
|
394
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
395
|
+
expect(payload.html).toBe('<p>Bob and Bob again</p>');
|
|
396
|
+
});
|
|
397
|
+
it('interpolates multiple distinct variables in the same content string', async ()=>{
|
|
398
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
399
|
+
htmlContent: '<p>{{greeting}}, {{name}}!</p>',
|
|
400
|
+
isHtml: true
|
|
401
|
+
}));
|
|
402
|
+
await service.sendTemplateEmail({
|
|
403
|
+
to: 'a@example.com',
|
|
404
|
+
templateId: 'template-uuid-1',
|
|
405
|
+
variables: {
|
|
406
|
+
greeting: 'Hello',
|
|
407
|
+
name: 'Bob'
|
|
408
|
+
}
|
|
409
|
+
}, buildMockUser());
|
|
410
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
411
|
+
expect(payload.html).toBe('<p>Hello, Bob!</p>');
|
|
412
|
+
});
|
|
413
|
+
it('stringifies and escapes non-string variables (numbers, objects)', async ()=>{
|
|
414
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
415
|
+
htmlContent: '<p>Count: {{count}}, Meta: {{meta}}</p>',
|
|
416
|
+
isHtml: true
|
|
417
|
+
}));
|
|
418
|
+
await service.sendTemplateEmail({
|
|
419
|
+
to: 'a@example.com',
|
|
420
|
+
templateId: 'template-uuid-1',
|
|
421
|
+
variables: {
|
|
422
|
+
count: 5,
|
|
423
|
+
meta: {
|
|
424
|
+
a: '<b>'
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}, buildMockUser());
|
|
428
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
429
|
+
expect(payload.html).toContain('Count: 5');
|
|
430
|
+
expect(payload.html).toContain('"a"');
|
|
431
|
+
expect(payload.html).not.toContain('<b>');
|
|
432
|
+
});
|
|
433
|
+
it('treats null/undefined variables as empty strings', async ()=>{
|
|
434
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
435
|
+
htmlContent: '<p>[{{missingVar}}]</p>',
|
|
436
|
+
isHtml: true
|
|
437
|
+
}));
|
|
438
|
+
await service.sendTemplateEmail({
|
|
439
|
+
to: 'a@example.com',
|
|
440
|
+
templateId: 'template-uuid-1',
|
|
441
|
+
variables: {
|
|
442
|
+
missingVar: null
|
|
443
|
+
}
|
|
444
|
+
}, buildMockUser());
|
|
445
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
446
|
+
expect(payload.html).toBe('<p>[]</p>');
|
|
447
|
+
});
|
|
448
|
+
it('returns the content unchanged when no variables are supplied', async ()=>{
|
|
449
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate({
|
|
450
|
+
htmlContent: '<p>Hi {{name}}</p>',
|
|
451
|
+
isHtml: true
|
|
452
|
+
}));
|
|
453
|
+
await service.sendTemplateEmail({
|
|
454
|
+
to: 'a@example.com',
|
|
455
|
+
templateId: 'template-uuid-1'
|
|
456
|
+
}, buildMockUser());
|
|
457
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
458
|
+
expect(payload.html).toBe('<p>Hi {{name}}</p>');
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
describe('sendTemplateEmail — provider dispatch', ()=>{
|
|
462
|
+
it('resolves the provider from the resolved config and sends the interpolated content', async ()=>{
|
|
463
|
+
const config = buildConfig({
|
|
464
|
+
id: 'config-uuid-9',
|
|
465
|
+
provider: 'sendgrid',
|
|
466
|
+
config: {
|
|
467
|
+
apiKey: 'x'
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
|
|
471
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate());
|
|
472
|
+
const result = await service.sendTemplateEmail({
|
|
473
|
+
to: 'a@example.com',
|
|
474
|
+
templateId: 'template-uuid-1',
|
|
475
|
+
emailConfigId: 'config-uuid-9',
|
|
476
|
+
variables: {
|
|
477
|
+
firstName: 'Bob'
|
|
478
|
+
}
|
|
479
|
+
}, buildMockUser());
|
|
480
|
+
expect(result).toEqual({
|
|
481
|
+
success: true,
|
|
482
|
+
messageId: 'msg-1'
|
|
483
|
+
});
|
|
484
|
+
expect(mockEmailFactory.createProvider).toHaveBeenCalledWith({
|
|
485
|
+
provider: 'sendgrid',
|
|
486
|
+
config: {
|
|
487
|
+
apiKey: 'x'
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
|
|
491
|
+
subject: 'Welcome Bob!',
|
|
492
|
+
html: '<h1>Welcome Bob!</h1>'
|
|
493
|
+
}));
|
|
494
|
+
});
|
|
495
|
+
it('propagates a provider-level send failure result without throwing', async ()=>{
|
|
496
|
+
mockEmailProviderConfigService.getDefaultConfig.mockResolvedValue(buildConfig());
|
|
497
|
+
mockEmailTemplateService.findByIdDirect.mockResolvedValue(buildTemplate());
|
|
498
|
+
mockProvider.sendEmail.mockResolvedValue({
|
|
499
|
+
success: false,
|
|
500
|
+
error: 'Connection refused'
|
|
501
|
+
});
|
|
502
|
+
const result = await service.sendTemplateEmail({
|
|
503
|
+
to: 'a@example.com',
|
|
504
|
+
templateId: 'template-uuid-1'
|
|
505
|
+
}, buildMockUser());
|
|
506
|
+
expect(result).toEqual({
|
|
507
|
+
success: false,
|
|
508
|
+
error: 'Connection refused'
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
describe('sendTestEmail', ()=>{
|
|
513
|
+
it('sends a test email using the resolved configuration and returns the provider result', async ()=>{
|
|
514
|
+
const config = buildConfig({
|
|
515
|
+
name: 'Prod SMTP',
|
|
516
|
+
provider: 'smtp'
|
|
517
|
+
});
|
|
518
|
+
mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
|
|
519
|
+
const result = await service.sendTestEmail('config-uuid-1', 'recipient@example.com', buildMockUser());
|
|
520
|
+
expect(result).toEqual({
|
|
521
|
+
success: true,
|
|
522
|
+
messageId: 'msg-1'
|
|
523
|
+
});
|
|
524
|
+
expect(mockProvider.sendEmail).toHaveBeenCalledWith(expect.objectContaining({
|
|
525
|
+
to: 'recipient@example.com',
|
|
526
|
+
subject: 'Test Email from FLUSYS',
|
|
527
|
+
from: 'noreply@example.com'
|
|
528
|
+
}));
|
|
529
|
+
});
|
|
530
|
+
it('HTML-escapes the config name/provider before embedding them in the test email body', async ()=>{
|
|
531
|
+
const config = buildConfig({
|
|
532
|
+
name: '<script>alert(1)</script>',
|
|
533
|
+
provider: 'smtp'
|
|
534
|
+
});
|
|
535
|
+
mockEmailProviderConfigService.findByIdDirect.mockResolvedValue(config);
|
|
536
|
+
await service.sendTestEmail('config-uuid-1', 'recipient@example.com', buildMockUser());
|
|
537
|
+
const [payload] = mockProvider.sendEmail.mock.calls[0];
|
|
538
|
+
expect(payload.html).toContain('<script>alert(1)</script>');
|
|
539
|
+
expect(payload.html).not.toContain('<script>alert(1)</script>');
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
});
|