@hogsend/engine 0.9.0 → 0.10.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 +9 -6
- package/src/container.ts +76 -21
- package/src/env.ts +23 -1
- package/src/index.ts +21 -6
- package/src/lib/email-provider-registry.ts +45 -0
- package/src/lib/email-providers-from-env.ts +94 -0
- package/src/lib/email-service-types.ts +40 -4
- package/src/lib/headers.ts +13 -0
- package/src/lib/mailer.ts +120 -70
- package/src/lib/outbound.ts +11 -2
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +26 -11
- package/src/routes/admin/emails.ts +5 -1
- package/src/routes/tracking/click.ts +1 -1
- package/src/routes/tracking/open.ts +1 -1
- package/src/routes/webhooks/email-provider.ts +124 -0
- package/src/routes/webhooks/index.ts +7 -0
- package/src/routes/webhooks/resend.ts +14 -29
- package/src/routes/webhooks/sources.ts +15 -4
- package/src/workflows/send-email.ts +2 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -38,11 +38,14 @@
|
|
|
38
38
|
"svix": "^1.95.1",
|
|
39
39
|
"winston": "^3.19.0",
|
|
40
40
|
"zod": "^4.4.3",
|
|
41
|
-
"@hogsend/core": "^0.
|
|
42
|
-
"@hogsend/db": "^0.
|
|
43
|
-
"@hogsend/email": "^0.
|
|
44
|
-
"@hogsend/plugin-posthog": "^0.
|
|
45
|
-
"@hogsend/plugin-resend": "^0.
|
|
41
|
+
"@hogsend/core": "^0.10.0",
|
|
42
|
+
"@hogsend/db": "^0.10.0",
|
|
43
|
+
"@hogsend/email": "^0.10.0",
|
|
44
|
+
"@hogsend/plugin-posthog": "^0.10.0",
|
|
45
|
+
"@hogsend/plugin-resend": "^0.10.0"
|
|
46
|
+
},
|
|
47
|
+
"optionalDependencies": {
|
|
48
|
+
"@hogsend/plugin-postmark": "^0.10.0"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
51
|
"@types/node": "^22.15.3",
|
package/src/container.ts
CHANGED
|
@@ -9,11 +9,6 @@ import {
|
|
|
9
9
|
type JournalShape,
|
|
10
10
|
} from "@hogsend/db";
|
|
11
11
|
import type { TemplateRegistry } from "@hogsend/email";
|
|
12
|
-
import {
|
|
13
|
-
createResendClient,
|
|
14
|
-
createResendProvider,
|
|
15
|
-
} from "@hogsend/plugin-resend";
|
|
16
|
-
import type { Resend } from "resend";
|
|
17
12
|
import { createBucketAccessor } from "./buckets/bucket-access.js";
|
|
18
13
|
import type { DefinedBucket } from "./buckets/define-bucket.js";
|
|
19
14
|
import {
|
|
@@ -33,6 +28,8 @@ import { buildJourneyRegistry } from "./journeys/registry.js";
|
|
|
33
28
|
import { setAnalytics } from "./lib/analytics-singleton.js";
|
|
34
29
|
import { type Auth, createAuth } from "./lib/auth.js";
|
|
35
30
|
import { setEmailService } from "./lib/email.js";
|
|
31
|
+
import { EmailProviderRegistry } from "./lib/email-provider-registry.js";
|
|
32
|
+
import { emailProvidersFromEnv } from "./lib/email-providers-from-env.js";
|
|
36
33
|
import type {
|
|
37
34
|
EmailService,
|
|
38
35
|
FrequencyCapConfig,
|
|
@@ -61,8 +58,19 @@ export interface HogsendClient {
|
|
|
61
58
|
db: Database;
|
|
62
59
|
dbClient: DatabaseClient;
|
|
63
60
|
auth: Auth;
|
|
64
|
-
email: Resend;
|
|
65
61
|
emailService: EmailService;
|
|
62
|
+
/**
|
|
63
|
+
* The container-held registry of email providers, keyed by `meta.id`. The
|
|
64
|
+
* `POST /v1/webhooks/email/:providerId` route resolves the verifying provider
|
|
65
|
+
* out of this. Holds at least the resolved active provider.
|
|
66
|
+
*/
|
|
67
|
+
emailProviders: EmailProviderRegistry;
|
|
68
|
+
/**
|
|
69
|
+
* The single resolved active email provider (the one the mailer sends
|
|
70
|
+
* through). Resolved from `opts.email.defaultProvider` / `EMAIL_PROVIDER`,
|
|
71
|
+
* defaulting to the env-built Resend provider for byte-for-byte parity.
|
|
72
|
+
*/
|
|
73
|
+
emailProvider: EmailProvider;
|
|
66
74
|
/**
|
|
67
75
|
* The app's template registry (key → component + subject + category +
|
|
68
76
|
* optional preview/examples). Same object threaded into the engine mailer;
|
|
@@ -121,10 +129,18 @@ export interface HogsendClientOptions {
|
|
|
121
129
|
* (templates → render → preference checks → tracking → `email_sends` write),
|
|
122
130
|
* and the {@link EmailProvider} is only the swappable wire under it.
|
|
123
131
|
*
|
|
124
|
-
* - `provider` —
|
|
125
|
-
*
|
|
126
|
-
* `
|
|
127
|
-
* free regardless of which provider you supply.
|
|
132
|
+
* - `provider` — a single swappable email provider (Resend, Postmark, SES…),
|
|
133
|
+
* the back-compat one-provider seam. MERGED LAST (after env presets and
|
|
134
|
+
* `providers`), so it wins on id collision. Tracking/rendering/preferences
|
|
135
|
+
* come along for free regardless of which provider you supply.
|
|
136
|
+
* - `providers` — register MANY providers into the {@link EmailProviderRegistry}
|
|
137
|
+
* (e.g. Resend + Postmark) so the `POST /v1/webhooks/email/:providerId`
|
|
138
|
+
* route can verify each one's webhooks. Merged AFTER the env presets and
|
|
139
|
+
* BEFORE `provider`.
|
|
140
|
+
* - `defaultProvider` — the active provider id the mailer sends through.
|
|
141
|
+
* Resolves as `defaultProvider ?? EMAIL_PROVIDER ?? "resend"`. If it names a
|
|
142
|
+
* provider that isn't registered, the container throws at boot with the list
|
|
143
|
+
* of registered ids.
|
|
128
144
|
* - `templates` — the app's template registry (key → component + subject +
|
|
129
145
|
* category), threaded into the engine mailer and onward to
|
|
130
146
|
* `getTemplate(..., { registry })`. The engine bakes in no business
|
|
@@ -136,6 +152,8 @@ export interface HogsendClientOptions {
|
|
|
136
152
|
*/
|
|
137
153
|
email?: {
|
|
138
154
|
provider?: EmailProvider;
|
|
155
|
+
providers?: EmailProvider[];
|
|
156
|
+
defaultProvider?: string;
|
|
139
157
|
templates?: TemplateRegistry;
|
|
140
158
|
};
|
|
141
159
|
/**
|
|
@@ -249,8 +267,6 @@ export function createHogsendClient(
|
|
|
249
267
|
),
|
|
250
268
|
});
|
|
251
269
|
|
|
252
|
-
const email = createResendClient({ apiKey: env.RESEND_API_KEY });
|
|
253
|
-
|
|
254
270
|
const registry = buildJourneyRegistry(
|
|
255
271
|
opts.journeys ?? [],
|
|
256
272
|
opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
|
|
@@ -301,12 +317,51 @@ export function createHogsendClient(
|
|
|
301
317
|
opts.enabledLists ?? env.ENABLED_LISTS,
|
|
302
318
|
);
|
|
303
319
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
320
|
+
// Build the email provider registry, then resolve the single active provider
|
|
321
|
+
// the mailer sends through. Merge order is load-bearing (consumer last/wins,
|
|
322
|
+
// mirroring the destinations merge): env presets FIRST, then
|
|
323
|
+
// `opts.email.providers`, then the single back-compat `opts.email.provider`
|
|
324
|
+
// LAST — so a consumer-supplied provider overrides an env preset of the same
|
|
325
|
+
// id (last-writer-wins on the registry). The registry is what the
|
|
326
|
+
// `POST /v1/webhooks/email/:providerId` route dispatches by id.
|
|
327
|
+
const emailProviders = new EmailProviderRegistry([
|
|
328
|
+
...emailProvidersFromEnv(env),
|
|
329
|
+
...(opts.email?.providers ?? []),
|
|
330
|
+
...(opts.email?.provider ? [opts.email.provider] : []),
|
|
331
|
+
]);
|
|
332
|
+
|
|
333
|
+
// The active provider id the mailer sends through:
|
|
334
|
+
// `defaultProvider ?? EMAIL_PROVIDER ?? "resend"`. The default Resend provider
|
|
335
|
+
// is built (when RESEND_API_KEY is set) by `emailProvidersFromEnv` above — the
|
|
336
|
+
// SINGLE place Resend is constructed from env — so resolution is just a
|
|
337
|
+
// registry lookup that throws if the active id resolves to nothing. NEVER
|
|
338
|
+
// silently fall back for a non-resend id.
|
|
339
|
+
const activeId =
|
|
340
|
+
opts.email?.defaultProvider ?? env.EMAIL_PROVIDER ?? "resend";
|
|
341
|
+
const provider = emailProviders.get(activeId);
|
|
342
|
+
|
|
343
|
+
if (!provider) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`email provider "${activeId}" is not registered (registered: ${emailProviders
|
|
346
|
+
.getAll()
|
|
347
|
+
.map((p) => p.meta?.id ?? "resend")
|
|
348
|
+
.join(", ")})`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Tracking sovereignty: first-party open/click tracking is the single source
|
|
353
|
+
// of truth. A provider that can't force its OWN tracking off per-send (an
|
|
354
|
+
// account-level toggle — e.g. Resend) declares `nativeTracking: true`. We
|
|
355
|
+
// can't reach that toggle, so we WARN at boot. The outbound-echo suppression
|
|
356
|
+
// in `dispatchWebhook` is the defence: a native open/click webhook only
|
|
357
|
+
// touches DB status, never re-emits outbound.
|
|
358
|
+
if (provider.capabilities?.nativeTracking === true) {
|
|
359
|
+
logger.warn(
|
|
360
|
+
`provider ${
|
|
361
|
+
provider.meta?.id ?? "resend"
|
|
362
|
+
} reports account-level native tracking ON; disable it in the dashboard — first-party tracking is Hogsend's source of truth.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
310
365
|
|
|
311
366
|
const defaults: HogsendDefaults = {
|
|
312
367
|
timezone: opts.defaults?.timezone ?? "UTC",
|
|
@@ -327,10 +382,9 @@ export function createHogsendClient(
|
|
|
327
382
|
opts.overrides?.mailer ??
|
|
328
383
|
createTrackedMailer(
|
|
329
384
|
{
|
|
330
|
-
defaultFrom: env.RESEND_FROM_EMAIL,
|
|
385
|
+
defaultFrom: env.EMAIL_FROM ?? env.RESEND_FROM_EMAIL,
|
|
331
386
|
templates,
|
|
332
387
|
db,
|
|
333
|
-
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
|
334
388
|
bounceThreshold: 3,
|
|
335
389
|
baseUrl: env.API_PUBLIC_URL,
|
|
336
390
|
frequencyCap: defaults.frequencyCap,
|
|
@@ -404,8 +458,9 @@ export function createHogsendClient(
|
|
|
404
458
|
db,
|
|
405
459
|
dbClient: created.client,
|
|
406
460
|
auth,
|
|
407
|
-
email,
|
|
408
461
|
emailService,
|
|
462
|
+
emailProviders,
|
|
463
|
+
emailProvider: provider,
|
|
409
464
|
templates,
|
|
410
465
|
analytics,
|
|
411
466
|
registry,
|
package/src/env.ts
CHANGED
|
@@ -27,8 +27,30 @@ export const env = createEnv({
|
|
|
27
27
|
// comma-separated. Needed when the Studio is served from a different origin
|
|
28
28
|
// than the API — e.g. the `hogsend studio` CLI pointing at a remote instance.
|
|
29
29
|
BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
|
|
30
|
-
|
|
30
|
+
// Optional: a deploy may run a non-Resend provider (Postmark, SES…) and set
|
|
31
|
+
// no Resend key at all. Read directly ONLY in the lazy-resend default branch
|
|
32
|
+
// (container.ts) and the future `emailProvidersFromEnv` preset. With this
|
|
33
|
+
// optional, a Postmark-only deploy boots without a Resend key.
|
|
34
|
+
RESEND_API_KEY: z.string().min(1).optional(),
|
|
31
35
|
RESEND_FROM_EMAIL: z.string().email().default("noreply@hogsend.com"),
|
|
36
|
+
// --- Provider-neutral email config (BYO email provider) ---
|
|
37
|
+
// The active email provider id the container resolves from the
|
|
38
|
+
// EmailProviderRegistry. Absent → "resend" (today's byte-for-byte default).
|
|
39
|
+
EMAIL_PROVIDER: z.string().optional(),
|
|
40
|
+
// Neutral default-from address. The mailer's `defaultFrom` is
|
|
41
|
+
// `EMAIL_FROM ?? RESEND_FROM_EMAIL`, so an unset EMAIL_FROM keeps today's
|
|
42
|
+
// Resend-named default.
|
|
43
|
+
EMAIL_FROM: z.string().email().optional(),
|
|
44
|
+
// --- Postmark (opt-in BYO provider) ---
|
|
45
|
+
// Postmark stays OPT-IN: a preset is built only when POSTMARK_SERVER_TOKEN
|
|
46
|
+
// is present, and it NEVER changes the default active provider — set
|
|
47
|
+
// EMAIL_PROVIDER=postmark to activate it. Postmark has no HMAC, so webhook
|
|
48
|
+
// authenticity is HTTP Basic creds in the webhook URL — fail-closed when
|
|
49
|
+
// unset (status updates rejected).
|
|
50
|
+
POSTMARK_SERVER_TOKEN: z.string().min(1).optional(),
|
|
51
|
+
POSTMARK_MESSAGE_STREAM: z.string().min(1).optional(),
|
|
52
|
+
POSTMARK_WEBHOOK_USER: z.string().min(1).optional(),
|
|
53
|
+
POSTMARK_WEBHOOK_PASS: z.string().min(1).optional(),
|
|
32
54
|
// Hatchet connection contract. The @hatchet-dev SDK also reads these straight
|
|
33
55
|
// from process.env via its own config-loader, so this schema is a presence /
|
|
34
56
|
// shape check that keeps the contract in one place — the values still flow to
|
package/src/index.ts
CHANGED
|
@@ -3,24 +3,32 @@
|
|
|
3
3
|
// Content (journeys, webhook sources, workflows) is injected into these
|
|
4
4
|
// factories by client app code; the engine never imports content.
|
|
5
5
|
|
|
6
|
-
// --- Capability-provider contracts (canonical origin: @hogsend/core) ---
|
|
7
|
-
// Email provider contract + analytics contract, re-exported so consumers can
|
|
8
|
-
// import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
|
|
9
|
-
// omitted here: the engine's public `SendEmailOptions` is the high-level
|
|
10
|
-
// journey-facing send options from `./lib/email.js`; the provider-contract
|
|
11
|
-
// `SendEmailOptions` remains available via `@hogsend/core`.)
|
|
12
6
|
export type {
|
|
13
7
|
BatchEmailItem,
|
|
14
8
|
CaptureOptions,
|
|
9
|
+
EmailEvent,
|
|
10
|
+
EmailEventType,
|
|
15
11
|
EmailProvider,
|
|
12
|
+
EmailProviderCapabilities,
|
|
13
|
+
EmailProviderMeta,
|
|
14
|
+
/** @deprecated Use {@link EmailEvent}. Frozen `event.raw` cast target. */
|
|
15
|
+
LegacyResendWebhookEvent,
|
|
16
16
|
PostHogService,
|
|
17
17
|
SendResult,
|
|
18
|
+
/** @deprecated Use {@link EmailEvent}. Kept for one minor. */
|
|
18
19
|
WebhookEvent,
|
|
19
20
|
WebhookHandlerMap,
|
|
20
21
|
} from "@hogsend/core";
|
|
21
22
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
22
23
|
// types) so content can import everything from `@hogsend/engine`.
|
|
23
24
|
export * from "@hogsend/core";
|
|
25
|
+
// --- Capability-provider contracts (canonical origin: @hogsend/core) ---
|
|
26
|
+
// Email provider contract + analytics contract, re-exported so consumers can
|
|
27
|
+
// import them from `@hogsend/engine`. (`SendEmailOptions` is intentionally
|
|
28
|
+
// omitted here: the engine's public `SendEmailOptions` is the high-level
|
|
29
|
+
// journey-facing send options from `./lib/email.js`; the provider-contract
|
|
30
|
+
// `SendEmailOptions` remains available via `@hogsend/core`.)
|
|
31
|
+
export { defineEmailProvider, WebhookHandshakeSignal } from "@hogsend/core";
|
|
24
32
|
export {
|
|
25
33
|
BucketRegistry,
|
|
26
34
|
JourneyRegistry,
|
|
@@ -149,6 +157,8 @@ export {
|
|
|
149
157
|
sendEmail,
|
|
150
158
|
setEmailService,
|
|
151
159
|
} from "./lib/email.js";
|
|
160
|
+
// --- Email provider registry (container-held, keyed by meta.id) ---
|
|
161
|
+
export { EmailProviderRegistry } from "./lib/email-provider-registry.js";
|
|
152
162
|
// --- Email service (engine-owned tracked mailer) ---
|
|
153
163
|
export type {
|
|
154
164
|
EmailService,
|
|
@@ -205,6 +215,11 @@ export {
|
|
|
205
215
|
export {
|
|
206
216
|
pushTrackingEvent,
|
|
207
217
|
resolveEmailSendContext,
|
|
218
|
+
resolveEmailSendContextByMessageId,
|
|
219
|
+
/**
|
|
220
|
+
* @deprecated Kept for one minor; use
|
|
221
|
+
* {@link resolveEmailSendContextByMessageId}.
|
|
222
|
+
*/
|
|
208
223
|
resolveEmailSendContextByResendId,
|
|
209
224
|
} from "./lib/tracking-events.js";
|
|
210
225
|
// --- Outbound webhooks: signing core (Section 1.2) ---
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EmailProvider } from "@hogsend/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Container-held registry of email providers, keyed by `provider.meta.id`. The
|
|
5
|
+
* webhook route (`POST /v1/webhooks/email/:providerId`) resolves the verifying
|
|
6
|
+
* provider out of this registry via `c.get("container")`, and the container
|
|
7
|
+
* also picks ONE `active` provider out of it for the mailer.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately NOT a process singleton: unlike the `DestinationRegistry`
|
|
10
|
+
* singleton — which exists only because the self-booting `deliverWebhookTask`
|
|
11
|
+
* has no container — both readers of this registry (the mailer the container
|
|
12
|
+
* constructs, and the webhook route which has the container) have a container
|
|
13
|
+
* reference, so the singleton + lazy-preset fallback would be dead weight.
|
|
14
|
+
*
|
|
15
|
+
* Keyed by `meta.id` with last-writer-wins, so a consumer-supplied provider of
|
|
16
|
+
* the same id overrides an env preset of that id.
|
|
17
|
+
*/
|
|
18
|
+
export class EmailProviderRegistry {
|
|
19
|
+
private byId = new Map<string, EmailProvider>();
|
|
20
|
+
|
|
21
|
+
constructor(providers: EmailProvider[] = []) {
|
|
22
|
+
for (const provider of providers) this.register(provider);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register (or replace) a provider. Falls back to `"resend"` for a provider
|
|
27
|
+
* built before `meta` existed (the contract keeps `meta` optional for
|
|
28
|
+
* back-compat). Last-writer-wins.
|
|
29
|
+
*/
|
|
30
|
+
register(provider: EmailProvider): void {
|
|
31
|
+
this.byId.set(provider.meta?.id ?? "resend", provider);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get(id: string): EmailProvider | undefined {
|
|
35
|
+
return this.byId.get(id);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getAll(): EmailProvider[] {
|
|
39
|
+
return [...this.byId.values()];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
count(): number {
|
|
43
|
+
return this.byId.size;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { EmailProvider } from "@hogsend/core";
|
|
2
|
+
import { createResendProvider } from "@hogsend/plugin-resend";
|
|
3
|
+
import type { env as envSchema } from "../env.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `@hogsend/plugin-postmark` is an OPT-IN, deferred-publish package: it is an
|
|
7
|
+
* engine `optionalDependency`, NOT a hard one, and it is not on the npm registry
|
|
8
|
+
* yet. So we MUST NOT statically import it — a static `import` would make the
|
|
9
|
+
* package mandatory at engine load, and `npm install @hogsend/engine` would fail
|
|
10
|
+
* with E404 on plugin-postmark for every consumer that doesn't have it.
|
|
11
|
+
*
|
|
12
|
+
* Instead we load it lazily, ONCE, behind a top-level guarded dynamic import:
|
|
13
|
+
* the `import()` only fires when `POSTMARK_SERVER_TOKEN` is present (the same
|
|
14
|
+
* gate the preset below uses), so a deploy that never sets that var never
|
|
15
|
+
* touches the package and degrades gracefully when it isn't installed. ESM
|
|
16
|
+
* top-level await keeps `emailProvidersFromEnv` itself synchronous (it reads the
|
|
17
|
+
* already-resolved factory), so `createHogsendClient` stays synchronous.
|
|
18
|
+
*
|
|
19
|
+
* The specifier is assembled at runtime (not a string literal) ON PURPOSE: a
|
|
20
|
+
* literal `import("@hogsend/plugin-postmark")` makes `tsc` resolve the module's
|
|
21
|
+
* types, which fails with TS2307 for any consumer that doesn't have the opt-in
|
|
22
|
+
* package installed (e.g. a fresh `create-hogsend` app). A computed specifier is
|
|
23
|
+
* opaque to the type-checker — resolved only at runtime — so the engine
|
|
24
|
+
* type-checks identically with or without the package present.
|
|
25
|
+
*/
|
|
26
|
+
type CreatePostmarkProvider = (cfg: {
|
|
27
|
+
serverToken: string;
|
|
28
|
+
messageStream?: string;
|
|
29
|
+
webhookBasicAuth?: { user: string; pass: string };
|
|
30
|
+
}) => EmailProvider;
|
|
31
|
+
|
|
32
|
+
const POSTMARK_PACKAGE = ["@hogsend", "plugin-postmark"].join("/");
|
|
33
|
+
|
|
34
|
+
let createPostmarkProvider: CreatePostmarkProvider | null = null;
|
|
35
|
+
if (process.env.POSTMARK_SERVER_TOKEN) {
|
|
36
|
+
try {
|
|
37
|
+
({ createPostmarkProvider } = (await import(POSTMARK_PACKAGE)) as {
|
|
38
|
+
createPostmarkProvider: CreatePostmarkProvider;
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
// The token is set but the opt-in package isn't installed. Leave the factory
|
|
42
|
+
// null — `emailProvidersFromEnv` skips the preset, and if Postmark was the
|
|
43
|
+
// resolved active provider the container throws a clear "not registered"
|
|
44
|
+
// error directing the operator to install `@hogsend/plugin-postmark`.
|
|
45
|
+
createPostmarkProvider = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the env-enabled email-provider presets. Mirrors `destinationsFromEnv`:
|
|
51
|
+
* a preset is constructed ONLY when its credential is present, so a
|
|
52
|
+
* Postmark-only deploy (no `RESEND_API_KEY`) contributes no Resend provider.
|
|
53
|
+
*
|
|
54
|
+
* These presets come FIRST in the container's merge — a consumer-supplied
|
|
55
|
+
* provider of the same id wins (last-writer-wins on the registry).
|
|
56
|
+
*/
|
|
57
|
+
export function emailProvidersFromEnv(env: typeof envSchema): EmailProvider[] {
|
|
58
|
+
const providers: EmailProvider[] = [];
|
|
59
|
+
|
|
60
|
+
if (env.RESEND_API_KEY) {
|
|
61
|
+
providers.push(
|
|
62
|
+
createResendProvider({
|
|
63
|
+
apiKey: env.RESEND_API_KEY,
|
|
64
|
+
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Postmark is OPT-IN: built only when its token is present AND the opt-in
|
|
70
|
+
// package resolved (see the guarded dynamic import above), and it never
|
|
71
|
+
// changes the default active provider — set EMAIL_PROVIDER=postmark to
|
|
72
|
+
// activate it. Postmark has no HMAC, so webhook auth is HTTP Basic creds (the
|
|
73
|
+
// provider fails closed when they're unset).
|
|
74
|
+
if (env.POSTMARK_SERVER_TOKEN && createPostmarkProvider) {
|
|
75
|
+
providers.push(
|
|
76
|
+
createPostmarkProvider({
|
|
77
|
+
serverToken: env.POSTMARK_SERVER_TOKEN,
|
|
78
|
+
...(env.POSTMARK_MESSAGE_STREAM
|
|
79
|
+
? { messageStream: env.POSTMARK_MESSAGE_STREAM }
|
|
80
|
+
: {}),
|
|
81
|
+
...(env.POSTMARK_WEBHOOK_USER && env.POSTMARK_WEBHOOK_PASS
|
|
82
|
+
? {
|
|
83
|
+
webhookBasicAuth: {
|
|
84
|
+
user: env.POSTMARK_WEBHOOK_USER,
|
|
85
|
+
pass: env.POSTMARK_WEBHOOK_PASS,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
: {}),
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return providers;
|
|
94
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
BatchEmailItem,
|
|
3
3
|
DurationObject,
|
|
4
|
+
EmailEvent,
|
|
5
|
+
EmailEventType,
|
|
4
6
|
SendEmailOptions,
|
|
5
7
|
SendResult,
|
|
6
|
-
WebhookEventType,
|
|
7
8
|
WebhookHandlerMap,
|
|
8
9
|
} from "@hogsend/core";
|
|
9
10
|
import type {
|
|
@@ -63,12 +64,36 @@ export interface SendTrackedEmailOptions<
|
|
|
63
64
|
|
|
64
65
|
export interface TrackedSendResult {
|
|
65
66
|
emailSendId: string;
|
|
67
|
+
/** The provider's neutral message id (Resend email_id / Postmark MessageID). */
|
|
68
|
+
messageId: string;
|
|
69
|
+
/**
|
|
70
|
+
* @deprecated Renamed to {@link TrackedSendResult.messageId}. This read-alias
|
|
71
|
+
* always mirrors `messageId`; kept for one minor and removed the following
|
|
72
|
+
* minor. Build results via {@link trackedSendResult} so the alias stays live.
|
|
73
|
+
*/
|
|
66
74
|
resendId: string;
|
|
67
75
|
status: "sent" | "suppressed" | "unsubscribed" | "skipped";
|
|
68
76
|
/** Present only when `status === "skipped"` by the frequency cap. */
|
|
69
77
|
reason?: "frequency_capped";
|
|
70
78
|
}
|
|
71
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Build a {@link TrackedSendResult}, attaching a live `@deprecated` `resendId`
|
|
82
|
+
* read-alias getter that mirrors `messageId`. Lets every send path return a
|
|
83
|
+
* single canonical `messageId` while public consumers reading the old `resendId`
|
|
84
|
+
* field keep working for one minor.
|
|
85
|
+
*/
|
|
86
|
+
export function trackedSendResult(
|
|
87
|
+
result: Omit<TrackedSendResult, "resendId">,
|
|
88
|
+
): TrackedSendResult {
|
|
89
|
+
return Object.defineProperty({ ...result }, "resendId", {
|
|
90
|
+
get(this: { messageId: string }) {
|
|
91
|
+
return this.messageId;
|
|
92
|
+
},
|
|
93
|
+
enumerable: true,
|
|
94
|
+
}) as TrackedSendResult;
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
// ---------------------------------------------------------------------------
|
|
73
98
|
// Frequency capping (client default config)
|
|
74
99
|
// ---------------------------------------------------------------------------
|
|
@@ -102,7 +127,6 @@ export interface EmailServiceConfig {
|
|
|
102
127
|
*/
|
|
103
128
|
templates: TemplateRegistry;
|
|
104
129
|
db?: unknown;
|
|
105
|
-
webhookSecret?: string;
|
|
106
130
|
webhookHandlers?: WebhookHandlerMap;
|
|
107
131
|
retryOptions?: RetryOptions;
|
|
108
132
|
bounceThreshold?: number;
|
|
@@ -138,13 +162,18 @@ export interface EmailServiceSendOptions<
|
|
|
138
162
|
idempotencyKey?: string;
|
|
139
163
|
}
|
|
140
164
|
|
|
165
|
+
/**
|
|
166
|
+
* @deprecated The route now verifies the provider webhook and hands
|
|
167
|
+
* {@link EmailService.handleWebhook} an already-parsed {@link EmailEvent}. This
|
|
168
|
+
* raw `{ payload, headers }` shape is no longer the handler input.
|
|
169
|
+
*/
|
|
141
170
|
export interface EmailServiceWebhookOptions {
|
|
142
171
|
payload: string;
|
|
143
172
|
headers: Record<string, string>;
|
|
144
173
|
}
|
|
145
174
|
|
|
146
175
|
export interface EmailServiceWebhookResult {
|
|
147
|
-
type:
|
|
176
|
+
type: EmailEventType;
|
|
148
177
|
handled: boolean;
|
|
149
178
|
}
|
|
150
179
|
|
|
@@ -163,7 +192,14 @@ export interface EmailService {
|
|
|
163
192
|
options: EmailServiceRenderOptions<K>,
|
|
164
193
|
): Promise<EmailServiceRenderResult>;
|
|
165
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Dispatch an already-verified, provider-neutral {@link EmailEvent} into the
|
|
197
|
+
* status/suppression/outbound pipeline. The webhook route owns provider
|
|
198
|
+
* resolution + signature verification and passes the parsed event + the
|
|
199
|
+
* resolving `providerId` (the latter is informational for now).
|
|
200
|
+
*/
|
|
166
201
|
handleWebhook(
|
|
167
|
-
|
|
202
|
+
event: EmailEvent,
|
|
203
|
+
providerId?: string,
|
|
168
204
|
): Promise<EmailServiceWebhookResult>;
|
|
169
205
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten a `Headers` instance into a plain lowercased `Record<string, string>`.
|
|
3
|
+
* Webhook routes verify signatures over the EXACT received bytes, so they need a
|
|
4
|
+
* case-insensitive header lookup — this is the single place that lowercasing
|
|
5
|
+
* lives. Pass `c.req.raw.headers`.
|
|
6
|
+
*/
|
|
7
|
+
export function headersToRecord(headers: Headers): Record<string, string> {
|
|
8
|
+
const record: Record<string, string> = {};
|
|
9
|
+
for (const [key, value] of headers.entries()) {
|
|
10
|
+
record[key.toLowerCase()] = value;
|
|
11
|
+
}
|
|
12
|
+
return record;
|
|
13
|
+
}
|