@stamhoofd/backend 1.0.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.
Files changed (150) hide show
  1. package/.env.template.json +63 -0
  2. package/.eslintrc.js +61 -0
  3. package/README.md +40 -0
  4. package/index.ts +172 -0
  5. package/jest.config.js +11 -0
  6. package/migrations.ts +33 -0
  7. package/package.json +48 -0
  8. package/src/crons.ts +845 -0
  9. package/src/endpoints/admin/organizations/GetOrganizationsCountEndpoint.ts +42 -0
  10. package/src/endpoints/admin/organizations/GetOrganizationsEndpoint.ts +320 -0
  11. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +171 -0
  12. package/src/endpoints/auth/CreateAdminEndpoint.ts +137 -0
  13. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +68 -0
  14. package/src/endpoints/auth/CreateTokenEndpoint.ts +200 -0
  15. package/src/endpoints/auth/DeleteTokenEndpoint.ts +31 -0
  16. package/src/endpoints/auth/ForgotPasswordEndpoint.ts +70 -0
  17. package/src/endpoints/auth/GetUserEndpoint.test.ts +64 -0
  18. package/src/endpoints/auth/GetUserEndpoint.ts +57 -0
  19. package/src/endpoints/auth/PatchApiUserEndpoint.ts +90 -0
  20. package/src/endpoints/auth/PatchUserEndpoint.ts +122 -0
  21. package/src/endpoints/auth/PollEmailVerificationEndpoint.ts +37 -0
  22. package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +41 -0
  23. package/src/endpoints/auth/SignupEndpoint.ts +107 -0
  24. package/src/endpoints/auth/VerifyEmailEndpoint.ts +89 -0
  25. package/src/endpoints/global/addresses/SearchRegionsEndpoint.ts +95 -0
  26. package/src/endpoints/global/addresses/ValidateAddressEndpoint.ts +31 -0
  27. package/src/endpoints/global/caddy/CheckDomainCertEndpoint.ts +101 -0
  28. package/src/endpoints/global/email/GetEmailAddressEndpoint.ts +53 -0
  29. package/src/endpoints/global/email/ManageEmailAddressEndpoint.ts +57 -0
  30. package/src/endpoints/global/files/UploadFile.ts +147 -0
  31. package/src/endpoints/global/files/UploadImage.ts +119 -0
  32. package/src/endpoints/global/members/GetMemberFamilyEndpoint.ts +76 -0
  33. package/src/endpoints/global/members/GetMembersCountEndpoint.ts +43 -0
  34. package/src/endpoints/global/members/GetMembersEndpoint.ts +429 -0
  35. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +734 -0
  36. package/src/endpoints/global/organizations/CheckRegisterCodeEndpoint.ts +45 -0
  37. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.test.ts +105 -0
  38. package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +146 -0
  39. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.test.ts +52 -0
  40. package/src/endpoints/global/organizations/GetOrganizationFromDomainEndpoint.ts +80 -0
  41. package/src/endpoints/global/organizations/GetOrganizationFromUriEndpoint.ts +49 -0
  42. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.test.ts +58 -0
  43. package/src/endpoints/global/organizations/SearchOrganizationEndpoint.ts +62 -0
  44. package/src/endpoints/global/payments/ExchangeSTPaymentEndpoint.ts +153 -0
  45. package/src/endpoints/global/payments/StripeWebhookEndpoint.ts +134 -0
  46. package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +44 -0
  47. package/src/endpoints/global/platform/GetPlatformEnpoint.ts +39 -0
  48. package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +63 -0
  49. package/src/endpoints/global/registration/GetPaymentRegistrations.ts +68 -0
  50. package/src/endpoints/global/registration/GetUserBalanceEndpoint.ts +39 -0
  51. package/src/endpoints/global/registration/GetUserDocumentsEndpoint.ts +80 -0
  52. package/src/endpoints/global/registration/GetUserMembersEndpoint.ts +41 -0
  53. package/src/endpoints/global/registration/PatchUserMembersEndpoint.ts +134 -0
  54. package/src/endpoints/global/registration/RegisterMembersEndpoint.ts +521 -0
  55. package/src/endpoints/global/registration-periods/GetRegistrationPeriodsEndpoint.ts +37 -0
  56. package/src/endpoints/global/registration-periods/PatchRegistrationPeriodsEndpoint.ts +115 -0
  57. package/src/endpoints/global/webshops/GetWebshopFromDomainEndpoint.ts +187 -0
  58. package/src/endpoints/organization/dashboard/billing/ActivatePackagesEndpoint.ts +424 -0
  59. package/src/endpoints/organization/dashboard/billing/DeactivatePackageEndpoint.ts +67 -0
  60. package/src/endpoints/organization/dashboard/billing/GetBillingStatusEndpoint.ts +39 -0
  61. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +57 -0
  62. package/src/endpoints/organization/dashboard/documents/GetDocumentTemplatesEndpoint.ts +50 -0
  63. package/src/endpoints/organization/dashboard/documents/GetDocumentsEndpoint.ts +50 -0
  64. package/src/endpoints/organization/dashboard/documents/PatchDocumentEndpoint.ts +129 -0
  65. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplateEndpoint.ts +114 -0
  66. package/src/endpoints/organization/dashboard/email/CheckEmailBouncesEndpoint.ts +50 -0
  67. package/src/endpoints/organization/dashboard/email/EmailEndpoint.ts +234 -0
  68. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.ts +62 -0
  69. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.ts +85 -0
  70. package/src/endpoints/organization/dashboard/mollie/CheckMollieEndpoint.ts +80 -0
  71. package/src/endpoints/organization/dashboard/mollie/ConnectMollieEndpoint.ts +54 -0
  72. package/src/endpoints/organization/dashboard/mollie/DisconnectMollieEndpoint.ts +49 -0
  73. package/src/endpoints/organization/dashboard/mollie/GetMollieDashboardEndpoint.ts +63 -0
  74. package/src/endpoints/organization/dashboard/nolt/CreateNoltTokenEndpoint.ts +61 -0
  75. package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.test.ts +64 -0
  76. package/src/endpoints/organization/dashboard/organization/ApplyRegisterCodeEndpoint.ts +84 -0
  77. package/src/endpoints/organization/dashboard/organization/GetOrganizationArchivedGroups.ts +43 -0
  78. package/src/endpoints/organization/dashboard/organization/GetOrganizationDeletedGroups.ts +42 -0
  79. package/src/endpoints/organization/dashboard/organization/GetOrganizationSSOEndpoint.ts +43 -0
  80. package/src/endpoints/organization/dashboard/organization/GetRegisterCodeEndpoint.ts +65 -0
  81. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +281 -0
  82. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +338 -0
  83. package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +196 -0
  84. package/src/endpoints/organization/dashboard/organization/SetOrganizationSSOEndpoint.ts +50 -0
  85. package/src/endpoints/organization/dashboard/payments/GetMemberBalanceEndpoint.ts +48 -0
  86. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.ts +207 -0
  87. package/src/endpoints/organization/dashboard/payments/PatchBalanceItemsEndpoint.ts +202 -0
  88. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +233 -0
  89. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.ts +66 -0
  90. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.ts +210 -0
  91. package/src/endpoints/organization/dashboard/stripe/ConnectStripeEndpoint.ts +93 -0
  92. package/src/endpoints/organization/dashboard/stripe/DeleteStripeAccountEndpoint.ts +59 -0
  93. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountLinkEndpoint.ts +78 -0
  94. package/src/endpoints/organization/dashboard/stripe/GetStripeAccountsEndpoint.ts +40 -0
  95. package/src/endpoints/organization/dashboard/stripe/GetStripeLoginLinkEndpoint.ts +69 -0
  96. package/src/endpoints/organization/dashboard/stripe/UpdateStripeAccountEndpoint.ts +52 -0
  97. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +73 -0
  98. package/src/endpoints/organization/dashboard/users/DeleteUserEndpoint.ts +60 -0
  99. package/src/endpoints/organization/dashboard/users/GetApiUsersEndpoint.ts +47 -0
  100. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +41 -0
  101. package/src/endpoints/organization/dashboard/webshops/CreateWebshopEndpoint.ts +217 -0
  102. package/src/endpoints/organization/dashboard/webshops/DeleteWebshopEndpoint.ts +51 -0
  103. package/src/endpoints/organization/dashboard/webshops/GetDiscountCodesEndpoint.ts +47 -0
  104. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.ts +83 -0
  105. package/src/endpoints/organization/dashboard/webshops/GetWebshopTicketsEndpoint.ts +68 -0
  106. package/src/endpoints/organization/dashboard/webshops/GetWebshopUriAvailabilityEndpoint.ts +69 -0
  107. package/src/endpoints/organization/dashboard/webshops/PatchDiscountCodesEndpoint.ts +125 -0
  108. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.ts +204 -0
  109. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +278 -0
  110. package/src/endpoints/organization/dashboard/webshops/PatchWebshopTicketsEndpoint.ts +80 -0
  111. package/src/endpoints/organization/dashboard/webshops/VerifyWebshopDomainEndpoint.ts +60 -0
  112. package/src/endpoints/organization/shared/ExchangePaymentEndpoint.ts +379 -0
  113. package/src/endpoints/organization/shared/GetDocumentHtml.ts +54 -0
  114. package/src/endpoints/organization/shared/GetPaymentEndpoint.ts +45 -0
  115. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +78 -0
  116. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.ts +34 -0
  117. package/src/endpoints/organization/shared/auth/OpenIDConnectCallbackEndpoint.ts +44 -0
  118. package/src/endpoints/organization/shared/auth/OpenIDConnectStartEndpoint.ts +82 -0
  119. package/src/endpoints/organization/webshops/CheckWebshopDiscountCodesEndpoint.ts +59 -0
  120. package/src/endpoints/organization/webshops/GetOrderByPaymentEndpoint.ts +51 -0
  121. package/src/endpoints/organization/webshops/GetOrderEndpoint.ts +40 -0
  122. package/src/endpoints/organization/webshops/GetTicketsEndpoint.ts +124 -0
  123. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +130 -0
  124. package/src/endpoints/organization/webshops/GetWebshopEndpoint.ts +50 -0
  125. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +450 -0
  126. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +335 -0
  127. package/src/helpers/AddressValidator.test.ts +40 -0
  128. package/src/helpers/AddressValidator.ts +256 -0
  129. package/src/helpers/AdminPermissionChecker.ts +1031 -0
  130. package/src/helpers/AuthenticatedStructures.ts +158 -0
  131. package/src/helpers/BuckarooHelper.ts +279 -0
  132. package/src/helpers/CheckSettlements.ts +215 -0
  133. package/src/helpers/Context.ts +202 -0
  134. package/src/helpers/CookieHelper.ts +45 -0
  135. package/src/helpers/ForwardHandler.test.ts +216 -0
  136. package/src/helpers/ForwardHandler.ts +140 -0
  137. package/src/helpers/OpenIDConnectHelper.ts +284 -0
  138. package/src/helpers/StripeHelper.ts +293 -0
  139. package/src/helpers/StripePayoutChecker.ts +188 -0
  140. package/src/middleware/ContextMiddleware.ts +16 -0
  141. package/src/migrations/1646578856-validate-addresses.ts +60 -0
  142. package/src/seeds/0000000000-example.ts +13 -0
  143. package/src/seeds/1715028563-user-permissions.ts +52 -0
  144. package/tests/e2e/stock.test.ts +2120 -0
  145. package/tests/e2e/tickets.test.ts +926 -0
  146. package/tests/helpers/StripeMocker.ts +362 -0
  147. package/tests/helpers/TestServer.ts +21 -0
  148. package/tests/jest.global.setup.ts +29 -0
  149. package/tests/jest.setup.ts +59 -0
  150. package/tsconfig.json +42 -0
@@ -0,0 +1,202 @@
1
+ import { Request } from "@simonbackx/simple-endpoints";
2
+ import { SimpleError } from "@simonbackx/simple-errors";
3
+ import { I18n } from "@stamhoofd/backend-i18n";
4
+ import { Organization, Platform, RateLimiter, Token, User } from "@stamhoofd/models";
5
+ import { AsyncLocalStorage } from "async_hooks";
6
+
7
+ import { AdminPermissionChecker } from "./AdminPermissionChecker";
8
+
9
+ export const apiUserRateLimiter = new RateLimiter({
10
+ limits: [
11
+ {
12
+ // Block heavy bursts (5req/s for 5s)
13
+ limit: 25,
14
+ duration: 5 * 1000
15
+ },
16
+ {
17
+ // max 1req/s during 150s
18
+ limit: 150,
19
+ duration: 150 * 1000
20
+ },
21
+ {
22
+ // 1000 requests per hour
23
+ limit: 1000,
24
+ duration: 60 * 1000 * 60
25
+ },
26
+ {
27
+ // 2000 requests per day
28
+ limit: 2000,
29
+ duration: 24 * 60 * 1000 * 60
30
+ }
31
+ ]
32
+ });
33
+
34
+ export class ContextInstance {
35
+ request: Request
36
+
37
+ user?: User
38
+ organization?: Organization
39
+
40
+ #i18n: I18n|null = null
41
+ #auth: AdminPermissionChecker|null = null
42
+
43
+ constructor(request: Request) {
44
+ this.request = request;
45
+ }
46
+
47
+ static asyncLocalStorage = new AsyncLocalStorage<ContextInstance>();
48
+
49
+ static get current(): ContextInstance {
50
+ const c = this.asyncLocalStorage.getStore();
51
+
52
+ if (!c) {
53
+ throw new SimpleError({
54
+ code: 'no_context',
55
+ message: 'No context found',
56
+ statusCode: 500
57
+ })
58
+ }
59
+
60
+ return c;
61
+ }
62
+
63
+ static async start<T>(request: Request, handler: () => Promise<T>): Promise<T> {
64
+ const context = new ContextInstance(request);
65
+
66
+ return await this.asyncLocalStorage.run(context, async () => {
67
+ return await handler()
68
+ });
69
+ }
70
+
71
+ get version() {
72
+ return this.request.getVersion()
73
+ }
74
+
75
+ get i18n() {
76
+ if (!this.#i18n) {
77
+ this.#i18n = I18n.fromRequest(this.request)
78
+ }
79
+ return this.#i18n
80
+ }
81
+
82
+ get auth() {
83
+ if (!this.#auth) {
84
+ throw new SimpleError({
85
+ code: 'internal_error',
86
+ statusCode: 500,
87
+ message: 'AdminPermissionChecker not set in RequestContext: make sure the request is authenticated before using the permissionChecker'
88
+ })
89
+ }
90
+ return this.#auth
91
+ }
92
+
93
+ get optionalAuth() {
94
+ return this.#auth
95
+ }
96
+
97
+ async setOptionalOrganizationScope() {
98
+ try {
99
+ return await this.setOrganizationScope()
100
+ } catch (e) {
101
+ return null
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Require organization scope if userMode is not platform
107
+ */
108
+ async setUserOrganizationScope() {
109
+ if (STAMHOOFD.userMode === 'platform') {
110
+ return null;
111
+ }
112
+ return await this.setOrganizationScope()
113
+ }
114
+
115
+ async setOrganizationScope() {
116
+ const organization = await Organization.fromApiHost(this.request.host);
117
+
118
+ this.organization = organization
119
+ this.i18n.switchToLocale({ country: organization.address.country })
120
+
121
+ return organization
122
+ }
123
+
124
+ async optionalAuthenticate({allowWithoutAccount = false}: {allowWithoutAccount?: boolean} = {}): Promise<{user?: User}> {
125
+ const header = this.request.headers.authorization
126
+ if (!header) {
127
+ return {}
128
+ }
129
+ return this.authenticate({allowWithoutAccount})
130
+ }
131
+
132
+ async authenticate({allowWithoutAccount = false}: {allowWithoutAccount?: boolean} = {}): Promise<{user: User, token: Token}> {
133
+ const header = this.request.headers.authorization
134
+ if (!header) {
135
+ throw new SimpleError({
136
+ code: "not_authenticated",
137
+ message: "Missing required authorization header",
138
+ statusCode: 401
139
+ })
140
+ }
141
+
142
+ if (!header.startsWith("Bearer ")) {
143
+ throw new SimpleError({
144
+ code: "not_supported_authentication",
145
+ message: "Authentication method not supported. Please authenticate with OAuth2",
146
+ statusCode: 401
147
+ })
148
+ }
149
+
150
+ const accessToken = header.substring("Bearer ".length);
151
+
152
+ const token = await Token.getByAccessToken(accessToken, true)
153
+
154
+ if (!token || (this.organization && token.user.organizationId !== null && token.user.organizationId !== this.organization.id) || (!this.organization && token.user.organizationId)) {
155
+ throw new SimpleError({
156
+ code: "invalid_access_token",
157
+ message: "The access token is invalid",
158
+ human: "Je bent automatisch uitgelogd, log opnieuw in om verder te gaan",
159
+ statusCode: 401
160
+ })
161
+ }
162
+
163
+ if (token.isAccessTokenExpired()) {
164
+ throw new SimpleError({
165
+ code: "expired_access_token",
166
+ message: "The access token is expired",
167
+ human: "Je bent automatisch uitgelogd, log opnieuw in om verder te gaan",
168
+ statusCode: 401
169
+ })
170
+ }
171
+
172
+ if (!token.user.hasAccount() && !allowWithoutAccount) {
173
+ throw new SimpleError({
174
+ code: "not_activated",
175
+ message: "This user is not yet activated",
176
+ human: "Maak een account aan op dit e-mailadres om een wachtwoord in te stellen voor je inlogt.",
177
+ statusCode: 401
178
+ })
179
+ }
180
+
181
+ // Rate limits for api users
182
+ if (token.user.isApiUser) {
183
+ apiUserRateLimiter.track(this.organization?.id ?? token.user.id)
184
+ }
185
+
186
+ const user = token.user
187
+ this.user = user
188
+ this.#auth = new AdminPermissionChecker(user, await Platform.getSharedPrivateStruct(), this.organization);
189
+
190
+ return {user, token};
191
+ }
192
+ }
193
+
194
+ export const Context = new Proxy(ContextInstance, {
195
+ get(target, prop, receiver) {
196
+ const c = target.current[prop];
197
+ if (c && typeof c == 'function') {
198
+ return c.bind(target.current)
199
+ }
200
+ return c;
201
+ }
202
+ }) as unknown as ContextInstance;
@@ -0,0 +1,45 @@
1
+ import { DecodedRequest, Response } from "@simonbackx/simple-endpoints";
2
+ import cookie from 'cookie';
3
+
4
+ type DecodedRequestWithCookies = DecodedRequest<any, any, any> & { cookies?: Record<string, string>}
5
+
6
+ export class CookieHelper {
7
+ static getCookies(request: DecodedRequest<any, any, any>): Record<string, string> {
8
+ const r = request as DecodedRequestWithCookies
9
+ if (r.cookies) {
10
+ return r.cookies
11
+ }
12
+
13
+ const header = r.headers.cookie
14
+ if (!header) {
15
+ r.cookies = {}
16
+ return r.cookies
17
+ }
18
+
19
+ // Parse
20
+ r.cookies = cookie.parse(header)
21
+ return r.cookies
22
+ }
23
+
24
+ static getCookie(request: DecodedRequest<any, any, any>, name: string): string | undefined {
25
+ const cookies = this.getCookies(request)
26
+ return cookies[name]
27
+ }
28
+
29
+ static setCookie(response: Response<any>, name: string, value: string, options?: cookie.CookieSerializeOptions | undefined) {
30
+ const cookies = cookie.serialize(name, value, options)
31
+ let currentCookies = response.headers['set-cookie']
32
+ if (!currentCookies) {
33
+ response.headers['set-cookie'] = [
34
+ cookies
35
+ ]
36
+ } else {
37
+ if (!Array.isArray(currentCookies)) {
38
+ currentCookies = [currentCookies.toString()]
39
+ response.headers['set-cookie'] = currentCookies
40
+ }
41
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
42
+ (currentCookies ).push(cookies)
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,216 @@
1
+ /* eslint-disable jest/expect-expect */
2
+
3
+ import { EmailAddress } from "@stamhoofd/email"
4
+ import { OrganizationFactory, UserFactory } from "@stamhoofd/models"
5
+ import { OrganizationEmail, PermissionLevel, Permissions } from "@stamhoofd/structures"
6
+
7
+ import { ForwardHandler } from "./ForwardHandler"
8
+
9
+ describe("ForwardHandler", () => {
10
+ it("should send to default e-mail", async () => {
11
+ const organization = await new OrganizationFactory({}).create()
12
+ organization.privateMeta.emails.push(OrganizationEmail.create({
13
+ name: "First",
14
+ email: "first@example.com"
15
+ }))
16
+ organization.privateMeta.emails.push(OrganizationEmail.create({
17
+ name: "default",
18
+ email: "def@example.com",
19
+ default: true
20
+ }))
21
+ await organization.save()
22
+
23
+ const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
24
+ recipients: [organization.uri + "@stamhoofd.email"],
25
+ spamVerdict: { status: 'PASS' },
26
+ virusVerdict: { status: 'PASS' },
27
+ spfVerdict: { status: 'PASS' },
28
+ dkimVerdict: { status: 'PASS' },
29
+ dmarcVerdict: { status: 'PASS' },
30
+ })
31
+ expect(options).toMatchObject({
32
+ to: [
33
+ {
34
+ email: "def@example.com",
35
+ name: "default",
36
+ }
37
+ ],
38
+ subject: "Hello",
39
+ replyTo: "someone@example.com"
40
+ })
41
+ expect(options!.text).toContain("Content hier")
42
+ })
43
+
44
+ it("should send to first e-mail", async () => {
45
+ const organization = await new OrganizationFactory({}).create()
46
+ organization.privateMeta.emails.push(OrganizationEmail.create({
47
+ name: "First",
48
+ email: "first@example.com"
49
+ }))
50
+ organization.privateMeta.emails.push(OrganizationEmail.create({
51
+ name: "second",
52
+ email: "second@example.com",
53
+ }))
54
+ await organization.save()
55
+
56
+ const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
57
+ recipients: [organization.uri + "@stamhoofd.email"],
58
+ spamVerdict: { status: 'PASS' },
59
+ virusVerdict: { status: 'PASS' },
60
+ spfVerdict: { status: 'PASS' },
61
+ dkimVerdict: { status: 'PASS' },
62
+ dmarcVerdict: { status: 'PASS' },
63
+ })
64
+ expect(options).toMatchObject({
65
+ to: [
66
+ {
67
+ email: "first@example.com",
68
+ name: "First"
69
+ }
70
+ ],
71
+ subject: "Hello",
72
+ replyTo: "someone@example.com"
73
+ })
74
+ expect(options!.text).toContain("Content hier")
75
+ })
76
+
77
+ it("should send to administrators if no emails defined", async () => {
78
+ const organization = await new OrganizationFactory({}).create()
79
+ const user = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create()
80
+
81
+ const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
82
+ recipients: [organization.uri + "@stamhoofd.email"],
83
+ spamVerdict: { status: 'PASS' },
84
+ virusVerdict: { status: 'PASS' },
85
+ spfVerdict: { status: 'PASS' },
86
+ dkimVerdict: { status: 'PASS' },
87
+ dmarcVerdict: { status: 'PASS' },
88
+ })
89
+ expect(options).toMatchObject({
90
+ to: [
91
+ {
92
+ email: user.email,
93
+ name: null
94
+ }
95
+ ],
96
+ subject: "Hello",
97
+ replyTo: "someone@example.com"
98
+ })
99
+ expect(options!.text).toContain("Content hier")
100
+
101
+ // Check notice
102
+ expect(options!.text).toContain("naar alle beheerders")
103
+ })
104
+
105
+ it("should send to all full administrators if no emails defined", async () => {
106
+ const organization = await new OrganizationFactory({}).create()
107
+ const user = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create()
108
+ const user2 = await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Full }) }).create()
109
+
110
+ // Admin that should get ignored
111
+ await new UserFactory({ organization, permissions: Permissions.create({ level: PermissionLevel.Read }) }).create()
112
+
113
+ const options = await ForwardHandler.handle("From: someone@example.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
114
+ recipients: [organization.uri + "@stamhoofd.email"],
115
+ spamVerdict: { status: 'PASS' },
116
+ virusVerdict: { status: 'PASS' },
117
+ spfVerdict: { status: 'PASS' },
118
+ dkimVerdict: { status: 'PASS' },
119
+ dmarcVerdict: { status: 'PASS' },
120
+ })
121
+
122
+ expect(options).toMatchObject({
123
+ subject: "Hello",
124
+ replyTo: "someone@example.com"
125
+ })
126
+ expect(options!.to).toIncludeAllMembers([
127
+ {
128
+ email: user.email,
129
+ name: null
130
+ },
131
+ {
132
+ email: user2.email,
133
+ name: null
134
+ }
135
+ ])
136
+ expect(options!.text).toContain("Content hier")
137
+
138
+ // Check notice
139
+ expect(options!.text).toContain("naar alle beheerders")
140
+ })
141
+
142
+ it("should ignore aws bounce emails", async () => {
143
+ const organization = await new OrganizationFactory({}).create()
144
+ organization.privateMeta.emails.push(OrganizationEmail.create({
145
+ name: "First",
146
+ email: "first@example.com"
147
+ }))
148
+ organization.privateMeta.emails.push(OrganizationEmail.create({
149
+ name: "second",
150
+ email: "second@example.com",
151
+ }))
152
+ await organization.save()
153
+
154
+ const options = await ForwardHandler.handle("From: bounces@amazonses.com\nSubject: Hello\nTo: "+organization.uri + "@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
155
+ recipients: [organization.uri + "@stamhoofd.email"],
156
+ spamVerdict: { status: 'PASS' },
157
+ virusVerdict: { status: 'PASS' },
158
+ spfVerdict: { status: 'PASS' },
159
+ dkimVerdict: { status: 'PASS' },
160
+ dmarcVerdict: { status: 'PASS' },
161
+ })
162
+ expect(options).toBeUndefined()
163
+ })
164
+
165
+ it("should ignore aws bounce emails for unknown organizations", async () => {
166
+ const options = await ForwardHandler.handle("From: bounces@amazonses.com\nSubject: Hello\nTo: ksjdgsdgkjlsdg@stamhoofd.email\nContent-Type: text/plain\n\nContent hier", {
167
+ recipients: ["ksjdgsdgkjlsdg@stamhoofd.email"],
168
+ spamVerdict: { status: 'PASS' },
169
+ virusVerdict: { status: 'PASS' },
170
+ spfVerdict: { status: 'PASS' },
171
+ dkimVerdict: { status: 'PASS' },
172
+ dmarcVerdict: { status: 'PASS' },
173
+ })
174
+ expect(options).toBeUndefined()
175
+ })
176
+
177
+ it("should unsubscribe email addresses that send to unsubscribe", async () => {
178
+ const address = new EmailAddress()
179
+ address.email = "exampleaddress-unsusbcribe-test@example.com";
180
+ address.organizationId = null
181
+ address.token = null;
182
+ await address.save()
183
+
184
+ const id = address.id
185
+
186
+ const options = await ForwardHandler.handle(`From: bounces@amazonses.com\nSubject: Hello\nTo: unsubscribe+${id}@stamhoofd.email\nContent-Type: text/plain\n\nContent hier`, {
187
+ recipients: [`unsubscribe+${id}@stamhoofd.email`],
188
+ spamVerdict: { status: 'PASS' },
189
+ virusVerdict: { status: 'PASS' },
190
+ spfVerdict: { status: 'PASS' },
191
+ dkimVerdict: { status: 'PASS' },
192
+ dmarcVerdict: { status: 'PASS' },
193
+ })
194
+ expect(options).toBeUndefined()
195
+
196
+ // Refresh adress and check unsubscribed for all
197
+ const updatedAddress = await EmailAddress.getByID(id)
198
+ expect(updatedAddress).toBeDefined()
199
+ expect(updatedAddress!.unsubscribedAll).toEqual(true);
200
+ })
201
+
202
+ it("should forward unsubscribe emails to unrecognized id", async () => {
203
+ const options = await ForwardHandler.handle(`From: bounces@amazonses.com\nSubject: Hello\nTo: unsubscribe+testid@stamhoofd.email\nContent-Type: text/plain\n\nContent hier`, {
204
+ recipients: [`unsubscribe+testid@stamhoofd.email`],
205
+ spamVerdict: { status: 'PASS' },
206
+ virusVerdict: { status: 'PASS' },
207
+ spfVerdict: { status: 'PASS' },
208
+ dkimVerdict: { status: 'PASS' },
209
+ dmarcVerdict: { status: 'PASS' },
210
+ })
211
+ expect(options).toMatchObject({
212
+ to: "hallo@stamhoofd.be",
213
+ subject: "E-mail unsubscribe mislukt",
214
+ });
215
+ })
216
+ })
@@ -0,0 +1,140 @@
1
+ import { EmailAddress, EmailInterfaceRecipient } from "@stamhoofd/email";
2
+ import { Organization } from "@stamhoofd/models";
3
+ import { Formatter } from "@stamhoofd/utility";
4
+ import { simpleParser } from "mailparser";
5
+
6
+ export class ForwardHandler {
7
+ static async handle(content: any, receipt: {
8
+ recipients: string[];
9
+ spamVerdict: { status: 'PASS' | string };
10
+ virusVerdict: { status: 'PASS' | string };
11
+ spfVerdict: { status: 'PASS' | string };
12
+ dkimVerdict: { status: 'PASS' | string };
13
+ dmarcVerdict: { status: 'PASS' | string };
14
+ }
15
+ ) {
16
+ const recipients = receipt.recipients
17
+ const email: string | undefined = recipients[0]
18
+ const organization: Organization | undefined = email ? await Organization.getByEmail(email) : undefined
19
+
20
+ const parsed = await simpleParser(content);
21
+ const from = parsed.from?.value[0]?.address
22
+
23
+ if (from && from.endsWith("amazonses.com") && organization) {
24
+ console.log("Bounce e-mails from AWS SES for organizations are not forwarded. Received from "+from+", to "+email)
25
+ return;
26
+ }
27
+
28
+ // Unsubscribe email?
29
+ if (email && email?.startsWith("unsubscribe+") && email.endsWith('@stamhoofd.email')) {
30
+ // Get id
31
+ const id = email.substring("unsubscribe+".length, email.indexOf('@stamhoofd.email'))
32
+ const model = await EmailAddress.getByID(id)
33
+
34
+ if (model) {
35
+ console.log('[Unsubscribe] Received an unsubscribe request for ' + model.email + ' from ' + from)
36
+ if (model.unsubscribedAll) {
37
+ // Ignore
38
+ return;
39
+ }
40
+ model.unsubscribedAll = true
41
+ await model.save()
42
+ } else {
43
+ console.error('[Unsubscribe] Received an unsubscribe request for unknown ID ' + id + ' from ' + from)
44
+
45
+ // Forward
46
+ return {
47
+ from: "unsubscribe@stamhoofd.be",
48
+ to: "hallo@stamhoofd.be",
49
+ subject: "E-mail unsubscribe mislukt",
50
+ text: "Beste,\n\nEr werd een unsubscribe gemeld op "+email+" die niet kon worden verwerkt. Gelieve dit na te kijken.\n\nStamhoofd"
51
+ }
52
+
53
+ }
54
+ return;
55
+ }
56
+
57
+ if (receipt.spamVerdict.status != "PASS" || receipt.virusVerdict.status != "PASS" || !(receipt.spfVerdict.status == "PASS" || receipt.dkimVerdict.status == "PASS")) {
58
+ console.error("Received spam or virus e-mail. Ignoring", 'to', recipients, 'from', email, 'subject', parsed.subject)
59
+ return;
60
+ }
61
+
62
+ // Send a new e-mail
63
+ let defaultEmail: EmailInterfaceRecipient[]|string = "hallo@stamhoofd.be"
64
+ let organizationEmails: EmailInterfaceRecipient[] = []
65
+ const extraDescription = "Dit bericht werd verstuurd naar "+email+", en werd automatisch doorgestuurd naar alle beheerders. Stel in Stamhoofd de e-mailadressen in om ervoor te zorgen dat antwoorden naar een specifiek e-mailadres worden verstuurd."
66
+
67
+ function doBounce() {
68
+ if (!from) {
69
+ return
70
+ }
71
+
72
+ if (from.endsWith("@amazonses.com")) {
73
+ // Ignore
74
+ return
75
+ }
76
+
77
+ // Send back to receiver without including the original message to avoid spam
78
+ return {
79
+ from: email ?? "unknown@stamhoofd.be",
80
+ to: from,
81
+ subject: "Ongeldig e-mailadres",
82
+ text: "Beste,\n\nDe vereniging die je probeert te bereiken via "+email+" is helaas niet bereikbaar via dit e-mailadres. Dit e-mailadres wordt enkel gebruikt voor het versturen van automatische e-mails in naam van een vereniging. Probeer de vereniging te contacteren via een ander e-mailadres.\n\nBedankt."
83
+ }
84
+ }
85
+
86
+ if (organization) {
87
+ organizationEmails = await organization.getReplyEmails()
88
+ if (!organizationEmails) {
89
+ if (STAMHOOFD.environment === "test") {
90
+ // ignore
91
+ } else {
92
+ console.error("Missing reply emails for organization "+organization.id)
93
+ }
94
+ return doBounce();
95
+ } else {
96
+ defaultEmail = organizationEmails
97
+ }
98
+ } else {
99
+ return doBounce();
100
+ }
101
+
102
+ console.log("Forward to "+defaultEmail)
103
+
104
+ let html: string | undefined = undefined
105
+
106
+ if (parsed.html !== false) {
107
+ // Search for body
108
+ const body = parsed.html.toLowerCase().indexOf("<body")
109
+
110
+ if (body != -1) {
111
+ const endTag = parsed.html.indexOf(">", body)
112
+ html = parsed.html.substring(0, endTag + 1) + "<p><i>"+Formatter.escapeHtml(extraDescription)+"<br><br></i></p>"+parsed.html.substring(endTag + 1)
113
+ } else {
114
+ html = "<p><i>"+Formatter.escapeHtml(extraDescription)+"<br><br></i></p>"+parsed.html
115
+ }
116
+ }
117
+
118
+ const options = {
119
+ from: email ?? "unknown@stamhoofd.be",
120
+ to: defaultEmail,
121
+ replyTo: parsed.from?.text,
122
+ subject: parsed.subject ?? "Doorgestuurd bericht",
123
+ text: parsed.text ? extraDescription + "\n\n" + parsed.text : undefined,
124
+ html: html,
125
+ attachments: parsed.attachments.flatMap(a => {
126
+ if (a.cid) {
127
+ // Already done inline in html
128
+ return []
129
+ }
130
+ return [{
131
+ filename: a.filename ?? "",
132
+ content: a.content.toString("utf-8"),
133
+ contentType: a.contentType
134
+ }]
135
+ })
136
+ }
137
+
138
+ return options
139
+ }
140
+ }