@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.
- package/package.json +10 -10
- package/src/audit-logs/EmailLogger.ts +27 -22
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +8 -0
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +174 -0
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -2
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +623 -0
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +672 -3
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +28 -33
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +167 -0
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +1 -1
- package/src/endpoints/global/email-recipients/helpers/validateEmailRecipientFilter.ts +21 -4
- package/src/helpers/AdminPermissionChecker.ts +8 -3
- package/src/helpers/AuthenticatedStructures.ts +5 -3
- package/src/helpers/Context.ts +3 -3
- package/src/seeds/1755790070-fill-email-recipient-errors.ts +3 -3
- package/src/sql-filters/members.ts +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
|
|
2
2
|
import { Email, Platform } from '@stamhoofd/models';
|
|
3
|
-
import { EmailPreview,
|
|
3
|
+
import { EmailPreview, EmailStatus, Email as EmailStruct, PermissionLevel } from '@stamhoofd/structures';
|
|
4
4
|
|
|
5
5
|
import { AutoEncoderPatchType, Decoder, patchObject } from '@simonbackx/simple-encoding';
|
|
6
6
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
@@ -40,8 +40,8 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
40
40
|
if (!model || (model.organizationId !== (organization?.id ?? null))) {
|
|
41
41
|
throw new SimpleError({
|
|
42
42
|
code: 'not_found',
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
message: 'Email not found',
|
|
44
|
+
human: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
|
|
45
45
|
statusCode: 404,
|
|
46
46
|
});
|
|
47
47
|
}
|
|
@@ -118,8 +118,8 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
118
118
|
if (model.status !== EmailStatus.Draft) {
|
|
119
119
|
throw new SimpleError({
|
|
120
120
|
code: 'not_draft',
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
message: 'Email is not a draft',
|
|
122
|
+
human: $t(`ace4d2e8-88d6-479f-bd8b-d576cc0ed1f2`),
|
|
123
123
|
statusCode: 400,
|
|
124
124
|
});
|
|
125
125
|
}
|
|
@@ -128,6 +128,29 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
128
128
|
rebuild = true;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
if (request.body.sendAsEmail !== undefined) {
|
|
132
|
+
if (model.status !== EmailStatus.Draft) {
|
|
133
|
+
throw new SimpleError({
|
|
134
|
+
code: 'not_draft',
|
|
135
|
+
message: 'Email is not a draft',
|
|
136
|
+
human: $t(`02b05c0d-908b-4200-8fb8-5fc01f539514`),
|
|
137
|
+
statusCode: 400,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
model.sendAsEmail = request.body.sendAsEmail ?? true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (request.body.showInMemberPortal !== undefined) {
|
|
145
|
+
model.showInMemberPortal = request.body.showInMemberPortal ?? false;
|
|
146
|
+
|
|
147
|
+
if (model.showInMemberPortal) {
|
|
148
|
+
if (!model.recipientFilter.canShowInMemberPortal) {
|
|
149
|
+
model.showInMemberPortal = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
// Attachments
|
|
132
155
|
if (request.body.attachments !== undefined) {
|
|
133
156
|
model.attachments = patchObject(model.attachments, request.body.attachments);
|
|
@@ -159,34 +182,6 @@ export class PatchEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBo
|
|
|
159
182
|
});
|
|
160
183
|
}
|
|
161
184
|
|
|
162
|
-
model.throwIfNotReadyToSend();
|
|
163
|
-
|
|
164
|
-
const replacement = '{{unsubscribeUrl}}';
|
|
165
|
-
|
|
166
|
-
if (model.html) {
|
|
167
|
-
// Check email contains an unsubscribe button
|
|
168
|
-
if (!model.html.includes(replacement)) {
|
|
169
|
-
throw new SimpleError({
|
|
170
|
-
code: 'missing_unsubscribe_button',
|
|
171
|
-
message: 'Missing unsubscribe button',
|
|
172
|
-
human: $t(`dd55e04b-e5d9-4d9a-befc-443eef4175a8`),
|
|
173
|
-
field: 'html',
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (model.text) {
|
|
179
|
-
// Check email contains an unsubscribe button
|
|
180
|
-
if (!model.text.includes(replacement)) {
|
|
181
|
-
throw new SimpleError({
|
|
182
|
-
code: 'missing_unsubscribe_button',
|
|
183
|
-
message: 'Missing unsubscribe button',
|
|
184
|
-
human: $t(`dd55e04b-e5d9-4d9a-befc-443eef4175a8`),
|
|
185
|
-
field: 'text',
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
185
|
// Preview the sending status
|
|
191
186
|
await model.queueForSending();
|
|
192
187
|
}
|
|
@@ -186,6 +186,173 @@ describe('Endpoint.GetEmailRecipients', () => {
|
|
|
186
186
|
});
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
+
test('It can request all email recipients of a single email in combination with other filters', async () => {
|
|
190
|
+
const email = new Email();
|
|
191
|
+
email.subject = 'test subject';
|
|
192
|
+
email.status = EmailStatus.Draft;
|
|
193
|
+
email.text = 'test email';
|
|
194
|
+
email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
|
|
195
|
+
email.json = {};
|
|
196
|
+
email.organizationId = organization.id;
|
|
197
|
+
email.senderId = sender2.id;
|
|
198
|
+
await email.save();
|
|
199
|
+
|
|
200
|
+
const emailRecipient = new EmailRecipient();
|
|
201
|
+
emailRecipient.email = 'jan.janssens@geenemail.com';
|
|
202
|
+
emailRecipient.firstName = 'Jan';
|
|
203
|
+
emailRecipient.lastName = 'Janssens';
|
|
204
|
+
emailRecipient.emailId = email.id;
|
|
205
|
+
emailRecipient.organizationId = organization.id;
|
|
206
|
+
await emailRecipient.save();
|
|
207
|
+
|
|
208
|
+
const request = Request.get({
|
|
209
|
+
path: baseUrl,
|
|
210
|
+
host: organization.getApiHost(),
|
|
211
|
+
query: new LimitedFilteredRequest({
|
|
212
|
+
filter: {
|
|
213
|
+
emailId: email.id,
|
|
214
|
+
email: {
|
|
215
|
+
$contains: 'jan',
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
limit: 10,
|
|
219
|
+
}),
|
|
220
|
+
headers: {
|
|
221
|
+
authorization: 'Bearer ' + token2.accessToken,
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
const result = await testServer.test(endpoint, request);
|
|
225
|
+
expect(result.body.results).toHaveLength(1);
|
|
226
|
+
expect(result.body.results[0]).toMatchObject({
|
|
227
|
+
id: emailRecipient.id,
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('[Regression] It can request all email recipients of a single email in combination with $and filters', async () => {
|
|
232
|
+
const email = new Email();
|
|
233
|
+
email.subject = 'test subject';
|
|
234
|
+
email.status = EmailStatus.Draft;
|
|
235
|
+
email.text = 'test email';
|
|
236
|
+
email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
|
|
237
|
+
email.json = {};
|
|
238
|
+
email.organizationId = organization.id;
|
|
239
|
+
email.senderId = sender2.id;
|
|
240
|
+
await email.save();
|
|
241
|
+
|
|
242
|
+
const emailRecipient = new EmailRecipient();
|
|
243
|
+
emailRecipient.email = 'jan.janssens@geenemail.com';
|
|
244
|
+
emailRecipient.firstName = 'Jan';
|
|
245
|
+
emailRecipient.lastName = 'Janssens';
|
|
246
|
+
emailRecipient.emailId = email.id;
|
|
247
|
+
emailRecipient.organizationId = organization.id;
|
|
248
|
+
await emailRecipient.save();
|
|
249
|
+
|
|
250
|
+
const request = Request.get({
|
|
251
|
+
path: baseUrl,
|
|
252
|
+
host: organization.getApiHost(),
|
|
253
|
+
query: new LimitedFilteredRequest({
|
|
254
|
+
filter: {
|
|
255
|
+
$and: [
|
|
256
|
+
{ emailId: email.id },
|
|
257
|
+
{ email: { $contains: 'jan' } },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
limit: 10,
|
|
261
|
+
}),
|
|
262
|
+
headers: {
|
|
263
|
+
authorization: 'Bearer ' + token2.accessToken,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
const result = await testServer.test(endpoint, request);
|
|
267
|
+
expect(result.body.results).toHaveLength(1);
|
|
268
|
+
expect(result.body.results[0]).toMatchObject({
|
|
269
|
+
id: emailRecipient.id,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('[Regression] It can request all email recipients of a single email in combination with multiple $and filters', async () => {
|
|
274
|
+
const email = new Email();
|
|
275
|
+
email.subject = 'test subject';
|
|
276
|
+
email.status = EmailStatus.Draft;
|
|
277
|
+
email.text = 'test email';
|
|
278
|
+
email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
|
|
279
|
+
email.json = {};
|
|
280
|
+
email.organizationId = organization.id;
|
|
281
|
+
email.senderId = sender2.id;
|
|
282
|
+
await email.save();
|
|
283
|
+
|
|
284
|
+
const emailRecipient = new EmailRecipient();
|
|
285
|
+
emailRecipient.email = 'jan.janssens@geenemail.com';
|
|
286
|
+
emailRecipient.firstName = 'Jan';
|
|
287
|
+
emailRecipient.lastName = 'Janssens';
|
|
288
|
+
emailRecipient.emailId = email.id;
|
|
289
|
+
emailRecipient.organizationId = organization.id;
|
|
290
|
+
await emailRecipient.save();
|
|
291
|
+
|
|
292
|
+
const request = Request.get({
|
|
293
|
+
path: baseUrl,
|
|
294
|
+
host: organization.getApiHost(),
|
|
295
|
+
query: new LimitedFilteredRequest({
|
|
296
|
+
filter: {
|
|
297
|
+
$and: [
|
|
298
|
+
{ emailId: email.id },
|
|
299
|
+
{ email: { $contains: 'jan' } },
|
|
300
|
+
],
|
|
301
|
+
email: { $contains: 'ssens' },
|
|
302
|
+
},
|
|
303
|
+
limit: 10,
|
|
304
|
+
}),
|
|
305
|
+
headers: {
|
|
306
|
+
authorization: 'Bearer ' + token2.accessToken,
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const result = await testServer.test(endpoint, request);
|
|
310
|
+
expect(result.body.results).toHaveLength(1);
|
|
311
|
+
expect(result.body.results[0]).toMatchObject({
|
|
312
|
+
id: emailRecipient.id,
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('It cannot request all email recipients of a single email in combination with $or filters', async () => {
|
|
317
|
+
const email = new Email();
|
|
318
|
+
email.subject = 'test subject';
|
|
319
|
+
email.status = EmailStatus.Draft;
|
|
320
|
+
email.text = 'test email';
|
|
321
|
+
email.html = `<p style="margin: 0; padding: 0; line-height: 1.4;">test email</p>`;
|
|
322
|
+
email.json = {};
|
|
323
|
+
email.organizationId = organization.id;
|
|
324
|
+
email.senderId = sender2.id;
|
|
325
|
+
await email.save();
|
|
326
|
+
|
|
327
|
+
const emailRecipient = new EmailRecipient();
|
|
328
|
+
emailRecipient.email = 'jan.janssens@geenemail.com';
|
|
329
|
+
emailRecipient.firstName = 'Jan';
|
|
330
|
+
emailRecipient.lastName = 'Janssens';
|
|
331
|
+
emailRecipient.emailId = email.id;
|
|
332
|
+
emailRecipient.organizationId = organization.id;
|
|
333
|
+
await emailRecipient.save();
|
|
334
|
+
|
|
335
|
+
const request = Request.get({
|
|
336
|
+
path: baseUrl,
|
|
337
|
+
host: organization.getApiHost(),
|
|
338
|
+
query: new LimitedFilteredRequest({
|
|
339
|
+
filter: {
|
|
340
|
+
$or: [
|
|
341
|
+
{ emailId: email.id },
|
|
342
|
+
{ email: { $contains: 'jan' } },
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
limit: 10,
|
|
346
|
+
}),
|
|
347
|
+
headers: {
|
|
348
|
+
authorization: 'Bearer ' + token2.accessToken,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
await expect(testServer.test(endpoint, request))
|
|
352
|
+
.rejects
|
|
353
|
+
.toThrow(STExpect.errorWithCode('permission_denied'));
|
|
354
|
+
});
|
|
355
|
+
|
|
189
356
|
test('It cannot request all email recipients of a single email if read permission for another sender', async () => {
|
|
190
357
|
const email = new Email();
|
|
191
358
|
email.subject = 'test subject';
|
|
@@ -50,7 +50,7 @@ export class GetEmailRecipientsEndpoint extends Endpoint<Params, Query, Body, Re
|
|
|
50
50
|
if (!await validateEmailRecipientFilter({ filter: q.filter, permissionLevel: PermissionLevel.Read })) {
|
|
51
51
|
throw Context.auth.error({
|
|
52
52
|
message: 'You do not have sufficient permissions to view all email recipients',
|
|
53
|
-
human: $t(`
|
|
53
|
+
human: $t(`d499972f-270c-44f6-ad7e-9d5359aef609`),
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
}
|
|
@@ -9,9 +9,26 @@ export async function validateEmailRecipientFilter({ filter, permissionLevel }:
|
|
|
9
9
|
emailId: FilterWrapperMarker,
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
let unwrapped = unwrapFilter(filter, requiredFilter);
|
|
13
13
|
if (!unwrapped.match) {
|
|
14
|
-
|
|
14
|
+
if (typeof filter === 'object'
|
|
15
|
+
&& filter !== null
|
|
16
|
+
&& filter['$and']
|
|
17
|
+
&& Array.isArray(filter['$and'])
|
|
18
|
+
&& Object.keys(filter).length >= 1 // does not matter if more than 1, because root is always $and together
|
|
19
|
+
&& filter['$and'].length > 0
|
|
20
|
+
) {
|
|
21
|
+
for (const subFilter of filter['$and']) {
|
|
22
|
+
unwrapped = unwrapFilter(subFilter as StamhoofdFilter, requiredFilter);
|
|
23
|
+
if (unwrapped.match) {
|
|
24
|
+
// Found!
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!unwrapped.match) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
15
32
|
}
|
|
16
33
|
|
|
17
34
|
const emailIds = typeof unwrapped.markerValue === 'string'
|
|
@@ -25,7 +42,7 @@ export async function validateEmailRecipientFilter({ filter, permissionLevel }:
|
|
|
25
42
|
code: 'invalid_field',
|
|
26
43
|
field: 'filter',
|
|
27
44
|
message: 'You must filter on an email id of the email recipients you are trying to access',
|
|
28
|
-
human: $t(`
|
|
45
|
+
human: $t(`9f352fd4-5e4d-4899-81ce-b69889ebfe9d`),
|
|
29
46
|
});
|
|
30
47
|
}
|
|
31
48
|
|
|
@@ -55,7 +72,7 @@ export async function validateEmailRecipientFilter({ filter, permissionLevel }:
|
|
|
55
72
|
if (!await Context.auth.canAccessEmail(email, permissionLevel)) {
|
|
56
73
|
throw Context.auth.error({
|
|
57
74
|
message: 'You do not have access to this email',
|
|
58
|
-
human: $t(`
|
|
75
|
+
human: $t(`590a37ed-0f3a-4c66-8e20-08ac00ae761d`),
|
|
59
76
|
});
|
|
60
77
|
}
|
|
61
78
|
}
|
|
@@ -161,9 +161,14 @@ export class AdminPermissionChecker {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
else {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
if (STAMHOOFD.userMode === 'organization') {
|
|
165
|
+
// User is limited to a scope: can't access platform resources
|
|
166
|
+
if (this.user.organizationId) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// User can access platform resources (e.g. API keys)
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -605,7 +605,8 @@ export class AuthenticatedStructures {
|
|
|
605
605
|
|
|
606
606
|
const memberBlobs = membersBlob.members;
|
|
607
607
|
|
|
608
|
-
const registrationWithMemberBlobs =
|
|
608
|
+
const registrationWithMemberBlobs: RegistrationWithMemberBlob[] = [];
|
|
609
|
+
for (const registration of registrations) {
|
|
609
610
|
const memberBlob = memberBlobs.find(m => m.id === registration.memberId);
|
|
610
611
|
if (!memberBlob) {
|
|
611
612
|
throw new Error('Member not found');
|
|
@@ -633,11 +634,12 @@ export class AuthenticatedStructures {
|
|
|
633
634
|
}
|
|
634
635
|
}
|
|
635
636
|
|
|
636
|
-
|
|
637
|
+
const struct = RegistrationWithMemberBlob.create({
|
|
637
638
|
...r,
|
|
638
639
|
member: memberBlob,
|
|
639
640
|
});
|
|
640
|
-
|
|
641
|
+
registrationWithMemberBlobs.push(struct);
|
|
642
|
+
}
|
|
641
643
|
|
|
642
644
|
return RegistrationsBlob.create({
|
|
643
645
|
registrations: registrationWithMemberBlobs,
|
package/src/helpers/Context.ts
CHANGED
|
@@ -309,11 +309,11 @@ export class ContextInstance {
|
|
|
309
309
|
|
|
310
310
|
if (this.organization && !this.organization.active) {
|
|
311
311
|
// For inactive organizations, you always need permissions to view them
|
|
312
|
-
if (!await Context.auth.hasFullAccess(this.organization.id)) {
|
|
312
|
+
if (!Context.auth.hasSomePlatformAccess() || !await Context.auth.hasFullAccess(this.organization.id)) {
|
|
313
313
|
throw new SimpleError({
|
|
314
314
|
code: 'archived',
|
|
315
|
-
message: '
|
|
316
|
-
human: $t('
|
|
315
|
+
message: 'Platform access is required to view inactive organizations',
|
|
316
|
+
human: $t('3e8dba08-a505-41ec-96c1-b2b5c1c17852'),
|
|
317
317
|
statusCode: 401,
|
|
318
318
|
});
|
|
319
319
|
}
|
|
@@ -28,7 +28,7 @@ function stringToError(message: string) {
|
|
|
28
28
|
new SimpleError({
|
|
29
29
|
code: 'email_skipped_unsubscribed',
|
|
30
30
|
message: 'Recipient has unsubscribed',
|
|
31
|
-
human: $t('
|
|
31
|
+
human: $t('ffbebae7-eac3-44fe-863b-25942c5be7d0'),
|
|
32
32
|
}),
|
|
33
33
|
);
|
|
34
34
|
}
|
|
@@ -38,7 +38,7 @@ function stringToError(message: string) {
|
|
|
38
38
|
new SimpleError({
|
|
39
39
|
code: 'email_skipped_unsubscribed',
|
|
40
40
|
message: 'Recipient has unsubscribed from marketing',
|
|
41
|
-
human: $t('
|
|
41
|
+
human: $t('ffbebae7-eac3-44fe-863b-25942c5be7d0'),
|
|
42
42
|
}),
|
|
43
43
|
);
|
|
44
44
|
}
|
|
@@ -48,7 +48,7 @@ function stringToError(message: string) {
|
|
|
48
48
|
new SimpleError({
|
|
49
49
|
code: 'all_filtered',
|
|
50
50
|
message: 'All recipients are filtered due to hard bounce or spam',
|
|
51
|
-
human: $t('
|
|
51
|
+
human: $t('f6ca0939-f191-4aba-9c53-cd370453c0bc'),
|
|
52
52
|
}),
|
|
53
53
|
);
|
|
54
54
|
}
|
|
@@ -440,14 +440,14 @@ export const memberFilterCompilers: SQLFilterDefinitions = {
|
|
|
440
440
|
throw new SimpleError({
|
|
441
441
|
code: 'not_found',
|
|
442
442
|
message: 'This email does not exist.',
|
|
443
|
-
human: $t('
|
|
443
|
+
human: $t('491156e4-a75d-4487-a97e-b208cd3a1d11'),
|
|
444
444
|
statusCode: 404,
|
|
445
445
|
});
|
|
446
446
|
}
|
|
447
447
|
if (!await Context.auth.canAccessEmail(email)) {
|
|
448
448
|
throw Context.auth.error({
|
|
449
449
|
message: 'No permissions to access this email.',
|
|
450
|
-
human: $t('
|
|
450
|
+
human: $t('be46002c-d77e-42ca-b163-23bde0bc628c'),
|
|
451
451
|
});
|
|
452
452
|
}
|
|
453
453
|
},
|