@stamhoofd/backend 2.96.2 → 2.97.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.96.2",
3
+ "version": "2.97.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.96.2",
49
- "@stamhoofd/backend-middleware": "2.96.2",
50
- "@stamhoofd/email": "2.96.2",
51
- "@stamhoofd/models": "2.96.2",
52
- "@stamhoofd/queues": "2.96.2",
53
- "@stamhoofd/sql": "2.96.2",
54
- "@stamhoofd/structures": "2.96.2",
55
- "@stamhoofd/utility": "2.96.2",
48
+ "@stamhoofd/backend-i18n": "2.97.0",
49
+ "@stamhoofd/backend-middleware": "2.97.0",
50
+ "@stamhoofd/email": "2.97.0",
51
+ "@stamhoofd/models": "2.97.0",
52
+ "@stamhoofd/queues": "2.97.0",
53
+ "@stamhoofd/sql": "2.97.0",
54
+ "@stamhoofd/structures": "2.97.0",
55
+ "@stamhoofd/utility": "2.97.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": "75a7b770bbf180c1140146194a9c9f7f451c3cd2"
73
+ "gitHead": "bfac4c11840fea2de6617bf31ac9b72d08d696fe"
74
74
  }
@@ -1,42 +1,50 @@
1
- import { Email, EmailRecipient } from '@stamhoofd/models';
2
- import { AuditLogReplacement, AuditLogReplacementType, AuditLogType, EmailStatus, replaceEmailHtml } from '@stamhoofd/structures';
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
- async optionsGenerator(event) {
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
- const newStatus = event.model.status as EmailStatus;
19
- if (newStatus === oldStatus) {
20
- return;
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
- if (newStatus !== EmailStatus.Sent && newStatus !== EmailStatus.Sending) {
24
- return;
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: replaceEmailHtml(model.subject ?? '', options.data.recipient?.replacements ?? []),
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 (options.data.recipient) {
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
- human: 'Email not found',
44
- message: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
43
+ message: 'Email not found',
44
+ human: $t(`9ddb6616-f62d-4c91-82a9-e5cf398e4c4a`),
45
45
  statusCode: 404,
46
46
  });
47
47
  }