mailery 0.5.1 → 0.8.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,
@@ -1734,6 +1914,10 @@ async function dispatchSend(sendId, ctx) {
1734
1914
  await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
1735
1915
  return;
1736
1916
  }
1917
+ const headers = send.kind === "marketing" ? {
1918
+ "List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
1919
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
1920
+ } : {};
1737
1921
  try {
1738
1922
  const result = await provider.send({
1739
1923
  to: send.emailAtSend,
@@ -1743,10 +1927,7 @@ async function dispatchSend(sendId, ctx) {
1743
1927
  subject: rendered.subject,
1744
1928
  html: tracking.html,
1745
1929
  text: rendered.plainText,
1746
- headers: {
1747
- "List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
1748
- "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
1749
- },
1930
+ headers,
1750
1931
  messageMeta: { sendId: String(send._id) }
1751
1932
  });
1752
1933
  await ctx.collections.sends.updateOne(
@@ -1783,7 +1964,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
1783
1964
  }
1784
1965
  return ctx.config.defaultProvider;
1785
1966
  }
1786
- function buildRenderContext(contact, run, vars, ctx) {
1967
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
1787
1968
  const scope = "marketing";
1788
1969
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
1789
1970
  const token = signUnsubscribeToken(
@@ -1792,8 +1973,10 @@ function buildRenderContext(contact, run, vars, ctx) {
1792
1973
  );
1793
1974
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
1794
1975
  return {
1976
+ ...resolved,
1795
1977
  contact,
1796
1978
  vars,
1979
+ event: run?.triggerEvent?.properties ?? {},
1797
1980
  unsubscribeUrl,
1798
1981
  senderAddress: ctx.config.senderAddress
1799
1982
  };
@@ -1878,8 +2061,15 @@ async function processOneRunStep(runId, ctx) {
1878
2061
  return handleCondition(run, step, contact, ctx);
1879
2062
  case "branch":
1880
2063
  return handleBranch(run, step, contact, ctx);
1881
- case "send":
2064
+ case "send": {
2065
+ if (step.delivery) {
2066
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
2067
+ if (deliverAt.getTime() > Date.now() + 3e4) {
2068
+ return deferSendForWindow(run, deliverAt, ctx);
2069
+ }
2070
+ }
1882
2071
  return handleSend(run, step, contact, flow, ctx);
2072
+ }
1883
2073
  case "tag":
1884
2074
  return handleTag(run, step, ctx);
1885
2075
  case "fire_event":
@@ -2030,6 +2220,34 @@ async function handleWebhookStep(run, step, ctx) {
2030
2220
  }
2031
2221
  }
2032
2222
  }
2223
+ async function deferSendForWindow(run, deliverAt, ctx) {
2224
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
2225
+ // Only write once per deferral — if nextActionAt already points at (or
2226
+ // past) the slot, another worker/tick got here first.
2227
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
2228
+ {
2229
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
2230
+ $push: {
2231
+ history: {
2232
+ stepIndex: run.currentStepIndex,
2233
+ action: "send_deferred",
2234
+ at: /* @__PURE__ */ new Date(),
2235
+ details: { until: deliverAt }
2236
+ }
2237
+ }
2238
+ },
2239
+ { returnDocument: "after" }
2240
+ );
2241
+ if (!updated) return;
2242
+ await ctx.queues.advance.add(
2243
+ "advance",
2244
+ { flowRunId: String(run._id) },
2245
+ {
2246
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
2247
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
2248
+ }
2249
+ );
2250
+ }
2033
2251
  async function advanceStep(run, ctx, log, opts = {}) {
2034
2252
  const stepInc = opts.stepInc ?? 1;
2035
2253
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -3554,6 +3772,7 @@ var Mailer = class _Mailer {
3554
3772
  db: this.db,
3555
3773
  collections: this.collections,
3556
3774
  adapter: this.adapter,
3775
+ varsAdapter: this.config.varsAdapter,
3557
3776
  providers: this.providers,
3558
3777
  queues: this.queues,
3559
3778
  config: this.config,
@@ -3633,6 +3852,10 @@ var Mailer = class _Mailer {
3633
3852
  if (!config.providers[config.defaultProvider]) {
3634
3853
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
3635
3854
  }
3855
+ if (config.varsAdapter) {
3856
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
3857
+ assertNoReservedVarKeys2(config.varsAdapter);
3858
+ }
3636
3859
  const collections = getCollections(config.db, config.collectionPrefix);
3637
3860
  await ensureIndexes(config.db, config.collectionPrefix);
3638
3861
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -3848,6 +4071,72 @@ var Mailer = class _Mailer {
3848
4071
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
3849
4072
  }
3850
4073
  }
4074
+ // -------------------------------------------------------------------------
4075
+ // Flow abort
4076
+ // -------------------------------------------------------------------------
4077
+ /**
4078
+ * Abort every active run of one flow for a contact, immediately. Runs parked
4079
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
4080
+ * no-op. Also cancels any of the flow's emails still sitting in the send
4081
+ * queue for this contact (queued or awaiting retry), so an abort means no
4082
+ * further mail, not just no further steps.
4083
+ *
4084
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
4085
+ * same handler that processes the business event ("user upgraded").
4086
+ */
4087
+ async abortFlow(flowSlug, externalId, opts = {}) {
4088
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
4089
+ const flow = await this.collections.flows.findOne(
4090
+ { slug: parsed.flowSlug },
4091
+ { projection: { _id: 1 } }
4092
+ );
4093
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
4094
+ const result = await this.abortActiveRuns(
4095
+ { externalId: parsed.externalId, flowId: flow._id },
4096
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4097
+ );
4098
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4099
+ await this.audit({
4100
+ actor: "host",
4101
+ action: "flow.abort",
4102
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
4103
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4104
+ });
4105
+ }
4106
+ return result;
4107
+ }
4108
+ /**
4109
+ * Abort every active flow run for a contact across all flows. Same semantics
4110
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
4111
+ */
4112
+ async abortAllFlows(externalId, opts = {}) {
4113
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
4114
+ const result = await this.abortActiveRuns(
4115
+ { externalId: parsed.externalId },
4116
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4117
+ );
4118
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4119
+ await this.audit({
4120
+ actor: "host",
4121
+ action: "flow.abort_all",
4122
+ resource: { collection: "mailer_flow_runs" },
4123
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4124
+ });
4125
+ }
4126
+ return result;
4127
+ }
4128
+ async abortActiveRuns(filter, exitReason) {
4129
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
4130
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
4131
+ for (const run of runs) {
4132
+ await exitFlowRun(run, exitReason, this.runnerContext);
4133
+ }
4134
+ const cancelled = await this.collections.sends.updateMany(
4135
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
4136
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
4137
+ );
4138
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
4139
+ }
3851
4140
  /**
3852
4141
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
3853
4142
  * suppression row to block re-import. INVARIANT 9.
@@ -4050,6 +4339,7 @@ var Mailer = class _Mailer {
4050
4339
 
4051
4340
  // src/server/index.ts
4052
4341
  init_mongo();
4342
+ init_vars();
4053
4343
 
4054
4344
  // src/server/providers/null.ts
4055
4345
  var counter = 0;
@@ -4188,6 +4478,18 @@ ${input.plainText}`);
4188
4478
  hint: "Front-load the important words so the truncated preview still makes sense."
4189
4479
  });
4190
4480
  }
4481
+ if (config.varsJsonSchema) {
4482
+ const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
4483
+ const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
4484
+ if (unknown.length > 0) {
4485
+ issues.push({
4486
+ rule: "unknown_variable",
4487
+ severity: "warning",
4488
+ message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
4489
+ 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."
4490
+ });
4491
+ }
4492
+ }
4191
4493
  const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
4192
4494
  if (linkCount > 10) {
4193
4495
  issues.push({
@@ -4250,6 +4552,60 @@ function findSpamSignals(text) {
4250
4552
  if (/!{3,}/.test(text)) out.add('excessive "!!!"');
4251
4553
  return Array.from(out);
4252
4554
  }
4555
+ var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
4556
+ "contact",
4557
+ "vars",
4558
+ "event",
4559
+ "unsubscribeUrl",
4560
+ "viewInBrowserUrl",
4561
+ "preferenceCenterUrl",
4562
+ "senderAddress",
4563
+ "this"
4564
+ ]);
4565
+ var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
4566
+ function findUnknownVariables(source, schema) {
4567
+ const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
4568
+ const unknown = /* @__PURE__ */ new Set();
4569
+ const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
4570
+ let m;
4571
+ while (m = re.exec(source)) {
4572
+ const expr = m[1].trim();
4573
+ if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
4574
+ if (expr.includes("(")) continue;
4575
+ const parts = expr.split(/\s+/);
4576
+ const candidates = parts.length > 1 ? parts.slice(1) : parts;
4577
+ for (const raw of candidates) {
4578
+ if (!PATH_TOKEN.test(raw)) continue;
4579
+ if (raw.startsWith("@")) continue;
4580
+ const segments = raw.split(".");
4581
+ if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
4582
+ if (hasBlockScopes && segments.length === 1) continue;
4583
+ if (!schemaHasPath(schema, segments)) unknown.add(raw);
4584
+ }
4585
+ }
4586
+ return Array.from(unknown);
4587
+ }
4588
+ function schemaHasPath(node, segments) {
4589
+ if (segments.length === 0) return true;
4590
+ if (!node || typeof node !== "object") return true;
4591
+ const n = node;
4592
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
4593
+ if (Array.isArray(n[key])) {
4594
+ return n[key].some((branch) => schemaHasPath(branch, segments));
4595
+ }
4596
+ }
4597
+ if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
4598
+ if (n.type === "array") {
4599
+ if (segments[0] === "length") return true;
4600
+ return schemaHasPath(n.items, segments);
4601
+ }
4602
+ const props = n.properties;
4603
+ if (!props || typeof props !== "object") return true;
4604
+ const [head, ...rest] = segments;
4605
+ if (head in props) return schemaHasPath(props[head], rest);
4606
+ if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
4607
+ return true;
4608
+ }
4253
4609
  function isAllCaps(subject) {
4254
4610
  const letters = subject.replace(/[^a-zA-Z]/g, "");
4255
4611
  if (letters.length < 5) return false;
@@ -4257,6 +4613,9 @@ function isAllCaps(subject) {
4257
4613
  return upper / letters.length > 0.5;
4258
4614
  }
4259
4615
 
4616
+ // src/server/api/admin.ts
4617
+ init_vars();
4618
+
4260
4619
  // src/server/api/setup-status.ts
4261
4620
  async function runSetupChecks(mailer) {
4262
4621
  const checks = [];
@@ -4695,7 +5054,7 @@ async function checkPostalAddress(mailer) {
4695
5054
  label: "CAN-SPAM postal address",
4696
5055
  severity: "warn",
4697
5056
  message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
4698
- hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference it via `{{senderAddress}}` in your templates."
5057
+ hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference the `{{senderAddress}}` render variable in your templates."
4699
5058
  };
4700
5059
  }
4701
5060
  async function checkDoiTemplate(mailer) {
@@ -4989,19 +5348,30 @@ async function persistScore(ctx, input) {
4989
5348
  }
4990
5349
  async function evaluateMailTesterGate(ctx, input) {
4991
5350
  const cfg = ctx.config.mailTester;
4992
- if (!cfg?.apiKey) return { allowed: true, reason: null, score: null };
5351
+ if (!cfg?.apiKey) return { allowed: true, reason: null, score: null, code: null };
4993
5352
  const minScore = cfg.minScore ?? 8;
4994
5353
  const key = mailTesterContentKey(input);
4995
5354
  const cached = await findCachedScore(ctx, key);
4996
- if (!cached) return { allowed: true, reason: null, score: null };
5355
+ if (!cached) {
5356
+ if (cfg.requireScore) {
5357
+ return {
5358
+ allowed: false,
5359
+ reason: "No Mail-Tester score for this exact content. Run a deliverability check before publishing.",
5360
+ score: null,
5361
+ code: "no_score"
5362
+ };
5363
+ }
5364
+ return { allowed: true, reason: null, score: null, code: null };
5365
+ }
4997
5366
  if (cached.score < minScore) {
4998
5367
  return {
4999
5368
  allowed: false,
5000
5369
  reason: `Mail-Tester score ${cached.score.toFixed(1)} is below minimum ${minScore.toFixed(1)}`,
5001
- score: cached
5370
+ score: cached,
5371
+ code: "low_score"
5002
5372
  };
5003
5373
  }
5004
- return { allowed: true, reason: null, score: cached };
5374
+ return { allowed: true, reason: null, score: cached, code: null };
5005
5375
  }
5006
5376
 
5007
5377
  // src/server/api/admin.ts
@@ -5036,12 +5406,19 @@ function createAdminRouter(mailer, opts = {}) {
5036
5406
  function apiRouter(mailer, opts = {}) {
5037
5407
  const r = Router();
5038
5408
  const c = mailer.collections;
5409
+ const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
5039
5410
  function getMailTesterClient() {
5040
5411
  if (opts.mailTesterClient) return opts.mailTesterClient;
5041
5412
  const cfg = mailer.config.mailTester;
5042
5413
  if (!cfg?.apiKey) return null;
5043
5414
  return createMailTesterClient(cfg);
5044
5415
  }
5416
+ r.get(
5417
+ "/vars-schema",
5418
+ asyncHandler(async (_req, res) => {
5419
+ res.json({ schema: varsSchema, builtins: RESERVED_VAR_KEYS });
5420
+ })
5421
+ );
5045
5422
  r.get(
5046
5423
  "/me",
5047
5424
  asyncHandler(async (req, res) => {
@@ -5988,6 +6365,7 @@ function apiRouter(mailer, opts = {}) {
5988
6365
  res.json({
5989
6366
  configured,
5990
6367
  minScore: cfg?.minScore ?? 8,
6368
+ requireScore: !!cfg?.requireScore,
5991
6369
  cacheHours: cfg?.cacheHours ?? 24,
5992
6370
  score: cached
5993
6371
  });
@@ -6128,9 +6506,11 @@ function apiRouter(mailer, opts = {}) {
6128
6506
  compileFailed: true
6129
6507
  });
6130
6508
  }
6509
+ if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
6510
+ if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
6131
6511
  const lint = lintTemplate(
6132
6512
  { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
6133
- { senderDomains: mailer.config.senderDomains }
6513
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6134
6514
  );
6135
6515
  res.json({ ...lint, compileFailed: false });
6136
6516
  })
@@ -6181,7 +6561,7 @@ function apiRouter(mailer, opts = {}) {
6181
6561
  kind: tpl.kind,
6182
6562
  fromEmail: tpl.fromEmail
6183
6563
  },
6184
- { senderDomains: mailer.config.senderDomains }
6564
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6185
6565
  );
6186
6566
  if (lint.errors.length > 0) {
6187
6567
  return res.status(422).json({
@@ -6200,9 +6580,10 @@ function apiRouter(mailer, opts = {}) {
6200
6580
  if (!gate.allowed) {
6201
6581
  return res.status(422).json({
6202
6582
  error: "mail_tester_blocked",
6583
+ code: gate.code,
6203
6584
  message: gate.reason,
6204
6585
  score: gate.score,
6205
- hint: "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
6586
+ hint: gate.code === "no_score" ? "POST to `/templates/:slug/mail-tester-check`, poll `/mail-tester-result`, then publish \u2014 or POST `bypassMailTester: true` to publish anyway." : "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
6206
6587
  });
6207
6588
  }
6208
6589
  }
@@ -6273,40 +6654,93 @@ function apiRouter(mailer, opts = {}) {
6273
6654
  html = tpl.body.html;
6274
6655
  plainText = tpl.body.plainText;
6275
6656
  }
6276
- const sampleContact = req.body?.sampleContact ?? {
6277
- externalId: "preview-contact",
6278
- email: "preview@example.com",
6279
- tags: [],
6280
- fields: { firstName: "Alex" }
6281
- };
6657
+ let contact;
6658
+ const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
6659
+ if (contactId) {
6660
+ const found = await mailer.adapter.getById(contactId);
6661
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6662
+ contact = found;
6663
+ } else {
6664
+ contact = req.body?.sampleContact ?? {
6665
+ externalId: "preview-contact",
6666
+ email: "preview@example.com",
6667
+ tags: [],
6668
+ fields: { firstName: "Alex" }
6669
+ };
6670
+ }
6671
+ const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
6672
+ let resolved = {};
6673
+ try {
6674
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6675
+ reason: "preview",
6676
+ templateSlug: tpl.slug,
6677
+ eventProperties
6678
+ });
6679
+ } catch (err) {
6680
+ return res.status(502).json({
6681
+ error: "vars_resolve_failed",
6682
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6683
+ });
6684
+ }
6282
6685
  const renderCtx = {
6283
- contact: sampleContact,
6686
+ ...resolved,
6687
+ contact,
6284
6688
  vars: req.body?.vars ?? {},
6689
+ event: eventProperties ?? {},
6285
6690
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
6286
6691
  senderAddress: mailer.config.senderAddress
6287
6692
  };
6288
6693
  const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
6289
6694
  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 });
6695
+ return res.json({
6696
+ subject: rendered.subject,
6697
+ preheader: rendered.preheader,
6698
+ html: rendered.html,
6699
+ plainText: rendered.plainText,
6700
+ contact: { externalId: contact.externalId, email: contact.email }
6701
+ });
6291
6702
  })
6292
6703
  );
6293
6704
  r.post(
6294
6705
  "/templates/:slug/send-test",
6295
6706
  asyncHandler(async (req, res) => {
6296
- const { to, sampleData } = req.body ?? {};
6707
+ const { to, sampleData, contactId, eventProperties } = req.body ?? {};
6297
6708
  if (!to) return res.status(400).json({ error: "to_required" });
6298
6709
  const tpl = await c.templates.findOne({ slug: req.params.slug });
6299
6710
  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;
6711
+ let contact;
6712
+ if (typeof contactId === "string" && contactId) {
6713
+ const found = await mailer.adapter.getById(contactId);
6714
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6715
+ contact = { ...found, email: to };
6716
+ } else {
6717
+ contact = sampleData?.contact ?? {
6718
+ externalId: "test-recipient",
6719
+ email: to,
6720
+ tags: [],
6721
+ fields: { firstName: "Test" }
6722
+ };
6723
+ contact.email = to;
6724
+ }
6725
+ const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
6726
+ let resolved = {};
6727
+ try {
6728
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6729
+ reason: "test",
6730
+ templateSlug: tpl.slug,
6731
+ eventProperties: evProps
6732
+ });
6733
+ } catch (err) {
6734
+ return res.status(502).json({
6735
+ error: "vars_resolve_failed",
6736
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6737
+ });
6738
+ }
6307
6739
  const renderCtx = {
6740
+ ...resolved,
6308
6741
  contact,
6309
6742
  vars: sampleData?.vars ?? {},
6743
+ event: evProps ?? {},
6310
6744
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
6311
6745
  senderAddress: mailer.config.senderAddress
6312
6746
  };
@@ -6957,8 +7391,8 @@ var DEDUPE_POLICIES = [
6957
7391
  ];
6958
7392
 
6959
7393
  // src/server/index.ts
6960
- var VERSION = "0.1.0";
7394
+ var VERSION = "0.8.0";
6961
7395
 
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 };
7396
+ 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
7397
  //# sourceMappingURL=index.js.map
6964
7398
  //# sourceMappingURL=index.js.map