@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.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 crypto from 'crypto';
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
- role: { type: String, enum: audienceValues || Object.values(TEMPLATE_AUDIENCE), required: true },
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 validation = this.renderService.validateTemplate(input.body);
572
- if (!validation.valid) {
573
- throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
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 variables = input.variables || this.renderService.extractVariables(
576
- `${input.subject} ${input.body} ${input.textBody || ""}`
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.body) {
588
- const validation = this.renderService.validateTemplate(input.body);
589
- if (!validation.valid) {
590
- throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
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.body || input.subject || input.textBody) {
594
- const subject = input.subject ?? template.subject;
595
- const body = input.body ?? template.body;
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
- input.variables = this.renderService.extractVariables(
598
- `${subject} ${body} ${textBody || ""}`
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.body || input.subject || input.textBody) {
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.subject,
633
- template.body,
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.subject,
656
- template.body,
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
- const compatError = validateRuleTemplateCompat(
729
- input.target.role,
730
- input.target.platform,
731
- template
732
- );
733
- if (compatError) {
734
- throw new RuleTemplateIncompatibleError(compatError);
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
- const targetRole = input.target?.role ?? rule.target.role;
743
- const targetPlatform = input.target?.platform ?? rule.target.platform;
744
- if (input.templateId || input.target) {
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(targetRole, targetPlatform, template);
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 = crypto.randomUUID();
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
- const keyPrefix = config.redis.keyPrefix || "";
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
- config.redis.connection,
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
- async runAllRules(triggeredBy = RUN_TRIGGER.Cron) {
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
- return;
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
- for (const rule of activeRules) {
915
- const stats = await this.executeRule(rule, throttleMap, throttleConfig, templateMap);
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 compiled = this.templateRenderer.compileBatch(
997
- template.subject,
998
- template.body,
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 rendered = this.templateRenderer.renderFromCompiled(compiled, templateData);
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: rendered.subject,
1053
- htmlBody: rendered.html,
1054
- textBody: rendered.text,
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: rendered.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, subject, body, category, audience, platform } = req.body;
1221
- if (!name || !subject || !body || !category || !audience || !platform) {
1222
- return res.status(400).json({ success: false, error: "name, subject, body, category, audience, and platform are required" });
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
- if (!target.role || !isValidValue2(validAudiences, target.role)) {
1375
- return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
1376
- }
1377
- if (platformValues && !platformValues.includes(target.platform)) {
1378
- return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
1379
- }
1380
- if (!Array.isArray(target.conditions)) {
1381
- return res.status(400).json({ success: false, error: "target.conditions must be an array" });
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?.role && !isValidValue2(validAudiences, target.role)) {
1397
- return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
1398
- }
1399
- if (target?.platform && platformValues && !platformValues.includes(target.platform)) {
1400
- return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
1401
- }
1402
- if (target?.conditions && !Array.isArray(target.conditions)) {
1403
- return res.status(400).json({ success: false, error: "target.conditions must be an array" });
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.runAllRules(RUN_TRIGGER.Manual).catch((err) => {
1478
- log.error("Manual rule run failed", { error: err });
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
- return { triggerManualRun, getLatestRun };
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, deps.logger);
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);