@hogsend/cli 0.13.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.13.2",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  "tsup": "^8.5.1",
35
35
  "tsx": "^4.22.4",
36
36
  "vitest": "^4.1.7",
37
- "@hogsend/studio": "^0.13.2",
37
+ "@hogsend/studio": "^0.16.0",
38
38
  "@repo/typescript-config": "0.0.0"
39
39
  },
40
40
  "engines": {
@@ -44,8 +44,8 @@
44
44
  "@clack/prompts": "^1.5.0",
45
45
  "better-auth": "^1.6.11",
46
46
  "picocolors": "^1.1.1",
47
- "@hogsend/db": "^0.13.2",
48
- "@hogsend/engine": "^0.13.2"
47
+ "@hogsend/db": "^0.16.0",
48
+ "@hogsend/engine": "^0.16.0"
49
49
  },
50
50
  "scripts": {
51
51
  "prebuild": "node scripts/bundle-studio.mjs",
@@ -50,6 +50,12 @@ get a type error at the send call site — the #1 trap. See
50
50
  snippet; `category` drives frequency-cap exemption (`transactional` is exempt).
51
51
  - **Tracking + unsubscribe are automatic on `emailService.send` / `sendEmail`.**
52
52
  You never call `prepareTrackedHtml` or `generateUnsubscribeUrl` yourself.
53
+ - **Semantic links (`EmailAction` from `@hogsend/email`)** make a click MEAN
54
+ something: an anchor that fires a real event (`event` + scalar `properties`)
55
+ through the full ingest pipeline — in-email yes/no questions, NPS scores,
56
+ one-tap choices. First answer per (send, event name) wins; scanner
57
+ click-bursts are suppressed. Details + rules in
58
+ `references/tracking-and-unsubscribe.md`.
53
59
 
54
60
  ## Task playbooks — load the matching reference
55
61
 
@@ -131,3 +131,65 @@ the click endpoint. You don't need to do anything for this; it's handled.
131
131
 
132
132
  Author the component; the engine guarantees tracking + unsubscribe on send.
133
133
  Full system docs: `docs/tracking.md`.
134
+
135
+ ---
136
+
137
+ ## Semantic links — in-email answers via `EmailAction`
138
+
139
+ A plain tracked link tells you it WAS clicked; a semantic link tells you what
140
+ the click MEANT. `EmailAction` (from `@hogsend/email`) renders an anchor that
141
+ carries an event name + scalar properties; the engine lifts that metadata into
142
+ the `tracked_links` row at send time (the attributes never reach the inbox) and
143
+ emits the event through the FULL ingest pipeline at click time — journeys can
144
+ `ctx.waitForEvent` it, destinations (PostHog et al) receive it as
145
+ `email.action`, and `user_events` records it.
146
+
147
+ ```tsx
148
+ import { EmailAction } from "@hogsend/email";
149
+ import { Events } from "../journeys/constants/index.js";
150
+
151
+ <EmailAction
152
+ event={Events.CHECKIN_ANSWERED} // your event — NOT email.*/journey.*/bucket.*/contact.*
153
+ properties={{ answer: "yes" }} // scalars only (string/number/boolean/null)
154
+ href="https://app.example.com/thanks" // where the human lands after the click
155
+ className="…" // styled like any anchor (Tailwind works)
156
+ >
157
+ Going great
158
+ </EmailAction>
159
+ ```
160
+
161
+ Rules the engine enforces (a violation FAILS the send, loudly):
162
+
163
+ - `event` must not use a reserved namespace (`email.`, `journey.`, `bucket.`,
164
+ `contact.` — dot or colon form).
165
+ - `properties` values must be scalars; the JSON must stay under 2 KB.
166
+ - The `href` must be an absolute http(s) URL (it is also click-tracked as
167
+ normal).
168
+
169
+ Semantics at click time:
170
+
171
+ - **First answer wins, per (send, event name).** An NPS row of eleven
172
+ EmailActions shares one answer slot — only the first clicked score ingests
173
+ (idempotency key `sem:<emailSendId>:<event>`). The generic `email.link_clicked`
174
+ still fires for every hit.
175
+ - **Answers confirm after a ~30s window.** A click is only a provisional
176
+ answer: a deferred task judges it once the burst window has fully elapsed,
177
+ so a scanner that clicks every link (Outlook SafeLinks / Proofpoint) is seen
178
+ in full — including its first click — and suppressed before anything is
179
+ recorded. Cost: ~30s of answer latency (invisible to day-scale journey
180
+ waits). Still don't make destructive actions ("cancel my account") a
181
+ one-click EmailAction.
182
+ - Two EmailActions may share the same `href` with different
183
+ `event`/`properties` — they get separate tracked links (no URL collapse).
184
+ - The answering journey reads the payload from
185
+ `ctx.waitForEvent(...) → { timedOut, properties }` — see the
186
+ hogsend-authoring-journeys skill.
187
+ - **No landing page?** `href={HOSTED_ANSWER_HREF}` (from `@hogsend/email`)
188
+ resolves to the engine-hosted answer page: a thanks page with an optional
189
+ free-text box whose submission ingests `<event>.comment` (one comment per
190
+ send + event). Possession of the link is the auth, like unsubscribe.
191
+ - **Cross-device stitch (opt-in):** `TRACKING_IDENTITY_TOKEN=true` appends an
192
+ encrypted one-hour `hs_t` token to tracked redirects; the landing site
193
+ exchanges it at `POST /v1/t/identify` for the distinct id and calls
194
+ `posthog.identify`. Never put a raw email in a URL — the token exists so
195
+ you don't have to.
@@ -36,7 +36,7 @@ await ctx.sleepUntil(at, { label: "morning-nudge" });
36
36
  // whichever first. The reactive alternative to "sleep a fixed window, then poll
37
37
  // ctx.history": it resumes the INSTANT the event lands. Forward-looking — only
38
38
  // events fired AFTER the wait begins count (use ctx.history.hasEvent for the past).
39
- const { timedOut } = await ctx.waitForEvent({
39
+ const { timedOut, properties } = await ctx.waitForEvent({
40
40
  event: Events.FEATURE_USED,
41
41
  timeout: days(7), // REQUIRED, capped at the 720h task execution limit
42
42
  label: "await-activation", // optional — written as currentNodeId
@@ -44,10 +44,23 @@ const { timedOut } = await ctx.waitForEvent({
44
44
  if (timedOut) {
45
45
  // they never did it — nudge (re-check ctx.guard.isSubscribed() first after a long wait)
46
46
  } else {
47
- // event arrived — they activated on their own
47
+ // event arrived — they activated on their own. `properties` carries the
48
+ // matched event's payload (best-effort, scalars only) — branch on the
49
+ // answer directly, e.g. a semantic-link NPS score:
50
+ // if (typeof properties?.score === "number" && properties.score <= 6) { … }
48
51
  }
49
52
  ```
50
53
 
54
+ NEVER put the awaited event in `exitOn` too: an `exitOn` match mid-wait aborts
55
+ the run (`JourneyExitedError`) BEFORE your post-wait branch executes. React via
56
+ `waitForEvent` OR exit via `exitOn` — one event name, one role.
57
+
58
+ Waiting twice for the same event (e.g. after a reminder send)? Pass
59
+ `lookback: hours(1)` on the second wait — the wait is forward-looking, so an
60
+ answer landing in the gap between the two waits would otherwise be missed;
61
+ `lookback` checks recent `user_events` first and resolves immediately with the
62
+ payload.
63
+
51
64
  If the journey `exitOn`-matches (or is cancelled) WHILE waiting, the run aborts
52
65
  cleanly — state goes `"exited"`, the durable run is cancelled, and no post-wait
53
66
  step (or email) fires. You don't catch anything; the engine handles it.
@@ -37,10 +37,12 @@ Not every surface accepts all four types — match the type to the field:
37
37
 
38
38
  - **`trigger.where`** (journey) → `PropertyCondition[]` ONLY. Property
39
39
  conditions, AND-ed together, evaluated against the triggering event's
40
- properties. No event/engagement/composite legs here.
40
+ properties. No event/engagement/composite legs here. Authoring sugar: a
41
+ builder function — `where: (b) => b.prop("score").lte(6)` (or an array of
42
+ terminals) — resolves ONCE at `defineJourney` time to the identical POJOs.
41
43
  - **`exitOn[].where`** (journey) → `PropertyCondition[]` ONLY. Same shape;
42
44
  AND-ed against the incoming event's properties. Omit `where` to exit on the
43
- event name alone.
45
+ event name alone. Accepts the same builder-function sugar.
44
46
  - **`criteria`** (bucket) → a single `ConditionEval` tree — ALL FOUR types,
45
47
  composed with `composite` / `b.all()` / `b.any()`. This is the only surface
46
48
  that runs against the database (event counts, engagement, windows).
@@ -57,7 +59,8 @@ Not every surface accepts all four types — match the type to the field:
57
59
 
58
60
  ## Golden rules
59
61
 
60
- 1. `trigger.where` and `exitOn[].where` take `PropertyCondition[]` only — if
62
+ 1. `trigger.where` and `exitOn[].where` take `PropertyCondition[]` (write them
63
+ with the `(b) => b.prop(...)` builder for short) — if
61
64
  you need an event count or a time window, that logic belongs in the
62
65
  journey's `run` body (`ctx.history.hasEvent`) or in a bucket's `criteria`,
63
66
  not in `where`.
@@ -14,6 +14,23 @@ properties. Only `property` conditions are valid here — there is no `within`,
14
14
  no event count, no composite. (Need a count or a window? Put that logic in the
15
15
  `run` body via `ctx.history.hasEvent`, or model it as a bucket.)
16
16
 
17
+ Author it either way — the builder form resolves once at `defineJourney` time
18
+ to the identical data (same machinery as bucket criteria):
19
+
20
+ ```ts
21
+ // Builder form (recommended)
22
+ trigger: {
23
+ event: Events.NPS_DETRACTOR,
24
+ where: (b) => b.prop("score").lte(3),
25
+ },
26
+
27
+ // Multiple conditions: return an array (AND-ed)
28
+ trigger: {
29
+ event: Events.CHECKOUT_ABANDONED,
30
+ where: (b) => [b.prop("plan").eq("pro"), b.prop("cartValue").gte(100)],
31
+ },
32
+ ```
33
+
17
34
  ```ts
18
35
  import { days, hours } from "@hogsend/core";
19
36
  import { defineJourney, sendEmail } from "@hogsend/engine";
@@ -56,7 +73,8 @@ export const proCheckoutAbandoned = defineJourney({
56
73
 
57
74
  `exitOn` is an array of `{ event, where? }`. The engine matches the incoming
58
75
  event name; if `where` is present it must pass (`PropertyCondition[]`, AND-ed)
59
- for the exit to fire. Omit `where` to exit on the event name alone.
76
+ for the exit to fire. Omit `where` to exit on the event name alone. The
77
+ builder form works here too: `where: (b) => b.prop("plan").eq("pro")`.
60
78
 
61
79
  ```ts
62
80
  meta: {
@@ -4,7 +4,7 @@ import { color } from "../lib/output.js";
4
4
  import type { Command, CommandContext } from "./types.js";
5
5
 
6
6
  /**
7
- * The 13-event outbound catalog, VENDORED from the engine's
7
+ * The 14-event outbound catalog, VENDORED from the engine's
8
8
  * `WEBHOOK_EVENT_TYPES` (lib/webhook-signing.ts). The CLI cannot import the
9
9
  * engine, so the tuple is re-declared here and MUST be kept in sync BY HAND when
10
10
  * the engine catalog changes. The `webhook.test` sentinel is NOT a member.
@@ -18,6 +18,7 @@ const WEBHOOK_EVENT_TYPES = [
18
18
  "email.delivered",
19
19
  "email.opened",
20
20
  "email.clicked",
21
+ "email.action",
21
22
  "email.bounced",
22
23
  "email.complained",
23
24
  "journey.completed",