@hogsend/cli 0.13.2 → 0.14.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 +53 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +15 -2
- 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.14.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.14.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.14.0",
|
|
48
|
+
"@hogsend/engine": "^0.14.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,56 @@ 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.
|
|
@@ -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.
|
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",
|