@astralibx/email-rule-engine 3.0.2 → 5.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 +1 -1
- package/dist/index.d.mts +83 -10
- package/dist/index.d.ts +83 -10
- package/dist/index.js +537 -102
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +536 -101
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ var Handlebars = require('handlebars');
|
|
|
5
5
|
var mjml2html = require('mjml');
|
|
6
6
|
var htmlToText = require('html-to-text');
|
|
7
7
|
var core = require('@astralibx/core');
|
|
8
|
-
var
|
|
8
|
+
var crypto2 = require('crypto');
|
|
9
9
|
var express = require('express');
|
|
10
10
|
var zod = require('zod');
|
|
11
11
|
|
|
@@ -13,7 +13,7 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
|
13
13
|
|
|
14
14
|
var Handlebars__default = /*#__PURE__*/_interopDefault(Handlebars);
|
|
15
15
|
var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
|
|
16
|
-
var
|
|
16
|
+
var crypto2__default = /*#__PURE__*/_interopDefault(crypto2);
|
|
17
17
|
|
|
18
18
|
// src/schemas/template.schema.ts
|
|
19
19
|
|
|
@@ -76,9 +76,9 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
|
|
|
76
76
|
required: true,
|
|
77
77
|
...platformValues ? { enum: platformValues } : {}
|
|
78
78
|
},
|
|
79
|
-
subject: { type: String, required: true },
|
|
80
|
-
body: { type: String, required: true },
|
|
81
79
|
textBody: String,
|
|
80
|
+
subjects: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one subject is required"] },
|
|
81
|
+
bodies: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one body is required"] },
|
|
82
82
|
variables: [{ type: String }],
|
|
83
83
|
version: { type: Number, default: 1 },
|
|
84
84
|
isActive: { type: Boolean, default: true, index: true }
|
|
@@ -110,9 +110,9 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
|
|
|
110
110
|
category: input.category,
|
|
111
111
|
audience: input.audience,
|
|
112
112
|
platform: input.platform,
|
|
113
|
-
subject: input.subject,
|
|
114
|
-
body: input.body,
|
|
115
113
|
textBody: input.textBody,
|
|
114
|
+
subjects: input.subjects,
|
|
115
|
+
bodies: input.bodies,
|
|
116
116
|
variables: input.variables || [],
|
|
117
117
|
version: 1,
|
|
118
118
|
isActive: true
|
|
@@ -132,13 +132,14 @@ function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix)
|
|
|
132
132
|
value: { type: mongoose.Schema.Types.Mixed }
|
|
133
133
|
}, { _id: false });
|
|
134
134
|
const RuleTargetSchema = new mongoose.Schema({
|
|
135
|
-
|
|
135
|
+
mode: { type: String, enum: ["query", "list"], required: true },
|
|
136
|
+
role: { type: String, enum: audienceValues || Object.values(TEMPLATE_AUDIENCE) },
|
|
136
137
|
platform: {
|
|
137
138
|
type: String,
|
|
138
|
-
required: true,
|
|
139
139
|
...platformValues ? { enum: platformValues } : {}
|
|
140
140
|
},
|
|
141
|
-
conditions: [RuleConditionSchema]
|
|
141
|
+
conditions: [RuleConditionSchema],
|
|
142
|
+
identifiers: [{ type: String }]
|
|
142
143
|
}, { _id: false });
|
|
143
144
|
const RuleRunStatsSchema = new mongoose.Schema({
|
|
144
145
|
matched: { type: Number, default: 0 },
|
|
@@ -215,6 +216,8 @@ function createEmailRuleSendSchema(collectionPrefix) {
|
|
|
215
216
|
accountId: { type: String },
|
|
216
217
|
senderName: { type: String },
|
|
217
218
|
subject: { type: String },
|
|
219
|
+
subjectIndex: { type: Number },
|
|
220
|
+
bodyIndex: { type: Number },
|
|
218
221
|
failureReason: { type: String }
|
|
219
222
|
},
|
|
220
223
|
{
|
|
@@ -267,7 +270,9 @@ function createEmailRuleRunLogSchema(collectionPrefix) {
|
|
|
267
270
|
}, { _id: false });
|
|
268
271
|
const schema = new mongoose.Schema(
|
|
269
272
|
{
|
|
273
|
+
runId: { type: String, index: true },
|
|
270
274
|
runAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
|
|
275
|
+
status: { type: String, enum: ["completed", "cancelled", "failed"], default: "completed" },
|
|
271
276
|
triggeredBy: { type: String, enum: Object.values(RUN_TRIGGER), required: true },
|
|
272
277
|
duration: { type: Number, required: true },
|
|
273
278
|
rulesProcessed: { type: Number, required: true },
|
|
@@ -437,6 +442,16 @@ var TemplateRenderService = class {
|
|
|
437
442
|
const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
|
|
438
443
|
return { subjectFn, bodyFn, textBodyFn };
|
|
439
444
|
}
|
|
445
|
+
compileBatchVariants(subjects, bodies, textBody) {
|
|
446
|
+
const subjectFns = subjects.map((s) => Handlebars__default.default.compile(s, { strict: true }));
|
|
447
|
+
const bodyFns = bodies.map((b) => {
|
|
448
|
+
const mjmlSource = wrapInMjml(b);
|
|
449
|
+
const htmlWithHandlebars = compileMjml(mjmlSource);
|
|
450
|
+
return Handlebars__default.default.compile(htmlWithHandlebars, { strict: true });
|
|
451
|
+
});
|
|
452
|
+
const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
|
|
453
|
+
return { subjectFns, bodyFns, textBodyFn };
|
|
454
|
+
}
|
|
440
455
|
renderFromCompiled(compiled, data) {
|
|
441
456
|
const subject = compiled.subjectFn(data);
|
|
442
457
|
const html = compiled.bodyFn(data);
|
|
@@ -446,6 +461,9 @@ var TemplateRenderService = class {
|
|
|
446
461
|
renderPreview(subject, body, data, textBody) {
|
|
447
462
|
return this.renderSingle(subject, body, data, textBody);
|
|
448
463
|
}
|
|
464
|
+
htmlToText(html) {
|
|
465
|
+
return htmlToPlainText(html);
|
|
466
|
+
}
|
|
449
467
|
extractVariables(template) {
|
|
450
468
|
const regex = /\{\{(?!#|\/|!|>)([^}]+)\}\}/g;
|
|
451
469
|
const variables = /* @__PURE__ */ new Set();
|
|
@@ -541,9 +559,9 @@ var UPDATEABLE_FIELDS = /* @__PURE__ */ new Set([
|
|
|
541
559
|
"category",
|
|
542
560
|
"audience",
|
|
543
561
|
"platform",
|
|
544
|
-
"subject",
|
|
545
|
-
"body",
|
|
546
562
|
"textBody",
|
|
563
|
+
"subjects",
|
|
564
|
+
"bodies",
|
|
547
565
|
"variables",
|
|
548
566
|
"isActive"
|
|
549
567
|
]);
|
|
@@ -576,35 +594,49 @@ var TemplateService = class {
|
|
|
576
594
|
if (existing) {
|
|
577
595
|
throw new DuplicateSlugError(slug);
|
|
578
596
|
}
|
|
579
|
-
const
|
|
580
|
-
if (
|
|
581
|
-
|
|
597
|
+
const { subjects, bodies } = input;
|
|
598
|
+
if (subjects.length === 0) throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
|
|
599
|
+
if (bodies.length === 0) throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
|
|
600
|
+
for (const b of bodies) {
|
|
601
|
+
const validation = this.renderService.validateTemplate(b);
|
|
602
|
+
if (!validation.valid) {
|
|
603
|
+
throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
|
|
604
|
+
}
|
|
582
605
|
}
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
);
|
|
606
|
+
const allContent = [...subjects, ...bodies, input.textBody || ""].join(" ");
|
|
607
|
+
const variables = input.variables || this.renderService.extractVariables(allContent);
|
|
586
608
|
return this.EmailTemplate.createTemplate({
|
|
587
609
|
...input,
|
|
588
610
|
slug,
|
|
611
|
+
subjects,
|
|
612
|
+
bodies,
|
|
589
613
|
variables
|
|
590
614
|
});
|
|
591
615
|
}
|
|
592
616
|
async update(id, input) {
|
|
593
617
|
const template = await this.EmailTemplate.findById(id);
|
|
594
618
|
if (!template) return null;
|
|
595
|
-
if (input.
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
619
|
+
if (input.subjects && input.subjects.length === 0) {
|
|
620
|
+
throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
|
|
621
|
+
}
|
|
622
|
+
if (input.bodies && input.bodies.length === 0) {
|
|
623
|
+
throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
|
|
624
|
+
}
|
|
625
|
+
const bodiesToValidate = input.bodies || null;
|
|
626
|
+
if (bodiesToValidate) {
|
|
627
|
+
for (const b of bodiesToValidate) {
|
|
628
|
+
const validation = this.renderService.validateTemplate(b);
|
|
629
|
+
if (!validation.valid) {
|
|
630
|
+
throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
|
|
631
|
+
}
|
|
599
632
|
}
|
|
600
633
|
}
|
|
601
|
-
if (input.
|
|
602
|
-
const
|
|
603
|
-
const
|
|
634
|
+
if (input.textBody || input.subjects || input.bodies) {
|
|
635
|
+
const subjects = input.subjects ?? template.subjects;
|
|
636
|
+
const bodies = input.bodies ?? template.bodies;
|
|
604
637
|
const textBody = input.textBody ?? template.textBody;
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
);
|
|
638
|
+
const allContent = [...subjects, ...bodies, textBody || ""].join(" ");
|
|
639
|
+
input.variables = this.renderService.extractVariables(allContent);
|
|
608
640
|
}
|
|
609
641
|
const setFields = {};
|
|
610
642
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -613,7 +645,7 @@ var TemplateService = class {
|
|
|
613
645
|
}
|
|
614
646
|
}
|
|
615
647
|
const update = { $set: setFields };
|
|
616
|
-
if (input.
|
|
648
|
+
if (input.textBody || input.subjects || input.bodies) {
|
|
617
649
|
update["$inc"] = { version: 1 };
|
|
618
650
|
}
|
|
619
651
|
return this.EmailTemplate.findByIdAndUpdate(
|
|
@@ -637,8 +669,8 @@ var TemplateService = class {
|
|
|
637
669
|
const template = await this.EmailTemplate.findById(id);
|
|
638
670
|
if (!template) return null;
|
|
639
671
|
return this.renderService.renderPreview(
|
|
640
|
-
template.
|
|
641
|
-
template.
|
|
672
|
+
template.subjects[0],
|
|
673
|
+
template.bodies[0],
|
|
642
674
|
sampleData,
|
|
643
675
|
template.textBody
|
|
644
676
|
);
|
|
@@ -660,8 +692,8 @@ var TemplateService = class {
|
|
|
660
692
|
return { success: false, error: "Template not found" };
|
|
661
693
|
}
|
|
662
694
|
const rendered = this.renderService.renderSingle(
|
|
663
|
-
template.
|
|
664
|
-
template.
|
|
695
|
+
template.subjects[0],
|
|
696
|
+
template.bodies[0],
|
|
665
697
|
sampleData,
|
|
666
698
|
template.textBody
|
|
667
699
|
);
|
|
@@ -680,6 +712,9 @@ var TemplateService = class {
|
|
|
680
712
|
};
|
|
681
713
|
|
|
682
714
|
// src/services/rule.service.ts
|
|
715
|
+
function isQueryTarget(target) {
|
|
716
|
+
return !target.mode || target.mode === "query";
|
|
717
|
+
}
|
|
683
718
|
var UPDATEABLE_FIELDS2 = /* @__PURE__ */ new Set([
|
|
684
719
|
"name",
|
|
685
720
|
"description",
|
|
@@ -733,13 +768,22 @@ var RuleService = class {
|
|
|
733
768
|
if (!template) {
|
|
734
769
|
throw new TemplateNotFoundError(input.templateId);
|
|
735
770
|
}
|
|
736
|
-
|
|
737
|
-
input.target.role
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
771
|
+
if (isQueryTarget(input.target)) {
|
|
772
|
+
if (!input.target.role || !input.target.platform) {
|
|
773
|
+
throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
|
|
774
|
+
}
|
|
775
|
+
const compatError = validateRuleTemplateCompat(
|
|
776
|
+
input.target.role,
|
|
777
|
+
input.target.platform,
|
|
778
|
+
template
|
|
779
|
+
);
|
|
780
|
+
if (compatError) {
|
|
781
|
+
throw new RuleTemplateIncompatibleError(compatError);
|
|
782
|
+
}
|
|
783
|
+
} else {
|
|
784
|
+
if (!input.target.identifiers || input.target.identifiers.length === 0) {
|
|
785
|
+
throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
|
|
786
|
+
}
|
|
743
787
|
}
|
|
744
788
|
return this.EmailRule.createRule(input);
|
|
745
789
|
}
|
|
@@ -747,14 +791,25 @@ var RuleService = class {
|
|
|
747
791
|
const rule = await this.EmailRule.findById(id);
|
|
748
792
|
if (!rule) return null;
|
|
749
793
|
const templateId = input.templateId ?? rule.templateId.toString();
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
794
|
+
if (input.target) {
|
|
795
|
+
if (isQueryTarget(input.target)) {
|
|
796
|
+
if (!input.target.role || !input.target.platform) {
|
|
797
|
+
throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
if (!input.target.identifiers || input.target.identifiers.length === 0) {
|
|
801
|
+
throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const effectiveTarget = input.target ?? rule.target;
|
|
806
|
+
if ((input.templateId || input.target) && isQueryTarget(effectiveTarget)) {
|
|
807
|
+
const qt = effectiveTarget;
|
|
753
808
|
const template = await this.EmailTemplate.findById(templateId);
|
|
754
809
|
if (!template) {
|
|
755
810
|
throw new TemplateNotFoundError(templateId);
|
|
756
811
|
}
|
|
757
|
-
const compatError = validateRuleTemplateCompat(
|
|
812
|
+
const compatError = validateRuleTemplateCompat(qt.role, qt.platform, template);
|
|
758
813
|
if (compatError) {
|
|
759
814
|
throw new RuleTemplateIncompatibleError(compatError);
|
|
760
815
|
}
|
|
@@ -803,6 +858,11 @@ var RuleService = class {
|
|
|
803
858
|
if (!rule) {
|
|
804
859
|
throw new RuleNotFoundError(id);
|
|
805
860
|
}
|
|
861
|
+
const target = rule.target;
|
|
862
|
+
if (target.mode === "list") {
|
|
863
|
+
const identifiers = target.identifiers || [];
|
|
864
|
+
return { matchedCount: identifiers.length, ruleId: id };
|
|
865
|
+
}
|
|
806
866
|
const users = await this.config.adapters.queryUsers(rule.target, 5e4);
|
|
807
867
|
return { matchedCount: users.length, ruleId: id };
|
|
808
868
|
}
|
|
@@ -819,7 +879,7 @@ var RedisLock = class {
|
|
|
819
879
|
}
|
|
820
880
|
lockValue = "";
|
|
821
881
|
async acquire() {
|
|
822
|
-
this.lockValue =
|
|
882
|
+
this.lockValue = crypto2__default.default.randomUUID();
|
|
823
883
|
const result = await this.redis.set(this.lockKey, this.lockValue, "PX", this.ttlMs, "NX");
|
|
824
884
|
return result === "OK";
|
|
825
885
|
}
|
|
@@ -832,8 +892,6 @@ var RedisLock = class {
|
|
|
832
892
|
}
|
|
833
893
|
}
|
|
834
894
|
};
|
|
835
|
-
|
|
836
|
-
// src/services/rule-runner.service.ts
|
|
837
895
|
var MS_PER_DAY = 864e5;
|
|
838
896
|
var DEFAULT_LOCK_TTL_MS = 30 * 60 * 1e3;
|
|
839
897
|
var IDENTIFIER_CHUNK_SIZE = 50;
|
|
@@ -862,11 +920,12 @@ var RuleRunnerService = class {
|
|
|
862
920
|
this.EmailRuleRunLog = EmailRuleRunLog;
|
|
863
921
|
this.EmailThrottleConfig = EmailThrottleConfig;
|
|
864
922
|
this.config = config;
|
|
865
|
-
|
|
923
|
+
this.keyPrefix = config.redis.keyPrefix || "";
|
|
924
|
+
this.redis = config.redis.connection;
|
|
866
925
|
const lockTTL = config.options?.lockTTLMs || DEFAULT_LOCK_TTL_MS;
|
|
867
926
|
this.lock = new RedisLock(
|
|
868
|
-
|
|
869
|
-
`${keyPrefix}email-rule-runner:lock`,
|
|
927
|
+
this.redis,
|
|
928
|
+
`${this.keyPrefix}email-rule-runner:lock`,
|
|
870
929
|
lockTTL,
|
|
871
930
|
config.logger
|
|
872
931
|
);
|
|
@@ -875,7 +934,11 @@ var RuleRunnerService = class {
|
|
|
875
934
|
templateRenderer = new TemplateRenderService();
|
|
876
935
|
lock;
|
|
877
936
|
logger;
|
|
878
|
-
|
|
937
|
+
redis;
|
|
938
|
+
keyPrefix;
|
|
939
|
+
async runAllRules(triggeredBy = RUN_TRIGGER.Cron, runId) {
|
|
940
|
+
if (!runId) runId = crypto2__default.default.randomUUID();
|
|
941
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
879
942
|
if (this.config.options?.sendWindow) {
|
|
880
943
|
const { startHour, endHour, timezone } = this.config.options.sendWindow;
|
|
881
944
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -883,30 +946,45 @@ var RuleRunnerService = class {
|
|
|
883
946
|
const currentHour = parseInt(formatter.format(now), 10);
|
|
884
947
|
if (currentHour < startHour || currentHour >= endHour) {
|
|
885
948
|
this.logger.info("Outside send window, skipping run", { currentHour, startHour, endHour, timezone });
|
|
886
|
-
return;
|
|
949
|
+
return { runId };
|
|
887
950
|
}
|
|
888
951
|
}
|
|
889
952
|
const lockAcquired = await this.lock.acquire();
|
|
890
953
|
if (!lockAcquired) {
|
|
891
954
|
this.logger.warn("Rule runner already executing, skipping");
|
|
892
|
-
return;
|
|
955
|
+
return { runId };
|
|
893
956
|
}
|
|
894
957
|
const runStartTime = Date.now();
|
|
958
|
+
await this.updateRunProgress(runId, {
|
|
959
|
+
runId,
|
|
960
|
+
status: "running",
|
|
961
|
+
currentRule: "",
|
|
962
|
+
progress: { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 },
|
|
963
|
+
startedAt,
|
|
964
|
+
elapsed: 0
|
|
965
|
+
});
|
|
966
|
+
let runStatus = "completed";
|
|
895
967
|
try {
|
|
896
968
|
const throttleConfig = await this.EmailThrottleConfig.getConfig();
|
|
897
969
|
const activeRules = await this.EmailRule.findActive();
|
|
898
970
|
this.config.hooks?.onRunStart?.({ rulesCount: activeRules.length, triggeredBy });
|
|
971
|
+
await this.updateRunProgress(runId, {
|
|
972
|
+
progress: { rulesTotal: activeRules.length, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 }
|
|
973
|
+
});
|
|
899
974
|
if (activeRules.length === 0) {
|
|
900
975
|
this.logger.info("No active rules to process");
|
|
901
976
|
await this.EmailRuleRunLog.create({
|
|
977
|
+
runId,
|
|
902
978
|
runAt: /* @__PURE__ */ new Date(),
|
|
903
979
|
triggeredBy,
|
|
904
980
|
duration: Date.now() - runStartTime,
|
|
905
981
|
rulesProcessed: 0,
|
|
906
982
|
totalStats: { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 },
|
|
907
|
-
perRuleStats: []
|
|
983
|
+
perRuleStats: [],
|
|
984
|
+
status: "completed"
|
|
908
985
|
});
|
|
909
|
-
|
|
986
|
+
await this.updateRunProgress(runId, { status: "completed", elapsed: Date.now() - runStartTime });
|
|
987
|
+
return { runId };
|
|
910
988
|
}
|
|
911
989
|
const templateIds = [...new Set(activeRules.map((r) => r.templateId.toString()))];
|
|
912
990
|
const templates = await this.EmailTemplate.find({ _id: { $in: templateIds } }).lean();
|
|
@@ -919,13 +997,43 @@ var RuleRunnerService = class {
|
|
|
919
997
|
}).lean();
|
|
920
998
|
const throttleMap = this.buildThrottleMap(recentSends);
|
|
921
999
|
const perRuleStats = [];
|
|
922
|
-
|
|
923
|
-
|
|
1000
|
+
let totalSent = 0;
|
|
1001
|
+
let totalFailed = 0;
|
|
1002
|
+
let totalSkipped = 0;
|
|
1003
|
+
let totalInvalid = 0;
|
|
1004
|
+
for (let ri = 0; ri < activeRules.length; ri++) {
|
|
1005
|
+
const rule = activeRules[ri];
|
|
1006
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1007
|
+
const cancelled = await this.redis.exists(cancelKey);
|
|
1008
|
+
if (cancelled) {
|
|
1009
|
+
runStatus = "cancelled";
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
await this.updateRunProgress(runId, {
|
|
1013
|
+
currentRule: rule.name,
|
|
1014
|
+
elapsed: Date.now() - runStartTime
|
|
1015
|
+
});
|
|
1016
|
+
const stats = await this.executeRule(rule, throttleMap, throttleConfig, templateMap, runId);
|
|
1017
|
+
totalSent += stats.sent;
|
|
1018
|
+
totalFailed += stats.errors;
|
|
1019
|
+
totalSkipped += stats.skipped + stats.skippedByThrottle;
|
|
1020
|
+
totalInvalid += stats.matched - stats.sent - stats.skipped - stats.skippedByThrottle - stats.errors;
|
|
924
1021
|
perRuleStats.push({
|
|
925
1022
|
ruleId: rule._id.toString(),
|
|
926
1023
|
ruleName: rule.name,
|
|
927
1024
|
...stats
|
|
928
1025
|
});
|
|
1026
|
+
await this.updateRunProgress(runId, {
|
|
1027
|
+
progress: {
|
|
1028
|
+
rulesTotal: activeRules.length,
|
|
1029
|
+
rulesCompleted: ri + 1,
|
|
1030
|
+
sent: totalSent,
|
|
1031
|
+
failed: totalFailed,
|
|
1032
|
+
skipped: totalSkipped,
|
|
1033
|
+
invalid: totalInvalid < 0 ? 0 : totalInvalid
|
|
1034
|
+
},
|
|
1035
|
+
elapsed: Date.now() - runStartTime
|
|
1036
|
+
});
|
|
929
1037
|
}
|
|
930
1038
|
const totalStats = perRuleStats.reduce(
|
|
931
1039
|
(acc, s) => ({
|
|
@@ -938,13 +1046,16 @@ var RuleRunnerService = class {
|
|
|
938
1046
|
{ matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 }
|
|
939
1047
|
);
|
|
940
1048
|
await this.EmailRuleRunLog.create({
|
|
1049
|
+
runId,
|
|
941
1050
|
runAt: /* @__PURE__ */ new Date(),
|
|
942
1051
|
triggeredBy,
|
|
943
1052
|
duration: Date.now() - runStartTime,
|
|
944
1053
|
rulesProcessed: activeRules.length,
|
|
945
1054
|
totalStats,
|
|
946
|
-
perRuleStats
|
|
1055
|
+
perRuleStats,
|
|
1056
|
+
status: runStatus
|
|
947
1057
|
});
|
|
1058
|
+
await this.updateRunProgress(runId, { status: runStatus, currentRule: "", elapsed: Date.now() - runStartTime });
|
|
948
1059
|
this.config.hooks?.onRunComplete?.({ duration: Date.now() - runStartTime, totalStats, perRuleStats });
|
|
949
1060
|
this.logger.info("Rule run completed", {
|
|
950
1061
|
triggeredBy,
|
|
@@ -953,11 +1064,16 @@ var RuleRunnerService = class {
|
|
|
953
1064
|
totalSkipped: totalStats.skipped,
|
|
954
1065
|
duration: Date.now() - runStartTime
|
|
955
1066
|
});
|
|
1067
|
+
} catch (err) {
|
|
1068
|
+
runStatus = "failed";
|
|
1069
|
+
await this.updateRunProgress(runId, { status: "failed", elapsed: Date.now() - runStartTime });
|
|
1070
|
+
throw err;
|
|
956
1071
|
} finally {
|
|
957
1072
|
await this.lock.release();
|
|
958
1073
|
}
|
|
1074
|
+
return { runId };
|
|
959
1075
|
}
|
|
960
|
-
async executeRule(rule, throttleMap, throttleConfig, templateMap) {
|
|
1076
|
+
async executeRule(rule, throttleMap, throttleConfig, templateMap, runId) {
|
|
961
1077
|
const stats = { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 };
|
|
962
1078
|
const template = templateMap?.get(rule.templateId.toString()) ?? await this.EmailTemplate.findById(rule.templateId);
|
|
963
1079
|
if (!template) {
|
|
@@ -965,6 +1081,182 @@ var RuleRunnerService = class {
|
|
|
965
1081
|
stats.errors = 1;
|
|
966
1082
|
return stats;
|
|
967
1083
|
}
|
|
1084
|
+
const isListMode = rule.target?.mode === "list";
|
|
1085
|
+
if (isListMode) {
|
|
1086
|
+
return this.executeListMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1087
|
+
}
|
|
1088
|
+
return this.executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1089
|
+
}
|
|
1090
|
+
async executeListMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
1091
|
+
const rawIdentifiers = rule.target.identifiers || [];
|
|
1092
|
+
const uniqueEmails = [...new Set(rawIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean))];
|
|
1093
|
+
const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
|
|
1094
|
+
const emailsToProcess = uniqueEmails.slice(0, limit);
|
|
1095
|
+
stats.matched = emailsToProcess.length;
|
|
1096
|
+
const ruleId = rule._id.toString();
|
|
1097
|
+
const templateId = rule.templateId.toString();
|
|
1098
|
+
this.config.hooks?.onRuleStart?.({ ruleId, ruleName: rule.name, matchedCount: emailsToProcess.length });
|
|
1099
|
+
if (emailsToProcess.length === 0) return stats;
|
|
1100
|
+
const identifierResults = await processInChunks(
|
|
1101
|
+
emailsToProcess,
|
|
1102
|
+
async (email) => {
|
|
1103
|
+
const result = await this.config.adapters.findIdentifier(email);
|
|
1104
|
+
return result ? { email, ...result } : null;
|
|
1105
|
+
},
|
|
1106
|
+
IDENTIFIER_CHUNK_SIZE
|
|
1107
|
+
);
|
|
1108
|
+
const identifierMap = /* @__PURE__ */ new Map();
|
|
1109
|
+
for (const result of identifierResults) {
|
|
1110
|
+
if (result) {
|
|
1111
|
+
identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const validEmails = emailsToProcess.filter((e) => identifierMap.has(e));
|
|
1115
|
+
const identifierIds = validEmails.map((e) => identifierMap.get(e).id);
|
|
1116
|
+
const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: identifierIds } }).sort({ sentAt: -1 }).lean();
|
|
1117
|
+
const sendMap = /* @__PURE__ */ new Map();
|
|
1118
|
+
for (const send of allRuleSends) {
|
|
1119
|
+
const uid = send.userId.toString();
|
|
1120
|
+
if (!sendMap.has(uid)) {
|
|
1121
|
+
sendMap.set(uid, send);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const compiledVariants = this.templateRenderer.compileBatchVariants(
|
|
1125
|
+
template.subjects,
|
|
1126
|
+
template.bodies,
|
|
1127
|
+
template.textBody
|
|
1128
|
+
);
|
|
1129
|
+
let totalProcessed = 0;
|
|
1130
|
+
for (let i = 0; i < emailsToProcess.length; i++) {
|
|
1131
|
+
const email = emailsToProcess[i];
|
|
1132
|
+
if (runId && i % 10 === 0) {
|
|
1133
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1134
|
+
const cancelled = await this.redis.exists(cancelKey);
|
|
1135
|
+
if (cancelled) break;
|
|
1136
|
+
}
|
|
1137
|
+
try {
|
|
1138
|
+
const identifier = identifierMap.get(email);
|
|
1139
|
+
if (!identifier) {
|
|
1140
|
+
stats.skipped++;
|
|
1141
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "invalid" });
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
const dedupKey = identifier.id;
|
|
1145
|
+
const lastSend = sendMap.get(dedupKey);
|
|
1146
|
+
if (lastSend) {
|
|
1147
|
+
if (rule.sendOnce && !rule.resendAfterDays) {
|
|
1148
|
+
stats.skipped++;
|
|
1149
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (rule.resendAfterDays) {
|
|
1153
|
+
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1154
|
+
if (daysSince < rule.resendAfterDays) {
|
|
1155
|
+
stats.skipped++;
|
|
1156
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1157
|
+
continue;
|
|
1158
|
+
}
|
|
1159
|
+
} else {
|
|
1160
|
+
stats.skipped++;
|
|
1161
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
if (!this.checkThrottle(rule, dedupKey, email, throttleMap, throttleConfig, stats)) continue;
|
|
1166
|
+
const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
|
|
1167
|
+
if (!agentSelection) {
|
|
1168
|
+
stats.skipped++;
|
|
1169
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
const user = { _id: identifier.id, email };
|
|
1173
|
+
const templateData = this.config.adapters.resolveData(user);
|
|
1174
|
+
const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
|
|
1175
|
+
const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
|
|
1176
|
+
const renderedSubject = compiledVariants.subjectFns[si](templateData);
|
|
1177
|
+
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1178
|
+
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1179
|
+
let finalHtml = renderedHtml;
|
|
1180
|
+
let finalText = renderedText;
|
|
1181
|
+
let finalSubject = renderedSubject;
|
|
1182
|
+
if (this.config.hooks?.beforeSend) {
|
|
1183
|
+
try {
|
|
1184
|
+
const modified = await this.config.hooks.beforeSend({
|
|
1185
|
+
htmlBody: finalHtml,
|
|
1186
|
+
textBody: finalText,
|
|
1187
|
+
subject: finalSubject,
|
|
1188
|
+
account: {
|
|
1189
|
+
id: agentSelection.accountId,
|
|
1190
|
+
email: agentSelection.email,
|
|
1191
|
+
metadata: agentSelection.metadata
|
|
1192
|
+
},
|
|
1193
|
+
user: {
|
|
1194
|
+
id: dedupKey,
|
|
1195
|
+
email,
|
|
1196
|
+
name: ""
|
|
1197
|
+
}
|
|
1198
|
+
});
|
|
1199
|
+
finalHtml = modified.htmlBody;
|
|
1200
|
+
finalText = modified.textBody;
|
|
1201
|
+
finalSubject = modified.subject;
|
|
1202
|
+
} catch (hookErr) {
|
|
1203
|
+
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1204
|
+
stats.errors++;
|
|
1205
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
await this.config.adapters.sendEmail({
|
|
1210
|
+
identifierId: identifier.id,
|
|
1211
|
+
contactId: identifier.contactId,
|
|
1212
|
+
accountId: agentSelection.accountId,
|
|
1213
|
+
subject: finalSubject,
|
|
1214
|
+
htmlBody: finalHtml,
|
|
1215
|
+
textBody: finalText,
|
|
1216
|
+
ruleId,
|
|
1217
|
+
autoApprove: rule.autoApprove ?? true
|
|
1218
|
+
});
|
|
1219
|
+
await this.EmailRuleSend.logSend(
|
|
1220
|
+
ruleId,
|
|
1221
|
+
dedupKey,
|
|
1222
|
+
identifier.id,
|
|
1223
|
+
void 0,
|
|
1224
|
+
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
|
|
1225
|
+
);
|
|
1226
|
+
const current = throttleMap.get(dedupKey) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1227
|
+
throttleMap.set(dedupKey, {
|
|
1228
|
+
today: current.today + 1,
|
|
1229
|
+
thisWeek: current.thisWeek + 1,
|
|
1230
|
+
lastSentDate: /* @__PURE__ */ new Date()
|
|
1231
|
+
});
|
|
1232
|
+
stats.sent++;
|
|
1233
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent" });
|
|
1234
|
+
totalProcessed++;
|
|
1235
|
+
if (runId && totalProcessed % 10 === 0) {
|
|
1236
|
+
await this.updateRunSendProgress(runId, stats);
|
|
1237
|
+
}
|
|
1238
|
+
if (i < emailsToProcess.length - 1) {
|
|
1239
|
+
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1240
|
+
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1241
|
+
if (delayMs > 0 || jitterMs > 0) {
|
|
1242
|
+
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1243
|
+
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
stats.errors++;
|
|
1248
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
|
|
1249
|
+
this.logger.error(`Rule "${rule.name}" failed for identifier ${email}`, { error: err });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
await this.EmailRule.findByIdAndUpdate(rule._id, {
|
|
1253
|
+
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1254
|
+
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1255
|
+
});
|
|
1256
|
+
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats });
|
|
1257
|
+
return stats;
|
|
1258
|
+
}
|
|
1259
|
+
async executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
968
1260
|
let users;
|
|
969
1261
|
try {
|
|
970
1262
|
users = await this.config.adapters.queryUsers(rule.target, rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500);
|
|
@@ -1001,15 +1293,21 @@ var RuleRunnerService = class {
|
|
|
1001
1293
|
identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1002
1294
|
}
|
|
1003
1295
|
}
|
|
1004
|
-
const
|
|
1005
|
-
template.
|
|
1006
|
-
template.
|
|
1296
|
+
const compiledVariants = this.templateRenderer.compileBatchVariants(
|
|
1297
|
+
template.subjects,
|
|
1298
|
+
template.bodies,
|
|
1007
1299
|
template.textBody
|
|
1008
1300
|
);
|
|
1009
1301
|
const ruleId = rule._id.toString();
|
|
1010
1302
|
const templateId = rule.templateId.toString();
|
|
1303
|
+
let totalProcessed = 0;
|
|
1011
1304
|
for (let i = 0; i < users.length; i++) {
|
|
1012
1305
|
const user = users[i];
|
|
1306
|
+
if (runId && i % 10 === 0) {
|
|
1307
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1308
|
+
const cancelled = await this.redis.exists(cancelKey);
|
|
1309
|
+
if (cancelled) break;
|
|
1310
|
+
}
|
|
1013
1311
|
try {
|
|
1014
1312
|
const userId = user._id?.toString();
|
|
1015
1313
|
const email = user.email;
|
|
@@ -1052,14 +1350,48 @@ var RuleRunnerService = class {
|
|
|
1052
1350
|
continue;
|
|
1053
1351
|
}
|
|
1054
1352
|
const templateData = this.config.adapters.resolveData(user);
|
|
1055
|
-
const
|
|
1353
|
+
const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
|
|
1354
|
+
const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
|
|
1355
|
+
const renderedSubject = compiledVariants.subjectFns[si](templateData);
|
|
1356
|
+
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1357
|
+
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1358
|
+
let finalHtml = renderedHtml;
|
|
1359
|
+
let finalText = renderedText;
|
|
1360
|
+
let finalSubject = renderedSubject;
|
|
1361
|
+
if (this.config.hooks?.beforeSend) {
|
|
1362
|
+
try {
|
|
1363
|
+
const modified = await this.config.hooks.beforeSend({
|
|
1364
|
+
htmlBody: finalHtml,
|
|
1365
|
+
textBody: finalText,
|
|
1366
|
+
subject: finalSubject,
|
|
1367
|
+
account: {
|
|
1368
|
+
id: agentSelection.accountId,
|
|
1369
|
+
email: agentSelection.email,
|
|
1370
|
+
metadata: agentSelection.metadata
|
|
1371
|
+
},
|
|
1372
|
+
user: {
|
|
1373
|
+
id: String(userId),
|
|
1374
|
+
email,
|
|
1375
|
+
name: String(user.name || user.firstName || "")
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
finalHtml = modified.htmlBody;
|
|
1379
|
+
finalText = modified.textBody;
|
|
1380
|
+
finalSubject = modified.subject;
|
|
1381
|
+
} catch (hookErr) {
|
|
1382
|
+
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1383
|
+
stats.errors++;
|
|
1384
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1056
1388
|
await this.config.adapters.sendEmail({
|
|
1057
1389
|
identifierId: identifier.id,
|
|
1058
1390
|
contactId: identifier.contactId,
|
|
1059
1391
|
accountId: agentSelection.accountId,
|
|
1060
|
-
subject:
|
|
1061
|
-
htmlBody:
|
|
1062
|
-
textBody:
|
|
1392
|
+
subject: finalSubject,
|
|
1393
|
+
htmlBody: finalHtml,
|
|
1394
|
+
textBody: finalText,
|
|
1063
1395
|
ruleId,
|
|
1064
1396
|
autoApprove: rule.autoApprove ?? true
|
|
1065
1397
|
});
|
|
@@ -1068,7 +1400,7 @@ var RuleRunnerService = class {
|
|
|
1068
1400
|
userId,
|
|
1069
1401
|
identifier.id,
|
|
1070
1402
|
void 0,
|
|
1071
|
-
{ status: "sent", accountId: agentSelection.accountId, subject:
|
|
1403
|
+
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
|
|
1072
1404
|
);
|
|
1073
1405
|
const current = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1074
1406
|
throttleMap.set(userId, {
|
|
@@ -1078,6 +1410,10 @@ var RuleRunnerService = class {
|
|
|
1078
1410
|
});
|
|
1079
1411
|
stats.sent++;
|
|
1080
1412
|
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent" });
|
|
1413
|
+
totalProcessed++;
|
|
1414
|
+
if (runId && totalProcessed % 10 === 0) {
|
|
1415
|
+
await this.updateRunSendProgress(runId, stats);
|
|
1416
|
+
}
|
|
1081
1417
|
if (i < users.length - 1) {
|
|
1082
1418
|
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1083
1419
|
const jitterMs = this.config.options?.jitterMs || 0;
|
|
@@ -1157,6 +1493,74 @@ var RuleRunnerService = class {
|
|
|
1157
1493
|
todayStart.setHours(0, 0, 0, 0);
|
|
1158
1494
|
return todayStart;
|
|
1159
1495
|
}
|
|
1496
|
+
async updateRunProgress(runId, data) {
|
|
1497
|
+
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1498
|
+
const flat = [];
|
|
1499
|
+
for (const [k, v] of Object.entries(data)) {
|
|
1500
|
+
if (typeof v === "object" && v !== null) {
|
|
1501
|
+
flat.push(k, JSON.stringify(v));
|
|
1502
|
+
} else {
|
|
1503
|
+
flat.push(k, String(v));
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
if (flat.length > 0) {
|
|
1507
|
+
await this.redis.hset(key, ...flat);
|
|
1508
|
+
await this.redis.expire(key, 3600);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
async updateRunSendProgress(runId, stats) {
|
|
1512
|
+
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1513
|
+
const existing = await this.redis.hget(key, "progress");
|
|
1514
|
+
let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
|
|
1515
|
+
if (existing) {
|
|
1516
|
+
try {
|
|
1517
|
+
progress = JSON.parse(existing);
|
|
1518
|
+
} catch {
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
progress.sent = stats.sent;
|
|
1522
|
+
progress.failed = stats.errors;
|
|
1523
|
+
progress.skipped = stats.skipped + stats.skippedByThrottle;
|
|
1524
|
+
await this.redis.hset(key, "progress", JSON.stringify(progress));
|
|
1525
|
+
await this.redis.expire(key, 3600);
|
|
1526
|
+
}
|
|
1527
|
+
async getStatus(runId) {
|
|
1528
|
+
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1529
|
+
const data = await this.redis.hgetall(key);
|
|
1530
|
+
if (!data || Object.keys(data).length === 0) return null;
|
|
1531
|
+
let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
|
|
1532
|
+
if (data.progress) {
|
|
1533
|
+
try {
|
|
1534
|
+
progress = JSON.parse(data.progress);
|
|
1535
|
+
} catch {
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
return {
|
|
1539
|
+
runId: data.runId || runId,
|
|
1540
|
+
status: data.status || "running",
|
|
1541
|
+
currentRule: data.currentRule || "",
|
|
1542
|
+
progress,
|
|
1543
|
+
startedAt: data.startedAt || "",
|
|
1544
|
+
elapsed: parseInt(data.elapsed || "0", 10)
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
async cancel(runId) {
|
|
1548
|
+
const progressKey = `${this.keyPrefix}run:${runId}:progress`;
|
|
1549
|
+
const exists = await this.redis.exists(progressKey);
|
|
1550
|
+
if (!exists) return { ok: false };
|
|
1551
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1552
|
+
await this.redis.set(cancelKey, "1", "EX", 3600);
|
|
1553
|
+
return { ok: true };
|
|
1554
|
+
}
|
|
1555
|
+
trigger(triggeredBy) {
|
|
1556
|
+
const runId = crypto2__default.default.randomUUID();
|
|
1557
|
+
this.runAllRules(triggeredBy || RUN_TRIGGER.Manual, runId).catch((err) => {
|
|
1558
|
+
this.logger.error("Background rule run failed", { error: err, runId });
|
|
1559
|
+
this.updateRunProgress(runId, { status: "failed" }).catch(() => {
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
return { runId };
|
|
1563
|
+
}
|
|
1160
1564
|
buildThrottleMap(recentSends) {
|
|
1161
1565
|
const map = /* @__PURE__ */ new Map();
|
|
1162
1566
|
const todayStart = this.getTodayStart();
|
|
@@ -1225,9 +1629,9 @@ function createTemplateController(templateService, options) {
|
|
|
1225
1629
|
}
|
|
1226
1630
|
async function create(req, res) {
|
|
1227
1631
|
try {
|
|
1228
|
-
const { name,
|
|
1229
|
-
if (!name || !
|
|
1230
|
-
return res.status(400).json({ success: false, error: "name,
|
|
1632
|
+
const { name, subjects, bodies, category, audience, platform } = req.body;
|
|
1633
|
+
if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
|
|
1634
|
+
return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
|
|
1231
1635
|
}
|
|
1232
1636
|
if (!isValidValue(validCategories, category)) {
|
|
1233
1637
|
return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
|
|
@@ -1379,14 +1783,21 @@ function createRuleController(ruleService, options) {
|
|
|
1379
1783
|
if (!name || !target || !templateId) {
|
|
1380
1784
|
return res.status(400).json({ success: false, error: "name, target, and templateId are required" });
|
|
1381
1785
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
|
|
1786
|
+
const mode = target.mode || "query";
|
|
1787
|
+
if (mode === "list") {
|
|
1788
|
+
if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
|
|
1789
|
+
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
1790
|
+
}
|
|
1791
|
+
} else {
|
|
1792
|
+
if (!target.role || !isValidValue2(validAudiences, target.role)) {
|
|
1793
|
+
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
1794
|
+
}
|
|
1795
|
+
if (platformValues && !platformValues.includes(target.platform)) {
|
|
1796
|
+
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
1797
|
+
}
|
|
1798
|
+
if (!Array.isArray(target.conditions)) {
|
|
1799
|
+
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
1800
|
+
}
|
|
1390
1801
|
}
|
|
1391
1802
|
if (req.body.emailType && !isValidValue2(validEmailTypes, req.body.emailType)) {
|
|
1392
1803
|
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
@@ -1401,14 +1812,23 @@ function createRuleController(ruleService, options) {
|
|
|
1401
1812
|
async function update(req, res) {
|
|
1402
1813
|
try {
|
|
1403
1814
|
const { target, emailType } = req.body;
|
|
1404
|
-
if (target
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1815
|
+
if (target) {
|
|
1816
|
+
const mode = target.mode || "query";
|
|
1817
|
+
if (mode === "list") {
|
|
1818
|
+
if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
|
|
1819
|
+
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
1820
|
+
}
|
|
1821
|
+
} else {
|
|
1822
|
+
if (target.role && !isValidValue2(validAudiences, target.role)) {
|
|
1823
|
+
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
1824
|
+
}
|
|
1825
|
+
if (target.platform && platformValues && !platformValues.includes(target.platform)) {
|
|
1826
|
+
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
1827
|
+
}
|
|
1828
|
+
if (target.conditions && !Array.isArray(target.conditions)) {
|
|
1829
|
+
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1412
1832
|
}
|
|
1413
1833
|
if (emailType && !isValidValue2(validEmailTypes, emailType)) {
|
|
1414
1834
|
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
@@ -1469,23 +1889,10 @@ function createRuleController(ruleService, options) {
|
|
|
1469
1889
|
}
|
|
1470
1890
|
return { list, getById, create, update, remove, toggleActive, dryRun, runHistory };
|
|
1471
1891
|
}
|
|
1472
|
-
|
|
1473
|
-
// src/controllers/runner.controller.ts
|
|
1474
|
-
var defaultLogger2 = {
|
|
1475
|
-
info: () => {
|
|
1476
|
-
},
|
|
1477
|
-
warn: () => {
|
|
1478
|
-
},
|
|
1479
|
-
error: () => {
|
|
1480
|
-
}
|
|
1481
|
-
};
|
|
1482
1892
|
function createRunnerController(runnerService, EmailRuleRunLog, logger) {
|
|
1483
|
-
const log = logger || defaultLogger2;
|
|
1484
1893
|
async function triggerManualRun(_req, res) {
|
|
1485
|
-
runnerService.
|
|
1486
|
-
|
|
1487
|
-
});
|
|
1488
|
-
res.json({ success: true, data: { message: "Rule run triggered" } });
|
|
1894
|
+
const { runId } = runnerService.trigger(RUN_TRIGGER.Manual);
|
|
1895
|
+
res.json({ success: true, data: { message: "Rule run triggered", runId } });
|
|
1489
1896
|
}
|
|
1490
1897
|
async function getLatestRun(_req, res) {
|
|
1491
1898
|
try {
|
|
@@ -1496,7 +1903,33 @@ function createRunnerController(runnerService, EmailRuleRunLog, logger) {
|
|
|
1496
1903
|
res.status(500).json({ success: false, error: message });
|
|
1497
1904
|
}
|
|
1498
1905
|
}
|
|
1499
|
-
|
|
1906
|
+
async function getStatusByRunId(req, res) {
|
|
1907
|
+
try {
|
|
1908
|
+
const status = await runnerService.getStatus(getParam(req, "runId"));
|
|
1909
|
+
if (!status) {
|
|
1910
|
+
res.status(404).json({ success: false, error: "Run not found" });
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
res.json({ success: true, data: status });
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1916
|
+
res.status(500).json({ success: false, error: message });
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
async function cancelRun(req, res) {
|
|
1920
|
+
try {
|
|
1921
|
+
const result = await runnerService.cancel(getParam(req, "runId"));
|
|
1922
|
+
if (!result.ok) {
|
|
1923
|
+
res.status(404).json({ success: false, error: "Run not found" });
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
res.json({ success: true, data: { message: "Cancel requested" } });
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1929
|
+
res.status(500).json({ success: false, error: message });
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
return { triggerManualRun, getLatestRun, getStatusByRunId, cancelRun };
|
|
1500
1933
|
}
|
|
1501
1934
|
|
|
1502
1935
|
// src/controllers/settings.controller.ts
|
|
@@ -1567,7 +2000,7 @@ function createRoutes(deps) {
|
|
|
1567
2000
|
platforms: deps.platformValues,
|
|
1568
2001
|
audiences: deps.audienceValues
|
|
1569
2002
|
});
|
|
1570
|
-
const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog
|
|
2003
|
+
const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog);
|
|
1571
2004
|
const settingsCtrl = createSettingsController(deps.EmailThrottleConfig);
|
|
1572
2005
|
const templateRouter = express.Router();
|
|
1573
2006
|
templateRouter.get("/", templateCtrl.list);
|
|
@@ -1592,6 +2025,8 @@ function createRoutes(deps) {
|
|
|
1592
2025
|
const runnerRouter = express.Router();
|
|
1593
2026
|
runnerRouter.post("/", runnerCtrl.triggerManualRun);
|
|
1594
2027
|
runnerRouter.get("/status", runnerCtrl.getLatestRun);
|
|
2028
|
+
runnerRouter.get("/status/:runId", runnerCtrl.getStatusByRunId);
|
|
2029
|
+
runnerRouter.post("/cancel/:runId", runnerCtrl.cancelRun);
|
|
1595
2030
|
const settingsRouter = express.Router();
|
|
1596
2031
|
settingsRouter.get("/throttle", settingsCtrl.getThrottleConfig);
|
|
1597
2032
|
settingsRouter.patch("/throttle", settingsCtrl.updateThrottleConfig);
|