@astralibx/email-rule-engine 12.7.3 → 12.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -11,6 +11,26 @@ import { Queue, Worker } from 'bullmq';
11
11
 
12
12
  // src/schemas/template.schema.ts
13
13
 
14
+ // src/constants/field-types.ts
15
+ var FIELD_TYPE = {
16
+ String: "string",
17
+ Number: "number",
18
+ Boolean: "boolean",
19
+ Date: "date",
20
+ ObjectId: "objectId",
21
+ Array: "array",
22
+ Object: "object"
23
+ };
24
+ var TYPE_OPERATORS = {
25
+ string: ["eq", "neq", "contains", "in", "not_in", "exists", "not_exists"],
26
+ number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in", "exists", "not_exists"],
27
+ boolean: ["eq", "neq", "exists", "not_exists"],
28
+ date: ["eq", "neq", "gt", "gte", "lt", "lte", "exists", "not_exists"],
29
+ objectId: ["eq", "neq", "in", "not_in", "exists", "not_exists"],
30
+ array: ["contains", "in", "not_in", "exists", "not_exists"],
31
+ object: ["exists", "not_exists"]
32
+ };
33
+
14
34
  // src/constants/index.ts
15
35
  var TEMPLATE_CATEGORY = {
16
36
  Onboarding: "onboarding",
@@ -152,6 +172,17 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
152
172
  schema.index({ audience: 1, platform: 1, isActive: 1 });
153
173
  return schema;
154
174
  }
175
+ function createRunStatsSchema() {
176
+ return new Schema({
177
+ matched: { type: Number, default: 0 },
178
+ sent: { type: Number, default: 0 },
179
+ skipped: { type: Number, default: 0 },
180
+ skippedByThrottle: { type: Number, default: 0 },
181
+ errorCount: { type: Number, default: 0 }
182
+ }, { _id: false });
183
+ }
184
+
185
+ // src/schemas/rule.schema.ts
155
186
  function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix) {
156
187
  const RuleConditionSchema = new Schema({
157
188
  field: { type: String, required: true },
@@ -166,15 +197,10 @@ function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix)
166
197
  ...platformValues ? { enum: platformValues } : {}
167
198
  },
168
199
  conditions: [RuleConditionSchema],
169
- identifiers: [{ type: String }]
170
- }, { _id: false });
171
- const RuleRunStatsSchema = new Schema({
172
- matched: { type: Number, default: 0 },
173
- sent: { type: Number, default: 0 },
174
- skipped: { type: Number, default: 0 },
175
- skippedByThrottle: { type: Number, default: 0 },
176
- errorCount: { type: Number, default: 0 }
200
+ identifiers: [{ type: String }],
201
+ collection: { type: String }
177
202
  }, { _id: false });
203
+ const RuleRunStatsSchema = createRunStatsSchema();
178
204
  const schema = new Schema(
179
205
  {
180
206
  name: { type: String, required: true },
@@ -257,9 +283,9 @@ function createEmailRuleSendSchema(collectionPrefix) {
257
283
  const schema = new Schema(
258
284
  {
259
285
  ruleId: { type: Schema.Types.ObjectId, ref: "EmailRule", required: true },
260
- userId: { type: Schema.Types.ObjectId, required: true },
261
- emailIdentifierId: { type: Schema.Types.ObjectId },
262
- messageId: { type: Schema.Types.ObjectId },
286
+ userId: { type: String, required: true },
287
+ emailIdentifierId: { type: String },
288
+ messageId: { type: String },
263
289
  sentAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
264
290
  status: { type: String },
265
291
  accountId: { type: String },
@@ -299,25 +325,17 @@ function createEmailRuleSendSchema(collectionPrefix) {
299
325
  schema.index({ ruleId: 1, userId: 1, sentAt: -1 });
300
326
  schema.index({ userId: 1, sentAt: -1 });
301
327
  schema.index({ ruleId: 1, sentAt: -1 });
328
+ schema.index({ status: 1, sentAt: -1 });
302
329
  return schema;
303
330
  }
304
331
  function createEmailRuleRunLogSchema(collectionPrefix) {
332
+ const baseStatsSchema = createRunStatsSchema();
305
333
  const PerRuleStatsSchema = new Schema({
306
334
  ruleId: { type: Schema.Types.ObjectId, ref: "EmailRule", required: true },
307
335
  ruleName: { type: String, required: true },
308
- matched: { type: Number, default: 0 },
309
- sent: { type: Number, default: 0 },
310
- skipped: { type: Number, default: 0 },
311
- skippedByThrottle: { type: Number, default: 0 },
312
- errorCount: { type: Number, default: 0 }
313
- }, { _id: false });
314
- const TotalStatsSchema = new Schema({
315
- matched: { type: Number, default: 0 },
316
- sent: { type: Number, default: 0 },
317
- skipped: { type: Number, default: 0 },
318
- skippedByThrottle: { type: Number, default: 0 },
319
- errorCount: { type: Number, default: 0 }
336
+ ...baseStatsSchema.obj
320
337
  }, { _id: false });
338
+ const TotalStatsSchema = createRunStatsSchema();
321
339
  const schema = new Schema(
322
340
  {
323
341
  runId: { type: String, index: true },
@@ -341,8 +359,7 @@ function createEmailRuleRunLogSchema(collectionPrefix) {
341
359
  }
342
360
  }
343
361
  );
344
- schema.index({ runAt: -1 });
345
- schema.index({ runAt: 1 }, { expireAfterSeconds: 90 * 86400 });
362
+ schema.index({ runAt: -1 }, { expireAfterSeconds: 90 * 86400 });
346
363
  return schema;
347
364
  }
348
365
  function createEmailThrottleConfigSchema(collectionPrefix) {
@@ -469,15 +486,15 @@ var TemplateRenderService = class {
469
486
  ensureHelpers();
470
487
  }
471
488
  renderSingle(subject, body, data, textBody) {
472
- const subjectFn = Handlebars.compile(subject, { strict: true });
489
+ const subjectFn = Handlebars.compile(subject, { strict: false });
473
490
  const resolvedSubject = subjectFn(data);
474
- const bodyFn = Handlebars.compile(body, { strict: true });
491
+ const bodyFn = Handlebars.compile(body, { strict: false });
475
492
  const resolvedBody = bodyFn(data);
476
493
  const mjmlSource = wrapInMjml(resolvedBody);
477
494
  const html = compileMjml(mjmlSource);
478
495
  let text;
479
496
  if (textBody) {
480
- const textFn = Handlebars.compile(textBody, { strict: true });
497
+ const textFn = Handlebars.compile(textBody, { strict: false });
481
498
  text = textFn(data);
482
499
  } else {
483
500
  text = htmlToPlainText(html);
@@ -487,20 +504,20 @@ var TemplateRenderService = class {
487
504
  compileBatch(subject, body, textBody) {
488
505
  const mjmlSource = wrapInMjml(body);
489
506
  const htmlWithHandlebars = compileMjml(mjmlSource);
490
- const subjectFn = Handlebars.compile(subject, { strict: true });
491
- const bodyFn = Handlebars.compile(htmlWithHandlebars, { strict: true });
492
- const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: true }) : void 0;
507
+ const subjectFn = Handlebars.compile(subject, { strict: false });
508
+ const bodyFn = Handlebars.compile(htmlWithHandlebars, { strict: false });
509
+ const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: false }) : void 0;
493
510
  return { subjectFn, bodyFn, textBodyFn };
494
511
  }
495
512
  compileBatchVariants(subjects, bodies, textBody, preheaders) {
496
- const subjectFns = subjects.map((s) => Handlebars.compile(s, { strict: true }));
513
+ const subjectFns = subjects.map((s) => Handlebars.compile(s, { strict: false }));
497
514
  const bodyFns = bodies.map((b) => {
498
515
  const mjmlSource = wrapInMjml(b);
499
516
  const htmlWithHandlebars = compileMjml(mjmlSource);
500
- return Handlebars.compile(htmlWithHandlebars, { strict: true });
517
+ return Handlebars.compile(htmlWithHandlebars, { strict: false });
501
518
  });
502
- const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: true }) : void 0;
503
- const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars.compile(p, { strict: true })) : void 0;
519
+ const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: false }) : void 0;
520
+ const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars.compile(p, { strict: false })) : void 0;
504
521
  return { subjectFns, bodyFns, textBodyFn, preheaderFns };
505
522
  }
506
523
  renderFromCompiled(compiled, data) {
@@ -616,6 +633,38 @@ var DuplicateSlugError = class extends AlxEmailError {
616
633
  }
617
634
  };
618
635
 
636
+ // src/utils/query-helpers.ts
637
+ function isValidDateString(s) {
638
+ return s.trim() !== "" && !isNaN(new Date(s).getTime());
639
+ }
640
+ function buildDateRangeFilter(dateField, from, to) {
641
+ const validFrom = from && isValidDateString(from) ? from : void 0;
642
+ const validTo = to && isValidDateString(to) ? to : void 0;
643
+ if (!validFrom && !validTo) return {};
644
+ const filter = {};
645
+ filter[dateField] = {};
646
+ if (validFrom) filter[dateField].$gte = new Date(validFrom);
647
+ if (validTo) filter[dateField].$lte = /* @__PURE__ */ new Date(validTo + "T23:59:59.999Z");
648
+ return filter;
649
+ }
650
+ function calculatePagination(page, limit, maxLimit = 500) {
651
+ const rawPage = page != null && !isNaN(page) ? page : 1;
652
+ const rawLimit = limit != null && !isNaN(limit) ? limit : 200;
653
+ const p = Math.max(1, rawPage);
654
+ const l = Math.max(1, Math.min(rawLimit, maxLimit));
655
+ const skip = (p - 1) * l;
656
+ return { page: p, limit: l, skip };
657
+ }
658
+ function filterUpdateableFields(input, allowedFields) {
659
+ const result = {};
660
+ for (const [key, value] of Object.entries(input)) {
661
+ if (value !== void 0 && allowedFields.has(key)) {
662
+ result[key] = value;
663
+ }
664
+ }
665
+ return result;
666
+ }
667
+
619
668
  // src/services/template.service.ts
620
669
  function stripScriptTags(text) {
621
670
  return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
@@ -639,9 +688,10 @@ function slugify(name) {
639
688
  return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
640
689
  }
641
690
  var TemplateService = class {
642
- constructor(EmailTemplate, config) {
691
+ constructor(EmailTemplate, config, EmailRule) {
643
692
  this.EmailTemplate = EmailTemplate;
644
693
  this.config = config;
694
+ this.EmailRule = EmailRule;
645
695
  }
646
696
  renderService = new TemplateRenderService();
647
697
  async list(filters) {
@@ -650,7 +700,14 @@ var TemplateService = class {
650
700
  if (filters?.audience) query["audience"] = filters.audience;
651
701
  if (filters?.platform) query["platform"] = filters.platform;
652
702
  if (filters?.isActive !== void 0) query["isActive"] = filters.isActive;
653
- return this.EmailTemplate.find(query).sort({ category: 1, name: 1 });
703
+ const page = filters?.page ?? 1;
704
+ const limit = filters?.limit ?? 200;
705
+ const skip = (page - 1) * limit;
706
+ const [templates, total] = await Promise.all([
707
+ this.EmailTemplate.find(query).sort({ category: 1, name: 1 }).skip(skip).limit(limit),
708
+ this.EmailTemplate.countDocuments(query)
709
+ ]);
710
+ return { templates, total };
654
711
  }
655
712
  async getById(id) {
656
713
  return this.EmailTemplate.findById(id);
@@ -716,12 +773,7 @@ var TemplateService = class {
716
773
  const allContent = [...subjects, ...bodies, ...preheaders, textBody || ""].join(" ");
717
774
  input.variables = this.renderService.extractVariables(allContent);
718
775
  }
719
- const setFields = {};
720
- for (const [key, value] of Object.entries(input)) {
721
- if (value !== void 0 && UPDATEABLE_FIELDS.has(key)) {
722
- setFields[key] = value;
723
- }
724
- }
776
+ const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS);
725
777
  const update = { $set: setFields };
726
778
  if (input.textBody || input.subjects || input.bodies || input.preheaders) {
727
779
  update["$inc"] = { version: 1 };
@@ -733,6 +785,13 @@ var TemplateService = class {
733
785
  );
734
786
  }
735
787
  async delete(id) {
788
+ if (this.EmailRule) {
789
+ const activeRules = await this.EmailRule.find({ templateId: id, isActive: true });
790
+ if (activeRules.length > 0) {
791
+ const names = activeRules.map((r) => r.name).join(", ");
792
+ throw new Error(`Cannot delete template: ${activeRules.length} active rule(s) reference it (${names}). Deactivate them first.`);
793
+ }
794
+ }
736
795
  const result = await this.EmailTemplate.findByIdAndDelete(id);
737
796
  return result !== null;
738
797
  }
@@ -812,7 +871,8 @@ var TemplateService = class {
812
871
  async previewWithRecipient(templateId, recipientData) {
813
872
  const template = await this.EmailTemplate.findById(templateId);
814
873
  if (!template) return null;
815
- const data = { ...template.fields ?? {}, ...recipientData };
874
+ const variables = template.variables ?? [];
875
+ const data = this._buildSampleData(variables, { ...template.fields ?? {}, ...recipientData });
816
876
  return this.renderService.renderPreview(
817
877
  template.subjects[0],
818
878
  template.bodies[0],
@@ -822,6 +882,125 @@ var TemplateService = class {
822
882
  }
823
883
  };
824
884
 
885
+ // src/controllers/collection.controller.ts
886
+ function flattenFields(fields, prefix = "", parentIsArray = false) {
887
+ const result = [];
888
+ for (const field of fields) {
889
+ const path = prefix ? `${prefix}.${field.name}` : field.name;
890
+ const isArray = field.type === "array";
891
+ if (field.type === "object" && field.fields?.length) {
892
+ result.push({
893
+ path,
894
+ type: "object",
895
+ label: field.label,
896
+ description: field.description
897
+ });
898
+ result.push(...flattenFields(field.fields, path, false));
899
+ } else if (isArray && field.fields?.length) {
900
+ result.push({
901
+ path: `${path}[]`,
902
+ type: "array",
903
+ label: field.label,
904
+ description: field.description,
905
+ isArray: true
906
+ });
907
+ result.push(...flattenFields(field.fields, `${path}[]`, true));
908
+ } else {
909
+ result.push({
910
+ path,
911
+ type: field.type,
912
+ label: field.label,
913
+ description: field.description,
914
+ enumValues: field.enumValues,
915
+ isArray: parentIsArray || isArray
916
+ });
917
+ }
918
+ }
919
+ return result;
920
+ }
921
+ function createCollectionController(collections) {
922
+ return {
923
+ list(_req, res) {
924
+ const summary = collections.map((c) => ({
925
+ name: c.name,
926
+ label: c.label,
927
+ description: c.description,
928
+ identifierField: c.identifierField,
929
+ fieldCount: c.fields.length,
930
+ joinCount: c.joins?.length ?? 0
931
+ }));
932
+ res.json({ collections: summary });
933
+ },
934
+ getFields(req, res) {
935
+ const { name } = req.params;
936
+ const collection = collections.find((c) => c.name === name);
937
+ if (!collection) {
938
+ res.status(404).json({ error: `Collection "${name}" not found` });
939
+ return;
940
+ }
941
+ const fields = flattenFields(collection.fields);
942
+ if (collection.joins?.length) {
943
+ for (const join of collection.joins) {
944
+ const joinedCollection = collections.find((c) => c.name === join.from);
945
+ if (joinedCollection) {
946
+ const joinedFields = flattenFields(joinedCollection.fields, join.as);
947
+ fields.push(...joinedFields);
948
+ }
949
+ }
950
+ }
951
+ res.json({
952
+ name: collection.name,
953
+ label: collection.label,
954
+ identifierField: collection.identifierField,
955
+ fields,
956
+ typeOperators: TYPE_OPERATORS
957
+ });
958
+ }
959
+ };
960
+ }
961
+
962
+ // src/validation/condition.validator.ts
963
+ function validateConditions(conditions, collectionName, collections) {
964
+ if (!collectionName || collections.length === 0) return [];
965
+ const collection = collections.find((c) => c.name === collectionName);
966
+ if (!collection) return [];
967
+ const flatFields = flattenFields(collection.fields);
968
+ if (collection.joins?.length) {
969
+ for (const join of collection.joins) {
970
+ const joinedCollection = collections.find((c) => c.name === join.from);
971
+ if (joinedCollection) {
972
+ flatFields.push(...flattenFields(joinedCollection.fields, join.as));
973
+ }
974
+ }
975
+ }
976
+ const fieldMap = /* @__PURE__ */ new Map();
977
+ for (const f of flatFields) {
978
+ fieldMap.set(f.path, f);
979
+ }
980
+ const errors = [];
981
+ for (let i = 0; i < conditions.length; i++) {
982
+ const cond = conditions[i];
983
+ const fieldDef = fieldMap.get(cond.field);
984
+ if (!fieldDef) {
985
+ errors.push({
986
+ index: i,
987
+ field: cond.field,
988
+ message: `Field "${cond.field}" does not exist in collection "${collectionName}"`
989
+ });
990
+ continue;
991
+ }
992
+ const allowedOps = TYPE_OPERATORS[fieldDef.type];
993
+ if (allowedOps && !allowedOps.includes(cond.operator)) {
994
+ errors.push({
995
+ index: i,
996
+ field: cond.field,
997
+ message: `Operator "${cond.operator}" is not valid for field type "${fieldDef.type}"`
998
+ });
999
+ }
1000
+ }
1001
+ return errors;
1002
+ }
1003
+
825
1004
  // src/services/rule.service.ts
826
1005
  function isQueryTarget(target) {
827
1006
  return !target.mode || target.mode === "query";
@@ -872,8 +1051,15 @@ var RuleService = class {
872
1051
  this.EmailRuleRunLog = EmailRuleRunLog;
873
1052
  this.config = config;
874
1053
  }
875
- async list() {
876
- return this.EmailRule.find().populate("templateId", "name slug").sort({ sortOrder: 1, createdAt: -1 });
1054
+ async list(opts) {
1055
+ const page = opts?.page ?? 1;
1056
+ const limit = opts?.limit ?? 200;
1057
+ const skip = (page - 1) * limit;
1058
+ const [rules, total] = await Promise.all([
1059
+ this.EmailRule.find().populate("templateId", "name slug").sort({ sortOrder: 1, createdAt: -1 }).skip(skip).limit(limit),
1060
+ this.EmailRule.countDocuments()
1061
+ ]);
1062
+ return { rules, total };
877
1063
  }
878
1064
  async getById(id) {
879
1065
  return this.EmailRule.findById(id);
@@ -900,6 +1086,14 @@ var RuleService = class {
900
1086
  throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
901
1087
  }
902
1088
  }
1089
+ if (isQueryTarget(input.target) && input.target.collection && this.config.collections?.length) {
1090
+ const condErrors = validateConditions(input.target.conditions, input.target.collection, this.config.collections);
1091
+ if (condErrors.length > 0) {
1092
+ throw new RuleTemplateIncompatibleError(
1093
+ `Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
1094
+ );
1095
+ }
1096
+ }
903
1097
  return this.EmailRule.createRule(input);
904
1098
  }
905
1099
  async update(id, input) {
@@ -929,12 +1123,18 @@ var RuleService = class {
929
1123
  throw new RuleTemplateIncompatibleError(compatError);
930
1124
  }
931
1125
  }
932
- const setFields = {};
933
- for (const [key, value] of Object.entries(input)) {
934
- if (value !== void 0 && UPDATEABLE_FIELDS2.has(key)) {
935
- setFields[key] = value;
1126
+ if (isQueryTarget(effectiveTarget)) {
1127
+ const qt = effectiveTarget;
1128
+ if (qt.collection && this.config.collections?.length) {
1129
+ const condErrors = validateConditions(qt.conditions || [], qt.collection, this.config.collections);
1130
+ if (condErrors.length > 0) {
1131
+ throw new RuleTemplateIncompatibleError(
1132
+ `Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
1133
+ );
1134
+ }
936
1135
  }
937
1136
  }
1137
+ const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS2);
938
1138
  return this.EmailRule.findByIdAndUpdate(
939
1139
  id,
940
1140
  { $set: setFields },
@@ -983,7 +1183,10 @@ var RuleService = class {
983
1183
  const sample2 = identifiers.slice(0, 10).map((id2) => ({ email: id2 }));
984
1184
  return { matchedCount: matchedCount2, effectiveLimit, willProcess: willProcess2, ruleId: id, sample: sample2 };
985
1185
  }
986
- const users = await this.config.adapters.queryUsers(rule.target, 5e4);
1186
+ const queryTarget = rule.target;
1187
+ const collectionName = queryTarget.collection;
1188
+ const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
1189
+ const users = await this.config.adapters.queryUsers(rule.target, 5e4, collectionSchema ? { collectionSchema } : void 0);
987
1190
  const matchedCount = users.length;
988
1191
  const willProcess = Math.min(matchedCount, effectiveLimit);
989
1192
  const sample = users.slice(0, 10).map((u) => ({
@@ -1005,8 +1208,14 @@ var RuleService = class {
1005
1208
  rest.lastRunStats = void 0;
1006
1209
  return this.EmailRule.create(rest);
1007
1210
  }
1008
- async getRunHistory(limit = 20) {
1009
- return this.EmailRuleRunLog.getRecent(limit);
1211
+ async getRunHistory(limit = 20, opts) {
1212
+ const filter = buildDateRangeFilter("runAt", opts?.from, opts?.to);
1213
+ const pagination = calculatePagination(opts?.page, limit);
1214
+ return this.EmailRuleRunLog.find(filter).sort({ runAt: -1 }).skip(pagination.skip).limit(pagination.limit);
1215
+ }
1216
+ async getRunHistoryCount(opts) {
1217
+ const filter = buildDateRangeFilter("runAt", opts?.from, opts?.to);
1218
+ return this.EmailRuleRunLog.countDocuments(filter);
1010
1219
  }
1011
1220
  };
1012
1221
  var MS_PER_DAY = 864e5;
@@ -1076,6 +1285,7 @@ var RuleRunnerService = class {
1076
1285
  const lockAcquired = await this.lock.acquire();
1077
1286
  if (!lockAcquired) {
1078
1287
  this.logger.warn("Rule runner already executing, skipping");
1288
+ await this.updateRunProgress(runId, { status: "failed", currentRule: "Another run is already in progress" });
1079
1289
  return { runId };
1080
1290
  }
1081
1291
  const runStartTime = Date.now();
@@ -1226,193 +1436,252 @@ var RuleRunnerService = class {
1226
1436
  }
1227
1437
  return this.executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId);
1228
1438
  }
1229
- async executeListMode(rule, template, throttleMap, throttleConfig, stats, runId) {
1230
- const rawIdentifiers = rule.target.identifiers || [];
1231
- const uniqueEmails = [...new Set(rawIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean))];
1232
- const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
1233
- if (uniqueEmails.length > limit) {
1234
- this.logger.warn(`Rule "${rule.name}" matched ${uniqueEmails.length} users but maxPerRun is ${limit} \u2014 only ${limit} will be processed`, { ruleId: rule._id.toString(), matchedCount: uniqueEmails.length, maxPerRun: limit });
1439
+ emitSendEvent(rule, email, status, templateId, runId, opts) {
1440
+ this.config.hooks?.onSend?.({
1441
+ ruleId: rule._id.toString(),
1442
+ ruleName: rule.name,
1443
+ email,
1444
+ status,
1445
+ accountId: opts?.accountId ?? "",
1446
+ templateId,
1447
+ runId: runId || "",
1448
+ subjectIndex: opts?.subjectIndex ?? -1,
1449
+ bodyIndex: opts?.bodyIndex ?? -1,
1450
+ preheaderIndex: opts?.preheaderIndex,
1451
+ failureReason: opts?.failureReason
1452
+ });
1453
+ }
1454
+ async processSingleUser(params) {
1455
+ const { rule, email, userKey, identifier, user, sendMap, throttleMap, throttleConfig, template, compiledVariants, templateId, ruleId, runId, stats } = params;
1456
+ const lastSend = sendMap.get(userKey);
1457
+ if (lastSend) {
1458
+ if (rule.sendOnce && rule.resendAfterDays == null) {
1459
+ stats.skipped++;
1460
+ this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "send once" });
1461
+ return "skipped";
1462
+ }
1463
+ if (rule.resendAfterDays != null) {
1464
+ const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1465
+ if (daysSince < rule.resendAfterDays) {
1466
+ stats.skipped++;
1467
+ this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "resend too soon" });
1468
+ return "skipped";
1469
+ }
1470
+ } else {
1471
+ stats.skipped++;
1472
+ this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "send once" });
1473
+ return "skipped";
1474
+ }
1475
+ if (rule.cooldownDays) {
1476
+ const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1477
+ if (daysSince < rule.cooldownDays) {
1478
+ stats.skipped++;
1479
+ this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "cooldown period" });
1480
+ return "skipped";
1481
+ }
1482
+ }
1235
1483
  }
1236
- const emailsToProcess = uniqueEmails.slice(0, limit);
1237
- stats.matched = emailsToProcess.length;
1238
- const ruleId = rule._id.toString();
1239
- const templateId = rule.templateId.toString();
1240
- this.config.hooks?.onRuleStart?.({ ruleId, ruleName: rule.name, matchedCount: emailsToProcess.length, templateId, runId: runId || "" });
1241
- if (emailsToProcess.length === 0) return stats;
1484
+ if (!this.checkThrottle(rule, userKey, email, throttleMap, throttleConfig, stats, templateId, runId)) return "skipped";
1485
+ const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
1486
+ if (!agentSelection) {
1487
+ stats.skipped++;
1488
+ this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "no account available" });
1489
+ return "skipped";
1490
+ }
1491
+ const resolvedData = this.config.adapters.resolveData(user);
1492
+ const templateData = { ...template.fields || {}, ...resolvedData };
1493
+ const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
1494
+ const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
1495
+ const renderedSubject = compiledVariants.subjectFns[si](templateData);
1496
+ const renderedHtml = compiledVariants.bodyFns[bi](templateData);
1497
+ const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
1498
+ let finalHtml = renderedHtml;
1499
+ let finalText = renderedText;
1500
+ let finalSubject = renderedSubject;
1501
+ let pi;
1502
+ if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
1503
+ pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
1504
+ const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
1505
+ if (renderedPreheader) {
1506
+ const preheaderHtml = `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">${renderedPreheader}</div>`;
1507
+ finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
1508
+ }
1509
+ }
1510
+ if (this.config.hooks?.beforeSend) {
1511
+ try {
1512
+ const modified = await this.config.hooks.beforeSend({
1513
+ htmlBody: finalHtml,
1514
+ textBody: finalText,
1515
+ subject: finalSubject,
1516
+ account: {
1517
+ id: agentSelection.accountId,
1518
+ email: agentSelection.email,
1519
+ metadata: agentSelection.metadata
1520
+ },
1521
+ user: {
1522
+ id: String(userKey),
1523
+ email,
1524
+ name: String(user.name || user.firstName || "")
1525
+ },
1526
+ context: {
1527
+ ruleId,
1528
+ templateId,
1529
+ runId: runId || ""
1530
+ }
1531
+ });
1532
+ finalHtml = modified.htmlBody;
1533
+ finalText = modified.textBody;
1534
+ finalSubject = modified.subject;
1535
+ } catch (hookErr) {
1536
+ this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
1537
+ stats.errorCount++;
1538
+ this.emitSendEvent(rule, email, "error", templateId, runId || "", { accountId: agentSelection.accountId, subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
1539
+ return "error";
1540
+ }
1541
+ }
1542
+ await this.config.adapters.sendEmail({
1543
+ identifierId: identifier.id,
1544
+ contactId: identifier.contactId,
1545
+ accountId: agentSelection.accountId,
1546
+ subject: finalSubject,
1547
+ htmlBody: finalHtml,
1548
+ textBody: finalText,
1549
+ ruleId,
1550
+ autoApprove: rule.autoApprove ?? true,
1551
+ attachments: template.attachments || []
1552
+ });
1553
+ await this.EmailRuleSend.logSend(
1554
+ ruleId,
1555
+ userKey,
1556
+ identifier.id,
1557
+ void 0,
1558
+ { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
1559
+ );
1560
+ const current = throttleMap.get(userKey) || { today: 0, thisWeek: 0};
1561
+ throttleMap.set(userKey, {
1562
+ today: current.today + 1,
1563
+ thisWeek: current.thisWeek + 1,
1564
+ lastSentDate: /* @__PURE__ */ new Date()
1565
+ });
1566
+ stats.sent++;
1567
+ this.emitSendEvent(rule, email, "sent", templateId, runId || "", { accountId: agentSelection.accountId, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi });
1568
+ return "sent";
1569
+ }
1570
+ async resolveIdentifiers(emails) {
1242
1571
  const identifierResults = await processInChunks(
1243
- emailsToProcess,
1572
+ emails,
1244
1573
  async (email) => {
1245
1574
  const result = await this.config.adapters.findIdentifier(email);
1246
1575
  return result ? { email, ...result } : null;
1247
1576
  },
1248
1577
  IDENTIFIER_CHUNK_SIZE
1249
1578
  );
1250
- const identifierMap = /* @__PURE__ */ new Map();
1579
+ const map = /* @__PURE__ */ new Map();
1251
1580
  for (const result of identifierResults) {
1252
1581
  if (result) {
1253
- identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
1582
+ map.set(result.email, { id: result.id, contactId: result.contactId });
1254
1583
  }
1255
1584
  }
1256
- const validEmails = emailsToProcess.filter((e) => identifierMap.has(e));
1257
- const identifierIds = validEmails.map((e) => identifierMap.get(e).id);
1258
- const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: identifierIds } }).sort({ sentAt: -1 }).lean();
1259
- const sendMap = /* @__PURE__ */ new Map();
1260
- for (const send of allRuleSends) {
1585
+ return map;
1586
+ }
1587
+ buildSendMap(sends) {
1588
+ const map = /* @__PURE__ */ new Map();
1589
+ for (const send of sends) {
1261
1590
  const uid = send.userId.toString();
1262
- if (!sendMap.has(uid)) {
1263
- sendMap.set(uid, send);
1591
+ if (!map.has(uid)) {
1592
+ map.set(uid, send);
1264
1593
  }
1265
1594
  }
1595
+ return map;
1596
+ }
1597
+ compileTemplateVariants(template) {
1266
1598
  const preheaders = template.preheaders || [];
1267
- const compiledVariants = this.templateRenderer.compileBatchVariants(
1599
+ return this.templateRenderer.compileBatchVariants(
1268
1600
  template.subjects,
1269
1601
  template.bodies,
1270
1602
  template.textBody,
1271
1603
  preheaders
1272
1604
  );
1605
+ }
1606
+ async checkCancelled(runId, index) {
1607
+ if (!runId || index % 10 !== 0) return false;
1608
+ const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
1609
+ return !!await this.redis.exists(cancelKey);
1610
+ }
1611
+ async applySendDelay(isLast) {
1612
+ if (isLast) return;
1613
+ const delayMs = this.config.options?.delayBetweenSendsMs || 0;
1614
+ const jitterMs = this.config.options?.jitterMs || 0;
1615
+ if (delayMs > 0 || jitterMs > 0) {
1616
+ const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
1617
+ if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
1618
+ }
1619
+ }
1620
+ async finalizeRuleStats(rule, stats, ruleId, templateId, runId) {
1621
+ await this.EmailRule.findByIdAndUpdate(rule._id, {
1622
+ $set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
1623
+ $inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
1624
+ });
1625
+ this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
1626
+ }
1627
+ async executeListMode(rule, template, throttleMap, throttleConfig, stats, runId) {
1628
+ const rawIdentifiers = rule.target.identifiers || [];
1629
+ const uniqueEmails = [...new Set(rawIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean))];
1630
+ const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
1631
+ if (uniqueEmails.length > limit) {
1632
+ this.logger.warn(`Rule "${rule.name}" matched ${uniqueEmails.length} users but maxPerRun is ${limit} \u2014 only ${limit} will be processed`, { ruleId: rule._id.toString(), matchedCount: uniqueEmails.length, maxPerRun: limit });
1633
+ }
1634
+ const emailsToProcess = uniqueEmails.slice(0, limit);
1635
+ stats.matched = emailsToProcess.length;
1636
+ const ruleId = rule._id.toString();
1637
+ const templateId = rule.templateId.toString();
1638
+ this.config.hooks?.onRuleStart?.({ ruleId, ruleName: rule.name, matchedCount: emailsToProcess.length, templateId, runId: runId || "" });
1639
+ if (emailsToProcess.length === 0) return stats;
1640
+ const identifierMap = await this.resolveIdentifiers(emailsToProcess);
1641
+ const validEmails = emailsToProcess.filter((e) => identifierMap.has(e));
1642
+ const identifierIds = validEmails.map((e) => identifierMap.get(e).id);
1643
+ const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: identifierIds } }).sort({ sentAt: -1 }).lean();
1644
+ const sendMap = this.buildSendMap(allRuleSends);
1645
+ const compiledVariants = this.compileTemplateVariants(template);
1273
1646
  let totalProcessed = 0;
1274
1647
  for (let i = 0; i < emailsToProcess.length; i++) {
1275
1648
  const email = emailsToProcess[i];
1276
- if (runId && i % 10 === 0) {
1277
- const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
1278
- const cancelled = await this.redis.exists(cancelKey);
1279
- if (cancelled) break;
1280
- }
1649
+ if (await this.checkCancelled(runId, i)) break;
1281
1650
  try {
1282
1651
  const identifier = identifierMap.get(email);
1283
1652
  if (!identifier) {
1284
1653
  stats.skipped++;
1285
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "invalid", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "invalid email" });
1286
- continue;
1287
- }
1288
- const dedupKey = identifier.id;
1289
- const lastSend = sendMap.get(dedupKey);
1290
- if (lastSend) {
1291
- if (rule.sendOnce && !rule.resendAfterDays) {
1292
- stats.skipped++;
1293
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1294
- continue;
1295
- }
1296
- if (rule.resendAfterDays) {
1297
- const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1298
- if (daysSince < rule.resendAfterDays) {
1299
- stats.skipped++;
1300
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "resend too soon" });
1301
- continue;
1302
- }
1303
- } else {
1304
- stats.skipped++;
1305
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1306
- continue;
1307
- }
1308
- }
1309
- if (!this.checkThrottle(rule, dedupKey, email, throttleMap, throttleConfig, stats, templateId, runId)) continue;
1310
- const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
1311
- if (!agentSelection) {
1312
- stats.skipped++;
1313
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "no account available" });
1654
+ this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
1314
1655
  continue;
1315
1656
  }
1316
- const user = { _id: identifier.id, email };
1317
- const resolvedData = this.config.adapters.resolveData(user);
1318
- const templateData = { ...template.fields || {}, ...resolvedData };
1319
- const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
1320
- const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
1321
- const renderedSubject = compiledVariants.subjectFns[si](templateData);
1322
- const renderedHtml = compiledVariants.bodyFns[bi](templateData);
1323
- const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
1324
- let finalHtml = renderedHtml;
1325
- let finalText = renderedText;
1326
- let finalSubject = renderedSubject;
1327
- let pi;
1328
- if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
1329
- pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
1330
- const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
1331
- if (renderedPreheader) {
1332
- const preheaderHtml = `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">${renderedPreheader}</div>`;
1333
- finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
1334
- }
1335
- }
1336
- if (this.config.hooks?.beforeSend) {
1337
- try {
1338
- const modified = await this.config.hooks.beforeSend({
1339
- htmlBody: finalHtml,
1340
- textBody: finalText,
1341
- subject: finalSubject,
1342
- account: {
1343
- id: agentSelection.accountId,
1344
- email: agentSelection.email,
1345
- metadata: agentSelection.metadata
1346
- },
1347
- user: {
1348
- id: dedupKey,
1349
- email,
1350
- name: ""
1351
- },
1352
- context: {
1353
- ruleId,
1354
- templateId,
1355
- runId: runId || ""
1356
- }
1357
- });
1358
- finalHtml = modified.htmlBody;
1359
- finalText = modified.textBody;
1360
- finalSubject = modified.subject;
1361
- } catch (hookErr) {
1362
- this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
1363
- stats.errorCount++;
1364
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
1365
- continue;
1366
- }
1367
- }
1368
- await this.config.adapters.sendEmail({
1369
- identifierId: identifier.id,
1370
- contactId: identifier.contactId,
1371
- accountId: agentSelection.accountId,
1372
- subject: finalSubject,
1373
- htmlBody: finalHtml,
1374
- textBody: finalText,
1657
+ const result = await this.processSingleUser({
1658
+ rule,
1659
+ email,
1660
+ userKey: identifier.id,
1661
+ identifier,
1662
+ user: { _id: identifier.id, email },
1663
+ sendMap,
1664
+ throttleMap,
1665
+ throttleConfig,
1666
+ template,
1667
+ compiledVariants,
1668
+ templateId,
1375
1669
  ruleId,
1376
- autoApprove: rule.autoApprove ?? true,
1377
- attachments: template.attachments || []
1378
- });
1379
- await this.EmailRuleSend.logSend(
1380
- ruleId,
1381
- dedupKey,
1382
- identifier.id,
1383
- void 0,
1384
- { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
1385
- );
1386
- const current = throttleMap.get(dedupKey) || { today: 0, thisWeek: 0, lastSentDate: null };
1387
- throttleMap.set(dedupKey, {
1388
- today: current.today + 1,
1389
- thisWeek: current.thisWeek + 1,
1390
- lastSentDate: /* @__PURE__ */ new Date()
1670
+ runId,
1671
+ stats
1391
1672
  });
1392
- stats.sent++;
1393
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi });
1394
- totalProcessed++;
1395
- if (runId && totalProcessed % 10 === 0) {
1396
- await this.updateRunSendProgress(runId, stats);
1397
- }
1398
- if (i < emailsToProcess.length - 1) {
1399
- const delayMs = this.config.options?.delayBetweenSendsMs || 0;
1400
- const jitterMs = this.config.options?.jitterMs || 0;
1401
- if (delayMs > 0 || jitterMs > 0) {
1402
- const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
1403
- if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
1404
- }
1673
+ if (result === "sent") {
1674
+ totalProcessed++;
1675
+ if (runId && totalProcessed % 10 === 0) await this.updateRunSendProgress(runId, stats);
1676
+ await this.applySendDelay(i >= emailsToProcess.length - 1);
1405
1677
  }
1406
1678
  } catch (err) {
1407
1679
  stats.errorCount++;
1408
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: err.message || "unknown error" });
1680
+ this.emitSendEvent(rule, email, "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
1409
1681
  this.logger.error(`Rule "${rule.name}" failed for identifier ${email}`, { error: err });
1410
1682
  }
1411
1683
  }
1412
- await this.EmailRule.findByIdAndUpdate(rule._id, {
1413
- $set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
1414
- $inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
1415
- });
1684
+ await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
1416
1685
  if (rule.sendOnce) {
1417
1686
  const allIdentifiers = rule.target.identifiers || [];
1418
1687
  const totalIdentifiers = new Set(allIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean)).size;
@@ -1428,14 +1697,15 @@ var RuleRunnerService = class {
1428
1697
  this.logger.info(`Rule '${rule.name}' auto-disabled \u2014 all identifiers processed`);
1429
1698
  }
1430
1699
  }
1431
- this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
1432
1700
  return stats;
1433
1701
  }
1434
1702
  async executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId) {
1435
1703
  const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
1436
1704
  let users;
1437
1705
  try {
1438
- users = await this.config.adapters.queryUsers(rule.target, limit);
1706
+ const collectionName = rule.target?.collection;
1707
+ const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
1708
+ users = await this.config.adapters.queryUsers(rule.target, limit, collectionSchema ? { collectionSchema } : void 0);
1439
1709
  } catch (err) {
1440
1710
  this.logger.error(`Rule "${rule.name}": query failed`, { error: err });
1441
1711
  stats.errorCount = 1;
@@ -1450,186 +1720,58 @@ var RuleRunnerService = class {
1450
1720
  const userIds = users.map((u) => u._id?.toString()).filter(Boolean);
1451
1721
  const emails = users.map((u) => u.email).filter(Boolean);
1452
1722
  const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: userIds } }).sort({ sentAt: -1 }).lean();
1453
- const sendMap = /* @__PURE__ */ new Map();
1454
- for (const send of allRuleSends) {
1455
- const uid = send.userId.toString();
1456
- if (!sendMap.has(uid)) {
1457
- sendMap.set(uid, send);
1458
- }
1459
- }
1723
+ const sendMap = this.buildSendMap(allRuleSends);
1460
1724
  const uniqueEmails = [...new Set(emails.map((e) => e.toLowerCase().trim()))];
1461
- const identifierResults = await processInChunks(
1462
- uniqueEmails,
1463
- async (email) => {
1464
- const result = await this.config.adapters.findIdentifier(email);
1465
- return result ? { email, ...result } : null;
1466
- },
1467
- IDENTIFIER_CHUNK_SIZE
1468
- );
1469
- const identifierMap = /* @__PURE__ */ new Map();
1470
- for (const result of identifierResults) {
1471
- if (result) {
1472
- identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
1473
- }
1474
- }
1475
- const preheadersQ = template.preheaders || [];
1476
- const compiledVariants = this.templateRenderer.compileBatchVariants(
1477
- template.subjects,
1478
- template.bodies,
1479
- template.textBody,
1480
- preheadersQ
1481
- );
1725
+ const identifierMap = await this.resolveIdentifiers(uniqueEmails);
1726
+ const compiledVariants = this.compileTemplateVariants(template);
1482
1727
  const ruleId = rule._id.toString();
1483
1728
  const templateId = rule.templateId.toString();
1484
1729
  let totalProcessed = 0;
1485
1730
  for (let i = 0; i < users.length; i++) {
1486
1731
  const user = users[i];
1487
- if (runId && i % 10 === 0) {
1488
- const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
1489
- const cancelled = await this.redis.exists(cancelKey);
1490
- if (cancelled) break;
1491
- }
1732
+ if (await this.checkCancelled(runId, i)) break;
1492
1733
  try {
1493
1734
  const userId = user._id?.toString();
1494
1735
  const email = user.email;
1495
1736
  if (!userId || !email) {
1496
1737
  stats.skipped++;
1497
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email: email || "unknown", status: "invalid", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "invalid email" });
1738
+ this.emitSendEvent(rule, email || "unknown", "invalid", templateId, runId || "", { failureReason: "invalid email" });
1498
1739
  continue;
1499
1740
  }
1500
- const lastSend = sendMap.get(userId);
1501
- if (lastSend) {
1502
- if (rule.sendOnce && !rule.resendAfterDays) {
1503
- stats.skipped++;
1504
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1505
- continue;
1506
- }
1507
- if (rule.resendAfterDays) {
1508
- const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1509
- if (daysSince < rule.resendAfterDays) {
1510
- stats.skipped++;
1511
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "resend too soon" });
1512
- continue;
1513
- }
1514
- } else {
1515
- stats.skipped++;
1516
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
1517
- continue;
1518
- }
1519
- }
1520
1741
  const identifier = identifierMap.get(email.toLowerCase().trim());
1521
1742
  if (!identifier) {
1522
1743
  stats.skipped++;
1523
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "invalid", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "invalid email" });
1744
+ this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
1524
1745
  continue;
1525
1746
  }
1526
- if (!this.checkThrottle(rule, userId, email, throttleMap, throttleConfig, stats, templateId, runId)) continue;
1527
- const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
1528
- if (!agentSelection) {
1529
- stats.skipped++;
1530
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "no account available" });
1531
- continue;
1532
- }
1533
- const resolvedData = this.config.adapters.resolveData(user);
1534
- const templateData = { ...template.fields || {}, ...resolvedData };
1535
- const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
1536
- const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
1537
- const renderedSubject = compiledVariants.subjectFns[si](templateData);
1538
- const renderedHtml = compiledVariants.bodyFns[bi](templateData);
1539
- const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
1540
- let finalHtml = renderedHtml;
1541
- let finalText = renderedText;
1542
- let finalSubject = renderedSubject;
1543
- let pi;
1544
- if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
1545
- pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
1546
- const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
1547
- if (renderedPreheader) {
1548
- const preheaderHtml = `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">${renderedPreheader}</div>`;
1549
- finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
1550
- }
1551
- }
1552
- if (this.config.hooks?.beforeSend) {
1553
- try {
1554
- const modified = await this.config.hooks.beforeSend({
1555
- htmlBody: finalHtml,
1556
- textBody: finalText,
1557
- subject: finalSubject,
1558
- account: {
1559
- id: agentSelection.accountId,
1560
- email: agentSelection.email,
1561
- metadata: agentSelection.metadata
1562
- },
1563
- user: {
1564
- id: String(userId),
1565
- email,
1566
- name: String(user.name || user.firstName || "")
1567
- },
1568
- context: {
1569
- ruleId,
1570
- templateId,
1571
- runId: runId || ""
1572
- }
1573
- });
1574
- finalHtml = modified.htmlBody;
1575
- finalText = modified.textBody;
1576
- finalSubject = modified.subject;
1577
- } catch (hookErr) {
1578
- this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
1579
- stats.errorCount++;
1580
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
1581
- continue;
1582
- }
1583
- }
1584
- await this.config.adapters.sendEmail({
1585
- identifierId: identifier.id,
1586
- contactId: identifier.contactId,
1587
- accountId: agentSelection.accountId,
1588
- subject: finalSubject,
1589
- htmlBody: finalHtml,
1590
- textBody: finalText,
1747
+ const result = await this.processSingleUser({
1748
+ rule,
1749
+ email,
1750
+ userKey: userId,
1751
+ identifier,
1752
+ user,
1753
+ sendMap,
1754
+ throttleMap,
1755
+ throttleConfig,
1756
+ template,
1757
+ compiledVariants,
1758
+ templateId,
1591
1759
  ruleId,
1592
- autoApprove: rule.autoApprove ?? true,
1593
- attachments: template.attachments || []
1594
- });
1595
- await this.EmailRuleSend.logSend(
1596
- ruleId,
1597
- userId,
1598
- identifier.id,
1599
- void 0,
1600
- { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
1601
- );
1602
- const current = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
1603
- throttleMap.set(userId, {
1604
- today: current.today + 1,
1605
- thisWeek: current.thisWeek + 1,
1606
- lastSentDate: /* @__PURE__ */ new Date()
1760
+ runId,
1761
+ stats
1607
1762
  });
1608
- stats.sent++;
1609
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi });
1610
- totalProcessed++;
1611
- if (runId && totalProcessed % 10 === 0) {
1612
- await this.updateRunSendProgress(runId, stats);
1613
- }
1614
- if (i < users.length - 1) {
1615
- const delayMs = this.config.options?.delayBetweenSendsMs || 0;
1616
- const jitterMs = this.config.options?.jitterMs || 0;
1617
- if (delayMs > 0 || jitterMs > 0) {
1618
- const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
1619
- if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
1620
- }
1763
+ if (result === "sent") {
1764
+ totalProcessed++;
1765
+ if (runId && totalProcessed % 10 === 0) await this.updateRunSendProgress(runId, stats);
1766
+ await this.applySendDelay(i >= users.length - 1);
1621
1767
  }
1622
1768
  } catch (err) {
1623
1769
  stats.errorCount++;
1624
- this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email: user.email || "unknown", status: "error", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: err.message || "unknown error" });
1770
+ this.emitSendEvent(rule, user.email || "unknown", "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
1625
1771
  this.logger.error(`Rule "${rule.name}" failed for user ${user._id?.toString()}`, { error: err });
1626
1772
  }
1627
1773
  }
1628
- await this.EmailRule.findByIdAndUpdate(rule._id, {
1629
- $set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
1630
- $inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
1631
- });
1632
- this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
1774
+ await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
1633
1775
  return stats;
1634
1776
  }
1635
1777
  checkThrottle(rule, userId, email, throttleMap, config, stats, templateId, runId) {
@@ -1641,19 +1783,19 @@ var RuleRunnerService = class {
1641
1783
  const userThrottle = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
1642
1784
  if (userThrottle.today >= dailyLimit) {
1643
1785
  stats.skippedByThrottle++;
1644
- this.config.hooks?.onSend?.({ ruleId: rule._id.toString(), ruleName: rule.name, email, status: "throttled", accountId: "", templateId: templateId || "", runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "daily throttle limit" });
1786
+ this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "daily throttle limit" });
1645
1787
  return false;
1646
1788
  }
1647
1789
  if (userThrottle.thisWeek >= weeklyLimit) {
1648
1790
  stats.skippedByThrottle++;
1649
- this.config.hooks?.onSend?.({ ruleId: rule._id.toString(), ruleName: rule.name, email, status: "throttled", accountId: "", templateId: templateId || "", runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "weekly throttle limit" });
1791
+ this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "weekly throttle limit" });
1650
1792
  return false;
1651
1793
  }
1652
1794
  if (userThrottle.lastSentDate) {
1653
1795
  const daysSinceLastSend = (Date.now() - userThrottle.lastSentDate.getTime()) / MS_PER_DAY;
1654
1796
  if (daysSinceLastSend < minGap) {
1655
1797
  stats.skippedByThrottle++;
1656
- this.config.hooks?.onSend?.({ ruleId: rule._id.toString(), ruleName: rule.name, email, status: "throttled", accountId: "", templateId: templateId || "", runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "min gap days" });
1798
+ this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "min gap days" });
1657
1799
  return false;
1658
1800
  }
1659
1801
  }
@@ -1760,7 +1902,7 @@ var RuleRunnerService = class {
1760
1902
  this.updateRunProgress(runId, { status: "failed" }).catch(() => {
1761
1903
  });
1762
1904
  });
1763
- return { runId };
1905
+ return { runId, started: true };
1764
1906
  }
1765
1907
  buildThrottleMap(recentSends) {
1766
1908
  const map = /* @__PURE__ */ new Map();
@@ -1781,467 +1923,342 @@ var RuleRunnerService = class {
1781
1923
  return map;
1782
1924
  }
1783
1925
  };
1784
- function isValidValue(allowed, value) {
1785
- return typeof value === "string" && allowed.includes(value);
1786
- }
1926
+
1927
+ // src/utils/controller.ts
1787
1928
  function getErrorStatus(message) {
1788
- if (message.includes("already exists") || message.includes("validation failed")) return 400;
1789
1929
  if (message.includes("not found")) return 404;
1930
+ if (message.includes("already exists") || message.includes("validation failed") || message.includes("mismatch") || message.includes("Cannot activate") || message.includes("Cannot delete")) return 400;
1790
1931
  return 500;
1791
1932
  }
1933
+ function isValidValue(allowed, value) {
1934
+ return typeof value === "string" && allowed.includes(value);
1935
+ }
1936
+ function asyncHandler(handler) {
1937
+ return (req, res) => {
1938
+ handler(req, res).catch((error) => {
1939
+ if (res.headersSent) return;
1940
+ const message = error instanceof Error ? error.message : "Unknown error";
1941
+ const status = getErrorStatus(message);
1942
+ res.status(status).json({ success: false, error: message });
1943
+ });
1944
+ };
1945
+ }
1946
+
1947
+ // src/controllers/template.controller.ts
1792
1948
  function createTemplateController(templateService, options) {
1793
1949
  const platformValues = options?.platforms;
1794
1950
  const validCategories = options?.categories || Object.values(TEMPLATE_CATEGORY);
1795
1951
  const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
1796
- async function list(req, res) {
1797
- try {
1798
- const { category, audience, platform, isActive } = req.query;
1799
- const templates = await templateService.list({
1800
- category,
1801
- audience,
1802
- platform,
1803
- isActive: isActive !== void 0 ? isActive === "true" : void 0
1804
- });
1805
- res.json({ success: true, data: { templates } });
1806
- } catch (error) {
1807
- const message = error instanceof Error ? error.message : "Unknown error";
1808
- res.status(500).json({ success: false, error: message });
1952
+ const list = asyncHandler(async (req, res) => {
1953
+ const { category, audience, platform, isActive, page, limit } = req.query;
1954
+ const { templates, total } = await templateService.list({
1955
+ category,
1956
+ audience,
1957
+ platform,
1958
+ isActive: isActive !== void 0 ? isActive === "true" : void 0,
1959
+ ...calculatePagination(parseInt(String(page), 10) || void 0, parseInt(String(limit), 10) || void 0)
1960
+ });
1961
+ res.json({ success: true, data: { templates, total } });
1962
+ });
1963
+ const getById = asyncHandler(async (req, res) => {
1964
+ const template = await templateService.getById(getParam(req, "id"));
1965
+ if (!template) {
1966
+ return res.status(404).json({ success: false, error: "Template not found" });
1809
1967
  }
1810
- }
1811
- async function getById(req, res) {
1812
- try {
1813
- const template = await templateService.getById(getParam(req, "id"));
1814
- if (!template) {
1815
- return res.status(404).json({ success: false, error: "Template not found" });
1816
- }
1817
- res.json({ success: true, data: { template } });
1818
- } catch (error) {
1819
- const message = error instanceof Error ? error.message : "Unknown error";
1820
- res.status(500).json({ success: false, error: message });
1968
+ res.json({ success: true, data: { template } });
1969
+ });
1970
+ const create = asyncHandler(async (req, res) => {
1971
+ const { name, subjects, bodies, category, audience, platform, preheaders } = req.body;
1972
+ if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
1973
+ return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
1821
1974
  }
1822
- }
1823
- async function create(req, res) {
1824
- try {
1825
- const { name, subjects, bodies, category, audience, platform, preheaders } = req.body;
1826
- if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
1827
- return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
1828
- }
1829
- if (!isValidValue(validCategories, category)) {
1830
- return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
1831
- }
1832
- if (!isValidValue(validAudiences, audience)) {
1833
- return res.status(400).json({ success: false, error: `Invalid audience. Must be one of: ${validAudiences.join(", ")}` });
1834
- }
1835
- if (platformValues && !platformValues.includes(platform)) {
1836
- return res.status(400).json({ success: false, error: `Invalid platform. Must be one of: ${platformValues.join(", ")}` });
1837
- }
1838
- const template = await templateService.create(req.body);
1839
- res.status(201).json({ success: true, data: { template } });
1840
- } catch (error) {
1841
- const message = error instanceof Error ? error.message : "Unknown error";
1842
- res.status(getErrorStatus(message)).json({ success: false, error: message });
1975
+ if (!isValidValue(validCategories, category)) {
1976
+ return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
1843
1977
  }
1844
- }
1845
- async function update(req, res) {
1846
- try {
1847
- const template = await templateService.update(getParam(req, "id"), req.body);
1848
- if (!template) {
1849
- return res.status(404).json({ success: false, error: "Template not found" });
1850
- }
1851
- res.json({ success: true, data: { template } });
1852
- } catch (error) {
1853
- const message = error instanceof Error ? error.message : "Unknown error";
1854
- res.status(getErrorStatus(message)).json({ success: false, error: message });
1978
+ if (!isValidValue(validAudiences, audience)) {
1979
+ return res.status(400).json({ success: false, error: `Invalid audience. Must be one of: ${validAudiences.join(", ")}` });
1855
1980
  }
1856
- }
1857
- async function remove(req, res) {
1858
- try {
1859
- const deleted = await templateService.delete(getParam(req, "id"));
1860
- if (!deleted) {
1861
- return res.status(404).json({ success: false, error: "Template not found" });
1862
- }
1863
- res.json({ success: true });
1864
- } catch (error) {
1865
- const message = error instanceof Error ? error.message : "Unknown error";
1866
- res.status(500).json({ success: false, error: message });
1981
+ if (platformValues && !platformValues.includes(platform)) {
1982
+ return res.status(400).json({ success: false, error: `Invalid platform. Must be one of: ${platformValues.join(", ")}` });
1867
1983
  }
1868
- }
1869
- async function toggleActive(req, res) {
1870
- try {
1871
- const template = await templateService.toggleActive(getParam(req, "id"));
1872
- if (!template) {
1873
- return res.status(404).json({ success: false, error: "Template not found" });
1874
- }
1875
- res.json({ success: true, data: { template } });
1876
- } catch (error) {
1877
- const message = error instanceof Error ? error.message : "Unknown error";
1878
- res.status(500).json({ success: false, error: message });
1984
+ const template = await templateService.create(req.body);
1985
+ res.status(201).json({ success: true, data: { template } });
1986
+ });
1987
+ const update = asyncHandler(async (req, res) => {
1988
+ const template = await templateService.update(getParam(req, "id"), req.body);
1989
+ if (!template) {
1990
+ return res.status(404).json({ success: false, error: "Template not found" });
1879
1991
  }
1880
- }
1881
- async function preview(req, res) {
1882
- try {
1883
- const { sampleData } = req.body;
1884
- const result = await templateService.preview(getParam(req, "id"), sampleData || {});
1885
- if (!result) {
1886
- return res.status(404).json({ success: false, error: "Template not found" });
1887
- }
1888
- res.json({ success: true, data: result });
1889
- } catch (error) {
1890
- const message = error instanceof Error ? error.message : "Unknown error";
1891
- res.status(500).json({ success: false, error: message });
1992
+ res.json({ success: true, data: { template } });
1993
+ });
1994
+ const remove = asyncHandler(async (req, res) => {
1995
+ const deleted = await templateService.delete(getParam(req, "id"));
1996
+ if (!deleted) {
1997
+ return res.status(404).json({ success: false, error: "Template not found" });
1892
1998
  }
1893
- }
1894
- async function previewRaw(req, res) {
1895
- try {
1896
- const { subject, body, textBody, sampleData, variables } = req.body;
1897
- if (!subject || !body) {
1898
- return res.status(400).json({ success: false, error: "subject and body are required" });
1899
- }
1900
- const result = await templateService.previewRaw(subject, body, sampleData || {}, variables, textBody);
1901
- res.json({ success: true, data: result });
1902
- } catch (error) {
1903
- const message = error instanceof Error ? error.message : "Unknown error";
1904
- res.status(500).json({ success: false, error: message });
1999
+ res.json({ success: true });
2000
+ });
2001
+ const toggleActive = asyncHandler(async (req, res) => {
2002
+ const template = await templateService.toggleActive(getParam(req, "id"));
2003
+ if (!template) {
2004
+ return res.status(404).json({ success: false, error: "Template not found" });
1905
2005
  }
1906
- }
1907
- async function validate(req, res) {
1908
- try {
1909
- const { body: templateBody } = req.body;
1910
- if (!templateBody) {
1911
- return res.status(400).json({ success: false, error: "body is required" });
1912
- }
1913
- const result = await templateService.validate(templateBody);
1914
- res.json({ success: true, data: result });
1915
- } catch (error) {
1916
- const message = error instanceof Error ? error.message : "Unknown error";
1917
- res.status(500).json({ success: false, error: message });
2006
+ res.json({ success: true, data: { template } });
2007
+ });
2008
+ const preview = asyncHandler(async (req, res) => {
2009
+ const { sampleData } = req.body;
2010
+ const result = await templateService.preview(getParam(req, "id"), sampleData || {});
2011
+ if (!result) {
2012
+ return res.status(404).json({ success: false, error: "Template not found" });
1918
2013
  }
1919
- }
1920
- async function clone(req, res) {
1921
- try {
1922
- const { name } = req.body;
1923
- const result = await templateService.clone(getParam(req, "id"), name);
1924
- res.json({ success: true, data: result });
1925
- } catch (error) {
1926
- const message = error instanceof Error ? error.message : "Unknown error";
1927
- res.status(error instanceof Error && error.message === "Template not found" ? 404 : 500).json({ success: false, error: message });
2014
+ res.json({ success: true, data: result });
2015
+ });
2016
+ const previewRaw = asyncHandler(async (req, res) => {
2017
+ const { subject, body, textBody, sampleData, variables } = req.body;
2018
+ if (!subject || !body) {
2019
+ return res.status(400).json({ success: false, error: "subject and body are required" });
1928
2020
  }
1929
- }
1930
- async function sendTestEmail(req, res) {
1931
- try {
1932
- const { testEmail, sampleData } = req.body;
1933
- if (!testEmail) {
1934
- return res.status(400).json({ success: false, error: "testEmail is required" });
1935
- }
1936
- const result = await templateService.sendTestEmail(getParam(req, "id"), testEmail, sampleData || {});
1937
- if (!result.success) {
1938
- return res.status(400).json({ success: false, error: result.error });
1939
- }
1940
- res.json({ success: true });
1941
- } catch (error) {
1942
- const message = error instanceof Error ? error.message : "Unknown error";
1943
- res.status(500).json({ success: false, error: message });
2021
+ const result = await templateService.previewRaw(subject, body, sampleData || {}, variables, textBody);
2022
+ res.json({ success: true, data: result });
2023
+ });
2024
+ const validate = asyncHandler(async (req, res) => {
2025
+ const { body: templateBody } = req.body;
2026
+ if (!templateBody) {
2027
+ return res.status(400).json({ success: false, error: "body is required" });
1944
2028
  }
1945
- }
1946
- async function previewWithRecipient(req, res) {
1947
- try {
1948
- const { recipientData } = req.body;
1949
- if (!recipientData || typeof recipientData !== "object") {
1950
- return res.status(400).json({ success: false, error: "recipientData object is required" });
1951
- }
1952
- const result = await templateService.previewWithRecipient(getParam(req, "id"), recipientData);
1953
- if (!result) {
1954
- return res.status(404).json({ success: false, error: "Template not found" });
1955
- }
1956
- res.json({ success: true, data: result });
1957
- } catch (error) {
1958
- const message = error instanceof Error ? error.message : "Unknown error";
1959
- res.status(500).json({ success: false, error: message });
2029
+ const result = await templateService.validate(templateBody);
2030
+ res.json({ success: true, data: result });
2031
+ });
2032
+ const clone = asyncHandler(async (req, res) => {
2033
+ const { name } = req.body;
2034
+ const result = await templateService.clone(getParam(req, "id"), name);
2035
+ res.json({ success: true, data: result });
2036
+ });
2037
+ const sendTestEmail = asyncHandler(async (req, res) => {
2038
+ const { testEmail, sampleData } = req.body;
2039
+ if (!testEmail) {
2040
+ return res.status(400).json({ success: false, error: "testEmail is required" });
1960
2041
  }
1961
- }
2042
+ const result = await templateService.sendTestEmail(getParam(req, "id"), testEmail, sampleData || {});
2043
+ if (!result.success) {
2044
+ return res.status(400).json({ success: false, error: result.error });
2045
+ }
2046
+ res.json({ success: true });
2047
+ });
2048
+ const previewWithRecipient = asyncHandler(async (req, res) => {
2049
+ const { recipientData } = req.body;
2050
+ if (!recipientData || typeof recipientData !== "object") {
2051
+ return res.status(400).json({ success: false, error: "recipientData object is required" });
2052
+ }
2053
+ const result = await templateService.previewWithRecipient(getParam(req, "id"), recipientData);
2054
+ if (!result) {
2055
+ return res.status(404).json({ success: false, error: "Template not found" });
2056
+ }
2057
+ res.json({ success: true, data: result });
2058
+ });
1962
2059
  return { list, getById, create, update, remove, toggleActive, preview, previewRaw, validate, sendTestEmail, clone, previewWithRecipient };
1963
2060
  }
1964
- function isValidValue2(allowed, value) {
1965
- return typeof value === "string" && allowed.includes(value);
1966
- }
1967
- function getErrorStatus2(message) {
1968
- if (message.includes("not found")) return 404;
1969
- if (message.includes("mismatch") || message.includes("validation failed") || message.includes("Cannot activate")) return 400;
1970
- return 500;
1971
- }
1972
2061
  function createRuleController(ruleService, options) {
1973
2062
  const platformValues = options?.platforms;
1974
2063
  const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
1975
2064
  const validEmailTypes = Object.values(EMAIL_TYPE);
1976
- async function list(_req, res) {
1977
- try {
1978
- const rules = await ruleService.list();
1979
- res.json({ success: true, data: { rules } });
1980
- } catch (error) {
1981
- const message = error instanceof Error ? error.message : "Unknown error";
1982
- res.status(500).json({ success: false, error: message });
2065
+ const list = asyncHandler(async (req, res) => {
2066
+ const { page, limit } = calculatePagination(
2067
+ parseInt(String(req.query.page), 10) || void 0,
2068
+ parseInt(String(req.query.limit), 10) || void 0
2069
+ );
2070
+ const { rules, total } = await ruleService.list({ page, limit });
2071
+ res.json({ success: true, data: { rules, total } });
2072
+ });
2073
+ const getById = asyncHandler(async (req, res) => {
2074
+ const rule = await ruleService.getById(getParam(req, "id"));
2075
+ if (!rule) {
2076
+ return res.status(404).json({ success: false, error: "Rule not found" });
1983
2077
  }
1984
- }
1985
- async function getById(req, res) {
1986
- try {
1987
- const rule = await ruleService.getById(getParam(req, "id"));
1988
- if (!rule) {
1989
- return res.status(404).json({ success: false, error: "Rule not found" });
1990
- }
1991
- res.json({ success: true, data: { rule } });
1992
- } catch (error) {
1993
- const message = error instanceof Error ? error.message : "Unknown error";
1994
- res.status(500).json({ success: false, error: message });
2078
+ res.json({ success: true, data: { rule } });
2079
+ });
2080
+ const create = asyncHandler(async (req, res) => {
2081
+ const { name, target, templateId } = req.body;
2082
+ if (!name || !target || !templateId) {
2083
+ return res.status(400).json({ success: false, error: "name, target, and templateId are required" });
1995
2084
  }
1996
- }
1997
- async function create(req, res) {
1998
- try {
1999
- const { name, target, templateId } = req.body;
2000
- if (!name || !target || !templateId) {
2001
- return res.status(400).json({ success: false, error: "name, target, and templateId are required" });
2085
+ const mode = target.mode || "query";
2086
+ if (mode === "list") {
2087
+ if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
2088
+ return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
2089
+ }
2090
+ } else {
2091
+ if (!target.role || !isValidValue(validAudiences, target.role)) {
2092
+ return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
2093
+ }
2094
+ if (platformValues && !platformValues.includes(target.platform)) {
2095
+ return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
2096
+ }
2097
+ if (!Array.isArray(target.conditions)) {
2098
+ return res.status(400).json({ success: false, error: "target.conditions must be an array" });
2002
2099
  }
2100
+ }
2101
+ if (req.body.emailType && !isValidValue(validEmailTypes, req.body.emailType)) {
2102
+ return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
2103
+ }
2104
+ const rule = await ruleService.create(req.body);
2105
+ res.status(201).json({ success: true, data: { rule } });
2106
+ });
2107
+ const update = asyncHandler(async (req, res) => {
2108
+ const { target, emailType } = req.body;
2109
+ if (target) {
2003
2110
  const mode = target.mode || "query";
2004
2111
  if (mode === "list") {
2005
- if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
2112
+ if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
2006
2113
  return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
2007
2114
  }
2008
2115
  } else {
2009
- if (!target.role || !isValidValue2(validAudiences, target.role)) {
2116
+ if (target.role && !isValidValue(validAudiences, target.role)) {
2010
2117
  return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
2011
2118
  }
2012
- if (platformValues && !platformValues.includes(target.platform)) {
2119
+ if (target.platform && platformValues && !platformValues.includes(target.platform)) {
2013
2120
  return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
2014
2121
  }
2015
- if (!Array.isArray(target.conditions)) {
2122
+ if (target.conditions && !Array.isArray(target.conditions)) {
2016
2123
  return res.status(400).json({ success: false, error: "target.conditions must be an array" });
2017
2124
  }
2018
2125
  }
2019
- if (req.body.emailType && !isValidValue2(validEmailTypes, req.body.emailType)) {
2020
- return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
2021
- }
2022
- const rule = await ruleService.create(req.body);
2023
- res.status(201).json({ success: true, data: { rule } });
2024
- } catch (error) {
2025
- const message = error instanceof Error ? error.message : "Unknown error";
2026
- res.status(getErrorStatus2(message)).json({ success: false, error: message });
2027
- }
2028
- }
2029
- async function update(req, res) {
2030
- try {
2031
- const { target, emailType } = req.body;
2032
- if (target) {
2033
- const mode = target.mode || "query";
2034
- if (mode === "list") {
2035
- if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
2036
- return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
2037
- }
2038
- } else {
2039
- if (target.role && !isValidValue2(validAudiences, target.role)) {
2040
- return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
2041
- }
2042
- if (target.platform && platformValues && !platformValues.includes(target.platform)) {
2043
- return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
2044
- }
2045
- if (target.conditions && !Array.isArray(target.conditions)) {
2046
- return res.status(400).json({ success: false, error: "target.conditions must be an array" });
2047
- }
2048
- }
2049
- }
2050
- if (emailType && !isValidValue2(validEmailTypes, emailType)) {
2051
- return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
2052
- }
2053
- const rule = await ruleService.update(getParam(req, "id"), req.body);
2054
- if (!rule) {
2055
- return res.status(404).json({ success: false, error: "Rule not found" });
2056
- }
2057
- res.json({ success: true, data: { rule } });
2058
- } catch (error) {
2059
- const message = error instanceof Error ? error.message : "Unknown error";
2060
- res.status(getErrorStatus2(message)).json({ success: false, error: message });
2061
- }
2062
- }
2063
- async function remove(req, res) {
2064
- try {
2065
- const result = await ruleService.delete(getParam(req, "id"));
2066
- if (!result.deleted && !result.disabled) {
2067
- return res.status(404).json({ success: false, error: "Rule not found" });
2068
- }
2069
- res.json({ success: true, data: result });
2070
- } catch (error) {
2071
- const message = error instanceof Error ? error.message : "Unknown error";
2072
- res.status(500).json({ success: false, error: message });
2073
2126
  }
2074
- }
2075
- async function toggleActive(req, res) {
2076
- try {
2077
- const rule = await ruleService.toggleActive(getParam(req, "id"));
2078
- if (!rule) {
2079
- return res.status(404).json({ success: false, error: "Rule not found" });
2080
- }
2081
- res.json({ success: true, data: { rule } });
2082
- } catch (error) {
2083
- const message = error instanceof Error ? error.message : "Unknown error";
2084
- res.status(getErrorStatus2(message)).json({ success: false, error: message });
2127
+ if (emailType && !isValidValue(validEmailTypes, emailType)) {
2128
+ return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
2085
2129
  }
2086
- }
2087
- async function dryRun(req, res) {
2088
- try {
2089
- const result = await ruleService.dryRun(getParam(req, "id"));
2090
- res.json({ success: true, data: result });
2091
- } catch (error) {
2092
- const message = error instanceof Error ? error.message : "Unknown error";
2093
- res.status(getErrorStatus2(message)).json({ success: false, error: message });
2130
+ const rule = await ruleService.update(getParam(req, "id"), req.body);
2131
+ if (!rule) {
2132
+ return res.status(404).json({ success: false, error: "Rule not found" });
2094
2133
  }
2095
- }
2096
- async function clone(req, res) {
2097
- try {
2098
- const { name } = req.body;
2099
- const result = await ruleService.clone(getParam(req, "id"), name);
2100
- res.json({ success: true, data: result });
2101
- } catch (error) {
2102
- const message = error instanceof Error ? error.message : "Unknown error";
2103
- res.status(error instanceof Error && error.message === "Rule not found" ? 404 : 500).json({ success: false, error: message });
2134
+ res.json({ success: true, data: { rule } });
2135
+ });
2136
+ const remove = asyncHandler(async (req, res) => {
2137
+ const result = await ruleService.delete(getParam(req, "id"));
2138
+ if (!result.deleted && !result.disabled) {
2139
+ return res.status(404).json({ success: false, error: "Rule not found" });
2104
2140
  }
2105
- }
2106
- async function runHistory(req, res) {
2107
- try {
2108
- const limitParam = req.query.limit;
2109
- const limit = parseInt(String(Array.isArray(limitParam) ? limitParam[0] : limitParam), 10) || 20;
2110
- const logs = await ruleService.getRunHistory(limit);
2111
- res.json({ success: true, data: { logs } });
2112
- } catch (error) {
2113
- const message = error instanceof Error ? error.message : "Unknown error";
2114
- res.status(500).json({ success: false, error: message });
2141
+ res.json({ success: true, data: result });
2142
+ });
2143
+ const toggleActive = asyncHandler(async (req, res) => {
2144
+ const rule = await ruleService.toggleActive(getParam(req, "id"));
2145
+ if (!rule) {
2146
+ return res.status(404).json({ success: false, error: "Rule not found" });
2115
2147
  }
2116
- }
2148
+ res.json({ success: true, data: { rule } });
2149
+ });
2150
+ const dryRun = asyncHandler(async (req, res) => {
2151
+ const result = await ruleService.dryRun(getParam(req, "id"));
2152
+ res.json({ success: true, data: result });
2153
+ });
2154
+ const clone = asyncHandler(async (req, res) => {
2155
+ const { name } = req.body;
2156
+ const result = await ruleService.clone(getParam(req, "id"), name);
2157
+ res.json({ success: true, data: result });
2158
+ });
2159
+ const runHistory = asyncHandler(async (req, res) => {
2160
+ const { page, limit } = calculatePagination(
2161
+ parseInt(String(req.query.page), 10) || void 0,
2162
+ parseInt(String(req.query.limit), 10) || 20
2163
+ );
2164
+ const from = req.query.from ? String(req.query.from) : void 0;
2165
+ const to = req.query.to ? String(req.query.to) : void 0;
2166
+ const logs = await ruleService.getRunHistory(limit, { page, from, to });
2167
+ const total = await ruleService.getRunHistoryCount({ from, to });
2168
+ res.json({ success: true, data: { logs, total } });
2169
+ });
2117
2170
  return { list, getById, create, update, remove, toggleActive, dryRun, runHistory, clone };
2118
2171
  }
2119
2172
  function createRunnerController(runnerService, EmailRuleRunLog, logger) {
2120
- async function triggerManualRun(_req, res) {
2173
+ const triggerManualRun = asyncHandler(async (_req, res) => {
2121
2174
  const { runId } = runnerService.trigger(RUN_TRIGGER.Manual);
2122
2175
  res.json({ success: true, data: { message: "Rule run triggered", runId } });
2123
- }
2124
- async function getLatestRun(_req, res) {
2125
- try {
2126
- const latestRun = await EmailRuleRunLog.findOne().sort({ runAt: -1 });
2127
- res.json({ success: true, data: { latestRun } });
2128
- } catch (error) {
2129
- const message = error instanceof Error ? error.message : "Unknown error";
2130
- res.status(500).json({ success: false, error: message });
2131
- }
2132
- }
2133
- async function getStatusByRunId(req, res) {
2134
- try {
2135
- const status = await runnerService.getStatus(getParam(req, "runId"));
2136
- if (!status) {
2137
- res.status(404).json({ success: false, error: "Run not found" });
2138
- return;
2139
- }
2140
- res.json({ success: true, data: status });
2141
- } catch (error) {
2142
- const message = error instanceof Error ? error.message : "Unknown error";
2143
- res.status(500).json({ success: false, error: message });
2176
+ });
2177
+ const getLatestRun = asyncHandler(async (_req, res) => {
2178
+ const latestRun = await EmailRuleRunLog.findOne().sort({ runAt: -1 });
2179
+ res.json({ success: true, data: { latestRun } });
2180
+ });
2181
+ const getStatusByRunId = asyncHandler(async (req, res) => {
2182
+ const status = await runnerService.getStatus(getParam(req, "runId"));
2183
+ if (!status) {
2184
+ res.status(404).json({ success: false, error: "Run not found" });
2185
+ return;
2144
2186
  }
2145
- }
2146
- async function cancelRun(req, res) {
2147
- try {
2148
- const result = await runnerService.cancel(getParam(req, "runId"));
2149
- if (!result.ok) {
2150
- res.status(404).json({ success: false, error: "Run not found" });
2151
- return;
2152
- }
2153
- res.json({ success: true, data: { message: "Cancel requested" } });
2154
- } catch (error) {
2155
- const message = error instanceof Error ? error.message : "Unknown error";
2156
- res.status(500).json({ success: false, error: message });
2187
+ res.json({ success: true, data: status });
2188
+ });
2189
+ const cancelRun = asyncHandler(async (req, res) => {
2190
+ const result = await runnerService.cancel(getParam(req, "runId"));
2191
+ if (!result.ok) {
2192
+ res.status(404).json({ success: false, error: "Run not found" });
2193
+ return;
2157
2194
  }
2158
- }
2195
+ res.json({ success: true, data: { message: "Cancel requested" } });
2196
+ });
2159
2197
  return { triggerManualRun, getLatestRun, getStatusByRunId, cancelRun };
2160
2198
  }
2161
2199
 
2162
2200
  // src/controllers/settings.controller.ts
2163
2201
  function createSettingsController(EmailThrottleConfig) {
2164
- async function getThrottleConfig(_req, res) {
2165
- try {
2166
- const config = await EmailThrottleConfig.getConfig();
2167
- res.json({ success: true, data: { config } });
2168
- } catch (error) {
2169
- const message = error instanceof Error ? error.message : "Unknown error";
2170
- res.status(500).json({ success: false, error: message });
2171
- }
2172
- }
2173
- async function updateThrottleConfig(req, res) {
2174
- try {
2175
- const { maxPerUserPerDay, maxPerUserPerWeek, minGapDays } = req.body;
2176
- const updates = {};
2177
- if (maxPerUserPerDay !== void 0) {
2178
- if (!Number.isInteger(maxPerUserPerDay) || maxPerUserPerDay < 1) {
2179
- return res.status(400).json({ success: false, error: "maxPerUserPerDay must be a positive integer" });
2180
- }
2181
- updates.maxPerUserPerDay = maxPerUserPerDay;
2182
- }
2183
- if (maxPerUserPerWeek !== void 0) {
2184
- if (!Number.isInteger(maxPerUserPerWeek) || maxPerUserPerWeek < 1) {
2185
- return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be a positive integer" });
2186
- }
2187
- updates.maxPerUserPerWeek = maxPerUserPerWeek;
2188
- }
2189
- if (minGapDays !== void 0) {
2190
- if (!Number.isInteger(minGapDays) || minGapDays < 0) {
2191
- return res.status(400).json({ success: false, error: "minGapDays must be a non-negative integer" });
2192
- }
2193
- updates.minGapDays = minGapDays;
2202
+ const getThrottleConfig = asyncHandler(async (_req, res) => {
2203
+ const config = await EmailThrottleConfig.getConfig();
2204
+ res.json({ success: true, data: { config } });
2205
+ });
2206
+ const updateThrottleConfig = asyncHandler(async (req, res) => {
2207
+ const { maxPerUserPerDay, maxPerUserPerWeek, minGapDays } = req.body;
2208
+ const updates = {};
2209
+ if (maxPerUserPerDay !== void 0) {
2210
+ if (!Number.isInteger(maxPerUserPerDay) || maxPerUserPerDay < 1) {
2211
+ return res.status(400).json({ success: false, error: "maxPerUserPerDay must be a positive integer" });
2194
2212
  }
2195
- if (Object.keys(updates).length === 0) {
2196
- return res.status(400).json({ success: false, error: "No valid fields to update" });
2213
+ updates.maxPerUserPerDay = maxPerUserPerDay;
2214
+ }
2215
+ if (maxPerUserPerWeek !== void 0) {
2216
+ if (!Number.isInteger(maxPerUserPerWeek) || maxPerUserPerWeek < 1) {
2217
+ return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be a positive integer" });
2197
2218
  }
2198
- const config = await EmailThrottleConfig.getConfig();
2199
- const finalDaily = updates.maxPerUserPerDay ?? config.maxPerUserPerDay;
2200
- const finalWeekly = updates.maxPerUserPerWeek ?? config.maxPerUserPerWeek;
2201
- if (finalWeekly < finalDaily) {
2202
- return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be >= maxPerUserPerDay" });
2219
+ updates.maxPerUserPerWeek = maxPerUserPerWeek;
2220
+ }
2221
+ if (minGapDays !== void 0) {
2222
+ if (!Number.isInteger(minGapDays) || minGapDays < 0) {
2223
+ return res.status(400).json({ success: false, error: "minGapDays must be a non-negative integer" });
2203
2224
  }
2204
- const updated = await EmailThrottleConfig.findByIdAndUpdate(
2205
- config._id,
2206
- { $set: updates },
2207
- { new: true }
2208
- );
2209
- res.json({ success: true, data: { config: updated } });
2210
- } catch (error) {
2211
- const message = error instanceof Error ? error.message : "Unknown error";
2212
- res.status(500).json({ success: false, error: message });
2225
+ updates.minGapDays = minGapDays;
2213
2226
  }
2214
- }
2227
+ if (Object.keys(updates).length === 0) {
2228
+ return res.status(400).json({ success: false, error: "No valid fields to update" });
2229
+ }
2230
+ const config = await EmailThrottleConfig.getConfig();
2231
+ const finalDaily = updates.maxPerUserPerDay ?? config.maxPerUserPerDay;
2232
+ const finalWeekly = updates.maxPerUserPerWeek ?? config.maxPerUserPerWeek;
2233
+ if (finalWeekly < finalDaily) {
2234
+ return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be >= maxPerUserPerDay" });
2235
+ }
2236
+ const updated = await EmailThrottleConfig.findByIdAndUpdate(
2237
+ config._id,
2238
+ { $set: updates },
2239
+ { new: true }
2240
+ );
2241
+ res.json({ success: true, data: { config: updated } });
2242
+ });
2215
2243
  return { getThrottleConfig, updateThrottleConfig };
2216
2244
  }
2217
2245
 
2218
2246
  // src/controllers/send-log.controller.ts
2219
2247
  function createSendLogController(EmailRuleSend) {
2220
- async function list(req, res) {
2221
- try {
2222
- const { ruleId, status, email, from, to, page, limit } = req.query;
2223
- const filter = {};
2224
- if (ruleId) filter.ruleId = ruleId;
2225
- if (status) filter.status = status;
2226
- if (email) filter.email = { $regex: email, $options: "i" };
2227
- if (from || to) {
2228
- filter.sentAt = {};
2229
- if (from) filter.sentAt.$gte = new Date(from);
2230
- if (to) filter.sentAt.$lte = new Date(to);
2231
- }
2232
- const pageNum = Number(page) || 1;
2233
- const limitNum = Math.min(Number(limit) || 50, 200);
2234
- const skip = (pageNum - 1) * limitNum;
2235
- const [sends, total] = await Promise.all([
2236
- EmailRuleSend.find(filter).sort({ sentAt: -1 }).skip(skip).limit(limitNum).lean(),
2237
- EmailRuleSend.countDocuments(filter)
2238
- ]);
2239
- res.json({ success: true, data: { sends, total } });
2240
- } catch (error) {
2241
- const message = error instanceof Error ? error.message : "Failed to query send logs";
2242
- res.status(500).json({ success: false, error: message });
2243
- }
2244
- }
2248
+ const list = asyncHandler(async (req, res) => {
2249
+ const { ruleId, status, email, from, to, page, limit } = req.query;
2250
+ const filter = {};
2251
+ if (ruleId) filter.ruleId = ruleId;
2252
+ if (status) filter.status = status;
2253
+ if (email) filter.userId = { $regex: email, $options: "i" };
2254
+ Object.assign(filter, buildDateRangeFilter("sentAt", from, to));
2255
+ const pagination = calculatePagination(Number(page) || void 0, Number(limit) || 50, 200);
2256
+ const [sends, total] = await Promise.all([
2257
+ EmailRuleSend.find(filter).sort({ sentAt: -1 }).skip(pagination.skip).limit(pagination.limit).lean(),
2258
+ EmailRuleSend.countDocuments(filter)
2259
+ ]);
2260
+ res.json({ success: true, data: { sends, total } });
2261
+ });
2245
2262
  return { list };
2246
2263
  }
2247
2264
 
@@ -2260,6 +2277,7 @@ function createRoutes(deps) {
2260
2277
  const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog);
2261
2278
  const settingsCtrl = createSettingsController(deps.EmailThrottleConfig);
2262
2279
  const sendLogCtrl = createSendLogController(deps.EmailRuleSend);
2280
+ const collectionCtrl = createCollectionController(deps.collections || []);
2263
2281
  const templateRouter = Router();
2264
2282
  templateRouter.get("/", templateCtrl.list);
2265
2283
  templateRouter.post("/", templateCtrl.create);
@@ -2290,10 +2308,14 @@ function createRoutes(deps) {
2290
2308
  runnerRouter.get("/logs", ruleCtrl.runHistory);
2291
2309
  const sendLogRouter = Router();
2292
2310
  sendLogRouter.get("/", sendLogCtrl.list);
2311
+ const collectionRouter = Router();
2312
+ collectionRouter.get("/", collectionCtrl.list);
2313
+ collectionRouter.get("/:name/fields", collectionCtrl.getFields);
2293
2314
  router.use("/templates", templateRouter);
2294
2315
  router.use("/rules", ruleRouter);
2295
2316
  router.use("/runner", runnerRouter);
2296
2317
  router.use("/sends", sendLogRouter);
2318
+ router.use("/collections", collectionRouter);
2297
2319
  router.get("/throttle", settingsCtrl.getThrottleConfig);
2298
2320
  router.put("/throttle", settingsCtrl.updateThrottleConfig);
2299
2321
  return router;
@@ -2309,6 +2331,19 @@ var configSchema = z.object({
2309
2331
  findIdentifier: z.function(),
2310
2332
  sendTestEmail: z.function().optional()
2311
2333
  }),
2334
+ collections: z.array(z.object({
2335
+ name: z.string(),
2336
+ label: z.string().optional(),
2337
+ description: z.string().optional(),
2338
+ identifierField: z.string().optional(),
2339
+ fields: z.array(z.any()),
2340
+ joins: z.array(z.object({
2341
+ from: z.string(),
2342
+ localField: z.string(),
2343
+ foreignField: z.string(),
2344
+ as: z.string()
2345
+ })).optional()
2346
+ })).optional(),
2312
2347
  platforms: z.array(z.string()).optional(),
2313
2348
  audiences: z.array(z.string()).optional(),
2314
2349
  categories: z.array(z.string()).optional(),
@@ -2385,7 +2420,10 @@ var SchedulerService = class {
2385
2420
  }, { connection: connectionOpts, prefix: this.keyPrefix });
2386
2421
  }
2387
2422
  async stopWorker() {
2388
- await this.worker?.close();
2423
+ if (this.worker) {
2424
+ await this.worker.close();
2425
+ this.worker = void 0;
2426
+ }
2389
2427
  }
2390
2428
  async getScheduledJobs() {
2391
2429
  const jobs = await this.queue.getRepeatableJobs();
@@ -2422,7 +2460,7 @@ function createEmailRuleEngine(config) {
2422
2460
  `${prefix}EmailThrottleConfig`,
2423
2461
  createEmailThrottleConfigSchema(prefix)
2424
2462
  );
2425
- const templateService = new TemplateService(EmailTemplate, config);
2463
+ const templateService = new TemplateService(EmailTemplate, config, EmailRule);
2426
2464
  const ruleService = new RuleService(EmailRule, EmailTemplate, EmailRuleRunLog, config);
2427
2465
  const runnerService = new RuleRunnerService(
2428
2466
  EmailRule,
@@ -2442,7 +2480,8 @@ function createEmailRuleEngine(config) {
2442
2480
  platformValues: config.platforms,
2443
2481
  categoryValues: config.categories,
2444
2482
  audienceValues: config.audiences,
2445
- logger: config.logger
2483
+ logger: config.logger,
2484
+ collections: config.collections || []
2446
2485
  });
2447
2486
  return {
2448
2487
  routes,
@@ -2453,6 +2492,6 @@ function createEmailRuleEngine(config) {
2453
2492
  };
2454
2493
  }
2455
2494
 
2456
- export { AlxEmailError, ConfigValidationError, DuplicateSlugError, EMAIL_SEND_STATUS, EMAIL_TYPE, LockAcquisitionError, RULE_OPERATOR, RUN_LOG_STATUS, RUN_TRIGGER, RuleNotFoundError, RuleRunnerService, RuleService, RuleTemplateIncompatibleError, SchedulerService, TARGET_MODE, TEMPLATE_AUDIENCE, TEMPLATE_CATEGORY, THROTTLE_WINDOW, TemplateNotFoundError, TemplateRenderService, TemplateService, TemplateSyntaxError, createEmailRuleEngine, createEmailRuleRunLogSchema, createEmailRuleSchema, createEmailRuleSendSchema, createEmailTemplateSchema, createEmailThrottleConfigSchema, validateConfig };
2495
+ export { AlxEmailError, ConfigValidationError, DuplicateSlugError, EMAIL_SEND_STATUS, EMAIL_TYPE, FIELD_TYPE, LockAcquisitionError, RULE_OPERATOR, RUN_LOG_STATUS, RUN_TRIGGER, RuleNotFoundError, RuleRunnerService, RuleService, RuleTemplateIncompatibleError, SchedulerService, TARGET_MODE, TEMPLATE_AUDIENCE, TEMPLATE_CATEGORY, THROTTLE_WINDOW, TYPE_OPERATORS, TemplateNotFoundError, TemplateRenderService, TemplateService, TemplateSyntaxError, createEmailRuleEngine, createEmailRuleRunLogSchema, createEmailRuleSchema, createEmailRuleSendSchema, createEmailTemplateSchema, createEmailThrottleConfigSchema, flattenFields, validateConditions, validateConfig };
2457
2496
  //# sourceMappingURL=index.mjs.map
2458
2497
  //# sourceMappingURL=index.mjs.map