@shware/workflow 5.0.1 → 6.1.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.
Files changed (190) hide show
  1. package/README.md +224 -53
  2. package/dist/aws/index.cjs +1 -1
  3. package/dist/aws/index.mjs +1 -1
  4. package/dist/client.cjs +68 -4
  5. package/dist/client.cjs.map +1 -1
  6. package/dist/client.d.cts +39 -3
  7. package/dist/client.d.cts.map +1 -1
  8. package/dist/client.d.mts +39 -3
  9. package/dist/client.d.mts.map +1 -1
  10. package/dist/client.mjs +68 -4
  11. package/dist/client.mjs.map +1 -1
  12. package/dist/cloudflare/index.cjs +4 -4
  13. package/dist/cloudflare/index.mjs +4 -4
  14. package/dist/cloudflare/runner.cjs +1 -1
  15. package/dist/cloudflare/runner.d.cts +1 -1
  16. package/dist/cloudflare/runner.d.mts +1 -1
  17. package/dist/cloudflare/runner.mjs +1 -1
  18. package/dist/config.cjs +24 -3
  19. package/dist/config.cjs.map +1 -1
  20. package/dist/config.d.cts +15 -9
  21. package/dist/config.d.cts.map +1 -1
  22. package/dist/config.d.mts +15 -9
  23. package/dist/config.d.mts.map +1 -1
  24. package/dist/config.mjs +25 -4
  25. package/dist/config.mjs.map +1 -1
  26. package/dist/contract.cjs +11 -0
  27. package/dist/contract.cjs.map +1 -1
  28. package/dist/contract.d.cts +28 -7
  29. package/dist/contract.d.cts.map +1 -1
  30. package/dist/contract.d.mts +28 -7
  31. package/dist/contract.d.mts.map +1 -1
  32. package/dist/contract.mjs +11 -0
  33. package/dist/contract.mjs.map +1 -1
  34. package/dist/dsl/bundle.cjs +4 -3
  35. package/dist/dsl/bundle.cjs.map +1 -1
  36. package/dist/dsl/bundle.d.cts +5 -1
  37. package/dist/dsl/bundle.d.cts.map +1 -1
  38. package/dist/dsl/bundle.d.mts +5 -1
  39. package/dist/dsl/bundle.d.mts.map +1 -1
  40. package/dist/dsl/bundle.mjs +4 -3
  41. package/dist/dsl/bundle.mjs.map +1 -1
  42. package/dist/dsl/flow.cjs +2 -1
  43. package/dist/dsl/flow.cjs.map +1 -1
  44. package/dist/dsl/flow.d.cts +1 -1
  45. package/dist/dsl/flow.d.cts.map +1 -1
  46. package/dist/dsl/flow.d.mts +1 -1
  47. package/dist/dsl/flow.d.mts.map +1 -1
  48. package/dist/dsl/flow.mjs +2 -1
  49. package/dist/dsl/flow.mjs.map +1 -1
  50. package/dist/dsl/workflow.cjs +2 -2
  51. package/dist/dsl/workflow.mjs +2 -2
  52. package/dist/email-sender.cjs +3 -2
  53. package/dist/email-sender.cjs.map +1 -1
  54. package/dist/email-sender.d.cts +5 -4
  55. package/dist/email-sender.d.cts.map +1 -1
  56. package/dist/email-sender.d.mts +5 -4
  57. package/dist/email-sender.d.mts.map +1 -1
  58. package/dist/email-sender.mjs +3 -2
  59. package/dist/email-sender.mjs.map +1 -1
  60. package/dist/email.cjs +66 -11
  61. package/dist/email.cjs.map +1 -1
  62. package/dist/email.d.cts +72 -12
  63. package/dist/email.d.cts.map +1 -1
  64. package/dist/email.d.mts +72 -12
  65. package/dist/email.d.mts.map +1 -1
  66. package/dist/email.mjs +63 -12
  67. package/dist/email.mjs.map +1 -1
  68. package/dist/engine/condition.cjs.map +1 -1
  69. package/dist/engine/condition.d.cts.map +1 -1
  70. package/dist/engine/condition.d.mts.map +1 -1
  71. package/dist/engine/condition.mjs.map +1 -1
  72. package/dist/engine/index.cjs +1 -1
  73. package/dist/engine/index.mjs +1 -1
  74. package/dist/engine/interpreter.cjs +21 -4
  75. package/dist/engine/interpreter.cjs.map +1 -1
  76. package/dist/engine/interpreter.mjs +21 -4
  77. package/dist/engine/interpreter.mjs.map +1 -1
  78. package/dist/engine/ports.d.cts +14 -2
  79. package/dist/engine/ports.d.cts.map +1 -1
  80. package/dist/engine/ports.d.mts +14 -2
  81. package/dist/engine/ports.d.mts.map +1 -1
  82. package/dist/host/channels.cjs +1 -1
  83. package/dist/host/channels.mjs +1 -1
  84. package/dist/host/execute.cjs +2 -2
  85. package/dist/host/execute.d.cts +1 -1
  86. package/dist/host/execute.d.mts +1 -1
  87. package/dist/host/execute.mjs +2 -2
  88. package/dist/host/http.cjs +75 -5
  89. package/dist/host/http.cjs.map +1 -1
  90. package/dist/host/http.d.cts +2 -2
  91. package/dist/host/http.d.cts.map +1 -1
  92. package/dist/host/http.d.mts +2 -2
  93. package/dist/host/http.d.mts.map +1 -1
  94. package/dist/host/http.mjs +76 -6
  95. package/dist/host/http.mjs.map +1 -1
  96. package/dist/host/ingest.cjs +173 -4
  97. package/dist/host/ingest.cjs.map +1 -1
  98. package/dist/host/ingest.d.cts +99 -0
  99. package/dist/host/ingest.d.cts.map +1 -1
  100. package/dist/host/ingest.d.mts +99 -0
  101. package/dist/host/ingest.d.mts.map +1 -1
  102. package/dist/host/ingest.mjs +167 -5
  103. package/dist/host/ingest.mjs.map +1 -1
  104. package/dist/host/journey-host.cjs +10 -8
  105. package/dist/host/journey-host.cjs.map +1 -1
  106. package/dist/host/journey-host.mjs +10 -8
  107. package/dist/host/journey-host.mjs.map +1 -1
  108. package/dist/index.cjs +8 -2
  109. package/dist/index.d.cts +5 -5
  110. package/dist/index.d.mts +5 -5
  111. package/dist/index.mjs +5 -5
  112. package/dist/ir.cjs +11 -2
  113. package/dist/ir.cjs.map +1 -1
  114. package/dist/ir.d.cts +17 -0
  115. package/dist/ir.d.cts.map +1 -1
  116. package/dist/ir.d.mts +17 -0
  117. package/dist/ir.d.mts.map +1 -1
  118. package/dist/ir.mjs +11 -3
  119. package/dist/ir.mjs.map +1 -1
  120. package/dist/react-email.cjs +6 -5
  121. package/dist/react-email.cjs.map +1 -1
  122. package/dist/react-email.d.cts.map +1 -1
  123. package/dist/react-email.d.mts.map +1 -1
  124. package/dist/react-email.mjs +6 -5
  125. package/dist/react-email.mjs.map +1 -1
  126. package/dist/reporting/observer.cjs +4 -1
  127. package/dist/reporting/observer.cjs.map +1 -1
  128. package/dist/reporting/observer.d.cts +2 -1
  129. package/dist/reporting/observer.d.cts.map +1 -1
  130. package/dist/reporting/observer.d.mts +2 -1
  131. package/dist/reporting/observer.d.mts.map +1 -1
  132. package/dist/reporting/observer.mjs +4 -1
  133. package/dist/reporting/observer.mjs.map +1 -1
  134. package/dist/reporting/tracking.cjs +31 -10
  135. package/dist/reporting/tracking.cjs.map +1 -1
  136. package/dist/reporting/tracking.d.cts +15 -6
  137. package/dist/reporting/tracking.d.cts.map +1 -1
  138. package/dist/reporting/tracking.d.mts +15 -6
  139. package/dist/reporting/tracking.d.mts.map +1 -1
  140. package/dist/reporting/tracking.mjs +31 -11
  141. package/dist/reporting/tracking.mjs.map +1 -1
  142. package/dist/store/facts.cjs +3 -3
  143. package/dist/store/facts.cjs.map +1 -1
  144. package/dist/store/facts.d.cts +2 -2
  145. package/dist/store/facts.d.cts.map +1 -1
  146. package/dist/store/facts.d.mts +2 -2
  147. package/dist/store/facts.d.mts.map +1 -1
  148. package/dist/store/facts.mjs +3 -3
  149. package/dist/store/facts.mjs.map +1 -1
  150. package/dist/store/index.cjs +1 -1
  151. package/dist/store/index.cjs.map +1 -1
  152. package/dist/store/index.d.cts +44 -13
  153. package/dist/store/index.d.cts.map +1 -1
  154. package/dist/store/index.d.mts +44 -13
  155. package/dist/store/index.d.mts.map +1 -1
  156. package/dist/store/index.mjs +1 -1
  157. package/dist/store/index.mjs.map +1 -1
  158. package/dist/store/postgres-reporting.cjs +30 -10
  159. package/dist/store/postgres-reporting.cjs.map +1 -1
  160. package/dist/store/postgres-reporting.d.cts +4 -2
  161. package/dist/store/postgres-reporting.d.cts.map +1 -1
  162. package/dist/store/postgres-reporting.d.mts +4 -2
  163. package/dist/store/postgres-reporting.d.mts.map +1 -1
  164. package/dist/store/postgres-reporting.mjs +30 -10
  165. package/dist/store/postgres-reporting.mjs.map +1 -1
  166. package/dist/store/postgres.cjs +39 -3
  167. package/dist/store/postgres.cjs.map +1 -1
  168. package/dist/store/postgres.d.cts +4 -2
  169. package/dist/store/postgres.d.cts.map +1 -1
  170. package/dist/store/postgres.d.mts +4 -2
  171. package/dist/store/postgres.d.mts.map +1 -1
  172. package/dist/store/postgres.mjs +40 -4
  173. package/dist/store/postgres.mjs.map +1 -1
  174. package/dist/store/tables.cjs +75 -11
  175. package/dist/store/tables.cjs.map +1 -1
  176. package/dist/store/tables.d.cts +17 -1
  177. package/dist/store/tables.d.cts.map +1 -1
  178. package/dist/store/tables.d.mts +17 -1
  179. package/dist/store/tables.d.mts.map +1 -1
  180. package/dist/store/tables.mjs +74 -12
  181. package/dist/store/tables.mjs.map +1 -1
  182. package/dist/templates.cjs +60 -7
  183. package/dist/templates.cjs.map +1 -1
  184. package/dist/templates.d.cts +101 -13
  185. package/dist/templates.d.cts.map +1 -1
  186. package/dist/templates.d.mts +101 -13
  187. package/dist/templates.d.mts.map +1 -1
  188. package/dist/templates.mjs +61 -9
  189. package/dist/templates.mjs.map +1 -1
  190. package/package.json +1 -1
package/dist/ir.mjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"ir.mjs","names":[],"sources":["../src/ir.ts"],"sourcesContent":["import * as z from 'zod/mini';\n\n/**\n * IR (Intermediate Representation) — what the DSL compiles to: serializable\n * JSON. It is the three-way contract between the engine that interprets it,\n * the UI (react-flow) that renders it, and diff / audit tooling.\n *\n * Validation strategy: the zod/mini schemas are the authoritative definition.\n * TS types are either inferred from a schema or hand-written under the same\n * name (zod cannot infer recursive types; the hand-written type is anchored\n * with a ZodMiniType<T> annotation, so any drift fails to compile). IR crosses\n * process boundaries (database, network, UI), so runtime validation is a must.\n *\n * ## Versioning (two independent axes)\n *\n * 1. IR format version (irVersion): evolution of the IR schema itself. Adding\n * optional fields does not bump it; only structurally incompatible changes\n * do (changing a discriminant, dropping a field, altering semantics).\n * Readers dispatch on irVersion to the matching schema/migrator; writers\n * always write the current version.\n * 2. Content version (contentHash): a content-addressed id for a single\n * definition's **execution semantics** — the canonical-JSON hash after\n * metadata (meta / label / reason) is stripped, see hash.ts. It is used to:\n * - pin a version when a user enters, so an in-flight journey runs the\n * pinned version to completion (the default policy; compatible hot\n * updates are a future explicit-migration topic). Known exception:\n * segment conditions are resolved **by id at evaluation time** — a\n * segment redeploy changes behaviour of in-flight journeys that\n * reference it. Pinning covers the workflow's own structure; per-hash\n * segment snapshots are future work;\n * - diff on deploy: same hash means unchanged, skip publishing;\n * - audit: answer \"which version was this user on\" at any point.\n *\n * ## Node ids\n *\n * Derived from the structural path at compile time — stable and deterministic:\n * root level '0', '1', …; the j-th child of a branch's i-th case is\n * '{branchId}.c{i}.{j}', the default arm '{branchId}.o.{j}'; cohort arms use\n * the arm name '{cohortId}.{armName}.{j}'; a waitUntil timeout flow '{id}.t.{j}'.\n * Editing a node's parameters keeps its id; inserting a node shifts the ids of\n * its later siblings (a known trade-off — locating in-flight users relies on\n * the pinned version rather than cross-version id alignment, and cross-version\n * mapping is an explicit migrator's job).\n */\n\nexport const IR_VERSION = 2;\n\n/* -------------------------------- Base values ------------------------------- */\n\nexport const ScalarIR = z.union([z.string(), z.number(), z.boolean()]);\nexport type ScalarIR = z.infer<typeof ScalarIR>;\n\n/** Duration: `value` keeps the DSL literal ('23 hours', for display), `ms` is what the engine uses. */\n/*\n * TODO: `value` is hashed alongside `ms`, so '7 days' / '1 week' / '168 hours'\n * — identical to the engine, which only reads ms — produce three different\n * contentHashes. Rewording a duration therefore reports as a semantic change in\n * plan and repins in-flight users. Moving `value` out of the hash (it is a\n * display string) would fix it; deferred because it changes existing hashes.\n */\nexport const DurationIR = z.object({ value: z.string(), ms: z.number() });\nexport type DurationIR = z.infer<typeof DurationIR>;\n\n/** User-property reference — isomorphic to the DSL's UserPropertyRef once serialized (phantom type dropped). */\nexport const UserPropertyRefIR = z.object({\n type: z.literal('user_property'),\n path: z.string(),\n});\nexport type UserPropertyRefIR = z.infer<typeof UserPropertyRefIR>;\n\n/** A message prop / event payload value: either a literal or a user-property reference. */\nexport const PropValueIR = z.union([ScalarIR, UserPropertyRefIR]);\nexport type PropValueIR = z.infer<typeof PropValueIR>;\n\nexport const ChannelIR = z.enum(['email', 'sms', 'push', 'in_app', 'slack', 'discord', 'survey']);\nexport type ChannelIR = z.infer<typeof ChannelIR>;\n\nexport const WeekdayIR = z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);\nexport type WeekdayIR = z.infer<typeof WeekdayIR>;\n\n/* --------------------------------- Provenance -------------------------------- */\n\n/**\n * Source location of the builder call that produced a definition — the IR's\n * source map. Lives under `meta`, so it is **excluded from contentHash**\n * (see hash.ts): provenance never affects execution identity or plan.\n * `file` is cwd-relative when compiled under Node, a URL under Vite dev.\n */\nexport const SourceLocIR = z.object({\n file: z.string(),\n line: z.number(),\n column: z.number(),\n});\nexport type SourceLocIR = z.infer<typeof SourceLocIR>;\n\n/** Node-level metadata: provenance only, for now. Excluded from contentHash. */\nexport const NodeMetaIR = z.object({\n loc: z.optional(SourceLocIR),\n});\nexport type NodeMetaIR = z.infer<typeof NodeMetaIR>;\n\n/* -------------------------------- Conditions -------------------------------- */\n\nexport const PropertyOperatorIR = z.enum([\n 'eq',\n 'ne',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'not_between',\n 'in_array',\n 'not_in_array',\n 'exists',\n 'not_exists',\n 'contains',\n 'not_contains',\n]);\nexport type PropertyOperatorIR = z.infer<typeof PropertyOperatorIR>;\n\n/**\n * Condition expression tree. The shape of value/values follows `op`:\n * eq/ne/gt/gte/lt/lte/contains/not_contains → value; in_array/not_in_array →\n * values; between/not_between → values = [min, max]; exists/not_exists → neither.\n *\n * `payload` predicates test the payload of an event (dotted paths reach nested\n * fields). They are only valid inside a `where` subtree — `performed.where`\n * and the event trigger's `where` — where an event row is in scope; the\n * compiler (compileBundle) and the evaluator both enforce the placement.\n * TODO: runtime refinement narrowing by op.\n */\n/**\n * Provenance rides on conditions too, so the studio can point at the exact\n * `eq(...)` / `and(...)` that produced a node — same rule as nodes: `meta` is\n * dropped before hashing, so carrying it never affects execution identity.\n */\ninterface ConditionBaseIR {\n meta?: NodeMetaIR | undefined;\n}\n\nexport type ConditionIR =\n | (ConditionBaseIR & { type: 'and'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'or'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'not'; condition: ConditionIR })\n /** Reference a segment by id (defined in SegmentIR, versioned separately). */\n | (ConditionBaseIR & { type: 'segment'; segment: string })\n | (ConditionBaseIR & {\n type: 'property';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n /** Event payload predicate; `path` is dotted for nested fields ('tags.utm_source'). */\n type: 'payload';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n type: 'performed';\n event: string;\n /** Payload filter: only events matching it count (a payload-only subtree). */\n where?: ConditionIR | undefined;\n within?: DurationIR | undefined;\n count?: number | undefined;\n })\n /**\n * The user can be reached on the channel right now: for push, the host's\n * device directory lists at least one device; for the others, the channel's\n * recipient property (email, phone, …) is set.\n */\n | (ConditionBaseIR & { type: 'reachable'; channel: ChannelIR });\n\nconst conditionBase = { meta: z.optional(NodeMetaIR) };\n\nexport const ConditionIR: z.ZodMiniType<ConditionIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({ ...conditionBase, type: z.literal('and'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('or'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('not'), condition: ConditionIR }),\n z.object({ ...conditionBase, type: z.literal('segment'), segment: z.string() }),\n z.object({\n ...conditionBase,\n type: z.literal('property'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('payload'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('performed'),\n event: z.string(),\n where: z.optional(ConditionIR),\n within: z.optional(DurationIR),\n count: z.optional(z.number()),\n }),\n z.object({ ...conditionBase, type: z.literal('reachable'), channel: ChannelIR }),\n ])\n);\n\n/* --------------------------------- Triggers --------------------------------- */\n\n/**\n * Trigger shapes. The event trigger has two independent gates: `where` tests\n * the triggering event's payload (payload-only subtree, evaluated against the\n * incoming event before anything else), `filter` tests the user's profile.\n */\nexport const TriggerIR = z.discriminatedUnion('type', [\n z.object({\n type: z.literal('event'),\n event: z.string(),\n where: z.optional(ConditionIR),\n filter: z.optional(ConditionIR),\n }),\n z.object({ type: z.literal('segment'), segment: z.string() }),\n z.object({ type: z.literal('date'), at: z.string() }),\n z.object({ type: z.literal('webhook') }),\n]);\nexport type TriggerIR = z.infer<typeof TriggerIR>;\n\n/* ----------------------------------- Nodes ---------------------------------- */\n\ninterface NodeBaseIR {\n /** Stable id derived from the structural path (rules in the file header). */\n id: string;\n /** Optional node name: UI title / observability handle (branch's optional first argument lands here). */\n label?: string | undefined;\n /** Metadata (provenance); excluded from contentHash. */\n meta?: NodeMetaIR | undefined;\n}\n\nexport type NodeIR =\n | (NodeBaseIR & {\n type: 'message';\n channel: ChannelIR;\n template: string;\n props: Record<string, PropValueIR>;\n /**\n * Push only: which of the user's devices get a copy. Absent means every\n * device the directory lists; 'latest' only the most recently registered.\n */\n devices?: 'latest' | undefined;\n })\n | (NodeBaseIR & { type: 'delay'; duration: DurationIR })\n | (NodeBaseIR & { type: 'random_delay'; min: DurationIR; max: DurationIR })\n | (NodeBaseIR & {\n type: 'time_window';\n days: WeekdayIR[];\n between: [string, string];\n tz: string;\n })\n | (NodeBaseIR & {\n type: 'wait_until';\n condition: ConditionIR;\n timeout: DurationIR;\n onTimeout: 'continue' | 'exit' | NodeIR[];\n })\n | (NodeBaseIR & {\n type: 'branch';\n cases: { label?: string | undefined; condition: ConditionIR; flow: NodeIR[] }[];\n /** Default arm (the DSL's bare tail argument). Absent = fall through to the main line. */\n otherwise?: NodeIR[] | undefined;\n })\n | (NodeBaseIR & {\n type: 'filter';\n /** Audit-log line for the users this gate drops. Metadata: excluded from contentHash. */\n reason?: string | undefined;\n condition: ConditionIR;\n })\n | (NodeBaseIR & {\n type: 'cohort';\n /**\n * Explicit experiment key: bucketing hashes `userId:key` instead of the\n * structural node id, so the assignment survives node insertion/moves and\n * the experiment keeps one identity across workflow versions. Absent →\n * the node id is used (fine for one-off splits).\n */\n key?: string | undefined;\n /**\n * Arms in authoring order — the order the interpreter accumulates weights\n * in, so it decides which bucket range each arm owns. The DSL keeps that\n * promise by rejecting integer-like arm names, which JavaScript would\n * hoist to the front of the object in numeric order. Weights sum to 100.\n */\n arms: { name: string; weight: number; flow: NodeIR[] }[];\n })\n | (NodeBaseIR & {\n /** `reason` is the audit-log line. Metadata: excluded from contentHash. */\n type: 'exit';\n reason?: string | undefined;\n })\n | (NodeBaseIR & { type: 'send_event'; event: string; payload: Record<string, PropValueIR> })\n | (NodeBaseIR & {\n type: 'action';\n /** Registry name of the custom action; the code itself lives in the deployed Worker bundle. */\n action: string;\n /** Argument values; user_property references are resolved at run time, like message props. */\n args: Record<string, PropValueIR>;\n });\n\nconst nodeBase = { id: z.string(), label: z.optional(z.string()), meta: z.optional(NodeMetaIR) };\n\nexport const NodeIR: z.ZodMiniType<NodeIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({\n ...nodeBase,\n type: z.literal('message'),\n channel: ChannelIR,\n template: z.string(),\n props: z.record(z.string(), PropValueIR),\n devices: z.optional(z.literal('latest')),\n }),\n z.object({ ...nodeBase, type: z.literal('delay'), duration: DurationIR }),\n z.object({ ...nodeBase, type: z.literal('random_delay'), min: DurationIR, max: DurationIR }),\n z.object({\n ...nodeBase,\n type: z.literal('time_window'),\n days: z.array(WeekdayIR),\n between: z.tuple([z.string(), z.string()]),\n tz: z.string(),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('wait_until'),\n condition: ConditionIR,\n timeout: DurationIR,\n onTimeout: z.union([z.literal('continue'), z.literal('exit'), z.array(NodeIR)]),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('branch'),\n cases: z.array(\n z.object({\n label: z.optional(z.string()),\n condition: ConditionIR,\n flow: z.array(NodeIR),\n })\n ),\n otherwise: z.optional(z.array(NodeIR)),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('filter'),\n condition: ConditionIR,\n reason: z.optional(z.string()),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('cohort'),\n key: z.optional(z.string()),\n arms: z.array(z.object({ name: z.string(), weight: z.number(), flow: z.array(NodeIR) })),\n }),\n z.object({ ...nodeBase, type: z.literal('exit'), reason: z.optional(z.string()) }),\n z.object({\n ...nodeBase,\n type: z.literal('send_event'),\n event: z.string(),\n payload: z.record(z.string(), PropValueIR),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('action'),\n action: z.string(),\n args: z.record(z.string(), PropValueIR),\n }),\n ])\n);\n\n/* ---------------------- Top-level definitions and bundle --------------------- */\n\nexport const GoalIR = z.object({\n condition: ConditionIR,\n within: z.optional(DurationIR),\n exitOnMatch: z.boolean(),\n});\nexport type GoalIR = z.infer<typeof GoalIR>;\n\n/**\n * Human-facing metadata: **excluded from contentHash** (see hash.ts).\n * Editing a name or description does not touch execution semantics, so it never\n * invalidates the version in-flight users are pinned to, nor shows up in plan.\n */\nexport const MetaIR = z.object({\n /**\n * Human label. The asset's `id` is its identity — a wire key that routing\n * tables and the entry ledger are built on — so it is not something to\n * rename for readability. This is: it is metadata, dropped before hashing,\n * and the studio shows it in place of the key wherever it is set.\n */\n name: z.optional(z.string()),\n description: z.optional(z.string()),\n loc: z.optional(SourceLocIR),\n});\nexport type MetaIR = z.infer<typeof MetaIR>;\n\nexport const WorkflowIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`workflow({ id })`): routing tables, the entry ledger and reporting are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n meta: z.optional(MetaIR),\n trigger: TriggerIR,\n goal: z.optional(GoalIR),\n exitWhen: z.optional(ConditionIR),\n flow: z.array(NodeIR),\n});\nexport type WorkflowIR = z.infer<typeof WorkflowIR>;\n\nexport const SegmentIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`segment({ id })`): segment references and the membership store are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n /** Provenance plus the human label — MetaIR, same as a workflow's. */\n meta: z.optional(MetaIR),\n condition: ConditionIR,\n});\nexport type SegmentIR = z.infer<typeof SegmentIR>;\n\n/** Template manifest entry: IR records only the reference; the body lives in the template system. */\nexport const TemplateIR = z.object({\n irVersion: z.literal(IR_VERSION),\n key: z.string(),\n channel: ChannelIR,\n});\nexport type TemplateIR = z.infer<typeof TemplateIR>;\n\n/**\n * Action manifest entry: IR records the name; the implementation solidifies\n * into the Worker bundle at deploy time, never into IR (custom code stays out\n * of the data plane by design).\n */\nexport const ActionIR = z.object({\n irVersion: z.literal(IR_VERSION),\n name: z.string(),\n});\nexport type ActionIR = z.infer<typeof ActionIR>;\n\n/**\n * Deployment unit: the complete snapshot one `deploy` produces (terraform\n * apply, same mental model). The server diffs each definition's contentHash to\n * decide publish / skip / retire.\n */\nexport const BundleIR = z.object({\n irVersion: z.literal(IR_VERSION),\n workflows: z.array(WorkflowIR),\n segments: z.array(SegmentIR),\n templates: z.array(TemplateIR),\n actions: z.array(ActionIR),\n});\nexport type BundleIR = z.infer<typeof BundleIR>;\n"],"mappings":"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"}
1
+ {"version":3,"file":"ir.mjs","names":[],"sources":["../src/ir.ts"],"sourcesContent":["import * as z from 'zod/mini';\n\n/**\n * IR (Intermediate Representation) — what the DSL compiles to: serializable\n * JSON. It is the three-way contract between the engine that interprets it,\n * the UI (react-flow) that renders it, and diff / audit tooling.\n *\n * Validation strategy: the zod/mini schemas are the authoritative definition.\n * TS types are either inferred from a schema or hand-written under the same\n * name (zod cannot infer recursive types; the hand-written type is anchored\n * with a ZodMiniType<T> annotation, so any drift fails to compile). IR crosses\n * process boundaries (database, network, UI), so runtime validation is a must.\n *\n * ## Versioning (two independent axes)\n *\n * 1. IR format version (irVersion): evolution of the IR schema itself. Adding\n * optional fields does not bump it; only structurally incompatible changes\n * do (changing a discriminant, dropping a field, altering semantics).\n * Readers dispatch on irVersion to the matching schema/migrator; writers\n * always write the current version.\n * 2. Content version (contentHash): a content-addressed id for a single\n * definition's **execution semantics** — the canonical-JSON hash after\n * metadata (meta / label / reason) is stripped, see hash.ts. It is used to:\n * - pin a version when a user enters, so an in-flight journey runs the\n * pinned version to completion (the default policy; compatible hot\n * updates are a future explicit-migration topic). Known exception:\n * segment conditions are resolved **by id at evaluation time** — a\n * segment redeploy changes behaviour of in-flight journeys that\n * reference it. Pinning covers the workflow's own structure; per-hash\n * segment snapshots are future work;\n * - diff on deploy: same hash means unchanged, skip publishing;\n * - audit: answer \"which version was this user on\" at any point.\n *\n * ## Node ids\n *\n * Derived from the structural path at compile time — stable and deterministic:\n * root level '0', '1', …; the j-th child of a branch's i-th case is\n * '{branchId}.c{i}.{j}', the default arm '{branchId}.o.{j}'; cohort arms use\n * the arm name '{cohortId}.{armName}.{j}'; a waitUntil timeout flow '{id}.t.{j}'.\n * Editing a node's parameters keeps its id; inserting a node shifts the ids of\n * its later siblings (a known trade-off — locating in-flight users relies on\n * the pinned version rather than cross-version id alignment, and cross-version\n * mapping is an explicit migrator's job).\n */\n\nexport const IR_VERSION = 2;\n\n/* -------------------------------- Base values ------------------------------- */\n\nexport const ScalarIR = z.union([z.string(), z.number(), z.boolean()]);\nexport type ScalarIR = z.infer<typeof ScalarIR>;\n\n/** Duration: `value` keeps the DSL literal ('23 hours', for display), `ms` is what the engine uses. */\n/*\n * TODO: `value` is hashed alongside `ms`, so '7 days' / '1 week' / '168 hours'\n * — identical to the engine, which only reads ms — produce three different\n * contentHashes. Rewording a duration therefore reports as a semantic change in\n * plan and repins in-flight users. Moving `value` out of the hash (it is a\n * display string) would fix it; deferred because it changes existing hashes.\n */\nexport const DurationIR = z.object({ value: z.string(), ms: z.number() });\nexport type DurationIR = z.infer<typeof DurationIR>;\n\n/** User-property reference — isomorphic to the DSL's UserPropertyRef once serialized (phantom type dropped). */\nexport const UserPropertyRefIR = z.object({\n type: z.literal('user_property'),\n path: z.string(),\n});\nexport type UserPropertyRefIR = z.infer<typeof UserPropertyRefIR>;\n\n/** A message prop / event payload value: either a literal or a user-property reference. */\nexport const PropValueIR = z.union([ScalarIR, UserPropertyRefIR]);\nexport type PropValueIR = z.infer<typeof PropValueIR>;\n\nexport const ChannelIR = z.enum(['email', 'sms', 'push', 'in_app', 'slack', 'discord', 'survey']);\nexport type ChannelIR = z.infer<typeof ChannelIR>;\n\nexport const WeekdayIR = z.enum(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);\nexport type WeekdayIR = z.infer<typeof WeekdayIR>;\n\n/* --------------------------------- Provenance -------------------------------- */\n\n/**\n * Source location of the builder call that produced a definition — the IR's\n * source map. Lives under `meta`, so it is **excluded from contentHash**\n * (see hash.ts): provenance never affects execution identity or plan.\n * `file` is cwd-relative when compiled under Node, a URL under Vite dev.\n */\nexport const SourceLocIR = z.object({\n file: z.string(),\n line: z.number(),\n column: z.number(),\n});\nexport type SourceLocIR = z.infer<typeof SourceLocIR>;\n\n/** Node-level metadata: provenance only, for now. Excluded from contentHash. */\nexport const NodeMetaIR = z.object({\n loc: z.optional(SourceLocIR),\n});\nexport type NodeMetaIR = z.infer<typeof NodeMetaIR>;\n\n/* -------------------------------- Conditions -------------------------------- */\n\nexport const PropertyOperatorIR = z.enum([\n 'eq',\n 'ne',\n 'gt',\n 'gte',\n 'lt',\n 'lte',\n 'between',\n 'not_between',\n 'in_array',\n 'not_in_array',\n 'exists',\n 'not_exists',\n 'contains',\n 'not_contains',\n]);\nexport type PropertyOperatorIR = z.infer<typeof PropertyOperatorIR>;\n\n/**\n * Condition expression tree. The shape of value/values follows `op`:\n * eq/ne/gt/gte/lt/lte/contains/not_contains → value; in_array/not_in_array →\n * values; between/not_between → values = [min, max]; exists/not_exists → neither.\n *\n * `payload` predicates test the payload of an event (dotted paths reach nested\n * fields). They are only valid inside a `where` subtree — `performed.where`\n * and the event trigger's `where` — where an event row is in scope; the\n * compiler (compileBundle) and the evaluator both enforce the placement.\n * TODO: runtime refinement narrowing by op.\n */\n/**\n * Provenance rides on conditions too, so the studio can point at the exact\n * `eq(...)` / `and(...)` that produced a node — same rule as nodes: `meta` is\n * dropped before hashing, so carrying it never affects execution identity.\n */\ninterface ConditionBaseIR {\n meta?: NodeMetaIR | undefined;\n}\n\nexport type ConditionIR =\n | (ConditionBaseIR & { type: 'and'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'or'; conditions: ConditionIR[] })\n | (ConditionBaseIR & { type: 'not'; condition: ConditionIR })\n /** Reference a segment by id (defined in SegmentIR, versioned separately). */\n | (ConditionBaseIR & { type: 'segment'; segment: string })\n | (ConditionBaseIR & {\n type: 'property';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n /** Event payload predicate; `path` is dotted for nested fields ('tags.utm_source'). */\n type: 'payload';\n path: string;\n op: PropertyOperatorIR;\n value?: ScalarIR | undefined;\n values?: ScalarIR[] | undefined;\n })\n | (ConditionBaseIR & {\n type: 'performed';\n event: string;\n /** Payload filter: only events matching it count (a payload-only subtree). */\n where?: ConditionIR | undefined;\n within?: DurationIR | undefined;\n count?: number | undefined;\n })\n /**\n * The user can be reached on the channel right now: for push, the host's\n * device directory lists at least one device; for the others, the channel's\n * recipient property (email, phone, …) is set.\n */\n | (ConditionBaseIR & { type: 'reachable'; channel: ChannelIR });\n\nconst conditionBase = { meta: z.optional(NodeMetaIR) };\n\nexport const ConditionIR: z.ZodMiniType<ConditionIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({ ...conditionBase, type: z.literal('and'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('or'), conditions: z.array(ConditionIR) }),\n z.object({ ...conditionBase, type: z.literal('not'), condition: ConditionIR }),\n z.object({ ...conditionBase, type: z.literal('segment'), segment: z.string() }),\n z.object({\n ...conditionBase,\n type: z.literal('property'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('payload'),\n path: z.string(),\n op: PropertyOperatorIR,\n value: z.optional(ScalarIR),\n values: z.optional(z.array(ScalarIR)),\n }),\n z.object({\n ...conditionBase,\n type: z.literal('performed'),\n event: z.string(),\n where: z.optional(ConditionIR),\n within: z.optional(DurationIR),\n count: z.optional(z.number()),\n }),\n z.object({ ...conditionBase, type: z.literal('reachable'), channel: ChannelIR }),\n ])\n);\n\n/* --------------------------------- Triggers --------------------------------- */\n\n/**\n * Trigger shapes. The event trigger has two independent gates: `where` tests\n * the triggering event's payload (payload-only subtree, evaluated against the\n * incoming event before anything else), `filter` tests the user's profile.\n */\nexport const TriggerIR = z.discriminatedUnion('type', [\n z.object({\n type: z.literal('event'),\n event: z.string(),\n where: z.optional(ConditionIR),\n filter: z.optional(ConditionIR),\n }),\n z.object({ type: z.literal('segment'), segment: z.string() }),\n z.object({ type: z.literal('date'), at: z.string() }),\n z.object({ type: z.literal('webhook') }),\n]);\nexport type TriggerIR = z.infer<typeof TriggerIR>;\n\n/* ----------------------------------- Nodes ---------------------------------- */\n\ninterface NodeBaseIR {\n /** Stable id derived from the structural path (rules in the file header). */\n id: string;\n /** Optional node name: UI title / observability handle (branch's optional first argument lands here). */\n label?: string | undefined;\n /** Metadata (provenance); excluded from contentHash. */\n meta?: NodeMetaIR | undefined;\n}\n\nexport type NodeIR =\n | (NodeBaseIR & {\n type: 'message';\n channel: ChannelIR;\n template: string;\n props: Record<string, PropValueIR>;\n /**\n * Push only: which of the user's devices get a copy. Absent means every\n * device the directory lists; 'latest' only the most recently registered.\n */\n devices?: 'latest' | undefined;\n /**\n * Email and push: the template's subscription topic, or null for a transactional message\n * (sent whatever the user opted out of). Absent for other channels, and in IR compiled\n * before topics existed: such a step checks the channel-level opt-out only, as it always did.\n */\n topic?: string | null | undefined;\n })\n | (NodeBaseIR & { type: 'delay'; duration: DurationIR })\n | (NodeBaseIR & { type: 'random_delay'; min: DurationIR; max: DurationIR })\n | (NodeBaseIR & {\n type: 'time_window';\n days: WeekdayIR[];\n between: [string, string];\n tz: string;\n })\n | (NodeBaseIR & {\n type: 'wait_until';\n condition: ConditionIR;\n timeout: DurationIR;\n onTimeout: 'continue' | 'exit' | NodeIR[];\n })\n | (NodeBaseIR & {\n type: 'branch';\n cases: { label?: string | undefined; condition: ConditionIR; flow: NodeIR[] }[];\n /** Default arm (the DSL's bare tail argument). Absent = fall through to the main line. */\n otherwise?: NodeIR[] | undefined;\n })\n | (NodeBaseIR & {\n type: 'filter';\n /** Audit-log line for the users this gate drops. Metadata: excluded from contentHash. */\n reason?: string | undefined;\n condition: ConditionIR;\n })\n | (NodeBaseIR & {\n type: 'cohort';\n /**\n * Explicit experiment key: bucketing hashes `userId:key` instead of the\n * structural node id, so the assignment survives node insertion/moves and\n * the experiment keeps one identity across workflow versions. Absent →\n * the node id is used (fine for one-off splits).\n */\n key?: string | undefined;\n /**\n * Arms in authoring order — the order the interpreter accumulates weights\n * in, so it decides which bucket range each arm owns. The DSL keeps that\n * promise by rejecting integer-like arm names, which JavaScript would\n * hoist to the front of the object in numeric order. Weights sum to 100.\n */\n arms: { name: string; weight: number; flow: NodeIR[] }[];\n })\n | (NodeBaseIR & {\n /** `reason` is the audit-log line. Metadata: excluded from contentHash. */\n type: 'exit';\n reason?: string | undefined;\n })\n | (NodeBaseIR & { type: 'send_event'; event: string; payload: Record<string, PropValueIR> })\n | (NodeBaseIR & {\n type: 'action';\n /** Registry name of the custom action; the code itself lives in the deployed Worker bundle. */\n action: string;\n /** Argument values; user_property references are resolved at run time, like message props. */\n args: Record<string, PropValueIR>;\n });\n\nconst nodeBase = { id: z.string(), label: z.optional(z.string()), meta: z.optional(NodeMetaIR) };\n\nexport const NodeIR: z.ZodMiniType<NodeIR> = z.lazy(() =>\n z.discriminatedUnion('type', [\n z.object({\n ...nodeBase,\n type: z.literal('message'),\n channel: ChannelIR,\n template: z.string(),\n props: z.record(z.string(), PropValueIR),\n devices: z.optional(z.literal('latest')),\n topic: z.optional(z.nullable(z.string())),\n }),\n z.object({ ...nodeBase, type: z.literal('delay'), duration: DurationIR }),\n z.object({ ...nodeBase, type: z.literal('random_delay'), min: DurationIR, max: DurationIR }),\n z.object({\n ...nodeBase,\n type: z.literal('time_window'),\n days: z.array(WeekdayIR),\n between: z.tuple([z.string(), z.string()]),\n tz: z.string(),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('wait_until'),\n condition: ConditionIR,\n timeout: DurationIR,\n onTimeout: z.union([z.literal('continue'), z.literal('exit'), z.array(NodeIR)]),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('branch'),\n cases: z.array(\n z.object({\n label: z.optional(z.string()),\n condition: ConditionIR,\n flow: z.array(NodeIR),\n })\n ),\n otherwise: z.optional(z.array(NodeIR)),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('filter'),\n condition: ConditionIR,\n reason: z.optional(z.string()),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('cohort'),\n key: z.optional(z.string()),\n arms: z.array(z.object({ name: z.string(), weight: z.number(), flow: z.array(NodeIR) })),\n }),\n z.object({ ...nodeBase, type: z.literal('exit'), reason: z.optional(z.string()) }),\n z.object({\n ...nodeBase,\n type: z.literal('send_event'),\n event: z.string(),\n payload: z.record(z.string(), PropValueIR),\n }),\n z.object({\n ...nodeBase,\n type: z.literal('action'),\n action: z.string(),\n args: z.record(z.string(), PropValueIR),\n }),\n ])\n);\n\n/* ---------------------- Top-level definitions and bundle --------------------- */\n\nexport const GoalIR = z.object({\n condition: ConditionIR,\n within: z.optional(DurationIR),\n exitOnMatch: z.boolean(),\n});\nexport type GoalIR = z.infer<typeof GoalIR>;\n\n/**\n * Human-facing metadata: **excluded from contentHash** (see hash.ts).\n * Editing a name or description does not touch execution semantics, so it never\n * invalidates the version in-flight users are pinned to, nor shows up in plan.\n */\nexport const MetaIR = z.object({\n /**\n * Human label. The asset's `id` is its identity — a wire key that routing\n * tables and the entry ledger are built on — so it is not something to\n * rename for readability. This is: it is metadata, dropped before hashing,\n * and the studio shows it in place of the key wherever it is set.\n */\n name: z.optional(z.string()),\n description: z.optional(z.string()),\n loc: z.optional(SourceLocIR),\n});\nexport type MetaIR = z.infer<typeof MetaIR>;\n\nexport const WorkflowIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`workflow({ id })`): routing tables, the entry ledger and reporting are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n meta: z.optional(MetaIR),\n trigger: TriggerIR,\n goal: z.optional(GoalIR),\n exitWhen: z.optional(ConditionIR),\n flow: z.array(NodeIR),\n});\nexport type WorkflowIR = z.infer<typeof WorkflowIR>;\n\nexport const SegmentIR = z.object({\n irVersion: z.literal(IR_VERSION),\n /** Wire identity (`segment({ id })`): segment references and the membership store are keyed on it. */\n id: z.string(),\n contentHash: z.string(),\n /** Provenance plus the human label — MetaIR, same as a workflow's. */\n meta: z.optional(MetaIR),\n condition: ConditionIR,\n});\nexport type SegmentIR = z.infer<typeof SegmentIR>;\n\n/** Template manifest entry: IR records only the reference; the body lives in the template system. */\nexport const TemplateIR = z.object({\n irVersion: z.literal(IR_VERSION),\n key: z.string(),\n channel: ChannelIR,\n});\nexport type TemplateIR = z.infer<typeof TemplateIR>;\n\n/**\n * Action manifest entry: IR records the name; the implementation solidifies\n * into the Worker bundle at deploy time, never into IR (custom code stays out\n * of the data plane by design).\n */\nexport const ActionIR = z.object({\n irVersion: z.literal(IR_VERSION),\n name: z.string(),\n});\nexport type ActionIR = z.infer<typeof ActionIR>;\n\n/**\n * Deployment unit: the complete snapshot one `deploy` produces (terraform\n * apply, same mental model). The server diffs each definition's contentHash to\n * decide publish / skip / retire.\n */\n/**\n * The subscription topics the contract declares, carried by the bundle so the engine validates\n * `/subscriptions` and the templates' topics against the deployed list, not the SDK's word.\n */\nexport const SubscriptionsIR = z.object({\n topics: z.array(z.string()),\n});\nexport type SubscriptionsIR = z.infer<typeof SubscriptionsIR>;\n\nexport const BundleIR = z.object({\n irVersion: z.literal(IR_VERSION),\n workflows: z.array(WorkflowIR),\n segments: z.array(SegmentIR),\n templates: z.array(TemplateIR),\n actions: z.array(ActionIR),\n /** Absent in a bundle compiled before topics existed: no topics. */\n subscriptions: z.optional(SubscriptionsIR),\n});\nexport type BundleIR = z.infer<typeof BundleIR>;\n"],"mappings":"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"}
@@ -14,9 +14,10 @@ let react_dom_server = require("react-dom/server");
14
14
  * parsed against the template's schema before the component renders — a
15
15
  * failure throws TemplateValidationError and nothing renders — and the
16
16
  * component renders as an element inside an `EmailProvider` carrying the
17
- * template's resolved metadata (`resolveEmail` against the config's `email`),
18
- * so hooks and context work and a layout's `useEmailContext()` reads the
19
- * unsubscribe link the email is sent with; the render returns that metadata
17
+ * template's resolved metadata (`resolveEmail` against the config's `email`
18
+ * and the copy's message id, when the sender has one), so hooks and context
19
+ * work and a layout's `useEmailContext()` reads the unsubscribe and
20
+ * preferences links the email is sent with; the render returns that metadata
20
21
  * with the subject and HTML, for the sender. Rendering
21
22
  * streams and waits for every Suspense boundary (`allReady`) — react-email's
22
23
  * `<Tailwind>` suspends while it compiles the classes, which synchronous
@@ -27,10 +28,10 @@ let react_dom_server = require("react-dom/server");
27
28
  const DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
28
29
  function registryRenderer(emails, config) {
29
30
  const index = require_templates.indexTemplates("email", emails);
30
- return async (id, props) => {
31
+ return async (id, props, options) => {
31
32
  const template = require_templates.lookupTemplate(index, "email", id);
32
33
  const parsed = require_templates.parseTemplateProps(template, props);
33
- const email = require_email.resolveEmail(template, config);
34
+ const email = require_email.resolveEmail(template, config, options);
34
35
  const element = (0, react.createElement)(require_email_context.EmailProvider, { email }, (0, react.createElement)(template.component, parsed));
35
36
  return {
36
37
  ...email,
@@ -1 +1 @@
1
- {"version":3,"file":"react-email.cjs","names":["indexTemplates","lookupTemplate","parseTemplateProps","resolveEmail","createElement","EmailProvider","renderToReadableStream"],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport { type EmailConfig, resolveEmail } from './email';\nimport { EmailProvider } from './email-context';\nimport type { EmailRenderer } from './email-sender';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element inside an `EmailProvider` carrying the\n * template's resolved metadata (`resolveEmail` against the config's `email`),\n * so hooks and context work and a layout's `useEmailContext()` reads the\n * unsubscribe link the email is sent with; the render returns that metadata\n * with the subject and HTML, for the sender. Rendering\n * streams and waits for every Suspense boundary (`allReady`) — react-email's\n * `<Tailwind>` suspends while it compiles the classes, which synchronous\n * static markup cannot wait for — then prepends the XHTML doctype react-email\n * uses. @react-email/render itself is avoided because its Workers build\n * statically imports prettier and html-to-text for options never used here.\n */\n\nconst DOCTYPE =\n '<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">';\n\nexport function registryRenderer(\n emails: Readonly<Record<string, EmailTemplate>>,\n config?: EmailConfig\n): EmailRenderer {\n const index = indexTemplates('email', emails);\n return async (id, props) => {\n const template = lookupTemplate(index, 'email', id);\n const parsed = parseTemplateProps(template, props);\n const email = resolveEmail(template, config);\n const element = createElement(\n EmailProvider,\n { email },\n createElement(template.component as ComponentType<object>, parsed)\n );\n return { ...email, subject: template.subject, html: DOCTYPE + (await renderToHtml(element)) };\n };\n}\n\n/** Render to a string once every Suspense boundary has resolved; a rendering error rejects. */\nasync function renderToHtml(element: ReactElement): Promise<string> {\n let failure: unknown;\n const stream = await renderToReadableStream(element, {\n onError: (error) => {\n failure = error;\n },\n });\n await stream.allReady;\n const html = await new Response(stream).text();\n if (failure !== undefined) {\n throw failure instanceof Error ? failure : new Error('email render failed', { cause: failure });\n }\n // Streaming a document adds what static markup did not: an HTML5 doctype (the caller prepends\n // the XHTML one email clients expect) and React 19's image preload hints, which mean nothing in\n // an email. Both are dropped, as @react-email/render does.\n return html\n .replace(/^<!DOCTYPE html>/i, '')\n .replace(/<link rel=\"preload\" as=\"image\"[^>]*\\/>/g, '');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAM,UACJ;AAEF,SAAgB,iBACd,QACA,QACe;CACf,MAAM,QAAQA,kBAAAA,eAAe,SAAS,MAAM;CAC5C,OAAO,OAAO,IAAI,UAAU;EAC1B,MAAM,WAAWC,kBAAAA,eAAe,OAAO,SAAS,EAAE;EAClD,MAAM,SAASC,kBAAAA,mBAAmB,UAAU,KAAK;EACjD,MAAM,QAAQC,cAAAA,aAAa,UAAU,MAAM;EAC3C,MAAM,WAAA,GAAUC,MAAAA,cAAAA,CACdC,sBAAAA,eACA,EAAE,MAAM,IAAA,GACRD,MAAAA,cAAAA,CAAc,SAAS,WAAoC,MAAM,CACnE;EACA,OAAO;GAAE,GAAG;GAAO,SAAS,SAAS;GAAS,MAAM,UAAW,MAAM,aAAa,OAAO;EAAG;CAC9F;AACF;;AAGA,eAAe,aAAa,SAAwC;CAClE,IAAI;CACJ,MAAM,SAAS,OAAA,GAAME,iBAAAA,uBAAAA,CAAuB,SAAS,EACnD,UAAU,UAAU;EAClB,UAAU;CACZ,EACF,CAAC;CACD,MAAM,OAAO;CACb,MAAM,OAAO,MAAM,IAAI,SAAS,MAAM,CAAC,CAAC,KAAK;CAC7C,IAAI,YAAY,KAAA,GACd,MAAM,mBAAmB,QAAQ,UAAU,IAAI,MAAM,uBAAuB,EAAE,OAAO,QAAQ,CAAC;CAKhG,OAAO,KACJ,QAAQ,qBAAqB,EAAE,CAAC,CAChC,QAAQ,2CAA2C,EAAE;AAC1D"}
1
+ {"version":3,"file":"react-email.cjs","names":["indexTemplates","lookupTemplate","parseTemplateProps","resolveEmail","createElement","EmailProvider","renderToReadableStream"],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport { type EmailConfig, resolveEmail } from './email';\nimport { EmailProvider } from './email-context';\nimport type { EmailRenderer } from './email-sender';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element inside an `EmailProvider` carrying the\n * template's resolved metadata (`resolveEmail` against the config's `email`\n * and the copy's message id, when the sender has one), so hooks and context\n * work and a layout's `useEmailContext()` reads the unsubscribe and\n * preferences links the email is sent with; the render returns that metadata\n * with the subject and HTML, for the sender. Rendering\n * streams and waits for every Suspense boundary (`allReady`) — react-email's\n * `<Tailwind>` suspends while it compiles the classes, which synchronous\n * static markup cannot wait for — then prepends the XHTML doctype react-email\n * uses. @react-email/render itself is avoided because its Workers build\n * statically imports prettier and html-to-text for options never used here.\n */\n\nconst DOCTYPE =\n '<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">';\n\nexport function registryRenderer(\n emails: Readonly<Record<string, EmailTemplate>>,\n config?: EmailConfig\n): EmailRenderer {\n const index = indexTemplates('email', emails);\n return async (id, props, options) => {\n const template = lookupTemplate(index, 'email', id);\n const parsed = parseTemplateProps(template, props);\n const email = resolveEmail(template, config, options);\n const element = createElement(\n EmailProvider,\n { email },\n createElement(template.component as ComponentType<object>, parsed)\n );\n return { ...email, subject: template.subject, html: DOCTYPE + (await renderToHtml(element)) };\n };\n}\n\n/** Render to a string once every Suspense boundary has resolved; a rendering error rejects. */\nasync function renderToHtml(element: ReactElement): Promise<string> {\n let failure: unknown;\n const stream = await renderToReadableStream(element, {\n onError: (error) => {\n failure = error;\n },\n });\n await stream.allReady;\n const html = await new Response(stream).text();\n if (failure !== undefined) {\n throw failure instanceof Error ? failure : new Error('email render failed', { cause: failure });\n }\n // Streaming a document adds what static markup did not: an HTML5 doctype (the caller prepends\n // the XHTML one email clients expect) and React 19's image preload hints, which mean nothing in\n // an email. Both are dropped, as @react-email/render does.\n return html\n .replace(/^<!DOCTYPE html>/i, '')\n .replace(/<link rel=\"preload\" as=\"image\"[^>]*\\/>/g, '');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAM,UACJ;AAEF,SAAgB,iBACd,QACA,QACe;CACf,MAAM,QAAQA,kBAAAA,eAAe,SAAS,MAAM;CAC5C,OAAO,OAAO,IAAI,OAAO,YAAY;EACnC,MAAM,WAAWC,kBAAAA,eAAe,OAAO,SAAS,EAAE;EAClD,MAAM,SAASC,kBAAAA,mBAAmB,UAAU,KAAK;EACjD,MAAM,QAAQC,cAAAA,aAAa,UAAU,QAAQ,OAAO;EACpD,MAAM,WAAA,GAAUC,MAAAA,cAAAA,CACdC,sBAAAA,eACA,EAAE,MAAM,IAAA,GACRD,MAAAA,cAAAA,CAAc,SAAS,WAAoC,MAAM,CACnE;EACA,OAAO;GAAE,GAAG;GAAO,SAAS,SAAS;GAAS,MAAM,UAAW,MAAM,aAAa,OAAO;EAAG;CAC9F;AACF;;AAGA,eAAe,aAAa,SAAwC;CAClE,IAAI;CACJ,MAAM,SAAS,OAAA,GAAME,iBAAAA,uBAAAA,CAAuB,SAAS,EACnD,UAAU,UAAU;EAClB,UAAU;CACZ,EACF,CAAC;CACD,MAAM,OAAO;CACb,MAAM,OAAO,MAAM,IAAI,SAAS,MAAM,CAAC,CAAC,KAAK;CAC7C,IAAI,YAAY,KAAA,GACd,MAAM,mBAAmB,QAAQ,UAAU,IAAI,MAAM,uBAAuB,EAAE,OAAO,QAAQ,CAAC;CAKhG,OAAO,KACJ,QAAQ,qBAAqB,EAAE,CAAC,CAChC,QAAQ,2CAA2C,EAAE;AAC1D"}
@@ -1 +1 @@
1
- {"version":3,"file":"react-email.d.cts","names":[],"sources":["../src/react-email.ts"],"mappings":";;;wBAmCgB,iBACd,QAAQ,SAAS,eAAe,iBAChC,SAAS,cACR"}
1
+ {"version":3,"file":"react-email.d.cts","names":[],"sources":["../src/react-email.ts"],"mappings":";;;wBAoCgB,iBACd,QAAQ,SAAS,eAAe,iBAChC,SAAS,cACR"}
@@ -1 +1 @@
1
- {"version":3,"file":"react-email.d.mts","names":[],"sources":["../src/react-email.ts"],"mappings":";;;wBAmCgB,iBACd,QAAQ,SAAS,eAAe,iBAChC,SAAS,cACR"}
1
+ {"version":3,"file":"react-email.d.mts","names":[],"sources":["../src/react-email.ts"],"mappings":";;;wBAoCgB,iBACd,QAAQ,SAAS,eAAe,iBAChC,SAAS,cACR"}
@@ -13,9 +13,10 @@ import { renderToReadableStream } from "react-dom/server";
13
13
  * parsed against the template's schema before the component renders — a
14
14
  * failure throws TemplateValidationError and nothing renders — and the
15
15
  * component renders as an element inside an `EmailProvider` carrying the
16
- * template's resolved metadata (`resolveEmail` against the config's `email`),
17
- * so hooks and context work and a layout's `useEmailContext()` reads the
18
- * unsubscribe link the email is sent with; the render returns that metadata
16
+ * template's resolved metadata (`resolveEmail` against the config's `email`
17
+ * and the copy's message id, when the sender has one), so hooks and context
18
+ * work and a layout's `useEmailContext()` reads the unsubscribe and
19
+ * preferences links the email is sent with; the render returns that metadata
19
20
  * with the subject and HTML, for the sender. Rendering
20
21
  * streams and waits for every Suspense boundary (`allReady`) — react-email's
21
22
  * `<Tailwind>` suspends while it compiles the classes, which synchronous
@@ -26,10 +27,10 @@ import { renderToReadableStream } from "react-dom/server";
26
27
  const DOCTYPE = "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">";
27
28
  function registryRenderer(emails, config) {
28
29
  const index = indexTemplates("email", emails);
29
- return async (id, props) => {
30
+ return async (id, props, options) => {
30
31
  const template = lookupTemplate(index, "email", id);
31
32
  const parsed = parseTemplateProps(template, props);
32
- const email = resolveEmail(template, config);
33
+ const email = resolveEmail(template, config, options);
33
34
  const element = createElement(EmailProvider, { email }, createElement(template.component, parsed));
34
35
  return {
35
36
  ...email,
@@ -1 +1 @@
1
- {"version":3,"file":"react-email.mjs","names":[],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport { type EmailConfig, resolveEmail } from './email';\nimport { EmailProvider } from './email-context';\nimport type { EmailRenderer } from './email-sender';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element inside an `EmailProvider` carrying the\n * template's resolved metadata (`resolveEmail` against the config's `email`),\n * so hooks and context work and a layout's `useEmailContext()` reads the\n * unsubscribe link the email is sent with; the render returns that metadata\n * with the subject and HTML, for the sender. Rendering\n * streams and waits for every Suspense boundary (`allReady`) — react-email's\n * `<Tailwind>` suspends while it compiles the classes, which synchronous\n * static markup cannot wait for — then prepends the XHTML doctype react-email\n * uses. @react-email/render itself is avoided because its Workers build\n * statically imports prettier and html-to-text for options never used here.\n */\n\nconst DOCTYPE =\n '<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">';\n\nexport function registryRenderer(\n emails: Readonly<Record<string, EmailTemplate>>,\n config?: EmailConfig\n): EmailRenderer {\n const index = indexTemplates('email', emails);\n return async (id, props) => {\n const template = lookupTemplate(index, 'email', id);\n const parsed = parseTemplateProps(template, props);\n const email = resolveEmail(template, config);\n const element = createElement(\n EmailProvider,\n { email },\n createElement(template.component as ComponentType<object>, parsed)\n );\n return { ...email, subject: template.subject, html: DOCTYPE + (await renderToHtml(element)) };\n };\n}\n\n/** Render to a string once every Suspense boundary has resolved; a rendering error rejects. */\nasync function renderToHtml(element: ReactElement): Promise<string> {\n let failure: unknown;\n const stream = await renderToReadableStream(element, {\n onError: (error) => {\n failure = error;\n },\n });\n await stream.allReady;\n const html = await new Response(stream).text();\n if (failure !== undefined) {\n throw failure instanceof Error ? failure : new Error('email render failed', { cause: failure });\n }\n // Streaming a document adds what static markup did not: an HTML5 doctype (the caller prepends\n // the XHTML one email clients expect) and React 19's image preload hints, which mean nothing in\n // an email. Both are dropped, as @react-email/render does.\n return html\n .replace(/^<!DOCTYPE html>/i, '')\n .replace(/<link rel=\"preload\" as=\"image\"[^>]*\\/>/g, '');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,MAAM,UACJ;AAEF,SAAgB,iBACd,QACA,QACe;CACf,MAAM,QAAQ,eAAe,SAAS,MAAM;CAC5C,OAAO,OAAO,IAAI,UAAU;EAC1B,MAAM,WAAW,eAAe,OAAO,SAAS,EAAE;EAClD,MAAM,SAAS,mBAAmB,UAAU,KAAK;EACjD,MAAM,QAAQ,aAAa,UAAU,MAAM;EAC3C,MAAM,UAAU,cACd,eACA,EAAE,MAAM,GACR,cAAc,SAAS,WAAoC,MAAM,CACnE;EACA,OAAO;GAAE,GAAG;GAAO,SAAS,SAAS;GAAS,MAAM,UAAW,MAAM,aAAa,OAAO;EAAG;CAC9F;AACF;;AAGA,eAAe,aAAa,SAAwC;CAClE,IAAI;CACJ,MAAM,SAAS,MAAM,uBAAuB,SAAS,EACnD,UAAU,UAAU;EAClB,UAAU;CACZ,EACF,CAAC;CACD,MAAM,OAAO;CACb,MAAM,OAAO,MAAM,IAAI,SAAS,MAAM,CAAC,CAAC,KAAK;CAC7C,IAAI,YAAY,KAAA,GACd,MAAM,mBAAmB,QAAQ,UAAU,IAAI,MAAM,uBAAuB,EAAE,OAAO,QAAQ,CAAC;CAKhG,OAAO,KACJ,QAAQ,qBAAqB,EAAE,CAAC,CAChC,QAAQ,2CAA2C,EAAE;AAC1D"}
1
+ {"version":3,"file":"react-email.mjs","names":[],"sources":["../src/react-email.ts"],"sourcesContent":["import { type ComponentType, type ReactElement, createElement } from 'react';\nimport { renderToReadableStream } from 'react-dom/server';\nimport { type EmailConfig, resolveEmail } from './email';\nimport { EmailProvider } from './email-context';\nimport type { EmailRenderer } from './email-sender';\nimport {\n type EmailTemplate,\n indexTemplates,\n lookupTemplate,\n parseTemplateProps,\n} from './templates';\n\n/**\n * `@shware/workflow/react-email` — an EmailRenderer over the project's email\n * registry (`react-dom` is an optional peer dependency; import this subpath\n * only when you use it).\n *\n * The registry holds `emailTemplate({...})` definitions, looked up by their `id`. Props are\n * parsed against the template's schema before the component renders — a\n * failure throws TemplateValidationError and nothing renders — and the\n * component renders as an element inside an `EmailProvider` carrying the\n * template's resolved metadata (`resolveEmail` against the config's `email`\n * and the copy's message id, when the sender has one), so hooks and context\n * work and a layout's `useEmailContext()` reads the unsubscribe and\n * preferences links the email is sent with; the render returns that metadata\n * with the subject and HTML, for the sender. Rendering\n * streams and waits for every Suspense boundary (`allReady`) — react-email's\n * `<Tailwind>` suspends while it compiles the classes, which synchronous\n * static markup cannot wait for — then prepends the XHTML doctype react-email\n * uses. @react-email/render itself is avoided because its Workers build\n * statically imports prettier and html-to-text for options never used here.\n */\n\nconst DOCTYPE =\n '<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">';\n\nexport function registryRenderer(\n emails: Readonly<Record<string, EmailTemplate>>,\n config?: EmailConfig\n): EmailRenderer {\n const index = indexTemplates('email', emails);\n return async (id, props, options) => {\n const template = lookupTemplate(index, 'email', id);\n const parsed = parseTemplateProps(template, props);\n const email = resolveEmail(template, config, options);\n const element = createElement(\n EmailProvider,\n { email },\n createElement(template.component as ComponentType<object>, parsed)\n );\n return { ...email, subject: template.subject, html: DOCTYPE + (await renderToHtml(element)) };\n };\n}\n\n/** Render to a string once every Suspense boundary has resolved; a rendering error rejects. */\nasync function renderToHtml(element: ReactElement): Promise<string> {\n let failure: unknown;\n const stream = await renderToReadableStream(element, {\n onError: (error) => {\n failure = error;\n },\n });\n await stream.allReady;\n const html = await new Response(stream).text();\n if (failure !== undefined) {\n throw failure instanceof Error ? failure : new Error('email render failed', { cause: failure });\n }\n // Streaming a document adds what static markup did not: an HTML5 doctype (the caller prepends\n // the XHTML one email clients expect) and React 19's image preload hints, which mean nothing in\n // an email. Both are dropped, as @react-email/render does.\n return html\n .replace(/^<!DOCTYPE html>/i, '')\n .replace(/<link rel=\"preload\" as=\"image\"[^>]*\\/>/g, '');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAM,UACJ;AAEF,SAAgB,iBACd,QACA,QACe;CACf,MAAM,QAAQ,eAAe,SAAS,MAAM;CAC5C,OAAO,OAAO,IAAI,OAAO,YAAY;EACnC,MAAM,WAAW,eAAe,OAAO,SAAS,EAAE;EAClD,MAAM,SAAS,mBAAmB,UAAU,KAAK;EACjD,MAAM,QAAQ,aAAa,UAAU,QAAQ,OAAO;EACpD,MAAM,UAAU,cACd,eACA,EAAE,MAAM,GACR,cAAc,SAAS,WAAoC,MAAM,CACnE;EACA,OAAO;GAAE,GAAG;GAAO,SAAS,SAAS;GAAS,MAAM,UAAW,MAAM,aAAa,OAAO;EAAG;CAC9F;AACF;;AAGA,eAAe,aAAa,SAAwC;CAClE,IAAI;CACJ,MAAM,SAAS,MAAM,uBAAuB,SAAS,EACnD,UAAU,UAAU;EAClB,UAAU;CACZ,EACF,CAAC;CACD,MAAM,OAAO;CACb,MAAM,OAAO,MAAM,IAAI,SAAS,MAAM,CAAC,CAAC,KAAK;CAC7C,IAAI,YAAY,KAAA,GACd,MAAM,mBAAmB,QAAQ,UAAU,IAAI,MAAM,uBAAuB,EAAE,OAAO,QAAQ,CAAC;CAKhG,OAAO,KACJ,QAAQ,qBAAqB,EAAE,CAAC,CAChC,QAAQ,2CAA2C,EAAE;AAC1D"}
@@ -7,7 +7,8 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
8
8
  * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
9
9
  * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
10
- * (served by the Worker, ./tracking.ts), and a push carries
10
+ * (served by the Worker, ./tracking.ts); the renderer gets the id too, for the email's
11
+ * preferences link (`email.preferencesUrl?m=<message>`), and a push carries
11
12
  * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
12
13
  * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
13
14
  * The links are recorded when the sender commits the rewritten email, before it goes out.
@@ -26,11 +27,13 @@ function reportingObserver(reporting, origin) {
26
27
  template: message.template,
27
28
  channel: channelOf(message),
28
29
  userId: message.userId,
30
+ topic: message.channel === "email" || message.channel === "push" ? message.topic ?? null : null,
29
31
  sentAt: Date.now()
30
32
  });
31
33
  if (message.channel === "email" && base !== void 0) {
32
34
  const urls = [];
33
35
  return {
36
+ messageId: id,
34
37
  link: (url, purpose) => {
35
38
  urls.push(url);
36
39
  return `${base}/t/${purpose === "unsubscribe" ? "u" : "l"}/${id}/${urls.length - 1}`;
@@ -1 +1 @@
1
- {"version":3,"file":"observer.cjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n unsubscribe: `${base}/t/u/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KACtB,aAAa,GAAG,KAAK,OAAO;KAE5B,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
1
+ {"version":3,"file":"observer.cjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts); the renderer gets the id too, for the email's\n * preferences link (`email.preferencesUrl?m=<message>`), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n topic:\n message.channel === 'email' || message.channel === 'push'\n ? (message.topic ?? null)\n : null,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n messageId: id,\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n unsubscribe: `${base}/t/u/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;;;AAgBA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,OACE,QAAQ,YAAY,WAAW,QAAQ,YAAY,SAC9C,QAAQ,SAAS,OAClB;IACN,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,WAAW;KACX,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KACtB,aAAa,GAAG,KAAK,OAAO;KAE5B,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
@@ -8,7 +8,8 @@ import { JourneyReporting } from "../store/index.cjs";
8
8
  * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
9
9
  * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
10
10
  * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
11
- * (served by the Worker, ./tracking.ts), and a push carries
11
+ * (served by the Worker, ./tracking.ts); the renderer gets the id too, for the email's
12
+ * preferences link (`email.preferencesUrl?m=<message>`), and a push carries
12
13
  * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
13
14
  * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
14
15
  * The links are recorded when the sender commits the rewritten email, before it goes out.
@@ -1 +1 @@
1
- {"version":3,"file":"observer.d.cts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":";;;;;;;;;;;;;;;wBAegB,kBACd,WAAW,kBACX,6BACC"}
1
+ {"version":3,"file":"observer.d.cts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":";;;;;;;;;;;;;;;;wBAgBgB,kBACd,WAAW,kBACX,6BACC"}
@@ -8,7 +8,8 @@ import { JourneyReporting } from "../store/index.mjs";
8
8
  * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
9
9
  * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
10
10
  * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
11
- * (served by the Worker, ./tracking.ts), and a push carries
11
+ * (served by the Worker, ./tracking.ts); the renderer gets the id too, for the email's
12
+ * preferences link (`email.preferencesUrl?m=<message>`), and a push carries
12
13
  * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
13
14
  * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
14
15
  * The links are recorded when the sender commits the rewritten email, before it goes out.
@@ -1 +1 @@
1
- {"version":3,"file":"observer.d.mts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":";;;;;;;;;;;;;;;wBAegB,kBACd,WAAW,kBACX,6BACC"}
1
+ {"version":3,"file":"observer.d.mts","names":[],"sources":["../../src/reporting/observer.ts"],"mappings":";;;;;;;;;;;;;;;;wBAgBgB,kBACd,WAAW,kBACX,6BACC"}
@@ -6,7 +6,8 @@
6
6
  * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,
7
7
  * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe
8
8
  * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`
9
- * (served by the Worker, ./tracking.ts), and a push carries
9
+ * (served by the Worker, ./tracking.ts); the renderer gets the id too, for the email's
10
+ * preferences link (`email.preferencesUrl?m=<message>`), and a push carries
10
11
  * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),
11
12
  * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.
12
13
  * The links are recorded when the sender commits the rewritten email, before it goes out.
@@ -25,11 +26,13 @@ function reportingObserver(reporting, origin) {
25
26
  template: message.template,
26
27
  channel: channelOf(message),
27
28
  userId: message.userId,
29
+ topic: message.channel === "email" || message.channel === "push" ? message.topic ?? null : null,
28
30
  sentAt: Date.now()
29
31
  });
30
32
  if (message.channel === "email" && base !== void 0) {
31
33
  const urls = [];
32
34
  return {
35
+ messageId: id,
33
36
  link: (url, purpose) => {
34
37
  urls.push(url);
35
38
  return `${base}/t/${purpose === "unsubscribe" ? "u" : "l"}/${id}/${urls.length - 1}`;
@@ -1 +1 @@
1
- {"version":3,"file":"observer.mjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n unsubscribe: `${base}/t/u/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;AAeA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KACtB,aAAa,GAAG,KAAK,OAAO;KAE5B,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
1
+ {"version":3,"file":"observer.mjs","names":[],"sources":["../../src/reporting/observer.ts"],"sourcesContent":["import type { JourneyObserver, OutboundMessage } from '../engine';\nimport type { JourneyReporting } from '../store';\n\n/**\n * The observer journeyWorker installs when the store keeps reporting: every node arrival, copy\n * and outcome recorded through `store.reporting`.\n *\n * A copy is recorded before it goes out, so its id can travel with it: with a tracking origin,\n * an email's links become `<origin>/t/l/<message>/<position>` — `/t/u/...` for its unsubscribe\n * link, so what a click means is fixed when it is sent — and an open pixel `<origin>/t/o/<message>`\n * (served by the Worker, ./tracking.ts); the renderer gets the id too, for the email's\n * preferences link (`email.preferencesUrl?m=<message>`), and a push carries\n * `message_id` in its payload for the app to report the tap (`JourneyClient.messageOpened`),\n * plus `delivered_url` / `opened_url` for a client that reports straight to the Worker.\n * The links are recorded when the sender commits the rewritten email, before it goes out.\n */\nexport function reportingObserver(\n reporting: JourneyReporting,\n origin: string | undefined\n): JourneyObserver {\n const base = origin?.replace(/\\/$/, '');\n\n return {\n started: (event) => reporting.instanceStarted(event),\n\n nodeReached: (event) => reporting.nodeReached(event),\n\n async messagePrepared(message) {\n const id = await reporting.recordMessage({\n idempotencyKey: message.idempotencyKey,\n workflow: message.workflow,\n instanceId: message.instanceId,\n nodeId: message.nodeId,\n template: message.template,\n channel: channelOf(message),\n userId: message.userId,\n topic:\n message.channel === 'email' || message.channel === 'push'\n ? (message.topic ?? null)\n : null,\n sentAt: Date.now(),\n });\n if (message.channel === 'email' && base !== undefined) {\n const urls: string[] = [];\n return {\n messageId: id,\n link: (url, purpose) => {\n urls.push(url);\n return `${base}/t/${purpose === 'unsubscribe' ? 'u' : 'l'}/${id}/${urls.length - 1}`;\n },\n pixel: `${base}/t/o/${id}`,\n unsubscribe: `${base}/t/u/${id}`,\n // Before the transport takes it, so no click can reach a link not yet recorded\n commit: () => reporting.recordLinks(id, urls),\n };\n }\n if (message.channel === 'push') {\n // The app reports the tap with `message_id`; a client that can reach the Worker on its\n // own (iOS's notification service extension, a browser's service worker) posts to these\n return {\n data: {\n message_id: id,\n ...(base === undefined\n ? {}\n : { delivered_url: `${base}/t/d/${id}`, opened_url: `${base}/t/p/${id}` }),\n },\n };\n }\n return undefined;\n },\n\n async messageSent(message, result) {\n await reporting.settleMessage(message.idempotencyKey, result, Date.now());\n },\n\n finished: (event) => reporting.instanceFinished(event),\n };\n}\n\n/** Where the copy lands — the Studio's metric channel: a push is reported per platform. */\nfunction channelOf(message: OutboundMessage): string {\n if (message.channel !== 'push') return message.channel;\n return `push_${message.device?.platform ?? 'unknown'}`;\n}\n"],"mappings":";;;;;;;;;;;;;;AAgBA,SAAgB,kBACd,WACA,QACiB;CACjB,MAAM,OAAO,QAAQ,QAAQ,OAAO,EAAE;CAEtC,OAAO;EACL,UAAU,UAAU,UAAU,gBAAgB,KAAK;EAEnD,cAAc,UAAU,UAAU,YAAY,KAAK;EAEnD,MAAM,gBAAgB,SAAS;GAC7B,MAAM,KAAK,MAAM,UAAU,cAAc;IACvC,gBAAgB,QAAQ;IACxB,UAAU,QAAQ;IAClB,YAAY,QAAQ;IACpB,QAAQ,QAAQ;IAChB,UAAU,QAAQ;IAClB,SAAS,UAAU,OAAO;IAC1B,QAAQ,QAAQ;IAChB,OACE,QAAQ,YAAY,WAAW,QAAQ,YAAY,SAC9C,QAAQ,SAAS,OAClB;IACN,QAAQ,KAAK,IAAI;GACnB,CAAC;GACD,IAAI,QAAQ,YAAY,WAAW,SAAS,KAAA,GAAW;IACrD,MAAM,OAAiB,CAAC;IACxB,OAAO;KACL,WAAW;KACX,OAAO,KAAK,YAAY;MACtB,KAAK,KAAK,GAAG;MACb,OAAO,GAAG,KAAK,KAAK,YAAY,gBAAgB,MAAM,IAAI,GAAG,GAAG,GAAG,KAAK,SAAS;KACnF;KACA,OAAO,GAAG,KAAK,OAAO;KACtB,aAAa,GAAG,KAAK,OAAO;KAE5B,cAAc,UAAU,YAAY,IAAI,IAAI;IAC9C;GACF;GACA,IAAI,QAAQ,YAAY,QAGtB,OAAO,EACL,MAAM;IACJ,YAAY;IACZ,GAAI,SAAS,KAAA,IACT,CAAC,IACD;KAAE,eAAe,GAAG,KAAK,OAAO;KAAM,YAAY,GAAG,KAAK,OAAO;IAAK;GAC5E,EACF;EAGJ;EAEA,MAAM,YAAY,SAAS,QAAQ;GACjC,MAAM,UAAU,cAAc,QAAQ,gBAAgB,QAAQ,KAAK,IAAI,CAAC;EAC1E;EAEA,WAAW,UAAU,UAAU,iBAAiB,KAAK;CACvD;AACF;;AAGA,SAAS,UAAU,SAAkC;CACnD,IAAI,QAAQ,YAAY,QAAQ,OAAO,QAAQ;CAC/C,OAAO,QAAQ,QAAQ,QAAQ,YAAY;AAC7C"}
@@ -3,6 +3,8 @@ const require_reporting_classify = require("./classify.cjs");
3
3
  // src/reporting/tracking.ts
4
4
  const GIF = Uint8Array.from(atob("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"), (char) => char.charCodeAt(0));
5
5
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+ /** Whether `id` has the shape of a message id (a UUID); anything else names no copy. */
7
+ const isMessageId = (id) => UUID.test(id);
6
8
  function requester(request) {
7
9
  const cf = request.cf;
8
10
  return {
@@ -15,7 +17,8 @@ function requester(request) {
15
17
  }
16
18
  /** A `/t/...` request's response, or undefined when the path is not a tracking route. */
17
19
  async function handleTracking(request, reporting, options = {}) {
18
- const path = new URL(request.url).pathname.split("/");
20
+ const url = new URL(request.url);
21
+ const path = url.pathname.split("/");
19
22
  const root = path.at(1);
20
23
  const kind = path.at(2);
21
24
  const id = path.at(3);
@@ -33,10 +36,10 @@ async function handleTracking(request, reporting, options = {}) {
33
36
  if ((kind === "l" || kind === "u") && position !== void 0 && read) {
34
37
  const target = await recordClick(reporting, id, Number(position), who, options);
35
38
  if (target === void 0) return page(options, "This link has expired.", 404);
36
- if (kind === "u") return confirmUnsubscribe(options, id);
39
+ if (kind === "u") return confirmUnsubscribe(reporting, options, id);
37
40
  return Response.redirect(target, 302);
38
41
  }
39
- if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(options, id);
42
+ if (kind === "u" && position === void 0 && read) return confirmUnsubscribe(reporting, options, id);
40
43
  if (kind === "d" && who.method === "POST") {
41
44
  const known = await reporting.recordDeviceDelivered(id, Date.now());
42
45
  return new Response(null, { status: known ? 204 : 404 });
@@ -66,23 +69,40 @@ async function handleTracking(request, reporting, options = {}) {
66
69
  }
67
70
  if (kind === "u" && who.method === "POST") {
68
71
  const at = Date.now();
69
- const unsubscribed = await reporting.recordUnsubscribe(id, at);
72
+ const all = url.searchParams.get("scope") === "all";
73
+ const unsubscribed = await reporting.recordUnsubscribe(id, at, all ? "all" : "topic");
70
74
  if (unsubscribed === void 0) return page(options, "You won't get these emails anymore.");
75
+ if (unsubscribed.topic === null) return page(options, "This link has expired.", 404);
71
76
  if (options.onUnsubscribed !== void 0) try {
72
77
  await options.onUnsubscribed({
73
78
  messageId: id,
74
79
  userId: unsubscribed.userId,
75
- at
80
+ at,
81
+ topic: unsubscribed.topic,
82
+ all
76
83
  });
77
84
  } catch (error) {
78
85
  console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);
79
86
  }
80
- return page(options, options.product === void 0 ? "You won't get these emails anymore." : `You won't get these emails from ${escape(options.product)} anymore.`);
87
+ const from = options.product === void 0 ? "" : ` from ${escape(options.product)}`;
88
+ return page(options, all ? `You won't get emails${from} anymore.` : `You won't get ${escape(topicName(unsubscribed.topic))} emails${from} anymore.`);
81
89
  }
82
90
  }
83
- /** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */
84
- function confirmUnsubscribe(options, id) {
85
- return page(options, `Unsubscribe from ${escape(options.product ?? "these")} emails?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe</button></form>`);
91
+ /** A topic id as the page shows it: `product_updates` reads as "product updates". */
92
+ function topicName(topic) {
93
+ return topic.replace(/[_-]+/g, " ");
94
+ }
95
+ /**
96
+ * The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks: the
97
+ * email's topic (the default), or every email. A copy nobody knows, or a transactional one (the
98
+ * link was never in it), is an expired link.
99
+ */
100
+ async function confirmUnsubscribe(reporting, options, id) {
101
+ const message = await reporting.lookupMessage(id);
102
+ if (message?.channel !== "email" || message.topic === null) return page(options, "This link has expired.", 404);
103
+ const product = options.product === void 0 ? "" : ` from ${escape(options.product)}`;
104
+ const topic = escape(topicName(message.topic));
105
+ return page(options, `Unsubscribe from ${topic} emails${product}?`, 200, `<form method="post" action="/t/u/${id}"><button type="submit">Unsubscribe from ${topic}</button></form><form method="post" action="/t/u/${id}?scope=all"><button type="submit" class="secondary">Unsubscribe from all emails</button></form>`);
86
106
  }
87
107
  /**
88
108
  * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on
@@ -150,13 +170,14 @@ function escape(text) {
150
170
  return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
151
171
  }
152
172
  function page(options, message, status = 200, extra = "") {
153
- const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
173
+ const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(options.product ?? "Email")}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}form{margin:12px 0}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}button.secondary{background:#e4e4e7;color:#18181b}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;
154
174
  return new Response(html, {
155
175
  status,
156
176
  headers: { "content-type": "text/html; charset=utf-8" }
157
177
  });
158
178
  }
159
179
  exports.handleTracking = handleTracking;
180
+ exports.isMessageId = isMessageId;
160
181
  exports.recordPushOpen = recordPushOpen;
161
182
 
162
183
  //# sourceMappingURL=tracking.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"tracking.cjs","names":["classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here)\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. Changing a template's or the config's unsubscribe URL, or\n * removing the template, changes nothing for emails already sent.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): the host wakes the user's waits that read email\n * reachability, so an `exitWhen: not(reachable('email'))` takes effect at once. Its failure is\n * logged, never a reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read) return confirmUnsubscribe(options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n const unsubscribed = await reporting.recordUnsubscribe(id, at);\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({ messageId: id, userId: unsubscribed.userId, at });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n return page(\n options,\n options.product === undefined\n ? \"You won't get these emails anymore.\"\n : `You won't get these emails from ${escape(options.product)} anymore.`\n );\n }\n return undefined;\n}\n\n/** The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks. */\nfunction confirmUnsubscribe(options: TrackingOptions, id: string): Response {\n return page(\n options,\n `Unsubscribe from ${escape(options.product ?? 'these')} emails?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"tracking.cjs","names":["classifyOpen","BURST_WINDOW_MS","classifyClick"],"sources":["../../src/reporting/tracking.ts"],"sourcesContent":["import type { JourneyReporting } from '../store';\nimport { BURST_WINDOW_MS, classifyClick, classifyOpen } from './classify';\n\n/**\n * The endpoints a journey email's tracking points at (see ./observer.ts), served by journeyWorker\n * without a bearer — recipients open them:\n *\n * GET /t/o/<message> the open pixel: records the open, answers a 1×1 GIF\n * GET /t/l/<message>/<position> a link: records the click, redirects to the recorded URL\n * GET /t/u/<message>/<position> the unsubscribe link: records the click, asks to confirm\n * GET /t/u/<message> the List-Unsubscribe header's URL, opened: asks to confirm\n * POST /t/u/<message> the unsubscribe confirmation, and the header's one-click\n * unsubscribe (RFC 8058: a mail client's button posts here):\n * opts out of the email's topic; `?scope=all` (the page's\n * second button) of every email\n * POST /t/d/<message> a push the device received (iOS's notification service\n * extension, a browser's service worker)\n * POST /t/p/<message> a push opened, reported by a browser's service worker\n *\n * Every open and click is recorded with its verdict (./classify.ts), and the message keeps its\n * first open and click of each kind. The redirect goes only to the URL recorded for that position,\n * never to one the request carries, so the endpoint cannot bounce visitors anywhere else. An\n * email's unsubscribe link is a tracked link too, and which link it is was fixed when the email was\n * sent (its route): following it shows a confirmation page, and only the confirmation — a POST,\n * which no link scanner sends — counts. The page offers the email's topic (the default, and what\n * a mail client's one-click POST does) and every email. Changing a template's or the config's\n * unsubscribe URL, or removing the template, changes nothing for emails already sent. A\n * transactional email (no topic) never carried the link: every `/t/u` variant answers 404 for it.\n */\n\nexport interface TrackingOptions {\n /** The name the confirmation pages show (the config's `title`). */\n product?: string | undefined;\n /** `email.tracking.quickClickMs`: how soon after delivery an open or click is a weak machine signal. */\n quickMs?: number | undefined;\n /** A browser reported a push tapped (`/t/p`): the host records it as an event too (`webPush.openedEvent`). */\n pushOpened?:\n | ((open: { messageId: string; userId: string; at: number }) => Promise<void>)\n | undefined;\n /**\n * An unsubscribe was recorded (`POST /t/u`): from the email's `topic`, or with `all` from every\n * email — then the host wakes the user's waits that read email reachability, so an\n * `exitWhen: not(reachable('email'))` takes effect at once. Its failure is logged, never a\n * reason to fail the unsubscribe.\n */\n onUnsubscribed?:\n | ((unsubscribe: {\n messageId: string;\n userId: string;\n at: number;\n topic: string;\n all: boolean;\n }) => Promise<void>)\n | undefined;\n}\n\nconst GIF = Uint8Array.from(\n atob('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'),\n (char) => char.charCodeAt(0)\n);\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** Whether `id` has the shape of a message id (a UUID); anything else names no copy. */\nexport const isMessageId = (id: string): boolean => UUID.test(id);\n\ninterface Requester {\n method: string;\n userAgent: string | null;\n asn: number | null;\n organization: string | null;\n country: string | null;\n}\n\nfunction requester(request: Request): Requester {\n const cf = (\n request as Request & { cf?: { asn?: number; asOrganization?: string; country?: string } }\n ).cf;\n return {\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n asn: cf?.asn ?? null,\n organization: cf?.asOrganization ?? null,\n country: cf?.country ?? null,\n };\n}\n\n/** A `/t/...` request's response, or undefined when the path is not a tracking route. */\nexport async function handleTracking(\n request: Request,\n reporting: JourneyReporting,\n options: TrackingOptions = {}\n): Promise<Response | undefined> {\n const url = new URL(request.url);\n const path = url.pathname.split('/');\n const root = path.at(1);\n const kind = path.at(2);\n const id = path.at(3);\n const position = path.at(4);\n if (root !== 't' || id === undefined || !UUID.test(id)) return undefined;\n const who = requester(request);\n const read = who.method === 'GET' || who.method === 'HEAD';\n if (kind === 'o' && read) {\n await recordOpen(reporting, id, who, options);\n return new Response(who.method === 'HEAD' ? null : GIF, {\n headers: { 'content-type': 'image/gif', 'cache-control': 'no-store, max-age=0' },\n });\n }\n if ((kind === 'l' || kind === 'u') && position !== undefined && read) {\n const target = await recordClick(reporting, id, Number(position), who, options);\n if (target === undefined) return page(options, 'This link has expired.', 404);\n if (kind === 'u') return confirmUnsubscribe(reporting, options, id);\n return Response.redirect(target, 302);\n }\n // The List-Unsubscribe URL opened in a browser (a client without one-click): the same question\n if (kind === 'u' && position === undefined && read)\n return confirmUnsubscribe(reporting, options, id);\n if (kind === 'd' && who.method === 'POST') {\n const known = await reporting.recordDeviceDelivered(id, Date.now());\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'p' && who.method === 'POST') {\n // A notification tap: a person, as the message id is what proves it is theirs\n const at = Date.now();\n let userId: string | undefined;\n const known = await reporting.recordOpen(id, (message) => {\n userId = message.userId;\n return { at, machine: false, reason: null, ...network(who) };\n });\n if (known && userId !== undefined && options.pushOpened !== undefined) {\n try {\n await options.pushOpened({ messageId: id, userId, at });\n } catch (error) {\n // The open is recorded; the event is the host's extra, never a reason to fail the beacon\n console.error(`[workflow] recording push open ${id} as an event failed`, error);\n }\n }\n return new Response(null, { status: known ? 204 : 404 });\n }\n if (kind === 'u' && who.method === 'POST') {\n const at = Date.now();\n // The page's \"all emails\" button; a mail client's one-click POST carries no scope\n const all = url.searchParams.get('scope') === 'all';\n const unsubscribed = await reporting.recordUnsubscribe(id, at, all ? 'all' : 'topic');\n // A mail client's one-click POST must succeed: for a copy no longer on record (its user\n // erased) there is nothing left to send to, so the answer is the same\n if (unsubscribed === undefined) return page(options, \"You won't get these emails anymore.\");\n // A transactional email never carried the link\n if (unsubscribed.topic === null) return page(options, 'This link has expired.', 404);\n if (options.onUnsubscribed !== undefined) {\n try {\n await options.onUnsubscribed({\n messageId: id,\n userId: unsubscribed.userId,\n at,\n topic: unsubscribed.topic,\n all,\n });\n } catch (error) {\n console.error(`[workflow] acting on the unsubscribe of ${id} failed`, error);\n }\n }\n const from = options.product === undefined ? '' : ` from ${escape(options.product)}`;\n return page(\n options,\n all\n ? `You won't get emails${from} anymore.`\n : `You won't get ${escape(topicName(unsubscribed.topic))} emails${from} anymore.`\n );\n }\n return undefined;\n}\n\n/** A topic id as the page shows it: `product_updates` reads as \"product updates\". */\nfunction topicName(topic: string): string {\n return topic.replace(/[_-]+/g, ' ');\n}\n\n/**\n * The question an unsubscribe link, or the List-Unsubscribe URL opened in a browser, asks: the\n * email's topic (the default), or every email. A copy nobody knows, or a transactional one (the\n * link was never in it), is an expired link.\n */\nasync function confirmUnsubscribe(\n reporting: JourneyReporting,\n options: TrackingOptions,\n id: string\n): Promise<Response> {\n const message = await reporting.lookupMessage(id);\n if (message?.channel !== 'email' || message.topic === null) {\n return page(options, 'This link has expired.', 404);\n }\n const product = options.product === undefined ? '' : ` from ${escape(options.product)}`;\n const topic = escape(topicName(message.topic));\n return page(\n options,\n `Unsubscribe from ${topic} emails${product}?`,\n 200,\n `<form method=\"post\" action=\"/t/u/${id}\"><button type=\"submit\">Unsubscribe from ${topic}</button></form>` +\n `<form method=\"post\" action=\"/t/u/${id}?scope=all\"><button type=\"submit\" class=\"secondary\">Unsubscribe from all emails</button></form>`\n );\n}\n\n/**\n * A journey push tapped open, as the app reports it (`JourneyClient.messageOpened`): an open on\n * the copy — only the recipient's own, and never a machine, since no proxy or scanner taps a\n * notification. False when the message is unknown or not the user's.\n */\nexport async function recordPushOpen(\n reporting: JourneyReporting,\n userId: string,\n messageId: string,\n at = Date.now()\n): Promise<boolean> {\n if (!UUID.test(messageId)) return false;\n let recorded = false;\n await reporting.recordOpen(messageId, (message) => {\n if (message.userId !== userId) return undefined;\n recorded = true;\n return {\n at,\n machine: false,\n reason: null,\n userAgent: null,\n asn: null,\n organization: null,\n country: null,\n };\n });\n return recorded;\n}\n\nasync function recordOpen(\n reporting: JourneyReporting,\n id: string,\n who: Requester,\n options: TrackingOptions\n) {\n const at = Date.now();\n await reporting.recordOpen(id, (message) => ({\n at,\n ...classifyOpen(\n { ...who, sinceDeliveredMs: at - message.deliveredAt },\n { quickMs: options.quickMs }\n ),\n ...network(who),\n }));\n}\n\nconst network = (who: Requester) => ({\n userAgent: who.userAgent,\n asn: who.asn,\n organization: who.organization,\n country: who.country,\n});\n\n/** Records the click and answers the URL to send the visitor to, or undefined for an unknown link. */\nasync function recordClick(\n reporting: JourneyReporting,\n id: string,\n position: number,\n who: Requester,\n options: TrackingOptions\n): Promise<string | undefined> {\n if (!Number.isInteger(position) || position < 0) return undefined;\n const at = Date.now();\n const recorded = await reporting.recordClick(id, position, at, BURST_WINDOW_MS, (link) => {\n const linksInBurst = new Set([position, ...link.recent.map((click) => click.position)]).size;\n const verdict = classifyClick(\n { ...who, sinceDeliveredMs: at - link.deliveredAt, linksInBurst, linkCount: link.linkCount },\n { quickMs: options.quickMs }\n );\n return {\n engagement: { at, ...verdict, ...network(who) },\n backdate: verdict.reason === 'link_burst' || verdict.reason === 'quick+link_pair',\n };\n });\n return recorded?.url;\n}\n\nfunction escape(text: string): string {\n return text.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n}\n\nfunction page(options: TrackingOptions, message: string, status = 200, extra = ''): Response {\n const title = escape(options.product ?? 'Email');\n const html = `<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>${title}</title><style>body{font:16px system-ui;margin:0;display:grid;place-items:center;min-height:100vh;background:#fafafa;color:#18181b}main{text-align:center;padding:24px}form{margin:12px 0}button{font:inherit;padding:10px 20px;border-radius:9999px;border:0;background:#18181b;color:#fff;cursor:pointer}button.secondary{background:#e4e4e7;color:#18181b}</style></head><body><main><p>${message}</p>${extra}</main></body></html>`;\n return new Response(html, { status, headers: { 'content-type': 'text/html; charset=utf-8' } });\n}\n"],"mappings":"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"}