@astralibx/email-rule-engine 12.9.0 → 12.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +559 -772
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.mjs +559 -772
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -179,6 +179,17 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
|
|
|
179
179
|
schema.index({ audience: 1, platform: 1, isActive: 1 });
|
|
180
180
|
return schema;
|
|
181
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
|
+
}
|
|
191
|
+
|
|
192
|
+
// src/schemas/rule.schema.ts
|
|
182
193
|
function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix) {
|
|
183
194
|
const RuleConditionSchema = new mongoose.Schema({
|
|
184
195
|
field: { type: String, required: true },
|
|
@@ -196,13 +207,7 @@ function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix)
|
|
|
196
207
|
identifiers: [{ type: String }],
|
|
197
208
|
collection: { type: String }
|
|
198
209
|
}, { _id: false });
|
|
199
|
-
const RuleRunStatsSchema =
|
|
200
|
-
matched: { type: Number, default: 0 },
|
|
201
|
-
sent: { type: Number, default: 0 },
|
|
202
|
-
skipped: { type: Number, default: 0 },
|
|
203
|
-
skippedByThrottle: { type: Number, default: 0 },
|
|
204
|
-
errorCount: { type: Number, default: 0 }
|
|
205
|
-
}, { _id: false });
|
|
210
|
+
const RuleRunStatsSchema = createRunStatsSchema();
|
|
206
211
|
const schema = new mongoose.Schema(
|
|
207
212
|
{
|
|
208
213
|
name: { type: String, required: true },
|
|
@@ -331,22 +336,13 @@ function createEmailRuleSendSchema(collectionPrefix) {
|
|
|
331
336
|
return schema;
|
|
332
337
|
}
|
|
333
338
|
function createEmailRuleRunLogSchema(collectionPrefix) {
|
|
339
|
+
const baseStatsSchema = createRunStatsSchema();
|
|
334
340
|
const PerRuleStatsSchema = new mongoose.Schema({
|
|
335
341
|
ruleId: { type: mongoose.Schema.Types.ObjectId, ref: "EmailRule", required: true },
|
|
336
342
|
ruleName: { type: String, required: true },
|
|
337
|
-
|
|
338
|
-
sent: { type: Number, default: 0 },
|
|
339
|
-
skipped: { type: Number, default: 0 },
|
|
340
|
-
skippedByThrottle: { type: Number, default: 0 },
|
|
341
|
-
errorCount: { type: Number, default: 0 }
|
|
342
|
-
}, { _id: false });
|
|
343
|
-
const TotalStatsSchema = new mongoose.Schema({
|
|
344
|
-
matched: { type: Number, default: 0 },
|
|
345
|
-
sent: { type: Number, default: 0 },
|
|
346
|
-
skipped: { type: Number, default: 0 },
|
|
347
|
-
skippedByThrottle: { type: Number, default: 0 },
|
|
348
|
-
errorCount: { type: Number, default: 0 }
|
|
343
|
+
...baseStatsSchema.obj
|
|
349
344
|
}, { _id: false });
|
|
345
|
+
const TotalStatsSchema = createRunStatsSchema();
|
|
350
346
|
const schema = new mongoose.Schema(
|
|
351
347
|
{
|
|
352
348
|
runId: { type: String, index: true },
|
|
@@ -644,6 +640,38 @@ var DuplicateSlugError = class extends AlxEmailError {
|
|
|
644
640
|
}
|
|
645
641
|
};
|
|
646
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
|
+
|
|
647
675
|
// src/services/template.service.ts
|
|
648
676
|
function stripScriptTags(text) {
|
|
649
677
|
return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
|
@@ -752,12 +780,7 @@ var TemplateService = class {
|
|
|
752
780
|
const allContent = [...subjects, ...bodies, ...preheaders, textBody || ""].join(" ");
|
|
753
781
|
input.variables = this.renderService.extractVariables(allContent);
|
|
754
782
|
}
|
|
755
|
-
const setFields =
|
|
756
|
-
for (const [key, value] of Object.entries(input)) {
|
|
757
|
-
if (value !== void 0 && UPDATEABLE_FIELDS.has(key)) {
|
|
758
|
-
setFields[key] = value;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
783
|
+
const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS);
|
|
761
784
|
const update = { $set: setFields };
|
|
762
785
|
if (input.textBody || input.subjects || input.bodies || input.preheaders) {
|
|
763
786
|
update["$inc"] = { version: 1 };
|
|
@@ -1118,12 +1141,7 @@ var RuleService = class {
|
|
|
1118
1141
|
}
|
|
1119
1142
|
}
|
|
1120
1143
|
}
|
|
1121
|
-
const setFields =
|
|
1122
|
-
for (const [key, value] of Object.entries(input)) {
|
|
1123
|
-
if (value !== void 0 && UPDATEABLE_FIELDS2.has(key)) {
|
|
1124
|
-
setFields[key] = value;
|
|
1125
|
-
}
|
|
1126
|
-
}
|
|
1144
|
+
const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS2);
|
|
1127
1145
|
return this.EmailRule.findByIdAndUpdate(
|
|
1128
1146
|
id,
|
|
1129
1147
|
{ $set: setFields },
|
|
@@ -1198,23 +1216,12 @@ var RuleService = class {
|
|
|
1198
1216
|
return this.EmailRule.create(rest);
|
|
1199
1217
|
}
|
|
1200
1218
|
async getRunHistory(limit = 20, opts) {
|
|
1201
|
-
const filter =
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
if (opts.from) filter.runAt["$gte"] = new Date(opts.from);
|
|
1205
|
-
if (opts.to) filter.runAt["$lte"] = /* @__PURE__ */ new Date(opts.to + "T23:59:59.999Z");
|
|
1206
|
-
}
|
|
1207
|
-
const page = opts?.page ?? 1;
|
|
1208
|
-
const skip = (page - 1) * limit;
|
|
1209
|
-
return this.EmailRuleRunLog.find(filter).sort({ runAt: -1 }).skip(skip).limit(limit);
|
|
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);
|
|
1210
1222
|
}
|
|
1211
1223
|
async getRunHistoryCount(opts) {
|
|
1212
|
-
const filter =
|
|
1213
|
-
if (opts?.from || opts?.to) {
|
|
1214
|
-
filter.runAt = {};
|
|
1215
|
-
if (opts.from) filter.runAt["$gte"] = new Date(opts.from);
|
|
1216
|
-
if (opts.to) filter.runAt["$lte"] = /* @__PURE__ */ new Date(opts.to + "T23:59:59.999Z");
|
|
1217
|
-
}
|
|
1224
|
+
const filter = buildDateRangeFilter("runAt", opts?.from, opts?.to);
|
|
1218
1225
|
return this.EmailRuleRunLog.countDocuments(filter);
|
|
1219
1226
|
}
|
|
1220
1227
|
};
|
|
@@ -1436,201 +1443,252 @@ var RuleRunnerService = class {
|
|
|
1436
1443
|
}
|
|
1437
1444
|
return this.executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1438
1445
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
+
}
|
|
1445
1490
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
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
|
+
}
|
|
1538
|
+
});
|
|
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) {
|
|
1452
1578
|
const identifierResults = await processInChunks(
|
|
1453
|
-
|
|
1579
|
+
emails,
|
|
1454
1580
|
async (email) => {
|
|
1455
1581
|
const result = await this.config.adapters.findIdentifier(email);
|
|
1456
1582
|
return result ? { email, ...result } : null;
|
|
1457
1583
|
},
|
|
1458
1584
|
IDENTIFIER_CHUNK_SIZE
|
|
1459
1585
|
);
|
|
1460
|
-
const
|
|
1586
|
+
const map = /* @__PURE__ */ new Map();
|
|
1461
1587
|
for (const result of identifierResults) {
|
|
1462
1588
|
if (result) {
|
|
1463
|
-
|
|
1589
|
+
map.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1464
1590
|
}
|
|
1465
1591
|
}
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
const
|
|
1470
|
-
for (const send of
|
|
1592
|
+
return map;
|
|
1593
|
+
}
|
|
1594
|
+
buildSendMap(sends) {
|
|
1595
|
+
const map = /* @__PURE__ */ new Map();
|
|
1596
|
+
for (const send of sends) {
|
|
1471
1597
|
const uid = send.userId.toString();
|
|
1472
|
-
if (!
|
|
1473
|
-
|
|
1598
|
+
if (!map.has(uid)) {
|
|
1599
|
+
map.set(uid, send);
|
|
1474
1600
|
}
|
|
1475
1601
|
}
|
|
1602
|
+
return map;
|
|
1603
|
+
}
|
|
1604
|
+
compileTemplateVariants(template) {
|
|
1476
1605
|
const preheaders = template.preheaders || [];
|
|
1477
|
-
|
|
1606
|
+
return this.templateRenderer.compileBatchVariants(
|
|
1478
1607
|
template.subjects,
|
|
1479
1608
|
template.bodies,
|
|
1480
1609
|
template.textBody,
|
|
1481
1610
|
preheaders
|
|
1482
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));
|
|
1625
|
+
}
|
|
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);
|
|
1483
1653
|
let totalProcessed = 0;
|
|
1484
1654
|
for (let i = 0; i < emailsToProcess.length; i++) {
|
|
1485
1655
|
const email = emailsToProcess[i];
|
|
1486
|
-
if (runId
|
|
1487
|
-
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1488
|
-
const cancelled = await this.redis.exists(cancelKey);
|
|
1489
|
-
if (cancelled) break;
|
|
1490
|
-
}
|
|
1656
|
+
if (await this.checkCancelled(runId, i)) break;
|
|
1491
1657
|
try {
|
|
1492
1658
|
const identifier = identifierMap.get(email);
|
|
1493
1659
|
if (!identifier) {
|
|
1494
1660
|
stats.skipped++;
|
|
1495
|
-
this.
|
|
1661
|
+
this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1496
1662
|
continue;
|
|
1497
1663
|
}
|
|
1498
|
-
const
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "resend too soon" });
|
|
1511
|
-
continue;
|
|
1512
|
-
}
|
|
1513
|
-
} else {
|
|
1514
|
-
stats.skipped++;
|
|
1515
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
|
|
1516
|
-
continue;
|
|
1517
|
-
}
|
|
1518
|
-
if (rule.cooldownDays) {
|
|
1519
|
-
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1520
|
-
if (daysSince < rule.cooldownDays) {
|
|
1521
|
-
stats.skipped++;
|
|
1522
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "cooldown period" });
|
|
1523
|
-
continue;
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
if (!this.checkThrottle(rule, dedupKey, email, throttleMap, throttleConfig, stats, templateId, runId)) continue;
|
|
1528
|
-
const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
|
|
1529
|
-
if (!agentSelection) {
|
|
1530
|
-
stats.skipped++;
|
|
1531
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "no account available" });
|
|
1532
|
-
continue;
|
|
1533
|
-
}
|
|
1534
|
-
const user = { _id: identifier.id, email };
|
|
1535
|
-
const resolvedData = this.config.adapters.resolveData(user);
|
|
1536
|
-
const templateData = { ...template.fields || {}, ...resolvedData };
|
|
1537
|
-
const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
|
|
1538
|
-
const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
|
|
1539
|
-
const renderedSubject = compiledVariants.subjectFns[si](templateData);
|
|
1540
|
-
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1541
|
-
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1542
|
-
let finalHtml = renderedHtml;
|
|
1543
|
-
let finalText = renderedText;
|
|
1544
|
-
let finalSubject = renderedSubject;
|
|
1545
|
-
let pi;
|
|
1546
|
-
if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
|
|
1547
|
-
pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
|
|
1548
|
-
const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
|
|
1549
|
-
if (renderedPreheader) {
|
|
1550
|
-
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>`;
|
|
1551
|
-
finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
if (this.config.hooks?.beforeSend) {
|
|
1555
|
-
try {
|
|
1556
|
-
const modified = await this.config.hooks.beforeSend({
|
|
1557
|
-
htmlBody: finalHtml,
|
|
1558
|
-
textBody: finalText,
|
|
1559
|
-
subject: finalSubject,
|
|
1560
|
-
account: {
|
|
1561
|
-
id: agentSelection.accountId,
|
|
1562
|
-
email: agentSelection.email,
|
|
1563
|
-
metadata: agentSelection.metadata
|
|
1564
|
-
},
|
|
1565
|
-
user: {
|
|
1566
|
-
id: dedupKey,
|
|
1567
|
-
email,
|
|
1568
|
-
name: ""
|
|
1569
|
-
},
|
|
1570
|
-
context: {
|
|
1571
|
-
ruleId,
|
|
1572
|
-
templateId,
|
|
1573
|
-
runId: runId || ""
|
|
1574
|
-
}
|
|
1575
|
-
});
|
|
1576
|
-
finalHtml = modified.htmlBody;
|
|
1577
|
-
finalText = modified.textBody;
|
|
1578
|
-
finalSubject = modified.subject;
|
|
1579
|
-
} catch (hookErr) {
|
|
1580
|
-
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1581
|
-
stats.errorCount++;
|
|
1582
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
|
|
1583
|
-
continue;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
await this.config.adapters.sendEmail({
|
|
1587
|
-
identifierId: identifier.id,
|
|
1588
|
-
contactId: identifier.contactId,
|
|
1589
|
-
accountId: agentSelection.accountId,
|
|
1590
|
-
subject: finalSubject,
|
|
1591
|
-
htmlBody: finalHtml,
|
|
1592
|
-
textBody: finalText,
|
|
1593
|
-
ruleId,
|
|
1594
|
-
autoApprove: rule.autoApprove ?? true,
|
|
1595
|
-
attachments: template.attachments || []
|
|
1596
|
-
});
|
|
1597
|
-
await this.EmailRuleSend.logSend(
|
|
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,
|
|
1598
1676
|
ruleId,
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
void 0,
|
|
1602
|
-
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
|
|
1603
|
-
);
|
|
1604
|
-
const current = throttleMap.get(dedupKey) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1605
|
-
throttleMap.set(dedupKey, {
|
|
1606
|
-
today: current.today + 1,
|
|
1607
|
-
thisWeek: current.thisWeek + 1,
|
|
1608
|
-
lastSentDate: /* @__PURE__ */ new Date()
|
|
1677
|
+
runId,
|
|
1678
|
+
stats
|
|
1609
1679
|
});
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
await this.updateRunSendProgress(runId, stats);
|
|
1615
|
-
}
|
|
1616
|
-
if (i < emailsToProcess.length - 1) {
|
|
1617
|
-
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1618
|
-
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1619
|
-
if (delayMs > 0 || jitterMs > 0) {
|
|
1620
|
-
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1621
|
-
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
1622
|
-
}
|
|
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);
|
|
1623
1684
|
}
|
|
1624
1685
|
} catch (err) {
|
|
1625
1686
|
stats.errorCount++;
|
|
1626
|
-
this.
|
|
1687
|
+
this.emitSendEvent(rule, email, "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
|
|
1627
1688
|
this.logger.error(`Rule "${rule.name}" failed for identifier ${email}`, { error: err });
|
|
1628
1689
|
}
|
|
1629
1690
|
}
|
|
1630
|
-
await this.
|
|
1631
|
-
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1632
|
-
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1633
|
-
});
|
|
1691
|
+
await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
|
|
1634
1692
|
if (rule.sendOnce) {
|
|
1635
1693
|
const allIdentifiers = rule.target.identifiers || [];
|
|
1636
1694
|
const totalIdentifiers = new Set(allIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean)).size;
|
|
@@ -1646,7 +1704,6 @@ var RuleRunnerService = class {
|
|
|
1646
1704
|
this.logger.info(`Rule '${rule.name}' auto-disabled \u2014 all identifiers processed`);
|
|
1647
1705
|
}
|
|
1648
1706
|
}
|
|
1649
|
-
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
|
|
1650
1707
|
return stats;
|
|
1651
1708
|
}
|
|
1652
1709
|
async executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
@@ -1670,194 +1727,58 @@ var RuleRunnerService = class {
|
|
|
1670
1727
|
const userIds = users.map((u) => u._id?.toString()).filter(Boolean);
|
|
1671
1728
|
const emails = users.map((u) => u.email).filter(Boolean);
|
|
1672
1729
|
const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: userIds } }).sort({ sentAt: -1 }).lean();
|
|
1673
|
-
const sendMap =
|
|
1674
|
-
for (const send of allRuleSends) {
|
|
1675
|
-
const uid = send.userId.toString();
|
|
1676
|
-
if (!sendMap.has(uid)) {
|
|
1677
|
-
sendMap.set(uid, send);
|
|
1678
|
-
}
|
|
1679
|
-
}
|
|
1730
|
+
const sendMap = this.buildSendMap(allRuleSends);
|
|
1680
1731
|
const uniqueEmails = [...new Set(emails.map((e) => e.toLowerCase().trim()))];
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
async (email) => {
|
|
1684
|
-
const result = await this.config.adapters.findIdentifier(email);
|
|
1685
|
-
return result ? { email, ...result } : null;
|
|
1686
|
-
},
|
|
1687
|
-
IDENTIFIER_CHUNK_SIZE
|
|
1688
|
-
);
|
|
1689
|
-
const identifierMap = /* @__PURE__ */ new Map();
|
|
1690
|
-
for (const result of identifierResults) {
|
|
1691
|
-
if (result) {
|
|
1692
|
-
identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
const preheadersQ = template.preheaders || [];
|
|
1696
|
-
const compiledVariants = this.templateRenderer.compileBatchVariants(
|
|
1697
|
-
template.subjects,
|
|
1698
|
-
template.bodies,
|
|
1699
|
-
template.textBody,
|
|
1700
|
-
preheadersQ
|
|
1701
|
-
);
|
|
1732
|
+
const identifierMap = await this.resolveIdentifiers(uniqueEmails);
|
|
1733
|
+
const compiledVariants = this.compileTemplateVariants(template);
|
|
1702
1734
|
const ruleId = rule._id.toString();
|
|
1703
1735
|
const templateId = rule.templateId.toString();
|
|
1704
1736
|
let totalProcessed = 0;
|
|
1705
1737
|
for (let i = 0; i < users.length; i++) {
|
|
1706
1738
|
const user = users[i];
|
|
1707
|
-
if (runId
|
|
1708
|
-
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1709
|
-
const cancelled = await this.redis.exists(cancelKey);
|
|
1710
|
-
if (cancelled) break;
|
|
1711
|
-
}
|
|
1739
|
+
if (await this.checkCancelled(runId, i)) break;
|
|
1712
1740
|
try {
|
|
1713
1741
|
const userId = user._id?.toString();
|
|
1714
1742
|
const email = user.email;
|
|
1715
1743
|
if (!userId || !email) {
|
|
1716
1744
|
stats.skipped++;
|
|
1717
|
-
this.
|
|
1745
|
+
this.emitSendEvent(rule, email || "unknown", "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1718
1746
|
continue;
|
|
1719
1747
|
}
|
|
1720
|
-
const lastSend = sendMap.get(userId);
|
|
1721
|
-
if (lastSend) {
|
|
1722
|
-
if (rule.sendOnce && rule.resendAfterDays == null) {
|
|
1723
|
-
stats.skipped++;
|
|
1724
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
|
|
1725
|
-
continue;
|
|
1726
|
-
}
|
|
1727
|
-
if (rule.resendAfterDays != null) {
|
|
1728
|
-
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1729
|
-
if (daysSince < rule.resendAfterDays) {
|
|
1730
|
-
stats.skipped++;
|
|
1731
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "resend too soon" });
|
|
1732
|
-
continue;
|
|
1733
|
-
}
|
|
1734
|
-
} else {
|
|
1735
|
-
stats.skipped++;
|
|
1736
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
|
|
1737
|
-
continue;
|
|
1738
|
-
}
|
|
1739
|
-
if (rule.cooldownDays) {
|
|
1740
|
-
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1741
|
-
if (daysSince < rule.cooldownDays) {
|
|
1742
|
-
stats.skipped++;
|
|
1743
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "cooldown period" });
|
|
1744
|
-
continue;
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
1748
|
const identifier = identifierMap.get(email.toLowerCase().trim());
|
|
1749
1749
|
if (!identifier) {
|
|
1750
1750
|
stats.skipped++;
|
|
1751
|
-
this.
|
|
1752
|
-
continue;
|
|
1753
|
-
}
|
|
1754
|
-
if (!this.checkThrottle(rule, userId, email, throttleMap, throttleConfig, stats, templateId, runId)) continue;
|
|
1755
|
-
const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
|
|
1756
|
-
if (!agentSelection) {
|
|
1757
|
-
stats.skipped++;
|
|
1758
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "no account available" });
|
|
1751
|
+
this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1759
1752
|
continue;
|
|
1760
1753
|
}
|
|
1761
|
-
const
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
|
|
1774
|
-
const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
|
|
1775
|
-
if (renderedPreheader) {
|
|
1776
|
-
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>`;
|
|
1777
|
-
finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
if (this.config.hooks?.beforeSend) {
|
|
1781
|
-
try {
|
|
1782
|
-
const modified = await this.config.hooks.beforeSend({
|
|
1783
|
-
htmlBody: finalHtml,
|
|
1784
|
-
textBody: finalText,
|
|
1785
|
-
subject: finalSubject,
|
|
1786
|
-
account: {
|
|
1787
|
-
id: agentSelection.accountId,
|
|
1788
|
-
email: agentSelection.email,
|
|
1789
|
-
metadata: agentSelection.metadata
|
|
1790
|
-
},
|
|
1791
|
-
user: {
|
|
1792
|
-
id: String(userId),
|
|
1793
|
-
email,
|
|
1794
|
-
name: String(user.name || user.firstName || "")
|
|
1795
|
-
},
|
|
1796
|
-
context: {
|
|
1797
|
-
ruleId,
|
|
1798
|
-
templateId,
|
|
1799
|
-
runId: runId || ""
|
|
1800
|
-
}
|
|
1801
|
-
});
|
|
1802
|
-
finalHtml = modified.htmlBody;
|
|
1803
|
-
finalText = modified.textBody;
|
|
1804
|
-
finalSubject = modified.subject;
|
|
1805
|
-
} catch (hookErr) {
|
|
1806
|
-
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1807
|
-
stats.errorCount++;
|
|
1808
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
|
|
1809
|
-
continue;
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
|
-
await this.config.adapters.sendEmail({
|
|
1813
|
-
identifierId: identifier.id,
|
|
1814
|
-
contactId: identifier.contactId,
|
|
1815
|
-
accountId: agentSelection.accountId,
|
|
1816
|
-
subject: finalSubject,
|
|
1817
|
-
htmlBody: finalHtml,
|
|
1818
|
-
textBody: finalText,
|
|
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,
|
|
1819
1766
|
ruleId,
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
});
|
|
1823
|
-
await this.EmailRuleSend.logSend(
|
|
1824
|
-
ruleId,
|
|
1825
|
-
userId,
|
|
1826
|
-
identifier.id,
|
|
1827
|
-
void 0,
|
|
1828
|
-
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
|
|
1829
|
-
);
|
|
1830
|
-
const current = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1831
|
-
throttleMap.set(userId, {
|
|
1832
|
-
today: current.today + 1,
|
|
1833
|
-
thisWeek: current.thisWeek + 1,
|
|
1834
|
-
lastSentDate: /* @__PURE__ */ new Date()
|
|
1767
|
+
runId,
|
|
1768
|
+
stats
|
|
1835
1769
|
});
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
await this.updateRunSendProgress(runId, stats);
|
|
1841
|
-
}
|
|
1842
|
-
if (i < users.length - 1) {
|
|
1843
|
-
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1844
|
-
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1845
|
-
if (delayMs > 0 || jitterMs > 0) {
|
|
1846
|
-
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1847
|
-
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
1848
|
-
}
|
|
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);
|
|
1849
1774
|
}
|
|
1850
1775
|
} catch (err) {
|
|
1851
1776
|
stats.errorCount++;
|
|
1852
|
-
this.
|
|
1777
|
+
this.emitSendEvent(rule, user.email || "unknown", "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
|
|
1853
1778
|
this.logger.error(`Rule "${rule.name}" failed for user ${user._id?.toString()}`, { error: err });
|
|
1854
1779
|
}
|
|
1855
1780
|
}
|
|
1856
|
-
await this.
|
|
1857
|
-
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1858
|
-
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1859
|
-
});
|
|
1860
|
-
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
|
|
1781
|
+
await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
|
|
1861
1782
|
return stats;
|
|
1862
1783
|
}
|
|
1863
1784
|
checkThrottle(rule, userId, email, throttleMap, config, stats, templateId, runId) {
|
|
@@ -1869,19 +1790,19 @@ var RuleRunnerService = class {
|
|
|
1869
1790
|
const userThrottle = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1870
1791
|
if (userThrottle.today >= dailyLimit) {
|
|
1871
1792
|
stats.skippedByThrottle++;
|
|
1872
|
-
this.
|
|
1793
|
+
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "daily throttle limit" });
|
|
1873
1794
|
return false;
|
|
1874
1795
|
}
|
|
1875
1796
|
if (userThrottle.thisWeek >= weeklyLimit) {
|
|
1876
1797
|
stats.skippedByThrottle++;
|
|
1877
|
-
this.
|
|
1798
|
+
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "weekly throttle limit" });
|
|
1878
1799
|
return false;
|
|
1879
1800
|
}
|
|
1880
1801
|
if (userThrottle.lastSentDate) {
|
|
1881
1802
|
const daysSinceLastSend = (Date.now() - userThrottle.lastSentDate.getTime()) / MS_PER_DAY;
|
|
1882
1803
|
if (daysSinceLastSend < minGap) {
|
|
1883
1804
|
stats.skippedByThrottle++;
|
|
1884
|
-
this.
|
|
1805
|
+
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "min gap days" });
|
|
1885
1806
|
return false;
|
|
1886
1807
|
}
|
|
1887
1808
|
}
|
|
@@ -2009,476 +1930,342 @@ var RuleRunnerService = class {
|
|
|
2009
1930
|
return map;
|
|
2010
1931
|
}
|
|
2011
1932
|
};
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
}
|
|
1933
|
+
|
|
1934
|
+
// src/utils/controller.ts
|
|
2015
1935
|
function getErrorStatus(message) {
|
|
2016
|
-
if (message.includes("already exists") || message.includes("validation failed")) return 400;
|
|
2017
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;
|
|
2018
1938
|
return 500;
|
|
2019
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
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// src/controllers/template.controller.ts
|
|
2020
1955
|
function createTemplateController(templateService, options) {
|
|
2021
1956
|
const platformValues = options?.platforms;
|
|
2022
1957
|
const validCategories = options?.categories || Object.values(TEMPLATE_CATEGORY);
|
|
2023
1958
|
const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
res.status(
|
|
1959
|
+
const list = asyncHandler(async (req, res) => {
|
|
1960
|
+
const { category, audience, platform, isActive, page, limit } = req.query;
|
|
1961
|
+
const { templates, total } = await templateService.list({
|
|
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" });
|
|
2039
1974
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
}
|
|
2047
|
-
res.json({ success: true, data: { template } });
|
|
2048
|
-
} catch (error) {
|
|
2049
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2050
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2051
1981
|
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
try {
|
|
2055
|
-
const { name, subjects, bodies, category, audience, platform, preheaders } = req.body;
|
|
2056
|
-
if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
|
|
2057
|
-
return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
|
|
2058
|
-
}
|
|
2059
|
-
if (!isValidValue(validCategories, category)) {
|
|
2060
|
-
return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
|
|
2061
|
-
}
|
|
2062
|
-
if (!isValidValue(validAudiences, audience)) {
|
|
2063
|
-
return res.status(400).json({ success: false, error: `Invalid audience. Must be one of: ${validAudiences.join(", ")}` });
|
|
2064
|
-
}
|
|
2065
|
-
if (platformValues && !platformValues.includes(platform)) {
|
|
2066
|
-
return res.status(400).json({ success: false, error: `Invalid platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2067
|
-
}
|
|
2068
|
-
const template = await templateService.create(req.body);
|
|
2069
|
-
res.status(201).json({ success: true, data: { template } });
|
|
2070
|
-
} catch (error) {
|
|
2071
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2072
|
-
res.status(getErrorStatus(message)).json({ success: false, error: message });
|
|
1982
|
+
if (!isValidValue(validCategories, category)) {
|
|
1983
|
+
return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
|
|
2073
1984
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
try {
|
|
2077
|
-
const template = await templateService.update(core.getParam(req, "id"), req.body);
|
|
2078
|
-
if (!template) {
|
|
2079
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2080
|
-
}
|
|
2081
|
-
res.json({ success: true, data: { template } });
|
|
2082
|
-
} catch (error) {
|
|
2083
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2084
|
-
res.status(getErrorStatus(message)).json({ success: false, error: message });
|
|
1985
|
+
if (!isValidValue(validAudiences, audience)) {
|
|
1986
|
+
return res.status(400).json({ success: false, error: `Invalid audience. Must be one of: ${validAudiences.join(", ")}` });
|
|
2085
1987
|
}
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
try {
|
|
2089
|
-
const deleted = await templateService.delete(core.getParam(req, "id"));
|
|
2090
|
-
if (!deleted) {
|
|
2091
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2092
|
-
}
|
|
2093
|
-
res.json({ success: true });
|
|
2094
|
-
} catch (error) {
|
|
2095
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2096
|
-
res.status(500).json({ success: false, error: message });
|
|
1988
|
+
if (platformValues && !platformValues.includes(platform)) {
|
|
1989
|
+
return res.status(400).json({ success: false, error: `Invalid platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2097
1990
|
}
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
}
|
|
2105
|
-
res.json({ success: true, data: { template } });
|
|
2106
|
-
} catch (error) {
|
|
2107
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2108
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2109
1998
|
}
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2117
|
-
}
|
|
2118
|
-
res.json({ success: true, data: result });
|
|
2119
|
-
} catch (error) {
|
|
2120
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2121
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2122
2005
|
}
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
}
|
|
2130
|
-
const result = await templateService.previewRaw(subject, body, sampleData || {}, variables, textBody);
|
|
2131
|
-
res.json({ success: true, data: result });
|
|
2132
|
-
} catch (error) {
|
|
2133
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2134
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2135
2012
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
}
|
|
2143
|
-
const result = await templateService.validate(templateBody);
|
|
2144
|
-
res.json({ success: true, data: result });
|
|
2145
|
-
} catch (error) {
|
|
2146
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2147
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2148
2020
|
}
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
res.json({ success:
|
|
2155
|
-
} catch (error) {
|
|
2156
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2157
|
-
res.status(error instanceof Error && error.message === "Template not found" ? 404 : 500).json({ success: false, error: message });
|
|
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" });
|
|
2158
2027
|
}
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
}
|
|
2166
|
-
const result = await templateService.sendTestEmail(core.getParam(req, "id"), testEmail, sampleData || {});
|
|
2167
|
-
if (!result.success) {
|
|
2168
|
-
return res.status(400).json({ success: false, error: result.error });
|
|
2169
|
-
}
|
|
2170
|
-
res.json({ success: true });
|
|
2171
|
-
} catch (error) {
|
|
2172
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2173
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2174
2035
|
}
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
res.json({ success:
|
|
2187
|
-
} catch (error) {
|
|
2188
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2189
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2190
2048
|
}
|
|
2191
|
-
|
|
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
|
+
});
|
|
2192
2066
|
return { list, getById, create, update, remove, toggleActive, preview, previewRaw, validate, sendTestEmail, clone, previewWithRecipient };
|
|
2193
2067
|
}
|
|
2194
|
-
function isValidValue2(allowed, value) {
|
|
2195
|
-
return typeof value === "string" && allowed.includes(value);
|
|
2196
|
-
}
|
|
2197
|
-
function getErrorStatus2(message) {
|
|
2198
|
-
if (message.includes("not found")) return 404;
|
|
2199
|
-
if (message.includes("mismatch") || message.includes("validation failed") || message.includes("Cannot activate")) return 400;
|
|
2200
|
-
return 500;
|
|
2201
|
-
}
|
|
2202
2068
|
function createRuleController(ruleService, options) {
|
|
2203
2069
|
const platformValues = options?.platforms;
|
|
2204
2070
|
const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
|
|
2205
2071
|
const validEmailTypes = Object.values(EMAIL_TYPE);
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
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" });
|
|
2215
2084
|
}
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
}
|
|
2223
|
-
res.json({ success: true, data: { rule } });
|
|
2224
|
-
} catch (error) {
|
|
2225
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2226
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2227
2091
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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(", ")}` });
|
|
2234
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) {
|
|
2235
2117
|
const mode = target.mode || "query";
|
|
2236
2118
|
if (mode === "list") {
|
|
2237
|
-
if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
|
|
2119
|
+
if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
|
|
2238
2120
|
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
2239
2121
|
}
|
|
2240
2122
|
} else {
|
|
2241
|
-
if (
|
|
2123
|
+
if (target.role && !isValidValue(validAudiences, target.role)) {
|
|
2242
2124
|
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
2243
2125
|
}
|
|
2244
|
-
if (platformValues && !platformValues.includes(target.platform)) {
|
|
2126
|
+
if (target.platform && platformValues && !platformValues.includes(target.platform)) {
|
|
2245
2127
|
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2246
2128
|
}
|
|
2247
|
-
if (!Array.isArray(target.conditions)) {
|
|
2129
|
+
if (target.conditions && !Array.isArray(target.conditions)) {
|
|
2248
2130
|
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
2249
2131
|
}
|
|
2250
2132
|
}
|
|
2251
|
-
if (req.body.emailType && !isValidValue2(validEmailTypes, req.body.emailType)) {
|
|
2252
|
-
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2253
|
-
}
|
|
2254
|
-
const rule = await ruleService.create(req.body);
|
|
2255
|
-
res.status(201).json({ success: true, data: { rule } });
|
|
2256
|
-
} catch (error) {
|
|
2257
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2258
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
2259
|
-
}
|
|
2260
|
-
}
|
|
2261
|
-
async function update(req, res) {
|
|
2262
|
-
try {
|
|
2263
|
-
const { target, emailType } = req.body;
|
|
2264
|
-
if (target) {
|
|
2265
|
-
const mode = target.mode || "query";
|
|
2266
|
-
if (mode === "list") {
|
|
2267
|
-
if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
|
|
2268
|
-
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
2269
|
-
}
|
|
2270
|
-
} else {
|
|
2271
|
-
if (target.role && !isValidValue2(validAudiences, target.role)) {
|
|
2272
|
-
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
2273
|
-
}
|
|
2274
|
-
if (target.platform && platformValues && !platformValues.includes(target.platform)) {
|
|
2275
|
-
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2276
|
-
}
|
|
2277
|
-
if (target.conditions && !Array.isArray(target.conditions)) {
|
|
2278
|
-
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
2279
|
-
}
|
|
2280
|
-
}
|
|
2281
|
-
}
|
|
2282
|
-
if (emailType && !isValidValue2(validEmailTypes, emailType)) {
|
|
2283
|
-
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2284
|
-
}
|
|
2285
|
-
const rule = await ruleService.update(core.getParam(req, "id"), req.body);
|
|
2286
|
-
if (!rule) {
|
|
2287
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2288
|
-
}
|
|
2289
|
-
res.json({ success: true, data: { rule } });
|
|
2290
|
-
} catch (error) {
|
|
2291
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2292
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
2293
2133
|
}
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
try {
|
|
2297
|
-
const result = await ruleService.delete(core.getParam(req, "id"));
|
|
2298
|
-
if (!result.deleted && !result.disabled) {
|
|
2299
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2300
|
-
}
|
|
2301
|
-
res.json({ success: true, data: result });
|
|
2302
|
-
} catch (error) {
|
|
2303
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2304
|
-
res.status(500).json({ success: false, error: message });
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
async function toggleActive(req, res) {
|
|
2308
|
-
try {
|
|
2309
|
-
const rule = await ruleService.toggleActive(core.getParam(req, "id"));
|
|
2310
|
-
if (!rule) {
|
|
2311
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2312
|
-
}
|
|
2313
|
-
res.json({ success: true, data: { rule } });
|
|
2314
|
-
} catch (error) {
|
|
2315
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2316
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
2134
|
+
if (emailType && !isValidValue(validEmailTypes, emailType)) {
|
|
2135
|
+
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2317
2136
|
}
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
const result = await ruleService.dryRun(core.getParam(req, "id"));
|
|
2322
|
-
res.json({ success: true, data: result });
|
|
2323
|
-
} catch (error) {
|
|
2324
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2325
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
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" });
|
|
2326
2140
|
}
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
res.json({ success:
|
|
2333
|
-
} catch (error) {
|
|
2334
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2335
|
-
res.status(error instanceof Error && error.message === "Rule not found" ? 404 : 500).json({ success: false, error: message });
|
|
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" });
|
|
2336
2147
|
}
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
const page = Math.max(1, parseInt(String(Array.isArray(pageParam) ? pageParam[0] : pageParam), 10) || 1);
|
|
2344
|
-
const from = req.query.from ? String(req.query.from) : void 0;
|
|
2345
|
-
const to = req.query.to ? String(req.query.to) : void 0;
|
|
2346
|
-
const logs = await ruleService.getRunHistory(limit, { page, from, to });
|
|
2347
|
-
const total = await ruleService.getRunHistoryCount({ from, to });
|
|
2348
|
-
res.json({ success: true, data: { logs, total } });
|
|
2349
|
-
} catch (error) {
|
|
2350
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2351
|
-
res.status(500).json({ success: false, error: message });
|
|
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" });
|
|
2352
2154
|
}
|
|
2353
|
-
|
|
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
|
+
});
|
|
2354
2177
|
return { list, getById, create, update, remove, toggleActive, dryRun, runHistory, clone };
|
|
2355
2178
|
}
|
|
2356
2179
|
function createRunnerController(runnerService, EmailRuleRunLog, logger) {
|
|
2357
|
-
|
|
2180
|
+
const triggerManualRun = asyncHandler(async (_req, res) => {
|
|
2358
2181
|
const { runId } = runnerService.trigger(RUN_TRIGGER.Manual);
|
|
2359
2182
|
res.json({ success: true, data: { message: "Rule run triggered", runId } });
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
async function getStatusByRunId(req, res) {
|
|
2371
|
-
try {
|
|
2372
|
-
const status = await runnerService.getStatus(core.getParam(req, "runId"));
|
|
2373
|
-
if (!status) {
|
|
2374
|
-
res.status(404).json({ success: false, error: "Run not found" });
|
|
2375
|
-
return;
|
|
2376
|
-
}
|
|
2377
|
-
res.json({ success: true, data: status });
|
|
2378
|
-
} catch (error) {
|
|
2379
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2380
|
-
res.status(500).json({ success: false, error: message });
|
|
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;
|
|
2381
2193
|
}
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
}
|
|
2390
|
-
res.json({ success: true, data: { message: "Cancel requested" } });
|
|
2391
|
-
} catch (error) {
|
|
2392
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2393
|
-
res.status(500).json({ success: false, error: message });
|
|
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;
|
|
2394
2201
|
}
|
|
2395
|
-
|
|
2202
|
+
res.json({ success: true, data: { message: "Cancel requested" } });
|
|
2203
|
+
});
|
|
2396
2204
|
return { triggerManualRun, getLatestRun, getStatusByRunId, cancelRun };
|
|
2397
2205
|
}
|
|
2398
2206
|
|
|
2399
2207
|
// src/controllers/settings.controller.ts
|
|
2400
2208
|
function createSettingsController(EmailThrottleConfig) {
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
try {
|
|
2412
|
-
const { maxPerUserPerDay, maxPerUserPerWeek, minGapDays } = req.body;
|
|
2413
|
-
const updates = {};
|
|
2414
|
-
if (maxPerUserPerDay !== void 0) {
|
|
2415
|
-
if (!Number.isInteger(maxPerUserPerDay) || maxPerUserPerDay < 1) {
|
|
2416
|
-
return res.status(400).json({ success: false, error: "maxPerUserPerDay must be a positive integer" });
|
|
2417
|
-
}
|
|
2418
|
-
updates.maxPerUserPerDay = maxPerUserPerDay;
|
|
2419
|
-
}
|
|
2420
|
-
if (maxPerUserPerWeek !== void 0) {
|
|
2421
|
-
if (!Number.isInteger(maxPerUserPerWeek) || maxPerUserPerWeek < 1) {
|
|
2422
|
-
return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be a positive integer" });
|
|
2423
|
-
}
|
|
2424
|
-
updates.maxPerUserPerWeek = maxPerUserPerWeek;
|
|
2425
|
-
}
|
|
2426
|
-
if (minGapDays !== void 0) {
|
|
2427
|
-
if (!Number.isInteger(minGapDays) || minGapDays < 0) {
|
|
2428
|
-
return res.status(400).json({ success: false, error: "minGapDays must be a non-negative integer" });
|
|
2429
|
-
}
|
|
2430
|
-
updates.minGapDays = minGapDays;
|
|
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" });
|
|
2431
2219
|
}
|
|
2432
|
-
|
|
2433
|
-
|
|
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" });
|
|
2434
2225
|
}
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
if (
|
|
2439
|
-
return res.status(400).json({ success: false, error: "
|
|
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" });
|
|
2440
2231
|
}
|
|
2441
|
-
|
|
2442
|
-
config._id,
|
|
2443
|
-
{ $set: updates },
|
|
2444
|
-
{ new: true }
|
|
2445
|
-
);
|
|
2446
|
-
res.json({ success: true, data: { config: updated } });
|
|
2447
|
-
} catch (error) {
|
|
2448
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2449
|
-
res.status(500).json({ success: false, error: message });
|
|
2232
|
+
updates.minGapDays = minGapDays;
|
|
2450
2233
|
}
|
|
2451
|
-
|
|
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
|
+
});
|
|
2452
2250
|
return { getThrottleConfig, updateThrottleConfig };
|
|
2453
2251
|
}
|
|
2454
2252
|
|
|
2455
2253
|
// src/controllers/send-log.controller.ts
|
|
2456
2254
|
function createSendLogController(EmailRuleSend) {
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
const skip = (pageNum - 1) * limitNum;
|
|
2472
|
-
const [sends, total] = await Promise.all([
|
|
2473
|
-
EmailRuleSend.find(filter).sort({ sentAt: -1 }).skip(skip).limit(limitNum).lean(),
|
|
2474
|
-
EmailRuleSend.countDocuments(filter)
|
|
2475
|
-
]);
|
|
2476
|
-
res.json({ success: true, data: { sends, total } });
|
|
2477
|
-
} catch (error) {
|
|
2478
|
-
const message = error instanceof Error ? error.message : "Failed to query send logs";
|
|
2479
|
-
res.status(500).json({ success: false, error: message });
|
|
2480
|
-
}
|
|
2481
|
-
}
|
|
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
|
+
});
|
|
2482
2269
|
return { list };
|
|
2483
2270
|
}
|
|
2484
2271
|
|