@stamhoofd/backend 2.79.8 → 2.80.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.79.8",
3
+ "version": "2.80.1",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -35,17 +35,17 @@
35
35
  "@bwip-js/node": "^4.5.1",
36
36
  "@mollie/api-client": "3.7.0",
37
37
  "@simonbackx/simple-database": "1.29.1",
38
- "@simonbackx/simple-encoding": "2.21.0",
38
+ "@simonbackx/simple-encoding": "2.22.0",
39
39
  "@simonbackx/simple-endpoints": "1.19.1",
40
40
  "@simonbackx/simple-logging": "^1.0.1",
41
- "@stamhoofd/backend-i18n": "2.79.8",
42
- "@stamhoofd/backend-middleware": "2.79.8",
43
- "@stamhoofd/email": "2.79.8",
44
- "@stamhoofd/models": "2.79.8",
45
- "@stamhoofd/queues": "2.79.8",
46
- "@stamhoofd/sql": "2.79.8",
47
- "@stamhoofd/structures": "2.79.8",
48
- "@stamhoofd/utility": "2.79.8",
41
+ "@stamhoofd/backend-i18n": "2.80.1",
42
+ "@stamhoofd/backend-middleware": "2.80.1",
43
+ "@stamhoofd/email": "2.80.1",
44
+ "@stamhoofd/models": "2.80.1",
45
+ "@stamhoofd/queues": "2.80.1",
46
+ "@stamhoofd/sql": "2.80.1",
47
+ "@stamhoofd/structures": "2.80.1",
48
+ "@stamhoofd/utility": "2.80.1",
49
49
  "archiver": "^7.0.1",
50
50
  "aws-sdk": "^2.885.0",
51
51
  "axios": "1.6.8",
@@ -65,5 +65,5 @@
65
65
  "publishConfig": {
66
66
  "access": "public"
67
67
  },
68
- "gitHead": "06e4690b6413a21c3466d6ab76da3a883e1441c8"
68
+ "gitHead": "37793d55487f7e8792d7b97df20750f8bd5be9a3"
69
69
  }
@@ -1,9 +1,9 @@
1
1
  import { Model, ModelEvent } from '@simonbackx/simple-database';
2
2
  import { AuditLog } from '@stamhoofd/models';
3
+ import { ObjectDiffer } from '@stamhoofd/object-differ';
3
4
  import { AuditLogPatchItem, AuditLogPatchItemType, AuditLogReplacement, AuditLogSource, AuditLogType } from '@stamhoofd/structures';
4
5
  import { ContextInstance } from '../helpers/Context';
5
6
  import { AuditLogService } from '../services/AuditLogService';
6
- import { diffUnknown } from '../services/diff';
7
7
 
8
8
  export type ModelEventLogOptions<D> = {
9
9
  type: AuditLogType;
@@ -180,7 +180,7 @@ export class ModelLogger<ModelType extends typeof Model, M extends InstanceType<
180
180
  }));
181
181
  }
182
182
  else {
183
- log.patchList.push(...diffUnknown(
183
+ log.patchList.push(...ObjectDiffer.diff(
184
184
  key in oldModel ? oldModel[key] : undefined,
185
185
  key in event.model ? event.model[key] : undefined,
186
186
  AuditLogReplacement.key(renamedKey),
@@ -27,13 +27,17 @@ async function saveLog({ email, organization, type, subType, subject, response,
27
27
  type: AuditLogReplacementType.EmailAddress,
28
28
  })],
29
29
  ['subType', AuditLogReplacement.key(subType || 'unknown')],
30
- ['response', AuditLogReplacement.longText(response)],
31
30
  ['subject', AuditLogReplacement.string(subject)],
32
31
  ['sender', AuditLogReplacement.create({
33
32
  value: sender,
34
33
  type: AuditLogReplacementType.EmailAddress,
35
34
  })],
36
35
  ]);
36
+
37
+ if (response) {
38
+ log.replacements.set('response', AuditLogReplacement.longText(response));
39
+ }
40
+
37
41
  // Check if we already logged this bounce
38
42
  const existing = await AuditLog.select().where('externalId', log.externalId).first(false);
39
43
  if (existing) {
@@ -44,140 +48,170 @@ async function saveLog({ email, organization, type, subType, subject, response,
44
48
  await log.save();
45
49
  }
46
50
 
47
- async function checkBounces() {
48
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
49
- return;
50
- }
51
-
52
- console.log('[AWS BOUNCES] Checking bounces from AWS SQS');
53
- const sqs = new AWS.SQS();
54
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-bounces-queue', MaxNumberOfMessages: 10 }).promise();
55
- if (messages.Messages) {
56
- for (const message of messages.Messages) {
57
- console.log('[AWS BOUNCES] Received bounce message');
58
- console.log('[AWS BOUNCES]', message);
59
-
60
- if (message.ReceiptHandle) {
61
- if (STAMHOOFD.environment === 'production') {
62
- await sqs.deleteMessage({
63
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-bounces-queue',
64
- ReceiptHandle: message.ReceiptHandle,
65
- }).promise();
66
- console.log('[AWS BOUNCES] Deleted from queue');
67
- }
51
+ async function handleBounce(message: any) {
52
+ if (message.bounce) {
53
+ const b = message.bounce;
54
+ // Block all receivers that generate a permanent bounce
55
+ const type = b.bounceType;
56
+ const subtype = b.bounceSubType;
57
+
58
+ const source = message.mail.source;
59
+
60
+ // try to find organization that is responsible for this e-mail address
61
+
62
+ for (const recipient of b.bouncedRecipients) {
63
+ const email = recipient.emailAddress;
64
+
65
+ if (
66
+ type === 'Permanent'
67
+ || (
68
+ recipient.diagnosticCode && (
69
+ (recipient.diagnosticCode as string).toLowerCase().includes('invalid domain')
70
+ || (recipient.diagnosticCode as string).toLowerCase().includes('unable to lookup dns')
71
+ )
72
+ )
73
+ ) {
74
+ const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
75
+ const emailAddress = await EmailAddress.getOrCreate(email, organization?.id ?? null);
76
+ emailAddress.hardBounce = true;
77
+ await emailAddress.save();
78
+
79
+ await saveLog({
80
+ id: b.feedbackId,
81
+ email,
82
+ organization,
83
+ type: AuditLogType.EmailAddressHardBounced,
84
+ subType: subtype || 'unknown',
85
+ sender: source,
86
+ response: recipient.diagnosticCode || '',
87
+ subject: message.mail.commonHeaders?.subject || '',
88
+ });
68
89
  }
90
+ else if (
91
+ type === 'Transient'
92
+ ) {
93
+ const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
94
+ await saveLog({
95
+ id: b.feedbackId,
96
+ email,
97
+ organization,
98
+ type: AuditLogType.EmailAddressSoftBounced,
99
+ subType: subtype || 'unknown',
100
+ sender: source,
101
+ response: recipient.diagnosticCode || '',
102
+ subject: message.mail.commonHeaders?.subject || '',
103
+ });
104
+ }
105
+ }
106
+ console.log('[AWS BOUNCES] For domain ' + source);
107
+ }
108
+ else {
109
+ console.log("[AWS BOUNCES] 'bounce' field missing in bounce message");
110
+ }
111
+ }
69
112
 
70
- try {
71
- if (message.Body) {
72
- // decode the JSON value
73
- const bounce = JSON.parse(message.Body);
74
-
75
- if (bounce.Message) {
76
- const message = JSON.parse(bounce.Message);
77
-
78
- if (message.bounce) {
79
- const b = message.bounce;
80
- // Block all receivers that generate a permanent bounce
81
- const type = b.bounceType;
82
- const subtype = b.bounceSubType;
83
-
84
- const source = message.mail.source;
85
-
86
- // try to find organization that is responsible for this e-mail address
87
-
88
- for (const recipient of b.bouncedRecipients) {
89
- const email = recipient.emailAddress;
90
-
91
- if (
92
- type === 'Permanent'
93
- || (
94
- recipient.diagnosticCode && (
95
- (recipient.diagnosticCode as string).toLowerCase().includes('invalid domain')
96
- || (recipient.diagnosticCode as string).toLowerCase().includes('unable to lookup dns')
97
- )
98
- )
99
- ) {
100
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
101
- if (organization) {
102
- const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
103
- emailAddress.hardBounce = true;
104
- await emailAddress.save();
105
- }
106
- else {
107
- console.error('[AWS BOUNCES] Unknown organization for email address ' + source);
108
- }
109
-
110
- await saveLog({
111
- id: b.feedbackId,
112
- email,
113
- organization,
114
- type: AuditLogType.EmailAddressHardBounced,
115
- subType: subtype || 'unknown',
116
- sender: source,
117
- response: b.diagnosticCode || '',
118
- subject: message.mail.commonHeaders?.subject || '',
119
- });
120
- }
121
- else if (
122
- type === 'Transient'
123
- ) {
124
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
125
- await saveLog({
126
- id: b.feedbackId,
127
- email,
128
- organization,
129
- type: AuditLogType.EmailAddressSoftBounced,
130
- subType: subtype || 'unknown',
131
- sender: source,
132
- response: b.diagnosticCode || '',
133
- subject: message.mail.commonHeaders?.subject || '',
134
- });
135
- }
136
- }
137
- console.log('[AWS BOUNCES] For domain ' + source);
138
- }
139
- else {
140
- console.log("[AWS BOUNCES] 'bounce' field missing in bounce message");
141
- }
142
- }
143
- else {
144
- console.log("[AWS BOUNCES] 'Message' field missing in bounce message");
145
- }
113
+ async function handleComplaint(message: any) {
114
+ if (message.complaint) {
115
+ const b = message.complaint;
116
+ const source = message.mail.source;
117
+ const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
118
+
119
+ const type: 'abuse' | 'auth-failure' | 'fraud' | 'not-spam' | 'other' | 'virus' = b.complaintFeedbackType;
120
+
121
+ for (const recipient of b.complainedRecipients) {
122
+ const email = recipient.emailAddress;
123
+ const emailAddress = await EmailAddress.getOrCreate(email, organization?.id ?? null);
124
+ emailAddress.markedAsSpam = type !== 'not-spam';
125
+ await emailAddress.save();
126
+
127
+ if (type !== 'not-spam') {
128
+ if (type === 'virus' || type === 'fraud') {
129
+ await saveLog({
130
+ id: b.feedbackId,
131
+ email: source,
132
+ organization,
133
+ type: AuditLogType.EmailAddressFraudComplaint,
134
+ subType: type || 'unknown',
135
+ sender: source,
136
+ response: recipient.diagnosticCode || '',
137
+ subject: message.mail.commonHeaders?.subject || '',
138
+ });
146
139
  }
147
140
  else {
148
- console.log('[AWS BOUNCES] Message Body missing in bounce');
141
+ await saveLog({
142
+ id: b.feedbackId,
143
+ email: source,
144
+ organization,
145
+ type: AuditLogType.EmailAddressMarkedAsSpam,
146
+ subType: type || 'unknown',
147
+ sender: source,
148
+ response: recipient.diagnosticCode || '',
149
+ subject: message.mail.commonHeaders?.subject || '',
150
+ });
149
151
  }
150
152
  }
151
- catch (e) {
152
- console.log('[AWS BOUNCES] Bounce message processing failed:');
153
- console.error('[AWS BOUNCES] Bounce message processing failed:');
154
- console.error('[AWS BOUNCES]', e);
153
+ }
154
+
155
+ if (type === 'virus' || type === 'fraud') {
156
+ console.error('[AWS COMPLAINTS] Received virus / fraud complaint!');
157
+ console.error('[AWS COMPLAINTS]', message.complaint);
158
+ if (STAMHOOFD.environment !== 'development') {
159
+ Email.sendWebmaster({
160
+ subject: 'Received a ' + type + ' email notification',
161
+ text: 'We received a ' + type + ' notification for an e-mail from the organization: ' + organization?.name + '. Please check and adjust if needed.\n',
162
+ });
155
163
  }
156
164
  }
157
165
  }
166
+ else {
167
+ console.log('[AWS COMPLAINTS] Missing complaint field');
168
+ }
158
169
  }
159
170
 
160
- async function checkReplies() {
161
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
162
- return;
171
+ async function handleForward(message: any) {
172
+ if (message.mail && message.content && message.receipt) {
173
+ const content = message.content;
174
+ const receipt = message.receipt as {
175
+ recipients: string[];
176
+ spamVerdict: { status: 'PASS' | string };
177
+ virusVerdict: { status: 'PASS' | string };
178
+ spfVerdict: { status: 'PASS' | string };
179
+ dkimVerdict: { status: 'PASS' | string };
180
+ dmarcVerdict: { status: 'PASS' | string };
181
+ };
182
+
183
+ const options = await ForwardHandler.handle(content, receipt);
184
+ if (options) {
185
+ if (STAMHOOFD.environment !== 'development') {
186
+ Email.send(options);
187
+ }
188
+ }
189
+ }
190
+ else {
191
+ console.log('[AWS FORWARDING] Missing mail, content or receipt field');
163
192
  }
193
+ }
164
194
 
165
- console.log('Checking replies from AWS SQS');
195
+ async function readFromQueue(queueUrl: string) {
196
+ console.log('[AWS Queue] Checking ' + queueUrl);
166
197
  const sqs = new AWS.SQS();
167
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-email-forwarding', MaxNumberOfMessages: 10 }).promise();
198
+ const messages = await sqs.receiveMessage({ QueueUrl: queueUrl, MaxNumberOfMessages: 10 }).promise();
199
+ let didProcess = 0;
168
200
  if (messages.Messages) {
169
201
  for (const message of messages.Messages) {
170
- console.log('Received message from forwarding queue');
202
+ console.log('[AWS Queue] Received message');
203
+ console.log('[AWS Queue]', message);
171
204
 
172
205
  if (message.ReceiptHandle) {
173
- if (STAMHOOFD.environment === 'production') {
206
+ if (STAMHOOFD.environment !== 'development') {
174
207
  await sqs.deleteMessage({
175
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-email-forwarding',
208
+ QueueUrl: queueUrl,
176
209
  ReceiptHandle: message.ReceiptHandle,
177
210
  }).promise();
178
- console.log('Deleted from queue');
211
+ console.log('[AWS Queue] Deleted from queue');
179
212
  }
180
213
  }
214
+ didProcess += 1;
181
215
 
182
216
  try {
183
217
  if (message.Body) {
@@ -187,138 +221,82 @@ async function checkReplies() {
187
221
  if (bounce.Message) {
188
222
  const message = JSON.parse(bounce.Message);
189
223
 
224
+ // Docs: https://docs.aws.amazon.com/ses/latest/dg/event-publishing-retrieving-sns-contents.html
225
+ if (message.bounce) {
226
+ await handleBounce(message);
227
+ }
228
+ else if (message.complaint) {
229
+ await handleComplaint(message);
230
+ }
190
231
  // Read message content
191
- if (message.mail && message.content && message.receipt) {
192
- const content = message.content;
193
- const receipt = message.receipt as {
194
- recipients: string[];
195
- spamVerdict: { status: 'PASS' | string };
196
- virusVerdict: { status: 'PASS' | string };
197
- spfVerdict: { status: 'PASS' | string };
198
- dkimVerdict: { status: 'PASS' | string };
199
- dmarcVerdict: { status: 'PASS' | string };
200
- };
201
-
202
- const options = await ForwardHandler.handle(content, receipt);
203
- if (options) {
204
- if (STAMHOOFD.environment === 'production') {
205
- Email.send(options);
206
- }
207
- }
232
+ // https://docs.aws.amazon.com/ses/latest/dg/receiving-email-notifications-contents.html
233
+ else if (message.mail && message.content && message.receipt) {
234
+ await handleForward(message);
208
235
  }
236
+ else {
237
+ console.log('[AWS Queue] Unsupported message');
238
+ }
239
+ }
240
+ else {
241
+ console.log("[AWS Queue] 'Message' field missing");
209
242
  }
210
243
  }
244
+ else {
245
+ console.log('[AWS Queue] Message Body missing in bounce');
246
+ }
211
247
  }
212
248
  catch (e) {
213
- console.error(e);
249
+ console.log('[AWS Queue] Message processing failed:');
250
+ console.log('[AWS Queue]', e);
251
+
252
+ console.error('[AWS Queue] Message processing failed:');
253
+ console.error('[AWS Queue]', e);
214
254
  }
215
255
  }
216
256
  }
257
+
258
+ if (didProcess) {
259
+ console.log(`[AWS Queue] Processed ${didProcess} message(s) from queue`);
260
+ }
261
+ else {
262
+ console.log(`[AWS Queue] No message to process from queue`);
263
+ }
264
+ return didProcess;
217
265
  }
218
266
 
219
- async function checkComplaints() {
220
- if (STAMHOOFD.environment !== 'production' || !STAMHOOFD.AWS_ACCESS_KEY_ID) {
267
+ async function readAllFromQueue(queueUrl: string) {
268
+ let readCount = 0;
269
+ while (readCount < 20) {
270
+ const didProcess = await readFromQueue(queueUrl);
271
+ if (!didProcess) {
272
+ break;
273
+ }
274
+ readCount += didProcess;
275
+ }
276
+ console.log(`[AWS Queue] Finished processing all messages from queue (${readCount} messages processed)`);
277
+ return true;
278
+ }
279
+
280
+ async function checkBounces() {
281
+ if (!STAMHOOFD.AWS_ACCESS_KEY_ID || !STAMHOOFD.AWS_BOUNCE_QUEUE_URL) {
221
282
  return;
222
283
  }
223
284
 
224
- console.log('[AWS COMPLAINTS] Checking complaints from AWS SQS');
225
- const sqs = new AWS.SQS();
226
- const messages = await sqs.receiveMessage({ QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-complaints-queue', MaxNumberOfMessages: 10 }).promise();
227
- if (messages.Messages) {
228
- for (const message of messages.Messages) {
229
- console.log('[AWS COMPLAINTS] Received complaint message');
230
- console.log('[AWS COMPLAINTS]', message);
285
+ await readAllFromQueue(STAMHOOFD.AWS_BOUNCE_QUEUE_URL);
286
+ }
231
287
 
232
- if (message.ReceiptHandle) {
233
- if (STAMHOOFD.environment === 'production') {
234
- await sqs.deleteMessage({
235
- QueueUrl: 'https://sqs.eu-west-1.amazonaws.com/118244293157/stamhoofd-complaints-queue',
236
- ReceiptHandle: message.ReceiptHandle,
237
- }).promise();
238
- console.log('[AWS COMPLAINTS] Deleted from queue');
239
- }
240
- }
288
+ async function checkReplies() {
289
+ if (!STAMHOOFD.AWS_ACCESS_KEY_ID || !STAMHOOFD.AWS_FORWARDING_QUEUE_URL) {
290
+ return;
291
+ }
241
292
 
242
- try {
243
- if (message.Body) {
244
- // decode the JSON value
245
- const complaint = JSON.parse(message.Body);
246
- console.log('[AWS COMPLAINTS]', complaint);
247
-
248
- if (complaint.Message) {
249
- const message = JSON.parse(complaint.Message);
250
-
251
- if (message.complaint) {
252
- const b = message.complaint;
253
- const source = message.mail.source;
254
- const organization: Organization | undefined = source ? await Organization.getByEmail(source) : undefined;
255
-
256
- const type: 'abuse' | 'auth-failure' | 'fraud' | 'not-spam' | 'other' | 'virus' = b.complaintFeedbackType;
257
-
258
- if (organization) {
259
- for (const recipient of b.complainedRecipients) {
260
- const email = recipient.emailAddress;
261
- const emailAddress = await EmailAddress.getOrCreate(email, organization.id);
262
- emailAddress.markedAsSpam = type !== 'not-spam';
263
- await emailAddress.save();
264
-
265
- if (type !== 'not-spam') {
266
- if (type === 'virus' || type === 'fraud') {
267
- await saveLog({
268
- id: b.feedbackId,
269
- email: source,
270
- organization,
271
- type: AuditLogType.EmailAddressFraudComplaint,
272
- subType: type || 'unknown',
273
- sender: source,
274
- response: b.diagnosticCode || '',
275
- subject: message.mail.commonHeaders?.subject || '',
276
- });
277
- }
278
- else {
279
- await saveLog({
280
- id: b.feedbackId,
281
- email: source,
282
- organization,
283
- type: AuditLogType.EmailAddressMarkedAsSpam,
284
- subType: type || 'unknown',
285
- sender: source,
286
- response: b.diagnosticCode || '',
287
- subject: message.mail.commonHeaders?.subject || '',
288
- });
289
- }
290
- }
291
- }
292
- }
293
- else {
294
- console.error('[AWS COMPLAINTS] Unknown organization for email address ' + source);
295
- }
296
-
297
- if (type === 'virus' || type === 'fraud') {
298
- console.error('[AWS COMPLAINTS] Received virus / fraud complaint!');
299
- console.error('[AWS COMPLAINTS]', complaint);
300
- if (STAMHOOFD.environment === 'production') {
301
- Email.sendWebmaster({
302
- subject: 'Received a ' + type + ' email notification',
303
- text: 'We received a ' + type + ' notification for an e-mail from the organization: ' + organization?.name + '. Please check and adjust if needed.\n',
304
- });
305
- }
306
- }
307
- }
308
- else {
309
- console.log('[AWS COMPLAINTS] Missing complaint field');
310
- }
311
- }
312
- else {
313
- console.log('[AWS COMPLAINTS] Missing message field in complaint');
314
- }
315
- }
316
- }
317
- catch (e) {
318
- console.log('[AWS COMPLAINTS] Complain message processing failed:');
319
- console.error('[AWS COMPLAINTS] Complain message processing failed:');
320
- console.error('[AWS COMPLAINTS]', e);
321
- }
322
- }
293
+ await readAllFromQueue(STAMHOOFD.AWS_FORWARDING_QUEUE_URL);
294
+ }
295
+
296
+ async function checkComplaints() {
297
+ if (!STAMHOOFD.AWS_ACCESS_KEY_ID || !STAMHOOFD.AWS_COMPLAINTS_QUEUE_URL) {
298
+ return;
323
299
  }
300
+
301
+ await readAllFromQueue(STAMHOOFD.AWS_COMPLAINTS_QUEUE_URL);
324
302
  }
@@ -4,7 +4,7 @@ import { clearExcelCacheHelper } from './clearExcelCache';
4
4
 
5
5
  const testPath = '/Users/user/project/backend/app/api/.cache';
6
6
  jest.mock('fs/promises');
7
- const fsMock = jest.mocked(fs, true);
7
+ const fsMock = jest.mocked(fs, { shallow: true });
8
8
 
9
9
  describe('clearExcelCacheHelper', () => {
10
10
  it('should only run between 3 and 6 AM', async () => {