@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
@@ -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
 
@@ -61,8 +89,64 @@ export class Email extends QueryableModel {
61
89
  @column({ type: 'string', nullable: true })
62
90
  fromName: string | null = null;
63
91
 
92
+ /**
93
+ * Amount of recipients with an email address
94
+ */
64
95
  @column({ type: 'integer', nullable: true })
65
- 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;
66
150
 
67
151
  @column({ type: 'string' })
68
152
  status = EmailStatus.Draft;
@@ -70,6 +154,18 @@ export class Email extends QueryableModel {
70
154
  @column({ type: 'string' })
71
155
  recipientsStatus = EmailRecipientsStatus.NotCreated;
72
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
+
73
169
  /**
74
170
  * todo: ignore automatically
75
171
  */
@@ -109,6 +205,8 @@ export class Email extends QueryableModel {
109
205
  count(request: LimitedFilteredRequest, subfilter: StamhoofdFilter | null): Promise<number>;
110
206
  }> = new Map();
111
207
 
208
+ static pendingNotificationCountUpdates: Map<string, { timer: NodeJS.Timeout | null; lastUpdate: Date | null }> = new Map();
209
+
112
210
  throwIfNotReadyToSend() {
113
211
  if (this.subject == null || this.subject.length == 0) {
114
212
  throw new SimpleError({
@@ -142,6 +240,14 @@ export class Email extends QueryableModel {
142
240
  });
143
241
  }
144
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
+
145
251
  this.validateAttachments();
146
252
  }
147
253
 
@@ -264,13 +370,10 @@ export class Email extends QueryableModel {
264
370
  return true;
265
371
  }
266
372
 
267
- async send(): Promise<Email | null> {
268
- this.throwIfNotReadyToSend();
269
- await this.save();
270
-
373
+ async lock<T>(callback: (upToDate: Email, options: QueueHandlerOptions) => Promise<T> | T): Promise<T> {
271
374
  const id = this.id;
272
- return await QueueHandler.schedule('send-email', async function (this: unknown, { abort }) {
273
- let upToDate = await Email.getByID(id);
375
+ return await QueueHandler.schedule('lock-email-' + id, async (options) => {
376
+ const upToDate = await Email.getByID(id);
274
377
  if (!upToDate) {
275
378
  throw new SimpleError({
276
379
  code: 'not_found',
@@ -278,258 +381,546 @@ export class Email extends QueryableModel {
278
381
  human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
279
382
  });
280
383
  }
281
- if (upToDate.status === EmailStatus.Sent || upToDate.status === EmailStatus.Failed) {
282
- // Already done
283
- // In other cases -> queue has stopped and we can retry
284
- return upToDate;
285
- }
384
+ const c = await callback(upToDate, options);
385
+ this.copyFrom(upToDate);
386
+ return c;
387
+ });
388
+ }
286
389
 
287
- if (upToDate.status === EmailStatus.Sending) {
288
- // This is a retry.
289
- if (upToDate.emailType) {
290
- // Not eligible for retry
291
- upToDate.status = EmailStatus.Failed;
292
- await upToDate.save();
293
- return upToDate;
294
- }
295
- if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
296
- // Too long
297
- console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
298
- upToDate.status = EmailStatus.Failed;
299
- await upToDate.save();
300
- return upToDate;
301
- }
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;
302
403
  }
303
- const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
304
- upToDate.throwIfNotReadyToSend();
404
+ case 'soft-bounce': {
405
+ base.set('softBouncesCount', new SQLCalculation(
406
+ SQL.column('softBouncesCount'),
407
+ new SQLPlusSign(),
408
+ readDynamicSQLExpression(1),
409
+ ));
410
+ break;
411
+ }
412
+ case 'complaint': {
413
+ base.set('spamComplaintsCount', new SQLCalculation(
414
+ SQL.column('spamComplaintsCount'),
415
+ new SQLPlusSign(),
416
+ readDynamicSQLExpression(1),
417
+ ));
418
+ break;
419
+ }
420
+ }
305
421
 
306
- let from = upToDate.getDefaultFromAddress(organization);
307
- let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
422
+ await base.update();
308
423
 
309
- if (!from) {
310
- throw new SimpleError({
311
- code: 'invalid_field',
312
- message: 'Missing from',
313
- human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
314
- });
315
- }
424
+ await this.checkNeedsNotificationCountUpdate(emailId, true);
425
+ }
316
426
 
317
- // Can we send from this e-mail or reply-to?
318
- if (upToDate.fromAddress && await canSendFromEmail(upToDate.fromAddress, organization ?? null)) {
319
- from = upToDate.getFromAddress();
320
- replyTo = null;
321
- }
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
+ };
322
433
 
323
- abort.throwIfAborted();
324
- upToDate.status = EmailStatus.Sending;
325
- upToDate.sentAt = upToDate.sentAt ?? new Date();
326
- await upToDate.save();
434
+ if (didUpdate) {
435
+ object.lastUpdate = new Date();
436
+ }
327
437
 
328
- // Create recipients if not yet created
329
- await upToDate.buildRecipients();
330
- abort.throwIfAborted();
438
+ if (existing) {
439
+ this.pendingNotificationCountUpdates.set(emailId, object);
440
+ }
331
441
 
332
- // Refresh model
333
- upToDate = await Email.getByID(id);
334
- if (!upToDate) {
335
- throw new SimpleError({
336
- code: 'not_found',
337
- message: 'Email not found',
338
- human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
339
- });
340
- }
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);
341
445
 
342
- if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
343
- throw new SimpleError({
344
- code: 'recipients_not_created',
345
- message: 'Failed to create recipients',
346
- human: $t(`f660b2eb-e382-4d21-86e4-673ca7bc2d4a`),
347
- });
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;
348
496
  }
349
497
 
350
- // Start actually sending in batches of recipients that are not yet sent
351
- let idPointer = '';
352
- const batchSize = 100;
353
- const recipientsSet = new Set<string>();
498
+ const hardBounces = row['hardBounces'];
499
+ const softBounces = row['softBounces'];
500
+ const complaints = row['complaints'];
354
501
 
355
- // Create a buffer of all attachments
356
- 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
+ }
357
506
 
358
- for (const attachment of upToDate.attachments) {
359
- if (!attachment.content && !attachment.file) {
360
- console.warn('Attachment without content found, skipping', attachment);
361
- continue;
362
- }
507
+ console.log('Updating email notification counts', emailId, hardBounces, softBounces, complaints);
363
508
 
364
- 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
+ }
365
518
 
366
- if (attachment.contentType === 'application/pdf') {
367
- // tmp solution for pdf only
368
- filename += '.pdf';
369
- }
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
+ }
370
535
 
371
- if (attachment.file?.name) {
372
- 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;
373
544
  }
374
545
 
375
- // Correct file name if needed
376
- if (attachment.filename) {
377
- 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
+ }
378
561
  }
379
-
380
- if (attachment.content) {
381
- attachments.push({
382
- filename: filename,
383
- content: attachment.content,
384
- contentType: attachment.contentType ?? undefined,
385
- encoding: 'base64',
386
- });
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;
387
565
  }
388
- else {
389
- // 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
390
- const withSigned = await attachment.file!.withSignedUrl();
391
- 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) {
392
578
  throw new SimpleError({
393
- code: 'attachment_not_found',
394
- message: 'Attachment not found',
395
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
579
+ code: 'invalid_field',
580
+ message: 'Missing from',
581
+ human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
396
582
  });
397
583
  }
398
584
 
399
- const filePath = withSigned.signedUrl;
400
- let fileBuffer: Buffer | null = null;
401
- try {
402
- const response = await fetch(filePath);
403
- 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;
404
589
  }
405
- 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) {
406
603
  throw new SimpleError({
407
- code: 'attachment_not_found',
408
- message: 'Attachment not found',
409
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
604
+ code: 'not_found',
605
+ message: 'Email not found',
606
+ human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
410
607
  });
411
608
  }
609
+ upToDate.copyFrom(c);
412
610
 
413
- attachments.push({
414
- filename: filename,
415
- contentType: attachment.contentType ?? undefined,
416
- content: fileBuffer,
417
- });
418
- }
419
- }
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
+ }
420
618
 
421
- while (true) {
422
- abort.throwIfAborted();
423
- const data = await SQL.select()
424
- .from('email_recipients')
425
- .where('emailId', upToDate.id)
426
- .where('sentAt', null)
427
- .where('id', SQLWhereSign.Greater, idPointer)
428
- .orderBy(SQL.column('id'), 'ASC')
429
- .limit(batchSize)
430
- .fetch();
431
-
432
- const recipients = EmailRecipient.fromRows(data, 'email_recipients');
433
-
434
- if (recipients.length === 0) {
435
- break;
436
- }
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
+ }
437
625
 
438
- const sendingPromises: Promise<void>[] = [];
626
+ let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
439
627
 
440
- for (const recipient of recipients) {
441
- if (recipientsSet.has(recipient.id)) {
442
- console.error('Found duplicate recipient while sending email', recipient.id);
443
- continue;
444
- }
628
+ if (attachment.contentType === 'application/pdf') {
629
+ // tmp solution for pdf only
630
+ filename += '.pdf';
631
+ }
445
632
 
446
- recipientsSet.add(recipient.email);
447
- idPointer = recipient.id;
633
+ if (attachment.file?.name) {
634
+ filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
635
+ }
448
636
 
449
- let promiseResolve: (value: void | PromiseLike<void>) => void;
450
- const promise = new Promise<void>((resolve) => {
451
- promiseResolve = resolve;
452
- });
637
+ // Correct file name if needed
638
+ if (attachment.filename) {
639
+ filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
640
+ }
641
+
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
+ }
453
674
 
454
- const virtualRecipient = recipient.getRecipient();
675
+ attachments.push({
676
+ filename: filename,
677
+ contentType: attachment.contentType ?? undefined,
678
+ content: fileBuffer,
679
+ });
680
+ }
681
+ }
455
682
 
456
- let resolved = false;
457
- const callback = async (error: Error | null) => {
458
- if (resolved) {
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();
689
+
690
+ async function saveStatus() {
691
+ if (!upToDate) {
692
+ return;
693
+ }
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
459
703
  return;
460
704
  }
461
- resolved = true;
705
+
706
+ lastStatusSave = new Date();
707
+ isSavingStatus = true;
708
+ upToDate.succeededCount = succeededCount;
709
+ upToDate.softFailedCount = softFailedCount;
710
+ upToDate.failedCount = failedCount;
462
711
 
463
712
  try {
464
- if (error === null) {
465
- // Mark saved
466
- recipient.sentAt = new Date();
713
+ await upToDate.save();
714
+ }
715
+ finally {
716
+ isSavingStatus = false;
717
+ }
718
+ }
467
719
 
468
- // Update repacements that have been generated
469
- recipient.replacements = virtualRecipient.replacements;
470
- 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;
471
751
  }
472
- 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
+
473
762
  recipient.failCount += 1;
474
- 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
+
475
772
  recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
476
773
  recipient.lastFailedAt = new Date();
477
774
  await recipient.save();
775
+
776
+ await saveStatus();
777
+ skipped++;
778
+ continue;
478
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);
479
854
  }
480
- catch (e) {
481
- console.error(e);
855
+
856
+ if (sendingPromises.length > 0 || skipped > 0) {
857
+ await Promise.all(sendingPromises);
482
858
  }
483
- promiseResolve();
484
- };
485
-
486
- // Do send the email
487
- // Create e-mail builder
488
- const builder = await getEmailBuilder(organization ?? null, {
489
- recipients: [
490
- virtualRecipient,
491
- ],
492
- from,
493
- replyTo,
494
- subject: upToDate.subject!,
495
- html: upToDate.html!,
496
- type: upToDate.emailType ? 'transactional' : 'broadcast',
497
- attachments,
498
- callback(error: Error | null) {
499
- callback(error).catch(console.error);
500
- },
501
- });
502
- abort.throwIfAborted(); // do not schedule if aborted
503
- EmailClass.schedule(builder);
504
- sendingPromises.push(promise);
859
+ else {
860
+ break;
861
+ }
862
+ }
505
863
  }
864
+ catch (e) {
865
+ if (!upToDate) {
866
+ throw e;
867
+ }
506
868
 
507
- if (sendingPromises.length > 0) {
508
- await Promise.all(sendingPromises);
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
+ }
878
+
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
+ );
509
904
  }
510
905
  else {
511
- break;
906
+ upToDate.status = EmailStatus.Sent;
907
+ upToDate.emailErrors = null;
512
908
  }
513
- }
514
909
 
515
- if (upToDate.recipientCount === 0 && upToDate.userId === null) {
516
- // We only delete automated emails (email type) if they have no recipients
517
- console.log('No recipients found for email ', upToDate.id, ' deleting...');
518
- await upToDate.delete();
519
- return null;
520
- }
910
+ upToDate.succeededCount = succeededCount;
911
+ upToDate.softFailedCount = softFailedCount;
912
+ upToDate.failedCount = failedCount;
521
913
 
522
- console.log('Finished sending email', upToDate.id);
523
- // Mark email as sent
524
- upToDate.status = EmailStatus.Sent;
525
- await upToDate.save();
526
- return upToDate;
914
+ await upToDate.save();
915
+ return upToDate;
916
+ });
527
917
  });
528
918
  }
529
919
 
530
920
  updateCount() {
531
921
  const id = this.id;
532
- 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 }) {
533
924
  let upToDate = await Email.getByID(id);
534
925
 
535
926
  if (!upToDate || upToDate.sentAt || !upToDate.id || upToDate.status !== EmailStatus.Draft) {
@@ -558,12 +949,14 @@ export class Email extends QueryableModel {
558
949
  search: subfilter.search,
559
950
  });
560
951
 
952
+ abort.throwIfAborted();
561
953
  const c = await loader.count(request, subfilter.subfilter);
562
954
 
563
955
  count += c;
564
956
  }
957
+ abort.throwIfAborted();
565
958
 
566
- // Check if we have a more reliable recipientCount in the meantime
959
+ // Check if we have a more reliable emailRecipientsCount in the meantime
567
960
  upToDate = await Email.getByID(id);
568
961
 
569
962
  if (!upToDate) {
@@ -572,12 +965,27 @@ export class Email extends QueryableModel {
572
965
  if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
573
966
  return;
574
967
  }
575
- upToDate.recipientCount = count;
968
+ upToDate.emailRecipientsCount = count;
576
969
  await upToDate.save();
577
970
  }
578
971
  catch (e) {
972
+ if (isAbortedError(e)) {
973
+ return;
974
+ }
579
975
  console.error('Failed to update count for email', id);
580
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();
581
989
  }
582
990
  }).catch(console.error);
583
991
  }
@@ -606,7 +1014,10 @@ export class Email extends QueryableModel {
606
1014
  upToDate.recipientsStatus = EmailRecipientsStatus.Creating;
607
1015
  await upToDate.save();
608
1016
 
1017
+ const membersSet = new Set<string>();
1018
+
609
1019
  let count = 0;
1020
+ let countWithoutEmail = 0;
610
1021
 
611
1022
  try {
612
1023
  // Delete all recipients
@@ -639,10 +1050,10 @@ export class Email extends QueryableModel {
639
1050
  const response = await loader.fetch(request, subfilter.subfilter);
640
1051
 
641
1052
  for (const item of response.results) {
642
- if (!item.email) {
1053
+ if (!item.email && !item.memberId && !item.userId) {
643
1054
  continue;
644
1055
  }
645
- count += 1;
1056
+
646
1057
  const recipient = new EmailRecipient();
647
1058
  recipient.emailType = upToDate.emailType;
648
1059
  recipient.objectId = item.objectId;
@@ -651,8 +1062,22 @@ export class Email extends QueryableModel {
651
1062
  recipient.firstName = item.firstName;
652
1063
  recipient.lastName = item.lastName;
653
1064
  recipient.replacements = item.replacements;
1065
+ recipient.memberId = item.memberId ?? null;
1066
+ recipient.userId = item.userId ?? null;
1067
+ recipient.organizationId = upToDate.organizationId ?? null;
654
1068
 
655
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
+ }
656
1081
  }
657
1082
 
658
1083
  request = response.next ?? null;
@@ -660,13 +1085,17 @@ export class Email extends QueryableModel {
660
1085
  }
661
1086
 
662
1087
  upToDate.recipientsStatus = EmailRecipientsStatus.Created;
663
- upToDate.recipientCount = count;
1088
+ upToDate.emailRecipientsCount = count;
1089
+ upToDate.otherRecipientsCount = countWithoutEmail;
1090
+ upToDate.recipientsErrors = null;
1091
+ upToDate.membersCount = membersSet.size;
664
1092
  await upToDate.save();
665
1093
  }
666
- catch (e) {
1094
+ catch (e: unknown) {
667
1095
  console.error('Failed to build recipients for email', id);
668
1096
  console.error(e);
669
1097
  upToDate.recipientsStatus = EmailRecipientsStatus.NotCreated;
1098
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
670
1099
  await upToDate.save();
671
1100
  }
672
1101
  });