@stamhoofd/models 2.96.2 → 2.97.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.
@@ -13,6 +13,7 @@ import { EmailRecipient } from './EmailRecipient';
13
13
  import { EmailTemplate } from './EmailTemplate';
14
14
  import { Organization } from './Organization';
15
15
  import { User } from './User';
16
+ import { Platform } from './Platform';
16
17
 
17
18
  function errorToSimpleErrors(e: unknown) {
18
19
  if (isSimpleErrors(e)) {
@@ -26,7 +27,7 @@ function errorToSimpleErrors(e: unknown) {
26
27
  new SimpleError({
27
28
  code: 'unknown_error',
28
29
  message: ((typeof e === 'object' && e !== null && 'message' in e && typeof e.message === 'string') ? e.message : 'Unknown error'),
29
- human: $t(`Onbekende fout`),
30
+ human: $t(`41db9fc8-77f4-49a7-a77b-40a4ae8c4d8f`),
30
31
  }),
31
32
  );
32
33
  }
@@ -51,6 +52,23 @@ export class Email extends QueryableModel {
51
52
  @column({ type: 'string', nullable: true })
52
53
  userId: string | null = null;
53
54
 
55
+ /**
56
+ * Send the message as an email.
57
+ * You can't edit this after the message has been published.
58
+ *
59
+ * If false, when sending the message, it will switch to 'Sent' directly without adjusting the email_recipients directly.
60
+ */
61
+ @column({ type: 'boolean' })
62
+ sendAsEmail = true;
63
+
64
+ /**
65
+ * Show the message in the member portal
66
+ *
67
+ * Note: status should be 'Sent' for the message to be visible
68
+ */
69
+ @column({ type: 'boolean' })
70
+ showInMemberPortal = true;
71
+
54
72
  @column({ type: 'json', decoder: EmailRecipientFilter })
55
73
  recipientFilter: EmailRecipientFilter = EmailRecipientFilter.create({});
56
74
 
@@ -241,7 +259,7 @@ export class Email extends QueryableModel {
241
259
  throw new SimpleError({
242
260
  code: 'invalid_recipients',
243
261
  message: 'Failed to build recipients (count)',
244
- 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(),
262
+ human: $t(`457ec920-2867-4d59-bbec-4466677e1b50`) + ' ' + this.recipientsErrors.getHuman(),
245
263
  });
246
264
  }
247
265
 
@@ -249,13 +267,50 @@ export class Email extends QueryableModel {
249
267
  throw new SimpleError({
250
268
  code: 'invalid_state',
251
269
  message: 'Email is deleted',
252
- human: $t(`Deze e-mail is verwijderd en kan niet verzonden worden.`),
270
+ human: $t(`a0524f41-bdde-4fcc-9a9a-9350905377d8`),
253
271
  });
254
272
  }
255
273
 
256
274
  this.validateAttachments();
257
275
  }
258
276
 
277
+ throwIfNoUnsubscribeButton() {
278
+ if (this.sendAsEmail === false) {
279
+ return;
280
+ }
281
+
282
+ if (this.emailType) {
283
+ // System email, no need for unsubscribe button
284
+ return;
285
+ }
286
+
287
+ const replacement = '{{unsubscribeUrl}}';
288
+
289
+ if (this.html) {
290
+ // Check email contains an unsubscribe button
291
+ if (!this.html.includes(replacement)) {
292
+ throw new SimpleError({
293
+ code: 'missing_unsubscribe_button',
294
+ message: 'Missing unsubscribe button',
295
+ human: $t(`dd55e04b-e5d9-4d9a-befc-443eef4175a8`),
296
+ field: 'html',
297
+ });
298
+ }
299
+ }
300
+
301
+ if (this.text) {
302
+ // Check email contains an unsubscribe button
303
+ if (!this.text.includes(replacement)) {
304
+ throw new SimpleError({
305
+ code: 'missing_unsubscribe_button',
306
+ message: 'Missing unsubscribe button',
307
+ human: $t(`dd55e04b-e5d9-4d9a-befc-443eef4175a8`),
308
+ field: 'text',
309
+ });
310
+ }
311
+ }
312
+ }
313
+
259
314
  validateAttachments() {
260
315
  // Validate attachments
261
316
  const size = this.attachments.reduce((value: number, attachment) => {
@@ -526,6 +581,7 @@ export class Email extends QueryableModel {
526
581
 
527
582
  async queueForSending(waitForSending = false) {
528
583
  this.throwIfNotReadyToSend();
584
+ this.throwIfNoUnsubscribeButton();
529
585
  await this.lock(async (upToDate) => {
530
586
  if (upToDate.status === EmailStatus.Draft) {
531
587
  upToDate.status = EmailStatus.Queued;
@@ -585,6 +641,7 @@ export class Email extends QueryableModel {
585
641
 
586
642
  try {
587
643
  upToDate.throwIfNotReadyToSend();
644
+ upToDate.throwIfNoUnsubscribeButton();
588
645
 
589
646
  if (!from) {
590
647
  throw new SimpleError({
@@ -629,223 +686,225 @@ export class Email extends QueryableModel {
629
686
  }
630
687
 
631
688
  // Create a buffer of all attachments
632
- for (const attachment of upToDate.attachments) {
633
- if (!attachment.content && !attachment.file) {
634
- console.warn('Attachment without content found, skipping', attachment);
635
- continue;
636
- }
689
+ 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
+ }
637
695
 
638
- let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
696
+ let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
639
697
 
640
- if (attachment.contentType === 'application/pdf') {
698
+ if (attachment.contentType === 'application/pdf') {
641
699
  // tmp solution for pdf only
642
- filename += '.pdf';
643
- }
700
+ filename += '.pdf';
701
+ }
644
702
 
645
- if (attachment.file?.name) {
646
- filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
647
- }
703
+ if (attachment.file?.name) {
704
+ filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
705
+ }
648
706
 
649
- // Correct file name if needed
650
- if (attachment.filename) {
651
- filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
652
- }
707
+ // Correct file name if needed
708
+ if (attachment.filename) {
709
+ filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
710
+ }
653
711
 
654
- if (attachment.content) {
655
- attachments.push({
656
- filename: filename,
657
- content: attachment.content,
658
- contentType: attachment.contentType ?? undefined,
659
- encoding: 'base64',
660
- });
661
- }
662
- else {
663
- // 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
664
- const withSigned = await attachment.file!.withSignedUrl();
665
- if (!withSigned || !withSigned.signedUrl) {
666
- throw new SimpleError({
667
- code: 'attachment_not_found',
668
- message: 'Attachment not found',
669
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
712
+ if (attachment.content) {
713
+ attachments.push({
714
+ filename: filename,
715
+ content: attachment.content,
716
+ contentType: attachment.contentType ?? undefined,
717
+ encoding: 'base64',
670
718
  });
671
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
+ }
672
730
 
673
- const filePath = withSigned.signedUrl;
674
- let fileBuffer: Buffer | null = null;
675
- try {
676
- const response = await fetch(filePath);
677
- fileBuffer = Buffer.from(await response.arrayBuffer());
678
- }
679
- catch (e) {
680
- throw new SimpleError({
681
- code: 'attachment_not_found',
682
- message: 'Attachment not found',
683
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
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,
684
749
  });
685
750
  }
686
-
687
- attachments.push({
688
- filename: filename,
689
- contentType: attachment.contentType ?? undefined,
690
- content: fileBuffer,
691
- });
692
751
  }
693
- }
694
752
 
695
- // Start actually sending in batches of recipients that are not yet sent
696
- let idPointer = '';
697
- const batchSize = 100;
698
- let isSavingStatus = false;
699
- let lastStatusSave = new Date();
753
+ // Start actually sending in batches of recipients that are not yet sent
754
+ let idPointer = '';
755
+ const batchSize = 100;
756
+ let isSavingStatus = false;
757
+ let lastStatusSave = new Date();
700
758
 
701
- async function saveStatus() {
702
- if (!upToDate) {
703
- return;
704
- }
705
- if (isSavingStatus) {
706
- return;
707
- }
708
- if ((new Date().getTime() - lastStatusSave.getTime()) < 1000 * 5) {
759
+ async function saveStatus() {
760
+ if (!upToDate) {
761
+ return;
762
+ }
763
+ if (isSavingStatus) {
764
+ return;
765
+ }
766
+ if ((new Date().getTime() - lastStatusSave.getTime()) < 1000 * 5) {
709
767
  // Save at most every 5 seconds
710
- return;
711
- }
712
- if (succeededCount < upToDate.succeededCount || softFailedCount < upToDate.softFailedCount || failedCount < upToDate.failedCount) {
768
+ return;
769
+ }
770
+ if (succeededCount < upToDate.succeededCount || softFailedCount < upToDate.softFailedCount || failedCount < upToDate.failedCount) {
713
771
  // Do not update on retries
714
- return;
715
- }
772
+ return;
773
+ }
716
774
 
717
- lastStatusSave = new Date();
718
- isSavingStatus = true;
719
- upToDate.succeededCount = succeededCount;
720
- upToDate.softFailedCount = softFailedCount;
721
- upToDate.failedCount = failedCount;
775
+ lastStatusSave = new Date();
776
+ isSavingStatus = true;
777
+ upToDate.succeededCount = succeededCount;
778
+ upToDate.softFailedCount = softFailedCount;
779
+ upToDate.failedCount = failedCount;
722
780
 
723
- try {
724
- await upToDate.save();
725
- }
726
- finally {
727
- isSavingStatus = false;
728
- }
729
- }
730
-
731
- while (true) {
732
- abort.throwIfAborted();
733
- const data = await SQL.select()
734
- .from('email_recipients')
735
- .where('emailId', upToDate.id)
736
- .where('id', SQLWhereSign.Greater, idPointer)
737
- .orderBy(SQL.column('id'), 'ASC')
738
- .limit(batchSize)
739
- .fetch();
740
-
741
- const recipients = EmailRecipient.fromRows(data, 'email_recipients');
742
-
743
- if (recipients.length === 0) {
744
- break;
781
+ try {
782
+ await upToDate.save();
783
+ }
784
+ finally {
785
+ isSavingStatus = false;
786
+ }
745
787
  }
746
788
 
747
- const sendingPromises: Promise<void>[] = [];
748
- let skipped = 0;
789
+ while (true) {
790
+ abort.throwIfAborted();
791
+ const data = await SQL.select()
792
+ .from('email_recipients')
793
+ .where('emailId', upToDate.id)
794
+ .where('id', SQLWhereSign.Greater, idPointer)
795
+ .orderBy(SQL.column('id'), 'ASC')
796
+ .limit(batchSize)
797
+ .fetch();
749
798
 
750
- for (const recipient of recipients) {
751
- idPointer = recipient.id;
799
+ const recipients = EmailRecipient.fromRows(data, 'email_recipients');
752
800
 
753
- if (recipient.sentAt) {
754
- succeededCount += 1;
755
- await saveStatus();
756
- skipped++;
757
- continue;
801
+ if (recipients.length === 0) {
802
+ break;
758
803
  }
759
804
 
760
- if (!recipient.email) {
761
- skipped++;
762
- continue;
763
- }
805
+ const sendingPromises: Promise<void>[] = [];
806
+ let skipped = 0;
764
807
 
765
- if (recipient.duplicateOfRecipientId) {
766
- skipped++;
767
- continue;
768
- }
808
+ for (const recipient of recipients) {
809
+ idPointer = recipient.id;
769
810
 
770
- let promiseResolve: (value: void | PromiseLike<void>) => void;
771
- const promise = new Promise<void>((resolve) => {
772
- promiseResolve = resolve;
773
- });
811
+ if (recipient.sentAt) {
812
+ succeededCount += 1;
813
+ await saveStatus();
814
+ skipped++;
815
+ continue;
816
+ }
774
817
 
775
- const virtualRecipient = recipient.getRecipient();
818
+ if (!recipient.email) {
819
+ skipped++;
820
+ continue;
821
+ }
776
822
 
777
- let resolved = false;
778
- const callback = async (error: Error | null) => {
779
- if (resolved) {
780
- return;
823
+ if (recipient.duplicateOfRecipientId) {
824
+ skipped++;
825
+ continue;
781
826
  }
782
- resolved = true;
783
827
 
784
- try {
785
- if (error === null) {
786
- // Mark saved
787
- recipient.sentAt = new Date();
828
+ let promiseResolve: (value: void | PromiseLike<void>) => void;
829
+ const promise = new Promise<void>((resolve) => {
830
+ promiseResolve = resolve;
831
+ });
788
832
 
789
- // Update repacements that have been generated
790
- recipient.replacements = virtualRecipient.replacements;
833
+ const virtualRecipient = recipient.getRecipient();
791
834
 
792
- succeededCount += 1;
793
- await recipient.save();
794
- await saveStatus();
835
+ let resolved = false;
836
+ const callback = async (error: Error | null) => {
837
+ if (resolved) {
838
+ return;
795
839
  }
796
- else {
797
- recipient.failCount += 1;
798
- recipient.failErrorMessage = error.message;
799
- recipient.failError = errorToSimpleErrors(error);
800
- recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
801
- recipient.lastFailedAt = new Date();
802
-
803
- if (isSoftEmailRecipientError(recipient.failError)) {
804
- softFailedCount += 1;
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();
805
853
  }
806
854
  else {
807
- failedCount += 1;
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();
808
869
  }
809
- await recipient.save();
810
- await saveStatus();
811
870
  }
812
- }
813
- catch (e) {
814
- console.error(e);
815
- }
816
- promiseResolve();
817
- };
818
-
819
- // Do send the email
820
- // Create e-mail builder
821
- const builder = await getEmailBuilder(organization ?? null, {
822
- recipients: [
823
- virtualRecipient,
824
- ],
825
- from,
826
- replyTo,
827
- subject: upToDate.subject!,
828
- html: upToDate.html!,
829
- type: upToDate.emailType ? 'transactional' : 'broadcast',
830
- attachments,
831
- callback(error: Error | null) {
832
- callback(error).catch(console.error);
833
- },
834
- headers: {
835
- 'X-Email-Id': upToDate.id,
836
- 'X-Email-Recipient-Id': recipient.id,
837
- },
838
- });
839
- abort.throwIfAborted(); // do not schedule if aborted
840
- EmailClass.schedule(builder);
841
- sendingPromises.push(promise);
842
- }
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
+ ],
883
+ from,
884
+ replyTo,
885
+ subject: upToDate.subject!,
886
+ html: upToDate.html!,
887
+ type: upToDate.emailType ? 'transactional' : 'broadcast',
888
+ 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
+ });
897
+ abort.throwIfAborted(); // do not schedule if aborted
898
+ EmailClass.schedule(builder);
899
+ sendingPromises.push(promise);
900
+ }
843
901
 
844
- if (sendingPromises.length > 0 || skipped > 0) {
845
- await Promise.all(sendingPromises);
846
- }
847
- else {
848
- break;
902
+ if (sendingPromises.length > 0 || skipped > 0) {
903
+ await Promise.all(sendingPromises);
904
+ }
905
+ else {
906
+ break;
907
+ }
849
908
  }
850
909
  }
851
910
  }
@@ -870,7 +929,7 @@ export class Email extends QueryableModel {
870
929
  throw e;
871
930
  }
872
931
 
873
- if (upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
932
+ if (upToDate.sendAsEmail && upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
874
933
  // We only delete automated emails (email type) if they have no recipients
875
934
  console.log('No recipients found for email ', upToDate.id, ' deleting...');
876
935
  await upToDate.delete();
@@ -880,13 +939,13 @@ export class Email extends QueryableModel {
880
939
  console.log('Finished sending email', upToDate.id);
881
940
  // Mark email as sent
882
941
 
883
- if ((succeededCount + failedCount + softFailedCount) === 0) {
942
+ if (upToDate.sendAsEmail && !upToDate.showInMemberPortal && (succeededCount + failedCount + softFailedCount) === 0) {
884
943
  upToDate.status = EmailStatus.Failed;
885
944
  upToDate.emailErrors = new SimpleErrors(
886
945
  new SimpleError({
887
946
  code: 'no_recipients',
888
947
  message: 'No recipients',
889
- human: $t(`Geen ontvangers gevonden`),
948
+ human: $t(`9fe3de8e-090c-4949-97da-4810ce9e61c7`),
890
949
  }),
891
950
  );
892
951
  }
@@ -996,6 +1055,15 @@ export class Email extends QueryableModel {
996
1055
  }
997
1056
 
998
1057
  abort.throwIfAborted();
1058
+ const organization = upToDate.organizationId ? (await Organization.getByID(upToDate.organizationId) ?? null) : null;
1059
+ if (upToDate.organizationId && !organization) {
1060
+ throw new SimpleError({
1061
+ code: 'organization_not_found',
1062
+ message: 'Organization not found',
1063
+ human: $t(`f3c6e2b1-2f3a-4e2f-8f7a-1e5f3d3c8e2a`),
1064
+ });
1065
+ }
1066
+ const platform = await Platform.getSharedPrivateStruct();
999
1067
 
1000
1068
  console.log('Building recipients for email', id);
1001
1069
 
@@ -1045,7 +1113,26 @@ export class Email extends QueryableModel {
1045
1113
  continue;
1046
1114
  }
1047
1115
 
1048
- item.replacements = removeUnusedReplacements(upToDate.html ?? '', item.replacements);
1116
+ const recipient = new EmailRecipient();
1117
+ recipient.emailType = upToDate.emailType;
1118
+ recipient.objectId = item.objectId;
1119
+ recipient.emailId = upToDate.id;
1120
+ recipient.email = item.email;
1121
+ recipient.firstName = item.firstName;
1122
+ recipient.lastName = item.lastName;
1123
+ recipient.replacements = item.replacements;
1124
+ recipient.memberId = item.memberId ?? null;
1125
+ recipient.userId = item.userId ?? null;
1126
+ recipient.organizationId = upToDate.organizationId ?? null;
1127
+
1128
+ await fillRecipientReplacements(recipient, {
1129
+ platform,
1130
+ organization,
1131
+ from: upToDate.getFromAddress(),
1132
+ replyTo: null,
1133
+ forPreview: false,
1134
+ });
1135
+ recipient.replacements = removeUnusedReplacements(upToDate.html ?? '', recipient.replacements);
1049
1136
 
1050
1137
  let duplicateOfRecipientId: string | null = null;
1051
1138
  if (item.email && emailsSet.has(item.email)) {
@@ -1055,12 +1142,13 @@ export class Email extends QueryableModel {
1055
1142
  const existing = await EmailRecipient.select()
1056
1143
  .where('emailId', upToDate.id)
1057
1144
  .where('email', item.email)
1145
+ .where('duplicateOfRecipientId', null)
1058
1146
  .fetch();
1059
1147
 
1060
1148
  for (const other of existing) {
1061
- const merged = mergeReplacementsIfEqual(other.replacements, item.replacements);
1149
+ const merged = mergeReplacementsIfEqual(other.replacements, recipient.replacements);
1062
1150
  if (merged !== false) {
1063
- console.log('Found duplicate email recipient', item.email, other.id);
1151
+ console.log('Found mergeable duplicate email recipient', item.email, other.id);
1064
1152
  duplicateOfRecipientId = other.id;
1065
1153
 
1066
1154
  other.replacements = merged;
@@ -1068,7 +1156,7 @@ export class Email extends QueryableModel {
1068
1156
  other.lastName = other.lastName || item.lastName;
1069
1157
  await other.save();
1070
1158
 
1071
- item.replacements = merged;
1159
+ recipient.replacements = merged;
1072
1160
 
1073
1161
  break;
1074
1162
  }
@@ -1077,18 +1165,6 @@ export class Email extends QueryableModel {
1077
1165
  }
1078
1166
  }
1079
1167
  }
1080
-
1081
- const recipient = new EmailRecipient();
1082
- recipient.emailType = upToDate.emailType;
1083
- recipient.objectId = item.objectId;
1084
- recipient.emailId = upToDate.id;
1085
- recipient.email = item.email;
1086
- recipient.firstName = item.firstName;
1087
- recipient.lastName = item.lastName;
1088
- recipient.replacements = item.replacements;
1089
- recipient.memberId = item.memberId ?? null;
1090
- recipient.userId = item.userId ?? null;
1091
- recipient.organizationId = upToDate.organizationId ?? null;
1092
1168
  recipient.duplicateOfRecipientId = duplicateOfRecipientId;
1093
1169
 
1094
1170
  await recipient.save();