@hogsend/cli 0.1.0 → 0.2.1

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 (45) hide show
  1. package/dist/bin.js +575 -104
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -1
  4. package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
  5. package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
  6. package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
  7. package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
  8. package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
  9. package/skills/hogsend-authoring-emails/SKILL.md +68 -0
  10. package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
  11. package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
  12. package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
  13. package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
  14. package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
  15. package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +117 -0
  16. package/skills/hogsend-authoring-journeys/references/journey-context.md +133 -0
  17. package/skills/hogsend-authoring-journeys/references/journey-meta.md +145 -0
  18. package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
  19. package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
  20. package/skills/hogsend-cli/SKILL.md +1 -0
  21. package/skills/hogsend-conditions/SKILL.md +70 -0
  22. package/skills/hogsend-conditions/references/condition-types.md +251 -0
  23. package/skills/hogsend-conditions/references/durations.md +90 -0
  24. package/skills/hogsend-conditions/references/examples.md +188 -0
  25. package/skills/hogsend-database/SKILL.md +70 -0
  26. package/skills/hogsend-database/references/client-track-schema.md +97 -0
  27. package/skills/hogsend-database/references/migrations.md +132 -0
  28. package/skills/hogsend-database/references/schema-drift.md +123 -0
  29. package/skills/hogsend-deploy/SKILL.md +62 -0
  30. package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
  31. package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
  32. package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
  33. package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
  34. package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
  35. package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
  36. package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
  37. package/src/commands/doctor.ts +22 -0
  38. package/src/commands/index.ts +4 -0
  39. package/src/commands/skills.ts +36 -96
  40. package/src/commands/studio.ts +261 -0
  41. package/src/commands/upgrade.ts +245 -0
  42. package/src/lib/skills.ts +186 -0
  43. package/studio/assets/index-BVA9GZqq.css +1 -0
  44. package/studio/assets/index-kPwzOOyG.js +230 -0
  45. package/studio/index.html +13 -0
@@ -0,0 +1,112 @@
1
+ # Building the component — shared `_components`, props, preview/category, plaintext
2
+
3
+ You write the `.tsx` with [react-email](https://react.email) primitives, but
4
+ compose the shared chrome in `src/emails/_components/` instead of hand-rolling
5
+ HTML. That keeps every email visually consistent and leaves the right slots open
6
+ for the engine to inject tracking + unsubscribe.
7
+
8
+ ## The shared `_components`
9
+
10
+ | Module | Exports | Role |
11
+ |--------|---------|------|
12
+ | `_components/layout.js` | `Layout` | The shell: `<Html>` + `<Head>` + `<Preview>` + `<Tailwind>`, the wordmark, one bordered white card, then the footer. |
13
+ | `_components/ui.js` | `Eyebrow`, `Title`, `Body`, `Button`, `Callout`, `CodeBlock`, `Bullets`, `Divider` | Email-safe design-system primitives. |
14
+ | `_components/logo.js` | `Logo` | Your wordmark above the card. |
15
+ | `_components/footer.js` | `Footer` | Renders the `unsubscribeUrl` / `preferencesUrl` links (rendered by `Layout`, not directly by you). |
16
+
17
+ `Layout`'s props are the only surface most templates touch:
18
+
19
+ ```ts
20
+ interface LayoutProps {
21
+ preview: string; // inbox snippet — required
22
+ eyebrow?: string; // small uppercase label above the heading
23
+ unsubscribeUrl?: string; // forwarded to <Footer>
24
+ preferencesUrl?: string; // forwarded to <Footer>
25
+ children: ReactNode;
26
+ }
27
+ ```
28
+
29
+ A minimal template composes them like this:
30
+
31
+ ```tsx
32
+ // biome-ignore lint/correctness/noUnusedImports: required for JSX runtime
33
+ import React from "react";
34
+ import { Layout } from "./_components/layout.js";
35
+ import { Body, Bullets, Button, Callout, Divider, Title } from "./_components/ui.js";
36
+ import type { TrialEndingEmailProps } from "./types.js";
37
+
38
+ export default function TrialEndingEmail({
39
+ name = "there",
40
+ daysLeft = 3,
41
+ upgradeUrl = "https://app.example.com/billing",
42
+ unsubscribeUrl,
43
+ }: TrialEndingEmailProps) {
44
+ return (
45
+ <Layout
46
+ preview={`${daysLeft} days left on your trial`}
47
+ eyebrow="Heads up"
48
+ unsubscribeUrl={unsubscribeUrl}
49
+ >
50
+ <Title>Your trial ends in {daysLeft} days</Title>
51
+ <Body>Hey {name}, here's what you keep when you upgrade:</Body>
52
+ <Bullets items={["Unlimited journeys", "Full event history", "Priority support"]} />
53
+ <Divider />
54
+ <Button href={upgradeUrl}>Upgrade now</Button>
55
+ <Callout tone="warn">Card on file is never charged automatically.</Callout>
56
+ </Layout>
57
+ );
58
+ }
59
+ ```
60
+
61
+ These files live in YOUR repo and are yours to edit — change the colors, add
62
+ primitives, swap `Logo` for a hosted `<Img>`. Use ESM `.js` extensions on the
63
+ relative imports (consumer convention), even though the files are `.tsx`.
64
+
65
+ ## Props typing
66
+
67
+ Define the props interface in `src/emails/types.ts` and import it into the
68
+ component. Two conventions:
69
+
70
+ - **Default every prop in the destructure** (`name = "there"`) so previews and
71
+ admin catalogs render without real data, and a missing prop never produces
72
+ `undefined` in the body.
73
+ - **Always accept an optional `unsubscribeUrl?: string`.** The engine injects it
74
+ on send so `Layout`/`Footer` can render the unsubscribe link — see
75
+ `tracking-and-unsubscribe.md`. Make it optional; direct render/preview calls
76
+ won't pass it.
77
+
78
+ ## Subject, preview, category — on the registry entry, not the component
79
+
80
+ These live on the `TemplateDefinition` in `src/emails/registry.ts`, NOT inside
81
+ the `.tsx`:
82
+
83
+ ```ts
84
+ export interface TemplateDefinition<P = Record<string, unknown>> {
85
+ component: (props: P) => ReactElement;
86
+ defaultSubject: string; // fallback subject when send() omits `subject`
87
+ category?: string; // e.g. "transactional" | "journey"
88
+ preview?: (props: P) => string; // inbox snippet, computed from props
89
+ examples?: Partial<P>; // sample props for admin previews only
90
+ }
91
+ ```
92
+
93
+ - **`defaultSubject`** is used when the send call doesn't pass an explicit
94
+ `subject`. Journeys usually pass their own subject; transactional one-offs lean
95
+ on the default.
96
+ - **`category`** drives frequency capping. The engine's frequency cap exempts
97
+ `"transactional"` by default — mark genuine transactional mail (receipts,
98
+ password resets) `"transactional"` and marketing/lifecycle mail `"journey"` so
99
+ caps apply correctly.
100
+ - **`preview`** sets the snippet most inboxes show next to the subject. Note the
101
+ `Layout`'s own `preview` prop renders react-email's hidden `<Preview>` text in
102
+ the HTML; the registry `preview` is the value surfaced to admin/preview tooling
103
+ via `getPreviewText`. Keep them consistent.
104
+
105
+ ## Plaintext
106
+
107
+ You do not author a separate plaintext file. The engine renders both halves from
108
+ the same component: `renderToHtml(element)` and `renderToPlainText(element)`
109
+ (react-email's `render(element, { plainText: true })`). Write the component once;
110
+ keep links as real `<a href>` / react-email `Button`/`Link` so the plaintext
111
+ extractor produces readable URLs. See `preview-and-render.md` for the render
112
+ machinery.
@@ -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
+ `waitForEvent`, `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.