@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 +2 -3
- package/src/providers/email.ts +215 -28
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/core",
|
|
3
|
-
"version": "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.
|
|
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
|
},
|
package/src/providers/email.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
html
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
129
|
-
*
|
|
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
|
-
}):
|
|
311
|
+
}): Promise<EmailEvent> | EmailEvent;
|
|
312
|
+
|
|
313
|
+
/** Parse an unsigned webhook payload (trusted contexts/tests). */
|
|
314
|
+
parseWebhook(payload: string): EmailEvent;
|
|
315
|
+
}
|
|
135
316
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
}
|