@hogsend/cli 0.10.0 → 0.12.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.
Files changed (41) hide show
  1. package/dist/bin.js +13517 -1754
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +6 -3
  4. package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +5 -4
  5. package/skills/hogsend-cli/SKILL.md +32 -1
  6. package/skills/hogsend-extending/SKILL.md +4 -1
  7. package/skills/hogsend-extending/references/swap-a-provider.md +273 -51
  8. package/skills/hogsend-integrate/SKILL.md +198 -0
  9. package/skills/hogsend-integrate/references/auth-billing-seams.md +199 -0
  10. package/skills/hogsend-integrate/references/framework-recipes.md +208 -0
  11. package/skills/hogsend-integrate/references/verification.md +86 -0
  12. package/skills/hogsend-migrate/SKILL.md +147 -0
  13. package/skills/hogsend-migrate/references/customerio-mapping.md +93 -0
  14. package/skills/hogsend-migrate/references/cutover-checklist.md +136 -0
  15. package/skills/hogsend-migrate/references/loops-mapping.md +132 -0
  16. package/skills/hogsend-migrate/references/resend-broadcasts-mapping.md +120 -0
  17. package/src/__tests__/admin-recovery.test.ts +193 -0
  18. package/src/__tests__/dev.test.ts +323 -0
  19. package/src/__tests__/dns-apply.test.ts +297 -0
  20. package/src/__tests__/dns.test.ts +143 -0
  21. package/src/__tests__/domain-command.test.ts +216 -0
  22. package/src/__tests__/proc.test.ts +177 -0
  23. package/src/__tests__/setup-steps.test.ts +363 -0
  24. package/src/bin.ts +13 -3
  25. package/src/commands/dev.ts +444 -0
  26. package/src/commands/domain.ts +437 -0
  27. package/src/commands/events.ts +4 -1
  28. package/src/commands/index.ts +4 -0
  29. package/src/commands/setup.ts +34 -163
  30. package/src/commands/studio-admin.ts +340 -0
  31. package/src/commands/studio.ts +17 -1
  32. package/src/lib/admin-recovery.ts +193 -0
  33. package/src/lib/dns-apply.ts +218 -0
  34. package/src/lib/dns.ts +217 -0
  35. package/src/lib/proc.ts +189 -0
  36. package/src/lib/setup-steps.ts +333 -0
  37. package/studio/assets/index-CSXAjTbe.js +265 -0
  38. package/studio/assets/index-DCsT0fnT.css +1 -0
  39. package/studio/index.html +2 -2
  40. package/studio/assets/index-BNDE5JtQ.css +0 -1
  41. package/studio/assets/index-CgJBk-Ft.js +0 -250
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: hogsend-migrate
3
+ description: Use when migrating a product OFF Loops, Customer.io, or Resend Broadcasts/Audiences onto Hogsend — auditing the existing email-SaaS integration (SDK calls in code + GUI-side workflows/campaigns/segments/templates), mapping each source concept to its Hogsend equivalent (contacts.upsert, events.send, defineJourney journeys, four-file react-email templates, defineList lists, buckets, campaigns, webhook sources), then executing an incremental dual-write → verify → switch → remove cutover that preserves unsubscribes/suppression. NOT for a greenfield integration with no incumbent (that is hogsend-integrate) and NOT for authoring mechanics themselves (delegate to the hogsend-authoring-* skills).
4
+ license: MIT
5
+ metadata:
6
+ author: withSeismic
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Migrate to Hogsend (from Loops / Customer.io / Resend Broadcasts)
11
+
12
+ A migration has two halves: the **code half** (SDK calls you can grep) and the
13
+ **GUI half** (workflows, campaigns, segments, templates that live only in the
14
+ source platform's dashboard). This skill audits both, maps every source concept
15
+ to its Hogsend equivalent, and runs an incremental cutover — never a big-bang
16
+ switch.
17
+
18
+ Two codebases are in play. The **host product** keeps (rewired) SDK calls — work
19
+ there follows the **hogsend-integrate** patterns. The **Hogsend app** (a
20
+ scaffolded `create-hogsend` project) is where journeys, templates, and lists are
21
+ authored — mechanics delegate to the **hogsend-authoring-\*** skills.
22
+
23
+ ## Non-negotiable safety rules (read before anything else)
24
+
25
+ 1. **Import unsubscribes BEFORE any Hogsend send.** A migration that emails one
26
+ unsubscribed person is a failed migration (and a legal problem). Export the
27
+ source platform's suppression/unsubscribe list first and apply it (cutover
28
+ reference shows how). Never bulk-unsubscribe everyone "to be safe" either —
29
+ polarity matters.
30
+ 2. **Dual-write before switching.** Old and new pipelines run side by side
31
+ until verified. Hogsend writes are idempotent-friendly (`idempotencyKey`),
32
+ so dual-writing is cheap.
33
+ 3. **One sender of each email at a time.** When a Hogsend journey goes live,
34
+ pause the corresponding source-platform workflow in the same change window —
35
+ duplicated sends erode trust faster than missed ones.
36
+
37
+ ## Step 1 — audit what exists
38
+
39
+ **Code half** — grep the host product (full per-platform greps + before/after
40
+ snippets in the matching reference):
41
+
42
+ | Platform | Grep for |
43
+ |---|---|
44
+ | Loops | `loops` dep (`loops` / `@loops/*`), `sendEvent`, `updateContact`, `sendTransactionalEmail`, `transactionalId` |
45
+ | Customer.io | `customerio-node` dep, `cio.identify`, `cio.track`, `trackAnonymous`, `triggerBroadcast`, in-app `_cio` snippet |
46
+ | Resend Broadcasts | `resend.broadcasts.`, `resend.audiences.`, `resend.contacts.` — DISTINCT from plain `resend.emails.send` (transactional, may stay) |
47
+
48
+ **GUI half** — not in the codebase; ask the user to export or screenshot from
49
+ the source dashboard:
50
+
51
+ - workflows/campaigns (triggers, delays, branches, exit rules)
52
+ - segments/audiences (definitions, member counts)
53
+ - templates (HTML/content + which are transactional vs marketing)
54
+ - the suppression/unsubscribe list (CRITICAL — rule 1)
55
+ - API keys' event names actually flowing (last 30 days), to scope the event
56
+ vocabulary
57
+
58
+ Output of this step: an inventory table (source asset → type → destination in
59
+ Hogsend → owner) the user signs off on.
60
+
61
+ ## Step 2 — map concepts (per-platform tables)
62
+
63
+ The shared shape — almost everything lands in one of seven Hogsend concepts:
64
+
65
+ | Source concept (any platform) | Hogsend equivalent |
66
+ |---|---|
67
+ | Contact / person / audience member | contact — `hs.contacts.upsert` (`@hogsend/client`) |
68
+ | Event / track call | `hs.events.send` (`eventProperties` vs `contactProperties` split) |
69
+ | Workflow / loop / campaign-with-delays | journey — `defineJourney()` in the Hogsend app's `src/journeys/` |
70
+ | Transactional email | `hs.emails.send` + a four-file react-email template in `src/emails/` |
71
+ | Mailing list / audience / topic | list — `defineList()` (polarity via `defaultOptIn`) |
72
+ | Behavioral segment | bucket — `defineBucket()` (criteria-driven, real-time) |
73
+ | One-off broadcast/newsletter | campaign — `hs.campaigns.send` / `hogsend campaigns send` (targets a list or bucket) |
74
+
75
+ Platform-specific tables, audit greps, and rewrite examples:
76
+
77
+ - **Loops** → `references/loops-mapping.md`
78
+ - **Customer.io** → `references/customerio-mapping.md`
79
+ - **Resend Broadcasts/Audiences** → `references/resend-broadcasts-mapping.md`
80
+ (special case: Resend can REMAIN the delivery wire — Hogsend's default
81
+ `EmailProvider` is Resend, so this migration replaces the orchestration
82
+ layer, not necessarily the sending infrastructure)
83
+
84
+ ## Step 3 — generate the Hogsend setup
85
+
86
+ Work in the Hogsend app, one source asset at a time. This skill decides WHAT to
87
+ build; the authoring skills own HOW:
88
+
89
+ - **Each workflow/campaign-with-logic → a `defineJourney()`** in
90
+ `src/journeys/`. Translate: entry trigger → `trigger: { event, where? }`;
91
+ re-entry rules → `entryLimit: "once" | "once_per_period" | "unlimited"`;
92
+ goal/exit rules → `exitOn`; delays → `await ctx.sleep({ duration: days(n) })`;
93
+ branches → plain TypeScript `if` on `ctx.history.hasEvent(...)`. Mechanics +
94
+ registration ritual → **hogsend-authoring-journeys**; condition syntax →
95
+ **hogsend-conditions**.
96
+ - **Each template → the four-file contract** (`src/emails/<name>.tsx` +
97
+ `types.ts` props + `registry.ts` entry + `templates.d.ts` augmentation).
98
+ Port content into react-email components; subjects/preview/category live on
99
+ the registry entry. → **hogsend-authoring-emails**.
100
+ - **Each list/audience/topic → `defineList({ id, name, defaultOptIn })`** in
101
+ `src/lists/`. Get `defaultOptIn` right: a newsletter people subscribed to is
102
+ opt-in (`defaultOptIn: false`); product/account notices are typically opt-out
103
+ (`defaultOptIn: true`). → **hogsend-authoring-lists**.
104
+ - **Each behavioral segment → a bucket** (criteria tree over events/properties)
105
+ when it gates journeys or campaigns. → **hogsend-authoring-buckets**.
106
+ - **Inbound webhooks worth keeping** (the source platform was receiving
107
+ provider webhooks that should now hit Hogsend) → `defineWebhookSource()` or
108
+ the built-in Clerk/Supabase/Stripe/Segment presets. →
109
+ **hogsend-webhooks-and-workflows**.
110
+ - **Outbound syncs** (source platform pushed data to other tools) → webhook
111
+ endpoints / keyed destinations (`hs.webhooks.create` with `kind`). →
112
+ **hogsend-authoring-destinations**.
113
+
114
+ Then rewire the host product's SDK calls to `@hogsend/client` equivalents
115
+ (per-platform before/after in the references; client patterns in
116
+ **hogsend-client-sdk** / **hogsend-integrate**).
117
+
118
+ ## Step 4 — incremental cutover (dual-write → verify → switch → remove)
119
+
120
+ The full checklist with commands is `references/cutover-checklist.md`. The
121
+ stages:
122
+
123
+ 1. **Dual-write.** Import contacts + suppression state (suppression FIRST).
124
+ Add Hogsend calls BESIDE the existing SDK calls — same handlers, both fire.
125
+ Journeys are authored but disabled (`enabled: false` or left out of
126
+ `ENABLED_JOURNEYS`). Nothing user-visible changes.
127
+ 2. **Verify.** Compare event volume between platforms over a few days
128
+ (`hogsend stats --json`, `hogsend events <userId> --json` spot checks).
129
+ Enable each journey for a seed/test contact, walk the full flow, diff
130
+ rendered templates against the originals (`hogsend emails send <template>
131
+ --to you@…`).
132
+ 3. **Switch.** Per flow: enable the Hogsend journey
133
+ (`hogsend journeys enable <id>` or `ENABLED_JOURNEYS`) and pause the source
134
+ workflow in the same window. Flip transactional sends to `hs.emails.send`.
135
+ Run broadcasts as Hogsend campaigns.
136
+ 4. **Remove.** After a clean soak (1-2 send cycles), delete the old SDK calls,
137
+ uninstall the dep, revoke the old API keys, export a final archive from the
138
+ source platform, then close the account.
139
+
140
+ ## Task playbooks — load the matching reference
141
+
142
+ - **Migrating from Loops** → `references/loops-mapping.md`
143
+ - **Migrating from Customer.io** → `references/customerio-mapping.md`
144
+ - **Migrating from Resend Broadcasts/Audiences** →
145
+ `references/resend-broadcasts-mapping.md`
146
+ - **Executing the cutover (imports, verification commands, switch order,
147
+ rollback)** → `references/cutover-checklist.md`
@@ -0,0 +1,93 @@
1
+ # Customer.io → Hogsend mapping
2
+
3
+ Audit greps, the concept table, and before/after rewrites for a codebase using
4
+ Customer.io. Customer.io's API surface varies by SDK (`customerio-node` Track
5
+ vs App API clients) and product tier — treat the left-hand column as "look
6
+ for", and verify against the project's actual calls.
7
+
8
+ ## Audit greps (code half)
9
+
10
+ ```bash
11
+ grep -rn "customerio" package.json # commonly `customerio-node`
12
+ grep -rn "TrackClient\|APIClient\|cio\." src/
13
+ grep -rn "identify\|\.track(\|trackAnonymous\|triggerBroadcast" src/
14
+ grep -rn "_cio" src/ public/ # in-app/browser snippet
15
+ grep -rn "CUSTOMERIO\|CIO_" . --include="*.env*"
16
+ ```
17
+
18
+ GUI half (export/screenshot from the Customer.io dashboard): campaigns (entry
19
+ trigger, filters, delays, branches, goal/exit settings), segments (definitions
20
+ + whether data-driven or manual), broadcasts/newsletters, transactional
21
+ messages, subscription preferences/topics, and the suppression list.
22
+
23
+ ## Concept mapping
24
+
25
+ | Customer.io concept | Hogsend equivalent | Notes |
26
+ |---|---|---|
27
+ | Person + attributes (`identify`) | contact — `hs.contacts.upsert({ userId, email?, properties })` | CIO is id-keyed; Hogsend takes `userId` and/or `email`. Attributes → `properties` |
28
+ | `track(id, event, data)` | `hs.events.send({ userId, name, eventProperties, contactProperties? })` | CIO has ONE data bag; Hogsend has two. Sort each field: per-occurrence → `eventProperties`, durable → `contactProperties` |
29
+ | Anonymous events (`trackAnonymous`) | no direct equivalent | Hogsend writes need an identity (`email`/`userId`). Defer anonymous tracking to your product-analytics tool; ingest on identification |
30
+ | Campaign (triggered workflow) | journey — `defineJourney()` | Trigger → `trigger.event` (+ `where` for filters); frequency settings → `entryLimit`; goal/exit → `exitOn`; delays → `ctx.sleep`; time-windows → `ctx.when`; "wait until event" → `ctx.waitForEvent` |
31
+ | Segment (data-driven) | bucket — `defineBucket()` criteria tree | Real-time membership; journeys can trigger on `bucket.entered`/`bucket.left` transitions → hogsend-authoring-buckets |
32
+ | Segment (manual) | list — `defineList()` + explicit membership writes | Manual segments are membership-by-assignment, which is what lists are |
33
+ | Broadcast / newsletter | campaign — `hs.campaigns.send({ list \| bucket, template })` | API-triggered broadcasts (`triggerBroadcast`) also map here |
34
+ | Transactional message | `hs.emails.send({ to \| userId, template, props })` | Each transactional message id becomes a template key in `src/emails/` |
35
+ | Subscription preferences / topics | lists (`defineList`) + the built-in preference center | Topic opt-ins → `defaultOptIn: false`; suppression import → `cutover-checklist.md` |
36
+ | Reporting webhooks (CIO → your systems) | webhook endpoints / keyed destinations — `hs.webhooks.create({ url, eventTypes, kind?, config? })` | Hogsend emits `email.*`, `contact.*`, `journey.completed`, `bucket.*` on a durable signed spine |
37
+
38
+ ## Before / after
39
+
40
+ Identify + track (host product code):
41
+
42
+ ```ts
43
+ // BEFORE (customerio-node Track API — shape varies by version)
44
+ cio.identify("u_1", { email: "ada@example.com", plan: "pro" });
45
+ cio.track("u_1", { name: "subscription_started", data: { plan: "pro", amount: 49 } });
46
+
47
+ // AFTER (@hogsend/client)
48
+ import { hogsend } from "../lib/hogsend.js";
49
+
50
+ await hogsend.contacts.upsert({
51
+ userId: "u_1",
52
+ email: "ada@example.com",
53
+ properties: { plan: "pro" },
54
+ });
55
+ await hogsend.events.send({
56
+ userId: "u_1",
57
+ name: "subscription_started",
58
+ eventProperties: { plan: "pro", amount: 49 }, // facts about THIS event
59
+ contactProperties: { plan: "pro" }, // durable fact on the person
60
+ });
61
+ ```
62
+
63
+ Campaign → journey translation guide (authored in the Hogsend app —
64
+ mechanics → hogsend-authoring-journeys, conditions → hogsend-conditions):
65
+
66
+ | Campaign builder block | Journey code |
67
+ |---|---|
68
+ | "Person enters when event X" | `trigger: { event: "X" }` |
69
+ | "…and attribute filter" | `trigger: { where: { type: "property", … } }` |
70
+ | "Can enter once / every N days" | `entryLimit: "once"` / `"once_per_period"` |
71
+ | "Exit when goal event Y" | `exitOn: [{ event: "Y" }]` |
72
+ | "Wait 3 days" | `await ctx.sleep({ duration: days(3) })` |
73
+ | "Wait until Tuesday 9am (their tz)" | `ctx.when` scheduler + `ctx.sleepUntil` |
74
+ | "Wait up to 7 days for event Z" | `await ctx.waitForEvent({ event: "Z", timeout: days(7) })` |
75
+ | "True/false branch on behavior" | `if` on `ctx.history.hasEvent(...)` / `ctx.history.email(...)` |
76
+ | "Send email" | `await sendEmail({ …, journeyStateId: user.stateId })` |
77
+
78
+ ## Customer.io-specific gotchas
79
+
80
+ - **One data bag → two.** The single `data` object on `cio.track` must be
81
+ consciously split into `eventProperties` vs `contactProperties`. Default
82
+ per-field to `eventProperties`; promote to `contactProperties` only what a
83
+ later check on the person should read.
84
+ - **Anonymous → identified flows have no Hogsend equivalent** — plan to start
85
+ lifecycle tracking at identification (signup), which is where journeys
86
+ almost always start anyway.
87
+ - **In-app `_cio` snippet** (browser tracking) is product analytics, not
88
+ lifecycle orchestration. Don't port it to Hogsend; if the team uses PostHog,
89
+ that's its home, and Hogsend can consume those signals later.
90
+ - **Liquid templates** don't transplant. Re-author as react-email with typed
91
+ props; conditional content blocks become plain JSX conditionals.
92
+ - **Campaign analytics history stays behind.** Export CSVs for the record;
93
+ Hogsend metrics start at cutover.
@@ -0,0 +1,136 @@
1
+ # Cutover checklist — dual-write → verify → switch → remove
2
+
3
+ The execution playbook. Work flow-by-flow, never big-bang. Keys: data-plane
4
+ writes use the ingest key (`HOGSEND_API_KEY`); admin reads/writes use an admin
5
+ key (`HOGSEND_ADMIN_KEY` / `ADMIN_API_KEY`). `--json` on every CLI call you
6
+ parse.
7
+
8
+ ## Stage 0 — imports (BEFORE anything sends)
9
+
10
+ ### 0a. Contacts
11
+
12
+ Bulk path — the admin import endpoint (admin key):
13
+
14
+ ```
15
+ POST /v1/admin/contacts/import
16
+ { "format": "csv" | "json", "data": "<file contents>", "fileName?": "..." }
17
+ → 202 { jobId, status }
18
+ GET /v1/admin/contacts/import/{jobId} → processed/failed counts + errors
19
+ ```
20
+
21
+ Rows carry `externalId?`, `email?`, `properties?` (CSV: those columns; at least
22
+ one identity per row). **The bulk import does NOT carry list membership or
23
+ unsubscribe state** — handle those in 0b/0c.
24
+
25
+ Scripted alternative (and the way to set list membership in the same pass):
26
+ loop the export through the SDK —
27
+
28
+ ```ts
29
+ for (const row of exported) {
30
+ await hogsend.contacts.upsert({
31
+ email: row.email,
32
+ userId: row.externalId,
33
+ properties: row.properties,
34
+ lists: { newsletter: row.subscribedToNewsletter === true },
35
+ });
36
+ }
37
+ ```
38
+
39
+ `contacts.upsert` is a true upsert — re-running the loop is safe.
40
+
41
+ ### 0b. List membership
42
+
43
+ Either inline via `lists` on the upsert (above), or per-list:
44
+
45
+ ```ts
46
+ await hogsend.lists.subscribe({ list: "newsletter", email: row.email });
47
+ ```
48
+
49
+ Mind polarity: for an opt-in list (`defaultOptIn: false`) you only need to
50
+ subscribe the members; for an opt-out list you only need to record the
51
+ unsubscribes.
52
+
53
+ ### 0c. Suppression / unsubscribes (THE critical import)
54
+
55
+ - **List-level unsubscribes** →
56
+ `hogsend.lists.unsubscribe({ list, email })` per contact+list.
57
+ - **Global unsubscribes** ("never email this person") → the admin preferences
58
+ route, per contact (UUID or externalId both work as `{contactId}`):
59
+
60
+ ```
61
+ PUT /v1/admin/contacts/{contactId}/preferences
62
+ { "unsubscribedAll": true }
63
+ ```
64
+
65
+ (`suppressed: true` is also accepted there — use it for hard-bounced/complained
66
+ addresses from the source platform's suppression export.)
67
+
68
+ **Gate: do not proceed to any send until a spot check passes** — pick 3 known
69
+ unsubscribed addresses, confirm via
70
+ `hogsend contacts get <id> --json` / the preferences route that they show
71
+ `unsubscribedAll: true`, and confirm a test `hogsend emails send` to one of
72
+ them is refused by the preference check.
73
+
74
+ ## Stage 1 — dual-write
75
+
76
+ - Add `@hogsend/client` calls BESIDE the existing SDK calls (same handlers,
77
+ both fire). Use `idempotencyKey` on webhook-driven events.
78
+ - Author journeys/templates/lists in the Hogsend app, but keep journeys OFF:
79
+ `enabled: false` in meta, or excluded from the `ENABLED_JOURNEYS` env
80
+ (comma-separated ids; `*` = all).
81
+ - Keep the source platform fully live. Nothing user-visible changes.
82
+
83
+ ## Stage 2 — verify (run a few days)
84
+
85
+ ```bash
86
+ hogsend doctor --json # instance healthy
87
+ hogsend stats --json # totalContacts matches the import
88
+ hogsend events <userId> --json # spot-check: events arriving for real users
89
+ hogsend contacts timeline <id> --json # merged activity view for a known user
90
+ ```
91
+
92
+ - **Volume:** compare daily event counts per event name against the source
93
+ platform's numbers. Investigate gaps > a few percent.
94
+ - **Journeys:** enroll a seed contact (your own address):
95
+ `hogsend events send signup --email you@yourco.com --json`, temporarily
96
+ enable the journey (`hogsend journeys enable <id>`), walk the full flow —
97
+ delays, branches, sends — then disable again if the soak isn't done.
98
+ - **Templates:** send each to yourself
99
+ (`hogsend emails send <template> --to you@yourco.com --prop key=value`) and
100
+ diff against the source platform's rendering. Check the unsubscribe link
101
+ resolves.
102
+
103
+ ## Stage 3 — switch (per flow, old OFF in the same window)
104
+
105
+ For each lifecycle flow, in one change window:
106
+
107
+ 1. Enable the Hogsend journey — `hogsend journeys enable <id> --json` (or ship
108
+ `enabled: true` / add to `ENABLED_JOURNEYS` and deploy).
109
+ 2. Pause the corresponding workflow/campaign in the source platform's
110
+ dashboard.
111
+ 3. Watch the first real enrollments: `hogsend journeys get <id> --json`
112
+ (state counts + recent states).
113
+
114
+ For transactional: flip each call site from the old SDK to `hs.emails.send`
115
+ (delete the old call, don't dual-send transactional — a user must never get
116
+ two password resets). For broadcasts: run the next one as a Hogsend campaign
117
+ (`hogsend campaigns send --list … --template …`; progress via
118
+ `hogsend campaigns status <id> --json`).
119
+
120
+ ## Stage 4 — remove
121
+
122
+ After 1-2 clean send cycles:
123
+
124
+ - Delete the old SDK calls and the dep (`pnpm remove <pkg>`); remove its env
125
+ vars from every environment.
126
+ - Revoke the source platform's API keys.
127
+ - Export final archives (campaign history, analytics) — they don't migrate.
128
+ - Re-export the source's suppression list ONE more time and re-run 0c — people
129
+ unsubscribed during the dual-write window must not be lost.
130
+ - Close/downgrade the account.
131
+
132
+ ## Rollback
133
+
134
+ Until Stage 4, rollback is cheap and symmetrical: disable the Hogsend journey
135
+ (`hogsend journeys disable <id>`), resume the source workflow. That symmetry is
136
+ the reason for flow-by-flow switching — keep it until you delete the old code.
@@ -0,0 +1,132 @@
1
+ # Loops → Hogsend mapping
2
+
3
+ Audit greps, the concept table, and before/after rewrites for a codebase using
4
+ Loops (loops.so). Loops' own API may differ by SDK version — treat the
5
+ left-hand column as "look for", and verify against the project's actual calls.
6
+
7
+ ## Audit greps (code half)
8
+
9
+ ```bash
10
+ grep -rn "loops" package.json # the SDK dep (commonly `loops`)
11
+ grep -rn "sendEvent\|updateContact\|sendTransactionalEmail\|createContact" src/
12
+ grep -rn "transactionalId" src/ # transactional template ids
13
+ grep -rn "LOOPS_API_KEY\|loops.so" . --include="*.env*" --include="*.ts"
14
+ ```
15
+
16
+ GUI half (ask the user to export/screenshot from the Loops dashboard): Loops
17
+ (the workflows), audiences/segments, mailing lists + their opt-in state,
18
+ email templates, and the unsubscribed-contacts export.
19
+
20
+ ## Concept mapping
21
+
22
+ | Loops concept | Hogsend equivalent | Notes |
23
+ |---|---|---|
24
+ | Contact (+ contact properties) | contact — `hs.contacts.upsert({ email, userId?, properties })` | Loops keys contacts on email; carry the app's user id as `userId` too — Hogsend identity is `email` and/or `userId` |
25
+ | `sendEvent` (event + event/contact properties) | `hs.events.send({ …, eventProperties, contactProperties })` | Keep the bag split deliberate: per-occurrence facts → `eventProperties` (drive journey `trigger.where`/`exitOn`); durable facts → `contactProperties` |
26
+ | Loop (the visual workflow) | journey — `defineJourney()` in `src/journeys/` | Entry trigger → `trigger.event`; "enter once" → `entryLimit: "once"`; goal/exit → `exitOn`; timers → `ctx.sleep({ duration: days(n) })`; audience filters → `trigger.where` conditions |
27
+ | Transactional email (`sendTransactionalEmail` + `transactionalId`) | `hs.emails.send({ to | userId, template, props })` + a four-file template | Each `transactionalId` becomes a template key in the Hogsend app's `src/emails/` registry; `dataVariables` become typed `props` |
28
+ | Mailing list (opt-in/opt-out toggle) | list — `defineList({ id, name, defaultOptIn })` | Loops' "public" opt-in lists → `defaultOptIn: false`; default-subscribed lists → `defaultOptIn: true` |
29
+ | Audience / segment filter | bucket (`defineBucket`) when behavioral; `trigger.where` when it's just an entry filter | Don't over-build: a one-condition audience is usually just a `where` clause |
30
+ | Campaign (one-off blast) | campaign — `hs.campaigns.send({ list | bucket, template, props })` | Or `hogsend campaigns send --list … --template …` |
31
+ | Unsubscribed contacts | suppression import — see `cutover-checklist.md` | Import BEFORE any send |
32
+
33
+ ## Before / after
34
+
35
+ Contact + event (host product code):
36
+
37
+ ```ts
38
+ // BEFORE (Loops — shape varies by SDK version)
39
+ await loops.updateContact("ada@example.com", { plan: "pro" });
40
+ await loops.sendEvent({
41
+ email: "ada@example.com",
42
+ eventName: "subscription_started",
43
+ eventProperties: { plan: "pro" },
44
+ });
45
+
46
+ // AFTER (@hogsend/client)
47
+ import { hogsend } from "../lib/hogsend.js";
48
+
49
+ await hogsend.contacts.upsert({
50
+ email: "ada@example.com",
51
+ userId: user.id,
52
+ properties: { plan: "pro" },
53
+ });
54
+ await hogsend.events.send({
55
+ email: "ada@example.com",
56
+ userId: user.id,
57
+ name: "subscription_started",
58
+ eventProperties: { plan: "pro" },
59
+ });
60
+ ```
61
+
62
+ Transactional:
63
+
64
+ ```ts
65
+ // BEFORE (Loops)
66
+ await loops.sendTransactionalEmail({
67
+ transactionalId: "clxyz...",
68
+ email: "ada@example.com",
69
+ dataVariables: { resetUrl },
70
+ });
71
+
72
+ // AFTER — template authored in the Hogsend app first
73
+ // (four-file contract → hogsend-authoring-emails skill)
74
+ await hogsend.emails.send({
75
+ to: "ada@example.com",
76
+ template: "password-reset",
77
+ props: { resetUrl },
78
+ });
79
+ ```
80
+
81
+ A Loop becomes a journey (authored in the HOGSEND app, not the host —
82
+ mechanics → hogsend-authoring-journeys):
83
+
84
+ ```ts
85
+ // "When signup occurs → wait 2 days → if no project created, send nudge"
86
+ import { days } from "@hogsend/core";
87
+ import { defineJourney, sendEmail } from "@hogsend/engine";
88
+ import { Events, Templates } from "./constants/index.js";
89
+
90
+ export const onboardingNudge = defineJourney({
91
+ meta: {
92
+ id: "onboarding-nudge",
93
+ name: "Onboarding nudge",
94
+ enabled: false, // stays off until the cutover switch stage
95
+ trigger: { event: Events.SIGNUP },
96
+ entryLimit: "once",
97
+ suppress: days(7),
98
+ exitOn: [{ event: Events.PROJECT_CREATED }],
99
+ },
100
+ run: async (user, ctx) => {
101
+ await ctx.sleep({ duration: days(2), label: "post-signup" });
102
+ const { found } = await ctx.history.hasEvent({
103
+ userId: user.id,
104
+ event: Events.PROJECT_CREATED,
105
+ });
106
+ if (!found) {
107
+ await sendEmail({
108
+ to: user.email,
109
+ userId: user.id,
110
+ journeyStateId: user.stateId,
111
+ template: Templates.ONBOARDING_NUDGE,
112
+ journeyName: user.journeyName,
113
+ });
114
+ }
115
+ },
116
+ });
117
+ ```
118
+
119
+ ## Loops-specific gotchas
120
+
121
+ - **Email-keyed identity.** Loops contacts are keyed on email. If the app has
122
+ stable user ids, send BOTH on every Hogsend write so identities link
123
+ (`linked: true` in the upsert result confirms a merge).
124
+ - **Event property names become your journey vocabulary.** Journeys'
125
+ `trigger.where` matches `eventProperties` keys — keep names stable across the
126
+ rewrite, or update both sides together.
127
+ - **Template content is GUI-side.** Export each template's content from the
128
+ Loops editor; you are re-authoring it as react-email, not copying HTML
129
+ verbatim (use the Hogsend app's `src/emails/_components/` chrome).
130
+ - **Loops' unsubscribe is per-mailing-list + global.** Map list-level
131
+ unsubscribes to `hs.lists.unsubscribe({ list, email })` and global ones to
132
+ the admin preferences route (`unsubscribedAll`) — see `cutover-checklist.md`.
@@ -0,0 +1,120 @@
1
+ # Resend Broadcasts/Audiences → Hogsend mapping
2
+
3
+ Audit greps, the concept table, and rewrites for a codebase using Resend's
4
+ Broadcasts + Audiences + Contacts APIs. Resend's API may differ by SDK version —
5
+ treat the left-hand column as "look for", and verify against the project's
6
+ actual calls.
7
+
8
+ **The special case first: Resend can stay.** Hogsend's default `EmailProvider`
9
+ IS Resend (`@hogsend/plugin-resend`) — the same `RESEND_API_KEY` keeps
10
+ delivering the mail. This migration replaces the ORCHESTRATION layer (who
11
+ decides what to send to whom, when) — broadcasts, audiences, contact state —
12
+ not necessarily the wire. That makes it the lowest-risk migration of the three:
13
+ deliverability, domains, and DKIM are already proven.
14
+
15
+ ## Audit greps (code half)
16
+
17
+ ```bash
18
+ grep -rn "resend" package.json
19
+ # The orchestration surface being migrated:
20
+ grep -rn "resend.broadcasts\.\|resend.audiences\.\|resend.contacts\." src/
21
+ # Plain transactional — DISTINCT, may stay on resend.emails.send or move:
22
+ grep -rn "resend.emails.send" src/
23
+ grep -rn "RESEND_API_KEY\|RESEND_AUDIENCE" . --include="*.env*" --include="*.ts"
24
+ ```
25
+
26
+ GUI half (from the Resend dashboard): audiences + contact counts, broadcast
27
+ history + their content, unsubscribed contacts per audience, and the verified
28
+ sending domains (these carry over untouched when Resend stays the provider).
29
+
30
+ ## Concept mapping
31
+
32
+ | Resend concept | Hogsend equivalent | Notes |
33
+ |---|---|---|
34
+ | Audience | list — `defineList({ id, name, defaultOptIn })` | Resend audiences are explicit-membership mailing lists → almost always opt-in (`defaultOptIn: false`) |
35
+ | Contact (in an audience) | contact + list membership — `hs.contacts.upsert({ email, properties?, lists: { "<list-id>": true } })` | `firstName`/`lastName` → `properties`; `unsubscribed: true` contacts → suppression import (see `cutover-checklist.md`) |
36
+ | Broadcast | campaign — `hs.campaigns.send({ list, template, props })` | Or `hogsend campaigns send --list <id> --template <key>`. Content becomes a four-file react-email template |
37
+ | `resend.emails.send` (transactional) | EITHER keep as-is (it still works) OR move to `hs.emails.send` | Moving buys first-party open/click tracking, preference/suppression checks, send history, and journey attribution — recommended, not required |
38
+ | Scheduled broadcast | campaign triggered when you want it | A campaign sends on enqueue; schedule via your own cron/workflow if needed |
39
+ | (No workflow product) | journeys — `defineJourney()` | Net-new capability, not a port. Teams on Resend Broadcasts usually hand-rolled drip logic in app code — grep for `setTimeout`/cron-driven email sends worth replacing with journeys |
40
+
41
+ ## Before / after
42
+
43
+ Audience + contact management (host product code):
44
+
45
+ ```ts
46
+ // BEFORE (Resend — shapes vary by SDK version)
47
+ await resend.contacts.create({
48
+ email: "ada@example.com",
49
+ firstName: "Ada",
50
+ audienceId: NEWSLETTER_AUDIENCE_ID,
51
+ });
52
+
53
+ // AFTER (@hogsend/client) — list defined once in the Hogsend app via
54
+ // defineList({ id: "newsletter", name: "Newsletter", defaultOptIn: false })
55
+ import { hogsend } from "../lib/hogsend.js";
56
+
57
+ await hogsend.contacts.upsert({
58
+ email: "ada@example.com",
59
+ userId: user.id,
60
+ properties: { firstName: "Ada" },
61
+ lists: { newsletter: true },
62
+ });
63
+ // (or, membership alone: hogsend.lists.subscribe({ list: "newsletter", email: "ada@example.com" }))
64
+ ```
65
+
66
+ Broadcast → campaign:
67
+
68
+ ```ts
69
+ // BEFORE: a broadcast created in the Resend dashboard / broadcasts API
70
+
71
+ // AFTER — template authored in the Hogsend app first
72
+ // (four-file contract → hogsend-authoring-emails skill)
73
+ const { campaignId } = await hogsend.campaigns.send({
74
+ list: "newsletter",
75
+ template: "june-update",
76
+ props: {},
77
+ name: "June update",
78
+ });
79
+ // Poll progress: await hogsend.campaigns.get(campaignId)
80
+ // CLI: hogsend campaigns send --list newsletter --template june-update
81
+ // hogsend campaigns status <campaignId> --json
82
+ ```
83
+
84
+ Transactional, if moving (optional but recommended):
85
+
86
+ ```ts
87
+ // BEFORE (Resend direct)
88
+ await resend.emails.send({
89
+ from: "Acme <hello@acme.com>",
90
+ to: "ada@example.com",
91
+ subject: "Reset your password",
92
+ html: renderedHtml,
93
+ });
94
+
95
+ // AFTER — Hogsend renders the react-email template, applies tracking +
96
+ // preference checks, records the send, THEN delivers via the same Resend key
97
+ await hogsend.emails.send({
98
+ to: "ada@example.com",
99
+ template: "password-reset",
100
+ props: { resetUrl },
101
+ });
102
+ ```
103
+
104
+ ## Resend-specific gotchas
105
+
106
+ - **Don't double-manage contacts.** Once Hogsend owns the audience (as a
107
+ list), stop writing to `resend.audiences`/`resend.contacts` — Hogsend's
108
+ provider integration uses Resend purely as a send wire, not as a contact
109
+ store.
110
+ - **Unsubscribes move to Hogsend.** Hogsend injects its own unsubscribe/
111
+ preference links and stores state in its `email_preferences`. Import Resend's
112
+ per-audience unsubscribed contacts BEFORE the first campaign
113
+ (`cutover-checklist.md`), and stop relying on Resend-side unsubscribe state
114
+ afterward.
115
+ - **Domains/DKIM carry over** when Resend stays the provider — verify
116
+ `RESEND_API_KEY` (and the from-address envs) are set on the Hogsend
117
+ deployment, then nothing else changes on the deliverability side.
118
+ - **Broadcast HTML is re-authored**, not pasted: rebuild as a react-email
119
+ component using the Hogsend app's `src/emails/_components/` chrome so
120
+ tracking + unsubscribe slots work.