@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stamhoofd/backend",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.97.1",
|
|
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.97.1",
|
|
49
|
+
"@stamhoofd/backend-middleware": "2.97.1",
|
|
50
|
+
"@stamhoofd/email": "2.97.1",
|
|
51
|
+
"@stamhoofd/models": "2.97.1",
|
|
52
|
+
"@stamhoofd/queues": "2.97.1",
|
|
53
|
+
"@stamhoofd/sql": "2.97.1",
|
|
54
|
+
"@stamhoofd/structures": "2.97.1",
|
|
55
|
+
"@stamhoofd/utility": "2.97.1",
|
|
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": "bf96e4489028e06431539f4b041086f4dbc3d277"
|
|
74
74
|
}
|
|
@@ -1,42 +1,50 @@
|
|
|
1
|
-
import { Email
|
|
2
|
-
import { AuditLogReplacement, AuditLogReplacementType, AuditLogType, EmailStatus
|
|
1
|
+
import { Email } from '@stamhoofd/models';
|
|
2
|
+
import { AuditLogReplacement, AuditLogReplacementType, AuditLogType, EmailStatus } from '@stamhoofd/structures';
|
|
3
3
|
import { Formatter } from '@stamhoofd/utility';
|
|
4
4
|
import { ModelLogger } from './ModelLogger';
|
|
5
5
|
|
|
6
6
|
export const EmailLogger = new ModelLogger(Email, {
|
|
7
|
-
|
|
8
|
-
if (event.type === 'deleted') {
|
|
9
|
-
return;
|
|
10
|
-
}
|
|
7
|
+
skipKeys: ['json', 'text', 'status', 'userId', 'createdAt', 'updatedAt', 'deletedAt', 'recipientFilter', 'emailRecipientsCount', 'otherRecipientsCount', 'succeededCount', 'softFailedCount', 'failedCount', 'membersCount', 'hardBouncesCount', 'softBouncesCount', 'spamComplaintsCount', 'recipientsStatus', 'recipientsErrors', 'emailErrors', 'sentAt'],
|
|
11
8
|
|
|
9
|
+
async optionsGenerator(event) {
|
|
12
10
|
let oldStatus = EmailStatus.Draft;
|
|
13
11
|
|
|
14
12
|
if (event.type === 'updated') {
|
|
15
13
|
oldStatus = event.originalFields.status as EmailStatus;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
if (event.type === 'deleted' || (event.type === 'updated' && event.model.deletedAt && !event.getOldModel().deletedAt)) {
|
|
17
|
+
return {
|
|
18
|
+
type: AuditLogType.EmailDeleted,
|
|
19
|
+
data: {
|
|
20
|
+
},
|
|
21
|
+
generatePatchList: false,
|
|
22
|
+
};
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
const newStatus = event.model.status as EmailStatus;
|
|
26
|
+
if (newStatus === oldStatus || (newStatus !== EmailStatus.Sent && newStatus !== EmailStatus.Sending)) {
|
|
27
|
+
if (newStatus === EmailStatus.Draft) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
type: AuditLogType.EmailEdited,
|
|
32
|
+
data: {
|
|
33
|
+
},
|
|
34
|
+
generatePatchList: true,
|
|
35
|
+
};
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
if (newStatus === EmailStatus.Sent) {
|
|
28
|
-
const recipient = await EmailRecipient.select().where('emailId', event.model.id).whereNot('sentAt', null).first(false);
|
|
29
|
-
// Get first recipient
|
|
30
39
|
return {
|
|
31
|
-
type: AuditLogType.EmailSent,
|
|
40
|
+
type: event.model.sendAsEmail ? AuditLogType.EmailSent : AuditLogType.EmailPublished,
|
|
32
41
|
data: {
|
|
33
|
-
recipient,
|
|
34
42
|
},
|
|
35
43
|
generatePatchList: false,
|
|
36
44
|
};
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
if (event.model.emailType) {
|
|
47
|
+
if (event.model.emailType || !event.model.sendAsEmail) {
|
|
40
48
|
// don't log the scheduled part of automated emails
|
|
41
49
|
return;
|
|
42
50
|
}
|
|
@@ -44,7 +52,6 @@ export const EmailLogger = new ModelLogger(Email, {
|
|
|
44
52
|
return {
|
|
45
53
|
type: AuditLogType.EmailSending,
|
|
46
54
|
data: {
|
|
47
|
-
recipient: null,
|
|
48
55
|
},
|
|
49
56
|
generatePatchList: false,
|
|
50
57
|
};
|
|
@@ -54,7 +61,7 @@ export const EmailLogger = new ModelLogger(Email, {
|
|
|
54
61
|
const map = new Map([
|
|
55
62
|
['e', AuditLogReplacement.create({
|
|
56
63
|
id: model.id,
|
|
57
|
-
value:
|
|
64
|
+
value: model.subject ?? '',
|
|
58
65
|
type: AuditLogReplacementType.Email,
|
|
59
66
|
})],
|
|
60
67
|
['c', AuditLogReplacement.create({
|
|
@@ -62,10 +69,8 @@ export const EmailLogger = new ModelLogger(Email, {
|
|
|
62
69
|
count: model.emailRecipientsCount ?? 0,
|
|
63
70
|
})],
|
|
64
71
|
]);
|
|
65
|
-
if (
|
|
66
|
-
map.set('html', AuditLogReplacement.html(
|
|
67
|
-
replaceEmailHtml(model.html ?? '', options.data.recipient?.replacements ?? []),
|
|
68
|
-
));
|
|
72
|
+
if (model.html) {
|
|
73
|
+
map.set('html', AuditLogReplacement.html(model.html));
|
|
69
74
|
}
|
|
70
75
|
return map;
|
|
71
76
|
},
|
|
@@ -83,6 +83,14 @@ export class CreateEmailEndpoint extends Endpoint<Params, Query, Body, ResponseB
|
|
|
83
83
|
model.json = request.body.json;
|
|
84
84
|
model.status = request.body.status;
|
|
85
85
|
model.attachments = request.body.attachments;
|
|
86
|
+
model.sendAsEmail = request.body.sendAsEmail ?? true;
|
|
87
|
+
model.showInMemberPortal = request.body.showInMemberPortal ?? false;
|
|
88
|
+
|
|
89
|
+
if (model.showInMemberPortal) {
|
|
90
|
+
if (!model.recipientFilter.canShowInMemberPortal) {
|
|
91
|
+
model.showInMemberPortal = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
86
94
|
|
|
87
95
|
const list = organization ? organization.privateMeta.emails : (await Platform.getShared()).privateConfig.emails;
|
|
88
96
|
const sender = list.find(e => e.id === request.body.senderId);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Request } from '@simonbackx/simple-endpoints';
|
|
2
|
+
import { Email, EmailRecipient, MemberFactory, Organization, OrganizationFactory, RegistrationPeriod, RegistrationPeriodFactory, Token, User, UserFactory } from '@stamhoofd/models';
|
|
3
|
+
import { EmailStatus, LimitedFilteredRequest, PermissionLevel, Permissions, Replacement } from '@stamhoofd/structures';
|
|
4
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
5
|
+
import { testServer } from '../../../../tests/helpers/TestServer';
|
|
6
|
+
import { GetAdminEmailsEndpoint } from './GetAdminEmailsEndpoint';
|
|
7
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
8
|
+
|
|
9
|
+
const baseUrl = `/email`;
|
|
10
|
+
|
|
11
|
+
describe('Endpoint.getAdminEmails', () => {
|
|
12
|
+
const endpoint = new GetAdminEmailsEndpoint();
|
|
13
|
+
let period: RegistrationPeriod;
|
|
14
|
+
let organization: Organization;
|
|
15
|
+
let userToken: Token;
|
|
16
|
+
let user: User;
|
|
17
|
+
let member: any; // MemberWithRegistrations type
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
TestUtils.setPermanentEnvironment('userMode', 'platform');
|
|
21
|
+
|
|
22
|
+
period = await new RegistrationPeriodFactory({
|
|
23
|
+
startDate: new Date(2023, 0, 1),
|
|
24
|
+
endDate: new Date(2023, 11, 31),
|
|
25
|
+
}).create();
|
|
26
|
+
|
|
27
|
+
organization = await new OrganizationFactory({ period })
|
|
28
|
+
.create();
|
|
29
|
+
|
|
30
|
+
user = await new UserFactory({
|
|
31
|
+
organization,
|
|
32
|
+
permissions: Permissions.create({ level: PermissionLevel.Full }),
|
|
33
|
+
}).create();
|
|
34
|
+
|
|
35
|
+
// Create a member associated with the user
|
|
36
|
+
member = await new MemberFactory({
|
|
37
|
+
organization,
|
|
38
|
+
user,
|
|
39
|
+
}).create();
|
|
40
|
+
|
|
41
|
+
userToken = await Token.createToken(user);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const getAdminEmails = async (query: LimitedFilteredRequest = new LimitedFilteredRequest({ limit: 10 }), token: Token = userToken, testOrganization: Organization = organization) => {
|
|
45
|
+
const request = Request.get({
|
|
46
|
+
path: baseUrl,
|
|
47
|
+
host: testOrganization.getApiHost(),
|
|
48
|
+
query,
|
|
49
|
+
headers: {
|
|
50
|
+
authorization: 'Bearer ' + token.accessToken,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return await testServer.test(endpoint, request);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test('Should return empty list when no emails are sent to user', async () => {
|
|
57
|
+
const response = await getAdminEmails();
|
|
58
|
+
expect(response.body.results).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('Should strip sensitive information from loginDetails', async () => {
|
|
62
|
+
// Create another user that will have sensitive data
|
|
63
|
+
const sensitiveUser = await new UserFactory({
|
|
64
|
+
organization,
|
|
65
|
+
}).create();
|
|
66
|
+
|
|
67
|
+
// Create an email
|
|
68
|
+
const email = new Email();
|
|
69
|
+
email.subject = 'Sensitive Data Email';
|
|
70
|
+
email.status = EmailStatus.Sent;
|
|
71
|
+
email.text = 'Email with sensitive replacements {{outstandingBalance}} {{loginDetails}} {{unsubscribeUrl}}';
|
|
72
|
+
email.html = '<p>Email with sensitive replacements {{outstandingBalance}} {{loginDetails}} {{unsubscribeUrl}}</p>';
|
|
73
|
+
email.json = {};
|
|
74
|
+
email.organizationId = organization.id;
|
|
75
|
+
email.showInMemberPortal = true;
|
|
76
|
+
email.sentAt = new Date();
|
|
77
|
+
await email.save();
|
|
78
|
+
|
|
79
|
+
// Create a recipient for the sensitive user (with different user data than our test user)
|
|
80
|
+
const sensitiveRecipient = new EmailRecipient();
|
|
81
|
+
sensitiveRecipient.emailId = email.id;
|
|
82
|
+
sensitiveRecipient.memberId = member.id; // Same member as our test user
|
|
83
|
+
sensitiveRecipient.userId = sensitiveUser.id; // Different user ID
|
|
84
|
+
sensitiveRecipient.email = sensitiveUser.email; // Different email
|
|
85
|
+
sensitiveRecipient.firstName = member.details.firstName;
|
|
86
|
+
sensitiveRecipient.lastName = member.details.lastName;
|
|
87
|
+
sensitiveRecipient.sentAt = new Date();
|
|
88
|
+
|
|
89
|
+
// Add sensitive replacements that should be stripped by the API
|
|
90
|
+
sensitiveRecipient.replacements = [
|
|
91
|
+
Replacement.create({
|
|
92
|
+
token: 'loginDetails',
|
|
93
|
+
value: '',
|
|
94
|
+
html: `<p class="description"><em>Login with <strong>${sensitiveUser.email}</strong> Alice Security Code: <span class="style-inline-code">ABCD-EFGH-IJKL-MNOP</span></em></p>`,
|
|
95
|
+
}),
|
|
96
|
+
Replacement.create({
|
|
97
|
+
token: 'unsubscribeUrl',
|
|
98
|
+
value: 'https://example.com/unsubscribe?token=secret-token-12345',
|
|
99
|
+
}),
|
|
100
|
+
Replacement.create({
|
|
101
|
+
token: 'signInUrl',
|
|
102
|
+
value: 'https://example.com/login?token=private-signin-token-67890',
|
|
103
|
+
}),
|
|
104
|
+
Replacement.create({
|
|
105
|
+
token: 'outstandingBalance',
|
|
106
|
+
value: '€ 150.00',
|
|
107
|
+
}),
|
|
108
|
+
Replacement.create({
|
|
109
|
+
token: 'balanceTable',
|
|
110
|
+
html: '<table><tr><td>Private balance information</td><td>€ 150.00</td></tr></table>',
|
|
111
|
+
}),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
await sensitiveRecipient.save();
|
|
115
|
+
|
|
116
|
+
// Search specifically for this email to avoid interference from other tests
|
|
117
|
+
const searchQuery = new LimitedFilteredRequest({
|
|
118
|
+
limit: 10,
|
|
119
|
+
search: 'Sensitive Data Email',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const response = await getAdminEmails(searchQuery);
|
|
123
|
+
|
|
124
|
+
expect(response.body.results).toHaveLength(1);
|
|
125
|
+
const emailResult = response.body.results[0];
|
|
126
|
+
|
|
127
|
+
const recipient = emailResult.exampleRecipient!;
|
|
128
|
+
expect(recipient).toBeDefined();
|
|
129
|
+
|
|
130
|
+
// The original recipient struct keeps its original userId and email from the sensitive user
|
|
131
|
+
expect(recipient.userId).toBe(sensitiveUser.id); // Original userId
|
|
132
|
+
expect(recipient.email).toBe(sensitiveUser.email); // Original email
|
|
133
|
+
|
|
134
|
+
// Verify that sensitive data has been properly processed
|
|
135
|
+
expect(recipient.replacements).toBeDefined();
|
|
136
|
+
expect(Array.isArray(recipient.replacements)).toBe(true);
|
|
137
|
+
|
|
138
|
+
const allReplacementsString = JSON.stringify(recipient.replacements);
|
|
139
|
+
expect(allReplacementsString).not.toContain('ABCD-EFGH-IJKL-MNOP'); // Original security code should be gone
|
|
140
|
+
expect(allReplacementsString).not.toContain('secret-token-12345'); // Original sensitive unsubscribe token should be gone
|
|
141
|
+
expect(allReplacementsString).not.toContain('private-signin-token-67890'); // Original sensitive signin token should be gone
|
|
142
|
+
|
|
143
|
+
// Verify that safe, current-user-appropriate replacements are created
|
|
144
|
+
const loginDetailsReplacement = recipient.replacements.find(r => r.token === 'loginDetails');
|
|
145
|
+
const unsubscribeUrlReplacement = recipient.replacements.find(r => r.token === 'unsubscribeUrl');
|
|
146
|
+
|
|
147
|
+
// loginDetails should exist and be empty/generic for web display
|
|
148
|
+
expect(loginDetailsReplacement).toBeDefined();
|
|
149
|
+
expect(loginDetailsReplacement!.html).not.toBe(undefined); // Should be empty for web display
|
|
150
|
+
expect(loginDetailsReplacement!.value).toBe('');
|
|
151
|
+
// Check html contains ••••
|
|
152
|
+
expect(loginDetailsReplacement!.html).toContain('••••');
|
|
153
|
+
|
|
154
|
+
// unsubscribeUrl should exist and be safe for web display
|
|
155
|
+
expect(unsubscribeUrlReplacement).toBeDefined();
|
|
156
|
+
expect(unsubscribeUrlReplacement!.value).toMatch(/^https:\/\//); // Should still be a valid URL
|
|
157
|
+
expect(unsubscribeUrlReplacement!.value).not.toContain('secret-token-12345'); // Original sensitive token should be gone
|
|
158
|
+
|
|
159
|
+
// This tests that Email.getStructureForUser properly handles sensitive data by:
|
|
160
|
+
// 1. Removing original sensitive replacements from other users' data
|
|
161
|
+
// 2. Creating fresh, appropriate replacements for the current viewer
|
|
162
|
+
// 3. Ensuring web safety of all replacement values
|
|
163
|
+
|
|
164
|
+
// Check outstandingBalance replacement IS not altered
|
|
165
|
+
const balanceReplacement = recipient.replacements.find(r => r.token === 'outstandingBalance');
|
|
166
|
+
expect(balanceReplacement).toBeDefined();
|
|
167
|
+
expect(balanceReplacement!.value).toBe('€ 150.00'); // Should be corrected to the new user
|
|
168
|
+
|
|
169
|
+
// Check balanceTable replacement IS not altered
|
|
170
|
+
const balanceTableReplacement = recipient.replacements.find(r => r.token === 'balanceTable');
|
|
171
|
+
expect(balanceTableReplacement).toBeDefined();
|
|
172
|
+
expect(balanceTableReplacement!.html).toBe('<table><tr><td>Private balance information</td><td>€ 150.00</td></tr></table>'); // Should be corrected to the new user
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -40,8 +40,8 @@ export class GetEmailEndpoint extends Endpoint<Params, Query, Body, ResponseBody
|
|
|
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
|
}
|