@stamhoofd/models 2.92.0 → 2.94.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 (56) hide show
  1. package/dist/src/factories/RegistrationPeriodFactory.d.ts +1 -0
  2. package/dist/src/factories/RegistrationPeriodFactory.d.ts.map +1 -1
  3. package/dist/src/factories/RegistrationPeriodFactory.js +4 -0
  4. package/dist/src/factories/RegistrationPeriodFactory.js.map +1 -1
  5. package/dist/src/helpers/EmailBuilder.d.ts +1 -0
  6. package/dist/src/helpers/EmailBuilder.d.ts.map +1 -1
  7. package/dist/src/helpers/EmailBuilder.js +5 -3
  8. package/dist/src/helpers/EmailBuilder.js.map +1 -1
  9. package/dist/src/migrations/1755789797-email-counts-errors.sql +7 -0
  10. package/dist/src/migrations/1755789798-email-recipient-errors.sql +2 -0
  11. package/dist/src/migrations/1756115313-email-recipient-ids-and-errors.sql +10 -0
  12. package/dist/src/migrations/1756115314-email-recipient-email-optional.sql +2 -0
  13. package/dist/src/migrations/1756115315-email-recipients-count.sql +3 -0
  14. package/dist/src/migrations/1756115316-email-cached-counts.sql +4 -0
  15. package/dist/src/migrations/1756115317-email-deleted-at.sql +2 -0
  16. package/dist/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
  17. package/dist/src/migrations/1756293495-platform-next-period-id.sql +3 -0
  18. package/dist/src/models/Email.d.ts +62 -2
  19. package/dist/src/models/Email.d.ts.map +1 -1
  20. package/dist/src/models/Email.js +544 -198
  21. package/dist/src/models/Email.js.map +1 -1
  22. package/dist/src/models/Email.test.js +151 -43
  23. package/dist/src/models/Email.test.js.map +1 -1
  24. package/dist/src/models/EmailRecipient.d.ts +31 -1
  25. package/dist/src/models/EmailRecipient.d.ts.map +1 -1
  26. package/dist/src/models/EmailRecipient.js +53 -2
  27. package/dist/src/models/EmailRecipient.js.map +1 -1
  28. package/dist/src/models/Event.d.ts.map +1 -1
  29. package/dist/src/models/Event.js +2 -0
  30. package/dist/src/models/Event.js.map +1 -1
  31. package/dist/src/models/Platform.d.ts +1 -0
  32. package/dist/src/models/Platform.d.ts.map +1 -1
  33. package/dist/src/models/Platform.js +5 -0
  34. package/dist/src/models/Platform.js.map +1 -1
  35. package/dist/src/models/RegistrationPeriod.d.ts +3 -1
  36. package/dist/src/models/RegistrationPeriod.d.ts.map +1 -1
  37. package/dist/src/models/RegistrationPeriod.js +28 -7
  38. package/dist/src/models/RegistrationPeriod.js.map +1 -1
  39. package/package.json +2 -2
  40. package/src/factories/RegistrationPeriodFactory.ts +4 -0
  41. package/src/helpers/EmailBuilder.ts +6 -3
  42. package/src/migrations/1755789797-email-counts-errors.sql +7 -0
  43. package/src/migrations/1755789798-email-recipient-errors.sql +2 -0
  44. package/src/migrations/1756115313-email-recipient-ids-and-errors.sql +10 -0
  45. package/src/migrations/1756115314-email-recipient-email-optional.sql +2 -0
  46. package/src/migrations/1756115315-email-recipients-count.sql +3 -0
  47. package/src/migrations/1756115316-email-cached-counts.sql +4 -0
  48. package/src/migrations/1756115317-email-deleted-at.sql +2 -0
  49. package/src/migrations/1756293494-registration-period-next-period-id.sql +3 -0
  50. package/src/migrations/1756293495-platform-next-period-id.sql +3 -0
  51. package/src/models/Email.test.ts +165 -44
  52. package/src/models/Email.ts +620 -212
  53. package/src/models/EmailRecipient.ts +46 -2
  54. package/src/models/Event.ts +2 -0
  55. package/src/models/Platform.ts +4 -0
  56. package/src/models/RegistrationPeriod.ts +29 -7
@@ -1,18 +1,36 @@
1
1
  import { column } from '@simonbackx/simple-database';
2
- import { EmailAttachment, EmailPreview, EmailRecipientFilter, EmailRecipientFilterType, EmailRecipientsStatus, EmailRecipient as EmailRecipientStruct, EmailStatus, Email as EmailStruct, EmailTemplateType, getExampleRecipient, LimitedFilteredRequest, PaginatedResponse, SortItemDirection, StamhoofdFilter } from '@stamhoofd/structures';
2
+ import { EmailAttachment, EmailPreview, EmailRecipientFilter, EmailRecipientFilterType, EmailRecipientsStatus, EmailRecipient as EmailRecipientStruct, EmailStatus, Email as EmailStruct, EmailTemplateType, getExampleRecipient, LimitedFilteredRequest, PaginatedResponse, SortItemDirection, StamhoofdFilter, isSoftEmailRecipientError } from '@stamhoofd/structures';
3
3
  import { v4 as uuidv4 } from 'uuid';
4
4
 
5
5
  import { AnyDecoder, ArrayDecoder } from '@simonbackx/simple-encoding';
6
- import { SimpleError } from '@simonbackx/simple-errors';
6
+ import { isSimpleError, isSimpleErrors, SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
7
7
  import { I18n } from '@stamhoofd/backend-i18n';
8
8
  import { Email as EmailClass, EmailInterfaceRecipient } from '@stamhoofd/email';
9
- import { QueueHandler } from '@stamhoofd/queues';
10
- import { QueryableModel, SQL, SQLWhereSign } from '@stamhoofd/sql';
9
+ import { isAbortedError, QueueHandler, QueueHandlerOptions } from '@stamhoofd/queues';
10
+ import { QueryableModel, readDynamicSQLExpression, SQL, SQLAlias, SQLCalculation, SQLCount, SQLPlusSign, SQLSelectAs, SQLWhereSign } from '@stamhoofd/sql';
11
11
  import { canSendFromEmail, fillRecipientReplacements, getEmailBuilder } from '../helpers/EmailBuilder';
12
12
  import { EmailRecipient } from './EmailRecipient';
13
13
  import { EmailTemplate } from './EmailTemplate';
14
14
  import { Organization } from './Organization';
15
15
 
16
+ function errorToSimpleErrors(e: unknown) {
17
+ if (isSimpleErrors(e)) {
18
+ return e;
19
+ }
20
+ else if (isSimpleError(e)) {
21
+ return new SimpleErrors(e);
22
+ }
23
+ else {
24
+ return new SimpleErrors(
25
+ new SimpleError({
26
+ code: 'unknown_error',
27
+ message: ((typeof e === 'object' && e !== null && 'message' in e && typeof e.message === 'string') ? e.message : 'Unknown error'),
28
+ human: $t(`Onbekende fout`),
29
+ }),
30
+ );
31
+ }
32
+ }
33
+
16
34
  export class Email extends QueryableModel {
17
35
  static table = 'emails';
18
36
 
@@ -61,8 +79,64 @@ export class Email extends QueryableModel {
61
79
  @column({ type: 'string', nullable: true })
62
80
  fromName: string | null = null;
63
81
 
82
+ /**
83
+ * Amount of recipients with an email address
84
+ */
85
+ @column({ type: 'integer', nullable: true })
86
+ emailRecipientsCount: number | null = null;
87
+
88
+ /**
89
+ * Amount of recipients without an email address
90
+ */
64
91
  @column({ type: 'integer', nullable: true })
65
- recipientCount: number | null = null;
92
+ otherRecipientsCount: number | null = null;
93
+
94
+ /**
95
+ * Amount of recipients that have successfully received the email.
96
+ */
97
+ @column({ type: 'integer' })
98
+ succeededCount = 0;
99
+
100
+ /**
101
+ * Amount of recipients that somehow failed to receive the email,
102
+ * but with a soft error that doesn't require action.
103
+ * - Duplicate email in recipient list
104
+ * - Unsubscribed
105
+ */
106
+ @column({ type: 'integer' })
107
+ softFailedCount = 0;
108
+
109
+ /**
110
+ * Amount of recipients that somehow failed to receive the email:
111
+ * - Invalid email address
112
+ * - Full email inbox
113
+ */
114
+ @column({ type: 'integer' })
115
+ failedCount = 0;
116
+
117
+ /**
118
+ * Unique amount of members that are in the recipients list.
119
+ */
120
+ @column({ type: 'integer' })
121
+ membersCount = 0;
122
+
123
+ /**
124
+ * Does only include bounces AFTER sending the email
125
+ */
126
+ @column({ type: 'integer' })
127
+ hardBouncesCount = 0;
128
+
129
+ /**
130
+ * Does only include bounces AFTER sending the email
131
+ */
132
+ @column({ type: 'integer' })
133
+ softBouncesCount = 0;
134
+
135
+ /**
136
+ * Does only include bounces AFTER sending the email
137
+ */
138
+ @column({ type: 'integer' })
139
+ spamComplaintsCount = 0;
66
140
 
67
141
  @column({ type: 'string' })
68
142
  status = EmailStatus.Draft;
@@ -70,6 +144,18 @@ export class Email extends QueryableModel {
70
144
  @column({ type: 'string' })
71
145
  recipientsStatus = EmailRecipientsStatus.NotCreated;
72
146
 
147
+ /**
148
+ * Errors related to creating the recipients.
149
+ */
150
+ @column({ type: 'json', nullable: true, decoder: SimpleErrors })
151
+ recipientsErrors: SimpleErrors | null = null;
152
+
153
+ /**
154
+ * Errors related to sending the email.
155
+ */
156
+ @column({ type: 'json', nullable: true, decoder: SimpleErrors })
157
+ emailErrors: SimpleErrors | null = null;
158
+
73
159
  /**
74
160
  * todo: ignore automatically
75
161
  */
@@ -82,6 +168,12 @@ export class Email extends QueryableModel {
82
168
  })
83
169
  sentAt: Date | null = null;
84
170
 
171
+ @column({
172
+ type: 'datetime',
173
+ nullable: true,
174
+ })
175
+ deletedAt: Date | null = null;
176
+
85
177
  @column({
86
178
  type: 'datetime', beforeSave(old?: any) {
87
179
  if (old !== undefined) {
@@ -109,6 +201,8 @@ export class Email extends QueryableModel {
109
201
  count(request: LimitedFilteredRequest, subfilter: StamhoofdFilter | null): Promise<number>;
110
202
  }> = new Map();
111
203
 
204
+ static pendingNotificationCountUpdates: Map<string, { timer: NodeJS.Timeout | null; lastUpdate: Date | null }> = new Map();
205
+
112
206
  throwIfNotReadyToSend() {
113
207
  if (this.subject == null || this.subject.length == 0) {
114
208
  throw new SimpleError({
@@ -142,6 +236,22 @@ export class Email extends QueryableModel {
142
236
  });
143
237
  }
144
238
 
239
+ if (this.status === EmailStatus.Draft && this.recipientsErrors !== null && this.recipientsStatus !== EmailRecipientsStatus.Created) {
240
+ throw new SimpleError({
241
+ code: 'invalid_recipients',
242
+ message: 'Failed to build recipients (count)',
243
+ human: $t(`Er ging iets mis bij het aanmaken van de ontvangers. Probeer je selectie aan te passen. Neem contact op als het probleem zich blijft voordoen.`) + ' ' + this.recipientsErrors.getHuman(),
244
+ });
245
+ }
246
+
247
+ if (this.deletedAt) {
248
+ throw new SimpleError({
249
+ code: 'invalid_state',
250
+ message: 'Email is deleted',
251
+ human: $t(`Deze e-mail is verwijderd en kan niet verzonden worden.`),
252
+ });
253
+ }
254
+
145
255
  this.validateAttachments();
146
256
  }
147
257
 
@@ -264,13 +374,10 @@ export class Email extends QueryableModel {
264
374
  return true;
265
375
  }
266
376
 
267
- async send(): Promise<Email | null> {
268
- this.throwIfNotReadyToSend();
269
- await this.save();
270
-
377
+ async lock<T>(callback: (upToDate: Email, options: QueueHandlerOptions) => Promise<T> | T): Promise<T> {
271
378
  const id = this.id;
272
- return await QueueHandler.schedule('send-email', async function (this: unknown, { abort }) {
273
- let upToDate = await Email.getByID(id);
379
+ return await QueueHandler.schedule('lock-email-' + id, async (options) => {
380
+ const upToDate = await Email.getByID(id);
274
381
  if (!upToDate) {
275
382
  throw new SimpleError({
276
383
  code: 'not_found',
@@ -278,258 +385,521 @@ export class Email extends QueryableModel {
278
385
  human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
279
386
  });
280
387
  }
281
- if (upToDate.status === EmailStatus.Sent || upToDate.status === EmailStatus.Failed) {
282
- // Already done
283
- // In other cases -> queue has stopped and we can retry
284
- return upToDate;
285
- }
388
+ const c = await callback(upToDate, options);
389
+ this.copyFrom(upToDate);
390
+ return c;
391
+ });
392
+ }
286
393
 
287
- if (upToDate.status === EmailStatus.Sending) {
288
- // This is a retry.
289
- if (upToDate.emailType) {
290
- // Not eligible for retry
291
- upToDate.status = EmailStatus.Failed;
292
- await upToDate.save();
293
- return upToDate;
294
- }
295
- if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
296
- // Too long
297
- console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
298
- upToDate.status = EmailStatus.Failed;
299
- await upToDate.save();
300
- return upToDate;
301
- }
394
+ static async bumpNotificationCount(emailId: string, type: 'hard-bounce' | 'soft-bounce' | 'complaint') {
395
+ // Send an update query
396
+ const base = Email.update()
397
+ .where('id', emailId);
398
+
399
+ switch (type) {
400
+ case 'hard-bounce': {
401
+ base.set('hardBouncesCount', new SQLCalculation(
402
+ SQL.column('hardBouncesCount'),
403
+ new SQLPlusSign(),
404
+ readDynamicSQLExpression(1),
405
+ ));
406
+ break;
407
+ }
408
+ case 'soft-bounce': {
409
+ base.set('softBouncesCount', new SQLCalculation(
410
+ SQL.column('softBouncesCount'),
411
+ new SQLPlusSign(),
412
+ readDynamicSQLExpression(1),
413
+ ));
414
+ break;
302
415
  }
303
- const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
304
- upToDate.throwIfNotReadyToSend();
416
+ case 'complaint': {
417
+ base.set('spamComplaintsCount', new SQLCalculation(
418
+ SQL.column('spamComplaintsCount'),
419
+ new SQLPlusSign(),
420
+ readDynamicSQLExpression(1),
421
+ ));
422
+ break;
423
+ }
424
+ }
305
425
 
306
- let from = upToDate.getDefaultFromAddress(organization);
307
- let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
426
+ await base.update();
308
427
 
309
- if (!from) {
310
- throw new SimpleError({
311
- code: 'invalid_field',
312
- message: 'Missing from',
313
- human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
314
- });
315
- }
428
+ await this.checkNeedsNotificationCountUpdate(emailId, true);
429
+ }
316
430
 
317
- // Can we send from this e-mail or reply-to?
318
- if (upToDate.fromAddress && await canSendFromEmail(upToDate.fromAddress, organization ?? null)) {
319
- from = upToDate.getFromAddress();
320
- replyTo = null;
321
- }
431
+ static async checkNeedsNotificationCountUpdate(emailId: string, didUpdate = false) {
432
+ const existing = this.pendingNotificationCountUpdates.get(emailId);
433
+ const object = existing ?? {
434
+ timer: null,
435
+ lastUpdate: didUpdate ? new Date() : null,
436
+ };
322
437
 
323
- abort.throwIfAborted();
324
- upToDate.status = EmailStatus.Sending;
325
- upToDate.sentAt = upToDate.sentAt ?? new Date();
326
- await upToDate.save();
438
+ if (didUpdate) {
439
+ object.lastUpdate = new Date();
440
+ }
327
441
 
328
- // Create recipients if not yet created
329
- await upToDate.buildRecipients();
330
- abort.throwIfAborted();
442
+ if (existing) {
443
+ this.pendingNotificationCountUpdates.set(emailId, object);
444
+ }
331
445
 
332
- // Refresh model
333
- upToDate = await Email.getByID(id);
334
- if (!upToDate) {
335
- throw new SimpleError({
336
- code: 'not_found',
337
- message: 'Email not found',
338
- human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
339
- });
340
- }
446
+ if (object.lastUpdate && object.lastUpdate < new Date(Date.now() - 5 * 60 * 1000)) {
447
+ // After 5 minutes without notifications, run an update.
448
+ await this.updateNotificationsCounts(emailId);
341
449
 
342
- if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
343
- throw new SimpleError({
344
- code: 'recipients_not_created',
345
- message: 'Failed to create recipients',
346
- human: $t(`f660b2eb-e382-4d21-86e4-673ca7bc2d4a`),
347
- });
450
+ // Stop here
451
+ return;
452
+ }
453
+
454
+ // Schedule a slow update of all counts
455
+ if (!object.timer) {
456
+ object.timer = setTimeout(() => {
457
+ object.timer = null;
458
+ this.checkNeedsNotificationCountUpdate(emailId).catch(console.error);
459
+ }, 1 * 60 * 1000);
460
+ }
461
+ }
462
+
463
+ static async updateNotificationsCounts(emailId: string) {
464
+ QueueHandler.cancel('updateNotificationsCounts-' + emailId);
465
+ return await QueueHandler.schedule('updateNotificationsCounts-' + emailId, async () => {
466
+ const query = SQL.select(
467
+ new SQLSelectAs(
468
+ new SQLCount(
469
+ SQL.column('hardBounceError'),
470
+ ),
471
+ new SQLAlias('data__hardBounces'),
472
+ ),
473
+ // If the current amount_due is negative, we can ignore that negative part if there is a future due item
474
+ new SQLSelectAs(
475
+ new SQLCount(
476
+ SQL.column('softBounceError'),
477
+ ),
478
+ new SQLAlias('data__softBounces'),
479
+ ),
480
+
481
+ new SQLSelectAs(
482
+ new SQLCount(
483
+ SQL.column('spamComplaintError'),
484
+ ),
485
+ new SQLAlias('data__complaints'),
486
+ ),
487
+ )
488
+ .from(EmailRecipient.table)
489
+ .where('emailId', emailId);
490
+
491
+ const result = await query.fetch();
492
+ if (result.length !== 1) {
493
+ console.error('Unexpected result', result);
494
+ return;
495
+ }
496
+ const row = result[0]['data'];
497
+ if (!row) {
498
+ console.error('Unexpected result row', result);
499
+ return;
348
500
  }
349
501
 
350
- // Start actually sending in batches of recipients that are not yet sent
351
- let idPointer = '';
352
- const batchSize = 100;
353
- const recipientsSet = new Set<string>();
502
+ const hardBounces = row['hardBounces'];
503
+ const softBounces = row['softBounces'];
504
+ const complaints = row['complaints'];
354
505
 
355
- // Create a buffer of all attachments
356
- const attachments: { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string }[] = [];
506
+ if (typeof hardBounces !== 'number' || typeof softBounces !== 'number' || typeof complaints !== 'number') {
507
+ console.error('Unexpected result values', row);
508
+ return;
509
+ }
357
510
 
358
- for (const attachment of upToDate.attachments) {
359
- if (!attachment.content && !attachment.file) {
360
- console.warn('Attachment without content found, skipping', attachment);
361
- continue;
362
- }
511
+ console.log('Updating email notification counts', emailId, hardBounces, softBounces, complaints);
363
512
 
364
- let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
513
+ // Send an update query
514
+ await Email.update()
515
+ .where('id', emailId)
516
+ .set('hardBouncesCount', hardBounces)
517
+ .set('softBouncesCount', softBounces)
518
+ .set('spamComplaintsCount', complaints)
519
+ .update();
520
+ });
521
+ }
365
522
 
366
- if (attachment.contentType === 'application/pdf') {
367
- // tmp solution for pdf only
368
- filename += '.pdf';
369
- }
523
+ async queueForSending(waitForSending = false) {
524
+ this.throwIfNotReadyToSend();
525
+ await this.lock(async (upToDate) => {
526
+ if (upToDate.status === EmailStatus.Draft) {
527
+ upToDate.status = EmailStatus.Queued;
528
+ }
529
+ if (upToDate.status === EmailStatus.Failed) {
530
+ // Retry failed email
531
+ upToDate.status = EmailStatus.Queued;
532
+ }
533
+ await upToDate.save();
534
+ });
535
+ if (waitForSending) {
536
+ return await this.resumeSending();
537
+ }
538
+ else {
539
+ this.resumeSending().catch(console.error);
540
+ }
541
+ return this;
542
+ }
370
543
 
371
- if (attachment.file?.name) {
372
- filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
544
+ async resumeSending(): Promise<Email | null> {
545
+ const id = this.id;
546
+ return await QueueHandler.schedule('send-email', async ({ abort }) => {
547
+ return await this.lock(async function (upToDate: Email) {
548
+ if (upToDate.status === EmailStatus.Sent) {
549
+ // Already done
550
+ // In other cases -> queue has stopped and we can retry
551
+ return upToDate;
373
552
  }
374
553
 
375
- // Correct file name if needed
376
- if (attachment.filename) {
377
- filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
554
+ if (upToDate.status === EmailStatus.Sending) {
555
+ // This is an automatic retry.
556
+ if (upToDate.emailType) {
557
+ // Not eligible for retry
558
+ upToDate.status = EmailStatus.Failed;
559
+ await upToDate.save();
560
+ return upToDate;
561
+ }
562
+ if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
563
+ // Too long
564
+ console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
565
+ upToDate.status = EmailStatus.Failed;
566
+ await upToDate.save();
567
+ return upToDate;
568
+ }
378
569
  }
379
-
380
- if (attachment.content) {
381
- attachments.push({
382
- filename: filename,
383
- content: attachment.content,
384
- contentType: attachment.contentType ?? undefined,
385
- encoding: 'base64',
386
- });
570
+ else if (upToDate.status !== EmailStatus.Queued) {
571
+ console.error('Email is not queued or sending, cannot send', upToDate.id, upToDate.status);
572
+ return upToDate;
387
573
  }
388
- else {
389
- // Note: because we send lots of emails, we better download the file here so we can reuse it in every email instead of downloading it every time
390
- const withSigned = await attachment.file!.withSignedUrl();
391
- if (!withSigned || !withSigned.signedUrl) {
574
+ const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
575
+ let from = upToDate.getDefaultFromAddress(organization);
576
+ let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
577
+ const attachments: { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string }[] = [];
578
+ let succeededCount = 0;
579
+ let softFailedCount = 0;
580
+ let failedCount = 0;
581
+
582
+ try {
583
+ upToDate.throwIfNotReadyToSend();
584
+
585
+ if (!from) {
392
586
  throw new SimpleError({
393
- code: 'attachment_not_found',
394
- message: 'Attachment not found',
395
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
587
+ code: 'invalid_field',
588
+ message: 'Missing from',
589
+ human: $t(`e92cd077-b0f1-4b0a-82a0-8a8baa82e73a`),
396
590
  });
397
591
  }
398
592
 
399
- const filePath = withSigned.signedUrl;
400
- let fileBuffer: Buffer | null = null;
401
- try {
402
- const response = await fetch(filePath);
403
- fileBuffer = Buffer.from(await response.arrayBuffer());
593
+ // Can we send from this e-mail or reply-to?
594
+ if (upToDate.fromAddress && await canSendFromEmail(upToDate.fromAddress, organization ?? null)) {
595
+ from = upToDate.getFromAddress();
596
+ replyTo = null;
404
597
  }
405
- catch (e) {
598
+
599
+ abort.throwIfAborted();
600
+ upToDate.status = EmailStatus.Sending;
601
+ upToDate.sentAt = upToDate.sentAt ?? new Date();
602
+ await upToDate.save();
603
+
604
+ // Create recipients if not yet created
605
+ await upToDate.buildRecipients();
606
+ abort.throwIfAborted();
607
+
608
+ // Refresh model
609
+ const c = (await Email.getByID(id))!;
610
+ if (!c) {
406
611
  throw new SimpleError({
407
- code: 'attachment_not_found',
408
- message: 'Attachment not found',
409
- human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
612
+ code: 'not_found',
613
+ message: 'Email not found',
614
+ human: $t(`55899a7c-f3d4-43fe-a431-70a3a9e78e34`),
410
615
  });
411
616
  }
617
+ upToDate.copyFrom(c);
412
618
 
413
- attachments.push({
414
- filename: filename,
415
- contentType: attachment.contentType ?? undefined,
416
- content: fileBuffer,
417
- });
418
- }
419
- }
619
+ if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
620
+ throw new SimpleError({
621
+ code: 'recipients_not_created',
622
+ message: 'Failed to create recipients',
623
+ human: $t(`f660b2eb-e382-4d21-86e4-673ca7bc2d4a`),
624
+ });
625
+ }
420
626
 
421
- while (true) {
422
- abort.throwIfAborted();
423
- const data = await SQL.select()
424
- .from('email_recipients')
425
- .where('emailId', upToDate.id)
426
- .where('sentAt', null)
427
- .where('id', SQLWhereSign.Greater, idPointer)
428
- .orderBy(SQL.column('id'), 'ASC')
429
- .limit(batchSize)
430
- .fetch();
431
-
432
- const recipients = EmailRecipient.fromRows(data, 'email_recipients');
433
-
434
- if (recipients.length === 0) {
435
- break;
436
- }
627
+ // Create a buffer of all attachments
628
+ for (const attachment of upToDate.attachments) {
629
+ if (!attachment.content && !attachment.file) {
630
+ console.warn('Attachment without content found, skipping', attachment);
631
+ continue;
632
+ }
437
633
 
438
- const sendingPromises: Promise<void>[] = [];
634
+ let filename = $t('b1291584-d2ad-4ebd-88ed-cbda4f3755b4');
439
635
 
440
- for (const recipient of recipients) {
441
- if (recipientsSet.has(recipient.id)) {
442
- console.error('Found duplicate recipient while sending email', recipient.id);
443
- continue;
444
- }
636
+ if (attachment.contentType === 'application/pdf') {
637
+ // tmp solution for pdf only
638
+ filename += '.pdf';
639
+ }
445
640
 
446
- recipientsSet.add(recipient.email);
447
- idPointer = recipient.id;
641
+ if (attachment.file?.name) {
642
+ filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
643
+ }
448
644
 
449
- let promiseResolve: (value: void | PromiseLike<void>) => void;
450
- const promise = new Promise<void>((resolve) => {
451
- promiseResolve = resolve;
452
- });
645
+ // Correct file name if needed
646
+ if (attachment.filename) {
647
+ filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
648
+ }
649
+
650
+ if (attachment.content) {
651
+ attachments.push({
652
+ filename: filename,
653
+ content: attachment.content,
654
+ contentType: attachment.contentType ?? undefined,
655
+ encoding: 'base64',
656
+ });
657
+ }
658
+ else {
659
+ // Note: because we send lots of emails, we better download the file here so we can reuse it in every email instead of downloading it every time
660
+ const withSigned = await attachment.file!.withSignedUrl();
661
+ if (!withSigned || !withSigned.signedUrl) {
662
+ throw new SimpleError({
663
+ code: 'attachment_not_found',
664
+ message: 'Attachment not found',
665
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
666
+ });
667
+ }
668
+
669
+ const filePath = withSigned.signedUrl;
670
+ let fileBuffer: Buffer | null = null;
671
+ try {
672
+ const response = await fetch(filePath);
673
+ fileBuffer = Buffer.from(await response.arrayBuffer());
674
+ }
675
+ catch (e) {
676
+ throw new SimpleError({
677
+ code: 'attachment_not_found',
678
+ message: 'Attachment not found',
679
+ human: $t(`ce6ddaf0-8347-42c5-b4b7-fbe860c7b7f2`),
680
+ });
681
+ }
682
+
683
+ attachments.push({
684
+ filename: filename,
685
+ contentType: attachment.contentType ?? undefined,
686
+ content: fileBuffer,
687
+ });
688
+ }
689
+ }
453
690
 
454
- const virtualRecipient = recipient.getRecipient();
691
+ // Start actually sending in batches of recipients that are not yet sent
692
+ let idPointer = '';
693
+ const batchSize = 100;
694
+ let isSavingStatus = false;
695
+ let lastStatusSave = new Date();
455
696
 
456
- let resolved = false;
457
- const callback = async (error: Error | null) => {
458
- if (resolved) {
697
+ async function saveStatus() {
698
+ if (!upToDate) {
699
+ return;
700
+ }
701
+ if (isSavingStatus) {
459
702
  return;
460
703
  }
461
- resolved = true;
704
+ if ((new Date().getTime() - lastStatusSave.getTime()) < 1000 * 5) {
705
+ // Save at most every 5 seconds
706
+ return;
707
+ }
708
+ if (succeededCount < upToDate.succeededCount || softFailedCount < upToDate.softFailedCount || failedCount < upToDate.failedCount) {
709
+ // Do not update on retries
710
+ return;
711
+ }
712
+
713
+ lastStatusSave = new Date();
714
+ isSavingStatus = true;
715
+ upToDate.succeededCount = succeededCount;
716
+ upToDate.softFailedCount = softFailedCount;
717
+ upToDate.failedCount = failedCount;
462
718
 
463
719
  try {
464
- if (error === null) {
465
- // Mark saved
466
- recipient.sentAt = new Date();
720
+ await upToDate.save();
721
+ }
722
+ finally {
723
+ isSavingStatus = false;
724
+ }
725
+ }
726
+
727
+ while (true) {
728
+ abort.throwIfAborted();
729
+ const data = await SQL.select()
730
+ .from('email_recipients')
731
+ .where('emailId', upToDate.id)
732
+ .where('id', SQLWhereSign.Greater, idPointer)
733
+ .orderBy(SQL.column('id'), 'ASC')
734
+ .limit(batchSize)
735
+ .fetch();
736
+
737
+ const recipients = EmailRecipient.fromRows(data, 'email_recipients');
738
+
739
+ if (recipients.length === 0) {
740
+ break;
741
+ }
742
+
743
+ const sendingPromises: Promise<void>[] = [];
744
+ let skipped = 0;
745
+
746
+ for (const recipient of recipients) {
747
+ idPointer = recipient.id;
467
748
 
468
- // Update repacements that have been generated
469
- recipient.replacements = virtualRecipient.replacements;
470
- await recipient.save();
749
+ if (recipient.sentAt) {
750
+ succeededCount += 1;
751
+ await saveStatus();
752
+ skipped++;
753
+ continue;
471
754
  }
472
- else {
473
- recipient.failCount += 1;
474
- recipient.failErrorMessage = error.message;
475
- recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
476
- recipient.lastFailedAt = new Date();
477
- await recipient.save();
755
+
756
+ if (!recipient.email) {
757
+ skipped++;
758
+ continue;
478
759
  }
760
+
761
+ let promiseResolve: (value: void | PromiseLike<void>) => void;
762
+ const promise = new Promise<void>((resolve) => {
763
+ promiseResolve = resolve;
764
+ });
765
+
766
+ const virtualRecipient = recipient.getRecipient();
767
+
768
+ let resolved = false;
769
+ const callback = async (error: Error | null) => {
770
+ if (resolved) {
771
+ return;
772
+ }
773
+ resolved = true;
774
+
775
+ try {
776
+ if (error === null) {
777
+ // Mark saved
778
+ recipient.sentAt = new Date();
779
+
780
+ // Update repacements that have been generated
781
+ recipient.replacements = virtualRecipient.replacements;
782
+
783
+ succeededCount += 1;
784
+ await recipient.save();
785
+ await saveStatus();
786
+ }
787
+ else {
788
+ recipient.failCount += 1;
789
+ recipient.failErrorMessage = error.message;
790
+ recipient.failError = errorToSimpleErrors(error);
791
+ recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
792
+ recipient.lastFailedAt = new Date();
793
+
794
+ if (isSoftEmailRecipientError(recipient.failError)) {
795
+ softFailedCount += 1;
796
+ }
797
+ else {
798
+ failedCount += 1;
799
+ }
800
+ await recipient.save();
801
+ await saveStatus();
802
+ }
803
+ }
804
+ catch (e) {
805
+ console.error(e);
806
+ }
807
+ promiseResolve();
808
+ };
809
+
810
+ // Do send the email
811
+ // Create e-mail builder
812
+ const builder = await getEmailBuilder(organization ?? null, {
813
+ recipients: [
814
+ virtualRecipient,
815
+ ],
816
+ from,
817
+ replyTo,
818
+ subject: upToDate.subject!,
819
+ html: upToDate.html!,
820
+ type: upToDate.emailType ? 'transactional' : 'broadcast',
821
+ attachments,
822
+ callback(error: Error | null) {
823
+ callback(error).catch(console.error);
824
+ },
825
+ headers: {
826
+ 'X-Email-Id': upToDate.id,
827
+ 'X-Email-Recipient-Id': recipient.id,
828
+ },
829
+ });
830
+ abort.throwIfAborted(); // do not schedule if aborted
831
+ EmailClass.schedule(builder);
832
+ sendingPromises.push(promise);
479
833
  }
480
- catch (e) {
481
- console.error(e);
834
+
835
+ if (sendingPromises.length > 0 || skipped > 0) {
836
+ await Promise.all(sendingPromises);
482
837
  }
483
- promiseResolve();
484
- };
485
-
486
- // Do send the email
487
- // Create e-mail builder
488
- const builder = await getEmailBuilder(organization ?? null, {
489
- recipients: [
490
- virtualRecipient,
491
- ],
492
- from,
493
- replyTo,
494
- subject: upToDate.subject!,
495
- html: upToDate.html!,
496
- type: upToDate.emailType ? 'transactional' : 'broadcast',
497
- attachments,
498
- callback(error: Error | null) {
499
- callback(error).catch(console.error);
500
- },
501
- });
502
- abort.throwIfAborted(); // do not schedule if aborted
503
- EmailClass.schedule(builder);
504
- sendingPromises.push(promise);
838
+ else {
839
+ break;
840
+ }
841
+ }
505
842
  }
843
+ catch (e) {
844
+ if (!upToDate) {
845
+ throw e;
846
+ }
506
847
 
507
- if (sendingPromises.length > 0) {
508
- await Promise.all(sendingPromises);
848
+ upToDate.succeededCount = succeededCount;
849
+ upToDate.softFailedCount = softFailedCount;
850
+ upToDate.failedCount = failedCount;
851
+
852
+ if (isAbortedError(e) || ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('SHUTDOWN'))) {
853
+ // Keep sending status: we'll resume after the reboot
854
+ await upToDate.save();
855
+ throw e;
856
+ }
857
+
858
+ upToDate.emailErrors = errorToSimpleErrors(e);
859
+ upToDate.status = EmailStatus.Failed;
860
+ await upToDate.save();
861
+ throw e;
862
+ }
863
+
864
+ if (upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
865
+ // We only delete automated emails (email type) if they have no recipients
866
+ console.log('No recipients found for email ', upToDate.id, ' deleting...');
867
+ await upToDate.delete();
868
+ return null;
869
+ }
870
+
871
+ console.log('Finished sending email', upToDate.id);
872
+ // Mark email as sent
873
+
874
+ if ((succeededCount + failedCount + softFailedCount) === 0) {
875
+ upToDate.status = EmailStatus.Failed;
876
+ upToDate.emailErrors = new SimpleErrors(
877
+ new SimpleError({
878
+ code: 'no_recipients',
879
+ message: 'No recipients',
880
+ human: $t(`Geen ontvangers gevonden`),
881
+ }),
882
+ );
509
883
  }
510
884
  else {
511
- break;
885
+ upToDate.status = EmailStatus.Sent;
886
+ upToDate.emailErrors = null;
512
887
  }
513
- }
514
888
 
515
- if (upToDate.recipientCount === 0 && upToDate.userId === null) {
516
- // We only delete automated emails (email type) if they have no recipients
517
- console.log('No recipients found for email ', upToDate.id, ' deleting...');
518
- await upToDate.delete();
519
- return null;
520
- }
889
+ upToDate.succeededCount = succeededCount;
890
+ upToDate.softFailedCount = softFailedCount;
891
+ upToDate.failedCount = failedCount;
521
892
 
522
- console.log('Finished sending email', upToDate.id);
523
- // Mark email as sent
524
- upToDate.status = EmailStatus.Sent;
525
- await upToDate.save();
526
- return upToDate;
893
+ await upToDate.save();
894
+ return upToDate;
895
+ });
527
896
  });
528
897
  }
529
898
 
530
899
  updateCount() {
531
900
  const id = this.id;
532
- QueueHandler.schedule('email-count-' + this.id, async function () {
901
+ QueueHandler.abort('email-count-' + this.id);
902
+ QueueHandler.schedule('email-count-' + this.id, async function ({ abort }) {
533
903
  let upToDate = await Email.getByID(id);
534
904
 
535
905
  if (!upToDate || upToDate.sentAt || !upToDate.id || upToDate.status !== EmailStatus.Draft) {
@@ -558,12 +928,14 @@ export class Email extends QueryableModel {
558
928
  search: subfilter.search,
559
929
  });
560
930
 
931
+ abort.throwIfAborted();
561
932
  const c = await loader.count(request, subfilter.subfilter);
562
933
 
563
934
  count += c;
564
935
  }
936
+ abort.throwIfAborted();
565
937
 
566
- // Check if we have a more reliable recipientCount in the meantime
938
+ // Check if we have a more reliable emailRecipientsCount in the meantime
567
939
  upToDate = await Email.getByID(id);
568
940
 
569
941
  if (!upToDate) {
@@ -572,12 +944,27 @@ export class Email extends QueryableModel {
572
944
  if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
573
945
  return;
574
946
  }
575
- upToDate.recipientCount = count;
947
+ upToDate.emailRecipientsCount = count;
576
948
  await upToDate.save();
577
949
  }
578
950
  catch (e) {
951
+ if (isAbortedError(e)) {
952
+ return;
953
+ }
579
954
  console.error('Failed to update count for email', id);
580
955
  console.error(e);
956
+
957
+ // Check if we have a more reliable emailRecipientsCount in the meantime
958
+ upToDate = await Email.getByID(id);
959
+
960
+ if (!upToDate) {
961
+ return;
962
+ }
963
+ if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
964
+ return;
965
+ }
966
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
967
+ await upToDate.save();
581
968
  }
582
969
  }).catch(console.error);
583
970
  }
@@ -606,7 +993,10 @@ export class Email extends QueryableModel {
606
993
  upToDate.recipientsStatus = EmailRecipientsStatus.Creating;
607
994
  await upToDate.save();
608
995
 
996
+ const membersSet = new Set<string>();
997
+
609
998
  let count = 0;
999
+ let countWithoutEmail = 0;
610
1000
 
611
1001
  try {
612
1002
  // Delete all recipients
@@ -639,10 +1029,10 @@ export class Email extends QueryableModel {
639
1029
  const response = await loader.fetch(request, subfilter.subfilter);
640
1030
 
641
1031
  for (const item of response.results) {
642
- if (!item.email) {
1032
+ if (!item.email && !item.memberId && !item.userId) {
643
1033
  continue;
644
1034
  }
645
- count += 1;
1035
+
646
1036
  const recipient = new EmailRecipient();
647
1037
  recipient.emailType = upToDate.emailType;
648
1038
  recipient.objectId = item.objectId;
@@ -651,8 +1041,22 @@ export class Email extends QueryableModel {
651
1041
  recipient.firstName = item.firstName;
652
1042
  recipient.lastName = item.lastName;
653
1043
  recipient.replacements = item.replacements;
1044
+ recipient.memberId = item.memberId ?? null;
1045
+ recipient.userId = item.userId ?? null;
1046
+ recipient.organizationId = upToDate.organizationId ?? null;
654
1047
 
655
1048
  await recipient.save();
1049
+
1050
+ if (recipient.memberId) {
1051
+ membersSet.add(recipient.memberId);
1052
+ }
1053
+
1054
+ if (!recipient.email) {
1055
+ countWithoutEmail += 1;
1056
+ }
1057
+ else {
1058
+ count += 1;
1059
+ }
656
1060
  }
657
1061
 
658
1062
  request = response.next ?? null;
@@ -660,13 +1064,17 @@ export class Email extends QueryableModel {
660
1064
  }
661
1065
 
662
1066
  upToDate.recipientsStatus = EmailRecipientsStatus.Created;
663
- upToDate.recipientCount = count;
1067
+ upToDate.emailRecipientsCount = count;
1068
+ upToDate.otherRecipientsCount = countWithoutEmail;
1069
+ upToDate.recipientsErrors = null;
1070
+ upToDate.membersCount = membersSet.size;
664
1071
  await upToDate.save();
665
1072
  }
666
- catch (e) {
1073
+ catch (e: unknown) {
667
1074
  console.error('Failed to build recipients for email', id);
668
1075
  console.error(e);
669
1076
  upToDate.recipientsStatus = EmailRecipientsStatus.NotCreated;
1077
+ upToDate.recipientsErrors = errorToSimpleErrors(e);
670
1078
  await upToDate.save();
671
1079
  }
672
1080
  });