@hogsend/engine 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 +11 -6
- package/src/app.ts +28 -17
- package/src/container.ts +133 -41
- package/src/env.ts +35 -1
- package/src/index.ts +40 -8
- package/src/lib/auth.ts +78 -1
- package/src/lib/bootstrap-admin.ts +90 -0
- package/src/lib/create-admin.ts +104 -0
- 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/redis.ts +68 -0
- package/src/lib/reset-email.ts +139 -0
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +26 -11
- package/src/middleware/rate-limit.ts +38 -3
- 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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { user } from "@hogsend/db";
|
|
3
|
+
import type { HogsendClient } from "../container.js";
|
|
4
|
+
import { AdminAlreadyExistsError, createAdminUser } from "./create-admin.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Boot-time first-admin bootstrap. Replaces the old web setup-token land-grab:
|
|
8
|
+
* with public sign-up disabled (lib/auth.ts `disableSignUp`), admins are minted
|
|
9
|
+
* ONLY by the CLI (DB-direct) or this in-process boot path — there is NO
|
|
10
|
+
* unauthenticated network path that creates a user.
|
|
11
|
+
*
|
|
12
|
+
* Contract (all conditions must hold to mint):
|
|
13
|
+
* - `STUDIO_ADMIN_EMAIL` is set (unset ⇒ no-op; CLI is then the only path).
|
|
14
|
+
* - The `user` table has ZERO rows (idempotent: never mints over an existing
|
|
15
|
+
* user, never rotates/re-prints anything once an admin exists).
|
|
16
|
+
*
|
|
17
|
+
* Password resolution:
|
|
18
|
+
* - `STUDIO_ADMIN_PASSWORD` if set (NEVER logged).
|
|
19
|
+
* - else an auto-generated strong password (base64url, >=16 chars) PRINTED
|
|
20
|
+
* ONCE to the server log — the single intended secret-logging exception,
|
|
21
|
+
* clearly labelled "shown once". The operator should rotate it immediately
|
|
22
|
+
* via the self-service forgot/reset flow (retained, revokes sessions).
|
|
23
|
+
*
|
|
24
|
+
* Concurrency: two API replicas booting on a fresh DB could both pass the
|
|
25
|
+
* 0-rows check; the `user.email` unique constraint makes the loser's
|
|
26
|
+
* `createUser` throw — caught here and treated as "already created" (no-op, no
|
|
27
|
+
* log of the loser's generated password). Invariant preserved: exactly one
|
|
28
|
+
* admin, no unauthenticated path.
|
|
29
|
+
*
|
|
30
|
+
* Never blocks boot beyond a clear fatal when an explicitly-set password is too
|
|
31
|
+
* weak — that validation lives in env.ts (`STUDIO_ADMIN_PASSWORD.min(8)`), so
|
|
32
|
+
* by the time we run the password is already known-valid or auto-generated.
|
|
33
|
+
*/
|
|
34
|
+
export async function bootstrapAdminFromEnv(opts: {
|
|
35
|
+
client: HogsendClient;
|
|
36
|
+
}): Promise<void> {
|
|
37
|
+
const { db, auth, env, logger } = opts.client;
|
|
38
|
+
|
|
39
|
+
const email = env.STUDIO_ADMIN_EMAIL;
|
|
40
|
+
if (!email) return;
|
|
41
|
+
|
|
42
|
+
// Idempotency gate: only mint into a fresh DB (zero users). Reuses the same
|
|
43
|
+
// zero-check the old /v1/auth/status used.
|
|
44
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
45
|
+
if (existing.length > 0) return;
|
|
46
|
+
|
|
47
|
+
// Auto-generate when no explicit password: base64url(18 bytes) ⇒ 24 chars,
|
|
48
|
+
// ~144 bits of entropy. We only log the generated value, never an env one.
|
|
49
|
+
const explicit = env.STUDIO_ADMIN_PASSWORD;
|
|
50
|
+
const password = explicit ?? randomBytes(18).toString("base64url");
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const admin = await createAdminUser({ auth, email, password });
|
|
54
|
+
|
|
55
|
+
if (!explicit) {
|
|
56
|
+
// The ONE intended secret-logging exception (auto-generated only). Shown
|
|
57
|
+
// once — never re-printed (we only reach here on a zero-user DB).
|
|
58
|
+
logger.warn(
|
|
59
|
+
`[studio] First admin created: ${admin.email}. ` +
|
|
60
|
+
`Generated password (save this, shown once): ${password}`,
|
|
61
|
+
);
|
|
62
|
+
logger.warn(
|
|
63
|
+
"[studio] Rotate it now via the Studio forgot-password flow " +
|
|
64
|
+
"(or set STUDIO_ADMIN_PASSWORD).",
|
|
65
|
+
);
|
|
66
|
+
} else {
|
|
67
|
+
logger.info(`[studio] First admin created from env: ${admin.email}.`);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof AdminAlreadyExistsError) {
|
|
71
|
+
// A concurrent replica won the race (or the user appeared between the
|
|
72
|
+
// zero-check and the insert). No-op — never log the generated password.
|
|
73
|
+
logger.debug(
|
|
74
|
+
"[studio] First-admin bootstrap skipped: an admin already exists.",
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// A unique-violation surfaced by the adapter (not our pre-check) is the same
|
|
79
|
+
// race; treat any duplicate-key error as "already created". Anything else is
|
|
80
|
+
// unexpected — surface it without leaking the password.
|
|
81
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
82
|
+
if (/duplicate key|unique constraint|already exists/i.test(message)) {
|
|
83
|
+
logger.debug(
|
|
84
|
+
"[studio] First-admin bootstrap lost a creation race; skipping.",
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
logger.error("[studio] First-admin bootstrap failed.", { error: message });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Auth } from "./auth.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared admin-minting primitive used by BOTH the CLI's `admin create` and the
|
|
5
|
+
* engine's env bootstrap. Mints a credential admin via better-auth's
|
|
6
|
+
* INTERNAL ADAPTER (scrypt-identical to the running app) rather than the public
|
|
7
|
+
* sign-up endpoint — which is now blocked by `disableSignUp` (see lib/auth.ts).
|
|
8
|
+
*
|
|
9
|
+
* Why the internal adapter (not `auth.api.signUpEmail`): in better-auth 1.6.11
|
|
10
|
+
* the `disableSignUp` check lives INSIDE the sign-up endpoint handler, and
|
|
11
|
+
* `auth.api.signUpEmail` dispatches through that SAME handler — so with sign-up
|
|
12
|
+
* disabled it throws `EMAIL_PASSWORD_SIGN_UP_DISABLED` for the in-process API
|
|
13
|
+
* too. The internal adapter is NOT subject to that guard. This mirrors exactly
|
|
14
|
+
* what admin-recovery's `reset()` already does for its no-credential branch
|
|
15
|
+
* (`ctx.password.hash` + `ctx.internalAdapter.createAccount({ providerId:
|
|
16
|
+
* "credential" })`).
|
|
17
|
+
*
|
|
18
|
+
* Security invariants (acceptance gates, not preferences):
|
|
19
|
+
* - The password is hashed via `ctx.password.hash` (scrypt, identical to the
|
|
20
|
+
* app). There is NO raw SQL password write.
|
|
21
|
+
* - The password is never logged and never returned in any result object.
|
|
22
|
+
* - `emailVerified: true` because this is an operator-minted admin (CLI or
|
|
23
|
+
* boot env), not a self-service signup.
|
|
24
|
+
*
|
|
25
|
+
* Lives in `lib/` (reachable via the `@hogsend/engine/create-admin` subpath)
|
|
26
|
+
* with a module graph that touches ONLY better-auth — it never pulls `env.ts`,
|
|
27
|
+
* Hatchet, or Resend — so the CLI can import it the same way it imports
|
|
28
|
+
* `createAuth` from `@hogsend/engine/auth`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A single admin row, no secrets. Shared with the CLI's `AdminSummary`. */
|
|
32
|
+
export interface CreatedAdmin {
|
|
33
|
+
id: string;
|
|
34
|
+
email: string;
|
|
35
|
+
name: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Thrown when an admin with the given email already exists. */
|
|
40
|
+
export class AdminAlreadyExistsError extends Error {
|
|
41
|
+
constructor(public readonly email: string) {
|
|
42
|
+
super(
|
|
43
|
+
`An admin with email "${email}" already exists. ` +
|
|
44
|
+
"Use `hogsend studio admin reset` to set a new password.",
|
|
45
|
+
);
|
|
46
|
+
this.name = "AdminAlreadyExistsError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Create a credential admin user against a built better-auth instance. Throws
|
|
52
|
+
* {@link AdminAlreadyExistsError} if the email already exists (so callers can
|
|
53
|
+
* point the operator at `reset`). The unique constraint on `user.email` is the
|
|
54
|
+
* backstop: a concurrent racer that slips past the pre-check throws on
|
|
55
|
+
* `createUser` — callers that need idempotency should catch that.
|
|
56
|
+
*/
|
|
57
|
+
export async function createAdminUser(opts: {
|
|
58
|
+
auth: Auth;
|
|
59
|
+
email: string;
|
|
60
|
+
name?: string;
|
|
61
|
+
password: string;
|
|
62
|
+
}): Promise<CreatedAdmin> {
|
|
63
|
+
const { auth, email, password } = opts;
|
|
64
|
+
const displayName = opts.name ?? email.split("@")[0] ?? email;
|
|
65
|
+
|
|
66
|
+
const ctx = await auth.$context;
|
|
67
|
+
|
|
68
|
+
// Pre-check for a clear error (better-auth lowercases on lookup + create).
|
|
69
|
+
const existing = await ctx.internalAdapter.findUserByEmail(email);
|
|
70
|
+
if (existing) {
|
|
71
|
+
throw new AdminAlreadyExistsError(email);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// scrypt hash — identical to the running app (admin-recovery.reset uses the
|
|
75
|
+
// same call). NO raw SQL password write.
|
|
76
|
+
const hashed = await ctx.password.hash(password);
|
|
77
|
+
|
|
78
|
+
// `createUser` returns the bare user row (createWithHooks → the created user),
|
|
79
|
+
// NOT `{ user }` — verified against better-auth@1.6.11 internal-adapter.mjs:75.
|
|
80
|
+
const created = await ctx.internalAdapter.createUser({
|
|
81
|
+
email,
|
|
82
|
+
name: displayName,
|
|
83
|
+
emailVerified: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await ctx.internalAdapter.createAccount({
|
|
87
|
+
userId: created.id,
|
|
88
|
+
providerId: "credential",
|
|
89
|
+
accountId: created.id,
|
|
90
|
+
password: hashed,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const createdAt =
|
|
94
|
+
created.createdAt instanceof Date
|
|
95
|
+
? created.createdAt.toISOString()
|
|
96
|
+
: String(created.createdAt ?? new Date().toISOString());
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id: created.id,
|
|
100
|
+
email: created.email,
|
|
101
|
+
name: created.name,
|
|
102
|
+
createdAt,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|