@stamhoofd/backend 2.96.3 → 2.97.1

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.
@@ -1,10 +1,19 @@
1
+ /* eslint-disable jest/no-conditional-expect */
2
+ import { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
1
3
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { Email, Organization, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
3
- import { AccessRight, EmailStatus, Email as EmailStruct, OrganizationEmail, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, Version } from '@stamhoofd/structures';
4
+ import { Email, EmailRecipient, GroupFactory, MemberFactory, Organization, OrganizationFactory, RegistrationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
5
+ import { AccessRight, EmailRecipientFilter, EmailRecipientFilterType, EmailRecipientSubfilter, EmailStatus, Email as EmailStruct, OrganizationEmail, Parent, PermissionLevel, Permissions, PermissionsResourceType, ResourcePermissions, UserPermissions, Version } from '@stamhoofd/structures';
4
6
  import { STExpect, TestUtils } from '@stamhoofd/test-utils';
5
7
  import { testServer } from '../../../../tests/helpers/TestServer';
6
8
  import { PatchEmailEndpoint } from './PatchEmailEndpoint';
7
- import { AutoEncoderPatchType } from '@simonbackx/simple-encoding';
9
+
10
+ // Import recipient loaders to initialize them
11
+ import { Formatter } from '@stamhoofd/utility';
12
+ import '../../../email-recipient-loaders/members';
13
+ import '../../../email-recipient-loaders/orders';
14
+ import '../../../email-recipient-loaders/receivable-balances';
15
+ import '../../../email-recipient-loaders/registrations';
16
+ import { EmailMocker } from '@stamhoofd/email';
8
17
 
9
18
  const baseUrl = `/v${Version}/email`;
10
19
 
@@ -32,6 +41,8 @@ describe('Endpoint.PatchEmailEndpoint', () => {
32
41
  });
33
42
 
34
43
  beforeAll(async () => {
44
+ TestUtils.setEnvironment('userMode', 'platform');
45
+
35
46
  period = await new RegistrationPeriodFactory({
36
47
  startDate: new Date(2023, 0, 1),
37
48
  endDate: new Date(2023, 11, 31),
@@ -532,4 +543,662 @@ describe('Endpoint.PatchEmailEndpoint', () => {
532
543
 
533
544
  await expect(patchEmail(body, token, organization)).toResolve();
534
545
  });
546
+
547
+ test('Should send email to members list with correct replacements and recipients', async () => {
548
+ // Create test groups
549
+ const testGroup = await new GroupFactory({
550
+ organization,
551
+ }).create();
552
+
553
+ // Update the user to have group permissions
554
+ if (!user.permissions) {
555
+ user.permissions = UserPermissions.create({});
556
+ }
557
+
558
+ // Get existing organization permissions
559
+ // Create new organization permissions if they don't exist
560
+ user.permissions.organizationPermissions.set(organization.id, Permissions.create({
561
+ level: PermissionLevel.None,
562
+ resources: new Map([
563
+ [PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
564
+ resourceName: sender.name!,
565
+ level: PermissionLevel.None,
566
+ accessRights: [AccessRight.SendMessages],
567
+ })]])],
568
+ [PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
569
+ resourceName: testGroup.settings.name.toString(),
570
+ level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
571
+ accessRights: [],
572
+ })]])],
573
+ ]),
574
+ }));
575
+ await user.save();
576
+
577
+ // Create test users - one with email and one without
578
+ const userWithEmail = await new UserFactory({
579
+ organization,
580
+ email: 'member-with-email@test.com',
581
+ }).create();
582
+
583
+ const userWithoutEmail = await new UserFactory({
584
+ organization,
585
+ email: 'user-without-email@test.com', // User has email but member won't
586
+ }).create();
587
+
588
+ // Create members - one with email address and one without
589
+ const memberWithEmail = await new MemberFactory({
590
+ organization,
591
+ user: userWithEmail,
592
+ }).create();
593
+ memberWithEmail.details.email = 'member-with-email@test.com';
594
+ await memberWithEmail.save();
595
+
596
+ const memberWithoutEmail = await new MemberFactory({
597
+ organization,
598
+ user: userWithoutEmail,
599
+ }).create();
600
+ // Explicitly ensure this member has no email
601
+ memberWithoutEmail.details.email = null;
602
+ await memberWithoutEmail.save();
603
+
604
+ await new RegistrationFactory({
605
+ member: memberWithEmail,
606
+ group: testGroup,
607
+ }).create();
608
+
609
+ await new RegistrationFactory({
610
+ member: memberWithoutEmail,
611
+ group: testGroup,
612
+ }).create();
613
+
614
+ // No mocking needed - we'll use the real recipient loading logic
615
+
616
+ // Create an email with recipient filter targeting these specific members
617
+ const email = new Email();
618
+ email.subject = 'Test Email with Replacements {{firstName}}';
619
+ email.status = EmailStatus.Draft;
620
+ email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
621
+ email.html = `<!DOCTYPE html>
622
+ <html>
623
+ <head>
624
+ <meta charset="utf-8" />
625
+ <title>Test Email</title>
626
+ </head>
627
+ <body>
628
+ <p>{{greeting}}</p>
629
+ <p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
630
+ <p>Login details: {{loginDetails}}</p>
631
+ <p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
632
+ <p>Member firstname: {{firstNameMember}}</p>
633
+ <p>Balance table: {{balanceTable}}</p>
634
+ <p>Outstanding balance: {{outstandingBalance}}</p>
635
+ </body>
636
+ </html>`;
637
+ email.json = {};
638
+
639
+ email.userId = user.id;
640
+ email.organizationId = organization.id;
641
+ email.senderId = sender.id;
642
+
643
+ // Set up recipient filter to target our specific members
644
+ email.recipientFilter = EmailRecipientFilter.create({
645
+ filters: [
646
+ EmailRecipientSubfilter.create({
647
+ type: EmailRecipientFilterType.Members,
648
+ filter: {
649
+ id: { $in: [memberWithEmail.id, memberWithoutEmail.id] },
650
+ },
651
+ }),
652
+ ],
653
+ });
654
+
655
+ await email.save();
656
+
657
+ // First try to build recipients with proper context by calling through the endpoint
658
+ const body = EmailStruct.patch({
659
+ id: email.id,
660
+ status: EmailStatus.Sending,
661
+ });
662
+
663
+ // This should start the recipient building process in the background
664
+ const response = await patchEmail(body, token, organization);
665
+ expect(response.status).toBe(200);
666
+
667
+ // Wait for the background process to complete (or fail)
668
+ await new Promise(resolve => setTimeout(resolve, 500));
669
+
670
+ // Refresh the email to check the status
671
+ await email.refresh();
672
+
673
+ // Check that EmailRecipient records were created correctly
674
+ const recipients = await EmailRecipient.select()
675
+ .where('emailId', email.id)
676
+ .fetch();
677
+
678
+ expect(recipients).toHaveLength(3);
679
+
680
+ // Two recipients with null email for both members
681
+ const recipientsWithoutEmail = recipients.filter(r => r.email === null);
682
+ expect(recipientsWithoutEmail).toHaveLength(2);
683
+
684
+ // Check one extra recipient with the email = memberWithEmail's email
685
+ const recipientWithEmail = recipients.find(r => r.email === userWithEmail.email);
686
+ expect(recipientWithEmail).toBeDefined();
687
+ expect(recipientWithEmail?.memberId).toBe(memberWithEmail.id);
688
+
689
+ for (const recipient of recipients) {
690
+ // Check {{greeting}} replacement
691
+ const greeting = recipient.replacements.find(r => r.token === 'greeting');
692
+ expect(greeting).toBeDefined();
693
+ expect(greeting?.value).toEqual('Dag ' + recipient.firstName + ',');
694
+
695
+ // Check loginDetails replacement includes email address
696
+ const loginDetails = recipient.replacements.find(r => r.token === 'loginDetails');
697
+ expect(loginDetails).toBeDefined();
698
+
699
+ if (recipient.email) {
700
+ expect(loginDetails?.html).toContain(recipient.email || ''); // If no email, won't contain it
701
+ }
702
+ else {
703
+ // Cehck loginDetails is an empty string
704
+ expect(loginDetails?.html).toBe(undefined);
705
+ expect(loginDetails?.value).toBe('');
706
+ }
707
+
708
+ const balanceTable = recipient.replacements.find(r => r.token === 'balanceTable');
709
+ expect(balanceTable).toBeDefined();
710
+ expect(balanceTable?.html).toInclude($t('4c4f6571-f7b5-469d-a16f-b1547b43a610'));
711
+
712
+ // Outstanding balance
713
+ const outstandingBalance = recipient.replacements.find(r => r.token === 'outstandingBalance');
714
+ expect(outstandingBalance).toBeDefined();
715
+ expect(outstandingBalance?.value).toBe(Formatter.price(0));
716
+
717
+ // firstNameMember
718
+ const firstNameMember = recipient.replacements.find(r => r.token === 'firstNameMember');
719
+ expect(firstNameMember).toBeDefined();
720
+ if (recipient.memberId === memberWithEmail.id) {
721
+ expect(firstNameMember?.value).toBe(memberWithEmail.details.firstName);
722
+ }
723
+ else if (recipient.memberId === memberWithoutEmail.id) {
724
+ expect(firstNameMember?.value).toBe(memberWithoutEmail.details.firstName);
725
+ }
726
+ else {
727
+ throw new Error('Recipient has unexpected memberId ' + recipient.memberId);
728
+ }
729
+
730
+ // Check lastNameMember is not present, because it is not used in the html
731
+ const lastNameMember = recipient.replacements.find(r => r.token === 'lastNameMember');
732
+ expect(lastNameMember).toBeUndefined();
733
+ }
734
+ });
735
+
736
+ test('Should merge identical emails', async () => {
737
+ // When you have two members with the same email address
738
+ // test that we only send a single email to this email address as long as we don't include replacements
739
+ // that are different between the members (like firstNameMember)
740
+
741
+ // Create test groups
742
+ const testGroup = await new GroupFactory({
743
+ organization,
744
+ }).create();
745
+
746
+ // Update the user to have group permissions
747
+ if (!user.permissions) {
748
+ user.permissions = UserPermissions.create({});
749
+ }
750
+
751
+ // Get existing organization permissions
752
+ // Create new organization permissions if they don't exist
753
+ user.permissions.organizationPermissions.set(organization.id, Permissions.create({
754
+ level: PermissionLevel.None,
755
+ resources: new Map([
756
+ [PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
757
+ resourceName: sender.name!,
758
+ level: PermissionLevel.None,
759
+ accessRights: [AccessRight.SendMessages],
760
+ })]])],
761
+ [PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
762
+ resourceName: testGroup.settings.name.toString(),
763
+ level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
764
+ accessRights: [],
765
+ })]])],
766
+ ]),
767
+ }));
768
+ await user.save();
769
+
770
+ const member1 = await new MemberFactory({
771
+ organization,
772
+ }).create();
773
+ member1.details.parents = [Parent.create({
774
+ firstName: 'Identical',
775
+ lastName: 'Email',
776
+ email: 'identical-email@test.com',
777
+ })];
778
+
779
+ await member1.save();
780
+
781
+ const member2 = await new MemberFactory({
782
+ organization,
783
+ }).create();
784
+ member2.details.parents = [Parent.create({
785
+ firstName: 'Identical',
786
+ lastName: 'Email',
787
+ email: 'identical-email@test.com',
788
+ })];
789
+ await member2.save();
790
+
791
+ await new RegistrationFactory({
792
+ member: member1,
793
+ group: testGroup,
794
+ }).create();
795
+
796
+ await new RegistrationFactory({
797
+ member: member2,
798
+ group: testGroup,
799
+ }).create();
800
+
801
+ const email = new Email();
802
+ email.subject = 'Test Email with Replacements {{firstName}}';
803
+ email.status = EmailStatus.Draft;
804
+ email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
805
+ email.html = `<!DOCTYPE html>
806
+ <html>
807
+ <head>
808
+ <meta charset="utf-8" />
809
+ <title>Test Email</title>
810
+ </head>
811
+ <body>
812
+ <p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
813
+ <p>Login details: {{loginDetails}}</p>
814
+ <p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
815
+ </body>
816
+ </html>`;
817
+ email.json = {};
818
+
819
+ email.userId = user.id;
820
+ email.organizationId = organization.id;
821
+ email.senderId = sender.id;
822
+
823
+ // Set up recipient filter to target our specific members
824
+ email.recipientFilter = EmailRecipientFilter.create({
825
+ filters: [
826
+ EmailRecipientSubfilter.create({
827
+ type: EmailRecipientFilterType.MemberParents,
828
+ filter: {
829
+ id: { $in: [member1.id, member2.id] },
830
+ },
831
+ }),
832
+ ],
833
+ });
834
+
835
+ await email.save();
836
+
837
+ // First try to build recipients with proper context by calling through the endpoint
838
+ const body = EmailStruct.patch({
839
+ id: email.id,
840
+ status: EmailStatus.Sending,
841
+ });
842
+
843
+ // This should start the recipient building process in the background
844
+ const response = await patchEmail(body, token, organization);
845
+ expect(response.status).toBe(200);
846
+
847
+ // Wait for the background process to complete (or fail)
848
+ await new Promise(resolve => setTimeout(resolve, 500));
849
+
850
+ // Refresh the email to check the status
851
+ await email.refresh();
852
+
853
+ expect(email.status).toBe(EmailStatus.Sent);
854
+
855
+ // Check that EmailRecipient records were created correctly
856
+ const recipients = await EmailRecipient.select()
857
+ .where('emailId', email.id)
858
+ .fetch();
859
+
860
+ expect(recipients).toHaveLength(2);
861
+
862
+ // No recipients with null, because we only targeted parents
863
+ const recipientsWithoutEmail = recipients.filter(r => r.email === null);
864
+ expect(recipientsWithoutEmail).toHaveLength(0);
865
+
866
+ const recipientsWithEmail = recipients.filter(r => r.email === 'identical-email@test.com');
867
+ expect(recipientsWithEmail).toHaveLength(2);
868
+
869
+ // Check sentAt, only one
870
+ const sentRecipients = recipients.filter(r => r.sentAt !== null);
871
+ expect(sentRecipients).toHaveLength(1);
872
+
873
+ // Check one with duplicateOfId set
874
+ const duplicateRecipients = recipientsWithEmail.filter(r => r.duplicateOfRecipientId !== null);
875
+ expect(duplicateRecipients).toHaveLength(1);
876
+ expect(duplicateRecipients[0].duplicateOfRecipientId).toBe(sentRecipients[0].id);
877
+
878
+ // Check only one email is sent
879
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
880
+ });
881
+
882
+ test('[Regression] Should merge identical emails to 3 members', async () => {
883
+ // When you have 3 members with the same email address
884
+ // test that we only send a single email to this email address as long as we don't include replacements
885
+ // that are different between the members (like firstNameMember)
886
+
887
+ // Create test groups
888
+ const testGroup = await new GroupFactory({
889
+ organization,
890
+ }).create();
891
+
892
+ // Update the user to have group permissions
893
+ if (!user.permissions) {
894
+ user.permissions = UserPermissions.create({});
895
+ }
896
+
897
+ // Get existing organization permissions
898
+ // Create new organization permissions if they don't exist
899
+ user.permissions.organizationPermissions.set(organization.id, Permissions.create({
900
+ level: PermissionLevel.None,
901
+ resources: new Map([
902
+ [PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
903
+ resourceName: sender.name!,
904
+ level: PermissionLevel.None,
905
+ accessRights: [AccessRight.SendMessages],
906
+ })]])],
907
+ [PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
908
+ resourceName: testGroup.settings.name.toString(),
909
+ level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
910
+ accessRights: [],
911
+ })]])],
912
+ ]),
913
+ }));
914
+ await user.save();
915
+
916
+ const member1 = await new MemberFactory({
917
+ organization,
918
+ }).create();
919
+ member1.details.parents = [Parent.create({
920
+ firstName: 'Identical',
921
+ lastName: 'Email',
922
+ email: 'identical-email@test.com',
923
+ })];
924
+
925
+ await member1.save();
926
+
927
+ const member2 = await new MemberFactory({
928
+ organization,
929
+ }).create();
930
+ member2.details.parents = [Parent.create({
931
+ firstName: 'Identical',
932
+ lastName: 'Email',
933
+ email: 'identical-email@test.com',
934
+ })];
935
+ await member2.save();
936
+
937
+ const member3 = await new MemberFactory({
938
+ organization,
939
+ }).create();
940
+ member3.details.parents = [Parent.create({
941
+ firstName: 'Identical',
942
+ lastName: 'Email',
943
+ email: 'identical-email@test.com',
944
+ })];
945
+ await member3.save();
946
+
947
+ await new RegistrationFactory({
948
+ member: member1,
949
+ group: testGroup,
950
+ }).create();
951
+
952
+ await new RegistrationFactory({
953
+ member: member2,
954
+ group: testGroup,
955
+ }).create();
956
+
957
+ await new RegistrationFactory({
958
+ member: member3,
959
+ group: testGroup,
960
+ }).create();
961
+
962
+ const email = new Email();
963
+ email.subject = 'Test Email with Replacements {{firstName}}';
964
+ email.status = EmailStatus.Draft;
965
+ email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
966
+ email.html = `<!DOCTYPE html>
967
+ <html>
968
+ <head>
969
+ <meta charset="utf-8" />
970
+ <title>Test Email</title>
971
+ </head>
972
+ <body>
973
+ <p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
974
+ <p>Login details: {{loginDetails}}</p>
975
+ <p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
976
+ </body>
977
+ </html>`;
978
+ email.json = {};
979
+
980
+ email.userId = user.id;
981
+ email.organizationId = organization.id;
982
+ email.senderId = sender.id;
983
+
984
+ // Set up recipient filter to target our specific members
985
+ email.recipientFilter = EmailRecipientFilter.create({
986
+ filters: [
987
+ EmailRecipientSubfilter.create({
988
+ type: EmailRecipientFilterType.MemberParents,
989
+ filter: {
990
+ id: { $in: [member1.id, member2.id, member3.id] },
991
+ },
992
+ }),
993
+ ],
994
+ });
995
+
996
+ await email.save();
997
+
998
+ // First try to build recipients with proper context by calling through the endpoint
999
+ const body = EmailStruct.patch({
1000
+ id: email.id,
1001
+ status: EmailStatus.Sending,
1002
+ });
1003
+
1004
+ // This should start the recipient building process in the background
1005
+ const response = await patchEmail(body, token, organization);
1006
+ expect(response.status).toBe(200);
1007
+
1008
+ // Wait for the background process to complete (or fail)
1009
+ await new Promise(resolve => setTimeout(resolve, 500));
1010
+
1011
+ // Refresh the email to check the status
1012
+ await email.refresh();
1013
+
1014
+ expect(email.status).toBe(EmailStatus.Sent);
1015
+
1016
+ // Check that EmailRecipient records were created correctly
1017
+ const recipients = await EmailRecipient.select()
1018
+ .where('emailId', email.id)
1019
+ .fetch();
1020
+
1021
+ expect(recipients).toHaveLength(3);
1022
+
1023
+ // No recipients with null, because we only targeted parents
1024
+ const recipientsWithoutEmail = recipients.filter(r => r.email === null);
1025
+ expect(recipientsWithoutEmail).toHaveLength(0);
1026
+
1027
+ const recipientsWithEmail = recipients.filter(r => r.email === 'identical-email@test.com');
1028
+ expect(recipientsWithEmail).toHaveLength(3);
1029
+
1030
+ // Check sentAt, only one
1031
+ const sentRecipients = recipients.filter(r => r.sentAt !== null);
1032
+ expect(sentRecipients).toHaveLength(1);
1033
+
1034
+ // Check one with duplicateOfId set
1035
+ const duplicateRecipients = recipientsWithEmail.filter(r => r.duplicateOfRecipientId !== null);
1036
+ expect(duplicateRecipients).toHaveLength(2);
1037
+ expect(duplicateRecipients[0].duplicateOfRecipientId).toBe(sentRecipients[0].id);
1038
+ expect(duplicateRecipients[1].duplicateOfRecipientId).toBe(sentRecipients[0].id);
1039
+
1040
+ // Check only one email is sent
1041
+ expect(await EmailMocker.getSucceededCount()).toBe(1);
1042
+ });
1043
+
1044
+ test('Should not merge emails to same email with different replacements', async () => {
1045
+ // When you have two members with the same email address
1046
+ // test that we only send a single email to this email address as long as we don't include replacements
1047
+ // that are different between the members (like firstNameMember)
1048
+
1049
+ // Create test groups
1050
+ const testGroup = await new GroupFactory({
1051
+ organization,
1052
+ }).create();
1053
+
1054
+ // Update the user to have group permissions
1055
+ if (!user.permissions) {
1056
+ user.permissions = UserPermissions.create({});
1057
+ }
1058
+
1059
+ // Get existing organization permissions
1060
+ // Create new organization permissions if they don't exist
1061
+ user.permissions.organizationPermissions.set(organization.id, Permissions.create({
1062
+ level: PermissionLevel.None,
1063
+ resources: new Map([
1064
+ [PermissionsResourceType.Senders, new Map([[sender.id, ResourcePermissions.create({
1065
+ resourceName: sender.name!,
1066
+ level: PermissionLevel.None,
1067
+ accessRights: [AccessRight.SendMessages],
1068
+ })]])],
1069
+ [PermissionsResourceType.Groups, new Map([[testGroup.id, ResourcePermissions.create({
1070
+ resourceName: testGroup.settings.name.toString(),
1071
+ level: PermissionLevel.Read, // Need at least Read access to access group members through registrations
1072
+ accessRights: [],
1073
+ })]])],
1074
+ ]),
1075
+ }));
1076
+ await user.save();
1077
+
1078
+ const member1 = await new MemberFactory({
1079
+ organization,
1080
+ }).create();
1081
+ member1.details.parents = [Parent.create({
1082
+ firstName: 'Identical',
1083
+ lastName: 'Email',
1084
+ email: 'identical-email@test.com',
1085
+ })];
1086
+
1087
+ await member1.save();
1088
+
1089
+ const member2 = await new MemberFactory({
1090
+ organization,
1091
+ }).create();
1092
+ member2.details.parents = [Parent.create({
1093
+ firstName: 'Identical',
1094
+ lastName: 'Email',
1095
+ email: 'identical-email@test.com',
1096
+ })];
1097
+ await member2.save();
1098
+
1099
+ await new RegistrationFactory({
1100
+ member: member1,
1101
+ group: testGroup,
1102
+ }).create();
1103
+
1104
+ await new RegistrationFactory({
1105
+ member: member2,
1106
+ group: testGroup,
1107
+ }).create();
1108
+
1109
+ const email = new Email();
1110
+ email.subject = 'Test Email with Replacements {{firstName}}';
1111
+ email.status = EmailStatus.Draft;
1112
+ email.text = 'Hello {{firstName}} {{lastName}}, this is a test email. {{unsubscribeUrl}} {{loginDetails}}';
1113
+ email.html = `<!DOCTYPE html>
1114
+ <html>
1115
+ <head>
1116
+ <meta charset="utf-8" />
1117
+ <title>Test Email</title>
1118
+ </head>
1119
+ <body>
1120
+ <p>Hello {{firstName}} {{lastName}}, this is a test email.</p>
1121
+ <p>Login details: {{loginDetails}}</p>
1122
+ <p>Unsubscribe: <a href="{{unsubscribeUrl}}">Click here</a></p>
1123
+ <p>Member firstname: {{firstNameMember}}</p>
1124
+ </body>
1125
+ </html>`;
1126
+ email.json = {};
1127
+
1128
+ email.userId = user.id;
1129
+ email.organizationId = organization.id;
1130
+ email.senderId = sender.id;
1131
+
1132
+ // Set up recipient filter to target our specific members
1133
+ email.recipientFilter = EmailRecipientFilter.create({
1134
+ filters: [
1135
+ EmailRecipientSubfilter.create({
1136
+ type: EmailRecipientFilterType.MemberParents,
1137
+ filter: {
1138
+ id: { $in: [member1.id, member2.id] },
1139
+ },
1140
+ }),
1141
+ ],
1142
+ });
1143
+
1144
+ await email.save();
1145
+
1146
+ // First try to build recipients with proper context by calling through the endpoint
1147
+ const body = EmailStruct.patch({
1148
+ id: email.id,
1149
+ status: EmailStatus.Sending,
1150
+ });
1151
+
1152
+ // This should start the recipient building process in the background
1153
+ const response = await patchEmail(body, token, organization);
1154
+ expect(response.status).toBe(200);
1155
+
1156
+ // Wait for the background process to complete (or fail)
1157
+ await new Promise(resolve => setTimeout(resolve, 500));
1158
+
1159
+ // Refresh the email to check the status
1160
+ await email.refresh();
1161
+
1162
+ expect(email.status).toBe(EmailStatus.Sent);
1163
+
1164
+ // Check that EmailRecipient records were created correctly
1165
+ const recipients = await EmailRecipient.select()
1166
+ .where('emailId', email.id)
1167
+ .fetch();
1168
+
1169
+ expect(recipients).toHaveLength(2);
1170
+
1171
+ // No recipients with null, because we only targeted parents
1172
+ const recipientsWithoutEmail = recipients.filter(r => r.email === null);
1173
+ expect(recipientsWithoutEmail).toHaveLength(0);
1174
+
1175
+ const recipientsWithEmail = recipients.filter(r => r.email === 'identical-email@test.com');
1176
+ expect(recipientsWithEmail).toHaveLength(2);
1177
+
1178
+ // Check sentAt, only one
1179
+ const sentRecipients = recipients.filter(r => r.sentAt !== null);
1180
+ expect(sentRecipients).toHaveLength(2);
1181
+
1182
+ // Check one with duplicateOfId set
1183
+ const duplicateRecipients = recipientsWithEmail.filter(r => r.duplicateOfRecipientId !== null);
1184
+ expect(duplicateRecipients).toHaveLength(0);
1185
+
1186
+ // Check only one email is sent
1187
+ expect(await EmailMocker.getSucceededCount()).toBe(2);
1188
+
1189
+ // Check content of each email is correct for each member. One should contain member1.firstName, other member2.firstName
1190
+ const sentEmails = await EmailMocker.getSucceededEmails();
1191
+ expect(sentEmails).toHaveLength(2);
1192
+ const emailWithMember1FirstName = sentEmails.find(e => e.html?.includes(member1.details.firstName!));
1193
+ const emailWithMember2FirstName = sentEmails.find(e => e.html?.includes(member2.details.firstName!));
1194
+ expect(emailWithMember1FirstName).toBeDefined();
1195
+ expect(emailWithMember2FirstName).toBeDefined();
1196
+
1197
+ expect(emailWithMember1FirstName).not.toBe(emailWithMember2FirstName);
1198
+
1199
+ // Check all emails contain Identical Email (name of the parent) in the body
1200
+ for (const sentEmail of sentEmails) {
1201
+ expect(sentEmail.html).toContain('Identical Email');
1202
+ }
1203
+ });
535
1204
  });