@stamhoofd/backend 2.137.3 → 2.137.5
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 +23 -17
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/CreateAdminEndpoint.ts +3 -2
- package/src/endpoints/auth/CreateTokenEndpoint.ts +2 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +3 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SignupEndpoint.ts +4 -2
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +9 -5
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +12 -9
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +214 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +211 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/RegistrationService.ts +3 -2
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +114 -2
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +100 -0
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { I18n } from '@stamhoofd/backend-i18n/I18n';
|
|
2
|
+
import type { Member, Organization, RegistrationPeriod, User } from '@stamhoofd/models';
|
|
3
|
+
import { Email, EmailRecipient, MemberFactory, OrganizationFactory, RegistrationPeriodFactory, UserFactory } from '@stamhoofd/models';
|
|
4
|
+
import { EmailContent, EmailStatus, Replacement } from '@stamhoofd/structures';
|
|
5
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
6
|
+
import { Country } from '@stamhoofd/types/Country';
|
|
7
|
+
import { Language } from '@stamhoofd/types/Language';
|
|
8
|
+
import { EmailPreviewService } from './EmailPreviewService.js';
|
|
9
|
+
|
|
10
|
+
describe('EmailPreviewService', () => {
|
|
11
|
+
let period: RegistrationPeriod;
|
|
12
|
+
let organization: Organization;
|
|
13
|
+
let user: User;
|
|
14
|
+
let member: Member;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
TestUtils.setPermanentEnvironment('userMode', 'platform');
|
|
18
|
+
|
|
19
|
+
period = await new RegistrationPeriodFactory({
|
|
20
|
+
startDate: new Date(2023, 0, 1),
|
|
21
|
+
endDate: new Date(2023, 11, 31),
|
|
22
|
+
}).create();
|
|
23
|
+
|
|
24
|
+
organization = await new OrganizationFactory({ period }).create();
|
|
25
|
+
|
|
26
|
+
user = await new UserFactory({ organization }).create();
|
|
27
|
+
member = await new MemberFactory({ organization, user }).create();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await Email.delete();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A sent email that is visible in the member portal.
|
|
36
|
+
*/
|
|
37
|
+
const createSentEmail = async (data: { html: string; text?: string; subject?: string } & Partial<Email>) => {
|
|
38
|
+
const email = new Email();
|
|
39
|
+
email.subject = data.subject ?? 'Test subject';
|
|
40
|
+
email.status = EmailStatus.Sent;
|
|
41
|
+
email.html = data.html;
|
|
42
|
+
email.text = data.text ?? 'Test text';
|
|
43
|
+
email.json = {};
|
|
44
|
+
email.language = data.language ?? null;
|
|
45
|
+
email.translations = data.translations ?? new Map();
|
|
46
|
+
email.organizationId = organization.id;
|
|
47
|
+
email.showInMemberPortal = true;
|
|
48
|
+
email.sentAt = new Date();
|
|
49
|
+
await email.save();
|
|
50
|
+
return email;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const createRecipient = async (email: Email, data: Partial<EmailRecipient> & { memberId: string }) => {
|
|
54
|
+
const recipient = new EmailRecipient();
|
|
55
|
+
recipient.emailId = email.id;
|
|
56
|
+
recipient.memberId = data.memberId;
|
|
57
|
+
recipient.userId = data.userId ?? null;
|
|
58
|
+
recipient.email = data.email ?? null;
|
|
59
|
+
recipient.firstName = data.firstName ?? 'Test';
|
|
60
|
+
recipient.lastName = data.lastName ?? 'Test';
|
|
61
|
+
recipient.language = data.language ?? null;
|
|
62
|
+
recipient.replacements = data.replacements ?? [];
|
|
63
|
+
recipient.sentAt = new Date();
|
|
64
|
+
await recipient.save();
|
|
65
|
+
return recipient;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe('getStructureForUser', () => {
|
|
69
|
+
test('strips the sensitive replacements that were stored for another recipient', async () => {
|
|
70
|
+
// The signInUrl of another user is a login link: it may never end up in the structure
|
|
71
|
+
// that is returned to this user.
|
|
72
|
+
const secret = 'private-signin-token-67890';
|
|
73
|
+
const otherUser = await new UserFactory({ organization }).create();
|
|
74
|
+
|
|
75
|
+
const email = await createSentEmail({
|
|
76
|
+
subject: 'Privacy boundary',
|
|
77
|
+
// The token has to be used in the content, otherwise removeUnusedReplacements
|
|
78
|
+
// drops it and the leak would not be observable
|
|
79
|
+
html: '<p>Sign in here: {{signInUrl}}</p>',
|
|
80
|
+
text: 'Sign in here: {{signInUrl}}',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// A recipient of the same member, but belonging to a different user
|
|
84
|
+
await createRecipient(email, {
|
|
85
|
+
memberId: member.id,
|
|
86
|
+
userId: otherUser.id,
|
|
87
|
+
email: otherUser.email,
|
|
88
|
+
replacements: [
|
|
89
|
+
Replacement.create({
|
|
90
|
+
token: 'signInUrl',
|
|
91
|
+
value: 'https://example.com/login?token=' + secret,
|
|
92
|
+
}),
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const structure = await EmailPreviewService.getStructureForUser(email, user, [member.id]);
|
|
97
|
+
|
|
98
|
+
expect(structure.recipients).toHaveLength(1);
|
|
99
|
+
const recipient = structure.recipients[0];
|
|
100
|
+
|
|
101
|
+
// The stored replacements of the other user are gone
|
|
102
|
+
expect(JSON.stringify(recipient.replacements)).not.toContain(secret);
|
|
103
|
+
|
|
104
|
+
// ...and replaced by a sign in url generated for the user that is viewing the email
|
|
105
|
+
const signInUrl = recipient.replacements.find(r => r.token === 'signInUrl');
|
|
106
|
+
expect(signInUrl).toBeDefined();
|
|
107
|
+
expect(signInUrl!.value).toContain(encodeURIComponent(user.email));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('renders the replacements in the language of the recipient, not in the ambient locale', async () => {
|
|
111
|
+
TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
|
|
112
|
+
|
|
113
|
+
// The web greeting is looked up in shared/locales/dist/locales/digit/fr-BE.json,
|
|
114
|
+
// hardcoded on purpose so we don't verify $t with the same $t machinery we're testing
|
|
115
|
+
const frenchGreeting = 'Bonjour,';
|
|
116
|
+
|
|
117
|
+
const email = await createSentEmail({
|
|
118
|
+
subject: 'Nederlands onderwerp',
|
|
119
|
+
html: '<p>{{greeting}} Nederlandse inhoud</p>',
|
|
120
|
+
text: '{{greeting}} Nederlandse tekst',
|
|
121
|
+
language: Language.Dutch,
|
|
122
|
+
translations: new Map([
|
|
123
|
+
[Language.French, EmailContent.create({
|
|
124
|
+
subject: 'Sujet français',
|
|
125
|
+
html: '<p>{{greeting}} Contenu français</p>',
|
|
126
|
+
text: 'Texte français',
|
|
127
|
+
})],
|
|
128
|
+
]),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await createRecipient(email, {
|
|
132
|
+
memberId: member.id,
|
|
133
|
+
userId: user.id,
|
|
134
|
+
email: user.email,
|
|
135
|
+
language: Language.French,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The caller is viewing in Dutch, the recipient received the email in French
|
|
139
|
+
const structure = await I18n.runWithLocale(
|
|
140
|
+
new I18n(Language.Dutch, Country.Belgium),
|
|
141
|
+
async () => await EmailPreviewService.getStructureForUser(email, user, [member.id]),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(structure.recipients).toHaveLength(1);
|
|
145
|
+
const recipient = structure.recipients[0];
|
|
146
|
+
|
|
147
|
+
expect(recipient.language).toBe(Language.French);
|
|
148
|
+
expect(recipient.replacements.find(r => r.token === 'greeting')?.value).toBe(frenchGreeting);
|
|
149
|
+
expect(structure.getSubjectFor(recipient)).toBe('Sujet français');
|
|
150
|
+
expect(structure.getHtmlFor(recipient)).toBe(`<p>${frenchGreeting} Contenu français</p>`);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('merges the recipients of different members when their replacements are equal', async () => {
|
|
154
|
+
const secondMember = await new MemberFactory({ organization, user }).create();
|
|
155
|
+
|
|
156
|
+
const email = await createSentEmail({
|
|
157
|
+
subject: 'Equal content',
|
|
158
|
+
html: '<p>Hello {{memberFirstName}}</p>',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
for (const memberId of [member.id, secondMember.id]) {
|
|
162
|
+
await createRecipient(email, {
|
|
163
|
+
memberId,
|
|
164
|
+
userId: user.id,
|
|
165
|
+
email: user.email,
|
|
166
|
+
replacements: [
|
|
167
|
+
Replacement.create({ token: 'memberFirstName', value: 'Same name' }),
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const structure = await EmailPreviewService.getStructureForUser(email, user, [member.id, secondMember.id]);
|
|
173
|
+
|
|
174
|
+
expect(structure.recipients).toHaveLength(1);
|
|
175
|
+
expect(structure.recipients[0].replacements.find(r => r.token === 'memberFirstName')?.value).toBe('Same name');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('keeps the recipients of different members separate when their replacements differ', async () => {
|
|
179
|
+
const secondMember = await new MemberFactory({ organization, user }).create();
|
|
180
|
+
|
|
181
|
+
const email = await createSentEmail({
|
|
182
|
+
subject: 'Different content',
|
|
183
|
+
html: '<p>Hello {{memberFirstName}}</p>',
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await createRecipient(email, {
|
|
187
|
+
memberId: member.id,
|
|
188
|
+
userId: user.id,
|
|
189
|
+
email: user.email,
|
|
190
|
+
replacements: [Replacement.create({ token: 'memberFirstName', value: 'Alice' })],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await createRecipient(email, {
|
|
194
|
+
memberId: secondMember.id,
|
|
195
|
+
userId: user.id,
|
|
196
|
+
email: user.email,
|
|
197
|
+
replacements: [Replacement.create({ token: 'memberFirstName', value: 'Bob' })],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const structure = await EmailPreviewService.getStructureForUser(email, user, [member.id, secondMember.id]);
|
|
201
|
+
|
|
202
|
+
expect(structure.recipients).toHaveLength(2);
|
|
203
|
+
const names = structure.recipients.map(r => r.replacements.find(rr => rr.token === 'memberFirstName')?.value);
|
|
204
|
+
expect(names).toIncludeSameMembers(['Alice', 'Bob']);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('getPreviewStructure', () => {
|
|
209
|
+
test('returns an example recipient for every language of a translated email', async () => {
|
|
210
|
+
TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
|
|
211
|
+
|
|
212
|
+
const email = await createSentEmail({
|
|
213
|
+
subject: 'Nederlands onderwerp',
|
|
214
|
+
html: '<p>{{greeting}} Nederlandse inhoud</p>',
|
|
215
|
+
language: Language.Dutch,
|
|
216
|
+
translations: new Map([
|
|
217
|
+
[Language.French, EmailContent.create({
|
|
218
|
+
subject: 'Sujet français',
|
|
219
|
+
html: '<p>{{greeting}} Contenu français</p>',
|
|
220
|
+
text: 'Texte français',
|
|
221
|
+
})],
|
|
222
|
+
]),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const preview = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
|
|
226
|
+
|
|
227
|
+
expect(preview.exampleRecipient).not.toBeNull();
|
|
228
|
+
expect([...preview.exampleRecipients.keys()]).toIncludeSameMembers([Language.Dutch, Language.French]);
|
|
229
|
+
expect(preview.exampleRecipients.get(Language.French)!.language).toBe(Language.French);
|
|
230
|
+
expect(preview.exampleRecipients.get(Language.Dutch)!.language).toBe(Language.Dutch);
|
|
231
|
+
|
|
232
|
+
// The replacements are regenerated per language, so the greeting differs
|
|
233
|
+
const dutchGreeting = preview.exampleRecipients.get(Language.Dutch)!.replacements.find(r => r.token === 'greeting')?.value;
|
|
234
|
+
const frenchGreeting = preview.exampleRecipients.get(Language.French)!.replacements.find(r => r.token === 'greeting')?.value;
|
|
235
|
+
expect(typeof dutchGreeting).toBe('string');
|
|
236
|
+
expect(typeof frenchGreeting).toBe('string');
|
|
237
|
+
expect(frenchGreeting).not.toBe(dutchGreeting);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('returns no per-language example recipients for an email with a single language', async () => {
|
|
241
|
+
TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
|
|
242
|
+
|
|
243
|
+
const email = await createSentEmail({
|
|
244
|
+
subject: 'Nederlands onderwerp',
|
|
245
|
+
html: '<p>{{greeting}} Nederlandse inhoud</p>',
|
|
246
|
+
language: Language.Dutch,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const preview = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
|
|
250
|
+
|
|
251
|
+
expect(preview.exampleRecipient).not.toBeNull();
|
|
252
|
+
expect(preview.exampleRecipients.size).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('the allLanguages option is not read: the example recipients only depend on the languages of the email', async () => {
|
|
256
|
+
TestUtils.setEnvironment('locales', { BE: [Language.Dutch, Language.French] });
|
|
257
|
+
|
|
258
|
+
const email = await createSentEmail({
|
|
259
|
+
subject: 'Nederlands onderwerp',
|
|
260
|
+
html: '<p>{{greeting}} Nederlandse inhoud</p>',
|
|
261
|
+
language: Language.Dutch,
|
|
262
|
+
translations: new Map([
|
|
263
|
+
[Language.French, EmailContent.create({
|
|
264
|
+
subject: 'Sujet français',
|
|
265
|
+
html: '<p>{{greeting}} Contenu français</p>',
|
|
266
|
+
text: 'Texte français',
|
|
267
|
+
})],
|
|
268
|
+
]),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const withOption = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
|
|
272
|
+
const withoutOption = await EmailPreviewService.getPreviewStructure(email);
|
|
273
|
+
|
|
274
|
+
expect([...withOption.exampleRecipients.keys()]).toIncludeSameMembers([Language.Dutch, Language.French]);
|
|
275
|
+
expect([...withoutOption.exampleRecipients.keys()]).toIncludeSameMembers([Language.Dutch, Language.French]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('uses a stored recipient as the example recipient when the email has one', async () => {
|
|
279
|
+
const email = await createSentEmail({
|
|
280
|
+
subject: 'With a recipient',
|
|
281
|
+
html: '<p>Hello {{memberFirstName}}</p>',
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
await createRecipient(email, {
|
|
285
|
+
memberId: member.id,
|
|
286
|
+
userId: user.id,
|
|
287
|
+
email: user.email,
|
|
288
|
+
firstName: 'Stored',
|
|
289
|
+
lastName: 'Recipient',
|
|
290
|
+
replacements: [Replacement.create({ token: 'memberFirstName', value: 'Stored' })],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const preview = await EmailPreviewService.getPreviewStructure(email, { allLanguages: true });
|
|
294
|
+
|
|
295
|
+
expect(preview.exampleRecipient).not.toBeNull();
|
|
296
|
+
expect(preview.exampleRecipient!.firstName).toBe('Stored');
|
|
297
|
+
expect(preview.exampleRecipient!.replacements.find(r => r.token === 'memberFirstName')?.value).toBe('Stored');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { I18n } from '@stamhoofd/backend-i18n/I18n';
|
|
2
|
+
import type { Email } from '@stamhoofd/models';
|
|
3
|
+
import { EmailRecipient, Organization, User } from '@stamhoofd/models';
|
|
4
|
+
import { fillRecipientReplacements, mergeReplacementsIfEqual, removeUnusedReplacements, runWithRecipientLocale, stripRecipientReplacementsForWebDisplay, stripSensitiveRecipientReplacements } from '@stamhoofd/models/helpers/EmailBuilder.js';
|
|
5
|
+
import type { BaseOrganization, EmailRecipient as EmailRecipientStruct, Replacement, User as UserStruct } from '@stamhoofd/structures';
|
|
6
|
+
import { EmailPreview, EmailRecipientFilter, EmailWithRecipients, getExampleRecipient } from '@stamhoofd/structures';
|
|
7
|
+
import { ExampleReplacements } from '@stamhoofd/structures/email/exampleReplacements.js';
|
|
8
|
+
import type { Language } from '@stamhoofd/types/Language';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds the read-only structures of an email: the preview an administrator sees while composing or
|
|
12
|
+
* browsing emails, and the version of a sent email a user sees in the member portal.
|
|
13
|
+
*/
|
|
14
|
+
export class EmailPreviewService {
|
|
15
|
+
/**
|
|
16
|
+
* @param options.allLanguages Also fill exampleRecipients: the same recipient with its
|
|
17
|
+
* replacements generated in each supported language. Only use this for detail endpoints,
|
|
18
|
+
* it repeats the replacement queries for every language.
|
|
19
|
+
*/
|
|
20
|
+
static async getPreviewStructure(email: Email, options: { allLanguages?: boolean } = {}) {
|
|
21
|
+
const emailRecipient = await EmailRecipient.select()
|
|
22
|
+
.where('emailId', email.id)
|
|
23
|
+
.where('email', '!=', null)
|
|
24
|
+
.first(false);
|
|
25
|
+
|
|
26
|
+
let baseRow: EmailRecipientStruct | undefined;
|
|
27
|
+
|
|
28
|
+
if (emailRecipient) {
|
|
29
|
+
baseRow = await emailRecipient.getStructure();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!baseRow) {
|
|
33
|
+
baseRow = getExampleRecipient();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const organization = email.organizationId ? (await Organization.getByID(email.organizationId))! : null;
|
|
37
|
+
const allowedLanguages = email.getLanguages();
|
|
38
|
+
|
|
39
|
+
const fillRow = async (row: EmailRecipientStruct) => {
|
|
40
|
+
const virtualRecipient = row.getRecipient();
|
|
41
|
+
|
|
42
|
+
await fillRecipientReplacements(virtualRecipient, {
|
|
43
|
+
organization,
|
|
44
|
+
from: email.getFromAddress(),
|
|
45
|
+
replyTo: null,
|
|
46
|
+
forPreview: true,
|
|
47
|
+
forceRefresh: !email.sentAt,
|
|
48
|
+
allowedLanguages,
|
|
49
|
+
});
|
|
50
|
+
row.replacements = virtualRecipient.replacements;
|
|
51
|
+
return row;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const recipientRow = await fillRow(baseRow.clone());
|
|
55
|
+
|
|
56
|
+
// The same recipient in every supported language: the replacements are regenerated in
|
|
57
|
+
// each language (the recipient's own language is ignored), so the composer can show
|
|
58
|
+
// example values in the language that is being edited
|
|
59
|
+
const exampleRecipients = new Map<Language, EmailRecipientStruct>();
|
|
60
|
+
if (allowedLanguages && allowedLanguages.length > 1) {
|
|
61
|
+
// The same country the recipient locale will resolve to (see getRecipientI18n)
|
|
62
|
+
const country = organization?.address?.country ?? $getCountry();
|
|
63
|
+
for (const language of allowedLanguages) {
|
|
64
|
+
const i18n = new I18n(language, country);
|
|
65
|
+
await I18n.runWithLocale(i18n, async () => {
|
|
66
|
+
const clone = baseRow.clone();
|
|
67
|
+
clone.language = language;
|
|
68
|
+
|
|
69
|
+
if (baseRow.language !== language) {
|
|
70
|
+
// Todo: this can be improved
|
|
71
|
+
// Replace all replacements with defaults
|
|
72
|
+
for (const replacement of clone.replacements) {
|
|
73
|
+
const example: Replacement | undefined = ExampleReplacements.all[replacement.token];
|
|
74
|
+
if (example) {
|
|
75
|
+
replacement.html = example.html;
|
|
76
|
+
replacement.value = example.value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clone.replacements = clone.replacements.filter(r => !['organizationName'].includes(r.token));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rr = await fillRow(clone);
|
|
84
|
+
exampleRecipients.set(language, rr);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let user: UserStruct | null = null;
|
|
90
|
+
if (email.userId) {
|
|
91
|
+
const u = await User.getByID(email.userId);
|
|
92
|
+
if (u) {
|
|
93
|
+
user = u.getStructure();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let organizationStruct: BaseOrganization | null = null;
|
|
98
|
+
if (organization) {
|
|
99
|
+
organizationStruct = organization.getBaseStructure();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return EmailPreview.create({
|
|
103
|
+
...email,
|
|
104
|
+
user,
|
|
105
|
+
organization: organizationStruct,
|
|
106
|
+
exampleRecipient: recipientRow,
|
|
107
|
+
exampleRecipients,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether the email has content in this language (strict: the default content only counts
|
|
113
|
+
* for its own language, see getEmailContentForLanguage)
|
|
114
|
+
*/
|
|
115
|
+
private static hasContentForLanguage(email: Email, language: Language): boolean {
|
|
116
|
+
return language === email.language || email.translations.has(language);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param options.language The language the caller is viewing in (from the request headers).
|
|
121
|
+
* Recipients are returned in this language when the email has content for it, otherwise in
|
|
122
|
+
* the language they received the email in.
|
|
123
|
+
*/
|
|
124
|
+
static async getStructureForUser(email: Email, user: User, memberIds: string[], options: { language?: Language | null } = {}) {
|
|
125
|
+
const emailRecipients = await EmailRecipient.select()
|
|
126
|
+
.where('emailId', email.id)
|
|
127
|
+
.where('memberId', memberIds)
|
|
128
|
+
.fetch();
|
|
129
|
+
const organization = email.organizationId ? (await Organization.getByID(email.organizationId))! : null;
|
|
130
|
+
|
|
131
|
+
const recipientsMap: Map<string, EmailRecipient> = new Map();
|
|
132
|
+
for (const memberId of memberIds) {
|
|
133
|
+
const preferred = emailRecipients.find(e => e.memberId === memberId && (e.userId === user.id || e.email === user.email));
|
|
134
|
+
if (preferred) {
|
|
135
|
+
recipientsMap.set(preferred.duplicateOfRecipientId ?? preferred.id, preferred);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const byMember = emailRecipients.find(e => e.memberId === memberId && e.userId === null && e.email === null);
|
|
140
|
+
if (byMember) {
|
|
141
|
+
recipientsMap.set(byMember.duplicateOfRecipientId ?? byMember.id, byMember);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const anyData = emailRecipients.find(e => e.memberId === memberId);
|
|
145
|
+
if (anyData) {
|
|
146
|
+
recipientsMap.set(anyData.duplicateOfRecipientId ?? anyData.id, anyData);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Remove duplicates that are marked as the same recipient
|
|
152
|
+
const cleanedRecipients: EmailRecipient[] = [...recipientsMap.values()];
|
|
153
|
+
const structures = await EmailRecipient.getStructures(cleanedRecipients);
|
|
154
|
+
|
|
155
|
+
for (const struct of structures) {
|
|
156
|
+
if (!(struct.userId === user.id || struct.email === user.email) && !((struct.userId === null && struct.email === null))) {
|
|
157
|
+
stripSensitiveRecipientReplacements(struct, {
|
|
158
|
+
organization,
|
|
159
|
+
willFill: true,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
struct.firstName = user.firstName;
|
|
164
|
+
struct.lastName = user.lastName;
|
|
165
|
+
struct.email = user.email;
|
|
166
|
+
struct.userId = user.id;
|
|
167
|
+
|
|
168
|
+
// Show the email in the language the caller is viewing in, but only when the email
|
|
169
|
+
// has content for it: otherwise keep the language the recipient received it in
|
|
170
|
+
// (better correct content in the wrong language than wrong content in the right language)
|
|
171
|
+
if (options.language && this.hasContentForLanguage(email, options.language)) {
|
|
172
|
+
struct.language = options.language;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// We always refresh the data when we display it on the web (so everything is up to date)
|
|
176
|
+
// The replacements are regenerated in struct.language, so they match the displayed content
|
|
177
|
+
await fillRecipientReplacements(struct, {
|
|
178
|
+
organization,
|
|
179
|
+
from: email.getFromAddress(),
|
|
180
|
+
replyTo: null,
|
|
181
|
+
forPreview: false,
|
|
182
|
+
forceRefresh: true,
|
|
183
|
+
allowedLanguages: email.getLanguages(),
|
|
184
|
+
});
|
|
185
|
+
runWithRecipientLocale(struct, organization, () => {
|
|
186
|
+
stripRecipientReplacementsForWebDisplay(struct, {
|
|
187
|
+
organization,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
if (email.html) {
|
|
191
|
+
struct.replacements = removeUnusedReplacements(email.getCombinedHtml(), struct.replacements);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Loop structures and remove if they have exactly the same content
|
|
196
|
+
// We do this here, because it is possible the user didn't receive any emails, so
|
|
197
|
+
// the merging at time of sending the emails didn't happen (uniqueness happened on email)
|
|
198
|
+
const uniqueStructures: EmailRecipientStruct[] = [];
|
|
199
|
+
for (const struct of structures) {
|
|
200
|
+
let found = false;
|
|
201
|
+
for (const unique of uniqueStructures) {
|
|
202
|
+
const merged = mergeReplacementsIfEqual(unique.replacements, struct.replacements);
|
|
203
|
+
if (merged !== false) {
|
|
204
|
+
unique.replacements = merged;
|
|
205
|
+
found = true;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!found) {
|
|
211
|
+
uniqueStructures.push(struct);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let organizationStruct: BaseOrganization | null = null;
|
|
216
|
+
if (organization) {
|
|
217
|
+
organizationStruct = organization.getBaseStructure();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return EmailWithRecipients.create({
|
|
221
|
+
...email,
|
|
222
|
+
organization: organizationStruct,
|
|
223
|
+
recipients: uniqueStructures,
|
|
224
|
+
|
|
225
|
+
// Remove private-like data
|
|
226
|
+
softBouncesCount: 0,
|
|
227
|
+
failedCount: 0,
|
|
228
|
+
emailErrors: null,
|
|
229
|
+
recipientsErrors: null,
|
|
230
|
+
succeededCount: 1,
|
|
231
|
+
emailRecipientsCount: 1,
|
|
232
|
+
hardBouncesCount: 0,
|
|
233
|
+
spamComplaintsCount: 0,
|
|
234
|
+
recipientFilter: EmailRecipientFilter.create({}),
|
|
235
|
+
membersCount: 1,
|
|
236
|
+
otherRecipientsCount: 0,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import { File } from '@stamhoofd/structures';
|
|
3
|
+
import { TestUtils } from '@stamhoofd/test-utils';
|
|
4
|
+
|
|
5
|
+
import { FileSignService } from './FileSignService.js';
|
|
6
|
+
|
|
7
|
+
describe('FileSignService', () => {
|
|
8
|
+
let originalClient: S3Client;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
TestUtils.setEnvironment('SPACES_BUCKET', 'test-bucket');
|
|
12
|
+
TestUtils.setEnvironment('SPACES_ENDPOINT', 'test.digitaloceanspaces.com');
|
|
13
|
+
|
|
14
|
+
originalClient = FileSignService.s3;
|
|
15
|
+
FileSignService.s3 = new S3Client({
|
|
16
|
+
forcePathStyle: false,
|
|
17
|
+
endpoint: 'https://test.digitaloceanspaces.com',
|
|
18
|
+
credentials: {
|
|
19
|
+
accessKeyId: 'test-key',
|
|
20
|
+
secretAccessKey: 'test-secret',
|
|
21
|
+
},
|
|
22
|
+
region: 'eu-west-1',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
FileSignService.s3 = originalClient;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const buildFile = (data: { path: string; contentType?: string | null }) => {
|
|
31
|
+
return new File({
|
|
32
|
+
id: '1c9ab9e6-1234-4c5e-9f1a-000000000000',
|
|
33
|
+
server: 'https://test-bucket.test.digitaloceanspaces.com',
|
|
34
|
+
path: data.path,
|
|
35
|
+
size: 100,
|
|
36
|
+
isPrivate: true,
|
|
37
|
+
contentType: data.contentType ?? null,
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getSignedQuery = async (file: File) => {
|
|
42
|
+
const signed = await FileSignService.withSignedUrl(file);
|
|
43
|
+
expect(signed?.signedUrl).toBeDefined();
|
|
44
|
+
return new URL(signed!.signedUrl!).searchParams;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
test('A signed url never renders a file type a browser could execute', async () => {
|
|
48
|
+
const query = await getSignedQuery(buildFile({ path: 'users/1/abc/report.docx', contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }));
|
|
49
|
+
|
|
50
|
+
expect(query.get('response-content-disposition')).toBe('attachment');
|
|
51
|
+
|
|
52
|
+
// The response headers are part of what is signed, so they can't be stripped from the url
|
|
53
|
+
expect(query.get('X-Amz-SignedHeaders')).toBeDefined();
|
|
54
|
+
expect(query.get('X-Amz-Signature')).toBeTruthy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('A signed url renders file types a browser cannot execute', async () => {
|
|
58
|
+
const pdf = await getSignedQuery(buildFile({ path: 'users/1/abc/invoice.pdf', contentType: 'application/pdf' }));
|
|
59
|
+
expect(pdf.get('response-content-disposition')).toBe('inline');
|
|
60
|
+
|
|
61
|
+
const image = await getSignedQuery(buildFile({ path: 'users/1/abc/photo.jpg', contentType: 'image/jpeg' }));
|
|
62
|
+
expect(image.get('response-content-disposition')).toBe('inline');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('It downloads files that were uploaded before we validated content types', async () => {
|
|
66
|
+
// These were stored with a content type chosen by the uploader, and with an extension derived from it
|
|
67
|
+
const svg = await getSignedQuery(buildFile({ path: 'users/1/abc/evil.svg', contentType: 'image/svg+xml' }));
|
|
68
|
+
expect(svg.get('response-content-disposition')).toBe('attachment');
|
|
69
|
+
|
|
70
|
+
const html = await getSignedQuery(buildFile({ path: 'users/1/abc/evil', contentType: 'text/html' }));
|
|
71
|
+
expect(html.get('response-content-disposition')).toBe('attachment');
|
|
72
|
+
|
|
73
|
+
// Both the extension and the content type have to be safe
|
|
74
|
+
const disguised = await getSignedQuery(buildFile({ path: 'users/1/abc/evil.pdf', contentType: 'text/html' }));
|
|
75
|
+
expect(disguised.get('response-content-disposition')).toBe('attachment');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('It uses the path when a file has no content type, like the images we generate', async () => {
|
|
79
|
+
const image = await getSignedQuery(buildFile({ path: 'users/1/abc/def.png', contentType: null }));
|
|
80
|
+
expect(image.get('response-content-disposition')).toBe('inline');
|
|
81
|
+
|
|
82
|
+
const unknown = await getSignedQuery(buildFile({ path: 'users/1/abc/def', contentType: null }));
|
|
83
|
+
expect(unknown.get('response-content-disposition')).toBe('attachment');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from '@aws-sdk/s3-request-presigner';
|
|
5
5
|
import type { DecodedRequest, Request, Response } from '@simonbackx/simple-endpoints';
|
|
6
6
|
import { SimpleError } from '@simonbackx/simple-errors';
|
|
7
|
-
import { File } from '@stamhoofd/structures';
|
|
7
|
+
import { File, supportedFileTypes } from '@stamhoofd/structures';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import * as jose from 'jose';
|
|
10
10
|
|
|
@@ -103,6 +103,27 @@ export class FileSignService {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Files uploaded before we validated content types can have any content type, so we decide here - and not
|
|
108
|
+
* from the stored object - whether a browser may render a file. Note that this only protects private
|
|
109
|
+
* files: public files are served straight from the file server, without a signed url.
|
|
110
|
+
*/
|
|
111
|
+
static getContentDisposition(file: File): 'inline' | 'attachment' {
|
|
112
|
+
// The path is generated by us and always ends with the extension the file was stored with, so it says
|
|
113
|
+
// more about how the file server will serve this file than the content type does. Files we generate
|
|
114
|
+
// ourselves (like the images of the Image model) don't even have a content type.
|
|
115
|
+
if (!supportedFileTypes.resolveUpload({ filename: file.path })?.canRenderInline) {
|
|
116
|
+
return 'attachment';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// When a file also carries a content type, both have to be safe
|
|
120
|
+
if (file.contentType && !supportedFileTypes.resolveUpload({ contentType: file.contentType })?.canRenderInline) {
|
|
121
|
+
return 'attachment';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return 'inline';
|
|
125
|
+
}
|
|
126
|
+
|
|
106
127
|
static async withSignedUrl(file: File, duration = 60 * 60) {
|
|
107
128
|
if (file.signedUrl) {
|
|
108
129
|
console.error('Warning: file already signed');
|
|
@@ -112,6 +133,7 @@ export class FileSignService {
|
|
|
112
133
|
const command = new GetObjectCommand({
|
|
113
134
|
Bucket: STAMHOOFD.SPACES_BUCKET,
|
|
114
135
|
Key: file.path,
|
|
136
|
+
ResponseContentDisposition: this.getContentDisposition(file),
|
|
115
137
|
});
|
|
116
138
|
const url = await getSignedUrl(this.s3, command, { expiresIn: duration });
|
|
117
139
|
|