@hogsend/cli 0.10.0 → 0.11.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 +10913 -328
- package/dist/bin.js.map +1 -1
- package/package.json +6 -3
- package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +5 -4
- package/skills/hogsend-cli/SKILL.md +32 -1
- package/skills/hogsend-extending/SKILL.md +4 -1
- package/skills/hogsend-extending/references/swap-a-provider.md +273 -51
- package/src/__tests__/admin-recovery.test.ts +193 -0
- package/src/bin.ts +13 -3
- package/src/commands/studio-admin.ts +340 -0
- package/src/commands/studio.ts +17 -1
- package/src/lib/admin-recovery.ts +193 -0
- package/studio/assets/index-BBOTQnww.js +250 -0
- package/studio/assets/index-DnfpcXbb.css +1 -0
- package/studio/index.html +2 -2
- package/studio/assets/index-BNDE5JtQ.css +0 -1
- package/studio/assets/index-CgJBk-Ft.js +0 -250
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"tsup": "^8.5.1",
|
|
33
33
|
"tsx": "^4.22.4",
|
|
34
34
|
"vitest": "^4.1.7",
|
|
35
|
-
"@hogsend/studio": "^0.
|
|
35
|
+
"@hogsend/studio": "^0.11.0",
|
|
36
36
|
"@repo/typescript-config": "0.0.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
@@ -40,7 +40,10 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@clack/prompts": "^1.5.0",
|
|
43
|
-
"
|
|
43
|
+
"better-auth": "^1.6.11",
|
|
44
|
+
"picocolors": "^1.1.1",
|
|
45
|
+
"@hogsend/db": "^0.11.0",
|
|
46
|
+
"@hogsend/engine": "^0.11.0"
|
|
44
47
|
},
|
|
45
48
|
"scripts": {
|
|
46
49
|
"prebuild": "node scripts/bundle-studio.mjs",
|
|
@@ -17,12 +17,13 @@ emailService.send({ template, props, to, ... }) // or sendEmail({...}) in a jo
|
|
|
17
17
|
(both skipped when skipPreferenceCheck is set)
|
|
18
18
|
2. getTemplate(key, props, registry) → resolve element + subject + category
|
|
19
19
|
3. insert email_sends row → gives the send a stable emailSendId (status "queued")
|
|
20
|
-
4. renderToHtml(element)
|
|
20
|
+
4. renderToHtml(element) ALWAYS (HTML-only wire — no React crosses the provider),
|
|
21
|
+
then prepareTrackedHtml(html, emailSendId, baseUrl, db):
|
|
21
22
|
• rewriteLinks() — every <a href="https?://…"> → /v1/t/c/:linkId
|
|
22
23
|
• injectOpenPixel() — <img src="/v1/t/o/:emailSendId"> before </body>
|
|
23
|
-
(only when baseUrl + prepareTrackedHtml are present; else send the
|
|
24
|
-
5. provider.send(...) —
|
|
25
|
-
6. update email_sends →
|
|
24
|
+
(rewrite/pixel only when baseUrl + prepareTrackedHtml are present; else send the plain rendered HTML)
|
|
25
|
+
5. provider.send(...) — the provider gets the already-rendered HTML
|
|
26
|
+
6. update email_sends → messageId + status "sent" (or "failed" on throw)
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
Tracking comes along regardless of which provider you supply, because steps 2–4
|
|
@@ -4,7 +4,7 @@ description: Use when an agent needs to inspect or operate a running Hogsend lif
|
|
|
4
4
|
license: MIT
|
|
5
5
|
metadata:
|
|
6
6
|
author: withSeismic
|
|
7
|
-
version: "1.
|
|
7
|
+
version: "1.3.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Hogsend CLI
|
|
@@ -75,6 +75,8 @@ Most commands READ (admin API). A handful WRITE through the data plane — marke
|
|
|
75
75
|
| `hogsend events send <name>` | **(write)** Push an event → `POST /v1/events`. `--email`/`--user-id` (≥1 required), `--prop`/`--props` (event props), `--contact-prop`/`--contact-props` (contact props), `--list`/`--unlist`, `--idempotency-key`, `--timestamp`. |
|
|
76
76
|
| `hogsend emails send <template>` | **(write)** Send a transactional email → `POST /v1/emails`. `--to`/`--user-id` (≥1 required), `--prop`/`--props`, `--subject`, `--from`, `--reply-to`, `--category`, `--idempotency-key`, `--skip-preference-check` (needs full-admin). |
|
|
77
77
|
| `hogsend webhooks list/get/create/update/delete/rotate-secret/test` | Manage **outbound** signed webhook endpoints (the event stream Hogsend emits to your URLs) → `/v1/admin/webhooks`. Needs the **admin key**, not the data key. `create --url <url>` + repeatable `--event <type>` or `--all-events`; the signing secret prints ONCE on `create` + `rotate-secret`. |
|
|
78
|
+
| `hogsend studio` | Serve the bundled Studio admin SPA locally (optionally against a remote `--base-url`). |
|
|
79
|
+
| `hogsend studio admin create/reset/list` | **Shell-gated** Studio admin create + recovery — DB-DIRECT, not HTTP. Gated by holding `DATABASE_URL` + `BETTER_AUTH_SECRET` (read from the ENVIRONMENT, not a `.env` file); writes passwords via Better Auth (scrypt, internal adapter), never raw SQL. Public sign-up is disabled, so this CLI (and the `STUDIO_ADMIN_EMAIL` boot bootstrap) are the ONLY ways to mint an admin. `create` bootstraps the first admin, `reset --email <e>` rotates a forgotten password (revokes sessions unless `--no-revoke`), `list` shows admins (no secrets). |
|
|
78
80
|
| `hogsend skills list/add` | Manage these bundled agent skills. |
|
|
79
81
|
| `hogsend upgrade` | Bump `@hogsend/*` deps to latest + refresh vendored skills. |
|
|
80
82
|
| `hogsend setup` | Interactive LOCAL onboarding (docker, secret, migrate). |
|
|
@@ -106,3 +108,32 @@ Run `hogsend <command> --help` for per-command usage.
|
|
|
106
108
|
make sure a data key resolves (`--data-key` > `HOGSEND_DATA_KEY` >
|
|
107
109
|
`HOGSEND_API_KEY`) for the data-plane writes.
|
|
108
110
|
4. Use `--limit`/`--offset` for pagination instead of dumping everything.
|
|
111
|
+
5. `studio admin` is the ONE family that does NOT use the HTTP API — it talks to
|
|
112
|
+
the database directly and is gated by `DATABASE_URL` + `BETTER_AUTH_SECRET`
|
|
113
|
+
(no `--url`/`--admin-key`). It's admin create/recovery, not data ops — prefer
|
|
114
|
+
the masked password prompt over `--password` (which can leak into shell
|
|
115
|
+
history). Those two vars are read from the ENVIRONMENT only (NOT a `.env`
|
|
116
|
+
file), so run it with env loaded: locally `pnpm studio:admin` (the scaffold's
|
|
117
|
+
`node --env-file=.env … hogsend studio admin create` wrapper) or
|
|
118
|
+
`dotenvx run -- hogsend studio admin create`; on Railway `railway run hogsend
|
|
119
|
+
studio admin create` (or `railway ssh`).
|
|
120
|
+
|
|
121
|
+
## First admin & closed sign-up
|
|
122
|
+
|
|
123
|
+
Public sign-up is DISABLED at the auth layer (`disableSignUp`) — `POST
|
|
124
|
+
/api/auth/sign-up/email` returns `400 EMAIL_PASSWORD_SIGN_UP_DISABLED` for
|
|
125
|
+
everyone, so there is NO unauthenticated network path that creates a user. The
|
|
126
|
+
first Studio admin is minted in one of two ways, both in-network:
|
|
127
|
+
|
|
128
|
+
- **CLI:** `hogsend studio admin create` (or the scaffold's `pnpm studio:admin`),
|
|
129
|
+
gated by `DATABASE_URL` + `BETTER_AUTH_SECRET`. The only explicit path.
|
|
130
|
+
- **Env bootstrap:** set `STUDIO_ADMIN_EMAIL` (+ optional `STUDIO_ADMIN_PASSWORD`)
|
|
131
|
+
in the deploy env. On boot, IF the user table is empty, the API mints that
|
|
132
|
+
admin (idempotent, race-safe). With no password set, a strong one is
|
|
133
|
+
auto-generated and printed ONCE to the server log — rotate it via the Studio
|
|
134
|
+
forgot/reset flow. Once an admin exists it never re-mints.
|
|
135
|
+
|
|
136
|
+
If you (an agent) need an admin on a fresh instance and have shell access to the
|
|
137
|
+
DB + secret, `studio admin create` is the move; otherwise tell the operator to
|
|
138
|
+
set `STUDIO_ADMIN_EMAIL` and restart. There is no web "create admin" form to
|
|
139
|
+
drive. Login + forgot/reset stay fully enabled over HTTP.
|
|
@@ -62,7 +62,10 @@ mirrored to a tool, durably? → a destination (3).
|
|
|
62
62
|
provider. They come along regardless of which provider you supply.
|
|
63
63
|
- **Defaults.** Pass nothing and you get Resend (built from `RESEND_API_KEY`) +
|
|
64
64
|
PostHog (from `POSTHOG_API_KEY`). Inbound delivery webhooks land at the
|
|
65
|
-
engine-owned route `POST /v1/webhooks/
|
|
65
|
+
engine-owned route `POST /v1/webhooks/email/:providerId` (`:providerId` =
|
|
66
|
+
`meta.id`, so `…/email/resend` for the default; `POST /v1/webhooks/resend` is a
|
|
67
|
+
deprecated alias). The provider's `verifyWebhook` normalizes the payload into an
|
|
68
|
+
`EmailEvent` the engine maps to `email_sends` status + suppression.
|
|
66
69
|
- ⚠️ The contract's `SendEmailOptions` imports from `@hogsend/core` (or
|
|
67
70
|
`@hogsend/plugin-resend`), **not** `@hogsend/engine` — the engine's own
|
|
68
71
|
`SendEmailOptions` is a different, higher-level send type.
|
|
@@ -4,110 +4,328 @@ A capability provider is a **swappable implementation of an engine-owned
|
|
|
4
4
|
contract**. The engine drives the capability and routes to whatever you supply.
|
|
5
5
|
Today there are two: email (`EmailProvider`) and analytics (`PostHogService`).
|
|
6
6
|
|
|
7
|
+
## Postmark is already shipped — install it, don't reimplement it
|
|
8
|
+
|
|
9
|
+
As of **0.10.0** Postmark is a **shipped reference implementation**:
|
|
10
|
+
`@hogsend/plugin-postmark` (`createPostmarkProvider`). You do **not** implement
|
|
11
|
+
the contract to use it — you install the package and opt in. Resend stays the
|
|
12
|
+
default; the engine type-checks identically with or without the package
|
|
13
|
+
installed (it's an engine `optionalDependency`, lazily `import()`-ed only when
|
|
14
|
+
`POSTMARK_SERVER_TOKEN` is set).
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @hogsend/plugin-postmark@latest
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Implementing the `EmailProvider` contract yourself is the path for a provider
|
|
21
|
+
that does **not** ship a plugin yet (e.g. SES). The contract below is what you
|
|
22
|
+
implement in that case — and what the shipped Resend/Postmark providers already
|
|
23
|
+
satisfy.
|
|
24
|
+
|
|
7
25
|
## The `EmailProvider` contract
|
|
8
26
|
|
|
9
27
|
Defined in `@hogsend/core`, re-exported canonically from `@hogsend/engine`. It is
|
|
10
28
|
a **dumb wire** — delivery + webhook parse/verify only. No tracking, DB,
|
|
11
|
-
preference, or render logic lives here.
|
|
29
|
+
preference, or render logic lives here. React **never** crosses this boundary:
|
|
30
|
+
the engine always renders React → HTML (via `@hogsend/email` `renderToHtml`)
|
|
31
|
+
before calling `send`, so the wire is **HTML-only** — there is no `react` field.
|
|
12
32
|
|
|
13
33
|
```ts
|
|
14
34
|
import type { EmailProvider } from "@hogsend/engine";
|
|
15
35
|
|
|
16
36
|
interface EmailProvider {
|
|
17
|
-
|
|
37
|
+
readonly meta?: EmailProviderMeta; // { id, name, description? }
|
|
38
|
+
readonly capabilities?: EmailProviderCapabilities; // tracking/scheduled/signed flags
|
|
39
|
+
|
|
40
|
+
// Deliver one / a batch. SendResult is { id } (the provider message id).
|
|
41
|
+
send(options: SendEmailOptions): Promise<SendResult>;
|
|
18
42
|
sendBatch(emails: BatchEmailItem[]): Promise<{ results: SendResult[] }>;
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
43
|
+
|
|
44
|
+
// Verify the provider's webhook (owns its OWN secrets, constructed-in) and
|
|
45
|
+
// return a normalized EmailEvent. Throws on a bad signature. Throws
|
|
46
|
+
// WebhookHandshakeSignal for non-status handshakes (the route 200s those).
|
|
47
|
+
// MAY be async (SES must GET the SNS SubscribeURL).
|
|
48
|
+
verifyWebhook(opts: {
|
|
49
|
+
payload: string;
|
|
50
|
+
headers: Record<string, string>;
|
|
51
|
+
}): Promise<EmailEvent> | EmailEvent;
|
|
52
|
+
|
|
22
53
|
// Parse an unsigned payload (trusted contexts/tests).
|
|
23
|
-
parseWebhook(payload: string):
|
|
54
|
+
parseWebhook(payload: string): EmailEvent;
|
|
24
55
|
}
|
|
25
56
|
```
|
|
26
57
|
|
|
27
|
-
`
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
`meta` and `capabilities`:
|
|
59
|
+
|
|
60
|
+
- **`meta`** (`{ id, name, description? }`) — `meta.id` is the registry key **and**
|
|
61
|
+
the `:providerId` that `POST /v1/webhooks/email/:providerId` dispatches on. It
|
|
62
|
+
is OPTIONAL for back-compat (the registry falls back to `"resend"` when
|
|
63
|
+
absent) but becomes required in a later breaking phase — **always supply it**.
|
|
64
|
+
- **`capabilities`** (`{ nativeTracking?, scheduledSend?, signedWebhooks? }`) —
|
|
65
|
+
optional; absent is treated conservatively. `nativeTracking: true` (Resend)
|
|
66
|
+
means the provider's own open/click tracking is an account-level toggle the
|
|
67
|
+
engine can't reach → the engine logs a boot WARN telling you to disable it
|
|
68
|
+
(first-party tracking is the single source of truth). `nativeTracking: false`
|
|
69
|
+
(Postmark, SES) means the provider disables its own tracking per-send and the
|
|
70
|
+
engine **trusts** it — no WARN. `scheduledSend` gates
|
|
71
|
+
`SendEmailOptions.scheduledAt`; `signedWebhooks: false` means the provider
|
|
72
|
+
fails-closed on its own (Postmark basic-auth).
|
|
73
|
+
|
|
74
|
+
The webhook wire normalized into `EmailEvent`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
type EmailEventType =
|
|
78
|
+
| "email.sent" | "email.delivered" | "email.bounced" | "email.complained"
|
|
79
|
+
| "email.delivery_delayed" | "email.opened" | "email.clicked";
|
|
80
|
+
|
|
81
|
+
type BounceClass = "permanent" | "transient" | "complaint" | "unknown";
|
|
82
|
+
|
|
83
|
+
interface EmailEvent {
|
|
84
|
+
type: EmailEventType;
|
|
85
|
+
messageId: string; // Resend email_id | Postmark MessageID | SES mail.messageId
|
|
86
|
+
recipients: string[]; // ALL recipients
|
|
87
|
+
occurredAt: string; // ISO 8601
|
|
88
|
+
bounce?: { class: BounceClass; code: string; reason?: string }; // on bounced/complained
|
|
89
|
+
click?: { url: string; at?: string; ip?: string; ua?: string }; // on clicked (native echo only)
|
|
90
|
+
raw: unknown; // untouched provider payload (escape hatch)
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`bounce.class` drives suppression: `permanent` auto-suppresses (the engine
|
|
95
|
+
increments `bounceCount`), `complaint` suppresses immediately, `transient` is
|
|
96
|
+
recorded but **never** suppresses, `unknown` is the conservative default.
|
|
97
|
+
|
|
98
|
+
The send wire is HTML-only:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
interface SendEmailOptions {
|
|
102
|
+
from: string;
|
|
103
|
+
to: string | string[];
|
|
104
|
+
subject: string;
|
|
105
|
+
html: string; // REQUIRED — the engine renders React → HTML before the wire
|
|
106
|
+
text?: string; // optional plain-text alternative
|
|
107
|
+
replyTo?: string | string[];
|
|
108
|
+
cc?: string | string[];
|
|
109
|
+
bcc?: string | string[];
|
|
110
|
+
tags?: Array<{ name: string; value: string }>; // neutral; each provider maps natively
|
|
111
|
+
headers?: Record<string, string>;
|
|
112
|
+
scheduledAt?: string; // honored only when capabilities.scheduledSend; else logged + ignored
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type BatchEmailItem = Omit<SendEmailOptions, "scheduledAt">;
|
|
116
|
+
interface SendResult { id: string }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
> **Migrating from a pre-0.10.0 provider:** the old `WebhookEvent` union and the
|
|
120
|
+
> nested `event.data.email_id` shape are **gone from the wire** — `verifyWebhook`
|
|
121
|
+
> / `parseWebhook` now return the provider-neutral `EmailEvent`. There is **no
|
|
122
|
+
> `react` field** on `SendEmailOptions`. The deprecated `WebhookEvent` union
|
|
123
|
+
> survives **only** as a frozen `event.raw` cast target (alias
|
|
124
|
+
> `LegacyResendWebhookEvent`) for one minor — a handler still on the old shape
|
|
125
|
+
> casts `event.raw as LegacyResendWebhookEvent`, but the supported path is
|
|
126
|
+
> `event.messageId` / `event.bounce` / `event.type`.
|
|
127
|
+
|
|
128
|
+
All of these types — plus the `defineEmailProvider` factory and the
|
|
129
|
+
`normalizeRecipients` / `joinRecipients` helpers — are exported from
|
|
130
|
+
`@hogsend/core` (re-exported canonically from `@hogsend/engine`):
|
|
31
131
|
|
|
32
132
|
```ts
|
|
33
|
-
import
|
|
34
|
-
|
|
133
|
+
import {
|
|
134
|
+
defineEmailProvider,
|
|
135
|
+
joinRecipients,
|
|
136
|
+
type BatchEmailItem,
|
|
137
|
+
type BounceClass,
|
|
138
|
+
type EmailEvent,
|
|
139
|
+
type EmailEventType,
|
|
140
|
+
type EmailProvider,
|
|
141
|
+
type SendEmailOptions,
|
|
142
|
+
type SendResult,
|
|
143
|
+
WebhookHandshakeSignal,
|
|
144
|
+
} from "@hogsend/core";
|
|
35
145
|
```
|
|
36
146
|
|
|
37
|
-
## A provider skeleton
|
|
147
|
+
## A provider skeleton (implementing the contract yourself, e.g. SES)
|
|
38
148
|
|
|
39
|
-
The reference
|
|
40
|
-
(`packages/plugin-resend/src/provider.ts`)
|
|
149
|
+
The shipped reference implementations to copy are `createResendProvider`
|
|
150
|
+
(`packages/plugin-resend/src/provider.ts`) and `createPostmarkProvider`
|
|
151
|
+
(`packages/plugin-postmark/src/index.ts`). Use `defineEmailProvider` so a typo
|
|
152
|
+
in `meta` or a missing method is caught at definition time. A custom one mirrors
|
|
153
|
+
them:
|
|
41
154
|
|
|
42
155
|
```ts
|
|
43
156
|
// src/lib/my-email-provider.ts — your content
|
|
44
|
-
import
|
|
45
|
-
|
|
157
|
+
import {
|
|
158
|
+
defineEmailProvider,
|
|
159
|
+
joinRecipients,
|
|
160
|
+
type EmailEvent,
|
|
161
|
+
type EmailProvider,
|
|
162
|
+
type SendEmailOptions,
|
|
163
|
+
type SendResult,
|
|
164
|
+
WebhookHandshakeSignal,
|
|
165
|
+
} from "@hogsend/core";
|
|
166
|
+
|
|
167
|
+
export function createMyEmailProvider(config: {
|
|
168
|
+
apiKey: string;
|
|
169
|
+
webhookSecret?: string;
|
|
170
|
+
}): EmailProvider {
|
|
171
|
+
return defineEmailProvider({
|
|
172
|
+
meta: { id: "myvendor", name: "MyVendor" }, // meta.id = registry key + :providerId
|
|
173
|
+
capabilities: {
|
|
174
|
+
nativeTracking: false, // disable your own tracking per-send → engine trusts it
|
|
175
|
+
scheduledSend: false, // honor SendEmailOptions.scheduledAt? else it's dropped + WARN
|
|
176
|
+
signedWebhooks: true, // false ⇒ you must fail-closed yourself
|
|
177
|
+
},
|
|
46
178
|
|
|
47
|
-
export function createMyEmailProvider(config: { apiKey: string; webhookSecret?: string }): EmailProvider {
|
|
48
|
-
return {
|
|
49
179
|
async send(options: SendEmailOptions): Promise<SendResult> {
|
|
50
|
-
// Call your vendor's SDK. The engine hands you HTML already
|
|
51
|
-
// link/open tracking (options.html) on the
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
180
|
+
// Call your vendor's SDK. The engine hands you HTML already rendered +
|
|
181
|
+
// rewritten for link/open tracking (options.html) — no React on the wire.
|
|
182
|
+
const id = await myVendor.send({
|
|
183
|
+
from: options.from,
|
|
184
|
+
to: options.to,
|
|
185
|
+
subject: options.subject,
|
|
186
|
+
html: options.html,
|
|
187
|
+
});
|
|
55
188
|
return { id };
|
|
56
189
|
},
|
|
190
|
+
|
|
57
191
|
async sendBatch(emails) {
|
|
58
|
-
const results = await Promise.all(emails.map((e) => this.send(e
|
|
192
|
+
const results = await Promise.all(emails.map((e) => this.send(e)));
|
|
59
193
|
return { results };
|
|
60
194
|
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
195
|
+
|
|
196
|
+
verifyWebhook({ payload, headers }): EmailEvent {
|
|
197
|
+
if (!config.webhookSecret) {
|
|
198
|
+
throw new Error("webhookSecret required to verify webhooks");
|
|
199
|
+
}
|
|
200
|
+
const raw = verify(payload, headers, config.webhookSecret);
|
|
201
|
+
// A non-delivery-status handshake (subscription confirmations, etc.)?
|
|
202
|
+
// Throw WebhookHandshakeSignal — the engine route 200s it without sniffing
|
|
203
|
+
// the body. Body-shape knowledge stays INSIDE the provider.
|
|
204
|
+
if (isHandshake(raw)) throw new WebhookHandshakeSignal("confirm-subscription");
|
|
205
|
+
// Otherwise NORMALIZE into the provider-neutral EmailEvent
|
|
206
|
+
// ({ type: "email.delivered" | "email.bounced" | ..., messageId, recipients, ... }).
|
|
207
|
+
return normalizeMyVendorEvent(raw);
|
|
66
208
|
},
|
|
67
|
-
|
|
209
|
+
|
|
210
|
+
parseWebhook(payload): EmailEvent {
|
|
68
211
|
return normalizeMyVendorEvent(JSON.parse(payload));
|
|
69
212
|
},
|
|
70
|
-
};
|
|
213
|
+
});
|
|
71
214
|
}
|
|
72
215
|
```
|
|
73
216
|
|
|
74
|
-
## Wire it
|
|
217
|
+
## Wire it / opt in
|
|
218
|
+
|
|
219
|
+
### Opt into Postmark (env, no code)
|
|
220
|
+
|
|
221
|
+
The simplest path — the engine builds the Postmark provider from env and
|
|
222
|
+
activates it. Setting `POSTMARK_SERVER_TOKEN` builds the preset but does **not**
|
|
223
|
+
change the active provider; you must also set `EMAIL_PROVIDER=postmark`.
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
EMAIL_PROVIDER=postmark
|
|
227
|
+
POSTMARK_SERVER_TOKEN=pm-server-xxxxxxxx # required — also gates the lazy import of the plugin
|
|
228
|
+
POSTMARK_MESSAGE_STREAM=outbound # optional
|
|
229
|
+
POSTMARK_WEBHOOK_USER=hook # Postmark has no HMAC — Basic-auth in the webhook URL
|
|
230
|
+
POSTMARK_WEBHOOK_PASS=super-secret # BOTH required together to enable verify
|
|
231
|
+
EMAIL_FROM=noreply@yourdomain.com # neutral from; else falls back to RESEND_FROM_EMAIL
|
|
232
|
+
# RESEND_API_KEY is now OPTIONAL — omit it entirely for a Postmark-only deploy
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
If `EMAIL_PROVIDER` names a provider that isn't registered, the container throws
|
|
236
|
+
at boot with the list of registered ids. If `POSTMARK_SERVER_TOKEN` is set but
|
|
237
|
+
`@hogsend/plugin-postmark` isn't installed, the preset is skipped — and if
|
|
238
|
+
Postmark was the active provider, boot fails with a "not registered" error
|
|
239
|
+
directing you to install it.
|
|
240
|
+
|
|
241
|
+
### Opt into a provider (code)
|
|
75
242
|
|
|
76
243
|
```ts
|
|
77
244
|
// src/index.ts — your content
|
|
78
245
|
import { createHogsendClient } from "@hogsend/engine";
|
|
246
|
+
import { createPostmarkProvider } from "@hogsend/plugin-postmark";
|
|
79
247
|
import { templates } from "./emails/registry.js";
|
|
80
|
-
import { createMyEmailProvider } from "./lib/my-email-provider.js";
|
|
81
248
|
|
|
82
249
|
const client = createHogsendClient({
|
|
83
250
|
journeys,
|
|
84
251
|
email: {
|
|
85
252
|
templates, // REQUIRED, nested under email
|
|
86
|
-
provider:
|
|
253
|
+
provider: createPostmarkProvider({ // merged LAST → wins on id collision
|
|
254
|
+
serverToken: process.env.POSTMARK_SERVER_TOKEN!,
|
|
255
|
+
webhookBasicAuth: { user: "hook", pass: process.env.POSTMARK_WEBHOOK_PASS! },
|
|
256
|
+
}),
|
|
257
|
+
defaultProvider: "postmark", // the active provider the mailer sends through
|
|
87
258
|
},
|
|
88
259
|
// analytics: createMyAnalytics(...), // top-level (engine uses it)
|
|
89
260
|
});
|
|
90
261
|
```
|
|
91
262
|
|
|
92
|
-
|
|
93
|
-
|
|
263
|
+
For a provider you implemented yourself, swap in `createMyEmailProvider({ ... })`
|
|
264
|
+
the same way.
|
|
265
|
+
|
|
266
|
+
### Register many providers
|
|
267
|
+
|
|
268
|
+
To register **more than one** provider (so `POST /v1/webhooks/email/:providerId`
|
|
269
|
+
can verify each) use `providers: [...]` and pick the active one with
|
|
270
|
+
`defaultProvider`:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { createResendProvider } from "@hogsend/plugin-resend";
|
|
274
|
+
import { createPostmarkProvider } from "@hogsend/plugin-postmark";
|
|
275
|
+
|
|
276
|
+
const client = createHogsendClient({
|
|
277
|
+
email: {
|
|
278
|
+
templates,
|
|
279
|
+
providers: [
|
|
280
|
+
createResendProvider({
|
|
281
|
+
apiKey: process.env.RESEND_API_KEY!,
|
|
282
|
+
webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
|
|
283
|
+
}),
|
|
284
|
+
createPostmarkProvider({
|
|
285
|
+
serverToken: process.env.POSTMARK_SERVER_TOKEN!,
|
|
286
|
+
webhookBasicAuth: { user: "hook", pass: process.env.POSTMARK_WEBHOOK_PASS! },
|
|
287
|
+
}),
|
|
288
|
+
],
|
|
289
|
+
defaultProvider: "postmark", // resolution: defaultProvider ?? EMAIL_PROVIDER ?? "resend"
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Registry merge order is last-writer-wins: env presets **first** →
|
|
295
|
+
`email.providers` → `email.provider` **last**. The container does a registry
|
|
296
|
+
lookup for the resolved active id and **throws at boot** if it isn't registered
|
|
297
|
+
(`email provider "<id>" is not registered (registered: <ids>)`) — it never
|
|
298
|
+
silently falls back for a non-`resend` id. If the active provider declares
|
|
299
|
+
`capabilities.nativeTracking === true` (Resend), the engine logs a boot WARN to
|
|
300
|
+
disable native tracking; Postmark (`nativeTracking: false`) gets no WARN.
|
|
301
|
+
|
|
302
|
+
Pass nothing under `email` and the engine builds the **default Resend provider**
|
|
303
|
+
from `RESEND_API_KEY` / `RESEND_WEBHOOK_SECRET` (active id `"resend"`).
|
|
94
304
|
|
|
95
305
|
## What comes along for free
|
|
96
306
|
|
|
97
307
|
Everything except the wire. The engine's `createTrackedMailer` runs, in order:
|
|
98
|
-
check preferences/suppression → frequency cap → resolve + render the template
|
|
99
|
-
write the `email_sends` row (
|
|
100
|
-
pixel → **then** `provider.send(...)` → update status. So
|
|
101
|
-
keeps **all** of tracking, rendering, preferences, and the
|
|
102
|
-
pipeline.
|
|
308
|
+
check preferences/suppression → frequency cap → resolve + render the template
|
|
309
|
+
(React → HTML) → write the `email_sends` row (column `message_id`) → rewrite
|
|
310
|
+
links + inject the open pixel → **then** `provider.send(...)` → update status. So
|
|
311
|
+
a swapped provider keeps **all** of tracking, rendering, preferences, and the
|
|
312
|
+
`email_sends` pipeline.
|
|
313
|
+
|
|
314
|
+
Reading the result of a send: use `result.messageId` (`TrackedSendResult.messageId`,
|
|
315
|
+
the provider-neutral id — Resend `email_id` / Postmark `MessageID`). The old
|
|
316
|
+
`result.resendId` still works but is `@deprecated` — it mirrors `messageId` for
|
|
317
|
+
one minor, then is removed. The persisted DB column is `message_id` (there is no
|
|
318
|
+
`resend_id` column).
|
|
103
319
|
|
|
104
320
|
## Inbound webhooks
|
|
105
321
|
|
|
106
|
-
The engine owns
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
322
|
+
The engine owns the inbound email-webhook route `POST /v1/webhooks/email/:providerId`
|
|
323
|
+
(`:providerId` = your `meta.id`; `POST /v1/webhooks/resend` is a deprecated static
|
|
324
|
+
alias for `…/email/resend`). It reads the raw body + headers, resolves the
|
|
325
|
+
matching provider from the registry, and calls that provider's `verifyWebhook`,
|
|
326
|
+
then maps the normalized `EmailEvent` to `email_sends` status updates (keyed by
|
|
327
|
+
`event.messageId`) and bounce/complaint → suppression. Your provider only has to
|
|
328
|
+
verify + normalize; the DB effects are engine-owned.
|
|
111
329
|
|
|
112
330
|
## Analytics (`PostHogService`)
|
|
113
331
|
|
|
@@ -133,7 +351,11 @@ provider must still satisfy:
|
|
|
133
351
|
|
|
134
352
|
## Don't over-reach
|
|
135
353
|
|
|
136
|
-
You are implementing a contract, not building a framework.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
354
|
+
You are implementing a contract, not building a framework. Two shipped reference
|
|
355
|
+
implementations exist — `@hogsend/plugin-resend` (the default) and
|
|
356
|
+
`@hogsend/plugin-postmark` — so before reimplementing, check whether a plugin
|
|
357
|
+
already covers your vendor and just install + opt in. For a vendor with **no**
|
|
358
|
+
plugin yet (e.g. SES) you implement `EmailProvider` (or `PostHogService`) with
|
|
359
|
+
`defineEmailProvider` and register it — there's an `EmailProviderRegistry` keyed
|
|
360
|
+
by `meta.id`, but no marketplace and no provider discovery. That's the whole
|
|
361
|
+
story.
|