mailery 0.8.1 → 0.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.
@@ -113,6 +113,21 @@ type Predicate = {
113
113
  };
114
114
  } | {
115
115
  fieldExists: string;
116
+ }
117
+ /**
118
+ * Tests a property of the event that STARTED this run, so the answer is
119
+ * per-run rather than per-contact. Use when the gate depends on what the run
120
+ * is about (which account, plan, order) instead of a durable trait of the
121
+ * person: a contact-level tag is shared by every concurrent run and the last
122
+ * writer wins, which silently changes branching in runs already in flight.
123
+ */
124
+ | {
125
+ triggerPropertyEquals: {
126
+ key: string;
127
+ value: string | number | boolean | null;
128
+ };
129
+ } | {
130
+ triggerPropertyTruthy: string;
116
131
  } | {
117
132
  hasFiredEvent: string;
118
133
  sinceFlowStart?: boolean;
@@ -378,6 +393,13 @@ interface Queues {
378
393
  type QueueDriverConfig = {
379
394
  driver: 'bull';
380
395
  redis: RedisOptions | IORedis;
396
+ /**
397
+ * Redis key prefix for all queue keys (BullMQ default: 'bull').
398
+ * Namespaces multiple mailery instances on one Redis cluster — e.g.
399
+ * 'mailery-dev' / 'mailery-prod' to isolate environments. Must not
400
+ * contain ':' (BullMQ uses it as the key separator).
401
+ */
402
+ prefix?: string;
381
403
  } | {
382
404
  driver: 'agenda';
383
405
  db?: Db;
@@ -1382,6 +1404,7 @@ declare class Mailer {
1382
1404
  * MAILER_MONGODB_URI — Mongo connection string (required)
1383
1405
  * MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
1384
1406
  * MAILER_REDIS_URL — Redis connection URL (required)
1407
+ * MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
1385
1408
  * MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
1386
1409
  * MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
1387
1410
  * MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
@@ -1419,6 +1442,7 @@ declare class Mailer {
1419
1442
  */
1420
1443
  abortFlow(flowSlug: string, externalId: string, opts?: {
1421
1444
  reason?: string;
1445
+ matchTriggerProperties?: Record<string, unknown>;
1422
1446
  }): Promise<{
1423
1447
  abortedRuns: number;
1424
1448
  cancelledSends: number;
@@ -113,6 +113,21 @@ type Predicate = {
113
113
  };
114
114
  } | {
115
115
  fieldExists: string;
116
+ }
117
+ /**
118
+ * Tests a property of the event that STARTED this run, so the answer is
119
+ * per-run rather than per-contact. Use when the gate depends on what the run
120
+ * is about (which account, plan, order) instead of a durable trait of the
121
+ * person: a contact-level tag is shared by every concurrent run and the last
122
+ * writer wins, which silently changes branching in runs already in flight.
123
+ */
124
+ | {
125
+ triggerPropertyEquals: {
126
+ key: string;
127
+ value: string | number | boolean | null;
128
+ };
129
+ } | {
130
+ triggerPropertyTruthy: string;
116
131
  } | {
117
132
  hasFiredEvent: string;
118
133
  sinceFlowStart?: boolean;
@@ -378,6 +393,13 @@ interface Queues {
378
393
  type QueueDriverConfig = {
379
394
  driver: 'bull';
380
395
  redis: RedisOptions | IORedis;
396
+ /**
397
+ * Redis key prefix for all queue keys (BullMQ default: 'bull').
398
+ * Namespaces multiple mailery instances on one Redis cluster — e.g.
399
+ * 'mailery-dev' / 'mailery-prod' to isolate environments. Must not
400
+ * contain ':' (BullMQ uses it as the key separator).
401
+ */
402
+ prefix?: string;
381
403
  } | {
382
404
  driver: 'agenda';
383
405
  db?: Db;
@@ -1382,6 +1404,7 @@ declare class Mailer {
1382
1404
  * MAILER_MONGODB_URI — Mongo connection string (required)
1383
1405
  * MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
1384
1406
  * MAILER_REDIS_URL — Redis connection URL (required)
1407
+ * MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
1385
1408
  * MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
1386
1409
  * MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
1387
1410
  * MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
@@ -1419,6 +1442,7 @@ declare class Mailer {
1419
1442
  */
1420
1443
  abortFlow(flowSlug: string, externalId: string, opts?: {
1421
1444
  reason?: string;
1445
+ matchTriggerProperties?: Record<string, unknown>;
1422
1446
  }): Promise<{
1423
1447
  abortedRuns: number;
1424
1448
  cancelledSends: number;
package/dist/testing.cjs CHANGED
@@ -14208,7 +14208,25 @@ var tagInputSchema = zod.z.object({
14208
14208
  var abortFlowInputSchema = zod.z.object({
14209
14209
  flowSlug: slugSchema,
14210
14210
  externalId: externalIdSchema,
14211
- reason: zod.z.string().min(1).max(200).optional()
14211
+ reason: zod.z.string().min(1).max(200).optional(),
14212
+ /**
14213
+ * Restrict the abort to runs whose trigger event carried these properties —
14214
+ * e.g. `{ accountId }` to cancel one account's series while the same
14215
+ * contact's other accounts keep running. Omit to abort every active run for
14216
+ * the contact on this flow.
14217
+ *
14218
+ * Keys and values are both constrained because these go straight into a
14219
+ * Mongo query. Values are primitives only: an object value like
14220
+ * `{ $ne: null }` would reach the query as an OPERATOR and match every
14221
+ * scoped run, turning a one-account abort into abort-everything. Hosts
14222
+ * typically pass an id from a request body, so treat it as untrusted. The
14223
+ * key regex likewise blocks `$`-prefixed keys and dots (a dot would silently
14224
+ * extend the path and change match semantics).
14225
+ */
14226
+ matchTriggerProperties: zod.z.record(
14227
+ zod.z.string().regex(/^[A-Za-z0-9_]+$/),
14228
+ zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()])
14229
+ ).optional()
14212
14230
  });
14213
14231
  var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
14214
14232
  var sendOneOffInputSchema = zod.z.object({
@@ -14277,6 +14295,13 @@ var predicateSchema = zod.z.lazy(
14277
14295
  zod.z.object({ notHasTag: zod.z.string() }),
14278
14296
  zod.z.object({ fieldEquals: zod.z.object({ field: zod.z.string(), value: zod.z.unknown() }) }),
14279
14297
  zod.z.object({ fieldExists: zod.z.string() }),
14298
+ zod.z.object({
14299
+ triggerPropertyEquals: zod.z.object({
14300
+ key: zod.z.string().min(1),
14301
+ value: zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()])
14302
+ })
14303
+ }),
14304
+ zod.z.object({ triggerPropertyTruthy: zod.z.string().min(1) }),
14280
14305
  zod.z.object({
14281
14306
  hasFiredEvent: zod.z.string(),
14282
14307
  sinceFlowStart: zod.z.boolean().optional(),
@@ -14636,7 +14661,7 @@ var BullDriver = class _BullDriver {
14636
14661
  bullQueues;
14637
14662
  workers = null;
14638
14663
  bull;
14639
- static async create(redisConfig) {
14664
+ static async create(redisConfig, prefix) {
14640
14665
  let bull;
14641
14666
  try {
14642
14667
  bull = await import('bullmq');
@@ -14645,14 +14670,22 @@ var BullDriver = class _BullDriver {
14645
14670
  "mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
14646
14671
  );
14647
14672
  }
14673
+ if (prefix?.includes(":")) {
14674
+ throw new Error(
14675
+ `mailery: queue prefix "${prefix}" must not contain ':' \u2014 BullMQ uses it as the Redis key separator.`
14676
+ );
14677
+ }
14648
14678
  const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
14649
- return new _BullDriver(bull, redis);
14679
+ return new _BullDriver(bull, redis, prefix);
14650
14680
  }
14651
- constructor(bull, redis) {
14681
+ prefix;
14682
+ constructor(bull, redis, prefix) {
14652
14683
  this.bull = bull;
14653
14684
  this.redis = redis;
14685
+ this.prefix = prefix;
14654
14686
  const opts = {
14655
14687
  connection: redis,
14688
+ prefix,
14656
14689
  defaultJobOptions: {
14657
14690
  removeOnComplete: { age: 24 * 3600, count: 1e3 },
14658
14691
  removeOnFail: { age: 7 * 24 * 3600 }
@@ -14684,7 +14717,7 @@ var BullDriver = class _BullDriver {
14684
14717
  }
14685
14718
  async startWorkers(opts) {
14686
14719
  if (this.workers) return;
14687
- const base = { connection: this.redis };
14720
+ const base = { connection: this.redis, prefix: this.prefix };
14688
14721
  const { Worker } = this.bull;
14689
14722
  const tick = new Worker(
14690
14723
  QUEUE_NAMES.tick,
@@ -14936,7 +14969,7 @@ var NoopDriver = class {
14936
14969
  async function createQueueDriver(config, fallbackDb) {
14937
14970
  switch (config.driver) {
14938
14971
  case "bull":
14939
- return BullDriver.create(config.redis);
14972
+ return BullDriver.create(config.redis, config.prefix);
14940
14973
  case "agenda":
14941
14974
  return AgendaDriver.create({
14942
14975
  db: config.db ?? fallbackDb,
@@ -15359,6 +15392,12 @@ async function evaluatePredicate(predicate, ctx) {
15359
15392
  if ("fieldExists" in p) {
15360
15393
  return ctx.contact.fields[p.fieldExists] !== void 0;
15361
15394
  }
15395
+ if ("triggerPropertyEquals" in p) {
15396
+ return (ctx.run.triggerEvent?.properties ?? {})[p.triggerPropertyEquals.key] === p.triggerPropertyEquals.value;
15397
+ }
15398
+ if ("triggerPropertyTruthy" in p) {
15399
+ return Boolean((ctx.run.triggerEvent?.properties ?? {})[p.triggerPropertyTruthy]);
15400
+ }
15362
15401
  if ("subscriptionStatus" in p) {
15363
15402
  const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
15364
15403
  return sub?.status === p.subscriptionStatus;
@@ -16105,7 +16144,14 @@ async function handleFireEvent(run, step2, ctx) {
16105
16144
  await ctx.collections.events.insertOne({
16106
16145
  externalId: run.externalId,
16107
16146
  name: step2.eventName,
16108
- properties: step2.properties ?? {},
16147
+ // Inherit the triggering event's properties so a handoff carries the
16148
+ // context that identifies what the run is ABOUT (which account, order,
16149
+ // subscription, ...). A step's `properties` are static — authored once in
16150
+ // the flow definition — so without this a fired event can only ever say
16151
+ // "this contact", losing the scope the originating event supplied, and
16152
+ // the receiving flow has nothing to resolve variables against. Explicit
16153
+ // step.properties win on conflict.
16154
+ properties: { ...run.triggerEvent?.properties ?? {}, ...step2.properties ?? {} },
16109
16155
  dedupeKey,
16110
16156
  occurredAt: /* @__PURE__ */ new Date(),
16111
16157
  createdAt: /* @__PURE__ */ new Date()
@@ -17364,6 +17410,7 @@ var Mailer = class _Mailer {
17364
17410
  * MAILER_MONGODB_URI — Mongo connection string (required)
17365
17411
  * MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
17366
17412
  * MAILER_REDIS_URL — Redis connection URL (required)
17413
+ * MAILER_QUEUE_PREFIX — Redis key prefix to namespace this instance (optional)
17367
17414
  * MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
17368
17415
  * MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
17369
17416
  * MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
@@ -17412,7 +17459,11 @@ var Mailer = class _Mailer {
17412
17459
  }
17413
17460
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
17414
17461
  const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
17415
- const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
17462
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : {
17463
+ driver: "bull",
17464
+ redis: { url: required("MAILER_REDIS_URL") },
17465
+ prefix: env.MAILER_QUEUE_PREFIX
17466
+ };
17416
17467
  return _Mailer.init({
17417
17468
  db,
17418
17469
  adapter,
@@ -17663,14 +17714,25 @@ var Mailer = class _Mailer {
17663
17714
  * same handler that processes the business event ("user upgraded").
17664
17715
  */
17665
17716
  async abortFlow(flowSlug, externalId, opts = {}) {
17666
- const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
17717
+ const parsed = abortFlowInputSchema.parse({
17718
+ flowSlug,
17719
+ externalId,
17720
+ reason: opts.reason,
17721
+ matchTriggerProperties: opts.matchTriggerProperties
17722
+ });
17667
17723
  const flow = await this.collections.flows.findOne(
17668
17724
  { slug: parsed.flowSlug },
17669
17725
  { projection: { _id: 1 } }
17670
17726
  );
17671
17727
  if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
17728
+ const triggerMatch = Object.fromEntries(
17729
+ Object.entries(parsed.matchTriggerProperties ?? {}).map(([k, v]) => [
17730
+ `triggerEvent.properties.${k}`,
17731
+ v
17732
+ ])
17733
+ );
17672
17734
  const result = await this.abortActiveRuns(
17673
- { externalId: parsed.externalId, flowId: flow._id },
17735
+ { externalId: parsed.externalId, flowId: flow._id, ...triggerMatch },
17674
17736
  parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17675
17737
  );
17676
17738
  if (result.abortedRuns > 0 || result.cancelledSends > 0) {
@@ -17678,7 +17740,9 @@ var Mailer = class _Mailer {
17678
17740
  actor: "host",
17679
17741
  action: "flow.abort",
17680
17742
  resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
17681
- diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17743
+ // Record the scope: without it a one-account abort and an abort-every-
17744
+ // run-for-this-contact are indistinguishable in the audit trail.
17745
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} scope=${parsed.matchTriggerProperties ? JSON.stringify(parsed.matchTriggerProperties) : "all"} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17682
17746
  });
17683
17747
  }
17684
17748
  return result;