@runbandit/sdk 2.0.0 → 2.0.1

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
@@ -34,6 +34,12 @@ function defaultSessionStorage() {
34
34
  }
35
35
 
36
36
  // src/client.ts
37
+ function currentPageUrl() {
38
+ if (typeof location !== "undefined" && location.origin && location.pathname) {
39
+ return location.origin + location.pathname;
40
+ }
41
+ return void 0;
42
+ }
37
43
  var BanditsClient = class {
38
44
  config;
39
45
  namedEventQueue = [];
@@ -58,6 +64,8 @@ var BanditsClient = class {
58
64
  context,
59
65
  sessionId: this.sessionId
60
66
  };
67
+ const pageUrl = currentPageUrl();
68
+ if (pageUrl) request.pageUrl = pageUrl;
61
69
  const response = await this.makeRequest(
62
70
  "/assignments",
63
71
  "POST",
@@ -92,7 +100,7 @@ var BanditsClient = class {
92
100
  sessionId: this.sessionId
93
101
  };
94
102
  if (opts?.immediate) {
95
- const url = `${this.config.apiUrl}/events`;
103
+ const url = `${this.normalizeApiUrl()}/events`;
96
104
  try {
97
105
  void fetch(url, {
98
106
  method: "POST",
@@ -117,7 +125,7 @@ var BanditsClient = class {
117
125
  this.namedEventQueue = [];
118
126
  if (namedEvents.length === 0) return;
119
127
  try {
120
- await fetch(`${this.config.apiUrl}/events/batch`, {
128
+ await fetch(`${this.normalizeApiUrl()}/events/batch`, {
121
129
  method: "POST",
122
130
  headers: {
123
131
  "Authorization": `Bearer ${this.config.apiKey}`,
@@ -186,6 +194,8 @@ var BanditsClient = class {
186
194
  context,
187
195
  sessionId: this.sessionId
188
196
  };
197
+ const pageUrl = currentPageUrl();
198
+ if (pageUrl) request.pageUrl = pageUrl;
189
199
  const response = await this.makeRequest("/bundles/assignment", "POST", request);
190
200
  if (!response.success || !response.data) {
191
201
  throw new Error(response.error || "Failed to get bundle assignment");
@@ -211,6 +221,17 @@ var BanditsClient = class {
211
221
  generateId() {
212
222
  return generateId();
213
223
  }
224
+ /**
225
+ * Normalize the apiUrl by adding /api prefix if not present.
226
+ * Supports both 'https://runbandit.com' and 'https://runbandit.com/api'.
227
+ */
228
+ normalizeApiUrl() {
229
+ let baseUrl = this.config.apiUrl.replace(/\/$/, "");
230
+ if (!baseUrl.endsWith("/api")) {
231
+ baseUrl = `${baseUrl}/api`;
232
+ }
233
+ return baseUrl;
234
+ }
214
235
  getOrCreateSessionId() {
215
236
  return getOrCreateSessionId({
216
237
  storage: defaultSessionStorage(),
@@ -218,7 +239,7 @@ var BanditsClient = class {
218
239
  });
219
240
  }
220
241
  async makeRequest(endpoint, method, body, extraHeaders) {
221
- const url = `${this.config.apiUrl}${endpoint}`;
242
+ const url = `${this.normalizeApiUrl()}${endpoint}`;
222
243
  const headers = {
223
244
  "Authorization": `Bearer ${this.config.apiKey}`,
224
245
  "Content-Type": "application/json",
@@ -267,6 +288,13 @@ var BanditsClient = class {
267
288
  };
268
289
 
269
290
  // ../shared/dist/types.js
291
+ var PLACEMENTS = [
292
+ "hero_headline",
293
+ "hero_subhead",
294
+ "cta_primary",
295
+ "banner_text",
296
+ "badge_text"
297
+ ];
270
298
  var ExperimentStatus;
271
299
  (function(ExperimentStatus2) {
272
300
  ExperimentStatus2["DRAFT"] = "draft";
@@ -280,6 +308,14 @@ var BanditAlgorithm;
280
308
  BanditAlgorithm2["THOMPSON_SAMPLING"] = "thompson_sampling";
281
309
  BanditAlgorithm2["CONTEXTUAL_LINEAR"] = "contextual_linear";
282
310
  })(BanditAlgorithm || (BanditAlgorithm = {}));
311
+ var Posterior;
312
+ (function(Posterior2) {
313
+ Posterior2["BETA"] = "beta";
314
+ Posterior2["NORMAL_GAMMA"] = "normal_gamma";
315
+ })(Posterior || (Posterior = {}));
316
+ function normalizePosterior(posterior) {
317
+ return posterior === Posterior.NORMAL_GAMMA ? Posterior.NORMAL_GAMMA : Posterior.BETA;
318
+ }
283
319
  var ForgettingMode;
284
320
  (function(ForgettingMode2) {
285
321
  ForgettingMode2["NONE"] = "none";
@@ -450,6 +486,251 @@ function buildSubdomainUrl(subdomain, baseDomain, path = "", protocol = "https")
450
486
  return path ? `${url}${path.startsWith("/") ? path : "/" + path}` : url;
451
487
  }
452
488
 
489
+ // ../shared/dist/email-token.js
490
+ var EMAIL_TOKEN_PARAM = "bt";
491
+ var encoder = new TextEncoder();
492
+ function base64UrlEncode(bytes) {
493
+ let binary = "";
494
+ for (const byte of bytes)
495
+ binary += String.fromCharCode(byte);
496
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
497
+ }
498
+ function base64UrlDecode(value) {
499
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
500
+ const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
501
+ const bytes = new Uint8Array(binary.length);
502
+ for (let i = 0; i < binary.length; i++)
503
+ bytes[i] = binary.charCodeAt(i);
504
+ return bytes;
505
+ }
506
+ async function hmac(secret, message) {
507
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
508
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(message)));
509
+ }
510
+ function timingSafeEqual(a, b) {
511
+ if (a.length !== b.length)
512
+ return false;
513
+ let diff = 0;
514
+ for (let i = 0; i < a.length; i++)
515
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
516
+ return diff === 0;
517
+ }
518
+ async function hashRecipient(recipientId, companySecret) {
519
+ const digest = await hmac(companySecret, `recipient:${recipientId}`);
520
+ return base64UrlEncode(digest.slice(0, 16));
521
+ }
522
+ async function mintEmailToken(payload, key) {
523
+ const body = base64UrlEncode(encoder.encode(JSON.stringify({
524
+ c: payload.c,
525
+ r: payload.r,
526
+ k: payload.k,
527
+ l: payload.l,
528
+ v: key.version
529
+ })));
530
+ const signature = base64UrlEncode(await hmac(key.secret, body));
531
+ return `${body}.${signature}`;
532
+ }
533
+ async function verifyEmailToken(token, keys) {
534
+ const parts = token.split(".");
535
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
536
+ return { valid: false, reason: "malformed" };
537
+ }
538
+ const [body, signature] = parts;
539
+ let payload;
540
+ try {
541
+ payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body)));
542
+ } catch {
543
+ return { valid: false, reason: "malformed" };
544
+ }
545
+ if (!payload || typeof payload.c !== "string" || typeof payload.r !== "string" || !Array.isArray(payload.k) || !payload.k.every((arm) => Number.isInteger(arm) && arm >= 0) || !Number.isInteger(payload.l) || payload.l < 0 || !Number.isInteger(payload.v)) {
546
+ return { valid: false, reason: "malformed" };
547
+ }
548
+ const key = keys.find((candidate) => candidate.version === payload.v);
549
+ if (!key)
550
+ return { valid: false, reason: "unknown_key" };
551
+ const expected = base64UrlEncode(await hmac(key.secret, body));
552
+ if (!timingSafeEqual(expected, signature)) {
553
+ return { valid: false, reason: "bad_signature" };
554
+ }
555
+ return { valid: true, payload };
556
+ }
557
+ function appendEmailToken(url, token) {
558
+ const hashIndex = url.indexOf("#");
559
+ const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
560
+ const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
561
+ const separator = base.includes("?") ? "&" : "?";
562
+ return `${base}${separator}${EMAIL_TOKEN_PARAM}=${encodeURIComponent(token)}${fragment}`;
563
+ }
564
+
565
+ // ../shared/dist/email-plan.js
566
+ async function uniformFor(salt, recipientId, slotIndex) {
567
+ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(salt), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
568
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${recipientId}:${slotIndex}`)));
569
+ let value = 0;
570
+ for (let i = 0; i < 6; i++)
571
+ value = value * 256 + mac[i];
572
+ return value / 2 ** 48;
573
+ }
574
+ function pickArm(probabilities, uniform) {
575
+ if (probabilities.length === 0)
576
+ return -1;
577
+ const total = probabilities.reduce((sum, p) => sum + Math.max(p, 0), 0);
578
+ if (total <= 0) {
579
+ return Math.min(probabilities.length - 1, Math.floor(uniform * probabilities.length));
580
+ }
581
+ const target = uniform * total;
582
+ let cumulative = 0;
583
+ for (let i = 0; i < probabilities.length; i++) {
584
+ cumulative += Math.max(probabilities[i], 0);
585
+ if (target < cumulative)
586
+ return i;
587
+ }
588
+ return probabilities.length - 1;
589
+ }
590
+ async function sampleCombination(plan, recipientId) {
591
+ const combination = [];
592
+ for (let i = 0; i < plan.slots.length; i++) {
593
+ const slot = plan.slots[i];
594
+ const uniform = await uniformFor(plan.salt, recipientId, i);
595
+ const chosen = pickArm(slot.probabilities, uniform);
596
+ combination.push(chosen === -1 ? 0 : slot.arms[chosen]?.armIndex ?? 0);
597
+ }
598
+ return combination;
599
+ }
600
+ async function impressionManifest(plan, recipientIds) {
601
+ const counts = /* @__PURE__ */ new Map();
602
+ for (const recipientId of recipientIds) {
603
+ for (let i = 0; i < plan.slots.length; i++) {
604
+ const slot = plan.slots[i];
605
+ const uniform = await uniformFor(plan.salt, recipientId, i);
606
+ const chosen = pickArm(slot.probabilities, uniform);
607
+ const arm = slot.arms[chosen === -1 ? 0 : chosen];
608
+ if (!arm)
609
+ continue;
610
+ const key = `${slot.experimentId}:${arm.treatmentId}`;
611
+ const entry = counts.get(key);
612
+ if (entry)
613
+ entry.count++;
614
+ else
615
+ counts.set(key, {
616
+ experimentId: slot.experimentId,
617
+ treatmentId: arm.treatmentId,
618
+ count: 1
619
+ });
620
+ }
621
+ }
622
+ return Array.from(counts.values());
623
+ }
624
+
625
+ // ../shared/dist/email-blocks.js
626
+ function isSlotBinding(value) {
627
+ return typeof value === "object" && value !== null && typeof value.$slot === "string";
628
+ }
629
+ var CONTENT_WIDTH = 600;
630
+ function escapeHtml(value) {
631
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
632
+ }
633
+ function resolve(value, slots, missing) {
634
+ if (value === void 0)
635
+ return "";
636
+ if (!isSlotBinding(value))
637
+ return value;
638
+ const resolved = slots[value.$slot];
639
+ if (resolved === void 0) {
640
+ missing.push(value.$slot);
641
+ return "";
642
+ }
643
+ return resolved;
644
+ }
645
+ function extractDocumentSlots(document) {
646
+ const names = [];
647
+ const seen = /* @__PURE__ */ new Set();
648
+ const visit = (value) => {
649
+ if (isSlotBinding(value)) {
650
+ if (!seen.has(value.$slot)) {
651
+ seen.add(value.$slot);
652
+ names.push(value.$slot);
653
+ }
654
+ return;
655
+ }
656
+ if (Array.isArray(value))
657
+ value.forEach(visit);
658
+ else if (typeof value === "object" && value !== null)
659
+ Object.values(value).forEach(visit);
660
+ };
661
+ visit(document.subject);
662
+ visit(document.preheader);
663
+ document.blocks.forEach(visit);
664
+ return names;
665
+ }
666
+ function renderBlock(block, slots, missing) {
667
+ const align = block.align ?? "left";
668
+ switch (block.type) {
669
+ case "heading": {
670
+ const size = block.level === 3 ? 18 : block.level === 2 ? 22 : 28;
671
+ const color = escapeHtml(resolve(block.color, slots, missing) || "#111111");
672
+ return `<tr><td style="padding:12px 24px;text-align:${align};font-family:Arial,Helvetica,sans-serif;font-size:${size}px;line-height:1.3;font-weight:bold;color:${color}">${escapeHtml(resolve(block.text, slots, missing))}</td></tr>`;
673
+ }
674
+ case "text": {
675
+ const color = escapeHtml(resolve(block.color, slots, missing) || "#333333");
676
+ return `<tr><td style="padding:8px 24px;text-align:${align};font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.5;color:${color}">${escapeHtml(resolve(block.text, slots, missing))}</td></tr>`;
677
+ }
678
+ case "button": {
679
+ const href = escapeHtml(resolve(block.href, slots, missing));
680
+ const label = escapeHtml(resolve(block.text, slots, missing));
681
+ const bg = escapeHtml(resolve(block.backgroundColor, slots, missing) || "#1a73e8");
682
+ const fg = escapeHtml(resolve(block.textColor, slots, missing) || "#ffffff");
683
+ return `<tr><td style="padding:16px 24px;text-align:${align}">
684
+ <!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${href}" style="height:44px;v-text-anchor:middle;width:220px;" arcsize="12%" strokecolor="${bg}" fillcolor="${bg}"><w:anchorlock/><center style="color:${fg};font-family:Arial,sans-serif;font-size:16px;font-weight:bold;">${label}</center></v:roundrect><![endif]-->
685
+ <!--[if !mso]><!--><a href="${href}" style="display:inline-block;padding:13px 28px;background-color:${bg};color:${fg};font-family:Arial,Helvetica,sans-serif;font-size:16px;font-weight:bold;text-decoration:none;border-radius:6px">${label}</a><!--<![endif]-->
686
+ </td></tr>`;
687
+ }
688
+ case "image": {
689
+ const src = escapeHtml(resolve(block.src, slots, missing));
690
+ const alt = escapeHtml(resolve(block.alt, slots, missing));
691
+ const width = block.width ?? CONTENT_WIDTH;
692
+ const img = `<img src="${src}" alt="${alt}" width="${width}" style="display:block;border:0;outline:none;text-decoration:none;max-width:100%;height:auto" />`;
693
+ const href = block.href ? escapeHtml(resolve(block.href, slots, missing)) : "";
694
+ return `<tr><td style="padding:0;text-align:center">${href ? `<a href="${href}">${img}</a>` : img}</td></tr>`;
695
+ }
696
+ case "divider":
697
+ return `<tr><td style="padding:12px 24px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-top:1px solid ${escapeHtml(block.color ?? "#e5e5e5")};font-size:0;line-height:0">&nbsp;</td></tr></table></td></tr>`;
698
+ case "spacer":
699
+ return `<tr><td style="height:${block.height ?? 24}px;font-size:0;line-height:0">&nbsp;</td></tr>`;
700
+ default:
701
+ return "";
702
+ }
703
+ }
704
+ function renderEmailDocument(document, slots) {
705
+ const missingSlots = [];
706
+ const width = document.contentWidth ?? CONTENT_WIDTH;
707
+ const background = escapeHtml(document.backgroundColor ?? "#f4f4f5");
708
+ const subject = resolve(document.subject, slots, missingSlots);
709
+ const preheader = resolve(document.preheader, slots, missingSlots);
710
+ const body = document.blocks.map((block) => renderBlock(block, slots, missingSlots)).join("\n");
711
+ const html = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
712
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
713
+ <head>
714
+ <meta charset="utf-8" />
715
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
716
+ <meta name="x-apple-disable-message-reformatting" />
717
+ <title>${escapeHtml(subject)}</title>
718
+ <!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
719
+ </head>
720
+ <body style="margin:0;padding:0;background-color:${background}">
721
+ <div style="display:none;font-size:0;line-height:0;max-height:0;overflow:hidden;mso-hide:all">${escapeHtml(preheader)}</div>
722
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${background}">
723
+ <tr><td align="center" style="padding:24px 12px">
724
+ <table role="presentation" width="${width}" cellpadding="0" cellspacing="0" border="0" style="width:${width}px;max-width:100%;background-color:#ffffff;border-radius:8px">
725
+ ${body}
726
+ </table>
727
+ </td></tr>
728
+ </table>
729
+ </body>
730
+ </html>`;
731
+ return { subject, html, missingSlots };
732
+ }
733
+
453
734
  // src/snapshot-cache.ts
454
735
  var STORAGE_KEY = "bandits_snapshot";
455
736
  var REPORT_QUEUE_KEY = "bandits_pending_reports";
@@ -672,6 +953,201 @@ var SnapshotClient = class {
672
953
  }
673
954
  }
674
955
  };
956
+
957
+ // src/email-links.ts
958
+ var UNTRACKABLE_SCHEME = /^(mailto:|tel:|sms:|javascript:|data:|#)/i;
959
+ var NOTRACK_ATTRIBUTE = "data-bandit-notrack";
960
+ function isTrackable(url) {
961
+ const trimmed = url.trim();
962
+ if (!trimmed || UNTRACKABLE_SCHEME.test(trimmed)) return false;
963
+ return /^https?:\/\//i.test(trimmed);
964
+ }
965
+ function appendToken(url, token) {
966
+ const hashIndex = url.indexOf("#");
967
+ const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
968
+ const base = hashIndex === -1 ? url : url.slice(0, hashIndex);
969
+ const separator = base.includes("?") ? base.includes("&amp;") ? "&amp;" : "&" : "?";
970
+ return `${base}${separator}${EMAIL_TOKEN_PARAM}=${token}${fragment}`;
971
+ }
972
+ function scanAnchors(html) {
973
+ const anchors = [];
974
+ let i = 0;
975
+ while (i < html.length) {
976
+ const lt = html.indexOf("<", i);
977
+ if (lt === -1) break;
978
+ if (html.startsWith("<!--", lt)) {
979
+ if (html.startsWith("<!--[if", lt) || html.startsWith("<!--<![endif]", lt)) {
980
+ const close = html.indexOf(">", lt);
981
+ i = close === -1 ? html.length : close + 1;
982
+ continue;
983
+ }
984
+ const end = html.indexOf("-->", lt);
985
+ i = end === -1 ? html.length : end + 3;
986
+ continue;
987
+ }
988
+ const rawText = /^<(script|style)\b/i.exec(html.slice(lt, lt + 8));
989
+ if (rawText) {
990
+ const closing = new RegExp(`</${rawText[1]}\\s*>`, "i");
991
+ const rest = html.slice(lt);
992
+ const match = closing.exec(rest);
993
+ i = match ? lt + match.index + match[0].length : html.length;
994
+ continue;
995
+ }
996
+ const isLinkElement = /^<a[\s/>]/i.test(html.slice(lt, lt + 3)) || /^<v:roundrect[\s/>]/i.test(html.slice(lt, lt + 13));
997
+ if (!isLinkElement) {
998
+ i = lt + 1;
999
+ continue;
1000
+ }
1001
+ const tagEnd = findTagEnd(html, lt);
1002
+ if (tagEnd === -1) break;
1003
+ const tag = html.slice(lt, tagEnd);
1004
+ if (!new RegExp(NOTRACK_ATTRIBUTE, "i").test(tag)) {
1005
+ const href = findHref(html, lt, tagEnd);
1006
+ if (href && isTrackable(href.url)) anchors.push(href);
1007
+ }
1008
+ i = tagEnd;
1009
+ }
1010
+ return anchors;
1011
+ }
1012
+ function findTagEnd(html, start) {
1013
+ let quote = null;
1014
+ for (let i = start + 1; i < html.length; i++) {
1015
+ const ch = html[i];
1016
+ if (quote) {
1017
+ if (ch === quote) quote = null;
1018
+ continue;
1019
+ }
1020
+ if (ch === '"' || ch === "'") {
1021
+ quote = ch;
1022
+ continue;
1023
+ }
1024
+ if (ch === ">") return i + 1;
1025
+ }
1026
+ return -1;
1027
+ }
1028
+ function findHref(html, tagStart, tagEnd) {
1029
+ const tag = html.slice(tagStart, tagEnd);
1030
+ const match = /\shref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
1031
+ if (!match) return null;
1032
+ const quoted = match[2] !== void 0 || match[3] !== void 0;
1033
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
1034
+ const valueStart = tagStart + match.index + match[0].indexOf(match[1]) + (quoted ? 1 : 0);
1035
+ return { valueStart, valueEnd: valueStart + value.length, url: value };
1036
+ }
1037
+ function extractEmailLinks(html) {
1038
+ const seen = /* @__PURE__ */ new Set();
1039
+ const urls = [];
1040
+ for (const anchor of scanAnchors(html)) {
1041
+ if (seen.has(anchor.url)) continue;
1042
+ seen.add(anchor.url);
1043
+ urls.push(anchor.url);
1044
+ }
1045
+ return urls;
1046
+ }
1047
+ function rewriteEmailLinks(html, options) {
1048
+ const { linkTable, tokens } = options;
1049
+ const indexOfUrl = /* @__PURE__ */ new Map();
1050
+ linkTable.forEach((url, index) => {
1051
+ if (!indexOfUrl.has(url)) indexOfUrl.set(url, index);
1052
+ });
1053
+ const anchors = scanAnchors(html);
1054
+ const untracked = [];
1055
+ let rewritten = 0;
1056
+ let output = html;
1057
+ for (let i = anchors.length - 1; i >= 0; i--) {
1058
+ const anchor = anchors[i];
1059
+ const index = indexOfUrl.get(anchor.url);
1060
+ if (index === void 0 || tokens[index] === void 0) {
1061
+ untracked.push(anchor.url);
1062
+ continue;
1063
+ }
1064
+ output = output.slice(0, anchor.valueStart) + appendToken(anchor.url, tokens[index]) + output.slice(anchor.valueEnd);
1065
+ rewritten++;
1066
+ }
1067
+ return { html: output, rewritten, untracked: untracked.reverse() };
1068
+ }
1069
+
1070
+ // src/email.ts
1071
+ var SLOT_PATTERN = /\{\{\s*bandit\.([a-zA-Z0-9_-]+)\s*\}\}/g;
1072
+ function renderEmail(templateHtml, options) {
1073
+ const missingSlots = [];
1074
+ const substituted = templateHtml.replace(SLOT_PATTERN, (match, name) => {
1075
+ const value = options.slots[name];
1076
+ if (value === void 0) {
1077
+ missingSlots.push(name);
1078
+ return match;
1079
+ }
1080
+ return value;
1081
+ });
1082
+ const { html, rewritten, untracked } = rewriteEmailLinks(substituted, {
1083
+ linkTable: options.linkTable,
1084
+ tokens: options.tokens
1085
+ });
1086
+ return { html, rewritten, untracked, missingSlots };
1087
+ }
1088
+ function extractSlotNames(templateHtml) {
1089
+ const names = /* @__PURE__ */ new Set();
1090
+ for (const match of templateHtml.matchAll(SLOT_PATTERN)) names.add(match[1]);
1091
+ return [...names];
1092
+ }
1093
+
1094
+ // src/email-plan.ts
1095
+ async function renderFromPlan(plan, recipientId, templateHtml, companySecret) {
1096
+ const slots = {};
1097
+ const combination = [];
1098
+ for (let i = 0; i < plan.slots.length; i++) {
1099
+ const slot = plan.slots[i];
1100
+ const chosen = pickArm(slot.probabilities, await uniformFor(plan.salt, recipientId, i));
1101
+ const index = chosen === -1 ? 0 : chosen;
1102
+ combination.push(slot.arms[index]?.armIndex ?? 0);
1103
+ const config = slot.configs[index];
1104
+ slots[slot.name] = typeof config === "string" ? config : config?.content ?? config?.text ?? "";
1105
+ }
1106
+ const recipientHash = await hashRecipient(recipientId, companySecret);
1107
+ const tokens = await Promise.all(
1108
+ plan.linkTable.map(
1109
+ (_, linkIndex) => mintEmailToken(
1110
+ { c: plan.campaignId, r: recipientHash, k: combination, l: linkIndex },
1111
+ plan.signingKey
1112
+ )
1113
+ )
1114
+ );
1115
+ const rendered = renderEmail(templateHtml, {
1116
+ slots,
1117
+ linkTable: plan.linkTable,
1118
+ tokens
1119
+ });
1120
+ return { ...rendered, combination, tokens };
1121
+ }
1122
+ function shouldRefreshPlan(plan, recipientsRenderedSincePlan, now = Date.now()) {
1123
+ return recipientsRenderedSincePlan >= plan.refreshEvery || now >= plan.expiresAt;
1124
+ }
1125
+ async function reportPlanImpressions(plan, recipientIds, options) {
1126
+ const counts = await impressionManifest(plan, recipientIds);
1127
+ if (counts.length === 0) return { recorded: false, alreadyRecorded: false };
1128
+ const response = await fetch(`${options.apiUrl}/api/email/impressions`, {
1129
+ method: "POST",
1130
+ headers: { "Content-Type": "application/json", "x-api-key": options.apiKey },
1131
+ body: JSON.stringify({
1132
+ campaignId: plan.campaignId,
1133
+ // Derived from the recipients this call covers, so a retry of the same
1134
+ // chunk collapses onto the same key without the caller needing to
1135
+ // invent and persist an idempotency token of its own.
1136
+ batchKey: options.batchKey ?? await batchKeyFor(plan.campaignId, recipientIds),
1137
+ counts
1138
+ })
1139
+ });
1140
+ if (!response.ok) {
1141
+ throw new Error(`Failed to report impressions: ${response.status}`);
1142
+ }
1143
+ const body = await response.json();
1144
+ return body.data ?? { recorded: false, alreadyRecorded: false };
1145
+ }
1146
+ async function batchKeyFor(campaignId, recipientIds) {
1147
+ const material = `${campaignId}|${[...recipientIds].sort().join(",")}`;
1148
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material));
1149
+ return Array.from(new Uint8Array(digest).slice(0, 16)).map((b) => b.toString(16).padStart(2, "0")).join("");
1150
+ }
675
1151
  export {
676
1152
  BanditAlgorithm,
677
1153
  BanditsClient as BanditClient,
@@ -680,27 +1156,49 @@ export {
680
1156
  DEFAULT_HALF_LIFE_MS,
681
1157
  DEFAULT_SESSION_TTL_MS,
682
1158
  DEFAULT_WINDOW_MS,
1159
+ EMAIL_TOKEN_PARAM,
683
1160
  ExperimentStatus,
684
1161
  ForgettingMode,
685
1162
  FunnelStatus,
1163
+ PLACEMENTS,
1164
+ Posterior,
686
1165
  SESSION_CREATED_AT_KEY,
687
1166
  SESSION_ID_KEY,
688
1167
  STATIONARY,
689
1168
  SnapshotClient,
1169
+ appendEmailToken,
690
1170
  buildSubdomainUrl,
691
1171
  calculateConfidenceInterval,
1172
+ extractDocumentSlots,
1173
+ extractEmailLinks,
1174
+ extractSlotNames,
692
1175
  extractSubdomain,
693
1176
  generateId2 as generateId,
694
1177
  generateSubdomain,
695
1178
  getContentTypeCategory,
696
1179
  getOrCreateSessionId,
1180
+ hashRecipient,
1181
+ impressionManifest,
1182
+ isSlotBinding,
697
1183
  isValidEmail,
698
1184
  isValidSubdomain,
1185
+ mintEmailToken,
699
1186
  normalizeAlgorithm,
1187
+ normalizePosterior,
700
1188
  normalizeTreatmentWeights,
1189
+ pickArm,
701
1190
  pickTreatment,
1191
+ renderEmail,
1192
+ renderEmailDocument,
1193
+ renderFromPlan,
1194
+ reportPlanImpressions,
702
1195
  resolveForgetting,
1196
+ rewriteEmailLinks,
1197
+ sampleCombination,
703
1198
  sanitizeMetadata,
704
- validateTreatmentWeights
1199
+ shouldRefreshPlan,
1200
+ uniformFor,
1201
+ validateTreatmentWeights,
1202
+ verifyEmailToken
705
1203
  };
706
1204
  //# sourceMappingURL=index.js.map