@stamhoofd/models 2.97.2 → 2.98.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.
@@ -15,6 +15,8 @@ import { Organization } from './Organization';
15
15
  import { User } from './User';
16
16
  import { Platform } from './Platform';
17
17
 
18
+ type Attachment = { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string };
19
+
18
20
  function errorToSimpleErrors(e: unknown) {
19
21
  if (isSimpleErrors(e)) {
20
22
  return e;
@@ -601,40 +603,191 @@ export class Email extends QueryableModel {
601
603
  return this;
602
604
  }
603
605
 
604
- async resumeSending(): Promise<Email | null> {
606
+ private async loadAttachments(): Promise<Attachment[]> {
607
+ const attachments: Attachment[] = [];
608
+ for (const attachment of this.attachments) {
609
+ if (!attachment.content && !attachment.file) {
610
+ console.warn('Attachment without content found, skipping', attachment);
611
+ continue;
612
+ }
613
+
614
+ let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
615
+
616
+ if (attachment.contentType === 'application/pdf') {
617
+ // tmp solution for pdf only
618
+ filename += '.pdf';
619
+ }
620
+
621
+ if (attachment.file?.name) {
622
+ filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
623
+ }
624
+
625
+ // Correct file name if needed
626
+ if (attachment.filename) {
627
+ filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
628
+ }
629
+
630
+ if (attachment.content) {
631
+ attachments.push({
632
+ filename: filename,
633
+ content: attachment.content,
634
+ contentType: attachment.contentType ?? undefined,
635
+ encoding: 'base64',
636
+ });
637
+ }
638
+ else {
639
+ // 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
640
+ const withSigned = await attachment.file!.withSignedUrl();
641
+ if (!withSigned || !withSigned.signedUrl) {
642
+ throw new SimpleError({
643
+ code: 'attachment_not_found',
644
+ message: 'Attachment not found',
645
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
646
+ });
647
+ }
648
+
649
+ const filePath = withSigned.signedUrl;
650
+ let fileBuffer: Buffer | null = null;
651
+ try {
652
+ const response = await fetch(filePath);
653
+ fileBuffer = Buffer.from(await response.arrayBuffer());
654
+ }
655
+ catch (e) {
656
+ throw new SimpleError({
657
+ code: 'attachment_not_found',
658
+ message: 'Attachment not found',
659
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
660
+ });
661
+ }
662
+
663
+ attachments.push({
664
+ filename: filename,
665
+ contentType: attachment.contentType ?? undefined,
666
+ content: fileBuffer,
667
+ });
668
+ }
669
+ }
670
+ return attachments;
671
+ }
672
+
673
+ private async sendSingleRecipient(recipient: EmailRecipient, organization: Organization | null, data: { from: EmailInterfaceRecipient; replyTo: EmailInterfaceRecipient | null; attachments: Attachment[] }) {
674
+ let promiseResolve: (value: void | PromiseLike<void>) => void;
675
+ const promise = new Promise<void>((resolve) => {
676
+ promiseResolve = resolve;
677
+ });
678
+
679
+ const virtualRecipient = recipient.getRecipient();
680
+
681
+ let resolved = false;
682
+ const callback = async (error: Error | null) => {
683
+ if (resolved) {
684
+ return;
685
+ }
686
+ resolved = true;
687
+
688
+ try {
689
+ if (error === null) {
690
+ // Mark saved
691
+ recipient.sentAt = new Date();
692
+ recipient.failErrorMessage = null;
693
+
694
+ if (recipient.failError) {
695
+ recipient.previousFailError = recipient.failError;
696
+ }
697
+ recipient.failError = null;
698
+
699
+ // Update repacements that have been generated
700
+ recipient.replacements = virtualRecipient.replacements;
701
+ await recipient.save();
702
+ }
703
+ else {
704
+ recipient.failCount += 1;
705
+ recipient.failErrorMessage = error.message;
706
+ if (recipient.failError) {
707
+ recipient.previousFailError = recipient.failError;
708
+ }
709
+ recipient.failError = errorToSimpleErrors(error);
710
+ recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
711
+ recipient.lastFailedAt = new Date();
712
+ await recipient.save();
713
+ }
714
+ }
715
+ catch (e) {
716
+ console.error(e);
717
+ }
718
+ promiseResolve();
719
+ };
720
+
721
+ // Do send the email
722
+ // Create e-mail builder
723
+ const builder = await getEmailBuilder(organization ?? null, {
724
+ recipients: [
725
+ virtualRecipient,
726
+ ],
727
+ from: data.from,
728
+ replyTo: data.replyTo,
729
+ subject: this.subject!,
730
+ html: this.html!,
731
+ type: this.emailType ? 'transactional' : 'broadcast',
732
+ attachments: data.attachments,
733
+ callback(error: Error | null) {
734
+ callback(error).catch(console.error);
735
+ },
736
+ headers: {
737
+ 'X-Email-Id': this.id,
738
+ 'X-Email-Recipient-Id': recipient.id,
739
+ },
740
+ });
741
+ EmailClass.schedule(builder);
742
+ return await promise;
743
+ }
744
+
745
+ async resumeSending(singleRecipientId: string | null = null): Promise<Email | null> {
605
746
  const id = this.id;
606
747
  return await QueueHandler.schedule('send-email', async ({ abort }) => {
607
748
  return await this.lock(async function (upToDate: Email) {
608
749
  if (upToDate.status === EmailStatus.Sent) {
609
750
  // Already done
610
751
  // In other cases -> queue has stopped and we can retry
611
- return upToDate;
752
+ if (!singleRecipientId) {
753
+ console.log('Email already sent, skipping...', upToDate.id);
754
+ return upToDate;
755
+ }
612
756
  }
757
+ else {
758
+ if (singleRecipientId) {
759
+ // Not possible
760
+ throw new SimpleError({
761
+ code: 'invalid_state',
762
+ message: 'Cannot retry single recipient for email that is not yet sent',
763
+ });
764
+ }
613
765
 
614
- if (upToDate.status === EmailStatus.Sending) {
766
+ if (upToDate.status === EmailStatus.Sending) {
615
767
  // This is an automatic retry.
616
- if (upToDate.emailType) {
768
+ if (upToDate.emailType) {
617
769
  // Not eligible for retry
618
- upToDate.status = EmailStatus.Failed;
619
- await upToDate.save();
620
- return upToDate;
621
- }
622
- if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
770
+ upToDate.status = EmailStatus.Failed;
771
+ await upToDate.save();
772
+ return upToDate;
773
+ }
774
+ if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
623
775
  // Too long
624
- console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
625
- upToDate.status = EmailStatus.Failed;
626
- await upToDate.save();
776
+ console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
777
+ upToDate.status = EmailStatus.Failed;
778
+ await upToDate.save();
779
+ return upToDate;
780
+ }
781
+ }
782
+ else if (upToDate.status !== EmailStatus.Queued) {
783
+ console.error('Email is not queued or sending, cannot send', upToDate.id, upToDate.status);
627
784
  return upToDate;
628
785
  }
629
786
  }
630
- else if (upToDate.status !== EmailStatus.Queued) {
631
- console.error('Email is not queued or sending, cannot send', upToDate.id, upToDate.status);
632
- return upToDate;
633
- }
787
+
634
788
  const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
635
789
  let from = upToDate.getDefaultFromAddress(organization);
636
790
  let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
637
- const attachments: { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string }[] = [];
638
791
  let succeededCount = 0;
639
792
  let softFailedCount = 0;
640
793
  let failedCount = 0;
@@ -658,7 +811,10 @@ export class Email extends QueryableModel {
658
811
  }
659
812
 
660
813
  abort.throwIfAborted();
661
- upToDate.status = EmailStatus.Sending;
814
+
815
+ if (!singleRecipientId) {
816
+ upToDate.status = EmailStatus.Sending;
817
+ }
662
818
  upToDate.sentAt = upToDate.sentAt ?? new Date();
663
819
  await upToDate.save();
664
820
 
@@ -687,68 +843,7 @@ export class Email extends QueryableModel {
687
843
 
688
844
  // Create a buffer of all attachments
689
845
  if (upToDate.sendAsEmail === true) {
690
- for (const attachment of upToDate.attachments) {
691
- if (!attachment.content && !attachment.file) {
692
- console.warn('Attachment without content found, skipping', attachment);
693
- continue;
694
- }
695
-
696
- let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
697
-
698
- if (attachment.contentType === 'application/pdf') {
699
- // tmp solution for pdf only
700
- filename += '.pdf';
701
- }
702
-
703
- if (attachment.file?.name) {
704
- filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
705
- }
706
-
707
- // Correct file name if needed
708
- if (attachment.filename) {
709
- filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
710
- }
711
-
712
- if (attachment.content) {
713
- attachments.push({
714
- filename: filename,
715
- content: attachment.content,
716
- contentType: attachment.contentType ?? undefined,
717
- encoding: 'base64',
718
- });
719
- }
720
- else {
721
- // 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
722
- const withSigned = await attachment.file!.withSignedUrl();
723
- if (!withSigned || !withSigned.signedUrl) {
724
- throw new SimpleError({
725
- code: 'attachment_not_found',
726
- message: 'Attachment not found',
727
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
728
- });
729
- }
730
-
731
- const filePath = withSigned.signedUrl;
732
- let fileBuffer: Buffer | null = null;
733
- try {
734
- const response = await fetch(filePath);
735
- fileBuffer = Buffer.from(await response.arrayBuffer());
736
- }
737
- catch (e) {
738
- throw new SimpleError({
739
- code: 'attachment_not_found',
740
- message: 'Attachment not found',
741
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
742
- });
743
- }
744
-
745
- attachments.push({
746
- filename: filename,
747
- contentType: attachment.contentType ?? undefined,
748
- content: fileBuffer,
749
- });
750
- }
751
- }
846
+ const attachments = await upToDate.loadAttachments();
752
847
 
753
848
  // Start actually sending in batches of recipients that are not yet sent
754
849
  let idPointer = '';
@@ -757,6 +852,11 @@ export class Email extends QueryableModel {
757
852
  let lastStatusSave = new Date();
758
853
 
759
854
  async function saveStatus() {
855
+ if (singleRecipientId) {
856
+ // Don't save during looping
857
+ return;
858
+ }
859
+
760
860
  if (!upToDate) {
761
861
  return;
762
862
  }
@@ -825,78 +925,42 @@ export class Email extends QueryableModel {
825
925
  continue;
826
926
  }
827
927
 
828
- let promiseResolve: (value: void | PromiseLike<void>) => void;
829
- const promise = new Promise<void>((resolve) => {
830
- promiseResolve = resolve;
831
- });
832
-
833
- const virtualRecipient = recipient.getRecipient();
834
-
835
- let resolved = false;
836
- const callback = async (error: Error | null) => {
837
- if (resolved) {
838
- return;
839
- }
840
- resolved = true;
841
-
842
- try {
843
- if (error === null) {
844
- // Mark saved
845
- recipient.sentAt = new Date();
846
-
847
- // Update repacements that have been generated
848
- recipient.replacements = virtualRecipient.replacements;
849
-
850
- succeededCount += 1;
851
- await recipient.save();
852
- await saveStatus();
928
+ if (singleRecipientId) {
929
+ if (recipient.id !== singleRecipientId) {
930
+ // Failed or soft-failed
931
+ if (recipient.failError && isSoftEmailRecipientError(recipient.failError)) {
932
+ softFailedCount += 1;
853
933
  }
854
934
  else {
855
- recipient.failCount += 1;
856
- recipient.failErrorMessage = error.message;
857
- recipient.failError = errorToSimpleErrors(error);
858
- recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
859
- recipient.lastFailedAt = new Date();
860
-
861
- if (isSoftEmailRecipientError(recipient.failError)) {
862
- softFailedCount += 1;
863
- }
864
- else {
865
- failedCount += 1;
866
- }
867
- await recipient.save();
868
- await saveStatus();
935
+ failedCount += 1;
869
936
  }
937
+ skipped++;
938
+ await saveStatus();
939
+ continue;
870
940
  }
871
- catch (e) {
872
- console.error(e);
873
- }
874
- promiseResolve();
875
- };
876
-
877
- // Do send the email
878
- // Create e-mail builder
879
- const builder = await getEmailBuilder(organization ?? null, {
880
- recipients: [
881
- virtualRecipient,
882
- ],
941
+ }
942
+
943
+ const promise = upToDate.sendSingleRecipient(recipient, organization ?? null, {
883
944
  from,
884
945
  replyTo,
885
- subject: upToDate.subject!,
886
- html: upToDate.html!,
887
- type: upToDate.emailType ? 'transactional' : 'broadcast',
888
946
  attachments,
889
- callback(error: Error | null) {
890
- callback(error).catch(console.error);
891
- },
892
- headers: {
893
- 'X-Email-Id': upToDate.id,
894
- 'X-Email-Recipient-Id': recipient.id,
895
- },
896
947
  });
897
- abort.throwIfAborted(); // do not schedule if aborted
898
- EmailClass.schedule(builder);
899
- sendingPromises.push(promise);
948
+ sendingPromises.push(promise.then(async () => {
949
+ if (recipient.sentAt) {
950
+ succeededCount += 1;
951
+ await saveStatus();
952
+ }
953
+ else {
954
+ // Failed or soft-failed
955
+ if (recipient.failError && isSoftEmailRecipientError(recipient.failError)) {
956
+ softFailedCount += 1;
957
+ }
958
+ else {
959
+ failedCount += 1;
960
+ }
961
+ await saveStatus();
962
+ }
963
+ }));
900
964
  }
901
965
 
902
966
  if (sendingPromises.length > 0 || skipped > 0) {
@@ -913,45 +977,49 @@ export class Email extends QueryableModel {
913
977
  throw e;
914
978
  }
915
979
 
916
- upToDate.succeededCount = succeededCount;
917
- upToDate.softFailedCount = softFailedCount;
918
- upToDate.failedCount = failedCount;
980
+ if (!singleRecipientId) {
981
+ upToDate.succeededCount = succeededCount;
982
+ upToDate.softFailedCount = softFailedCount;
983
+ upToDate.failedCount = failedCount;
919
984
 
920
- if (isAbortedError(e) || ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('SHUTDOWN'))) {
921
- // Keep sending status: we'll resume after the reboot
985
+ if (isAbortedError(e) || ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('SHUTDOWN'))) {
986
+ // Keep sending status: we'll resume after the reboot
987
+ await upToDate.save();
988
+ throw e;
989
+ }
990
+
991
+ upToDate.emailErrors = errorToSimpleErrors(e);
992
+ upToDate.status = EmailStatus.Failed;
922
993
  await upToDate.save();
923
- throw e;
924
994
  }
925
-
926
- upToDate.emailErrors = errorToSimpleErrors(e);
927
- upToDate.status = EmailStatus.Failed;
928
- await upToDate.save();
929
995
  throw e;
930
996
  }
931
997
 
932
- if (upToDate.sendAsEmail && upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
933
- // We only delete automated emails (email type) if they have no recipients
934
- console.log('No recipients found for email ', upToDate.id, ' deleting...');
935
- await upToDate.delete();
936
- return null;
937
- }
998
+ if (!singleRecipientId) {
999
+ if (upToDate.sendAsEmail && upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
1000
+ // We only delete automated emails (email type) if they have no recipients
1001
+ console.log('No recipients found for email ', upToDate.id, ' deleting...');
1002
+ await upToDate.delete();
1003
+ return null;
1004
+ }
938
1005
 
939
- console.log('Finished sending email', upToDate.id);
940
- // Mark email as sent
941
-
942
- if (upToDate.sendAsEmail && !upToDate.showInMemberPortal && (succeededCount + failedCount + softFailedCount) === 0) {
943
- upToDate.status = EmailStatus.Failed;
944
- upToDate.emailErrors = new SimpleErrors(
945
- new SimpleError({
946
- code: 'no_recipients',
947
- message: 'No recipients',
948
- human: $t(`9fe3de8e-090c-4949-97da-4810ce9e61c7`),
949
- }),
950
- );
951
- }
952
- else {
953
- upToDate.status = EmailStatus.Sent;
954
- upToDate.emailErrors = null;
1006
+ console.log('Finished sending email', upToDate.id);
1007
+ // Mark email as sent
1008
+
1009
+ if (upToDate.sendAsEmail && !upToDate.showInMemberPortal && (succeededCount + failedCount + softFailedCount) === 0) {
1010
+ upToDate.status = EmailStatus.Failed;
1011
+ upToDate.emailErrors = new SimpleErrors(
1012
+ new SimpleError({
1013
+ code: 'no_recipients',
1014
+ message: 'No recipients',
1015
+ human: $t(`9fe3de8e-090c-4949-97da-4810ce9e61c7`),
1016
+ }),
1017
+ );
1018
+ }
1019
+ else {
1020
+ upToDate.status = EmailStatus.Sent;
1021
+ upToDate.emailErrors = null;
1022
+ }
955
1023
  }
956
1024
 
957
1025
  upToDate.succeededCount = succeededCount;
@@ -1365,37 +1433,53 @@ export class Email extends QueryableModel {
1365
1433
  const cleanedRecipients: EmailRecipient[] = [...recipientsMap.values()];
1366
1434
  const structures = await EmailRecipient.getStructures(cleanedRecipients);
1367
1435
 
1368
- const virtualRecipients = structures.map((struct) => {
1369
- const recipient = struct.getRecipient();
1370
-
1371
- return {
1372
- struct,
1373
- recipient,
1374
- };
1375
- });
1376
- for (const { struct, recipient } of virtualRecipients) {
1436
+ for (const struct of structures) {
1377
1437
  if (!(struct.userId === user.id || struct.email === user.email) && !((struct.userId === null && struct.email === null))) {
1378
- stripSensitiveRecipientReplacements(recipient, {
1438
+ stripSensitiveRecipientReplacements(struct, {
1379
1439
  organization,
1380
1440
  willFill: true,
1381
1441
  });
1382
1442
  }
1383
1443
 
1384
- recipient.email = user.email;
1385
- recipient.userId = user.id;
1444
+ struct.firstName = user.firstName;
1445
+ struct.lastName = user.lastName;
1446
+ struct.email = user.email;
1447
+ struct.userId = user.id;
1386
1448
 
1387
1449
  // We always refresh the data when we display it on the web (so everything is up to date)
1388
- await fillRecipientReplacements(recipient, {
1450
+ await fillRecipientReplacements(struct, {
1389
1451
  organization,
1390
1452
  from: this.getFromAddress(),
1391
1453
  replyTo: null,
1392
1454
  forPreview: false,
1393
1455
  forceRefresh: true,
1394
1456
  });
1395
- stripRecipientReplacementsForWebDisplay(recipient, {
1457
+ stripRecipientReplacementsForWebDisplay(struct, {
1396
1458
  organization,
1397
1459
  });
1398
- struct.replacements = recipient.replacements;
1460
+ if (this.html) {
1461
+ struct.replacements = removeUnusedReplacements(this.html, struct.replacements);
1462
+ }
1463
+ }
1464
+
1465
+ // Loop structures and remove if they have exactly the same content
1466
+ // We do this here, because it is possible the user didn't receive any emails, so
1467
+ // the merging at time of sending the emails didn't happen (uniqueness happened on email)
1468
+ const uniqueStructures: EmailRecipientStruct[] = [];
1469
+ for (const struct of structures) {
1470
+ let found = false;
1471
+ for (const unique of uniqueStructures) {
1472
+ const merged = mergeReplacementsIfEqual(unique.replacements, struct.replacements);
1473
+ if (merged !== false) {
1474
+ unique.replacements = merged;
1475
+ found = true;
1476
+ break;
1477
+ }
1478
+ }
1479
+
1480
+ if (!found) {
1481
+ uniqueStructures.push(struct);
1482
+ }
1399
1483
  }
1400
1484
 
1401
1485
  let organizationStruct: BaseOrganization | null = null;
@@ -1406,7 +1490,7 @@ export class Email extends QueryableModel {
1406
1490
  return EmailWithRecipients.create({
1407
1491
  ...this,
1408
1492
  organization: organizationStruct,
1409
- recipients: structures,
1493
+ recipients: uniqueStructures,
1410
1494
 
1411
1495
  // Remove private-like data
1412
1496
  softBouncesCount: 0,
@@ -57,6 +57,9 @@ export class EmailRecipient extends QueryableModel {
57
57
  @column({ type: 'json', nullable: true, decoder: SimpleErrors })
58
58
  failError: SimpleErrors | null = null;
59
59
 
60
+ @column({ type: 'json', nullable: true, decoder: SimpleErrors })
61
+ previousFailError: SimpleErrors | null = null;
62
+
60
63
  @column({ type: 'string', nullable: true })
61
64
  organizationId: string | null = null;
62
65