@hogsend/engine 0.7.0 → 0.9.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 +7 -6
- package/src/app.ts +36 -1
- package/src/container.ts +80 -8
- 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 +40 -0
- package/src/index.ts +59 -1
- package/src/journeys/define-journey.ts +26 -3
- package/src/journeys/journey-context.ts +1 -17
- package/src/lib/analytics-singleton.ts +7 -0
- package/src/lib/bucket-emit.ts +45 -0
- package/src/lib/contacts.ts +28 -6
- package/src/lib/mailer.ts +102 -0
- package/src/lib/outbound.ts +223 -0
- package/src/lib/preferences.ts +31 -0
- package/src/lib/seed-posthog-destination.ts +93 -0
- package/src/lib/tracked.ts +45 -3
- package/src/lib/tracking-events.ts +77 -10
- package/src/lib/webhook-signing.ts +152 -0
- package/src/routes/admin/contacts.ts +43 -3
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/webhooks.ts +557 -0
- package/src/routes/contacts/index.ts +48 -5
- package/src/routes/lists/index.ts +41 -5
- package/src/routes/tracking/click.ts +58 -22
- package/src/routes/tracking/open.ts +53 -22
- package/src/routes/webhooks/sources.ts +69 -10
- package/src/webhook-sources/define-webhook-source.ts +57 -5
- package/src/webhook-sources/presets/clerk.ts +185 -0
- package/src/webhook-sources/presets/index.ts +80 -0
- package/src/webhook-sources/presets/segment.ts +120 -0
- package/src/webhook-sources/presets/stripe.ts +147 -0
- package/src/webhook-sources/presets/supabase.ts +131 -0
- package/src/webhook-sources/verify.ts +172 -0
- package/src/worker.ts +6 -0
- package/src/workflows/deliver-webhook.ts +484 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { DefinedDestination } from "./define-destination.js";
|
|
2
|
+
import { PRESET_DESTINATIONS } from "./presets/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The process-wide destination registry, set once by `createHogsendClient` at
|
|
6
|
+
* startup and read by the delivery task (`workflows/deliver-webhook.ts`) to
|
|
7
|
+
* resolve a transform by `endpoint.kind`.
|
|
8
|
+
*
|
|
9
|
+
* Why a singleton (mirrors `lib/analytics-singleton.ts`): the durable
|
|
10
|
+
* `deliverWebhookTask` SELF-BOOTS — it opens its own `getDb()` from
|
|
11
|
+
* `process.env` and has NO client/container reference. So the registered
|
|
12
|
+
* transforms (presets + the consumer's `defineDestination()` destinations) MUST
|
|
13
|
+
* be reachable via a process singleton, exactly as analytics / the journey +
|
|
14
|
+
* bucket registries are. `createHogsendClient` runs in BOTH the API and worker,
|
|
15
|
+
* so by the time any worker task executes the registry has been installed.
|
|
16
|
+
*
|
|
17
|
+
* Resilient default: if a delivery task somehow runs in a process that never
|
|
18
|
+
* called `createHogsendClient` (a bare reaper re-drive in a test harness), the
|
|
19
|
+
* getter lazily falls back to the shipped {@link PRESET_DESTINATIONS} so the
|
|
20
|
+
* no-regression `webhook` + the `posthog` presets still resolve. Installing a
|
|
21
|
+
* registry via {@link setDestinationRegistry} replaces this fallback.
|
|
22
|
+
*/
|
|
23
|
+
export class DestinationRegistry {
|
|
24
|
+
private readonly byKind = new Map<string, DefinedDestination>();
|
|
25
|
+
|
|
26
|
+
constructor(destinations: DefinedDestination[] = []) {
|
|
27
|
+
for (const destination of destinations) {
|
|
28
|
+
// Last-writer-wins on id collision — the caller (container) orders the
|
|
29
|
+
// array so the consumer's destination wins over a preset of the same id.
|
|
30
|
+
this.byKind.set(destination.meta.id, destination);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve a destination by its `kind` id, or `undefined` when unregistered. */
|
|
35
|
+
get(kind: string): DefinedDestination | undefined {
|
|
36
|
+
return this.byKind.get(kind);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every registered destination (for diagnostics / catalog enumeration). */
|
|
40
|
+
getAll(): DefinedDestination[] {
|
|
41
|
+
return [...this.byKind.values()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Number of registered destinations. */
|
|
45
|
+
count(): number {
|
|
46
|
+
return this.byKind.size;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The lazily-built fallback registry of just the shipped presets. */
|
|
51
|
+
let fallback: DestinationRegistry | undefined;
|
|
52
|
+
let installed: DestinationRegistry | undefined;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Install the resolved destination registry. Called by `createHogsendClient`
|
|
56
|
+
* after merging the env presets with the consumer's `opts.destinations`.
|
|
57
|
+
*/
|
|
58
|
+
export function setDestinationRegistry(registry: DestinationRegistry): void {
|
|
59
|
+
installed = registry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read the destination registry. Returns the installed registry, or a lazily
|
|
64
|
+
* built preset-only fallback so a self-booting task always resolves the
|
|
65
|
+
* always-on `webhook` + `posthog` presets even before any container ran.
|
|
66
|
+
*/
|
|
67
|
+
export function getDestinationRegistry(): DestinationRegistry {
|
|
68
|
+
if (installed) return installed;
|
|
69
|
+
if (!fallback) {
|
|
70
|
+
fallback = new DestinationRegistry(Object.values(PRESET_DESTINATIONS));
|
|
71
|
+
}
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Reset the installed registry — only for test cleanup. */
|
|
76
|
+
export function resetDestinationRegistry(): void {
|
|
77
|
+
installed = undefined;
|
|
78
|
+
}
|
package/src/env.ts
CHANGED
|
@@ -57,6 +57,12 @@ export const env = createEnv({
|
|
|
57
57
|
POSTHOG_API_KEY: z.string().min(1).optional(),
|
|
58
58
|
POSTHOG_HOST: z.string().url().optional(),
|
|
59
59
|
POSTHOG_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
60
|
+
// When true AND POSTHOG_API_KEY is set, the engine idempotently auto-seeds
|
|
61
|
+
// ONE kind="posthog" webhook endpoint subscribed to the email funnel so the
|
|
62
|
+
// full email lifecycle fans out to PostHog DURABLY (on the delivery spine).
|
|
63
|
+
// Default OFF to avoid a surprise double-emit alongside the existing
|
|
64
|
+
// fire-and-forget PostHog capture path.
|
|
65
|
+
ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
|
|
60
66
|
RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
61
67
|
ADMIN_API_KEY: z.string().min(1).optional(),
|
|
62
68
|
API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
|
|
@@ -71,6 +77,40 @@ export const env = createEnv({
|
|
|
71
77
|
ENABLED_LISTS: z.string().default("*"),
|
|
72
78
|
// Cadence for the engine-owned bucket reconcile cron (time-based leaves).
|
|
73
79
|
BUCKET_RECONCILE_CRON: z.string().default("*/5 * * * *"),
|
|
80
|
+
// --- Outbound webhooks (Section 1.5/1.8) ---
|
|
81
|
+
// Cadence for the engine-owned outbound-delivery reaper cron (the retry
|
|
82
|
+
// scheduler + orphan-`sending` recovery). Declared for parity with
|
|
83
|
+
// BUCKET_RECONCILE_CRON; the delivery task also reads it raw off process.env.
|
|
84
|
+
OUTBOUND_WEBHOOK_REAPER_CRON: z.string().optional(),
|
|
85
|
+
// Delivery tunables — read raw off process.env inside the durable task;
|
|
86
|
+
// declared here so they are part of the validated env contract. All optional
|
|
87
|
+
// with task-internal defaults (MAX_ATTEMPTS 8, TIMEOUT 15s, BASE 5s,
|
|
88
|
+
// MAX_DELAY 6h, STUCK_AFTER 5min).
|
|
89
|
+
OUTBOUND_WEBHOOK_MAX_ATTEMPTS: z.coerce.number().optional(),
|
|
90
|
+
OUTBOUND_WEBHOOK_TIMEOUT_MS: z.coerce.number().optional(),
|
|
91
|
+
OUTBOUND_WEBHOOK_BASE_DELAY_MS: z.coerce.number().optional(),
|
|
92
|
+
OUTBOUND_WEBHOOK_MAX_DELAY_MS: z.coerce.number().optional(),
|
|
93
|
+
OUTBOUND_WEBHOOK_STUCK_AFTER_MS: z.coerce.number().optional(),
|
|
94
|
+
// --- Integration presets (Section 2.2) ---
|
|
95
|
+
// Signature-source secrets. The webhook route resolves a preset's secret via
|
|
96
|
+
// env[source.auth.envKey]; a signature source FAILS CLOSED when its secret is
|
|
97
|
+
// unset. Setting one auto-enables that preset at POST /v1/webhooks/<id>.
|
|
98
|
+
CLERK_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
99
|
+
SUPABASE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
100
|
+
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
101
|
+
SEGMENT_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
102
|
+
// Preset enablement override: csv of preset ids, `"*"` (all with a secret),
|
|
103
|
+
// or `"none"`. Absent → auto-enable any preset whose secret is set.
|
|
104
|
+
ENABLED_WEBHOOK_PRESETS: z.string().optional(),
|
|
105
|
+
// --- Outbound destination presets (Phase 3) ---
|
|
106
|
+
// Which `defineDestination()` PRESETS are registered into the process
|
|
107
|
+
// destination registry the delivery task resolves by `endpoint.kind`. csv of
|
|
108
|
+
// ids (e.g. "segment,slack"), `"*"` (all presets), or `"none"`. Absent → the
|
|
109
|
+
// DEFAULT set (webhook + posthog). The `webhook` and `posthog` presets are
|
|
110
|
+
// ALWAYS registered regardless of this value, so the no-regression delivery
|
|
111
|
+
// path can never be turned off by misconfiguration. Set this to add the
|
|
112
|
+
// segment/slack presets (credentials still live per-endpoint in `config`).
|
|
113
|
+
ENABLED_DESTINATION_PRESETS: z.string().optional(),
|
|
74
114
|
},
|
|
75
115
|
runtimeEnv: process.env,
|
|
76
116
|
emptyStringAsUndefined: true,
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,6 @@ export type {
|
|
|
16
16
|
PostHogService,
|
|
17
17
|
SendResult,
|
|
18
18
|
WebhookEvent,
|
|
19
|
-
WebhookEventType,
|
|
20
19
|
WebhookHandlerMap,
|
|
21
20
|
} from "@hogsend/core";
|
|
22
21
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
@@ -77,6 +76,31 @@ export {
|
|
|
77
76
|
type HogsendClientOptions,
|
|
78
77
|
type HogsendDefaults,
|
|
79
78
|
} from "./container.js";
|
|
79
|
+
// --- Outbound destinations: public authoring layer (Phase 3) ---
|
|
80
|
+
export {
|
|
81
|
+
type DefinedDestination,
|
|
82
|
+
type DestinationCtx,
|
|
83
|
+
type DestinationEnvelope,
|
|
84
|
+
type DestinationMeta,
|
|
85
|
+
type DestinationTransformResult,
|
|
86
|
+
defineDestination,
|
|
87
|
+
type WebhookEndpointRow,
|
|
88
|
+
} from "./destinations/define-destination.js";
|
|
89
|
+
export {
|
|
90
|
+
type DestinationPresetId,
|
|
91
|
+
destinationsFromEnv,
|
|
92
|
+
PRESET_DESTINATIONS,
|
|
93
|
+
posthogDestination,
|
|
94
|
+
segmentDestination,
|
|
95
|
+
slackDestination,
|
|
96
|
+
webhookDestination,
|
|
97
|
+
} from "./destinations/presets/index.js";
|
|
98
|
+
export {
|
|
99
|
+
DestinationRegistry,
|
|
100
|
+
getDestinationRegistry,
|
|
101
|
+
resetDestinationRegistry,
|
|
102
|
+
setDestinationRegistry,
|
|
103
|
+
} from "./destinations/registry-singleton.js";
|
|
80
104
|
// --- Env ---
|
|
81
105
|
export { API_VERSION, env } from "./env.js";
|
|
82
106
|
// --- Journeys ---
|
|
@@ -150,6 +174,13 @@ export {
|
|
|
150
174
|
// --- Logging ---
|
|
151
175
|
export { createLogger, type Logger } from "./lib/logger.js";
|
|
152
176
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
177
|
+
// --- Outbound webhooks: emit spine (Section 1.4) ---
|
|
178
|
+
export {
|
|
179
|
+
emitOutbound,
|
|
180
|
+
OUTBOUND_EVENTS,
|
|
181
|
+
type OutboundEventName,
|
|
182
|
+
type OutboundPayloads,
|
|
183
|
+
} from "./lib/outbound.js";
|
|
153
184
|
export { getPostHog } from "./lib/posthog.js";
|
|
154
185
|
export { getRedisIfConnected } from "./lib/redis.js";
|
|
155
186
|
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
@@ -174,7 +205,17 @@ export {
|
|
|
174
205
|
export {
|
|
175
206
|
pushTrackingEvent,
|
|
176
207
|
resolveEmailSendContext,
|
|
208
|
+
resolveEmailSendContextByResendId,
|
|
177
209
|
} from "./lib/tracking-events.js";
|
|
210
|
+
// --- Outbound webhooks: signing core (Section 1.2) ---
|
|
211
|
+
export {
|
|
212
|
+
generateWebhookSecret,
|
|
213
|
+
type SignedWebhook,
|
|
214
|
+
signWebhook,
|
|
215
|
+
verifyWebhookSignature,
|
|
216
|
+
WEBHOOK_EVENT_TYPES,
|
|
217
|
+
type WebhookEventType,
|
|
218
|
+
} from "./lib/webhook-signing.js";
|
|
178
219
|
// --- Lists (D3) ---
|
|
179
220
|
export {
|
|
180
221
|
type DefinedList,
|
|
@@ -191,9 +232,21 @@ export {
|
|
|
191
232
|
export {
|
|
192
233
|
type DefinedWebhookSource,
|
|
193
234
|
defineWebhookSource,
|
|
235
|
+
verifySignature,
|
|
236
|
+
type WebhookSourceAuth,
|
|
194
237
|
type WebhookSourceCtx,
|
|
195
238
|
type WebhookSourceMeta,
|
|
196
239
|
} from "./webhook-sources/define-webhook-source.js";
|
|
240
|
+
// --- Integration presets (Section 2.3/2.4) ---
|
|
241
|
+
export {
|
|
242
|
+
clerkSource,
|
|
243
|
+
PRESET_SOURCES,
|
|
244
|
+
type PresetId,
|
|
245
|
+
presetsFromEnv,
|
|
246
|
+
segmentSource,
|
|
247
|
+
stripeSource,
|
|
248
|
+
supabaseSource,
|
|
249
|
+
} from "./webhook-sources/presets/index.js";
|
|
197
250
|
export {
|
|
198
251
|
type CreateWorkerOptions,
|
|
199
252
|
createWorker,
|
|
@@ -211,6 +264,11 @@ export {
|
|
|
211
264
|
bucketReconcileTask,
|
|
212
265
|
} from "./workflows/bucket-reconcile.js";
|
|
213
266
|
export { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
267
|
+
// --- Outbound webhooks: durable delivery task + reaper (Section 1.5) ---
|
|
268
|
+
export {
|
|
269
|
+
deliverWebhookTask,
|
|
270
|
+
reapDueWebhookDeliveriesTask,
|
|
271
|
+
} from "./workflows/deliver-webhook.js";
|
|
214
272
|
export { importContactsTask } from "./workflows/import-contacts.js";
|
|
215
273
|
export { sendCampaignTask } from "./workflows/send-campaign.js";
|
|
216
274
|
// --- Built-in Hatchet workflow tasks ---
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "../lib/enrollment-guards.js";
|
|
16
16
|
import { hatchet } from "../lib/hatchet.js";
|
|
17
17
|
import { createLogger } from "../lib/logger.js";
|
|
18
|
+
import { emitOutbound } from "../lib/outbound.js";
|
|
18
19
|
import { resolveTimezoneWithSource } from "../lib/timezone.js";
|
|
19
20
|
import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
|
|
20
21
|
import { JOURNEY_EXECUTION_TIMEOUT } from "./constants.js";
|
|
@@ -178,7 +179,6 @@ export function defineJourney(options: {
|
|
|
178
179
|
hatchetCtx,
|
|
179
180
|
registry: getJourneyRegistrySingleton(),
|
|
180
181
|
logger,
|
|
181
|
-
posthog,
|
|
182
182
|
stateId,
|
|
183
183
|
userId,
|
|
184
184
|
userEmail,
|
|
@@ -190,12 +190,13 @@ export function defineJourney(options: {
|
|
|
190
190
|
try {
|
|
191
191
|
await options.run(user, ctx);
|
|
192
192
|
|
|
193
|
+
const completedAt = new Date();
|
|
193
194
|
await db
|
|
194
195
|
.update(journeyStates)
|
|
195
196
|
.set({
|
|
196
197
|
status: "completed",
|
|
197
|
-
completedAt
|
|
198
|
-
updatedAt:
|
|
198
|
+
completedAt,
|
|
199
|
+
updatedAt: completedAt,
|
|
199
200
|
})
|
|
200
201
|
.where(eq(journeyStates.id, stateId));
|
|
201
202
|
|
|
@@ -205,6 +206,28 @@ export function defineJourney(options: {
|
|
|
205
206
|
userId,
|
|
206
207
|
});
|
|
207
208
|
|
|
209
|
+
// OUTBOUND `journey.completed` — fired alongside the internal
|
|
210
|
+
// `journey:completed` push. Runs in the WORKER (this durable task), so it
|
|
211
|
+
// uses the engine `db`/`hatchet`/`logger` singletons. `dedupeKey` =
|
|
212
|
+
// `journey.completed:<stateId>`: a Hatchet re-execution recomputes the
|
|
213
|
+
// identical key and the unique `(endpointId, dedupeKey)` index absorbs the
|
|
214
|
+
// duplicate (risk 3). `journey:failed` is NOT in the catalog → no emit.
|
|
215
|
+
void emitOutbound({
|
|
216
|
+
db,
|
|
217
|
+
hatchet,
|
|
218
|
+
logger,
|
|
219
|
+
event: "journey.completed",
|
|
220
|
+
dedupeKey: `journey.completed:${stateId}`,
|
|
221
|
+
payload: {
|
|
222
|
+
journeyId: meta.id,
|
|
223
|
+
journeyName: meta.name,
|
|
224
|
+
stateId,
|
|
225
|
+
userId,
|
|
226
|
+
userEmail,
|
|
227
|
+
completedAt: completedAt.toISOString(),
|
|
228
|
+
},
|
|
229
|
+
}).catch(logger.warn);
|
|
230
|
+
|
|
208
231
|
return { stateId, status: "completed" };
|
|
209
232
|
} catch (err) {
|
|
210
233
|
// The journey reached a terminal state (exitOn / cancel) while suspended
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
SleepCondition,
|
|
8
8
|
UserEventCondition,
|
|
9
9
|
} from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
10
|
-
import type { DurationObject
|
|
10
|
+
import type { DurationObject } from "@hogsend/core";
|
|
11
11
|
import { durationToMs, evaluateEventCondition } from "@hogsend/core";
|
|
12
12
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
13
13
|
import {
|
|
@@ -69,7 +69,6 @@ interface JourneyContextConfig {
|
|
|
69
69
|
};
|
|
70
70
|
registry: JourneyRegistry;
|
|
71
71
|
logger: Logger;
|
|
72
|
-
posthog?: PostHogService;
|
|
73
72
|
stateId: string;
|
|
74
73
|
userId: string;
|
|
75
74
|
userEmail: string;
|
|
@@ -140,7 +139,6 @@ export function createJourneyContext(
|
|
|
140
139
|
hatchetCtx,
|
|
141
140
|
registry,
|
|
142
141
|
logger,
|
|
143
|
-
posthog,
|
|
144
142
|
stateId,
|
|
145
143
|
userId,
|
|
146
144
|
userEmail,
|
|
@@ -316,10 +314,6 @@ export function createJourneyContext(
|
|
|
316
314
|
});
|
|
317
315
|
},
|
|
318
316
|
|
|
319
|
-
identify(properties) {
|
|
320
|
-
posthog?.identify(userId, properties);
|
|
321
|
-
},
|
|
322
|
-
|
|
323
317
|
guard: {
|
|
324
318
|
async isSubscribed() {
|
|
325
319
|
const prefs = await checkEmailPreferences({ db, userId });
|
|
@@ -390,15 +384,5 @@ export function createJourneyContext(
|
|
|
390
384
|
};
|
|
391
385
|
},
|
|
392
386
|
},
|
|
393
|
-
|
|
394
|
-
posthog: {
|
|
395
|
-
capture({ event, properties }) {
|
|
396
|
-
posthog?.captureEvent({
|
|
397
|
-
distinctId: userId,
|
|
398
|
-
event,
|
|
399
|
-
properties,
|
|
400
|
-
});
|
|
401
|
-
},
|
|
402
|
-
},
|
|
403
387
|
};
|
|
404
388
|
}
|
|
@@ -6,6 +6,13 @@ import { createOptionalSingleton } from "./singleton.js";
|
|
|
6
6
|
* the module-level task-execution sites that have no client reference of their
|
|
7
7
|
* own (the journey durable task in `define-journey`, the bucket PostHog sync).
|
|
8
8
|
*
|
|
9
|
+
* Its role is deliberately NARROW (see the `analytics?` option doc on
|
|
10
|
+
* {@link createHogsendClient}): the identity PULL (`getPersonProperties` for
|
|
11
|
+
* per-user timezone resolution) and the opt-in `bucket.syncToPostHog`
|
|
12
|
+
* person-property mirror. It is explicitly NOT the outbound-catalog firing
|
|
13
|
+
* path — the email/contact/journey/bucket lifecycle fans out durably via
|
|
14
|
+
* DESTINATIONS on the webhook spine, not through this singleton.
|
|
15
|
+
*
|
|
9
16
|
* Mirrors the journey/bucket-registry + client-schedule-defaults singletons:
|
|
10
17
|
* `createHogsendClient` runs in BOTH the API and worker processes, so by the
|
|
11
18
|
* time any worker task executes, the container has already installed the
|
package/src/lib/bucket-emit.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { BucketLeaveReason } from "../buckets/bucket-reactions.js";
|
|
|
6
6
|
import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
|
|
7
7
|
import { ingestEvent } from "./ingestion.js";
|
|
8
8
|
import type { Logger } from "./logger.js";
|
|
9
|
+
import { emitOutbound } from "./outbound.js";
|
|
9
10
|
|
|
10
11
|
export type BucketTransitionKind = "entered" | "left" | "dwell";
|
|
11
12
|
|
|
@@ -146,4 +147,48 @@ export async function emitBucketTransition(opts: {
|
|
|
146
147
|
},
|
|
147
148
|
});
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
// OUTBOUND `bucket.entered` / `bucket.left` — this is the SINGLE producer for
|
|
152
|
+
// all three transition origins (real-time / reconcile / fast-expiry), so the
|
|
153
|
+
// emit lives here once. GATED to enter/leave (dwell is a recurring membership
|
|
154
|
+
// tick, not in the catalog — same gate as the PostHog mirror above). The
|
|
155
|
+
// `dedupeKey` is the SAME deterministic `idempotencyKey` all three producers
|
|
156
|
+
// compute, so a Hatchet re-execution converges to ONE emission via the unique
|
|
157
|
+
// `(endpointId, dedupeKey)` index (risk 3). Fire-and-forget.
|
|
158
|
+
if (kind === "entered") {
|
|
159
|
+
void emitOutbound({
|
|
160
|
+
db,
|
|
161
|
+
hatchet,
|
|
162
|
+
logger,
|
|
163
|
+
event: "bucket.entered",
|
|
164
|
+
dedupeKey: idempotencyKey,
|
|
165
|
+
payload: {
|
|
166
|
+
bucketId: bucket.id,
|
|
167
|
+
bucketName: bucket.name,
|
|
168
|
+
userId,
|
|
169
|
+
userEmail,
|
|
170
|
+
transition: "entered",
|
|
171
|
+
entryCount: epoch,
|
|
172
|
+
source,
|
|
173
|
+
},
|
|
174
|
+
}).catch(logger.warn);
|
|
175
|
+
} else if (kind === "left") {
|
|
176
|
+
void emitOutbound({
|
|
177
|
+
db,
|
|
178
|
+
hatchet,
|
|
179
|
+
logger,
|
|
180
|
+
event: "bucket.left",
|
|
181
|
+
dedupeKey: idempotencyKey,
|
|
182
|
+
payload: {
|
|
183
|
+
bucketId: bucket.id,
|
|
184
|
+
bucketName: bucket.name,
|
|
185
|
+
userId,
|
|
186
|
+
userEmail,
|
|
187
|
+
transition: "left",
|
|
188
|
+
entryCount: epoch,
|
|
189
|
+
source,
|
|
190
|
+
...(reason != null ? { reason } : {}),
|
|
191
|
+
},
|
|
192
|
+
}).catch(logger.warn);
|
|
193
|
+
}
|
|
149
194
|
}
|
package/src/lib/contacts.ts
CHANGED
|
@@ -38,7 +38,7 @@ export async function resolveContact(opts: { db: Database; id: string }) {
|
|
|
38
38
|
return rows[0] ?? null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
interface SerializedContact {
|
|
41
|
+
export interface SerializedContact {
|
|
42
42
|
id: string;
|
|
43
43
|
externalId: string | null;
|
|
44
44
|
email: string | null;
|
|
@@ -1007,13 +1007,23 @@ export async function findContacts(opts: {
|
|
|
1007
1007
|
|
|
1008
1008
|
/**
|
|
1009
1009
|
* Soft-delete a contact resolved by email or external id (sets `deletedAt`).
|
|
1010
|
-
*
|
|
1010
|
+
*
|
|
1011
|
+
* Returns `{ deleted }` plus the soft-deleted row's identity (`id`,
|
|
1012
|
+
* `externalId`, `email`) so the delete route can both make its 404 decision
|
|
1013
|
+
* (`deleted`) AND emit the `contact.deleted` outbound webhook with the real
|
|
1014
|
+
* identity — without a second read-back. `deleted` is false (and the identity
|
|
1015
|
+
* fields absent) when no live row matched.
|
|
1011
1016
|
*/
|
|
1012
1017
|
export async function softDeleteContact(opts: {
|
|
1013
1018
|
db: Database;
|
|
1014
1019
|
email?: string;
|
|
1015
1020
|
userId?: string;
|
|
1016
|
-
}): Promise<
|
|
1021
|
+
}): Promise<{
|
|
1022
|
+
deleted: boolean;
|
|
1023
|
+
id?: string;
|
|
1024
|
+
externalId?: string | null;
|
|
1025
|
+
email?: string | null;
|
|
1026
|
+
}> {
|
|
1017
1027
|
const { db } = opts;
|
|
1018
1028
|
const email = opts.email ? normalizeEmail(opts.email) : undefined;
|
|
1019
1029
|
const userId = opts.userId?.trim() || undefined;
|
|
@@ -1021,15 +1031,27 @@ export async function softDeleteContact(opts: {
|
|
|
1021
1031
|
const clauses = [];
|
|
1022
1032
|
if (email) clauses.push(eq(contacts.email, email));
|
|
1023
1033
|
if (userId) clauses.push(eq(contacts.externalId, userId));
|
|
1024
|
-
if (clauses.length === 0) return false;
|
|
1034
|
+
if (clauses.length === 0) return { deleted: false };
|
|
1025
1035
|
|
|
1026
1036
|
const updated = await db
|
|
1027
1037
|
.update(contacts)
|
|
1028
1038
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
|
1029
1039
|
.where(and(or(...clauses), isNull(contacts.deletedAt)))
|
|
1030
|
-
.returning({
|
|
1040
|
+
.returning({
|
|
1041
|
+
id: contacts.id,
|
|
1042
|
+
externalId: contacts.externalId,
|
|
1043
|
+
email: contacts.email,
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
const row = updated[0];
|
|
1047
|
+
if (!row) return { deleted: false };
|
|
1031
1048
|
|
|
1032
|
-
return
|
|
1049
|
+
return {
|
|
1050
|
+
deleted: true,
|
|
1051
|
+
id: row.id,
|
|
1052
|
+
externalId: row.externalId,
|
|
1053
|
+
email: row.email,
|
|
1054
|
+
};
|
|
1033
1055
|
}
|
|
1034
1056
|
|
|
1035
1057
|
/**
|
package/src/lib/mailer.ts
CHANGED
|
@@ -24,8 +24,17 @@ import type {
|
|
|
24
24
|
SendResult,
|
|
25
25
|
TrackedSendResult,
|
|
26
26
|
} from "./email-service-types.js";
|
|
27
|
+
import { hatchet } from "./hatchet.js";
|
|
28
|
+
import { createLogger } from "./logger.js";
|
|
29
|
+
import { emitOutbound } from "./outbound.js";
|
|
27
30
|
import type { PrepareTrackedHtmlFn } from "./tracked.js";
|
|
28
31
|
import { sendTrackedEmail } from "./tracked.js";
|
|
32
|
+
import { resolveEmailSendContextByResendId } from "./tracking-events.js";
|
|
33
|
+
|
|
34
|
+
// Fallback logger for the provider-webhook outbound emit — `config.logger` is
|
|
35
|
+
// optional, but `emitOutbound` requires one. Mirrors the engine-lib singleton
|
|
36
|
+
// pattern (define-journey, preferences, tracked).
|
|
37
|
+
const emitLogger = createLogger(process.env.LOG_LEVEL);
|
|
29
38
|
|
|
30
39
|
const WEBHOOK_TO_STATUS_FIELD: Partial<
|
|
31
40
|
Record<WebhookEventType, keyof typeof emailSends.$inferSelect>
|
|
@@ -187,9 +196,24 @@ export function createTrackedMailer(
|
|
|
187
196
|
): Promise<boolean> {
|
|
188
197
|
switch (event.type) {
|
|
189
198
|
case "email.sent":
|
|
199
|
+
// `email.sent` is emitted FIRST-PARTY from the tracked mailer's
|
|
200
|
+
// provider-accepted branch (lib/tracked.ts) with the rich payload — the
|
|
201
|
+
// provider-webhook echo only updates the DB status, it does NOT emit.
|
|
202
|
+
await updateEmailStatus(event.type, event.data.email_id);
|
|
203
|
+
break;
|
|
190
204
|
case "email.delivered":
|
|
205
|
+
await updateEmailStatus(event.type, event.data.email_id);
|
|
206
|
+
// OUTBOUND `email.delivered` — the provider webhook is the SINGLE source
|
|
207
|
+
// for delivered/bounced (these have no first-party signal).
|
|
208
|
+
await emitProviderEmailEvent("email.delivered", event.data.email_id);
|
|
209
|
+
break;
|
|
191
210
|
case "email.opened":
|
|
192
211
|
case "email.clicked":
|
|
212
|
+
// First-party pixel/redirect is the SINGLE outbound emitter for
|
|
213
|
+
// open/click — it now fires PER-HIT (every open/click → a delivery to
|
|
214
|
+
// every destination, owner decision 1). The provider-webhook echo is
|
|
215
|
+
// SUPPRESSED here: it only updates the DB status, it does NOT emit
|
|
216
|
+
// outbound (no double-source).
|
|
193
217
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
194
218
|
break;
|
|
195
219
|
case "email.bounced":
|
|
@@ -197,10 +221,18 @@ export function createTrackedMailer(
|
|
|
197
221
|
bounceType: event.data.bounce?.type,
|
|
198
222
|
bounceReason: event.data.bounce?.message,
|
|
199
223
|
});
|
|
224
|
+
// OUTBOUND `email.bounced` with the bounce detail.
|
|
225
|
+
await emitProviderEmailEvent("email.bounced", event.data.email_id, {
|
|
226
|
+
bounceType: event.data.bounce?.type,
|
|
227
|
+
bounceReason: event.data.bounce?.message,
|
|
228
|
+
});
|
|
200
229
|
await handleBounce(event.data.to);
|
|
201
230
|
break;
|
|
202
231
|
case "email.complained":
|
|
203
232
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
233
|
+
// OUTBOUND `email.complained` — the provider webhook is the SINGLE
|
|
234
|
+
// source for complaints (no first-party signal exists).
|
|
235
|
+
await emitProviderEmailEvent("email.complained", event.data.email_id);
|
|
204
236
|
await handleComplaint(event.data.to);
|
|
205
237
|
break;
|
|
206
238
|
case "email.delivery_delayed":
|
|
@@ -250,6 +282,76 @@ export function createTrackedMailer(
|
|
|
250
282
|
.where(eq(emailPreferences.email, email));
|
|
251
283
|
}
|
|
252
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Emit the provider-funnel outbound event (`email.delivered` /
|
|
287
|
+
* `email.bounced` / `email.complained`) for a Resend `email_id`. These three
|
|
288
|
+
* have no first-party signal — the provider webhook is their single source.
|
|
289
|
+
* Enriches via {@link resolveEmailSendContextByResendId}
|
|
290
|
+
* (the only handle a provider webhook holds is the Resend id). Fire-and-forget:
|
|
291
|
+
* a missing context (webhook racing the send-row commit) or a transient outbound
|
|
292
|
+
* error is logged and swallowed — never failing the webhook handler. No
|
|
293
|
+
* `dedupeKey`: the provider path is not a Hatchet-retryable producer, and the
|
|
294
|
+
* shared `Webhook-Id` is the subscriber-side dedup for any provider redelivery.
|
|
295
|
+
*/
|
|
296
|
+
function emitProviderEmailEvent(
|
|
297
|
+
event: "email.delivered" | "email.bounced" | "email.complained",
|
|
298
|
+
resendId: string,
|
|
299
|
+
bounce?: { bounceType?: string; bounceReason?: string },
|
|
300
|
+
): void {
|
|
301
|
+
if (!db) return;
|
|
302
|
+
const log = config.logger ?? emitLogger;
|
|
303
|
+
const database = db;
|
|
304
|
+
void resolveEmailSendContextByResendId(database, resendId)
|
|
305
|
+
.then((ctx) => {
|
|
306
|
+
if (!ctx) return;
|
|
307
|
+
const base = {
|
|
308
|
+
emailSendId: ctx.emailSendId,
|
|
309
|
+
resendId,
|
|
310
|
+
templateKey: ctx.templateKey,
|
|
311
|
+
userId: ctx.userId,
|
|
312
|
+
to: ctx.to,
|
|
313
|
+
at: new Date().toISOString(),
|
|
314
|
+
};
|
|
315
|
+
if (event === "email.bounced") {
|
|
316
|
+
return emitOutbound({
|
|
317
|
+
db: database,
|
|
318
|
+
hatchet,
|
|
319
|
+
logger: log,
|
|
320
|
+
event: "email.bounced",
|
|
321
|
+
payload: {
|
|
322
|
+
...base,
|
|
323
|
+
...(bounce?.bounceType ? { bounceType: bounce.bounceType } : {}),
|
|
324
|
+
...(bounce?.bounceReason
|
|
325
|
+
? { bounceReason: bounce.bounceReason }
|
|
326
|
+
: {}),
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (event === "email.complained") {
|
|
331
|
+
return emitOutbound({
|
|
332
|
+
db: database,
|
|
333
|
+
hatchet,
|
|
334
|
+
logger: log,
|
|
335
|
+
event: "email.complained",
|
|
336
|
+
payload: base,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return emitOutbound({
|
|
340
|
+
db: database,
|
|
341
|
+
hatchet,
|
|
342
|
+
logger: log,
|
|
343
|
+
event: "email.delivered",
|
|
344
|
+
payload: base,
|
|
345
|
+
});
|
|
346
|
+
})
|
|
347
|
+
.catch((err: unknown) => {
|
|
348
|
+
log.warn(`emitOutbound ${event} failed`, {
|
|
349
|
+
resendId,
|
|
350
|
+
error: err instanceof Error ? err.message : String(err),
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
253
355
|
async function updateEmailStatus(
|
|
254
356
|
eventType: WebhookEventType,
|
|
255
357
|
resendId: string,
|