@stamhoofd/backend 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.
- package/package.json +10 -10
- package/src/endpoints/global/email/GetEmailAddressEndpoint.ts +76 -16
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +144 -12
- package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +103 -15
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.98.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -45,14 +45,14 @@
|
|
|
45
45
|
"@simonbackx/simple-encoding": "2.22.0",
|
|
46
46
|
"@simonbackx/simple-endpoints": "1.20.1",
|
|
47
47
|
"@simonbackx/simple-logging": "^1.0.1",
|
|
48
|
-
"@stamhoofd/backend-i18n": "2.
|
|
49
|
-
"@stamhoofd/backend-middleware": "2.
|
|
50
|
-
"@stamhoofd/email": "2.
|
|
51
|
-
"@stamhoofd/models": "2.
|
|
52
|
-
"@stamhoofd/queues": "2.
|
|
53
|
-
"@stamhoofd/sql": "2.
|
|
54
|
-
"@stamhoofd/structures": "2.
|
|
55
|
-
"@stamhoofd/utility": "2.
|
|
48
|
+
"@stamhoofd/backend-i18n": "2.98.0",
|
|
49
|
+
"@stamhoofd/backend-middleware": "2.98.0",
|
|
50
|
+
"@stamhoofd/email": "2.98.0",
|
|
51
|
+
"@stamhoofd/models": "2.98.0",
|
|
52
|
+
"@stamhoofd/queues": "2.98.0",
|
|
53
|
+
"@stamhoofd/sql": "2.98.0",
|
|
54
|
+
"@stamhoofd/structures": "2.98.0",
|
|
55
|
+
"@stamhoofd/utility": "2.98.0",
|
|
56
56
|
"archiver": "^7.0.1",
|
|
57
57
|
"axios": "^1.8.2",
|
|
58
58
|
"cookie": "^0.7.0",
|
|
@@ -70,5 +70,5 @@
|
|
|
70
70
|
"publishConfig": {
|
|
71
71
|
"access": "public"
|
|
72
72
|
},
|
|
73
|
-
"gitHead": "
|
|
73
|
+
"gitHead": "d15c1756a1e10788fa2736efcc6abbc2e3f365a6"
|
|
74
74
|
}
|
|
@@ -4,16 +4,20 @@ import { SimpleError } from '@simonbackx/simple-errors';
|
|
|
4
4
|
import { EmailAddress } from '@stamhoofd/email';
|
|
5
5
|
import { Organization } from '@stamhoofd/models';
|
|
6
6
|
import { EmailAddressSettings, OrganizationSimple } from '@stamhoofd/structures';
|
|
7
|
+
import { Context } from '../../../helpers/Context';
|
|
7
8
|
|
|
8
9
|
type Params = Record<string, never>;
|
|
9
10
|
type Body = undefined;
|
|
10
11
|
|
|
11
12
|
class Query extends AutoEncoder {
|
|
12
|
-
@field({ decoder: StringDecoder })
|
|
13
|
-
id: string;
|
|
13
|
+
@field({ decoder: StringDecoder, nullable: true, optional: true })
|
|
14
|
+
id: string | null = null;
|
|
14
15
|
|
|
15
|
-
@field({ decoder: StringDecoder })
|
|
16
|
-
token: string;
|
|
16
|
+
@field({ decoder: StringDecoder, nullable: true, optional: true })
|
|
17
|
+
token: string | null = null;
|
|
18
|
+
|
|
19
|
+
@field({ decoder: StringDecoder, nullable: true, optional: true })
|
|
20
|
+
email: string | null = null;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
type ResponseBody = EmailAddressSettings;
|
|
@@ -35,19 +39,75 @@ export class GetEmailAddressEndpoint extends Endpoint<Params, Query, Body, Respo
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
if (request.query.id) {
|
|
43
|
+
if (!request.query.token) {
|
|
44
|
+
throw new SimpleError({
|
|
45
|
+
code: 'missing_field',
|
|
46
|
+
message: 'Missing token',
|
|
47
|
+
field: 'token',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const email = await EmailAddress.getByID(request.query.id);
|
|
52
|
+
if (!email || email.token !== request.query.token || request.query.token.length < 10 || request.query.id.length < 10) {
|
|
53
|
+
throw new SimpleError({
|
|
54
|
+
code: 'invalid_fields',
|
|
55
|
+
message: 'Invalid token or id',
|
|
56
|
+
human: $t(`ceacb5a8-7777-4366-abcb-9dd90ffb832e`),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const organization = email.organizationId ? (await Organization.getByID(email.organizationId)) : undefined;
|
|
61
|
+
return new Response(EmailAddressSettings.create({
|
|
62
|
+
...email,
|
|
63
|
+
organization: organization ? OrganizationSimple.create(organization) : null,
|
|
64
|
+
}));
|
|
45
65
|
}
|
|
66
|
+
else {
|
|
67
|
+
if (!request.query.email) {
|
|
68
|
+
throw new SimpleError({
|
|
69
|
+
code: 'missing_field',
|
|
70
|
+
message: 'Missing email or id',
|
|
71
|
+
field: 'email',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
76
|
+
await Context.authenticate();
|
|
77
|
+
|
|
78
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
79
|
+
throw Context.auth.error();
|
|
80
|
+
}
|
|
46
81
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
82
|
+
const query = EmailAddress.select().where(
|
|
83
|
+
'email', request.query.email,
|
|
84
|
+
);
|
|
85
|
+
if (organization) {
|
|
86
|
+
query.andWhere('organizationId', organization.id);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// No need
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const emails = await query.fetch();
|
|
93
|
+
|
|
94
|
+
if (emails.length === 0) {
|
|
95
|
+
throw new SimpleError({
|
|
96
|
+
code: 'not_found',
|
|
97
|
+
message: 'Email not found',
|
|
98
|
+
human: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
|
|
99
|
+
statusCode: 404,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return new Response(EmailAddressSettings.create({
|
|
104
|
+
email: request.query.email,
|
|
105
|
+
unsubscribedAll: !!emails.find(e => e.unsubscribedAll),
|
|
106
|
+
unsubscribedMarketing: !!emails.find(e => e.unsubscribedMarketing),
|
|
107
|
+
hardBounce: !!emails.find(e => e.hardBounce),
|
|
108
|
+
markedAsSpam: !!emails.find(e => e.markedAsSpam),
|
|
109
|
+
organization: organization ? OrganizationSimple.create(organization) : null,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
52
112
|
}
|
|
53
113
|
}
|
|
@@ -40,6 +40,11 @@ describe('Endpoint.GetUserEmails', () => {
|
|
|
40
40
|
userToken = await Token.createToken(user);
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
// Delete all emails
|
|
45
|
+
await Email.delete();
|
|
46
|
+
});
|
|
47
|
+
|
|
43
48
|
const getUserEmails = async (query: LimitedFilteredRequest = new LimitedFilteredRequest({ limit: 10 }), token: Token = userToken, testOrganization: Organization = organization) => {
|
|
44
49
|
const request = Request.get({
|
|
45
50
|
path: baseUrl,
|
|
@@ -379,8 +384,7 @@ describe('Endpoint.GetUserEmails', () => {
|
|
|
379
384
|
|
|
380
385
|
// Should prefer the exact match recipient
|
|
381
386
|
expect(emailResult.recipients).toHaveLength(1);
|
|
382
|
-
expect(emailResult.recipients[0].
|
|
383
|
-
expect(emailResult.recipients[0].lastName).toBe('Match');
|
|
387
|
+
expect(emailResult.recipients[0].id).toBe(exactMatchRecipient.id);
|
|
384
388
|
});
|
|
385
389
|
|
|
386
390
|
test('Should return generic data when recipient has no matching user id or email', async () => {
|
|
@@ -420,12 +424,7 @@ describe('Endpoint.GetUserEmails', () => {
|
|
|
420
424
|
|
|
421
425
|
// Should return the generic recipient data
|
|
422
426
|
expect(emailResult.recipients).toHaveLength(1);
|
|
423
|
-
expect(emailResult.recipients[0].
|
|
424
|
-
expect(emailResult.recipients[0].lastName).toBe('Member');
|
|
425
|
-
// The original recipient struct keeps its original userId and email
|
|
426
|
-
expect(emailResult.recipients[0].userId).toBe(null); // Original was null
|
|
427
|
-
expect(emailResult.recipients[0].email).toBe(null); // Original was null
|
|
428
|
-
// But replacements should be generated for the current user (tested below)
|
|
427
|
+
expect(emailResult.recipients[0].id).toBe(genericRecipient.id);
|
|
429
428
|
expect(emailResult.recipients[0].replacements).toBeDefined();
|
|
430
429
|
});
|
|
431
430
|
|
|
@@ -440,7 +439,7 @@ describe('Endpoint.GetUserEmails', () => {
|
|
|
440
439
|
email.subject = 'Sensitive Data Email';
|
|
441
440
|
email.status = EmailStatus.Sent;
|
|
442
441
|
email.text = 'Email with sensitive replacements {{outstandingBalance}} {{loginDetails}} {{unsubscribeUrl}}';
|
|
443
|
-
email.html = '<p>Email with sensitive replacements {{outstandingBalance}} {{loginDetails}} {{unsubscribeUrl}}</p>';
|
|
442
|
+
email.html = '<p>Email with sensitive replacements {{outstandingBalance}} {{loginDetails}} {{unsubscribeUrl}}</p>{{balanceTable}}';
|
|
444
443
|
email.json = {};
|
|
445
444
|
email.organizationId = organization.id;
|
|
446
445
|
email.showInMemberPortal = true;
|
|
@@ -498,9 +497,9 @@ describe('Endpoint.GetUserEmails', () => {
|
|
|
498
497
|
expect(emailResult.recipients).toHaveLength(1);
|
|
499
498
|
const recipient = emailResult.recipients[0];
|
|
500
499
|
|
|
501
|
-
// The original recipient struct keeps its original userId and email from the sensitive user
|
|
502
|
-
expect(recipient.userId).toBe(
|
|
503
|
-
expect(recipient.email).toBe(
|
|
500
|
+
// The original recipient struct keeps its original userId and email from the sensitive user, but returns different data:
|
|
501
|
+
expect(recipient.userId).toBe(user.id); // new userId
|
|
502
|
+
expect(recipient.email).toBe(user.email); // new email
|
|
504
503
|
|
|
505
504
|
// Verify that sensitive data has been properly processed
|
|
506
505
|
expect(recipient.replacements).toBeDefined();
|
|
@@ -552,6 +551,139 @@ describe('Endpoint.GetUserEmails', () => {
|
|
|
552
551
|
expect(balanceTableReplacement!.html).toBe('<p class="description">' + $t('4c4f6571-f7b5-469d-a16f-b1547b43a610') + '</p>');
|
|
553
552
|
});
|
|
554
553
|
|
|
554
|
+
test('Should return one recipient for each member the user is associated with, if the email is different for each member', async () => {
|
|
555
|
+
// Create another member associated with the same user
|
|
556
|
+
const secondMember = await new MemberFactory({
|
|
557
|
+
organization,
|
|
558
|
+
user,
|
|
559
|
+
}).create();
|
|
560
|
+
|
|
561
|
+
// Create an email
|
|
562
|
+
const email = new Email();
|
|
563
|
+
email.subject = 'Email for Multiple Members';
|
|
564
|
+
email.status = EmailStatus.Sent;
|
|
565
|
+
email.text = 'Member name: {{memberFirstName}}';
|
|
566
|
+
email.html = '<p>Member name: {{memberFirstName}}</p>';
|
|
567
|
+
email.json = {};
|
|
568
|
+
email.organizationId = organization.id;
|
|
569
|
+
email.showInMemberPortal = true;
|
|
570
|
+
email.sentAt = new Date();
|
|
571
|
+
await email.save();
|
|
572
|
+
|
|
573
|
+
// Create an email recipient linked to the FIRST member
|
|
574
|
+
const recipient1 = new EmailRecipient();
|
|
575
|
+
recipient1.emailId = email.id;
|
|
576
|
+
recipient1.memberId = member.id; // First member
|
|
577
|
+
recipient1.userId = user.id;
|
|
578
|
+
recipient1.email = user.email;
|
|
579
|
+
recipient1.firstName = member.details.firstName;
|
|
580
|
+
recipient1.lastName = member.details.lastName;
|
|
581
|
+
recipient1.replacements = [
|
|
582
|
+
Replacement.create({
|
|
583
|
+
token: 'memberFirstName',
|
|
584
|
+
value: member.details.firstName,
|
|
585
|
+
}),
|
|
586
|
+
];
|
|
587
|
+
recipient1.sentAt = new Date();
|
|
588
|
+
await recipient1.save();
|
|
589
|
+
|
|
590
|
+
// Create an email recipient linked to the SECOND member
|
|
591
|
+
const recipient2 = new EmailRecipient();
|
|
592
|
+
recipient2.emailId = email.id;
|
|
593
|
+
recipient2.memberId = secondMember.id; // Second member
|
|
594
|
+
recipient2.userId = user.id;
|
|
595
|
+
recipient2.email = user.email;
|
|
596
|
+
recipient2.firstName = secondMember.details.firstName;
|
|
597
|
+
recipient2.lastName = secondMember.details.lastName;
|
|
598
|
+
recipient2.sentAt = new Date();
|
|
599
|
+
recipient2.replacements = [
|
|
600
|
+
Replacement.create({
|
|
601
|
+
token: 'memberFirstName',
|
|
602
|
+
value: secondMember.details.firstName,
|
|
603
|
+
}),
|
|
604
|
+
];
|
|
605
|
+
await recipient2.save();
|
|
606
|
+
|
|
607
|
+
const response = await getUserEmails(
|
|
608
|
+
new LimitedFilteredRequest({ limit: 10, search: 'Email for Multiple Members' }),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
expect(response.body.results).toHaveLength(1);
|
|
612
|
+
const emailResult = response.body.results[0];
|
|
613
|
+
|
|
614
|
+
// Should include both members as separate recipients
|
|
615
|
+
expect(emailResult.recipients).toHaveLength(2);
|
|
616
|
+
const firstNames = emailResult.recipients.map(r => r.member?.firstName);
|
|
617
|
+
expect(firstNames).toContain(member.details.firstName);
|
|
618
|
+
expect(firstNames).toContain(secondMember.details.firstName);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
test('Should return a merged recipient for each member the user is associated with, if the email is the same for each member', async () => {
|
|
622
|
+
// Create another member associated with the same user
|
|
623
|
+
const secondMember = await new MemberFactory({
|
|
624
|
+
organization,
|
|
625
|
+
user,
|
|
626
|
+
}).create();
|
|
627
|
+
|
|
628
|
+
// Create an email
|
|
629
|
+
const email = new Email();
|
|
630
|
+
email.subject = 'Email for Multiple Members';
|
|
631
|
+
email.status = EmailStatus.Sent;
|
|
632
|
+
email.text = 'Same content';
|
|
633
|
+
email.html = '<p>Same content</p>';
|
|
634
|
+
email.json = {};
|
|
635
|
+
email.organizationId = organization.id;
|
|
636
|
+
email.showInMemberPortal = true;
|
|
637
|
+
email.sentAt = new Date();
|
|
638
|
+
await email.save();
|
|
639
|
+
|
|
640
|
+
// Create an email recipient linked to the FIRST member
|
|
641
|
+
const recipient1 = new EmailRecipient();
|
|
642
|
+
recipient1.emailId = email.id;
|
|
643
|
+
recipient1.memberId = member.id; // First member
|
|
644
|
+
recipient1.userId = user.id;
|
|
645
|
+
recipient1.email = user.email;
|
|
646
|
+
recipient1.firstName = member.details.firstName;
|
|
647
|
+
recipient1.lastName = member.details.lastName;
|
|
648
|
+
recipient1.replacements = [
|
|
649
|
+
// will get automatically removed because it is not used
|
|
650
|
+
Replacement.create({
|
|
651
|
+
token: 'memberFirstName',
|
|
652
|
+
value: member.details.firstName,
|
|
653
|
+
}),
|
|
654
|
+
];
|
|
655
|
+
recipient1.sentAt = new Date();
|
|
656
|
+
await recipient1.save();
|
|
657
|
+
|
|
658
|
+
// Create an email recipient linked to the SECOND member
|
|
659
|
+
const recipient2 = new EmailRecipient();
|
|
660
|
+
recipient2.emailId = email.id;
|
|
661
|
+
recipient2.memberId = secondMember.id; // Second member
|
|
662
|
+
recipient2.userId = user.id;
|
|
663
|
+
recipient2.email = user.email;
|
|
664
|
+
recipient2.firstName = secondMember.details.firstName;
|
|
665
|
+
recipient2.lastName = secondMember.details.lastName;
|
|
666
|
+
recipient2.sentAt = new Date();
|
|
667
|
+
recipient2.replacements = [
|
|
668
|
+
// will get automatically removed because it is not used
|
|
669
|
+
Replacement.create({
|
|
670
|
+
token: 'memberFirstName',
|
|
671
|
+
value: secondMember.details.firstName,
|
|
672
|
+
}),
|
|
673
|
+
];
|
|
674
|
+
await recipient2.save();
|
|
675
|
+
|
|
676
|
+
const response = await getUserEmails(
|
|
677
|
+
new LimitedFilteredRequest({ limit: 10, search: 'Email for Multiple Members' }),
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
expect(response.body.results).toHaveLength(1);
|
|
681
|
+
const emailResult = response.body.results[0];
|
|
682
|
+
|
|
683
|
+
// Should include both members as separate recipients
|
|
684
|
+
expect(emailResult.recipients).toHaveLength(1);
|
|
685
|
+
});
|
|
686
|
+
|
|
555
687
|
test('Should not return emails from other members the user does not have access to', async () => {
|
|
556
688
|
// Create another user and member
|
|
557
689
|
const otherUser = await new UserFactory({
|
|
@@ -2,22 +2,39 @@ import { AutoEncoder, BooleanDecoder, Decoder, field, StringDecoder } from '@sim
|
|
|
2
2
|
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
3
3
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
4
4
|
import { EmailAddress } from '@stamhoofd/email';
|
|
5
|
+
import { Context } from '../../../helpers/Context';
|
|
6
|
+
import { SESv2Client, DeleteSuppressedDestinationCommand } from '@aws-sdk/client-sesv2'; // ES Modules import
|
|
5
7
|
|
|
6
8
|
type Params = Record<string, never>;
|
|
7
9
|
type Query = undefined;
|
|
8
10
|
|
|
9
11
|
class Body extends AutoEncoder {
|
|
10
|
-
@field({ decoder: StringDecoder })
|
|
11
|
-
id: string;
|
|
12
|
+
@field({ decoder: StringDecoder, nullable: true, optional: true })
|
|
13
|
+
id: string | null = null;
|
|
12
14
|
|
|
13
|
-
@field({ decoder: StringDecoder })
|
|
14
|
-
token: string;
|
|
15
|
+
@field({ decoder: StringDecoder, nullable: true, optional: true })
|
|
16
|
+
token: string | null = null;
|
|
17
|
+
|
|
18
|
+
@field({ decoder: StringDecoder, nullable: true, optional: true })
|
|
19
|
+
email: string | null = null;
|
|
15
20
|
|
|
16
21
|
@field({ decoder: BooleanDecoder, optional: true })
|
|
17
22
|
unsubscribedMarketing?: boolean;
|
|
18
23
|
|
|
19
24
|
@field({ decoder: BooleanDecoder, optional: true })
|
|
20
25
|
unsubscribedAll?: boolean;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Set to false to unblock
|
|
29
|
+
*/
|
|
30
|
+
@field({ decoder: BooleanDecoder, optional: true })
|
|
31
|
+
hardBounce?: boolean;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Set to false to unblock
|
|
35
|
+
*/
|
|
36
|
+
@field({ decoder: BooleanDecoder, optional: true })
|
|
37
|
+
markedAsSpam?: boolean;
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
type ResponseBody = undefined;
|
|
@@ -39,19 +56,90 @@ export class ManageEmailAddressEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
59
|
+
if (request.body.id) {
|
|
60
|
+
if (!request.body.token) {
|
|
61
|
+
throw new SimpleError({
|
|
62
|
+
code: 'missing_field',
|
|
63
|
+
message: 'Missing token',
|
|
64
|
+
field: 'token',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
const email = await EmailAddress.getByID(request.body.id);
|
|
68
|
+
if (!email || email.token !== request.body.token || request.body.token.length < 10 || request.body.id.length < 10) {
|
|
69
|
+
throw new SimpleError({
|
|
70
|
+
code: 'invalid_fields',
|
|
71
|
+
message: 'Invalid token or id',
|
|
72
|
+
human: $t(`ceacb5a8-7777-4366-abcb-9dd90ffb832e`),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
email.unsubscribedAll = request.body.unsubscribedAll ?? email.unsubscribedAll;
|
|
77
|
+
email.unsubscribedMarketing = request.body.unsubscribedMarketing ?? email.unsubscribedMarketing;
|
|
78
|
+
|
|
79
|
+
await email.save();
|
|
80
|
+
return new Response(undefined);
|
|
49
81
|
}
|
|
82
|
+
else {
|
|
83
|
+
if (!request.body.email) {
|
|
84
|
+
throw new SimpleError({
|
|
85
|
+
code: 'missing_field',
|
|
86
|
+
message: 'Missing email or id',
|
|
87
|
+
field: 'email',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
50
90
|
|
|
51
|
-
|
|
52
|
-
|
|
91
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
92
|
+
await Context.authenticate();
|
|
53
93
|
|
|
54
|
-
|
|
55
|
-
|
|
94
|
+
if (!Context.auth.hasPlatformFullAccess()) {
|
|
95
|
+
throw Context.auth.error();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const query = EmailAddress.select().where(
|
|
99
|
+
'email', request.body.email,
|
|
100
|
+
);
|
|
101
|
+
if (organization) {
|
|
102
|
+
query.andWhere('organizationId', organization.id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const emails = await query.fetch();
|
|
106
|
+
|
|
107
|
+
if (emails.length === 0) {
|
|
108
|
+
throw new SimpleError({
|
|
109
|
+
code: 'not_found',
|
|
110
|
+
message: 'Email not found',
|
|
111
|
+
human: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
|
|
112
|
+
statusCode: 404,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
console.log('Updated email address settings', request.body);
|
|
116
|
+
const client = new SESv2Client({});
|
|
117
|
+
|
|
118
|
+
if (request.body.hardBounce === false || request.body.markedAsSpam === false) {
|
|
119
|
+
// Remove from AWS suppression list
|
|
120
|
+
// todo
|
|
121
|
+
try {
|
|
122
|
+
const input = {
|
|
123
|
+
EmailAddress: request.body.email,
|
|
124
|
+
};
|
|
125
|
+
const command = new DeleteSuppressedDestinationCommand(input);
|
|
126
|
+
const response = await client.send(command);
|
|
127
|
+
console.log('Removed from AWS suppression list', request.body.email, response);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
console.error('Error removing from suppression list', request.body.email, error);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const email of emails) {
|
|
135
|
+
email.unsubscribedAll = request.body.unsubscribedAll ?? email.unsubscribedAll;
|
|
136
|
+
email.unsubscribedMarketing = request.body.unsubscribedMarketing ?? email.unsubscribedMarketing;
|
|
137
|
+
email.hardBounce = request.body.hardBounce ?? email.hardBounce;
|
|
138
|
+
email.markedAsSpam = request.body.markedAsSpam ?? email.markedAsSpam;
|
|
139
|
+
await email.save();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return new Response(undefined);
|
|
143
|
+
}
|
|
56
144
|
}
|
|
57
145
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Email, EmailRecipient } from '@stamhoofd/models';
|
|
3
|
+
import { EmailRecipient as EmailRecipientStruct, EmailStatus, PermissionLevel } from '@stamhoofd/structures';
|
|
4
|
+
|
|
5
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
6
|
+
import { Context } from '../../../helpers/Context';
|
|
7
|
+
|
|
8
|
+
type Params = { id: string };
|
|
9
|
+
type Query = undefined;
|
|
10
|
+
type Body = undefined;
|
|
11
|
+
type ResponseBody = EmailRecipientStruct;
|
|
12
|
+
|
|
13
|
+
export class RetryEmailRecipientEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
|
|
14
|
+
protected doesMatch(request: Request): [true, Params] | [false] {
|
|
15
|
+
if (request.method !== 'POST') {
|
|
16
|
+
return [false];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const params = Endpoint.parseParameters(request.url, '/email-recipients/@id/retry', { id: String });
|
|
20
|
+
|
|
21
|
+
if (params) {
|
|
22
|
+
return [true, params as Params];
|
|
23
|
+
}
|
|
24
|
+
return [false];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async handle(request: DecodedRequest<Params, Query, Body>) {
|
|
28
|
+
const organization = await Context.setOptionalOrganizationScope();
|
|
29
|
+
await Context.authenticate();
|
|
30
|
+
|
|
31
|
+
if (!await Context.auth.canReadEmails(organization)) {
|
|
32
|
+
// Fast fail before query
|
|
33
|
+
throw Context.auth.error();
|
|
34
|
+
}
|
|
35
|
+
const emailRecipient = await EmailRecipient.getByID(request.params.id);
|
|
36
|
+
if (!emailRecipient || emailRecipient.organizationId !== (organization?.id ?? null)) {
|
|
37
|
+
throw new SimpleError({
|
|
38
|
+
code: 'not_found',
|
|
39
|
+
message: 'Email recipient not found',
|
|
40
|
+
human: $t(`2e79bf4a-878b-43b0-902b-9d080c8b7fdf`),
|
|
41
|
+
statusCode: 404,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const model = await Email.getByID(emailRecipient.emailId);
|
|
46
|
+
if (!model || (model.organizationId !== (organization?.id ?? null))) {
|
|
47
|
+
throw new SimpleError({
|
|
48
|
+
code: 'not_found',
|
|
49
|
+
message: 'Email not found',
|
|
50
|
+
human: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
|
|
51
|
+
statusCode: 404,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!await Context.auth.canAccessEmail(model, PermissionLevel.Write)) {
|
|
56
|
+
throw Context.auth.error();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (model.status !== EmailStatus.Sent) {
|
|
60
|
+
throw new SimpleError({
|
|
61
|
+
code: 'not_sent',
|
|
62
|
+
message: 'Cant retry email that is not sent',
|
|
63
|
+
statusCode: 400,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (emailRecipient.sentAt) {
|
|
68
|
+
throw new SimpleError({
|
|
69
|
+
code: 'already_sent',
|
|
70
|
+
message: 'Cant retry email recipient that is already sent',
|
|
71
|
+
human: $t(`f3f837eb-d6f7-4c6e-9477-d566a03e09b1`),
|
|
72
|
+
statusCode: 400,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Retry
|
|
77
|
+
await model.resumeSending(emailRecipient.id);
|
|
78
|
+
|
|
79
|
+
await emailRecipient.refresh();
|
|
80
|
+
|
|
81
|
+
return new Response((await EmailRecipient.getStructures([emailRecipient]))[0]);
|
|
82
|
+
}
|
|
83
|
+
}
|