@hogsend/cli 0.1.0 → 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 +575 -104
- package/dist/bin.js.map +1 -1
- package/package.json +4 -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 +1 -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/commands/doctor.ts +22 -0
- package/src/commands/index.ts +4 -0
- package/src/commands/skills.ts +36 -96
- package/src/commands/studio.ts +261 -0
- package/src/commands/upgrade.ts +245 -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,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
|
+
`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.
|