@astralibx/email-rule-engine 3.0.2 → 4.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/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.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import Handlebars from 'handlebars';
|
|
|
3
3
|
import mjml2html from 'mjml';
|
|
4
4
|
import { convert } from 'html-to-text';
|
|
5
5
|
import { baseRedisSchema, baseDbSchema, loggerSchema, AlxError } from '@astralibx/core';
|
|
6
|
-
import
|
|
6
|
+
import crypto2 from 'crypto';
|
|
7
7
|
import { Router } from 'express';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
|
|
@@ -68,9 +68,9 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
|
|
|
68
68
|
required: true,
|
|
69
69
|
...platformValues ? { enum: platformValues } : {}
|
|
70
70
|
},
|
|
71
|
-
subject: { type: String, required: true },
|
|
72
|
-
body: { type: String, required: true },
|
|
73
71
|
textBody: String,
|
|
72
|
+
subjects: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one subject is required"] },
|
|
73
|
+
bodies: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one body is required"] },
|
|
74
74
|
variables: [{ type: String }],
|
|
75
75
|
version: { type: Number, default: 1 },
|
|
76
76
|
isActive: { type: Boolean, default: true, index: true }
|
|
@@ -102,9 +102,9 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
|
|
|
102
102
|
category: input.category,
|
|
103
103
|
audience: input.audience,
|
|
104
104
|
platform: input.platform,
|
|
105
|
-
subject: input.subject,
|
|
106
|
-
body: input.body,
|
|
107
105
|
textBody: input.textBody,
|
|
106
|
+
subjects: input.subjects,
|
|
107
|
+
bodies: input.bodies,
|
|
108
108
|
variables: input.variables || [],
|
|
109
109
|
version: 1,
|
|
110
110
|
isActive: true
|
|
@@ -124,13 +124,14 @@ function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix)
|
|
|
124
124
|
value: { type: Schema.Types.Mixed }
|
|
125
125
|
}, { _id: false });
|
|
126
126
|
const RuleTargetSchema = new Schema({
|
|
127
|
-
|
|
127
|
+
mode: { type: String, enum: ["query", "list"], required: true },
|
|
128
|
+
role: { type: String, enum: audienceValues || Object.values(TEMPLATE_AUDIENCE) },
|
|
128
129
|
platform: {
|
|
129
130
|
type: String,
|
|
130
|
-
required: true,
|
|
131
131
|
...platformValues ? { enum: platformValues } : {}
|
|
132
132
|
},
|
|
133
|
-
conditions: [RuleConditionSchema]
|
|
133
|
+
conditions: [RuleConditionSchema],
|
|
134
|
+
identifiers: [{ type: String }]
|
|
134
135
|
}, { _id: false });
|
|
135
136
|
const RuleRunStatsSchema = new Schema({
|
|
136
137
|
matched: { type: Number, default: 0 },
|
|
@@ -207,6 +208,8 @@ function createEmailRuleSendSchema(collectionPrefix) {
|
|
|
207
208
|
accountId: { type: String },
|
|
208
209
|
senderName: { type: String },
|
|
209
210
|
subject: { type: String },
|
|
211
|
+
subjectIndex: { type: Number },
|
|
212
|
+
bodyIndex: { type: Number },
|
|
210
213
|
failureReason: { type: String }
|
|
211
214
|
},
|
|
212
215
|
{
|
|
@@ -259,7 +262,9 @@ function createEmailRuleRunLogSchema(collectionPrefix) {
|
|
|
259
262
|
}, { _id: false });
|
|
260
263
|
const schema = new Schema(
|
|
261
264
|
{
|
|
265
|
+
runId: { type: String, index: true },
|
|
262
266
|
runAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
|
|
267
|
+
status: { type: String, enum: ["completed", "cancelled", "failed"], default: "completed" },
|
|
263
268
|
triggeredBy: { type: String, enum: Object.values(RUN_TRIGGER), required: true },
|
|
264
269
|
duration: { type: Number, required: true },
|
|
265
270
|
rulesProcessed: { type: Number, required: true },
|
|
@@ -429,6 +434,16 @@ var TemplateRenderService = class {
|
|
|
429
434
|
const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: true }) : void 0;
|
|
430
435
|
return { subjectFn, bodyFn, textBodyFn };
|
|
431
436
|
}
|
|
437
|
+
compileBatchVariants(subjects, bodies, textBody) {
|
|
438
|
+
const subjectFns = subjects.map((s) => Handlebars.compile(s, { strict: true }));
|
|
439
|
+
const bodyFns = bodies.map((b) => {
|
|
440
|
+
const mjmlSource = wrapInMjml(b);
|
|
441
|
+
const htmlWithHandlebars = compileMjml(mjmlSource);
|
|
442
|
+
return Handlebars.compile(htmlWithHandlebars, { strict: true });
|
|
443
|
+
});
|
|
444
|
+
const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: true }) : void 0;
|
|
445
|
+
return { subjectFns, bodyFns, textBodyFn };
|
|
446
|
+
}
|
|
432
447
|
renderFromCompiled(compiled, data) {
|
|
433
448
|
const subject = compiled.subjectFn(data);
|
|
434
449
|
const html = compiled.bodyFn(data);
|
|
@@ -438,6 +453,9 @@ var TemplateRenderService = class {
|
|
|
438
453
|
renderPreview(subject, body, data, textBody) {
|
|
439
454
|
return this.renderSingle(subject, body, data, textBody);
|
|
440
455
|
}
|
|
456
|
+
htmlToText(html) {
|
|
457
|
+
return htmlToPlainText(html);
|
|
458
|
+
}
|
|
441
459
|
extractVariables(template) {
|
|
442
460
|
const regex = /\{\{(?!#|\/|!|>)([^}]+)\}\}/g;
|
|
443
461
|
const variables = /* @__PURE__ */ new Set();
|
|
@@ -533,9 +551,9 @@ var UPDATEABLE_FIELDS = /* @__PURE__ */ new Set([
|
|
|
533
551
|
"category",
|
|
534
552
|
"audience",
|
|
535
553
|
"platform",
|
|
536
|
-
"subject",
|
|
537
|
-
"body",
|
|
538
554
|
"textBody",
|
|
555
|
+
"subjects",
|
|
556
|
+
"bodies",
|
|
539
557
|
"variables",
|
|
540
558
|
"isActive"
|
|
541
559
|
]);
|
|
@@ -568,35 +586,49 @@ var TemplateService = class {
|
|
|
568
586
|
if (existing) {
|
|
569
587
|
throw new DuplicateSlugError(slug);
|
|
570
588
|
}
|
|
571
|
-
const
|
|
572
|
-
if (
|
|
573
|
-
|
|
589
|
+
const { subjects, bodies } = input;
|
|
590
|
+
if (subjects.length === 0) throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
|
|
591
|
+
if (bodies.length === 0) throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
|
|
592
|
+
for (const b of bodies) {
|
|
593
|
+
const validation = this.renderService.validateTemplate(b);
|
|
594
|
+
if (!validation.valid) {
|
|
595
|
+
throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
|
|
596
|
+
}
|
|
574
597
|
}
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
);
|
|
598
|
+
const allContent = [...subjects, ...bodies, input.textBody || ""].join(" ");
|
|
599
|
+
const variables = input.variables || this.renderService.extractVariables(allContent);
|
|
578
600
|
return this.EmailTemplate.createTemplate({
|
|
579
601
|
...input,
|
|
580
602
|
slug,
|
|
603
|
+
subjects,
|
|
604
|
+
bodies,
|
|
581
605
|
variables
|
|
582
606
|
});
|
|
583
607
|
}
|
|
584
608
|
async update(id, input) {
|
|
585
609
|
const template = await this.EmailTemplate.findById(id);
|
|
586
610
|
if (!template) return null;
|
|
587
|
-
if (input.
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
611
|
+
if (input.subjects && input.subjects.length === 0) {
|
|
612
|
+
throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
|
|
613
|
+
}
|
|
614
|
+
if (input.bodies && input.bodies.length === 0) {
|
|
615
|
+
throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
|
|
616
|
+
}
|
|
617
|
+
const bodiesToValidate = input.bodies || null;
|
|
618
|
+
if (bodiesToValidate) {
|
|
619
|
+
for (const b of bodiesToValidate) {
|
|
620
|
+
const validation = this.renderService.validateTemplate(b);
|
|
621
|
+
if (!validation.valid) {
|
|
622
|
+
throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
|
|
623
|
+
}
|
|
591
624
|
}
|
|
592
625
|
}
|
|
593
|
-
if (input.
|
|
594
|
-
const
|
|
595
|
-
const
|
|
626
|
+
if (input.textBody || input.subjects || input.bodies) {
|
|
627
|
+
const subjects = input.subjects ?? template.subjects;
|
|
628
|
+
const bodies = input.bodies ?? template.bodies;
|
|
596
629
|
const textBody = input.textBody ?? template.textBody;
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
);
|
|
630
|
+
const allContent = [...subjects, ...bodies, textBody || ""].join(" ");
|
|
631
|
+
input.variables = this.renderService.extractVariables(allContent);
|
|
600
632
|
}
|
|
601
633
|
const setFields = {};
|
|
602
634
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -605,7 +637,7 @@ var TemplateService = class {
|
|
|
605
637
|
}
|
|
606
638
|
}
|
|
607
639
|
const update = { $set: setFields };
|
|
608
|
-
if (input.
|
|
640
|
+
if (input.textBody || input.subjects || input.bodies) {
|
|
609
641
|
update["$inc"] = { version: 1 };
|
|
610
642
|
}
|
|
611
643
|
return this.EmailTemplate.findByIdAndUpdate(
|
|
@@ -629,8 +661,8 @@ var TemplateService = class {
|
|
|
629
661
|
const template = await this.EmailTemplate.findById(id);
|
|
630
662
|
if (!template) return null;
|
|
631
663
|
return this.renderService.renderPreview(
|
|
632
|
-
template.
|
|
633
|
-
template.
|
|
664
|
+
template.subjects[0],
|
|
665
|
+
template.bodies[0],
|
|
634
666
|
sampleData,
|
|
635
667
|
template.textBody
|
|
636
668
|
);
|
|
@@ -652,8 +684,8 @@ var TemplateService = class {
|
|
|
652
684
|
return { success: false, error: "Template not found" };
|
|
653
685
|
}
|
|
654
686
|
const rendered = this.renderService.renderSingle(
|
|
655
|
-
template.
|
|
656
|
-
template.
|
|
687
|
+
template.subjects[0],
|
|
688
|
+
template.bodies[0],
|
|
657
689
|
sampleData,
|
|
658
690
|
template.textBody
|
|
659
691
|
);
|
|
@@ -672,6 +704,9 @@ var TemplateService = class {
|
|
|
672
704
|
};
|
|
673
705
|
|
|
674
706
|
// src/services/rule.service.ts
|
|
707
|
+
function isQueryTarget(target) {
|
|
708
|
+
return !target.mode || target.mode === "query";
|
|
709
|
+
}
|
|
675
710
|
var UPDATEABLE_FIELDS2 = /* @__PURE__ */ new Set([
|
|
676
711
|
"name",
|
|
677
712
|
"description",
|
|
@@ -725,13 +760,22 @@ var RuleService = class {
|
|
|
725
760
|
if (!template) {
|
|
726
761
|
throw new TemplateNotFoundError(input.templateId);
|
|
727
762
|
}
|
|
728
|
-
|
|
729
|
-
input.target.role
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
763
|
+
if (isQueryTarget(input.target)) {
|
|
764
|
+
if (!input.target.role || !input.target.platform) {
|
|
765
|
+
throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
|
|
766
|
+
}
|
|
767
|
+
const compatError = validateRuleTemplateCompat(
|
|
768
|
+
input.target.role,
|
|
769
|
+
input.target.platform,
|
|
770
|
+
template
|
|
771
|
+
);
|
|
772
|
+
if (compatError) {
|
|
773
|
+
throw new RuleTemplateIncompatibleError(compatError);
|
|
774
|
+
}
|
|
775
|
+
} else {
|
|
776
|
+
if (!input.target.identifiers || input.target.identifiers.length === 0) {
|
|
777
|
+
throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
|
|
778
|
+
}
|
|
735
779
|
}
|
|
736
780
|
return this.EmailRule.createRule(input);
|
|
737
781
|
}
|
|
@@ -739,14 +783,25 @@ var RuleService = class {
|
|
|
739
783
|
const rule = await this.EmailRule.findById(id);
|
|
740
784
|
if (!rule) return null;
|
|
741
785
|
const templateId = input.templateId ?? rule.templateId.toString();
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
786
|
+
if (input.target) {
|
|
787
|
+
if (isQueryTarget(input.target)) {
|
|
788
|
+
if (!input.target.role || !input.target.platform) {
|
|
789
|
+
throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
if (!input.target.identifiers || input.target.identifiers.length === 0) {
|
|
793
|
+
throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
const effectiveTarget = input.target ?? rule.target;
|
|
798
|
+
if ((input.templateId || input.target) && isQueryTarget(effectiveTarget)) {
|
|
799
|
+
const qt = effectiveTarget;
|
|
745
800
|
const template = await this.EmailTemplate.findById(templateId);
|
|
746
801
|
if (!template) {
|
|
747
802
|
throw new TemplateNotFoundError(templateId);
|
|
748
803
|
}
|
|
749
|
-
const compatError = validateRuleTemplateCompat(
|
|
804
|
+
const compatError = validateRuleTemplateCompat(qt.role, qt.platform, template);
|
|
750
805
|
if (compatError) {
|
|
751
806
|
throw new RuleTemplateIncompatibleError(compatError);
|
|
752
807
|
}
|
|
@@ -795,6 +850,11 @@ var RuleService = class {
|
|
|
795
850
|
if (!rule) {
|
|
796
851
|
throw new RuleNotFoundError(id);
|
|
797
852
|
}
|
|
853
|
+
const target = rule.target;
|
|
854
|
+
if (target.mode === "list") {
|
|
855
|
+
const identifiers = target.identifiers || [];
|
|
856
|
+
return { matchedCount: identifiers.length, ruleId: id };
|
|
857
|
+
}
|
|
798
858
|
const users = await this.config.adapters.queryUsers(rule.target, 5e4);
|
|
799
859
|
return { matchedCount: users.length, ruleId: id };
|
|
800
860
|
}
|
|
@@ -811,7 +871,7 @@ var RedisLock = class {
|
|
|
811
871
|
}
|
|
812
872
|
lockValue = "";
|
|
813
873
|
async acquire() {
|
|
814
|
-
this.lockValue =
|
|
874
|
+
this.lockValue = crypto2.randomUUID();
|
|
815
875
|
const result = await this.redis.set(this.lockKey, this.lockValue, "PX", this.ttlMs, "NX");
|
|
816
876
|
return result === "OK";
|
|
817
877
|
}
|
|
@@ -824,8 +884,6 @@ var RedisLock = class {
|
|
|
824
884
|
}
|
|
825
885
|
}
|
|
826
886
|
};
|
|
827
|
-
|
|
828
|
-
// src/services/rule-runner.service.ts
|
|
829
887
|
var MS_PER_DAY = 864e5;
|
|
830
888
|
var DEFAULT_LOCK_TTL_MS = 30 * 60 * 1e3;
|
|
831
889
|
var IDENTIFIER_CHUNK_SIZE = 50;
|
|
@@ -854,11 +912,12 @@ var RuleRunnerService = class {
|
|
|
854
912
|
this.EmailRuleRunLog = EmailRuleRunLog;
|
|
855
913
|
this.EmailThrottleConfig = EmailThrottleConfig;
|
|
856
914
|
this.config = config;
|
|
857
|
-
|
|
915
|
+
this.keyPrefix = config.redis.keyPrefix || "";
|
|
916
|
+
this.redis = config.redis.connection;
|
|
858
917
|
const lockTTL = config.options?.lockTTLMs || DEFAULT_LOCK_TTL_MS;
|
|
859
918
|
this.lock = new RedisLock(
|
|
860
|
-
|
|
861
|
-
`${keyPrefix}email-rule-runner:lock`,
|
|
919
|
+
this.redis,
|
|
920
|
+
`${this.keyPrefix}email-rule-runner:lock`,
|
|
862
921
|
lockTTL,
|
|
863
922
|
config.logger
|
|
864
923
|
);
|
|
@@ -867,7 +926,11 @@ var RuleRunnerService = class {
|
|
|
867
926
|
templateRenderer = new TemplateRenderService();
|
|
868
927
|
lock;
|
|
869
928
|
logger;
|
|
870
|
-
|
|
929
|
+
redis;
|
|
930
|
+
keyPrefix;
|
|
931
|
+
async runAllRules(triggeredBy = RUN_TRIGGER.Cron, runId) {
|
|
932
|
+
if (!runId) runId = crypto2.randomUUID();
|
|
933
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
871
934
|
if (this.config.options?.sendWindow) {
|
|
872
935
|
const { startHour, endHour, timezone } = this.config.options.sendWindow;
|
|
873
936
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -875,30 +938,45 @@ var RuleRunnerService = class {
|
|
|
875
938
|
const currentHour = parseInt(formatter.format(now), 10);
|
|
876
939
|
if (currentHour < startHour || currentHour >= endHour) {
|
|
877
940
|
this.logger.info("Outside send window, skipping run", { currentHour, startHour, endHour, timezone });
|
|
878
|
-
return;
|
|
941
|
+
return { runId };
|
|
879
942
|
}
|
|
880
943
|
}
|
|
881
944
|
const lockAcquired = await this.lock.acquire();
|
|
882
945
|
if (!lockAcquired) {
|
|
883
946
|
this.logger.warn("Rule runner already executing, skipping");
|
|
884
|
-
return;
|
|
947
|
+
return { runId };
|
|
885
948
|
}
|
|
886
949
|
const runStartTime = Date.now();
|
|
950
|
+
await this.updateRunProgress(runId, {
|
|
951
|
+
runId,
|
|
952
|
+
status: "running",
|
|
953
|
+
currentRule: "",
|
|
954
|
+
progress: { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 },
|
|
955
|
+
startedAt,
|
|
956
|
+
elapsed: 0
|
|
957
|
+
});
|
|
958
|
+
let runStatus = "completed";
|
|
887
959
|
try {
|
|
888
960
|
const throttleConfig = await this.EmailThrottleConfig.getConfig();
|
|
889
961
|
const activeRules = await this.EmailRule.findActive();
|
|
890
962
|
this.config.hooks?.onRunStart?.({ rulesCount: activeRules.length, triggeredBy });
|
|
963
|
+
await this.updateRunProgress(runId, {
|
|
964
|
+
progress: { rulesTotal: activeRules.length, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 }
|
|
965
|
+
});
|
|
891
966
|
if (activeRules.length === 0) {
|
|
892
967
|
this.logger.info("No active rules to process");
|
|
893
968
|
await this.EmailRuleRunLog.create({
|
|
969
|
+
runId,
|
|
894
970
|
runAt: /* @__PURE__ */ new Date(),
|
|
895
971
|
triggeredBy,
|
|
896
972
|
duration: Date.now() - runStartTime,
|
|
897
973
|
rulesProcessed: 0,
|
|
898
974
|
totalStats: { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 },
|
|
899
|
-
perRuleStats: []
|
|
975
|
+
perRuleStats: [],
|
|
976
|
+
status: "completed"
|
|
900
977
|
});
|
|
901
|
-
|
|
978
|
+
await this.updateRunProgress(runId, { status: "completed", elapsed: Date.now() - runStartTime });
|
|
979
|
+
return { runId };
|
|
902
980
|
}
|
|
903
981
|
const templateIds = [...new Set(activeRules.map((r) => r.templateId.toString()))];
|
|
904
982
|
const templates = await this.EmailTemplate.find({ _id: { $in: templateIds } }).lean();
|
|
@@ -911,13 +989,43 @@ var RuleRunnerService = class {
|
|
|
911
989
|
}).lean();
|
|
912
990
|
const throttleMap = this.buildThrottleMap(recentSends);
|
|
913
991
|
const perRuleStats = [];
|
|
914
|
-
|
|
915
|
-
|
|
992
|
+
let totalSent = 0;
|
|
993
|
+
let totalFailed = 0;
|
|
994
|
+
let totalSkipped = 0;
|
|
995
|
+
let totalInvalid = 0;
|
|
996
|
+
for (let ri = 0; ri < activeRules.length; ri++) {
|
|
997
|
+
const rule = activeRules[ri];
|
|
998
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
999
|
+
const cancelled = await this.redis.exists(cancelKey);
|
|
1000
|
+
if (cancelled) {
|
|
1001
|
+
runStatus = "cancelled";
|
|
1002
|
+
break;
|
|
1003
|
+
}
|
|
1004
|
+
await this.updateRunProgress(runId, {
|
|
1005
|
+
currentRule: rule.name,
|
|
1006
|
+
elapsed: Date.now() - runStartTime
|
|
1007
|
+
});
|
|
1008
|
+
const stats = await this.executeRule(rule, throttleMap, throttleConfig, templateMap, runId);
|
|
1009
|
+
totalSent += stats.sent;
|
|
1010
|
+
totalFailed += stats.errors;
|
|
1011
|
+
totalSkipped += stats.skipped + stats.skippedByThrottle;
|
|
1012
|
+
totalInvalid += stats.matched - stats.sent - stats.skipped - stats.skippedByThrottle - stats.errors;
|
|
916
1013
|
perRuleStats.push({
|
|
917
1014
|
ruleId: rule._id.toString(),
|
|
918
1015
|
ruleName: rule.name,
|
|
919
1016
|
...stats
|
|
920
1017
|
});
|
|
1018
|
+
await this.updateRunProgress(runId, {
|
|
1019
|
+
progress: {
|
|
1020
|
+
rulesTotal: activeRules.length,
|
|
1021
|
+
rulesCompleted: ri + 1,
|
|
1022
|
+
sent: totalSent,
|
|
1023
|
+
failed: totalFailed,
|
|
1024
|
+
skipped: totalSkipped,
|
|
1025
|
+
invalid: totalInvalid < 0 ? 0 : totalInvalid
|
|
1026
|
+
},
|
|
1027
|
+
elapsed: Date.now() - runStartTime
|
|
1028
|
+
});
|
|
921
1029
|
}
|
|
922
1030
|
const totalStats = perRuleStats.reduce(
|
|
923
1031
|
(acc, s) => ({
|
|
@@ -930,13 +1038,16 @@ var RuleRunnerService = class {
|
|
|
930
1038
|
{ matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 }
|
|
931
1039
|
);
|
|
932
1040
|
await this.EmailRuleRunLog.create({
|
|
1041
|
+
runId,
|
|
933
1042
|
runAt: /* @__PURE__ */ new Date(),
|
|
934
1043
|
triggeredBy,
|
|
935
1044
|
duration: Date.now() - runStartTime,
|
|
936
1045
|
rulesProcessed: activeRules.length,
|
|
937
1046
|
totalStats,
|
|
938
|
-
perRuleStats
|
|
1047
|
+
perRuleStats,
|
|
1048
|
+
status: runStatus
|
|
939
1049
|
});
|
|
1050
|
+
await this.updateRunProgress(runId, { status: runStatus, currentRule: "", elapsed: Date.now() - runStartTime });
|
|
940
1051
|
this.config.hooks?.onRunComplete?.({ duration: Date.now() - runStartTime, totalStats, perRuleStats });
|
|
941
1052
|
this.logger.info("Rule run completed", {
|
|
942
1053
|
triggeredBy,
|
|
@@ -945,11 +1056,16 @@ var RuleRunnerService = class {
|
|
|
945
1056
|
totalSkipped: totalStats.skipped,
|
|
946
1057
|
duration: Date.now() - runStartTime
|
|
947
1058
|
});
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
runStatus = "failed";
|
|
1061
|
+
await this.updateRunProgress(runId, { status: "failed", elapsed: Date.now() - runStartTime });
|
|
1062
|
+
throw err;
|
|
948
1063
|
} finally {
|
|
949
1064
|
await this.lock.release();
|
|
950
1065
|
}
|
|
1066
|
+
return { runId };
|
|
951
1067
|
}
|
|
952
|
-
async executeRule(rule, throttleMap, throttleConfig, templateMap) {
|
|
1068
|
+
async executeRule(rule, throttleMap, throttleConfig, templateMap, runId) {
|
|
953
1069
|
const stats = { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 };
|
|
954
1070
|
const template = templateMap?.get(rule.templateId.toString()) ?? await this.EmailTemplate.findById(rule.templateId);
|
|
955
1071
|
if (!template) {
|
|
@@ -957,6 +1073,182 @@ var RuleRunnerService = class {
|
|
|
957
1073
|
stats.errors = 1;
|
|
958
1074
|
return stats;
|
|
959
1075
|
}
|
|
1076
|
+
const isListMode = rule.target?.mode === "list";
|
|
1077
|
+
if (isListMode) {
|
|
1078
|
+
return this.executeListMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1079
|
+
}
|
|
1080
|
+
return this.executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1081
|
+
}
|
|
1082
|
+
async executeListMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
1083
|
+
const rawIdentifiers = rule.target.identifiers || [];
|
|
1084
|
+
const uniqueEmails = [...new Set(rawIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean))];
|
|
1085
|
+
const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
|
|
1086
|
+
const emailsToProcess = uniqueEmails.slice(0, limit);
|
|
1087
|
+
stats.matched = emailsToProcess.length;
|
|
1088
|
+
const ruleId = rule._id.toString();
|
|
1089
|
+
const templateId = rule.templateId.toString();
|
|
1090
|
+
this.config.hooks?.onRuleStart?.({ ruleId, ruleName: rule.name, matchedCount: emailsToProcess.length });
|
|
1091
|
+
if (emailsToProcess.length === 0) return stats;
|
|
1092
|
+
const identifierResults = await processInChunks(
|
|
1093
|
+
emailsToProcess,
|
|
1094
|
+
async (email) => {
|
|
1095
|
+
const result = await this.config.adapters.findIdentifier(email);
|
|
1096
|
+
return result ? { email, ...result } : null;
|
|
1097
|
+
},
|
|
1098
|
+
IDENTIFIER_CHUNK_SIZE
|
|
1099
|
+
);
|
|
1100
|
+
const identifierMap = /* @__PURE__ */ new Map();
|
|
1101
|
+
for (const result of identifierResults) {
|
|
1102
|
+
if (result) {
|
|
1103
|
+
identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
const validEmails = emailsToProcess.filter((e) => identifierMap.has(e));
|
|
1107
|
+
const identifierIds = validEmails.map((e) => identifierMap.get(e).id);
|
|
1108
|
+
const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: identifierIds } }).sort({ sentAt: -1 }).lean();
|
|
1109
|
+
const sendMap = /* @__PURE__ */ new Map();
|
|
1110
|
+
for (const send of allRuleSends) {
|
|
1111
|
+
const uid = send.userId.toString();
|
|
1112
|
+
if (!sendMap.has(uid)) {
|
|
1113
|
+
sendMap.set(uid, send);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
const compiledVariants = this.templateRenderer.compileBatchVariants(
|
|
1117
|
+
template.subjects,
|
|
1118
|
+
template.bodies,
|
|
1119
|
+
template.textBody
|
|
1120
|
+
);
|
|
1121
|
+
let totalProcessed = 0;
|
|
1122
|
+
for (let i = 0; i < emailsToProcess.length; i++) {
|
|
1123
|
+
const email = emailsToProcess[i];
|
|
1124
|
+
if (runId && i % 10 === 0) {
|
|
1125
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1126
|
+
const cancelled = await this.redis.exists(cancelKey);
|
|
1127
|
+
if (cancelled) break;
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
const identifier = identifierMap.get(email);
|
|
1131
|
+
if (!identifier) {
|
|
1132
|
+
stats.skipped++;
|
|
1133
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "invalid" });
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
const dedupKey = identifier.id;
|
|
1137
|
+
const lastSend = sendMap.get(dedupKey);
|
|
1138
|
+
if (lastSend) {
|
|
1139
|
+
if (rule.sendOnce && !rule.resendAfterDays) {
|
|
1140
|
+
stats.skipped++;
|
|
1141
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
if (rule.resendAfterDays) {
|
|
1145
|
+
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1146
|
+
if (daysSince < rule.resendAfterDays) {
|
|
1147
|
+
stats.skipped++;
|
|
1148
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
} else {
|
|
1152
|
+
stats.skipped++;
|
|
1153
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (!this.checkThrottle(rule, dedupKey, email, throttleMap, throttleConfig, stats)) continue;
|
|
1158
|
+
const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
|
|
1159
|
+
if (!agentSelection) {
|
|
1160
|
+
stats.skipped++;
|
|
1161
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
const user = { _id: identifier.id, email };
|
|
1165
|
+
const templateData = this.config.adapters.resolveData(user);
|
|
1166
|
+
const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
|
|
1167
|
+
const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
|
|
1168
|
+
const renderedSubject = compiledVariants.subjectFns[si](templateData);
|
|
1169
|
+
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1170
|
+
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1171
|
+
let finalHtml = renderedHtml;
|
|
1172
|
+
let finalText = renderedText;
|
|
1173
|
+
let finalSubject = renderedSubject;
|
|
1174
|
+
if (this.config.hooks?.beforeSend) {
|
|
1175
|
+
try {
|
|
1176
|
+
const modified = await this.config.hooks.beforeSend({
|
|
1177
|
+
htmlBody: finalHtml,
|
|
1178
|
+
textBody: finalText,
|
|
1179
|
+
subject: finalSubject,
|
|
1180
|
+
account: {
|
|
1181
|
+
id: agentSelection.accountId,
|
|
1182
|
+
email: agentSelection.email,
|
|
1183
|
+
metadata: agentSelection.metadata
|
|
1184
|
+
},
|
|
1185
|
+
user: {
|
|
1186
|
+
id: dedupKey,
|
|
1187
|
+
email,
|
|
1188
|
+
name: ""
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
finalHtml = modified.htmlBody;
|
|
1192
|
+
finalText = modified.textBody;
|
|
1193
|
+
finalSubject = modified.subject;
|
|
1194
|
+
} catch (hookErr) {
|
|
1195
|
+
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1196
|
+
stats.errors++;
|
|
1197
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
await this.config.adapters.sendEmail({
|
|
1202
|
+
identifierId: identifier.id,
|
|
1203
|
+
contactId: identifier.contactId,
|
|
1204
|
+
accountId: agentSelection.accountId,
|
|
1205
|
+
subject: finalSubject,
|
|
1206
|
+
htmlBody: finalHtml,
|
|
1207
|
+
textBody: finalText,
|
|
1208
|
+
ruleId,
|
|
1209
|
+
autoApprove: rule.autoApprove ?? true
|
|
1210
|
+
});
|
|
1211
|
+
await this.EmailRuleSend.logSend(
|
|
1212
|
+
ruleId,
|
|
1213
|
+
dedupKey,
|
|
1214
|
+
identifier.id,
|
|
1215
|
+
void 0,
|
|
1216
|
+
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
|
|
1217
|
+
);
|
|
1218
|
+
const current = throttleMap.get(dedupKey) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1219
|
+
throttleMap.set(dedupKey, {
|
|
1220
|
+
today: current.today + 1,
|
|
1221
|
+
thisWeek: current.thisWeek + 1,
|
|
1222
|
+
lastSentDate: /* @__PURE__ */ new Date()
|
|
1223
|
+
});
|
|
1224
|
+
stats.sent++;
|
|
1225
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent" });
|
|
1226
|
+
totalProcessed++;
|
|
1227
|
+
if (runId && totalProcessed % 10 === 0) {
|
|
1228
|
+
await this.updateRunSendProgress(runId, stats);
|
|
1229
|
+
}
|
|
1230
|
+
if (i < emailsToProcess.length - 1) {
|
|
1231
|
+
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1232
|
+
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1233
|
+
if (delayMs > 0 || jitterMs > 0) {
|
|
1234
|
+
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1235
|
+
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
stats.errors++;
|
|
1240
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
|
|
1241
|
+
this.logger.error(`Rule "${rule.name}" failed for identifier ${email}`, { error: err });
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
await this.EmailRule.findByIdAndUpdate(rule._id, {
|
|
1245
|
+
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1246
|
+
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1247
|
+
});
|
|
1248
|
+
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats });
|
|
1249
|
+
return stats;
|
|
1250
|
+
}
|
|
1251
|
+
async executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
960
1252
|
let users;
|
|
961
1253
|
try {
|
|
962
1254
|
users = await this.config.adapters.queryUsers(rule.target, rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500);
|
|
@@ -993,15 +1285,21 @@ var RuleRunnerService = class {
|
|
|
993
1285
|
identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
|
|
994
1286
|
}
|
|
995
1287
|
}
|
|
996
|
-
const
|
|
997
|
-
template.
|
|
998
|
-
template.
|
|
1288
|
+
const compiledVariants = this.templateRenderer.compileBatchVariants(
|
|
1289
|
+
template.subjects,
|
|
1290
|
+
template.bodies,
|
|
999
1291
|
template.textBody
|
|
1000
1292
|
);
|
|
1001
1293
|
const ruleId = rule._id.toString();
|
|
1002
1294
|
const templateId = rule.templateId.toString();
|
|
1295
|
+
let totalProcessed = 0;
|
|
1003
1296
|
for (let i = 0; i < users.length; i++) {
|
|
1004
1297
|
const user = users[i];
|
|
1298
|
+
if (runId && i % 10 === 0) {
|
|
1299
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1300
|
+
const cancelled = await this.redis.exists(cancelKey);
|
|
1301
|
+
if (cancelled) break;
|
|
1302
|
+
}
|
|
1005
1303
|
try {
|
|
1006
1304
|
const userId = user._id?.toString();
|
|
1007
1305
|
const email = user.email;
|
|
@@ -1044,14 +1342,48 @@ var RuleRunnerService = class {
|
|
|
1044
1342
|
continue;
|
|
1045
1343
|
}
|
|
1046
1344
|
const templateData = this.config.adapters.resolveData(user);
|
|
1047
|
-
const
|
|
1345
|
+
const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
|
|
1346
|
+
const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
|
|
1347
|
+
const renderedSubject = compiledVariants.subjectFns[si](templateData);
|
|
1348
|
+
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1349
|
+
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1350
|
+
let finalHtml = renderedHtml;
|
|
1351
|
+
let finalText = renderedText;
|
|
1352
|
+
let finalSubject = renderedSubject;
|
|
1353
|
+
if (this.config.hooks?.beforeSend) {
|
|
1354
|
+
try {
|
|
1355
|
+
const modified = await this.config.hooks.beforeSend({
|
|
1356
|
+
htmlBody: finalHtml,
|
|
1357
|
+
textBody: finalText,
|
|
1358
|
+
subject: finalSubject,
|
|
1359
|
+
account: {
|
|
1360
|
+
id: agentSelection.accountId,
|
|
1361
|
+
email: agentSelection.email,
|
|
1362
|
+
metadata: agentSelection.metadata
|
|
1363
|
+
},
|
|
1364
|
+
user: {
|
|
1365
|
+
id: String(userId),
|
|
1366
|
+
email,
|
|
1367
|
+
name: String(user.name || user.firstName || "")
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
finalHtml = modified.htmlBody;
|
|
1371
|
+
finalText = modified.textBody;
|
|
1372
|
+
finalSubject = modified.subject;
|
|
1373
|
+
} catch (hookErr) {
|
|
1374
|
+
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1375
|
+
stats.errors++;
|
|
1376
|
+
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1048
1380
|
await this.config.adapters.sendEmail({
|
|
1049
1381
|
identifierId: identifier.id,
|
|
1050
1382
|
contactId: identifier.contactId,
|
|
1051
1383
|
accountId: agentSelection.accountId,
|
|
1052
|
-
subject:
|
|
1053
|
-
htmlBody:
|
|
1054
|
-
textBody:
|
|
1384
|
+
subject: finalSubject,
|
|
1385
|
+
htmlBody: finalHtml,
|
|
1386
|
+
textBody: finalText,
|
|
1055
1387
|
ruleId,
|
|
1056
1388
|
autoApprove: rule.autoApprove ?? true
|
|
1057
1389
|
});
|
|
@@ -1060,7 +1392,7 @@ var RuleRunnerService = class {
|
|
|
1060
1392
|
userId,
|
|
1061
1393
|
identifier.id,
|
|
1062
1394
|
void 0,
|
|
1063
|
-
{ status: "sent", accountId: agentSelection.accountId, subject:
|
|
1395
|
+
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
|
|
1064
1396
|
);
|
|
1065
1397
|
const current = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1066
1398
|
throttleMap.set(userId, {
|
|
@@ -1070,6 +1402,10 @@ var RuleRunnerService = class {
|
|
|
1070
1402
|
});
|
|
1071
1403
|
stats.sent++;
|
|
1072
1404
|
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent" });
|
|
1405
|
+
totalProcessed++;
|
|
1406
|
+
if (runId && totalProcessed % 10 === 0) {
|
|
1407
|
+
await this.updateRunSendProgress(runId, stats);
|
|
1408
|
+
}
|
|
1073
1409
|
if (i < users.length - 1) {
|
|
1074
1410
|
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1075
1411
|
const jitterMs = this.config.options?.jitterMs || 0;
|
|
@@ -1149,6 +1485,74 @@ var RuleRunnerService = class {
|
|
|
1149
1485
|
todayStart.setHours(0, 0, 0, 0);
|
|
1150
1486
|
return todayStart;
|
|
1151
1487
|
}
|
|
1488
|
+
async updateRunProgress(runId, data) {
|
|
1489
|
+
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1490
|
+
const flat = [];
|
|
1491
|
+
for (const [k, v] of Object.entries(data)) {
|
|
1492
|
+
if (typeof v === "object" && v !== null) {
|
|
1493
|
+
flat.push(k, JSON.stringify(v));
|
|
1494
|
+
} else {
|
|
1495
|
+
flat.push(k, String(v));
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
if (flat.length > 0) {
|
|
1499
|
+
await this.redis.hset(key, ...flat);
|
|
1500
|
+
await this.redis.expire(key, 3600);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
async updateRunSendProgress(runId, stats) {
|
|
1504
|
+
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1505
|
+
const existing = await this.redis.hget(key, "progress");
|
|
1506
|
+
let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
|
|
1507
|
+
if (existing) {
|
|
1508
|
+
try {
|
|
1509
|
+
progress = JSON.parse(existing);
|
|
1510
|
+
} catch {
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
progress.sent = stats.sent;
|
|
1514
|
+
progress.failed = stats.errors;
|
|
1515
|
+
progress.skipped = stats.skipped + stats.skippedByThrottle;
|
|
1516
|
+
await this.redis.hset(key, "progress", JSON.stringify(progress));
|
|
1517
|
+
await this.redis.expire(key, 3600);
|
|
1518
|
+
}
|
|
1519
|
+
async getStatus(runId) {
|
|
1520
|
+
const key = `${this.keyPrefix}run:${runId}:progress`;
|
|
1521
|
+
const data = await this.redis.hgetall(key);
|
|
1522
|
+
if (!data || Object.keys(data).length === 0) return null;
|
|
1523
|
+
let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
|
|
1524
|
+
if (data.progress) {
|
|
1525
|
+
try {
|
|
1526
|
+
progress = JSON.parse(data.progress);
|
|
1527
|
+
} catch {
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return {
|
|
1531
|
+
runId: data.runId || runId,
|
|
1532
|
+
status: data.status || "running",
|
|
1533
|
+
currentRule: data.currentRule || "",
|
|
1534
|
+
progress,
|
|
1535
|
+
startedAt: data.startedAt || "",
|
|
1536
|
+
elapsed: parseInt(data.elapsed || "0", 10)
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
async cancel(runId) {
|
|
1540
|
+
const progressKey = `${this.keyPrefix}run:${runId}:progress`;
|
|
1541
|
+
const exists = await this.redis.exists(progressKey);
|
|
1542
|
+
if (!exists) return { ok: false };
|
|
1543
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1544
|
+
await this.redis.set(cancelKey, "1", "EX", 3600);
|
|
1545
|
+
return { ok: true };
|
|
1546
|
+
}
|
|
1547
|
+
trigger(triggeredBy) {
|
|
1548
|
+
const runId = crypto2.randomUUID();
|
|
1549
|
+
this.runAllRules(triggeredBy || RUN_TRIGGER.Manual, runId).catch((err) => {
|
|
1550
|
+
this.logger.error("Background rule run failed", { error: err, runId });
|
|
1551
|
+
this.updateRunProgress(runId, { status: "failed" }).catch(() => {
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
return { runId };
|
|
1555
|
+
}
|
|
1152
1556
|
buildThrottleMap(recentSends) {
|
|
1153
1557
|
const map = /* @__PURE__ */ new Map();
|
|
1154
1558
|
const todayStart = this.getTodayStart();
|
|
@@ -1217,9 +1621,9 @@ function createTemplateController(templateService, options) {
|
|
|
1217
1621
|
}
|
|
1218
1622
|
async function create(req, res) {
|
|
1219
1623
|
try {
|
|
1220
|
-
const { name,
|
|
1221
|
-
if (!name || !
|
|
1222
|
-
return res.status(400).json({ success: false, error: "name,
|
|
1624
|
+
const { name, subjects, bodies, category, audience, platform } = req.body;
|
|
1625
|
+
if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
|
|
1626
|
+
return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
|
|
1223
1627
|
}
|
|
1224
1628
|
if (!isValidValue(validCategories, category)) {
|
|
1225
1629
|
return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
|
|
@@ -1371,14 +1775,21 @@ function createRuleController(ruleService, options) {
|
|
|
1371
1775
|
if (!name || !target || !templateId) {
|
|
1372
1776
|
return res.status(400).json({ success: false, error: "name, target, and templateId are required" });
|
|
1373
1777
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1778
|
+
const mode = target.mode || "query";
|
|
1779
|
+
if (mode === "list") {
|
|
1780
|
+
if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
|
|
1781
|
+
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
1782
|
+
}
|
|
1783
|
+
} else {
|
|
1784
|
+
if (!target.role || !isValidValue2(validAudiences, target.role)) {
|
|
1785
|
+
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
1786
|
+
}
|
|
1787
|
+
if (platformValues && !platformValues.includes(target.platform)) {
|
|
1788
|
+
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
1789
|
+
}
|
|
1790
|
+
if (!Array.isArray(target.conditions)) {
|
|
1791
|
+
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
1792
|
+
}
|
|
1382
1793
|
}
|
|
1383
1794
|
if (req.body.emailType && !isValidValue2(validEmailTypes, req.body.emailType)) {
|
|
1384
1795
|
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
@@ -1393,14 +1804,23 @@ function createRuleController(ruleService, options) {
|
|
|
1393
1804
|
async function update(req, res) {
|
|
1394
1805
|
try {
|
|
1395
1806
|
const { target, emailType } = req.body;
|
|
1396
|
-
if (target
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1807
|
+
if (target) {
|
|
1808
|
+
const mode = target.mode || "query";
|
|
1809
|
+
if (mode === "list") {
|
|
1810
|
+
if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
|
|
1811
|
+
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
1812
|
+
}
|
|
1813
|
+
} else {
|
|
1814
|
+
if (target.role && !isValidValue2(validAudiences, target.role)) {
|
|
1815
|
+
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
1816
|
+
}
|
|
1817
|
+
if (target.platform && platformValues && !platformValues.includes(target.platform)) {
|
|
1818
|
+
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
1819
|
+
}
|
|
1820
|
+
if (target.conditions && !Array.isArray(target.conditions)) {
|
|
1821
|
+
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1404
1824
|
}
|
|
1405
1825
|
if (emailType && !isValidValue2(validEmailTypes, emailType)) {
|
|
1406
1826
|
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
@@ -1461,23 +1881,10 @@ function createRuleController(ruleService, options) {
|
|
|
1461
1881
|
}
|
|
1462
1882
|
return { list, getById, create, update, remove, toggleActive, dryRun, runHistory };
|
|
1463
1883
|
}
|
|
1464
|
-
|
|
1465
|
-
// src/controllers/runner.controller.ts
|
|
1466
|
-
var defaultLogger2 = {
|
|
1467
|
-
info: () => {
|
|
1468
|
-
},
|
|
1469
|
-
warn: () => {
|
|
1470
|
-
},
|
|
1471
|
-
error: () => {
|
|
1472
|
-
}
|
|
1473
|
-
};
|
|
1474
1884
|
function createRunnerController(runnerService, EmailRuleRunLog, logger) {
|
|
1475
|
-
const log = logger || defaultLogger2;
|
|
1476
1885
|
async function triggerManualRun(_req, res) {
|
|
1477
|
-
runnerService.
|
|
1478
|
-
|
|
1479
|
-
});
|
|
1480
|
-
res.json({ success: true, data: { message: "Rule run triggered" } });
|
|
1886
|
+
const { runId } = runnerService.trigger(RUN_TRIGGER.Manual);
|
|
1887
|
+
res.json({ success: true, data: { message: "Rule run triggered", runId } });
|
|
1481
1888
|
}
|
|
1482
1889
|
async function getLatestRun(_req, res) {
|
|
1483
1890
|
try {
|
|
@@ -1488,7 +1895,33 @@ function createRunnerController(runnerService, EmailRuleRunLog, logger) {
|
|
|
1488
1895
|
res.status(500).json({ success: false, error: message });
|
|
1489
1896
|
}
|
|
1490
1897
|
}
|
|
1491
|
-
|
|
1898
|
+
async function getStatusByRunId(req, res) {
|
|
1899
|
+
try {
|
|
1900
|
+
const status = await runnerService.getStatus(getParam(req, "runId"));
|
|
1901
|
+
if (!status) {
|
|
1902
|
+
res.status(404).json({ success: false, error: "Run not found" });
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
res.json({ success: true, data: status });
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1908
|
+
res.status(500).json({ success: false, error: message });
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
async function cancelRun(req, res) {
|
|
1912
|
+
try {
|
|
1913
|
+
const result = await runnerService.cancel(getParam(req, "runId"));
|
|
1914
|
+
if (!result.ok) {
|
|
1915
|
+
res.status(404).json({ success: false, error: "Run not found" });
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1918
|
+
res.json({ success: true, data: { message: "Cancel requested" } });
|
|
1919
|
+
} catch (error) {
|
|
1920
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1921
|
+
res.status(500).json({ success: false, error: message });
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return { triggerManualRun, getLatestRun, getStatusByRunId, cancelRun };
|
|
1492
1925
|
}
|
|
1493
1926
|
|
|
1494
1927
|
// src/controllers/settings.controller.ts
|
|
@@ -1559,7 +1992,7 @@ function createRoutes(deps) {
|
|
|
1559
1992
|
platforms: deps.platformValues,
|
|
1560
1993
|
audiences: deps.audienceValues
|
|
1561
1994
|
});
|
|
1562
|
-
const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog
|
|
1995
|
+
const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog);
|
|
1563
1996
|
const settingsCtrl = createSettingsController(deps.EmailThrottleConfig);
|
|
1564
1997
|
const templateRouter = Router();
|
|
1565
1998
|
templateRouter.get("/", templateCtrl.list);
|
|
@@ -1584,6 +2017,8 @@ function createRoutes(deps) {
|
|
|
1584
2017
|
const runnerRouter = Router();
|
|
1585
2018
|
runnerRouter.post("/", runnerCtrl.triggerManualRun);
|
|
1586
2019
|
runnerRouter.get("/status", runnerCtrl.getLatestRun);
|
|
2020
|
+
runnerRouter.get("/status/:runId", runnerCtrl.getStatusByRunId);
|
|
2021
|
+
runnerRouter.post("/cancel/:runId", runnerCtrl.cancelRun);
|
|
1587
2022
|
const settingsRouter = Router();
|
|
1588
2023
|
settingsRouter.get("/throttle", settingsCtrl.getThrottleConfig);
|
|
1589
2024
|
settingsRouter.patch("/throttle", settingsCtrl.updateThrottleConfig);
|