@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,282 @@
1
+ import type { GetEmailIdentityCommandOutput } from '@aws-sdk/client-sesv2';
2
+ import { CreateEmailIdentityCommand, DeleteEmailIdentityCommand, GetEmailIdentityCommand, PutEmailIdentityFeedbackAttributesCommand, PutEmailIdentityMailFromAttributesCommand, SESv2Client } from '@aws-sdk/client-sesv2';
3
+ import type { Organization } from '@stamhoofd/models';
4
+ import { validateDNSRecords } from '@stamhoofd/models/helpers/DNSValidator.js';
5
+ import { DNSRecordStatus, EmailTemplateType } from '@stamhoofd/structures';
6
+ import { Formatter } from '@stamhoofd/utility';
7
+ import { OrganizationEmailService } from './OrganizationEmailService.js';
8
+
9
+ export class OrganizationDNSService {
10
+ static async updateDNSRecords(organization: Organization) {
11
+ // Check initial status
12
+ let isValidRecords = true;
13
+ for (const record of organization.privateMeta.dnsRecords) {
14
+ if (record.status !== DNSRecordStatus.Valid) {
15
+ isValidRecords = false;
16
+ }
17
+ }
18
+
19
+ const { allValid } = await validateDNSRecords(organization.privateMeta.dnsRecords);
20
+
21
+ if (organization.registerDomain ?? organization.privateMeta.pendingRegisterDomain) {
22
+ const registerDomainRecord = (organization.privateMeta.pendingRegisterDomain ?? organization.registerDomain) + '.';
23
+ const records = organization.privateMeta.dnsRecords.filter(r => r.name === registerDomainRecord);
24
+ const areRegisterDomainRecordsValid = records.length === 0 || records.every(r => r.status === DNSRecordStatus.Valid);
25
+
26
+ if (areRegisterDomainRecordsValid) {
27
+ // We can setup the register domain if needed
28
+ if (organization.privateMeta.pendingRegisterDomain !== null) {
29
+ organization.registerDomain = organization.privateMeta.pendingRegisterDomain;
30
+ organization.privateMeta.pendingRegisterDomain = null;
31
+
32
+ console.log('Did set register domain for ' + organization.id + ' to ' + organization.registerDomain);
33
+ }
34
+ } else {
35
+ // Clear register domain
36
+ if (organization.registerDomain) {
37
+ // We need to clear it, to prevent sending e-mails with invalid links
38
+ organization.privateMeta.pendingRegisterDomain = organization.privateMeta.pendingRegisterDomain ?? organization.registerDomain;
39
+ organization.registerDomain = null;
40
+
41
+ console.log('Cleared register domain for ' + organization.id + ' because of invalid non txt records');
42
+ }
43
+ }
44
+ }
45
+
46
+ if (allValid) {
47
+ if (organization.privateMeta.pendingMailDomain !== null) {
48
+ organization.privateMeta.mailDomain = organization.privateMeta.pendingMailDomain;
49
+ organization.privateMeta.pendingMailDomain = null;
50
+ }
51
+
52
+ const wasUnstable = organization.serverMeta.isDNSUnstable;
53
+ organization.serverMeta.markDNSValid();
54
+
55
+ const didSendDomainSetupMail = organization.serverMeta.didSendDomainSetupMail;
56
+ const didSendWarning = organization.serverMeta.DNSRecordWarningCount > 0;
57
+ organization.serverMeta.DNSRecordWarningCount = 0;
58
+
59
+ const wasActive = organization.privateMeta.mailDomainActive;
60
+ await this.updateAWSMailIdenitity(organization);
61
+
62
+ // yay! Do not Save until after doing AWS changes
63
+ await organization.save();
64
+
65
+ if (wasUnstable && !organization.serverMeta.isDNSUnstable) {
66
+ console.warn('DNS settings became stable for ' + organization.name + ' ' + organization.id);
67
+
68
+ await OrganizationEmailService.sendEmailTemplate(organization, {
69
+ type: EmailTemplateType.OrganizationStableDNS,
70
+ bcc: true,
71
+ });
72
+ } else if (!wasActive && organization.privateMeta.mailDomainActive && (!didSendDomainSetupMail || didSendWarning) && !organization.serverMeta.isDNSUnstable) {
73
+ organization.serverMeta.didSendDomainSetupMail = true;
74
+ await organization.save();
75
+
76
+ if (!didSendDomainSetupMail) {
77
+ await OrganizationEmailService.sendEmailTemplate(organization, {
78
+ type: EmailTemplateType.OrganizationDNSSetupComplete,
79
+ });
80
+ } else {
81
+ await OrganizationEmailService.sendEmailTemplate(organization, {
82
+ type: EmailTemplateType.OrganizationValidDNS,
83
+ });
84
+ }
85
+ }
86
+ } else {
87
+ // DNS settings gone broken
88
+ if (organization.privateMeta.mailDomain) {
89
+ organization.privateMeta.pendingMailDomain = organization.privateMeta.pendingMailDomain ?? organization.privateMeta.mailDomain;
90
+ organization.privateMeta.mailDomain = null;
91
+ }
92
+
93
+ const wasDNSUnstable = organization.serverMeta.isDNSUnstable;
94
+
95
+ organization.serverMeta.markDNSFailure();
96
+
97
+ // disable AWS emails
98
+ organization.privateMeta.mailDomainActive = false;
99
+
100
+ // save
101
+ await organization.save();
102
+
103
+ if (!wasDNSUnstable && organization.serverMeta.isDNSUnstable) {
104
+ // DNS became instable
105
+ console.warn('DNS settings became instable for ' + organization.name + ' ' + organization.id);
106
+
107
+ await OrganizationEmailService.sendEmailTemplate(organization, {
108
+ type: EmailTemplateType.OrganizationUnstableDNS,
109
+ bcc: true,
110
+ });
111
+ } else if (!organization.serverMeta.isDNSUnstable && organization.serverMeta.didSendDomainSetupMail && organization.serverMeta.DNSRecordWarningCount == 0) {
112
+ organization.serverMeta.DNSRecordWarningCount += 1;
113
+ await organization.save();
114
+
115
+ await OrganizationEmailService.sendEmailTemplate(organization, {
116
+ type: EmailTemplateType.OrganizationInvalidDNS,
117
+ });
118
+ }
119
+ }
120
+ }
121
+
122
+ static get forbiddenEmailDomains() {
123
+ return [
124
+ STAMHOOFD.domains.dashboard,
125
+ ...Object.values(STAMHOOFD.domains.defaultBroadcastEmail ?? {}),
126
+ ...Object.values(STAMHOOFD.domains.defaultTransactionalEmail ?? {}),
127
+ ];
128
+ }
129
+
130
+ static async deleteAWSMailIdenitity(organization: Organization, mailDomain: string) {
131
+ // Protect specific domain names
132
+ if (this.forbiddenEmailDomains.includes(mailDomain.toLowerCase())) {
133
+ return;
134
+ }
135
+
136
+ if (STAMHOOFD.environment !== 'production') {
137
+ // Temporary ignore this
138
+ return;
139
+ }
140
+
141
+ const client = new SESv2Client({});
142
+
143
+ // Check if mail identitiy already exists..
144
+ try {
145
+ const cmd = new GetEmailIdentityCommand({
146
+ EmailIdentity: mailDomain,
147
+ });
148
+ const result = await client.send(cmd);
149
+
150
+ if (result.VerifiedForSendingStatus === true) {
151
+ console.log('Cant delete AWS mail idenitiy @' + organization.id + ' for ' + mailDomain + ': already validated and might be in use by other organizations');
152
+ return;
153
+ }
154
+
155
+ console.log('Deleting AWS mail identity @' + organization.id + ' for ' + mailDomain);
156
+
157
+ const deleteCmd = new DeleteEmailIdentityCommand({
158
+ EmailIdentity: mailDomain,
159
+ });
160
+
161
+ await client.send(deleteCmd);
162
+ console.log('Deleted AWS mail idenitiy @' + organization.id + ' for ' + organization.privateMeta.mailDomain);
163
+ } catch (e) {
164
+ console.error('Could not delete AWS email identitiy @' + organization.id + ' for ' + organization.privateMeta.mailDomain);
165
+ console.error(e);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Create or update the AWS mail idenitiy and also update the active state of the mailDomain
171
+ */
172
+ static async updateAWSMailIdenitity(organization: Organization) {
173
+ if (organization.privateMeta.mailDomain === null) {
174
+ return;
175
+ }
176
+
177
+ // Protect specific domain names
178
+ if (['stamhoofd.be', 'stamhoofd.nl', 'stamhoofd.shop', 'stamhoofd.app', 'stamhoofd.email'].includes(organization.privateMeta.mailDomain)) {
179
+ console.error('Tried to validate AWS mail identity with protected domains @' + organization.id);
180
+ organization.privateMeta.mailDomainActive = false;
181
+ return;
182
+ }
183
+
184
+ if (STAMHOOFD.environment !== 'production') {
185
+ // Temporary ignore this
186
+ organization.privateMeta.mailDomainActive = true;
187
+ return;
188
+ }
189
+
190
+ const client = new SESv2Client({});
191
+ const expectedConfigurationSetName = Formatter.slug(STAMHOOFD.platformName + '-domains');
192
+
193
+ // Check if mail identitiy already exists..
194
+ let exists = false;
195
+ let existing: GetEmailIdentityCommandOutput | undefined = undefined;
196
+ try {
197
+ const cmd = new GetEmailIdentityCommand({
198
+ EmailIdentity: organization.privateMeta.mailDomain,
199
+ });
200
+
201
+ existing = await client.send(cmd);
202
+ exists = true;
203
+
204
+ console.log('AWS mail idenitiy exists already: just checking the verification status in AWS @' + organization.id);
205
+
206
+ if (existing.ConfigurationSetName !== expectedConfigurationSetName) {
207
+ // Not allowed to use this identity
208
+ organization.privateMeta.mailDomainActive = false;
209
+ console.error('Organization is not allowed to use email identity ' + organization.privateMeta.mailDomain + ' @' + organization.id + ', got ' + existing.ConfigurationSetName);
210
+ return;
211
+ }
212
+
213
+ organization.privateMeta.mailDomainActive = existing.VerifiedForSendingStatus ?? false;
214
+
215
+ if (existing.VerifiedForSendingStatus !== true) {
216
+ console.error('Not validated @' + organization.id);
217
+ }
218
+
219
+ if (existing.VerifiedForSendingStatus !== true && existing.DkimAttributes?.Status === 'FAILED') {
220
+ console.error('AWS failed to verify DKIM records. Triggering a forced recheck @' + organization.id);
221
+
222
+ const deleteCmd = new DeleteEmailIdentityCommand({
223
+ EmailIdentity: organization.privateMeta.mailDomain,
224
+ });
225
+ await client.send(deleteCmd);
226
+
227
+ // Recreate it immediately
228
+ exists = false;
229
+ }
230
+ } catch (e) {
231
+ console.error(e);
232
+ }
233
+
234
+ if (!exists) {
235
+ console.log('Creating email identity in AWS SES...');
236
+
237
+ const cmd = new CreateEmailIdentityCommand({
238
+ EmailIdentity: organization.privateMeta.mailDomain,
239
+ ConfigurationSetName: expectedConfigurationSetName,
240
+ DkimSigningAttributes: {
241
+ DomainSigningPrivateKey: organization.serverMeta.privateDKIMKey!,
242
+ DomainSigningSelector: Formatter.slug(STAMHOOFD.platformName),
243
+ },
244
+ Tags: [
245
+ {
246
+ Key: 'OrganizationId',
247
+ Value: organization.id,
248
+ },
249
+ {
250
+ Key: 'Environment',
251
+ Value: STAMHOOFD.environment ?? 'Unknown',
252
+ },
253
+ ],
254
+ });
255
+
256
+ const result = await client.send(cmd);
257
+ organization.privateMeta.mailDomainActive = result.VerifiedForSendingStatus ?? false;
258
+
259
+ // Disable email forwarding of bounces and complaints
260
+ // We handle this now with the configuration set
261
+ const putFeedbackCmd = new PutEmailIdentityFeedbackAttributesCommand({
262
+ EmailIdentity: organization.privateMeta.mailDomain,
263
+ EmailForwardingEnabled: false,
264
+ });
265
+
266
+ await client.send(putFeedbackCmd);
267
+ }
268
+
269
+ if (organization.privateMeta.mailFromDomain && (!exists || (existing && (!existing.MailFromAttributes || existing.MailFromAttributes.MailFromDomain !== organization.privateMeta.mailFromDomain)))) {
270
+ // Also set a from domain, to fix SPF
271
+ console.log('Setting mail from domain: ' + organization.privateMeta.mailFromDomain + ' for ' + organization.id);
272
+
273
+ const cmd = new PutEmailIdentityMailFromAttributesCommand({
274
+ EmailIdentity: organization.privateMeta.mailDomain,
275
+ BehaviorOnMxFailure: 'USE_DEFAULT_VALUE',
276
+ MailFromDomain: organization.privateMeta.mailFromDomain,
277
+ });
278
+
279
+ await client.send(cmd);
280
+ }
281
+ }
282
+ }
@@ -0,0 +1,57 @@
1
+ import { EmailMocker } from '@stamhoofd/email';
2
+ import type { Organization } from '@stamhoofd/models';
3
+ import { EmailTemplateFactory, OrganizationFactory, UserFactory } from '@stamhoofd/models';
4
+ import { EmailTemplateType, PermissionLevel, Permissions } from '@stamhoofd/structures';
5
+ import { TestUtils } from '@stamhoofd/test-utils';
6
+ import { OrganizationEmailService } from './OrganizationEmailService.js';
7
+
8
+ async function createOrganizationWithAdmin(): Promise<Organization> {
9
+ const organization = await new OrganizationFactory({}).create();
10
+ await new UserFactory({
11
+ organization,
12
+ email: 'admin@example.com',
13
+ firstName: 'Ad',
14
+ lastName: 'Min',
15
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
16
+ }).create();
17
+ return organization;
18
+ }
19
+
20
+ function setupEnvironment() {
21
+ TestUtils.setEnvironment('userMode', 'organization');
22
+
23
+ // The notification emails localize the marketing domain, which the shared test environment does not set
24
+ TestUtils.setEnvironment('domains', {
25
+ ...STAMHOOFD.domains,
26
+ marketing: { '': 'stamhoofd.dev', BE: 'be.stamhoofd.dev', NL: 'nl.stamhoofd.dev' },
27
+ });
28
+ }
29
+
30
+ describe('OrganizationEmailService.checkDrips', () => {
31
+ beforeAll(async () => {
32
+ await new EmailTemplateFactory({
33
+ type: EmailTemplateType.OrganizationDripWelcome,
34
+ subject: EmailTemplateType.OrganizationDripWelcome,
35
+ html: '<p>{{organizationName}} - {{mailDomain}}</p>',
36
+ text: '{{organizationName}} - {{mailDomain}}',
37
+ }).create();
38
+ });
39
+
40
+ beforeEach(() => {
41
+ setupEnvironment();
42
+ });
43
+
44
+ test('Sends the welcome drip email only once', async () => {
45
+ const organization = await createOrganizationWithAdmin();
46
+
47
+ await OrganizationEmailService.checkDrips(organization);
48
+
49
+ const emails = await EmailMocker.transactional.getSucceededEmails();
50
+ expect(emails).toHaveLength(1);
51
+ expect(emails[0].subject).toBe(EmailTemplateType.OrganizationDripWelcome);
52
+ expect(organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWelcome)).toBe(true);
53
+
54
+ await OrganizationEmailService.checkDrips(organization);
55
+ expect(await EmailMocker.transactional.getSucceededEmails()).toHaveLength(1);
56
+ });
57
+ });
@@ -0,0 +1,212 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import type { Organization } from '@stamhoofd/models';
3
+ import { sendEmailTemplate } from '../helpers/EmailBuilder.js';
4
+ import { EmailTemplateType, Replacement, STPackageType } from '@stamhoofd/structures';
5
+ import { Country } from '@stamhoofd/types/Country';
6
+ import { Language } from '@stamhoofd/types/Language';
7
+ import { OrganizationAdminService } from './OrganizationAdminService.js';
8
+
9
+ export class OrganizationEmailService {
10
+ static async sendEmailTemplate(organization: Organization, data: {
11
+ type: EmailTemplateType;
12
+ personal?: boolean;
13
+ bcc?: boolean;
14
+ }) {
15
+ const recipients = await OrganizationAdminService.getAdminRecipients(organization);
16
+ const defaultI18n = new I18n(Language.Dutch, Country.Belgium);
17
+ const i18n = organization.i18n;
18
+
19
+ const replaceAll = [
20
+ {
21
+ from: defaultI18n.localizedDomains.marketing(),
22
+ to: i18n.localizedDomains.marketing(),
23
+ },
24
+ {
25
+ from: defaultI18n.$t('%2a'),
26
+ to: i18n.$t('%2a'),
27
+ },
28
+ {
29
+ from: defaultI18n.$t('%19'),
30
+ to: i18n.$t('%19'),
31
+ },
32
+ ];
33
+
34
+ await sendEmailTemplate(null, {
35
+ replaceAll,
36
+ recipients,
37
+ template: {
38
+ type: data.type,
39
+ },
40
+ singleBcc: data.bcc ? { email: 'simon@stamhoofd.be' } : undefined,
41
+ type: 'transactional',
42
+ defaultReplacements: [
43
+ Replacement.create({
44
+ token: 'mailDomain',
45
+ value: organization.privateMeta.mailDomain ?? organization.privateMeta.pendingMailDomain ?? '',
46
+ }),
47
+ Replacement.create({
48
+ token: 'organizationName',
49
+ value: organization.name,
50
+ }),
51
+ ],
52
+ unsubscribeType: 'marketing',
53
+ fromStamhoofd: true,
54
+ });
55
+ }
56
+
57
+ static async checkDrips(organization: Organization) {
58
+ const days7 = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
59
+
60
+ // Welcome drip
61
+ // Created maximum 7 days ago
62
+ if (organization.createdAt > days7 && !organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWelcome)) {
63
+ await this.sendEmailTemplate(organization, {
64
+ type: EmailTemplateType.OrganizationDripWelcome,
65
+ personal: true,
66
+ });
67
+
68
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWelcome);
69
+ await organization.save();
70
+
71
+ return; // Never send more than 1 drip email on the same day
72
+ }
73
+
74
+ // Webshop trial checkin
75
+ if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWebshopTrialCheckin)) {
76
+ if (organization.meta.packages.isWebshopsTrial) {
77
+ const activeTime = organization.meta.packages.getActiveTime(STPackageType.TrialWebshops);
78
+ if (activeTime !== null && activeTime > 4 * 24 * 60 * 60 * 1000) {
79
+ // 7 days checkin
80
+ await this.sendEmailTemplate(organization, {
81
+ type: EmailTemplateType.OrganizationDripWebshopTrialCheckin,
82
+ personal: true,
83
+ });
84
+
85
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialCheckin);
86
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialCheckin); // also mark members checkin
87
+ await organization.save();
88
+
89
+ return; // Never send more than 1 drip email on the same day
90
+ }
91
+ }
92
+ }
93
+
94
+ // Members trial checkin
95
+ if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripMembersTrialCheckin)) {
96
+ if (organization.meta.packages.isMembersTrial) {
97
+ const activeTime = organization.meta.packages.getActiveTime(STPackageType.TrialMembers);
98
+ if (activeTime !== null && activeTime > 4 * 24 * 60 * 60 * 1000) {
99
+ // 7 days checkin
100
+ await this.sendEmailTemplate(organization, {
101
+ type: EmailTemplateType.OrganizationDripMembersTrialCheckin,
102
+ personal: true,
103
+ });
104
+
105
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialCheckin);
106
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialCheckin); // Also mark webshop trial checkin
107
+ await organization.save();
108
+
109
+ return; // Never send more than 1 drip email on the same day
110
+ }
111
+ }
112
+ }
113
+
114
+ // Webshop trial expired after 1 week
115
+ if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWebshopTrialExpired)) {
116
+ if (!organization.meta.packages.useWebshops) {
117
+ const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.TrialWebshops);
118
+ if (deactivatedTime !== null && deactivatedTime < 14 * 24 * 60 * 60 * 1000 && deactivatedTime > 7 * 24 * 60 * 60 * 1000) {
119
+ await this.sendEmailTemplate(organization, {
120
+ type: EmailTemplateType.OrganizationDripWebshopTrialExpired,
121
+ personal: true,
122
+ });
123
+
124
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialExpired);
125
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialExpired); // also mark members
126
+ await organization.save();
127
+
128
+ return; // Never send more than 1 drip email on the same day
129
+ }
130
+ }
131
+ }
132
+
133
+ if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripMembersTrialExpired)) {
134
+ if (!organization.meta.packages.useMembers) {
135
+ const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.TrialMembers);
136
+ if (deactivatedTime !== null && deactivatedTime < 14 * 24 * 60 * 60 * 1000 && deactivatedTime > 7 * 24 * 60 * 60 * 1000) {
137
+ await this.sendEmailTemplate(organization, {
138
+ type: EmailTemplateType.OrganizationDripMembersTrialExpired,
139
+ personal: true,
140
+ });
141
+
142
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersTrialExpired);
143
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopTrialExpired); // also mark webshops
144
+ await organization.save();
145
+
146
+ return; // Never send more than 1 drip email on the same day
147
+ }
148
+ }
149
+ }
150
+
151
+ // trial expired reminder (after 10 months)
152
+ if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripTrialExpiredReminder)) {
153
+ if (!organization.meta.packages.isPaid && !organization.meta.packages.wasPaid) {
154
+ const deactivatedTime1 = organization.meta.packages.getDeactivatedTime(STPackageType.TrialWebshops);
155
+ const deactivatedTime2 = organization.meta.packages.getDeactivatedTime(STPackageType.TrialMembers);
156
+
157
+ const deactivatedTime = deactivatedTime1 && deactivatedTime2 ? Math.max(deactivatedTime1, deactivatedTime2) : (deactivatedTime1 ? deactivatedTime1 : deactivatedTime2);
158
+
159
+ if (deactivatedTime !== null && deactivatedTime > 10 * 30 * 24 * 60 * 60 * 1000 && deactivatedTime < 13 * 31 * 24 * 60 * 60 * 1000) {
160
+ await this.sendEmailTemplate(organization, {
161
+ type: EmailTemplateType.OrganizationDripTrialExpiredReminder,
162
+ personal: true,
163
+ bcc: true,
164
+ });
165
+
166
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripTrialExpiredReminder);
167
+ await organization.save();
168
+
169
+ return; // Never send more than 1 drip email on the same day
170
+ }
171
+ }
172
+ }
173
+
174
+ if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripWebshopNotRenewed)) {
175
+ if (!organization.meta.packages.useWebshops) {
176
+ const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.Webshops);
177
+
178
+ if (deactivatedTime !== null && deactivatedTime > 30 * 24 * 60 * 60 * 1000 && deactivatedTime < 30 * 3 * 24 * 60 * 60 * 1000) {
179
+ await this.sendEmailTemplate(organization, {
180
+ type: EmailTemplateType.OrganizationDripWebshopNotRenewed,
181
+ personal: true,
182
+ bcc: true,
183
+ });
184
+
185
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripWebshopNotRenewed);
186
+ await organization.save();
187
+
188
+ return; // Never send more than 1 drip email on the same day
189
+ }
190
+ }
191
+ }
192
+
193
+ if (!organization.serverMeta.hasEmail(EmailTemplateType.OrganizationDripMembersNotRenewed)) {
194
+ if (!organization.meta.packages.useMembers) {
195
+ const deactivatedTime = organization.meta.packages.getDeactivatedTime(STPackageType.Members);
196
+
197
+ if (deactivatedTime !== null && deactivatedTime > 30 * 24 * 60 * 60 * 1000 && deactivatedTime < 30 * 3 * 24 * 60 * 60 * 1000) {
198
+ await this.sendEmailTemplate(organization, {
199
+ type: EmailTemplateType.OrganizationDripMembersNotRenewed,
200
+ personal: true,
201
+ bcc: true,
202
+ });
203
+
204
+ organization.serverMeta.addEmail(EmailTemplateType.OrganizationDripMembersNotRenewed);
205
+ await organization.save();
206
+
207
+ return; // Never send more than 1 drip email on the same day
208
+ }
209
+ }
210
+ }
211
+ }
212
+ }
@@ -0,0 +1,99 @@
1
+ import { I18n } from '@stamhoofd/backend-i18n';
2
+ import { Organization, OrganizationFactory, PasswordToken, User, UserFactory } from '@stamhoofd/models';
3
+ import { Address, OrganizationMetaData, PermissionLevel, Permissions } from '@stamhoofd/structures';
4
+ import { TestUtils } from '@stamhoofd/test-utils';
5
+ import { Country } from '@stamhoofd/types/Country';
6
+ import { PasswordForgotService } from './PasswordForgotService.js';
7
+
8
+ const i18n = new I18n(I18n.defaultLanguage, I18n.defaultCountry);
9
+
10
+ /**
11
+ * An organization that is not saved in the database: enough to build an url with.
12
+ */
13
+ function buildOrganization(id = 'organization-1') {
14
+ const organization = new Organization();
15
+ organization.id = id;
16
+ organization.name = 'Test organization';
17
+ organization.uri = 'test-organization';
18
+ organization.meta = OrganizationMetaData.create({});
19
+ organization.address = Address.create({
20
+ street: 'Demostraat',
21
+ number: '12',
22
+ city: 'Gent',
23
+ postalCode: '9000',
24
+ country: Country.Belgium,
25
+ });
26
+ return organization;
27
+ }
28
+
29
+ function buildUser(organizationId: string | null) {
30
+ const user = new User();
31
+ user.id = 'user-1';
32
+ user.organizationId = organizationId;
33
+ user.email = 'user@example.com';
34
+ return user;
35
+ }
36
+
37
+ function buildToken(user: User, token: string) {
38
+ const passwordToken = new PasswordToken();
39
+ passwordToken.token = token;
40
+ passwordToken.userId = user.id;
41
+ return passwordToken;
42
+ }
43
+
44
+ describe('PasswordForgotService', () => {
45
+ beforeEach(() => {
46
+ TestUtils.setEnvironment('userMode', 'organization');
47
+ });
48
+
49
+ describe('getPasswordRecoveryUrl', () => {
50
+ test('An administrator of the organization gets a dashboard url', async () => {
51
+ const organization = await new OrganizationFactory({}).create();
52
+ const admin = await new UserFactory({
53
+ organization,
54
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
55
+ }).create();
56
+
57
+ const url = new URL(await PasswordForgotService.getPasswordRecoveryUrl(admin, organization, i18n));
58
+
59
+ expect(url.host).toBe(STAMHOOFD.domains.dashboard);
60
+ expect(url.pathname).toMatch(/\/reset-password$/);
61
+ });
62
+
63
+ test('A user without permissions for the organization gets a registration url', async () => {
64
+ const organization = await new OrganizationFactory({}).create();
65
+ const member = await new UserFactory({ organization }).create();
66
+
67
+ const url = new URL(await PasswordForgotService.getPasswordRecoveryUrl(member, organization, i18n));
68
+
69
+ expect(url.host).not.toBe(STAMHOOFD.domains.dashboard);
70
+ expect(url.host.split('.')[0]).toBe(organization.uri);
71
+ expect(url.pathname).toMatch(/\/reset-password$/);
72
+ });
73
+
74
+ test('The url points to the reset-password page with a url encoded token', async () => {
75
+ const organization = buildOrganization();
76
+ const user = buildUser(organization.id);
77
+
78
+ const url = await PasswordForgotService.getPasswordRecoveryUrlForToken(buildToken(user, 'a+b/c=d&e'), organization, i18n, user);
79
+
80
+ expect(url).toMatch(/\/reset-password\?token=a%2Bb%2Fc%3Dd%26e$/);
81
+ expect(new URL(url).searchParams.get('token')).toBe('a+b/c=d&e');
82
+ });
83
+
84
+ test('Throws when the user belongs to a different organization', async () => {
85
+ const user = buildUser('a-different-organization');
86
+
87
+ await expect(PasswordForgotService.getPasswordRecoveryUrlForToken(buildToken(user, 'my-token'), buildOrganization(), i18n, user))
88
+ .rejects
89
+ .toThrow('Unexpected mismatch in organization id for PasswordToken');
90
+ });
91
+
92
+ test('Allows a user without an organization in every scope', async () => {
93
+ const user = buildUser(null);
94
+
95
+ await expect(PasswordForgotService.getPasswordRecoveryUrlForToken(buildToken(user, 'my-token'), buildOrganization(), i18n, user))
96
+ .toResolve();
97
+ });
98
+ });
99
+ });