@stamhoofd/backend 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.97.3",
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.97.3",
49
- "@stamhoofd/backend-middleware": "2.97.3",
50
- "@stamhoofd/email": "2.97.3",
51
- "@stamhoofd/models": "2.97.3",
52
- "@stamhoofd/queues": "2.97.3",
53
- "@stamhoofd/sql": "2.97.3",
54
- "@stamhoofd/structures": "2.97.3",
55
- "@stamhoofd/utility": "2.97.3",
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": "254bc1658dc444653db96e7e4197b05035907a44"
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
- const email = await EmailAddress.getByID(request.query.id);
39
- if (!email || email.token !== request.query.token || request.query.token.length < 10 || request.query.id.length < 10) {
40
- throw new SimpleError({
41
- code: 'invalid_fields',
42
- message: 'Invalid token or id',
43
- human: $t(`ceacb5a8-7777-4366-abcb-9dd90ffb832e`),
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
- const organization = email.organizationId ? (await Organization.getByID(email.organizationId)) : undefined;
48
- return new Response(EmailAddressSettings.create({
49
- ...email,
50
- organization: organization ? OrganizationSimple.create(organization) : null,
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
  }
@@ -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
- const email = await EmailAddress.getByID(request.body.id);
43
- if (!email || email.token !== request.body.token || request.body.token.length < 10 || request.body.id.length < 10) {
44
- throw new SimpleError({
45
- code: 'invalid_fields',
46
- message: 'Invalid token or id',
47
- human: $t(`ceacb5a8-7777-4366-abcb-9dd90ffb832e`),
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
- email.unsubscribedAll = request.body.unsubscribedAll ?? email.unsubscribedAll;
52
- email.unsubscribedMarketing = request.body.unsubscribedMarketing ?? email.unsubscribedMarketing;
91
+ const organization = await Context.setOptionalOrganizationScope();
92
+ await Context.authenticate();
53
93
 
54
- await email.save();
55
- return new Response(undefined);
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
+ }