@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/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -1
- package/package.json +4 -4
- package/skills/hogsend-authoring-emails/SKILL.md +6 -0
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +62 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +15 -2
- package/skills/hogsend-conditions/SKILL.md +6 -3
- package/skills/hogsend-conditions/references/examples.md +19 -1
- package/src/commands/webhooks.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
48
|
-
"@hogsend/engine": "^0.
|
|
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[]`
|
|
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: {
|
package/src/commands/webhooks.ts
CHANGED
|
@@ -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
|
|
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",
|