@stamhoofd/backend 2.137.5 → 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 (91) hide show
  1. package/package.json +20 -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/index.ts +1 -0
  6. package/src/crons/invoices.ts +5 -3
  7. package/src/email-recipient-loaders/orders.ts +2 -1
  8. package/src/email-recipient-loaders/organizations.ts +3 -2
  9. package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
  10. package/src/endpoints/auth/CreateAdminEndpoint.ts +2 -1
  11. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
  12. package/src/endpoints/auth/CreateTokenEndpoint.ts +133 -6
  13. package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
  14. package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
  15. package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
  16. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +2 -1
  17. package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
  18. package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
  19. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
  20. package/src/endpoints/auth/MFA.security.test.ts +688 -0
  21. package/src/endpoints/auth/MFA.test.ts +1398 -0
  22. package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
  23. package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
  24. package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
  25. package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
  26. package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
  27. package/src/endpoints/auth/SignupEndpoint.ts +13 -2
  28. package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
  29. package/src/endpoints/global/email/CreateEmailEndpoint.ts +3 -2
  30. package/src/endpoints/global/email/PatchEmailEndpoint.ts +3 -2
  31. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
  32. package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
  33. package/src/endpoints/global/files/ExportToExcelEndpoint.ts +2 -1
  34. package/src/endpoints/global/files/UploadFile.ts +1 -1
  35. package/src/endpoints/global/files/UploadImage.ts +1 -1
  36. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
  37. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
  38. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
  39. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
  40. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
  41. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
  42. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
  43. package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
  44. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
  45. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
  46. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
  47. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
  48. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
  49. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
  50. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
  51. package/src/helpers/AuthenticatedStructures.ts +9 -1
  52. package/src/helpers/Context.ts +114 -4
  53. package/src/helpers/EmailBuilder.test.ts +287 -0
  54. package/src/helpers/EmailBuilder.ts +811 -0
  55. package/src/helpers/EmailResumer.ts +2 -1
  56. package/src/helpers/ForwardHandler.ts +2 -1
  57. package/src/helpers/MFAEncryption.ts +34 -0
  58. package/src/helpers/RecoveryCodeHelper.ts +81 -0
  59. package/src/helpers/TOTPHelper.ts +79 -0
  60. package/src/helpers/TenantContext.test.ts +96 -0
  61. package/src/helpers/TenantContext.ts +67 -0
  62. package/src/helpers/TwoFactorHelper.ts +356 -0
  63. package/src/helpers/WebauthnHelper.ts +211 -0
  64. package/src/helpers/data/aaguids.json +1006 -0
  65. package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
  66. package/src/middleware/TenantScopeMiddleware.ts +24 -0
  67. package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
  68. package/src/services/AdminSessionService.ts +41 -0
  69. package/src/services/EmailPreviewService.ts +1 -1
  70. package/src/services/EmailSendService.test.ts +1218 -0
  71. package/src/services/EmailSendService.ts +705 -0
  72. package/src/services/EventNotificationService.ts +2 -1
  73. package/src/services/InvoicePdfService.ts +3 -3
  74. package/src/services/InvoiceService.ts +4 -2
  75. package/src/services/InvoiceXMLService.ts +2 -1
  76. package/src/services/OrderService.ts +2 -1
  77. package/src/services/OrganizationAdminService.test.ts +47 -0
  78. package/src/services/OrganizationAdminService.ts +139 -0
  79. package/src/services/OrganizationEmailService.ts +3 -2
  80. package/src/services/PaymentService.ts +5 -3
  81. package/src/services/ReferralService.ts +3 -2
  82. package/src/services/RegistrationService.ts +2 -1
  83. package/src/services/SSOService.ts +106 -14
  84. package/src/services/STPackageService.ts +4 -2
  85. package/src/services/TwoFactorAuditLogService.ts +64 -0
  86. package/src/services/VerificationCodeService.ts +2 -1
  87. package/tests/e2e/api-rate-limits.test.ts +1 -1
  88. package/tests/helpers/MFATestHelper.ts +42 -0
  89. package/tests/helpers/TestServer.ts +4 -0
  90. package/tests/helpers/index.ts +1 -0
  91. package/tsconfig.build.json +2 -1
@@ -1,14 +1,14 @@
1
1
  import { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { isSimpleError, SimpleError } from '@simonbackx/simple-errors';
3
3
  import { I18n } from '@stamhoofd/backend-i18n';
4
- import type { User } from '@stamhoofd/models';
5
- import { Organization, Platform, RateLimiter, Token } from '@stamhoofd/models';
4
+ import { MFAToken, Organization, Platform, RateLimiter, Token, User } from '@stamhoofd/models';
6
5
  import { AsyncLocalStorage } from 'async_hooks';
7
6
 
8
7
  import type { Decoder } from '@simonbackx/simple-encoding';
9
8
  import { AutoEncoder, field, StringDecoder } from '@simonbackx/simple-encoding';
10
9
  import { ApiUserRateLimits } from '@stamhoofd/structures';
11
10
  import { AdminPermissionChecker } from './AdminPermissionChecker.js';
11
+ import { TwoFactorHelper } from './TwoFactorHelper.js';
12
12
 
13
13
  export const apiUserRateLimiter = new RateLimiter({
14
14
  limits: [
@@ -180,7 +180,7 @@ export class ContextInstance {
180
180
  if (STAMHOOFD.userMode === 'platform') {
181
181
  return null;
182
182
  }
183
- return await this.setOrganizationScope(options);
183
+ return await this.setOptionalOrganizationScope(options);
184
184
  }
185
185
 
186
186
  async setOrganizationScope(options?: { willAuthenticate?: boolean }) {
@@ -223,7 +223,24 @@ export class ContextInstance {
223
223
  }
224
224
  }
225
225
 
226
- async authenticate({ allowWithoutAccount = false, allowUnscoped = false }: { allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token }> {
226
+ /**
227
+ * Identify the caller from the Authorization header without ever throwing: returns
228
+ * null when the request is unauthenticated or carries an invalid/expired token.
229
+ * Use this to give an already signed in user a smoother experience, never to grant
230
+ * access (there is no error to react to).
231
+ */
232
+ async optionalAuthenticatedUser(): Promise<User | null> {
233
+ try {
234
+ const { user } = await this.authenticate({ allowWithoutAccount: true });
235
+ return user;
236
+ } catch (e) {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ async authenticate(options: { allowMFASetupToken: true; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }>;
242
+ async authenticate(options?: { allowMFASetupToken?: false | undefined; allowWithoutAccount?: boolean; allowUnscoped?: boolean }): Promise<{ user: User; token: Token }>;
243
+ async authenticate({ allowWithoutAccount = false, allowUnscoped = false, allowMFASetupToken = false }: { allowMFASetupToken?: boolean; allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token } | { user: User; setupToken: MFAToken }> {
227
244
  let header = this.request.headers.authorization;
228
245
 
229
246
  if (!header && this.request.method === 'POST') {
@@ -244,6 +261,12 @@ export class ContextInstance {
244
261
  }
245
262
 
246
263
  if (!header.startsWith('Bearer ')) {
264
+ if (allowMFASetupToken) {
265
+ const result = await this.authenticateMFASetup();
266
+ if (result) {
267
+ return result;
268
+ }
269
+ }
247
270
  throw new SimpleError({
248
271
  code: 'not_supported_authentication',
249
272
  message: 'Authentication method not supported. Please authenticate with OAuth2',
@@ -320,6 +343,93 @@ export class ContextInstance {
320
343
  return { user, token };
321
344
  }
322
345
 
346
+ /**
347
+ * Like authenticate(), but additionally requires the access token to be "fresh"
348
+ * (minted by a real authentication within the FRESH_WINDOW, not via a refresh).
349
+ * Used to gate sensitive actions such as managing 2FA methods.
350
+ */
351
+ async authenticateFresh(options: { allowWithoutAccount?: boolean; allowUnscoped?: boolean } = {}): Promise<{ user: User; token: Token }> {
352
+ const result = await this.authenticate(options);
353
+ if (!result.token.isFresh()) {
354
+ throw new SimpleError({
355
+ code: 'require_fresh_auth',
356
+ message: 'A recent authentication is required for this action',
357
+ human: $t('%Zgs'),
358
+ statusCode: 403,
359
+ });
360
+ }
361
+ return result;
362
+ }
363
+
364
+ /**
365
+ * Authorize a 2FA enrollment/management action. Accepts either a fresh full session
366
+ * (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
367
+ * during forced enrollment, before a session exists.
368
+ */
369
+ async authenticateMFASetup(): Promise<{ user: User; setupToken: MFAToken } | null> {
370
+ const header = this.request.headers.authorization;
371
+ if (header && header.startsWith('MFASetup ')) {
372
+ const raw = header.substring('MFASetup '.length);
373
+ const setupToken = await MFAToken.getValid(raw, 'setup');
374
+ if (!setupToken) {
375
+ throw new SimpleError({
376
+ code: 'mfa_setup_expired',
377
+ message: 'The MFA setup session is invalid or expired',
378
+ human: $t('%ZhJ'),
379
+ statusCode: 401,
380
+ });
381
+ }
382
+ const user = await User.getByID(setupToken.userId);
383
+ if (!user) {
384
+ throw new SimpleError({
385
+ code: 'mfa_setup_expired',
386
+ message: 'The MFA setup session is invalid or expired',
387
+ human: $t('%ZhJ'),
388
+ statusCode: 401,
389
+ });
390
+ }
391
+
392
+ // A setup token is issued on the strength of a password alone, to bootstrap a
393
+ // *first* factor. If the user enrolled one in the meantime (e.g. from another
394
+ // device), it must stop being worth a session: otherwise someone who only
395
+ // knows the password keeps a 15 minute window in which they can enroll a
396
+ // factor of their own and sign in, even though the account is now protected.
397
+ if (await TwoFactorHelper.userHasFactors(user.id)) {
398
+ await setupToken.consume();
399
+ throw new SimpleError({
400
+ code: 'mfa_setup_expired',
401
+ message: 'The MFA setup session is no longer valid: the user already has a second factor',
402
+ human: $t('%ZhJ'),
403
+ statusCode: 401,
404
+ });
405
+ }
406
+
407
+ this.user = user;
408
+ await this.insecurelyAuthenticateAs(user);
409
+ return { user, setupToken };
410
+ }
411
+
412
+ return null;
413
+ }
414
+
415
+ /**
416
+ * Authorize a 2FA enrollment/management action. Accepts either a fresh full session
417
+ * (Bearer access token) or a setup token (Authorization: MFASetup <token>) issued
418
+ * during forced enrollment, before a session exists.
419
+ *
420
+ * Exactly one of `setupToken` / `token` is set: `token` is the session the request was
421
+ * made with, which enrollment keeps alive while signing out the user's other sessions.
422
+ */
423
+ async authenticateMFAEnrollment(): Promise<{ user: User; setupToken: MFAToken | null; token: Token | null }> {
424
+ const mfa = await this.authenticateMFASetup();
425
+ if (mfa) {
426
+ return { ...mfa, token: null };
427
+ }
428
+
429
+ const { user, token } = await this.authenticateFresh();
430
+ return { user, setupToken: null, token };
431
+ }
432
+
323
433
  async insecurelyAuthenticateAs(user: User) {
324
434
  this.#auth = new AdminPermissionChecker(user, await Platform.getSharedPrivateStruct(), this.organization);
325
435
 
@@ -0,0 +1,287 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import { EmailContent, EmailTemplateType, Recipient, Replacement } from '@stamhoofd/structures';
3
+ import { Country } from '@stamhoofd/types/Country';
4
+ import { Language } from '@stamhoofd/types/Language';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import type { Organization, RegistrationPeriod } from '@stamhoofd/models';
7
+ import { Email, EmailTemplateFactory, OrganizationFactory, RegistrationPeriodFactory } from '@stamhoofd/models';
8
+ import { removeUnusedReplacements, sendEmailTemplate } from './EmailBuilder.js';
9
+
10
+ describe('sendEmailTemplate with translations', () => {
11
+ let period: RegistrationPeriod;
12
+ let organization: Organization;
13
+
14
+ beforeAll(async () => {
15
+ period = await new RegistrationPeriodFactory({
16
+ startDate: new Date(2023, 0, 1),
17
+ endDate: new Date(2023, 11, 31),
18
+ }).create();
19
+ });
20
+
21
+ beforeEach(async () => {
22
+ organization = await new OrganizationFactory({ period }).create();
23
+ });
24
+
25
+ const type = EmailTemplateType.ForgotPassword;
26
+
27
+ test('each recipient receives the content in its own language, with the default as fallback', async () => {
28
+ await new EmailTemplateFactory({
29
+ organization,
30
+ type,
31
+ subject: 'Default subject',
32
+ html: '<p>Default html</p>',
33
+ text: 'Default text',
34
+ language: Language.Dutch,
35
+ translations: new Map([
36
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
37
+ ]),
38
+ }).create();
39
+
40
+ await sendEmailTemplate(organization, {
41
+ recipients: [
42
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
43
+ Recipient.create({ email: 'dutch@example.com', language: Language.Dutch }),
44
+ Recipient.create({ email: 'english@example.com', language: Language.English }),
45
+ Recipient.create({ email: 'unknown@example.com' }),
46
+ ],
47
+ template: { type },
48
+ type: 'transactional',
49
+ });
50
+
51
+ const emails = await EmailMocker.transactional.getSucceededEmails();
52
+ expect(emails).toHaveLength(4);
53
+
54
+ const french = emails.find(e => e.to.includes('french@example.com'))!;
55
+ expect(french.subject).toBe('Sujet français');
56
+ expect(french.html).toContain('Français');
57
+
58
+ // Dutch is the default language: its content lives in the default content, not in the translations
59
+ const dutch = emails.find(e => e.to.includes('dutch@example.com'))!;
60
+ expect(dutch.subject).toBe('Default subject');
61
+ expect(dutch.html).toContain('Default html');
62
+
63
+ // English has no translation: falls back to the default content
64
+ const english = emails.find(e => e.to.includes('english@example.com'))!;
65
+ expect(english.subject).toBe('Default subject');
66
+ expect(english.html).toContain('Default html');
67
+
68
+ const unknown = emails.find(e => e.to.includes('unknown@example.com'))!;
69
+ expect(unknown.subject).toBe('Default subject');
70
+ });
71
+
72
+ test('generates recipient replacements in the recipient language', async () => {
73
+ // French must be a valid locale, otherwise it gets corrected to the default language
74
+ TestUtils.setEnvironment('locales', { [Country.Belgium]: [Language.Dutch, Language.French] });
75
+
76
+ // The unsubscribe URL is localized per recipient (it is not part of the translatable content)
77
+ await new EmailTemplateFactory({
78
+ organization,
79
+ type,
80
+ subject: 'Subject',
81
+ html: '<p>{{greeting}} {{unsubscribeUrl}}</p>',
82
+ text: '{{greeting}} {{unsubscribeUrl}}',
83
+ }).create();
84
+
85
+ await sendEmailTemplate(organization, {
86
+ recipients: [
87
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
88
+ Recipient.create({ email: 'dutch@example.com', language: Language.Dutch }),
89
+ Recipient.create({ email: 'unknown@example.com' }),
90
+ ],
91
+ template: { type },
92
+ type: 'transactional',
93
+ });
94
+
95
+ const emails = await EmailMocker.transactional.getSucceededEmails();
96
+ const french = emails.find(e => e.to.includes('french@example.com'))!;
97
+ const dutch = emails.find(e => e.to.includes('dutch@example.com'))!;
98
+ const unknown = emails.find(e => e.to.includes('unknown@example.com'))!;
99
+
100
+ // The unsubscribe page URL points to the recipient's localized page
101
+ expect(french.html).toContain('/fr-BE/unsubscribe');
102
+ expect(dutch.html).toContain('/nl-BE/unsubscribe');
103
+ // No language set: falls back to the ambient (default) locale
104
+ expect(unknown.html).toContain('/nl-BE/unsubscribe');
105
+ });
106
+
107
+ test('a missing language never falls back to the translation of a different template', async () => {
108
+ // Platform level template with a French translation
109
+ await new EmailTemplateFactory({
110
+ type,
111
+ subject: 'Platform subject',
112
+ html: '<p>Platform html</p>',
113
+ text: 'Platform text',
114
+ language: Language.Dutch,
115
+ translations: new Map([
116
+ [Language.French, EmailContent.create({ subject: 'Sujet plateforme', html: '<p>Plateforme</p>', text: 'Plateforme' })],
117
+ ]),
118
+ }).create();
119
+
120
+ // Organization level template without any translations
121
+ await new EmailTemplateFactory({
122
+ organization,
123
+ type,
124
+ subject: 'Organization subject',
125
+ html: '<p>Organization html</p>',
126
+ text: 'Organization text',
127
+ }).create();
128
+
129
+ await sendEmailTemplate(organization, {
130
+ recipients: [
131
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
132
+ ],
133
+ template: { type },
134
+ type: 'transactional',
135
+ });
136
+
137
+ const emails = await EmailMocker.transactional.getSucceededEmails();
138
+ expect(emails).toHaveLength(1);
139
+
140
+ // The organization template wins, and its default content is used for French:
141
+ // never the French translation of the platform template
142
+ expect(emails[0].subject).toBe('Organization subject');
143
+ expect(emails[0].html).toContain('Organization html');
144
+ });
145
+
146
+ test('setFromTemplate copies only the default language of the template onto the email if no language chosen for email', async () => {
147
+ await new EmailTemplateFactory({
148
+ organization,
149
+ type: EmailTemplateType.SavedMembersEmail,
150
+ subject: 'Default subject',
151
+ html: '<p>Default html</p>',
152
+ text: 'Default text',
153
+ language: Language.Dutch,
154
+ translations: new Map([
155
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
156
+ ]),
157
+ }).create();
158
+
159
+ const email = new Email();
160
+ email.organizationId = organization.id;
161
+ expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
162
+
163
+ expect(email.subject).toBe('Default subject');
164
+ expect(email.language).toBe(null);
165
+ expect(email.translations.size).toBe(0);
166
+ });
167
+
168
+ test('setFromTemplate copies only the correct language of the template onto the email', async () => {
169
+ await new EmailTemplateFactory({
170
+ organization,
171
+ type: EmailTemplateType.SavedMembersEmail,
172
+ subject: 'Default subject',
173
+ html: '<p>Default html</p>',
174
+ text: 'Default text',
175
+ language: Language.Dutch,
176
+ translations: new Map([
177
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
178
+ ]),
179
+ }).create();
180
+
181
+ const email = new Email();
182
+ email.language = Language.French;
183
+ email.organizationId = organization.id;
184
+ expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
185
+
186
+ expect(email.subject).toBe('Sujet français');
187
+ expect(email.language).toBe(Language.French);
188
+ expect(email.translations.size).toBe(0);
189
+ });
190
+
191
+ test('setFromTemplate copies only the default language of the template onto the email if languages match', async () => {
192
+ await new EmailTemplateFactory({
193
+ organization,
194
+ type: EmailTemplateType.SavedMembersEmail,
195
+ subject: 'Default subject',
196
+ html: '<p>Default html</p>',
197
+ text: 'Default text',
198
+ language: Language.Dutch,
199
+ translations: new Map([
200
+ [Language.French, EmailContent.create({ subject: 'Sujet français', html: '<p>Français</p>', text: 'Français' })],
201
+ ]),
202
+ }).create();
203
+
204
+ const email = new Email();
205
+ email.language = Language.Dutch;
206
+ email.organizationId = organization.id;
207
+ expect(await email.setFromTemplate(EmailTemplateType.SavedMembersEmail)).toBe(true);
208
+
209
+ expect(email.subject).toBe('Default subject');
210
+ expect(email.language).toBe(Language.Dutch);
211
+ expect(email.translations.size).toBe(0);
212
+ });
213
+
214
+ test('replaceAll is applied to the html of every language, not only the default', async () => {
215
+ await new EmailTemplateFactory({
216
+ organization,
217
+ type,
218
+ subject: 'Subject',
219
+ // The same placeholder appears in both the default and the translated html
220
+ html: '<p>Default __PLACEHOLDER__</p>',
221
+ text: 'Default __PLACEHOLDER__',
222
+ language: Language.Dutch,
223
+ translations: new Map([
224
+ [Language.French, EmailContent.create({ subject: 'Sujet', html: '<p>Français __PLACEHOLDER__</p>', text: 'Français __PLACEHOLDER__' })],
225
+ ]),
226
+ }).create();
227
+
228
+ await sendEmailTemplate(organization, {
229
+ recipients: [
230
+ Recipient.create({ email: 'french@example.com', language: Language.French }),
231
+ Recipient.create({ email: 'default@example.com' }),
232
+ ],
233
+ template: { type },
234
+ type: 'transactional',
235
+ replaceAll: [{ from: '__PLACEHOLDER__', to: 'REPLACED' }],
236
+ });
237
+
238
+ const emails = await EmailMocker.transactional.getSucceededEmails();
239
+ const french = emails.find(e => e.to.includes('french@example.com'))!;
240
+ const fallback = emails.find(e => e.to.includes('default@example.com'))!;
241
+
242
+ // The replaceAll must reach the translated html too, otherwise the placeholder leaks
243
+ expect(french.html).toContain('Français REPLACED');
244
+ expect(french.html).not.toContain('__PLACEHOLDER__');
245
+ expect(fallback.html).toContain('Default REPLACED');
246
+ expect(fallback.html).not.toContain('__PLACEHOLDER__');
247
+ });
248
+ });
249
+
250
+ describe('Email.getCombinedHtml', () => {
251
+ test('combines the default html with the html of every translation', () => {
252
+ const email = new Email();
253
+ email.html = '<p>Default {{signInUrl}}</p>';
254
+ email.translations = new Map([
255
+ [Language.French, EmailContent.create({ html: '<p>Français {{balanceTable}}</p>' })],
256
+ ]);
257
+
258
+ const combined = email.getCombinedHtml();
259
+ expect(combined).toContain('{{signInUrl}}');
260
+ expect(combined).toContain('{{balanceTable}}');
261
+ });
262
+
263
+ test('keeps a replacement that is only used inside a translation', () => {
264
+ const email = new Email();
265
+ // The default html uses signInUrl, only the French translation uses balanceTable
266
+ email.html = '<p>Default {{signInUrl}}</p>';
267
+ email.translations = new Map([
268
+ [Language.French, EmailContent.create({ html: '<p>Français {{balanceTable}}</p>' })],
269
+ ]);
270
+
271
+ const replacements = [
272
+ Replacement.create({ token: 'signInUrl', value: 'https://example.com' }),
273
+ Replacement.create({ token: 'balanceTable', value: '', html: '<table></table>' }),
274
+ Replacement.create({ token: 'outstandingBalance', value: '€ 10' }),
275
+ ];
276
+
277
+ // Using only the default html would wrongly strip balanceTable (used only by the translation)
278
+ const usingDefaultHtml = removeUnusedReplacements(email.html ?? '', replacements).map(r => r.token);
279
+ expect(usingDefaultHtml).not.toContain('balanceTable');
280
+
281
+ // Using the combined html keeps every replacement that any language needs, and still drops the truly unused one
282
+ const usingCombinedHtml = removeUnusedReplacements(email.getCombinedHtml(), replacements).map(r => r.token);
283
+ expect(usingCombinedHtml).toContain('signInUrl');
284
+ expect(usingCombinedHtml).toContain('balanceTable');
285
+ expect(usingCombinedHtml).not.toContain('outstandingBalance');
286
+ });
287
+ });