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