@stamhoofd/models 2.97.3 → 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;
984
+
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
+ }
919
990
 
920
- if (isAbortedError(e) || ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('SHUTDOWN'))) {
921
- // Keep sending status: we'll resume after the reboot
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;
@@ -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