@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.
Files changed (34) hide show
  1. package/package.json +9 -6
  2. package/src/container.ts +156 -29
  3. package/src/destinations/define-destination.ts +104 -0
  4. package/src/destinations/presets/index.ts +94 -0
  5. package/src/destinations/presets/posthog.ts +71 -0
  6. package/src/destinations/presets/segment.ts +75 -0
  7. package/src/destinations/presets/slack.ts +66 -0
  8. package/src/destinations/presets/webhook.ts +37 -0
  9. package/src/destinations/registry-singleton.ts +78 -0
  10. package/src/env.ts +38 -1
  11. package/src/index.ts +46 -6
  12. package/src/journeys/define-journey.ts +0 -1
  13. package/src/journeys/journey-context.ts +1 -17
  14. package/src/lib/analytics-singleton.ts +7 -0
  15. package/src/lib/email-provider-registry.ts +45 -0
  16. package/src/lib/email-providers-from-env.ts +94 -0
  17. package/src/lib/email-service-types.ts +40 -4
  18. package/src/lib/headers.ts +13 -0
  19. package/src/lib/mailer.ts +137 -72
  20. package/src/lib/outbound.ts +18 -2
  21. package/src/lib/seed-posthog-destination.ts +93 -0
  22. package/src/lib/tracked.ts +34 -29
  23. package/src/lib/tracking-events.ts +37 -20
  24. package/src/lib/webhook-signing.ts +2 -1
  25. package/src/routes/admin/emails.ts +5 -1
  26. package/src/routes/admin/webhooks.ts +100 -9
  27. package/src/routes/tracking/click.ts +20 -25
  28. package/src/routes/tracking/open.ts +20 -27
  29. package/src/routes/webhooks/email-provider.ts +124 -0
  30. package/src/routes/webhooks/index.ts +7 -0
  31. package/src/routes/webhooks/resend.ts +14 -29
  32. package/src/routes/webhooks/sources.ts +15 -4
  33. package/src/workflows/deliver-webhook.ts +137 -52
  34. package/src/workflows/send-email.ts +2 -1
package/src/lib/mailer.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type {
2
2
  BatchEmailItem,
3
+ EmailEvent,
4
+ EmailEventType,
3
5
  EmailProvider,
4
- WebhookEvent,
5
- WebhookEventType,
6
6
  WebhookHandlerMap,
7
7
  } from "@hogsend/core";
8
8
  import type { Database } from "@hogsend/db";
@@ -13,23 +13,23 @@ import type {
13
13
  TemplateName,
14
14
  } from "@hogsend/email";
15
15
  import { getTemplate, renderToHtml, renderToPlainText } from "@hogsend/email";
16
- import { eq, sql } from "drizzle-orm";
17
- import type {
18
- EmailService,
19
- EmailServiceConfig,
20
- EmailServiceSendOptions,
21
- EmailServiceWebhookOptions,
22
- EmailServiceWebhookResult,
23
- SendRawOptions,
24
- SendResult,
25
- TrackedSendResult,
16
+ import { eq, inArray, sql } from "drizzle-orm";
17
+ import {
18
+ type EmailService,
19
+ type EmailServiceConfig,
20
+ type EmailServiceSendOptions,
21
+ type EmailServiceWebhookResult,
22
+ type SendRawOptions,
23
+ type SendResult,
24
+ type TrackedSendResult,
25
+ trackedSendResult,
26
26
  } from "./email-service-types.js";
27
27
  import { hatchet } from "./hatchet.js";
28
28
  import { createLogger } from "./logger.js";
29
29
  import { emitOutbound } from "./outbound.js";
30
30
  import type { PrepareTrackedHtmlFn } from "./tracked.js";
31
31
  import { sendTrackedEmail } from "./tracked.js";
32
- import { resolveEmailSendContextByResendId } from "./tracking-events.js";
32
+ import { resolveEmailSendContextByMessageId } from "./tracking-events.js";
33
33
 
34
34
  // Fallback logger for the provider-webhook outbound emit — `config.logger` is
35
35
  // optional, but `emitOutbound` requires one. Mirrors the engine-lib singleton
@@ -37,7 +37,7 @@ import { resolveEmailSendContextByResendId } from "./tracking-events.js";
37
37
  const emitLogger = createLogger(process.env.LOG_LEVEL);
38
38
 
39
39
  const WEBHOOK_TO_STATUS_FIELD: Partial<
40
- Record<WebhookEventType, keyof typeof emailSends.$inferSelect>
40
+ Record<EmailEventType, keyof typeof emailSends.$inferSelect>
41
41
  > = {
42
42
  "email.sent": "sentAt",
43
43
  "email.delivered": "deliveredAt",
@@ -47,7 +47,7 @@ const WEBHOOK_TO_STATUS_FIELD: Partial<
47
47
  "email.complained": "complainedAt",
48
48
  };
49
49
 
50
- const WEBHOOK_TO_STATUS: Partial<Record<WebhookEventType, string>> = {
50
+ const WEBHOOK_TO_STATUS: Partial<Record<EmailEventType, string>> = {
51
51
  "email.sent": "sent",
52
52
  "email.delivered": "delivered",
53
53
  "email.opened": "opened",
@@ -56,6 +56,10 @@ const WEBHOOK_TO_STATUS: Partial<Record<WebhookEventType, string>> = {
56
56
  "email.complained": "complained",
57
57
  };
58
58
 
59
+ /** Max recipients we will iterate on a bounce/complaint, to avoid a fan-out
60
+ * webhook mass-suppressing addresses. Above this we log + skip suppression. */
61
+ const MAX_SUPPRESSION_RECIPIENTS = 100;
62
+
59
63
  /**
60
64
  * The engine-owned high-level mailer. It owns the full send pipeline —
61
65
  * render → preference/suppression check → tracked-html rewrite → `email_sends`
@@ -78,6 +82,27 @@ export function createTrackedMailer(
78
82
  return overrideFrom ?? config.defaultFrom;
79
83
  }
80
84
 
85
+ /**
86
+ * Drop `scheduledAt` unless the active provider declares
87
+ * `capabilities.scheduledSend`. A provider that can't natively schedule (e.g.
88
+ * Postmark/SES) would silently ignore it — so the engine strips it and logs a
89
+ * WARN pointing at the durable alternative (`ctx.sleepUntil`).
90
+ */
91
+ function applyScheduledAtGate<T extends { scheduledAt?: string }>(
92
+ opts: T,
93
+ ): T {
94
+ if (opts.scheduledAt && provider.capabilities?.scheduledSend !== true) {
95
+ (config.logger ?? emitLogger).warn(
96
+ `scheduledAt ignored: provider ${
97
+ provider.meta?.id ?? "resend"
98
+ } has no native scheduled send; use ctx.sleepUntil`,
99
+ );
100
+ const { scheduledAt: _dropped, ...rest } = opts;
101
+ return rest as T;
102
+ }
103
+ return opts;
104
+ }
105
+
81
106
  const service: EmailService = {
82
107
  async send<K extends TemplateName>(
83
108
  options: EmailServiceSendOptions<K>,
@@ -118,25 +143,30 @@ export function createTrackedMailer(
118
143
  props: options.props,
119
144
  registry,
120
145
  });
146
+ // HTML-ONLY wire — the engine ALWAYS renders React → HTML itself before
147
+ // the provider. React Email stays first-class for authoring/Studio; it
148
+ // never crosses the provider boundary.
149
+ const html = await renderToHtml(element);
121
150
  const result = await provider.send({
122
151
  from,
123
152
  to: options.to,
124
153
  subject: options.subject ?? defaultSubject,
125
- react: element,
154
+ html,
126
155
  tags: options.tags,
127
156
  headers: options.headers,
128
157
  replyTo: options.replyTo,
129
158
  });
130
159
 
131
- return {
160
+ return trackedSendResult({
132
161
  emailSendId: "",
133
- resendId: result.id,
162
+ messageId: result.id,
134
163
  status: "sent",
135
- };
164
+ });
136
165
  },
137
166
 
138
167
  async sendRaw(options: SendRawOptions): Promise<SendResult> {
139
- return provider.send({ ...options, from: resolveFrom(options.from) });
168
+ const gated = applyScheduledAtGate(options);
169
+ return provider.send({ ...gated, from: resolveFrom(options.from) });
140
170
  },
141
171
 
142
172
  async sendBatch(options: {
@@ -167,23 +197,14 @@ export function createTrackedMailer(
167
197
  },
168
198
 
169
199
  async handleWebhook(
170
- options: EmailServiceWebhookOptions,
200
+ event: EmailEvent,
201
+ _providerId?: string,
171
202
  ): Promise<EmailServiceWebhookResult> {
172
- if (!config.webhookSecret) {
173
- throw new Error(
174
- "webhookSecret is required in EmailServiceConfig to handle webhooks",
175
- );
176
- }
177
-
203
+ // The route owns provider resolution + signature verification and hands us
204
+ // an already-verified, provider-neutral EmailEvent. No secret gate here —
205
+ // each provider owns its own webhook secret at construction time.
178
206
  const userHandlers: WebhookHandlerMap = config.webhookHandlers ?? {};
179
-
180
- const event = provider.verifyWebhook({
181
- payload: options.payload,
182
- headers: options.headers,
183
- });
184
-
185
207
  const handled = await dispatchWebhook(event, userHandlers);
186
-
187
208
  return { type: event.type, handled };
188
209
  },
189
210
  };
@@ -191,7 +212,7 @@ export function createTrackedMailer(
191
212
  const bounceThreshold = config.bounceThreshold ?? 3;
192
213
 
193
214
  async function dispatchWebhook(
194
- event: WebhookEvent,
215
+ event: EmailEvent,
195
216
  userHandlers: WebhookHandlerMap,
196
217
  ): Promise<boolean> {
197
218
  switch (event.type) {
@@ -199,44 +220,59 @@ export function createTrackedMailer(
199
220
  // `email.sent` is emitted FIRST-PARTY from the tracked mailer's
200
221
  // provider-accepted branch (lib/tracked.ts) with the rich payload — the
201
222
  // provider-webhook echo only updates the DB status, it does NOT emit.
202
- await updateEmailStatus(event.type, event.data.email_id);
223
+ await updateEmailStatus(event.type, event.messageId);
203
224
  break;
204
225
  case "email.delivered":
205
- await updateEmailStatus(event.type, event.data.email_id);
226
+ await updateEmailStatus(event.type, event.messageId);
206
227
  // OUTBOUND `email.delivered` — the provider webhook is the SINGLE source
207
228
  // for delivered/bounced (these have no first-party signal).
208
- await emitProviderEmailEvent("email.delivered", event.data.email_id);
229
+ await emitProviderEmailEvent("email.delivered", event.messageId);
209
230
  break;
210
231
  case "email.opened":
211
232
  case "email.clicked":
212
233
  // First-party pixel/redirect is the SINGLE outbound emitter for
213
- // open/click (gated on the first-touch nullset UPDATE in the tracking
214
- // routes risk 4). The provider-webhook echo is SUPPRESSED here: it only
215
- // updates the DB status, it does NOT emit outbound (no double-source).
216
- await updateEmailStatus(event.type, event.data.email_id);
234
+ // open/click it now fires PER-HIT (every open/click a delivery to
235
+ // every destination, owner decision 1). The provider-webhook echo is
236
+ // SUPPRESSED here: it only updates the DB status, it does NOT emit
237
+ // outbound (no double-source). This is the outbound-echo defence for a
238
+ // provider with native tracking left ON.
239
+ await updateEmailStatus(event.type, event.messageId);
217
240
  break;
218
241
  case "email.bounced":
219
- await updateEmailStatus(event.type, event.data.email_id, {
220
- bounceType: event.data.bounce?.type,
221
- bounceReason: event.data.bounce?.message,
242
+ // `bounce.class` is stored in `bounceType`, the human reason in
243
+ // `bounceReason`. Soft/transient bounces are recorded here too (status
244
+ // `bounced`, `class:'transient'`) — the old transient →
245
+ // `email.delivery_delayed` no-op is gone.
246
+ await updateEmailStatus(event.type, event.messageId, {
247
+ bounceType: event.bounce?.class,
248
+ bounceReason: event.bounce?.reason,
222
249
  });
223
- // OUTBOUND `email.bounced` with the bounce detail.
224
- await emitProviderEmailEvent("email.bounced", event.data.email_id, {
225
- bounceType: event.data.bounce?.type,
226
- bounceReason: event.data.bounce?.message,
250
+ // OUTBOUND `email.bounced` with the bounce detail (class + reason).
251
+ await emitProviderEmailEvent("email.bounced", event.messageId, {
252
+ bounceType: event.bounce?.class,
253
+ bounceReason: event.bounce?.reason,
227
254
  });
228
- await handleBounce(event.data.to);
255
+ // Suppress (increment bounceCount toward threshold) ONLY on a permanent
256
+ // bounce. Transient/unknown are recorded but never auto-suppress.
257
+ if (event.bounce?.class === "permanent") {
258
+ await handleBounce(event.recipients);
259
+ }
229
260
  break;
230
261
  case "email.complained":
231
- await updateEmailStatus(event.type, event.data.email_id);
232
- await handleComplaint(event.data.to);
262
+ await updateEmailStatus(event.type, event.messageId);
263
+ // OUTBOUND `email.complained` — the provider webhook is the SINGLE
264
+ // source for complaints (no first-party signal exists).
265
+ await emitProviderEmailEvent("email.complained", event.messageId);
266
+ await handleComplaint(event.recipients);
233
267
  break;
234
268
  case "email.delivery_delayed":
269
+ // No-op: providers now map transient bounces to `email.bounced` with
270
+ // `class:'transient'`, so soft bounces are recorded there instead.
235
271
  break;
236
272
  }
237
273
 
238
274
  const userHandler = userHandlers[event.type] as
239
- | ((e: WebhookEvent) => void | Promise<void>)
275
+ | ((e: EmailEvent) => void | Promise<void>)
240
276
  | undefined;
241
277
  if (userHandler) {
242
278
  await userHandler(event);
@@ -246,11 +282,28 @@ export function createTrackedMailer(
246
282
  return false;
247
283
  }
248
284
 
249
- async function handleBounce(toAddresses: string[]): Promise<void> {
285
+ /** Recipients to actually act on: de-duped, falsy-stripped, count-capped. A
286
+ * fan-out webhook over the cap is logged + skipped to avoid mass-suppression. */
287
+ function validRecipients(recipients: string[]): string[] {
288
+ const unique = [...new Set(recipients.filter(Boolean))];
289
+ if (unique.length > MAX_SUPPRESSION_RECIPIENTS) {
290
+ (config.logger ?? emitLogger).warn(
291
+ "suppression skipped: recipient count exceeds cap",
292
+ { count: unique.length, cap: MAX_SUPPRESSION_RECIPIENTS },
293
+ );
294
+ return [];
295
+ }
296
+ return unique;
297
+ }
298
+
299
+ async function handleBounce(recipients: string[]): Promise<void> {
250
300
  if (!db) return;
251
- const email = toAddresses[0];
252
- if (!email) return;
301
+ const emails = validRecipients(recipients);
302
+ if (emails.length === 0) return;
253
303
 
304
+ // ONE statement for all recipients. The CASE-WHEN auto-suppress at threshold
305
+ // is evaluated PER ROW inside the single UPDATE, so semantics match the old
306
+ // per-email loop exactly.
254
307
  await db
255
308
  .update(emailPreferences)
256
309
  .set({
@@ -260,14 +313,15 @@ export function createTrackedMailer(
260
313
  suppressedAt: sql`CASE WHEN ${emailPreferences.bounceCount} + 1 >= ${bounceThreshold} THEN NOW() ELSE ${emailPreferences.suppressedAt} END`,
261
314
  updatedAt: new Date(),
262
315
  })
263
- .where(eq(emailPreferences.email, email));
316
+ .where(inArray(emailPreferences.email, emails));
264
317
  }
265
318
 
266
- async function handleComplaint(toAddresses: string[]): Promise<void> {
319
+ async function handleComplaint(recipients: string[]): Promise<void> {
267
320
  if (!db) return;
268
- const email = toAddresses[0];
269
- if (!email) return;
321
+ const emails = validRecipients(recipients);
322
+ if (emails.length === 0) return;
270
323
 
324
+ // ONE statement for all recipients (same semantics as the old per-email loop).
271
325
  await db
272
326
  .update(emailPreferences)
273
327
  .set({
@@ -275,32 +329,34 @@ export function createTrackedMailer(
275
329
  suppressedAt: new Date(),
276
330
  updatedAt: new Date(),
277
331
  })
278
- .where(eq(emailPreferences.email, email));
332
+ .where(inArray(emailPreferences.email, emails));
279
333
  }
280
334
 
281
335
  /**
282
- * Emit the provider-funnel outbound event (`email.delivered` / `email.bounced`)
283
- * for a Resend `email_id`. Enriches via {@link resolveEmailSendContextByResendId}
284
- * (the only handle a provider webhook holds is the Resend id). Fire-and-forget:
336
+ * Emit the provider-funnel outbound event (`email.delivered` /
337
+ * `email.bounced` / `email.complained`) for a provider `messageId`. These three
338
+ * have no first-party signal — the provider webhook is their single source.
339
+ * Enriches via {@link resolveEmailSendContextByMessageId}
340
+ * (the only handle a provider webhook holds is the message id). Fire-and-forget:
285
341
  * a missing context (webhook racing the send-row commit) or a transient outbound
286
342
  * error is logged and swallowed — never failing the webhook handler. No
287
343
  * `dedupeKey`: the provider path is not a Hatchet-retryable producer, and the
288
344
  * shared `Webhook-Id` is the subscriber-side dedup for any provider redelivery.
289
345
  */
290
346
  function emitProviderEmailEvent(
291
- event: "email.delivered" | "email.bounced",
292
- resendId: string,
347
+ event: "email.delivered" | "email.bounced" | "email.complained",
348
+ messageId: string,
293
349
  bounce?: { bounceType?: string; bounceReason?: string },
294
350
  ): void {
295
351
  if (!db) return;
296
352
  const log = config.logger ?? emitLogger;
297
353
  const database = db;
298
- void resolveEmailSendContextByResendId(database, resendId)
354
+ void resolveEmailSendContextByMessageId(database, messageId)
299
355
  .then((ctx) => {
300
356
  if (!ctx) return;
301
357
  const base = {
302
358
  emailSendId: ctx.emailSendId,
303
- resendId,
359
+ messageId,
304
360
  templateKey: ctx.templateKey,
305
361
  userId: ctx.userId,
306
362
  to: ctx.to,
@@ -321,6 +377,15 @@ export function createTrackedMailer(
321
377
  },
322
378
  });
323
379
  }
380
+ if (event === "email.complained") {
381
+ return emitOutbound({
382
+ db: database,
383
+ hatchet,
384
+ logger: log,
385
+ event: "email.complained",
386
+ payload: base,
387
+ });
388
+ }
324
389
  return emitOutbound({
325
390
  db: database,
326
391
  hatchet,
@@ -331,15 +396,15 @@ export function createTrackedMailer(
331
396
  })
332
397
  .catch((err: unknown) => {
333
398
  log.warn(`emitOutbound ${event} failed`, {
334
- resendId,
399
+ messageId,
335
400
  error: err instanceof Error ? err.message : String(err),
336
401
  });
337
402
  });
338
403
  }
339
404
 
340
405
  async function updateEmailStatus(
341
- eventType: WebhookEventType,
342
- resendId: string,
406
+ eventType: EmailEventType,
407
+ messageId: string,
343
408
  extra?: { bounceType?: string; bounceReason?: string },
344
409
  ): Promise<void> {
345
410
  if (!db) return;
@@ -357,7 +422,7 @@ export function createTrackedMailer(
357
422
  ...(extra?.bounceReason ? { bounceReason: extra.bounceReason } : {}),
358
423
  updatedAt: new Date(),
359
424
  })
360
- .where(eq(emailSends.resendId, resendId));
425
+ .where(eq(emailSends.messageId, messageId));
361
426
  }
362
427
 
363
428
  return service;
@@ -15,7 +15,16 @@ import {
15
15
  } from "./webhook-signing.js";
16
16
 
17
17
  export {
18
+ type EmailSendContextByMessageId,
19
+ /**
20
+ * @deprecated Kept for one minor; use {@link EmailSendContextByMessageId}.
21
+ */
18
22
  type ResendEmailSendContext,
23
+ resolveEmailSendContextByMessageId,
24
+ /**
25
+ * @deprecated Kept for one minor; use
26
+ * {@link resolveEmailSendContextByMessageId}.
27
+ */
19
28
  resolveEmailSendContextByResendId,
20
29
  } from "./tracking-events.js";
21
30
 
@@ -28,11 +37,14 @@ export type OutboundEventName = WebhookEventType;
28
37
 
29
38
  interface EmailEventPayload {
30
39
  emailSendId: string;
31
- resendId: string | null;
40
+ messageId: string | null;
32
41
  templateKey: string | null;
33
42
  userId: string | null;
34
43
  to: string;
35
44
  at: string;
45
+ // Optional enrichment (additive — older subscribers ignore absent keys).
46
+ category?: string;
47
+ subject?: string;
36
48
  }
37
49
 
38
50
  interface BucketEventPayload {
@@ -66,7 +78,7 @@ export interface OutboundPayloads {
66
78
  };
67
79
  "email.sent": {
68
80
  emailSendId: string;
69
- resendId: string;
81
+ messageId: string;
70
82
  templateKey: string | null;
71
83
  to: string;
72
84
  userId: string | null;
@@ -82,6 +94,10 @@ export interface OutboundPayloads {
82
94
  bounceType?: string;
83
95
  bounceReason?: string;
84
96
  };
97
+ "email.complained": EmailEventPayload & {
98
+ complaintType?: string;
99
+ reason?: string;
100
+ };
85
101
  "journey.completed": {
86
102
  journeyId: string;
87
103
  journeyName: string;
@@ -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
+ }
@@ -14,10 +14,11 @@ import {
14
14
  } from "@hogsend/email";
15
15
  import { eq } from "drizzle-orm";
16
16
  import { getListRegistry } from "../lists/registry-singleton.js";
17
- import type {
18
- FrequencyCapConfig,
19
- SendTrackedEmailOptions,
20
- TrackedSendResult,
17
+ import {
18
+ type FrequencyCapConfig,
19
+ type SendTrackedEmailOptions,
20
+ type TrackedSendResult,
21
+ trackedSendResult,
21
22
  } from "./email-service-types.js";
22
23
  import { isFrequencyCapped } from "./frequency-cap.js";
23
24
  import { hatchet } from "./hatchet.js";
@@ -71,12 +72,12 @@ export async function sendTrackedEmail<K extends TemplateName>(
71
72
  id: string;
72
73
  status: string;
73
74
  }): TrackedSendResult =>
74
- ({
75
+ trackedSendResult({
75
76
  emailSendId: prior.id,
76
- resendId: "",
77
+ messageId: "",
77
78
  status: prior.status === "sent" ? "sent" : "skipped",
78
79
  ...(prior.status === "sent" ? {} : { reason: "frequency_capped" }),
79
- }) as TrackedSendResult;
80
+ } as Omit<TrackedSendResult, "resendId">);
80
81
 
81
82
  // Idempotency short-circuit (POST /v1/emails): a retry with the same key
82
83
  // returns the prior send instead of dispatching a duplicate provider call /
@@ -135,15 +136,15 @@ export async function sendTrackedEmail<K extends TemplateName>(
135
136
  const suppressedRow = rows[0];
136
137
  if (!suppressedRow) throw new Error("Failed to insert email_sends row");
137
138
 
138
- return {
139
+ return trackedSendResult({
139
140
  emailSendId: suppressedRow.id,
140
- resendId: "",
141
+ messageId: "",
141
142
  status:
142
143
  suppression === "unsubscribed" ||
143
144
  suppression === "category_unsubscribed"
144
145
  ? "unsubscribed"
145
146
  : "suppressed",
146
- };
147
+ });
147
148
  }
148
149
 
149
150
  // Frequency cap — consulted only for non-system sends (system mail sets
@@ -165,12 +166,12 @@ export async function sendTrackedEmail<K extends TemplateName>(
165
166
  to: options.to,
166
167
  category: options.category,
167
168
  });
168
- return {
169
+ return trackedSendResult({
169
170
  emailSendId: "",
170
- resendId: "",
171
+ messageId: "",
171
172
  status: "skipped",
172
173
  reason: "frequency_capped",
173
- };
174
+ });
174
175
  }
175
176
  }
176
177
  }
@@ -257,22 +258,26 @@ export async function sendTrackedEmail<K extends TemplateName>(
257
258
  const emailSendId = insertedRow.id;
258
259
 
259
260
  try {
260
- let html: string | undefined;
261
- if (options.baseUrl && prepareTrackedHtml) {
262
- const rawHtml = await renderToHtml(sendElement);
263
- html = await prepareTrackedHtml({
264
- html: rawHtml,
265
- emailSendId,
266
- baseUrl: options.baseUrl,
267
- db,
268
- });
269
- }
261
+ // HTML-ONLY wire the engine ALWAYS renders React → HTML itself. When
262
+ // tracking is on (baseUrl + prepareTrackedHtml) we render then rewrite
263
+ // links/inject the open pixel; otherwise we render plain HTML. React Email
264
+ // stays first-class for authoring/Studio; it never crosses the wire.
265
+ const rawHtml = await renderToHtml(sendElement);
266
+ const html =
267
+ options.baseUrl && prepareTrackedHtml
268
+ ? await prepareTrackedHtml({
269
+ html: rawHtml,
270
+ emailSendId,
271
+ baseUrl: options.baseUrl,
272
+ db,
273
+ })
274
+ : rawHtml;
270
275
 
271
276
  const result = await provider.send({
272
277
  from: options.from,
273
278
  to: options.to,
274
279
  subject,
275
- ...(html ? { html } : { react: sendElement }),
280
+ html,
276
281
  tags: options.tags,
277
282
  headers: sendHeaders,
278
283
  replyTo: options.replyTo,
@@ -282,7 +287,7 @@ export async function sendTrackedEmail<K extends TemplateName>(
282
287
  await db
283
288
  .update(emailSends)
284
289
  .set({
285
- resendId: result.id,
290
+ messageId: result.id,
286
291
  status: "sent",
287
292
  sentAt,
288
293
  updatedAt: sentAt,
@@ -306,7 +311,7 @@ export async function sendTrackedEmail<K extends TemplateName>(
306
311
  dedupeKey: `email.sent:${emailSendId}`,
307
312
  payload: {
308
313
  emailSendId,
309
- resendId: result.id,
314
+ messageId: result.id,
310
315
  templateKey: options.templateKey,
311
316
  to: options.to,
312
317
  userId: options.userId ?? null,
@@ -322,11 +327,11 @@ export async function sendTrackedEmail<K extends TemplateName>(
322
327
  });
323
328
  });
324
329
 
325
- return {
330
+ return trackedSendResult({
326
331
  emailSendId,
327
- resendId: result.id,
332
+ messageId: result.id,
328
333
  status: "sent",
329
- };
334
+ });
330
335
  } catch (error) {
331
336
  // A provider send failed (transient SMTP/network/429). Stamp `failed` AND
332
337
  // RELEASE the idempotency key (set it null), exactly like the suppression