@stamhoofd/backend 2.137.4 → 2.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/package.json +26 -17
  2. package/src/boot.ts +5 -0
  3. package/src/crons/balance-emails.ts +2 -1
  4. package/src/crons/delete-expired-mfa-tokens.ts +35 -0
  5. package/src/crons/drip-emails.ts +2 -1
  6. package/src/crons/index.ts +1 -0
  7. package/src/crons/invoices.ts +5 -3
  8. package/src/crons.ts +4 -2
  9. package/src/email-recipient-loaders/orders.ts +2 -1
  10. package/src/email-recipient-loaders/organizations.ts +3 -2
  11. package/src/email-recipient-loaders/payments.ts +3 -2
  12. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  13. package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
  14. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  15. package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
  16. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  17. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  18. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  19. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
  20. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  21. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  22. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  23. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  24. package/src/endpoints/auth/MFA.test.ts +1398 -0
  25. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  26. package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
  27. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  28. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  29. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  30. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
  31. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  32. package/src/endpoints/auth/SignupEndpoint.ts +17 -4
  33. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  34. package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
  35. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
  36. package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
  37. package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
  38. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
  39. package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
  40. package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
  41. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  42. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  43. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
  44. package/src/endpoints/global/files/UploadFile.test.ts +206 -0
  45. package/src/endpoints/global/files/UploadFile.ts +31 -6
  46. package/src/endpoints/global/files/UploadImage.test.ts +177 -0
  47. package/src/endpoints/global/files/UploadImage.ts +23 -4
  48. package/src/endpoints/global/files/upload-security.test.ts +837 -0
  49. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
  50. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  51. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
  52. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  53. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  54. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  55. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  56. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  57. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
  58. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  59. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
  60. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  62. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  63. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  64. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  65. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  66. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
  67. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  68. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  69. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
  70. package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
  72. package/src/excel-loaders/balance-items.ts +1 -1
  73. package/src/excel-loaders/event-notifications.ts +6 -7
  74. package/src/excel-loaders/members.test.ts +2 -2
  75. package/src/excel-loaders/members.ts +9 -10
  76. package/src/excel-loaders/organizations.ts +14 -20
  77. package/src/excel-loaders/payments.ts +1 -1
  78. package/src/excel-loaders/platform-memberships.ts +7 -7
  79. package/src/excel-loaders/platform-sheets.test.ts +113 -0
  80. package/src/excel-loaders/receivable-balances.ts +1 -1
  81. package/src/excel-loaders/registrations.ts +9 -9
  82. package/src/helpers/AdminPermissionChecker.ts +1 -1
  83. package/src/helpers/AuthenticatedStructures.ts +21 -10
  84. package/src/helpers/Context.ts +114 -4
  85. package/src/helpers/EmailBuilder.test.ts +287 -0
  86. package/src/helpers/EmailBuilder.ts +811 -0
  87. package/src/helpers/EmailResumer.ts +2 -1
  88. package/src/helpers/ForwardHandler.ts +2 -1
  89. package/src/helpers/MFAEncryption.ts +34 -0
  90. package/src/helpers/MembershipCharger.ts +3 -2
  91. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  92. package/src/helpers/TOTPHelper.ts +79 -0
  93. package/src/helpers/TenantContext.test.ts +96 -0
  94. package/src/helpers/TenantContext.ts +67 -0
  95. package/src/helpers/TwoFactorHelper.ts +356 -0
  96. package/src/helpers/WebauthnHelper.ts +211 -0
  97. package/src/helpers/data/aaguids.json +1006 -0
  98. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  99. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  100. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  101. package/src/services/AdminSessionService.ts +41 -0
  102. package/src/services/BalanceItemService.ts +2 -1
  103. package/src/services/DocumentRenderService.test.ts +229 -0
  104. package/src/services/DocumentRenderService.ts +180 -0
  105. package/src/services/EmailPreviewService.test.ts +300 -0
  106. package/src/services/EmailPreviewService.ts +239 -0
  107. package/src/services/EmailSendService.test.ts +1218 -0
  108. package/src/services/EmailSendService.ts +705 -0
  109. package/src/services/EventNotificationService.ts +2 -1
  110. package/src/services/FileSignService.test.ts +85 -0
  111. package/src/services/FileSignService.ts +23 -1
  112. package/src/services/InvoicePdfService.ts +3 -3
  113. package/src/services/InvoiceService.ts +4 -2
  114. package/src/services/InvoiceXMLService.ts +2 -1
  115. package/src/services/OrderService.test.ts +308 -0
  116. package/src/services/OrderService.ts +215 -0
  117. package/src/services/OrganizationAdminService.test.ts +47 -0
  118. package/src/services/OrganizationAdminService.ts +139 -0
  119. package/src/services/OrganizationDNSService.test.ts +177 -0
  120. package/src/services/OrganizationDNSService.ts +282 -0
  121. package/src/services/OrganizationEmailService.test.ts +57 -0
  122. package/src/services/OrganizationEmailService.ts +212 -0
  123. package/src/services/PasswordForgotService.test.ts +99 -0
  124. package/src/services/PasswordForgotService.ts +38 -0
  125. package/src/services/PaymentService.ts +5 -3
  126. package/src/services/PlatformMembershipService.test.ts +180 -0
  127. package/src/services/PlatformMembershipService.ts +281 -4
  128. package/src/services/ReferralService.ts +3 -2
  129. package/src/services/RegistrationService.ts +4 -2
  130. package/src/services/SSOService.ts +106 -14
  131. package/src/services/STPackageService.test.ts +191 -0
  132. package/src/services/STPackageService.ts +115 -1
  133. package/src/services/TwoFactorAuditLogService.ts +64 -0
  134. package/src/services/VerificationCodeService.test.ts +71 -0
  135. package/src/services/VerificationCodeService.ts +101 -0
  136. package/tests/e2e/api-rate-limits.test.ts +1 -1
  137. package/tests/e2e/documents.test.ts +2 -1
  138. package/tests/e2e/private-files.test.ts +77 -14
  139. package/tests/helpers/MFATestHelper.ts +42 -0
  140. package/tests/helpers/TestServer.ts +4 -0
  141. package/tests/helpers/index.ts +1 -0
  142. package/tsconfig.build.json +2 -1
@@ -0,0 +1,1218 @@
1
+ import type { LimitedFilteredRequest } from '@stamhoofd/structures';
2
+ import { BalanceItemType, EmailRecipientFilter, EmailRecipientsStatus, EmailRecipient as EmailRecipientStruct, EmailRecipientSubfilter, EmailStatus, OrganizationMetaData, PaginatedResponse } from '@stamhoofd/structures';
3
+ import { EmailMocker } from '@stamhoofd/email';
4
+ import { STExpect, TestUtils } from '@stamhoofd/test-utils';
5
+ import { BalanceItemFactory, CachedBalance, Email, EmailRecipient, MemberFactory, OrganizationFactory, Platform, UserFactory } from '@stamhoofd/models';
6
+ import { EmailSendService } from './EmailSendService.js';
7
+ import { Formatter } from '@stamhoofd/utility';
8
+ import { EmailRecipientFilterType } from '@stamhoofd/structures/email/EmailRecipientFilterType.js';
9
+
10
+ async function buildEmail(data: {
11
+ recipients: EmailRecipientStruct[];
12
+ } & Partial<Email>) {
13
+ Email.recipientLoaders.set(EmailRecipientFilterType.Members, {
14
+ fetch: async (query: LimitedFilteredRequest) => {
15
+ return Promise.resolve(
16
+ new PaginatedResponse<EmailRecipientStruct[], LimitedFilteredRequest>({
17
+ results: data.recipients,
18
+ next: undefined,
19
+ }),
20
+ );
21
+ },
22
+
23
+ count: async (query: LimitedFilteredRequest) => {
24
+ return data.recipients.length;
25
+ },
26
+ });
27
+
28
+ const model = new Email();
29
+ model.userId = null;
30
+ model.organizationId = data.organizationId ?? null;
31
+ model.recipientFilter = EmailRecipientFilter.create({
32
+ filters: [
33
+ EmailRecipientSubfilter.create({
34
+ type: EmailRecipientFilterType.Members,
35
+ filter: { id: { $in: ['test'] } },
36
+ }),
37
+ ],
38
+ });
39
+
40
+ model.subject = data.subject ?? 'This is a test email';
41
+ model.html = data.html ?? '<p>This is a test email</p> {{unsubscribeUrl}}';
42
+ model.text = data.text ?? 'This is a test email {{unsubscribeUrl}}';
43
+ model.json = data.json ?? {};
44
+ model.status = data.status ?? EmailStatus.Draft;
45
+ model.attachments = [];
46
+ model.fromAddress = data.fromAddress ?? 'test@stamhoofd.be';
47
+ model.fromName = data.fromName ?? null;
48
+ model.emailType = data.emailType ?? null;
49
+
50
+ await model.save();
51
+
52
+ return model;
53
+ }
54
+
55
+ /**
56
+ * An email that won't be able to fetch recipients
57
+ */
58
+ async function buildFailEmail(data: {
59
+ recipients: EmailRecipientStruct[];
60
+ } & Partial<Email>) {
61
+ Email.recipientLoaders.set(EmailRecipientFilterType.Members, {
62
+ fetch: async (query: LimitedFilteredRequest) => {
63
+ throw new Error('This is a simulated error while fetching recipients');
64
+ },
65
+
66
+ count: async (query: LimitedFilteredRequest) => {
67
+ return data.recipients.length;
68
+ },
69
+ });
70
+
71
+ const model = new Email();
72
+ model.userId = null;
73
+ model.organizationId = data.organizationId ?? null;
74
+ model.recipientFilter = EmailRecipientFilter.create({
75
+ filters: [
76
+ EmailRecipientSubfilter.create({
77
+ type: EmailRecipientFilterType.Members,
78
+ filter: { id: { $in: ['test'] } },
79
+ }),
80
+ ],
81
+ });
82
+
83
+ model.subject = data.subject ?? 'This is a test email';
84
+ model.html = data.html ?? '<p>This is a test email</p> {{unsubscribeUrl}}';
85
+ model.text = data.text ?? 'This is a test email {{unsubscribeUrl}}';
86
+ model.json = data.json ?? {};
87
+ model.status = data.status ?? EmailStatus.Draft;
88
+ model.attachments = [];
89
+ model.fromAddress = data.fromAddress ?? 'test@stamhoofd.be';
90
+ model.fromName = data.fromName ?? null;
91
+ model.emailType = data.emailType ?? null;
92
+
93
+ await model.save();
94
+
95
+ return model;
96
+ }
97
+
98
+ describe('EmailSendService', () => {
99
+ it('Correctly whitelists email recipients', async () => {
100
+ TestUtils.setEnvironment('WHITELISTED_EMAIL_DESTINATIONS', ['*@stamhoofd-allowed-domain-tests.be']);
101
+ const model = await buildEmail({
102
+ recipients: [
103
+ EmailRecipientStruct.create({
104
+ firstName: 'Test',
105
+ lastName: 'Test',
106
+ email: 'example@not-whitelisted-domain.be',
107
+ }),
108
+ EmailRecipientStruct.create({
109
+ firstName: 'Test',
110
+ lastName: 'Test',
111
+ email: 'example2@stamhoofd-allowed-domain-tests.be',
112
+ }),
113
+ ],
114
+ });
115
+ await EmailSendService.queueForSending(model, true);
116
+ await model.refresh();
117
+
118
+ // Check if it was sent correctly
119
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
120
+ expect(model.emailRecipientsCount).toBe(2);
121
+ expect(model.succeededCount).toBe(1);
122
+ expect(model.failedCount).toBe(1);
123
+ expect(model.emailErrors).toBe(null);
124
+ expect(model.recipientsErrors).toBe(null);
125
+ expect(model.status).toBe(EmailStatus.Sent);
126
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
127
+ expect(await EmailMocker.getFailedCount()).toBe(0); // never tried to send any failed emails (whitelist)
128
+
129
+ // Load recipietns
130
+ const recipients = await EmailRecipient.select().where('emailId', model.id).fetch();
131
+ expect(recipients).toIncludeSameMembers([
132
+ expect.objectContaining({
133
+ email: 'example2@stamhoofd-allowed-domain-tests.be',
134
+ sentAt: expect.any(Date),
135
+ failCount: 0,
136
+ failErrorMessage: null,
137
+ firstFailedAt: null,
138
+ lastFailedAt: null,
139
+ }),
140
+ expect.objectContaining({
141
+ email: 'example@not-whitelisted-domain.be',
142
+ sentAt: null,
143
+ failCount: 1,
144
+ failErrorMessage: 'All recipients are filtered due to environment',
145
+ firstFailedAt: expect.any(Date),
146
+ lastFailedAt: expect.any(Date),
147
+ }),
148
+ ]);
149
+ }, 15_000);
150
+
151
+ it('Retries emails on network errors', async () => {
152
+ const model = await buildEmail({
153
+ recipients: [
154
+ EmailRecipientStruct.create({
155
+ firstName: 'Test',
156
+ lastName: 'Test',
157
+ email: 'example@domain.be',
158
+ }),
159
+ EmailRecipientStruct.create({
160
+ firstName: 'Test',
161
+ lastName: 'Test',
162
+ email: 'example2@domain.be',
163
+ }),
164
+ ],
165
+ });
166
+
167
+ // Only one failure (the first email)
168
+ // It should automatically retry to send the email
169
+ EmailMocker.broadcast.failNext(new Error('This is a simulated network error'));
170
+
171
+ await EmailSendService.queueForSending(model, true);
172
+ await model.refresh();
173
+
174
+ // Check if it was sent correctly
175
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
176
+ expect(model.emailRecipientsCount).toBe(2);
177
+ expect(model.status).toBe(EmailStatus.Sent);
178
+ expect(model.succeededCount).toBe(2);
179
+ expect(model.failedCount).toBe(0);
180
+ expect(model.softFailedCount).toBe(0);
181
+
182
+ // Both have succeeded
183
+ expect(await EmailMocker.getSucceededCount()).toBe(2);
184
+ expect(await EmailMocker.getFailedCount()).toBe(1); // One retry
185
+
186
+ // Load recipietns
187
+ const recipients = await EmailRecipient.select().where('emailId', model.id).fetch();
188
+ expect(recipients).toIncludeSameMembers([
189
+ expect.objectContaining({
190
+ email: 'example@domain.be',
191
+ sentAt: expect.any(Date),
192
+ failCount: 0,
193
+ }),
194
+ expect.objectContaining({
195
+ email: 'example2@domain.be',
196
+ sentAt: expect.any(Date),
197
+ failCount: 0,
198
+ }),
199
+ ]);
200
+ }, 15_000);
201
+
202
+ it('Email changes to failed state if email recipients fails to build', async () => {
203
+ const model = await buildFailEmail({
204
+ recipients: [
205
+ EmailRecipientStruct.create({
206
+ firstName: 'Test',
207
+ lastName: 'Test',
208
+ email: 'example@domain.be',
209
+ }),
210
+ EmailRecipientStruct.create({
211
+ firstName: 'Test',
212
+ lastName: 'Test',
213
+ email: 'example2@domain.be',
214
+ }),
215
+ ],
216
+ });
217
+
218
+ await expect(EmailSendService.queueForSending(model, true)).rejects.toThrow();
219
+ await model.refresh();
220
+
221
+ // Check if it was sent correctly
222
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.NotCreated);
223
+ expect(model.recipientsErrors).toEqual(STExpect.simpleErrors([{
224
+ code: 'unknown_error',
225
+ message: 'This is a simulated error while fetching recipients',
226
+ }]));
227
+
228
+ expect(model.emailRecipientsCount).toBe(null);
229
+ expect(model.status).toBe(EmailStatus.Failed);
230
+ expect(model.succeededCount).toBe(0);
231
+ expect(model.failedCount).toBe(0);
232
+ expect(model.softFailedCount).toBe(0);
233
+
234
+ // Both have succeeded
235
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
236
+ expect(await EmailMocker.getFailedCount()).toBe(0); // One retry
237
+ }, 15_000);
238
+
239
+ it('Marks email recipient as failed if fails three times', async () => {
240
+ const model = await buildEmail({
241
+ recipients: [
242
+ EmailRecipientStruct.create({
243
+ firstName: 'Test',
244
+ lastName: 'Test',
245
+ email: 'example@domain.be',
246
+ }),
247
+ EmailRecipientStruct.create({
248
+ firstName: 'Test',
249
+ lastName: 'Test',
250
+ email: 'example2@domain.be',
251
+ }),
252
+ ],
253
+ });
254
+
255
+ EmailMocker.broadcast.failNext(new Error('This is a simulated network error 1'));
256
+ EmailMocker.broadcast.failNext(new Error('This is a simulated network error 2'));
257
+ EmailMocker.broadcast.failNext(new Error('This is a simulated network error 3'));
258
+ EmailMocker.broadcast.failNext(new Error('This is a simulated network error 4'));
259
+ EmailMocker.broadcast.failNext(new Error('This is a simulated network error 5'));
260
+ EmailMocker.broadcast.failNext(new Error('This is a simulated network error 6'));
261
+
262
+ await EmailSendService.queueForSending(model, true);
263
+ await model.refresh();
264
+
265
+ // Check if it was sent correctly
266
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
267
+ expect(model.emailRecipientsCount).toBe(2);
268
+ expect(model.status).toBe(EmailStatus.Sent);
269
+ expect(model.succeededCount).toBe(0);
270
+ expect(model.failedCount).toBe(2);
271
+ expect(model.softFailedCount).toBe(0);
272
+
273
+ // Both have succeeded
274
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
275
+ expect(await EmailMocker.getFailedCount()).toBe(6); // Two retries for each recipient
276
+
277
+ // Load recipietns
278
+ const recipients = await EmailRecipient.select().where('emailId', model.id).fetch();
279
+ expect(recipients).toIncludeSameMembers([
280
+ expect.objectContaining({
281
+ email: 'example@domain.be',
282
+ sentAt: null,
283
+ failCount: 1,
284
+ failError: STExpect.simpleErrors([{
285
+ message: /This is a simulated network error ([3-6])/,
286
+ }]),
287
+ firstFailedAt: expect.any(Date),
288
+ lastFailedAt: expect.any(Date),
289
+ }),
290
+ expect.objectContaining({
291
+ email: 'example2@domain.be',
292
+ sentAt: null,
293
+ failCount: 1,
294
+ failError: STExpect.simpleErrors([{
295
+ message: /This is a simulated network error ([3-6])/,
296
+ }]),
297
+ firstFailedAt: expect.any(Date),
298
+ lastFailedAt: expect.any(Date),
299
+ }),
300
+ ]);
301
+ }, 15_000);
302
+
303
+ it('Can handle recipients without name', async () => {
304
+ const model = await buildEmail({
305
+ recipients: [
306
+ EmailRecipientStruct.create({
307
+ email: 'example@domain.be',
308
+ }),
309
+ ],
310
+ });
311
+
312
+ await EmailSendService.queueForSending(model, true);
313
+ await model.refresh();
314
+
315
+ // Check if it was sent correctly
316
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
317
+ expect(model.emailRecipientsCount).toBe(1);
318
+ expect(model.status).toBe(EmailStatus.Sent);
319
+ expect(model.succeededCount).toBe(1);
320
+ expect(model.failedCount).toBe(0);
321
+ expect(model.softFailedCount).toBe(0);
322
+
323
+ // Both have succeeded
324
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
325
+ expect(await EmailMocker.getFailedCount()).toBe(0);
326
+
327
+ // Load recipietns
328
+ const recipients = await EmailRecipient.select().where('emailId', model.id).fetch();
329
+ expect(recipients).toIncludeSameMembers([
330
+ expect.objectContaining({
331
+ email: 'example@domain.be',
332
+ sentAt: expect.any(Date),
333
+ failCount: 0,
334
+ }),
335
+ ]);
336
+
337
+ // Check to header
338
+ expect(EmailMocker.getSucceededEmail(0).to).toEqual('example@domain.be');
339
+ }, 15_000);
340
+
341
+ it('Includes recipient names in mail header', async () => {
342
+ const model = await buildEmail({
343
+ recipients: [
344
+ EmailRecipientStruct.create({
345
+ firstName: 'John',
346
+ lastName: 'Von Doe',
347
+ email: 'example@domain.be',
348
+ }),
349
+ ],
350
+ });
351
+
352
+ await EmailSendService.queueForSending(model, true);
353
+ await model.refresh();
354
+
355
+ // Check if it was sent correctly
356
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
357
+ expect(model.emailRecipientsCount).toBe(1);
358
+ expect(model.status).toBe(EmailStatus.Sent);
359
+ expect(model.succeededCount).toBe(1);
360
+ expect(model.failedCount).toBe(0);
361
+ expect(model.softFailedCount).toBe(0);
362
+
363
+ // Both have succeeded
364
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
365
+ expect(await EmailMocker.getFailedCount()).toBe(0);
366
+
367
+ // Load recipietns
368
+ const recipients = await EmailRecipient.select().where('emailId', model.id).fetch();
369
+ expect(recipients).toIncludeSameMembers([
370
+ expect.objectContaining({
371
+ firstName: 'John',
372
+ lastName: 'Von Doe',
373
+ email: 'example@domain.be',
374
+ sentAt: expect.any(Date),
375
+ failCount: 0,
376
+ }),
377
+ ]);
378
+
379
+ // Check to header
380
+ expect(EmailMocker.getSucceededEmail(0).to).toEqual('"John Von Doe" <example@domain.be>');
381
+ }, 15_000);
382
+
383
+ it('Skips invalid email addresses', async () => {
384
+ const model = await buildEmail({
385
+ recipients: [
386
+ EmailRecipientStruct.create({
387
+ firstName: 'John',
388
+ lastName: 'Von Doe',
389
+ email: 'invalid',
390
+ }),
391
+ ],
392
+ });
393
+
394
+ await EmailSendService.queueForSending(model, true);
395
+ await model.refresh();
396
+
397
+ // Check if it was sent correctly
398
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
399
+ expect(model.emailRecipientsCount).toBe(1);
400
+ expect(model.status).toBe(EmailStatus.Sent);
401
+ expect(model.succeededCount).toBe(0);
402
+ expect(model.failedCount).toBe(1);
403
+ expect(model.softFailedCount).toBe(0);
404
+
405
+ // Both have succeeded
406
+ expect(await EmailMocker.getSucceededCount()).toBe(0);
407
+ expect(await EmailMocker.getFailedCount()).toBe(0);
408
+
409
+ // Load recipietns
410
+ const recipients = await EmailRecipient.select().where('emailId', model.id).fetch();
411
+ expect(recipients).toIncludeSameMembers([
412
+ expect.objectContaining({
413
+ firstName: 'John',
414
+ lastName: 'Von Doe',
415
+ email: 'invalid',
416
+ failCount: 1,
417
+ failError: STExpect.simpleErrors([{
418
+ code: 'invalid_email_address',
419
+ message: 'Invalid email address',
420
+ }]),
421
+ firstFailedAt: expect.any(Date),
422
+ lastFailedAt: expect.any(Date),
423
+ }),
424
+ ]);
425
+ }, 15_000);
426
+
427
+ it('Skips empty email addresses while creating recipients', async () => {
428
+ const model = await buildEmail({
429
+ recipients: [
430
+ EmailRecipientStruct.create({
431
+ firstName: '',
432
+ lastName: '',
433
+ email: '',
434
+ }),
435
+ EmailRecipientStruct.create({
436
+ firstName: '',
437
+ lastName: '',
438
+ email: 'valid@example.com',
439
+ }),
440
+ ],
441
+ });
442
+
443
+ await EmailSendService.queueForSending(model, true);
444
+ await model.refresh();
445
+
446
+ // Check if it was sent correctly
447
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
448
+ expect(model.emailRecipientsCount).toBe(1);
449
+ expect(model.status).toBe(EmailStatus.Sent);
450
+ expect(model.succeededCount).toBe(1);
451
+ expect(model.failedCount).toBe(0);
452
+ expect(model.softFailedCount).toBe(0);
453
+
454
+ // Both have succeeded
455
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
456
+ expect(await EmailMocker.getFailedCount()).toBe(0);
457
+
458
+ // Load recipietns
459
+ const recipients = await EmailRecipient.select().where('emailId', model.id).fetch();
460
+ expect(recipients).toIncludeSameMembers([
461
+ expect.objectContaining({
462
+ email: 'valid@example.com',
463
+ sentAt: expect.any(Date),
464
+ failError: null,
465
+ }),
466
+ ]);
467
+ }, 15_000);
468
+
469
+ it('Platform can send from default domain', async () => {
470
+ TestUtils.setEnvironment('domains', {
471
+ ...STAMHOOFD.domains,
472
+ defaultTransactionalEmail: {
473
+ '': 'my-platform.com',
474
+ },
475
+ defaultBroadcastEmail: {
476
+ '': 'broadcast.my-platform.com',
477
+ },
478
+ });
479
+
480
+ const model = await buildEmail({
481
+ recipients: [
482
+ EmailRecipientStruct.create({
483
+ email: 'example@domain.be',
484
+ }),
485
+ ],
486
+ fromAddress: 'info@my-platform.com',
487
+ fromName: 'My Platform',
488
+ });
489
+
490
+ await EmailSendService.queueForSending(model, true);
491
+ await model.refresh();
492
+
493
+ // Check if it was sent correctly
494
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
495
+ expect(model.emailRecipientsCount).toBe(1);
496
+ expect(model.status).toBe(EmailStatus.Sent);
497
+ expect(model.succeededCount).toBe(1);
498
+ expect(model.failedCount).toBe(0);
499
+ expect(model.softFailedCount).toBe(0);
500
+
501
+ // Both have succeeded
502
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
503
+ expect(await EmailMocker.getFailedCount()).toBe(0);
504
+
505
+ // Check to header
506
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
507
+ to: 'example@domain.be',
508
+ from: '"My Platform" <info@my-platform.com>',
509
+ replyTo: undefined,
510
+ });
511
+ }, 15_000);
512
+
513
+ it('Platform cannot send from unsupported domain', async () => {
514
+ TestUtils.setEnvironment('domains', {
515
+ ...STAMHOOFD.domains,
516
+ defaultTransactionalEmail: {
517
+ '': 'my-platform.com',
518
+ },
519
+ defaultBroadcastEmail: {
520
+ '': 'broadcast.my-platform.com',
521
+ },
522
+ });
523
+
524
+ const model = await buildEmail({
525
+ recipients: [
526
+ EmailRecipientStruct.create({
527
+ email: 'example@domain.be',
528
+ }),
529
+ ],
530
+ fromAddress: 'info@other-platform.com',
531
+ fromName: 'My Platform',
532
+ });
533
+
534
+ await EmailSendService.queueForSending(model, true);
535
+ await model.refresh();
536
+
537
+ // Check if it was sent correctly
538
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
539
+ expect(model.emailRecipientsCount).toBe(1);
540
+ expect(model.status).toBe(EmailStatus.Sent);
541
+ expect(model.succeededCount).toBe(1);
542
+ expect(model.failedCount).toBe(0);
543
+ expect(model.softFailedCount).toBe(0);
544
+
545
+ // Both have succeeded
546
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
547
+ expect(await EmailMocker.getFailedCount()).toBe(0);
548
+
549
+ // Check to header
550
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
551
+ to: 'example@domain.be',
552
+ from: '"My Platform" <noreply@broadcast.my-platform.com>', // domain has changed here
553
+ replyTo: '"My Platform" <info@other-platform.com>', // Reply to should be set
554
+ });
555
+ }, 15_000);
556
+
557
+ it('Organization cannot send from platform domain', async () => {
558
+ TestUtils.setEnvironment('domains', {
559
+ ...STAMHOOFD.domains,
560
+ defaultTransactionalEmail: {
561
+ '': 'my-platform.com',
562
+ },
563
+ defaultBroadcastEmail: {
564
+ '': 'broadcast.my-platform.com',
565
+ },
566
+ });
567
+
568
+ const organization = await new OrganizationFactory({
569
+ uri: 'uritest',
570
+ }).create();
571
+
572
+ const model = await buildEmail({
573
+ organizationId: organization.id,
574
+ recipients: [
575
+ EmailRecipientStruct.create({
576
+ email: 'example@domain.be',
577
+ }),
578
+ ],
579
+ fromAddress: 'info@my-platform.com',
580
+ fromName: 'My Platform',
581
+ });
582
+
583
+ await EmailSendService.queueForSending(model, true);
584
+ await model.refresh();
585
+
586
+ // Check if it was sent correctly
587
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
588
+ expect(model.emailRecipientsCount).toBe(1);
589
+ expect(model.status).toBe(EmailStatus.Sent);
590
+ expect(model.succeededCount).toBe(1);
591
+ expect(model.failedCount).toBe(0);
592
+ expect(model.softFailedCount).toBe(0);
593
+
594
+ // Both have succeeded
595
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
596
+ expect(await EmailMocker.getFailedCount()).toBe(0);
597
+
598
+ // Check to header
599
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
600
+ to: 'example@domain.be',
601
+ from: '"My Platform" <noreply-uritest@broadcast.my-platform.com>', // domain has changed here
602
+ replyTo: '"My Platform" <info@my-platform.com>', // Reply to should be set
603
+ });
604
+ }, 15_000);
605
+
606
+ it('Organization can send from platform domain if default membership organization', async () => {
607
+ TestUtils.setEnvironment('domains', {
608
+ ...STAMHOOFD.domains,
609
+ defaultTransactionalEmail: {
610
+ '': 'my-platform.com',
611
+ },
612
+ defaultBroadcastEmail: {
613
+ '': 'broadcast.my-platform.com',
614
+ },
615
+ });
616
+
617
+ const organization = await new OrganizationFactory({}).create();
618
+
619
+ const platform = await Platform.getForEditing();
620
+ platform.membershipOrganizationId = organization.id;
621
+ await platform.save();
622
+
623
+ const model = await buildEmail({
624
+ organizationId: organization.id,
625
+ recipients: [
626
+ EmailRecipientStruct.create({
627
+ email: 'example@domain.be',
628
+ }),
629
+ ],
630
+ fromAddress: 'info@my-platform.com',
631
+ fromName: 'My Platform',
632
+ });
633
+
634
+ await EmailSendService.queueForSending(model, true);
635
+ await model.refresh();
636
+
637
+ // Check if it was sent correctly
638
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
639
+ expect(model.emailRecipientsCount).toBe(1);
640
+ expect(model.status).toBe(EmailStatus.Sent);
641
+ expect(model.succeededCount).toBe(1);
642
+ expect(model.failedCount).toBe(0);
643
+ expect(model.softFailedCount).toBe(0);
644
+
645
+ // Both have succeeded
646
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
647
+ expect(await EmailMocker.getFailedCount()).toBe(0);
648
+
649
+ // Check to header
650
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
651
+ to: 'example@domain.be',
652
+ from: '"My Platform" <info@my-platform.com>', // domain has changed here
653
+ replyTo: undefined,
654
+ });
655
+ }, 15_000);
656
+
657
+ describe('Replacements', () => {
658
+ it('[Regression] When sending an email the organization fromAddress is replaced to the reply-to if it is not allowed to be sent from', async () => {
659
+ TestUtils.setEnvironment('domains', {
660
+ ...STAMHOOFD.domains,
661
+ defaultTransactionalEmail: {
662
+ '': 'my-platform.com',
663
+ },
664
+ defaultBroadcastEmail: {
665
+ '': 'broadcast.my-platform.com',
666
+ },
667
+ });
668
+
669
+ const organization = await new OrganizationFactory({}).create();
670
+
671
+ const model = await buildEmail({
672
+ organizationId: organization.id,
673
+ recipients: [
674
+ EmailRecipientStruct.create({
675
+ email: 'example@domain.be',
676
+ }),
677
+ ],
678
+ fromAddress: 'custom@customdomain.com',
679
+ fromName: 'Custom Name',
680
+ html: '{{fromAddress}}',
681
+ text: '{{fromAddress}}',
682
+ subject: '{{fromAddress}}',
683
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
684
+ });
685
+
686
+ await EmailSendService.queueForSending(model, true);
687
+ await model.refresh();
688
+
689
+ // Check if it was sent correctly
690
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
691
+ expect(model.emailRecipientsCount).toBe(1);
692
+ expect(model.status).toBe(EmailStatus.Sent);
693
+
694
+ // Both have succeeded
695
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
696
+ expect(await EmailMocker.getFailedCount()).toBe(0);
697
+
698
+ // Check to header
699
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
700
+ to: 'example@domain.be',
701
+ from: '"Custom Name" <noreply-' + organization.uri + '@broadcast.my-platform.com>',
702
+ replyTo: '"Custom Name" <custom@customdomain.com>', // domain has changed here
703
+ subject: 'custom@customdomain.com',
704
+ html: 'custom@customdomain.com',
705
+ text: 'custom@customdomain.com',
706
+ });
707
+ }, 15_000);
708
+
709
+ it('Default organization replacements work as expected', async () => {
710
+ TestUtils.setEnvironment('domains', {
711
+ ...STAMHOOFD.domains,
712
+ defaultTransactionalEmail: {
713
+ '': 'my-platform.com',
714
+ },
715
+ defaultBroadcastEmail: {
716
+ '': 'broadcast.my-platform.com',
717
+ },
718
+ });
719
+
720
+ const brightYellow = '#FFD700';
721
+ const expectedContrastColor = '#000'; // black
722
+
723
+ const organization = await new OrganizationFactory({
724
+ meta: OrganizationMetaData.create({
725
+ color: brightYellow,
726
+ }),
727
+ }).create();
728
+
729
+ const model = await buildEmail({
730
+ organizationId: organization.id,
731
+ recipients: [
732
+ EmailRecipientStruct.create({
733
+ email: 'example@domain.be',
734
+ }),
735
+ ],
736
+ fromAddress: 'custom@customdomain.com',
737
+ html: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
738
+ text: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
739
+ subject: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
740
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
741
+ });
742
+
743
+ await EmailSendService.queueForSending(model, true);
744
+ await model.refresh();
745
+
746
+ // Check if it was sent correctly
747
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
748
+ expect(model.emailRecipientsCount).toBe(1);
749
+ expect(model.status).toBe(EmailStatus.Sent);
750
+
751
+ // Both have succeeded
752
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
753
+ expect(await EmailMocker.getFailedCount()).toBe(0);
754
+
755
+ // Check to header
756
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
757
+ subject: `${brightYellow};${expectedContrastColor};${organization.name};${organization.name}`,
758
+ html: `${brightYellow};${expectedContrastColor};${organization.name};${organization.name}`,
759
+ text: `${brightYellow};${expectedContrastColor};${organization.name};${organization.name}`,
760
+ });
761
+ }, 15_000);
762
+
763
+ it('Organization replacements default to platform replacements if not set', async () => {
764
+ TestUtils.setEnvironment('domains', {
765
+ ...STAMHOOFD.domains,
766
+ defaultTransactionalEmail: {
767
+ '': 'my-platform.com',
768
+ },
769
+ defaultBroadcastEmail: {
770
+ '': 'broadcast.my-platform.com',
771
+ },
772
+ });
773
+
774
+ const brightBlue = '#0000FF';
775
+ const expectedContrastColor = '#fff'; // white
776
+
777
+ const platform = await Platform.getForEditing();
778
+ platform.config.color = brightBlue;
779
+ await platform.save();
780
+
781
+ const organization = await new OrganizationFactory({
782
+ meta: OrganizationMetaData.create({
783
+ color: null,
784
+ }),
785
+ }).create();
786
+
787
+ const model = await buildEmail({
788
+ organizationId: organization.id,
789
+ recipients: [
790
+ EmailRecipientStruct.create({
791
+ email: 'example@domain.be',
792
+ }),
793
+ ],
794
+ fromAddress: 'custom@customdomain.com',
795
+ fromName: 'Custom Name',
796
+ html: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
797
+ text: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
798
+ subject: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
799
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
800
+ });
801
+
802
+ await EmailSendService.queueForSending(model, true);
803
+ await model.refresh();
804
+
805
+ // Check if it was sent correctly
806
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
807
+ expect(model.emailRecipientsCount).toBe(1);
808
+ expect(model.status).toBe(EmailStatus.Sent);
809
+
810
+ // Both have succeeded
811
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
812
+ expect(await EmailMocker.getFailedCount()).toBe(0);
813
+
814
+ // Check to header
815
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
816
+ subject: `${brightBlue};${expectedContrastColor};${organization.name};Custom Name`,
817
+ html: `${brightBlue};${expectedContrastColor};${organization.name};Custom Name`,
818
+ text: `${brightBlue};${expectedContrastColor};${organization.name};Custom Name`,
819
+ });
820
+ }, 15_000);
821
+
822
+ it('Platform replacements are used for platform level emails', async () => {
823
+ TestUtils.setEnvironment('domains', {
824
+ ...STAMHOOFD.domains,
825
+ defaultTransactionalEmail: {
826
+ '': 'my-platform.com',
827
+ },
828
+ defaultBroadcastEmail: {
829
+ '': 'broadcast.my-platform.com',
830
+ },
831
+ });
832
+
833
+ const darkRed = '#8B0000';
834
+ const expectedContrastColor = '#fff'; // white
835
+
836
+ const platform = await Platform.getForEditing();
837
+ platform.config.color = darkRed;
838
+ await platform.save();
839
+
840
+ const model = await buildEmail({
841
+ recipients: [
842
+ EmailRecipientStruct.create({
843
+ email: 'example@domain.be',
844
+ }),
845
+ ],
846
+ fromAddress: 'custom@customdomain.com',
847
+ html: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
848
+ text: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
849
+ subject: '{{primaryColor}};{{primaryColorContrast}};{{organizationName}};{{fromName}}',
850
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
851
+ });
852
+
853
+ await EmailSendService.queueForSending(model, true);
854
+ await model.refresh();
855
+
856
+ // Check if it was sent correctly
857
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
858
+ expect(model.emailRecipientsCount).toBe(1);
859
+ expect(model.status).toBe(EmailStatus.Sent);
860
+
861
+ // Both have succeeded
862
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
863
+ expect(await EmailMocker.getFailedCount()).toBe(0);
864
+
865
+ // Check to header
866
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
867
+ subject: `${darkRed};${expectedContrastColor};${platform.config.name};${platform.config.name}`,
868
+ html: `${darkRed};${expectedContrastColor};${platform.config.name};${platform.config.name}`,
869
+ text: `${darkRed};${expectedContrastColor};${platform.config.name};${platform.config.name}`,
870
+ });
871
+ }, 15_000);
872
+
873
+ describe('User based replacements', () => {
874
+ it('The balance is added for existing users', async () => {
875
+ TestUtils.setEnvironment('domains', {
876
+ ...STAMHOOFD.domains,
877
+ defaultTransactionalEmail: {
878
+ '': 'my-platform.com',
879
+ },
880
+ defaultBroadcastEmail: {
881
+ '': 'broadcast.my-platform.com',
882
+ },
883
+ });
884
+
885
+ const organization = await new OrganizationFactory({}).create();
886
+ const existingUser = await new UserFactory({
887
+ organization,
888
+ }).create();
889
+
890
+ // Add outstanding balance items for this user
891
+ const balanceItem = await new BalanceItemFactory({
892
+ userId: existingUser.id,
893
+ organizationId: organization.id,
894
+ type: BalanceItemType.Other,
895
+ amount: 2,
896
+ unitPrice: 12_99_00,
897
+ description: 'Test balance item',
898
+ }).create();
899
+ await CachedBalance.updateForUsers(organization.id, [existingUser.id]);
900
+
901
+ const model = await buildEmail({
902
+ organizationId: organization.id,
903
+ recipients: [
904
+ EmailRecipientStruct.create({
905
+ email: existingUser.email,
906
+ userId: existingUser.id,
907
+ }),
908
+ ],
909
+ fromAddress: 'custom@customdomain.com',
910
+ html: '<p>{{outstandingBalance}}</p>{{balanceTable}}',
911
+ subject: '{{outstandingBalance}}',
912
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
913
+ });
914
+
915
+ await EmailSendService.queueForSending(model, true);
916
+ await model.refresh();
917
+
918
+ // Check if it was sent correctly
919
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
920
+ expect(model.emailRecipientsCount).toBe(1);
921
+ expect(model.status).toBe(EmailStatus.Sent);
922
+
923
+ // Both have succeeded
924
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
925
+ expect(await EmailMocker.getFailedCount()).toBe(0);
926
+
927
+ const expectedAmount = Formatter.price(balanceItem.unitPrice * balanceItem.amount);
928
+
929
+ // Check to header
930
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
931
+ subject: `${expectedAmount}`,
932
+ });
933
+
934
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch('<p>' + expectedAmount + '</p>');
935
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(expectedAmount);
936
+
937
+ // Check if the table is correct
938
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch('<table');
939
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch('2 x '); // amount
940
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch(Formatter.price(12_99_00)); // unit price
941
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch('<td>' + expectedAmount); // total price in table
942
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch('Test balance item'); // description
943
+
944
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch('2 x '); // amount
945
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(Formatter.price(12_99_00)); // unit price
946
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(expectedAmount); // total price in table
947
+ expect(EmailMocker.getSucceededEmail(0).text?.toLowerCase()).toMatch('test balance item'); // description
948
+ }, 15_000);
949
+
950
+ it('The balance is zero for unknown users', async () => {
951
+ TestUtils.setEnvironment('domains', {
952
+ ...STAMHOOFD.domains,
953
+ defaultTransactionalEmail: {
954
+ '': 'my-platform.com',
955
+ },
956
+ defaultBroadcastEmail: {
957
+ '': 'broadcast.my-platform.com',
958
+ },
959
+ });
960
+
961
+ const organization = await new OrganizationFactory({}).create();
962
+
963
+ const model = await buildEmail({
964
+ organizationId: organization.id,
965
+ recipients: [
966
+ EmailRecipientStruct.create({
967
+ email: 'unknown-user@example.com',
968
+ }),
969
+ ],
970
+ fromAddress: 'custom@customdomain.com',
971
+ html: '<p>{{outstandingBalance}}</p>{{balanceTable}}',
972
+ subject: '{{outstandingBalance}}',
973
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
974
+ });
975
+
976
+ await EmailSendService.queueForSending(model, true);
977
+ await model.refresh();
978
+
979
+ // Check if it was sent correctly
980
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
981
+ expect(model.emailRecipientsCount).toBe(1);
982
+ expect(model.status).toBe(EmailStatus.Sent);
983
+
984
+ // Both have succeeded
985
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
986
+ expect(await EmailMocker.getFailedCount()).toBe(0);
987
+
988
+ const expectedAmount = Formatter.price(0);
989
+
990
+ // Check to header
991
+ expect(EmailMocker.getSucceededEmail(0)).toMatchObject({
992
+ subject: `${expectedAmount}`,
993
+ });
994
+
995
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch('<p>' + expectedAmount + '</p>');
996
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(expectedAmount);
997
+
998
+ // Check if the table is correct
999
+ expect(EmailMocker.getSucceededEmail(0).html).not.toMatch('<table');
1000
+ expect(EmailMocker.getSucceededEmail(0).html).not.toMatch(' x '); // amount
1001
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch('<p class="description">' + $t('%hX') + '</p>');
1002
+
1003
+ expect(EmailMocker.getSucceededEmail(0).text).not.toMatch(' x '); // amount
1004
+ expect(EmailMocker.getSucceededEmail(0).text?.toLowerCase()).toMatch($t('%hX').toLowerCase());
1005
+ }, 15_000);
1006
+
1007
+ it('loginDetails are added for existing users without password', async () => {
1008
+ TestUtils.setEnvironment('domains', {
1009
+ ...STAMHOOFD.domains,
1010
+ defaultTransactionalEmail: {
1011
+ '': 'my-platform.com',
1012
+ },
1013
+ defaultBroadcastEmail: {
1014
+ '': 'broadcast.my-platform.com',
1015
+ },
1016
+ });
1017
+
1018
+ const organization = await new OrganizationFactory({}).create();
1019
+ const existingUser = await new UserFactory({
1020
+ organization,
1021
+ password: null,
1022
+ }).create();
1023
+
1024
+ const model = await buildEmail({
1025
+ organizationId: organization.id,
1026
+ recipients: [
1027
+ EmailRecipientStruct.create({
1028
+ email: existingUser.email,
1029
+ userId: existingUser.id,
1030
+ }),
1031
+ ],
1032
+ fromAddress: 'custom@customdomain.com',
1033
+ html: '{{loginDetails}}',
1034
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
1035
+ });
1036
+
1037
+ await EmailSendService.queueForSending(model, true);
1038
+ await model.refresh();
1039
+
1040
+ // Check if it was sent correctly
1041
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
1042
+ expect(model.emailRecipientsCount).toBe(1);
1043
+ expect(model.status).toBe(EmailStatus.Sent);
1044
+
1045
+ // Both have succeeded
1046
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
1047
+ expect(await EmailMocker.getFailedCount()).toBe(0);
1048
+
1049
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch(
1050
+ $t('%1EB', { email: `<strong>${Formatter.escapeHtml(existingUser.email)}</strong>` }),
1051
+ );
1052
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(
1053
+ $t('%1EB', { email: existingUser.email }),
1054
+ );
1055
+ }, 15_000);
1056
+
1057
+ it('loginDetails are added for inexisting users', async () => {
1058
+ TestUtils.setEnvironment('domains', {
1059
+ ...STAMHOOFD.domains,
1060
+ defaultTransactionalEmail: {
1061
+ '': 'my-platform.com',
1062
+ },
1063
+ defaultBroadcastEmail: {
1064
+ '': 'broadcast.my-platform.com',
1065
+ },
1066
+ });
1067
+
1068
+ const organization = await new OrganizationFactory({}).create();
1069
+
1070
+ const model = await buildEmail({
1071
+ organizationId: organization.id,
1072
+ recipients: [
1073
+ EmailRecipientStruct.create({
1074
+ email: 'unknown@example.com',
1075
+ }),
1076
+ ],
1077
+ fromAddress: 'custom@customdomain.com',
1078
+ html: '{{loginDetails}}',
1079
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
1080
+ });
1081
+
1082
+ await EmailSendService.queueForSending(model, true);
1083
+ await model.refresh();
1084
+
1085
+ // Check if it was sent correctly
1086
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
1087
+ expect(model.emailRecipientsCount).toBe(1);
1088
+ expect(model.status).toBe(EmailStatus.Sent);
1089
+
1090
+ // Both have succeeded
1091
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
1092
+ expect(await EmailMocker.getFailedCount()).toBe(0);
1093
+
1094
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch(
1095
+ $t('%1EB', { email: '<strong>unknown@example.com</strong>' }),
1096
+ );
1097
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(
1098
+ $t('%1EB', { email: 'unknown@example.com' }),
1099
+ );
1100
+ }, 15_000);
1101
+
1102
+ it('loginDetails are added for existing users with password', async () => {
1103
+ TestUtils.setEnvironment('domains', {
1104
+ ...STAMHOOFD.domains,
1105
+ defaultTransactionalEmail: {
1106
+ '': 'my-platform.com',
1107
+ },
1108
+ defaultBroadcastEmail: {
1109
+ '': 'broadcast.my-platform.com',
1110
+ },
1111
+ });
1112
+
1113
+ const organization = await new OrganizationFactory({}).create();
1114
+ const existingUser = await new UserFactory({
1115
+ organization,
1116
+ password: 'existing',
1117
+ }).create();
1118
+
1119
+ const model = await buildEmail({
1120
+ organizationId: organization.id,
1121
+ recipients: [
1122
+ EmailRecipientStruct.create({
1123
+ email: existingUser.email,
1124
+ userId: existingUser.id,
1125
+ }),
1126
+ ],
1127
+ fromAddress: 'custom@customdomain.com',
1128
+ html: '{{loginDetails}}',
1129
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
1130
+ });
1131
+
1132
+ await EmailSendService.queueForSending(model, true);
1133
+ await model.refresh();
1134
+
1135
+ // Check if it was sent correctly
1136
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
1137
+ expect(model.emailRecipientsCount).toBe(1);
1138
+ expect(model.status).toBe(EmailStatus.Sent);
1139
+
1140
+ // Both have succeeded
1141
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
1142
+ expect(await EmailMocker.getFailedCount()).toBe(0);
1143
+
1144
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch(
1145
+ $t('%1EA', { email: `<strong>${Formatter.escapeHtml(existingUser.email)}</strong>` }),
1146
+ );
1147
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(
1148
+ $t('%1EA', { email: existingUser.email }),
1149
+ );
1150
+ }, 15_000);
1151
+
1152
+ it('loginDetails include member security keys for existing users without password', async () => {
1153
+ TestUtils.setEnvironment('userMode', 'platform');
1154
+ TestUtils.setEnvironment('domains', {
1155
+ ...STAMHOOFD.domains,
1156
+ defaultTransactionalEmail: {
1157
+ '': 'my-platform.com',
1158
+ },
1159
+ defaultBroadcastEmail: {
1160
+ '': 'broadcast.my-platform.com',
1161
+ },
1162
+ });
1163
+
1164
+ const organization = await new OrganizationFactory({}).create();
1165
+ const existingUser = await new UserFactory({
1166
+ organization,
1167
+ password: null,
1168
+ }).create();
1169
+ const member = await new MemberFactory({
1170
+ organization,
1171
+ user: existingUser,
1172
+ }).create();
1173
+ member.details.securityCode = '123456790123456'; // 16 chars
1174
+ await member.save();
1175
+
1176
+ // Add a linked member
1177
+
1178
+ const model = await buildEmail({
1179
+ organizationId: organization.id,
1180
+ recipients: [
1181
+ EmailRecipientStruct.create({
1182
+ email: existingUser.email,
1183
+ userId: existingUser.id,
1184
+ }),
1185
+ ],
1186
+ fromAddress: 'custom@customdomain.com',
1187
+ html: '{{loginDetails}}',
1188
+ emailType: 'system-test', // Makes sure we don't need to include unsubscribeUrl
1189
+ });
1190
+
1191
+ await EmailSendService.queueForSending(model, true);
1192
+ await model.refresh();
1193
+
1194
+ // Check if it was sent correctly
1195
+ expect(model.recipientsStatus).toBe(EmailRecipientsStatus.Created);
1196
+ expect(model.emailRecipientsCount).toBe(1);
1197
+ expect(model.status).toBe(EmailStatus.Sent);
1198
+
1199
+ // Both have succeeded
1200
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
1201
+ expect(await EmailMocker.getFailedCount()).toBe(0);
1202
+
1203
+ expect(EmailMocker.getSucceededEmail(0).html).toMatch(
1204
+ $t('%1EC', {
1205
+ firstName: Formatter.escapeHtml(member.firstName),
1206
+ securityCode: `<span class="style-inline-code">${Formatter.escapeHtml(Formatter.spaceString(member.details.securityCode ?? '', 4, '-'))}</span>`,
1207
+ }),
1208
+ );
1209
+ expect(EmailMocker.getSucceededEmail(0).text).toMatch(
1210
+ $t('%1EC', {
1211
+ firstName: member.firstName,
1212
+ securityCode: Formatter.spaceString(member.details.securityCode ?? '', 4, '-'),
1213
+ }),
1214
+ );
1215
+ }, 15_000);
1216
+ });
1217
+ });
1218
+ });