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.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,
@@ -1752,6 +1932,10 @@ async function dispatchSend(sendId, ctx) {
1752
1932
  await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
1753
1933
  return;
1754
1934
  }
1935
+ const headers = send.kind === "marketing" ? {
1936
+ "List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
1937
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
1938
+ } : {};
1755
1939
  try {
1756
1940
  const result = await provider.send({
1757
1941
  to: send.emailAtSend,
@@ -1761,10 +1945,7 @@ async function dispatchSend(sendId, ctx) {
1761
1945
  subject: rendered.subject,
1762
1946
  html: tracking.html,
1763
1947
  text: rendered.plainText,
1764
- headers: {
1765
- "List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
1766
- "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
1767
- },
1948
+ headers,
1768
1949
  messageMeta: { sendId: String(send._id) }
1769
1950
  });
1770
1951
  await ctx.collections.sends.updateOne(
@@ -1801,7 +1982,7 @@ function pickProviderName(stepOverride, tpl, ctx) {
1801
1982
  }
1802
1983
  return ctx.config.defaultProvider;
1803
1984
  }
1804
- function buildRenderContext(contact, run, vars, ctx) {
1985
+ function buildRenderContext(contact, run, vars, ctx, resolved = {}) {
1805
1986
  const scope = "marketing";
1806
1987
  const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
1807
1988
  const token = signUnsubscribeToken(
@@ -1810,8 +1991,10 @@ function buildRenderContext(contact, run, vars, ctx) {
1810
1991
  );
1811
1992
  const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
1812
1993
  return {
1994
+ ...resolved,
1813
1995
  contact,
1814
1996
  vars,
1997
+ event: run?.triggerEvent?.properties ?? {},
1815
1998
  unsubscribeUrl,
1816
1999
  senderAddress: ctx.config.senderAddress
1817
2000
  };
@@ -1896,8 +2079,15 @@ async function processOneRunStep(runId, ctx) {
1896
2079
  return handleCondition(run, step, contact, ctx);
1897
2080
  case "branch":
1898
2081
  return handleBranch(run, step, contact, ctx);
1899
- case "send":
2082
+ case "send": {
2083
+ if (step.delivery) {
2084
+ const deliverAt = computeDeliveryTime(/* @__PURE__ */ new Date(), step.delivery, contact.timezone);
2085
+ if (deliverAt.getTime() > Date.now() + 3e4) {
2086
+ return deferSendForWindow(run, deliverAt, ctx);
2087
+ }
2088
+ }
1900
2089
  return handleSend(run, step, contact, flow, ctx);
2090
+ }
1901
2091
  case "tag":
1902
2092
  return handleTag(run, step, ctx);
1903
2093
  case "fire_event":
@@ -2048,6 +2238,34 @@ async function handleWebhookStep(run, step, ctx) {
2048
2238
  }
2049
2239
  }
2050
2240
  }
2241
+ async function deferSendForWindow(run, deliverAt, ctx) {
2242
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
2243
+ // Only write once per deferral — if nextActionAt already points at (or
2244
+ // past) the slot, another worker/tick got here first.
2245
+ { _id: run._id, currentStepIndex: run.currentStepIndex, nextActionAt: { $lt: deliverAt } },
2246
+ {
2247
+ $set: { nextActionAt: deliverAt, updatedAt: /* @__PURE__ */ new Date() },
2248
+ $push: {
2249
+ history: {
2250
+ stepIndex: run.currentStepIndex,
2251
+ action: "send_deferred",
2252
+ at: /* @__PURE__ */ new Date(),
2253
+ details: { until: deliverAt }
2254
+ }
2255
+ }
2256
+ },
2257
+ { returnDocument: "after" }
2258
+ );
2259
+ if (!updated) return;
2260
+ await ctx.queues.advance.add(
2261
+ "advance",
2262
+ { flowRunId: String(run._id) },
2263
+ {
2264
+ delay: Math.max(0, deliverAt.getTime() - Date.now()),
2265
+ jobId: `advance:${run._id}:${run.currentStepIndex}:window:${deliverAt.getTime()}`
2266
+ }
2267
+ );
2268
+ }
2051
2269
  async function advanceStep(run, ctx, log, opts = {}) {
2052
2270
  const stepInc = opts.stepInc ?? 1;
2053
2271
  const updated = await ctx.collections.flowRuns.findOneAndUpdate(
@@ -3572,6 +3790,7 @@ var Mailer = class _Mailer {
3572
3790
  db: this.db,
3573
3791
  collections: this.collections,
3574
3792
  adapter: this.adapter,
3793
+ varsAdapter: this.config.varsAdapter,
3575
3794
  providers: this.providers,
3576
3795
  queues: this.queues,
3577
3796
  config: this.config,
@@ -3651,6 +3870,10 @@ var Mailer = class _Mailer {
3651
3870
  if (!config.providers[config.defaultProvider]) {
3652
3871
  throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
3653
3872
  }
3873
+ if (config.varsAdapter) {
3874
+ const { assertNoReservedVarKeys: assertNoReservedVarKeys2 } = await Promise.resolve().then(() => (init_vars(), vars_exports));
3875
+ assertNoReservedVarKeys2(config.varsAdapter);
3876
+ }
3654
3877
  const collections = getCollections(config.db, config.collectionPrefix);
3655
3878
  await ensureIndexes(config.db, config.collectionPrefix);
3656
3879
  const queueDriver = await createQueueDriver(config.queue, config.db);
@@ -3866,6 +4089,72 @@ var Mailer = class _Mailer {
3866
4089
  await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
3867
4090
  }
3868
4091
  }
4092
+ // -------------------------------------------------------------------------
4093
+ // Flow abort
4094
+ // -------------------------------------------------------------------------
4095
+ /**
4096
+ * Abort every active run of one flow for a contact, immediately. Runs parked
4097
+ * in a `wait` exit too — their delayed wake-up jobs find the run exited and
4098
+ * no-op. Also cancels any of the flow's emails still sitting in the send
4099
+ * queue for this contact (queued or awaiting retry), so an abort means no
4100
+ * further mail, not just no further steps.
4101
+ *
4102
+ * No-op (returns zero counts) when nothing is active. Safe to call from the
4103
+ * same handler that processes the business event ("user upgraded").
4104
+ */
4105
+ async abortFlow(flowSlug, externalId, opts = {}) {
4106
+ const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
4107
+ const flow = await this.collections.flows.findOne(
4108
+ { slug: parsed.flowSlug },
4109
+ { projection: { _id: 1 } }
4110
+ );
4111
+ if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
4112
+ const result = await this.abortActiveRuns(
4113
+ { externalId: parsed.externalId, flowId: flow._id },
4114
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4115
+ );
4116
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4117
+ await this.audit({
4118
+ actor: "host",
4119
+ action: "flow.abort",
4120
+ resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
4121
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4122
+ });
4123
+ }
4124
+ return result;
4125
+ }
4126
+ /**
4127
+ * Abort every active flow run for a contact across all flows. Same semantics
4128
+ * as `abortFlow` — for "stop everything" events (account deleted, churned).
4129
+ */
4130
+ async abortAllFlows(externalId, opts = {}) {
4131
+ const parsed = abortAllFlowsInputSchema.parse({ externalId, reason: opts.reason });
4132
+ const result = await this.abortActiveRuns(
4133
+ { externalId: parsed.externalId },
4134
+ parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
4135
+ );
4136
+ if (result.abortedRuns > 0 || result.cancelledSends > 0) {
4137
+ await this.audit({
4138
+ actor: "host",
4139
+ action: "flow.abort_all",
4140
+ resource: { collection: "mailer_flow_runs" },
4141
+ diffSummary: `abortAllFlows externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
4142
+ });
4143
+ }
4144
+ return result;
4145
+ }
4146
+ async abortActiveRuns(filter, exitReason) {
4147
+ const runs = await this.collections.flowRuns.find({ ...filter, status: "active" }).toArray();
4148
+ if (runs.length === 0) return { abortedRuns: 0, cancelledSends: 0 };
4149
+ for (const run of runs) {
4150
+ await exitFlowRun(run, exitReason, this.runnerContext);
4151
+ }
4152
+ const cancelled = await this.collections.sends.updateMany(
4153
+ { flowRunId: { $in: runs.map((r) => r._id) }, status: { $in: ["queued", "failed"] } },
4154
+ { $set: { status: "cancelled", errorMessage: `cancelled: ${exitReason}`, updatedAt: /* @__PURE__ */ new Date() } }
4155
+ );
4156
+ return { abortedRuns: runs.length, cancelledSends: cancelled.modifiedCount };
4157
+ }
3869
4158
  /**
3870
4159
  * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
3871
4160
  * suppression row to block re-import. INVARIANT 9.
@@ -4068,6 +4357,7 @@ var Mailer = class _Mailer {
4068
4357
 
4069
4358
  // src/server/index.ts
4070
4359
  init_mongo();
4360
+ init_vars();
4071
4361
 
4072
4362
  // src/server/providers/null.ts
4073
4363
  var counter = 0;
@@ -4206,6 +4496,18 @@ ${input.plainText}`);
4206
4496
  hint: "Front-load the important words so the truncated preview still makes sense."
4207
4497
  });
4208
4498
  }
4499
+ if (config.varsJsonSchema) {
4500
+ const sources = [input.subject, input.preheader, input.mjml, JSON.stringify(input.editorJson ?? null)];
4501
+ const unknown = findUnknownVariables(sources.join("\n"), config.varsJsonSchema);
4502
+ if (unknown.length > 0) {
4503
+ issues.push({
4504
+ rule: "unknown_variable",
4505
+ severity: "warning",
4506
+ message: `Template references variable(s) not in the vars schema: ${unknown.join(", ")}.`,
4507
+ 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."
4508
+ });
4509
+ }
4510
+ }
4209
4511
  const linkCount = countMatches(input.html, /<a\s[^>]*\bhref\s*=/gi);
4210
4512
  if (linkCount > 10) {
4211
4513
  issues.push({
@@ -4268,6 +4570,60 @@ function findSpamSignals(text) {
4268
4570
  if (/!{3,}/.test(text)) out.add('excessive "!!!"');
4269
4571
  return Array.from(out);
4270
4572
  }
4573
+ var BUILTIN_VAR_ROOTS = /* @__PURE__ */ new Set([
4574
+ "contact",
4575
+ "vars",
4576
+ "event",
4577
+ "unsubscribeUrl",
4578
+ "viewInBrowserUrl",
4579
+ "preferenceCenterUrl",
4580
+ "senderAddress",
4581
+ "this"
4582
+ ]);
4583
+ var PATH_TOKEN = /^@?[A-Za-z_][\w$]*(\.[A-Za-z_][\w$]*)*$/;
4584
+ function findUnknownVariables(source, schema) {
4585
+ const hasBlockScopes = /\{\{[#^]\s*(each|with)\b/.test(source);
4586
+ const unknown = /* @__PURE__ */ new Set();
4587
+ const re = /\{\{\{?\s*([^{}]+?)\s*\}?\}\}/g;
4588
+ let m;
4589
+ while (m = re.exec(source)) {
4590
+ const expr = m[1].trim();
4591
+ if (!expr || /^[#^/>!]/.test(expr) || expr === "else") continue;
4592
+ if (expr.includes("(")) continue;
4593
+ const parts = expr.split(/\s+/);
4594
+ const candidates = parts.length > 1 ? parts.slice(1) : parts;
4595
+ for (const raw of candidates) {
4596
+ if (!PATH_TOKEN.test(raw)) continue;
4597
+ if (raw.startsWith("@")) continue;
4598
+ const segments = raw.split(".");
4599
+ if (BUILTIN_VAR_ROOTS.has(segments[0])) continue;
4600
+ if (hasBlockScopes && segments.length === 1) continue;
4601
+ if (!schemaHasPath(schema, segments)) unknown.add(raw);
4602
+ }
4603
+ }
4604
+ return Array.from(unknown);
4605
+ }
4606
+ function schemaHasPath(node, segments) {
4607
+ if (segments.length === 0) return true;
4608
+ if (!node || typeof node !== "object") return true;
4609
+ const n = node;
4610
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
4611
+ if (Array.isArray(n[key])) {
4612
+ return n[key].some((branch) => schemaHasPath(branch, segments));
4613
+ }
4614
+ }
4615
+ if (Array.isArray(n.type) && n.type.includes("object") && !n.properties) return true;
4616
+ if (n.type === "array") {
4617
+ if (segments[0] === "length") return true;
4618
+ return schemaHasPath(n.items, segments);
4619
+ }
4620
+ const props = n.properties;
4621
+ if (!props || typeof props !== "object") return true;
4622
+ const [head, ...rest] = segments;
4623
+ if (head in props) return schemaHasPath(props[head], rest);
4624
+ if (n.additionalProperties === void 0 || n.additionalProperties === false) return false;
4625
+ return true;
4626
+ }
4271
4627
  function isAllCaps(subject) {
4272
4628
  const letters = subject.replace(/[^a-zA-Z]/g, "");
4273
4629
  if (letters.length < 5) return false;
@@ -4275,6 +4631,9 @@ function isAllCaps(subject) {
4275
4631
  return upper / letters.length > 0.5;
4276
4632
  }
4277
4633
 
4634
+ // src/server/api/admin.ts
4635
+ init_vars();
4636
+
4278
4637
  // src/server/api/setup-status.ts
4279
4638
  async function runSetupChecks(mailer) {
4280
4639
  const checks = [];
@@ -4713,7 +5072,7 @@ async function checkPostalAddress(mailer) {
4713
5072
  label: "CAN-SPAM postal address",
4714
5073
  severity: "warn",
4715
5074
  message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
4716
- hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference it via `{{senderAddress}}` in your templates."
5075
+ 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."
4717
5076
  };
4718
5077
  }
4719
5078
  async function checkDoiTemplate(mailer) {
@@ -5007,19 +5366,30 @@ async function persistScore(ctx, input) {
5007
5366
  }
5008
5367
  async function evaluateMailTesterGate(ctx, input) {
5009
5368
  const cfg = ctx.config.mailTester;
5010
- if (!cfg?.apiKey) return { allowed: true, reason: null, score: null };
5369
+ if (!cfg?.apiKey) return { allowed: true, reason: null, score: null, code: null };
5011
5370
  const minScore = cfg.minScore ?? 8;
5012
5371
  const key = mailTesterContentKey(input);
5013
5372
  const cached = await findCachedScore(ctx, key);
5014
- if (!cached) return { allowed: true, reason: null, score: null };
5373
+ if (!cached) {
5374
+ if (cfg.requireScore) {
5375
+ return {
5376
+ allowed: false,
5377
+ reason: "No Mail-Tester score for this exact content. Run a deliverability check before publishing.",
5378
+ score: null,
5379
+ code: "no_score"
5380
+ };
5381
+ }
5382
+ return { allowed: true, reason: null, score: null, code: null };
5383
+ }
5015
5384
  if (cached.score < minScore) {
5016
5385
  return {
5017
5386
  allowed: false,
5018
5387
  reason: `Mail-Tester score ${cached.score.toFixed(1)} is below minimum ${minScore.toFixed(1)}`,
5019
- score: cached
5388
+ score: cached,
5389
+ code: "low_score"
5020
5390
  };
5021
5391
  }
5022
- return { allowed: true, reason: null, score: cached };
5392
+ return { allowed: true, reason: null, score: cached, code: null };
5023
5393
  }
5024
5394
 
5025
5395
  // src/server/api/admin.ts
@@ -5054,12 +5424,19 @@ function createAdminRouter(mailer, opts = {}) {
5054
5424
  function apiRouter(mailer, opts = {}) {
5055
5425
  const r = express.Router();
5056
5426
  const c = mailer.collections;
5427
+ const varsSchema = mailer.config.varsAdapter ? varsJsonSchema(mailer.config.varsAdapter) : null;
5057
5428
  function getMailTesterClient() {
5058
5429
  if (opts.mailTesterClient) return opts.mailTesterClient;
5059
5430
  const cfg = mailer.config.mailTester;
5060
5431
  if (!cfg?.apiKey) return null;
5061
5432
  return createMailTesterClient(cfg);
5062
5433
  }
5434
+ r.get(
5435
+ "/vars-schema",
5436
+ asyncHandler(async (_req, res) => {
5437
+ res.json({ schema: varsSchema, builtins: exports.RESERVED_VAR_KEYS });
5438
+ })
5439
+ );
5063
5440
  r.get(
5064
5441
  "/me",
5065
5442
  asyncHandler(async (req, res) => {
@@ -6006,6 +6383,7 @@ function apiRouter(mailer, opts = {}) {
6006
6383
  res.json({
6007
6384
  configured,
6008
6385
  minScore: cfg?.minScore ?? 8,
6386
+ requireScore: !!cfg?.requireScore,
6009
6387
  cacheHours: cfg?.cacheHours ?? 24,
6010
6388
  score: cached
6011
6389
  });
@@ -6146,9 +6524,11 @@ function apiRouter(mailer, opts = {}) {
6146
6524
  compileFailed: true
6147
6525
  });
6148
6526
  }
6527
+ if (!html && typeof tpl.body?.html === "string") html = tpl.body.html;
6528
+ if (!plainText && typeof tpl.body?.plainText === "string") plainText = tpl.body.plainText;
6149
6529
  const lint = lintTemplate(
6150
6530
  { subject, preheader, mjml, editorJson, html, plainText, kind, fromEmail },
6151
- { senderDomains: mailer.config.senderDomains }
6531
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6152
6532
  );
6153
6533
  res.json({ ...lint, compileFailed: false });
6154
6534
  })
@@ -6199,7 +6579,7 @@ function apiRouter(mailer, opts = {}) {
6199
6579
  kind: tpl.kind,
6200
6580
  fromEmail: tpl.fromEmail
6201
6581
  },
6202
- { senderDomains: mailer.config.senderDomains }
6582
+ { senderDomains: mailer.config.senderDomains, varsJsonSchema: varsSchema }
6203
6583
  );
6204
6584
  if (lint.errors.length > 0) {
6205
6585
  return res.status(422).json({
@@ -6218,9 +6598,10 @@ function apiRouter(mailer, opts = {}) {
6218
6598
  if (!gate.allowed) {
6219
6599
  return res.status(422).json({
6220
6600
  error: "mail_tester_blocked",
6601
+ code: gate.code,
6221
6602
  message: gate.reason,
6222
6603
  score: gate.score,
6223
- hint: "Re-run the deliverability check after fixing the feedback, or POST `bypassMailTester: true` to publish anyway."
6604
+ 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."
6224
6605
  });
6225
6606
  }
6226
6607
  }
@@ -6291,40 +6672,93 @@ function apiRouter(mailer, opts = {}) {
6291
6672
  html = tpl.body.html;
6292
6673
  plainText = tpl.body.plainText;
6293
6674
  }
6294
- const sampleContact = req.body?.sampleContact ?? {
6295
- externalId: "preview-contact",
6296
- email: "preview@example.com",
6297
- tags: [],
6298
- fields: { firstName: "Alex" }
6299
- };
6675
+ let contact;
6676
+ const contactId = typeof req.body?.contactId === "string" ? req.body.contactId : null;
6677
+ if (contactId) {
6678
+ const found = await mailer.adapter.getById(contactId);
6679
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6680
+ contact = found;
6681
+ } else {
6682
+ contact = req.body?.sampleContact ?? {
6683
+ externalId: "preview-contact",
6684
+ email: "preview@example.com",
6685
+ tags: [],
6686
+ fields: { firstName: "Alex" }
6687
+ };
6688
+ }
6689
+ const eventProperties = req.body?.eventProperties && typeof req.body.eventProperties === "object" ? req.body.eventProperties : void 0;
6690
+ let resolved = {};
6691
+ try {
6692
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6693
+ reason: "preview",
6694
+ templateSlug: tpl.slug,
6695
+ eventProperties
6696
+ });
6697
+ } catch (err) {
6698
+ return res.status(502).json({
6699
+ error: "vars_resolve_failed",
6700
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6701
+ });
6702
+ }
6300
6703
  const renderCtx = {
6301
- contact: sampleContact,
6704
+ ...resolved,
6705
+ contact,
6302
6706
  vars: req.body?.vars ?? {},
6707
+ event: eventProperties ?? {},
6303
6708
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
6304
6709
  senderAddress: mailer.config.senderAddress
6305
6710
  };
6306
6711
  const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
6307
6712
  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 });
6713
+ return res.json({
6714
+ subject: rendered.subject,
6715
+ preheader: rendered.preheader,
6716
+ html: rendered.html,
6717
+ plainText: rendered.plainText,
6718
+ contact: { externalId: contact.externalId, email: contact.email }
6719
+ });
6309
6720
  })
6310
6721
  );
6311
6722
  r.post(
6312
6723
  "/templates/:slug/send-test",
6313
6724
  asyncHandler(async (req, res) => {
6314
- const { to, sampleData } = req.body ?? {};
6725
+ const { to, sampleData, contactId, eventProperties } = req.body ?? {};
6315
6726
  if (!to) return res.status(400).json({ error: "to_required" });
6316
6727
  const tpl = await c.templates.findOne({ slug: req.params.slug });
6317
6728
  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;
6729
+ let contact;
6730
+ if (typeof contactId === "string" && contactId) {
6731
+ const found = await mailer.adapter.getById(contactId);
6732
+ if (!found) return res.status(404).json({ error: "contact_not_found", contactId });
6733
+ contact = { ...found, email: to };
6734
+ } else {
6735
+ contact = sampleData?.contact ?? {
6736
+ externalId: "test-recipient",
6737
+ email: to,
6738
+ tags: [],
6739
+ fields: { firstName: "Test" }
6740
+ };
6741
+ contact.email = to;
6742
+ }
6743
+ const evProps = eventProperties && typeof eventProperties === "object" ? eventProperties : void 0;
6744
+ let resolved = {};
6745
+ try {
6746
+ resolved = await resolveVars(mailer.config.varsAdapter, contact, {
6747
+ reason: "test",
6748
+ templateSlug: tpl.slug,
6749
+ eventProperties: evProps
6750
+ });
6751
+ } catch (err) {
6752
+ return res.status(502).json({
6753
+ error: "vars_resolve_failed",
6754
+ message: `varsAdapter.resolve threw: ${String(err?.message ?? err)}`
6755
+ });
6756
+ }
6325
6757
  const renderCtx = {
6758
+ ...resolved,
6326
6759
  contact,
6327
6760
  vars: sampleData?.vars ?? {},
6761
+ event: evProps ?? {},
6328
6762
  unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
6329
6763
  senderAddress: mailer.config.senderAddress
6330
6764
  };
@@ -6975,7 +7409,7 @@ var DEDUPE_POLICIES = [
6975
7409
  ];
6976
7410
 
6977
7411
  // src/server/index.ts
6978
- var VERSION = "0.1.0";
7412
+ var VERSION = "0.8.0";
6979
7413
 
6980
7414
  exports.DEDUPE_POLICIES = DEDUPE_POLICIES;
6981
7415
  exports.FLOW_STEP_KINDS = FLOW_STEP_KINDS;
@@ -6988,11 +7422,13 @@ exports.applyTracking = applyTracking;
6988
7422
  exports.applyWebhookEvent = applyWebhookEvent;
6989
7423
  exports.compileMailyTemplate = compileMailyTemplate;
6990
7424
  exports.compileTemplate = compileTemplate;
7425
+ exports.computeDeliveryTime = computeDeliveryTime;
6991
7426
  exports.createAdminRouter = createAdminRouter;
6992
7427
  exports.createPublicRouter = createPublicRouter;
6993
7428
  exports.defaultFlowStep = defaultFlowStep;
6994
7429
  exports.defaultPredicate = defaultPredicate;
6995
7430
  exports.defaultSegmentFilter = defaultSegmentFilter;
7431
+ exports.defineVars = defineVars;
6996
7432
  exports.derivePlaintext = derivePlaintext;
6997
7433
  exports.dispatchSend = dispatchSend;
6998
7434
  exports.ensureIndexes = ensureIndexes;
@@ -7006,6 +7442,7 @@ exports.sha256Hex = sha256Hex;
7006
7442
  exports.signUnsubscribeToken = signUnsubscribeToken;
7007
7443
  exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
7008
7444
  exports.validateSenderDomain = validateSenderDomain;
7445
+ exports.varsJsonSchema = varsJsonSchema;
7009
7446
  exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
7010
7447
  //# sourceMappingURL=index.cjs.map
7011
7448
  //# sourceMappingURL=index.cjs.map