@hogsend/engine 0.8.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 +156 -29
- package/src/destinations/define-destination.ts +104 -0
- package/src/destinations/presets/index.ts +94 -0
- package/src/destinations/presets/posthog.ts +71 -0
- package/src/destinations/presets/segment.ts +75 -0
- package/src/destinations/presets/slack.ts +66 -0
- package/src/destinations/presets/webhook.ts +37 -0
- package/src/destinations/registry-singleton.ts +78 -0
- package/src/env.ts +38 -1
- package/src/index.ts +46 -6
- package/src/journeys/define-journey.ts +0 -1
- package/src/journeys/journey-context.ts +1 -17
- package/src/lib/analytics-singleton.ts +7 -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 +137 -72
- package/src/lib/outbound.ts +18 -2
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracked.ts +34 -29
- package/src/lib/tracking-events.ts +37 -20
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/admin/emails.ts +5 -1
- package/src/routes/admin/webhooks.ts +100 -9
- package/src/routes/tracking/click.ts +20 -25
- package/src/routes/tracking/open.ts +20 -27
- 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/deliver-webhook.ts +137 -52
- package/src/workflows/send-email.ts +2 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
-
import type { PostHogService } from "@hogsend/core";
|
|
3
2
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
3
|
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
5
4
|
import { eq } from "drizzle-orm";
|
|
@@ -10,7 +9,7 @@ interface EmailSendContext {
|
|
|
10
9
|
userId: string;
|
|
11
10
|
userEmail: string;
|
|
12
11
|
templateKey: string | null;
|
|
13
|
-
|
|
12
|
+
messageId: string | null;
|
|
14
13
|
to: string;
|
|
15
14
|
}
|
|
16
15
|
|
|
@@ -22,7 +21,7 @@ export async function resolveEmailSendContext(
|
|
|
22
21
|
.select({
|
|
23
22
|
toEmail: emailSends.toEmail,
|
|
24
23
|
templateKey: emailSends.templateKey,
|
|
25
|
-
|
|
24
|
+
messageId: emailSends.messageId,
|
|
26
25
|
userId: journeyStates.userId,
|
|
27
26
|
userEmail: journeyStates.userEmail,
|
|
28
27
|
})
|
|
@@ -38,12 +37,12 @@ export async function resolveEmailSendContext(
|
|
|
38
37
|
userId: row.userId ?? row.toEmail,
|
|
39
38
|
userEmail: row.userEmail ?? row.toEmail,
|
|
40
39
|
templateKey: row.templateKey,
|
|
41
|
-
|
|
40
|
+
messageId: row.messageId,
|
|
42
41
|
to: row.toEmail,
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
export interface
|
|
45
|
+
export interface EmailSendContextByMessageId {
|
|
47
46
|
emailSendId: string;
|
|
48
47
|
userId: string;
|
|
49
48
|
userEmail: string;
|
|
@@ -51,21 +50,28 @@ export interface ResendEmailSendContext {
|
|
|
51
50
|
to: string;
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* @deprecated Renamed to {@link EmailSendContextByMessageId} as part of the
|
|
55
|
+
* provider-neutral `resendId` → `messageId` rename. Kept as an alias for one
|
|
56
|
+
* minor; removed the following minor.
|
|
57
|
+
*/
|
|
58
|
+
export type ResendEmailSendContext = EmailSendContextByMessageId;
|
|
59
|
+
|
|
54
60
|
/**
|
|
55
61
|
* Mirror of {@link resolveEmailSendContext} that resolves by the provider's
|
|
56
|
-
* `
|
|
62
|
+
* `messageId` instead of the internal `email_sends.id`. Used by the
|
|
57
63
|
* provider-webhook enrichment path (`email.delivered`/`email.bounced`) where the
|
|
58
|
-
* only handle we hold is the
|
|
64
|
+
* only handle we hold is the provider message id.
|
|
59
65
|
*
|
|
60
66
|
* Returns the internal `emailSendId` plus the same denormalized identity
|
|
61
67
|
* (`userId`/`userEmail` fall back to the recipient address, exactly like the
|
|
62
68
|
* id-keyed resolver) and `to` recipient. Returns null when no send row carries
|
|
63
|
-
* that `
|
|
69
|
+
* that `messageId` yet (e.g. a webhook arriving before the send row is committed).
|
|
64
70
|
*/
|
|
65
|
-
export async function
|
|
71
|
+
export async function resolveEmailSendContextByMessageId(
|
|
66
72
|
db: Database,
|
|
67
|
-
|
|
68
|
-
): Promise<
|
|
73
|
+
messageId: string,
|
|
74
|
+
): Promise<EmailSendContextByMessageId | null> {
|
|
69
75
|
const rows = await db
|
|
70
76
|
.select({
|
|
71
77
|
emailSendId: emailSends.id,
|
|
@@ -78,7 +84,7 @@ export async function resolveEmailSendContextByResendId(
|
|
|
78
84
|
})
|
|
79
85
|
.from(emailSends)
|
|
80
86
|
.leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
|
|
81
|
-
.where(eq(emailSends.
|
|
87
|
+
.where(eq(emailSends.messageId, messageId))
|
|
82
88
|
.limit(1);
|
|
83
89
|
|
|
84
90
|
const row = rows[0];
|
|
@@ -93,12 +99,19 @@ export async function resolveEmailSendContextByResendId(
|
|
|
93
99
|
};
|
|
94
100
|
}
|
|
95
101
|
|
|
102
|
+
/**
|
|
103
|
+
* @deprecated Renamed to {@link resolveEmailSendContextByMessageId} as part of
|
|
104
|
+
* the provider-neutral `resendId` → `messageId` rename. Kept as an alias for one
|
|
105
|
+
* minor; removed the following minor.
|
|
106
|
+
*/
|
|
107
|
+
export const resolveEmailSendContextByResendId =
|
|
108
|
+
resolveEmailSendContextByMessageId;
|
|
109
|
+
|
|
96
110
|
export interface PushTrackingEventOpts {
|
|
97
111
|
db: Database;
|
|
98
112
|
hatchet: HatchetClient;
|
|
99
113
|
registry: JourneyRegistry;
|
|
100
114
|
logger: Logger;
|
|
101
|
-
posthog?: PostHogService;
|
|
102
115
|
event: string;
|
|
103
116
|
emailSendId: string;
|
|
104
117
|
properties?: Record<string, unknown>;
|
|
@@ -111,10 +124,20 @@ export interface PushTrackingEventOpts {
|
|
|
111
124
|
resolvedContext?: EmailSendContext | null;
|
|
112
125
|
}
|
|
113
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Re-push a first-party tracking event (open/click) back onto the INTERNAL bus
|
|
129
|
+
* (`ingestEvent`) for journey routing + `userEvents` persistence.
|
|
130
|
+
*
|
|
131
|
+
* NOTE (Phase 2): this NO LONGER fires a fire-and-forget PostHog `captureEvent`.
|
|
132
|
+
* PostHog now receives opens/clicks PER-HIT via the durable outbound spine — a
|
|
133
|
+
* `kind="posthog"` destination subscribed to `email.opened`/`email.clicked` (the
|
|
134
|
+
* tracking routes call `emitOutbound` alongside this). The legacy double-emit was
|
|
135
|
+
* removed so PostHog gets exactly one, durable copy of each hit.
|
|
136
|
+
*/
|
|
114
137
|
export async function pushTrackingEvent(
|
|
115
138
|
opts: PushTrackingEventOpts,
|
|
116
139
|
): Promise<void> {
|
|
117
|
-
const { db, hatchet, registry, logger,
|
|
140
|
+
const { db, hatchet, registry, logger, event, emailSendId } = opts;
|
|
118
141
|
|
|
119
142
|
const ctx =
|
|
120
143
|
opts.resolvedContext !== undefined
|
|
@@ -128,12 +151,6 @@ export async function pushTrackingEvent(
|
|
|
128
151
|
...opts.properties,
|
|
129
152
|
};
|
|
130
153
|
|
|
131
|
-
posthog?.captureEvent({
|
|
132
|
-
distinctId: ctx.userId,
|
|
133
|
-
event,
|
|
134
|
-
properties,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
154
|
await ingestEvent({
|
|
138
155
|
db,
|
|
139
156
|
registry,
|
|
@@ -25,7 +25,7 @@ import { Webhook } from "svix";
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* The
|
|
28
|
+
* The 13-event catalog — the SINGLE source of truth (schema, routes, client,
|
|
29
29
|
* CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
|
|
30
30
|
* member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
|
|
31
31
|
*/
|
|
@@ -39,6 +39,7 @@ export const WEBHOOK_EVENT_TYPES = [
|
|
|
39
39
|
"email.opened",
|
|
40
40
|
"email.clicked",
|
|
41
41
|
"email.bounced",
|
|
42
|
+
"email.complained",
|
|
42
43
|
"journey.completed",
|
|
43
44
|
"bucket.entered",
|
|
44
45
|
"bucket.left",
|
|
@@ -26,6 +26,8 @@ const emailSchema = z.object({
|
|
|
26
26
|
id: z.string(),
|
|
27
27
|
journeyStateId: z.string().nullable(),
|
|
28
28
|
templateKey: z.string().nullable(),
|
|
29
|
+
messageId: z.string().nullable(),
|
|
30
|
+
/** @deprecated Mirrors `messageId`; kept for one minor, removed thereafter. */
|
|
29
31
|
resendId: z.string().nullable(),
|
|
30
32
|
fromEmail: z.string(),
|
|
31
33
|
toEmail: z.string(),
|
|
@@ -88,7 +90,9 @@ function serializeEmail(
|
|
|
88
90
|
id: row.id,
|
|
89
91
|
journeyStateId: row.journeyStateId,
|
|
90
92
|
templateKey: row.templateKey,
|
|
91
|
-
|
|
93
|
+
messageId: row.messageId,
|
|
94
|
+
// @deprecated Mirror of `messageId` for one minor (back-compat).
|
|
95
|
+
resendId: row.messageId,
|
|
92
96
|
fromEmail: row.fromEmail,
|
|
93
97
|
toEmail: row.toEmail,
|
|
94
98
|
subject: row.subject,
|
|
@@ -3,6 +3,7 @@ import { webhookDeliveries, webhookEndpoints } from "@hogsend/db";
|
|
|
3
3
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
4
4
|
import { count, desc, eq } from "drizzle-orm";
|
|
5
5
|
import type { AppEnv } from "../../app.js";
|
|
6
|
+
import { PRESET_DESTINATIONS } from "../../destinations/presets/index.js";
|
|
6
7
|
import { errorSchema } from "../../lib/schemas.js";
|
|
7
8
|
import {
|
|
8
9
|
generateWebhookSecret,
|
|
@@ -24,17 +25,34 @@ import { deliverWebhookTask } from "../../workflows/deliver-webhook.js";
|
|
|
24
25
|
|
|
25
26
|
// The catalog enum for request validation — derived from the SINGLE source of
|
|
26
27
|
// truth in `webhook-signing.ts` (Section 1.3). `z.enum` needs a non-empty tuple,
|
|
27
|
-
// which `WEBHOOK_EVENT_TYPES` (
|
|
28
|
+
// which `WEBHOOK_EVENT_TYPES` (13 strings, `as const`) satisfies.
|
|
28
29
|
const eventTypeEnum = z.enum(
|
|
29
30
|
WEBHOOK_EVENT_TYPES as unknown as [WebhookEventType, ...WebhookEventType[]],
|
|
30
31
|
);
|
|
31
32
|
|
|
33
|
+
// The destination `kind` enum. "webhook" (default) is the signed POST; any
|
|
34
|
+
// other value selects a delivery-time transform adapter keyed by this column.
|
|
35
|
+
// This MUST cover every shipped preset (the skills + env.example tell operators
|
|
36
|
+
// to create segment/slack endpoints via this admin API / the `hs.webhooks` SDK),
|
|
37
|
+
// so it is derived from `PRESET_DESTINATIONS` — the single source of truth for
|
|
38
|
+
// the shipped `kind`s — rather than a hand-maintained literal list. A `kind`
|
|
39
|
+
// whose transform is not REGISTERED at delivery time still fails its delivery as
|
|
40
|
+
// a config error (DLQ); the registry, not the env, decides resolvability — so we
|
|
41
|
+
// accept any shipped preset id here regardless of `ENABLED_DESTINATION_PRESETS`.
|
|
42
|
+
const kindEnum = z.enum(
|
|
43
|
+
Object.keys(PRESET_DESTINATIONS) as unknown as [string, ...string[]],
|
|
44
|
+
);
|
|
45
|
+
|
|
32
46
|
const webhookEndpointSchema = z.object({
|
|
33
47
|
id: z.string(),
|
|
34
48
|
url: z.string(),
|
|
35
49
|
description: z.string().nullable(),
|
|
36
50
|
eventTypes: z.array(z.string()),
|
|
37
|
-
|
|
51
|
+
// Keyed destinations have no signing secret (null secretPrefix).
|
|
52
|
+
secretPrefix: z.string().nullable(),
|
|
53
|
+
kind: z.string(),
|
|
54
|
+
// REDACTED config view — credentials (e.g. config.apiKey) are masked.
|
|
55
|
+
config: z.record(z.string(), z.unknown()).nullable(),
|
|
38
56
|
status: z.enum(["enabled", "disabled"]),
|
|
39
57
|
organizationId: z.string().nullable(),
|
|
40
58
|
lastDeliveryAt: z.string().nullable(),
|
|
@@ -85,6 +103,11 @@ const createEndpointRoute = createRoute({
|
|
|
85
103
|
eventTypes: z.array(eventTypeEnum).min(1),
|
|
86
104
|
description: z.string().max(500).optional(),
|
|
87
105
|
disabled: z.boolean().optional(),
|
|
106
|
+
// Destination selector. Defaults to "webhook" (signed POST).
|
|
107
|
+
kind: kindEnum.optional(),
|
|
108
|
+
// Per-destination config for keyed adapters (e.g. PostHog's
|
|
109
|
+
// `{ apiKey, host }`). Ignored for kind="webhook".
|
|
110
|
+
config: z.record(z.string(), z.unknown()).optional(),
|
|
88
111
|
}),
|
|
89
112
|
},
|
|
90
113
|
},
|
|
@@ -94,7 +117,11 @@ const createEndpointRoute = createRoute({
|
|
|
94
117
|
201: {
|
|
95
118
|
content: {
|
|
96
119
|
"application/json": {
|
|
97
|
-
|
|
120
|
+
// `secret` is present ONLY for kind="webhook" (the one-time signing
|
|
121
|
+
// secret). Keyed destinations carry no secret.
|
|
122
|
+
schema: webhookEndpointSchema.extend({
|
|
123
|
+
secret: z.string().optional(),
|
|
124
|
+
}),
|
|
98
125
|
},
|
|
99
126
|
},
|
|
100
127
|
description: "Endpoint created — signing secret shown only once",
|
|
@@ -139,6 +166,8 @@ const updateEndpointRoute = createRoute({
|
|
|
139
166
|
eventTypes: z.array(eventTypeEnum).min(1).optional(),
|
|
140
167
|
description: z.string().max(500).nullable().optional(),
|
|
141
168
|
disabled: z.boolean().optional(),
|
|
169
|
+
kind: kindEnum.optional(),
|
|
170
|
+
config: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
142
171
|
}),
|
|
143
172
|
},
|
|
144
173
|
},
|
|
@@ -237,10 +266,30 @@ const testRoute = createRoute({
|
|
|
237
266
|
},
|
|
238
267
|
});
|
|
239
268
|
|
|
269
|
+
/** Config keys whose values are credentials and must be masked in responses. */
|
|
270
|
+
const REDACTED_CONFIG_KEYS = new Set(["apiKey", "apikey", "api_key"]);
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Mask credential values in a keyed destination's `config` for API responses —
|
|
274
|
+
* `config.apiKey` becomes `"***"` so a list/get never leaks the secret. Returns
|
|
275
|
+
* null when there is no config (kind="webhook").
|
|
276
|
+
*/
|
|
277
|
+
function redactConfig(
|
|
278
|
+
config: Record<string, unknown> | null,
|
|
279
|
+
): Record<string, unknown> | null {
|
|
280
|
+
if (!config) return null;
|
|
281
|
+
const out: Record<string, unknown> = {};
|
|
282
|
+
for (const [key, value] of Object.entries(config)) {
|
|
283
|
+
out[key] = REDACTED_CONFIG_KEYS.has(key) ? "***" : value;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
240
288
|
/**
|
|
241
289
|
* Serialize an endpoint row for an API response. NEVER includes `secret` — the
|
|
242
290
|
* full `whsec_…` is surfaced only on create + rotate-secret via the dedicated
|
|
243
|
-
* response shapes. `
|
|
291
|
+
* response shapes. `config` is REDACTED (credentials masked). `status` is
|
|
292
|
+
* derived from the `disabled` boolean.
|
|
244
293
|
*/
|
|
245
294
|
function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
|
|
246
295
|
return {
|
|
@@ -249,6 +298,8 @@ function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
|
|
|
249
298
|
description: row.description,
|
|
250
299
|
eventTypes: row.eventTypes as string[],
|
|
251
300
|
secretPrefix: row.secretPrefix,
|
|
301
|
+
kind: row.kind,
|
|
302
|
+
config: redactConfig(row.config),
|
|
252
303
|
status: (row.disabled ? "disabled" : "enabled") as "enabled" | "disabled",
|
|
253
304
|
organizationId: row.organizationId,
|
|
254
305
|
lastDeliveryAt: row.lastDeliveryAt?.toISOString() ?? null,
|
|
@@ -292,7 +343,13 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
292
343
|
const { db } = c.get("container");
|
|
293
344
|
const body = c.req.valid("json");
|
|
294
345
|
|
|
295
|
-
const
|
|
346
|
+
const kind = body.kind ?? "webhook";
|
|
347
|
+
// Only the signed "webhook" kind gets a signing secret; keyed destinations
|
|
348
|
+
// authenticate via `config` (their secret/secretPrefix stay null).
|
|
349
|
+
const credentials =
|
|
350
|
+
kind === "webhook"
|
|
351
|
+
? generateWebhookSecret()
|
|
352
|
+
: { secret: null, secretPrefix: null };
|
|
296
353
|
|
|
297
354
|
const [created] = await db
|
|
298
355
|
.insert(webhookEndpoints)
|
|
@@ -301,15 +358,24 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
301
358
|
eventTypes: body.eventTypes,
|
|
302
359
|
description: body.description ?? null,
|
|
303
360
|
disabled: body.disabled ?? false,
|
|
304
|
-
|
|
305
|
-
|
|
361
|
+
kind,
|
|
362
|
+
config: body.config ?? null,
|
|
363
|
+
secret: credentials.secret,
|
|
364
|
+
secretPrefix: credentials.secretPrefix,
|
|
306
365
|
})
|
|
307
366
|
.returning();
|
|
308
367
|
|
|
309
368
|
if (!created) throw new Error("Failed to create webhook endpoint");
|
|
310
369
|
|
|
311
|
-
// The ONLY list/get-shaped response that also carries the full secret
|
|
312
|
-
|
|
370
|
+
// The ONLY list/get-shaped response that also carries the full secret — and
|
|
371
|
+
// ONLY for kind="webhook" (keyed destinations have no secret to surface).
|
|
372
|
+
if (kind === "webhook" && credentials.secret) {
|
|
373
|
+
return c.json(
|
|
374
|
+
{ ...serializeEndpoint(created), secret: credentials.secret },
|
|
375
|
+
201,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return c.json(serializeEndpoint(created), 201);
|
|
313
379
|
})
|
|
314
380
|
.openapi(getEndpointRoute, async (c) => {
|
|
315
381
|
const { db } = c.get("container");
|
|
@@ -349,6 +415,19 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
349
415
|
if (body.eventTypes !== undefined) patch.eventTypes = body.eventTypes;
|
|
350
416
|
if (body.description !== undefined) patch.description = body.description;
|
|
351
417
|
if (body.disabled !== undefined) patch.disabled = body.disabled;
|
|
418
|
+
if (body.kind !== undefined) patch.kind = body.kind;
|
|
419
|
+
if (body.config !== undefined) patch.config = body.config;
|
|
420
|
+
|
|
421
|
+
// Foot-gun guard: an endpoint that ends up as a signed "webhook" with no
|
|
422
|
+
// secret would dead-letter every delivery (the webhook transform signs with
|
|
423
|
+
// the live secret). Mint one when switching to (or back to) kind="webhook"
|
|
424
|
+
// without an existing secret. Keyed kinds keep their null secret — harmless.
|
|
425
|
+
const effectiveKind = body.kind ?? existing.kind;
|
|
426
|
+
if (effectiveKind === "webhook" && !existing.secret) {
|
|
427
|
+
const { secret, secretPrefix } = generateWebhookSecret();
|
|
428
|
+
patch.secret = secret;
|
|
429
|
+
patch.secretPrefix = secretPrefix;
|
|
430
|
+
}
|
|
352
431
|
|
|
353
432
|
const [updated] = await db
|
|
354
433
|
.update(webhookEndpoints)
|
|
@@ -418,6 +497,14 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
418
497
|
// endpoint's `eventTypes`. Build a synthetic delivery row directly — it does
|
|
419
498
|
// NOT go through `emitOutbound` (which filters by subscription) — then enqueue
|
|
420
499
|
// the same durable delivery task the live emit path uses.
|
|
500
|
+
//
|
|
501
|
+
// The synthetic envelope routes THROUGH the per-kind delivery adapter (the
|
|
502
|
+
// delivery task resolves it by `endpoint.kind`), so the `data` must carry the
|
|
503
|
+
// fields every adapter needs to build a VALID request. For `kind="webhook"`
|
|
504
|
+
// the adapter only signs the frozen bytes, so the extra fields are harmless;
|
|
505
|
+
// for `kind="posthog"` the capture adapter coalesces `distinct_id` from
|
|
506
|
+
// `userId`/`to`/`userEmail`, so a synthetic recipient (`to`) is required or
|
|
507
|
+
// the test would POST a malformed capture with no distinct_id.
|
|
421
508
|
const webhookId = `msg_${randomUUID()}`;
|
|
422
509
|
const timestamp = new Date();
|
|
423
510
|
const envelope = {
|
|
@@ -428,6 +515,10 @@ export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
|
428
515
|
message: "Hogsend test event",
|
|
429
516
|
endpointId: endpoint.id,
|
|
430
517
|
sentAt: timestamp.toISOString(),
|
|
518
|
+
// Adapter-friendly synthetic identity so a keyed destination (e.g.
|
|
519
|
+
// posthog) builds a valid request from the test envelope.
|
|
520
|
+
userId: `test_${endpoint.id}`,
|
|
521
|
+
to: "test@hogsend.com",
|
|
431
522
|
},
|
|
432
523
|
};
|
|
433
524
|
|
|
@@ -52,10 +52,11 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
52
52
|
null;
|
|
53
53
|
const userAgent = c.req.header("user-agent") ?? null;
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
55
|
+
// First-touch state UPDATE: the `WHERE clickedAt IS NULL` sets `clickedAt`
|
|
56
|
+
// exactly once (the first click), which is the row-level state we keep. The
|
|
57
|
+
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
58
|
+
// EVERY click (owner decision 1), so the emit below fires per-hit.
|
|
59
|
+
await Promise.all([
|
|
59
60
|
db.insert(linkClicks).values({
|
|
60
61
|
trackedLinkId: link.id,
|
|
61
62
|
ipAddress: ip,
|
|
@@ -79,25 +80,17 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
79
80
|
eq(emailSends.id, link.emailSendId),
|
|
80
81
|
isNull(emailSends.clickedAt),
|
|
81
82
|
),
|
|
82
|
-
)
|
|
83
|
-
.returning({ id: emailSends.id }),
|
|
83
|
+
),
|
|
84
84
|
]);
|
|
85
85
|
|
|
86
|
-
const {
|
|
87
|
-
hatchet,
|
|
88
|
-
registry,
|
|
89
|
-
logger,
|
|
90
|
-
analytics: posthog,
|
|
91
|
-
} = c.get("container");
|
|
86
|
+
const { hatchet, registry, logger } = c.get("container");
|
|
92
87
|
|
|
93
88
|
// Resolve the send context ONCE (off the response path) and feed both the
|
|
94
|
-
// re-ingest
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// SINGLE emitter for `email.clicked` (the provider-webhook echo is suppressed).
|
|
89
|
+
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
90
|
+
// `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
|
|
91
|
+
// NULL dedupe key is distinct in Postgres, so every click creates a fresh
|
|
92
|
+
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
99
93
|
const emailSendId = link.emailSendId;
|
|
100
|
-
const isFirstClick = clicked.length > 0;
|
|
101
94
|
void resolveEmailSendContext(db, emailSendId)
|
|
102
95
|
.then(async (ctx) => {
|
|
103
96
|
await pushTrackingEvent({
|
|
@@ -105,7 +98,6 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
105
98
|
hatchet,
|
|
106
99
|
registry,
|
|
107
100
|
logger,
|
|
108
|
-
posthog,
|
|
109
101
|
event: EMAIL_LINK_CLICKED,
|
|
110
102
|
emailSendId,
|
|
111
103
|
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
@@ -117,19 +109,22 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
117
109
|
});
|
|
118
110
|
});
|
|
119
111
|
|
|
120
|
-
|
|
112
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
113
|
+
// (orphaned tracked link / deleted send) has no userId or recipient to
|
|
114
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
115
|
+
// an empty distinct_id. A normal click always resolves a non-null userId.
|
|
116
|
+
if (ctx) {
|
|
121
117
|
await emitOutbound({
|
|
122
118
|
db,
|
|
123
119
|
hatchet,
|
|
124
120
|
logger,
|
|
125
121
|
event: "email.clicked",
|
|
126
|
-
dedupeKey: `email.clicked:${emailSendId}`,
|
|
127
122
|
payload: {
|
|
128
123
|
emailSendId,
|
|
129
|
-
|
|
130
|
-
templateKey: ctx
|
|
131
|
-
userId: ctx
|
|
132
|
-
to: ctx
|
|
124
|
+
messageId: ctx.messageId ?? null,
|
|
125
|
+
templateKey: ctx.templateKey ?? null,
|
|
126
|
+
userId: ctx.userId ?? null,
|
|
127
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
133
128
|
at: new Date().toISOString(),
|
|
134
129
|
linkUrl: link.originalUrl,
|
|
135
130
|
linkId: link.id,
|
|
@@ -33,34 +33,25 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
33
33
|
openRoute,
|
|
34
34
|
async (c) => {
|
|
35
35
|
const { id } = c.req.valid("param");
|
|
36
|
-
const {
|
|
37
|
-
db,
|
|
38
|
-
hatchet,
|
|
39
|
-
registry,
|
|
40
|
-
logger,
|
|
41
|
-
analytics: posthog,
|
|
42
|
-
} = c.get("container");
|
|
36
|
+
const { db, hatchet, registry, logger } = c.get("container");
|
|
43
37
|
|
|
44
|
-
// First-touch
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
38
|
+
// First-touch state UPDATE: the `WHERE openedAt IS NULL` sets `openedAt`
|
|
39
|
+
// exactly once (the first open), which is the row-level state we keep. The
|
|
40
|
+
// outbound emit is NO LONGER gated on this — every destination must receive
|
|
41
|
+
// EVERY open (owner decision 1), so the emit below fires per-hit.
|
|
42
|
+
await db
|
|
49
43
|
.update(emailSends)
|
|
50
44
|
.set({
|
|
51
45
|
openedAt: new Date(),
|
|
52
46
|
updatedAt: new Date(),
|
|
53
47
|
})
|
|
54
|
-
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)))
|
|
55
|
-
.returning({ id: emailSends.id });
|
|
48
|
+
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)));
|
|
56
49
|
|
|
57
50
|
// Resolve the send context ONCE (off the response path) and feed both the
|
|
58
|
-
// re-ingest
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// emitter for `email.opened` (the provider-webhook echo is suppressed).
|
|
63
|
-
const isFirstOpen = opened.length > 0;
|
|
51
|
+
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
52
|
+
// `resolveEmailSendContext` read on the pixel hot path. NO `dedupeKey`: a
|
|
53
|
+
// NULL dedupe key is distinct in Postgres, so every open creates a fresh
|
|
54
|
+
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
64
55
|
void resolveEmailSendContext(db, id)
|
|
65
56
|
.then(async (ctx) => {
|
|
66
57
|
await pushTrackingEvent({
|
|
@@ -68,7 +59,6 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
68
59
|
hatchet,
|
|
69
60
|
registry,
|
|
70
61
|
logger,
|
|
71
|
-
posthog,
|
|
72
62
|
event: EMAIL_OPENED,
|
|
73
63
|
emailSendId: id,
|
|
74
64
|
resolvedContext: ctx,
|
|
@@ -79,19 +69,22 @@ export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
79
69
|
});
|
|
80
70
|
});
|
|
81
71
|
|
|
82
|
-
|
|
72
|
+
// Only emit when the send-context resolved. A missing emailSends row
|
|
73
|
+
// (orphaned tracked pixel / deleted send) has no userId or recipient to
|
|
74
|
+
// attribute, and a keyed destination (PostHog) would otherwise receive
|
|
75
|
+
// an empty distinct_id. A normal open always resolves a non-null userId.
|
|
76
|
+
if (ctx) {
|
|
83
77
|
await emitOutbound({
|
|
84
78
|
db,
|
|
85
79
|
hatchet,
|
|
86
80
|
logger,
|
|
87
81
|
event: "email.opened",
|
|
88
|
-
dedupeKey: `email.opened:${id}`,
|
|
89
82
|
payload: {
|
|
90
83
|
emailSendId: id,
|
|
91
|
-
|
|
92
|
-
templateKey: ctx
|
|
93
|
-
userId: ctx
|
|
94
|
-
to: ctx
|
|
84
|
+
messageId: ctx.messageId ?? null,
|
|
85
|
+
templateKey: ctx.templateKey ?? null,
|
|
86
|
+
userId: ctx.userId ?? null,
|
|
87
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
95
88
|
at: new Date().toISOString(),
|
|
96
89
|
},
|
|
97
90
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { WebhookHandshakeSignal } from "@hogsend/core";
|
|
2
|
+
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { headersToRecord } from "../../lib/headers.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared email-provider webhook dispatch used by BOTH the id-dispatched
|
|
9
|
+
* `POST /v1/webhooks/email/:providerId` route and the thin
|
|
10
|
+
* `POST /v1/webhooks/resend` alias. Resolves the provider from the container's
|
|
11
|
+
* {@link EmailProviderRegistry} (404 on unknown id), reads the raw body as the
|
|
12
|
+
* EXACT received bytes (signature schemes verify over these), verifies +
|
|
13
|
+
* dispatches the normalized {@link EmailEvent}, 200s a
|
|
14
|
+
* {@link WebhookHandshakeSignal}, and 401s a verification error.
|
|
15
|
+
*/
|
|
16
|
+
export async function dispatchProviderWebhook(
|
|
17
|
+
c: Context<AppEnv>,
|
|
18
|
+
providerId: string,
|
|
19
|
+
) {
|
|
20
|
+
const { emailProviders, emailService, logger } = c.get("container");
|
|
21
|
+
|
|
22
|
+
const provider = emailProviders.get(providerId);
|
|
23
|
+
if (!provider) {
|
|
24
|
+
return c.json({ error: "Unknown email provider" }, 404);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Read the body ONCE as the EXACT received bytes — signature schemes verify
|
|
28
|
+
// over these bytes, so we must not re-stringify.
|
|
29
|
+
const payload = await c.req.text();
|
|
30
|
+
const headers = headersToRecord(c.req.raw.headers);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const event = await provider.verifyWebhook({ payload, headers });
|
|
34
|
+
const result = await emailService.handleWebhook(event, providerId);
|
|
35
|
+
|
|
36
|
+
logger.info("Email provider webhook processed", {
|
|
37
|
+
providerId,
|
|
38
|
+
type: event.type,
|
|
39
|
+
handled: result.handled,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return c.json({ ok: true }, 200);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof WebhookHandshakeSignal) {
|
|
45
|
+
// A non-delivery-status handshake (SNS confirm, Postmark subscription
|
|
46
|
+
// change) the provider already handled — ack with 200.
|
|
47
|
+
logger.info("Email webhook handshake", {
|
|
48
|
+
providerId,
|
|
49
|
+
action: err.action,
|
|
50
|
+
});
|
|
51
|
+
return c.json({ ok: true }, 200);
|
|
52
|
+
}
|
|
53
|
+
logger.warn("Email provider webhook failed", {
|
|
54
|
+
providerId,
|
|
55
|
+
error: err instanceof Error ? err.message : String(err),
|
|
56
|
+
});
|
|
57
|
+
return c.json({ error: "Webhook verification failed" }, 401);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Id-dispatched email-provider webhook receiver:
|
|
63
|
+
* `POST /v1/webhooks/email/:providerId`.
|
|
64
|
+
*
|
|
65
|
+
* Resolves the provider from the container's {@link EmailProviderRegistry} (so
|
|
66
|
+
* an unknown id is a clean 404), verifies the webhook via that provider (which
|
|
67
|
+
* owns its OWN secrets), and dispatches the normalized {@link EmailEvent} into
|
|
68
|
+
* `emailService.handleWebhook`. Registered BEFORE the `:sourceId` catch-all so
|
|
69
|
+
* Hono matches the static `email/` prefix first.
|
|
70
|
+
*
|
|
71
|
+
* The provider's `verifyWebhook` is the ONLY place body-shape knowledge lives —
|
|
72
|
+
* the route never sniffs the payload. It returns a normalized event, OR throws
|
|
73
|
+
* {@link WebhookHandshakeSignal} for non-status handshakes (route 200s), OR
|
|
74
|
+
* throws a verification error (route 401s).
|
|
75
|
+
*/
|
|
76
|
+
const emailProviderWebhookRoute = createRoute({
|
|
77
|
+
method: "post",
|
|
78
|
+
path: "/v1/webhooks/email/{providerId}",
|
|
79
|
+
tags: ["Webhooks"],
|
|
80
|
+
summary: "Email provider webhook receiver",
|
|
81
|
+
request: {
|
|
82
|
+
params: z.object({ providerId: z.string() }),
|
|
83
|
+
body: {
|
|
84
|
+
content: {
|
|
85
|
+
"application/json": {
|
|
86
|
+
schema: z.record(z.string(), z.unknown()),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
responses: {
|
|
92
|
+
200: {
|
|
93
|
+
content: {
|
|
94
|
+
"application/json": {
|
|
95
|
+
schema: z.object({ ok: z.boolean() }),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
description: "Webhook processed",
|
|
99
|
+
},
|
|
100
|
+
401: {
|
|
101
|
+
content: {
|
|
102
|
+
"application/json": {
|
|
103
|
+
schema: z.object({ error: z.string() }),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
description: "Missing or invalid webhook signature",
|
|
107
|
+
},
|
|
108
|
+
404: {
|
|
109
|
+
content: {
|
|
110
|
+
"application/json": {
|
|
111
|
+
schema: z.object({ error: z.string() }),
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
description: "Unknown email provider",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export function registerEmailProviderRoutes(app: OpenAPIHono<AppEnv>) {
|
|
120
|
+
app.openapi(emailProviderWebhookRoute, (c) => {
|
|
121
|
+
const { providerId } = c.req.valid("param");
|
|
122
|
+
return dispatchProviderWebhook(c, providerId);
|
|
123
|
+
});
|
|
124
|
+
}
|