@astralibx/email-rule-engine 12.7.3 → 12.9.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 CHANGED
@@ -96,6 +96,7 @@ The `createEmailRuleEngine(config)` factory accepts an `EmailRuleEngineConfig` o
96
96
  | `db` | Yes | Mongoose connection and optional collection prefix |
97
97
  | `redis` | Yes | ioredis connection for distributed locking |
98
98
  | `adapters` | Yes | 5 required + 1 optional function bridging your app to the engine |
99
+ | `collections` | No | MongoDB collection schemas for field dropdowns, type-aware operators, and validation |
99
100
  | `platforms` | No | Valid platform values for schema validation |
100
101
  | `audiences` | No | Valid audience/role values for schema validation |
101
102
  | `categories` | No | Valid template categories for schema validation |
@@ -114,6 +115,8 @@ See [docs/configuration.md](https://github.com/Hariprakash1997/astralib/blob/mai
114
115
  5. [Execution Flow](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/execution-flow.md) — Understand how the runner processes rules
115
116
  6. [Throttling](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/throttling.md) — Configure per-user send limits
116
117
 
118
+ 7. [Collections](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/collections.md) — Register collection schemas for field dropdowns and type-aware validation
119
+
117
120
  Reference: [API Routes](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/api-routes.md) | [Programmatic API](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/programmatic-api.md) | [Types](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/types.md) | [Constants](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/constants.md) | [Error Handling](https://github.com/Hariprakash1997/astralib/blob/main/packages/email/rule-engine/docs/error-handling.md)
118
121
 
119
122
  ### Redis Key Prefix (Required for Multi-Project Deployments)
package/dist/index.cjs CHANGED
@@ -18,6 +18,26 @@ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
18
18
 
19
19
  // src/schemas/template.schema.ts
20
20
 
21
+ // src/constants/field-types.ts
22
+ var FIELD_TYPE = {
23
+ String: "string",
24
+ Number: "number",
25
+ Boolean: "boolean",
26
+ Date: "date",
27
+ ObjectId: "objectId",
28
+ Array: "array",
29
+ Object: "object"
30
+ };
31
+ var TYPE_OPERATORS = {
32
+ string: ["eq", "neq", "contains", "in", "not_in", "exists", "not_exists"],
33
+ number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in", "exists", "not_exists"],
34
+ boolean: ["eq", "neq", "exists", "not_exists"],
35
+ date: ["eq", "neq", "gt", "gte", "lt", "lte", "exists", "not_exists"],
36
+ objectId: ["eq", "neq", "in", "not_in", "exists", "not_exists"],
37
+ array: ["contains", "in", "not_in", "exists", "not_exists"],
38
+ object: ["exists", "not_exists"]
39
+ };
40
+
21
41
  // src/constants/index.ts
22
42
  var TEMPLATE_CATEGORY = {
23
43
  Onboarding: "onboarding",
@@ -173,7 +193,8 @@ function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix)
173
193
  ...platformValues ? { enum: platformValues } : {}
174
194
  },
175
195
  conditions: [RuleConditionSchema],
176
- identifiers: [{ type: String }]
196
+ identifiers: [{ type: String }],
197
+ collection: { type: String }
177
198
  }, { _id: false });
178
199
  const RuleRunStatsSchema = new mongoose.Schema({
179
200
  matched: { type: Number, default: 0 },
@@ -264,9 +285,9 @@ function createEmailRuleSendSchema(collectionPrefix) {
264
285
  const schema = new mongoose.Schema(
265
286
  {
266
287
  ruleId: { type: mongoose.Schema.Types.ObjectId, ref: "EmailRule", required: true },
267
- userId: { type: mongoose.Schema.Types.ObjectId, required: true },
268
- emailIdentifierId: { type: mongoose.Schema.Types.ObjectId },
269
- messageId: { type: mongoose.Schema.Types.ObjectId },
288
+ userId: { type: String, required: true },
289
+ emailIdentifierId: { type: String },
290
+ messageId: { type: String },
270
291
  sentAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
271
292
  status: { type: String },
272
293
  accountId: { type: String },
@@ -306,6 +327,7 @@ function createEmailRuleSendSchema(collectionPrefix) {
306
327
  schema.index({ ruleId: 1, userId: 1, sentAt: -1 });
307
328
  schema.index({ userId: 1, sentAt: -1 });
308
329
  schema.index({ ruleId: 1, sentAt: -1 });
330
+ schema.index({ status: 1, sentAt: -1 });
309
331
  return schema;
310
332
  }
311
333
  function createEmailRuleRunLogSchema(collectionPrefix) {
@@ -348,8 +370,7 @@ function createEmailRuleRunLogSchema(collectionPrefix) {
348
370
  }
349
371
  }
350
372
  );
351
- schema.index({ runAt: -1 });
352
- schema.index({ runAt: 1 }, { expireAfterSeconds: 90 * 86400 });
373
+ schema.index({ runAt: -1 }, { expireAfterSeconds: 90 * 86400 });
353
374
  return schema;
354
375
  }
355
376
  function createEmailThrottleConfigSchema(collectionPrefix) {
@@ -476,15 +497,15 @@ var TemplateRenderService = class {
476
497
  ensureHelpers();
477
498
  }
478
499
  renderSingle(subject, body, data, textBody) {
479
- const subjectFn = Handlebars__default.default.compile(subject, { strict: true });
500
+ const subjectFn = Handlebars__default.default.compile(subject, { strict: false });
480
501
  const resolvedSubject = subjectFn(data);
481
- const bodyFn = Handlebars__default.default.compile(body, { strict: true });
502
+ const bodyFn = Handlebars__default.default.compile(body, { strict: false });
482
503
  const resolvedBody = bodyFn(data);
483
504
  const mjmlSource = wrapInMjml(resolvedBody);
484
505
  const html = compileMjml(mjmlSource);
485
506
  let text;
486
507
  if (textBody) {
487
- const textFn = Handlebars__default.default.compile(textBody, { strict: true });
508
+ const textFn = Handlebars__default.default.compile(textBody, { strict: false });
488
509
  text = textFn(data);
489
510
  } else {
490
511
  text = htmlToPlainText(html);
@@ -494,20 +515,20 @@ var TemplateRenderService = class {
494
515
  compileBatch(subject, body, textBody) {
495
516
  const mjmlSource = wrapInMjml(body);
496
517
  const htmlWithHandlebars = compileMjml(mjmlSource);
497
- const subjectFn = Handlebars__default.default.compile(subject, { strict: true });
498
- const bodyFn = Handlebars__default.default.compile(htmlWithHandlebars, { strict: true });
499
- const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
518
+ const subjectFn = Handlebars__default.default.compile(subject, { strict: false });
519
+ const bodyFn = Handlebars__default.default.compile(htmlWithHandlebars, { strict: false });
520
+ const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: false }) : void 0;
500
521
  return { subjectFn, bodyFn, textBodyFn };
501
522
  }
502
523
  compileBatchVariants(subjects, bodies, textBody, preheaders) {
503
- const subjectFns = subjects.map((s) => Handlebars__default.default.compile(s, { strict: true }));
524
+ const subjectFns = subjects.map((s) => Handlebars__default.default.compile(s, { strict: false }));
504
525
  const bodyFns = bodies.map((b) => {
505
526
  const mjmlSource = wrapInMjml(b);
506
527
  const htmlWithHandlebars = compileMjml(mjmlSource);
507
- return Handlebars__default.default.compile(htmlWithHandlebars, { strict: true });
528
+ return Handlebars__default.default.compile(htmlWithHandlebars, { strict: false });
508
529
  });
509
- const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
510
- const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars__default.default.compile(p, { strict: true })) : void 0;
530
+ const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: false }) : void 0;
531
+ const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars__default.default.compile(p, { strict: false })) : void 0;
511
532
  return { subjectFns, bodyFns, textBodyFn, preheaderFns };
512
533
  }
513
534
  renderFromCompiled(compiled, data) {
@@ -646,9 +667,10 @@ function slugify(name) {
646
667
  return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
647
668
  }
648
669
  var TemplateService = class {
649
- constructor(EmailTemplate, config) {
670
+ constructor(EmailTemplate, config, EmailRule) {
650
671
  this.EmailTemplate = EmailTemplate;
651
672
  this.config = config;
673
+ this.EmailRule = EmailRule;
652
674
  }
653
675
  renderService = new TemplateRenderService();
654
676
  async list(filters) {
@@ -657,7 +679,14 @@ var TemplateService = class {
657
679
  if (filters?.audience) query["audience"] = filters.audience;
658
680
  if (filters?.platform) query["platform"] = filters.platform;
659
681
  if (filters?.isActive !== void 0) query["isActive"] = filters.isActive;
660
- return this.EmailTemplate.find(query).sort({ category: 1, name: 1 });
682
+ const page = filters?.page ?? 1;
683
+ const limit = filters?.limit ?? 200;
684
+ const skip = (page - 1) * limit;
685
+ const [templates, total] = await Promise.all([
686
+ this.EmailTemplate.find(query).sort({ category: 1, name: 1 }).skip(skip).limit(limit),
687
+ this.EmailTemplate.countDocuments(query)
688
+ ]);
689
+ return { templates, total };
661
690
  }
662
691
  async getById(id) {
663
692
  return this.EmailTemplate.findById(id);
@@ -740,6 +769,13 @@ var TemplateService = class {
740
769
  );
741
770
  }
742
771
  async delete(id) {
772
+ if (this.EmailRule) {
773
+ const activeRules = await this.EmailRule.find({ templateId: id, isActive: true });
774
+ if (activeRules.length > 0) {
775
+ const names = activeRules.map((r) => r.name).join(", ");
776
+ throw new Error(`Cannot delete template: ${activeRules.length} active rule(s) reference it (${names}). Deactivate them first.`);
777
+ }
778
+ }
743
779
  const result = await this.EmailTemplate.findByIdAndDelete(id);
744
780
  return result !== null;
745
781
  }
@@ -819,7 +855,8 @@ var TemplateService = class {
819
855
  async previewWithRecipient(templateId, recipientData) {
820
856
  const template = await this.EmailTemplate.findById(templateId);
821
857
  if (!template) return null;
822
- const data = { ...template.fields ?? {}, ...recipientData };
858
+ const variables = template.variables ?? [];
859
+ const data = this._buildSampleData(variables, { ...template.fields ?? {}, ...recipientData });
823
860
  return this.renderService.renderPreview(
824
861
  template.subjects[0],
825
862
  template.bodies[0],
@@ -829,6 +866,125 @@ var TemplateService = class {
829
866
  }
830
867
  };
831
868
 
869
+ // src/controllers/collection.controller.ts
870
+ function flattenFields(fields, prefix = "", parentIsArray = false) {
871
+ const result = [];
872
+ for (const field of fields) {
873
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
874
+ const isArray = field.type === "array";
875
+ if (field.type === "object" && field.fields?.length) {
876
+ result.push({
877
+ path,
878
+ type: "object",
879
+ label: field.label,
880
+ description: field.description
881
+ });
882
+ result.push(...flattenFields(field.fields, path, false));
883
+ } else if (isArray && field.fields?.length) {
884
+ result.push({
885
+ path: `${path}[]`,
886
+ type: "array",
887
+ label: field.label,
888
+ description: field.description,
889
+ isArray: true
890
+ });
891
+ result.push(...flattenFields(field.fields, `${path}[]`, true));
892
+ } else {
893
+ result.push({
894
+ path,
895
+ type: field.type,
896
+ label: field.label,
897
+ description: field.description,
898
+ enumValues: field.enumValues,
899
+ isArray: parentIsArray || isArray
900
+ });
901
+ }
902
+ }
903
+ return result;
904
+ }
905
+ function createCollectionController(collections) {
906
+ return {
907
+ list(_req, res) {
908
+ const summary = collections.map((c) => ({
909
+ name: c.name,
910
+ label: c.label,
911
+ description: c.description,
912
+ identifierField: c.identifierField,
913
+ fieldCount: c.fields.length,
914
+ joinCount: c.joins?.length ?? 0
915
+ }));
916
+ res.json({ collections: summary });
917
+ },
918
+ getFields(req, res) {
919
+ const { name } = req.params;
920
+ const collection = collections.find((c) => c.name === name);
921
+ if (!collection) {
922
+ res.status(404).json({ error: `Collection "${name}" not found` });
923
+ return;
924
+ }
925
+ const fields = flattenFields(collection.fields);
926
+ if (collection.joins?.length) {
927
+ for (const join of collection.joins) {
928
+ const joinedCollection = collections.find((c) => c.name === join.from);
929
+ if (joinedCollection) {
930
+ const joinedFields = flattenFields(joinedCollection.fields, join.as);
931
+ fields.push(...joinedFields);
932
+ }
933
+ }
934
+ }
935
+ res.json({
936
+ name: collection.name,
937
+ label: collection.label,
938
+ identifierField: collection.identifierField,
939
+ fields,
940
+ typeOperators: TYPE_OPERATORS
941
+ });
942
+ }
943
+ };
944
+ }
945
+
946
+ // src/validation/condition.validator.ts
947
+ function validateConditions(conditions, collectionName, collections) {
948
+ if (!collectionName || collections.length === 0) return [];
949
+ const collection = collections.find((c) => c.name === collectionName);
950
+ if (!collection) return [];
951
+ const flatFields = flattenFields(collection.fields);
952
+ if (collection.joins?.length) {
953
+ for (const join of collection.joins) {
954
+ const joinedCollection = collections.find((c) => c.name === join.from);
955
+ if (joinedCollection) {
956
+ flatFields.push(...flattenFields(joinedCollection.fields, join.as));
957
+ }
958
+ }
959
+ }
960
+ const fieldMap = /* @__PURE__ */ new Map();
961
+ for (const f of flatFields) {
962
+ fieldMap.set(f.path, f);
963
+ }
964
+ const errors = [];
965
+ for (let i = 0; i < conditions.length; i++) {
966
+ const cond = conditions[i];
967
+ const fieldDef = fieldMap.get(cond.field);
968
+ if (!fieldDef) {
969
+ errors.push({
970
+ index: i,
971
+ field: cond.field,
972
+ message: `Field "${cond.field}" does not exist in collection "${collectionName}"`
973
+ });
974
+ continue;
975
+ }
976
+ const allowedOps = TYPE_OPERATORS[fieldDef.type];
977
+ if (allowedOps && !allowedOps.includes(cond.operator)) {
978
+ errors.push({
979
+ index: i,
980
+ field: cond.field,
981
+ message: `Operator "${cond.operator}" is not valid for field type "${fieldDef.type}"`
982
+ });
983
+ }
984
+ }
985
+ return errors;
986
+ }
987
+
832
988
  // src/services/rule.service.ts
833
989
  function isQueryTarget(target) {
834
990
  return !target.mode || target.mode === "query";
@@ -879,8 +1035,15 @@ var RuleService = class {
879
1035
  this.EmailRuleRunLog = EmailRuleRunLog;
880
1036
  this.config = config;
881
1037
  }
882
- async list() {
883
- return this.EmailRule.find().populate("templateId", "name slug").sort({ sortOrder: 1, createdAt: -1 });
1038
+ async list(opts) {
1039
+ const page = opts?.page ?? 1;
1040
+ const limit = opts?.limit ?? 200;
1041
+ const skip = (page - 1) * limit;
1042
+ const [rules, total] = await Promise.all([
1043
+ this.EmailRule.find().populate("templateId", "name slug").sort({ sortOrder: 1, createdAt: -1 }).skip(skip).limit(limit),
1044
+ this.EmailRule.countDocuments()
1045
+ ]);
1046
+ return { rules, total };
884
1047
  }
885
1048
  async getById(id) {
886
1049
  return this.EmailRule.findById(id);
@@ -907,6 +1070,14 @@ var RuleService = class {
907
1070
  throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
908
1071
  }
909
1072
  }
1073
+ if (isQueryTarget(input.target) && input.target.collection && this.config.collections?.length) {
1074
+ const condErrors = validateConditions(input.target.conditions, input.target.collection, this.config.collections);
1075
+ if (condErrors.length > 0) {
1076
+ throw new RuleTemplateIncompatibleError(
1077
+ `Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
1078
+ );
1079
+ }
1080
+ }
910
1081
  return this.EmailRule.createRule(input);
911
1082
  }
912
1083
  async update(id, input) {
@@ -936,6 +1107,17 @@ var RuleService = class {
936
1107
  throw new RuleTemplateIncompatibleError(compatError);
937
1108
  }
938
1109
  }
1110
+ if (isQueryTarget(effectiveTarget)) {
1111
+ const qt = effectiveTarget;
1112
+ if (qt.collection && this.config.collections?.length) {
1113
+ const condErrors = validateConditions(qt.conditions || [], qt.collection, this.config.collections);
1114
+ if (condErrors.length > 0) {
1115
+ throw new RuleTemplateIncompatibleError(
1116
+ `Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
1117
+ );
1118
+ }
1119
+ }
1120
+ }
939
1121
  const setFields = {};
940
1122
  for (const [key, value] of Object.entries(input)) {
941
1123
  if (value !== void 0 && UPDATEABLE_FIELDS2.has(key)) {
@@ -990,7 +1172,10 @@ var RuleService = class {
990
1172
  const sample2 = identifiers.slice(0, 10).map((id2) => ({ email: id2 }));
991
1173
  return { matchedCount: matchedCount2, effectiveLimit, willProcess: willProcess2, ruleId: id, sample: sample2 };
992
1174
  }
993
- const users = await this.config.adapters.queryUsers(rule.target, 5e4);
1175
+ const queryTarget = rule.target;
1176
+ const collectionName = queryTarget.collection;
1177
+ const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
1178
+ const users = await this.config.adapters.queryUsers(rule.target, 5e4, collectionSchema ? { collectionSchema } : void 0);
994
1179
  const matchedCount = users.length;
995
1180
  const willProcess = Math.min(matchedCount, effectiveLimit);
996
1181
  const sample = users.slice(0, 10).map((u) => ({
@@ -1012,8 +1197,25 @@ var RuleService = class {
1012
1197
  rest.lastRunStats = void 0;
1013
1198
  return this.EmailRule.create(rest);
1014
1199
  }
1015
- async getRunHistory(limit = 20) {
1016
- return this.EmailRuleRunLog.getRecent(limit);
1200
+ async getRunHistory(limit = 20, opts) {
1201
+ const filter = {};
1202
+ if (opts?.from || opts?.to) {
1203
+ filter.runAt = {};
1204
+ if (opts.from) filter.runAt["$gte"] = new Date(opts.from);
1205
+ if (opts.to) filter.runAt["$lte"] = /* @__PURE__ */ new Date(opts.to + "T23:59:59.999Z");
1206
+ }
1207
+ const page = opts?.page ?? 1;
1208
+ const skip = (page - 1) * limit;
1209
+ return this.EmailRuleRunLog.find(filter).sort({ runAt: -1 }).skip(skip).limit(limit);
1210
+ }
1211
+ async getRunHistoryCount(opts) {
1212
+ const filter = {};
1213
+ if (opts?.from || opts?.to) {
1214
+ filter.runAt = {};
1215
+ if (opts.from) filter.runAt["$gte"] = new Date(opts.from);
1216
+ if (opts.to) filter.runAt["$lte"] = /* @__PURE__ */ new Date(opts.to + "T23:59:59.999Z");
1217
+ }
1218
+ return this.EmailRuleRunLog.countDocuments(filter);
1017
1219
  }
1018
1220
  };
1019
1221
  var MS_PER_DAY = 864e5;
@@ -1083,6 +1285,7 @@ var RuleRunnerService = class {
1083
1285
  const lockAcquired = await this.lock.acquire();
1084
1286
  if (!lockAcquired) {
1085
1287
  this.logger.warn("Rule runner already executing, skipping");
1288
+ await this.updateRunProgress(runId, { status: "failed", currentRule: "Another run is already in progress" });
1086
1289
  return { runId };
1087
1290
  }
1088
1291
  const runStartTime = Date.now();
@@ -1295,12 +1498,12 @@ var RuleRunnerService = class {
1295
1498
  const dedupKey = identifier.id;
1296
1499
  const lastSend = sendMap.get(dedupKey);
1297
1500
  if (lastSend) {
1298
- if (rule.sendOnce && !rule.resendAfterDays) {
1501
+ if (rule.sendOnce && rule.resendAfterDays == null) {
1299
1502
  stats.skipped++;
1300
1503
  this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1301
1504
  continue;
1302
1505
  }
1303
- if (rule.resendAfterDays) {
1506
+ if (rule.resendAfterDays != null) {
1304
1507
  const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1305
1508
  if (daysSince < rule.resendAfterDays) {
1306
1509
  stats.skipped++;
@@ -1312,6 +1515,14 @@ var RuleRunnerService = class {
1312
1515
  this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1313
1516
  continue;
1314
1517
  }
1518
+ if (rule.cooldownDays) {
1519
+ const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1520
+ if (daysSince < rule.cooldownDays) {
1521
+ stats.skipped++;
1522
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "cooldown period" });
1523
+ continue;
1524
+ }
1525
+ }
1315
1526
  }
1316
1527
  if (!this.checkThrottle(rule, dedupKey, email, throttleMap, throttleConfig, stats, templateId, runId)) continue;
1317
1528
  const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
@@ -1397,7 +1608,7 @@ var RuleRunnerService = class {
1397
1608
  lastSentDate: /* @__PURE__ */ new Date()
1398
1609
  });
1399
1610
  stats.sent++;
1400
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi });
1611
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, preheaderIndex: pi });
1401
1612
  totalProcessed++;
1402
1613
  if (runId && totalProcessed % 10 === 0) {
1403
1614
  await this.updateRunSendProgress(runId, stats);
@@ -1442,7 +1653,9 @@ var RuleRunnerService = class {
1442
1653
  const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
1443
1654
  let users;
1444
1655
  try {
1445
- users = await this.config.adapters.queryUsers(rule.target, limit);
1656
+ const collectionName = rule.target?.collection;
1657
+ const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
1658
+ users = await this.config.adapters.queryUsers(rule.target, limit, collectionSchema ? { collectionSchema } : void 0);
1446
1659
  } catch (err) {
1447
1660
  this.logger.error(`Rule "${rule.name}": query failed`, { error: err });
1448
1661
  stats.errorCount = 1;
@@ -1506,12 +1719,12 @@ var RuleRunnerService = class {
1506
1719
  }
1507
1720
  const lastSend = sendMap.get(userId);
1508
1721
  if (lastSend) {
1509
- if (rule.sendOnce && !rule.resendAfterDays) {
1722
+ if (rule.sendOnce && rule.resendAfterDays == null) {
1510
1723
  stats.skipped++;
1511
1724
  this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1512
1725
  continue;
1513
1726
  }
1514
- if (rule.resendAfterDays) {
1727
+ if (rule.resendAfterDays != null) {
1515
1728
  const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1516
1729
  if (daysSince < rule.resendAfterDays) {
1517
1730
  stats.skipped++;
@@ -1523,6 +1736,14 @@ var RuleRunnerService = class {
1523
1736
  this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1524
1737
  continue;
1525
1738
  }
1739
+ if (rule.cooldownDays) {
1740
+ const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1741
+ if (daysSince < rule.cooldownDays) {
1742
+ stats.skipped++;
1743
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "cooldown period" });
1744
+ continue;
1745
+ }
1746
+ }
1526
1747
  }
1527
1748
  const identifier = identifierMap.get(email.toLowerCase().trim());
1528
1749
  if (!identifier) {
@@ -1613,7 +1834,7 @@ var RuleRunnerService = class {
1613
1834
  lastSentDate: /* @__PURE__ */ new Date()
1614
1835
  });
1615
1836
  stats.sent++;
1616
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi });
1837
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, preheaderIndex: pi });
1617
1838
  totalProcessed++;
1618
1839
  if (runId && totalProcessed % 10 === 0) {
1619
1840
  await this.updateRunSendProgress(runId, stats);
@@ -1767,7 +1988,7 @@ var RuleRunnerService = class {
1767
1988
  this.updateRunProgress(runId, { status: "failed" }).catch(() => {
1768
1989
  });
1769
1990
  });
1770
- return { runId };
1991
+ return { runId, started: true };
1771
1992
  }
1772
1993
  buildThrottleMap(recentSends) {
1773
1994
  const map = /* @__PURE__ */ new Map();
@@ -1802,14 +2023,16 @@ function createTemplateController(templateService, options) {
1802
2023
  const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
1803
2024
  async function list(req, res) {
1804
2025
  try {
1805
- const { category, audience, platform, isActive } = req.query;
1806
- const templates = await templateService.list({
2026
+ const { category, audience, platform, isActive, page, limit } = req.query;
2027
+ const { templates, total } = await templateService.list({
1807
2028
  category,
1808
2029
  audience,
1809
2030
  platform,
1810
- isActive: isActive !== void 0 ? isActive === "true" : void 0
2031
+ isActive: isActive !== void 0 ? isActive === "true" : void 0,
2032
+ page: Math.max(1, parseInt(String(page), 10) || 1),
2033
+ limit: Math.min(parseInt(String(limit), 10) || 200, 500)
1811
2034
  });
1812
- res.json({ success: true, data: { templates } });
2035
+ res.json({ success: true, data: { templates, total } });
1813
2036
  } catch (error) {
1814
2037
  const message = error instanceof Error ? error.message : "Unknown error";
1815
2038
  res.status(500).json({ success: false, error: message });
@@ -1980,10 +2203,12 @@ function createRuleController(ruleService, options) {
1980
2203
  const platformValues = options?.platforms;
1981
2204
  const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
1982
2205
  const validEmailTypes = Object.values(EMAIL_TYPE);
1983
- async function list(_req, res) {
2206
+ async function list(req, res) {
1984
2207
  try {
1985
- const rules = await ruleService.list();
1986
- res.json({ success: true, data: { rules } });
2208
+ const page = Math.max(1, parseInt(String(req.query.page), 10) || 1);
2209
+ const limit = Math.min(parseInt(String(req.query.limit), 10) || 200, 500);
2210
+ const { rules, total } = await ruleService.list({ page, limit });
2211
+ res.json({ success: true, data: { rules, total } });
1987
2212
  } catch (error) {
1988
2213
  const message = error instanceof Error ? error.message : "Unknown error";
1989
2214
  res.status(500).json({ success: false, error: message });
@@ -2114,8 +2339,13 @@ function createRuleController(ruleService, options) {
2114
2339
  try {
2115
2340
  const limitParam = req.query.limit;
2116
2341
  const limit = parseInt(String(Array.isArray(limitParam) ? limitParam[0] : limitParam), 10) || 20;
2117
- const logs = await ruleService.getRunHistory(limit);
2118
- res.json({ success: true, data: { logs } });
2342
+ const pageParam = req.query.page;
2343
+ const page = Math.max(1, parseInt(String(Array.isArray(pageParam) ? pageParam[0] : pageParam), 10) || 1);
2344
+ const from = req.query.from ? String(req.query.from) : void 0;
2345
+ const to = req.query.to ? String(req.query.to) : void 0;
2346
+ const logs = await ruleService.getRunHistory(limit, { page, from, to });
2347
+ const total = await ruleService.getRunHistoryCount({ from, to });
2348
+ res.json({ success: true, data: { logs, total } });
2119
2349
  } catch (error) {
2120
2350
  const message = error instanceof Error ? error.message : "Unknown error";
2121
2351
  res.status(500).json({ success: false, error: message });
@@ -2230,7 +2460,7 @@ function createSendLogController(EmailRuleSend) {
2230
2460
  const filter = {};
2231
2461
  if (ruleId) filter.ruleId = ruleId;
2232
2462
  if (status) filter.status = status;
2233
- if (email) filter.email = { $regex: email, $options: "i" };
2463
+ if (email) filter.userId = { $regex: email, $options: "i" };
2234
2464
  if (from || to) {
2235
2465
  filter.sentAt = {};
2236
2466
  if (from) filter.sentAt.$gte = new Date(from);
@@ -2267,6 +2497,7 @@ function createRoutes(deps) {
2267
2497
  const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog);
2268
2498
  const settingsCtrl = createSettingsController(deps.EmailThrottleConfig);
2269
2499
  const sendLogCtrl = createSendLogController(deps.EmailRuleSend);
2500
+ const collectionCtrl = createCollectionController(deps.collections || []);
2270
2501
  const templateRouter = express.Router();
2271
2502
  templateRouter.get("/", templateCtrl.list);
2272
2503
  templateRouter.post("/", templateCtrl.create);
@@ -2297,10 +2528,14 @@ function createRoutes(deps) {
2297
2528
  runnerRouter.get("/logs", ruleCtrl.runHistory);
2298
2529
  const sendLogRouter = express.Router();
2299
2530
  sendLogRouter.get("/", sendLogCtrl.list);
2531
+ const collectionRouter = express.Router();
2532
+ collectionRouter.get("/", collectionCtrl.list);
2533
+ collectionRouter.get("/:name/fields", collectionCtrl.getFields);
2300
2534
  router.use("/templates", templateRouter);
2301
2535
  router.use("/rules", ruleRouter);
2302
2536
  router.use("/runner", runnerRouter);
2303
2537
  router.use("/sends", sendLogRouter);
2538
+ router.use("/collections", collectionRouter);
2304
2539
  router.get("/throttle", settingsCtrl.getThrottleConfig);
2305
2540
  router.put("/throttle", settingsCtrl.updateThrottleConfig);
2306
2541
  return router;
@@ -2316,6 +2551,19 @@ var configSchema = zod.z.object({
2316
2551
  findIdentifier: zod.z.function(),
2317
2552
  sendTestEmail: zod.z.function().optional()
2318
2553
  }),
2554
+ collections: zod.z.array(zod.z.object({
2555
+ name: zod.z.string(),
2556
+ label: zod.z.string().optional(),
2557
+ description: zod.z.string().optional(),
2558
+ identifierField: zod.z.string().optional(),
2559
+ fields: zod.z.array(zod.z.any()),
2560
+ joins: zod.z.array(zod.z.object({
2561
+ from: zod.z.string(),
2562
+ localField: zod.z.string(),
2563
+ foreignField: zod.z.string(),
2564
+ as: zod.z.string()
2565
+ })).optional()
2566
+ })).optional(),
2319
2567
  platforms: zod.z.array(zod.z.string()).optional(),
2320
2568
  audiences: zod.z.array(zod.z.string()).optional(),
2321
2569
  categories: zod.z.array(zod.z.string()).optional(),
@@ -2392,7 +2640,10 @@ var SchedulerService = class {
2392
2640
  }, { connection: connectionOpts, prefix: this.keyPrefix });
2393
2641
  }
2394
2642
  async stopWorker() {
2395
- await this.worker?.close();
2643
+ if (this.worker) {
2644
+ await this.worker.close();
2645
+ this.worker = void 0;
2646
+ }
2396
2647
  }
2397
2648
  async getScheduledJobs() {
2398
2649
  const jobs = await this.queue.getRepeatableJobs();
@@ -2429,7 +2680,7 @@ function createEmailRuleEngine(config) {
2429
2680
  `${prefix}EmailThrottleConfig`,
2430
2681
  createEmailThrottleConfigSchema(prefix)
2431
2682
  );
2432
- const templateService = new TemplateService(EmailTemplate, config);
2683
+ const templateService = new TemplateService(EmailTemplate, config, EmailRule);
2433
2684
  const ruleService = new RuleService(EmailRule, EmailTemplate, EmailRuleRunLog, config);
2434
2685
  const runnerService = new RuleRunnerService(
2435
2686
  EmailRule,
@@ -2449,7 +2700,8 @@ function createEmailRuleEngine(config) {
2449
2700
  platformValues: config.platforms,
2450
2701
  categoryValues: config.categories,
2451
2702
  audienceValues: config.audiences,
2452
- logger: config.logger
2703
+ logger: config.logger,
2704
+ collections: config.collections || []
2453
2705
  });
2454
2706
  return {
2455
2707
  routes,
@@ -2469,6 +2721,7 @@ exports.ConfigValidationError = ConfigValidationError;
2469
2721
  exports.DuplicateSlugError = DuplicateSlugError;
2470
2722
  exports.EMAIL_SEND_STATUS = EMAIL_SEND_STATUS;
2471
2723
  exports.EMAIL_TYPE = EMAIL_TYPE;
2724
+ exports.FIELD_TYPE = FIELD_TYPE;
2472
2725
  exports.LockAcquisitionError = LockAcquisitionError;
2473
2726
  exports.RULE_OPERATOR = RULE_OPERATOR;
2474
2727
  exports.RUN_LOG_STATUS = RUN_LOG_STATUS;
@@ -2482,6 +2735,7 @@ exports.TARGET_MODE = TARGET_MODE;
2482
2735
  exports.TEMPLATE_AUDIENCE = TEMPLATE_AUDIENCE;
2483
2736
  exports.TEMPLATE_CATEGORY = TEMPLATE_CATEGORY;
2484
2737
  exports.THROTTLE_WINDOW = THROTTLE_WINDOW;
2738
+ exports.TYPE_OPERATORS = TYPE_OPERATORS;
2485
2739
  exports.TemplateNotFoundError = TemplateNotFoundError;
2486
2740
  exports.TemplateRenderService = TemplateRenderService;
2487
2741
  exports.TemplateService = TemplateService;
@@ -2492,6 +2746,8 @@ exports.createEmailRuleSchema = createEmailRuleSchema;
2492
2746
  exports.createEmailRuleSendSchema = createEmailRuleSendSchema;
2493
2747
  exports.createEmailTemplateSchema = createEmailTemplateSchema;
2494
2748
  exports.createEmailThrottleConfigSchema = createEmailThrottleConfigSchema;
2749
+ exports.flattenFields = flattenFields;
2750
+ exports.validateConditions = validateConditions;
2495
2751
  exports.validateConfig = validateConfig;
2496
2752
  //# sourceMappingURL=index.cjs.map
2497
2753
  //# sourceMappingURL=index.cjs.map