@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/cli",
3
- "version": "0.10.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.10.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
- "picocolors": "^1.1.1"
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), then prepareTrackedHtml(html, emailSendId, baseUrl, db):
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 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)
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.1.0"
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/resend`.
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
- send(options: SendEmailOptions): Promise<SendResult>; // { id }
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
- // Verify a provider webhook signature and return the parsed event.
20
- // Throws if the signature is missing/invalid.
21
- verifyWebhook(opts: { payload: string; headers: Record<string, string> }): WebhookEvent;
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): WebhookEvent;
54
+ parseWebhook(payload: string): EmailEvent;
24
55
  }
25
56
  ```
26
57
 
27
- `SendEmailOptions`, `BatchEmailItem`, `SendResult`, and `WebhookEvent` are the
28
- contract's supporting types. **Import the contract types from `@hogsend/engine`**,
29
- **except `SendEmailOptions`**, which collides with the engine's higher-level send
30
- type import that one from `@hogsend/core`:
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 type { EmailProvider, SendResult, WebhookEvent } from "@hogsend/engine";
34
- import type { SendEmailOptions } from "@hogsend/core";
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 implementation to copy is `createResendProvider`
40
- (`packages/plugin-resend/src/provider.ts`). A custom one mirrors it:
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 type { SendEmailOptions } from "@hogsend/core";
45
- import type { EmailProvider, SendResult, WebhookEvent } from "@hogsend/engine";
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 rewritten for
51
- // link/open tracking (options.html) on the tracked path; render
52
- // options.react yourself (renderToHtml/renderToPlainText from
53
- // @hogsend/email) only if your vendor can't take React.
54
- const id = await myVendor.send({ from: options.from, to: options.to, subject: options.subject, html: options.html });
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 as never)));
192
+ const results = await Promise.all(emails.map((e) => this.send(e)));
59
193
  return { results };
60
194
  },
61
- verifyWebhook({ payload, headers }): WebhookEvent {
62
- if (!config.webhookSecret) throw new Error("webhookSecret required to verify webhooks");
63
- // Verify with your vendor's scheme, then NORMALIZE into the engine's
64
- // WebhookEvent shape ({ type: "email.delivered" | "email.bounced" | ... }).
65
- return normalizeMyVendorEvent(verify(payload, headers, config.webhookSecret));
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
- parseWebhook(payload): WebhookEvent {
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: createMyEmailProvider({ apiKey: process.env.MY_API_KEY! }),
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
- Pass nothing under `email.provider` and the engine builds the **default Resend
93
- provider** from `RESEND_API_KEY` / `RESEND_WEBHOOK_SECRET`.
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 (status `queued`) → rewrite links + inject the open
100
- pixel → **then** `provider.send(...)` → update status. So a swapped provider
101
- keeps **all** of tracking, rendering, preferences, and the `email_sends`
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 one inbound email-webhook route: `POST /v1/webhooks/resend`. It
107
- reads the raw body + headers and calls your provider's `verifyWebhook`, then maps
108
- the normalized `WebhookEvent` to `email_sends` status updates and
109
- bounce/complaint suppression. Your provider only has to verify + normalize; the
110
- DB effects are engine-owned.
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. There is no provider
137
- registry, no marketplace, and no `@hogsend/provider-*` packages to support a
138
- new vendor you implement `EmailProvider` (or `PostHogService`) and pass it in.
139
- That's the whole story.
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.