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.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');
@@ -49,6 +49,55 @@ var __export = (target, all) => {
49
49
  __defProp(target, name, { get: all[name], enumerable: true });
50
50
  };
51
51
 
52
+ // src/server/adapters/vars.ts
53
+ var vars_exports = {};
54
+ __export(vars_exports, {
55
+ RESERVED_VAR_KEYS: () => exports.RESERVED_VAR_KEYS,
56
+ assertNoReservedVarKeys: () => assertNoReservedVarKeys,
57
+ defineVars: () => defineVars,
58
+ resolveVars: () => resolveVars,
59
+ varsJsonSchema: () => varsJsonSchema
60
+ });
61
+ function defineVars(adapter) {
62
+ return adapter;
63
+ }
64
+ function assertNoReservedVarKeys(adapter) {
65
+ const json = varsJsonSchema(adapter);
66
+ const props = json && typeof json === "object" ? json.properties : void 0;
67
+ if (!props) return;
68
+ const clashes = exports.RESERVED_VAR_KEYS.filter((k) => k in props);
69
+ if (clashes.length > 0) {
70
+ throw new Error(
71
+ `varsAdapter schema declares reserved key(s): ${clashes.join(", ")}. These names are provided by mailery itself \u2014 rename them in your schema.`
72
+ );
73
+ }
74
+ }
75
+ function varsJsonSchema(adapter) {
76
+ return zod.z.toJSONSchema(adapter.schema, { io: "output" });
77
+ }
78
+ async function resolveVars(adapter, contact, info) {
79
+ if (!adapter) return {};
80
+ const resolved = await adapter.resolve(contact, info);
81
+ if (!resolved || typeof resolved !== "object") return {};
82
+ const out = { ...resolved };
83
+ for (const k of exports.RESERVED_VAR_KEYS) delete out[k];
84
+ return out;
85
+ }
86
+ exports.RESERVED_VAR_KEYS = void 0;
87
+ var init_vars = __esm({
88
+ "src/server/adapters/vars.ts"() {
89
+ exports.RESERVED_VAR_KEYS = [
90
+ "contact",
91
+ "vars",
92
+ "event",
93
+ "unsubscribeUrl",
94
+ "viewInBrowserUrl",
95
+ "preferenceCenterUrl",
96
+ "senderAddress"
97
+ ];
98
+ }
99
+ });
100
+
52
101
  // src/server/adapters/mongo.ts
53
102
  var mongo_exports = {};
54
103
  __export(mongo_exports, {
@@ -362,6 +411,12 @@ var tagInputSchema = zod.z.object({
362
411
  externalId: externalIdSchema,
363
412
  tag: zod.z.string().min(1).max(128)
364
413
  });
414
+ var abortFlowInputSchema = zod.z.object({
415
+ flowSlug: slugSchema,
416
+ externalId: externalIdSchema,
417
+ reason: zod.z.string().min(1).max(200).optional()
418
+ });
419
+ var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
365
420
  var sendOneOffInputSchema = zod.z.object({
366
421
  templateSlug: slugSchema,
367
422
  externalId: externalIdSchema,
@@ -391,7 +446,13 @@ var flowStepSchema = zod.z.lazy(
391
446
  type: zod.z.literal("send"),
392
447
  templateSlug: slugSchema,
393
448
  providerOverride: zod.z.string().optional(),
394
- vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
449
+ vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
450
+ delivery: zod.z.object({
451
+ weekdaysOnly: zod.z.boolean().optional(),
452
+ timeOfDay: zod.z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "expected HH:mm").optional(),
453
+ useContactTimezone: zod.z.boolean().optional(),
454
+ timezone: zod.z.string().optional()
455
+ }).optional()
395
456
  }),
396
457
  zod.z.object({
397
458
  type: zod.z.literal("tag"),
@@ -1181,6 +1242,7 @@ async function tryEnterFlow(flow, event, ctx) {
1181
1242
  flowSlug: flow.slug,
1182
1243
  flowVersion: flow.version,
1183
1244
  emailAtEntry: sub.emailAtSubscribe,
1245
+ triggerEvent: { name: event.name, properties: event.properties ?? {}, occurredAt: event.occurredAt },
1184
1246
  enteredAt: /* @__PURE__ */ new Date(),
1185
1247
  status: "active",
1186
1248
  currentStepIndex: 0,
@@ -1278,6 +1340,105 @@ function effectiveLowerBound(ctx, opts) {
1278
1340
  }
1279
1341
  return null;
1280
1342
  }
1343
+
1344
+ // src/server/runner/delivery-window.ts
1345
+ var TIME_OF_DAY_GRACE_MS = 60 * 6e4;
1346
+ function computeDeliveryTime(now, window, contactTimezone) {
1347
+ const tz = pickTimezone(window, contactTimezone);
1348
+ let candidate = now;
1349
+ if (window.timeOfDay) {
1350
+ const [hh, mm] = window.timeOfDay.split(":").map(Number);
1351
+ const local = localParts(candidate, tz);
1352
+ const todaySlot = utcFromLocal(local.y, local.mo, local.d, hh, mm, tz);
1353
+ if (candidate.getTime() < todaySlot.getTime()) {
1354
+ candidate = todaySlot;
1355
+ } else if (candidate.getTime() - todaySlot.getTime() > TIME_OF_DAY_GRACE_MS) {
1356
+ const next = addLocalDays(local, 1);
1357
+ candidate = utcFromLocal(next.y, next.mo, next.d, hh, mm, tz);
1358
+ }
1359
+ }
1360
+ if (window.weekdaysOnly) {
1361
+ for (let guard = 0; guard < 3; guard++) {
1362
+ const local = localParts(candidate, tz);
1363
+ if (local.weekday !== "Sat" && local.weekday !== "Sun") break;
1364
+ const shift = local.weekday === "Sat" ? 2 : 1;
1365
+ const moved = addLocalDays(local, shift);
1366
+ candidate = utcFromLocal(moved.y, moved.mo, moved.d, local.hh, local.mi, tz);
1367
+ }
1368
+ }
1369
+ return candidate;
1370
+ }
1371
+ function pickTimezone(window, contactTimezone) {
1372
+ const candidates = [
1373
+ window.useContactTimezone ? contactTimezone : void 0,
1374
+ window.timezone,
1375
+ "UTC"
1376
+ ];
1377
+ for (const tz of candidates) {
1378
+ if (tz && isValidTimezone(tz)) return tz;
1379
+ }
1380
+ return "UTC";
1381
+ }
1382
+ var validatedZones = /* @__PURE__ */ new Map();
1383
+ function isValidTimezone(tz) {
1384
+ const cached = validatedZones.get(tz);
1385
+ if (cached !== void 0) return cached;
1386
+ let ok = true;
1387
+ try {
1388
+ new Intl.DateTimeFormat("en-US", { timeZone: tz });
1389
+ } catch {
1390
+ ok = false;
1391
+ }
1392
+ validatedZones.set(tz, ok);
1393
+ return ok;
1394
+ }
1395
+ var partFormatters = /* @__PURE__ */ new Map();
1396
+ function formatterFor(tz) {
1397
+ let f = partFormatters.get(tz);
1398
+ if (!f) {
1399
+ f = new Intl.DateTimeFormat("en-US", {
1400
+ timeZone: tz,
1401
+ year: "numeric",
1402
+ month: "2-digit",
1403
+ day: "2-digit",
1404
+ hour: "2-digit",
1405
+ minute: "2-digit",
1406
+ second: "2-digit",
1407
+ weekday: "short",
1408
+ hour12: false
1409
+ });
1410
+ partFormatters.set(tz, f);
1411
+ }
1412
+ return f;
1413
+ }
1414
+ function localParts(date, tz) {
1415
+ const parts = {};
1416
+ for (const p of formatterFor(tz).formatToParts(date)) parts[p.type] = p.value;
1417
+ return {
1418
+ y: Number(parts.year),
1419
+ mo: Number(parts.month),
1420
+ d: Number(parts.day),
1421
+ hh: Number(parts.hour) % 24,
1422
+ // Intl emits '24' for midnight in some locales
1423
+ mi: Number(parts.minute),
1424
+ ss: Number(parts.second),
1425
+ weekday: parts.weekday
1426
+ };
1427
+ }
1428
+ function utcFromLocal(y, mo, d, hh, mi, tz) {
1429
+ let ts = Date.UTC(y, mo - 1, d, hh, mi, 0);
1430
+ for (let i = 0; i < 2; i++) {
1431
+ const p = localParts(new Date(ts), tz);
1432
+ const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
1433
+ const offset = asUtc - ts;
1434
+ ts = Date.UTC(y, mo - 1, d, hh, mi, 0) - offset;
1435
+ }
1436
+ return new Date(ts);
1437
+ }
1438
+ function addLocalDays(p, days) {
1439
+ const dt = new Date(Date.UTC(p.y, p.mo - 1, p.d + days));
1440
+ return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
1441
+ }
1281
1442
  async function compileTemplate(mjml) {
1282
1443
  const out = await mjml2html__default.default(mjml, { validationLevel: "soft", minify: false });
1283
1444
  const plainText = derivePlaintext(out.html);
@@ -1401,6 +1562,9 @@ function makeHandlebars(extra) {
1401
1562
  return hb;
1402
1563
  }
1403
1564
 
1565
+ // src/server/runner/send.ts
1566
+ init_vars();
1567
+
1404
1568
  // src/server/runner/suppression.ts
1405
1569
  var SCOPES_BY_KIND = {
1406
1570
  marketing: ["all", "marketing"],
@@ -1719,13 +1883,29 @@ async function dispatchSend(sendId, ctx) {
1719
1883
  return;
1720
1884
  }
1721
1885
  const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
1722
- const renderCtx = buildRenderContext(
1723
- contact,
1724
- run,
1725
- send.vars ?? {},
1726
- ctx
1727
- );
1728
- const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1886
+ if (run && run.status === "exited" && run.exitReason?.startsWith("aborted_by_host")) {
1887
+ await ctx.collections.sends.updateOne(
1888
+ { _id: send._id },
1889
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${run.exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
1890
+ );
1891
+ return;
1892
+ }
1893
+ let renderCtx;
1894
+ let rendered;
1895
+ try {
1896
+ const resolved = await resolveVars(ctx.varsAdapter, contact, {
1897
+ reason: "send",
1898
+ templateSlug: template.slug,
1899
+ flowSlug: run?.flowSlug,
1900
+ eventName: run?.triggerEvent?.name,
1901
+ eventProperties: run?.triggerEvent?.properties
1902
+ });
1903
+ renderCtx = buildRenderContext(contact, run, send.vars ?? {}, ctx, resolved);
1904
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1905
+ } catch (err) {
1906
+ await markFailed(send._id, `render error: ${String(err?.message ?? err)}`, ctx);
1907
+ throw err;
1908
+ }
1729
1909
  const tracking = applyTracking(rendered.html, {
1730
1910
  sendId: String(send._id),
1731
1911
  publicUrl: ctx.config.publicUrl,
@@ -1801,7 +1981,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
1801
1981
  }
1802
1982
  return ctx.config.defaultProvider;
1803
1983
  }
1804
- function buildRenderContext(contact, run, vars, ctx) {
1984
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
1805
1985
  const scope = "marketing";
1806
1986
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
1807
1987
  const token = signUnsubscribeToken(
@@ -1810,8 +1990,10 @@ function buildRenderContext(contact, run, vars, ctx) {
1810
1990
  );
1811
1991
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
1812
1992
  return {
1993
+ ...resolved,
1813
1994
  contact,
1814
1995
  vars,
1996
+ event: run?.triggerEvent?.properties ?? {},
1815
1997
  unsubscribeUrl,
1816
1998
  senderAddress: ctx.config.senderAddress
1817
1999
  };
@@ -1896,8 +2078,15 @@ async function processOneRunStep(runId, ctx) {
1896
2078
  return handleCondition(run, step, contact, ctx);
1897
2079
  case "branch":
1898
2080
  return handleBranch(run, step, contact, ctx);
1899
- case "send":
2081
+ case "send": {
2082
+ if (step.delivery) {
2083
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
2084
+ if (deliverAt.getTime() > Date.now() + 3e4) {
2085
+ return deferSendForWindow(run, deliverAt, ctx);
2086
+ }
2087
+ }
1900
2088
  return handleSend(run, step, contact, flow, ctx);
2089
+ }
1901
2090
  case "tag":
1902
2091
  return handleTag(run, step, ctx);
1903
2092
  case "fire_event":
@@ -2048,6 +2237,34 @@ async function handleWebhookStep(run, step, ctx) {
2048
2237
  }
2049
2238
  }
2050
2239
  }
2240
+ async function deferSendForWindow(run, deliverAt, ctx) {
2241
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
2242
+ // Only write once per deferral — if nextActionAt already points at (or
2243
+ // past) the slot, another worker/tick got here first.
2244
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
2245
+ {
2246
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
2247
+ $push: {
2248
+ history: {
2249
+ stepIndex: run.currentStepIndex,
2250
+ action: "send_deferred",
2251
+ at: /* @__PURE__ */ new Date(),
2252
+ details: { until: deliverAt }
2253
+ }
2254
+ }
2255
+ },
2256
+ { returnDocument: "after" }
2257
+ );
2258
+ if (!updated) return;
2259
+ await ctx.queues.advance.add(
2260
+ "advance",
2261
+ { flowRunId: String(run._id) },
2262
+ {
2263
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
2264
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
2265
+ }
2266
+ );
2267
+ }
2051
2268
  async function advanceStep(run, ctx, log, opts = {}) {
2052
2269
  const stepInc = opts.stepInc ?? 1;
2053
2270
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -3572,6 +3789,7 @@ var Mailer = class _Mailer {
3572
3789
  db: this.db,
3573
3790
  collections: this.collections,
3574
3791
  adapter: this.adapter,
3792
+ varsAdapter: this.config.varsAdapter,
3575
3793
  providers: this.providers,
3576
3794
  queues: this.queues,
3577
3795
  config: this.config,
@@ -3651,6 +3869,10 @@ var Mailer = class _Mailer {
3651
3869
  if (!config.providers[config.defaultProvider]) {
3652
3870
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
3653
3871
  }
3872
+ if (config.varsAdapter) {
3873
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
3874
+ assertNoReservedVarKeys2(config.varsAdapter);
3875
+ }
3654
3876
  const collections = getCollections(config.db, config.collectionPrefix);
3655
3877
  await ensureIndexes(config.db, config.collectionPrefix);
3656
3878
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -3866,6 +4088,72 @@ var Mailer = class _Mailer {
3866
4088
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
3867
4089
  }
3868
4090
  }
4091
+ // -------------------------------------------------------------------------
4092
+ // Flow abort
4093
+ // -------------------------------------------------------------------------
4094
+ /**
4095
+ * Abort every active run of one flow for a contact, immediately. Runs parked
4096
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
4097
+ * no-op. Also cancels any of the flow's emails still sitting in the send
4098
+ * queue for this contact (queued or awaiting retry), so an abort means no
4099
+ * further mail, not just no further steps.
4100
+ *
4101
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
4102
+ * same handler that processes the business event ("user upgraded").
4103
+ */
4104
+ async abortFlow(flowSlug, externalId, opts = {}) {
4105
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
4106
+ const flow = await this.collections.flows.findOne(
4107
+ { slug: parsed.flowSlug },
4108
+ { projection: { _id: 1 } }
4109
+ );
4110
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
4111
+ const result = await this.abortActiveRuns(
4112
+ { externalId: parsed.externalId, flowId: flow._id },
4113
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4114
+ );
4115
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4116
+ await this.audit({
4117
+ actor: "host",
4118
+ action: "flow.abort",
4119
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
4120
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4121
+ });
4122
+ }
4123
+ return result;
4124
+ }
4125
+ /**
4126
+ * Abort every active flow run for a contact across all flows. Same semantics
4127
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
4128
+ */
4129
+ async abortAllFlows(externalId, opts = {}) {
4130
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
4131
+ const result = await this.abortActiveRuns(
4132
+ { externalId: parsed.externalId },
4133
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4134
+ );
4135
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4136
+ await this.audit({
4137
+ actor: "host",
4138
+ action: "flow.abort_all",
4139
+ resource: { collection: "mailer_flow_runs" },
4140
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4141
+ });
4142
+ }
4143
+ return result;
4144
+ }
4145
+ async abortActiveRuns(filter, exitReason) {
4146
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
4147
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
4148
+ for (const run of runs) {
4149
+ await exitFlowRun(run, exitReason, this.runnerContext);
4150
+ }
4151
+ const cancelled = await this.collections.sends.updateMany(
4152
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
4153
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
4154
+ );
4155
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
4156
+ }
3869
4157
  /**
3870
4158
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
3871
4159
  * suppression row to block re-import. INVARIANT 9.
@@ -4068,6 +4356,7 @@ var Mailer = class _Mailer {
4068
4356
 
4069
4357
  // src/server/index.ts
4070
4358
  init_mongo();
4359
+ init_vars();
4071
4360
 
4072
4361
  // src/server/providers/null.ts
4073
4362
  var counter = 0;
@@ -4206,6 +4495,18 @@ ${input.plainText}`);
4206
4495
  hint: "Front-load the important words so the truncated preview still makes sense."
4207
4496
  });
4208
4497
  }
4498
+ if (config.varsJsonSchema) {
4499
+ const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
4500
+ const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
4501
+ if (unknown.length > 0) {
4502
+ issues.push({
4503
+ rule: "unknown_variable",
4504
+ severity: "warning",
4505
+ message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
4506
+ 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."
4507
+ });
4508
+ }
4509
+ }
4209
4510
  const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
4210
4511
  if (linkCount > 10) {
4211
4512
  issues.push({
@@ -4268,6 +4569,60 @@ function findSpamSignals(text) {
4268
4569
  if (/!{3,}/.test(text)) out.add('excessive "!!!"');
4269
4570
  return Array.from(out);
4270
4571
  }
4572
+ var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
4573
+ "contact",
4574
+ "vars",
4575
+ "event",
4576
+ "unsubscribeUrl",
4577
+ "viewInBrowserUrl",
4578
+ "preferenceCenterUrl",
4579
+ "senderAddress",
4580
+ "this"
4581
+ ]);
4582
+ var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
4583
+ function findUnknownVariables(source, schema) {
4584
+ const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
4585
+ const unknown = /* @__PURE__ */ new Set();
4586
+ const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
4587
+ let m;
4588
+ while (m = re.exec(source)) {
4589
+ const expr = m[1].trim();
4590
+ if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
4591
+ if (expr.includes("(")) continue;
4592
+ const parts = expr.split(/\s+/);
4593
+ const candidates = parts.length > 1 ? parts.slice(1) : parts;
4594
+ for (const raw of candidates) {
4595
+ if (!PATH_TOKEN.test(raw)) continue;
4596
+ if (raw.startsWith("@")) continue;
4597
+ const segments = raw.split(".");
4598
+ if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
4599
+ if (hasBlockScopes && segments.length === 1) continue;
4600
+ if (!schemaHasPath(schema, segments)) unknown.add(raw);
4601
+ }
4602
+ }
4603
+ return Array.from(unknown);
4604
+ }
4605
+ function schemaHasPath(node, segments) {
4606
+ if (segments.length === 0) return true;
4607
+ if (!node || typeof node !== "object") return true;
4608
+ const n = node;
4609
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
4610
+ if (Array.isArray(n[key])) {
4611
+ return n[key].some((branch) => schemaHasPath(branch, segments));
4612
+ }
4613
+ }
4614
+ if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
4615
+ if (n.type === "array") {
4616
+ if (segments[0] === "length") return true;
4617
+ return schemaHasPath(n.items, segments);
4618
+ }
4619
+ const props = n.properties;
4620
+ if (!props || typeof props !== "object") return true;
4621
+ const [head, ...rest] = segments;
4622
+ if (head in props) return schemaHasPath(props[head], rest);
4623
+ if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
4624
+ return true;
4625
+ }
4271
4626
  function isAllCaps(subject) {
4272
4627
  const letters = subject.replace(/[^a-zA-Z]/g, "");
4273
4628
  if (letters.length < 5) return false;
@@ -4275,6 +4630,9 @@ function isAllCaps(subject) {
4275
4630
  return upper / letters.length > 0.5;
4276
4631
  }
4277
4632
 
4633
+ // src/server/api/admin.ts
4634
+ init_vars();
4635
+
4278
4636
  // src/server/api/setup-status.ts
4279
4637
  async function runSetupChecks(mailer) {
4280
4638
  const checks = [];
@@ -5054,12 +5412,19 @@ function createAdminRouter(mailer, opts = {}) {
5054
5412
  function apiRouter(mailer, opts = {}) {
5055
5413
  const r = express.Router();
5056
5414
  const c = mailer.collections;
5415
+ const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
5057
5416
  function getMailTesterClient() {
5058
5417
  if (opts.mailTesterClient) return opts.mailTesterClient;
5059
5418
  const cfg = mailer.config.mailTester;
5060
5419
  if (!cfg?.apiKey) return null;
5061
5420
  return createMailTesterClient(cfg);
5062
5421
  }
5422
+ r.get(
5423
+ "/vars-schema",
5424
+ asyncHandler(async (_req, res) => {
5425
+ res.json({ schema: varsSchema, builtins: exports.RESERVED_VAR_KEYS });
5426
+ })
5427
+ );
5063
5428
  r.get(
5064
5429
  "/me",
5065
5430
  asyncHandler(async (req, res) => {
@@ -6146,9 +6511,11 @@ function apiRouter(mailer, opts = {}) {
6146
6511
  compileFailed: true
6147
6512
  });
6148
6513
  }
6514
+ if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
6515
+ if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
6149
6516
  const lint = lintTemplate(
6150
6517
  { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
6151
- { senderDomains: mailer.config.senderDomains }
6518
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6152
6519
  );
6153
6520
  res.json({ ...lint, compileFailed: false });
6154
6521
  })
@@ -6199,7 +6566,7 @@ function apiRouter(mailer, opts = {}) {
6199
6566
  kind: tpl.kind,
6200
6567
  fromEmail: tpl.fromEmail
6201
6568
  },
6202
- { senderDomains: mailer.config.senderDomains }
6569
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6203
6570
  );
6204
6571
  if (lint.errors.length > 0) {
6205
6572
  return res.status(422).json({
@@ -6291,40 +6658,93 @@ function apiRouter(mailer, opts = {}) {
6291
6658
  html = tpl.body.html;
6292
6659
  plainText = tpl.body.plainText;
6293
6660
  }
6294
- const sampleContact = req.body?.sampleContact ?? {
6295
- externalId: "preview-contact",
6296
- email: "preview@example.com",
6297
- tags: [],
6298
- fields: { firstName: "Alex" }
6299
- };
6661
+ let contact;
6662
+ const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
6663
+ if (contactId) {
6664
+ const found = await mailer.adapter.getById(contactId);
6665
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6666
+ contact = found;
6667
+ } else {
6668
+ contact = req.body?.sampleContact ?? {
6669
+ externalId: "preview-contact",
6670
+ email: "preview@example.com",
6671
+ tags: [],
6672
+ fields: { firstName: "Alex" }
6673
+ };
6674
+ }
6675
+ const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
6676
+ let resolved = {};
6677
+ try {
6678
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6679
+ reason: "preview",
6680
+ templateSlug: tpl.slug,
6681
+ eventProperties
6682
+ });
6683
+ } catch (err) {
6684
+ return res.status(502).json({
6685
+ error: "vars_resolve_failed",
6686
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6687
+ });
6688
+ }
6300
6689
  const renderCtx = {
6301
- contact: sampleContact,
6690
+ ...resolved,
6691
+ contact,
6302
6692
  vars: req.body?.vars ?? {},
6693
+ event: eventProperties ?? {},
6303
6694
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
6304
6695
  senderAddress: mailer.config.senderAddress
6305
6696
  };
6306
6697
  const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
6307
6698
  const rendered = await renderTemplate(previewTpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
6308
- return res.json({ subject: rendered.subject, preheader: rendered.preheader, html: rendered.html, plainText: rendered.plainText });
6699
+ return res.json({
6700
+ subject: rendered.subject,
6701
+ preheader: rendered.preheader,
6702
+ html: rendered.html,
6703
+ plainText: rendered.plainText,
6704
+ contact: { externalId: contact.externalId, email: contact.email }
6705
+ });
6309
6706
  })
6310
6707
  );
6311
6708
  r.post(
6312
6709
  "/templates/:slug/send-test",
6313
6710
  asyncHandler(async (req, res) => {
6314
- const { to, sampleData } = req.body ?? {};
6711
+ const { to, sampleData, contactId, eventProperties } = req.body ?? {};
6315
6712
  if (!to) return res.status(400).json({ error: "to_required" });
6316
6713
  const tpl = await c.templates.findOne({ slug: req.params.slug });
6317
6714
  if (!tpl) return res.status(404).json({ error: "not_found" });
6318
- const contact = sampleData?.contact ?? {
6319
- externalId: "test-recipient",
6320
- email: to,
6321
- tags: [],
6322
- fields: { firstName: "Test" }
6323
- };
6324
- contact.email = to;
6715
+ let contact;
6716
+ if (typeof contactId === "string" && contactId) {
6717
+ const found = await mailer.adapter.getById(contactId);
6718
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6719
+ contact = { ...found, email: to };
6720
+ } else {
6721
+ contact = sampleData?.contact ?? {
6722
+ externalId: "test-recipient",
6723
+ email: to,
6724
+ tags: [],
6725
+ fields: { firstName: "Test" }
6726
+ };
6727
+ contact.email = to;
6728
+ }
6729
+ const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
6730
+ let resolved = {};
6731
+ try {
6732
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6733
+ reason: "test",
6734
+ templateSlug: tpl.slug,
6735
+ eventProperties: evProps
6736
+ });
6737
+ } catch (err) {
6738
+ return res.status(502).json({
6739
+ error: "vars_resolve_failed",
6740
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6741
+ });
6742
+ }
6325
6743
  const renderCtx = {
6744
+ ...resolved,
6326
6745
  contact,
6327
6746
  vars: sampleData?.vars ?? {},
6747
+ event: evProps ?? {},
6328
6748
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
6329
6749
  senderAddress: mailer.config.senderAddress
6330
6750
  };
@@ -6975,7 +7395,7 @@ var DEDUPE_POLICIES = [
6975
7395
  ];
6976
7396
 
6977
7397
  // src/server/index.ts
6978
- var VERSION = "0.1.0";
7398
+ var VERSION = "0.7.0";
6979
7399
 
6980
7400
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
6981
7401
  exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
@@ -6988,11 +7408,13 @@ exports.applyTracking = applyTracking;
6988
7408
  exports.applyWebhookEvent = applyWebhookEvent;
6989
7409
  exports.compileMailyTemplate = compileMailyTemplate;
6990
7410
  exports.compileTemplate = compileTemplate;
7411
+ exports.computeDeliveryTime = computeDeliveryTime;
6991
7412
  exports.createAdminRouter = createAdminRouter;
6992
7413
  exports.createPublicRouter = createPublicRouter;
6993
7414
  exports.defaultFlowStep = defaultFlowStep;
6994
7415
  exports.defaultPredicate = defaultPredicate;
6995
7416
  exports.defaultSegmentFilter = defaultSegmentFilter;
7417
+ exports.defineVars = defineVars;
6996
7418
  exports.derivePlaintext = derivePlaintext;
6997
7419
  exports.dispatchSend = dispatchSend;
6998
7420
  exports.ensureIndexes = ensureIndexes;
@@ -7006,6 +7428,7 @@ exports.sha256Hex = sha256Hex;
7006
7428
  exports.signUnsubscribeToken = signUnsubscribeToken;
7007
7429
  exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
7008
7430
  exports.validateSenderDomain = validateSenderDomain;
7431
+ exports.varsJsonSchema = varsJsonSchema;
7009
7432
  exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
7010
7433
  //# sourceMappingURL=index.cjs.map
7011
7434
  //# sourceMappingURL=index.cjs.map