@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,223 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
3
|
+
import {
|
|
4
|
+
type Database,
|
|
5
|
+
webhookDeliveries,
|
|
6
|
+
webhookEndpoints,
|
|
7
|
+
} from "@hogsend/db";
|
|
8
|
+
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
9
|
+
import { deliverWebhookTask } from "../workflows/deliver-webhook.js";
|
|
10
|
+
import type { SerializedContact } from "./contacts.js";
|
|
11
|
+
import type { Logger } from "./logger.js";
|
|
12
|
+
import {
|
|
13
|
+
WEBHOOK_EVENT_TYPES,
|
|
14
|
+
type WebhookEventType,
|
|
15
|
+
} from "./webhook-signing.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
type ResendEmailSendContext,
|
|
19
|
+
resolveEmailSendContextByResendId,
|
|
20
|
+
} from "./tracking-events.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The outbound event catalog re-exported for the emit spine. Identical to the
|
|
24
|
+
* signing lib's {@link WEBHOOK_EVENT_TYPES} — the single source of truth.
|
|
25
|
+
*/
|
|
26
|
+
export const OUTBOUND_EVENTS = WEBHOOK_EVENT_TYPES;
|
|
27
|
+
export type OutboundEventName = WebhookEventType;
|
|
28
|
+
|
|
29
|
+
interface EmailEventPayload {
|
|
30
|
+
emailSendId: string;
|
|
31
|
+
resendId: string | null;
|
|
32
|
+
templateKey: string | null;
|
|
33
|
+
userId: string | null;
|
|
34
|
+
to: string;
|
|
35
|
+
at: string;
|
|
36
|
+
// Optional enrichment (additive — older subscribers ignore absent keys).
|
|
37
|
+
category?: string;
|
|
38
|
+
subject?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface BucketEventPayload {
|
|
42
|
+
bucketId: string;
|
|
43
|
+
bucketName: string;
|
|
44
|
+
userId: string;
|
|
45
|
+
userEmail: string | null;
|
|
46
|
+
transition: "entered" | "left";
|
|
47
|
+
entryCount: number;
|
|
48
|
+
source: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The typed per-event payload map. `data` in each delivered envelope is exactly
|
|
53
|
+
* `OutboundPayloads[E]` for the emitted event `E`. Producers (the 12 hook
|
|
54
|
+
* points) construct these; subscribers receive them under `envelope.data`.
|
|
55
|
+
*/
|
|
56
|
+
export interface OutboundPayloads {
|
|
57
|
+
"contact.created": SerializedContact;
|
|
58
|
+
"contact.updated": SerializedContact;
|
|
59
|
+
"contact.deleted": {
|
|
60
|
+
id: string;
|
|
61
|
+
externalId: string | null;
|
|
62
|
+
email: string | null;
|
|
63
|
+
};
|
|
64
|
+
"contact.unsubscribed": {
|
|
65
|
+
externalId: string | null;
|
|
66
|
+
email: string | null;
|
|
67
|
+
category: string | null;
|
|
68
|
+
scope: "all" | "category";
|
|
69
|
+
};
|
|
70
|
+
"email.sent": {
|
|
71
|
+
emailSendId: string;
|
|
72
|
+
resendId: string;
|
|
73
|
+
templateKey: string | null;
|
|
74
|
+
to: string;
|
|
75
|
+
userId: string | null;
|
|
76
|
+
category: string | null;
|
|
77
|
+
journeyStateId: string | null;
|
|
78
|
+
subject: string;
|
|
79
|
+
sentAt: string;
|
|
80
|
+
};
|
|
81
|
+
"email.delivered": EmailEventPayload;
|
|
82
|
+
"email.opened": EmailEventPayload;
|
|
83
|
+
"email.clicked": EmailEventPayload & { linkUrl?: string; linkId?: string };
|
|
84
|
+
"email.bounced": EmailEventPayload & {
|
|
85
|
+
bounceType?: string;
|
|
86
|
+
bounceReason?: string;
|
|
87
|
+
};
|
|
88
|
+
"email.complained": EmailEventPayload & {
|
|
89
|
+
complaintType?: string;
|
|
90
|
+
reason?: string;
|
|
91
|
+
};
|
|
92
|
+
"journey.completed": {
|
|
93
|
+
journeyId: string;
|
|
94
|
+
journeyName: string;
|
|
95
|
+
stateId: string;
|
|
96
|
+
userId: string;
|
|
97
|
+
userEmail: string;
|
|
98
|
+
completedAt: string;
|
|
99
|
+
};
|
|
100
|
+
"bucket.entered": BucketEventPayload;
|
|
101
|
+
"bucket.left": BucketEventPayload & { reason?: string };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The signed envelope shape written to `webhook_deliveries.payload` and sent
|
|
106
|
+
* verbatim to subscribers. `id` is the shared `Webhook-Id`; `timestamp` is the
|
|
107
|
+
* logical-event time (ISO); `data` is the typed per-event payload.
|
|
108
|
+
*/
|
|
109
|
+
interface OutboundEnvelope<E extends OutboundEventName> {
|
|
110
|
+
id: string;
|
|
111
|
+
type: E;
|
|
112
|
+
timestamp: string;
|
|
113
|
+
data: OutboundPayloads[E];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* THE fire-and-forget emit spine. It does NOT deliver — it selects the active,
|
|
118
|
+
* subscribed endpoints for `event`, inserts one `webhook_deliveries` row per
|
|
119
|
+
* endpoint (all sharing ONE `webhookId` = the `Webhook-Id` header), and enqueues
|
|
120
|
+
* the durable {@link deliverWebhookTask} per inserted row.
|
|
121
|
+
*
|
|
122
|
+
* Idempotency: when `dedupeKey` is provided, the unique
|
|
123
|
+
* `(endpointId, dedupeKey)` index makes a re-emit (e.g. a Hatchet retry of the
|
|
124
|
+
* producing task) a no-op via `onConflictDoNothing` — no duplicate row, no
|
|
125
|
+
* second enqueue. Events without a `dedupeKey` are never deduped (NULL keys are
|
|
126
|
+
* distinct in Postgres), which is correct for non-retryable emit points.
|
|
127
|
+
*
|
|
128
|
+
* NEVER throws to callers. Internal failures (endpoint select, insert, enqueue)
|
|
129
|
+
* are logged via `logger.warn` and swallowed so a transient outbound error can
|
|
130
|
+
* never fail a contact upsert / email send / journey step. Callers MUST STILL
|
|
131
|
+
* wrap the call as `void emitOutbound(...).catch(logger.warn)` — the `.catch` is
|
|
132
|
+
* defence-in-depth against a programming error that escapes this guard.
|
|
133
|
+
*
|
|
134
|
+
* Single-tenant: only endpoints with `organizationId IS NULL` are selected and
|
|
135
|
+
* the delivery rows are written with `organizationId ?? null`. Multi-tenant
|
|
136
|
+
* scoping is a later non-breaking change.
|
|
137
|
+
*/
|
|
138
|
+
export async function emitOutbound<E extends OutboundEventName>(opts: {
|
|
139
|
+
db: Database;
|
|
140
|
+
hatchet: HatchetClient;
|
|
141
|
+
logger: Logger;
|
|
142
|
+
event: E;
|
|
143
|
+
payload: OutboundPayloads[E];
|
|
144
|
+
dedupeKey?: string;
|
|
145
|
+
organizationId?: string | null;
|
|
146
|
+
}): Promise<void> {
|
|
147
|
+
const { db, logger, event, payload, dedupeKey } = opts;
|
|
148
|
+
const organizationId = opts.organizationId ?? null;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const webhookId = `msg_${randomUUID()}`;
|
|
152
|
+
const timestamp = new Date();
|
|
153
|
+
|
|
154
|
+
// (2) Active, subscribed endpoints. `event_types @> '["<event>"]'` matches
|
|
155
|
+
// the jsonb array containing this event. Single-tenant: organizationId IS
|
|
156
|
+
// NULL (NOT a hardcoded tenant — keeps the MT wiring non-breaking).
|
|
157
|
+
const endpoints = await db
|
|
158
|
+
.select({ id: webhookEndpoints.id })
|
|
159
|
+
.from(webhookEndpoints)
|
|
160
|
+
.where(
|
|
161
|
+
and(
|
|
162
|
+
eq(webhookEndpoints.disabled, false),
|
|
163
|
+
isNull(webhookEndpoints.organizationId),
|
|
164
|
+
sql`${webhookEndpoints.eventTypes} @> ${JSON.stringify([event])}::jsonb`,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (endpoints.length === 0) return;
|
|
169
|
+
|
|
170
|
+
// (3) The frozen envelope — signed + sent verbatim by the delivery task.
|
|
171
|
+
const envelope: OutboundEnvelope<E> = {
|
|
172
|
+
id: webhookId,
|
|
173
|
+
type: event,
|
|
174
|
+
timestamp: timestamp.toISOString(),
|
|
175
|
+
data: payload,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// (4) One delivery row per endpoint, sharing the webhookId. onConflictDoNothing
|
|
179
|
+
// on (endpointId, dedupeKey) is the producer-side fan-out idempotency guard.
|
|
180
|
+
const inserted = await db
|
|
181
|
+
.insert(webhookDeliveries)
|
|
182
|
+
.values(
|
|
183
|
+
endpoints.map((endpoint) => ({
|
|
184
|
+
endpointId: endpoint.id,
|
|
185
|
+
organizationId,
|
|
186
|
+
webhookId,
|
|
187
|
+
eventType: event,
|
|
188
|
+
dedupeKey: dedupeKey ?? null,
|
|
189
|
+
payload: envelope as unknown as Record<string, unknown>,
|
|
190
|
+
status: "pending" as const,
|
|
191
|
+
attemptCount: 0,
|
|
192
|
+
nextRetryAt: timestamp,
|
|
193
|
+
})),
|
|
194
|
+
)
|
|
195
|
+
.onConflictDoNothing({
|
|
196
|
+
target: [webhookDeliveries.endpointId, webhookDeliveries.dedupeKey],
|
|
197
|
+
})
|
|
198
|
+
.returning({ id: webhookDeliveries.id });
|
|
199
|
+
|
|
200
|
+
// (5) Enqueue the durable delivery task per freshly-inserted row,
|
|
201
|
+
// fire-and-forget. A failed enqueue is recovered by the reaper (the row is
|
|
202
|
+
// already `pending` with `nextRetryAt <= now`), so a broker hiccup here only
|
|
203
|
+
// delays — never drops — a delivery.
|
|
204
|
+
for (const row of inserted) {
|
|
205
|
+
void deliverWebhookTask
|
|
206
|
+
.runNoWait({ deliveryId: row.id })
|
|
207
|
+
.catch((error: unknown) => {
|
|
208
|
+
logger.warn("emitOutbound: deliverWebhookTask enqueue failed", {
|
|
209
|
+
deliveryId: row.id,
|
|
210
|
+
event,
|
|
211
|
+
error: error instanceof Error ? error.message : String(error),
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// FAIL-SAFE: never propagate an outbound error onto the producer's hot path.
|
|
217
|
+
logger.warn("emitOutbound failed", {
|
|
218
|
+
event,
|
|
219
|
+
dedupeKey,
|
|
220
|
+
error: error instanceof Error ? error.message : String(error),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/lib/preferences.ts
CHANGED
|
@@ -2,6 +2,11 @@ import type { Database } from "@hogsend/db";
|
|
|
2
2
|
import { emailPreferences } from "@hogsend/db";
|
|
3
3
|
import { sql } from "drizzle-orm";
|
|
4
4
|
import { resolveRecipient } from "./contacts.js";
|
|
5
|
+
import { hatchet } from "./hatchet.js";
|
|
6
|
+
import { createLogger } from "./logger.js";
|
|
7
|
+
import { emitOutbound } from "./outbound.js";
|
|
8
|
+
|
|
9
|
+
const logger = createLogger(process.env.LOG_LEVEL);
|
|
5
10
|
|
|
6
11
|
/**
|
|
7
12
|
* Single source of truth for an `email_preferences` upsert: the `(user_id, email)`
|
|
@@ -61,6 +66,32 @@ export async function upsertEmailPreference(opts: {
|
|
|
61
66
|
target: [emailPreferences.userId, emailPreferences.email],
|
|
62
67
|
set: setClause,
|
|
63
68
|
});
|
|
69
|
+
|
|
70
|
+
// OUTBOUND `contact.unsubscribed` — this is the SINGLE choke for ALL preference
|
|
71
|
+
// writes (token unsub, preference center, list-membership flips), so the emit
|
|
72
|
+
// lives here once. GATED to a genuine opt-OUT only: a full unsubscribe
|
|
73
|
+
// (`unsubscribedAll === true`) or a category flip to false. A resubscribe
|
|
74
|
+
// (`unsubscribedAll === false` / `categoryValue === true`) does NOT emit. Uses
|
|
75
|
+
// the engine `hatchet`/`logger` singletons (this lib has no request container);
|
|
76
|
+
// fire-and-forget so a transient outbound error never fails the pref write.
|
|
77
|
+
const isUnsubscribe =
|
|
78
|
+
update.unsubscribedAll === true || update.categoryValue === false;
|
|
79
|
+
if (isUnsubscribe) {
|
|
80
|
+
const scope: "all" | "category" =
|
|
81
|
+
update.unsubscribedAll === true ? "all" : "category";
|
|
82
|
+
void emitOutbound({
|
|
83
|
+
db,
|
|
84
|
+
hatchet,
|
|
85
|
+
logger,
|
|
86
|
+
event: "contact.unsubscribed",
|
|
87
|
+
payload: {
|
|
88
|
+
externalId,
|
|
89
|
+
email,
|
|
90
|
+
category: update.categoryKey ?? null,
|
|
91
|
+
scope,
|
|
92
|
+
},
|
|
93
|
+
}).catch(logger.warn);
|
|
94
|
+
}
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
/**
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { type Database, webhookEndpoints } from "@hogsend/db";
|
|
2
|
+
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
3
|
+
import type { Logger } from "./logger.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Transaction-scoped advisory-lock key serializing the single-tenant PostHog
|
|
7
|
+
* seed across concurrent API + worker boots. An arbitrary fixed constant within
|
|
8
|
+
* int4 range (the single-arg `pg_advisory_xact_lock` overload casts to bigint).
|
|
9
|
+
*/
|
|
10
|
+
const SEED_ADVISORY_LOCK_KEY = 1426198835;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The email funnel a seeded PostHog destination subscribes to — the full
|
|
14
|
+
* lifecycle that reaches PostHog on NO path before this destination existed.
|
|
15
|
+
*/
|
|
16
|
+
const POSTHOG_FUNNEL_EVENTS = [
|
|
17
|
+
"email.sent",
|
|
18
|
+
"email.delivered",
|
|
19
|
+
"email.opened",
|
|
20
|
+
"email.clicked",
|
|
21
|
+
"email.bounced",
|
|
22
|
+
"email.complained",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Idempotently seed ONE `kind="posthog"` webhook endpoint subscribed to the
|
|
27
|
+
* email funnel, so the full email lifecycle fans out to PostHog DURABLY on the
|
|
28
|
+
* delivery spine.
|
|
29
|
+
*
|
|
30
|
+
* Guarded against duplicates: it inserts only when no single-tenant
|
|
31
|
+
* (`organization_id IS NULL`) `kind="posthog"` endpoint already exists. Safe to
|
|
32
|
+
* call from BOTH the API and worker boots (both build the client) — the second
|
|
33
|
+
* caller finds the row and no-ops. Fire-and-forget at the call site: a transient
|
|
34
|
+
* seed failure must never block boot.
|
|
35
|
+
*/
|
|
36
|
+
export async function seedPostHogDestination(opts: {
|
|
37
|
+
db: Database;
|
|
38
|
+
logger: Logger;
|
|
39
|
+
apiKey: string;
|
|
40
|
+
host?: string;
|
|
41
|
+
}): Promise<{ seeded: boolean }> {
|
|
42
|
+
const { db, logger, apiKey, host } = opts;
|
|
43
|
+
|
|
44
|
+
// Serialize the check-then-insert across concurrent API + worker boots (both
|
|
45
|
+
// build the client) with a transaction-scoped advisory lock, so the race can
|
|
46
|
+
// never double-seed. A per-endpoint unique constraint is intentionally avoided
|
|
47
|
+
// — operators may legitimately create multiple PostHog endpoints by hand.
|
|
48
|
+
return db.transaction(async (tx) => {
|
|
49
|
+
await tx.execute(
|
|
50
|
+
sql`SELECT pg_advisory_xact_lock(${SEED_ADVISORY_LOCK_KEY})`,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const existing = await tx
|
|
54
|
+
.select({ id: webhookEndpoints.id })
|
|
55
|
+
.from(webhookEndpoints)
|
|
56
|
+
.where(
|
|
57
|
+
and(
|
|
58
|
+
isNull(webhookEndpoints.organizationId),
|
|
59
|
+
eq(webhookEndpoints.kind, "posthog"),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
.limit(1);
|
|
63
|
+
|
|
64
|
+
if (existing.length > 0) {
|
|
65
|
+
return { seeded: false };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await tx.insert(webhookEndpoints).values({
|
|
69
|
+
url: "posthog://capture",
|
|
70
|
+
description:
|
|
71
|
+
"Auto-seeded PostHog destination (ENABLE_POSTHOG_DESTINATION)",
|
|
72
|
+
kind: "posthog",
|
|
73
|
+
config: {
|
|
74
|
+
apiKey,
|
|
75
|
+
...(host ? { host } : {}),
|
|
76
|
+
// Preserve continuity with the legacy fire-and-forget PostHog path,
|
|
77
|
+
// which captured clicks as "email.link_clicked"; the posthog transform
|
|
78
|
+
// remaps the canonical "email.clicked" back so existing PostHog funnels
|
|
79
|
+
// keep working after the cutover.
|
|
80
|
+
eventNames: { "email.clicked": "email.link_clicked" },
|
|
81
|
+
},
|
|
82
|
+
eventTypes: [...POSTHOG_FUNNEL_EVENTS],
|
|
83
|
+
secret: null,
|
|
84
|
+
secretPrefix: null,
|
|
85
|
+
disabled: false,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
logger.info("Seeded PostHog destination on the outbound spine", {
|
|
89
|
+
events: POSTHOG_FUNNEL_EVENTS.length,
|
|
90
|
+
});
|
|
91
|
+
return { seeded: true };
|
|
92
|
+
});
|
|
93
|
+
}
|
package/src/lib/tracked.ts
CHANGED
|
@@ -20,7 +20,15 @@ import type {
|
|
|
20
20
|
TrackedSendResult,
|
|
21
21
|
} from "./email-service-types.js";
|
|
22
22
|
import { isFrequencyCapped } from "./frequency-cap.js";
|
|
23
|
-
import
|
|
23
|
+
import { hatchet } from "./hatchet.js";
|
|
24
|
+
import { createLogger, type Logger } from "./logger.js";
|
|
25
|
+
import { emitOutbound } from "./outbound.js";
|
|
26
|
+
|
|
27
|
+
// Module-level fallback logger for the outbound emit — the tracked-mailer's
|
|
28
|
+
// `logger` dep is optional, but `emitOutbound` requires one. Mirrors the
|
|
29
|
+
// `createLogger(process.env.LOG_LEVEL)` singleton pattern used elsewhere in the
|
|
30
|
+
// engine libs (define-journey, preferences).
|
|
31
|
+
const emitLogger = createLogger(process.env.LOG_LEVEL);
|
|
24
32
|
|
|
25
33
|
export type PrepareTrackedHtmlFn = (opts: {
|
|
26
34
|
html: string;
|
|
@@ -270,16 +278,50 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
270
278
|
replyTo: options.replyTo,
|
|
271
279
|
});
|
|
272
280
|
|
|
281
|
+
const sentAt = new Date();
|
|
273
282
|
await db
|
|
274
283
|
.update(emailSends)
|
|
275
284
|
.set({
|
|
276
285
|
resendId: result.id,
|
|
277
286
|
status: "sent",
|
|
278
|
-
sentAt
|
|
279
|
-
updatedAt:
|
|
287
|
+
sentAt,
|
|
288
|
+
updatedAt: sentAt,
|
|
280
289
|
})
|
|
281
290
|
.where(eq(emailSends.id, emailSendId));
|
|
282
291
|
|
|
292
|
+
// OUTBOUND `email.sent` — fired ONLY on a real provider-accepted send (this
|
|
293
|
+
// success branch). Suppressed/frequency-capped/failed branches and the
|
|
294
|
+
// `db === undefined` mailer fallback do NOT reach here, so they never emit.
|
|
295
|
+
// `dedupeKey` = `email.sent:<emailSendId>`: this runs inside the tracked
|
|
296
|
+
// mailer which a journey (a Hatchet-retryable durable task) invokes, so a
|
|
297
|
+
// re-execution recomputes the identical key and the unique
|
|
298
|
+
// `(endpointId, dedupeKey)` index absorbs the duplicate. STRICTLY
|
|
299
|
+
// fire-and-forget: an un-caught reject here would bubble into the catch below
|
|
300
|
+
// and wrongly re-mark the (already sent) row `failed` (risk 2).
|
|
301
|
+
void emitOutbound({
|
|
302
|
+
db,
|
|
303
|
+
hatchet,
|
|
304
|
+
logger: logger ?? emitLogger,
|
|
305
|
+
event: "email.sent",
|
|
306
|
+
dedupeKey: `email.sent:${emailSendId}`,
|
|
307
|
+
payload: {
|
|
308
|
+
emailSendId,
|
|
309
|
+
resendId: result.id,
|
|
310
|
+
templateKey: options.templateKey,
|
|
311
|
+
to: options.to,
|
|
312
|
+
userId: options.userId ?? null,
|
|
313
|
+
category: effectiveCategory ?? null,
|
|
314
|
+
journeyStateId: options.journeyStateId ?? null,
|
|
315
|
+
subject,
|
|
316
|
+
sentAt: sentAt.toISOString(),
|
|
317
|
+
},
|
|
318
|
+
}).catch((err: unknown) => {
|
|
319
|
+
(logger ?? emitLogger).warn("emitOutbound email.sent failed", {
|
|
320
|
+
emailSendId,
|
|
321
|
+
error: err instanceof Error ? err.message : String(err),
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
283
325
|
return {
|
|
284
326
|
emailSendId,
|
|
285
327
|
resendId: result.id,
|
|
@@ -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,6 +9,8 @@ interface EmailSendContext {
|
|
|
10
9
|
userId: string;
|
|
11
10
|
userEmail: string;
|
|
12
11
|
templateKey: string | null;
|
|
12
|
+
resendId: string | null;
|
|
13
|
+
to: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export async function resolveEmailSendContext(
|
|
@@ -20,6 +21,7 @@ export async function resolveEmailSendContext(
|
|
|
20
21
|
.select({
|
|
21
22
|
toEmail: emailSends.toEmail,
|
|
22
23
|
templateKey: emailSends.templateKey,
|
|
24
|
+
resendId: emailSends.resendId,
|
|
23
25
|
userId: journeyStates.userId,
|
|
24
26
|
userEmail: journeyStates.userEmail,
|
|
25
27
|
})
|
|
@@ -35,6 +37,58 @@ export async function resolveEmailSendContext(
|
|
|
35
37
|
userId: row.userId ?? row.toEmail,
|
|
36
38
|
userEmail: row.userEmail ?? row.toEmail,
|
|
37
39
|
templateKey: row.templateKey,
|
|
40
|
+
resendId: row.resendId,
|
|
41
|
+
to: row.toEmail,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ResendEmailSendContext {
|
|
46
|
+
emailSendId: string;
|
|
47
|
+
userId: string;
|
|
48
|
+
userEmail: string;
|
|
49
|
+
templateKey: string | null;
|
|
50
|
+
to: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Mirror of {@link resolveEmailSendContext} that resolves by the provider's
|
|
55
|
+
* `resendId` instead of the internal `email_sends.id`. Used by the
|
|
56
|
+
* provider-webhook enrichment path (`email.delivered`/`email.bounced`) where the
|
|
57
|
+
* only handle we hold is the Resend `email_id`.
|
|
58
|
+
*
|
|
59
|
+
* Returns the internal `emailSendId` plus the same denormalized identity
|
|
60
|
+
* (`userId`/`userEmail` fall back to the recipient address, exactly like the
|
|
61
|
+
* id-keyed resolver) and `to` recipient. Returns null when no send row carries
|
|
62
|
+
* that `resendId` yet (e.g. a webhook arriving before the send row is committed).
|
|
63
|
+
*/
|
|
64
|
+
export async function resolveEmailSendContextByResendId(
|
|
65
|
+
db: Database,
|
|
66
|
+
resendId: string,
|
|
67
|
+
): Promise<ResendEmailSendContext | null> {
|
|
68
|
+
const rows = await db
|
|
69
|
+
.select({
|
|
70
|
+
emailSendId: emailSends.id,
|
|
71
|
+
toEmail: emailSends.toEmail,
|
|
72
|
+
templateKey: emailSends.templateKey,
|
|
73
|
+
userId: journeyStates.userId,
|
|
74
|
+
userEmail: journeyStates.userEmail,
|
|
75
|
+
sendUserId: emailSends.userId,
|
|
76
|
+
sendUserEmail: emailSends.userEmail,
|
|
77
|
+
})
|
|
78
|
+
.from(emailSends)
|
|
79
|
+
.leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
|
|
80
|
+
.where(eq(emailSends.resendId, resendId))
|
|
81
|
+
.limit(1);
|
|
82
|
+
|
|
83
|
+
const row = rows[0];
|
|
84
|
+
if (!row) return null;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
emailSendId: row.emailSendId,
|
|
88
|
+
userId: row.userId ?? row.sendUserId ?? row.toEmail,
|
|
89
|
+
userEmail: row.userEmail ?? row.sendUserEmail ?? row.toEmail,
|
|
90
|
+
templateKey: row.templateKey,
|
|
91
|
+
to: row.toEmail,
|
|
38
92
|
};
|
|
39
93
|
}
|
|
40
94
|
|
|
@@ -43,18 +97,37 @@ export interface PushTrackingEventOpts {
|
|
|
43
97
|
hatchet: HatchetClient;
|
|
44
98
|
registry: JourneyRegistry;
|
|
45
99
|
logger: Logger;
|
|
46
|
-
posthog?: PostHogService;
|
|
47
100
|
event: string;
|
|
48
101
|
emailSendId: string;
|
|
49
102
|
properties?: Record<string, unknown>;
|
|
103
|
+
/**
|
|
104
|
+
* Pre-resolved send context. When provided (including `null`), the duplicate
|
|
105
|
+
* `resolveEmailSendContext` read is skipped — the tracking routes resolve once
|
|
106
|
+
* and share the result with the outbound emit on the hot path. Omit to resolve
|
|
107
|
+
* lazily.
|
|
108
|
+
*/
|
|
109
|
+
resolvedContext?: EmailSendContext | null;
|
|
50
110
|
}
|
|
51
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Re-push a first-party tracking event (open/click) back onto the INTERNAL bus
|
|
114
|
+
* (`ingestEvent`) for journey routing + `userEvents` persistence.
|
|
115
|
+
*
|
|
116
|
+
* NOTE (Phase 2): this NO LONGER fires a fire-and-forget PostHog `captureEvent`.
|
|
117
|
+
* PostHog now receives opens/clicks PER-HIT via the durable outbound spine — a
|
|
118
|
+
* `kind="posthog"` destination subscribed to `email.opened`/`email.clicked` (the
|
|
119
|
+
* tracking routes call `emitOutbound` alongside this). The legacy double-emit was
|
|
120
|
+
* removed so PostHog gets exactly one, durable copy of each hit.
|
|
121
|
+
*/
|
|
52
122
|
export async function pushTrackingEvent(
|
|
53
123
|
opts: PushTrackingEventOpts,
|
|
54
124
|
): Promise<void> {
|
|
55
|
-
const { db, hatchet, registry, logger,
|
|
125
|
+
const { db, hatchet, registry, logger, event, emailSendId } = opts;
|
|
56
126
|
|
|
57
|
-
const ctx =
|
|
127
|
+
const ctx =
|
|
128
|
+
opts.resolvedContext !== undefined
|
|
129
|
+
? opts.resolvedContext
|
|
130
|
+
: await resolveEmailSendContext(db, emailSendId);
|
|
58
131
|
if (!ctx) return;
|
|
59
132
|
|
|
60
133
|
const properties: Record<string, unknown> = {
|
|
@@ -63,12 +136,6 @@ export async function pushTrackingEvent(
|
|
|
63
136
|
...opts.properties,
|
|
64
137
|
};
|
|
65
138
|
|
|
66
|
-
posthog?.captureEvent({
|
|
67
|
-
distinctId: ctx.userId,
|
|
68
|
-
event,
|
|
69
|
-
properties,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
139
|
await ingestEvent({
|
|
73
140
|
db,
|
|
74
141
|
registry,
|