mailery 0.5.1 → 0.7.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/testing.cjs CHANGED
@@ -1,9 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ var zod = require('zod');
3
4
  var mongodb = require('mongodb');
4
5
  var crypto2 = require('crypto');
5
6
  var sgMail = require('@sendgrid/mail');
6
- var zod = require('zod');
7
7
  var IORedis = require('ioredis');
8
8
  var Handlebars = require('handlebars');
9
9
  var htmlToText = require('html-to-text');
@@ -63,6 +63,55 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
63
63
  ));
64
64
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
65
65
 
66
+ // src/server/adapters/vars.ts
67
+ var vars_exports = {};
68
+ __export(vars_exports, {
69
+ RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
70
+ assertNoReservedVarKeys: () => assertNoReservedVarKeys,
71
+ defineVars: () => defineVars,
72
+ resolveVars: () => resolveVars,
73
+ varsJsonSchema: () => varsJsonSchema
74
+ });
75
+ function defineVars(adapter) {
76
+ return adapter;
77
+ }
78
+ function assertNoReservedVarKeys(adapter) {
79
+ const json = varsJsonSchema(adapter);
80
+ const props = json && typeof json === "object" ? json.properties : void 0;
81
+ if (!props) return;
82
+ const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
83
+ if (clashes.length > 0) {
84
+ throw new Error(
85
+ `varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
86
+ );
87
+ }
88
+ }
89
+ function varsJsonSchema(adapter) {
90
+ return zod.z.toJSONSchema(adapter.schema, { io: "output" });
91
+ }
92
+ async function resolveVars(adapter, contact, info) {
93
+ if (!adapter) return {};
94
+ const resolved = await adapter.resolve(contact, info);
95
+ if (!resolved || typeof resolved !== "object") return {};
96
+ const out = { ...resolved };
97
+ for (const k of RESERVED_VAR_KEYS) delete out[k];
98
+ return out;
99
+ }
100
+ var RESERVED_VAR_KEYS;
101
+ var init_vars = __esm({
102
+ "src/server/adapters/vars.ts"() {
103
+ RESERVED_VAR_KEYS = [
104
+ "contact",
105
+ "vars",
106
+ "event",
107
+ "unsubscribeUrl",
108
+ "viewInBrowserUrl",
109
+ "preferenceCenterUrl",
110
+ "senderAddress"
111
+ ];
112
+ }
113
+ });
114
+
66
115
  // src/server/adapters/mongo.ts
67
116
  var mongo_exports = {};
68
117
  __export(mongo_exports, {
@@ -3005,19 +3054,19 @@ var require_range = __commonJS({
3005
3054
  var replaceCaret = (comp, options2) => {
3006
3055
  debug("caret", comp, options2);
3007
3056
  const r = options2.loose ? re[t.CARETLOOSE] : re[t.CARET];
3008
- const z2 = options2.includePrerelease ? "-0" : "";
3057
+ const z3 = options2.includePrerelease ? "-0" : "";
3009
3058
  return comp.replace(r, (_, M, m, p, pr) => {
3010
3059
  debug("caret", comp, _, M, m, p, pr);
3011
3060
  let ret;
3012
3061
  if (isX(M)) {
3013
3062
  ret = "";
3014
3063
  } else if (isX(m)) {
3015
- ret = `>=${M}.0.0${z2} <${+M + 1}.0.0-0`;
3064
+ ret = `>=${M}.0.0${z3} <${+M + 1}.0.0-0`;
3016
3065
  } else if (isX(p)) {
3017
3066
  if (M === "0") {
3018
- ret = `>=${M}.${m}.0${z2} <${M}.${+m + 1}.0-0`;
3067
+ ret = `>=${M}.${m}.0${z3} <${M}.${+m + 1}.0-0`;
3019
3068
  } else {
3020
- ret = `>=${M}.${m}.0${z2} <${+M + 1}.0.0-0`;
3069
+ ret = `>=${M}.${m}.0${z3} <${+M + 1}.0.0-0`;
3021
3070
  }
3022
3071
  } else if (pr) {
3023
3072
  debug("replaceCaret pr", pr);
@@ -3034,9 +3083,9 @@ var require_range = __commonJS({
3034
3083
  debug("no pr");
3035
3084
  if (M === "0") {
3036
3085
  if (m === "0") {
3037
- ret = `>=${M}.${m}.${p}${z2} <${M}.${m}.${+p + 1}-0`;
3086
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${m}.${+p + 1}-0`;
3038
3087
  } else {
3039
- ret = `>=${M}.${m}.${p}${z2} <${M}.${+m + 1}.0-0`;
3088
+ ret = `>=${M}.${m}.${p}${z3} <${M}.${+m + 1}.0-0`;
3040
3089
  }
3041
3090
  } else {
3042
3091
  ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
@@ -14156,6 +14205,12 @@ var tagInputSchema = zod.z.object({
14156
14205
  externalId: externalIdSchema,
14157
14206
  tag: zod.z.string().min(1).max(128)
14158
14207
  });
14208
+ var abortFlowInputSchema = zod.z.object({
14209
+ flowSlug: slugSchema,
14210
+ externalId: externalIdSchema,
14211
+ reason: zod.z.string().min(1).max(200).optional()
14212
+ });
14213
+ var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
14159
14214
  var sendOneOffInputSchema = zod.z.object({
14160
14215
  templateSlug: slugSchema,
14161
14216
  externalId: externalIdSchema,
@@ -14185,7 +14240,13 @@ var flowStepSchema = zod.z.lazy(
14185
14240
  type: zod.z.literal("send"),
14186
14241
  templateSlug: slugSchema,
14187
14242
  providerOverride: zod.z.string().optional(),
14188
- vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
14243
+ vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
14244
+ delivery: zod.z.object({
14245
+ weekdaysOnly: zod.z.boolean().optional(),
14246
+ timeOfDay: zod.z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
14247
+ useContactTimezone: zod.z.boolean().optional(),
14248
+ timezone: zod.z.string().optional()
14249
+ }).optional()
14189
14250
  }),
14190
14251
  zod.z.object({
14191
14252
  type: zod.z.literal("tag"),
@@ -14920,6 +14981,7 @@ async function tryEnterFlow(flow, event, ctx) {
14920
14981
  flowSlug: flow.slug,
14921
14982
  flowVersion: flow.version,
14922
14983
  emailAtEntry: sub.emailAtSubscribe,
14984
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
14923
14985
  enteredAt: /* @__PURE__ */ new Date(),
14924
14986
  status: "active",
14925
14987
  currentStepIndex: 0,
@@ -15017,6 +15079,105 @@ function effectiveLowerBound(ctx, opts) {
15017
15079
  }
15018
15080
  return null;
15019
15081
  }
15082
+
15083
+ // src/server/runner/delivery-window.ts
15084
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
15085
+ function computeDeliveryTime(now, window2, contactTimezone) {
15086
+ const tz = pickTimezone(window2, contactTimezone);
15087
+ let candidate = now;
15088
+ if (window2.timeOfDay) {
15089
+ const [hh, mm] = window2.timeOfDay.split(":").map(Number);
15090
+ const local = localParts(candidate, tz);
15091
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
15092
+ if (candidate.getTime() < todaySlot.getTime()) {
15093
+ candidate = todaySlot;
15094
+ } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
15095
+ const next = addLocalDays(local, 1);
15096
+ candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
15097
+ }
15098
+ }
15099
+ if (window2.weekdaysOnly) {
15100
+ for (let guard = 0; guard < 3; guard++) {
15101
+ const local = localParts(candidate, tz);
15102
+ if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
15103
+ const shift = local.weekday === "Sat" ? 2 : 1;
15104
+ const moved = addLocalDays(local, shift);
15105
+ candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
15106
+ }
15107
+ }
15108
+ return candidate;
15109
+ }
15110
+ function pickTimezone(window2, contactTimezone) {
15111
+ const candidates = [
15112
+ window2.useContactTimezone ? contactTimezone : void 0,
15113
+ window2.timezone,
15114
+ "UTC"
15115
+ ];
15116
+ for (const tz of candidates) {
15117
+ if (tz && isValidTimezone(tz)) return tz;
15118
+ }
15119
+ return "UTC";
15120
+ }
15121
+ var validatedZones = /* @__PURE__ */ new Map();
15122
+ function isValidTimezone(tz) {
15123
+ const cached = validatedZones.get(tz);
15124
+ if (cached !== void 0) return cached;
15125
+ let ok = true;
15126
+ try {
15127
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
15128
+ } catch {
15129
+ ok = false;
15130
+ }
15131
+ validatedZones.set(tz, ok);
15132
+ return ok;
15133
+ }
15134
+ var partFormatters = /* @__PURE__ */ new Map();
15135
+ function formatterFor(tz) {
15136
+ let f = partFormatters.get(tz);
15137
+ if (!f) {
15138
+ f = new Intl.DateTimeFormat("en-US", {
15139
+ timeZone: tz,
15140
+ year: "numeric",
15141
+ month: "2-digit",
15142
+ day: "2-digit",
15143
+ hour: "2-digit",
15144
+ minute: "2-digit",
15145
+ second: "2-digit",
15146
+ weekday: "short",
15147
+ hour12: false
15148
+ });
15149
+ partFormatters.set(tz, f);
15150
+ }
15151
+ return f;
15152
+ }
15153
+ function localParts(date, tz) {
15154
+ const parts = {};
15155
+ for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
15156
+ return {
15157
+ y: Number(parts.year),
15158
+ mo: Number(parts.month),
15159
+ d: Number(parts.day),
15160
+ hh: Number(parts.hour) % 24,
15161
+ // Intl emits '24' for midnight in some locales
15162
+ mi: Number(parts.minute),
15163
+ ss: Number(parts.second),
15164
+ weekday: parts.weekday
15165
+ };
15166
+ }
15167
+ function utcFromLocal(y, mo, d, hh, mi, tz) {
15168
+ let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
15169
+ for (let i = 0; i < 2; i++) {
15170
+ const p = localParts(new Date(ts), tz);
15171
+ const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
15172
+ const offset = asUtc - ts;
15173
+ ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
15174
+ }
15175
+ return new Date(ts);
15176
+ }
15177
+ function addLocalDays(p, days) {
15178
+ const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
15179
+ return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
15180
+ }
15020
15181
  async function compileTemplate(mjml) {
15021
15182
  const out = await mjml2html__default.default(mjml, { validationLevel: "soft", minify: false });
15022
15183
  const plainText = derivePlaintext(out.html);
@@ -15134,6 +15295,9 @@ function makeHandlebars(extra) {
15134
15295
  return hb;
15135
15296
  }
15136
15297
 
15298
+ // src/server/runner/send.ts
15299
+ init_vars();
15300
+
15137
15301
  // src/server/runner/suppression.ts
15138
15302
  var SCOPES_BY_KIND = {
15139
15303
  marketing: ["all", "marketing"],
@@ -15414,13 +15578,29 @@ async function dispatchSend(sendId, ctx) {
15414
15578
  return;
15415
15579
  }
15416
15580
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
15417
- const renderCtx = buildRenderContext(
15418
- contact,
15419
- run,
15420
- send.vars ?? {},
15421
- ctx
15422
- );
15423
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15581
+ if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
15582
+ await ctx.collections.sends.updateOne(
15583
+ { _id: send._id },
15584
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
15585
+ );
15586
+ return;
15587
+ }
15588
+ let renderCtx;
15589
+ let rendered;
15590
+ try {
15591
+ const resolved = await resolveVars(ctx.varsAdapter, contact, {
15592
+ reason: "send",
15593
+ templateSlug: template.slug,
15594
+ flowSlug: run?.flowSlug,
15595
+ eventName: run?.triggerEvent?.name,
15596
+ eventProperties: run?.triggerEvent?.properties
15597
+ });
15598
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
15599
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
15600
+ } catch (err) {
15601
+ await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
15602
+ throw err;
15603
+ }
15424
15604
  const tracking = applyTracking(rendered.html, {
15425
15605
  sendId: String(send._id),
15426
15606
  publicUrl: ctx.config.publicUrl,
@@ -15496,7 +15676,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
15496
15676
  }
15497
15677
  return ctx.config.defaultProvider;
15498
15678
  }
15499
- function buildRenderContext(contact, run, vars, ctx) {
15679
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
15500
15680
  const scope = "marketing";
15501
15681
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
15502
15682
  const token = signUnsubscribeToken(
@@ -15505,8 +15685,10 @@ function buildRenderContext(contact, run, vars, ctx) {
15505
15685
  );
15506
15686
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
15507
15687
  return {
15688
+ ...resolved,
15508
15689
  contact,
15509
15690
  vars,
15691
+ event: run?.triggerEvent?.properties ?? {},
15510
15692
  unsubscribeUrl,
15511
15693
  senderAddress: ctx.config.senderAddress
15512
15694
  };
@@ -15591,8 +15773,15 @@ async function processOneRunStep(runId, ctx) {
15591
15773
  return handleCondition(run, step, contact, ctx);
15592
15774
  case "branch":
15593
15775
  return handleBranch(run, step, contact, ctx);
15594
- case "send":
15776
+ case "send": {
15777
+ if (step.delivery) {
15778
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
15779
+ if (deliverAt.getTime() > Date.now() + 3e4) {
15780
+ return deferSendForWindow(run, deliverAt, ctx);
15781
+ }
15782
+ }
15595
15783
  return handleSend(run, step, contact, flow, ctx);
15784
+ }
15596
15785
  case "tag":
15597
15786
  return handleTag(run, step, ctx);
15598
15787
  case "fire_event":
@@ -15743,6 +15932,34 @@ async function handleWebhookStep(run, step, ctx) {
15743
15932
  }
15744
15933
  }
15745
15934
  }
15935
+ async function deferSendForWindow(run, deliverAt, ctx) {
15936
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
15937
+ // Only write once per deferral — if nextActionAt already points at (or
15938
+ // past) the slot, another worker/tick got here first.
15939
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
15940
+ {
15941
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
15942
+ $push: {
15943
+ history: {
15944
+ stepIndex: run.currentStepIndex,
15945
+ action: "send_deferred",
15946
+ at: /* @__PURE__ */ new Date(),
15947
+ details: { until: deliverAt }
15948
+ }
15949
+ }
15950
+ },
15951
+ { returnDocument: "after" }
15952
+ );
15953
+ if (!updated) return;
15954
+ await ctx.queues.advance.add(
15955
+ "advance",
15956
+ { flowRunId: String(run._id) },
15957
+ {
15958
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
15959
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
15960
+ }
15961
+ );
15962
+ }
15746
15963
  async function advanceStep(run, ctx, log, opts = {}) {
15747
15964
  const stepInc = opts.stepInc ?? 1;
15748
15965
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -16995,6 +17212,7 @@ var Mailer = class _Mailer {
16995
17212
  db: this.db,
16996
17213
  collections: this.collections,
16997
17214
  adapter: this.adapter,
17215
+ varsAdapter: this.config.varsAdapter,
16998
17216
  providers: this.providers,
16999
17217
  queues: this.queues,
17000
17218
  config: this.config,
@@ -17074,6 +17292,10 @@ var Mailer = class _Mailer {
17074
17292
  if (!config.providers[config.defaultProvider]) {
17075
17293
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
17076
17294
  }
17295
+ if (config.varsAdapter) {
17296
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
17297
+ assertNoReservedVarKeys2(config.varsAdapter);
17298
+ }
17077
17299
  const collections = getCollections(config.db, config.collectionPrefix);
17078
17300
  await ensureIndexes(config.db, config.collectionPrefix);
17079
17301
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -17289,6 +17511,72 @@ var Mailer = class _Mailer {
17289
17511
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
17290
17512
  }
17291
17513
  }
17514
+ // -------------------------------------------------------------------------
17515
+ // Flow abort
17516
+ // -------------------------------------------------------------------------
17517
+ /**
17518
+ * Abort every active run of one flow for a contact, immediately. Runs parked
17519
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
17520
+ * no-op. Also cancels any of the flow's emails still sitting in the send
17521
+ * queue for this contact (queued or awaiting retry), so an abort means no
17522
+ * further mail, not just no further steps.
17523
+ *
17524
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
17525
+ * same handler that processes the business event ("user upgraded").
17526
+ */
17527
+ async abortFlow(flowSlug, externalId, opts = {}) {
17528
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
17529
+ const flow = await this.collections.flows.findOne(
17530
+ { slug: parsed.flowSlug },
17531
+ { projection: { _id: 1 } }
17532
+ );
17533
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
17534
+ const result = await this.abortActiveRuns(
17535
+ { externalId: parsed.externalId, flowId: flow._id },
17536
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17537
+ );
17538
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17539
+ await this.audit({
17540
+ actor: "host",
17541
+ action: "flow.abort",
17542
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
17543
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17544
+ });
17545
+ }
17546
+ return result;
17547
+ }
17548
+ /**
17549
+ * Abort every active flow run for a contact across all flows. Same semantics
17550
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
17551
+ */
17552
+ async abortAllFlows(externalId, opts = {}) {
17553
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
17554
+ const result = await this.abortActiveRuns(
17555
+ { externalId: parsed.externalId },
17556
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17557
+ );
17558
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
17559
+ await this.audit({
17560
+ actor: "host",
17561
+ action: "flow.abort_all",
17562
+ resource: { collection: "mailer_flow_runs" },
17563
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17564
+ });
17565
+ }
17566
+ return result;
17567
+ }
17568
+ async abortActiveRuns(filter, exitReason) {
17569
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
17570
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
17571
+ for (const run of runs) {
17572
+ await exitFlowRun(run, exitReason, this.runnerContext);
17573
+ }
17574
+ const cancelled = await this.collections.sends.updateMany(
17575
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
17576
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
17577
+ );
17578
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
17579
+ }
17292
17580
  /**
17293
17581
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
17294
17582
  * suppression row to block re-import. INVARIANT 9.