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/index.js CHANGED
@@ -1,7 +1,7 @@
1
+ import { z } from 'zod';
1
2
  import { ObjectId } from 'mongodb';
2
3
  import crypto2 from 'crypto';
3
4
  import sgMail from '@sendgrid/mail';
4
- import { z } from 'zod';
5
5
  import IORedis from 'ioredis';
6
6
  import Handlebars from 'handlebars';
7
7
  import { convert } from 'html-to-text';
@@ -31,6 +31,55 @@ var __export = (target, all) => {
31
31
  __defProp(target, name, { get: all[name], enumerable: true });
32
32
  };
33
33
 
34
+ // src/server/adapters/vars.ts
35
+ var vars_exports = {};
36
+ __export(vars_exports, {
37
+ RESERVED_VAR_KEYS: () => RESERVED_VAR_KEYS,
38
+ assertNoReservedVarKeys: () => assertNoReservedVarKeys,
39
+ defineVars: () => defineVars,
40
+ resolveVars: () => resolveVars,
41
+ varsJsonSchema: () => varsJsonSchema
42
+ });
43
+ function defineVars(adapter) {
44
+ return adapter;
45
+ }
46
+ function assertNoReservedVarKeys(adapter) {
47
+ const json = varsJsonSchema(adapter);
48
+ const props = json && typeof json === "object" ? json.properties : void 0;
49
+ if (!props) return;
50
+ const clashes = RESERVED_VAR_KEYS.filter((k) => k in props);
51
+ if (clashes.length > 0) {
52
+ throw new Error(
53
+ `varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
54
+ );
55
+ }
56
+ }
57
+ function varsJsonSchema(adapter) {
58
+ return z.toJSONSchema(adapter.schema, { io: "output" });
59
+ }
60
+ async function resolveVars(adapter, contact, info) {
61
+ if (!adapter) return {};
62
+ const resolved = await adapter.resolve(contact, info);
63
+ if (!resolved || typeof resolved !== "object") return {};
64
+ const out = { ...resolved };
65
+ for (const k of RESERVED_VAR_KEYS) delete out[k];
66
+ return out;
67
+ }
68
+ var RESERVED_VAR_KEYS;
69
+ var init_vars = __esm({
70
+ "src/server/adapters/vars.ts"() {
71
+ RESERVED_VAR_KEYS = [
72
+ "contact",
73
+ "vars",
74
+ "event",
75
+ "unsubscribeUrl",
76
+ "viewInBrowserUrl",
77
+ "preferenceCenterUrl",
78
+ "senderAddress"
79
+ ];
80
+ }
81
+ });
82
+
34
83
  // src/server/adapters/mongo.ts
35
84
  var mongo_exports = {};
36
85
  __export(mongo_exports, {
@@ -344,6 +393,12 @@ var tagInputSchema = z.object({
344
393
  externalId: externalIdSchema,
345
394
  tag: z.string().min(1).max(128)
346
395
  });
396
+ var abortFlowInputSchema = z.object({
397
+ flowSlug: slugSchema,
398
+ externalId: externalIdSchema,
399
+ reason: z.string().min(1).max(200).optional()
400
+ });
401
+ var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
347
402
  var sendOneOffInputSchema = z.object({
348
403
  templateSlug: slugSchema,
349
404
  externalId: externalIdSchema,
@@ -373,7 +428,13 @@ var flowStepSchema = z.lazy(
373
428
  type: z.literal("send"),
374
429
  templateSlug: slugSchema,
375
430
  providerOverride: z.string().optional(),
376
- vars: z.record(z.string(), z.unknown()).optional()
431
+ vars: z.record(z.string(), z.unknown()).optional(),
432
+ delivery: z.object({
433
+ weekdaysOnly: z.boolean().optional(),
434
+ timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
435
+ useContactTimezone: z.boolean().optional(),
436
+ timezone: z.string().optional()
437
+ }).optional()
377
438
  }),
378
439
  z.object({
379
440
  type: z.literal("tag"),
@@ -1163,6 +1224,7 @@ async function tryEnterFlow(flow, event, ctx) {
1163
1224
  flowSlug: flow.slug,
1164
1225
  flowVersion: flow.version,
1165
1226
  emailAtEntry: sub.emailAtSubscribe,
1227
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
1166
1228
  enteredAt: /* @__PURE__ */ new Date(),
1167
1229
  status: "active",
1168
1230
  currentStepIndex: 0,
@@ -1260,6 +1322,105 @@ function effectiveLowerBound(ctx, opts) {
1260
1322
  }
1261
1323
  return null;
1262
1324
  }
1325
+
1326
+ // src/server/runner/delivery-window.ts
1327
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
1328
+ function computeDeliveryTime(now, window, contactTimezone) {
1329
+ const tz = pickTimezone(window, contactTimezone);
1330
+ let candidate = now;
1331
+ if (window.timeOfDay) {
1332
+ const [hh, mm] = window.timeOfDay.split(":").map(Number);
1333
+ const local = localParts(candidate, tz);
1334
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
1335
+ if (candidate.getTime() < todaySlot.getTime()) {
1336
+ candidate = todaySlot;
1337
+ } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
1338
+ const next = addLocalDays(local, 1);
1339
+ candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
1340
+ }
1341
+ }
1342
+ if (window.weekdaysOnly) {
1343
+ for (let guard = 0; guard < 3; guard++) {
1344
+ const local = localParts(candidate, tz);
1345
+ if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
1346
+ const shift = local.weekday === "Sat" ? 2 : 1;
1347
+ const moved = addLocalDays(local, shift);
1348
+ candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
1349
+ }
1350
+ }
1351
+ return candidate;
1352
+ }
1353
+ function pickTimezone(window, contactTimezone) {
1354
+ const candidates = [
1355
+ window.useContactTimezone ? contactTimezone : void 0,
1356
+ window.timezone,
1357
+ "UTC"
1358
+ ];
1359
+ for (const tz of candidates) {
1360
+ if (tz && isValidTimezone(tz)) return tz;
1361
+ }
1362
+ return "UTC";
1363
+ }
1364
+ var validatedZones = /* @__PURE__ */ new Map();
1365
+ function isValidTimezone(tz) {
1366
+ const cached = validatedZones.get(tz);
1367
+ if (cached !== void 0) return cached;
1368
+ let ok = true;
1369
+ try {
1370
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
1371
+ } catch {
1372
+ ok = false;
1373
+ }
1374
+ validatedZones.set(tz, ok);
1375
+ return ok;
1376
+ }
1377
+ var partFormatters = /* @__PURE__ */ new Map();
1378
+ function formatterFor(tz) {
1379
+ let f = partFormatters.get(tz);
1380
+ if (!f) {
1381
+ f = new Intl.DateTimeFormat("en-US", {
1382
+ timeZone: tz,
1383
+ year: "numeric",
1384
+ month: "2-digit",
1385
+ day: "2-digit",
1386
+ hour: "2-digit",
1387
+ minute: "2-digit",
1388
+ second: "2-digit",
1389
+ weekday: "short",
1390
+ hour12: false
1391
+ });
1392
+ partFormatters.set(tz, f);
1393
+ }
1394
+ return f;
1395
+ }
1396
+ function localParts(date, tz) {
1397
+ const parts = {};
1398
+ for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
1399
+ return {
1400
+ y: Number(parts.year),
1401
+ mo: Number(parts.month),
1402
+ d: Number(parts.day),
1403
+ hh: Number(parts.hour) % 24,
1404
+ // Intl emits '24' for midnight in some locales
1405
+ mi: Number(parts.minute),
1406
+ ss: Number(parts.second),
1407
+ weekday: parts.weekday
1408
+ };
1409
+ }
1410
+ function utcFromLocal(y, mo, d, hh, mi, tz) {
1411
+ let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
1412
+ for (let i = 0; i < 2; i++) {
1413
+ const p = localParts(new Date(ts), tz);
1414
+ const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
1415
+ const offset = asUtc - ts;
1416
+ ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
1417
+ }
1418
+ return new Date(ts);
1419
+ }
1420
+ function addLocalDays(p, days) {
1421
+ const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
1422
+ return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
1423
+ }
1263
1424
  async function compileTemplate(mjml) {
1264
1425
  const out = await mjml2html(mjml, { validationLevel: "soft", minify: false });
1265
1426
  const plainText = derivePlaintext(out.html);
@@ -1383,6 +1544,9 @@ function makeHandlebars(extra) {
1383
1544
  return hb;
1384
1545
  }
1385
1546
 
1547
+ // src/server/runner/send.ts
1548
+ init_vars();
1549
+
1386
1550
  // src/server/runner/suppression.ts
1387
1551
  var SCOPES_BY_KIND = {
1388
1552
  marketing: ["all", "marketing"],
@@ -1701,13 +1865,29 @@ async function dispatchSend(sendId, ctx) {
1701
1865
  return;
1702
1866
  }
1703
1867
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
1704
- const renderCtx = buildRenderContext(
1705
- contact,
1706
- run,
1707
- send.vars ?? {},
1708
- ctx
1709
- );
1710
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1868
+ if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
1869
+ await ctx.collections.sends.updateOne(
1870
+ { _id: send._id },
1871
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
1872
+ );
1873
+ return;
1874
+ }
1875
+ let renderCtx;
1876
+ let rendered;
1877
+ try {
1878
+ const resolved = await resolveVars(ctx.varsAdapter, contact, {
1879
+ reason: "send",
1880
+ templateSlug: template.slug,
1881
+ flowSlug: run?.flowSlug,
1882
+ eventName: run?.triggerEvent?.name,
1883
+ eventProperties: run?.triggerEvent?.properties
1884
+ });
1885
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
1886
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1887
+ } catch (err) {
1888
+ await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
1889
+ throw err;
1890
+ }
1711
1891
  const tracking = applyTracking(rendered.html, {
1712
1892
  sendId: String(send._id),
1713
1893
  publicUrl: ctx.config.publicUrl,
@@ -1783,7 +1963,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
1783
1963
  }
1784
1964
  return ctx.config.defaultProvider;
1785
1965
  }
1786
- function buildRenderContext(contact, run, vars, ctx) {
1966
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
1787
1967
  const scope = "marketing";
1788
1968
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
1789
1969
  const token = signUnsubscribeToken(
@@ -1792,8 +1972,10 @@ function buildRenderContext(contact, run, vars, ctx) {
1792
1972
  );
1793
1973
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
1794
1974
  return {
1975
+ ...resolved,
1795
1976
  contact,
1796
1977
  vars,
1978
+ event: run?.triggerEvent?.properties ?? {},
1797
1979
  unsubscribeUrl,
1798
1980
  senderAddress: ctx.config.senderAddress
1799
1981
  };
@@ -1878,8 +2060,15 @@ async function processOneRunStep(runId, ctx) {
1878
2060
  return handleCondition(run, step, contact, ctx);
1879
2061
  case "branch":
1880
2062
  return handleBranch(run, step, contact, ctx);
1881
- case "send":
2063
+ case "send": {
2064
+ if (step.delivery) {
2065
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
2066
+ if (deliverAt.getTime() > Date.now() + 3e4) {
2067
+ return deferSendForWindow(run, deliverAt, ctx);
2068
+ }
2069
+ }
1882
2070
  return handleSend(run, step, contact, flow, ctx);
2071
+ }
1883
2072
  case "tag":
1884
2073
  return handleTag(run, step, ctx);
1885
2074
  case "fire_event":
@@ -2030,6 +2219,34 @@ async function handleWebhookStep(run, step, ctx) {
2030
2219
  }
2031
2220
  }
2032
2221
  }
2222
+ async function deferSendForWindow(run, deliverAt, ctx) {
2223
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
2224
+ // Only write once per deferral — if nextActionAt already points at (or
2225
+ // past) the slot, another worker/tick got here first.
2226
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
2227
+ {
2228
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
2229
+ $push: {
2230
+ history: {
2231
+ stepIndex: run.currentStepIndex,
2232
+ action: "send_deferred",
2233
+ at: /* @__PURE__ */ new Date(),
2234
+ details: { until: deliverAt }
2235
+ }
2236
+ }
2237
+ },
2238
+ { returnDocument: "after" }
2239
+ );
2240
+ if (!updated) return;
2241
+ await ctx.queues.advance.add(
2242
+ "advance",
2243
+ { flowRunId: String(run._id) },
2244
+ {
2245
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
2246
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
2247
+ }
2248
+ );
2249
+ }
2033
2250
  async function advanceStep(run, ctx, log, opts = {}) {
2034
2251
  const stepInc = opts.stepInc ?? 1;
2035
2252
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -3554,6 +3771,7 @@ var Mailer = class _Mailer {
3554
3771
  db: this.db,
3555
3772
  collections: this.collections,
3556
3773
  adapter: this.adapter,
3774
+ varsAdapter: this.config.varsAdapter,
3557
3775
  providers: this.providers,
3558
3776
  queues: this.queues,
3559
3777
  config: this.config,
@@ -3633,6 +3851,10 @@ var Mailer = class _Mailer {
3633
3851
  if (!config.providers[config.defaultProvider]) {
3634
3852
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
3635
3853
  }
3854
+ if (config.varsAdapter) {
3855
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
3856
+ assertNoReservedVarKeys2(config.varsAdapter);
3857
+ }
3636
3858
  const collections = getCollections(config.db, config.collectionPrefix);
3637
3859
  await ensureIndexes(config.db, config.collectionPrefix);
3638
3860
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -3848,6 +4070,72 @@ var Mailer = class _Mailer {
3848
4070
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
3849
4071
  }
3850
4072
  }
4073
+ // -------------------------------------------------------------------------
4074
+ // Flow abort
4075
+ // -------------------------------------------------------------------------
4076
+ /**
4077
+ * Abort every active run of one flow for a contact, immediately. Runs parked
4078
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
4079
+ * no-op. Also cancels any of the flow's emails still sitting in the send
4080
+ * queue for this contact (queued or awaiting retry), so an abort means no
4081
+ * further mail, not just no further steps.
4082
+ *
4083
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
4084
+ * same handler that processes the business event ("user upgraded").
4085
+ */
4086
+ async abortFlow(flowSlug, externalId, opts = {}) {
4087
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
4088
+ const flow = await this.collections.flows.findOne(
4089
+ { slug: parsed.flowSlug },
4090
+ { projection: { _id: 1 } }
4091
+ );
4092
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
4093
+ const result = await this.abortActiveRuns(
4094
+ { externalId: parsed.externalId, flowId: flow._id },
4095
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4096
+ );
4097
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4098
+ await this.audit({
4099
+ actor: "host",
4100
+ action: "flow.abort",
4101
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
4102
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4103
+ });
4104
+ }
4105
+ return result;
4106
+ }
4107
+ /**
4108
+ * Abort every active flow run for a contact across all flows. Same semantics
4109
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
4110
+ */
4111
+ async abortAllFlows(externalId, opts = {}) {
4112
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
4113
+ const result = await this.abortActiveRuns(
4114
+ { externalId: parsed.externalId },
4115
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4116
+ );
4117
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4118
+ await this.audit({
4119
+ actor: "host",
4120
+ action: "flow.abort_all",
4121
+ resource: { collection: "mailer_flow_runs" },
4122
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4123
+ });
4124
+ }
4125
+ return result;
4126
+ }
4127
+ async abortActiveRuns(filter, exitReason) {
4128
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
4129
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
4130
+ for (const run of runs) {
4131
+ await exitFlowRun(run, exitReason, this.runnerContext);
4132
+ }
4133
+ const cancelled = await this.collections.sends.updateMany(
4134
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
4135
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
4136
+ );
4137
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
4138
+ }
3851
4139
  /**
3852
4140
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
3853
4141
  * suppression row to block re-import. INVARIANT 9.
@@ -4050,6 +4338,7 @@ var Mailer = class _Mailer {
4050
4338
 
4051
4339
  // src/server/index.ts
4052
4340
  init_mongo();
4341
+ init_vars();
4053
4342
 
4054
4343
  // src/server/providers/null.ts
4055
4344
  var counter = 0;
@@ -4188,6 +4477,18 @@ ${input.plainText}`);
4188
4477
  hint: "Front-load the important words so the truncated preview still makes sense."
4189
4478
  });
4190
4479
  }
4480
+ if (config.varsJsonSchema) {
4481
+ const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
4482
+ const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
4483
+ if (unknown.length > 0) {
4484
+ issues.push({
4485
+ rule: "unknown_variable",
4486
+ severity: "warning",
4487
+ message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
4488
+ hint: "These render as empty strings. Check for typos, or add the key to your varsAdapter schema. Paths inside {{#each}}/{{#with}} blocks are relative and not checked."
4489
+ });
4490
+ }
4491
+ }
4191
4492
  const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
4192
4493
  if (linkCount > 10) {
4193
4494
  issues.push({
@@ -4250,6 +4551,60 @@ function findSpamSignals(text) {
4250
4551
  if (/!{3,}/.test(text)) out.add('excessive "!!!"');
4251
4552
  return Array.from(out);
4252
4553
  }
4554
+ var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
4555
+ "contact",
4556
+ "vars",
4557
+ "event",
4558
+ "unsubscribeUrl",
4559
+ "viewInBrowserUrl",
4560
+ "preferenceCenterUrl",
4561
+ "senderAddress",
4562
+ "this"
4563
+ ]);
4564
+ var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
4565
+ function findUnknownVariables(source, schema) {
4566
+ const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
4567
+ const unknown = /* @__PURE__ */ new Set();
4568
+ const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
4569
+ let m;
4570
+ while (m = re.exec(source)) {
4571
+ const expr = m[1].trim();
4572
+ if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
4573
+ if (expr.includes("(")) continue;
4574
+ const parts = expr.split(/\s+/);
4575
+ const candidates = parts.length > 1 ? parts.slice(1) : parts;
4576
+ for (const raw of candidates) {
4577
+ if (!PATH_TOKEN.test(raw)) continue;
4578
+ if (raw.startsWith("@")) continue;
4579
+ const segments = raw.split(".");
4580
+ if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
4581
+ if (hasBlockScopes && segments.length === 1) continue;
4582
+ if (!schemaHasPath(schema, segments)) unknown.add(raw);
4583
+ }
4584
+ }
4585
+ return Array.from(unknown);
4586
+ }
4587
+ function schemaHasPath(node, segments) {
4588
+ if (segments.length === 0) return true;
4589
+ if (!node || typeof node !== "object") return true;
4590
+ const n = node;
4591
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
4592
+ if (Array.isArray(n[key])) {
4593
+ return n[key].some((branch) => schemaHasPath(branch, segments));
4594
+ }
4595
+ }
4596
+ if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
4597
+ if (n.type === "array") {
4598
+ if (segments[0] === "length") return true;
4599
+ return schemaHasPath(n.items, segments);
4600
+ }
4601
+ const props = n.properties;
4602
+ if (!props || typeof props !== "object") return true;
4603
+ const [head, ...rest] = segments;
4604
+ if (head in props) return schemaHasPath(props[head], rest);
4605
+ if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
4606
+ return true;
4607
+ }
4253
4608
  function isAllCaps(subject) {
4254
4609
  const letters = subject.replace(/[^a-zA-Z]/g, "");
4255
4610
  if (letters.length < 5) return false;
@@ -4257,6 +4612,9 @@ function isAllCaps(subject) {
4257
4612
  return upper / letters.length > 0.5;
4258
4613
  }
4259
4614
 
4615
+ // src/server/api/admin.ts
4616
+ init_vars();
4617
+
4260
4618
  // src/server/api/setup-status.ts
4261
4619
  async function runSetupChecks(mailer) {
4262
4620
  const checks = [];
@@ -5036,12 +5394,19 @@ function createAdminRouter(mailer, opts = {}) {
5036
5394
  function apiRouter(mailer, opts = {}) {
5037
5395
  const r = Router();
5038
5396
  const c = mailer.collections;
5397
+ const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
5039
5398
  function getMailTesterClient() {
5040
5399
  if (opts.mailTesterClient) return opts.mailTesterClient;
5041
5400
  const cfg = mailer.config.mailTester;
5042
5401
  if (!cfg?.apiKey) return null;
5043
5402
  return createMailTesterClient(cfg);
5044
5403
  }
5404
+ r.get(
5405
+ "/vars-schema",
5406
+ asyncHandler(async (_req, res) => {
5407
+ res.json({ schema: varsSchema, builtins: RESERVED_VAR_KEYS });
5408
+ })
5409
+ );
5045
5410
  r.get(
5046
5411
  "/me",
5047
5412
  asyncHandler(async (req, res) => {
@@ -6128,9 +6493,11 @@ function apiRouter(mailer, opts = {}) {
6128
6493
  compileFailed: true
6129
6494
  });
6130
6495
  }
6496
+ if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
6497
+ if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
6131
6498
  const lint = lintTemplate(
6132
6499
  { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
6133
- { senderDomains: mailer.config.senderDomains }
6500
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6134
6501
  );
6135
6502
  res.json({ ...lint, compileFailed: false });
6136
6503
  })
@@ -6181,7 +6548,7 @@ function apiRouter(mailer, opts = {}) {
6181
6548
  kind: tpl.kind,
6182
6549
  fromEmail: tpl.fromEmail
6183
6550
  },
6184
- { senderDomains: mailer.config.senderDomains }
6551
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6185
6552
  );
6186
6553
  if (lint.errors.length > 0) {
6187
6554
  return res.status(422).json({
@@ -6273,40 +6640,93 @@ function apiRouter(mailer, opts = {}) {
6273
6640
  html = tpl.body.html;
6274
6641
  plainText = tpl.body.plainText;
6275
6642
  }
6276
- const sampleContact = req.body?.sampleContact ?? {
6277
- externalId: "preview-contact",
6278
- email: "preview@example.com",
6279
- tags: [],
6280
- fields: { firstName: "Alex" }
6281
- };
6643
+ let contact;
6644
+ const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
6645
+ if (contactId) {
6646
+ const found = await mailer.adapter.getById(contactId);
6647
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6648
+ contact = found;
6649
+ } else {
6650
+ contact = req.body?.sampleContact ?? {
6651
+ externalId: "preview-contact",
6652
+ email: "preview@example.com",
6653
+ tags: [],
6654
+ fields: { firstName: "Alex" }
6655
+ };
6656
+ }
6657
+ const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
6658
+ let resolved = {};
6659
+ try {
6660
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6661
+ reason: "preview",
6662
+ templateSlug: tpl.slug,
6663
+ eventProperties
6664
+ });
6665
+ } catch (err) {
6666
+ return res.status(502).json({
6667
+ error: "vars_resolve_failed",
6668
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6669
+ });
6670
+ }
6282
6671
  const renderCtx = {
6283
- contact: sampleContact,
6672
+ ...resolved,
6673
+ contact,
6284
6674
  vars: req.body?.vars ?? {},
6675
+ event: eventProperties ?? {},
6285
6676
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
6286
6677
  senderAddress: mailer.config.senderAddress
6287
6678
  };
6288
6679
  const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
6289
6680
  const rendered = await renderTemplate(previewTpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
6290
- return res.json({ subject: rendered.subject, preheader: rendered.preheader, html: rendered.html, plainText: rendered.plainText });
6681
+ return res.json({
6682
+ subject: rendered.subject,
6683
+ preheader: rendered.preheader,
6684
+ html: rendered.html,
6685
+ plainText: rendered.plainText,
6686
+ contact: { externalId: contact.externalId, email: contact.email }
6687
+ });
6291
6688
  })
6292
6689
  );
6293
6690
  r.post(
6294
6691
  "/templates/:slug/send-test",
6295
6692
  asyncHandler(async (req, res) => {
6296
- const { to, sampleData } = req.body ?? {};
6693
+ const { to, sampleData, contactId, eventProperties } = req.body ?? {};
6297
6694
  if (!to) return res.status(400).json({ error: "to_required" });
6298
6695
  const tpl = await c.templates.findOne({ slug: req.params.slug });
6299
6696
  if (!tpl) return res.status(404).json({ error: "not_found" });
6300
- const contact = sampleData?.contact ?? {
6301
- externalId: "test-recipient",
6302
- email: to,
6303
- tags: [],
6304
- fields: { firstName: "Test" }
6305
- };
6306
- contact.email = to;
6697
+ let contact;
6698
+ if (typeof contactId === "string" && contactId) {
6699
+ const found = await mailer.adapter.getById(contactId);
6700
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6701
+ contact = { ...found, email: to };
6702
+ } else {
6703
+ contact = sampleData?.contact ?? {
6704
+ externalId: "test-recipient",
6705
+ email: to,
6706
+ tags: [],
6707
+ fields: { firstName: "Test" }
6708
+ };
6709
+ contact.email = to;
6710
+ }
6711
+ const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
6712
+ let resolved = {};
6713
+ try {
6714
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6715
+ reason: "test",
6716
+ templateSlug: tpl.slug,
6717
+ eventProperties: evProps
6718
+ });
6719
+ } catch (err) {
6720
+ return res.status(502).json({
6721
+ error: "vars_resolve_failed",
6722
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6723
+ });
6724
+ }
6307
6725
  const renderCtx = {
6726
+ ...resolved,
6308
6727
  contact,
6309
6728
  vars: sampleData?.vars ?? {},
6729
+ event: evProps ?? {},
6310
6730
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
6311
6731
  senderAddress: mailer.config.senderAddress
6312
6732
  };
@@ -6957,8 +7377,8 @@ var DEDUPE_POLICIES = [
6957
7377
  ];
6958
7378
 
6959
7379
  // src/server/index.ts
6960
- var VERSION = "0.1.0";
7380
+ var VERSION = "0.7.0";
6961
7381
 
6962
- export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, verifyUnsubscribeToken };
7382
+ export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, RESERVED_VAR_KEYS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, computeDeliveryTime, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, defineVars, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, varsJsonSchema, verifyUnsubscribeToken };
6963
7383
  //# sourceMappingURL=index.js.map
6964
7384
  //# sourceMappingURL=index.js.map