@hogsend/cli 0.2.2 → 0.6.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.
@@ -0,0 +1,181 @@
1
+ # `@hogsend/client` — full API surface
2
+
3
+ Every method on the `Hogsend` instance, with its input and result shape and the
4
+ data-plane route it maps to. All inputs that mutate require an **identity** —
5
+ at least one of `email` or `userId` (both may be supplied), enforced by
6
+ `assertIdentity` at runtime and the `Identity` union at compile time.
7
+
8
+ ## `hs.contacts`
9
+
10
+ ### `contacts.upsert(input) → { id, created, linked }`
11
+
12
+ `PUT /v1/contacts`. Upsert a contact by identity; the server resolves/merges
13
+ (including identity linking when both `email` and `userId` are given).
14
+
15
+ ```ts
16
+ type UpsertContactInput = Identity & {
17
+ properties?: Record<string, unknown>; // merged onto the contact
18
+ lists?: Record<string, boolean>; // inline list membership flips
19
+ };
20
+ // → { id: string; created: boolean; linked: boolean }
21
+ ```
22
+
23
+ - `created` — `true` when a brand-new contact row was inserted.
24
+ - `linked` — `true` when the upsert merged two previously-separate identities
25
+ (an `email`-only and a `userId`-only contact) into one.
26
+
27
+ ### `contacts.find(input) → Contact[]`
28
+
29
+ `GET /v1/contacts/find`. Look up non-deleted contacts by EXACTLY ONE of `email`
30
+ or `userId` (the find input is `{ email } | { userId }`, not the general
31
+ identity union). Returns an array (may be empty).
32
+
33
+ ```ts
34
+ type FindContactsInput = { email: string } | { userId: string };
35
+
36
+ interface Contact {
37
+ id: string;
38
+ externalId: string | null;
39
+ email: string | null;
40
+ properties: Record<string, unknown>;
41
+ firstSeenAt: string; // ISO — always present
42
+ lastSeenAt: string; // ISO
43
+ createdAt: string; // ISO
44
+ updatedAt: string; // ISO
45
+ }
46
+ ```
47
+
48
+ ### `contacts.delete(input) → { deleted }`
49
+
50
+ `DELETE /v1/contacts`. Soft-delete a contact by identity. `deleted` is `true`
51
+ when a matching contact was found and marked deleted.
52
+
53
+ ## `hs.events`
54
+
55
+ ### `events.send(input) → { stored, exits, listsError? }` · alias `events.track`
56
+
57
+ `POST /v1/events` → **202 Accepted**. Push an event through the full ingestion
58
+ pipeline (store → route to Hatchet/journeys → evaluate `exitOn` → upsert contact).
59
+ `events.track` is a literal alias of `events.send`.
60
+
61
+ ```ts
62
+ type SendEventInput = Identity & {
63
+ name: string;
64
+ eventProperties?: Record<string, unknown>; // stored ON the event → trigger.where / exitOn
65
+ contactProperties?: Record<string, unknown>; // merged onto the CONTACT
66
+ lists?: Record<string, boolean>; // inline list membership
67
+ idempotencyKey?: string; // dedup
68
+ };
69
+
70
+ interface IngestResult {
71
+ stored: boolean; // false only on idempotency-key dedup
72
+ exits: { journeyId: string; stateId: string; exited: boolean }[];
73
+ listsError?: string; // present only if the post-ingest lists write failed
74
+ }
75
+ ```
76
+
77
+ - See the main SKILL for the `eventProperties` vs `contactProperties` split and
78
+ the `listsError`-on-202 semantics.
79
+ - **`idempotencyKey`** is sent BOTH as the `Idempotency-Key` HTTP header (which
80
+ wins server-side) AND in the body, matching the route. Reuse the same key to
81
+ make a retried `events.send` a no-op (`stored: false`).
82
+
83
+ ## `hs.emails`
84
+
85
+ ### `emails.send(input) → { emailSendId, status, reason? }`
86
+
87
+ `POST /v1/emails`. Send a transactional email by template through the full
88
+ preferences + tracking pipeline (link-click + open rewriting applied
89
+ automatically). Recipient is `to` (raw address) OR `userId` (resolved to the
90
+ contact's email server-side).
91
+
92
+ ```ts
93
+ type SendEmailInput = SendEmailEnvelope & ( typed-or-untyped template variant );
94
+
95
+ type SendEmailEnvelope = {
96
+ to?: string;
97
+ userId?: string;
98
+ from?: string;
99
+ subject?: string;
100
+ replyTo?: string;
101
+ category?: string; // a LIST id to gate the send on (see hogsend-authoring-lists)
102
+ skipPreferenceCheck?: boolean;// bypass unsub/suppression — needs full-admin
103
+ idempotencyKey?: string;
104
+ };
105
+
106
+ interface SendEmailResult {
107
+ emailSendId: string;
108
+ status: string; // queued | sent | suppressed | unsubscribed | skipped
109
+ reason?: string;
110
+ }
111
+ ```
112
+
113
+ **Typed templates.** When `@hogsend/email` is installed (a TYPE-ONLY optional
114
+ peer) and you augment its `TemplateRegistryMap`, `template` and `props` are
115
+ fully type-checked per known template key. Without it, the shape degrades to
116
+ `{ template: string; props?: Record<string, unknown> }`.
117
+
118
+ > tsconfig caveat: the shipped `.d.ts` references `@hogsend/email` by module
119
+ > name. If you do NOT install that optional peer, keep `skipLibCheck: true` (the
120
+ > scaffold default) or `tsc` emits `TS2307: Cannot find module '@hogsend/email'`
121
+ > from this package's declarations. Installing the peer (even type-only) removes
122
+ > the caveat. The runtime JS has no dependency on `@hogsend/email`.
123
+
124
+ - `category` gates the send on a code-defined list's opt-in/opt-out polarity —
125
+ see the hogsend-authoring-lists skill.
126
+ - `skipPreferenceCheck: true` bypasses the unsubscribe/suppression gate and
127
+ requires a `full-admin` key, not a plain `ingest` key.
128
+
129
+ ## `hs.lists`
130
+
131
+ ### `lists.list() → ListSummary[]`
132
+
133
+ `GET /v1/lists`. Every code-defined list in the app.
134
+
135
+ ```ts
136
+ interface ListSummary {
137
+ id: string;
138
+ name: string;
139
+ description?: string;
140
+ defaultOptIn: boolean;
141
+ }
142
+ ```
143
+
144
+ ### `lists.subscribe(input) → { subscribed: true }`
145
+
146
+ `POST /v1/lists/:id/subscribe`. Sets `categories[id] = true` for the identity.
147
+
148
+ ### `lists.unsubscribe(input) → { unsubscribed: true }`
149
+
150
+ `POST /v1/lists/:id/unsubscribe`. Sets `categories[id] = false` for the identity.
151
+
152
+ ```ts
153
+ type SubscribeInput = Identity & { list: string };
154
+ ```
155
+
156
+ For how `subscribe`/`unsubscribe` interact with a list's `defaultOptIn` polarity
157
+ (opt-in needs an exact `true`, opt-out is blocked only on an exact `false`), see
158
+ the hogsend-authoring-lists skill.
159
+
160
+ ## Construction options (recap)
161
+
162
+ ```ts
163
+ interface HogsendOptions {
164
+ baseUrl: string; // required — e.g. https://api.example.com
165
+ apiKey: string; // required — hsk_… key with the `ingest` scope
166
+ fetch?: typeof fetch; // default: global fetch
167
+ timeoutMs?: number; // default: 30000 (aborts the request)
168
+ headers?: Record<string, string>; // merged onto every request
169
+ }
170
+ ```
171
+
172
+ ## Errors (recap)
173
+
174
+ - `HogsendAPIError` — `{ status, body }`. `status === 0` = transport failure
175
+ (DNS/connect/timeout); `body` is parsed JSON or raw text.
176
+ - `RateLimitError extends HogsendAPIError` — `status === 429`, with `retryAfter`
177
+ (seconds) from the `Retry-After` header when present.
178
+
179
+ Both are exported from `@hogsend/client` and are real classes, so
180
+ `err instanceof RateLimitError` / `err instanceof HogsendAPIError` narrows
181
+ correctly (check `RateLimitError` first — it is the subclass).
package/src/bin.ts CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { commands } from "./commands/index.js";
4
4
  import type { Command } from "./commands/types.js";
5
5
  import { parseGlobalFlags, resolveConfig } from "./lib/config.js";
6
- import { createAdminClient } from "./lib/http.js";
6
+ import { createAdminClient, createDataPlaneClient } from "./lib/http.js";
7
7
  import { color, createOutput } from "./lib/output.js";
8
8
 
9
9
  function version(): string {
@@ -31,6 +31,7 @@ ${list}
31
31
  ${color.dim("Global options:")}
32
32
  --url <baseUrl> Target instance (default HOGSEND_API_URL or http://localhost:3002)
33
33
  --admin-key <key> Admin bearer token (default HOGSEND_ADMIN_KEY / ADMIN_API_KEY)
34
+ --data-key <key> Ingest bearer token for writes (default HOGSEND_DATA_KEY / HOGSEND_API_KEY)
34
35
  --json Emit machine-readable JSON only (for agents)
35
36
  -h, --help Show help (use after a command for command help)
36
37
  -v, --version Show version
@@ -80,11 +81,13 @@ async function main(): Promise<void> {
80
81
 
81
82
  const cfg = resolveConfig(flags);
82
83
  const http = createAdminClient(cfg);
84
+ const dataHttp = createDataPlaneClient(cfg);
83
85
 
84
86
  await command.run({
85
87
  argv: flags.rest,
86
88
  cfg,
87
89
  http,
90
+ dataHttp,
88
91
  out,
89
92
  json: flags.json,
90
93
  });
@@ -0,0 +1,309 @@
1
+ import { parseArgs } from "node:util";
2
+ import { isHttpError } from "../lib/http.js";
3
+ import { color } from "../lib/output.js";
4
+ import type { Command, CommandContext } from "./types.js";
5
+
6
+ const usage = `hogsend campaigns <subcommand> [options]
7
+
8
+ Queue and inspect broadcasts: durably send one email template to every
9
+ subscribed member of a list (or every active member of a bucket). Wraps the
10
+ data-plane campaigns routes (POST /v1/campaigns, GET /v1/campaigns/{id}).
11
+
12
+ Subcommands:
13
+ send Queue a campaign. Sends run async in the worker.
14
+ status <id> Show a campaign's status + send counts.
15
+
16
+ send options (exactly one of --list / --bucket, plus --template, required):
17
+ --list <id> Target every subscribed member of this list.
18
+ --bucket <id> Target every active member of this bucket.
19
+ --template <key> Email template to send.
20
+ --prop <key=value> Template prop; repeatable. Value parsed as JSON, falling
21
+ back to a string.
22
+ --props <json> Template props as one JSON object (merged with --prop).
23
+ --name <text> Human label for the campaign.
24
+ --from <addr> Override the default From address.
25
+ --subject <text> Override the rendered subject.
26
+
27
+ Global options (handled by the router): --url, --admin-key, --data-key, --json,
28
+ -h/--help.
29
+
30
+ Examples:
31
+ hogsend campaigns send --list newsletter --template june-update --name "June"
32
+ hogsend campaigns send --bucket power-users --template feature-launch --json
33
+ hogsend campaigns status cmp_123 --json`;
34
+
35
+ const badge = `${color.bgMagenta(color.black(" hogsend "))} campaigns`;
36
+
37
+ /** Shape returned by POST /v1/campaigns (202 enqueue ack). */
38
+ interface SendResponse {
39
+ campaignId: string;
40
+ status: "queued" | "sending" | "sent" | "failed";
41
+ }
42
+
43
+ /** Shape returned by GET /v1/campaigns/{id}. */
44
+ interface StatusResponse {
45
+ id: string;
46
+ name: string;
47
+ status: "queued" | "sending" | "sent" | "failed";
48
+ audienceKind: "list" | "bucket";
49
+ audienceId: string;
50
+ templateKey: string;
51
+ totalRecipients: number;
52
+ sentCount: number;
53
+ skippedCount: number;
54
+ failedCount: number;
55
+ startedAt: string | null;
56
+ completedAt: string | null;
57
+ createdAt: string;
58
+ }
59
+
60
+ /**
61
+ * Parse `--prop key=value` (repeatable) + an optional `--props <json>` object
62
+ * into a single props record. Each `--prop` value is JSON-parsed when valid
63
+ * JSON, else kept as a string. The `--props` object is applied first, so later
64
+ * `--prop` flags win.
65
+ */
66
+ function parseProps(
67
+ ctx: CommandContext,
68
+ propsJson: string | undefined,
69
+ propPairs: string[] | undefined,
70
+ ): Record<string, unknown> | undefined {
71
+ const out: Record<string, unknown> = {};
72
+ let any = false;
73
+
74
+ if (propsJson !== undefined) {
75
+ let parsed: unknown;
76
+ try {
77
+ parsed = JSON.parse(propsJson);
78
+ } catch {
79
+ ctx.out.fail(`--props must be valid JSON, got: ${propsJson}`);
80
+ }
81
+ if (
82
+ parsed === null ||
83
+ typeof parsed !== "object" ||
84
+ Array.isArray(parsed)
85
+ ) {
86
+ ctx.out.fail("--props must be a JSON object");
87
+ }
88
+ Object.assign(out, parsed as Record<string, unknown>);
89
+ any = true;
90
+ }
91
+
92
+ for (const pair of propPairs ?? []) {
93
+ const eq = pair.indexOf("=");
94
+ if (eq === -1) {
95
+ ctx.out.fail(`--prop must be key=value, got: ${pair}`);
96
+ }
97
+ const key = pair.slice(0, eq).trim();
98
+ if (key === "") {
99
+ ctx.out.fail(`--prop key cannot be empty, got: ${pair}`);
100
+ }
101
+ out[key] = coerceValue(pair.slice(eq + 1));
102
+ any = true;
103
+ }
104
+
105
+ return any ? out : undefined;
106
+ }
107
+
108
+ /** JSON-parse a flag value, falling back to the raw string. */
109
+ function coerceValue(raw: string): unknown {
110
+ try {
111
+ return JSON.parse(raw);
112
+ } catch {
113
+ return raw;
114
+ }
115
+ }
116
+
117
+ function statusColor(status: SendResponse["status"]): string {
118
+ switch (status) {
119
+ case "sent":
120
+ return color.green(status);
121
+ case "queued":
122
+ case "sending":
123
+ return color.cyan(status);
124
+ default:
125
+ // failed
126
+ return color.red(status);
127
+ }
128
+ }
129
+
130
+ async function runSend(ctx: CommandContext, argv: string[]): Promise<void> {
131
+ const { values } = parseArgs({
132
+ args: argv,
133
+ allowPositionals: true,
134
+ options: {
135
+ list: { type: "string" },
136
+ bucket: { type: "string" },
137
+ template: { type: "string" },
138
+ prop: { type: "string", multiple: true },
139
+ props: { type: "string" },
140
+ name: { type: "string" },
141
+ from: { type: "string" },
142
+ subject: { type: "string" },
143
+ help: { type: "boolean", short: "h", default: false },
144
+ },
145
+ });
146
+
147
+ if (values.help) {
148
+ ctx.out.log(usage);
149
+ return;
150
+ }
151
+
152
+ const list = values.list;
153
+ const bucket = values.bucket;
154
+ if ((list && bucket) || (!list && !bucket)) {
155
+ ctx.out.fail("campaigns send requires exactly one of --list or --bucket");
156
+ }
157
+
158
+ const template = values.template;
159
+ if (!template) {
160
+ ctx.out.fail(
161
+ "campaigns send requires --template, e.g. hogsend campaigns send --list newsletter --template welcome",
162
+ );
163
+ }
164
+
165
+ const props = parseProps(ctx, values.props, values.prop);
166
+
167
+ const body: {
168
+ template: string;
169
+ list?: string;
170
+ bucket?: string;
171
+ props?: Record<string, unknown>;
172
+ name?: string;
173
+ from?: string;
174
+ subject?: string;
175
+ } = { template };
176
+ if (list) body.list = list;
177
+ if (bucket) body.bucket = bucket;
178
+ if (props) body.props = props;
179
+ if (values.name) body.name = values.name;
180
+ if (values.from) body.from = values.from;
181
+ if (values.subject) body.subject = values.subject;
182
+
183
+ let res: SendResponse;
184
+ try {
185
+ res = await ctx.out.step(`Queuing campaign ${template}`, () =>
186
+ ctx.dataHttp.post<SendResponse>("/v1/campaigns", body),
187
+ );
188
+ } catch (error) {
189
+ if (isHttpError(error)) {
190
+ ctx.out.fail(error.message);
191
+ }
192
+ throw error;
193
+ }
194
+
195
+ if (ctx.json) {
196
+ ctx.out.json(res);
197
+ return;
198
+ }
199
+
200
+ ctx.out.intro(`${badge} send`);
201
+ ctx.out.kv(
202
+ {
203
+ campaignId: res.campaignId,
204
+ template,
205
+ audience: list ? `list:${list}` : `bucket:${bucket}`,
206
+ status: statusColor(res.status),
207
+ },
208
+ "Campaign queued",
209
+ );
210
+ ctx.out.outro(
211
+ `${color.green("Queued")} — poll ${color.cyan(`hogsend campaigns status ${res.campaignId}`)} for progress.`,
212
+ );
213
+ }
214
+
215
+ async function runStatus(ctx: CommandContext, argv: string[]): Promise<void> {
216
+ const { values, positionals } = parseArgs({
217
+ args: argv,
218
+ allowPositionals: true,
219
+ options: {
220
+ help: { type: "boolean", short: "h", default: false },
221
+ },
222
+ });
223
+
224
+ if (values.help) {
225
+ ctx.out.log(usage);
226
+ return;
227
+ }
228
+
229
+ const id = positionals[0];
230
+ if (!id) {
231
+ ctx.out.fail(
232
+ "campaigns status requires a campaign id, e.g. hogsend campaigns status cmp_123",
233
+ );
234
+ }
235
+
236
+ let res: StatusResponse;
237
+ try {
238
+ res = await ctx.out.step(`Fetching campaign ${id}`, () =>
239
+ ctx.dataHttp.get<StatusResponse>(
240
+ `/v1/campaigns/${encodeURIComponent(id)}`,
241
+ ),
242
+ );
243
+ } catch (error) {
244
+ if (isHttpError(error)) {
245
+ ctx.out.fail(error.message);
246
+ }
247
+ throw error;
248
+ }
249
+
250
+ if (ctx.json) {
251
+ ctx.out.json(res);
252
+ return;
253
+ }
254
+
255
+ ctx.out.intro(`${badge} status`);
256
+ ctx.out.kv(
257
+ {
258
+ id: res.id,
259
+ name: res.name,
260
+ status: statusColor(res.status),
261
+ audience: `${res.audienceKind}:${res.audienceId}`,
262
+ template: res.templateKey,
263
+ recipients: res.totalRecipients,
264
+ sent: color.green(String(res.sentCount)),
265
+ skipped: color.yellow(String(res.skippedCount)),
266
+ failed:
267
+ res.failedCount > 0
268
+ ? color.red(String(res.failedCount))
269
+ : String(res.failedCount),
270
+ startedAt: res.startedAt ?? "",
271
+ completedAt: res.completedAt ?? "",
272
+ },
273
+ "Campaign",
274
+ );
275
+ ctx.out.outro(
276
+ `${res.name} → ${statusColor(res.status)} ` +
277
+ color.dim(
278
+ `(${res.sentCount}/${res.totalRecipients} sent, ${res.skippedCount} skipped, ${res.failedCount} failed)`,
279
+ ),
280
+ );
281
+ }
282
+
283
+ async function run(ctx: CommandContext): Promise<void> {
284
+ const sub = ctx.argv[0];
285
+
286
+ switch (sub) {
287
+ case "send":
288
+ // Strip the leading "send" token; the rest is send's own args.
289
+ return runSend(ctx, ctx.argv.slice(1));
290
+ case "status":
291
+ return runStatus(ctx, ctx.argv.slice(1));
292
+ case undefined:
293
+ ctx.out.fail(
294
+ "campaigns requires a subcommand: send | status (see hogsend campaigns --help)",
295
+ );
296
+ break;
297
+ default:
298
+ ctx.out.fail(
299
+ `unknown campaigns subcommand "${sub}" — expected send or status`,
300
+ );
301
+ }
302
+ }
303
+
304
+ export const campaignsCommand: Command = {
305
+ name: "campaigns",
306
+ summary: "Queue a broadcast to a list/bucket, or check its status",
307
+ usage,
308
+ run,
309
+ };