@stamhoofd/models 2.91.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 (47) hide show
  1. package/dist/src/factories/RegistrationPeriodFactory.d.ts +2 -1
  2. package/dist/src/factories/RegistrationPeriodFactory.d.ts.map +1 -1
  3. package/dist/src/factories/RegistrationPeriodFactory.js +2 -1
  4. package/dist/src/factories/RegistrationPeriodFactory.js.map +1 -1
  5. package/dist/src/helpers/EmailBuilder.d.ts +1 -2
  6. package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
  7. package/dist/src/helpers/EmailBuilder.js +7 -21
  8. package/dist/src/helpers/EmailBuilder.js.map +1 -1
  9. package/dist/src/helpers/MemberMerger.d.ts.map +1 -1
  10. package/dist/src/helpers/MemberMerger.js +6 -0
  11. package/dist/src/helpers/MemberMerger.js.map +1 -1
  12. package/dist/src/migrations/1755529026-email-sender-id.sql +2 -0
  13. package/dist/src/migrations/1755789797-email-counts-errors.sql +7 -0
  14. package/dist/src/migrations/1755789798-email-recipient-errors.sql +2 -0
  15. package/dist/src/migrations/1756115313-email-recipient-ids-and-errors.sql +10 -0
  16. package/dist/src/migrations/1756115314-email-recipient-email-optional.sql +2 -0
  17. package/dist/src/migrations/1756115315-email-recipients-count.sql +3 -0
  18. package/dist/src/migrations/1756115316-email-cached-counts.sql +4 -0
  19. package/dist/src/models/Email.d.ts +63 -2
  20. package/dist/src/models/Email.d.ts.map +1 -1
  21. package/dist/src/models/Email.js +559 -194
  22. package/dist/src/models/Email.js.map +1 -1
  23. package/dist/src/models/Email.test.js +151 -43
  24. package/dist/src/models/Email.test.js.map +1 -1
  25. package/dist/src/models/EmailRecipient.d.ts +31 -1
  26. package/dist/src/models/EmailRecipient.d.ts.map +1 -1
  27. package/dist/src/models/EmailRecipient.js +53 -2
  28. package/dist/src/models/EmailRecipient.js.map +1 -1
  29. package/dist/src/models/WebshopUitpasNumber.d.ts +2 -1
  30. package/dist/src/models/WebshopUitpasNumber.d.ts.map +1 -1
  31. package/dist/src/models/WebshopUitpasNumber.js +20 -3
  32. package/dist/src/models/WebshopUitpasNumber.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/factories/RegistrationPeriodFactory.ts +3 -2
  35. package/src/helpers/EmailBuilder.ts +9 -24
  36. package/src/helpers/MemberMerger.ts +7 -0
  37. package/src/migrations/1755529026-email-sender-id.sql +2 -0
  38. package/src/migrations/1755789797-email-counts-errors.sql +7 -0
  39. package/src/migrations/1755789798-email-recipient-errors.sql +2 -0
  40. package/src/migrations/1756115313-email-recipient-ids-and-errors.sql +10 -0
  41. package/src/migrations/1756115314-email-recipient-email-optional.sql +2 -0
  42. package/src/migrations/1756115315-email-recipients-count.sql +3 -0
  43. package/src/migrations/1756115316-email-cached-counts.sql +4 -0
  44. package/src/models/Email.test.ts +165 -44
  45. package/src/models/Email.ts +639 -207
  46. package/src/models/EmailRecipient.ts +46 -2
  47. package/src/models/WebshopUitpasNumber.ts +23 -3
@@ -3,16 +3,44 @@ import { EmailAttachment, EmailPreview, EmailRecipientFilter, EmailRecipientFilt
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
 
5
5
  import { AnyDecoder, ArrayDecoder } from '@simonbackx/simple-encoding';
6
- import { SimpleError } from '@simonbackx/simple-errors';
6
+ import { isSimpleError, isSimpleErrors, SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
7
7
  import { I18n } from '@stamhoofd/backend-i18n';
8
8
  import { Email as EmailClass, EmailInterfaceRecipient } from '@stamhoofd/email';
9
- import { QueueHandler } from '@stamhoofd/queues';
10
- import { QueryableModel, SQL, SQLWhereSign } from '@stamhoofd/sql';
9
+ import { isAbortedError, QueueHandler, QueueHandlerOptions } from '@stamhoofd/queues';
10
+ import { QueryableModel, readDynamicSQLExpression, SQL, SQLAlias, SQLCalculation, SQLCount, SQLPlusSign, SQLSelectAs, SQLWhereSign } from '@stamhoofd/sql';
11
11
  import { canSendFromEmail, fillRecipientReplacements, getEmailBuilder } from '../helpers/EmailBuilder';
12
12
  import { EmailRecipient } from './EmailRecipient';
13
13
  import { EmailTemplate } from './EmailTemplate';
14
14
  import { Organization } from './Organization';
15
15
 
16
+ function errorToSimpleErrors(e: unknown) {
17
+ if (isSimpleErrors(e)) {
18
+ return e;
19
+ }
20
+ else if (isSimpleError(e)) {
21
+ return new SimpleErrors(e);
22
+ }
23
+ else {
24
+ return new SimpleErrors(
25
+ new SimpleError({
26
+ code: 'unknown_error',
27
+ message: ((typeof e === 'object' && e !== null && 'message' in e && typeof e.message === 'string') ? e.message : 'Unknown error'),
28
+ human: $t(`Onbekende fout`),
29
+ }),
30
+ );
31
+ }
32
+ }
33
+
34
+ export function isSoftEmailRecipientError(error: SimpleErrors) {
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
+ }
43
+
16
44
  export class Email extends QueryableModel {
17
45
  static table = 'emails';
18
46
 
@@ -26,6 +54,9 @@ export class Email extends QueryableModel {
26
54
  @column({ type: 'string', nullable: true })
27
55
  organizationId: string | null = null;
28
56
 
57
+ @column({ type: 'string', nullable: true })
58
+ senderId: string | null = null;
59
+
29
60
  @column({ type: 'string', nullable: true })
30
61
  userId: string | null = null;
31
62
 
@@ -58,8 +89,64 @@ export class Email extends QueryableModel {
58
89
  @column({ type: 'string', nullable: true })
59
90
  fromName: string | null = null;
60
91
 
92
+ /**
93
+ * Amount of recipients with an email address
94
+ */
61
95
  @column({ type: 'integer', nullable: true })
62
- recipientCount: number | null = null;
96
+ emailRecipientsCount: number | null = null;
97
+
98
+ /**
99
+ * Amount of recipients without an email address
100
+ */
101
+ @column({ type: 'integer', nullable: true })
102
+ otherRecipientsCount: number | null = null;
103
+
104
+ /**
105
+ * Amount of recipients that have successfully received the email.
106
+ */
107
+ @column({ type: 'integer' })
108
+ succeededCount = 0;
109
+
110
+ /**
111
+ * Amount of recipients that somehow failed to receive the email,
112
+ * but with a soft error that doesn't require action.
113
+ * - Duplicate email in recipient list
114
+ * - Unsubscribed
115
+ */
116
+ @column({ type: 'integer' })
117
+ softFailedCount = 0;
118
+
119
+ /**
120
+ * Amount of recipients that somehow failed to receive the email:
121
+ * - Invalid email address
122
+ * - Full email inbox
123
+ */
124
+ @column({ type: 'integer' })
125
+ failedCount = 0;
126
+
127
+ /**
128
+ * Unique amount of members that are in the recipients list.
129
+ */
130
+ @column({ type: 'integer' })
131
+ membersCount = 0;
132
+
133
+ /**
134
+ * Does only include bounces AFTER sending the email
135
+ */
136
+ @column({ type: 'integer' })
137
+ hardBouncesCount = 0;
138
+
139
+ /**
140
+ * Does only include bounces AFTER sending the email
141
+ */
142
+ @column({ type: 'integer' })
143
+ softBouncesCount = 0;
144
+
145
+ /**
146
+ * Does only include bounces AFTER sending the email
147
+ */
148
+ @column({ type: 'integer' })
149
+ spamComplaintsCount = 0;
63
150
 
64
151
  @column({ type: 'string' })
65
152
  status = EmailStatus.Draft;
@@ -67,6 +154,18 @@ export class Email extends QueryableModel {
67
154
  @column({ type: 'string' })
68
155
  recipientsStatus = EmailRecipientsStatus.NotCreated;
69
156
 
157
+ /**
158
+ * Errors related to creating the recipients.
159
+ */
160
+ @column({ type: 'json', nullable: true, decoder: SimpleErrors })
161
+ recipientsErrors: SimpleErrors | null = null;
162
+
163
+ /**
164
+ * Errors related to sending the email.
165
+ */
166
+ @column({ type: 'json', nullable: true, decoder: SimpleErrors })
167
+ emailErrors: SimpleErrors | null = null;
168
+
70
169
  /**
71
170
  * todo: ignore automatically
72
171
  */
@@ -106,6 +205,8 @@ export class Email extends QueryableModel {
106
205
  count(request: LimitedFilteredRequest, subfilter: StamhoofdFilter | null): Promise<number>;
107
206
  }> = new Map();
108
207
 
208
+ static pendingNotificationCountUpdates: Map<string, { timer: NodeJS.Timeout | null; lastUpdate: Date | null }> = new Map();
209
+
109
210
  throwIfNotReadyToSend() {
110
211
  if (this.subject == null || this.subject.length == 0) {
111
212
  throw new SimpleError({
@@ -139,6 +240,14 @@ export class Email extends QueryableModel {
139
240
  });
140
241
  }
141
242
 
243
+ if (this.recipientsErrors !== null && this.recipientsStatus !== EmailRecipientsStatus.Created) {
244
+ throw new SimpleError({
245
+ code: 'invalid_recipients',
246
+ message: 'Failed to build recipients (count)',
247
+ 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(),
248
+ });
249
+ }
250
+
142
251
  this.validateAttachments();
143
252
  }
144
253
 
@@ -261,13 +370,10 @@ export class Email extends QueryableModel {
261
370
  return true;
262
371
  }
263
372
 
264
- async send(): Promise<Email | null> {
265
- this.throwIfNotReadyToSend();
266
- await this.save();
267
-
373
+ async lock<T>(callback: (upToDate: Email, options: QueueHandlerOptions) => Promise<T> | T): Promise<T> {
268
374
  const id = this.id;
269
- return await QueueHandler.schedule('send-email', async function (this: unknown, { abort }) {
270
- let upToDate = await Email.getByID(id);
375
+ return await QueueHandler.schedule('lock-email-' + id, async (options) => {
376
+ const upToDate = await Email.getByID(id);
271
377
  if (!upToDate) {
272
378
  throw new SimpleError({
273
379
  code: 'not_found',
@@ -275,258 +381,546 @@ export class Email extends QueryableModel {
275
381
  human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
276
382
  });
277
383
  }
278
- if (upToDate.status === EmailStatus.Sent || upToDate.status === EmailStatus.Failed) {
279
- // Already done
280
- // In other cases -> queue has stopped and we can retry
281
- return upToDate;
282
- }
384
+ const c = await callback(upToDate, options);
385
+ this.copyFrom(upToDate);
386
+ return c;
387
+ });
388
+ }
283
389
 
284
- if (upToDate.status === EmailStatus.Sending) {
285
- // This is a retry.
286
- if (upToDate.emailType) {
287
- // Not eligible for retry
288
- upToDate.status = EmailStatus.Failed;
289
- await upToDate.save();
290
- return upToDate;
291
- }
292
- if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
293
- // Too long
294
- console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
295
- upToDate.status = EmailStatus.Failed;
296
- await upToDate.save();
297
- return upToDate;
298
- }
390
+ static async bumpNotificationCount(emailId: string, type: 'hard-bounce' | 'soft-bounce' | 'complaint') {
391
+ // Send an update query
392
+ const base = Email.update()
393
+ .where('id', emailId);
394
+
395
+ switch (type) {
396
+ case 'hard-bounce': {
397
+ base.set('hardBouncesCount', new SQLCalculation(
398
+ SQL.column('hardBouncesCount'),
399
+ new SQLPlusSign(),
400
+ readDynamicSQLExpression(1),
401
+ ));
402
+ break;
403
+ }
404
+ case 'soft-bounce': {
405
+ base.set('softBouncesCount', new SQLCalculation(
406
+ SQL.column('softBouncesCount'),
407
+ new SQLPlusSign(),
408
+ readDynamicSQLExpression(1),
409
+ ));
410
+ break;
299
411
  }
300
- const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
301
- upToDate.throwIfNotReadyToSend();
412
+ case 'complaint': {
413
+ base.set('spamComplaintsCount', new SQLCalculation(
414
+ SQL.column('spamComplaintsCount'),
415
+ new SQLPlusSign(),
416
+ readDynamicSQLExpression(1),
417
+ ));
418
+ break;
419
+ }
420
+ }
302
421
 
303
- let from = upToDate.getDefaultFromAddress(organization);
304
- let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
422
+ await base.update();
305
423
 
306
- if (!from) {
307
- throw new SimpleError({
308
- code: 'invalid_field',
309
- message: 'Missing from',
310
- human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
311
- });
312
- }
424
+ await this.checkNeedsNotificationCountUpdate(emailId, true);
425
+ }
313
426
 
314
- // Can we send from this e-mail or reply-to?
315
- if (upToDate.fromAddress && await canSendFromEmail(upToDate.fromAddress, organization ?? null)) {
316
- from = upToDate.getFromAddress();
317
- replyTo = null;
318
- }
427
+ static async checkNeedsNotificationCountUpdate(emailId: string, didUpdate = false) {
428
+ const existing = this.pendingNotificationCountUpdates.get(emailId);
429
+ const object = existing ?? {
430
+ timer: null,
431
+ lastUpdate: didUpdate ? new Date() : null,
432
+ };
319
433
 
320
- abort.throwIfAborted();
321
- upToDate.status = EmailStatus.Sending;
322
- upToDate.sentAt = upToDate.sentAt ?? new Date();
323
- await upToDate.save();
434
+ if (didUpdate) {
435
+ object.lastUpdate = new Date();
436
+ }
324
437
 
325
- // Create recipients if not yet created
326
- await upToDate.buildRecipients();
327
- abort.throwIfAborted();
438
+ if (existing) {
439
+ this.pendingNotificationCountUpdates.set(emailId, object);
440
+ }
328
441
 
329
- // Refresh model
330
- upToDate = await Email.getByID(id);
331
- if (!upToDate) {
332
- throw new SimpleError({
333
- code: 'not_found',
334
- message: 'Email not found',
335
- human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
336
- });
337
- }
442
+ if (object.lastUpdate && object.lastUpdate < new Date(Date.now() - 5 * 60 * 1000)) {
443
+ // After 5 minutes without notifications, run an update.
444
+ await this.updateNotificationsCounts(emailId);
338
445
 
339
- if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
340
- throw new SimpleError({
341
- code: 'recipients_not_created',
342
- message: 'Failed to create recipients',
343
- human: $t(`f660b2eb-e382-4d21-86e4-673ca7bc2d4a`),
344
- });
446
+ // Stop here
447
+ return;
448
+ }
449
+
450
+ // Schedule a slow update of all counts
451
+ if (!object.timer) {
452
+ object.timer = setTimeout(() => {
453
+ object.timer = null;
454
+ this.checkNeedsNotificationCountUpdate(emailId).catch(console.error);
455
+ }, 1 * 60 * 1000);
456
+ }
457
+ }
458
+
459
+ static async updateNotificationsCounts(emailId: string) {
460
+ QueueHandler.cancel('updateNotificationsCounts-' + emailId);
461
+ return await QueueHandler.schedule('updateNotificationsCounts-' + emailId, async () => {
462
+ const query = SQL.select(
463
+ new SQLSelectAs(
464
+ new SQLCount(
465
+ SQL.column('hardBounceError'),
466
+ ),
467
+ new SQLAlias('data__hardBounces'),
468
+ ),
469
+ // If the current amount_due is negative, we can ignore that negative part if there is a future due item
470
+ new SQLSelectAs(
471
+ new SQLCount(
472
+ SQL.column('softBounceError'),
473
+ ),
474
+ new SQLAlias('data__softBounces'),
475
+ ),
476
+
477
+ new SQLSelectAs(
478
+ new SQLCount(
479
+ SQL.column('spamComplaintError'),
480
+ ),
481
+ new SQLAlias('data__complaints'),
482
+ ),
483
+ )
484
+ .from(EmailRecipient.table)
485
+ .where('emailId', emailId);
486
+
487
+ const result = await query.fetch();
488
+ if (result.length !== 1) {
489
+ console.error('Unexpected result', result);
490
+ return;
491
+ }
492
+ const row = result[0]['data'];
493
+ if (!row) {
494
+ console.error('Unexpected result row', result);
495
+ return;
345
496
  }
346
497
 
347
- // Start actually sending in batches of recipients that are not yet sent
348
- let idPointer = '';
349
- const batchSize = 100;
350
- const recipientsSet = new Set<string>();
498
+ const hardBounces = row['hardBounces'];
499
+ const softBounces = row['softBounces'];
500
+ const complaints = row['complaints'];
351
501
 
352
- // Create a buffer of all attachments
353
- const attachments: { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string }[] = [];
502
+ if (typeof hardBounces !== 'number' || typeof softBounces !== 'number' || typeof complaints !== 'number') {
503
+ console.error('Unexpected result values', row);
504
+ return;
505
+ }
354
506
 
355
- for (const attachment of upToDate.attachments) {
356
- if (!attachment.content && !attachment.file) {
357
- console.warn('Attachment without content found, skipping', attachment);
358
- continue;
359
- }
507
+ console.log('Updating email notification counts', emailId, hardBounces, softBounces, complaints);
360
508
 
361
- let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
509
+ // Send an update query
510
+ await Email.update()
511
+ .where('id', emailId)
512
+ .set('hardBouncesCount', hardBounces)
513
+ .set('softBouncesCount', softBounces)
514
+ .set('spamComplaintsCount', complaints)
515
+ .update();
516
+ });
517
+ }
362
518
 
363
- if (attachment.contentType === 'application/pdf') {
364
- // tmp solution for pdf only
365
- filename += '.pdf';
366
- }
519
+ async queueForSending(waitForSending = false) {
520
+ this.throwIfNotReadyToSend();
521
+ await this.lock(async (upToDate) => {
522
+ if (upToDate.status === EmailStatus.Draft) {
523
+ upToDate.status = EmailStatus.Queued;
524
+ }
525
+ await upToDate.save();
526
+ });
527
+ if (waitForSending) {
528
+ return await this.resumeSending();
529
+ }
530
+ else {
531
+ this.resumeSending().catch(console.error);
532
+ }
533
+ return this;
534
+ }
367
535
 
368
- if (attachment.file?.name) {
369
- filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
536
+ async resumeSending(): Promise<Email | null> {
537
+ const id = this.id;
538
+ return await QueueHandler.schedule('send-email', async ({ abort }) => {
539
+ return await this.lock(async function (upToDate: Email) {
540
+ if (upToDate.status === EmailStatus.Sent) {
541
+ // Already done
542
+ // In other cases -> queue has stopped and we can retry
543
+ return upToDate;
370
544
  }
371
545
 
372
- // Correct file name if needed
373
- if (attachment.filename) {
374
- filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
546
+ if (upToDate.status === EmailStatus.Sending) {
547
+ // This is an automatic retry.
548
+ if (upToDate.emailType) {
549
+ // Not eligible for retry
550
+ upToDate.status = EmailStatus.Failed;
551
+ await upToDate.save();
552
+ return upToDate;
553
+ }
554
+ if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
555
+ // Too long
556
+ console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
557
+ upToDate.status = EmailStatus.Failed;
558
+ await upToDate.save();
559
+ return upToDate;
560
+ }
375
561
  }
376
-
377
- if (attachment.content) {
378
- attachments.push({
379
- filename: filename,
380
- content: attachment.content,
381
- contentType: attachment.contentType ?? undefined,
382
- encoding: 'base64',
383
- });
562
+ else if (upToDate.status !== EmailStatus.Queued) {
563
+ console.error('Email is not queued or sending, cannot send', upToDate.id, upToDate.status);
564
+ return upToDate;
384
565
  }
385
- else {
386
- // 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
387
- const withSigned = await attachment.file!.withSignedUrl();
388
- if (!withSigned || !withSigned.signedUrl) {
566
+ const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
567
+ let from = upToDate.getDefaultFromAddress(organization);
568
+ let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
569
+ const attachments: { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string }[] = [];
570
+ let succeededCount = 0;
571
+ let softFailedCount = 0;
572
+ let failedCount = 0;
573
+
574
+ try {
575
+ upToDate.throwIfNotReadyToSend();
576
+
577
+ if (!from) {
389
578
  throw new SimpleError({
390
- code: 'attachment_not_found',
391
- message: 'Attachment not found',
392
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
579
+ code: 'invalid_field',
580
+ message: 'Missing from',
581
+ human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
393
582
  });
394
583
  }
395
584
 
396
- const filePath = withSigned.signedUrl;
397
- let fileBuffer: Buffer | null = null;
398
- try {
399
- const response = await fetch(filePath);
400
- fileBuffer = Buffer.from(await response.arrayBuffer());
585
+ // Can we send from this e-mail or reply-to?
586
+ if (upToDate.fromAddress && await canSendFromEmail(upToDate.fromAddress, organization ?? null)) {
587
+ from = upToDate.getFromAddress();
588
+ replyTo = null;
401
589
  }
402
- catch (e) {
590
+
591
+ abort.throwIfAborted();
592
+ upToDate.status = EmailStatus.Sending;
593
+ upToDate.sentAt = upToDate.sentAt ?? new Date();
594
+ await upToDate.save();
595
+
596
+ // Create recipients if not yet created
597
+ await upToDate.buildRecipients();
598
+ abort.throwIfAborted();
599
+
600
+ // Refresh model
601
+ const c = (await Email.getByID(id))!;
602
+ if (!c) {
403
603
  throw new SimpleError({
404
- code: 'attachment_not_found',
405
- message: 'Attachment not found',
406
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
604
+ code: 'not_found',
605
+ message: 'Email not found',
606
+ human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
407
607
  });
408
608
  }
609
+ upToDate.copyFrom(c);
409
610
 
410
- attachments.push({
411
- filename: filename,
412
- contentType: attachment.contentType ?? undefined,
413
- content: fileBuffer,
414
- });
415
- }
416
- }
611
+ if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
612
+ throw new SimpleError({
613
+ code: 'recipients_not_created',
614
+ message: 'Failed to create recipients',
615
+ human: $t(`f660b2eb-e382-4d21-86e4-673ca7bc2d4a`),
616
+ });
617
+ }
417
618
 
418
- while (true) {
419
- abort.throwIfAborted();
420
- const data = await SQL.select()
421
- .from('email_recipients')
422
- .where('emailId', upToDate.id)
423
- .where('sentAt', null)
424
- .where('id', SQLWhereSign.Greater, idPointer)
425
- .orderBy(SQL.column('id'), 'ASC')
426
- .limit(batchSize)
427
- .fetch();
428
-
429
- const recipients = EmailRecipient.fromRows(data, 'email_recipients');
430
-
431
- if (recipients.length === 0) {
432
- break;
433
- }
619
+ // Create a buffer of all attachments
620
+ for (const attachment of upToDate.attachments) {
621
+ if (!attachment.content && !attachment.file) {
622
+ console.warn('Attachment without content found, skipping', attachment);
623
+ continue;
624
+ }
434
625
 
435
- const sendingPromises: Promise<void>[] = [];
626
+ let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
436
627
 
437
- for (const recipient of recipients) {
438
- if (recipientsSet.has(recipient.id)) {
439
- console.error('Found duplicate recipient while sending email', recipient.id);
440
- continue;
441
- }
628
+ if (attachment.contentType === 'application/pdf') {
629
+ // tmp solution for pdf only
630
+ filename += '.pdf';
631
+ }
632
+
633
+ if (attachment.file?.name) {
634
+ filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
635
+ }
442
636
 
443
- recipientsSet.add(recipient.email);
444
- idPointer = recipient.id;
637
+ // Correct file name if needed
638
+ if (attachment.filename) {
639
+ filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
640
+ }
445
641
 
446
- let promiseResolve: (value: void | PromiseLike<void>) => void;
447
- const promise = new Promise<void>((resolve) => {
448
- promiseResolve = resolve;
449
- });
642
+ if (attachment.content) {
643
+ attachments.push({
644
+ filename: filename,
645
+ content: attachment.content,
646
+ contentType: attachment.contentType ?? undefined,
647
+ encoding: 'base64',
648
+ });
649
+ }
650
+ else {
651
+ // 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
652
+ const withSigned = await attachment.file!.withSignedUrl();
653
+ if (!withSigned || !withSigned.signedUrl) {
654
+ throw new SimpleError({
655
+ code: 'attachment_not_found',
656
+ message: 'Attachment not found',
657
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
658
+ });
659
+ }
660
+
661
+ const filePath = withSigned.signedUrl;
662
+ let fileBuffer: Buffer | null = null;
663
+ try {
664
+ const response = await fetch(filePath);
665
+ fileBuffer = Buffer.from(await response.arrayBuffer());
666
+ }
667
+ catch (e) {
668
+ throw new SimpleError({
669
+ code: 'attachment_not_found',
670
+ message: 'Attachment not found',
671
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
672
+ });
673
+ }
674
+
675
+ attachments.push({
676
+ filename: filename,
677
+ contentType: attachment.contentType ?? undefined,
678
+ content: fileBuffer,
679
+ });
680
+ }
681
+ }
450
682
 
451
- const virtualRecipient = recipient.getRecipient();
683
+ // Start actually sending in batches of recipients that are not yet sent
684
+ let idPointer = '';
685
+ const batchSize = 100;
686
+ const recipientsSet = new Set<string>();
687
+ let isSavingStatus = false;
688
+ let lastStatusSave = new Date();
452
689
 
453
- let resolved = false;
454
- const callback = async (error: Error | null) => {
455
- if (resolved) {
690
+ async function saveStatus() {
691
+ if (!upToDate) {
456
692
  return;
457
693
  }
458
- resolved = true;
694
+ if (isSavingStatus) {
695
+ return;
696
+ }
697
+ if ((new Date().getTime() - lastStatusSave.getTime()) < 1000 * 5) {
698
+ // Save at most every 5 seconds
699
+ return;
700
+ }
701
+ if (succeededCount < upToDate.succeededCount || softFailedCount < upToDate.softFailedCount || failedCount < upToDate.failedCount) {
702
+ // Do not update on retries
703
+ return;
704
+ }
705
+
706
+ lastStatusSave = new Date();
707
+ isSavingStatus = true;
708
+ upToDate.succeededCount = succeededCount;
709
+ upToDate.softFailedCount = softFailedCount;
710
+ upToDate.failedCount = failedCount;
459
711
 
460
712
  try {
461
- if (error === null) {
462
- // Mark saved
463
- recipient.sentAt = new Date();
713
+ await upToDate.save();
714
+ }
715
+ finally {
716
+ isSavingStatus = false;
717
+ }
718
+ }
464
719
 
465
- // Update repacements that have been generated
466
- recipient.replacements = virtualRecipient.replacements;
467
- await recipient.save();
720
+ while (true) {
721
+ abort.throwIfAborted();
722
+ const data = await SQL.select()
723
+ .from('email_recipients')
724
+ .where('emailId', upToDate.id)
725
+ .where('id', SQLWhereSign.Greater, idPointer)
726
+ .orderBy(SQL.column('id'), 'ASC')
727
+ .limit(batchSize)
728
+ .fetch();
729
+
730
+ const recipients = EmailRecipient.fromRows(data, 'email_recipients');
731
+
732
+ if (recipients.length === 0) {
733
+ break;
734
+ }
735
+
736
+ const sendingPromises: Promise<void>[] = [];
737
+ let skipped = 0;
738
+
739
+ for (const recipient of recipients) {
740
+ idPointer = recipient.id;
741
+
742
+ if (recipient.sentAt) {
743
+ // Already sent
744
+ if (recipient.email) {
745
+ recipientsSet.add(recipient.email);
746
+ }
747
+ succeededCount += 1;
748
+ await saveStatus();
749
+ skipped++;
750
+ continue;
468
751
  }
469
- else {
752
+
753
+ if (!recipient.email) {
754
+ skipped++;
755
+ continue;
756
+ }
757
+
758
+ if (recipientsSet.has(recipient.id)) {
759
+ console.error('Found duplicate recipient while sending email', recipient.id);
760
+ softFailedCount += 1;
761
+
470
762
  recipient.failCount += 1;
471
- recipient.failErrorMessage = error.message;
763
+ recipient.failErrorMessage = 'Duplicate recipient';
764
+ recipient.failError = new SimpleErrors(
765
+ new SimpleError({
766
+ code: 'email_skipped_duplicate_recipient',
767
+ message: 'Duplicate recipient',
768
+ human: $t('Dit e-mailadres staat meerdere keren tussen de ontvangers en werd daarom overgeslagen'),
769
+ }),
770
+ );
771
+
472
772
  recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
473
773
  recipient.lastFailedAt = new Date();
474
774
  await recipient.save();
775
+
776
+ await saveStatus();
777
+ skipped++;
778
+ continue;
475
779
  }
780
+ recipientsSet.add(recipient.email);
781
+
782
+ let promiseResolve: (value: void | PromiseLike<void>) => void;
783
+ const promise = new Promise<void>((resolve) => {
784
+ promiseResolve = resolve;
785
+ });
786
+
787
+ const virtualRecipient = recipient.getRecipient();
788
+
789
+ let resolved = false;
790
+ const callback = async (error: Error | null) => {
791
+ if (resolved) {
792
+ return;
793
+ }
794
+ resolved = true;
795
+
796
+ try {
797
+ if (error === null) {
798
+ // Mark saved
799
+ recipient.sentAt = new Date();
800
+
801
+ // Update repacements that have been generated
802
+ recipient.replacements = virtualRecipient.replacements;
803
+
804
+ succeededCount += 1;
805
+ await recipient.save();
806
+ await saveStatus();
807
+ }
808
+ else {
809
+ recipient.failCount += 1;
810
+ recipient.failErrorMessage = error.message;
811
+ recipient.failError = errorToSimpleErrors(error);
812
+ recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
813
+ recipient.lastFailedAt = new Date();
814
+
815
+ if (isSoftEmailRecipientError(recipient.failError)) {
816
+ softFailedCount += 1;
817
+ }
818
+ else {
819
+ failedCount += 1;
820
+ }
821
+ await recipient.save();
822
+ await saveStatus();
823
+ }
824
+ }
825
+ catch (e) {
826
+ console.error(e);
827
+ }
828
+ promiseResolve();
829
+ };
830
+
831
+ // Do send the email
832
+ // Create e-mail builder
833
+ const builder = await getEmailBuilder(organization ?? null, {
834
+ recipients: [
835
+ virtualRecipient,
836
+ ],
837
+ from,
838
+ replyTo,
839
+ subject: upToDate.subject!,
840
+ html: upToDate.html!,
841
+ type: upToDate.emailType ? 'transactional' : 'broadcast',
842
+ attachments,
843
+ callback(error: Error | null) {
844
+ callback(error).catch(console.error);
845
+ },
846
+ headers: {
847
+ 'X-Email-Id': upToDate.id,
848
+ 'X-Email-Recipient-Id': recipient.id,
849
+ },
850
+ });
851
+ abort.throwIfAborted(); // do not schedule if aborted
852
+ EmailClass.schedule(builder);
853
+ sendingPromises.push(promise);
476
854
  }
477
- catch (e) {
478
- console.error(e);
855
+
856
+ if (sendingPromises.length > 0 || skipped > 0) {
857
+ await Promise.all(sendingPromises);
479
858
  }
480
- promiseResolve();
481
- };
482
-
483
- // Do send the email
484
- // Create e-mail builder
485
- const builder = await getEmailBuilder(organization ?? null, {
486
- recipients: [
487
- virtualRecipient,
488
- ],
489
- from,
490
- replyTo,
491
- subject: upToDate.subject!,
492
- html: upToDate.html!,
493
- type: upToDate.emailType ? 'transactional' : 'broadcast',
494
- attachments,
495
- callback(error: Error | null) {
496
- callback(error).catch(console.error);
497
- },
498
- });
499
- abort.throwIfAborted(); // do not schedule if aborted
500
- EmailClass.schedule(builder);
501
- sendingPromises.push(promise);
859
+ else {
860
+ break;
861
+ }
862
+ }
502
863
  }
864
+ catch (e) {
865
+ if (!upToDate) {
866
+ throw e;
867
+ }
868
+
869
+ upToDate.succeededCount = succeededCount;
870
+ upToDate.softFailedCount = softFailedCount;
871
+ upToDate.failedCount = failedCount;
872
+
873
+ if (isAbortedError(e) || ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('SHUTDOWN'))) {
874
+ // Keep sending status: we'll resume after the reboot
875
+ await upToDate.save();
876
+ throw e;
877
+ }
503
878
 
504
- if (sendingPromises.length > 0) {
505
- await Promise.all(sendingPromises);
879
+ upToDate.emailErrors = errorToSimpleErrors(e);
880
+ upToDate.status = EmailStatus.Failed;
881
+ await upToDate.save();
882
+ throw e;
883
+ }
884
+
885
+ if (upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
886
+ // We only delete automated emails (email type) if they have no recipients
887
+ console.log('No recipients found for email ', upToDate.id, ' deleting...');
888
+ await upToDate.delete();
889
+ return null;
890
+ }
891
+
892
+ console.log('Finished sending email', upToDate.id);
893
+ // Mark email as sent
894
+
895
+ if ((succeededCount + failedCount + softFailedCount) === 0) {
896
+ upToDate.status = EmailStatus.Failed;
897
+ upToDate.emailErrors = new SimpleErrors(
898
+ new SimpleError({
899
+ code: 'no_recipients',
900
+ message: 'No recipients',
901
+ human: $t(`Geen ontvangers gevonden`),
902
+ }),
903
+ );
506
904
  }
507
905
  else {
508
- break;
906
+ upToDate.status = EmailStatus.Sent;
907
+ upToDate.emailErrors = null;
509
908
  }
510
- }
511
909
 
512
- if (upToDate.recipientCount === 0 && upToDate.userId === null) {
513
- // We only delete automated emails (email type) if they have no recipients
514
- console.log('No recipients found for email ', upToDate.id, ' deleting...');
515
- await upToDate.delete();
516
- return null;
517
- }
910
+ upToDate.succeededCount = succeededCount;
911
+ upToDate.softFailedCount = softFailedCount;
912
+ upToDate.failedCount = failedCount;
518
913
 
519
- console.log('Finished sending email', upToDate.id);
520
- // Mark email as sent
521
- upToDate.status = EmailStatus.Sent;
522
- await upToDate.save();
523
- return upToDate;
914
+ await upToDate.save();
915
+ return upToDate;
916
+ });
524
917
  });
525
918
  }
526
919
 
527
920
  updateCount() {
528
921
  const id = this.id;
529
- QueueHandler.schedule('email-count-' + this.id, async function () {
922
+ QueueHandler.abort('email-count-' + this.id);
923
+ QueueHandler.schedule('email-count-' + this.id, async function ({ abort }) {
530
924
  let upToDate = await Email.getByID(id);
531
925
 
532
926
  if (!upToDate || upToDate.sentAt || !upToDate.id || upToDate.status !== EmailStatus.Draft) {
@@ -555,12 +949,14 @@ export class Email extends QueryableModel {
555
949
  search: subfilter.search,
556
950
  });
557
951
 
952
+ abort.throwIfAborted();
558
953
  const c = await loader.count(request, subfilter.subfilter);
559
954
 
560
955
  count += c;
561
956
  }
957
+ abort.throwIfAborted();
562
958
 
563
- // Check if we have a more reliable recipientCount in the meantime
959
+ // Check if we have a more reliable emailRecipientsCount in the meantime
564
960
  upToDate = await Email.getByID(id);
565
961
 
566
962
  if (!upToDate) {
@@ -569,12 +965,27 @@ export class Email extends QueryableModel {
569
965
  if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
570
966
  return;
571
967
  }
572
- upToDate.recipientCount = count;
968
+ upToDate.emailRecipientsCount = count;
573
969
  await upToDate.save();
574
970
  }
575
971
  catch (e) {
972
+ if (isAbortedError(e)) {
973
+ return;
974
+ }
576
975
  console.error('Failed to update count for email', id);
577
976
  console.error(e);
977
+
978
+ // Check if we have a more reliable emailRecipientsCount in the meantime
979
+ upToDate = await Email.getByID(id);
980
+
981
+ if (!upToDate) {
982
+ return;
983
+ }
984
+ if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
985
+ return;
986
+ }
987
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
988
+ await upToDate.save();
578
989
  }
579
990
  }).catch(console.error);
580
991
  }
@@ -603,7 +1014,10 @@ export class Email extends QueryableModel {
603
1014
  upToDate.recipientsStatus = EmailRecipientsStatus.Creating;
604
1015
  await upToDate.save();
605
1016
 
1017
+ const membersSet = new Set<string>();
1018
+
606
1019
  let count = 0;
1020
+ let countWithoutEmail = 0;
607
1021
 
608
1022
  try {
609
1023
  // Delete all recipients
@@ -636,10 +1050,10 @@ export class Email extends QueryableModel {
636
1050
  const response = await loader.fetch(request, subfilter.subfilter);
637
1051
 
638
1052
  for (const item of response.results) {
639
- if (!item.email) {
1053
+ if (!item.email && !item.memberId && !item.userId) {
640
1054
  continue;
641
1055
  }
642
- count += 1;
1056
+
643
1057
  const recipient = new EmailRecipient();
644
1058
  recipient.emailType = upToDate.emailType;
645
1059
  recipient.objectId = item.objectId;
@@ -648,8 +1062,22 @@ export class Email extends QueryableModel {
648
1062
  recipient.firstName = item.firstName;
649
1063
  recipient.lastName = item.lastName;
650
1064
  recipient.replacements = item.replacements;
1065
+ recipient.memberId = item.memberId ?? null;
1066
+ recipient.userId = item.userId ?? null;
1067
+ recipient.organizationId = upToDate.organizationId ?? null;
651
1068
 
652
1069
  await recipient.save();
1070
+
1071
+ if (recipient.memberId) {
1072
+ membersSet.add(recipient.memberId);
1073
+ }
1074
+
1075
+ if (!recipient.email) {
1076
+ countWithoutEmail += 1;
1077
+ }
1078
+ else {
1079
+ count += 1;
1080
+ }
653
1081
  }
654
1082
 
655
1083
  request = response.next ?? null;
@@ -657,13 +1085,17 @@ export class Email extends QueryableModel {
657
1085
  }
658
1086
 
659
1087
  upToDate.recipientsStatus = EmailRecipientsStatus.Created;
660
- upToDate.recipientCount = count;
1088
+ upToDate.emailRecipientsCount = count;
1089
+ upToDate.otherRecipientsCount = countWithoutEmail;
1090
+ upToDate.recipientsErrors = null;
1091
+ upToDate.membersCount = membersSet.size;
661
1092
  await upToDate.save();
662
1093
  }
663
- catch (e) {
1094
+ catch (e: unknown) {
664
1095
  console.error('Failed to build recipients for email', id);
665
1096
  console.error(e);
666
1097
  upToDate.recipientsStatus = EmailRecipientsStatus.NotCreated;
1098
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
667
1099
  await upToDate.save();
668
1100
  }
669
1101
  });