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