@astralibx/email-rule-engine 6.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,21 +102,18 @@ The `createEmailRuleEngine(config)` factory accepts an `EmailRuleEngineConfig` o
102
102
 
103
103
  See [docs/configuration.md](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/configuration.md) for the full reference with examples.
104
104
 
105
- ## Documentation
106
-
107
- | Document | Description |
108
- |----------|-------------|
109
- | [Configuration](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/configuration.md) | Full config reference -- db, redis, adapters, platforms, options, hooks, logger |
110
- | [Adapters](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/adapters.md) | All 6 adapters with type signatures and example implementations |
111
- | [Templates](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/templates.md) | Creating templates, MJML + Handlebars syntax, built-in helpers |
112
- | [Rules](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/rules.md) | Targeting conditions, operators, sendOnce/resend, dry runs |
113
- | [Throttling](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/throttling.md) | Per-user limits, global caps, bypass rules, tracking |
114
- | [API Routes](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/api-routes.md) | All REST endpoints with curl examples |
115
- | [Programmatic API](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/programmatic-api.md) | Using services directly -- runner, templateService, ruleService |
116
- | [Execution Flow](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/execution-flow.md) | Step-by-step runner flow and error behavior |
117
- | [Error Handling](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/error-handling.md) | All error classes with codes and when thrown |
118
- | [Constants](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/constants.md) | All exported constants and derived types |
119
- | [Migration v1 to v2](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/migration-v1-to-v2.md) | Breaking changes from v1 |
105
+ ## Getting Started Guide
106
+
107
+ 1. [Configuration](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/configuration.md) Set up database, Redis, and options
108
+ 2. [Adapters](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/adapters.md) — Implement the 6 required adapter functions
109
+ 3. [Templates](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/templates.md) Create email templates with MJML + Handlebars
110
+ 4. [Rules](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/rules.md) Define targeting rules with conditions or explicit lists
111
+ 5. [Execution Flow](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/execution-flow.md) Understand how the runner processes rules
112
+ 6. [Throttling](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/throttling.md) Configure per-user send limits
113
+
114
+ Reference: [API Routes](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/api-routes.md) | [Programmatic API](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/programmatic-api.md) | [Types](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/types.md) | [Constants](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/constants.md) | [Error Handling](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/error-handling.md)
115
+
116
+ > **Important:** Configure throttle settings before running rules. Default limits (1/day, 2/week) may be too restrictive. See [Throttling](https://github.com/Hariprakash1997/astralib/blob/main/packages/email-rule-engine/docs/throttling.md).
120
117
 
121
118
  ## License
122
119
 
package/dist/index.d.mts CHANGED
@@ -295,6 +295,7 @@ interface EmailTemplate {
295
295
  textBody?: string;
296
296
  subjects: string[];
297
297
  bodies: string[];
298
+ preheaders?: string[];
298
299
  fields?: Record<string, string>;
299
300
  variables: string[];
300
301
  version: number;
@@ -312,6 +313,7 @@ interface CreateEmailTemplateInput {
312
313
  textBody?: string;
313
314
  subjects: string[];
314
315
  bodies: string[];
316
+ preheaders?: string[];
315
317
  fields?: Record<string, string>;
316
318
  variables?: string[];
317
319
  }
@@ -324,6 +326,7 @@ interface UpdateEmailTemplateInput {
324
326
  textBody?: string;
325
327
  subjects?: string[];
326
328
  bodies?: string[];
329
+ preheaders?: string[];
327
330
  fields?: Record<string, string>;
328
331
  variables?: string[];
329
332
  isActive?: boolean;
@@ -382,6 +385,7 @@ interface IEmailRuleSend {
382
385
  subject?: string;
383
386
  subjectIndex?: number;
384
387
  bodyIndex?: number;
388
+ preheaderIndex?: number;
385
389
  failureReason?: string;
386
390
  }
387
391
  type EmailRuleSendDocument = HydratedDocument<IEmailRuleSend>;
@@ -395,6 +399,7 @@ interface EmailRuleSendStatics {
395
399
  subject?: string;
396
400
  subjectIndex?: number;
397
401
  bodyIndex?: number;
402
+ preheaderIndex?: number;
398
403
  failureReason?: string;
399
404
  }): Promise<EmailRuleSendDocument>;
400
405
  }
@@ -625,10 +630,11 @@ declare class TemplateRenderService {
625
630
  constructor();
626
631
  renderSingle(subject: string, body: string, data: Record<string, unknown>, textBody?: string): RenderResult;
627
632
  compileBatch(subject: string, body: string, textBody?: string): CompiledTemplate;
628
- compileBatchVariants(subjects: string[], bodies: string[], textBody?: string): {
633
+ compileBatchVariants(subjects: string[], bodies: string[], textBody?: string, preheaders?: string[]): {
629
634
  subjectFns: HandlebarsTemplateDelegate[];
630
635
  bodyFns: HandlebarsTemplateDelegate[];
631
636
  textBodyFn?: HandlebarsTemplateDelegate;
637
+ preheaderFns?: HandlebarsTemplateDelegate[];
632
638
  };
633
639
  renderFromCompiled(compiled: CompiledTemplate, data: Record<string, unknown>): RenderResult;
634
640
  renderPreview(subject: string, body: string, data: Record<string, unknown>, textBody?: string): RenderResult;
package/dist/index.d.ts CHANGED
@@ -295,6 +295,7 @@ interface EmailTemplate {
295
295
  textBody?: string;
296
296
  subjects: string[];
297
297
  bodies: string[];
298
+ preheaders?: string[];
298
299
  fields?: Record<string, string>;
299
300
  variables: string[];
300
301
  version: number;
@@ -312,6 +313,7 @@ interface CreateEmailTemplateInput {
312
313
  textBody?: string;
313
314
  subjects: string[];
314
315
  bodies: string[];
316
+ preheaders?: string[];
315
317
  fields?: Record<string, string>;
316
318
  variables?: string[];
317
319
  }
@@ -324,6 +326,7 @@ interface UpdateEmailTemplateInput {
324
326
  textBody?: string;
325
327
  subjects?: string[];
326
328
  bodies?: string[];
329
+ preheaders?: string[];
327
330
  fields?: Record<string, string>;
328
331
  variables?: string[];
329
332
  isActive?: boolean;
@@ -382,6 +385,7 @@ interface IEmailRuleSend {
382
385
  subject?: string;
383
386
  subjectIndex?: number;
384
387
  bodyIndex?: number;
388
+ preheaderIndex?: number;
385
389
  failureReason?: string;
386
390
  }
387
391
  type EmailRuleSendDocument = HydratedDocument<IEmailRuleSend>;
@@ -395,6 +399,7 @@ interface EmailRuleSendStatics {
395
399
  subject?: string;
396
400
  subjectIndex?: number;
397
401
  bodyIndex?: number;
402
+ preheaderIndex?: number;
398
403
  failureReason?: string;
399
404
  }): Promise<EmailRuleSendDocument>;
400
405
  }
@@ -625,10 +630,11 @@ declare class TemplateRenderService {
625
630
  constructor();
626
631
  renderSingle(subject: string, body: string, data: Record<string, unknown>, textBody?: string): RenderResult;
627
632
  compileBatch(subject: string, body: string, textBody?: string): CompiledTemplate;
628
- compileBatchVariants(subjects: string[], bodies: string[], textBody?: string): {
633
+ compileBatchVariants(subjects: string[], bodies: string[], textBody?: string, preheaders?: string[]): {
629
634
  subjectFns: HandlebarsTemplateDelegate[];
630
635
  bodyFns: HandlebarsTemplateDelegate[];
631
636
  textBodyFn?: HandlebarsTemplateDelegate;
637
+ preheaderFns?: HandlebarsTemplateDelegate[];
632
638
  };
633
639
  renderFromCompiled(compiled: CompiledTemplate, data: Record<string, unknown>): RenderResult;
634
640
  renderPreview(subject: string, body: string, data: Record<string, unknown>, textBody?: string): RenderResult;
package/dist/index.js CHANGED
@@ -79,7 +79,18 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
79
79
  textBody: String,
80
80
  subjects: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one subject is required"] },
81
81
  bodies: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one body is required"] },
82
- fields: { type: mongoose.Schema.Types.Mixed, default: {} },
82
+ preheaders: [{ type: String }],
83
+ fields: {
84
+ type: mongoose.Schema.Types.Mixed,
85
+ default: {},
86
+ validate: {
87
+ validator: (v) => {
88
+ if (!v || typeof v !== "object") return true;
89
+ return Object.values(v).every((val) => typeof val === "string");
90
+ },
91
+ message: "All field values must be strings"
92
+ }
93
+ },
83
94
  variables: [{ type: String }],
84
95
  version: { type: Number, default: 1 },
85
96
  isActive: { type: Boolean, default: true, index: true }
@@ -114,6 +125,7 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
114
125
  textBody: input.textBody,
115
126
  subjects: input.subjects,
116
127
  bodies: input.bodies,
128
+ preheaders: input.preheaders || [],
117
129
  fields: input.fields || {},
118
130
  variables: input.variables || [],
119
131
  version: 1,
@@ -224,6 +236,7 @@ function createEmailRuleSendSchema(collectionPrefix) {
224
236
  subject: { type: String },
225
237
  subjectIndex: { type: Number },
226
238
  bodyIndex: { type: Number },
239
+ preheaderIndex: { type: Number },
227
240
  failureReason: { type: String }
228
241
  },
229
242
  {
@@ -448,7 +461,7 @@ var TemplateRenderService = class {
448
461
  const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
449
462
  return { subjectFn, bodyFn, textBodyFn };
450
463
  }
451
- compileBatchVariants(subjects, bodies, textBody) {
464
+ compileBatchVariants(subjects, bodies, textBody, preheaders) {
452
465
  const subjectFns = subjects.map((s) => Handlebars__default.default.compile(s, { strict: true }));
453
466
  const bodyFns = bodies.map((b) => {
454
467
  const mjmlSource = wrapInMjml(b);
@@ -456,7 +469,8 @@ var TemplateRenderService = class {
456
469
  return Handlebars__default.default.compile(htmlWithHandlebars, { strict: true });
457
470
  });
458
471
  const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
459
- return { subjectFns, bodyFns, textBodyFn };
472
+ const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars__default.default.compile(p, { strict: true })) : void 0;
473
+ return { subjectFns, bodyFns, textBodyFn, preheaderFns };
460
474
  }
461
475
  renderFromCompiled(compiled, data) {
462
476
  const subject = compiled.subjectFn(data);
@@ -568,6 +582,7 @@ var UPDATEABLE_FIELDS = /* @__PURE__ */ new Set([
568
582
  "textBody",
569
583
  "subjects",
570
584
  "bodies",
585
+ "preheaders",
571
586
  "variables",
572
587
  "isActive",
573
588
  "fields"
@@ -610,7 +625,7 @@ var TemplateService = class {
610
625
  throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
611
626
  }
612
627
  }
613
- const allContent = [...subjects, ...bodies, input.textBody || ""].join(" ");
628
+ const allContent = [...subjects, ...bodies, ...input.preheaders || [], input.textBody || ""].join(" ");
614
629
  const variables = input.variables || this.renderService.extractVariables(allContent);
615
630
  return this.EmailTemplate.createTemplate({
616
631
  ...input,
@@ -638,11 +653,12 @@ var TemplateService = class {
638
653
  }
639
654
  }
640
655
  }
641
- if (input.textBody || input.subjects || input.bodies) {
656
+ if (input.textBody || input.subjects || input.bodies || input.preheaders) {
642
657
  const subjects = input.subjects ?? template.subjects;
643
658
  const bodies = input.bodies ?? template.bodies;
659
+ const preheaders = input.preheaders ?? template.preheaders ?? [];
644
660
  const textBody = input.textBody ?? template.textBody;
645
- const allContent = [...subjects, ...bodies, textBody || ""].join(" ");
661
+ const allContent = [...subjects, ...bodies, ...preheaders, textBody || ""].join(" ");
646
662
  input.variables = this.renderService.extractVariables(allContent);
647
663
  }
648
664
  const setFields = {};
@@ -652,7 +668,7 @@ var TemplateService = class {
652
668
  }
653
669
  }
654
670
  const update = { $set: setFields };
655
- if (input.textBody || input.subjects || input.bodies) {
671
+ if (input.textBody || input.subjects || input.bodies || input.preheaders) {
656
672
  update["$inc"] = { version: 1 };
657
673
  }
658
674
  return this.EmailTemplate.findByIdAndUpdate(
@@ -904,6 +920,21 @@ var RedisLock = class {
904
920
  var MS_PER_DAY = 864e5;
905
921
  var DEFAULT_LOCK_TTL_MS = 30 * 60 * 1e3;
906
922
  var IDENTIFIER_CHUNK_SIZE = 50;
923
+ function getLocalDate(date, timezone) {
924
+ if (!timezone) return date;
925
+ const parts = new Intl.DateTimeFormat("en-US", {
926
+ timeZone: timezone,
927
+ year: "numeric",
928
+ month: "2-digit",
929
+ day: "2-digit",
930
+ hour: "2-digit",
931
+ minute: "2-digit",
932
+ second: "2-digit",
933
+ hour12: false
934
+ }).formatToParts(date);
935
+ const get = (type) => parts.find((p) => p.type === type)?.value || "0";
936
+ return /* @__PURE__ */ new Date(`${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}`);
937
+ }
907
938
  async function processInChunks(items, fn, chunkSize) {
908
939
  const results = [];
909
940
  for (let i = 0; i < items.length; i += chunkSize) {
@@ -977,9 +1008,18 @@ var RuleRunnerService = class {
977
1008
  const throttleConfig = await this.EmailThrottleConfig.getConfig();
978
1009
  const allActiveRules = await this.EmailRule.findActive();
979
1010
  const now = /* @__PURE__ */ new Date();
1011
+ const tz = this.config.options?.sendWindow?.timezone;
980
1012
  const activeRules = allActiveRules.filter((rule) => {
981
- if (rule.validFrom && now < new Date(rule.validFrom)) return false;
982
- if (rule.validTill && now > new Date(rule.validTill)) return false;
1013
+ if (rule.validFrom) {
1014
+ const localNow = getLocalDate(now, tz);
1015
+ const localValidFrom = getLocalDate(new Date(rule.validFrom), tz);
1016
+ if (localNow < localValidFrom) return false;
1017
+ }
1018
+ if (rule.validTill) {
1019
+ const localNow = getLocalDate(now, tz);
1020
+ const localValidTill = getLocalDate(new Date(rule.validTill), tz);
1021
+ if (localNow > localValidTill) return false;
1022
+ }
983
1023
  return true;
984
1024
  });
985
1025
  this.config.hooks?.onRunStart?.({ rulesCount: activeRules.length, triggeredBy });
@@ -1136,10 +1176,12 @@ var RuleRunnerService = class {
1136
1176
  sendMap.set(uid, send);
1137
1177
  }
1138
1178
  }
1179
+ const preheaders = template.preheaders || [];
1139
1180
  const compiledVariants = this.templateRenderer.compileBatchVariants(
1140
1181
  template.subjects,
1141
1182
  template.bodies,
1142
- template.textBody
1183
+ template.textBody,
1184
+ preheaders
1143
1185
  );
1144
1186
  let totalProcessed = 0;
1145
1187
  for (let i = 0; i < emailsToProcess.length; i++) {
@@ -1195,6 +1237,15 @@ var RuleRunnerService = class {
1195
1237
  let finalHtml = renderedHtml;
1196
1238
  let finalText = renderedText;
1197
1239
  let finalSubject = renderedSubject;
1240
+ let pi;
1241
+ if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
1242
+ pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
1243
+ const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
1244
+ if (renderedPreheader) {
1245
+ 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>`;
1246
+ finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
1247
+ }
1248
+ }
1198
1249
  if (this.config.hooks?.beforeSend) {
1199
1250
  try {
1200
1251
  const modified = await this.config.hooks.beforeSend({
@@ -1237,7 +1288,7 @@ var RuleRunnerService = class {
1237
1288
  dedupKey,
1238
1289
  identifier.id,
1239
1290
  void 0,
1240
- { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
1291
+ { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
1241
1292
  );
1242
1293
  const current = throttleMap.get(dedupKey) || { today: 0, thisWeek: 0, lastSentDate: null };
1243
1294
  throttleMap.set(dedupKey, {
@@ -1270,24 +1321,16 @@ var RuleRunnerService = class {
1270
1321
  $inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
1271
1322
  });
1272
1323
  if (rule.sendOnce) {
1273
- const allIdentifiers = rule.target.identifiers;
1324
+ const allIdentifiers = rule.target.identifiers || [];
1325
+ const totalIdentifiers = new Set(allIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean)).size;
1274
1326
  const sends = await this.EmailRuleSend.find({
1275
1327
  ruleId: rule._id
1276
1328
  }).lean();
1277
1329
  const sentOrProcessedIds = new Set(
1278
1330
  sends.filter((s) => s.status !== "throttled").map((s) => String(s.userId || s.emailIdentifierId))
1279
1331
  );
1280
- let pendingCount = 0;
1281
- for (const email of allIdentifiers) {
1282
- const identifier = identifierMap.get(email.toLowerCase().trim());
1283
- if (!identifier) continue;
1284
- if (!sentOrProcessedIds.has(String(identifier.id))) {
1285
- pendingCount++;
1286
- }
1287
- }
1288
1332
  const throttledCount = sends.filter((s) => s.status === "throttled").length;
1289
- pendingCount += throttledCount;
1290
- if (pendingCount === 0) {
1333
+ if (sentOrProcessedIds.size >= totalIdentifiers && throttledCount === 0) {
1291
1334
  await this.EmailRule.findByIdAndUpdate(rule._id, { $set: { isActive: false } });
1292
1335
  this.logger.info(`Rule '${rule.name}' auto-disabled \u2014 all identifiers processed`);
1293
1336
  }
@@ -1332,10 +1375,12 @@ var RuleRunnerService = class {
1332
1375
  identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
1333
1376
  }
1334
1377
  }
1378
+ const preheadersQ = template.preheaders || [];
1335
1379
  const compiledVariants = this.templateRenderer.compileBatchVariants(
1336
1380
  template.subjects,
1337
1381
  template.bodies,
1338
- template.textBody
1382
+ template.textBody,
1383
+ preheadersQ
1339
1384
  );
1340
1385
  const ruleId = rule._id.toString();
1341
1386
  const templateId = rule.templateId.toString();
@@ -1398,6 +1443,15 @@ var RuleRunnerService = class {
1398
1443
  let finalHtml = renderedHtml;
1399
1444
  let finalText = renderedText;
1400
1445
  let finalSubject = renderedSubject;
1446
+ let pi;
1447
+ if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
1448
+ pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
1449
+ const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
1450
+ if (renderedPreheader) {
1451
+ 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>`;
1452
+ finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
1453
+ }
1454
+ }
1401
1455
  if (this.config.hooks?.beforeSend) {
1402
1456
  try {
1403
1457
  const modified = await this.config.hooks.beforeSend({
@@ -1440,7 +1494,7 @@ var RuleRunnerService = class {
1440
1494
  userId,
1441
1495
  identifier.id,
1442
1496
  void 0,
1443
- { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
1497
+ { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
1444
1498
  );
1445
1499
  const current = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
1446
1500
  throttleMap.set(userId, {
@@ -1669,7 +1723,7 @@ function createTemplateController(templateService, options) {
1669
1723
  }
1670
1724
  async function create(req, res) {
1671
1725
  try {
1672
- const { name, subjects, bodies, category, audience, platform } = req.body;
1726
+ const { name, subjects, bodies, category, audience, platform, preheaders } = req.body;
1673
1727
  if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
1674
1728
  return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
1675
1729
  }