@hogsend/cli 0.0.1 → 0.2.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 +2238 -75
- package/dist/bin.js.map +1 -1
- package/package.json +9 -1
- package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
- package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
- package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
- package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
- package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
- package/skills/hogsend-authoring-emails/SKILL.md +68 -0
- package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
- package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
- package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
- package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
- package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +93 -0
- package/skills/hogsend-authoring-journeys/references/journey-context.md +110 -0
- package/skills/hogsend-authoring-journeys/references/journey-meta.md +142 -0
- package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
- package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
- package/skills/hogsend-cli/SKILL.md +81 -0
- package/skills/hogsend-cli/references/debug-a-journey.md +66 -0
- package/skills/hogsend-cli/references/manage-journeys.md +53 -0
- package/skills/hogsend-cli/references/query-stats.md +66 -0
- package/skills/hogsend-cli/references/setup-local.md +52 -0
- package/skills/hogsend-conditions/SKILL.md +70 -0
- package/skills/hogsend-conditions/references/condition-types.md +251 -0
- package/skills/hogsend-conditions/references/durations.md +90 -0
- package/skills/hogsend-conditions/references/examples.md +188 -0
- package/skills/hogsend-database/SKILL.md +70 -0
- package/skills/hogsend-database/references/client-track-schema.md +97 -0
- package/skills/hogsend-database/references/migrations.md +132 -0
- package/skills/hogsend-database/references/schema-drift.md +123 -0
- package/skills/hogsend-deploy/SKILL.md +62 -0
- package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
- package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
- package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
- package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
- package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
- package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
- package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
- package/src/bin.ts +73 -111
- package/src/commands/contacts.ts +316 -0
- package/src/commands/doctor.ts +239 -0
- package/src/commands/eject.ts +106 -0
- package/src/commands/events.ts +154 -0
- package/src/commands/index.ts +36 -0
- package/src/commands/journeys.ts +343 -0
- package/src/commands/patch.ts +80 -0
- package/src/commands/setup.ts +322 -0
- package/src/commands/skills.ts +208 -0
- package/src/commands/stats.ts +87 -0
- package/src/commands/studio.ts +261 -0
- package/src/commands/types.ts +41 -0
- package/src/commands/upgrade.ts +245 -0
- package/src/index.ts +2 -0
- package/src/lib/config.ts +147 -0
- package/src/lib/http.ts +145 -0
- package/src/lib/output.ts +185 -0
- package/src/lib/prompt.ts +17 -0
- package/src/lib/skills.ts +186 -0
- package/studio/assets/index-BVA9GZqq.css +1 -0
- package/studio/assets/index-kPwzOOyG.js +230 -0
- package/studio/index.html +13 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Render & preview — render helpers, the registry, how the consumer wires it
|
|
2
|
+
|
|
3
|
+
`@hogsend/email` is render machinery only. It exposes the render functions, the
|
|
4
|
+
registry helpers, and the open `TemplateRegistryMap` type — but no concrete
|
|
5
|
+
templates. Your app supplies the registry; the engine threads it through at send
|
|
6
|
+
and render time.
|
|
7
|
+
|
|
8
|
+
## The render helpers
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { renderToHtml, renderToPlainText } from "@hogsend/email";
|
|
12
|
+
import type { ReactElement } from "react";
|
|
13
|
+
|
|
14
|
+
// Both wrap react-email's render():
|
|
15
|
+
const html = await renderToHtml(element); // render(element)
|
|
16
|
+
const text = await renderToPlainText(element); // render(element, { plainText: true })
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
You rarely call these directly — the engine's mailer does it for you (HTML + text
|
|
20
|
+
from one component). They're useful for snapshot tests of a `.tsx`.
|
|
21
|
+
|
|
22
|
+
## The registry helpers
|
|
23
|
+
|
|
24
|
+
`@hogsend/email` resolves a template KEY against a `TemplateRegistry` you pass
|
|
25
|
+
in:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { getTemplate, getPreviewText, createRegistry } from "@hogsend/email";
|
|
29
|
+
|
|
30
|
+
// Resolve key → { element, subject, category }
|
|
31
|
+
const { element, subject, category } = getTemplate({
|
|
32
|
+
key: "activation/welcome",
|
|
33
|
+
props: { name: "Ada", dashboardUrl: "https://app.example.com" },
|
|
34
|
+
registry, // your src/emails/registry.ts `templates`
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Compute the inbox snippet from the entry's `preview(props)`
|
|
38
|
+
const snippet = getPreviewText({ key: "activation/welcome", props, registry });
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`createRegistry(base, overrides)` shallow-merges a partial over a base registry —
|
|
42
|
+
handy if you ever inherit a starter set and tweak a few keys:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
export const templates = createRegistry(starterTemplates, {
|
|
46
|
+
"activation/welcome": { ...starterTemplates["activation/welcome"], category: "journey" },
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How the consumer registry is built and threaded
|
|
51
|
+
|
|
52
|
+
You build the registry once in `src/emails/registry.ts` and re-export it from
|
|
53
|
+
`src/emails/index.ts`:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// src/emails/registry.ts
|
|
57
|
+
import type { TemplateRegistry } from "@hogsend/email";
|
|
58
|
+
import WelcomeEmail from "./welcome.js";
|
|
59
|
+
import ActivationNudgeEmail from "./activation-nudge.js";
|
|
60
|
+
|
|
61
|
+
export const templates: TemplateRegistry = {
|
|
62
|
+
"activation/welcome": {
|
|
63
|
+
component: WelcomeEmail,
|
|
64
|
+
defaultSubject: "Welcome aboard",
|
|
65
|
+
category: "transactional",
|
|
66
|
+
preview: (props) => `Welcome, ${props.name}!`,
|
|
67
|
+
},
|
|
68
|
+
"activation/nudge": {
|
|
69
|
+
component: ActivationNudgeEmail,
|
|
70
|
+
defaultSubject: "You haven't tried the key feature yet",
|
|
71
|
+
category: "journey",
|
|
72
|
+
preview: (props) => `${props.name}, you're missing out`,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// src/emails/index.ts
|
|
79
|
+
export { templates } from "./registry.js";
|
|
80
|
+
export type { ActivationNudgeEmailProps, WelcomeEmailProps } from "./types.js";
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The wiring into the engine already exists in `src/index.ts` — you don't add it,
|
|
84
|
+
but this is what it looks like:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// src/index.ts (already present in the scaffold)
|
|
88
|
+
import { createHogsendClient } from "@hogsend/engine";
|
|
89
|
+
import { templates } from "./emails/index.js";
|
|
90
|
+
|
|
91
|
+
const client = createHogsendClient({ journeys, buckets, email: { templates } });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`createHogsendClient` stores your `templates` on the email-service config; the
|
|
95
|
+
engine's `createTrackedMailer` reads `config.templates` and passes it to
|
|
96
|
+
`getTemplate({ key, props, registry })` on every `send` and `render`. That is the
|
|
97
|
+
whole reason the engine never bakes in business templates — yours flow in here.
|
|
98
|
+
|
|
99
|
+
## Previewing a template
|
|
100
|
+
|
|
101
|
+
For an at-a-glance HTML preview during development, render a component to a file:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
// scripts/preview.ts (your repo, run with tsx)
|
|
105
|
+
import { renderToHtml } from "@hogsend/email";
|
|
106
|
+
import { writeFileSync } from "node:fs";
|
|
107
|
+
import { templates } from "../src/emails/index.js";
|
|
108
|
+
|
|
109
|
+
const { component } = templates["activation/welcome"];
|
|
110
|
+
const html = await renderToHtml(component({ name: "Ada" }));
|
|
111
|
+
writeFileSync("preview.html", html);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Or run react-email's own dev server if you keep one configured. To inspect real
|
|
115
|
+
sends (open/click/bounce rates per template) against a running instance, use the
|
|
116
|
+
**hogsend-cli** skill rather than rendering locally.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# The template contract — five touch points that must agree
|
|
2
|
+
|
|
3
|
+
A sendable, type-checked template is one string (`"activation/welcome"`) wired
|
|
4
|
+
across five files. Get any of them out of sync and you get a compile error at
|
|
5
|
+
the `send` call site — this is the single most common email-authoring bug. The
|
|
6
|
+
checklist:
|
|
7
|
+
|
|
8
|
+
| # | File | What it declares | Keyed on |
|
|
9
|
+
|---|------|------------------|----------|
|
|
10
|
+
| 1 | `src/emails/<name>.tsx` | the react-email component (default export) | imported by (3) |
|
|
11
|
+
| 2 | `src/emails/types.ts` | the `Props` interface | imported by (1) + (4) |
|
|
12
|
+
| 3 | `src/emails/registry.ts` | `templates[key]` → component + subject + category | **the key** |
|
|
13
|
+
| 4 | `src/emails/templates.d.ts` | augments `TemplateRegistryMap` so `key → Props` | **the key** + Props |
|
|
14
|
+
| 5 | `src/journeys/constants/index.ts` | `Templates.*` constant journeys send | **the key** |
|
|
15
|
+
|
|
16
|
+
The **key** in (3), (4), and (5) must be byte-identical. The **Props** in (2)
|
|
17
|
+
must match what (1) destructures and what (4) maps the key to.
|
|
18
|
+
|
|
19
|
+
## Walk through, file by file
|
|
20
|
+
|
|
21
|
+
### 1. The component — `src/emails/order-shipped.tsx`
|
|
22
|
+
|
|
23
|
+
```tsx
|
|
24
|
+
// biome-ignore lint/correctness/noUnusedImports: required for JSX runtime
|
|
25
|
+
import React from "react";
|
|
26
|
+
import { Layout } from "./_components/layout.js";
|
|
27
|
+
import { Body, Button, Title } from "./_components/ui.js";
|
|
28
|
+
import type { OrderShippedEmailProps } from "./types.js";
|
|
29
|
+
|
|
30
|
+
export default function OrderShippedEmail({
|
|
31
|
+
name = "there",
|
|
32
|
+
trackingUrl = "https://example.com/track",
|
|
33
|
+
unsubscribeUrl,
|
|
34
|
+
}: OrderShippedEmailProps) {
|
|
35
|
+
return (
|
|
36
|
+
<Layout
|
|
37
|
+
preview={`Your order is on its way, ${name}`}
|
|
38
|
+
eyebrow="Shipped"
|
|
39
|
+
unsubscribeUrl={unsubscribeUrl}
|
|
40
|
+
>
|
|
41
|
+
<Title>Your order is on its way</Title>
|
|
42
|
+
<Body>Hey {name}, we just handed it to the carrier.</Body>
|
|
43
|
+
<Button href={trackingUrl}>Track your package</Button>
|
|
44
|
+
</Layout>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. The props — `src/emails/types.ts`
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
export interface OrderShippedEmailProps {
|
|
53
|
+
name: string;
|
|
54
|
+
trackingUrl?: string;
|
|
55
|
+
// Engine-injected on send (see tracking-and-unsubscribe.md) — accept it so
|
|
56
|
+
// the Layout/Footer can render an unsubscribe link. Always optional.
|
|
57
|
+
unsubscribeUrl?: string;
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### 3. The registry entry — `src/emails/registry.ts`
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import type { TemplateRegistry } from "@hogsend/email";
|
|
65
|
+
import OrderShippedEmail from "./order-shipped.js";
|
|
66
|
+
// ...other imports
|
|
67
|
+
|
|
68
|
+
export const templates: TemplateRegistry = {
|
|
69
|
+
// ...other entries
|
|
70
|
+
"fulfilment/order-shipped": {
|
|
71
|
+
component: OrderShippedEmail,
|
|
72
|
+
defaultSubject: "Your order is on its way",
|
|
73
|
+
category: "transactional",
|
|
74
|
+
preview: (props) => `On its way, ${props.name}`,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 4. The augmentation — `src/emails/templates.d.ts`
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import type { OrderShippedEmailProps } from "./types.js";
|
|
83
|
+
|
|
84
|
+
declare module "@hogsend/email" {
|
|
85
|
+
interface TemplateRegistryMap {
|
|
86
|
+
// ...other keys
|
|
87
|
+
"fulfilment/order-shipped": OrderShippedEmailProps;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`@hogsend/email` ships an **empty** `TemplateRegistryMap`. This `declare module`
|
|
93
|
+
block is the only thing that teaches the type system your keys → props, which is
|
|
94
|
+
what makes `emailService.send({ template, props })` type-check.
|
|
95
|
+
|
|
96
|
+
### 5. The constant — `src/journeys/constants/index.ts`
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
export const Templates = {
|
|
100
|
+
// ...existing keys
|
|
101
|
+
ORDER_SHIPPED: "fulfilment/order-shipped",
|
|
102
|
+
} as const;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Journeys send `template: Templates.ORDER_SHIPPED`, never a raw string, so a typo
|
|
106
|
+
is a compile error rather than a silently-missing template at runtime.
|
|
107
|
+
|
|
108
|
+
## The #1 type-error trap
|
|
109
|
+
|
|
110
|
+
The send site resolves props from the registry map:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// engine signature (do not edit): send<K extends TemplateName>(
|
|
114
|
+
// options: { template: K; props: TemplateRegistryMap[K]; ... })
|
|
115
|
+
await container.emailService.send({
|
|
116
|
+
template: "fulfilment/order-shipped",
|
|
117
|
+
props: { name: "Ada", trackingUrl: "https://…" }, // typed by templates.d.ts
|
|
118
|
+
to: user.email,
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
If you added the registry entry (3) but forgot the augmentation (4), `TemplateName`
|
|
123
|
+
won't include your key and `template:` rejects the string. If (2) and (4)
|
|
124
|
+
disagree on Props, `props:` rejects the object. **When you see "is not assignable
|
|
125
|
+
to TemplateName" or a props mismatch, re-check 2/3/4/5 against each other.**
|
|
126
|
+
|
|
127
|
+
## Rename / delete checklist
|
|
128
|
+
|
|
129
|
+
Renaming `"a/old"` → `"a/new"`: change the literal in (3), (4), and (5) together;
|
|
130
|
+
no `.tsx`/`types.ts` change needed if the component/props are unchanged. Deleting
|
|
131
|
+
a template: remove its entry from (3), its line from (4), its `Templates.*`
|
|
132
|
+
constant from (5) if present, then delete the `.tsx` and its `Props` from (2).
|
|
133
|
+
Leaving a key in (4) with no (3) entry, or vice-versa, will surface as a type or
|
|
134
|
+
runtime error.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Tracking & unsubscribe — what happens automatically on every send
|
|
2
|
+
|
|
3
|
+
This is co-located with email authoring on purpose: link-click tracking, open
|
|
4
|
+
tracking, preference checks, and unsubscribe are **automatic on every send**.
|
|
5
|
+
The engine's `createTrackedMailer` owns the whole pipeline; the email provider
|
|
6
|
+
(`createResendProvider`) is just the dumb wire. You author the `.tsx`; the engine
|
|
7
|
+
does the rest. You should NOT call any of these helpers yourself — this reference
|
|
8
|
+
explains what runs so you author components that leave room for it.
|
|
9
|
+
|
|
10
|
+
## The send pipeline (engine-owned, runs on `send` / `sendEmail`)
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
emailService.send({ template, props, to, ... }) // or sendEmail({...}) in a journey
|
|
14
|
+
│
|
|
15
|
+
▼ createTrackedMailer.send → sendTrackedEmail (the DB-backed path)
|
|
16
|
+
1. preference / suppression check, then frequency cap
|
|
17
|
+
(both skipped when skipPreferenceCheck is set)
|
|
18
|
+
2. getTemplate(key, props, registry) → resolve element + subject + category
|
|
19
|
+
3. insert email_sends row → gives the send a stable emailSendId (status "queued")
|
|
20
|
+
4. renderToHtml(element), then prepareTrackedHtml(html, emailSendId, baseUrl, db):
|
|
21
|
+
• rewriteLinks() — every <a href="https?://…"> → /v1/t/c/:linkId
|
|
22
|
+
• injectOpenPixel() — <img src="/v1/t/o/:emailSendId"> before </body>
|
|
23
|
+
(only when baseUrl + prepareTrackedHtml are present; else send the raw react element)
|
|
24
|
+
5. provider.send(...) — Resend gets the already-rewritten HTML
|
|
25
|
+
6. update email_sends → resendId + status "sent" (or "failed" on throw)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Tracking comes along regardless of which provider you supply, because steps 2–4
|
|
29
|
+
live in the engine, not the provider. The tracking domain is `options.baseUrl`
|
|
30
|
+
(threaded from `config.baseUrl`, i.e. `API_PUBLIC_URL`).
|
|
31
|
+
|
|
32
|
+
## Link-click tracking
|
|
33
|
+
|
|
34
|
+
`rewriteLinks` (engine `lib/tracking.ts`) scans the rendered HTML for
|
|
35
|
+
`href="https://…"`, deduplicates the URLs, bulk-inserts one `tracked_links` row
|
|
36
|
+
per unique URL, then single-pass replaces each href with
|
|
37
|
+
`{API_PUBLIC_URL}/v1/t/c/{linkId}`. At click time:
|
|
38
|
+
|
|
39
|
+
- `GET /v1/t/c/:id` records a `link_clicks` row (IP + user-agent), increments
|
|
40
|
+
`tracked_links.click_count`, sets `email_sends.clicked_at` (first click only,
|
|
41
|
+
`WHERE clicked_at IS NULL`), then **302-redirects to the original URL**.
|
|
42
|
+
- After responding it fire-and-forgets an `email.link_clicked` event (props:
|
|
43
|
+
`emailSendId`, `templateKey`, `linkUrl`, `linkId`) into PostHog + the ingest
|
|
44
|
+
pipeline, so journeys can branch on it and `exitOn` can fire.
|
|
45
|
+
|
|
46
|
+
Authoring implication: use real `<a href>` / react-email `Button`/`Link` with
|
|
47
|
+
absolute `https://` URLs and they're tracked automatically. Non-HTTP links
|
|
48
|
+
(`mailto:`, `tel:`) are left alone — the regex only matches `https?://`.
|
|
49
|
+
|
|
50
|
+
## Open tracking
|
|
51
|
+
|
|
52
|
+
`injectOpenPixel` appends a 1×1 GIF `<img src="{API_PUBLIC_URL}/v1/t/o/{emailSendId}">`
|
|
53
|
+
just before `</body>` (so always compose inside `Layout`, which emits a proper
|
|
54
|
+
`<body>`). At open time:
|
|
55
|
+
|
|
56
|
+
- `GET /v1/t/o/:id` sets `email_sends.opened_at` (first open only), returns a
|
|
57
|
+
42-byte transparent GIF with `Cache-Control: no-store`.
|
|
58
|
+
- Then fire-and-forgets an `email.opened` event (props: `emailSendId`,
|
|
59
|
+
`templateKey`).
|
|
60
|
+
|
|
61
|
+
The engine's own constants for these are `EMAIL_OPENED = "email.opened"` and
|
|
62
|
+
`EMAIL_LINK_CLICKED = "email.link_clicked"`. In journey code, reference them via
|
|
63
|
+
your `Events` constants and check `ctx.history.hasEvent({ event })` to branch on
|
|
64
|
+
engagement — see the **hogsend-authoring-journeys** skill.
|
|
65
|
+
|
|
66
|
+
## Preference / suppression check
|
|
67
|
+
|
|
68
|
+
Before sending, `sendTrackedEmail` checks `email_preferences` for the recipient.
|
|
69
|
+
If the user is unsubscribed/suppressed/category-unsubscribed, the send is skipped
|
|
70
|
+
and the result `status` reflects it (`"unsubscribed"` / `"suppressed"` /
|
|
71
|
+
`"skipped"`) — no provider call. Genuinely transactional mail that must always go
|
|
72
|
+
out can pass `skipPreferenceCheck: true`. Frequency caps also short-circuit here
|
|
73
|
+
(`status: "skipped", reason: "frequency_capped"`); `category: "transactional"` is
|
|
74
|
+
exempt from caps by default.
|
|
75
|
+
|
|
76
|
+
## Unsubscribe — token, URL, and the footer slot
|
|
77
|
+
|
|
78
|
+
The unsubscribe link is a signed, expiring token, not a DB lookup. The engine's
|
|
79
|
+
`sendEmail` builds it for journey sends:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { generateUnsubscribeUrl } from "@hogsend/email";
|
|
83
|
+
// engine builds this for you when API_PUBLIC_URL + BETTER_AUTH_SECRET are set:
|
|
84
|
+
const unsubscribeUrl = generateUnsubscribeUrl({
|
|
85
|
+
baseUrl: process.env.API_PUBLIC_URL,
|
|
86
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
87
|
+
externalId: userId,
|
|
88
|
+
email: to,
|
|
89
|
+
});
|
|
90
|
+
// → {baseUrl}/v1/email/unsubscribe?token=<base64url payload>.<hmac-sha256 sig>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It is injected into your template as the `unsubscribeUrl` prop AND set as the
|
|
94
|
+
`List-Unsubscribe` + `List-Unsubscribe-Post` headers (one-click unsubscribe). All
|
|
95
|
+
you do as the author is **accept `unsubscribeUrl?: string` in your props and pass
|
|
96
|
+
it to `Layout`** (which forwards it to `Footer`) — that's the slot:
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
export default function MyEmail({ name = "there", unsubscribeUrl }: MyEmailProps) {
|
|
100
|
+
return (
|
|
101
|
+
<Layout preview={`Hi ${name}`} unsubscribeUrl={unsubscribeUrl}>
|
|
102
|
+
{/* … */}
|
|
103
|
+
</Layout>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
There is a matching `generatePreferenceCenterUrl(...)` →
|
|
109
|
+
`{baseUrl}/v1/email/preferences?token=…` (action `"manage"`) for a full
|
|
110
|
+
preference center link; pass it as `preferencesUrl` to `Layout` the same way.
|
|
111
|
+
|
|
112
|
+
## Why these links aren't click-tracked
|
|
113
|
+
|
|
114
|
+
`rewriteLinks` skips any URL containing `/v1/email/unsubscribe` or
|
|
115
|
+
`/v1/email/preferences` (the `SKIP_PATTERNS`), so unsubscribe and preference
|
|
116
|
+
links go straight through un-rewritten — an unsubscribe must never bounce through
|
|
117
|
+
the click endpoint. You don't need to do anything for this; it's handled.
|
|
118
|
+
|
|
119
|
+
## What you do NOT do
|
|
120
|
+
|
|
121
|
+
- Don't call `prepareTrackedHtml`, `rewriteLinks`, or `injectOpenPixel` — the
|
|
122
|
+
mailer calls them.
|
|
123
|
+
- Don't call `generateUnsubscribeUrl` in a template — the engine injects the URL.
|
|
124
|
+
- Don't hand-roll an open pixel or rewrite links in your `.tsx`.
|
|
125
|
+
|
|
126
|
+
Author the component; the engine guarantees tracking + unsubscribe on send.
|
|
127
|
+
Full system docs: `docs/tracking.md`.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: hogsend-authoring-journeys
|
|
3
|
+
description: Use when adding or editing a lifecycle journey in src/journeys/ — wiring a defineJourney() trigger/entryLimit/exitOn/suppress, writing the run(user, ctx) control flow, durable sleeps, branching on history/engagement, sending email from a journey, and the register-in-index + thread-into-client/worker ritual.
|
|
4
|
+
license: MIT
|
|
5
|
+
metadata:
|
|
6
|
+
author: withSeismic
|
|
7
|
+
version: "1.0.0"
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Authoring Hogsend journeys
|
|
11
|
+
|
|
12
|
+
A journey is a code-first lifecycle flow. You declare a `defineJourney({ meta,
|
|
13
|
+
run })` in `src/journeys/`: `meta` says who enters and when they exit, and
|
|
14
|
+
`run(user, ctx)` is plain TypeScript control flow — send email, durably sleep,
|
|
15
|
+
branch on history. Each journey compiles to its own Hatchet durable task, so the
|
|
16
|
+
worker can restart mid-flow and resume exactly where it left off.
|
|
17
|
+
|
|
18
|
+
You are editing a **scaffolded consumer app** (content only). You import from
|
|
19
|
+
`@hogsend/engine` and `@hogsend/core`; you never touch engine internals.
|
|
20
|
+
|
|
21
|
+
## Anatomy of a journey
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { days, hours } from "@hogsend/core";
|
|
25
|
+
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
26
|
+
import { Events, Templates } from "./constants/index.js";
|
|
27
|
+
|
|
28
|
+
export const welcome = defineJourney({
|
|
29
|
+
meta: {
|
|
30
|
+
id: "welcome", // stable, unique id
|
|
31
|
+
name: "Welcome Series",
|
|
32
|
+
enabled: true,
|
|
33
|
+
trigger: { event: Events.USER_CREATED }, // event that enrolls a user
|
|
34
|
+
entryLimit: "once", // re-entry policy (+ entryPeriod)
|
|
35
|
+
suppress: hours(12), // required declared cool-down field
|
|
36
|
+
exitOn: [{ event: Events.USER_DELETED }], // events that pull users out
|
|
37
|
+
},
|
|
38
|
+
run: async (user, ctx) => {
|
|
39
|
+
await sendEmail({ // STANDALONE import, not ctx.*
|
|
40
|
+
to: user.email,
|
|
41
|
+
userId: user.id,
|
|
42
|
+
journeyStateId: user.stateId,
|
|
43
|
+
template: Templates.ACTIVATION_WELCOME,
|
|
44
|
+
subject: "Welcome — let's get you set up",
|
|
45
|
+
journeyName: user.journeyName,
|
|
46
|
+
});
|
|
47
|
+
await ctx.sleep({ duration: days(2), label: "post-welcome" });
|
|
48
|
+
const { found } = await ctx.history.hasEvent({
|
|
49
|
+
userId: user.id,
|
|
50
|
+
event: Events.FEATURE_USED,
|
|
51
|
+
});
|
|
52
|
+
if (!found) {
|
|
53
|
+
await sendEmail({ /* nudge */ });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Key concepts
|
|
60
|
+
|
|
61
|
+
- **`ctx` is orchestration primitives ONLY** — `sleep`, `sleepUntil`, `when`,
|
|
62
|
+
`checkpoint`, `trigger`, `identify`, `guard.isSubscribed`,
|
|
63
|
+
`history.hasEvent/journey/email`, `posthog.capture`. Features are standalone
|
|
64
|
+
imports: `sendEmail()` and `getPostHog()` come from `@hogsend/engine`, NOT off
|
|
65
|
+
`ctx`.
|
|
66
|
+
- **Duration helpers** `days()` / `hours()` / `minutes()` from `@hogsend/core`
|
|
67
|
+
(also re-exported by `@hogsend/engine`) — never magic strings.
|
|
68
|
+
- **`user`** carries `id`, `email`, `properties`, `stateId`, `journeyId`,
|
|
69
|
+
`journeyName` — pass `user.stateId` to `sendEmail` so the send is attributed.
|
|
70
|
+
- **Constants** `Events` / `Templates` live in your `src/journeys/constants/`.
|
|
71
|
+
`Templates` keys must match a key in `src/emails/` registry.
|
|
72
|
+
|
|
73
|
+
## Task playbooks — load the matching reference
|
|
74
|
+
|
|
75
|
+
- **Shape `meta` (trigger, entryLimit, exitOn, suppress) + understand the
|
|
76
|
+
enrollment gates and state transitions** → `references/journey-meta.md`
|
|
77
|
+
- **The full `ctx` primitive API and what is deliberately NOT on it** →
|
|
78
|
+
`references/journey-context.md`
|
|
79
|
+
- **Send an email from inside `run`** → `references/sending-email-from-a-journey.md`
|
|
80
|
+
- **Branch after a sleep on engagement / history, idempotently** →
|
|
81
|
+
`references/branch-on-engagement.md`
|
|
82
|
+
- **Register a new journey: export + thread into client/worker + ENABLED_JOURNEYS**
|
|
83
|
+
→ `references/register-a-journey.md`
|
|
84
|
+
|
|
85
|
+
For `trigger.where` / `exitOn[].where` property conditions and the duration
|
|
86
|
+
helpers in depth, see the **hogsend-conditions** skill. To verify a journey runs
|
|
87
|
+
against a live instance (enroll a test user, watch it complete), see the
|
|
88
|
+
**hogsend-cli** skill.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Branching on engagement / history after a sleep
|
|
2
|
+
|
|
3
|
+
The classic lifecycle shape: send something, durably wait, then branch on what
|
|
4
|
+
the user did (or didn't do) in the meantime. The branch primitives live on
|
|
5
|
+
`ctx.history.*` and `ctx.guard.isSubscribed()`; the wait is `ctx.sleep`.
|
|
6
|
+
|
|
7
|
+
## The pattern
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { days } from "@hogsend/core";
|
|
11
|
+
import { defineJourney, sendEmail } from "@hogsend/engine";
|
|
12
|
+
import { Events, Templates } from "./constants/index.js";
|
|
13
|
+
|
|
14
|
+
export const activation = defineJourney({
|
|
15
|
+
meta: { /* ... */ },
|
|
16
|
+
run: async (user, ctx) => {
|
|
17
|
+
await sendEmail({
|
|
18
|
+
to: user.email,
|
|
19
|
+
userId: user.id,
|
|
20
|
+
journeyStateId: user.stateId,
|
|
21
|
+
template: Templates.ACTIVATION_WELCOME,
|
|
22
|
+
subject: "Welcome — let's get you set up",
|
|
23
|
+
journeyName: user.journeyName,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Durable wait. The worker can restart here and resume.
|
|
27
|
+
await ctx.sleep({ duration: days(2), label: "post-welcome" });
|
|
28
|
+
|
|
29
|
+
// Branch on behaviour that happened DURING the sleep.
|
|
30
|
+
const { found: activated } = await ctx.history.hasEvent({
|
|
31
|
+
userId: user.id,
|
|
32
|
+
event: Events.FEATURE_USED,
|
|
33
|
+
});
|
|
34
|
+
if (activated) return; // happy path — nothing more to do
|
|
35
|
+
|
|
36
|
+
// Re-check subscription after the long wait before sending again.
|
|
37
|
+
if (!(await ctx.guard.isSubscribed())) return;
|
|
38
|
+
|
|
39
|
+
await sendEmail({
|
|
40
|
+
to: user.email,
|
|
41
|
+
userId: user.id,
|
|
42
|
+
journeyStateId: user.stateId,
|
|
43
|
+
template: Templates.ACTIVATION_NUDGE,
|
|
44
|
+
subject: "You haven't tried the key feature yet",
|
|
45
|
+
journeyName: user.journeyName,
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## The branch sources
|
|
52
|
+
|
|
53
|
+
- **`ctx.history.hasEvent({ userId, event, within? })`** → `{ found, count }`.
|
|
54
|
+
Did the user fire an event? Add `within: days(7)` to scope to a window. This is
|
|
55
|
+
the workhorse for "did they activate / convert / open the app".
|
|
56
|
+
- **`ctx.history.email({ email, template })`** → `{ sent, lastSentAt, count }`.
|
|
57
|
+
Did this email already go out? Use it to avoid re-sending the same template on
|
|
58
|
+
a re-run.
|
|
59
|
+
- **`ctx.history.journey({ userId, journeyId })`** →
|
|
60
|
+
`{ completed, lastCompletedAt, entryCount }`. Has the user been through another
|
|
61
|
+
journey? Branch on cross-journey state.
|
|
62
|
+
- **`ctx.guard.isSubscribed()`** → `boolean`. ALWAYS re-check before sending
|
|
63
|
+
after a long `ctx.sleep` — a user can unsubscribe during the wait. Enrollment
|
|
64
|
+
only checks preferences at entry, not at each send.
|
|
65
|
+
|
|
66
|
+
## Idempotency — journeys can replay
|
|
67
|
+
|
|
68
|
+
A journey task is durable, and a step before a `ctx.sleep` can be re-executed if
|
|
69
|
+
the worker restarts mid-flow. `sendEmail` is NOT deduped, so guard repeatable
|
|
70
|
+
sends:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
const { sent } = await ctx.history.email({
|
|
74
|
+
email: user.email,
|
|
75
|
+
template: Templates.ACTIVATION_NUDGE,
|
|
76
|
+
});
|
|
77
|
+
if (!sent) {
|
|
78
|
+
await sendEmail({ /* ... template: Templates.ACTIVATION_NUDGE ... */ });
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Guidelines:
|
|
83
|
+
|
|
84
|
+
- Make each send conditional on `ctx.history.email(...)` when a step can run more
|
|
85
|
+
than once.
|
|
86
|
+
- Use `ctx.checkpoint("label")` before/after meaningful steps so a restart is
|
|
87
|
+
observable on the `journeyStates` row.
|
|
88
|
+
- Keep side effects (the actual `sendEmail`, `ctx.trigger`) AFTER the history
|
|
89
|
+
checks so the check reflects reality at that moment.
|
|
90
|
+
|
|
91
|
+
For the time-window / `within` duration helpers and richer condition shapes
|
|
92
|
+
(`property`, `event`, `email_engagement`, `composite`), see the
|
|
93
|
+
**hogsend-conditions** skill.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# JourneyContext (`ctx`) — the full primitive API
|
|
2
|
+
|
|
3
|
+
`ctx` is the SECOND argument to `run(user, ctx)`. It exposes **durable
|
|
4
|
+
orchestration primitives only** — the things that need Hatchet's durable
|
|
5
|
+
execution or the journey's bound state (sleep, checkpoints, triggering events,
|
|
6
|
+
reading history). It is deliberately small.
|
|
7
|
+
|
|
8
|
+
It is the `JourneyContext` type from `@hogsend/core`. Everything below is a real
|
|
9
|
+
method.
|
|
10
|
+
|
|
11
|
+
## Durable timing
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// Durable Hatchet sleep. Sets state → "waiting" for the duration, then "active".
|
|
15
|
+
// The worker can restart mid-sleep and resume — this is the core durability win.
|
|
16
|
+
const { sleptAt, resumedAt } = await ctx.sleep({
|
|
17
|
+
duration: days(2), // DurationObject from days()/hours()/minutes()
|
|
18
|
+
label: "post-welcome", // optional — also written as currentNodeId
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Durable sleep until an absolute instant (Date or ISO string).
|
|
22
|
+
await ctx.sleepUntil(someDate, { label: "wait-for-renewal" });
|
|
23
|
+
|
|
24
|
+
// Timezone-bound fluent scheduler — always resolves to an absolute Date you
|
|
25
|
+
// then pass to sleepUntil. Bound to the user's resolved timezone.
|
|
26
|
+
const at = ctx.when.tomorrow().at("09:00"); // 9am local, tomorrow
|
|
27
|
+
await ctx.sleepUntil(at, { label: "morning-nudge" });
|
|
28
|
+
// other ctx.when builders: .next("mon").at("HH:mm"), .nextLocal("HH:mm"),
|
|
29
|
+
// .in(days(3)).at("HH:mm"), and chainers .tz(zone) / .window(start,end) / .ifPast("next"|"now")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Observability
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// Update currentNodeId on the journeyStates row — a breadcrumb for dashboards.
|
|
36
|
+
await ctx.checkpoint("awaiting-activation");
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Firing events (cross-journey orchestration)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// Push an event through the FULL ingest pipeline (stores it, routes to matching
|
|
43
|
+
// journey tasks via Hatchet, processes exitOn). Lets one journey trigger another.
|
|
44
|
+
await ctx.trigger({
|
|
45
|
+
event: Events.JOURNEY_PRO_PATH,
|
|
46
|
+
userId: user.id, // defaults userEmail to the current user's email
|
|
47
|
+
userEmail: user.email, // optional override
|
|
48
|
+
properties: { step: "pro_branch" },
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## PostHog (no-op without POSTHOG_API_KEY)
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// Set person properties on PostHog for the current user.
|
|
56
|
+
ctx.identify({ plan: "pro", onboarded: true }); // synchronous, void
|
|
57
|
+
|
|
58
|
+
// Fire a custom PostHog event for the current user.
|
|
59
|
+
ctx.posthog.capture({ event: "journey_step_reached", properties: { step: 2 } });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Guards
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// Re-check subscription AFTER a long sleep, before sending again.
|
|
66
|
+
if (await ctx.guard.isSubscribed()) {
|
|
67
|
+
await sendEmail({ /* ... */ });
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## History reads (branch on what already happened)
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// Did this user fire an event (optionally within a window)?
|
|
75
|
+
const { found, count } = await ctx.history.hasEvent({
|
|
76
|
+
userId: user.id,
|
|
77
|
+
event: Events.FEATURE_USED,
|
|
78
|
+
within: days(7), // optional DurationObject
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Has this user completed another journey before? How many times entered?
|
|
82
|
+
const { completed, lastCompletedAt, entryCount } = await ctx.history.journey({
|
|
83
|
+
userId: user.id,
|
|
84
|
+
journeyId: "onboarding",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Has this email already received a given template?
|
|
88
|
+
const { sent, lastSentAt, count } = await ctx.history.email({
|
|
89
|
+
email: user.email,
|
|
90
|
+
template: Templates.ACTIVATION_WELCOME,
|
|
91
|
+
});
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## What is NOT on `ctx`
|
|
95
|
+
|
|
96
|
+
These are **standalone imports**, not methods — keeping `ctx` to pure
|
|
97
|
+
orchestration:
|
|
98
|
+
|
|
99
|
+
- **`sendEmail()`** — `import { sendEmail } from "@hogsend/engine"`. See
|
|
100
|
+
`references/sending-email-from-a-journey.md`.
|
|
101
|
+
- **`getPostHog()`** — `import { getPostHog } from "@hogsend/engine"` for the raw
|
|
102
|
+
PostHog service (`ctx.identify` / `ctx.posthog.capture` cover the common cases).
|
|
103
|
+
- **SMS / push / Slack** — plain functions you import, never on `ctx`.
|
|
104
|
+
- There is **no `ctx.db`, no `ctx.sendEmail`, no `ctx.hatchet`** surfaced to
|
|
105
|
+
consumer journeys. If you reach for one of those, you are modelling it wrong —
|
|
106
|
+
use a primitive above or a standalone import.
|
|
107
|
+
|
|
108
|
+
The `user` argument (first param) carries identity + attribution: `user.id`,
|
|
109
|
+
`user.email`, `user.properties`, `user.stateId` (pass to `sendEmail`),
|
|
110
|
+
`user.journeyId`, `user.journeyName`.
|