@hogsend/core 0.9.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/core",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,11 +32,10 @@
32
32
  "drizzle-orm": "^0.45.2",
33
33
  "iana-db-timezones": "^0.3.0",
34
34
  "zod": "^4.4.3",
35
- "@hogsend/db": "^0.9.0"
35
+ "@hogsend/db": "^0.11.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "latest",
39
- "@types/react": "^19.2.16",
40
39
  "vitest": "^4.1.8",
41
40
  "@repo/typescript-config": "0.0.0"
42
41
  },
@@ -1,41 +1,141 @@
1
- import type { ReactElement } from "react";
2
-
3
1
  // ---------------------------------------------------------------------------
4
- // Send options
2
+ // Send options (HTML-only wire — NO React)
5
3
  // ---------------------------------------------------------------------------
6
4
 
5
+ /**
6
+ * The provider send wire. HTML-ONLY: the engine ALWAYS renders React → HTML
7
+ * itself (via `@hogsend/email` `renderToHtml`) before calling `send`, so no
8
+ * React ever crosses the provider boundary. React Email stays first-class for
9
+ * template authoring + Studio preview — only this wire is HTML.
10
+ */
7
11
  export interface SendEmailOptions {
8
12
  from: string;
9
13
  to: string | string[];
10
14
  subject: string;
11
- react?: ReactElement;
12
- html?: string;
15
+ /** REQUIRED — the engine always renders React → HTML before the wire. */
16
+ html: string;
17
+ /** Optional plain-text alternative. */
18
+ text?: string;
13
19
  replyTo?: string | string[];
14
20
  cc?: string | string[];
15
21
  bcc?: string | string[];
16
- scheduledAt?: string;
22
+ /**
23
+ * Neutral `{name,value}[]` tags. Each provider maps them natively: Resend
24
+ * passes them straight through; Postmark takes the first tag's value as `Tag`
25
+ * and all of them as `Metadata`; SES emits identical `MessageTag[]`.
26
+ */
17
27
  tags?: Array<{ name: string; value: string }>;
18
28
  headers?: Record<string, string>;
29
+ /** Honored only when `capabilities.scheduledSend`; else logged + ignored. */
30
+ scheduledAt?: string;
19
31
  }
20
32
 
21
- export interface BatchEmailItem {
22
- from: string;
23
- to: string | string[];
24
- subject: string;
25
- react: ReactElement;
26
- replyTo?: string | string[];
27
- cc?: string | string[];
28
- bcc?: string | string[];
29
- tags?: Array<{ name: string; value: string }>;
30
- headers?: Record<string, string>;
31
- }
33
+ /** A single batch item — the send wire minus the per-message `scheduledAt`. */
34
+ export type BatchEmailItem = Omit<SendEmailOptions, "scheduledAt">;
32
35
 
33
36
  export interface SendResult {
34
37
  id: string;
35
38
  }
36
39
 
37
40
  // ---------------------------------------------------------------------------
38
- // Webhook events
41
+ // Recipient normalization (shared by every provider's send wire)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Normalize a `string | string[] | undefined` recipient field to a string[]. */
45
+ export function normalizeRecipients(v?: string | string[]): string[] {
46
+ if (v === undefined) return [];
47
+ return Array.isArray(v) ? v : [v];
48
+ }
49
+
50
+ /** Join a recipient field into a single comma-separated string (Postmark wire). */
51
+ export function joinRecipients(v?: string | string[]): string {
52
+ return normalizeRecipients(v).join(",");
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Provider-neutral email events (the normalized webhook shape)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Provider-neutral email event types. The `email.` prefix is intentional — it
61
+ * keeps the `WebhookHandlerMap` keys, `WEBHOOK_TO_STATUS`,
62
+ * `WEBHOOK_TO_STATUS_FIELD`, and the outbound catalog all UNCHANGED.
63
+ */
64
+ export type EmailEventType =
65
+ | "email.sent"
66
+ | "email.delivered"
67
+ | "email.bounced"
68
+ | "email.complained"
69
+ | "email.delivery_delayed"
70
+ | "email.opened"
71
+ | "email.clicked";
72
+
73
+ /**
74
+ * Provider-neutral bounce classification. Drives suppression: `permanent`
75
+ * auto-suppresses (the engine increments `bounceCount`), `complaint` suppresses
76
+ * immediately, `transient` is recorded but never suppresses, and `unknown` is
77
+ * the conservative default (recorded, never suppresses). Each provider's
78
+ * classifier returns this from its own wire-specific bounce shape.
79
+ */
80
+ export type BounceClass = "permanent" | "transient" | "complaint" | "unknown";
81
+
82
+ /**
83
+ * The provider-neutral email event every provider's `verifyWebhook`/
84
+ * `parseWebhook` normalizes its verbatim webhook into. This is the ONE shape the
85
+ * engine's `dispatchWebhook` reads — Resend, Postmark, and SES all adapt their
86
+ * wire payloads into this. The untouched provider payload is preserved in `raw`
87
+ * as a handler escape hatch (cast to {@link LegacyResendWebhookEvent} for the
88
+ * old Resend shape during the deprecation window).
89
+ */
90
+ export interface EmailEvent {
91
+ type: EmailEventType;
92
+ /** Resend `email_id` | Postmark `MessageID` | SES `mail.messageId`. */
93
+ messageId: string;
94
+ /** ALL recipients (SES bounce/complaint carry many). */
95
+ recipients: string[];
96
+ /** ISO 8601 timestamp of the provider event. */
97
+ occurredAt: string;
98
+ /** Present on `email.bounced` / `email.complained`. Drives suppression. */
99
+ bounce?: {
100
+ class: BounceClass;
101
+ code: string;
102
+ reason?: string;
103
+ };
104
+ /** Present on `email.clicked` (native-tracking echo only; first-party owns clicks). */
105
+ click?: { url: string; at?: string; ip?: string; ua?: string };
106
+ /** The untouched provider payload, for handler escape-hatch + debugging. */
107
+ raw: unknown;
108
+ }
109
+
110
+ /**
111
+ * Per-event handler map. Keys are UNCHANGED `email.*` event types; each handler
112
+ * now receives the provider-neutral {@link EmailEvent}. Handler bodies that read
113
+ * the old Resend shape (`event.data.email_id`, `event.data.bounce`) must switch
114
+ * to `event.messageId` / `event.bounce` OR cast
115
+ * `event.raw as LegacyResendWebhookEvent` during the deprecation window.
116
+ */
117
+ export type WebhookHandlerMap = {
118
+ [K in EmailEventType]?: (
119
+ event: Extract<EmailEvent, { type: K }>,
120
+ ) => void | Promise<void>;
121
+ };
122
+
123
+ /**
124
+ * Thrown by `verifyWebhook` when the request was a non-delivery-status handshake
125
+ * (e.g. SNS SubscriptionConfirmation, Postmark SubscriptionChange) that the
126
+ * provider already handled. The webhook route catches it and returns 200.
127
+ * Provider-specific body-shape knowledge stays entirely inside the provider —
128
+ * the engine route NEVER sniffs the body.
129
+ */
130
+ export class WebhookHandshakeSignal extends Error {
131
+ constructor(readonly action: string) {
132
+ super(action);
133
+ this.name = "WebhookHandshakeSignal";
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Legacy Resend-shaped webhook union (frozen escape hatch, one minor)
39
139
  // ---------------------------------------------------------------------------
40
140
 
41
141
  interface WebhookEventBase {
@@ -49,14 +149,17 @@ interface WebhookEventBase {
49
149
  };
50
150
  }
51
151
 
152
+ /** @deprecated Use {@link EmailEvent}. Frozen for one minor as a `raw` cast target. */
52
153
  export interface EmailSentEvent extends WebhookEventBase {
53
154
  type: "email.sent";
54
155
  }
55
156
 
157
+ /** @deprecated Use {@link EmailEvent}. Frozen for one minor as a `raw` cast target. */
56
158
  export interface EmailDeliveredEvent extends WebhookEventBase {
57
159
  type: "email.delivered";
58
160
  }
59
161
 
162
+ /** @deprecated Use {@link EmailEvent}. Frozen for one minor as a `raw` cast target. */
60
163
  export interface EmailBouncedEvent extends WebhookEventBase {
61
164
  type: "email.bounced";
62
165
  data: WebhookEventBase["data"] & {
@@ -67,18 +170,22 @@ export interface EmailBouncedEvent extends WebhookEventBase {
67
170
  };
68
171
  }
69
172
 
173
+ /** @deprecated Use {@link EmailEvent}. Frozen for one minor as a `raw` cast target. */
70
174
  export interface EmailComplainedEvent extends WebhookEventBase {
71
175
  type: "email.complained";
72
176
  }
73
177
 
178
+ /** @deprecated Use {@link EmailEvent}. Frozen for one minor as a `raw` cast target. */
74
179
  export interface EmailDeliveryDelayedEvent extends WebhookEventBase {
75
180
  type: "email.delivery_delayed";
76
181
  }
77
182
 
183
+ /** @deprecated Use {@link EmailEvent}. Frozen for one minor as a `raw` cast target. */
78
184
  export interface EmailOpenedEvent extends WebhookEventBase {
79
185
  type: "email.opened";
80
186
  }
81
187
 
188
+ /** @deprecated Use {@link EmailEvent}. Frozen for one minor as a `raw` cast target. */
82
189
  export interface EmailClickedEvent extends WebhookEventBase {
83
190
  type: "email.clicked";
84
191
  data: WebhookEventBase["data"] & {
@@ -91,6 +198,14 @@ export interface EmailClickedEvent extends WebhookEventBase {
91
198
  };
92
199
  }
93
200
 
201
+ /**
202
+ * @deprecated The Resend-shaped webhook union, frozen for one minor. It no
203
+ * longer flows through `verifyWebhook`/`parseWebhook` — those now return the
204
+ * provider-neutral {@link EmailEvent}. Cast `event.raw as WebhookEvent` (alias
205
+ * {@link LegacyResendWebhookEvent}) inside a `webhookHandler` to keep reading the
206
+ * old nested shape while you migrate to {@link EmailEvent} fields. Removed the
207
+ * following minor.
208
+ */
94
209
  export type WebhookEvent =
95
210
  | EmailSentEvent
96
211
  | EmailDeliveredEvent
@@ -100,13 +215,58 @@ export type WebhookEvent =
100
215
  | EmailOpenedEvent
101
216
  | EmailClickedEvent;
102
217
 
218
+ /**
219
+ * @deprecated The Resend-shaped webhook union, frozen for one minor. Cast
220
+ * `event.raw as LegacyResendWebhookEvent` inside a `webhookHandler` to keep
221
+ * reading the old nested shape while you migrate to {@link EmailEvent} fields.
222
+ * Removed the following minor.
223
+ */
224
+ export type LegacyResendWebhookEvent = WebhookEvent;
225
+
226
+ /**
227
+ * @deprecated Use {@link EmailEventType}. Kept for one minor as the type of the
228
+ * legacy union's `type` discriminant.
229
+ */
103
230
  export type WebhookEventType = WebhookEvent["type"];
104
231
 
105
- export type WebhookHandlerMap = {
106
- [K in WebhookEventType]?: (
107
- event: Extract<WebhookEvent, { type: K }>,
108
- ) => void | Promise<void>;
109
- };
232
+ // ---------------------------------------------------------------------------
233
+ // Provider identity & capabilities
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Provider identity. `id` is the key the {@link EmailProviderRegistry} indexes
238
+ * by and the `:providerId` the `POST /v1/webhooks/email/:providerId` route
239
+ * dispatches on. `name` is the human label; `description` is optional prose.
240
+ */
241
+ export interface EmailProviderMeta {
242
+ id: string;
243
+ name: string;
244
+ description?: string;
245
+ }
246
+
247
+ /**
248
+ * What the provider's wire can and can't do. Drives engine-side enforcement
249
+ * decisions (the tracking-sovereignty boot WARN, the `scheduledAt` capability
250
+ * gate). All flags are optional — an absent flag is treated conservatively.
251
+ */
252
+ export interface EmailProviderCapabilities {
253
+ /**
254
+ * Whether the provider's OWN open/click tracking is active and the engine
255
+ * cannot force it off per-send. `false` = the provider disables it per-send
256
+ * (Postmark TrackOpens:false/TrackLinks:'None'; SES omit from config-set) and
257
+ * the engine TRUSTS that. `true` = an account-level toggle the engine can't
258
+ * reach (Resend) → the engine logs a boot WARN. First-party open/click
259
+ * tracking is always the single source of truth.
260
+ */
261
+ nativeTracking?: boolean;
262
+ /** Honors `SendEmailOptions.scheduledAt` (Resend yes; Postmark/SES no). */
263
+ scheduledSend?: boolean;
264
+ /**
265
+ * Has a crypto signature scheme (Resend svix; SES SNS cert). `false` = the
266
+ * provider must fail-closed on its own (Postmark basic-auth).
267
+ */
268
+ signedWebhooks?: boolean;
269
+ }
110
270
 
111
271
  // ---------------------------------------------------------------------------
112
272
  // EmailProvider contract (the entire provider surface)
@@ -118,6 +278,21 @@ export type WebhookHandlerMap = {
118
278
  * render logic lives in the engine's `createTrackedMailer`, never here.
119
279
  */
120
280
  export interface EmailProvider {
281
+ /**
282
+ * Provider identity. `meta.id` is the key the {@link EmailProviderRegistry}
283
+ * indexes by and the `:providerId` the webhook route dispatches on. Optional
284
+ * for back-compat with providers built before the registry; the registry
285
+ * falls back to `"resend"` when absent. Becomes required in a later
286
+ * (breaking) phase — new providers should always supply it.
287
+ */
288
+ readonly meta?: EmailProviderMeta;
289
+ /**
290
+ * Optional declaration of what the provider's wire supports. Read by the
291
+ * engine for the native-tracking boot WARN and the `scheduledAt` gate. Absent
292
+ * is treated conservatively (no native tracking assumed, no scheduled send).
293
+ */
294
+ readonly capabilities?: EmailProviderCapabilities;
295
+
121
296
  /** Deliver a single message. Returns the provider message id. */
122
297
  send(options: SendEmailOptions): Promise<SendResult>;
123
298
 
@@ -125,14 +300,26 @@ export interface EmailProvider {
125
300
  sendBatch(emails: BatchEmailItem[]): Promise<{ results: SendResult[] }>;
126
301
 
127
302
  /**
128
- * Verify a provider webhook signature and return the parsed event. Throws
129
- * if the signature is missing/invalid.
303
+ * Verify the provider's webhook (owns its OWN secrets, constructed-in) and
304
+ * return a normalized {@link EmailEvent}. Throws on a bad signature. Throws
305
+ * {@link WebhookHandshakeSignal} for non-status handshakes (the route 200s
306
+ * those). MAY be async (SES must GET the SNS SubscribeURL).
130
307
  */
131
308
  verifyWebhook(opts: {
132
309
  payload: string;
133
310
  headers: Record<string, string>;
134
- }): WebhookEvent;
311
+ }): Promise<EmailEvent> | EmailEvent;
312
+
313
+ /** Parse an unsigned webhook payload (trusted contexts/tests). */
314
+ parseWebhook(payload: string): EmailEvent;
315
+ }
135
316
 
136
- /** Parse an unsigned webhook payload (used in trusted contexts/tests). */
137
- parseWebhook(payload: string): WebhookEvent;
317
+ /**
318
+ * Identity factory for an {@link EmailProvider}. Mirrors `defineWebhookSource` /
319
+ * `defineDestination` — it returns its argument unchanged but pins the literal
320
+ * shape to the {@link EmailProvider} contract, so a typo in `meta` or a missing
321
+ * method is caught at definition time rather than at the call site.
322
+ */
323
+ export function defineEmailProvider(provider: EmailProvider): EmailProvider {
324
+ return provider;
138
325
  }