@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,705 @@
1
+ import { isSimpleError, isSimpleErrors, SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
2
+ import type { EmailInterfaceRecipient } from '@stamhoofd/email';
3
+ import { Email as EmailClass } from '@stamhoofd/email';
4
+ import { Email, EmailRecipient, Organization, Platform } from '@stamhoofd/models';
5
+ import { canSendFromEmail, fillRecipientReplacements, getEmailBuilder, mergeReplacementsIfEqual, removeUnusedReplacements } from '../helpers/EmailBuilder.js';
6
+ import { errorToSimpleErrors } from '@stamhoofd/models/helpers/errorToSimpleErrors.js';
7
+ import { isAbortedError, QueueHandler } from '@stamhoofd/queues';
8
+ import { SQL, SQLWhereSign } from '@stamhoofd/sql';
9
+ import { EmailRecipientsStatus, EmailStatus, isSoftEmailRecipientError, LimitedFilteredRequest, SortItemDirection } from '@stamhoofd/structures';
10
+
11
+ type Attachment = { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string };
12
+
13
+ /**
14
+ * Sends an email to its recipients: queueing, the per-recipient send and resuming an interrupted send.
15
+ */
16
+ export class EmailSendService {
17
+ static async queueForSending(email: Email, waitForSending = false) {
18
+ console.log('Queueing email for sending', email.id);
19
+ email.throwIfNotReadyToSend();
20
+ email.throwIfNoUnsubscribeButton();
21
+ await email.lock(async (upToDate) => {
22
+ if (upToDate.status === EmailStatus.Draft) {
23
+ upToDate.status = EmailStatus.Queued;
24
+ }
25
+ if (upToDate.status === EmailStatus.Failed) {
26
+ // Retry failed email
27
+ upToDate.status = EmailStatus.Queued;
28
+ }
29
+ await upToDate.save();
30
+ });
31
+ if (waitForSending) {
32
+ return await this.resumeSending(email);
33
+ } else {
34
+ this.resumeSending(email).catch(console.error);
35
+ }
36
+ return email;
37
+ }
38
+
39
+ private static async loadAttachments(email: Email): Promise<Attachment[]> {
40
+ const attachments: Attachment[] = [];
41
+ for (const attachment of email.attachments) {
42
+ if (!attachment.content && !attachment.file) {
43
+ console.warn('Attachment without content found, skipping', attachment);
44
+ continue;
45
+ }
46
+
47
+ let filename = $t('%180');
48
+
49
+ if (attachment.contentType === 'application/pdf') {
50
+ // tmp solution for pdf only
51
+ filename += '.pdf';
52
+ }
53
+
54
+ if (attachment.file?.name) {
55
+ filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
56
+ }
57
+
58
+ // Correct file name if needed
59
+ if (attachment.filename) {
60
+ filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
61
+ }
62
+
63
+ if (attachment.content) {
64
+ attachments.push({
65
+ filename: filename,
66
+ content: attachment.content,
67
+ contentType: attachment.contentType ?? undefined,
68
+ encoding: 'base64',
69
+ });
70
+ } else {
71
+ // Note: because we send lots of emails, we better download the file here so we can reuse it in every email instead of downloading it every time
72
+ const withSigned = await attachment.file!.withSignedUrl();
73
+ if (!withSigned || !withSigned.signedUrl) {
74
+ throw new SimpleError({
75
+ code: 'attachment_not_found',
76
+ message: 'Attachment not found',
77
+ human: $t(`%181`),
78
+ });
79
+ }
80
+
81
+ const filePath = withSigned.signedUrl;
82
+ let fileBuffer: Buffer | null;
83
+ try {
84
+ const response = await fetch(filePath);
85
+ fileBuffer = Buffer.from(await response.arrayBuffer());
86
+ } catch (e) {
87
+ throw new SimpleError({
88
+ code: 'attachment_not_found',
89
+ message: 'Attachment not found',
90
+ human: $t(`%181`),
91
+ });
92
+ }
93
+
94
+ attachments.push({
95
+ filename: filename,
96
+ contentType: attachment.contentType ?? undefined,
97
+ content: fileBuffer,
98
+ });
99
+ }
100
+ }
101
+ return attachments;
102
+ }
103
+
104
+ private static async sendSingleRecipient(email: Email, recipient: EmailRecipient, organization: Organization | null, data: { from: EmailInterfaceRecipient; replyTo: EmailInterfaceRecipient | null; attachments: Attachment[] }) {
105
+ let promiseResolve: (value: void | PromiseLike<void>) => void;
106
+ const promise = new Promise<void>((resolve) => {
107
+ promiseResolve = resolve;
108
+ });
109
+
110
+ const virtualRecipient = recipient.getRecipient();
111
+
112
+ let resolved = false;
113
+ const callback = async (error: Error | null) => {
114
+ if (resolved) {
115
+ return;
116
+ }
117
+ resolved = true;
118
+
119
+ try {
120
+ if (error === null) {
121
+ // Mark saved
122
+ recipient.sentAt = new Date();
123
+ recipient.failErrorMessage = null;
124
+
125
+ if (recipient.failError) {
126
+ recipient.previousFailError = recipient.failError;
127
+ }
128
+ recipient.failError = null;
129
+
130
+ // Update repacements that have been generated
131
+ recipient.replacements = virtualRecipient.replacements;
132
+ await recipient.save();
133
+ } else {
134
+ recipient.failCount += 1;
135
+ recipient.failErrorMessage = error.message;
136
+ if (recipient.failError) {
137
+ recipient.previousFailError = recipient.failError;
138
+ }
139
+ recipient.failError = errorToSimpleErrors(error);
140
+ recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
141
+ recipient.lastFailedAt = new Date();
142
+ await recipient.save();
143
+ }
144
+ } catch (e) {
145
+ console.error(e);
146
+ }
147
+ promiseResolve();
148
+ };
149
+
150
+ // Do send the email
151
+ // Create e-mail builder
152
+ const builder = await getEmailBuilder(organization ?? null, {
153
+ recipients: [
154
+ virtualRecipient,
155
+ ],
156
+ from: data.from,
157
+ replyTo: data.replyTo,
158
+ subject: email.subject!,
159
+ html: email.html!,
160
+ translations: email.translations,
161
+ type: email.emailType ? 'transactional' : 'broadcast',
162
+ attachments: data.attachments,
163
+ callback(error: Error | null) {
164
+ callback(error).catch(console.error);
165
+ },
166
+ headers: {
167
+ 'X-Email-Id': email.id,
168
+ 'X-Email-Recipient-Id': recipient.id,
169
+ },
170
+ });
171
+ EmailClass.schedule(builder);
172
+ return await promise;
173
+ }
174
+
175
+ static async resumeSending(email: Email, singleRecipientId: string | null = null): Promise<Email | null> {
176
+ const id = email.id;
177
+ return await QueueHandler.schedule('send-email', async ({ abort }) => {
178
+ return await email.lock(async function (upToDate: Email) {
179
+ if (upToDate.status === EmailStatus.Sent) {
180
+ // Already done
181
+ // In other cases -> queue has stopped and we can retry
182
+ if (!singleRecipientId) {
183
+ console.log('Email already sent, skipping...', upToDate.id);
184
+ return upToDate;
185
+ }
186
+ } else {
187
+ if (singleRecipientId) {
188
+ // Not possible
189
+ throw new SimpleError({
190
+ code: 'invalid_state',
191
+ message: 'Cannot retry single recipient for email that is not yet sent',
192
+ });
193
+ }
194
+
195
+ if (upToDate.status === EmailStatus.Sending) {
196
+ // This is an automatic retry.
197
+ if (upToDate.emailType) {
198
+ // Not eligible for retry
199
+ upToDate.status = EmailStatus.Failed;
200
+ await upToDate.save();
201
+ return upToDate;
202
+ }
203
+ if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
204
+ // Too long
205
+ console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
206
+ upToDate.status = EmailStatus.Failed;
207
+ await upToDate.save();
208
+ return upToDate;
209
+ }
210
+ } else if (upToDate.status !== EmailStatus.Queued) {
211
+ console.error('Email is not queued or sending, cannot send', upToDate.id, upToDate.status);
212
+ return upToDate;
213
+ }
214
+ }
215
+
216
+ const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
217
+ let from = upToDate.getDefaultFromAddress(organization);
218
+ let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
219
+ let succeededCount = 0;
220
+ let softFailedCount = 0;
221
+ let failedCount = 0;
222
+
223
+ try {
224
+ upToDate.throwIfNotReadyToSend();
225
+ upToDate.throwIfNoUnsubscribeButton();
226
+
227
+ if (!from) {
228
+ throw new SimpleError({
229
+ code: 'invalid_field',
230
+ message: 'Missing from',
231
+ human: $t(`%ww`),
232
+ });
233
+ }
234
+
235
+ // Can we send from this e-mail or reply-to?
236
+ if (upToDate.fromAddress && await canSendFromEmail(upToDate.fromAddress, organization ?? null)) {
237
+ from = upToDate.getFromAddress();
238
+ replyTo = null;
239
+ }
240
+
241
+ abort.throwIfAborted();
242
+
243
+ if (!singleRecipientId) {
244
+ upToDate.status = EmailStatus.Sending;
245
+ }
246
+ upToDate.sentAt = upToDate.sentAt ?? new Date();
247
+ await upToDate.save();
248
+
249
+ // Create recipients if not yet created
250
+ await EmailSendService.buildRecipients(upToDate);
251
+ abort.throwIfAborted();
252
+
253
+ // Refresh model
254
+ const c = (await Email.getByID(id))!;
255
+ if (!c) {
256
+ throw new SimpleError({
257
+ code: 'not_found',
258
+ message: 'Email not found',
259
+ human: $t(`%wz`),
260
+ });
261
+ }
262
+ upToDate.copyFrom(c);
263
+
264
+ if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
265
+ throw new SimpleError({
266
+ code: 'recipients_not_created',
267
+ message: 'Failed to create recipients',
268
+ human: $t(`%x0`),
269
+ });
270
+ }
271
+
272
+ // Create a buffer of all attachments
273
+ if (upToDate.sendAsEmail === true) {
274
+ const attachments = await EmailSendService.loadAttachments(upToDate);
275
+
276
+ // Start actually sending in batches of recipients that are not yet sent
277
+ let idPointer = '';
278
+ const batchSize = 100;
279
+ let isSavingStatus = false;
280
+ let lastStatusSave = new Date();
281
+
282
+ async function saveStatus() {
283
+ if (singleRecipientId) {
284
+ // Don't save during looping
285
+ return;
286
+ }
287
+
288
+ if (!upToDate) {
289
+ return;
290
+ }
291
+ if (isSavingStatus) {
292
+ return;
293
+ }
294
+ if ((new Date().getTime() - lastStatusSave.getTime()) < 1000 * 5) {
295
+ // Save at most every 5 seconds
296
+ return;
297
+ }
298
+ if (succeededCount < upToDate.succeededCount || softFailedCount < upToDate.softFailedCount || failedCount < upToDate.failedCount) {
299
+ // Do not update on retries
300
+ return;
301
+ }
302
+
303
+ lastStatusSave = new Date();
304
+ isSavingStatus = true;
305
+ upToDate.succeededCount = succeededCount;
306
+ upToDate.softFailedCount = softFailedCount;
307
+ upToDate.failedCount = failedCount;
308
+
309
+ try {
310
+ await upToDate.save();
311
+ } finally {
312
+ isSavingStatus = false;
313
+ }
314
+ }
315
+
316
+ while (true) {
317
+ abort.throwIfAborted();
318
+ const data = await SQL.select()
319
+ .from('email_recipients')
320
+ .where('emailId', upToDate.id)
321
+ .where('id', SQLWhereSign.Greater, idPointer)
322
+ .orderBy(SQL.column('id'), 'ASC')
323
+ .limit(batchSize)
324
+ .fetch();
325
+
326
+ const recipients = EmailRecipient.fromRows(data, 'email_recipients');
327
+
328
+ if (recipients.length === 0) {
329
+ break;
330
+ }
331
+
332
+ const sendingPromises: Promise<void>[] = [];
333
+ let skipped = 0;
334
+
335
+ for (const recipient of recipients) {
336
+ idPointer = recipient.id;
337
+
338
+ if (recipient.sentAt) {
339
+ succeededCount += 1;
340
+ await saveStatus();
341
+ skipped++;
342
+ continue;
343
+ }
344
+
345
+ if (!recipient.email) {
346
+ skipped++;
347
+ continue;
348
+ }
349
+
350
+ if (recipient.duplicateOfRecipientId) {
351
+ skipped++;
352
+ continue;
353
+ }
354
+
355
+ if (singleRecipientId) {
356
+ if (recipient.id !== singleRecipientId) {
357
+ // Failed or soft-failed
358
+ if (recipient.failError && isSoftEmailRecipientError(recipient.failError)) {
359
+ softFailedCount += 1;
360
+ } else {
361
+ failedCount += 1;
362
+ }
363
+ skipped++;
364
+ await saveStatus();
365
+ continue;
366
+ }
367
+ }
368
+
369
+ const promise = EmailSendService.sendSingleRecipient(upToDate, recipient, organization ?? null, {
370
+ from,
371
+ replyTo,
372
+ attachments,
373
+ });
374
+ sendingPromises.push(promise.then(async () => {
375
+ if (recipient.sentAt) {
376
+ succeededCount += 1;
377
+ await saveStatus();
378
+ } else {
379
+ // Failed or soft-failed
380
+ if (recipient.failError && isSoftEmailRecipientError(recipient.failError)) {
381
+ softFailedCount += 1;
382
+ } else {
383
+ failedCount += 1;
384
+ }
385
+ await saveStatus();
386
+ }
387
+ }));
388
+ }
389
+
390
+ if (sendingPromises.length > 0 || skipped > 0) {
391
+ await Promise.all(sendingPromises);
392
+ } else {
393
+ break;
394
+ }
395
+ }
396
+ }
397
+ } catch (e) {
398
+ if (!upToDate) {
399
+ throw e;
400
+ }
401
+
402
+ if (!singleRecipientId) {
403
+ upToDate.succeededCount = succeededCount;
404
+ upToDate.softFailedCount = softFailedCount;
405
+ upToDate.failedCount = failedCount;
406
+
407
+ if (isAbortedError(e) || ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('SHUTDOWN'))) {
408
+ // Keep sending status: we'll resume after the reboot
409
+ await upToDate.save();
410
+ throw e;
411
+ }
412
+
413
+ upToDate.emailErrors = errorToSimpleErrors(e);
414
+ upToDate.status = EmailStatus.Failed;
415
+ await upToDate.save();
416
+ }
417
+ throw e;
418
+ }
419
+
420
+ if (!singleRecipientId) {
421
+ if (upToDate.sendAsEmail && upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
422
+ // We only delete automated emails (email type) if they have no recipients
423
+ console.log('No recipients found for email ', upToDate.id, ' deleting...');
424
+ await upToDate.delete();
425
+ return null;
426
+ }
427
+
428
+ console.log('Finished sending email', upToDate.id);
429
+ // Mark email as sent
430
+
431
+ if (upToDate.sendAsEmail && !upToDate.showInMemberPortal && (succeededCount + failedCount + softFailedCount) === 0) {
432
+ upToDate.status = EmailStatus.Failed;
433
+ upToDate.emailErrors = new SimpleErrors(
434
+ new SimpleError({
435
+ code: 'no_recipients',
436
+ message: 'No recipients',
437
+ human: $t(`%1EG`),
438
+ }),
439
+ );
440
+ } else {
441
+ upToDate.status = EmailStatus.Sent;
442
+ upToDate.emailErrors = null;
443
+ }
444
+ }
445
+
446
+ upToDate.succeededCount = succeededCount;
447
+ upToDate.softFailedCount = softFailedCount;
448
+ upToDate.failedCount = failedCount;
449
+
450
+ await upToDate.save();
451
+ return upToDate;
452
+ });
453
+ });
454
+ }
455
+
456
+ static async buildRecipients(email: Email) {
457
+ const id = email.id;
458
+ await QueueHandler.schedule('email-build-recipients-' + email.id, async function ({ abort }) {
459
+ const upToDate = await Email.getByID(id);
460
+
461
+ if (!upToDate || !upToDate.id) {
462
+ return;
463
+ }
464
+
465
+ if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
466
+ return;
467
+ }
468
+
469
+ if (upToDate.status === EmailStatus.Sent) {
470
+ return;
471
+ }
472
+
473
+ abort.throwIfAborted();
474
+ const organization = upToDate.organizationId ? (await Organization.getByID(upToDate.organizationId) ?? null) : null;
475
+ if (upToDate.organizationId && !organization) {
476
+ throw new SimpleError({
477
+ code: 'organization_not_found',
478
+ message: 'Organization not found',
479
+ human: $t(`%1NV`),
480
+ });
481
+ }
482
+ const platform = await Platform.getSharedPrivateStruct();
483
+
484
+ console.log('Building recipients for email', id);
485
+
486
+ // If it is already creating -> something went wrong (e.g. server restart) and we can safely try again
487
+
488
+ upToDate.recipientsStatus = EmailRecipientsStatus.Creating;
489
+ await upToDate.save();
490
+
491
+ const membersSet = new Set<string>();
492
+ const emailsSet = new Set<string>();
493
+ const combinedHtml = upToDate.getCombinedHtml();
494
+
495
+ let count = 0;
496
+ let countWithoutEmail = 0;
497
+
498
+ try {
499
+ // Delete all recipients
500
+ await SQL
501
+ .delete()
502
+ .from(
503
+ SQL.table('email_recipients'),
504
+ )
505
+ .where(SQL.column('emailId'), upToDate.id);
506
+
507
+ abort.throwIfAborted();
508
+
509
+ for (const subfilter of upToDate.recipientFilter.filters) {
510
+ // Create recipients
511
+ const loader = Email.recipientLoaders.get(subfilter.type);
512
+
513
+ if (!loader) {
514
+ throw new Error('Loader for type ' + subfilter.type + ' has not been initialised on the Email model');
515
+ }
516
+
517
+ let request: LimitedFilteredRequest | null = new LimitedFilteredRequest({
518
+ filter: subfilter.filter,
519
+ sort: [{ key: 'id', order: SortItemDirection.ASC }],
520
+ limit: 100,
521
+ search: subfilter.search,
522
+ });
523
+
524
+ const beforeFetchAllResult = loader.beforeFetchAll ? await loader.beforeFetchAll(request, subfilter.subfilter) : undefined;
525
+
526
+ while (request) {
527
+ abort.throwIfAborted();
528
+ const response = await loader.fetch(request, subfilter.subfilter, beforeFetchAllResult, { allowedLanguages: upToDate.getLanguages() });
529
+
530
+ for (const item of response.results) {
531
+ if (!item.email && !item.memberId && !item.userId) {
532
+ continue;
533
+ }
534
+
535
+ const recipient = new EmailRecipient();
536
+ recipient.emailType = upToDate.emailType;
537
+ recipient.objectId = item.objectId;
538
+ recipient.emailId = upToDate.id;
539
+ recipient.email = item.email;
540
+ recipient.firstName = item.firstName;
541
+ recipient.lastName = item.lastName;
542
+ recipient.language = item.language ?? null;
543
+ recipient.replacements = item.replacements;
544
+ recipient.memberId = item.memberId ?? null;
545
+ recipient.userId = item.userId ?? null;
546
+ recipient.organizationId = upToDate.organizationId ?? null;
547
+
548
+ await fillRecipientReplacements(recipient, {
549
+ platform,
550
+ organization,
551
+ from: upToDate.getFromAddress(),
552
+ replyTo: null,
553
+ forPreview: false,
554
+ allowedLanguages: upToDate.getLanguages(),
555
+ });
556
+ recipient.replacements = removeUnusedReplacements(combinedHtml, recipient.replacements);
557
+
558
+ let duplicateOfRecipientId: string | null = null;
559
+ if (item.email && emailsSet.has(item.email)) {
560
+ console.log('Found duplicate email recipient', item.email);
561
+
562
+ // Try to merge
563
+ const existing = await EmailRecipient.select()
564
+ .where('emailId', upToDate.id)
565
+ .where('email', item.email)
566
+ .where('duplicateOfRecipientId', null)
567
+ .fetch();
568
+
569
+ for (const other of existing) {
570
+ const merged = mergeReplacementsIfEqual(other.replacements, recipient.replacements);
571
+ if (merged !== false) {
572
+ console.log('Found mergeable duplicate email recipient', item.email, other.id);
573
+ duplicateOfRecipientId = other.id;
574
+
575
+ other.replacements = merged;
576
+ other.firstName = other.firstName || item.firstName;
577
+ other.lastName = other.lastName || item.lastName;
578
+ await other.save();
579
+
580
+ recipient.replacements = merged;
581
+
582
+ break;
583
+ } else {
584
+ console.log('Could not merge duplicate email recipient', item.email, other.id, 'keeping both', other.replacements, item.replacements);
585
+ }
586
+ }
587
+ }
588
+ recipient.duplicateOfRecipientId = duplicateOfRecipientId;
589
+
590
+ await recipient.save();
591
+
592
+ if (recipient.memberId) {
593
+ membersSet.add(recipient.memberId);
594
+ }
595
+
596
+ if (recipient.email) {
597
+ emailsSet.add(recipient.email);
598
+ }
599
+
600
+ if (!recipient.email || duplicateOfRecipientId) {
601
+ countWithoutEmail += 1;
602
+ } else {
603
+ count += 1;
604
+ }
605
+ }
606
+
607
+ request = response.next ?? null;
608
+ }
609
+ }
610
+
611
+ upToDate.recipientsStatus = EmailRecipientsStatus.Created;
612
+ upToDate.emailRecipientsCount = count;
613
+ upToDate.otherRecipientsCount = countWithoutEmail;
614
+ upToDate.recipientsErrors = null;
615
+ upToDate.membersCount = membersSet.size;
616
+ await upToDate.save();
617
+ } catch (e: unknown) {
618
+ console.error('Failed to build recipients for email', id);
619
+ console.error(e);
620
+ upToDate.recipientsStatus = EmailRecipientsStatus.NotCreated;
621
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
622
+ await upToDate.save();
623
+ }
624
+ });
625
+ }
626
+
627
+ static async buildExampleRecipient(email: Email, isNewlyCreated = false) {
628
+ const id = email.id;
629
+ await QueueHandler.schedule('email-build-recipients-' + email.id, async function () {
630
+ const upToDate = await Email.getByID(id);
631
+
632
+ if (!upToDate || upToDate.sentAt || !upToDate.id) {
633
+ return;
634
+ }
635
+
636
+ if (upToDate.recipientsStatus !== EmailRecipientsStatus.NotCreated) {
637
+ return;
638
+ }
639
+
640
+ try {
641
+ // Delete all recipients
642
+ if (!isNewlyCreated) {
643
+ await SQL
644
+ .delete()
645
+ .from(
646
+ SQL.table('email_recipients'),
647
+ )
648
+ .where(SQL.column('emailId'), upToDate.id);
649
+ }
650
+
651
+ for (const subfilter of upToDate.recipientFilter.filters) {
652
+ // Create recipients
653
+ const loader = Email.recipientLoaders.get(subfilter.type);
654
+
655
+ if (!loader) {
656
+ throw new Error('Loader for type ' + subfilter.type + ' has not been initialised on the Email model');
657
+ }
658
+
659
+ let request: LimitedFilteredRequest | null = new LimitedFilteredRequest({
660
+ filter: subfilter.filter,
661
+ sort: [{ key: 'id', order: SortItemDirection.ASC }],
662
+ limit: 10,
663
+ search: subfilter.search,
664
+ });
665
+
666
+ const beforeFetchAllResult = loader.beforeFetchAll ? await loader.beforeFetchAll(request, subfilter.subfilter) : undefined;
667
+
668
+ while (request) {
669
+ const response = await loader.fetch(request, subfilter.subfilter, beforeFetchAllResult, { allowedLanguages: upToDate.getLanguages() });
670
+
671
+ // Note: it is possible that a result in the database doesn't return a recipient (in memory filtering)
672
+ // so we do need pagination
673
+
674
+ for (const item of response.results) {
675
+ const recipient = new EmailRecipient();
676
+ recipient.emailType = upToDate.emailType;
677
+ recipient.emailId = upToDate.id;
678
+ recipient.objectId = item.objectId;
679
+ recipient.email = item.email;
680
+ recipient.firstName = item.firstName;
681
+ recipient.lastName = item.lastName;
682
+ recipient.language = item.language ?? null;
683
+ recipient.replacements = item.replacements;
684
+ recipient.memberId = item.memberId ?? null;
685
+ recipient.userId = item.userId ?? null;
686
+ recipient.organizationId = upToDate.organizationId ?? null;
687
+ await recipient.save();
688
+
689
+ if (recipient.email || recipient.userId) {
690
+ return;
691
+ }
692
+ }
693
+
694
+ request = null;
695
+ }
696
+ }
697
+
698
+ console.warn('No example recipient found for email', id);
699
+ } catch (e) {
700
+ console.error('Failed to build example recipient for email', id);
701
+ console.error(e);
702
+ }
703
+ });
704
+ }
705
+ }