@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,811 @@
|
|
|
1
|
+
import type { EmailBuilder, EmailInterfaceRecipient } from '@stamhoofd/email';
|
|
2
|
+
import { Email, EmailAddress } from '@stamhoofd/email';
|
|
3
|
+
import type { EmailContent, EmailRecipient as EmailRecipientStruct, EmailTemplateType, OrganizationEmail, Platform as PlatformStruct, Recipient } from '@stamhoofd/structures';
|
|
4
|
+
import { BalanceItem as BalanceItemStruct, getAppHost, ReceivableBalanceType, replaceEmailHtml, replaceEmailText, Replacement } from '@stamhoofd/structures';
|
|
5
|
+
import type { Language } from '@stamhoofd/types/Language';
|
|
6
|
+
import { Formatter } from '@stamhoofd/utility';
|
|
7
|
+
|
|
8
|
+
import { SimpleError } from '@simonbackx/simple-errors';
|
|
9
|
+
import { I18n } from '@stamhoofd/backend-i18n/I18n';
|
|
10
|
+
import type { Group, Organization, Webshop } from '@stamhoofd/models';
|
|
11
|
+
import { CachedBalance, EmailRecipient, EmailTemplate, Member, Platform, User } from '@stamhoofd/models';
|
|
12
|
+
|
|
13
|
+
export type EmailTemplateOptions = {
|
|
14
|
+
type: EmailTemplateType;
|
|
15
|
+
webshop?: Webshop | null;
|
|
16
|
+
group?: Group | null;
|
|
17
|
+
organizationId?: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function getEmailTemplate(data: EmailTemplateOptions) {
|
|
21
|
+
// Most specific template: for specific group
|
|
22
|
+
const q = EmailTemplate.select()
|
|
23
|
+
.where('type', data.type);
|
|
24
|
+
|
|
25
|
+
if (data.group) {
|
|
26
|
+
q.where('groupId', data.group.id);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (data.organizationId) {
|
|
30
|
+
q.where('organizationId', data.organizationId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (data.webshop) {
|
|
34
|
+
q.where('webshopId', data.webshop.id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let templates = await q.limit(1).fetch();
|
|
38
|
+
|
|
39
|
+
// Specific for organization
|
|
40
|
+
if (templates.length == 0 && (data.group?.id || data.webshop?.id) && data.organizationId) {
|
|
41
|
+
templates = await EmailTemplate.select()
|
|
42
|
+
.where('type', data.type)
|
|
43
|
+
.where('organizationId', data.organizationId)
|
|
44
|
+
.where('groupId', null)
|
|
45
|
+
.where('webshopId', null)
|
|
46
|
+
.limit(1)
|
|
47
|
+
.fetch();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Default for platform
|
|
51
|
+
if (templates.length == 0 && (data.group?.id || data.webshop?.id || data.organizationId)) {
|
|
52
|
+
templates = await EmailTemplate.select()
|
|
53
|
+
.where('type', data.type)
|
|
54
|
+
.where('organizationId', null)
|
|
55
|
+
.where('groupId', null)
|
|
56
|
+
.where('webshopId', null)
|
|
57
|
+
.limit(1)
|
|
58
|
+
.fetch();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (templates.length == 0) {
|
|
62
|
+
if (STAMHOOFD.environment === 'test') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.error('Could not find email template for type ' + data.type);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return templates[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function canSendFromEmail(fromAddress: string, organization: Organization | null) {
|
|
73
|
+
if (organization) {
|
|
74
|
+
if (organization.privateMeta.mailDomain && organization.privateMeta.mailDomainActive && fromAddress.endsWith('@' + organization.privateMeta.mailDomain)) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (organization.id === (await Platform.getSharedPrivateStruct()).membershipOrganizationId) {
|
|
79
|
+
return canSendFromEmail(fromAddress, null);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const transactionalDomains = Object.values(STAMHOOFD.domains.defaultTransactionalEmail ?? {});
|
|
85
|
+
const broadcastDomains = Object.values(STAMHOOFD.domains.defaultBroadcastEmail ?? {});
|
|
86
|
+
const domains = Formatter.uniqueArray([...transactionalDomains, ...broadcastDomains]);
|
|
87
|
+
|
|
88
|
+
for (const domain of domains) {
|
|
89
|
+
if (fromAddress.endsWith('@' + domain)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getDefaultEmailFrom(organization: Organization | null, options: Pick<EmailBuilderOptions, 'type'> & { template: Omit<EmailTemplateOptions, 'organizationId' | 'type'> }) {
|
|
98
|
+
// When choosing sending domain, prefer using the one with the highest reputation
|
|
99
|
+
let preferEmailId: string | null = null;
|
|
100
|
+
|
|
101
|
+
if (options.template.group) {
|
|
102
|
+
preferEmailId = options.template.group.privateSettings.defaultEmailId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (options.template.webshop) {
|
|
106
|
+
preferEmailId = options.template.webshop.privateMeta.defaultEmailId;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (organization) {
|
|
110
|
+
// Default email address for the chosen email type
|
|
111
|
+
let from = organization.getDefaultFrom(organization.i18n, options.type ?? 'broadcast');
|
|
112
|
+
|
|
113
|
+
const sender: OrganizationEmail | undefined = (preferEmailId ? organization.privateMeta.emails.find(e => e.id === preferEmailId) : null) ?? organization.privateMeta.emails.find(e => e.default) ?? organization.privateMeta.emails[0];
|
|
114
|
+
let replyTo: EmailInterfaceRecipient | undefined = undefined;
|
|
115
|
+
|
|
116
|
+
if (sender) {
|
|
117
|
+
replyTo = {
|
|
118
|
+
email: sender.email,
|
|
119
|
+
name: sender.name,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Can we send from this e-mail or reply-to?
|
|
123
|
+
if (await canSendFromEmail(sender.email, organization)) {
|
|
124
|
+
from = {
|
|
125
|
+
email: sender.email,
|
|
126
|
+
name: sender.name,
|
|
127
|
+
};
|
|
128
|
+
replyTo = undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Default to organization name
|
|
132
|
+
if (!from.name) {
|
|
133
|
+
from.name = organization.name;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (replyTo) {
|
|
137
|
+
if (!replyTo.name) {
|
|
138
|
+
replyTo.name = organization.name;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
from, replyTo,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
148
|
+
|
|
149
|
+
// Default e-mail if no email addresses are configured
|
|
150
|
+
const i18n = new I18n($getLanguage(), $getCountry());
|
|
151
|
+
const transactionalDomain = i18n.localizedDomains.defaultTransactionalEmail();
|
|
152
|
+
const broadcastDomain = i18n.localizedDomains.defaultBroadcastEmail();
|
|
153
|
+
|
|
154
|
+
const domain = (options.type === 'transactional' ? transactionalDomain : broadcastDomain);
|
|
155
|
+
let from: EmailInterfaceRecipient = {
|
|
156
|
+
email: 'hallo@' + domain,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Platform
|
|
160
|
+
const sender: OrganizationEmail | undefined = (preferEmailId ? platform.privateConfig.emails.find(e => e.id === preferEmailId) : null) ?? platform.privateConfig.emails.find(e => e.default) ?? platform.privateConfig.emails[0];
|
|
161
|
+
let replyTo: EmailInterfaceRecipient | undefined = undefined;
|
|
162
|
+
|
|
163
|
+
if (sender) {
|
|
164
|
+
replyTo = {
|
|
165
|
+
email: sender.email,
|
|
166
|
+
name: sender.name,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Are we allowed to send an e-mail from this domain?
|
|
170
|
+
if (await canSendFromEmail(sender.email, null)) {
|
|
171
|
+
// Allowed to send from
|
|
172
|
+
from = {
|
|
173
|
+
email: sender.email,
|
|
174
|
+
name: sender.name,
|
|
175
|
+
};
|
|
176
|
+
replyTo = undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Default to platform name
|
|
180
|
+
if (!from.name) {
|
|
181
|
+
from.name = platform.config.name;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (replyTo) {
|
|
185
|
+
if (!replyTo.name) {
|
|
186
|
+
replyTo.name = platform.config.name;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
from, replyTo,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function sendEmailTemplate(organization: Organization | null, options: Omit<EmailBuilderOptions, 'subject' | 'html' | 'from' | 'replyTo'> & { template: Omit<EmailTemplateOptions, 'organizationId'> }) {
|
|
197
|
+
if (options.template.webshop) {
|
|
198
|
+
options.defaultReplacements = [...(options.defaultReplacements ?? []), ...options.template.webshop.meta.getEmailReplacements()];
|
|
199
|
+
}
|
|
200
|
+
const builder = await getEmailBuilderForTemplate(organization, {
|
|
201
|
+
...options,
|
|
202
|
+
...(await getDefaultEmailFrom(organization, options)),
|
|
203
|
+
});
|
|
204
|
+
if (builder) {
|
|
205
|
+
Email.schedule(builder);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function getEmailBuilderForTemplate(organization: Organization | null, options: Omit<EmailBuilderOptions, 'subject' | 'html'> & { template: Omit<EmailTemplateOptions, 'organizationId'> }) {
|
|
210
|
+
const template = await getEmailTemplate({
|
|
211
|
+
...options.template,
|
|
212
|
+
organizationId: organization?.id ?? null,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!template) {
|
|
216
|
+
if (STAMHOOFD.environment === 'production') {
|
|
217
|
+
console.warn('No email template found for ' + options.template.type);
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return await getEmailBuilder(organization, {
|
|
223
|
+
...options,
|
|
224
|
+
subject: template.subject,
|
|
225
|
+
html: template.html,
|
|
226
|
+
translations: template.translations,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export type EmailBuilderOptions = {
|
|
231
|
+
defaultReplacements?: Replacement[];
|
|
232
|
+
recipients: Recipient[];
|
|
233
|
+
from: EmailInterfaceRecipient;
|
|
234
|
+
replyTo?: EmailInterfaceRecipient | null;
|
|
235
|
+
subject: string;
|
|
236
|
+
html: string;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Full content overrides per language. Recipients with a language that has an override
|
|
240
|
+
* receive that content, all others receive the default subject/html.
|
|
241
|
+
*/
|
|
242
|
+
translations?: Map<Language, EmailContent>;
|
|
243
|
+
attachments?: { filename: string; path?: string; href?: string; content?: string | Buffer; contentType?: string; encoding?: string }[];
|
|
244
|
+
type?: 'transactional' | 'broadcast';
|
|
245
|
+
unsubscribeType?: 'all' | 'marketing';
|
|
246
|
+
fromStamhoofd?: boolean;
|
|
247
|
+
singleBcc?: EmailInterfaceRecipient;
|
|
248
|
+
replaceAll?: { from: string; to: string }[]; // replace in all e-mails, not recipient dependent
|
|
249
|
+
callback?: (error: Error | null) => void; // for each email
|
|
250
|
+
headers?: Record<string, string>;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param organization defines replacements and unsubsribe behaviour
|
|
255
|
+
*/
|
|
256
|
+
export async function getEmailBuilder(organization: Organization | null, email: EmailBuilderOptions) {
|
|
257
|
+
const platform = await Platform.getSharedPrivateStruct();
|
|
258
|
+
// Update recipients
|
|
259
|
+
const cleaned: Recipient[] = [];
|
|
260
|
+
for (const recipient of email.recipients) {
|
|
261
|
+
try {
|
|
262
|
+
const unsubscribeGlobal = await EmailAddress.getWhereHardBounceOrSpam(recipient.email);
|
|
263
|
+
if ((unsubscribeGlobal && (unsubscribeGlobal.hardBounce))) {
|
|
264
|
+
// Ignore
|
|
265
|
+
if (email.callback) {
|
|
266
|
+
email.callback(
|
|
267
|
+
new SimpleError({
|
|
268
|
+
code: 'email_skipped_hard_bounce',
|
|
269
|
+
message: 'Recipient has hard bounced',
|
|
270
|
+
human: $t(`%ws`),
|
|
271
|
+
}),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (unsubscribeGlobal && (unsubscribeGlobal.markedAsSpam)) {
|
|
278
|
+
// Ignore
|
|
279
|
+
if (email.callback) {
|
|
280
|
+
email.callback(
|
|
281
|
+
new SimpleError({
|
|
282
|
+
code: 'email_skipped_spam',
|
|
283
|
+
message: 'Recipient has marked as spam',
|
|
284
|
+
human: $t(`%wt`),
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const unsubscribe = await EmailAddress.getOrCreate(recipient.email, email.fromStamhoofd || !organization ? null : organization.id);
|
|
292
|
+
if (unsubscribe.unsubscribedAll || unsubscribe.hardBounce || unsubscribe.markedAsSpam || !unsubscribe.token || (unsubscribe.unsubscribedMarketing && email.unsubscribeType === 'marketing')) {
|
|
293
|
+
// Ignore
|
|
294
|
+
if (email.callback) {
|
|
295
|
+
email.callback(
|
|
296
|
+
new SimpleError({
|
|
297
|
+
code: 'email_skipped_unsubscribed',
|
|
298
|
+
message: unsubscribe.unsubscribedAll ? 'Recipient has unsubscribed' : (unsubscribe.hardBounce ? 'Recipient has hard bounced' : (unsubscribe.markedAsSpam ? 'Recipient has marked as spam' : 'Recipient has unsubscribed from marketing')),
|
|
299
|
+
human: $t('%1E3'),
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Localize the unsubscribe page to the recipient's language (Organization.i18n is always Dutch)
|
|
307
|
+
const unsubscribeLocale = organization ? getRecipientI18n(recipient, organization).locale : null;
|
|
308
|
+
const unsubscribeUrl = 'https://' + STAMHOOFD.domains.dashboard + '/' + (unsubscribeLocale ? (unsubscribeLocale + '/') : '') + 'unsubscribe?id=' + encodeURIComponent(unsubscribe.id) + '&token=' + encodeURIComponent(unsubscribe.token) + '&type=' + encodeURIComponent(email.unsubscribeType ?? 'all');
|
|
309
|
+
recipient.replacements.push(Replacement.create({
|
|
310
|
+
token: 'unsubscribeUrl',
|
|
311
|
+
value: unsubscribeUrl,
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
// Override headers
|
|
315
|
+
recipient.headers = {
|
|
316
|
+
...email.headers,
|
|
317
|
+
'List-Unsubscribe': STAMHOOFD.domains.defaultBroadcastEmail !== undefined ? '<mailto:unsubscribe+' + unsubscribe.id + '@' + STAMHOOFD.domains.defaultBroadcastEmail![''] + `>, <${unsubscribeUrl}>` : `<${unsubscribeUrl}>`,
|
|
318
|
+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
|
319
|
+
};
|
|
320
|
+
cleaned.push(recipient);
|
|
321
|
+
} catch (e) {
|
|
322
|
+
console.error(e);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
email.recipients = cleaned;
|
|
326
|
+
|
|
327
|
+
// Update recipients
|
|
328
|
+
for (const recipient of email.recipients) {
|
|
329
|
+
recipient.replacements = recipient.replacements.slice();
|
|
330
|
+
|
|
331
|
+
if (email.defaultReplacements) {
|
|
332
|
+
recipient.replacements.push(...email.defaultReplacements);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
await fillRecipientReplacements(recipient, {
|
|
336
|
+
organization,
|
|
337
|
+
platform,
|
|
338
|
+
from: email.from,
|
|
339
|
+
replyTo: email.replyTo ?? null,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const queue = email.recipients.slice();
|
|
344
|
+
|
|
345
|
+
let emailIndex = 0;
|
|
346
|
+
|
|
347
|
+
// The subject and html can differ per recipient language
|
|
348
|
+
const contentCache = new Map<Language | null, { subject: string; html: string }>();
|
|
349
|
+
const resolveContent = (language: Language | null) => {
|
|
350
|
+
const cached = contentCache.get(language);
|
|
351
|
+
if (cached) {
|
|
352
|
+
return cached;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Strict selection: only use a translation of this email, never fall back to
|
|
356
|
+
// a different template for a missing language (content correctness above language)
|
|
357
|
+
const translation = language !== null ? email.translations?.get(language) : undefined;
|
|
358
|
+
const subject = translation ? translation.subject : email.subject;
|
|
359
|
+
let html = translation ? translation.html : email.html;
|
|
360
|
+
|
|
361
|
+
for (const s of email.replaceAll ?? []) {
|
|
362
|
+
html = html.replaceAll(s.from, s.to);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const result = { subject, html };
|
|
366
|
+
contentCache.set(language, result);
|
|
367
|
+
return result;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
if (queue.length === 0) {
|
|
371
|
+
if (email.callback) {
|
|
372
|
+
email.callback(new SimpleError({
|
|
373
|
+
code: 'no_recipients',
|
|
374
|
+
message: 'No recipients left',
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Create e-mail builder
|
|
380
|
+
const builder: EmailBuilder = () => {
|
|
381
|
+
const recipient = queue.shift();
|
|
382
|
+
if (!recipient) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const content = resolveContent(recipient.language ?? null);
|
|
387
|
+
const replacedHtml = replaceEmailHtml(content.html, recipient.replacements);
|
|
388
|
+
const replacedSubject = replaceEmailText(content.subject, recipient.replacements);
|
|
389
|
+
|
|
390
|
+
emailIndex += 1;
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
from: email.from,
|
|
394
|
+
replyTo: email.replyTo ?? undefined,
|
|
395
|
+
bcc: emailIndex === 1 && email.singleBcc ? [email.singleBcc] : undefined,
|
|
396
|
+
to: [
|
|
397
|
+
{
|
|
398
|
+
// Name will get cleaned by email service
|
|
399
|
+
name: (recipient.firstName ?? '') + ' ' + (recipient.lastName ?? ''),
|
|
400
|
+
email: recipient.email,
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
subject: replacedSubject,
|
|
404
|
+
html: replacedHtml ?? undefined,
|
|
405
|
+
attachments: email.attachments,
|
|
406
|
+
headers: recipient.headers,
|
|
407
|
+
type: email.type,
|
|
408
|
+
callback: email.callback,
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
return builder;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function mergeReplacement(replacementA: Replacement, replacementB: Replacement): Replacement | false {
|
|
415
|
+
if (replacementA.token !== replacementB.token) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (replacementA.token === 'greeting') {
|
|
420
|
+
// Just take the first one
|
|
421
|
+
return replacementA;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (replacementA.token === 'unsubscribeUrl') {
|
|
425
|
+
return replacementA;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (replacementA.token === 'signInUrl') {
|
|
429
|
+
return replacementA;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (replacementA.token === 'loginDetails') {
|
|
433
|
+
// loginDetails are always the same for the same user.
|
|
434
|
+
return replacementA;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (replacementA.token === 'objectName') {
|
|
438
|
+
// Add comma if values are not the same
|
|
439
|
+
const aa = replacementA.value.split(', ');
|
|
440
|
+
|
|
441
|
+
return Replacement.create({
|
|
442
|
+
token: 'objectName',
|
|
443
|
+
value: Formatter.uniqueArray([...aa, ...replacementB.value.split(', ')]).join(', '),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Remove duplicates
|
|
452
|
+
*/
|
|
453
|
+
export function cleanReplacements(replacements: Replacement[]) {
|
|
454
|
+
const foundIds: Set<string> = new Set();
|
|
455
|
+
const cleaned: Replacement[] = [];
|
|
456
|
+
for (const r of replacements) {
|
|
457
|
+
if (foundIds.has(r.token)) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
foundIds.add(r.token);
|
|
461
|
+
cleaned.push(r);
|
|
462
|
+
}
|
|
463
|
+
return cleaned;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function removeUnusedReplacements(html: string, replacements: Replacement[]) {
|
|
467
|
+
const cleaned: Replacement[] = [];
|
|
468
|
+
for (const r of cleanReplacements(replacements)) {
|
|
469
|
+
if (html.includes(`{{${r.token}}}`)) {
|
|
470
|
+
cleaned.push(r);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return cleaned;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function mergeReplacementsIfEqual(replacementsA: Replacement[], replacementsB: Replacement[]): Replacement[] | false {
|
|
477
|
+
replacementsA = cleanReplacements(replacementsA);
|
|
478
|
+
replacementsB = cleanReplacements(replacementsB);
|
|
479
|
+
|
|
480
|
+
if (replacementsA.length !== replacementsB.length) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const merged: Replacement[] = [];
|
|
485
|
+
for (const rA of replacementsA) {
|
|
486
|
+
const rB = replacementsB.find(r => r.token === rA.token);
|
|
487
|
+
if (!rB) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (rA.html === rB.html && rA.value === rB.value) {
|
|
492
|
+
merged.push(rA);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const m = mergeReplacement(rA, rB);
|
|
497
|
+
if (!m) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
merged.push(m);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return merged;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Filter replacements for display in the backend.
|
|
508
|
+
* @param options.forPreview if true, it will hide sensitive information in the preview that could leak information to admin users
|
|
509
|
+
*/
|
|
510
|
+
export function stripSensitiveRecipientReplacements(recipient: Recipient | EmailRecipientStruct | EmailRecipient, options: {
|
|
511
|
+
organization: Organization | null;
|
|
512
|
+
willFill?: boolean;
|
|
513
|
+
}) {
|
|
514
|
+
const { organization } = options;
|
|
515
|
+
// Remove unsubscribeUrl and signInUrl if present
|
|
516
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'unsubscribeUrl' && r.token !== 'signInUrl');
|
|
517
|
+
|
|
518
|
+
if (options.willFill) {
|
|
519
|
+
// Also strip loginDetails, balanceTable and outstandingBalance
|
|
520
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'balanceTable' && r.token !== 'outstandingBalance' && r.token !== 'loginDetails');
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Add dummy unsubscribeUrl
|
|
525
|
+
const dummyUnsubscribeUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false) : STAMHOOFD.domains.dashboard) + '/unsubscribe?token=example';
|
|
526
|
+
recipient.replacements.push(Replacement.create({
|
|
527
|
+
token: 'unsubscribeUrl',
|
|
528
|
+
value: dummyUnsubscribeUrl,
|
|
529
|
+
}));
|
|
530
|
+
|
|
531
|
+
// dummy signInUrl
|
|
532
|
+
const dummySignInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false) : STAMHOOFD.domains.dashboard) + '/login';
|
|
533
|
+
recipient.replacements.push(Replacement.create({
|
|
534
|
+
token: 'signInUrl',
|
|
535
|
+
value: dummySignInUrl,
|
|
536
|
+
}));
|
|
537
|
+
|
|
538
|
+
// Strip security codes (because we list ALL security codes, also from members a viewer might not have access to)
|
|
539
|
+
recipient.replacements = recipient.replacements.map((r) => {
|
|
540
|
+
if (r.token !== 'loginDetails') {
|
|
541
|
+
return r;
|
|
542
|
+
}
|
|
543
|
+
return Replacement.create({
|
|
544
|
+
...r,
|
|
545
|
+
// Strip <span class="style-inline-code">(.*)</span> and replace content with XXXX-XXXX-XXXX-XXXX
|
|
546
|
+
html: r.html ? r.html.replace(/<span class="style-inline-code">.*?<\/span>/g, '<span class="style-inline-code">••••</span>') : r.html,
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Fill and hide replacements that don't make sense for web display to the user
|
|
553
|
+
*/
|
|
554
|
+
export function stripRecipientReplacementsForWebDisplay(recipient: Recipient | EmailRecipientStruct | EmailRecipient, options: {
|
|
555
|
+
organization: Organization | null;
|
|
556
|
+
}) {
|
|
557
|
+
const { organization } = options;
|
|
558
|
+
// Remove unsubscribeUrl if present
|
|
559
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'unsubscribeUrl' && r.token !== 'loginDetails' && r.token !== 'greeting');
|
|
560
|
+
|
|
561
|
+
// Add dummy unsubscribeUrl
|
|
562
|
+
const dummyUnsubscribeUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false) : STAMHOOFD.domains.dashboard);
|
|
563
|
+
recipient.replacements.push(Replacement.create({
|
|
564
|
+
token: 'unsubscribeUrl',
|
|
565
|
+
value: dummyUnsubscribeUrl,
|
|
566
|
+
}));
|
|
567
|
+
|
|
568
|
+
recipient.replacements.push(Replacement.create({
|
|
569
|
+
token: 'loginDetails',
|
|
570
|
+
value: '',
|
|
571
|
+
}));
|
|
572
|
+
|
|
573
|
+
recipient.replacements.push(Replacement.create({
|
|
574
|
+
token: 'greeting',
|
|
575
|
+
value: $t('%1E9'),
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Build the I18n an email should be rendered in for a specific recipient.
|
|
581
|
+
*
|
|
582
|
+
* Falls back to the current (ambient) locale when the recipient has no language set — so flows
|
|
583
|
+
* that already established a locale (order emails wrapped in I18n.runWithLocale, or the request
|
|
584
|
+
* locale) keep working unchanged.
|
|
585
|
+
*/
|
|
586
|
+
export function getRecipientI18n(recipient: { language?: Language | null }, organization: Organization | null, options?: { allowedLanguages?: Language[] | null }): I18n {
|
|
587
|
+
let lang = recipient.language ?? $getLanguage();
|
|
588
|
+
if (options?.allowedLanguages && options.allowedLanguages.length) {
|
|
589
|
+
if (!options.allowedLanguages.includes(lang)) {
|
|
590
|
+
if (options.allowedLanguages.includes($getLanguage())) {
|
|
591
|
+
lang = $getLanguage();
|
|
592
|
+
} else {
|
|
593
|
+
lang = options.allowedLanguages[0];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return new I18n(lang, organization?.address.country ?? $getCountry());
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Render everything inside `handler` in the recipient's language: all $t / $getLanguage /
|
|
602
|
+
* $getCountry calls (and therefore every generated replacement) use the recipient's locale.
|
|
603
|
+
*
|
|
604
|
+
* This is the single place that binds "a recipient" to "its language" when generating email
|
|
605
|
+
* content. Generate recipient replacements inside this wrapper so new replacements are localized
|
|
606
|
+
* automatically, and pass the provided i18n to helpers that take an explicit locale (e.g. getAppHost).
|
|
607
|
+
*/
|
|
608
|
+
export function runWithRecipientLocale<T>(recipient: { language?: Language | null }, organization: Organization | null, handler: (i18n: I18n) => T, options?: { allowedLanguages?: Language[] | null }): T {
|
|
609
|
+
const i18n = getRecipientI18n(recipient, organization, options);
|
|
610
|
+
return I18n.runWithLocale(i18n, () => handler(i18n));
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* @param options.forPreview if true, it will hide sensitive information in the preview that could leak information to admin users
|
|
615
|
+
*/
|
|
616
|
+
export async function fillRecipientReplacements(recipient: Recipient | EmailRecipientStruct | EmailRecipient, options: {
|
|
617
|
+
organization: Organization | null;
|
|
618
|
+
platform?: PlatformStruct;
|
|
619
|
+
from: EmailInterfaceRecipient | null;
|
|
620
|
+
replyTo: EmailInterfaceRecipient | null;
|
|
621
|
+
forPreview?: boolean;
|
|
622
|
+
forceRefresh?: boolean;
|
|
623
|
+
allowedLanguages?: Language[] | null;
|
|
624
|
+
}) {
|
|
625
|
+
if (!options.platform) {
|
|
626
|
+
options.platform = await Platform.getSharedPrivateStruct();
|
|
627
|
+
}
|
|
628
|
+
const { organization, platform, from, replyTo } = options;
|
|
629
|
+
|
|
630
|
+
// Render every recipient replacement (greeting, loginDetails, balance table, URLs...) in the
|
|
631
|
+
// recipient's own language, so translated emails are consistent end-to-end. Any replacement
|
|
632
|
+
// added below is localized automatically; helpers with an explicit locale get the same i18n.
|
|
633
|
+
await runWithRecipientLocale(recipient, organization, async (i18n) => {
|
|
634
|
+
let recipientUser: User | null | undefined = null;
|
|
635
|
+
recipient.replacements = recipient.replacements.slice();
|
|
636
|
+
if (options.forPreview) {
|
|
637
|
+
stripSensitiveRecipientReplacements(recipient, options);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!recipient.email && !recipient.userId) {
|
|
641
|
+
const signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/login';
|
|
642
|
+
recipient.replacements.push(Replacement.create({
|
|
643
|
+
token: 'signInUrl',
|
|
644
|
+
value: signInUrl,
|
|
645
|
+
}));
|
|
646
|
+
|
|
647
|
+
if (!recipient.replacements.find(r => r.token === 'loginDetails')) {
|
|
648
|
+
recipient.replacements.push(Replacement.create({
|
|
649
|
+
token: 'loginDetails',
|
|
650
|
+
value: '',
|
|
651
|
+
}));
|
|
652
|
+
}
|
|
653
|
+
} else {
|
|
654
|
+
// Default signInUrl
|
|
655
|
+
recipientUser = recipient.userId ? await User.select().where('id', recipient.userId).first(false) : await User.getForAuthentication(organization?.id ?? null, recipient.email!, { allowWithoutAccount: true });
|
|
656
|
+
if (STAMHOOFD.userMode !== 'platform' && recipientUser && recipientUser.organizationId && recipientUser.organizationId !== (organization?.id ?? null)) {
|
|
657
|
+
console.warn('User organization does not match current organization, ignoring userId', recipient.userId, recipientUser.organizationId, organization?.id ?? null);
|
|
658
|
+
recipientUser = null;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let signInUrl: string;
|
|
662
|
+
if (!recipientUser || !recipientUser.hasAccount()) {
|
|
663
|
+
// We can create a special token
|
|
664
|
+
if (recipientUser) {
|
|
665
|
+
signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/account-aanmaken?email=' + encodeURIComponent(recipientUser?.email);
|
|
666
|
+
} else {
|
|
667
|
+
signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/account-aanmaken';
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
signInUrl = 'https://' + (organization && STAMHOOFD.userMode === 'organization' ? getAppHost('registration', organization, false, i18n) : STAMHOOFD.domains.dashboard) + '/login?email=' + encodeURIComponent(recipientUser.email);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
recipient.replacements.push(Replacement.create({
|
|
674
|
+
token: 'signInUrl',
|
|
675
|
+
value: signInUrl,
|
|
676
|
+
}));
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (options.forceRefresh) {
|
|
680
|
+
// Remove loginDetails to force refresh
|
|
681
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'loginDetails');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (!recipient.replacements.find(r => r.token === 'loginDetails')) {
|
|
685
|
+
if (recipientUser) {
|
|
686
|
+
const emailEscaped = `<strong>${Formatter.escapeHtml(recipientUser.email)}</strong>`;
|
|
687
|
+
const suffixes: string[] = [];
|
|
688
|
+
const memberIds = await Member.getMemberIdsForUser(recipientUser);
|
|
689
|
+
const members = await Member.getByIDs(...memberIds);
|
|
690
|
+
if (members.length > 0) {
|
|
691
|
+
for (const member of members) {
|
|
692
|
+
suffixes.push(
|
|
693
|
+
$t('%1EC', {
|
|
694
|
+
firstName: Formatter.escapeHtml(member.firstName),
|
|
695
|
+
securityCode: `<span class="style-inline-code">${Formatter.escapeHtml(options.forPreview ? '••••' : Formatter.spaceString(member.details.securityCode ?? '', 4, '-'))}</span>`,
|
|
696
|
+
}),
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
} else {
|
|
700
|
+
console.log('No member found for user', recipientUser.id);
|
|
701
|
+
}
|
|
702
|
+
const suffix = suffixes.length > 0 ? (' ' + suffixes.join(' ')) : '';
|
|
703
|
+
recipient.replacements.push(
|
|
704
|
+
Replacement.create({
|
|
705
|
+
token: 'loginDetails',
|
|
706
|
+
value: '',
|
|
707
|
+
html: recipientUser.hasAccount()
|
|
708
|
+
? `<p class="description"><em>${$t('%1EA', { email: emailEscaped })}${suffix}</em></p>`
|
|
709
|
+
: `<p class="description"><em>${$t('%1EB', { email: emailEscaped })}${suffix}</em></p>`,
|
|
710
|
+
}),
|
|
711
|
+
);
|
|
712
|
+
} else {
|
|
713
|
+
if (recipient.email) {
|
|
714
|
+
const emailEscaped = `<strong>${Formatter.escapeHtml(recipient.email)}</strong>`;
|
|
715
|
+
console.log('No user found for email', recipient.email);
|
|
716
|
+
recipient.replacements.push(
|
|
717
|
+
Replacement.create({
|
|
718
|
+
token: 'loginDetails',
|
|
719
|
+
value: '',
|
|
720
|
+
html: `<p class="description"><em>${$t('%1EB', { email: emailEscaped })}</em></p>`,
|
|
721
|
+
}),
|
|
722
|
+
);
|
|
723
|
+
} else {
|
|
724
|
+
recipient.replacements.push(
|
|
725
|
+
Replacement.create({
|
|
726
|
+
token: 'loginDetails',
|
|
727
|
+
value: '',
|
|
728
|
+
html: '',
|
|
729
|
+
}),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (options.forceRefresh) {
|
|
736
|
+
// Remove loginDetails to force refresh
|
|
737
|
+
recipient.replacements = recipient.replacements.filter(r => r.token !== 'balanceTable' && r.token !== 'outstandingBalance');
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Load balance of this user
|
|
741
|
+
// todo: only if detected it is used
|
|
742
|
+
if (!recipient.replacements.find(r => r.token === 'balanceTable')) {
|
|
743
|
+
if (organization && recipientUser) {
|
|
744
|
+
const balanceItemModels = await CachedBalance.balanceForObjects(organization.id, [recipientUser.id], ReceivableBalanceType.user);
|
|
745
|
+
const balanceItems = balanceItemModels.map(i => i.getStructure());
|
|
746
|
+
|
|
747
|
+
// Get members
|
|
748
|
+
recipient.replacements.push(
|
|
749
|
+
Replacement.create({
|
|
750
|
+
token: 'outstandingBalance',
|
|
751
|
+
value: Formatter.price(balanceItems.reduce((sum, i) => sum + i.priceOpen, 0)),
|
|
752
|
+
}),
|
|
753
|
+
Replacement.create({
|
|
754
|
+
token: 'balanceTable',
|
|
755
|
+
value: '',
|
|
756
|
+
html: BalanceItemStruct.getDetailsHTMLTable(balanceItems),
|
|
757
|
+
}),
|
|
758
|
+
);
|
|
759
|
+
} else {
|
|
760
|
+
recipient.replacements.push(
|
|
761
|
+
Replacement.create({
|
|
762
|
+
token: 'outstandingBalance',
|
|
763
|
+
value: Formatter.price(0),
|
|
764
|
+
}),
|
|
765
|
+
Replacement.create({
|
|
766
|
+
token: 'balanceTable',
|
|
767
|
+
value: '',
|
|
768
|
+
html: BalanceItemStruct.getDetailsHTMLTable([]),
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (from || replyTo) {
|
|
775
|
+
const fromAddress = replyTo?.email ?? from!.email;
|
|
776
|
+
|
|
777
|
+
if (fromAddress) {
|
|
778
|
+
recipient.replacements.push(Replacement.create({
|
|
779
|
+
token: 'fromAddress',
|
|
780
|
+
value: fromAddress,
|
|
781
|
+
}));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const name = replyTo?.name ?? from?.name;
|
|
785
|
+
if (name) {
|
|
786
|
+
recipient.replacements.push(Replacement.create({
|
|
787
|
+
token: 'fromName',
|
|
788
|
+
value: name,
|
|
789
|
+
}));
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (recipient instanceof EmailRecipient) {
|
|
794
|
+
recipient.replacements.push(...recipient.getRecipient().getDefaultReplacements());
|
|
795
|
+
} else {
|
|
796
|
+
recipient.replacements.push(...recipient.getDefaultReplacements());
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (organization) {
|
|
800
|
+
const extra = organization.meta.getEmailReplacements(organization);
|
|
801
|
+
recipient.replacements.push(...extra);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Defaults
|
|
805
|
+
const extra = platform.config.getEmailReplacements(platform);
|
|
806
|
+
recipient.replacements.push(...extra);
|
|
807
|
+
|
|
808
|
+
// Remove duplicates
|
|
809
|
+
cleanReplacements(recipient.replacements);
|
|
810
|
+
}, options);
|
|
811
|
+
}
|