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