@stamhoofd/backend 2.137.4 → 2.138.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/package.json +26 -17
- package/src/boot.ts +5 -0
- package/src/crons/balance-emails.ts +2 -1
- package/src/crons/delete-expired-mfa-tokens.ts +35 -0
- package/src/crons/drip-emails.ts +2 -1
- package/src/crons/index.ts +1 -0
- package/src/crons/invoices.ts +5 -3
- package/src/crons.ts +4 -2
- package/src/email-recipient-loaders/orders.ts +2 -1
- package/src/email-recipient-loaders/organizations.ts +3 -2
- package/src/email-recipient-loaders/payments.ts +3 -2
- package/src/endpoints/auth/ConfirmTOTPEndpoint.ts +84 -0
- package/src/endpoints/auth/CreateAdminEndpoint.ts +4 -2
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +61 -1
- package/src/endpoints/auth/CreateTokenEndpoint.ts +135 -7
- package/src/endpoints/auth/DeletePasskeyEndpoint.ts +61 -0
- package/src/endpoints/auth/DeleteTOTPEndpoint.ts +62 -0
- package/src/endpoints/auth/DeleteUserEndpoint.ts +1 -1
- package/src/endpoints/auth/ForgotPasswordEndpoint.ts +4 -2
- package/src/endpoints/auth/GetMFAChallengeEndpoint.ts +56 -0
- package/src/endpoints/auth/GetMFAStatusEndpoint.ts +31 -0
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -1
- package/src/endpoints/auth/MFA.security.test.ts +688 -0
- package/src/endpoints/auth/MFA.test.ts +1398 -0
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +2 -2
- package/src/endpoints/auth/PatchUserEndpoint.ts +6 -3
- package/src/endpoints/auth/RegenerateRecoveryCodesEndpoint.ts +51 -0
- package/src/endpoints/auth/RegisterPasskeyEndpoint.ts +99 -0
- package/src/endpoints/auth/RegisterPasskeyOptionsEndpoint.ts +49 -0
- package/src/endpoints/auth/RetryEmailVerificationEndpoint.ts +2 -1
- package/src/endpoints/auth/SetupTOTPEndpoint.ts +54 -0
- package/src/endpoints/auth/SignupEndpoint.ts +17 -4
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +16 -0
- package/src/endpoints/global/email/CreateEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetEmailEndpoint.ts +2 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +1 -1
- package/src/endpoints/global/email/GetUserEmailsEndpoint.ts +2 -1
- package/src/endpoints/global/email/PatchEmailEndpoint.ts +5 -3
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.ts +2 -1
- package/src/endpoints/global/email-recipients/RetryEmailRecipientEndpoint.ts +2 -1
- package/src/endpoints/global/files/ExportToExcelEndpoint.ts +11 -6
- package/src/endpoints/global/files/UploadFile.test.ts +206 -0
- package/src/endpoints/global/files/UploadFile.ts +31 -6
- package/src/endpoints/global/files/UploadImage.test.ts +177 -0
- package/src/endpoints/global/files/UploadImage.ts +23 -4
- package/src/endpoints/global/files/upload-security.test.ts +837 -0
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.ts +1 -1
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.ts +2 -1
- package/src/endpoints/global/organizations/CreateOrganizationEndpoint.ts +2 -1
- package/src/endpoints/global/platform/GetPlatformAdminsEndpoint.ts +4 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +35 -1
- package/src/endpoints/global/platform/PatchPlatformEnpoint.ts +8 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +92 -0
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.ts +43 -0
- package/src/endpoints/organization/dashboard/documents/GetDocumentTemplateXML.ts +2 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +1 -0
- package/src/endpoints/organization/dashboard/organization/SetOrganizationDomainEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/receivable-balances/ChargeReceivableBalancesEndpoint.ts +3 -2
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +32 -3
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +100 -0
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.ts +4 -1
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +5 -5
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.ts +5 -3
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +160 -0
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.ts +44 -0
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.ts +4 -3
- package/src/endpoints/organization/shared/GetDocumentHtml.ts +2 -1
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +4 -3
- package/src/excel-loaders/balance-items.ts +1 -1
- package/src/excel-loaders/event-notifications.ts +6 -7
- package/src/excel-loaders/members.test.ts +2 -2
- package/src/excel-loaders/members.ts +9 -10
- package/src/excel-loaders/organizations.ts +14 -20
- package/src/excel-loaders/payments.ts +1 -1
- package/src/excel-loaders/platform-memberships.ts +7 -7
- package/src/excel-loaders/platform-sheets.test.ts +113 -0
- package/src/excel-loaders/receivable-balances.ts +1 -1
- package/src/excel-loaders/registrations.ts +9 -9
- package/src/helpers/AdminPermissionChecker.ts +1 -1
- package/src/helpers/AuthenticatedStructures.ts +21 -10
- package/src/helpers/Context.ts +114 -4
- package/src/helpers/EmailBuilder.test.ts +287 -0
- package/src/helpers/EmailBuilder.ts +811 -0
- package/src/helpers/EmailResumer.ts +2 -1
- package/src/helpers/ForwardHandler.ts +2 -1
- package/src/helpers/MFAEncryption.ts +34 -0
- package/src/helpers/MembershipCharger.ts +3 -2
- package/src/helpers/RecoveryCodeHelper.ts +81 -0
- package/src/helpers/TOTPHelper.ts +79 -0
- package/src/helpers/TenantContext.test.ts +96 -0
- package/src/helpers/TenantContext.ts +67 -0
- package/src/helpers/TwoFactorHelper.ts +356 -0
- package/src/helpers/WebauthnHelper.ts +211 -0
- package/src/helpers/data/aaguids.json +1006 -0
- package/src/middleware/TenantScopeMiddleware.test.ts +41 -0
- package/src/middleware/TenantScopeMiddleware.ts +24 -0
- package/src/seeds/1785417302-fill-user-last-active-at.sql +6 -0
- package/src/services/AdminSessionService.ts +41 -0
- package/src/services/BalanceItemService.ts +2 -1
- package/src/services/DocumentRenderService.test.ts +229 -0
- package/src/services/DocumentRenderService.ts +180 -0
- package/src/services/EmailPreviewService.test.ts +300 -0
- package/src/services/EmailPreviewService.ts +239 -0
- package/src/services/EmailSendService.test.ts +1218 -0
- package/src/services/EmailSendService.ts +705 -0
- package/src/services/EventNotificationService.ts +2 -1
- package/src/services/FileSignService.test.ts +85 -0
- package/src/services/FileSignService.ts +23 -1
- package/src/services/InvoicePdfService.ts +3 -3
- package/src/services/InvoiceService.ts +4 -2
- package/src/services/InvoiceXMLService.ts +2 -1
- package/src/services/OrderService.test.ts +308 -0
- package/src/services/OrderService.ts +215 -0
- package/src/services/OrganizationAdminService.test.ts +47 -0
- package/src/services/OrganizationAdminService.ts +139 -0
- package/src/services/OrganizationDNSService.test.ts +177 -0
- package/src/services/OrganizationDNSService.ts +282 -0
- package/src/services/OrganizationEmailService.test.ts +57 -0
- package/src/services/OrganizationEmailService.ts +212 -0
- package/src/services/PasswordForgotService.test.ts +99 -0
- package/src/services/PasswordForgotService.ts +38 -0
- package/src/services/PaymentService.ts +5 -3
- package/src/services/PlatformMembershipService.test.ts +180 -0
- package/src/services/PlatformMembershipService.ts +281 -4
- package/src/services/ReferralService.ts +3 -2
- package/src/services/RegistrationService.ts +4 -2
- package/src/services/SSOService.ts +106 -14
- package/src/services/STPackageService.test.ts +191 -0
- package/src/services/STPackageService.ts +115 -1
- package/src/services/TwoFactorAuditLogService.ts +64 -0
- package/src/services/VerificationCodeService.test.ts +71 -0
- package/src/services/VerificationCodeService.ts +101 -0
- package/tests/e2e/api-rate-limits.test.ts +1 -1
- package/tests/e2e/documents.test.ts +2 -1
- package/tests/e2e/private-files.test.ts +77 -14
- package/tests/helpers/MFATestHelper.ts +42 -0
- package/tests/helpers/TestServer.ts +4 -0
- package/tests/helpers/index.ts +1 -0
- package/tsconfig.build.json +2 -1
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { isSimpleError, isSimpleErrors, SimpleError, SimpleErrors } from '@simonbackx/simple-errors';
|
|
2
|
+
import type { EmailInterfaceRecipient } from '@stamhoofd/email';
|
|
3
|
+
import { Email as EmailClass } from '@stamhoofd/email';
|
|
4
|
+
import { Email, EmailRecipient, Organization, Platform } from '@stamhoofd/models';
|
|
5
|
+
import { canSendFromEmail, fillRecipientReplacements, getEmailBuilder, mergeReplacementsIfEqual, removeUnusedReplacements } from '../helpers/EmailBuilder.js';
|
|
6
|
+
import { errorToSimpleErrors } from '@stamhoofd/models/helpers/errorToSimpleErrors.js';
|
|
7
|
+
import { isAbortedError, QueueHandler } from '@stamhoofd/queues';
|
|
8
|
+
import { SQL, SQLWhereSign } from '@stamhoofd/sql';
|
|
9
|
+
import { EmailRecipientsStatus, EmailStatus, isSoftEmailRecipientError, LimitedFilteredRequest, SortItemDirection } from '@stamhoofd/structures';
|
|
10
|
+
|
|
11
|
+
type Attachment = { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Sends an email to its recipients: queueing, the per-recipient send and resuming an interrupted send.
|
|
15
|
+
*/
|
|
16
|
+
export class EmailSendService {
|
|
17
|
+
static async queueForSending(email: Email, waitForSending = false) {
|
|
18
|
+
console.log('Queueing email for sending', email.id);
|
|
19
|
+
email.throwIfNotReadyToSend();
|
|
20
|
+
email.throwIfNoUnsubscribeButton();
|
|
21
|
+
await email.lock(async (upToDate) => {
|
|
22
|
+
if (upToDate.status === EmailStatus.Draft) {
|
|
23
|
+
upToDate.status = EmailStatus.Queued;
|
|
24
|
+
}
|
|
25
|
+
if (upToDate.status === EmailStatus.Failed) {
|
|
26
|
+
// Retry failed email
|
|
27
|
+
upToDate.status = EmailStatus.Queued;
|
|
28
|
+
}
|
|
29
|
+
await upToDate.save();
|
|
30
|
+
});
|
|
31
|
+
if (waitForSending) {
|
|
32
|
+
return await this.resumeSending(email);
|
|
33
|
+
} else {
|
|
34
|
+
this.resumeSending(email).catch(console.error);
|
|
35
|
+
}
|
|
36
|
+
return email;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private static async loadAttachments(email: Email): Promise<Attachment[]> {
|
|
40
|
+
const attachments: Attachment[] = [];
|
|
41
|
+
for (const attachment of email.attachments) {
|
|
42
|
+
if (!attachment.content && !attachment.file) {
|
|
43
|
+
console.warn('Attachment without content found, skipping', attachment);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let filename = $t('%180');
|
|
48
|
+
|
|
49
|
+
if (attachment.contentType === 'application/pdf') {
|
|
50
|
+
// tmp solution for pdf only
|
|
51
|
+
filename += '.pdf';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (attachment.file?.name) {
|
|
55
|
+
filename = attachment.file.name.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Correct file name if needed
|
|
59
|
+
if (attachment.filename) {
|
|
60
|
+
filename = attachment.filename.toLowerCase().replace(/[^a-z0-9.]+/g, '-').replace(/^-+/, '').replace(/-+$/, '');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (attachment.content) {
|
|
64
|
+
attachments.push({
|
|
65
|
+
filename: filename,
|
|
66
|
+
content: attachment.content,
|
|
67
|
+
contentType: attachment.contentType ?? undefined,
|
|
68
|
+
encoding: 'base64',
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
// 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
|
|
72
|
+
const withSigned = await attachment.file!.withSignedUrl();
|
|
73
|
+
if (!withSigned || !withSigned.signedUrl) {
|
|
74
|
+
throw new SimpleError({
|
|
75
|
+
code: 'attachment_not_found',
|
|
76
|
+
message: 'Attachment not found',
|
|
77
|
+
human: $t(`%181`),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const filePath = withSigned.signedUrl;
|
|
82
|
+
let fileBuffer: Buffer | null;
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch(filePath);
|
|
85
|
+
fileBuffer = Buffer.from(await response.arrayBuffer());
|
|
86
|
+
} catch (e) {
|
|
87
|
+
throw new SimpleError({
|
|
88
|
+
code: 'attachment_not_found',
|
|
89
|
+
message: 'Attachment not found',
|
|
90
|
+
human: $t(`%181`),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
attachments.push({
|
|
95
|
+
filename: filename,
|
|
96
|
+
contentType: attachment.contentType ?? undefined,
|
|
97
|
+
content: fileBuffer,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return attachments;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private static async sendSingleRecipient(email: Email, recipient: EmailRecipient, organization: Organization | null, data: { from: EmailInterfaceRecipient; replyTo: EmailInterfaceRecipient | null; attachments: Attachment[] }) {
|
|
105
|
+
let promiseResolve: (value: void | PromiseLike<void>) => void;
|
|
106
|
+
const promise = new Promise<void>((resolve) => {
|
|
107
|
+
promiseResolve = resolve;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const virtualRecipient = recipient.getRecipient();
|
|
111
|
+
|
|
112
|
+
let resolved = false;
|
|
113
|
+
const callback = async (error: Error | null) => {
|
|
114
|
+
if (resolved) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
resolved = true;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (error === null) {
|
|
121
|
+
// Mark saved
|
|
122
|
+
recipient.sentAt = new Date();
|
|
123
|
+
recipient.failErrorMessage = null;
|
|
124
|
+
|
|
125
|
+
if (recipient.failError) {
|
|
126
|
+
recipient.previousFailError = recipient.failError;
|
|
127
|
+
}
|
|
128
|
+
recipient.failError = null;
|
|
129
|
+
|
|
130
|
+
// Update repacements that have been generated
|
|
131
|
+
recipient.replacements = virtualRecipient.replacements;
|
|
132
|
+
await recipient.save();
|
|
133
|
+
} else {
|
|
134
|
+
recipient.failCount += 1;
|
|
135
|
+
recipient.failErrorMessage = error.message;
|
|
136
|
+
if (recipient.failError) {
|
|
137
|
+
recipient.previousFailError = recipient.failError;
|
|
138
|
+
}
|
|
139
|
+
recipient.failError = errorToSimpleErrors(error);
|
|
140
|
+
recipient.firstFailedAt = recipient.firstFailedAt ?? new Date();
|
|
141
|
+
recipient.lastFailedAt = new Date();
|
|
142
|
+
await recipient.save();
|
|
143
|
+
}
|
|
144
|
+
} catch (e) {
|
|
145
|
+
console.error(e);
|
|
146
|
+
}
|
|
147
|
+
promiseResolve();
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Do send the email
|
|
151
|
+
// Create e-mail builder
|
|
152
|
+
const builder = await getEmailBuilder(organization ?? null, {
|
|
153
|
+
recipients: [
|
|
154
|
+
virtualRecipient,
|
|
155
|
+
],
|
|
156
|
+
from: data.from,
|
|
157
|
+
replyTo: data.replyTo,
|
|
158
|
+
subject: email.subject!,
|
|
159
|
+
html: email.html!,
|
|
160
|
+
translations: email.translations,
|
|
161
|
+
type: email.emailType ? 'transactional' : 'broadcast',
|
|
162
|
+
attachments: data.attachments,
|
|
163
|
+
callback(error: Error | null) {
|
|
164
|
+
callback(error).catch(console.error);
|
|
165
|
+
},
|
|
166
|
+
headers: {
|
|
167
|
+
'X-Email-Id': email.id,
|
|
168
|
+
'X-Email-Recipient-Id': recipient.id,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
EmailClass.schedule(builder);
|
|
172
|
+
return await promise;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
static async resumeSending(email: Email, singleRecipientId: string | null = null): Promise<Email | null> {
|
|
176
|
+
const id = email.id;
|
|
177
|
+
return await QueueHandler.schedule('send-email', async ({ abort }) => {
|
|
178
|
+
return await email.lock(async function (upToDate: Email) {
|
|
179
|
+
if (upToDate.status === EmailStatus.Sent) {
|
|
180
|
+
// Already done
|
|
181
|
+
// In other cases -> queue has stopped and we can retry
|
|
182
|
+
if (!singleRecipientId) {
|
|
183
|
+
console.log('Email already sent, skipping...', upToDate.id);
|
|
184
|
+
return upToDate;
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
if (singleRecipientId) {
|
|
188
|
+
// Not possible
|
|
189
|
+
throw new SimpleError({
|
|
190
|
+
code: 'invalid_state',
|
|
191
|
+
message: 'Cannot retry single recipient for email that is not yet sent',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (upToDate.status === EmailStatus.Sending) {
|
|
196
|
+
// This is an automatic retry.
|
|
197
|
+
if (upToDate.emailType) {
|
|
198
|
+
// Not eligible for retry
|
|
199
|
+
upToDate.status = EmailStatus.Failed;
|
|
200
|
+
await upToDate.save();
|
|
201
|
+
return upToDate;
|
|
202
|
+
}
|
|
203
|
+
if (upToDate.createdAt < new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2)) {
|
|
204
|
+
// Too long
|
|
205
|
+
console.error('Email has been sending for too long. Marking as failed...', upToDate.id);
|
|
206
|
+
upToDate.status = EmailStatus.Failed;
|
|
207
|
+
await upToDate.save();
|
|
208
|
+
return upToDate;
|
|
209
|
+
}
|
|
210
|
+
} else if (upToDate.status !== EmailStatus.Queued) {
|
|
211
|
+
console.error('Email is not queued or sending, cannot send', upToDate.id, upToDate.status);
|
|
212
|
+
return upToDate;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const organization = upToDate.organizationId ? await Organization.getByID(upToDate.organizationId) : null;
|
|
217
|
+
let from = upToDate.getDefaultFromAddress(organization);
|
|
218
|
+
let replyTo: EmailInterfaceRecipient | null = upToDate.getFromAddress();
|
|
219
|
+
let succeededCount = 0;
|
|
220
|
+
let softFailedCount = 0;
|
|
221
|
+
let failedCount = 0;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
upToDate.throwIfNotReadyToSend();
|
|
225
|
+
upToDate.throwIfNoUnsubscribeButton();
|
|
226
|
+
|
|
227
|
+
if (!from) {
|
|
228
|
+
throw new SimpleError({
|
|
229
|
+
code: 'invalid_field',
|
|
230
|
+
message: 'Missing from',
|
|
231
|
+
human: $t(`%ww`),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Can we send from this e-mail or reply-to?
|
|
236
|
+
if (upToDate.fromAddress && await canSendFromEmail(upToDate.fromAddress, organization ?? null)) {
|
|
237
|
+
from = upToDate.getFromAddress();
|
|
238
|
+
replyTo = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
abort.throwIfAborted();
|
|
242
|
+
|
|
243
|
+
if (!singleRecipientId) {
|
|
244
|
+
upToDate.status = EmailStatus.Sending;
|
|
245
|
+
}
|
|
246
|
+
upToDate.sentAt = upToDate.sentAt ?? new Date();
|
|
247
|
+
await upToDate.save();
|
|
248
|
+
|
|
249
|
+
// Create recipients if not yet created
|
|
250
|
+
await EmailSendService.buildRecipients(upToDate);
|
|
251
|
+
abort.throwIfAborted();
|
|
252
|
+
|
|
253
|
+
// Refresh model
|
|
254
|
+
const c = (await Email.getByID(id))!;
|
|
255
|
+
if (!c) {
|
|
256
|
+
throw new SimpleError({
|
|
257
|
+
code: 'not_found',
|
|
258
|
+
message: 'Email not found',
|
|
259
|
+
human: $t(`%wz`),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
upToDate.copyFrom(c);
|
|
263
|
+
|
|
264
|
+
if (upToDate.recipientsStatus !== EmailRecipientsStatus.Created) {
|
|
265
|
+
throw new SimpleError({
|
|
266
|
+
code: 'recipients_not_created',
|
|
267
|
+
message: 'Failed to create recipients',
|
|
268
|
+
human: $t(`%x0`),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Create a buffer of all attachments
|
|
273
|
+
if (upToDate.sendAsEmail === true) {
|
|
274
|
+
const attachments = await EmailSendService.loadAttachments(upToDate);
|
|
275
|
+
|
|
276
|
+
// Start actually sending in batches of recipients that are not yet sent
|
|
277
|
+
let idPointer = '';
|
|
278
|
+
const batchSize = 100;
|
|
279
|
+
let isSavingStatus = false;
|
|
280
|
+
let lastStatusSave = new Date();
|
|
281
|
+
|
|
282
|
+
async function saveStatus() {
|
|
283
|
+
if (singleRecipientId) {
|
|
284
|
+
// Don't save during looping
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!upToDate) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (isSavingStatus) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
if ((new Date().getTime() - lastStatusSave.getTime()) < 1000 * 5) {
|
|
295
|
+
// Save at most every 5 seconds
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (succeededCount < upToDate.succeededCount || softFailedCount < upToDate.softFailedCount || failedCount < upToDate.failedCount) {
|
|
299
|
+
// Do not update on retries
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
lastStatusSave = new Date();
|
|
304
|
+
isSavingStatus = true;
|
|
305
|
+
upToDate.succeededCount = succeededCount;
|
|
306
|
+
upToDate.softFailedCount = softFailedCount;
|
|
307
|
+
upToDate.failedCount = failedCount;
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await upToDate.save();
|
|
311
|
+
} finally {
|
|
312
|
+
isSavingStatus = false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
while (true) {
|
|
317
|
+
abort.throwIfAborted();
|
|
318
|
+
const data = await SQL.select()
|
|
319
|
+
.from('email_recipients')
|
|
320
|
+
.where('emailId', upToDate.id)
|
|
321
|
+
.where('id', SQLWhereSign.Greater, idPointer)
|
|
322
|
+
.orderBy(SQL.column('id'), 'ASC')
|
|
323
|
+
.limit(batchSize)
|
|
324
|
+
.fetch();
|
|
325
|
+
|
|
326
|
+
const recipients = EmailRecipient.fromRows(data, 'email_recipients');
|
|
327
|
+
|
|
328
|
+
if (recipients.length === 0) {
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const sendingPromises: Promise<void>[] = [];
|
|
333
|
+
let skipped = 0;
|
|
334
|
+
|
|
335
|
+
for (const recipient of recipients) {
|
|
336
|
+
idPointer = recipient.id;
|
|
337
|
+
|
|
338
|
+
if (recipient.sentAt) {
|
|
339
|
+
succeededCount += 1;
|
|
340
|
+
await saveStatus();
|
|
341
|
+
skipped++;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!recipient.email) {
|
|
346
|
+
skipped++;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (recipient.duplicateOfRecipientId) {
|
|
351
|
+
skipped++;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (singleRecipientId) {
|
|
356
|
+
if (recipient.id !== singleRecipientId) {
|
|
357
|
+
// Failed or soft-failed
|
|
358
|
+
if (recipient.failError && isSoftEmailRecipientError(recipient.failError)) {
|
|
359
|
+
softFailedCount += 1;
|
|
360
|
+
} else {
|
|
361
|
+
failedCount += 1;
|
|
362
|
+
}
|
|
363
|
+
skipped++;
|
|
364
|
+
await saveStatus();
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const promise = EmailSendService.sendSingleRecipient(upToDate, recipient, organization ?? null, {
|
|
370
|
+
from,
|
|
371
|
+
replyTo,
|
|
372
|
+
attachments,
|
|
373
|
+
});
|
|
374
|
+
sendingPromises.push(promise.then(async () => {
|
|
375
|
+
if (recipient.sentAt) {
|
|
376
|
+
succeededCount += 1;
|
|
377
|
+
await saveStatus();
|
|
378
|
+
} else {
|
|
379
|
+
// Failed or soft-failed
|
|
380
|
+
if (recipient.failError && isSoftEmailRecipientError(recipient.failError)) {
|
|
381
|
+
softFailedCount += 1;
|
|
382
|
+
} else {
|
|
383
|
+
failedCount += 1;
|
|
384
|
+
}
|
|
385
|
+
await saveStatus();
|
|
386
|
+
}
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (sendingPromises.length > 0 || skipped > 0) {
|
|
391
|
+
await Promise.all(sendingPromises);
|
|
392
|
+
} else {
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch (e) {
|
|
398
|
+
if (!upToDate) {
|
|
399
|
+
throw e;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!singleRecipientId) {
|
|
403
|
+
upToDate.succeededCount = succeededCount;
|
|
404
|
+
upToDate.softFailedCount = softFailedCount;
|
|
405
|
+
upToDate.failedCount = failedCount;
|
|
406
|
+
|
|
407
|
+
if (isAbortedError(e) || ((isSimpleError(e) || isSimpleErrors(e)) && e.hasCode('SHUTDOWN'))) {
|
|
408
|
+
// Keep sending status: we'll resume after the reboot
|
|
409
|
+
await upToDate.save();
|
|
410
|
+
throw e;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
upToDate.emailErrors = errorToSimpleErrors(e);
|
|
414
|
+
upToDate.status = EmailStatus.Failed;
|
|
415
|
+
await upToDate.save();
|
|
416
|
+
}
|
|
417
|
+
throw e;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!singleRecipientId) {
|
|
421
|
+
if (upToDate.sendAsEmail && upToDate.emailRecipientsCount === 0 && upToDate.userId === null) {
|
|
422
|
+
// We only delete automated emails (email type) if they have no recipients
|
|
423
|
+
console.log('No recipients found for email ', upToDate.id, ' deleting...');
|
|
424
|
+
await upToDate.delete();
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
console.log('Finished sending email', upToDate.id);
|
|
429
|
+
// Mark email as sent
|
|
430
|
+
|
|
431
|
+
if (upToDate.sendAsEmail && !upToDate.showInMemberPortal && (succeededCount + failedCount + softFailedCount) === 0) {
|
|
432
|
+
upToDate.status = EmailStatus.Failed;
|
|
433
|
+
upToDate.emailErrors = new SimpleErrors(
|
|
434
|
+
new SimpleError({
|
|
435
|
+
code: 'no_recipients',
|
|
436
|
+
message: 'No recipients',
|
|
437
|
+
human: $t(`%1EG`),
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
} else {
|
|
441
|
+
upToDate.status = EmailStatus.Sent;
|
|
442
|
+
upToDate.emailErrors = null;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
upToDate.succeededCount = succeededCount;
|
|
447
|
+
upToDate.softFailedCount = softFailedCount;
|
|
448
|
+
upToDate.failedCount = failedCount;
|
|
449
|
+
|
|
450
|
+
await upToDate.save();
|
|
451
|
+
return upToDate;
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
static async buildRecipients(email: Email) {
|
|
457
|
+
const id = email.id;
|
|
458
|
+
await QueueHandler.schedule('email-build-recipients-' + email.id, async function ({ abort }) {
|
|
459
|
+
const upToDate = await Email.getByID(id);
|
|
460
|
+
|
|
461
|
+
if (!upToDate || !upToDate.id) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (upToDate.recipientsStatus === EmailRecipientsStatus.Created) {
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (upToDate.status === EmailStatus.Sent) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
abort.throwIfAborted();
|
|
474
|
+
const organization = upToDate.organizationId ? (await Organization.getByID(upToDate.organizationId) ?? null) : null;
|
|
475
|
+
if (upToDate.organizationId && !organization) {
|
|
476
|
+
throw new SimpleError({
|
|
477
|
+
code: 'organization_not_found',
|
|
478
|
+
message: 'Organization not found',
|
|
479
|
+
human: $t(`%1NV`),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
483
|
+
|
|
484
|
+
console.log('Building recipients for email', id);
|
|
485
|
+
|
|
486
|
+
// If it is already creating -> something went wrong (e.g. server restart) and we can safely try again
|
|
487
|
+
|
|
488
|
+
upToDate.recipientsStatus = EmailRecipientsStatus.Creating;
|
|
489
|
+
await upToDate.save();
|
|
490
|
+
|
|
491
|
+
const membersSet = new Set<string>();
|
|
492
|
+
const emailsSet = new Set<string>();
|
|
493
|
+
const combinedHtml = upToDate.getCombinedHtml();
|
|
494
|
+
|
|
495
|
+
let count = 0;
|
|
496
|
+
let countWithoutEmail = 0;
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
// Delete all recipients
|
|
500
|
+
await SQL
|
|
501
|
+
.delete()
|
|
502
|
+
.from(
|
|
503
|
+
SQL.table('email_recipients'),
|
|
504
|
+
)
|
|
505
|
+
.where(SQL.column('emailId'), upToDate.id);
|
|
506
|
+
|
|
507
|
+
abort.throwIfAborted();
|
|
508
|
+
|
|
509
|
+
for (const subfilter of upToDate.recipientFilter.filters) {
|
|
510
|
+
// Create recipients
|
|
511
|
+
const loader = Email.recipientLoaders.get(subfilter.type);
|
|
512
|
+
|
|
513
|
+
if (!loader) {
|
|
514
|
+
throw new Error('Loader for type ' + subfilter.type + ' has not been initialised on the Email model');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let request: LimitedFilteredRequest | null = new LimitedFilteredRequest({
|
|
518
|
+
filter: subfilter.filter,
|
|
519
|
+
sort: [{ key: 'id', order: SortItemDirection.ASC }],
|
|
520
|
+
limit: 100,
|
|
521
|
+
search: subfilter.search,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const beforeFetchAllResult = loader.beforeFetchAll ? await loader.beforeFetchAll(request, subfilter.subfilter) : undefined;
|
|
525
|
+
|
|
526
|
+
while (request) {
|
|
527
|
+
abort.throwIfAborted();
|
|
528
|
+
const response = await loader.fetch(request, subfilter.subfilter, beforeFetchAllResult, { allowedLanguages: upToDate.getLanguages() });
|
|
529
|
+
|
|
530
|
+
for (const item of response.results) {
|
|
531
|
+
if (!item.email && !item.memberId && !item.userId) {
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const recipient = new EmailRecipient();
|
|
536
|
+
recipient.emailType = upToDate.emailType;
|
|
537
|
+
recipient.objectId = item.objectId;
|
|
538
|
+
recipient.emailId = upToDate.id;
|
|
539
|
+
recipient.email = item.email;
|
|
540
|
+
recipient.firstName = item.firstName;
|
|
541
|
+
recipient.lastName = item.lastName;
|
|
542
|
+
recipient.language = item.language ?? null;
|
|
543
|
+
recipient.replacements = item.replacements;
|
|
544
|
+
recipient.memberId = item.memberId ?? null;
|
|
545
|
+
recipient.userId = item.userId ?? null;
|
|
546
|
+
recipient.organizationId = upToDate.organizationId ?? null;
|
|
547
|
+
|
|
548
|
+
await fillRecipientReplacements(recipient, {
|
|
549
|
+
platform,
|
|
550
|
+
organization,
|
|
551
|
+
from: upToDate.getFromAddress(),
|
|
552
|
+
replyTo: null,
|
|
553
|
+
forPreview: false,
|
|
554
|
+
allowedLanguages: upToDate.getLanguages(),
|
|
555
|
+
});
|
|
556
|
+
recipient.replacements = removeUnusedReplacements(combinedHtml, recipient.replacements);
|
|
557
|
+
|
|
558
|
+
let duplicateOfRecipientId: string | null = null;
|
|
559
|
+
if (item.email && emailsSet.has(item.email)) {
|
|
560
|
+
console.log('Found duplicate email recipient', item.email);
|
|
561
|
+
|
|
562
|
+
// Try to merge
|
|
563
|
+
const existing = await EmailRecipient.select()
|
|
564
|
+
.where('emailId', upToDate.id)
|
|
565
|
+
.where('email', item.email)
|
|
566
|
+
.where('duplicateOfRecipientId', null)
|
|
567
|
+
.fetch();
|
|
568
|
+
|
|
569
|
+
for (const other of existing) {
|
|
570
|
+
const merged = mergeReplacementsIfEqual(other.replacements, recipient.replacements);
|
|
571
|
+
if (merged !== false) {
|
|
572
|
+
console.log('Found mergeable duplicate email recipient', item.email, other.id);
|
|
573
|
+
duplicateOfRecipientId = other.id;
|
|
574
|
+
|
|
575
|
+
other.replacements = merged;
|
|
576
|
+
other.firstName = other.firstName || item.firstName;
|
|
577
|
+
other.lastName = other.lastName || item.lastName;
|
|
578
|
+
await other.save();
|
|
579
|
+
|
|
580
|
+
recipient.replacements = merged;
|
|
581
|
+
|
|
582
|
+
break;
|
|
583
|
+
} else {
|
|
584
|
+
console.log('Could not merge duplicate email recipient', item.email, other.id, 'keeping both', other.replacements, item.replacements);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
recipient.duplicateOfRecipientId = duplicateOfRecipientId;
|
|
589
|
+
|
|
590
|
+
await recipient.save();
|
|
591
|
+
|
|
592
|
+
if (recipient.memberId) {
|
|
593
|
+
membersSet.add(recipient.memberId);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (recipient.email) {
|
|
597
|
+
emailsSet.add(recipient.email);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!recipient.email || duplicateOfRecipientId) {
|
|
601
|
+
countWithoutEmail += 1;
|
|
602
|
+
} else {
|
|
603
|
+
count += 1;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
request = response.next ?? null;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
upToDate.recipientsStatus = EmailRecipientsStatus.Created;
|
|
612
|
+
upToDate.emailRecipientsCount = count;
|
|
613
|
+
upToDate.otherRecipientsCount = countWithoutEmail;
|
|
614
|
+
upToDate.recipientsErrors = null;
|
|
615
|
+
upToDate.membersCount = membersSet.size;
|
|
616
|
+
await upToDate.save();
|
|
617
|
+
} catch (e: unknown) {
|
|
618
|
+
console.error('Failed to build recipients for email', id);
|
|
619
|
+
console.error(e);
|
|
620
|
+
upToDate.recipientsStatus = EmailRecipientsStatus.NotCreated;
|
|
621
|
+
upToDate.recipientsErrors = errorToSimpleErrors(e);
|
|
622
|
+
await upToDate.save();
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
static async buildExampleRecipient(email: Email, isNewlyCreated = false) {
|
|
628
|
+
const id = email.id;
|
|
629
|
+
await QueueHandler.schedule('email-build-recipients-' + email.id, async function () {
|
|
630
|
+
const upToDate = await Email.getByID(id);
|
|
631
|
+
|
|
632
|
+
if (!upToDate || upToDate.sentAt || !upToDate.id) {
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (upToDate.recipientsStatus !== EmailRecipientsStatus.NotCreated) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
// Delete all recipients
|
|
642
|
+
if (!isNewlyCreated) {
|
|
643
|
+
await SQL
|
|
644
|
+
.delete()
|
|
645
|
+
.from(
|
|
646
|
+
SQL.table('email_recipients'),
|
|
647
|
+
)
|
|
648
|
+
.where(SQL.column('emailId'), upToDate.id);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
for (const subfilter of upToDate.recipientFilter.filters) {
|
|
652
|
+
// Create recipients
|
|
653
|
+
const loader = Email.recipientLoaders.get(subfilter.type);
|
|
654
|
+
|
|
655
|
+
if (!loader) {
|
|
656
|
+
throw new Error('Loader for type ' + subfilter.type + ' has not been initialised on the Email model');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
let request: LimitedFilteredRequest | null = new LimitedFilteredRequest({
|
|
660
|
+
filter: subfilter.filter,
|
|
661
|
+
sort: [{ key: 'id', order: SortItemDirection.ASC }],
|
|
662
|
+
limit: 10,
|
|
663
|
+
search: subfilter.search,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const beforeFetchAllResult = loader.beforeFetchAll ? await loader.beforeFetchAll(request, subfilter.subfilter) : undefined;
|
|
667
|
+
|
|
668
|
+
while (request) {
|
|
669
|
+
const response = await loader.fetch(request, subfilter.subfilter, beforeFetchAllResult, { allowedLanguages: upToDate.getLanguages() });
|
|
670
|
+
|
|
671
|
+
// Note: it is possible that a result in the database doesn't return a recipient (in memory filtering)
|
|
672
|
+
// so we do need pagination
|
|
673
|
+
|
|
674
|
+
for (const item of response.results) {
|
|
675
|
+
const recipient = new EmailRecipient();
|
|
676
|
+
recipient.emailType = upToDate.emailType;
|
|
677
|
+
recipient.emailId = upToDate.id;
|
|
678
|
+
recipient.objectId = item.objectId;
|
|
679
|
+
recipient.email = item.email;
|
|
680
|
+
recipient.firstName = item.firstName;
|
|
681
|
+
recipient.lastName = item.lastName;
|
|
682
|
+
recipient.language = item.language ?? null;
|
|
683
|
+
recipient.replacements = item.replacements;
|
|
684
|
+
recipient.memberId = item.memberId ?? null;
|
|
685
|
+
recipient.userId = item.userId ?? null;
|
|
686
|
+
recipient.organizationId = upToDate.organizationId ?? null;
|
|
687
|
+
await recipient.save();
|
|
688
|
+
|
|
689
|
+
if (recipient.email || recipient.userId) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
request = null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
console.warn('No example recipient found for email', id);
|
|
699
|
+
} catch (e) {
|
|
700
|
+
console.error('Failed to build example recipient for email', id);
|
|
701
|
+
console.error(e);
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|