@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
@@ -0,0 +1,811 @@
1
+ import type { EmailBuilder, EmailInterfaceRecipient } from '@stamhoofd/email';
2
+ import { Email, EmailAddress } from '@stamhoofd/email';
3
+ import type { EmailContent, EmailRecipient as EmailRecipientStruct, EmailTemplateType, OrganizationEmail, Platform as PlatformStruct, Recipient } from '@stamhoofd/structures';
4
+ import { BalanceItem as BalanceItemStruct, getAppHost, ReceivableBalanceType, replaceEmailHtml, replaceEmailText, Replacement } from '@stamhoofd/structures';
5
+ import type { Language } from '@stamhoofd/types/Language';
6
+ import { Formatter } from '@stamhoofd/utility';
7
+
8
+ import { SimpleError } from '@simonbackx/simple-errors';
9
+ import { I18n } from '@stamhoofd/backend-i18n/I18n';
10
+ import type { Group, Organization, Webshop } from '@stamhoofd/models';
11
+ import { CachedBalance, EmailRecipient, EmailTemplate, Member, Platform, User } from '@stamhoofd/models';
12
+
13
+ export type EmailTemplateOptions = {
14
+ type: EmailTemplateType;
15
+ webshop?: Webshop | null;
16
+ group?: Group | null;
17
+ organizationId?: string | null;
18
+ };
19
+
20
+ export async function getEmailTemplate(data: EmailTemplateOptions) {
21
+ // Most specific template: for specific group
22
+ const q = EmailTemplate.select()
23
+ .where('type', data.type);
24
+
25
+ if (data.group) {
26
+ q.where('groupId', data.group.id);
27
+ }
28
+
29
+ if (data.organizationId) {
30
+ q.where('organizationId', data.organizationId);
31
+ }
32
+
33
+ if (data.webshop) {
34
+ q.where('webshopId', data.webshop.id);
35
+ }
36
+
37
+ let templates = await q.limit(1).fetch();
38
+
39
+ // Specific for organization
40
+ if (templates.length == 0 && (data.group?.id || data.webshop?.id) && data.organizationId) {
41
+ templates = await EmailTemplate.select()
42
+ .where('type', data.type)
43
+ .where('organizationId', data.organizationId)
44
+ .where('groupId', null)
45
+ .where('webshopId', null)
46
+ .limit(1)
47
+ .fetch();
48
+ }
49
+
50
+ // Default for platform
51
+ if (templates.length == 0 && (data.group?.id || data.webshop?.id || data.organizationId)) {
52
+ templates = await EmailTemplate.select()
53
+ .where('type', data.type)
54
+ .where('organizationId', null)
55
+ .where('groupId', null)
56
+ .where('webshopId', null)
57
+ .limit(1)
58
+ .fetch();
59
+ }
60
+
61
+ if (templates.length == 0) {
62
+ if (STAMHOOFD.environment === 'test') {
63
+ return;
64
+ }
65
+ console.error('Could not find email template for type ' + data.type);
66
+ return;
67
+ }
68
+
69
+ return templates[0];
70
+ }
71
+
72
+ export async function canSendFromEmail(fromAddress: string, organization: Organization | null) {
73
+ if (organization) {
74
+ if (organization.privateMeta.mailDomain && organization.privateMeta.mailDomainActive && fromAddress.endsWith('@' + organization.privateMeta.mailDomain)) {
75
+ return true;
76
+ }
77
+
78
+ if (organization.id === (await Platform.getSharedPrivateStruct()).membershipOrganizationId) {
79
+ return canSendFromEmail(fromAddress, null);
80
+ }
81
+
82
+ return false;
83
+ }
84
+ const transactionalDomains = Object.values(STAMHOOFD.domains.defaultTransactionalEmail ?? {});
85
+ const broadcastDomains = Object.values(STAMHOOFD.domains.defaultBroadcastEmail ?? {});
86
+ const domains = Formatter.uniqueArray([...transactionalDomains, ...broadcastDomains]);
87
+
88
+ for (const domain of domains) {
89
+ if (fromAddress.endsWith('@' + domain)) {
90
+ return true;
91
+ }
92
+ }
93
+
94
+ return false;
95
+ }
96
+
97
+ export async function getDefaultEmailFrom(organization: Organization | null, options: Pick<EmailBuilderOptions, 'type'> & { template: Omit<EmailTemplateOptions, 'organizationId' | 'type'> }) {
98
+ // When choosing sending domain, prefer using the one with the highest reputation
99
+ let preferEmailId: string | null = null;
100
+
101
+ if (options.template.group) {
102
+ preferEmailId = options.template.group.privateSettings.defaultEmailId;
103
+ }
104
+
105
+ if (options.template.webshop) {
106
+ preferEmailId = options.template.webshop.privateMeta.defaultEmailId;
107
+ }
108
+
109
+ if (organization) {
110
+ // Default email address for the chosen email type
111
+ let from = organization.getDefaultFrom(organization.i18n, options.type ?? 'broadcast');
112
+
113
+ const sender: OrganizationEmail | undefined = (preferEmailId ? organization.privateMeta.emails.find(e => e.id === preferEmailId) : null) ?? organization.privateMeta.emails.find(e => e.default) ?? organization.privateMeta.emails[0];
114
+ let replyTo: EmailInterfaceRecipient | undefined = undefined;
115
+
116
+ if (sender) {
117
+ replyTo = {
118
+ email: sender.email,
119
+ name: sender.name,
120
+ };
121
+
122
+ // Can we send from this e-mail or reply-to?
123
+ if (await canSendFromEmail(sender.email, organization)) {
124
+ from = {
125
+ email: sender.email,
126
+ name: sender.name,
127
+ };
128
+ replyTo = undefined;
129
+ }
130
+
131
+ // Default to organization name
132
+ if (!from.name) {
133
+ from.name = organization.name;
134
+ }
135
+
136
+ if (replyTo) {
137
+ if (!replyTo.name) {
138
+ replyTo.name = organization.name;
139
+ }
140
+ }
141
+ }
142
+
143
+ return {
144
+ from, replyTo,
145
+ };
146
+ }
147
+ const platform = await Platform.getSharedPrivateStruct();
148
+
149
+ // Default e-mail if no email addresses are configured
150
+ const i18n = new I18n($getLanguage(), $getCountry());
151
+ const transactionalDomain = i18n.localizedDomains.defaultTransactionalEmail();
152
+ const broadcastDomain = i18n.localizedDomains.defaultBroadcastEmail();
153
+
154
+ const domain = (options.type === 'transactional' ? transactionalDomain : broadcastDomain);
155
+ let from: EmailInterfaceRecipient = {
156
+ email: 'hallo@' + domain,
157
+ };
158
+
159
+ // Platform
160
+ const sender: OrganizationEmail | undefined = (preferEmailId ? platform.privateConfig.emails.find(e => e.id === preferEmailId) : null) ?? platform.privateConfig.emails.find(e => e.default) ?? platform.privateConfig.emails[0];
161
+ let replyTo: EmailInterfaceRecipient | undefined = undefined;
162
+
163
+ if (sender) {
164
+ replyTo = {
165
+ email: sender.email,
166
+ name: sender.name,
167
+ };
168
+
169
+ // Are we allowed to send an e-mail from this domain?
170
+ if (await canSendFromEmail(sender.email, null)) {
171
+ // Allowed to send from
172
+ from = {
173
+ email: sender.email,
174
+ name: sender.name,
175
+ };
176
+ replyTo = undefined;
177
+ }
178
+
179
+ // Default to platform name
180
+ if (!from.name) {
181
+ from.name = platform.config.name;
182
+ }
183
+
184
+ if (replyTo) {
185
+ if (!replyTo.name) {
186
+ replyTo.name = platform.config.name;
187
+ }
188
+ }
189
+ }
190
+
191
+ return {
192
+ from, replyTo,
193
+ };
194
+ }
195
+
196
+ export async function sendEmailTemplate(organization: Organization | null, options: Omit<EmailBuilderOptions, 'subject' | 'html' | 'from' | 'replyTo'> & { template: Omit<EmailTemplateOptions, 'organizationId'> }) {
197
+ if (options.template.webshop) {
198
+ options.defaultReplacements = [...(options.defaultReplacements ?? []), ...options.template.webshop.meta.getEmailReplacements()];
199
+ }
200
+ const builder = await getEmailBuilderForTemplate(organization, {
201
+ ...options,
202
+ ...(await getDefaultEmailFrom(organization, options)),
203
+ });
204
+ if (builder) {
205
+ Email.schedule(builder);
206
+ }
207
+ }
208
+
209
+ async function getEmailBuilderForTemplate(organization: Organization | null, options: Omit<EmailBuilderOptions, 'subject' | 'html'> & { template: Omit<EmailTemplateOptions, 'organizationId'> }) {
210
+ const template = await getEmailTemplate({
211
+ ...options.template,
212
+ organizationId: organization?.id ?? null,
213
+ });
214
+
215
+ if (!template) {
216
+ if (STAMHOOFD.environment === 'production') {
217
+ console.warn('No email template found for ' + options.template.type);
218
+ }
219
+ return undefined;
220
+ }
221
+
222
+ return await getEmailBuilder(organization, {
223
+ ...options,
224
+ subject: template.subject,
225
+ html: template.html,
226
+ translations: template.translations,
227
+ });
228
+ }
229
+
230
+ export type EmailBuilderOptions = {
231
+ defaultReplacements?: Replacement[];
232
+ recipients: Recipient[];
233
+ from: EmailInterfaceRecipient;
234
+ replyTo?: EmailInterfaceRecipient | null;
235
+ subject: string;
236
+ html: string;
237
+
238
+ /**
239
+ * Full content overrides per language. Recipients with a language that has an override
240
+ * receive that content, all others receive the default subject/html.
241
+ */
242
+ translations?: Map<Language, EmailContent>;
243
+ attachments?: { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string }[];
244
+ type?: 'transactional' | 'broadcast';
245
+ unsubscribeType?: 'all' | 'marketing';
246
+ fromStamhoofd?: boolean;
247
+ singleBcc?: EmailInterfaceRecipient;
248
+ replaceAll?: { from: string; to: string }[]; // replace in all e-mails, not recipient dependent
249
+ callback?: (error: Error | null) => void; // for each email
250
+ headers?: Record<string, string>;
251
+ };
252
+
253
+ /**
254
+ * @param organization defines replacements and unsubsribe behaviour
255
+ */
256
+ export async function getEmailBuilder(organization: Organization | null, email: EmailBuilderOptions) {
257
+ const platform = await Platform.getSharedPrivateStruct();
258
+ // Update recipients
259
+ const cleaned: Recipient[] = [];
260
+ for (const recipient of email.recipients) {
261
+ try {
262
+ const unsubscribeGlobal = await EmailAddress.getWhereHardBounceOrSpam(recipient.email);
263
+ if ((unsubscribeGlobal && (unsubscribeGlobal.hardBounce))) {
264
+ // Ignore
265
+ if (email.callback) {
266
+ email.callback(
267
+ new SimpleError({
268
+ code: 'email_skipped_hard_bounce',
269
+ message: 'Recipient has hard bounced',
270
+ human: $t(`%ws`),
271
+ }),
272
+ );
273
+ }
274
+ continue;
275
+ }
276
+
277
+ if (unsubscribeGlobal && (unsubscribeGlobal.markedAsSpam)) {
278
+ // Ignore
279
+ if (email.callback) {
280
+ email.callback(
281
+ new SimpleError({
282
+ code: 'email_skipped_spam',
283
+ message: 'Recipient has marked as spam',
284
+ human: $t(`%wt`),
285
+ }),
286
+ );
287
+ }
288
+ continue;
289
+ }
290
+
291
+ const unsubscribe = await EmailAddress.getOrCreate(recipient.email, email.fromStamhoofd || !organization ? null : organization.id);
292
+ if (unsubscribe.unsubscribedAll || unsubscribe.hardBounce || unsubscribe.markedAsSpam || !unsubscribe.token || (unsubscribe.unsubscribedMarketing && email.unsubscribeType === 'marketing')) {
293
+ // Ignore
294
+ if (email.callback) {
295
+ email.callback(
296
+ new SimpleError({
297
+ code: 'email_skipped_unsubscribed',
298
+ message: unsubscribe.unsubscribedAll ? 'Recipient has unsubscribed' : (unsubscribe.hardBounce ? 'Recipient has hard bounced' : (unsubscribe.markedAsSpam ? 'Recipient has marked as spam' : 'Recipient has unsubscribed from marketing')),
299
+ human: $t('%1E3'),
300
+ }),
301
+ );
302
+ }
303
+ continue;
304
+ }
305
+
306
+ // Localize the unsubscribe page to the recipient's language (Organization.i18n is always Dutch)
307
+ const unsubscribeLocale = organization ? getRecipientI18n(recipient, organization).locale : null;
308
+ const unsubscribeUrl = 'https://' + STAMHOOFD.domains.dashboard + '/' + (unsubscribeLocale ? (unsubscribeLocale + '/') : '') + 'unsubscribe?id=' + encodeURIComponent(unsubscribe.id) + '&token=' + encodeURIComponent(unsubscribe.token) + '&type=' + encodeURIComponent(email.unsubscribeType ?? 'all');
309
+ recipient.replacements.push(Replacement.create({
310
+ token: 'unsubscribeUrl',
311
+ value: unsubscribeUrl,
312
+ }));
313
+
314
+ // Override headers
315
+ recipient.headers = {
316
+ ...email.headers,
317
+ 'List-Unsubscribe': STAMHOOFD.domains.defaultBroadcastEmail !== undefined ? '<mailto:unsubscribe+' + unsubscribe.id + '@' + STAMHOOFD.domains.defaultBroadcastEmail![''] + `>, <${unsubscribeUrl}>` : `<${unsubscribeUrl}>`,
318
+ 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
319
+ };
320
+ cleaned.push(recipient);
321
+ } catch (e) {
322
+ console.error(e);
323
+ }
324
+ }
325
+ email.recipients = cleaned;
326
+
327
+ // Update recipients
328
+ for (const recipient of email.recipients) {
329
+ recipient.replacements = recipient.replacements.slice();
330
+
331
+ if (email.defaultReplacements) {
332
+ recipient.replacements.push(...email.defaultReplacements);
333
+ }
334
+
335
+ await fillRecipientReplacements(recipient, {
336
+ organization,
337
+ platform,
338
+ from: email.from,
339
+ replyTo: email.replyTo ?? null,
340
+ });
341
+ }
342
+
343
+ const queue = email.recipients.slice();
344
+
345
+ let emailIndex = 0;
346
+
347
+ // The subject and html can differ per recipient language
348
+ const contentCache = new Map<Language | null, { subject: string; html: string }>();
349
+ const resolveContent = (language: Language | null) => {
350
+ const cached = contentCache.get(language);
351
+ if (cached) {
352
+ return cached;
353
+ }
354
+
355
+ // Strict selection: only use a translation of this email, never fall back to
356
+ // a different template for a missing language (content correctness above language)
357
+ const translation = language !== null ? email.translations?.get(language) : undefined;
358
+ const subject = translation ? translation.subject : email.subject;
359
+ let html = translation ? translation.html : email.html;
360
+
361
+ for (const s of email.replaceAll ?? []) {
362
+ html = html.replaceAll(s.from, s.to);
363
+ }
364
+
365
+ const result = { subject, html };
366
+ contentCache.set(language, result);
367
+ return result;
368
+ };
369
+
370
+ if (queue.length === 0) {
371
+ if (email.callback) {
372
+ email.callback(new SimpleError({
373
+ code: 'no_recipients',
374
+ message: 'No recipients left',
375
+ }));
376
+ }
377
+ }
378
+
379
+ // Create e-mail builder
380
+ const builder: EmailBuilder = () => {
381
+ const recipient = queue.shift();
382
+ if (!recipient) {
383
+ return undefined;
384
+ }
385
+
386
+ const content = resolveContent(recipient.language ?? null);
387
+ const replacedHtml = replaceEmailHtml(content.html, recipient.replacements);
388
+ const replacedSubject = replaceEmailText(content.subject, recipient.replacements);
389
+
390
+ emailIndex += 1;
391
+
392
+ return {
393
+ from: email.from,
394
+ replyTo: email.replyTo ?? undefined,
395
+ bcc: emailIndex === 1 && email.singleBcc ? [email.singleBcc] : undefined,
396
+ to: [
397
+ {
398
+ // Name will get cleaned by email service
399
+ name: (recipient.firstName ?? '') + ' ' + (recipient.lastName ?? ''),
400
+ email: recipient.email,
401
+ },
402
+ ],
403
+ subject: replacedSubject,
404
+ html: replacedHtml ?? undefined,
405
+ attachments: email.attachments,
406
+ headers: recipient.headers,
407
+ type: email.type,
408
+ callback: email.callback,
409
+ };
410
+ };
411
+ return builder;
412
+ }
413
+
414
+ export function mergeReplacement(replacementA: Replacement, replacementB: Replacement): Replacement | false {
415
+ if (replacementA.token !== replacementB.token) {
416
+ return false;
417
+ }
418
+
419
+ if (replacementA.token === 'greeting') {
420
+ // Just take the first one
421
+ return replacementA;
422
+ }
423
+
424
+ if (replacementA.token === 'unsubscribeUrl') {
425
+ return replacementA;
426
+ }
427
+
428
+ if (replacementA.token === 'signInUrl') {
429
+ return replacementA;
430
+ }
431
+
432
+ if (replacementA.token === 'loginDetails') {
433
+ // loginDetails are always the same for the same user.
434
+ return replacementA;
435
+ }
436
+
437
+ if (replacementA.token === 'objectName') {
438
+ // Add comma if values are not the same
439
+ const aa = replacementA.value.split(', ');
440
+
441
+ return Replacement.create({
442
+ token: 'objectName',
443
+ value: Formatter.uniqueArray([...aa, ...replacementB.value.split(', ')]).join(', '),
444
+ });
445
+ }
446
+
447
+ return false;
448
+ }
449
+
450
+ /**
451
+ * Remove duplicates
452
+ */
453
+ export function cleanReplacements(replacements: Replacement[]) {
454
+ const foundIds: Set<string> = new Set();
455
+ const cleaned: Replacement[] = [];
456
+ for (const r of replacements) {
457
+ if (foundIds.has(r.token)) {
458
+ continue;
459
+ }
460
+ foundIds.add(r.token);
461
+ cleaned.push(r);
462
+ }
463
+ return cleaned;
464
+ }
465
+
466
+ export function removeUnusedReplacements(html: string, replacements: Replacement[]) {
467
+ const cleaned: Replacement[] = [];
468
+ for (const r of cleanReplacements(replacements)) {
469
+ if (html.includes(`{{${r.token}}}`)) {
470
+ cleaned.push(r);
471
+ }
472
+ }
473
+ return cleaned;
474
+ }
475
+
476
+ export function mergeReplacementsIfEqual(replacementsA: Replacement[], replacementsB: Replacement[]): Replacement[] | false {
477
+ replacementsA = cleanReplacements(replacementsA);
478
+ replacementsB = cleanReplacements(replacementsB);
479
+
480
+ if (replacementsA.length !== replacementsB.length) {
481
+ return false;
482
+ }
483
+
484
+ const merged: Replacement[] = [];
485
+ for (const rA of replacementsA) {
486
+ const rB = replacementsB.find(r => r.token === rA.token);
487
+ if (!rB) {
488
+ return false;
489
+ }
490
+
491
+ if (rA.html === rB.html && rA.value === rB.value) {
492
+ merged.push(rA);
493
+ continue;
494
+ }
495
+
496
+ const m = mergeReplacement(rA, rB);
497
+ if (!m) {
498
+ return false;
499
+ }
500
+ merged.push(m);
501
+ }
502
+
503
+ return merged;
504
+ }
505
+
506
+ /**
507
+ * Filter replacements for display in the backend.
508
+ * @param options.forPreview if true, it will hide sensitive information in the preview that could leak information to admin users
509
+ */
510
+ export function stripSensitiveRecipientReplacements(recipient: Recipient | EmailRecipientStruct | EmailRecipient, options: {
511
+ organization: Organization | null;
512
+ willFill?: boolean;
513
+ }) {
514
+ const { organization } = options;
515
+ // Remove unsubscribeUrl and signInUrl if present
516
+ recipient.replacements = recipient.replacements.filter(r => r.token !== 'unsubscribeUrl' && r.token !== 'signInUrl');
517
+
518
+ if (options.willFill) {
519
+ // Also strip loginDetails, balanceTable and outstandingBalance
520
+ recipient.replacements = recipient.replacements.filter(r => r.token !== 'balanceTable' && r.token !== 'outstandingBalance' && r.token !== 'loginDetails');
521
+ return;
522
+ }
523
+
524
+ // Add dummy unsubscribeUrl
525
+ const dummyUnsubscribeUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false) : STAMHOOFD.domains.dashboard) + '/unsubscribe?token=example';
526
+ recipient.replacements.push(Replacement.create({
527
+ token: 'unsubscribeUrl',
528
+ value: dummyUnsubscribeUrl,
529
+ }));
530
+
531
+ // dummy signInUrl
532
+ const dummySignInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false) : STAMHOOFD.domains.dashboard) + '/login';
533
+ recipient.replacements.push(Replacement.create({
534
+ token: 'signInUrl',
535
+ value: dummySignInUrl,
536
+ }));
537
+
538
+ // Strip security codes (because we list ALL security codes, also from members a viewer might not have access to)
539
+ recipient.replacements = recipient.replacements.map((r) => {
540
+ if (r.token !== 'loginDetails') {
541
+ return r;
542
+ }
543
+ return Replacement.create({
544
+ ...r,
545
+ // Strip <span class="style-inline-code">(.*)</span> and replace content with XXXX-XXXX-XXXX-XXXX
546
+ html: r.html ? r.html.replace(/<span class="style-inline-code">.*?<\/span>/g, '<span class="style-inline-code">••••</span>') : r.html,
547
+ });
548
+ });
549
+ }
550
+
551
+ /**
552
+ * Fill and hide replacements that don't make sense for web display to the user
553
+ */
554
+ export function stripRecipientReplacementsForWebDisplay(recipient: Recipient | EmailRecipientStruct | EmailRecipient, options: {
555
+ organization: Organization | null;
556
+ }) {
557
+ const { organization } = options;
558
+ // Remove unsubscribeUrl if present
559
+ recipient.replacements = recipient.replacements.filter(r => r.token !== 'unsubscribeUrl' && r.token !== 'loginDetails' && r.token !== 'greeting');
560
+
561
+ // Add dummy unsubscribeUrl
562
+ const dummyUnsubscribeUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false) : STAMHOOFD.domains.dashboard);
563
+ recipient.replacements.push(Replacement.create({
564
+ token: 'unsubscribeUrl',
565
+ value: dummyUnsubscribeUrl,
566
+ }));
567
+
568
+ recipient.replacements.push(Replacement.create({
569
+ token: 'loginDetails',
570
+ value: '',
571
+ }));
572
+
573
+ recipient.replacements.push(Replacement.create({
574
+ token: 'greeting',
575
+ value: $t('%1E9'),
576
+ }));
577
+ }
578
+
579
+ /**
580
+ * Build the I18n an email should be rendered in for a specific recipient.
581
+ *
582
+ * Falls back to the current (ambient) locale when the recipient has no language set — so flows
583
+ * that already established a locale (order emails wrapped in I18n.runWithLocale, or the request
584
+ * locale) keep working unchanged.
585
+ */
586
+ export function getRecipientI18n(recipient: { language?: Language | null }, organization: Organization | null, options?: { allowedLanguages?: Language[] | null }): I18n {
587
+ let lang = recipient.language ?? $getLanguage();
588
+ if (options?.allowedLanguages && options.allowedLanguages.length) {
589
+ if (!options.allowedLanguages.includes(lang)) {
590
+ if (options.allowedLanguages.includes($getLanguage())) {
591
+ lang = $getLanguage();
592
+ } else {
593
+ lang = options.allowedLanguages[0];
594
+ }
595
+ }
596
+ }
597
+ return new I18n(lang, organization?.address.country ?? $getCountry());
598
+ }
599
+
600
+ /**
601
+ * Render everything inside `handler` in the recipient's language: all $t / $getLanguage /
602
+ * $getCountry calls (and therefore every generated replacement) use the recipient's locale.
603
+ *
604
+ * This is the single place that binds "a recipient" to "its language" when generating email
605
+ * content. Generate recipient replacements inside this wrapper so new replacements are localized
606
+ * automatically, and pass the provided i18n to helpers that take an explicit locale (e.g. getAppHost).
607
+ */
608
+ export function runWithRecipientLocale<T>(recipient: { language?: Language | null }, organization: Organization | null, handler: (i18n: I18n) => T, options?: { allowedLanguages?: Language[] | null }): T {
609
+ const i18n = getRecipientI18n(recipient, organization, options);
610
+ return I18n.runWithLocale(i18n, () => handler(i18n));
611
+ }
612
+
613
+ /**
614
+ * @param options.forPreview if true, it will hide sensitive information in the preview that could leak information to admin users
615
+ */
616
+ export async function fillRecipientReplacements(recipient: Recipient | EmailRecipientStruct | EmailRecipient, options: {
617
+ organization: Organization | null;
618
+ platform?: PlatformStruct;
619
+ from: EmailInterfaceRecipient | null;
620
+ replyTo: EmailInterfaceRecipient | null;
621
+ forPreview?: boolean;
622
+ forceRefresh?: boolean;
623
+ allowedLanguages?: Language[] | null;
624
+ }) {
625
+ if (!options.platform) {
626
+ options.platform = await Platform.getSharedPrivateStruct();
627
+ }
628
+ const { organization, platform, from, replyTo } = options;
629
+
630
+ // Render every recipient replacement (greeting, loginDetails, balance table, URLs...) in the
631
+ // recipient's own language, so translated emails are consistent end-to-end. Any replacement
632
+ // added below is localized automatically; helpers with an explicit locale get the same i18n.
633
+ await runWithRecipientLocale(recipient, organization, async (i18n) => {
634
+ let recipientUser: User | null | undefined = null;
635
+ recipient.replacements = recipient.replacements.slice();
636
+ if (options.forPreview) {
637
+ stripSensitiveRecipientReplacements(recipient, options);
638
+ }
639
+
640
+ if (!recipient.email && !recipient.userId) {
641
+ const signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/login';
642
+ recipient.replacements.push(Replacement.create({
643
+ token: 'signInUrl',
644
+ value: signInUrl,
645
+ }));
646
+
647
+ if (!recipient.replacements.find(r => r.token === 'loginDetails')) {
648
+ recipient.replacements.push(Replacement.create({
649
+ token: 'loginDetails',
650
+ value: '',
651
+ }));
652
+ }
653
+ } else {
654
+ // Default signInUrl
655
+ recipientUser = recipient.userId ? await User.select().where('id', recipient.userId).first(false) : await User.getForAuthentication(organization?.id ?? null, recipient.email!, { allowWithoutAccount: true });
656
+ if (STAMHOOFD.userMode !== 'platform' && recipientUser && recipientUser.organizationId && recipientUser.organizationId !== (organization?.id ?? null)) {
657
+ console.warn('User organization does not match current organization, ignoring userId', recipient.userId, recipientUser.organizationId, organization?.id ?? null);
658
+ recipientUser = null;
659
+ }
660
+
661
+ let signInUrl: string;
662
+ if (!recipientUser || !recipientUser.hasAccount()) {
663
+ // We can create a special token
664
+ if (recipientUser) {
665
+ signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/account-aanmaken?email=' + encodeURIComponent(recipientUser?.email);
666
+ } else {
667
+ signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/account-aanmaken';
668
+ }
669
+ } else {
670
+ signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/login?email=' + encodeURIComponent(recipientUser.email);
671
+ }
672
+
673
+ recipient.replacements.push(Replacement.create({
674
+ token: 'signInUrl',
675
+ value: signInUrl,
676
+ }));
677
+ }
678
+
679
+ if (options.forceRefresh) {
680
+ // Remove loginDetails to force refresh
681
+ recipient.replacements = recipient.replacements.filter(r => r.token !== 'loginDetails');
682
+ }
683
+
684
+ if (!recipient.replacements.find(r => r.token === 'loginDetails')) {
685
+ if (recipientUser) {
686
+ const emailEscaped = `<strong>${Formatter.escapeHtml(recipientUser.email)}</strong>`;
687
+ const suffixes: string[] = [];
688
+ const memberIds = await Member.getMemberIdsForUser(recipientUser);
689
+ const members = await Member.getByIDs(...memberIds);
690
+ if (members.length > 0) {
691
+ for (const member of members) {
692
+ suffixes.push(
693
+ $t('%1EC', {
694
+ firstName: Formatter.escapeHtml(member.firstName),
695
+ securityCode: `<span class="style-inline-code">${Formatter.escapeHtml(options.forPreview ? '••••' : Formatter.spaceString(member.details.securityCode ?? '', 4, '-'))}</span>`,
696
+ }),
697
+ );
698
+ }
699
+ } else {
700
+ console.log('No member found for user', recipientUser.id);
701
+ }
702
+ const suffix = suffixes.length > 0 ? (' ' + suffixes.join(' ')) : '';
703
+ recipient.replacements.push(
704
+ Replacement.create({
705
+ token: 'loginDetails',
706
+ value: '',
707
+ html: recipientUser.hasAccount()
708
+ ? `<p class="description"><em>${$t('%1EA', { email: emailEscaped })}${suffix}</em></p>`
709
+ : `<p class="description"><em>${$t('%1EB', { email: emailEscaped })}${suffix}</em></p>`,
710
+ }),
711
+ );
712
+ } else {
713
+ if (recipient.email) {
714
+ const emailEscaped = `<strong>${Formatter.escapeHtml(recipient.email)}</strong>`;
715
+ console.log('No user found for email', recipient.email);
716
+ recipient.replacements.push(
717
+ Replacement.create({
718
+ token: 'loginDetails',
719
+ value: '',
720
+ html: `<p class="description"><em>${$t('%1EB', { email: emailEscaped })}</em></p>`,
721
+ }),
722
+ );
723
+ } else {
724
+ recipient.replacements.push(
725
+ Replacement.create({
726
+ token: 'loginDetails',
727
+ value: '',
728
+ html: '',
729
+ }),
730
+ );
731
+ }
732
+ }
733
+ }
734
+
735
+ if (options.forceRefresh) {
736
+ // Remove loginDetails to force refresh
737
+ recipient.replacements = recipient.replacements.filter(r => r.token !== 'balanceTable' && r.token !== 'outstandingBalance');
738
+ }
739
+
740
+ // Load balance of this user
741
+ // todo: only if detected it is used
742
+ if (!recipient.replacements.find(r => r.token === 'balanceTable')) {
743
+ if (organization && recipientUser) {
744
+ const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [recipientUser.id], ReceivableBalanceType.user);
745
+ const balanceItems = balanceItemModels.map(i => i.getStructure());
746
+
747
+ // Get members
748
+ recipient.replacements.push(
749
+ Replacement.create({
750
+ token: 'outstandingBalance',
751
+ value: Formatter.price(balanceItems.reduce((sum, i) => sum + i.priceOpen, 0)),
752
+ }),
753
+ Replacement.create({
754
+ token: 'balanceTable',
755
+ value: '',
756
+ html: BalanceItemStruct.getDetailsHTMLTable(balanceItems),
757
+ }),
758
+ );
759
+ } else {
760
+ recipient.replacements.push(
761
+ Replacement.create({
762
+ token: 'outstandingBalance',
763
+ value: Formatter.price(0),
764
+ }),
765
+ Replacement.create({
766
+ token: 'balanceTable',
767
+ value: '',
768
+ html: BalanceItemStruct.getDetailsHTMLTable([]),
769
+ }),
770
+ );
771
+ }
772
+ }
773
+
774
+ if (from || replyTo) {
775
+ const fromAddress = replyTo?.email ?? from!.email;
776
+
777
+ if (fromAddress) {
778
+ recipient.replacements.push(Replacement.create({
779
+ token: 'fromAddress',
780
+ value: fromAddress,
781
+ }));
782
+ }
783
+
784
+ const name = replyTo?.name ?? from?.name;
785
+ if (name) {
786
+ recipient.replacements.push(Replacement.create({
787
+ token: 'fromName',
788
+ value: name,
789
+ }));
790
+ }
791
+ }
792
+
793
+ if (recipient instanceof EmailRecipient) {
794
+ recipient.replacements.push(...recipient.getRecipient().getDefaultReplacements());
795
+ } else {
796
+ recipient.replacements.push(...recipient.getDefaultReplacements());
797
+ }
798
+
799
+ if (organization) {
800
+ const extra = organization.meta.getEmailReplacements(organization);
801
+ recipient.replacements.push(...extra);
802
+ }
803
+
804
+ // Defaults
805
+ const extra = platform.config.getEmailReplacements(platform);
806
+ recipient.replacements.push(...extra);
807
+
808
+ // Remove duplicates
809
+ cleanReplacements(recipient.replacements);
810
+ }, options);
811
+ }