@stamhoofd/models 2.92.0 → 2.94.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 (56) hide show
  1. package/dist/src/factories/RegistrationPeriodFactory.d.ts +1 -0
  2. package/dist/src/factories/RegistrationPeriodFactory.d.ts.map +1 -1
  3. package/dist/src/factories/RegistrationPeriodFactory.js +4 -0
  4. package/dist/src/factories/RegistrationPeriodFactory.js.map +1 -1
  5. package/dist/src/helpers/EmailBuilder.d.ts +1 -0
  6. package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
  7. package/dist/src/helpers/EmailBuilder.js +5 -3
  8. package/dist/src/helpers/EmailBuilder.js.map +1 -1
  9. package/dist/src/migrations/1755789797-email-counts-errors.sql +7 -0
  10. package/dist/src/migrations/1755789798-email-recipient-errors.sql +2 -0
  11. package/dist/src/migrations/1756115313-email-recipient-ids-and-errors.sql +10 -0
  12. package/dist/src/migrations/1756115314-email-recipient-email-optional.sql +2 -0
  13. package/dist/src/migrations/1756115315-email-recipients-count.sql +3 -0
  14. package/dist/src/migrations/1756115316-email-cached-counts.sql +4 -0
  15. package/dist/src/migrations/1756115317-email-deleted-at.sql +2 -0
  16. package/dist/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
  17. package/dist/src/migrations/1756293495-platform-next-period-id.sql +3 -0
  18. package/dist/src/models/Email.d.ts +62 -2
  19. package/dist/src/models/Email.d.ts.map +1 -1
  20. package/dist/src/models/Email.js +544 -198
  21. package/dist/src/models/Email.js.map +1 -1
  22. package/dist/src/models/Email.test.js +151 -43
  23. package/dist/src/models/Email.test.js.map +1 -1
  24. package/dist/src/models/EmailRecipient.d.ts +31 -1
  25. package/dist/src/models/EmailRecipient.d.ts.map +1 -1
  26. package/dist/src/models/EmailRecipient.js +53 -2
  27. package/dist/src/models/EmailRecipient.js.map +1 -1
  28. package/dist/src/models/Event.d.ts.map +1 -1
  29. package/dist/src/models/Event.js +2 -0
  30. package/dist/src/models/Event.js.map +1 -1
  31. package/dist/src/models/Platform.d.ts +1 -0
  32. package/dist/src/models/Platform.d.ts.map +1 -1
  33. package/dist/src/models/Platform.js +5 -0
  34. package/dist/src/models/Platform.js.map +1 -1
  35. package/dist/src/models/RegistrationPeriod.d.ts +3 -1
  36. package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
  37. package/dist/src/models/RegistrationPeriod.js +28 -7
  38. package/dist/src/models/RegistrationPeriod.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/factories/RegistrationPeriodFactory.ts +4 -0
  41. package/src/helpers/EmailBuilder.ts +6 -3
  42. package/src/migrations/1755789797-email-counts-errors.sql +7 -0
  43. package/src/migrations/1755789798-email-recipient-errors.sql +2 -0
  44. package/src/migrations/1756115313-email-recipient-ids-and-errors.sql +10 -0
  45. package/src/migrations/1756115314-email-recipient-email-optional.sql +2 -0
  46. package/src/migrations/1756115315-email-recipients-count.sql +3 -0
  47. package/src/migrations/1756115316-email-cached-counts.sql +4 -0
  48. package/src/migrations/1756115317-email-deleted-at.sql +2 -0
  49. package/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
  50. package/src/migrations/1756293495-platform-next-period-id.sql +3 -0
  51. package/src/models/Email.test.ts +165 -44
  52. package/src/models/Email.ts +620 -212
  53. package/src/models/EmailRecipient.ts +46 -2
  54. package/src/models/Event.ts +2 -0
  55. package/src/models/Platform.ts +4 -0
  56. package/src/models/RegistrationPeriod.ts +29 -7
@@ -15,6 +15,21 @@ const EmailBuilder_1 = require("../helpers/EmailBuilder");
15
15
  const EmailRecipient_1 = require("./EmailRecipient");
16
16
  const EmailTemplate_1 = require("./EmailTemplate");
17
17
  const Organization_1 = require("./Organization");
18
+ function errorToSimpleErrors(e) {
19
+ if ((0, simple_errors_1.isSimpleErrors)(e)) {
20
+ return e;
21
+ }
22
+ else if ((0, simple_errors_1.isSimpleError)(e)) {
23
+ return new simple_errors_1.SimpleErrors(e);
24
+ }
25
+ else {
26
+ return new simple_errors_1.SimpleErrors(new simple_errors_1.SimpleError({
27
+ code: 'unknown_error',
28
+ message: ((typeof e === 'object' && e !== null && 'message' in e && typeof e.message === 'string') ? e.message : 'Unknown error'),
29
+ human: $t(`Onbekende fout`),
30
+ }));
31
+ }
32
+ }
18
33
  class Email extends sql_1.QueryableModel {
19
34
  static table = 'emails';
20
35
  id;
@@ -34,17 +49,67 @@ class Email extends sql_1.QueryableModel {
34
49
  text = null;
35
50
  fromAddress = null;
36
51
  fromName = null;
37
- recipientCount = null;
52
+ /**
53
+ * Amount of recipients with an email address
54
+ */
55
+ emailRecipientsCount = null;
56
+ /**
57
+ * Amount of recipients without an email address
58
+ */
59
+ otherRecipientsCount = null;
60
+ /**
61
+ * Amount of recipients that have successfully received the email.
62
+ */
63
+ succeededCount = 0;
64
+ /**
65
+ * Amount of recipients that somehow failed to receive the email,
66
+ * but with a soft error that doesn't require action.
67
+ * - Duplicate email in recipient list
68
+ * - Unsubscribed
69
+ */
70
+ softFailedCount = 0;
71
+ /**
72
+ * Amount of recipients that somehow failed to receive the email:
73
+ * - Invalid email address
74
+ * - Full email inbox
75
+ */
76
+ failedCount = 0;
77
+ /**
78
+ * Unique amount of members that are in the recipients list.
79
+ */
80
+ membersCount = 0;
81
+ /**
82
+ * Does only include bounces AFTER sending the email
83
+ */
84
+ hardBouncesCount = 0;
85
+ /**
86
+ * Does only include bounces AFTER sending the email
87
+ */
88
+ softBouncesCount = 0;
89
+ /**
90
+ * Does only include bounces AFTER sending the email
91
+ */
92
+ spamComplaintsCount = 0;
38
93
  status = structures_1.EmailStatus.Draft;
39
94
  recipientsStatus = structures_1.EmailRecipientsStatus.NotCreated;
95
+ /**
96
+ * Errors related to creating the recipients.
97
+ */
98
+ recipientsErrors = null;
99
+ /**
100
+ * Errors related to sending the email.
101
+ */
102
+ emailErrors = null;
40
103
  /**
41
104
  * todo: ignore automatically
42
105
  */
43
106
  attachments = [];
44
107
  sentAt = null;
108
+ deletedAt = null;
45
109
  createdAt;
46
110
  updatedAt;
47
111
  static recipientLoaders = new Map();
112
+ static pendingNotificationCountUpdates = new Map();
48
113
  throwIfNotReadyToSend() {
49
114
  if (this.subject == null || this.subject.length == 0) {
50
115
  throw new simple_errors_1.SimpleError({
@@ -74,6 +139,20 @@ class Email extends sql_1.QueryableModel {
74
139
  human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
75
140
  });
76
141
  }
142
+ if (this.status === structures_1.EmailStatus.Draft && this.recipientsErrors !== null && this.recipientsStatus !== structures_1.EmailRecipientsStatus.Created) {
143
+ throw new simple_errors_1.SimpleError({
144
+ code: 'invalid_recipients',
145
+ message: 'Failed to build recipients (count)',
146
+ human: $t(`Er ging iets mis bij het aanmaken van de ontvangers. Probeer je selectie aan te passen. Neem contact op als het probleem zich blijft voordoen.`) + ' ' + this.recipientsErrors.getHuman(),
147
+ });
148
+ }
149
+ if (this.deletedAt) {
150
+ throw new simple_errors_1.SimpleError({
151
+ code: 'invalid_state',
152
+ message: 'Email is deleted',
153
+ human: $t(`Deze e-mail is verwijderd en kan niet verzonden worden.`),
154
+ });
155
+ }
77
156
  this.validateAttachments();
78
157
  }
79
158
  validateAttachments() {
@@ -178,12 +257,10 @@ class Email extends sql_1.QueryableModel {
178
257
  this.json = defaultTemplate.json;
179
258
  return true;
180
259
  }
181
- async send() {
182
- this.throwIfNotReadyToSend();
183
- await this.save();
260
+ async lock(callback) {
184
261
  const id = this.id;
185
- return await queues_1.QueueHandler.schedule('send-email', async function ({ abort }) {
186
- let upToDate = await Email.getByID(id);
262
+ return await queues_1.QueueHandler.schedule('lock-email-' + id, async (options) => {
263
+ const upToDate = await Email.getByID(id);
187
264
  if (!upToDate) {
188
265
  throw new simple_errors_1.SimpleError({
189
266
  code: 'not_found',
@@ -191,224 +268,425 @@ class Email extends sql_1.QueryableModel {
191
268
  human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
192
269
  });
193
270
  }
194
- if (upToDate.status === structures_1.EmailStatus.Sent || upToDate.status === structures_1.EmailStatus.Failed) {
195
- // Already done
196
- // In other cases -> queue has stopped and we can retry
197
- return upToDate;
271
+ const c = await callback(upToDate, options);
272
+ this.copyFrom(upToDate);
273
+ return c;
274
+ });
275
+ }
276
+ static async bumpNotificationCount(emailId, type) {
277
+ // Send an update query
278
+ const base = Email.update()
279
+ .where('id', emailId);
280
+ switch (type) {
281
+ case 'hard-bounce': {
282
+ base.set('hardBouncesCount', new sql_1.SQLCalculation(sql_1.SQL.column('hardBouncesCount'), new sql_1.SQLPlusSign(), (0, sql_1.readDynamicSQLExpression)(1)));
283
+ break;
198
284
  }
199
- if (upToDate.status === structures_1.EmailStatus.Sending) {
200
- // This is a retry.
201
- if (upToDate.emailType) {
202
- // Not eligible for retry
203
- upToDate.status = structures_1.EmailStatus.Failed;
204
- await upToDate.save();
205
- return upToDate;
206
- }
207
- if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
208
- // Too long
209
- console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
210
- upToDate.status = structures_1.EmailStatus.Failed;
211
- await upToDate.save();
212
- return upToDate;
213
- }
285
+ case 'soft-bounce': {
286
+ base.set('softBouncesCount', new sql_1.SQLCalculation(sql_1.SQL.column('softBouncesCount'), new sql_1.SQLPlusSign(), (0, sql_1.readDynamicSQLExpression)(1)));
287
+ break;
214
288
  }
215
- const organization = upToDate.organizationId ? await Organization_1.Organization.getByID(upToDate.organizationId) : null;
216
- upToDate.throwIfNotReadyToSend();
217
- let from = upToDate.getDefaultFromAddress(organization);
218
- let replyTo = upToDate.getFromAddress();
219
- if (!from) {
220
- throw new simple_errors_1.SimpleError({
221
- code: 'invalid_field',
222
- message: 'Missing from',
223
- human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
224
- });
289
+ case 'complaint': {
290
+ base.set('spamComplaintsCount', new sql_1.SQLCalculation(sql_1.SQL.column('spamComplaintsCount'), new sql_1.SQLPlusSign(), (0, sql_1.readDynamicSQLExpression)(1)));
291
+ break;
225
292
  }
226
- // Can we send from this e-mail or reply-to?
227
- if (upToDate.fromAddress && await (0, EmailBuilder_1.canSendFromEmail)(upToDate.fromAddress, organization ?? null)) {
228
- from = upToDate.getFromAddress();
229
- replyTo = null;
293
+ }
294
+ await base.update();
295
+ await this.checkNeedsNotificationCountUpdate(emailId, true);
296
+ }
297
+ static async checkNeedsNotificationCountUpdate(emailId, didUpdate = false) {
298
+ const existing = this.pendingNotificationCountUpdates.get(emailId);
299
+ const object = existing ?? {
300
+ timer: null,
301
+ lastUpdate: didUpdate ? new Date() : null,
302
+ };
303
+ if (didUpdate) {
304
+ object.lastUpdate = new Date();
305
+ }
306
+ if (existing) {
307
+ this.pendingNotificationCountUpdates.set(emailId, object);
308
+ }
309
+ if (object.lastUpdate && object.lastUpdate < new Date(Date.now() - 5 * 60 * 1000)) {
310
+ // After 5 minutes without notifications, run an update.
311
+ await this.updateNotificationsCounts(emailId);
312
+ // Stop here
313
+ return;
314
+ }
315
+ // Schedule a slow update of all counts
316
+ if (!object.timer) {
317
+ object.timer = setTimeout(() => {
318
+ object.timer = null;
319
+ this.checkNeedsNotificationCountUpdate(emailId).catch(console.error);
320
+ }, 1 * 60 * 1000);
321
+ }
322
+ }
323
+ static async updateNotificationsCounts(emailId) {
324
+ queues_1.QueueHandler.cancel('updateNotificationsCounts-' + emailId);
325
+ return await queues_1.QueueHandler.schedule('updateNotificationsCounts-' + emailId, async () => {
326
+ const query = sql_1.SQL.select(new sql_1.SQLSelectAs(new sql_1.SQLCount(sql_1.SQL.column('hardBounceError')), new sql_1.SQLAlias('data__hardBounces')),
327
+ // If the current amount_due is negative, we can ignore that negative part if there is a future due item
328
+ new sql_1.SQLSelectAs(new sql_1.SQLCount(sql_1.SQL.column('softBounceError')), new sql_1.SQLAlias('data__softBounces')), new sql_1.SQLSelectAs(new sql_1.SQLCount(sql_1.SQL.column('spamComplaintError')), new sql_1.SQLAlias('data__complaints')))
329
+ .from(EmailRecipient_1.EmailRecipient.table)
330
+ .where('emailId', emailId);
331
+ const result = await query.fetch();
332
+ if (result.length !== 1) {
333
+ console.error('Unexpected result', result);
334
+ return;
230
335
  }
231
- abort.throwIfAborted();
232
- upToDate.status = structures_1.EmailStatus.Sending;
233
- upToDate.sentAt = upToDate.sentAt ?? new Date();
234
- await upToDate.save();
235
- // Create recipients if not yet created
236
- await upToDate.buildRecipients();
237
- abort.throwIfAborted();
238
- // Refresh model
239
- upToDate = await Email.getByID(id);
240
- if (!upToDate) {
241
- throw new simple_errors_1.SimpleError({
242
- code: 'not_found',
243
- message: 'Email not found',
244
- human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
245
- });
336
+ const row = result[0]['data'];
337
+ if (!row) {
338
+ console.error('Unexpected result row', result);
339
+ return;
246
340
  }
247
- if (upToDate.recipientsStatus !== structures_1.EmailRecipientsStatus.Created) {
248
- throw new simple_errors_1.SimpleError({
249
- code: 'recipients_not_created',
250
- message: 'Failed to create recipients',
251
- human: $t(`f660b2eb-e382-4d21-86e4-673ca7bc2d4a`),
252
- });
341
+ const hardBounces = row['hardBounces'];
342
+ const softBounces = row['softBounces'];
343
+ const complaints = row['complaints'];
344
+ if (typeof hardBounces !== 'number' || typeof softBounces !== 'number' || typeof complaints !== 'number') {
345
+ console.error('Unexpected result values', row);
346
+ return;
253
347
  }
254
- // Start actually sending in batches of recipients that are not yet sent
255
- let idPointer = '';
256
- const batchSize = 100;
257
- const recipientsSet = new Set();
258
- // Create a buffer of all attachments
259
- const attachments = [];
260
- for (const attachment of upToDate.attachments) {
261
- if (!attachment.content && !attachment.file) {
262
- console.warn('Attachment without content found, skipping', attachment);
263
- continue;
264
- }
265
- let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
266
- if (attachment.contentType === 'application/pdf') {
267
- // tmp solution for pdf only
268
- filename += '.pdf';
269
- }
270
- if (attachment.file?.name) {
271
- filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
348
+ console.log('Updating email notification counts', emailId, hardBounces, softBounces, complaints);
349
+ // Send an update query
350
+ await Email.update()
351
+ .where('id', emailId)
352
+ .set('hardBouncesCount', hardBounces)
353
+ .set('softBouncesCount', softBounces)
354
+ .set('spamComplaintsCount', complaints)
355
+ .update();
356
+ });
357
+ }
358
+ async queueForSending(waitForSending = false) {
359
+ this.throwIfNotReadyToSend();
360
+ await this.lock(async (upToDate) => {
361
+ if (upToDate.status === structures_1.EmailStatus.Draft) {
362
+ upToDate.status = structures_1.EmailStatus.Queued;
363
+ }
364
+ if (upToDate.status === structures_1.EmailStatus.Failed) {
365
+ // Retry failed email
366
+ upToDate.status = structures_1.EmailStatus.Queued;
367
+ }
368
+ await upToDate.save();
369
+ });
370
+ if (waitForSending) {
371
+ return await this.resumeSending();
372
+ }
373
+ else {
374
+ this.resumeSending().catch(console.error);
375
+ }
376
+ return this;
377
+ }
378
+ async resumeSending() {
379
+ const id = this.id;
380
+ return await queues_1.QueueHandler.schedule('send-email', async ({ abort }) => {
381
+ return await this.lock(async function (upToDate) {
382
+ if (upToDate.status === structures_1.EmailStatus.Sent) {
383
+ // Already done
384
+ // In other cases -> queue has stopped and we can retry
385
+ return upToDate;
272
386
  }
273
- // Correct file name if needed
274
- if (attachment.filename) {
275
- filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
387
+ if (upToDate.status === structures_1.EmailStatus.Sending) {
388
+ // This is an automatic retry.
389
+ if (upToDate.emailType) {
390
+ // Not eligible for retry
391
+ upToDate.status = structures_1.EmailStatus.Failed;
392
+ await upToDate.save();
393
+ return upToDate;
394
+ }
395
+ if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
396
+ // Too long
397
+ console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
398
+ upToDate.status = structures_1.EmailStatus.Failed;
399
+ await upToDate.save();
400
+ return upToDate;
401
+ }
276
402
  }
277
- if (attachment.content) {
278
- attachments.push({
279
- filename: filename,
280
- content: attachment.content,
281
- contentType: attachment.contentType ?? undefined,
282
- encoding: 'base64',
283
- });
403
+ else if (upToDate.status !== structures_1.EmailStatus.Queued) {
404
+ console.error('Email is not queued or sending, cannot send', upToDate.id, upToDate.status);
405
+ return upToDate;
284
406
  }
285
- else {
286
- // 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
287
- const withSigned = await attachment.file.withSignedUrl();
288
- if (!withSigned || !withSigned.signedUrl) {
407
+ const organization = upToDate.organizationId ? await Organization_1.Organization.getByID(upToDate.organizationId) : null;
408
+ let from = upToDate.getDefaultFromAddress(organization);
409
+ let replyTo = upToDate.getFromAddress();
410
+ const attachments = [];
411
+ let succeededCount = 0;
412
+ let softFailedCount = 0;
413
+ let failedCount = 0;
414
+ try {
415
+ upToDate.throwIfNotReadyToSend();
416
+ if (!from) {
289
417
  throw new simple_errors_1.SimpleError({
290
- code: 'attachment_not_found',
291
- message: 'Attachment not found',
292
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
418
+ code: 'invalid_field',
419
+ message: 'Missing from',
420
+ human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
293
421
  });
294
422
  }
295
- const filePath = withSigned.signedUrl;
296
- let fileBuffer = null;
297
- try {
298
- const response = await fetch(filePath);
299
- fileBuffer = Buffer.from(await response.arrayBuffer());
423
+ // Can we send from this e-mail or reply-to?
424
+ if (upToDate.fromAddress && await (0, EmailBuilder_1.canSendFromEmail)(upToDate.fromAddress, organization ?? null)) {
425
+ from = upToDate.getFromAddress();
426
+ replyTo = null;
300
427
  }
301
- catch (e) {
428
+ abort.throwIfAborted();
429
+ upToDate.status = structures_1.EmailStatus.Sending;
430
+ upToDate.sentAt = upToDate.sentAt ?? new Date();
431
+ await upToDate.save();
432
+ // Create recipients if not yet created
433
+ await upToDate.buildRecipients();
434
+ abort.throwIfAborted();
435
+ // Refresh model
436
+ const c = (await Email.getByID(id));
437
+ if (!c) {
302
438
  throw new simple_errors_1.SimpleError({
303
- code: 'attachment_not_found',
304
- message: 'Attachment not found',
305
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
439
+ code: 'not_found',
440
+ message: 'Email not found',
441
+ human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
306
442
  });
307
443
  }
308
- attachments.push({
309
- filename: filename,
310
- contentType: attachment.contentType ?? undefined,
311
- content: fileBuffer,
312
- });
313
- }
314
- }
315
- while (true) {
316
- abort.throwIfAborted();
317
- const data = await sql_1.SQL.select()
318
- .from('email_recipients')
319
- .where('emailId', upToDate.id)
320
- .where('sentAt', null)
321
- .where('id', sql_1.SQLWhereSign.Greater, idPointer)
322
- .orderBy(sql_1.SQL.column('id'), 'ASC')
323
- .limit(batchSize)
324
- .fetch();
325
- const recipients = EmailRecipient_1.EmailRecipient.fromRows(data, 'email_recipients');
326
- if (recipients.length === 0) {
327
- break;
328
- }
329
- const sendingPromises = [];
330
- for (const recipient of recipients) {
331
- if (recipientsSet.has(recipient.id)) {
332
- console.error('Found duplicate recipient while sending email', recipient.id);
333
- continue;
444
+ upToDate.copyFrom(c);
445
+ if (upToDate.recipientsStatus !== structures_1.EmailRecipientsStatus.Created) {
446
+ throw new simple_errors_1.SimpleError({
447
+ code: 'recipients_not_created',
448
+ message: 'Failed to create recipients',
449
+ human: $t(`f660b2eb-e382-4d21-86e4-673ca7bc2d4a`),
450
+ });
334
451
  }
335
- recipientsSet.add(recipient.email);
336
- idPointer = recipient.id;
337
- let promiseResolve;
338
- const promise = new Promise((resolve) => {
339
- promiseResolve = resolve;
340
- });
341
- const virtualRecipient = recipient.getRecipient();
342
- let resolved = false;
343
- const callback = async (error) => {
344
- if (resolved) {
452
+ // Create a buffer of all attachments
453
+ for (const attachment of upToDate.attachments) {
454
+ if (!attachment.content && !attachment.file) {
455
+ console.warn('Attachment without content found, skipping', attachment);
456
+ continue;
457
+ }
458
+ let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
459
+ if (attachment.contentType === 'application/pdf') {
460
+ // tmp solution for pdf only
461
+ filename += '.pdf';
462
+ }
463
+ if (attachment.file?.name) {
464
+ filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
465
+ }
466
+ // Correct file name if needed
467
+ if (attachment.filename) {
468
+ filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
469
+ }
470
+ if (attachment.content) {
471
+ attachments.push({
472
+ filename: filename,
473
+ content: attachment.content,
474
+ contentType: attachment.contentType ?? undefined,
475
+ encoding: 'base64',
476
+ });
477
+ }
478
+ else {
479
+ // 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
480
+ const withSigned = await attachment.file.withSignedUrl();
481
+ if (!withSigned || !withSigned.signedUrl) {
482
+ throw new simple_errors_1.SimpleError({
483
+ code: 'attachment_not_found',
484
+ message: 'Attachment not found',
485
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
486
+ });
487
+ }
488
+ const filePath = withSigned.signedUrl;
489
+ let fileBuffer = null;
490
+ try {
491
+ const response = await fetch(filePath);
492
+ fileBuffer = Buffer.from(await response.arrayBuffer());
493
+ }
494
+ catch (e) {
495
+ throw new simple_errors_1.SimpleError({
496
+ code: 'attachment_not_found',
497
+ message: 'Attachment not found',
498
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
499
+ });
500
+ }
501
+ attachments.push({
502
+ filename: filename,
503
+ contentType: attachment.contentType ?? undefined,
504
+ content: fileBuffer,
505
+ });
506
+ }
507
+ }
508
+ // Start actually sending in batches of recipients that are not yet sent
509
+ let idPointer = '';
510
+ const batchSize = 100;
511
+ let isSavingStatus = false;
512
+ let lastStatusSave = new Date();
513
+ async function saveStatus() {
514
+ if (!upToDate) {
515
+ return;
516
+ }
517
+ if (isSavingStatus) {
518
+ return;
519
+ }
520
+ if ((new Date().getTime() - lastStatusSave.getTime()) < 1000 * 5) {
521
+ // Save at most every 5 seconds
345
522
  return;
346
523
  }
347
- resolved = true;
524
+ if (succeededCount < upToDate.succeededCount || softFailedCount < upToDate.softFailedCount || failedCount < upToDate.failedCount) {
525
+ // Do not update on retries
526
+ return;
527
+ }
528
+ lastStatusSave = new Date();
529
+ isSavingStatus = true;
530
+ upToDate.succeededCount = succeededCount;
531
+ upToDate.softFailedCount = softFailedCount;
532
+ upToDate.failedCount = failedCount;
348
533
  try {
349
- if (error === null) {
350
- // Mark saved
351
- recipient.sentAt = new Date();
352
- // Update repacements that have been generated
353
- recipient.replacements = virtualRecipient.replacements;
354
- await recipient.save();
534
+ await upToDate.save();
535
+ }
536
+ finally {
537
+ isSavingStatus = false;
538
+ }
539
+ }
540
+ while (true) {
541
+ abort.throwIfAborted();
542
+ const data = await sql_1.SQL.select()
543
+ .from('email_recipients')
544
+ .where('emailId', upToDate.id)
545
+ .where('id', sql_1.SQLWhereSign.Greater, idPointer)
546
+ .orderBy(sql_1.SQL.column('id'), 'ASC')
547
+ .limit(batchSize)
548
+ .fetch();
549
+ const recipients = EmailRecipient_1.EmailRecipient.fromRows(data, 'email_recipients');
550
+ if (recipients.length === 0) {
551
+ break;
552
+ }
553
+ const sendingPromises = [];
554
+ let skipped = 0;
555
+ for (const recipient of recipients) {
556
+ idPointer = recipient.id;
557
+ if (recipient.sentAt) {
558
+ succeededCount += 1;
559
+ await saveStatus();
560
+ skipped++;
561
+ continue;
355
562
  }
356
- else {
357
- recipient.failCount += 1;
358
- recipient.failErrorMessage = error.message;
359
- recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
360
- recipient.lastFailedAt = new Date();
361
- await recipient.save();
563
+ if (!recipient.email) {
564
+ skipped++;
565
+ continue;
362
566
  }
567
+ let promiseResolve;
568
+ const promise = new Promise((resolve) => {
569
+ promiseResolve = resolve;
570
+ });
571
+ const virtualRecipient = recipient.getRecipient();
572
+ let resolved = false;
573
+ const callback = async (error) => {
574
+ if (resolved) {
575
+ return;
576
+ }
577
+ resolved = true;
578
+ try {
579
+ if (error === null) {
580
+ // Mark saved
581
+ recipient.sentAt = new Date();
582
+ // Update repacements that have been generated
583
+ recipient.replacements = virtualRecipient.replacements;
584
+ succeededCount += 1;
585
+ await recipient.save();
586
+ await saveStatus();
587
+ }
588
+ else {
589
+ recipient.failCount += 1;
590
+ recipient.failErrorMessage = error.message;
591
+ recipient.failError = errorToSimpleErrors(error);
592
+ recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
593
+ recipient.lastFailedAt = new Date();
594
+ if ((0, structures_1.isSoftEmailRecipientError)(recipient.failError)) {
595
+ softFailedCount += 1;
596
+ }
597
+ else {
598
+ failedCount += 1;
599
+ }
600
+ await recipient.save();
601
+ await saveStatus();
602
+ }
603
+ }
604
+ catch (e) {
605
+ console.error(e);
606
+ }
607
+ promiseResolve();
608
+ };
609
+ // Do send the email
610
+ // Create e-mail builder
611
+ const builder = await (0, EmailBuilder_1.getEmailBuilder)(organization ?? null, {
612
+ recipients: [
613
+ virtualRecipient,
614
+ ],
615
+ from,
616
+ replyTo,
617
+ subject: upToDate.subject,
618
+ html: upToDate.html,
619
+ type: upToDate.emailType ? 'transactional' : 'broadcast',
620
+ attachments,
621
+ callback(error) {
622
+ callback(error).catch(console.error);
623
+ },
624
+ headers: {
625
+ 'X-Email-Id': upToDate.id,
626
+ 'X-Email-Recipient-Id': recipient.id,
627
+ },
628
+ });
629
+ abort.throwIfAborted(); // do not schedule if aborted
630
+ email_1.Email.schedule(builder);
631
+ sendingPromises.push(promise);
363
632
  }
364
- catch (e) {
365
- console.error(e);
633
+ if (sendingPromises.length > 0 || skipped > 0) {
634
+ await Promise.all(sendingPromises);
366
635
  }
367
- promiseResolve();
368
- };
369
- // Do send the email
370
- // Create e-mail builder
371
- const builder = await (0, EmailBuilder_1.getEmailBuilder)(organization ?? null, {
372
- recipients: [
373
- virtualRecipient,
374
- ],
375
- from,
376
- replyTo,
377
- subject: upToDate.subject,
378
- html: upToDate.html,
379
- type: upToDate.emailType ? 'transactional' : 'broadcast',
380
- attachments,
381
- callback(error) {
382
- callback(error).catch(console.error);
383
- },
384
- });
385
- abort.throwIfAborted(); // do not schedule if aborted
386
- email_1.Email.schedule(builder);
387
- sendingPromises.push(promise);
636
+ else {
637
+ break;
638
+ }
639
+ }
640
+ }
641
+ catch (e) {
642
+ if (!upToDate) {
643
+ throw e;
644
+ }
645
+ upToDate.succeededCount = succeededCount;
646
+ upToDate.softFailedCount = softFailedCount;
647
+ upToDate.failedCount = failedCount;
648
+ if ((0, queues_1.isAbortedError)(e) || (((0, simple_errors_1.isSimpleError)(e) || (0, simple_errors_1.isSimpleErrors)(e)) && e.hasCode('SHUTDOWN'))) {
649
+ // Keep sending status: we'll resume after the reboot
650
+ await upToDate.save();
651
+ throw e;
652
+ }
653
+ upToDate.emailErrors = errorToSimpleErrors(e);
654
+ upToDate.status = structures_1.EmailStatus.Failed;
655
+ await upToDate.save();
656
+ throw e;
388
657
  }
389
- if (sendingPromises.length > 0) {
390
- await Promise.all(sendingPromises);
658
+ if (upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
659
+ // We only delete automated emails (email type) if they have no recipients
660
+ console.log('No recipients found for email ', upToDate.id, ' deleting...');
661
+ await upToDate.delete();
662
+ return null;
663
+ }
664
+ console.log('Finished sending email', upToDate.id);
665
+ // Mark email as sent
666
+ if ((succeededCount + failedCount + softFailedCount) === 0) {
667
+ upToDate.status = structures_1.EmailStatus.Failed;
668
+ upToDate.emailErrors = new simple_errors_1.SimpleErrors(new simple_errors_1.SimpleError({
669
+ code: 'no_recipients',
670
+ message: 'No recipients',
671
+ human: $t(`Geen ontvangers gevonden`),
672
+ }));
391
673
  }
392
674
  else {
393
- break;
675
+ upToDate.status = structures_1.EmailStatus.Sent;
676
+ upToDate.emailErrors = null;
394
677
  }
395
- }
396
- if (upToDate.recipientCount === 0 && upToDate.userId === null) {
397
- // We only delete automated emails (email type) if they have no recipients
398
- console.log('No recipients found for email ', upToDate.id, ' deleting...');
399
- await upToDate.delete();
400
- return null;
401
- }
402
- console.log('Finished sending email', upToDate.id);
403
- // Mark email as sent
404
- upToDate.status = structures_1.EmailStatus.Sent;
405
- await upToDate.save();
406
- return upToDate;
678
+ upToDate.succeededCount = succeededCount;
679
+ upToDate.softFailedCount = softFailedCount;
680
+ upToDate.failedCount = failedCount;
681
+ await upToDate.save();
682
+ return upToDate;
683
+ });
407
684
  });
408
685
  }
409
686
  updateCount() {
410
687
  const id = this.id;
411
- queues_1.QueueHandler.schedule('email-count-' + this.id, async function () {
688
+ queues_1.QueueHandler.abort('email-count-' + this.id);
689
+ queues_1.QueueHandler.schedule('email-count-' + this.id, async function ({ abort }) {
412
690
  let upToDate = await Email.getByID(id);
413
691
  if (!upToDate || upToDate.sentAt || !upToDate.id || upToDate.status !== structures_1.EmailStatus.Draft) {
414
692
  return;
@@ -430,10 +708,12 @@ class Email extends sql_1.QueryableModel {
430
708
  limit: 1,
431
709
  search: subfilter.search,
432
710
  });
711
+ abort.throwIfAborted();
433
712
  const c = await loader.count(request, subfilter.subfilter);
434
713
  count += c;
435
714
  }
436
- // Check if we have a more reliable recipientCount in the meantime
715
+ abort.throwIfAborted();
716
+ // Check if we have a more reliable emailRecipientsCount in the meantime
437
717
  upToDate = await Email.getByID(id);
438
718
  if (!upToDate) {
439
719
  return;
@@ -441,12 +721,25 @@ class Email extends sql_1.QueryableModel {
441
721
  if (upToDate.recipientsStatus === structures_1.EmailRecipientsStatus.Created) {
442
722
  return;
443
723
  }
444
- upToDate.recipientCount = count;
724
+ upToDate.emailRecipientsCount = count;
445
725
  await upToDate.save();
446
726
  }
447
727
  catch (e) {
728
+ if ((0, queues_1.isAbortedError)(e)) {
729
+ return;
730
+ }
448
731
  console.error('Failed to update count for email', id);
449
732
  console.error(e);
733
+ // Check if we have a more reliable emailRecipientsCount in the meantime
734
+ upToDate = await Email.getByID(id);
735
+ if (!upToDate) {
736
+ return;
737
+ }
738
+ if (upToDate.recipientsStatus === structures_1.EmailRecipientsStatus.Created) {
739
+ return;
740
+ }
741
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
742
+ await upToDate.save();
450
743
  }
451
744
  }).catch(console.error);
452
745
  }
@@ -467,7 +760,9 @@ class Email extends sql_1.QueryableModel {
467
760
  // If it is already creating -> something went wrong (e.g. server restart) and we can safely try again
468
761
  upToDate.recipientsStatus = structures_1.EmailRecipientsStatus.Creating;
469
762
  await upToDate.save();
763
+ const membersSet = new Set();
470
764
  let count = 0;
765
+ let countWithoutEmail = 0;
471
766
  try {
472
767
  // Delete all recipients
473
768
  await sql_1.SQL
@@ -491,10 +786,9 @@ class Email extends sql_1.QueryableModel {
491
786
  abort.throwIfAborted();
492
787
  const response = await loader.fetch(request, subfilter.subfilter);
493
788
  for (const item of response.results) {
494
- if (!item.email) {
789
+ if (!item.email && !item.memberId && !item.userId) {
495
790
  continue;
496
791
  }
497
- count += 1;
498
792
  const recipient = new EmailRecipient_1.EmailRecipient();
499
793
  recipient.emailType = upToDate.emailType;
500
794
  recipient.objectId = item.objectId;
@@ -503,19 +797,35 @@ class Email extends sql_1.QueryableModel {
503
797
  recipient.firstName = item.firstName;
504
798
  recipient.lastName = item.lastName;
505
799
  recipient.replacements = item.replacements;
800
+ recipient.memberId = item.memberId ?? null;
801
+ recipient.userId = item.userId ?? null;
802
+ recipient.organizationId = upToDate.organizationId ?? null;
506
803
  await recipient.save();
804
+ if (recipient.memberId) {
805
+ membersSet.add(recipient.memberId);
806
+ }
807
+ if (!recipient.email) {
808
+ countWithoutEmail += 1;
809
+ }
810
+ else {
811
+ count += 1;
812
+ }
507
813
  }
508
814
  request = response.next ?? null;
509
815
  }
510
816
  }
511
817
  upToDate.recipientsStatus = structures_1.EmailRecipientsStatus.Created;
512
- upToDate.recipientCount = count;
818
+ upToDate.emailRecipientsCount = count;
819
+ upToDate.otherRecipientsCount = countWithoutEmail;
820
+ upToDate.recipientsErrors = null;
821
+ upToDate.membersCount = membersSet.size;
513
822
  await upToDate.save();
514
823
  }
515
824
  catch (e) {
516
825
  console.error('Failed to build recipients for email', id);
517
826
  console.error(e);
518
827
  upToDate.recipientsStatus = structures_1.EmailRecipientsStatus.NotCreated;
828
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
519
829
  await upToDate.save();
520
830
  }
521
831
  });
@@ -643,13 +953,43 @@ tslib_1.__decorate([
643
953
  ], Email.prototype, "fromName", void 0);
644
954
  tslib_1.__decorate([
645
955
  (0, simple_database_1.column)({ type: 'integer', nullable: true })
646
- ], Email.prototype, "recipientCount", void 0);
956
+ ], Email.prototype, "emailRecipientsCount", void 0);
957
+ tslib_1.__decorate([
958
+ (0, simple_database_1.column)({ type: 'integer', nullable: true })
959
+ ], Email.prototype, "otherRecipientsCount", void 0);
960
+ tslib_1.__decorate([
961
+ (0, simple_database_1.column)({ type: 'integer' })
962
+ ], Email.prototype, "succeededCount", void 0);
963
+ tslib_1.__decorate([
964
+ (0, simple_database_1.column)({ type: 'integer' })
965
+ ], Email.prototype, "softFailedCount", void 0);
966
+ tslib_1.__decorate([
967
+ (0, simple_database_1.column)({ type: 'integer' })
968
+ ], Email.prototype, "failedCount", void 0);
969
+ tslib_1.__decorate([
970
+ (0, simple_database_1.column)({ type: 'integer' })
971
+ ], Email.prototype, "membersCount", void 0);
972
+ tslib_1.__decorate([
973
+ (0, simple_database_1.column)({ type: 'integer' })
974
+ ], Email.prototype, "hardBouncesCount", void 0);
975
+ tslib_1.__decorate([
976
+ (0, simple_database_1.column)({ type: 'integer' })
977
+ ], Email.prototype, "softBouncesCount", void 0);
978
+ tslib_1.__decorate([
979
+ (0, simple_database_1.column)({ type: 'integer' })
980
+ ], Email.prototype, "spamComplaintsCount", void 0);
647
981
  tslib_1.__decorate([
648
982
  (0, simple_database_1.column)({ type: 'string' })
649
983
  ], Email.prototype, "status", void 0);
650
984
  tslib_1.__decorate([
651
985
  (0, simple_database_1.column)({ type: 'string' })
652
986
  ], Email.prototype, "recipientsStatus", void 0);
987
+ tslib_1.__decorate([
988
+ (0, simple_database_1.column)({ type: 'json', nullable: true, decoder: simple_errors_1.SimpleErrors })
989
+ ], Email.prototype, "recipientsErrors", void 0);
990
+ tslib_1.__decorate([
991
+ (0, simple_database_1.column)({ type: 'json', nullable: true, decoder: simple_errors_1.SimpleErrors })
992
+ ], Email.prototype, "emailErrors", void 0);
653
993
  tslib_1.__decorate([
654
994
  (0, simple_database_1.column)({ type: 'json', decoder: new simple_encoding_1.ArrayDecoder(structures_1.EmailAttachment) })
655
995
  ], Email.prototype, "attachments", void 0);
@@ -659,6 +999,12 @@ tslib_1.__decorate([
659
999
  nullable: true,
660
1000
  })
661
1001
  ], Email.prototype, "sentAt", void 0);
1002
+ tslib_1.__decorate([
1003
+ (0, simple_database_1.column)({
1004
+ type: 'datetime',
1005
+ nullable: true,
1006
+ })
1007
+ ], Email.prototype, "deletedAt", void 0);
662
1008
  tslib_1.__decorate([
663
1009
  (0, simple_database_1.column)({
664
1010
  type: 'datetime', beforeSave(old) {