@astralibx/email-rule-engine 12.10.1 → 14.0.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/README.md +26 -90
- package/dist/index.cjs +64 -2485
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +11 -859
- package/dist/index.d.ts +11 -859
- package/dist/index.mjs +57 -2450
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -10
package/dist/index.cjs
CHANGED
|
@@ -1,403 +1,16 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
4
|
-
var Handlebars = require('handlebars');
|
|
3
|
+
var ruleEngine = require('@astralibx/rule-engine');
|
|
5
4
|
var mjml2html = require('mjml');
|
|
6
5
|
var htmlToText = require('html-to-text');
|
|
7
|
-
var
|
|
8
|
-
var crypto = require('crypto');
|
|
9
|
-
var express = require('express');
|
|
10
|
-
var zod = require('zod');
|
|
11
|
-
var bullmq = require('bullmq');
|
|
6
|
+
var Handlebars = require('handlebars');
|
|
12
7
|
|
|
13
8
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
14
9
|
|
|
15
|
-
var Handlebars__default = /*#__PURE__*/_interopDefault(Handlebars);
|
|
16
10
|
var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
|
|
17
|
-
var
|
|
18
|
-
|
|
19
|
-
// src/schemas/template.schema.ts
|
|
20
|
-
|
|
21
|
-
// src/constants/field-types.ts
|
|
22
|
-
var FIELD_TYPE = {
|
|
23
|
-
String: "string",
|
|
24
|
-
Number: "number",
|
|
25
|
-
Boolean: "boolean",
|
|
26
|
-
Date: "date",
|
|
27
|
-
ObjectId: "objectId",
|
|
28
|
-
Array: "array",
|
|
29
|
-
Object: "object"
|
|
30
|
-
};
|
|
31
|
-
var TYPE_OPERATORS = {
|
|
32
|
-
string: ["eq", "neq", "contains", "in", "not_in", "exists", "not_exists"],
|
|
33
|
-
number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in", "exists", "not_exists"],
|
|
34
|
-
boolean: ["eq", "neq", "exists", "not_exists"],
|
|
35
|
-
date: ["eq", "neq", "gt", "gte", "lt", "lte", "exists", "not_exists"],
|
|
36
|
-
objectId: ["eq", "neq", "in", "not_in", "exists", "not_exists"],
|
|
37
|
-
array: ["contains", "in", "not_in", "exists", "not_exists"],
|
|
38
|
-
object: ["exists", "not_exists"]
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
// src/constants/index.ts
|
|
42
|
-
var TEMPLATE_CATEGORY = {
|
|
43
|
-
Onboarding: "onboarding",
|
|
44
|
-
Engagement: "engagement",
|
|
45
|
-
Transactional: "transactional",
|
|
46
|
-
ReEngagement: "re-engagement",
|
|
47
|
-
Announcement: "announcement"
|
|
48
|
-
};
|
|
49
|
-
var TEMPLATE_AUDIENCE = {
|
|
50
|
-
Customer: "customer",
|
|
51
|
-
Provider: "provider",
|
|
52
|
-
All: "all"
|
|
53
|
-
};
|
|
54
|
-
var RULE_OPERATOR = {
|
|
55
|
-
Eq: "eq",
|
|
56
|
-
Neq: "neq",
|
|
57
|
-
Gt: "gt",
|
|
58
|
-
Gte: "gte",
|
|
59
|
-
Lt: "lt",
|
|
60
|
-
Lte: "lte",
|
|
61
|
-
Exists: "exists",
|
|
62
|
-
NotExists: "not_exists",
|
|
63
|
-
In: "in",
|
|
64
|
-
NotIn: "not_in",
|
|
65
|
-
Contains: "contains"
|
|
66
|
-
};
|
|
67
|
-
var EMAIL_TYPE = {
|
|
68
|
-
Automated: "automated",
|
|
69
|
-
Transactional: "transactional"
|
|
70
|
-
};
|
|
71
|
-
var RUN_TRIGGER = {
|
|
72
|
-
Cron: "cron",
|
|
73
|
-
Manual: "manual"
|
|
74
|
-
};
|
|
75
|
-
var THROTTLE_WINDOW = {
|
|
76
|
-
Rolling: "rolling"
|
|
77
|
-
};
|
|
78
|
-
var EMAIL_SEND_STATUS = {
|
|
79
|
-
Sent: "sent",
|
|
80
|
-
Error: "error",
|
|
81
|
-
Skipped: "skipped",
|
|
82
|
-
Invalid: "invalid",
|
|
83
|
-
Throttled: "throttled"
|
|
84
|
-
};
|
|
85
|
-
var TARGET_MODE = {
|
|
86
|
-
Query: "query",
|
|
87
|
-
List: "list"
|
|
88
|
-
};
|
|
89
|
-
var RUN_LOG_STATUS = {
|
|
90
|
-
Completed: "completed",
|
|
91
|
-
Cancelled: "cancelled",
|
|
92
|
-
Failed: "failed"
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// src/schemas/template.schema.ts
|
|
96
|
-
function createEmailTemplateSchema(platformValues, audienceValues, categoryValues, collectionPrefix) {
|
|
97
|
-
const schema = new mongoose.Schema(
|
|
98
|
-
{
|
|
99
|
-
name: { type: String, required: true },
|
|
100
|
-
slug: { type: String, required: true, unique: true, maxlength: 200 },
|
|
101
|
-
description: String,
|
|
102
|
-
category: { type: String, enum: categoryValues || Object.values(TEMPLATE_CATEGORY), required: true },
|
|
103
|
-
audience: { type: String, enum: audienceValues || Object.values(TEMPLATE_AUDIENCE), required: true },
|
|
104
|
-
platform: {
|
|
105
|
-
type: String,
|
|
106
|
-
required: true,
|
|
107
|
-
...platformValues ? { enum: platformValues } : {}
|
|
108
|
-
},
|
|
109
|
-
textBody: String,
|
|
110
|
-
subjects: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one subject is required"] },
|
|
111
|
-
bodies: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one body is required"] },
|
|
112
|
-
preheaders: [{ type: String }],
|
|
113
|
-
fields: {
|
|
114
|
-
type: mongoose.Schema.Types.Mixed,
|
|
115
|
-
default: {},
|
|
116
|
-
validate: {
|
|
117
|
-
validator: (v) => {
|
|
118
|
-
if (!v || typeof v !== "object") return true;
|
|
119
|
-
return Object.values(v).every((val) => typeof val === "string");
|
|
120
|
-
},
|
|
121
|
-
message: "All field values must be strings"
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
variables: [{ type: String }],
|
|
125
|
-
attachments: {
|
|
126
|
-
type: [{
|
|
127
|
-
_id: false,
|
|
128
|
-
filename: { type: String, required: true },
|
|
129
|
-
url: { type: String, required: true },
|
|
130
|
-
contentType: { type: String, required: true }
|
|
131
|
-
}],
|
|
132
|
-
default: []
|
|
133
|
-
},
|
|
134
|
-
version: { type: Number, default: 1 },
|
|
135
|
-
isActive: { type: Boolean, default: true }
|
|
136
|
-
},
|
|
137
|
-
{
|
|
138
|
-
timestamps: true,
|
|
139
|
-
collection: `${collectionPrefix || ""}email_templates`,
|
|
140
|
-
statics: {
|
|
141
|
-
findBySlug(slug) {
|
|
142
|
-
return this.findOne({ slug });
|
|
143
|
-
},
|
|
144
|
-
findActive() {
|
|
145
|
-
return this.find({ isActive: true }).sort({ category: 1, name: 1 });
|
|
146
|
-
},
|
|
147
|
-
findByCategory(category) {
|
|
148
|
-
return this.find({ category, isActive: true }).sort({ name: 1 });
|
|
149
|
-
},
|
|
150
|
-
findByAudience(audience) {
|
|
151
|
-
return this.find({
|
|
152
|
-
$or: [{ audience }, { audience: TEMPLATE_AUDIENCE.All }],
|
|
153
|
-
isActive: true
|
|
154
|
-
}).sort({ name: 1 });
|
|
155
|
-
},
|
|
156
|
-
async createTemplate(input) {
|
|
157
|
-
return this.create({
|
|
158
|
-
name: input.name,
|
|
159
|
-
slug: input.slug,
|
|
160
|
-
description: input.description,
|
|
161
|
-
category: input.category,
|
|
162
|
-
audience: input.audience,
|
|
163
|
-
platform: input.platform,
|
|
164
|
-
textBody: input.textBody,
|
|
165
|
-
subjects: input.subjects,
|
|
166
|
-
bodies: input.bodies,
|
|
167
|
-
preheaders: input.preheaders || [],
|
|
168
|
-
fields: input.fields || {},
|
|
169
|
-
variables: input.variables || [],
|
|
170
|
-
attachments: input.attachments || [],
|
|
171
|
-
version: 1,
|
|
172
|
-
isActive: true
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
);
|
|
178
|
-
schema.index({ category: 1, isActive: 1 });
|
|
179
|
-
schema.index({ audience: 1, platform: 1, isActive: 1 });
|
|
180
|
-
return schema;
|
|
181
|
-
}
|
|
182
|
-
function createRunStatsSchema() {
|
|
183
|
-
return new mongoose.Schema({
|
|
184
|
-
matched: { type: Number, default: 0 },
|
|
185
|
-
sent: { type: Number, default: 0 },
|
|
186
|
-
skipped: { type: Number, default: 0 },
|
|
187
|
-
skippedByThrottle: { type: Number, default: 0 },
|
|
188
|
-
errorCount: { type: Number, default: 0 }
|
|
189
|
-
}, { _id: false });
|
|
190
|
-
}
|
|
11
|
+
var Handlebars__default = /*#__PURE__*/_interopDefault(Handlebars);
|
|
191
12
|
|
|
192
|
-
// src/
|
|
193
|
-
function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix) {
|
|
194
|
-
const RuleConditionSchema = new mongoose.Schema({
|
|
195
|
-
field: { type: String, required: true },
|
|
196
|
-
operator: { type: String, enum: Object.values(RULE_OPERATOR), required: true },
|
|
197
|
-
value: { type: mongoose.Schema.Types.Mixed }
|
|
198
|
-
}, { _id: false });
|
|
199
|
-
const RuleTargetSchema = new mongoose.Schema({
|
|
200
|
-
mode: { type: String, enum: Object.values(TARGET_MODE), required: true },
|
|
201
|
-
role: { type: String, enum: audienceValues || Object.values(TEMPLATE_AUDIENCE) },
|
|
202
|
-
platform: {
|
|
203
|
-
type: String,
|
|
204
|
-
...platformValues ? { enum: platformValues } : {}
|
|
205
|
-
},
|
|
206
|
-
conditions: [RuleConditionSchema],
|
|
207
|
-
identifiers: [{ type: String }],
|
|
208
|
-
collectionName: { type: String }
|
|
209
|
-
}, { _id: false });
|
|
210
|
-
const RuleRunStatsSchema = createRunStatsSchema();
|
|
211
|
-
const schema = new mongoose.Schema(
|
|
212
|
-
{
|
|
213
|
-
name: { type: String, required: true },
|
|
214
|
-
description: String,
|
|
215
|
-
isActive: { type: Boolean, default: false },
|
|
216
|
-
sortOrder: { type: Number, default: 10 },
|
|
217
|
-
target: { type: RuleTargetSchema, required: true },
|
|
218
|
-
templateId: { type: mongoose.Schema.Types.ObjectId, ref: "EmailTemplate", required: true, index: true },
|
|
219
|
-
sendOnce: { type: Boolean, default: true },
|
|
220
|
-
resendAfterDays: Number,
|
|
221
|
-
cooldownDays: Number,
|
|
222
|
-
autoApprove: { type: Boolean, default: true },
|
|
223
|
-
maxPerRun: Number,
|
|
224
|
-
validFrom: { type: Date },
|
|
225
|
-
validTill: { type: Date },
|
|
226
|
-
bypassThrottle: { type: Boolean, default: false },
|
|
227
|
-
throttleOverride: {
|
|
228
|
-
type: {
|
|
229
|
-
maxPerUserPerDay: { type: Number },
|
|
230
|
-
maxPerUserPerWeek: { type: Number },
|
|
231
|
-
minGapDays: { type: Number }
|
|
232
|
-
},
|
|
233
|
-
_id: false,
|
|
234
|
-
default: void 0
|
|
235
|
-
},
|
|
236
|
-
emailType: { type: String, enum: Object.values(EMAIL_TYPE), default: EMAIL_TYPE.Automated },
|
|
237
|
-
schedule: {
|
|
238
|
-
type: {
|
|
239
|
-
enabled: { type: Boolean, default: false },
|
|
240
|
-
cron: { type: String },
|
|
241
|
-
timezone: { type: String, default: "UTC" }
|
|
242
|
-
},
|
|
243
|
-
_id: false
|
|
244
|
-
},
|
|
245
|
-
totalSent: { type: Number, default: 0 },
|
|
246
|
-
totalSkipped: { type: Number, default: 0 },
|
|
247
|
-
lastRunAt: Date,
|
|
248
|
-
lastRunStats: RuleRunStatsSchema
|
|
249
|
-
},
|
|
250
|
-
{
|
|
251
|
-
timestamps: true,
|
|
252
|
-
collection: `${collectionPrefix || ""}email_rules`,
|
|
253
|
-
statics: {
|
|
254
|
-
findActive() {
|
|
255
|
-
return this.find({ isActive: true }).sort({ sortOrder: 1 });
|
|
256
|
-
},
|
|
257
|
-
findByTemplateId(templateId) {
|
|
258
|
-
return this.find({ templateId });
|
|
259
|
-
},
|
|
260
|
-
async createRule(input) {
|
|
261
|
-
return this.create({
|
|
262
|
-
name: input.name,
|
|
263
|
-
description: input.description,
|
|
264
|
-
isActive: false,
|
|
265
|
-
sortOrder: input.sortOrder ?? 10,
|
|
266
|
-
target: input.target,
|
|
267
|
-
templateId: input.templateId,
|
|
268
|
-
sendOnce: input.sendOnce ?? true,
|
|
269
|
-
resendAfterDays: input.resendAfterDays,
|
|
270
|
-
cooldownDays: input.cooldownDays,
|
|
271
|
-
autoApprove: input.autoApprove ?? true,
|
|
272
|
-
maxPerRun: input.maxPerRun,
|
|
273
|
-
validFrom: input.validFrom,
|
|
274
|
-
validTill: input.validTill,
|
|
275
|
-
bypassThrottle: input.bypassThrottle ?? false,
|
|
276
|
-
throttleOverride: input.throttleOverride,
|
|
277
|
-
schedule: input.schedule,
|
|
278
|
-
emailType: input.emailType ?? EMAIL_TYPE.Automated,
|
|
279
|
-
totalSent: 0,
|
|
280
|
-
totalSkipped: 0
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
);
|
|
286
|
-
schema.index({ isActive: 1, sortOrder: 1 });
|
|
287
|
-
return schema;
|
|
288
|
-
}
|
|
289
|
-
function createEmailRuleSendSchema(collectionPrefix) {
|
|
290
|
-
const schema = new mongoose.Schema(
|
|
291
|
-
{
|
|
292
|
-
ruleId: { type: mongoose.Schema.Types.ObjectId, ref: "EmailRule", required: true },
|
|
293
|
-
userId: { type: String, required: true },
|
|
294
|
-
emailIdentifierId: { type: String },
|
|
295
|
-
messageId: { type: String },
|
|
296
|
-
sentAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
|
|
297
|
-
status: { type: String },
|
|
298
|
-
accountId: { type: String },
|
|
299
|
-
senderName: { type: String },
|
|
300
|
-
subject: { type: String },
|
|
301
|
-
subjectIndex: { type: Number },
|
|
302
|
-
bodyIndex: { type: Number },
|
|
303
|
-
preheaderIndex: { type: Number },
|
|
304
|
-
failureReason: { type: String }
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
collection: `${collectionPrefix || ""}email_rule_sends`,
|
|
308
|
-
statics: {
|
|
309
|
-
findLatestForUser(ruleId, userId) {
|
|
310
|
-
return this.findOne({ ruleId, userId }).sort({ sentAt: -1 });
|
|
311
|
-
},
|
|
312
|
-
findRecentByUserIds(userIds, sinceDays) {
|
|
313
|
-
const since = new Date(Date.now() - sinceDays * 864e5);
|
|
314
|
-
return this.find({
|
|
315
|
-
userId: { $in: userIds },
|
|
316
|
-
sentAt: { $gte: since }
|
|
317
|
-
}).sort({ sentAt: -1 });
|
|
318
|
-
},
|
|
319
|
-
async logSend(ruleId, userId, emailIdentifierId, messageId, extra) {
|
|
320
|
-
return this.create({
|
|
321
|
-
ruleId,
|
|
322
|
-
userId,
|
|
323
|
-
emailIdentifierId,
|
|
324
|
-
messageId,
|
|
325
|
-
sentAt: /* @__PURE__ */ new Date(),
|
|
326
|
-
...extra
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
);
|
|
332
|
-
schema.index({ ruleId: 1, userId: 1, sentAt: -1 });
|
|
333
|
-
schema.index({ userId: 1, sentAt: -1 });
|
|
334
|
-
schema.index({ ruleId: 1, sentAt: -1 });
|
|
335
|
-
schema.index({ status: 1, sentAt: -1 });
|
|
336
|
-
return schema;
|
|
337
|
-
}
|
|
338
|
-
function createEmailRuleRunLogSchema(collectionPrefix) {
|
|
339
|
-
const baseStatsSchema = createRunStatsSchema();
|
|
340
|
-
const PerRuleStatsSchema = new mongoose.Schema({
|
|
341
|
-
ruleId: { type: mongoose.Schema.Types.ObjectId, ref: "EmailRule", required: true },
|
|
342
|
-
ruleName: { type: String, required: true },
|
|
343
|
-
...baseStatsSchema.obj
|
|
344
|
-
}, { _id: false });
|
|
345
|
-
const TotalStatsSchema = createRunStatsSchema();
|
|
346
|
-
const schema = new mongoose.Schema(
|
|
347
|
-
{
|
|
348
|
-
runId: { type: String, index: true },
|
|
349
|
-
runAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
|
|
350
|
-
status: { type: String, enum: Object.values(RUN_LOG_STATUS), default: RUN_LOG_STATUS.Completed },
|
|
351
|
-
triggeredBy: { type: String, enum: Object.values(RUN_TRIGGER), required: true },
|
|
352
|
-
duration: { type: Number, required: true },
|
|
353
|
-
rulesProcessed: { type: Number, required: true },
|
|
354
|
-
totalStats: { type: TotalStatsSchema, required: true },
|
|
355
|
-
perRuleStats: [PerRuleStatsSchema]
|
|
356
|
-
},
|
|
357
|
-
{
|
|
358
|
-
collection: `${collectionPrefix || ""}email_rule_run_logs`,
|
|
359
|
-
statics: {
|
|
360
|
-
getRecent(limit = 20) {
|
|
361
|
-
return this.find().sort({ runAt: -1 }).limit(limit);
|
|
362
|
-
},
|
|
363
|
-
getByRuleId(ruleId, limit = 20) {
|
|
364
|
-
return this.find({ "perRuleStats.ruleId": ruleId }).sort({ runAt: -1 }).limit(limit);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
);
|
|
369
|
-
schema.index({ runAt: -1 }, { expireAfterSeconds: 90 * 86400 });
|
|
370
|
-
return schema;
|
|
371
|
-
}
|
|
372
|
-
function createEmailThrottleConfigSchema(collectionPrefix) {
|
|
373
|
-
const schema = new mongoose.Schema(
|
|
374
|
-
{
|
|
375
|
-
maxPerUserPerDay: { type: Number, default: 1 },
|
|
376
|
-
maxPerUserPerWeek: { type: Number, default: 2 },
|
|
377
|
-
minGapDays: { type: Number, default: 3 },
|
|
378
|
-
throttleWindow: { type: String, enum: Object.values(THROTTLE_WINDOW), default: THROTTLE_WINDOW.Rolling }
|
|
379
|
-
},
|
|
380
|
-
{
|
|
381
|
-
timestamps: true,
|
|
382
|
-
collection: `${collectionPrefix || ""}email_throttle_config`,
|
|
383
|
-
statics: {
|
|
384
|
-
async getConfig() {
|
|
385
|
-
let config = await this.findOne();
|
|
386
|
-
if (!config) {
|
|
387
|
-
config = await this.create({
|
|
388
|
-
maxPerUserPerDay: 1,
|
|
389
|
-
maxPerUserPerWeek: 2,
|
|
390
|
-
minGapDays: 3,
|
|
391
|
-
throttleWindow: THROTTLE_WINDOW.Rolling
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
return config;
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
);
|
|
399
|
-
return schema;
|
|
400
|
-
}
|
|
13
|
+
// src/index.ts
|
|
401
14
|
var MJML_BASE_OPEN = `<mjml>
|
|
402
15
|
<mj-head>
|
|
403
16
|
<mj-attributes>
|
|
@@ -414,67 +27,19 @@ var MJML_BASE_CLOSE = ` </mj-text>
|
|
|
414
27
|
</mj-section>
|
|
415
28
|
</mj-body>
|
|
416
29
|
</mjml>`;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
month: "short",
|
|
420
|
-
year: "numeric"
|
|
421
|
-
};
|
|
422
|
-
function registerHelpers() {
|
|
423
|
-
Handlebars__default.default.registerHelper("currency", (val) => {
|
|
424
|
-
return `\u20B9${Number(val).toLocaleString("en-IN")}`;
|
|
425
|
-
});
|
|
426
|
-
Handlebars__default.default.registerHelper("formatDate", (date) => {
|
|
427
|
-
const d = new Date(date);
|
|
428
|
-
return d.toLocaleDateString("en-IN", DATE_FORMAT_OPTIONS);
|
|
429
|
-
});
|
|
430
|
-
Handlebars__default.default.registerHelper("capitalize", (str) => {
|
|
431
|
-
if (!str) return "";
|
|
432
|
-
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
433
|
-
});
|
|
434
|
-
Handlebars__default.default.registerHelper("eq", (a, b) => a === b);
|
|
435
|
-
Handlebars__default.default.registerHelper("neq", (a, b) => a !== b);
|
|
436
|
-
Handlebars__default.default.registerHelper("not", (val) => !val);
|
|
437
|
-
Handlebars__default.default.registerHelper("gt", (a, b) => a > b);
|
|
438
|
-
Handlebars__default.default.registerHelper("lt", (a, b) => a < b);
|
|
439
|
-
Handlebars__default.default.registerHelper("gte", (a, b) => a >= b);
|
|
440
|
-
Handlebars__default.default.registerHelper("lte", (a, b) => a <= b);
|
|
441
|
-
Handlebars__default.default.registerHelper("lowercase", (str) => {
|
|
442
|
-
return str ? str.toLowerCase() : "";
|
|
443
|
-
});
|
|
444
|
-
Handlebars__default.default.registerHelper("uppercase", (str) => {
|
|
445
|
-
return str ? str.toUpperCase() : "";
|
|
446
|
-
});
|
|
447
|
-
Handlebars__default.default.registerHelper("join", (arr, separator) => {
|
|
448
|
-
if (!Array.isArray(arr)) return "";
|
|
449
|
-
const sep = typeof separator === "string" ? separator : ", ";
|
|
450
|
-
return arr.join(sep);
|
|
451
|
-
});
|
|
452
|
-
Handlebars__default.default.registerHelper("pluralize", (count, singular, plural) => {
|
|
453
|
-
return count === 1 ? singular : typeof plural === "string" ? plural : singular + "s";
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
var helpersRegistered = false;
|
|
457
|
-
function ensureHelpers() {
|
|
458
|
-
if (!helpersRegistered) {
|
|
459
|
-
registerHelpers();
|
|
460
|
-
helpersRegistered = true;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
function wrapInMjml(body) {
|
|
464
|
-
if (body.trim().startsWith("<mjml")) {
|
|
30
|
+
function renderMjml(body) {
|
|
31
|
+
if (!body.includes("<mj-") && !body.trim().startsWith("<mjml")) {
|
|
465
32
|
return body;
|
|
466
33
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
function compileMjml(mjmlSource) {
|
|
470
|
-
const result = mjml2html__default.default(mjmlSource, {
|
|
34
|
+
const fullMjml = body.trim().startsWith("<mjml") ? body : `${MJML_BASE_OPEN}${body}${MJML_BASE_CLOSE}`;
|
|
35
|
+
const result = mjml2html__default.default(fullMjml, {
|
|
471
36
|
validationLevel: "soft",
|
|
472
37
|
minify: false
|
|
473
38
|
});
|
|
474
|
-
if (result.errors
|
|
475
|
-
const
|
|
476
|
-
if (
|
|
477
|
-
throw new Error(`MJML compilation errors: ${
|
|
39
|
+
if (result.errors?.length) {
|
|
40
|
+
const critical = result.errors.filter((e) => e.tagName !== void 0);
|
|
41
|
+
if (critical.length > 0) {
|
|
42
|
+
throw new Error(`MJML compilation errors: ${critical.map((e) => e.message).join("; ")}`);
|
|
478
43
|
}
|
|
479
44
|
}
|
|
480
45
|
return result.html;
|
|
@@ -488,2053 +53,67 @@ function htmlToPlainText(html) {
|
|
|
488
53
|
]
|
|
489
54
|
});
|
|
490
55
|
}
|
|
491
|
-
var
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
renderSingle(subject, body, data, textBody) {
|
|
496
|
-
const subjectFn = Handlebars__default.default.compile(subject, { strict: false });
|
|
497
|
-
const resolvedSubject = subjectFn(data);
|
|
498
|
-
const bodyFn = Handlebars__default.default.compile(body, { strict: false });
|
|
499
|
-
const resolvedBody = bodyFn(data);
|
|
500
|
-
const mjmlSource = wrapInMjml(resolvedBody);
|
|
501
|
-
const html = compileMjml(mjmlSource);
|
|
502
|
-
let text;
|
|
503
|
-
if (textBody) {
|
|
504
|
-
const textFn = Handlebars__default.default.compile(textBody, { strict: false });
|
|
505
|
-
text = textFn(data);
|
|
506
|
-
} else {
|
|
507
|
-
text = htmlToPlainText(html);
|
|
508
|
-
}
|
|
509
|
-
return { html, text, subject: resolvedSubject };
|
|
510
|
-
}
|
|
511
|
-
compileBatch(subject, body, textBody) {
|
|
512
|
-
const mjmlSource = wrapInMjml(body);
|
|
513
|
-
const htmlWithHandlebars = compileMjml(mjmlSource);
|
|
514
|
-
const subjectFn = Handlebars__default.default.compile(subject, { strict: false });
|
|
515
|
-
const bodyFn = Handlebars__default.default.compile(htmlWithHandlebars, { strict: false });
|
|
516
|
-
const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: false }) : void 0;
|
|
517
|
-
return { subjectFn, bodyFn, textBodyFn };
|
|
518
|
-
}
|
|
519
|
-
compileBatchVariants(subjects, bodies, textBody, preheaders) {
|
|
520
|
-
const subjectFns = subjects.map((s) => Handlebars__default.default.compile(s, { strict: false }));
|
|
521
|
-
const bodyFns = bodies.map((b) => {
|
|
522
|
-
const mjmlSource = wrapInMjml(b);
|
|
523
|
-
const htmlWithHandlebars = compileMjml(mjmlSource);
|
|
524
|
-
return Handlebars__default.default.compile(htmlWithHandlebars, { strict: false });
|
|
525
|
-
});
|
|
526
|
-
const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: false }) : void 0;
|
|
527
|
-
const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars__default.default.compile(p, { strict: false })) : void 0;
|
|
528
|
-
return { subjectFns, bodyFns, textBodyFn, preheaderFns };
|
|
529
|
-
}
|
|
530
|
-
renderFromCompiled(compiled, data) {
|
|
531
|
-
const subject = compiled.subjectFn(data);
|
|
532
|
-
const html = compiled.bodyFn(data);
|
|
533
|
-
const text = compiled.textBodyFn ? compiled.textBodyFn(data) : htmlToPlainText(html);
|
|
534
|
-
return { html, text, subject };
|
|
535
|
-
}
|
|
536
|
-
renderPreview(subject, body, data, textBody) {
|
|
537
|
-
const subjectFn = Handlebars__default.default.compile(subject, { strict: false });
|
|
538
|
-
const resolvedSubject = subjectFn(data);
|
|
539
|
-
const bodyFn = Handlebars__default.default.compile(body, { strict: false });
|
|
540
|
-
const resolvedBody = bodyFn(data);
|
|
541
|
-
const mjmlSource = wrapInMjml(resolvedBody);
|
|
542
|
-
const html = compileMjml(mjmlSource);
|
|
543
|
-
let text;
|
|
544
|
-
if (textBody) {
|
|
545
|
-
const textFn = Handlebars__default.default.compile(textBody, { strict: false });
|
|
546
|
-
text = textFn(data);
|
|
547
|
-
} else {
|
|
548
|
-
text = htmlToPlainText(html);
|
|
549
|
-
}
|
|
550
|
-
return { html, text, subject: resolvedSubject };
|
|
551
|
-
}
|
|
552
|
-
htmlToText(html) {
|
|
553
|
-
return htmlToPlainText(html);
|
|
554
|
-
}
|
|
555
|
-
extractVariables(template) {
|
|
556
|
-
const regex = /\{\{(?!#|\/|!|>)([^}]+)\}\}/g;
|
|
557
|
-
const variables = /* @__PURE__ */ new Set();
|
|
558
|
-
let match;
|
|
559
|
-
while ((match = regex.exec(template)) !== null) {
|
|
560
|
-
const variable = match[1].trim();
|
|
561
|
-
if (!variable.startsWith("else")) {
|
|
562
|
-
variables.add(variable);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
return Array.from(variables).sort();
|
|
566
|
-
}
|
|
567
|
-
validateTemplate(body) {
|
|
568
|
-
const errors = [];
|
|
569
|
-
try {
|
|
570
|
-
Handlebars__default.default.precompile(body);
|
|
571
|
-
} catch (e) {
|
|
572
|
-
errors.push(`Handlebars syntax error: ${e.message}`);
|
|
573
|
-
}
|
|
574
|
-
const mjmlSource = wrapInMjml(body);
|
|
575
|
-
try {
|
|
576
|
-
const result = mjml2html__default.default(mjmlSource, { validationLevel: "strict" });
|
|
577
|
-
if (result.errors && result.errors.length > 0) {
|
|
578
|
-
for (const err of result.errors) {
|
|
579
|
-
errors.push(`MJML error: ${err.message}`);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
} catch (e) {
|
|
583
|
-
errors.push(`MJML compilation error: ${e.message}`);
|
|
584
|
-
}
|
|
585
|
-
return { valid: errors.length === 0, errors };
|
|
586
|
-
}
|
|
587
|
-
};
|
|
588
|
-
var AlxEmailError = class extends core.AlxError {
|
|
589
|
-
constructor(message, code) {
|
|
590
|
-
super(message, code);
|
|
591
|
-
this.name = "AlxEmailError";
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
var ConfigValidationError = class extends AlxEmailError {
|
|
595
|
-
constructor(message, field) {
|
|
596
|
-
super(message, "CONFIG_VALIDATION");
|
|
597
|
-
this.field = field;
|
|
598
|
-
this.name = "ConfigValidationError";
|
|
599
|
-
}
|
|
600
|
-
};
|
|
601
|
-
var TemplateNotFoundError = class extends AlxEmailError {
|
|
602
|
-
constructor(templateId) {
|
|
603
|
-
super(`Template not found: ${templateId}`, "TEMPLATE_NOT_FOUND");
|
|
604
|
-
this.templateId = templateId;
|
|
605
|
-
this.name = "TemplateNotFoundError";
|
|
606
|
-
}
|
|
607
|
-
};
|
|
608
|
-
var TemplateSyntaxError = class extends AlxEmailError {
|
|
609
|
-
constructor(message, errors) {
|
|
610
|
-
super(message, "TEMPLATE_SYNTAX");
|
|
611
|
-
this.errors = errors;
|
|
612
|
-
this.name = "TemplateSyntaxError";
|
|
613
|
-
}
|
|
614
|
-
};
|
|
615
|
-
var RuleNotFoundError = class extends AlxEmailError {
|
|
616
|
-
constructor(ruleId) {
|
|
617
|
-
super(`Rule not found: ${ruleId}`, "RULE_NOT_FOUND");
|
|
618
|
-
this.ruleId = ruleId;
|
|
619
|
-
this.name = "RuleNotFoundError";
|
|
620
|
-
}
|
|
621
|
-
};
|
|
622
|
-
var RuleTemplateIncompatibleError = class extends AlxEmailError {
|
|
623
|
-
constructor(reason) {
|
|
624
|
-
super(`Rule-template incompatibility: ${reason}`, "RULE_TEMPLATE_INCOMPATIBLE");
|
|
625
|
-
this.reason = reason;
|
|
626
|
-
this.name = "RuleTemplateIncompatibleError";
|
|
627
|
-
}
|
|
628
|
-
};
|
|
629
|
-
var LockAcquisitionError = class extends AlxEmailError {
|
|
630
|
-
constructor() {
|
|
631
|
-
super("Could not acquire distributed lock \u2014 another run is in progress", "LOCK_ACQUISITION");
|
|
632
|
-
this.name = "LockAcquisitionError";
|
|
633
|
-
}
|
|
634
|
-
};
|
|
635
|
-
var DuplicateSlugError = class extends AlxEmailError {
|
|
636
|
-
constructor(slug) {
|
|
637
|
-
super(`Template with slug "${slug}" already exists`, "DUPLICATE_SLUG");
|
|
638
|
-
this.slug = slug;
|
|
639
|
-
this.name = "DuplicateSlugError";
|
|
640
|
-
}
|
|
641
|
-
};
|
|
642
|
-
|
|
643
|
-
// src/utils/query-helpers.ts
|
|
644
|
-
function isValidDateString(s) {
|
|
645
|
-
return s.trim() !== "" && !isNaN(new Date(s).getTime());
|
|
646
|
-
}
|
|
647
|
-
function buildDateRangeFilter(dateField, from, to) {
|
|
648
|
-
const validFrom = from && isValidDateString(from) ? from : void 0;
|
|
649
|
-
const validTo = to && isValidDateString(to) ? to : void 0;
|
|
650
|
-
if (!validFrom && !validTo) return {};
|
|
651
|
-
const filter = {};
|
|
652
|
-
filter[dateField] = {};
|
|
653
|
-
if (validFrom) filter[dateField].$gte = new Date(validFrom);
|
|
654
|
-
if (validTo) filter[dateField].$lte = /* @__PURE__ */ new Date(validTo + "T23:59:59.999Z");
|
|
655
|
-
return filter;
|
|
656
|
-
}
|
|
657
|
-
function calculatePagination(page, limit, maxLimit = 500) {
|
|
658
|
-
const rawPage = page != null && !isNaN(page) ? page : 1;
|
|
659
|
-
const rawLimit = limit != null && !isNaN(limit) ? limit : 200;
|
|
660
|
-
const p = Math.max(1, rawPage);
|
|
661
|
-
const l = Math.max(1, Math.min(rawLimit, maxLimit));
|
|
662
|
-
const skip = (p - 1) * l;
|
|
663
|
-
return { page: p, limit: l, skip };
|
|
664
|
-
}
|
|
665
|
-
function filterUpdateableFields(input, allowedFields) {
|
|
666
|
-
const result = {};
|
|
667
|
-
for (const [key, value] of Object.entries(input)) {
|
|
668
|
-
if (value !== void 0 && allowedFields.has(key)) {
|
|
669
|
-
result[key] = value;
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
return result;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// src/services/template.service.ts
|
|
676
|
-
function stripScriptTags(text) {
|
|
677
|
-
return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
|
678
|
-
}
|
|
679
|
-
var UPDATEABLE_FIELDS = /* @__PURE__ */ new Set([
|
|
680
|
-
"name",
|
|
681
|
-
"description",
|
|
682
|
-
"category",
|
|
683
|
-
"audience",
|
|
684
|
-
"platform",
|
|
685
|
-
"textBody",
|
|
686
|
-
"subjects",
|
|
687
|
-
"bodies",
|
|
688
|
-
"preheaders",
|
|
689
|
-
"variables",
|
|
690
|
-
"isActive",
|
|
691
|
-
"fields",
|
|
692
|
-
"attachments"
|
|
693
|
-
]);
|
|
694
|
-
function slugify(name) {
|
|
695
|
-
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
696
|
-
}
|
|
697
|
-
var TemplateService = class {
|
|
698
|
-
constructor(EmailTemplate, config, EmailRule) {
|
|
699
|
-
this.EmailTemplate = EmailTemplate;
|
|
700
|
-
this.config = config;
|
|
701
|
-
this.EmailRule = EmailRule;
|
|
702
|
-
}
|
|
703
|
-
renderService = new TemplateRenderService();
|
|
704
|
-
async list(filters) {
|
|
705
|
-
const query = {};
|
|
706
|
-
if (filters?.category) query["category"] = filters.category;
|
|
707
|
-
if (filters?.audience) query["audience"] = filters.audience;
|
|
708
|
-
if (filters?.platform) query["platform"] = filters.platform;
|
|
709
|
-
if (filters?.isActive !== void 0) query["isActive"] = filters.isActive;
|
|
710
|
-
const page = filters?.page ?? 1;
|
|
711
|
-
const limit = filters?.limit ?? 200;
|
|
712
|
-
const skip = (page - 1) * limit;
|
|
713
|
-
const [templates, total] = await Promise.all([
|
|
714
|
-
this.EmailTemplate.find(query).sort({ category: 1, name: 1 }).skip(skip).limit(limit),
|
|
715
|
-
this.EmailTemplate.countDocuments(query)
|
|
716
|
-
]);
|
|
717
|
-
return { templates, total };
|
|
718
|
-
}
|
|
719
|
-
async getById(id) {
|
|
720
|
-
return this.EmailTemplate.findById(id);
|
|
721
|
-
}
|
|
722
|
-
async getBySlug(slug) {
|
|
723
|
-
return this.EmailTemplate.findBySlug(slug);
|
|
724
|
-
}
|
|
725
|
-
async create(input) {
|
|
726
|
-
const slug = input.slug || slugify(input.name);
|
|
727
|
-
const existing = await this.EmailTemplate.findBySlug(slug);
|
|
728
|
-
if (existing) {
|
|
729
|
-
throw new DuplicateSlugError(slug);
|
|
730
|
-
}
|
|
731
|
-
const subjects = input.subjects.map((s) => stripScriptTags(s));
|
|
732
|
-
const bodies = input.bodies.map((b) => stripScriptTags(b));
|
|
733
|
-
if (subjects.length === 0) throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
|
|
734
|
-
if (bodies.length === 0) throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
|
|
735
|
-
for (const b of bodies) {
|
|
736
|
-
const validation = this.renderService.validateTemplate(b);
|
|
737
|
-
if (!validation.valid) {
|
|
738
|
-
throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
const allContent = [...subjects, ...bodies, ...input.preheaders || [], input.textBody || ""].join(" ");
|
|
742
|
-
const variables = input.variables || this.renderService.extractVariables(allContent);
|
|
743
|
-
return this.EmailTemplate.createTemplate({
|
|
744
|
-
...input,
|
|
745
|
-
slug,
|
|
746
|
-
subjects,
|
|
747
|
-
bodies,
|
|
748
|
-
variables
|
|
749
|
-
});
|
|
750
|
-
}
|
|
751
|
-
async update(id, input) {
|
|
752
|
-
const template = await this.EmailTemplate.findById(id);
|
|
753
|
-
if (!template) return null;
|
|
754
|
-
if (input.subjects) {
|
|
755
|
-
input.subjects = input.subjects.map((s) => stripScriptTags(s));
|
|
756
|
-
}
|
|
757
|
-
if (input.bodies) {
|
|
758
|
-
input.bodies = input.bodies.map((b) => stripScriptTags(b));
|
|
759
|
-
}
|
|
760
|
-
if (input.subjects && input.subjects.length === 0) {
|
|
761
|
-
throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
|
|
762
|
-
}
|
|
763
|
-
if (input.bodies && input.bodies.length === 0) {
|
|
764
|
-
throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
|
|
765
|
-
}
|
|
766
|
-
const bodiesToValidate = input.bodies || null;
|
|
767
|
-
if (bodiesToValidate) {
|
|
768
|
-
for (const b of bodiesToValidate) {
|
|
769
|
-
const validation = this.renderService.validateTemplate(b);
|
|
770
|
-
if (!validation.valid) {
|
|
771
|
-
throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
if (input.textBody || input.subjects || input.bodies || input.preheaders) {
|
|
776
|
-
const subjects = input.subjects ?? template.subjects;
|
|
777
|
-
const bodies = input.bodies ?? template.bodies;
|
|
778
|
-
const preheaders = input.preheaders ?? template.preheaders ?? [];
|
|
779
|
-
const textBody = input.textBody ?? template.textBody;
|
|
780
|
-
const allContent = [...subjects, ...bodies, ...preheaders, textBody || ""].join(" ");
|
|
781
|
-
input.variables = this.renderService.extractVariables(allContent);
|
|
782
|
-
}
|
|
783
|
-
const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS);
|
|
784
|
-
const update = { $set: setFields };
|
|
785
|
-
if (input.textBody || input.subjects || input.bodies || input.preheaders) {
|
|
786
|
-
update["$inc"] = { version: 1 };
|
|
787
|
-
}
|
|
788
|
-
return this.EmailTemplate.findByIdAndUpdate(
|
|
789
|
-
id,
|
|
790
|
-
update,
|
|
791
|
-
{ new: true }
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
async delete(id) {
|
|
795
|
-
if (this.EmailRule) {
|
|
796
|
-
const activeRules = await this.EmailRule.find({ templateId: id, isActive: true });
|
|
797
|
-
if (activeRules.length > 0) {
|
|
798
|
-
const names = activeRules.map((r) => r.name).join(", ");
|
|
799
|
-
throw new Error(`Cannot delete template: ${activeRules.length} active rule(s) reference it (${names}). Deactivate them first.`);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
const result = await this.EmailTemplate.findByIdAndDelete(id);
|
|
803
|
-
return result !== null;
|
|
804
|
-
}
|
|
805
|
-
async toggleActive(id) {
|
|
806
|
-
const template = await this.EmailTemplate.findById(id);
|
|
807
|
-
if (!template) return null;
|
|
808
|
-
template.isActive = !template.isActive;
|
|
809
|
-
await template.save();
|
|
810
|
-
return template;
|
|
811
|
-
}
|
|
812
|
-
_buildSampleData(variables, provided) {
|
|
813
|
-
const data = { ...provided };
|
|
814
|
-
for (const v of variables) {
|
|
815
|
-
if (!(v in data)) {
|
|
816
|
-
data[v] = `[${v}]`;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
return data;
|
|
820
|
-
}
|
|
821
|
-
async preview(id, sampleData) {
|
|
822
|
-
const template = await this.EmailTemplate.findById(id);
|
|
823
|
-
if (!template) return null;
|
|
824
|
-
const variables = template.variables ?? [];
|
|
825
|
-
const data = this._buildSampleData(variables, sampleData);
|
|
826
|
-
return this.renderService.renderPreview(
|
|
827
|
-
template.subjects[0],
|
|
828
|
-
template.bodies[0],
|
|
829
|
-
data,
|
|
830
|
-
template.textBody
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
async previewRaw(subject, body, sampleData, variables, textBody) {
|
|
834
|
-
const data = this._buildSampleData(variables ?? [], sampleData);
|
|
835
|
-
return this.renderService.renderPreview(subject, body, data, textBody);
|
|
836
|
-
}
|
|
837
|
-
async validate(body) {
|
|
838
|
-
const validation = this.renderService.validateTemplate(body);
|
|
839
|
-
const variables = this.renderService.extractVariables(body);
|
|
840
|
-
return { ...validation, variables };
|
|
841
|
-
}
|
|
842
|
-
async clone(sourceId, newName) {
|
|
843
|
-
const source = await this.EmailTemplate.findById(sourceId);
|
|
844
|
-
if (!source) throw new Error("Template not found");
|
|
845
|
-
const { _id, __v, createdAt, updatedAt, ...rest } = source.toObject();
|
|
846
|
-
rest.name = newName || `${rest.name} (copy)`;
|
|
847
|
-
rest.slug = `${rest.slug}-copy-${Date.now()}`;
|
|
848
|
-
rest.isActive = false;
|
|
849
|
-
return this.EmailTemplate.create(rest);
|
|
850
|
-
}
|
|
851
|
-
async sendTestEmail(id, testEmail, sampleData) {
|
|
852
|
-
if (!this.config.adapters.sendTestEmail) {
|
|
853
|
-
return { success: false, error: "Test email sending not configured" };
|
|
854
|
-
}
|
|
855
|
-
const template = await this.EmailTemplate.findById(id);
|
|
856
|
-
if (!template) {
|
|
857
|
-
return { success: false, error: "Template not found" };
|
|
858
|
-
}
|
|
859
|
-
const rendered = this.renderService.renderSingle(
|
|
860
|
-
template.subjects[0],
|
|
861
|
-
template.bodies[0],
|
|
862
|
-
sampleData,
|
|
863
|
-
template.textBody
|
|
864
|
-
);
|
|
865
|
-
try {
|
|
866
|
-
await this.config.adapters.sendTestEmail(
|
|
867
|
-
testEmail,
|
|
868
|
-
`[TEST] ${rendered.subject}`,
|
|
869
|
-
rendered.html,
|
|
870
|
-
rendered.text,
|
|
871
|
-
template.attachments || []
|
|
872
|
-
);
|
|
873
|
-
return { success: true };
|
|
874
|
-
} catch (error) {
|
|
875
|
-
return { success: false, error: error.message };
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
async previewWithRecipient(templateId, recipientData) {
|
|
879
|
-
const template = await this.EmailTemplate.findById(templateId);
|
|
880
|
-
if (!template) return null;
|
|
881
|
-
const variables = template.variables ?? [];
|
|
882
|
-
const data = this._buildSampleData(variables, { ...template.fields ?? {}, ...recipientData });
|
|
883
|
-
return this.renderService.renderPreview(
|
|
884
|
-
template.subjects[0],
|
|
885
|
-
template.bodies[0],
|
|
886
|
-
data,
|
|
887
|
-
template.textBody
|
|
888
|
-
);
|
|
889
|
-
}
|
|
890
|
-
};
|
|
891
|
-
|
|
892
|
-
// src/controllers/collection.controller.ts
|
|
893
|
-
function flattenFields(fields, prefix = "", parentIsArray = false) {
|
|
894
|
-
const result = [];
|
|
895
|
-
for (const field of fields) {
|
|
896
|
-
const path = prefix ? `${prefix}.${field.name}` : field.name;
|
|
897
|
-
const isArray = field.type === "array";
|
|
898
|
-
if (field.type === "object" && field.fields?.length) {
|
|
899
|
-
result.push({
|
|
900
|
-
path,
|
|
901
|
-
type: "object",
|
|
902
|
-
label: field.label,
|
|
903
|
-
description: field.description
|
|
904
|
-
});
|
|
905
|
-
result.push(...flattenFields(field.fields, path, false));
|
|
906
|
-
} else if (isArray && field.fields?.length) {
|
|
907
|
-
result.push({
|
|
908
|
-
path: `${path}[]`,
|
|
909
|
-
type: "array",
|
|
910
|
-
label: field.label,
|
|
911
|
-
description: field.description,
|
|
912
|
-
isArray: true
|
|
913
|
-
});
|
|
914
|
-
result.push(...flattenFields(field.fields, `${path}[]`, true));
|
|
915
|
-
} else {
|
|
916
|
-
result.push({
|
|
917
|
-
path,
|
|
918
|
-
type: field.type,
|
|
919
|
-
label: field.label,
|
|
920
|
-
description: field.description,
|
|
921
|
-
enumValues: field.enumValues,
|
|
922
|
-
isArray: parentIsArray || isArray
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
return result;
|
|
927
|
-
}
|
|
928
|
-
function createCollectionController(collections) {
|
|
929
|
-
return {
|
|
930
|
-
list(_req, res) {
|
|
931
|
-
const summary = collections.map((c) => ({
|
|
932
|
-
name: c.name,
|
|
933
|
-
label: c.label,
|
|
934
|
-
description: c.description,
|
|
935
|
-
identifierField: c.identifierField,
|
|
936
|
-
fieldCount: c.fields.length,
|
|
937
|
-
joinCount: c.joins?.length ?? 0
|
|
938
|
-
}));
|
|
939
|
-
res.json({ collections: summary });
|
|
940
|
-
},
|
|
941
|
-
getFields(req, res) {
|
|
942
|
-
const { name } = req.params;
|
|
943
|
-
const collection = collections.find((c) => c.name === name);
|
|
944
|
-
if (!collection) {
|
|
945
|
-
res.status(404).json({ error: `Collection "${name}" not found` });
|
|
946
|
-
return;
|
|
947
|
-
}
|
|
948
|
-
const fields = flattenFields(collection.fields);
|
|
949
|
-
if (collection.joins?.length) {
|
|
950
|
-
for (const join of collection.joins) {
|
|
951
|
-
const joinedCollection = collections.find((c) => c.name === join.from);
|
|
952
|
-
if (joinedCollection) {
|
|
953
|
-
const joinedFields = flattenFields(joinedCollection.fields, join.as);
|
|
954
|
-
fields.push(...joinedFields);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
res.json({
|
|
959
|
-
name: collection.name,
|
|
960
|
-
label: collection.label,
|
|
961
|
-
identifierField: collection.identifierField,
|
|
962
|
-
fields,
|
|
963
|
-
typeOperators: TYPE_OPERATORS
|
|
964
|
-
});
|
|
965
|
-
}
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// src/validation/condition.validator.ts
|
|
970
|
-
function validateConditions(conditions, collectionName, collections) {
|
|
971
|
-
if (!collectionName || collections.length === 0) return [];
|
|
972
|
-
const collection = collections.find((c) => c.name === collectionName);
|
|
973
|
-
if (!collection) return [];
|
|
974
|
-
const flatFields = flattenFields(collection.fields);
|
|
975
|
-
if (collection.joins?.length) {
|
|
976
|
-
for (const join of collection.joins) {
|
|
977
|
-
const joinedCollection = collections.find((c) => c.name === join.from);
|
|
978
|
-
if (joinedCollection) {
|
|
979
|
-
flatFields.push(...flattenFields(joinedCollection.fields, join.as));
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
const fieldMap = /* @__PURE__ */ new Map();
|
|
984
|
-
for (const f of flatFields) {
|
|
985
|
-
fieldMap.set(f.path, f);
|
|
986
|
-
}
|
|
987
|
-
const errors = [];
|
|
988
|
-
for (let i = 0; i < conditions.length; i++) {
|
|
989
|
-
const cond = conditions[i];
|
|
990
|
-
const fieldDef = fieldMap.get(cond.field);
|
|
991
|
-
if (!fieldDef) {
|
|
992
|
-
errors.push({
|
|
993
|
-
index: i,
|
|
994
|
-
field: cond.field,
|
|
995
|
-
message: `Field "${cond.field}" does not exist in collection "${collectionName}"`
|
|
996
|
-
});
|
|
997
|
-
continue;
|
|
998
|
-
}
|
|
999
|
-
const allowedOps = TYPE_OPERATORS[fieldDef.type];
|
|
1000
|
-
if (allowedOps && !allowedOps.includes(cond.operator)) {
|
|
1001
|
-
errors.push({
|
|
1002
|
-
index: i,
|
|
1003
|
-
field: cond.field,
|
|
1004
|
-
message: `Operator "${cond.operator}" is not valid for field type "${fieldDef.type}"`
|
|
1005
|
-
});
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
return errors;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// src/services/rule.service.ts
|
|
1012
|
-
function isQueryTarget(target) {
|
|
1013
|
-
return !target.mode || target.mode === "query";
|
|
1014
|
-
}
|
|
1015
|
-
var UPDATEABLE_FIELDS2 = /* @__PURE__ */ new Set([
|
|
1016
|
-
"name",
|
|
1017
|
-
"description",
|
|
1018
|
-
"sortOrder",
|
|
1019
|
-
"target",
|
|
1020
|
-
"templateId",
|
|
1021
|
-
"sendOnce",
|
|
1022
|
-
"resendAfterDays",
|
|
1023
|
-
"cooldownDays",
|
|
1024
|
-
"autoApprove",
|
|
1025
|
-
"maxPerRun",
|
|
1026
|
-
"bypassThrottle",
|
|
1027
|
-
"throttleOverride",
|
|
1028
|
-
"emailType",
|
|
1029
|
-
"validFrom",
|
|
1030
|
-
"validTill",
|
|
1031
|
-
"schedule"
|
|
1032
|
-
]);
|
|
1033
|
-
function validateRuleTemplateCompat(targetRole, targetPlatform, template) {
|
|
1034
|
-
const templateAudience = template.audience;
|
|
1035
|
-
const templatePlatform = template.platform;
|
|
1036
|
-
if (templateAudience !== "all") {
|
|
1037
|
-
if (targetRole === "all") {
|
|
1038
|
-
return `Template "${template.name}" targets ${templateAudience} only, but rule targets all users`;
|
|
1039
|
-
}
|
|
1040
|
-
if (targetRole !== templateAudience) {
|
|
1041
|
-
return `Template targets ${templateAudience}, but rule targets ${targetRole}`;
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
if (templatePlatform !== "both") {
|
|
1045
|
-
if (targetPlatform === "both") {
|
|
1046
|
-
return `Template is for ${templatePlatform} only, but rule targets all platforms`;
|
|
1047
|
-
}
|
|
1048
|
-
if (templatePlatform !== targetPlatform) {
|
|
1049
|
-
return `Template is for ${templatePlatform}, but rule targets ${targetPlatform}`;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
return null;
|
|
1053
|
-
}
|
|
1054
|
-
var RuleService = class {
|
|
1055
|
-
constructor(EmailRule, EmailTemplate, EmailRuleRunLog, config) {
|
|
1056
|
-
this.EmailRule = EmailRule;
|
|
1057
|
-
this.EmailTemplate = EmailTemplate;
|
|
1058
|
-
this.EmailRuleRunLog = EmailRuleRunLog;
|
|
1059
|
-
this.config = config;
|
|
1060
|
-
}
|
|
1061
|
-
async list(opts) {
|
|
1062
|
-
const page = opts?.page ?? 1;
|
|
1063
|
-
const limit = opts?.limit ?? 200;
|
|
1064
|
-
const skip = (page - 1) * limit;
|
|
1065
|
-
const [rules, total] = await Promise.all([
|
|
1066
|
-
this.EmailRule.find().populate("templateId", "name slug").sort({ sortOrder: 1, createdAt: -1 }).skip(skip).limit(limit),
|
|
1067
|
-
this.EmailRule.countDocuments()
|
|
1068
|
-
]);
|
|
1069
|
-
return { rules, total };
|
|
1070
|
-
}
|
|
1071
|
-
async getById(id) {
|
|
1072
|
-
return this.EmailRule.findById(id);
|
|
1073
|
-
}
|
|
1074
|
-
async create(input) {
|
|
1075
|
-
const template = await this.EmailTemplate.findById(input.templateId);
|
|
1076
|
-
if (!template) {
|
|
1077
|
-
throw new TemplateNotFoundError(input.templateId);
|
|
1078
|
-
}
|
|
1079
|
-
if (isQueryTarget(input.target)) {
|
|
1080
|
-
if (!input.target.role || !input.target.platform) {
|
|
1081
|
-
throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
|
|
1082
|
-
}
|
|
1083
|
-
const compatError = validateRuleTemplateCompat(
|
|
1084
|
-
input.target.role,
|
|
1085
|
-
input.target.platform,
|
|
1086
|
-
template
|
|
1087
|
-
);
|
|
1088
|
-
if (compatError) {
|
|
1089
|
-
throw new RuleTemplateIncompatibleError(compatError);
|
|
1090
|
-
}
|
|
1091
|
-
} else {
|
|
1092
|
-
if (!input.target.identifiers || input.target.identifiers.length === 0) {
|
|
1093
|
-
throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
if (isQueryTarget(input.target) && input.target.collectionName && this.config.collections?.length) {
|
|
1097
|
-
const condErrors = validateConditions(input.target.conditions, input.target.collectionName, this.config.collections);
|
|
1098
|
-
if (condErrors.length > 0) {
|
|
1099
|
-
throw new RuleTemplateIncompatibleError(
|
|
1100
|
-
`Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
|
|
1101
|
-
);
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
return this.EmailRule.createRule(input);
|
|
1105
|
-
}
|
|
1106
|
-
async update(id, input) {
|
|
1107
|
-
const rule = await this.EmailRule.findById(id);
|
|
1108
|
-
if (!rule) return null;
|
|
1109
|
-
const templateId = input.templateId ?? rule.templateId.toString();
|
|
1110
|
-
if (input.target) {
|
|
1111
|
-
if (isQueryTarget(input.target)) {
|
|
1112
|
-
if (!input.target.role || !input.target.platform) {
|
|
1113
|
-
throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
|
|
1114
|
-
}
|
|
1115
|
-
} else {
|
|
1116
|
-
if (!input.target.identifiers || input.target.identifiers.length === 0) {
|
|
1117
|
-
throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
const effectiveTarget = input.target ?? rule.target;
|
|
1122
|
-
if ((input.templateId || input.target) && isQueryTarget(effectiveTarget)) {
|
|
1123
|
-
const qt = effectiveTarget;
|
|
1124
|
-
const template = await this.EmailTemplate.findById(templateId);
|
|
1125
|
-
if (!template) {
|
|
1126
|
-
throw new TemplateNotFoundError(templateId);
|
|
1127
|
-
}
|
|
1128
|
-
const compatError = validateRuleTemplateCompat(qt.role, qt.platform, template);
|
|
1129
|
-
if (compatError) {
|
|
1130
|
-
throw new RuleTemplateIncompatibleError(compatError);
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
if (isQueryTarget(effectiveTarget)) {
|
|
1134
|
-
const qt = effectiveTarget;
|
|
1135
|
-
if (qt.collectionName && this.config.collections?.length) {
|
|
1136
|
-
const condErrors = validateConditions(qt.conditions || [], qt.collectionName, this.config.collections);
|
|
1137
|
-
if (condErrors.length > 0) {
|
|
1138
|
-
throw new RuleTemplateIncompatibleError(
|
|
1139
|
-
`Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
|
|
1140
|
-
);
|
|
1141
|
-
}
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS2);
|
|
1145
|
-
return this.EmailRule.findByIdAndUpdate(
|
|
1146
|
-
id,
|
|
1147
|
-
{ $set: setFields },
|
|
1148
|
-
{ new: true }
|
|
1149
|
-
);
|
|
1150
|
-
}
|
|
1151
|
-
async delete(id) {
|
|
1152
|
-
const rule = await this.EmailRule.findById(id);
|
|
1153
|
-
if (!rule) return { deleted: false };
|
|
1154
|
-
if (rule.totalSent > 0) {
|
|
1155
|
-
rule.isActive = false;
|
|
1156
|
-
await rule.save();
|
|
1157
|
-
return { deleted: false, disabled: true };
|
|
1158
|
-
}
|
|
1159
|
-
await this.EmailRule.findByIdAndDelete(id);
|
|
1160
|
-
return { deleted: true };
|
|
1161
|
-
}
|
|
1162
|
-
async toggleActive(id) {
|
|
1163
|
-
const rule = await this.EmailRule.findById(id);
|
|
1164
|
-
if (!rule) return null;
|
|
1165
|
-
if (!rule.isActive) {
|
|
1166
|
-
const template = await this.EmailTemplate.findById(rule.templateId);
|
|
1167
|
-
if (!template) {
|
|
1168
|
-
throw new TemplateNotFoundError(rule.templateId.toString());
|
|
1169
|
-
}
|
|
1170
|
-
if (!template.isActive) {
|
|
1171
|
-
throw new RuleTemplateIncompatibleError("Cannot activate rule: linked template is inactive");
|
|
1172
|
-
}
|
|
1173
|
-
}
|
|
1174
|
-
rule.isActive = !rule.isActive;
|
|
1175
|
-
await rule.save();
|
|
1176
|
-
return rule;
|
|
1177
|
-
}
|
|
1178
|
-
async dryRun(id) {
|
|
1179
|
-
const rule = await this.EmailRule.findById(id);
|
|
1180
|
-
if (!rule) {
|
|
1181
|
-
throw new RuleNotFoundError(id);
|
|
1182
|
-
}
|
|
1183
|
-
const defaultMaxPerRun = this.config.options?.defaultMaxPerRun;
|
|
1184
|
-
const effectiveLimit = rule.maxPerRun || defaultMaxPerRun || 500;
|
|
1185
|
-
const target = rule.target;
|
|
1186
|
-
if (target.mode === "list") {
|
|
1187
|
-
const identifiers = target.identifiers || [];
|
|
1188
|
-
const matchedCount2 = identifiers.length;
|
|
1189
|
-
const willProcess2 = Math.min(matchedCount2, effectiveLimit);
|
|
1190
|
-
const sample2 = identifiers.slice(0, 10).map((id2) => ({ email: id2 }));
|
|
1191
|
-
return { matchedCount: matchedCount2, effectiveLimit, willProcess: willProcess2, ruleId: id, sample: sample2 };
|
|
1192
|
-
}
|
|
1193
|
-
const queryTarget = rule.target;
|
|
1194
|
-
const collectionName = queryTarget.collectionName;
|
|
1195
|
-
const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
|
|
1196
|
-
const users = await this.config.adapters.queryUsers(rule.target, 5e4, collectionSchema ? { collectionSchema } : void 0);
|
|
1197
|
-
const matchedCount = users.length;
|
|
1198
|
-
const willProcess = Math.min(matchedCount, effectiveLimit);
|
|
1199
|
-
const sample = users.slice(0, 10).map((u) => ({
|
|
1200
|
-
email: u.email || "",
|
|
1201
|
-
name: u.name || u.firstName || "",
|
|
1202
|
-
...u._id ? { id: String(u._id) } : {}
|
|
1203
|
-
}));
|
|
1204
|
-
return { matchedCount, effectiveLimit, willProcess, ruleId: id, sample };
|
|
1205
|
-
}
|
|
1206
|
-
async clone(sourceId, newName) {
|
|
1207
|
-
const source = await this.EmailRule.findById(sourceId);
|
|
1208
|
-
if (!source) throw new Error("Rule not found");
|
|
1209
|
-
const { _id, __v, createdAt, updatedAt, ...rest } = source.toObject();
|
|
1210
|
-
rest.name = newName || `${rest.name} (copy)`;
|
|
1211
|
-
rest.isActive = false;
|
|
1212
|
-
rest.totalSent = 0;
|
|
1213
|
-
rest.totalSkipped = 0;
|
|
1214
|
-
rest.lastRunAt = void 0;
|
|
1215
|
-
rest.lastRunStats = void 0;
|
|
1216
|
-
return this.EmailRule.create(rest);
|
|
1217
|
-
}
|
|
1218
|
-
async getRunHistory(limit = 20, opts) {
|
|
1219
|
-
const filter = buildDateRangeFilter("runAt", opts?.from, opts?.to);
|
|
1220
|
-
const pagination = calculatePagination(opts?.page, limit);
|
|
1221
|
-
return this.EmailRuleRunLog.find(filter).sort({ runAt: -1 }).skip(pagination.skip).limit(pagination.limit);
|
|
1222
|
-
}
|
|
1223
|
-
async getRunHistoryCount(opts) {
|
|
1224
|
-
const filter = buildDateRangeFilter("runAt", opts?.from, opts?.to);
|
|
1225
|
-
return this.EmailRuleRunLog.countDocuments(filter);
|
|
1226
|
-
}
|
|
56
|
+
var DATE_FORMAT_OPTIONS = {
|
|
57
|
+
day: "numeric",
|
|
58
|
+
month: "short",
|
|
59
|
+
year: "numeric"
|
|
1227
60
|
};
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
second: "2-digit",
|
|
1241
|
-
hour12: false
|
|
1242
|
-
}).formatToParts(date);
|
|
1243
|
-
const get = (type) => parts.find((p) => p.type === type)?.value || "0";
|
|
1244
|
-
return /* @__PURE__ */ new Date(`${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}`);
|
|
1245
|
-
}
|
|
1246
|
-
async function processInChunks(items, fn, chunkSize) {
|
|
1247
|
-
const results = [];
|
|
1248
|
-
for (let i = 0; i < items.length; i += chunkSize) {
|
|
1249
|
-
const chunk = items.slice(i, i + chunkSize);
|
|
1250
|
-
const chunkResults = await Promise.all(chunk.map(fn));
|
|
1251
|
-
results.push(...chunkResults);
|
|
1252
|
-
}
|
|
1253
|
-
return results;
|
|
61
|
+
function registerEmailHelpers() {
|
|
62
|
+
Handlebars__default.default.registerHelper("currency", (val) => {
|
|
63
|
+
const num = Number(val);
|
|
64
|
+
if (isNaN(num)) return String(val ?? "");
|
|
65
|
+
return `\u20B9${num.toLocaleString("en-IN")}`;
|
|
66
|
+
});
|
|
67
|
+
Handlebars__default.default.registerHelper("formatDate", (date) => {
|
|
68
|
+
if (!date) return "";
|
|
69
|
+
const d = new Date(date);
|
|
70
|
+
if (isNaN(d.getTime())) return String(date);
|
|
71
|
+
return d.toLocaleDateString("en-IN", DATE_FORMAT_OPTIONS);
|
|
72
|
+
});
|
|
1254
73
|
}
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
redis;
|
|
1278
|
-
keyPrefix;
|
|
1279
|
-
async runAllRules(triggeredBy = RUN_TRIGGER.Cron, runId) {
|
|
1280
|
-
if (!runId) runId = crypto__default.default.randomUUID();
|
|
1281
|
-
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1282
|
-
if (this.config.options?.sendWindow) {
|
|
1283
|
-
const { startHour, endHour, timezone } = this.config.options.sendWindow;
|
|
1284
|
-
const now = /* @__PURE__ */ new Date();
|
|
1285
|
-
const formatter = new Intl.DateTimeFormat("en-US", { hour: "numeric", hour12: false, timeZone: timezone });
|
|
1286
|
-
const currentHour = parseInt(formatter.format(now), 10);
|
|
1287
|
-
if (currentHour < startHour || currentHour >= endHour) {
|
|
1288
|
-
this.logger.info("Outside send window, skipping run", { currentHour, startHour, endHour, timezone });
|
|
1289
|
-
return { runId };
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
const lockAcquired = await this.lock.acquire();
|
|
1293
|
-
if (!lockAcquired) {
|
|
1294
|
-
this.logger.warn("Rule runner already executing, skipping");
|
|
1295
|
-
await this.updateRunProgress(runId, { status: "failed", currentRule: "Another run is already in progress" });
|
|
1296
|
-
return { runId };
|
|
1297
|
-
}
|
|
1298
|
-
const runStartTime = Date.now();
|
|
1299
|
-
await this.updateRunProgress(runId, {
|
|
1300
|
-
runId,
|
|
1301
|
-
status: "running",
|
|
1302
|
-
currentRule: "",
|
|
1303
|
-
progress: { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 },
|
|
1304
|
-
startedAt,
|
|
1305
|
-
elapsed: 0
|
|
1306
|
-
});
|
|
1307
|
-
let runStatus = "completed";
|
|
1308
|
-
try {
|
|
1309
|
-
const throttleConfig = await this.EmailThrottleConfig.getConfig();
|
|
1310
|
-
const allActiveRules = await this.EmailRule.findActive();
|
|
1311
|
-
const now = /* @__PURE__ */ new Date();
|
|
1312
|
-
const tz = this.config.options?.sendWindow?.timezone;
|
|
1313
|
-
const activeRules = allActiveRules.filter((rule) => {
|
|
1314
|
-
if (rule.validFrom) {
|
|
1315
|
-
const localNow = getLocalDate(now, tz);
|
|
1316
|
-
const localValidFrom = getLocalDate(new Date(rule.validFrom), tz);
|
|
1317
|
-
if (localNow < localValidFrom) return false;
|
|
1318
|
-
}
|
|
1319
|
-
if (rule.validTill) {
|
|
1320
|
-
const localNow = getLocalDate(now, tz);
|
|
1321
|
-
const localValidTill = getLocalDate(new Date(rule.validTill), tz);
|
|
1322
|
-
if (localNow > localValidTill) return false;
|
|
1323
|
-
}
|
|
1324
|
-
return true;
|
|
1325
|
-
});
|
|
1326
|
-
this.config.hooks?.onRunStart?.({ rulesCount: activeRules.length, triggeredBy, runId });
|
|
1327
|
-
await this.updateRunProgress(runId, {
|
|
1328
|
-
progress: { rulesTotal: activeRules.length, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 }
|
|
1329
|
-
});
|
|
1330
|
-
if (activeRules.length === 0) {
|
|
1331
|
-
this.logger.info("No active rules to process");
|
|
1332
|
-
await this.EmailRuleRunLog.create({
|
|
1333
|
-
runId,
|
|
1334
|
-
runAt: /* @__PURE__ */ new Date(),
|
|
1335
|
-
triggeredBy,
|
|
1336
|
-
duration: Date.now() - runStartTime,
|
|
1337
|
-
rulesProcessed: 0,
|
|
1338
|
-
totalStats: { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errorCount: 0 },
|
|
1339
|
-
perRuleStats: [],
|
|
1340
|
-
status: "completed"
|
|
1341
|
-
});
|
|
1342
|
-
await this.updateRunProgress(runId, { status: "completed", elapsed: Date.now() - runStartTime });
|
|
1343
|
-
return { runId };
|
|
1344
|
-
}
|
|
1345
|
-
const templateIds = [...new Set(activeRules.map((r) => r.templateId.toString()))];
|
|
1346
|
-
const templates = await this.EmailTemplate.find({ _id: { $in: templateIds } }).lean();
|
|
1347
|
-
const templateMap = /* @__PURE__ */ new Map();
|
|
1348
|
-
for (const t of templates) {
|
|
1349
|
-
templateMap.set(t._id.toString(), t);
|
|
1350
|
-
}
|
|
1351
|
-
const recentSends = await this.EmailRuleSend.find({
|
|
1352
|
-
sentAt: { $gte: new Date(Date.now() - 7 * MS_PER_DAY) }
|
|
1353
|
-
}).lean();
|
|
1354
|
-
const throttleMap = this.buildThrottleMap(recentSends);
|
|
1355
|
-
const perRuleStats = [];
|
|
1356
|
-
let totalSent = 0;
|
|
1357
|
-
let totalFailed = 0;
|
|
1358
|
-
let totalSkipped = 0;
|
|
1359
|
-
let totalInvalid = 0;
|
|
1360
|
-
for (let ri = 0; ri < activeRules.length; ri++) {
|
|
1361
|
-
const rule = activeRules[ri];
|
|
1362
|
-
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1363
|
-
const cancelled = await this.redis.exists(cancelKey);
|
|
1364
|
-
if (cancelled) {
|
|
1365
|
-
runStatus = "cancelled";
|
|
1366
|
-
break;
|
|
1367
|
-
}
|
|
1368
|
-
await this.updateRunProgress(runId, {
|
|
1369
|
-
currentRule: rule.name,
|
|
1370
|
-
elapsed: Date.now() - runStartTime
|
|
1371
|
-
});
|
|
1372
|
-
const stats = await this.executeRule(rule, throttleMap, throttleConfig, templateMap, runId);
|
|
1373
|
-
totalSent += stats.sent;
|
|
1374
|
-
totalFailed += stats.errorCount;
|
|
1375
|
-
totalSkipped += stats.skipped + stats.skippedByThrottle;
|
|
1376
|
-
totalInvalid += stats.matched - stats.sent - stats.skipped - stats.skippedByThrottle - stats.errorCount;
|
|
1377
|
-
perRuleStats.push({
|
|
1378
|
-
ruleId: rule._id.toString(),
|
|
1379
|
-
ruleName: rule.name,
|
|
1380
|
-
...stats
|
|
1381
|
-
});
|
|
1382
|
-
await this.updateRunProgress(runId, {
|
|
1383
|
-
progress: {
|
|
1384
|
-
rulesTotal: activeRules.length,
|
|
1385
|
-
rulesCompleted: ri + 1,
|
|
1386
|
-
sent: totalSent,
|
|
1387
|
-
failed: totalFailed,
|
|
1388
|
-
skipped: totalSkipped,
|
|
1389
|
-
invalid: totalInvalid < 0 ? 0 : totalInvalid
|
|
1390
|
-
},
|
|
1391
|
-
elapsed: Date.now() - runStartTime
|
|
1392
|
-
});
|
|
1393
|
-
}
|
|
1394
|
-
const totalStats = perRuleStats.reduce(
|
|
1395
|
-
(acc, s) => ({
|
|
1396
|
-
matched: acc.matched + s.matched,
|
|
1397
|
-
sent: acc.sent + s.sent,
|
|
1398
|
-
skipped: acc.skipped + s.skipped,
|
|
1399
|
-
skippedByThrottle: acc.skippedByThrottle + s.skippedByThrottle,
|
|
1400
|
-
errorCount: acc.errorCount + s.errorCount
|
|
1401
|
-
}),
|
|
1402
|
-
{ matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errorCount: 0 }
|
|
1403
|
-
);
|
|
1404
|
-
await this.EmailRuleRunLog.create({
|
|
1405
|
-
runId,
|
|
1406
|
-
runAt: /* @__PURE__ */ new Date(),
|
|
1407
|
-
triggeredBy,
|
|
1408
|
-
duration: Date.now() - runStartTime,
|
|
1409
|
-
rulesProcessed: activeRules.length,
|
|
1410
|
-
totalStats,
|
|
1411
|
-
perRuleStats,
|
|
1412
|
-
status: runStatus
|
|
1413
|
-
});
|
|
1414
|
-
await this.updateRunProgress(runId, { status: runStatus, currentRule: "", elapsed: Date.now() - runStartTime });
|
|
1415
|
-
this.config.hooks?.onRunComplete?.({ duration: Date.now() - runStartTime, totalStats, perRuleStats, runId });
|
|
1416
|
-
this.logger.info("Rule run completed", {
|
|
1417
|
-
triggeredBy,
|
|
1418
|
-
rulesProcessed: activeRules.length,
|
|
1419
|
-
totalSent: totalStats.sent,
|
|
1420
|
-
totalSkipped: totalStats.skipped,
|
|
1421
|
-
duration: Date.now() - runStartTime
|
|
1422
|
-
});
|
|
1423
|
-
} catch (err) {
|
|
1424
|
-
runStatus = "failed";
|
|
1425
|
-
await this.updateRunProgress(runId, { status: "failed", elapsed: Date.now() - runStartTime });
|
|
1426
|
-
throw err;
|
|
1427
|
-
} finally {
|
|
1428
|
-
await this.lock.release();
|
|
1429
|
-
}
|
|
1430
|
-
return { runId };
|
|
1431
|
-
}
|
|
1432
|
-
async executeRule(rule, throttleMap, throttleConfig, templateMap, runId) {
|
|
1433
|
-
const stats = { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errorCount: 0 };
|
|
1434
|
-
const template = templateMap?.get(rule.templateId.toString()) ?? await this.EmailTemplate.findById(rule.templateId);
|
|
1435
|
-
if (!template) {
|
|
1436
|
-
this.logger.error(`Rule "${rule.name}": template ${rule.templateId} not found`);
|
|
1437
|
-
stats.errorCount = 1;
|
|
1438
|
-
return stats;
|
|
1439
|
-
}
|
|
1440
|
-
const isListMode = rule.target?.mode === "list";
|
|
1441
|
-
if (isListMode) {
|
|
1442
|
-
return this.executeListMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1443
|
-
}
|
|
1444
|
-
return this.executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1445
|
-
}
|
|
1446
|
-
emitSendEvent(rule, email, status, templateId, runId, opts) {
|
|
1447
|
-
this.config.hooks?.onSend?.({
|
|
1448
|
-
ruleId: rule._id.toString(),
|
|
1449
|
-
ruleName: rule.name,
|
|
1450
|
-
email,
|
|
1451
|
-
status,
|
|
1452
|
-
accountId: opts?.accountId ?? "",
|
|
1453
|
-
templateId,
|
|
1454
|
-
runId: runId || "",
|
|
1455
|
-
subjectIndex: opts?.subjectIndex ?? -1,
|
|
1456
|
-
bodyIndex: opts?.bodyIndex ?? -1,
|
|
1457
|
-
preheaderIndex: opts?.preheaderIndex,
|
|
1458
|
-
failureReason: opts?.failureReason
|
|
1459
|
-
});
|
|
1460
|
-
}
|
|
1461
|
-
async processSingleUser(params) {
|
|
1462
|
-
const { rule, email, userKey, identifier, user, sendMap, throttleMap, throttleConfig, template, compiledVariants, templateId, ruleId, runId, stats } = params;
|
|
1463
|
-
const lastSend = sendMap.get(userKey);
|
|
1464
|
-
if (lastSend) {
|
|
1465
|
-
if (rule.sendOnce && rule.resendAfterDays == null) {
|
|
1466
|
-
stats.skipped++;
|
|
1467
|
-
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "send once" });
|
|
1468
|
-
return "skipped";
|
|
1469
|
-
}
|
|
1470
|
-
if (rule.resendAfterDays != null) {
|
|
1471
|
-
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1472
|
-
if (daysSince < rule.resendAfterDays) {
|
|
1473
|
-
stats.skipped++;
|
|
1474
|
-
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "resend too soon" });
|
|
1475
|
-
return "skipped";
|
|
1476
|
-
}
|
|
1477
|
-
} else {
|
|
1478
|
-
stats.skipped++;
|
|
1479
|
-
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "send once" });
|
|
1480
|
-
return "skipped";
|
|
1481
|
-
}
|
|
1482
|
-
if (rule.cooldownDays) {
|
|
1483
|
-
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1484
|
-
if (daysSince < rule.cooldownDays) {
|
|
1485
|
-
stats.skipped++;
|
|
1486
|
-
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "cooldown period" });
|
|
1487
|
-
return "skipped";
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
if (!this.checkThrottle(rule, userKey, email, throttleMap, throttleConfig, stats, templateId, runId)) return "skipped";
|
|
1492
|
-
const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
|
|
1493
|
-
if (!agentSelection) {
|
|
1494
|
-
stats.skipped++;
|
|
1495
|
-
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "no account available" });
|
|
1496
|
-
return "skipped";
|
|
1497
|
-
}
|
|
1498
|
-
const resolvedData = this.config.adapters.resolveData(user);
|
|
1499
|
-
const templateData = { ...template.fields || {}, ...resolvedData };
|
|
1500
|
-
const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
|
|
1501
|
-
const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
|
|
1502
|
-
const renderedSubject = compiledVariants.subjectFns[si](templateData);
|
|
1503
|
-
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1504
|
-
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1505
|
-
let finalHtml = renderedHtml;
|
|
1506
|
-
let finalText = renderedText;
|
|
1507
|
-
let finalSubject = renderedSubject;
|
|
1508
|
-
let pi;
|
|
1509
|
-
if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
|
|
1510
|
-
pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
|
|
1511
|
-
const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
|
|
1512
|
-
if (renderedPreheader) {
|
|
1513
|
-
const preheaderHtml = `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">${renderedPreheader}</div>`;
|
|
1514
|
-
finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
|
|
1515
|
-
}
|
|
1516
|
-
}
|
|
1517
|
-
if (this.config.hooks?.beforeSend) {
|
|
1518
|
-
try {
|
|
1519
|
-
const modified = await this.config.hooks.beforeSend({
|
|
1520
|
-
htmlBody: finalHtml,
|
|
1521
|
-
textBody: finalText,
|
|
1522
|
-
subject: finalSubject,
|
|
1523
|
-
account: {
|
|
1524
|
-
id: agentSelection.accountId,
|
|
1525
|
-
email: agentSelection.email,
|
|
1526
|
-
metadata: agentSelection.metadata
|
|
1527
|
-
},
|
|
1528
|
-
user: {
|
|
1529
|
-
id: String(userKey),
|
|
1530
|
-
email,
|
|
1531
|
-
name: String(user.name || user.firstName || "")
|
|
1532
|
-
},
|
|
1533
|
-
context: {
|
|
1534
|
-
ruleId,
|
|
1535
|
-
templateId,
|
|
1536
|
-
runId: runId || ""
|
|
1537
|
-
}
|
|
74
|
+
function createEmailRuleEngine(config) {
|
|
75
|
+
registerEmailHelpers();
|
|
76
|
+
const coreConfig = {
|
|
77
|
+
...config,
|
|
78
|
+
adapters: {
|
|
79
|
+
queryUsers: config.adapters.queryUsers,
|
|
80
|
+
resolveData: config.adapters.resolveData,
|
|
81
|
+
selectAgent: config.adapters.selectAgent,
|
|
82
|
+
findIdentifier: config.adapters.findIdentifier,
|
|
83
|
+
send: async (params) => {
|
|
84
|
+
const html = renderMjml(params.body);
|
|
85
|
+
const text = params.textBody || htmlToPlainText(html);
|
|
86
|
+
await config.adapters.sendEmail({
|
|
87
|
+
identifierId: params.identifierId,
|
|
88
|
+
contactId: params.contactId,
|
|
89
|
+
accountId: params.accountId,
|
|
90
|
+
subject: params.subject || "",
|
|
91
|
+
htmlBody: html,
|
|
92
|
+
textBody: text,
|
|
93
|
+
ruleId: params.ruleId,
|
|
94
|
+
autoApprove: params.autoApprove,
|
|
95
|
+
attachments: params.metadata?.attachments ?? void 0
|
|
1538
96
|
});
|
|
1539
|
-
finalHtml = modified.htmlBody;
|
|
1540
|
-
finalText = modified.textBody;
|
|
1541
|
-
finalSubject = modified.subject;
|
|
1542
|
-
} catch (hookErr) {
|
|
1543
|
-
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1544
|
-
stats.errorCount++;
|
|
1545
|
-
this.emitSendEvent(rule, email, "error", templateId, runId || "", { accountId: agentSelection.accountId, subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
|
|
1546
|
-
return "error";
|
|
1547
|
-
}
|
|
1548
|
-
}
|
|
1549
|
-
await this.config.adapters.sendEmail({
|
|
1550
|
-
identifierId: identifier.id,
|
|
1551
|
-
contactId: identifier.contactId,
|
|
1552
|
-
accountId: agentSelection.accountId,
|
|
1553
|
-
subject: finalSubject,
|
|
1554
|
-
htmlBody: finalHtml,
|
|
1555
|
-
textBody: finalText,
|
|
1556
|
-
ruleId,
|
|
1557
|
-
autoApprove: rule.autoApprove ?? true,
|
|
1558
|
-
attachments: template.attachments || []
|
|
1559
|
-
});
|
|
1560
|
-
await this.EmailRuleSend.logSend(
|
|
1561
|
-
ruleId,
|
|
1562
|
-
userKey,
|
|
1563
|
-
identifier.id,
|
|
1564
|
-
void 0,
|
|
1565
|
-
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
|
|
1566
|
-
);
|
|
1567
|
-
const current = throttleMap.get(userKey) || { today: 0, thisWeek: 0};
|
|
1568
|
-
throttleMap.set(userKey, {
|
|
1569
|
-
today: current.today + 1,
|
|
1570
|
-
thisWeek: current.thisWeek + 1,
|
|
1571
|
-
lastSentDate: /* @__PURE__ */ new Date()
|
|
1572
|
-
});
|
|
1573
|
-
stats.sent++;
|
|
1574
|
-
this.emitSendEvent(rule, email, "sent", templateId, runId || "", { accountId: agentSelection.accountId, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi });
|
|
1575
|
-
return "sent";
|
|
1576
|
-
}
|
|
1577
|
-
async resolveIdentifiers(emails) {
|
|
1578
|
-
const identifierResults = await processInChunks(
|
|
1579
|
-
emails,
|
|
1580
|
-
async (email) => {
|
|
1581
|
-
const result = await this.config.adapters.findIdentifier(email);
|
|
1582
|
-
return result ? { email, ...result } : null;
|
|
1583
97
|
},
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
map.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
return map;
|
|
1593
|
-
}
|
|
1594
|
-
buildSendMap(sends) {
|
|
1595
|
-
const map = /* @__PURE__ */ new Map();
|
|
1596
|
-
for (const send of sends) {
|
|
1597
|
-
const uid = send.userId.toString();
|
|
1598
|
-
if (!map.has(uid)) {
|
|
1599
|
-
map.set(uid, send);
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
return map;
|
|
1603
|
-
}
|
|
1604
|
-
compileTemplateVariants(template) {
|
|
1605
|
-
const preheaders = template.preheaders || [];
|
|
1606
|
-
return this.templateRenderer.compileBatchVariants(
|
|
1607
|
-
template.subjects,
|
|
1608
|
-
template.bodies,
|
|
1609
|
-
template.textBody,
|
|
1610
|
-
preheaders
|
|
1611
|
-
);
|
|
1612
|
-
}
|
|
1613
|
-
async checkCancelled(runId, index) {
|
|
1614
|
-
if (!runId || index % 10 !== 0) return false;
|
|
1615
|
-
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1616
|
-
return !!await this.redis.exists(cancelKey);
|
|
1617
|
-
}
|
|
1618
|
-
async applySendDelay(isLast) {
|
|
1619
|
-
if (isLast) return;
|
|
1620
|
-
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1621
|
-
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1622
|
-
if (delayMs > 0 || jitterMs > 0) {
|
|
1623
|
-
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1624
|
-
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
98
|
+
sendTest: config.adapters.sendTestEmail ? async (to, body, subject, metadata) => {
|
|
99
|
+
const html = renderMjml(body);
|
|
100
|
+
const text = htmlToPlainText(html);
|
|
101
|
+
await config.adapters.sendTestEmail(to, subject || "", html, text, metadata?.attachments ?? void 0);
|
|
102
|
+
} : void 0
|
|
1625
103
|
}
|
|
1626
|
-
}
|
|
1627
|
-
async finalizeRuleStats(rule, stats, ruleId, templateId, runId) {
|
|
1628
|
-
await this.EmailRule.findByIdAndUpdate(rule._id, {
|
|
1629
|
-
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1630
|
-
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1631
|
-
});
|
|
1632
|
-
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
|
|
1633
|
-
}
|
|
1634
|
-
async executeListMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
1635
|
-
const rawIdentifiers = rule.target.identifiers || [];
|
|
1636
|
-
const uniqueEmails = [...new Set(rawIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean))];
|
|
1637
|
-
const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
|
|
1638
|
-
if (uniqueEmails.length > limit) {
|
|
1639
|
-
this.logger.warn(`Rule "${rule.name}" matched ${uniqueEmails.length} users but maxPerRun is ${limit} \u2014 only ${limit} will be processed`, { ruleId: rule._id.toString(), matchedCount: uniqueEmails.length, maxPerRun: limit });
|
|
1640
|
-
}
|
|
1641
|
-
const emailsToProcess = uniqueEmails.slice(0, limit);
|
|
1642
|
-
stats.matched = emailsToProcess.length;
|
|
1643
|
-
const ruleId = rule._id.toString();
|
|
1644
|
-
const templateId = rule.templateId.toString();
|
|
1645
|
-
this.config.hooks?.onRuleStart?.({ ruleId, ruleName: rule.name, matchedCount: emailsToProcess.length, templateId, runId: runId || "" });
|
|
1646
|
-
if (emailsToProcess.length === 0) return stats;
|
|
1647
|
-
const identifierMap = await this.resolveIdentifiers(emailsToProcess);
|
|
1648
|
-
const validEmails = emailsToProcess.filter((e) => identifierMap.has(e));
|
|
1649
|
-
const identifierIds = validEmails.map((e) => identifierMap.get(e).id);
|
|
1650
|
-
const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: identifierIds } }).sort({ sentAt: -1 }).lean();
|
|
1651
|
-
const sendMap = this.buildSendMap(allRuleSends);
|
|
1652
|
-
const compiledVariants = this.compileTemplateVariants(template);
|
|
1653
|
-
let totalProcessed = 0;
|
|
1654
|
-
for (let i = 0; i < emailsToProcess.length; i++) {
|
|
1655
|
-
const email = emailsToProcess[i];
|
|
1656
|
-
if (await this.checkCancelled(runId, i)) break;
|
|
1657
|
-
try {
|
|
1658
|
-
const identifier = identifierMap.get(email);
|
|
1659
|
-
if (!identifier) {
|
|
1660
|
-
stats.skipped++;
|
|
1661
|
-
this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1662
|
-
continue;
|
|
1663
|
-
}
|
|
1664
|
-
const result = await this.processSingleUser({
|
|
1665
|
-
rule,
|
|
1666
|
-
email,
|
|
1667
|
-
userKey: identifier.id,
|
|
1668
|
-
identifier,
|
|
1669
|
-
user: { _id: identifier.id, email },
|
|
1670
|
-
sendMap,
|
|
1671
|
-
throttleMap,
|
|
1672
|
-
throttleConfig,
|
|
1673
|
-
template,
|
|
1674
|
-
compiledVariants,
|
|
1675
|
-
templateId,
|
|
1676
|
-
ruleId,
|
|
1677
|
-
runId,
|
|
1678
|
-
stats
|
|
1679
|
-
});
|
|
1680
|
-
if (result === "sent") {
|
|
1681
|
-
totalProcessed++;
|
|
1682
|
-
if (runId && totalProcessed % 10 === 0) await this.updateRunSendProgress(runId, stats);
|
|
1683
|
-
await this.applySendDelay(i >= emailsToProcess.length - 1);
|
|
1684
|
-
}
|
|
1685
|
-
} catch (err) {
|
|
1686
|
-
stats.errorCount++;
|
|
1687
|
-
this.emitSendEvent(rule, email, "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
|
|
1688
|
-
this.logger.error(`Rule "${rule.name}" failed for identifier ${email}`, { error: err });
|
|
1689
|
-
}
|
|
1690
|
-
}
|
|
1691
|
-
await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
|
|
1692
|
-
if (rule.sendOnce) {
|
|
1693
|
-
const allIdentifiers = rule.target.identifiers || [];
|
|
1694
|
-
const totalIdentifiers = new Set(allIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean)).size;
|
|
1695
|
-
const sends = await this.EmailRuleSend.find({
|
|
1696
|
-
ruleId: rule._id
|
|
1697
|
-
}).lean();
|
|
1698
|
-
const sentOrProcessedIds = new Set(
|
|
1699
|
-
sends.filter((s) => s.status !== "throttled").map((s) => String(s.userId || s.emailIdentifierId))
|
|
1700
|
-
);
|
|
1701
|
-
const throttledCount = sends.filter((s) => s.status === "throttled").length;
|
|
1702
|
-
if (sentOrProcessedIds.size >= totalIdentifiers && throttledCount === 0) {
|
|
1703
|
-
await this.EmailRule.findByIdAndUpdate(rule._id, { $set: { isActive: false } });
|
|
1704
|
-
this.logger.info(`Rule '${rule.name}' auto-disabled \u2014 all identifiers processed`);
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
return stats;
|
|
1708
|
-
}
|
|
1709
|
-
async executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
1710
|
-
const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
|
|
1711
|
-
let users;
|
|
1712
|
-
try {
|
|
1713
|
-
const collectionName = rule.target?.collectionName;
|
|
1714
|
-
const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
|
|
1715
|
-
users = await this.config.adapters.queryUsers(rule.target, limit, collectionSchema ? { collectionSchema } : void 0);
|
|
1716
|
-
} catch (err) {
|
|
1717
|
-
this.logger.error(`Rule "${rule.name}": query failed`, { error: err });
|
|
1718
|
-
stats.errorCount = 1;
|
|
1719
|
-
return stats;
|
|
1720
|
-
}
|
|
1721
|
-
if (users.length > limit) {
|
|
1722
|
-
this.logger.warn(`Rule "${rule.name}" matched ${users.length} users but maxPerRun is ${limit} \u2014 only ${limit} will be processed`, { ruleId: rule._id.toString(), matchedCount: users.length, maxPerRun: limit });
|
|
1723
|
-
}
|
|
1724
|
-
stats.matched = users.length;
|
|
1725
|
-
this.config.hooks?.onRuleStart?.({ ruleId: rule._id.toString(), ruleName: rule.name, matchedCount: users.length, templateId: rule.templateId.toString(), runId: runId || "" });
|
|
1726
|
-
if (users.length === 0) return stats;
|
|
1727
|
-
const userIds = users.map((u) => u._id?.toString()).filter(Boolean);
|
|
1728
|
-
const emails = users.map((u) => u.email).filter(Boolean);
|
|
1729
|
-
const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: userIds } }).sort({ sentAt: -1 }).lean();
|
|
1730
|
-
const sendMap = this.buildSendMap(allRuleSends);
|
|
1731
|
-
const uniqueEmails = [...new Set(emails.map((e) => e.toLowerCase().trim()))];
|
|
1732
|
-
const identifierMap = await this.resolveIdentifiers(uniqueEmails);
|
|
1733
|
-
const compiledVariants = this.compileTemplateVariants(template);
|
|
1734
|
-
const ruleId = rule._id.toString();
|
|
1735
|
-
const templateId = rule.templateId.toString();
|
|
1736
|
-
let totalProcessed = 0;
|
|
1737
|
-
for (let i = 0; i < users.length; i++) {
|
|
1738
|
-
const user = users[i];
|
|
1739
|
-
if (await this.checkCancelled(runId, i)) break;
|
|
1740
|
-
try {
|
|
1741
|
-
const userId = user._id?.toString();
|
|
1742
|
-
const email = user.email;
|
|
1743
|
-
if (!userId || !email) {
|
|
1744
|
-
stats.skipped++;
|
|
1745
|
-
this.emitSendEvent(rule, email || "unknown", "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1746
|
-
continue;
|
|
1747
|
-
}
|
|
1748
|
-
const identifier = identifierMap.get(email.toLowerCase().trim());
|
|
1749
|
-
if (!identifier) {
|
|
1750
|
-
stats.skipped++;
|
|
1751
|
-
this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1752
|
-
continue;
|
|
1753
|
-
}
|
|
1754
|
-
const result = await this.processSingleUser({
|
|
1755
|
-
rule,
|
|
1756
|
-
email,
|
|
1757
|
-
userKey: userId,
|
|
1758
|
-
identifier,
|
|
1759
|
-
user,
|
|
1760
|
-
sendMap,
|
|
1761
|
-
throttleMap,
|
|
1762
|
-
throttleConfig,
|
|
1763
|
-
template,
|
|
1764
|
-
compiledVariants,
|
|
1765
|
-
templateId,
|
|
1766
|
-
ruleId,
|
|
1767
|
-
runId,
|
|
1768
|
-
stats
|
|
1769
|
-
});
|
|
1770
|
-
if (result === "sent") {
|
|
1771
|
-
totalProcessed++;
|
|
1772
|
-
if (runId && totalProcessed % 10 === 0) await this.updateRunSendProgress(runId, stats);
|
|
1773
|
-
await this.applySendDelay(i >= users.length - 1);
|
|
1774
|
-
}
|
|
1775
|
-
} catch (err) {
|
|
1776
|
-
stats.errorCount++;
|
|
1777
|
-
this.emitSendEvent(rule, user.email || "unknown", "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
|
|
1778
|
-
this.logger.error(`Rule "${rule.name}" failed for user ${user._id?.toString()}`, { error: err });
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
|
|
1782
|
-
return stats;
|
|
1783
|
-
}
|
|
1784
|
-
checkThrottle(rule, userId, email, throttleMap, config, stats, templateId, runId) {
|
|
1785
|
-
if (rule.emailType === EMAIL_TYPE.Transactional || rule.bypassThrottle) return true;
|
|
1786
|
-
const overrides = rule.throttleOverride || {};
|
|
1787
|
-
const dailyLimit = overrides.maxPerUserPerDay ?? config.maxPerUserPerDay;
|
|
1788
|
-
const weeklyLimit = overrides.maxPerUserPerWeek ?? config.maxPerUserPerWeek;
|
|
1789
|
-
const minGap = overrides.minGapDays ?? config.minGapDays;
|
|
1790
|
-
const userThrottle = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1791
|
-
if (userThrottle.today >= dailyLimit) {
|
|
1792
|
-
stats.skippedByThrottle++;
|
|
1793
|
-
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "daily throttle limit" });
|
|
1794
|
-
return false;
|
|
1795
|
-
}
|
|
1796
|
-
if (userThrottle.thisWeek >= weeklyLimit) {
|
|
1797
|
-
stats.skippedByThrottle++;
|
|
1798
|
-
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "weekly throttle limit" });
|
|
1799
|
-
return false;
|
|
1800
|
-
}
|
|
1801
|
-
if (userThrottle.lastSentDate) {
|
|
1802
|
-
const daysSinceLastSend = (Date.now() - userThrottle.lastSentDate.getTime()) / MS_PER_DAY;
|
|
1803
|
-
if (daysSinceLastSend < minGap) {
|
|
1804
|
-
stats.skippedByThrottle++;
|
|
1805
|
-
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "min gap days" });
|
|
1806
|
-
return false;
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
return true;
|
|
1810
|
-
}
|
|
1811
|
-
getTodayStart() {
|
|
1812
|
-
const timezone = this.config.options?.sendWindow?.timezone;
|
|
1813
|
-
if (timezone) {
|
|
1814
|
-
const now = /* @__PURE__ */ new Date();
|
|
1815
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
1816
|
-
timeZone: timezone,
|
|
1817
|
-
year: "numeric",
|
|
1818
|
-
month: "2-digit",
|
|
1819
|
-
day: "2-digit",
|
|
1820
|
-
hour: "2-digit",
|
|
1821
|
-
minute: "2-digit",
|
|
1822
|
-
second: "2-digit",
|
|
1823
|
-
hour12: false
|
|
1824
|
-
}).formatToParts(now);
|
|
1825
|
-
const get = (type) => parts.find((p) => p.type === type)?.value || "0";
|
|
1826
|
-
const tzNowMs = Date.UTC(
|
|
1827
|
-
parseInt(get("year")),
|
|
1828
|
-
parseInt(get("month")) - 1,
|
|
1829
|
-
parseInt(get("day")),
|
|
1830
|
-
parseInt(get("hour")),
|
|
1831
|
-
parseInt(get("minute")),
|
|
1832
|
-
parseInt(get("second"))
|
|
1833
|
-
);
|
|
1834
|
-
const tzMidnightMs = Date.UTC(
|
|
1835
|
-
parseInt(get("year")),
|
|
1836
|
-
parseInt(get("month")) - 1,
|
|
1837
|
-
parseInt(get("day"))
|
|
1838
|
-
);
|
|
1839
|
-
const offsetMs = now.getTime() - tzNowMs;
|
|
1840
|
-
return new Date(tzMidnightMs + offsetMs);
|
|
1841
|
-
}
|
|
1842
|
-
const todayStart = /* @__PURE__ */ new Date();
|
|
1843
|
-
todayStart.setHours(0, 0, 0, 0);
|
|
1844
|
-
return todayStart;
|
|
1845
|
-
}
|
|
1846
|
-
async updateRunProgress(runId, data) {
|
|
1847
|
-
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1848
|
-
const flat = [];
|
|
1849
|
-
for (const [k, v] of Object.entries(data)) {
|
|
1850
|
-
if (typeof v === "object" && v !== null) {
|
|
1851
|
-
flat.push(k, JSON.stringify(v));
|
|
1852
|
-
} else {
|
|
1853
|
-
flat.push(k, String(v));
|
|
1854
|
-
}
|
|
1855
|
-
}
|
|
1856
|
-
if (flat.length > 0) {
|
|
1857
|
-
await this.redis.hset(key, ...flat);
|
|
1858
|
-
await this.redis.expire(key, 3600);
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
async updateRunSendProgress(runId, stats) {
|
|
1862
|
-
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1863
|
-
const existing = await this.redis.hget(key, "progress");
|
|
1864
|
-
let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
|
|
1865
|
-
if (existing) {
|
|
1866
|
-
try {
|
|
1867
|
-
progress = JSON.parse(existing);
|
|
1868
|
-
} catch {
|
|
1869
|
-
}
|
|
1870
|
-
}
|
|
1871
|
-
progress.sent = stats.sent;
|
|
1872
|
-
progress.failed = stats.errorCount;
|
|
1873
|
-
progress.skipped = stats.skipped + stats.skippedByThrottle;
|
|
1874
|
-
await this.redis.hset(key, "progress", JSON.stringify(progress));
|
|
1875
|
-
await this.redis.expire(key, 3600);
|
|
1876
|
-
}
|
|
1877
|
-
async getStatus(runId) {
|
|
1878
|
-
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1879
|
-
const data = await this.redis.hgetall(key);
|
|
1880
|
-
if (!data || Object.keys(data).length === 0) return null;
|
|
1881
|
-
let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
|
|
1882
|
-
if (data.progress) {
|
|
1883
|
-
try {
|
|
1884
|
-
progress = JSON.parse(data.progress);
|
|
1885
|
-
} catch {
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
return {
|
|
1889
|
-
runId: data.runId || runId,
|
|
1890
|
-
status: data.status || "running",
|
|
1891
|
-
currentRule: data.currentRule || "",
|
|
1892
|
-
progress,
|
|
1893
|
-
startedAt: data.startedAt || "",
|
|
1894
|
-
elapsed: parseInt(data.elapsed || "0", 10)
|
|
1895
|
-
};
|
|
1896
|
-
}
|
|
1897
|
-
async cancel(runId) {
|
|
1898
|
-
const progressKey = `${this.keyPrefix}run:${runId}:progress`;
|
|
1899
|
-
const exists = await this.redis.exists(progressKey);
|
|
1900
|
-
if (!exists) return { ok: false };
|
|
1901
|
-
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1902
|
-
await this.redis.set(cancelKey, "1", "EX", 3600);
|
|
1903
|
-
return { ok: true };
|
|
1904
|
-
}
|
|
1905
|
-
trigger(triggeredBy) {
|
|
1906
|
-
const runId = crypto__default.default.randomUUID();
|
|
1907
|
-
this.runAllRules(triggeredBy || RUN_TRIGGER.Manual, runId).catch((err) => {
|
|
1908
|
-
this.logger.error("Background rule run failed", { error: err, runId });
|
|
1909
|
-
this.updateRunProgress(runId, { status: "failed" }).catch(() => {
|
|
1910
|
-
});
|
|
1911
|
-
});
|
|
1912
|
-
return { runId, started: true };
|
|
1913
|
-
}
|
|
1914
|
-
buildThrottleMap(recentSends) {
|
|
1915
|
-
const map = /* @__PURE__ */ new Map();
|
|
1916
|
-
const todayStart = this.getTodayStart();
|
|
1917
|
-
for (const send of recentSends) {
|
|
1918
|
-
const key = send.userId.toString();
|
|
1919
|
-
const current = map.get(key) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1920
|
-
const sentAt = new Date(send.sentAt);
|
|
1921
|
-
current.thisWeek++;
|
|
1922
|
-
if (sentAt >= todayStart) {
|
|
1923
|
-
current.today++;
|
|
1924
|
-
}
|
|
1925
|
-
if (!current.lastSentDate || sentAt > current.lastSentDate) {
|
|
1926
|
-
current.lastSentDate = sentAt;
|
|
1927
|
-
}
|
|
1928
|
-
map.set(key, current);
|
|
1929
|
-
}
|
|
1930
|
-
return map;
|
|
1931
|
-
}
|
|
1932
|
-
};
|
|
1933
|
-
|
|
1934
|
-
// src/utils/controller.ts
|
|
1935
|
-
function getErrorStatus(message) {
|
|
1936
|
-
if (message.includes("not found")) return 404;
|
|
1937
|
-
if (message.includes("already exists") || message.includes("validation failed") || message.includes("mismatch") || message.includes("Cannot activate") || message.includes("Cannot delete")) return 400;
|
|
1938
|
-
return 500;
|
|
1939
|
-
}
|
|
1940
|
-
function isValidValue(allowed, value) {
|
|
1941
|
-
return typeof value === "string" && allowed.includes(value);
|
|
1942
|
-
}
|
|
1943
|
-
function asyncHandler(handler) {
|
|
1944
|
-
return (req, res) => {
|
|
1945
|
-
handler(req, res).catch((error) => {
|
|
1946
|
-
if (res.headersSent) return;
|
|
1947
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1948
|
-
const status = getErrorStatus(message);
|
|
1949
|
-
res.status(status).json({ success: false, error: message });
|
|
1950
|
-
});
|
|
1951
104
|
};
|
|
105
|
+
return ruleEngine.createRuleEngine(coreConfig);
|
|
1952
106
|
}
|
|
1953
107
|
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
category,
|
|
1963
|
-
audience,
|
|
1964
|
-
platform,
|
|
1965
|
-
isActive: isActive !== void 0 ? isActive === "true" : void 0,
|
|
1966
|
-
...calculatePagination(parseInt(String(page), 10) || void 0, parseInt(String(limit), 10) || void 0)
|
|
1967
|
-
});
|
|
1968
|
-
res.json({ success: true, data: { templates, total } });
|
|
1969
|
-
});
|
|
1970
|
-
const getById = asyncHandler(async (req, res) => {
|
|
1971
|
-
const template = await templateService.getById(core.getParam(req, "id"));
|
|
1972
|
-
if (!template) {
|
|
1973
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1974
|
-
}
|
|
1975
|
-
res.json({ success: true, data: { template } });
|
|
1976
|
-
});
|
|
1977
|
-
const create = asyncHandler(async (req, res) => {
|
|
1978
|
-
const { name, subjects, bodies, category, audience, platform, preheaders } = req.body;
|
|
1979
|
-
if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
|
|
1980
|
-
return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
|
|
1981
|
-
}
|
|
1982
|
-
if (!isValidValue(validCategories, category)) {
|
|
1983
|
-
return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
|
|
1984
|
-
}
|
|
1985
|
-
if (!isValidValue(validAudiences, audience)) {
|
|
1986
|
-
return res.status(400).json({ success: false, error: `Invalid audience. Must be one of: ${validAudiences.join(", ")}` });
|
|
1987
|
-
}
|
|
1988
|
-
if (platformValues && !platformValues.includes(platform)) {
|
|
1989
|
-
return res.status(400).json({ success: false, error: `Invalid platform. Must be one of: ${platformValues.join(", ")}` });
|
|
1990
|
-
}
|
|
1991
|
-
const template = await templateService.create(req.body);
|
|
1992
|
-
res.status(201).json({ success: true, data: { template } });
|
|
1993
|
-
});
|
|
1994
|
-
const update = asyncHandler(async (req, res) => {
|
|
1995
|
-
const template = await templateService.update(core.getParam(req, "id"), req.body);
|
|
1996
|
-
if (!template) {
|
|
1997
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1998
|
-
}
|
|
1999
|
-
res.json({ success: true, data: { template } });
|
|
2000
|
-
});
|
|
2001
|
-
const remove = asyncHandler(async (req, res) => {
|
|
2002
|
-
const deleted = await templateService.delete(core.getParam(req, "id"));
|
|
2003
|
-
if (!deleted) {
|
|
2004
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2005
|
-
}
|
|
2006
|
-
res.json({ success: true });
|
|
2007
|
-
});
|
|
2008
|
-
const toggleActive = asyncHandler(async (req, res) => {
|
|
2009
|
-
const template = await templateService.toggleActive(core.getParam(req, "id"));
|
|
2010
|
-
if (!template) {
|
|
2011
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2012
|
-
}
|
|
2013
|
-
res.json({ success: true, data: { template } });
|
|
2014
|
-
});
|
|
2015
|
-
const preview = asyncHandler(async (req, res) => {
|
|
2016
|
-
const { sampleData } = req.body;
|
|
2017
|
-
const result = await templateService.preview(core.getParam(req, "id"), sampleData || {});
|
|
2018
|
-
if (!result) {
|
|
2019
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2020
|
-
}
|
|
2021
|
-
res.json({ success: true, data: result });
|
|
2022
|
-
});
|
|
2023
|
-
const previewRaw = asyncHandler(async (req, res) => {
|
|
2024
|
-
const { subject, body, textBody, sampleData, variables } = req.body;
|
|
2025
|
-
if (!subject || !body) {
|
|
2026
|
-
return res.status(400).json({ success: false, error: "subject and body are required" });
|
|
2027
|
-
}
|
|
2028
|
-
const result = await templateService.previewRaw(subject, body, sampleData || {}, variables, textBody);
|
|
2029
|
-
res.json({ success: true, data: result });
|
|
2030
|
-
});
|
|
2031
|
-
const validate = asyncHandler(async (req, res) => {
|
|
2032
|
-
const { body: templateBody } = req.body;
|
|
2033
|
-
if (!templateBody) {
|
|
2034
|
-
return res.status(400).json({ success: false, error: "body is required" });
|
|
2035
|
-
}
|
|
2036
|
-
const result = await templateService.validate(templateBody);
|
|
2037
|
-
res.json({ success: true, data: result });
|
|
2038
|
-
});
|
|
2039
|
-
const clone = asyncHandler(async (req, res) => {
|
|
2040
|
-
const { name } = req.body;
|
|
2041
|
-
const result = await templateService.clone(core.getParam(req, "id"), name);
|
|
2042
|
-
res.json({ success: true, data: result });
|
|
2043
|
-
});
|
|
2044
|
-
const sendTestEmail = asyncHandler(async (req, res) => {
|
|
2045
|
-
const { testEmail, sampleData } = req.body;
|
|
2046
|
-
if (!testEmail) {
|
|
2047
|
-
return res.status(400).json({ success: false, error: "testEmail is required" });
|
|
2048
|
-
}
|
|
2049
|
-
const result = await templateService.sendTestEmail(core.getParam(req, "id"), testEmail, sampleData || {});
|
|
2050
|
-
if (!result.success) {
|
|
2051
|
-
return res.status(400).json({ success: false, error: result.error });
|
|
2052
|
-
}
|
|
2053
|
-
res.json({ success: true });
|
|
2054
|
-
});
|
|
2055
|
-
const previewWithRecipient = asyncHandler(async (req, res) => {
|
|
2056
|
-
const { recipientData } = req.body;
|
|
2057
|
-
if (!recipientData || typeof recipientData !== "object") {
|
|
2058
|
-
return res.status(400).json({ success: false, error: "recipientData object is required" });
|
|
2059
|
-
}
|
|
2060
|
-
const result = await templateService.previewWithRecipient(core.getParam(req, "id"), recipientData);
|
|
2061
|
-
if (!result) {
|
|
2062
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2063
|
-
}
|
|
2064
|
-
res.json({ success: true, data: result });
|
|
2065
|
-
});
|
|
2066
|
-
return { list, getById, create, update, remove, toggleActive, preview, previewRaw, validate, sendTestEmail, clone, previewWithRecipient };
|
|
2067
|
-
}
|
|
2068
|
-
function createRuleController(ruleService, options) {
|
|
2069
|
-
const platformValues = options?.platforms;
|
|
2070
|
-
const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
|
|
2071
|
-
const validEmailTypes = Object.values(EMAIL_TYPE);
|
|
2072
|
-
const list = asyncHandler(async (req, res) => {
|
|
2073
|
-
const { page, limit } = calculatePagination(
|
|
2074
|
-
parseInt(String(req.query.page), 10) || void 0,
|
|
2075
|
-
parseInt(String(req.query.limit), 10) || void 0
|
|
2076
|
-
);
|
|
2077
|
-
const { rules, total } = await ruleService.list({ page, limit });
|
|
2078
|
-
res.json({ success: true, data: { rules, total } });
|
|
2079
|
-
});
|
|
2080
|
-
const getById = asyncHandler(async (req, res) => {
|
|
2081
|
-
const rule = await ruleService.getById(core.getParam(req, "id"));
|
|
2082
|
-
if (!rule) {
|
|
2083
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2084
|
-
}
|
|
2085
|
-
res.json({ success: true, data: { rule } });
|
|
2086
|
-
});
|
|
2087
|
-
const create = asyncHandler(async (req, res) => {
|
|
2088
|
-
const { name, target, templateId } = req.body;
|
|
2089
|
-
if (!name || !target || !templateId) {
|
|
2090
|
-
return res.status(400).json({ success: false, error: "name, target, and templateId are required" });
|
|
2091
|
-
}
|
|
2092
|
-
const mode = target.mode || "query";
|
|
2093
|
-
if (mode === "list") {
|
|
2094
|
-
if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
|
|
2095
|
-
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
2096
|
-
}
|
|
2097
|
-
} else {
|
|
2098
|
-
if (!target.role || !isValidValue(validAudiences, target.role)) {
|
|
2099
|
-
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
2100
|
-
}
|
|
2101
|
-
if (platformValues && !platformValues.includes(target.platform)) {
|
|
2102
|
-
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2103
|
-
}
|
|
2104
|
-
if (!Array.isArray(target.conditions)) {
|
|
2105
|
-
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
if (req.body.emailType && !isValidValue(validEmailTypes, req.body.emailType)) {
|
|
2109
|
-
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2110
|
-
}
|
|
2111
|
-
const rule = await ruleService.create(req.body);
|
|
2112
|
-
res.status(201).json({ success: true, data: { rule } });
|
|
2113
|
-
});
|
|
2114
|
-
const update = asyncHandler(async (req, res) => {
|
|
2115
|
-
const { target, emailType } = req.body;
|
|
2116
|
-
if (target) {
|
|
2117
|
-
const mode = target.mode || "query";
|
|
2118
|
-
if (mode === "list") {
|
|
2119
|
-
if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
|
|
2120
|
-
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
2121
|
-
}
|
|
2122
|
-
} else {
|
|
2123
|
-
if (target.role && !isValidValue(validAudiences, target.role)) {
|
|
2124
|
-
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
2125
|
-
}
|
|
2126
|
-
if (target.platform && platformValues && !platformValues.includes(target.platform)) {
|
|
2127
|
-
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2128
|
-
}
|
|
2129
|
-
if (target.conditions && !Array.isArray(target.conditions)) {
|
|
2130
|
-
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
2131
|
-
}
|
|
2132
|
-
}
|
|
2133
|
-
}
|
|
2134
|
-
if (emailType && !isValidValue(validEmailTypes, emailType)) {
|
|
2135
|
-
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2136
|
-
}
|
|
2137
|
-
const rule = await ruleService.update(core.getParam(req, "id"), req.body);
|
|
2138
|
-
if (!rule) {
|
|
2139
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2140
|
-
}
|
|
2141
|
-
res.json({ success: true, data: { rule } });
|
|
2142
|
-
});
|
|
2143
|
-
const remove = asyncHandler(async (req, res) => {
|
|
2144
|
-
const result = await ruleService.delete(core.getParam(req, "id"));
|
|
2145
|
-
if (!result.deleted && !result.disabled) {
|
|
2146
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2147
|
-
}
|
|
2148
|
-
res.json({ success: true, data: result });
|
|
2149
|
-
});
|
|
2150
|
-
const toggleActive = asyncHandler(async (req, res) => {
|
|
2151
|
-
const rule = await ruleService.toggleActive(core.getParam(req, "id"));
|
|
2152
|
-
if (!rule) {
|
|
2153
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2154
|
-
}
|
|
2155
|
-
res.json({ success: true, data: { rule } });
|
|
2156
|
-
});
|
|
2157
|
-
const dryRun = asyncHandler(async (req, res) => {
|
|
2158
|
-
const result = await ruleService.dryRun(core.getParam(req, "id"));
|
|
2159
|
-
res.json({ success: true, data: result });
|
|
2160
|
-
});
|
|
2161
|
-
const clone = asyncHandler(async (req, res) => {
|
|
2162
|
-
const { name } = req.body;
|
|
2163
|
-
const result = await ruleService.clone(core.getParam(req, "id"), name);
|
|
2164
|
-
res.json({ success: true, data: result });
|
|
2165
|
-
});
|
|
2166
|
-
const runHistory = asyncHandler(async (req, res) => {
|
|
2167
|
-
const { page, limit } = calculatePagination(
|
|
2168
|
-
parseInt(String(req.query.page), 10) || void 0,
|
|
2169
|
-
parseInt(String(req.query.limit), 10) || 20
|
|
2170
|
-
);
|
|
2171
|
-
const from = req.query.from ? String(req.query.from) : void 0;
|
|
2172
|
-
const to = req.query.to ? String(req.query.to) : void 0;
|
|
2173
|
-
const logs = await ruleService.getRunHistory(limit, { page, from, to });
|
|
2174
|
-
const total = await ruleService.getRunHistoryCount({ from, to });
|
|
2175
|
-
res.json({ success: true, data: { logs, total } });
|
|
2176
|
-
});
|
|
2177
|
-
return { list, getById, create, update, remove, toggleActive, dryRun, runHistory, clone };
|
|
2178
|
-
}
|
|
2179
|
-
function createRunnerController(runnerService, EmailRuleRunLog, logger) {
|
|
2180
|
-
const triggerManualRun = asyncHandler(async (_req, res) => {
|
|
2181
|
-
const { runId } = runnerService.trigger(RUN_TRIGGER.Manual);
|
|
2182
|
-
res.json({ success: true, data: { message: "Rule run triggered", runId } });
|
|
2183
|
-
});
|
|
2184
|
-
const getLatestRun = asyncHandler(async (_req, res) => {
|
|
2185
|
-
const latestRun = await EmailRuleRunLog.findOne().sort({ runAt: -1 });
|
|
2186
|
-
res.json({ success: true, data: { latestRun } });
|
|
2187
|
-
});
|
|
2188
|
-
const getStatusByRunId = asyncHandler(async (req, res) => {
|
|
2189
|
-
const status = await runnerService.getStatus(core.getParam(req, "runId"));
|
|
2190
|
-
if (!status) {
|
|
2191
|
-
res.status(404).json({ success: false, error: "Run not found" });
|
|
2192
|
-
return;
|
|
2193
|
-
}
|
|
2194
|
-
res.json({ success: true, data: status });
|
|
2195
|
-
});
|
|
2196
|
-
const cancelRun = asyncHandler(async (req, res) => {
|
|
2197
|
-
const result = await runnerService.cancel(core.getParam(req, "runId"));
|
|
2198
|
-
if (!result.ok) {
|
|
2199
|
-
res.status(404).json({ success: false, error: "Run not found" });
|
|
2200
|
-
return;
|
|
2201
|
-
}
|
|
2202
|
-
res.json({ success: true, data: { message: "Cancel requested" } });
|
|
2203
|
-
});
|
|
2204
|
-
return { triggerManualRun, getLatestRun, getStatusByRunId, cancelRun };
|
|
2205
|
-
}
|
|
2206
|
-
|
|
2207
|
-
// src/controllers/settings.controller.ts
|
|
2208
|
-
function createSettingsController(EmailThrottleConfig) {
|
|
2209
|
-
const getThrottleConfig = asyncHandler(async (_req, res) => {
|
|
2210
|
-
const config = await EmailThrottleConfig.getConfig();
|
|
2211
|
-
res.json({ success: true, data: { config } });
|
|
2212
|
-
});
|
|
2213
|
-
const updateThrottleConfig = asyncHandler(async (req, res) => {
|
|
2214
|
-
const { maxPerUserPerDay, maxPerUserPerWeek, minGapDays } = req.body;
|
|
2215
|
-
const updates = {};
|
|
2216
|
-
if (maxPerUserPerDay !== void 0) {
|
|
2217
|
-
if (!Number.isInteger(maxPerUserPerDay) || maxPerUserPerDay < 1) {
|
|
2218
|
-
return res.status(400).json({ success: false, error: "maxPerUserPerDay must be a positive integer" });
|
|
2219
|
-
}
|
|
2220
|
-
updates.maxPerUserPerDay = maxPerUserPerDay;
|
|
2221
|
-
}
|
|
2222
|
-
if (maxPerUserPerWeek !== void 0) {
|
|
2223
|
-
if (!Number.isInteger(maxPerUserPerWeek) || maxPerUserPerWeek < 1) {
|
|
2224
|
-
return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be a positive integer" });
|
|
2225
|
-
}
|
|
2226
|
-
updates.maxPerUserPerWeek = maxPerUserPerWeek;
|
|
2227
|
-
}
|
|
2228
|
-
if (minGapDays !== void 0) {
|
|
2229
|
-
if (!Number.isInteger(minGapDays) || minGapDays < 0) {
|
|
2230
|
-
return res.status(400).json({ success: false, error: "minGapDays must be a non-negative integer" });
|
|
2231
|
-
}
|
|
2232
|
-
updates.minGapDays = minGapDays;
|
|
2233
|
-
}
|
|
2234
|
-
if (Object.keys(updates).length === 0) {
|
|
2235
|
-
return res.status(400).json({ success: false, error: "No valid fields to update" });
|
|
2236
|
-
}
|
|
2237
|
-
const config = await EmailThrottleConfig.getConfig();
|
|
2238
|
-
const finalDaily = updates.maxPerUserPerDay ?? config.maxPerUserPerDay;
|
|
2239
|
-
const finalWeekly = updates.maxPerUserPerWeek ?? config.maxPerUserPerWeek;
|
|
2240
|
-
if (finalWeekly < finalDaily) {
|
|
2241
|
-
return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be >= maxPerUserPerDay" });
|
|
2242
|
-
}
|
|
2243
|
-
const updated = await EmailThrottleConfig.findByIdAndUpdate(
|
|
2244
|
-
config._id,
|
|
2245
|
-
{ $set: updates },
|
|
2246
|
-
{ new: true }
|
|
2247
|
-
);
|
|
2248
|
-
res.json({ success: true, data: { config: updated } });
|
|
2249
|
-
});
|
|
2250
|
-
return { getThrottleConfig, updateThrottleConfig };
|
|
2251
|
-
}
|
|
2252
|
-
|
|
2253
|
-
// src/controllers/send-log.controller.ts
|
|
2254
|
-
function createSendLogController(EmailRuleSend) {
|
|
2255
|
-
const list = asyncHandler(async (req, res) => {
|
|
2256
|
-
const { ruleId, status, email, from, to, page, limit } = req.query;
|
|
2257
|
-
const filter = {};
|
|
2258
|
-
if (ruleId) filter.ruleId = ruleId;
|
|
2259
|
-
if (status) filter.status = status;
|
|
2260
|
-
if (email) filter.userId = { $regex: email, $options: "i" };
|
|
2261
|
-
Object.assign(filter, buildDateRangeFilter("sentAt", from, to));
|
|
2262
|
-
const pagination = calculatePagination(Number(page) || void 0, Number(limit) || 50, 200);
|
|
2263
|
-
const [sends, total] = await Promise.all([
|
|
2264
|
-
EmailRuleSend.find(filter).sort({ sentAt: -1 }).skip(pagination.skip).limit(pagination.limit).lean(),
|
|
2265
|
-
EmailRuleSend.countDocuments(filter)
|
|
2266
|
-
]);
|
|
2267
|
-
res.json({ success: true, data: { sends, total } });
|
|
2268
|
-
});
|
|
2269
|
-
return { list };
|
|
2270
|
-
}
|
|
2271
|
-
|
|
2272
|
-
// src/routes/index.ts
|
|
2273
|
-
function createRoutes(deps) {
|
|
2274
|
-
const router = express.Router();
|
|
2275
|
-
const templateCtrl = createTemplateController(deps.templateService, {
|
|
2276
|
-
platforms: deps.platformValues,
|
|
2277
|
-
categories: deps.categoryValues,
|
|
2278
|
-
audiences: deps.audienceValues
|
|
2279
|
-
});
|
|
2280
|
-
const ruleCtrl = createRuleController(deps.ruleService, {
|
|
2281
|
-
platforms: deps.platformValues,
|
|
2282
|
-
audiences: deps.audienceValues
|
|
2283
|
-
});
|
|
2284
|
-
const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog);
|
|
2285
|
-
const settingsCtrl = createSettingsController(deps.EmailThrottleConfig);
|
|
2286
|
-
const sendLogCtrl = createSendLogController(deps.EmailRuleSend);
|
|
2287
|
-
const collectionCtrl = createCollectionController(deps.collections || []);
|
|
2288
|
-
const templateRouter = express.Router();
|
|
2289
|
-
templateRouter.get("/", templateCtrl.list);
|
|
2290
|
-
templateRouter.post("/", templateCtrl.create);
|
|
2291
|
-
templateRouter.post("/validate", templateCtrl.validate);
|
|
2292
|
-
templateRouter.post("/preview", templateCtrl.previewRaw);
|
|
2293
|
-
templateRouter.get("/:id", templateCtrl.getById);
|
|
2294
|
-
templateRouter.put("/:id", templateCtrl.update);
|
|
2295
|
-
templateRouter.delete("/:id", templateCtrl.remove);
|
|
2296
|
-
templateRouter.patch("/:id/toggle", templateCtrl.toggleActive);
|
|
2297
|
-
templateRouter.post("/:id/preview", templateCtrl.preview);
|
|
2298
|
-
templateRouter.post("/:id/test-email", templateCtrl.sendTestEmail);
|
|
2299
|
-
templateRouter.post("/:id/preview-with-data", templateCtrl.previewWithRecipient);
|
|
2300
|
-
templateRouter.post("/:id/clone", templateCtrl.clone);
|
|
2301
|
-
const ruleRouter = express.Router();
|
|
2302
|
-
ruleRouter.get("/", ruleCtrl.list);
|
|
2303
|
-
ruleRouter.post("/", ruleCtrl.create);
|
|
2304
|
-
ruleRouter.get("/:id", ruleCtrl.getById);
|
|
2305
|
-
ruleRouter.patch("/:id", ruleCtrl.update);
|
|
2306
|
-
ruleRouter.delete("/:id", ruleCtrl.remove);
|
|
2307
|
-
ruleRouter.post("/:id/toggle", ruleCtrl.toggleActive);
|
|
2308
|
-
ruleRouter.post("/:id/dry-run", ruleCtrl.dryRun);
|
|
2309
|
-
ruleRouter.post("/:id/clone", ruleCtrl.clone);
|
|
2310
|
-
const runnerRouter = express.Router();
|
|
2311
|
-
runnerRouter.post("/", runnerCtrl.triggerManualRun);
|
|
2312
|
-
runnerRouter.get("/status", runnerCtrl.getLatestRun);
|
|
2313
|
-
runnerRouter.get("/status/:runId", runnerCtrl.getStatusByRunId);
|
|
2314
|
-
runnerRouter.post("/cancel/:runId", runnerCtrl.cancelRun);
|
|
2315
|
-
runnerRouter.get("/logs", ruleCtrl.runHistory);
|
|
2316
|
-
const sendLogRouter = express.Router();
|
|
2317
|
-
sendLogRouter.get("/", sendLogCtrl.list);
|
|
2318
|
-
const collectionRouter = express.Router();
|
|
2319
|
-
collectionRouter.get("/", collectionCtrl.list);
|
|
2320
|
-
collectionRouter.get("/:name/fields", collectionCtrl.getFields);
|
|
2321
|
-
router.use("/templates", templateRouter);
|
|
2322
|
-
router.use("/rules", ruleRouter);
|
|
2323
|
-
router.use("/runner", runnerRouter);
|
|
2324
|
-
router.use("/sends", sendLogRouter);
|
|
2325
|
-
router.use("/collections", collectionRouter);
|
|
2326
|
-
router.get("/throttle", settingsCtrl.getThrottleConfig);
|
|
2327
|
-
router.put("/throttle", settingsCtrl.updateThrottleConfig);
|
|
2328
|
-
return router;
|
|
2329
|
-
}
|
|
2330
|
-
var configSchema = zod.z.object({
|
|
2331
|
-
db: core.baseDbSchema,
|
|
2332
|
-
redis: core.baseRedisSchema,
|
|
2333
|
-
adapters: zod.z.object({
|
|
2334
|
-
queryUsers: zod.z.function(),
|
|
2335
|
-
resolveData: zod.z.function(),
|
|
2336
|
-
sendEmail: zod.z.function(),
|
|
2337
|
-
selectAgent: zod.z.function(),
|
|
2338
|
-
findIdentifier: zod.z.function(),
|
|
2339
|
-
sendTestEmail: zod.z.function().optional()
|
|
2340
|
-
}),
|
|
2341
|
-
collections: zod.z.array(zod.z.object({
|
|
2342
|
-
name: zod.z.string(),
|
|
2343
|
-
label: zod.z.string().optional(),
|
|
2344
|
-
description: zod.z.string().optional(),
|
|
2345
|
-
identifierField: zod.z.string().optional(),
|
|
2346
|
-
fields: zod.z.array(zod.z.any()),
|
|
2347
|
-
joins: zod.z.array(zod.z.object({
|
|
2348
|
-
from: zod.z.string(),
|
|
2349
|
-
localField: zod.z.string(),
|
|
2350
|
-
foreignField: zod.z.string(),
|
|
2351
|
-
as: zod.z.string()
|
|
2352
|
-
})).optional()
|
|
2353
|
-
})).optional(),
|
|
2354
|
-
platforms: zod.z.array(zod.z.string()).optional(),
|
|
2355
|
-
audiences: zod.z.array(zod.z.string()).optional(),
|
|
2356
|
-
categories: zod.z.array(zod.z.string()).optional(),
|
|
2357
|
-
logger: core.loggerSchema.optional(),
|
|
2358
|
-
options: zod.z.object({
|
|
2359
|
-
lockTTLMs: zod.z.number().positive().optional(),
|
|
2360
|
-
defaultMaxPerRun: zod.z.number().positive().optional(),
|
|
2361
|
-
sendWindow: zod.z.object({
|
|
2362
|
-
startHour: zod.z.number().min(0).max(23),
|
|
2363
|
-
endHour: zod.z.number().min(0).max(23),
|
|
2364
|
-
timezone: zod.z.string()
|
|
2365
|
-
}).optional(),
|
|
2366
|
-
delayBetweenSendsMs: zod.z.number().min(0).optional(),
|
|
2367
|
-
jitterMs: zod.z.number().min(0).optional()
|
|
2368
|
-
}).optional(),
|
|
2369
|
-
hooks: zod.z.object({
|
|
2370
|
-
onRunStart: zod.z.function().optional(),
|
|
2371
|
-
onRuleStart: zod.z.function().optional(),
|
|
2372
|
-
onSend: zod.z.function().optional(),
|
|
2373
|
-
onRuleComplete: zod.z.function().optional(),
|
|
2374
|
-
onRunComplete: zod.z.function().optional()
|
|
2375
|
-
}).optional()
|
|
2376
|
-
});
|
|
2377
|
-
function validateConfig(raw) {
|
|
2378
|
-
const result = configSchema.safeParse(raw);
|
|
2379
|
-
if (!result.success) {
|
|
2380
|
-
const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
2381
|
-
throw new ConfigValidationError(
|
|
2382
|
-
`Invalid EmailRuleEngineConfig:
|
|
2383
|
-
${issues}`,
|
|
2384
|
-
result.error.issues[0]?.path.join(".") ?? ""
|
|
2385
|
-
);
|
|
2386
|
-
}
|
|
2387
|
-
}
|
|
2388
|
-
var SchedulerService = class {
|
|
2389
|
-
queue;
|
|
2390
|
-
worker;
|
|
2391
|
-
keyPrefix;
|
|
2392
|
-
constructor(connection, keyPrefix = "") {
|
|
2393
|
-
this.keyPrefix = keyPrefix;
|
|
2394
|
-
const connectionOpts = { host: connection.options?.host, port: connection.options?.port, password: connection.options?.password, db: connection.options?.db };
|
|
2395
|
-
this.queue = new bullmq.Queue("email-scheduler", { connection: connectionOpts, prefix: keyPrefix });
|
|
2396
|
-
}
|
|
2397
|
-
async syncRule(rule) {
|
|
2398
|
-
const ruleId = String(rule._id);
|
|
2399
|
-
const jobId = `sched-${ruleId}`;
|
|
2400
|
-
const existing = await this.queue.getRepeatableJobs();
|
|
2401
|
-
for (const job of existing) {
|
|
2402
|
-
if (job.id === jobId) {
|
|
2403
|
-
await this.queue.removeRepeatableByKey(job.key);
|
|
2404
|
-
}
|
|
2405
|
-
}
|
|
2406
|
-
if (rule.schedule?.enabled && rule.schedule.cron) {
|
|
2407
|
-
await this.queue.add("run-scheduled", { ruleId }, {
|
|
2408
|
-
repeat: { pattern: rule.schedule.cron, tz: rule.schedule.timezone || "UTC" },
|
|
2409
|
-
jobId
|
|
2410
|
-
});
|
|
2411
|
-
}
|
|
2412
|
-
}
|
|
2413
|
-
async removeRule(ruleId) {
|
|
2414
|
-
const jobId = `sched-${ruleId}`;
|
|
2415
|
-
const existing = await this.queue.getRepeatableJobs();
|
|
2416
|
-
for (const job of existing) {
|
|
2417
|
-
if (job.id === jobId) {
|
|
2418
|
-
await this.queue.removeRepeatableByKey(job.key);
|
|
2419
|
-
}
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
startWorker(runFn) {
|
|
2423
|
-
const connectionOpts = this.queue.opts?.connection;
|
|
2424
|
-
this.worker = new bullmq.Worker("email-scheduler", async (job) => {
|
|
2425
|
-
const { ruleId } = job.data;
|
|
2426
|
-
await runFn(`scheduled:${ruleId}`);
|
|
2427
|
-
}, { connection: connectionOpts, prefix: this.keyPrefix });
|
|
2428
|
-
}
|
|
2429
|
-
async stopWorker() {
|
|
2430
|
-
if (this.worker) {
|
|
2431
|
-
await this.worker.close();
|
|
2432
|
-
this.worker = void 0;
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
2435
|
-
async getScheduledJobs() {
|
|
2436
|
-
const jobs = await this.queue.getRepeatableJobs();
|
|
2437
|
-
return jobs.map((j) => ({
|
|
2438
|
-
ruleId: (j.id || "").replace("sched-", ""),
|
|
2439
|
-
cron: j.pattern || "",
|
|
2440
|
-
next: j.next ?? null
|
|
2441
|
-
}));
|
|
2442
|
-
}
|
|
2443
|
-
};
|
|
2444
|
-
|
|
2445
|
-
// src/index.ts
|
|
2446
|
-
function createEmailRuleEngine(config) {
|
|
2447
|
-
validateConfig(config);
|
|
2448
|
-
const conn = config.db.connection;
|
|
2449
|
-
const prefix = config.db.collectionPrefix || "";
|
|
2450
|
-
const EmailTemplate = conn.model(
|
|
2451
|
-
`${prefix}EmailTemplate`,
|
|
2452
|
-
createEmailTemplateSchema(config.platforms, config.audiences, config.categories, prefix)
|
|
2453
|
-
);
|
|
2454
|
-
const EmailRule = conn.model(
|
|
2455
|
-
`${prefix}EmailRule`,
|
|
2456
|
-
createEmailRuleSchema(config.platforms, config.audiences, prefix)
|
|
2457
|
-
);
|
|
2458
|
-
const EmailRuleSend = conn.model(
|
|
2459
|
-
`${prefix}EmailRuleSend`,
|
|
2460
|
-
createEmailRuleSendSchema(prefix)
|
|
2461
|
-
);
|
|
2462
|
-
const EmailRuleRunLog = conn.model(
|
|
2463
|
-
`${prefix}EmailRuleRunLog`,
|
|
2464
|
-
createEmailRuleRunLogSchema(prefix)
|
|
2465
|
-
);
|
|
2466
|
-
const EmailThrottleConfig = conn.model(
|
|
2467
|
-
`${prefix}EmailThrottleConfig`,
|
|
2468
|
-
createEmailThrottleConfigSchema(prefix)
|
|
2469
|
-
);
|
|
2470
|
-
const templateService = new TemplateService(EmailTemplate, config, EmailRule);
|
|
2471
|
-
const ruleService = new RuleService(EmailRule, EmailTemplate, EmailRuleRunLog, config);
|
|
2472
|
-
const runnerService = new RuleRunnerService(
|
|
2473
|
-
EmailRule,
|
|
2474
|
-
EmailTemplate,
|
|
2475
|
-
EmailRuleSend,
|
|
2476
|
-
EmailRuleRunLog,
|
|
2477
|
-
EmailThrottleConfig,
|
|
2478
|
-
config
|
|
2479
|
-
);
|
|
2480
|
-
const routes = createRoutes({
|
|
2481
|
-
templateService,
|
|
2482
|
-
ruleService,
|
|
2483
|
-
runnerService,
|
|
2484
|
-
EmailRuleRunLog,
|
|
2485
|
-
EmailRuleSend,
|
|
2486
|
-
EmailThrottleConfig,
|
|
2487
|
-
platformValues: config.platforms,
|
|
2488
|
-
categoryValues: config.categories,
|
|
2489
|
-
audienceValues: config.audiences,
|
|
2490
|
-
logger: config.logger,
|
|
2491
|
-
collections: config.collections || []
|
|
108
|
+
exports.createEmailRuleEngine = createEmailRuleEngine;
|
|
109
|
+
exports.htmlToPlainText = htmlToPlainText;
|
|
110
|
+
exports.registerEmailHelpers = registerEmailHelpers;
|
|
111
|
+
exports.renderMjml = renderMjml;
|
|
112
|
+
Object.keys(ruleEngine).forEach(function (k) {
|
|
113
|
+
if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, {
|
|
114
|
+
enumerable: true,
|
|
115
|
+
get: function () { return ruleEngine[k]; }
|
|
2492
116
|
});
|
|
2493
|
-
return {
|
|
2494
|
-
routes,
|
|
2495
|
-
runner: runnerService,
|
|
2496
|
-
templateService,
|
|
2497
|
-
ruleService,
|
|
2498
|
-
models: { EmailTemplate, EmailRule, EmailRuleSend, EmailRuleRunLog, EmailThrottleConfig }
|
|
2499
|
-
};
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
Object.defineProperty(exports, "RedisLock", {
|
|
2503
|
-
enumerable: true,
|
|
2504
|
-
get: function () { return core.RedisLock; }
|
|
2505
117
|
});
|
|
2506
|
-
exports.AlxEmailError = AlxEmailError;
|
|
2507
|
-
exports.ConfigValidationError = ConfigValidationError;
|
|
2508
|
-
exports.DuplicateSlugError = DuplicateSlugError;
|
|
2509
|
-
exports.EMAIL_SEND_STATUS = EMAIL_SEND_STATUS;
|
|
2510
|
-
exports.EMAIL_TYPE = EMAIL_TYPE;
|
|
2511
|
-
exports.FIELD_TYPE = FIELD_TYPE;
|
|
2512
|
-
exports.LockAcquisitionError = LockAcquisitionError;
|
|
2513
|
-
exports.RULE_OPERATOR = RULE_OPERATOR;
|
|
2514
|
-
exports.RUN_LOG_STATUS = RUN_LOG_STATUS;
|
|
2515
|
-
exports.RUN_TRIGGER = RUN_TRIGGER;
|
|
2516
|
-
exports.RuleNotFoundError = RuleNotFoundError;
|
|
2517
|
-
exports.RuleRunnerService = RuleRunnerService;
|
|
2518
|
-
exports.RuleService = RuleService;
|
|
2519
|
-
exports.RuleTemplateIncompatibleError = RuleTemplateIncompatibleError;
|
|
2520
|
-
exports.SchedulerService = SchedulerService;
|
|
2521
|
-
exports.TARGET_MODE = TARGET_MODE;
|
|
2522
|
-
exports.TEMPLATE_AUDIENCE = TEMPLATE_AUDIENCE;
|
|
2523
|
-
exports.TEMPLATE_CATEGORY = TEMPLATE_CATEGORY;
|
|
2524
|
-
exports.THROTTLE_WINDOW = THROTTLE_WINDOW;
|
|
2525
|
-
exports.TYPE_OPERATORS = TYPE_OPERATORS;
|
|
2526
|
-
exports.TemplateNotFoundError = TemplateNotFoundError;
|
|
2527
|
-
exports.TemplateRenderService = TemplateRenderService;
|
|
2528
|
-
exports.TemplateService = TemplateService;
|
|
2529
|
-
exports.TemplateSyntaxError = TemplateSyntaxError;
|
|
2530
|
-
exports.createEmailRuleEngine = createEmailRuleEngine;
|
|
2531
|
-
exports.createEmailRuleRunLogSchema = createEmailRuleRunLogSchema;
|
|
2532
|
-
exports.createEmailRuleSchema = createEmailRuleSchema;
|
|
2533
|
-
exports.createEmailRuleSendSchema = createEmailRuleSendSchema;
|
|
2534
|
-
exports.createEmailTemplateSchema = createEmailTemplateSchema;
|
|
2535
|
-
exports.createEmailThrottleConfigSchema = createEmailThrottleConfigSchema;
|
|
2536
|
-
exports.flattenFields = flattenFields;
|
|
2537
|
-
exports.validateConditions = validateConditions;
|
|
2538
|
-
exports.validateConfig = validateConfig;
|
|
2539
118
|
//# sourceMappingURL=index.cjs.map
|
|
2540
119
|
//# sourceMappingURL=index.cjs.map
|