@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
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
|
|
18
|
-
EmailService,
|
|
19
|
-
EmailServiceConfig,
|
|
20
|
-
EmailServiceSendOptions,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 {
|
|
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<
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
messageId: result.id,
|
|
134
163
|
status: "sent",
|
|
135
|
-
};
|
|
164
|
+
});
|
|
136
165
|
},
|
|
137
166
|
|
|
138
167
|
async sendRaw(options: SendRawOptions): Promise<SendResult> {
|
|
139
|
-
|
|
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
|
-
|
|
200
|
+
event: EmailEvent,
|
|
201
|
+
_providerId?: string,
|
|
171
202
|
): Promise<EmailServiceWebhookResult> {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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:
|
|
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.
|
|
223
|
+
await updateEmailStatus(event.type, event.messageId);
|
|
203
224
|
break;
|
|
204
225
|
case "email.delivered":
|
|
205
|
-
await updateEmailStatus(event.type, event.
|
|
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.
|
|
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
|
|
214
|
-
//
|
|
215
|
-
// updates the DB status, it does NOT emit
|
|
216
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
225
|
-
bounceType: event.
|
|
226
|
-
bounceReason: event.
|
|
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
|
-
|
|
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.
|
|
232
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
252
|
-
if (
|
|
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(
|
|
316
|
+
.where(inArray(emailPreferences.email, emails));
|
|
264
317
|
}
|
|
265
318
|
|
|
266
|
-
async function handleComplaint(
|
|
319
|
+
async function handleComplaint(recipients: string[]): Promise<void> {
|
|
267
320
|
if (!db) return;
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
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(
|
|
332
|
+
.where(inArray(emailPreferences.email, emails));
|
|
279
333
|
}
|
|
280
334
|
|
|
281
335
|
/**
|
|
282
|
-
* Emit the provider-funnel outbound event (`email.delivered` /
|
|
283
|
-
* for a
|
|
284
|
-
*
|
|
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
|
-
|
|
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
|
|
354
|
+
void resolveEmailSendContextByMessageId(database, messageId)
|
|
299
355
|
.then((ctx) => {
|
|
300
356
|
if (!ctx) return;
|
|
301
357
|
const base = {
|
|
302
358
|
emailSendId: ctx.emailSendId,
|
|
303
|
-
|
|
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
|
-
|
|
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:
|
|
342
|
-
|
|
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.
|
|
425
|
+
.where(eq(emailSends.messageId, messageId));
|
|
361
426
|
}
|
|
362
427
|
|
|
363
428
|
return service;
|
package/src/lib/outbound.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/tracked.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
77
|
+
messageId: "",
|
|
77
78
|
status: prior.status === "sent" ? "sent" : "skipped",
|
|
78
79
|
...(prior.status === "sent" ? {} : { reason: "frequency_capped" }),
|
|
79
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|