@hogsend/engine 0.0.1
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/LICENSE +93 -0
- package/README.md +18 -0
- package/package.json +58 -0
- package/src/app.ts +102 -0
- package/src/container.ts +172 -0
- package/src/env.ts +56 -0
- package/src/index.ts +114 -0
- package/src/journeys/define-journey.ts +188 -0
- package/src/journeys/journey-context.ts +179 -0
- package/src/journeys/registry-singleton.ts +21 -0
- package/src/journeys/registry.ts +53 -0
- package/src/lib/alerting.ts +205 -0
- package/src/lib/api-key-hash.ts +19 -0
- package/src/lib/auth.ts +39 -0
- package/src/lib/backfill.ts +84 -0
- package/src/lib/contacts.ts +68 -0
- package/src/lib/db.ts +13 -0
- package/src/lib/email-service-types.ts +115 -0
- package/src/lib/email-stats.ts +33 -0
- package/src/lib/email.ts +94 -0
- package/src/lib/enrollment-guards.ts +56 -0
- package/src/lib/hatchet.ts +20 -0
- package/src/lib/html.ts +25 -0
- package/src/lib/ingestion.ts +162 -0
- package/src/lib/logger.ts +32 -0
- package/src/lib/mailer.ts +266 -0
- package/src/lib/notifications.ts +61 -0
- package/src/lib/posthog.ts +19 -0
- package/src/lib/redis.ts +30 -0
- package/src/lib/schemas.ts +8 -0
- package/src/lib/tracked.ts +175 -0
- package/src/lib/tracking-event-names.ts +5 -0
- package/src/lib/tracking-events.ts +84 -0
- package/src/lib/tracking.ts +78 -0
- package/src/middleware/api-key.ts +129 -0
- package/src/middleware/audit.ts +47 -0
- package/src/middleware/auth.ts +24 -0
- package/src/middleware/error-handler.ts +22 -0
- package/src/middleware/rate-limit.ts +65 -0
- package/src/middleware/request-logger.ts +19 -0
- package/src/routes/admin/alerts.ts +347 -0
- package/src/routes/admin/api-keys.ts +211 -0
- package/src/routes/admin/audit-logs.ts +102 -0
- package/src/routes/admin/bulk.ts +503 -0
- package/src/routes/admin/contacts.ts +342 -0
- package/src/routes/admin/dlq.ts +202 -0
- package/src/routes/admin/emails.ts +269 -0
- package/src/routes/admin/events.ts +132 -0
- package/src/routes/admin/index.ts +36 -0
- package/src/routes/admin/journey-logs.ts +117 -0
- package/src/routes/admin/journeys.ts +677 -0
- package/src/routes/admin/metrics.ts +559 -0
- package/src/routes/admin/preferences.ts +165 -0
- package/src/routes/admin/timeline.ts +221 -0
- package/src/routes/email/index.ts +8 -0
- package/src/routes/email/preferences.ts +144 -0
- package/src/routes/email/unsubscribe.ts +161 -0
- package/src/routes/health.ts +131 -0
- package/src/routes/index.ts +32 -0
- package/src/routes/ingest.ts +71 -0
- package/src/routes/tracking/click.ts +103 -0
- package/src/routes/tracking/index.ts +9 -0
- package/src/routes/tracking/open.ts +71 -0
- package/src/routes/webhooks/index.ts +17 -0
- package/src/routes/webhooks/resend.ts +68 -0
- package/src/routes/webhooks/sources.ts +97 -0
- package/src/webhook-sources/define-webhook-source.ts +34 -0
- package/src/worker.ts +64 -0
- package/src/workflows/check-alerts.ts +24 -0
- package/src/workflows/import-contacts.ts +134 -0
- package/src/workflows/send-email.ts +54 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import winston from "winston";
|
|
2
|
+
|
|
3
|
+
// Log to stdout only (12-factor): the platform — Railway, Docker, a VPS's
|
|
4
|
+
// systemd/journald — captures the stream. Writing log FILES from inside the app
|
|
5
|
+
// breaks in a non-root container (mkdir EACCES on a root-owned /app) and is
|
|
6
|
+
// pointless on ephemeral container filesystems. If durable file logs are ever
|
|
7
|
+
// needed, add a File transport behind an explicit, writable LOG_DIR opt-in.
|
|
8
|
+
export function createLogger(level: string = "info") {
|
|
9
|
+
return winston.createLogger({
|
|
10
|
+
level,
|
|
11
|
+
format: winston.format.combine(
|
|
12
|
+
winston.format.timestamp(),
|
|
13
|
+
winston.format.errors({ stack: true }),
|
|
14
|
+
),
|
|
15
|
+
defaultMeta: { service: "growthhog-api" },
|
|
16
|
+
transports: [
|
|
17
|
+
new winston.transports.Console({
|
|
18
|
+
format: winston.format.combine(
|
|
19
|
+
winston.format.colorize(),
|
|
20
|
+
winston.format.printf(({ level, message, timestamp, ...meta }) => {
|
|
21
|
+
const metaStr = Object.keys(meta).length
|
|
22
|
+
? ` ${JSON.stringify(meta)}`
|
|
23
|
+
: "";
|
|
24
|
+
return `${timestamp} ${level}: ${message}${metaStr}`;
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
}),
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type Logger = winston.Logger;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
2
|
+
import { emailPreferences, emailSends } from "@hogsend/db";
|
|
3
|
+
import type {
|
|
4
|
+
EmailServiceRenderOptions,
|
|
5
|
+
EmailServiceRenderResult,
|
|
6
|
+
TemplateName,
|
|
7
|
+
} from "@hogsend/email";
|
|
8
|
+
import { getTemplate, renderToHtml, renderToPlainText } from "@hogsend/email";
|
|
9
|
+
import type {
|
|
10
|
+
BatchEmailItem,
|
|
11
|
+
EmailProvider,
|
|
12
|
+
WebhookEvent,
|
|
13
|
+
WebhookEventType,
|
|
14
|
+
WebhookHandlerMap,
|
|
15
|
+
} from "@hogsend/plugin-resend";
|
|
16
|
+
import { eq, sql } from "drizzle-orm";
|
|
17
|
+
import type {
|
|
18
|
+
EmailService,
|
|
19
|
+
EmailServiceConfig,
|
|
20
|
+
EmailServiceSendOptions,
|
|
21
|
+
EmailServiceWebhookOptions,
|
|
22
|
+
EmailServiceWebhookResult,
|
|
23
|
+
SendEmailOptions,
|
|
24
|
+
SendResult,
|
|
25
|
+
TrackedSendResult,
|
|
26
|
+
} from "./email-service-types.js";
|
|
27
|
+
import type { PrepareTrackedHtmlFn } from "./tracked.js";
|
|
28
|
+
import { sendTrackedEmail } from "./tracked.js";
|
|
29
|
+
|
|
30
|
+
const WEBHOOK_TO_STATUS_FIELD: Partial<
|
|
31
|
+
Record<WebhookEventType, keyof typeof emailSends.$inferSelect>
|
|
32
|
+
> = {
|
|
33
|
+
"email.sent": "sentAt",
|
|
34
|
+
"email.delivered": "deliveredAt",
|
|
35
|
+
"email.opened": "openedAt",
|
|
36
|
+
"email.clicked": "clickedAt",
|
|
37
|
+
"email.bounced": "bouncedAt",
|
|
38
|
+
"email.complained": "complainedAt",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const WEBHOOK_TO_STATUS: Partial<Record<WebhookEventType, string>> = {
|
|
42
|
+
"email.sent": "sent",
|
|
43
|
+
"email.delivered": "delivered",
|
|
44
|
+
"email.opened": "opened",
|
|
45
|
+
"email.clicked": "clicked",
|
|
46
|
+
"email.bounced": "bounced",
|
|
47
|
+
"email.complained": "complained",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The engine-owned high-level mailer. It owns the full send pipeline —
|
|
52
|
+
* render → preference/suppression check → tracked-html rewrite → `email_sends`
|
|
53
|
+
* insert → `provider.send(...)` → status record — and delegates only the raw
|
|
54
|
+
* provider delivery + webhook parse/verify to the injected {@link EmailProvider}.
|
|
55
|
+
*/
|
|
56
|
+
export function createTrackedMailer(
|
|
57
|
+
config: EmailServiceConfig,
|
|
58
|
+
deps: {
|
|
59
|
+
provider: EmailProvider;
|
|
60
|
+
prepareTrackedHtml?: PrepareTrackedHtmlFn;
|
|
61
|
+
},
|
|
62
|
+
): EmailService {
|
|
63
|
+
const { provider } = deps;
|
|
64
|
+
const db = config.db as Database | undefined;
|
|
65
|
+
const retryDefaults = config.retryOptions;
|
|
66
|
+
const registry = config.templates;
|
|
67
|
+
|
|
68
|
+
function resolveFrom(overrideFrom?: string): string {
|
|
69
|
+
return overrideFrom ?? config.defaultFrom;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const service: EmailService = {
|
|
73
|
+
async send<K extends TemplateName>(
|
|
74
|
+
options: EmailServiceSendOptions<K>,
|
|
75
|
+
): Promise<TrackedSendResult> {
|
|
76
|
+
const from = resolveFrom(options.from);
|
|
77
|
+
|
|
78
|
+
if (db) {
|
|
79
|
+
return sendTrackedEmail({
|
|
80
|
+
db,
|
|
81
|
+
provider,
|
|
82
|
+
registry,
|
|
83
|
+
retryOptions: retryDefaults,
|
|
84
|
+
prepareTrackedHtml: deps.prepareTrackedHtml,
|
|
85
|
+
options: {
|
|
86
|
+
templateKey: options.template,
|
|
87
|
+
props: options.props,
|
|
88
|
+
from,
|
|
89
|
+
to: options.to,
|
|
90
|
+
subject: options.subject,
|
|
91
|
+
journeyStateId: options.journeyStateId,
|
|
92
|
+
category: options.category,
|
|
93
|
+
tags: options.tags,
|
|
94
|
+
headers: options.headers,
|
|
95
|
+
replyTo: options.replyTo,
|
|
96
|
+
skipPreferenceCheck: options.skipPreferenceCheck,
|
|
97
|
+
baseUrl: config.baseUrl,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { element, subject: defaultSubject } = getTemplate({
|
|
103
|
+
key: options.template,
|
|
104
|
+
props: options.props,
|
|
105
|
+
registry,
|
|
106
|
+
});
|
|
107
|
+
const result = await provider.send({
|
|
108
|
+
from,
|
|
109
|
+
to: options.to,
|
|
110
|
+
subject: options.subject ?? defaultSubject,
|
|
111
|
+
react: element,
|
|
112
|
+
tags: options.tags,
|
|
113
|
+
headers: options.headers,
|
|
114
|
+
replyTo: options.replyTo,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
emailSendId: "",
|
|
119
|
+
resendId: result.id,
|
|
120
|
+
status: "sent",
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async sendRaw(options: SendEmailOptions): Promise<SendResult> {
|
|
125
|
+
return provider.send({ ...options, from: resolveFrom(options.from) });
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async sendBatch(options: {
|
|
129
|
+
emails: BatchEmailItem[];
|
|
130
|
+
}): Promise<{ results: SendResult[] }> {
|
|
131
|
+
const emails = options.emails.map((e) => ({
|
|
132
|
+
...e,
|
|
133
|
+
from: resolveFrom(e.from),
|
|
134
|
+
}));
|
|
135
|
+
return provider.sendBatch(emails);
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
async render<K extends TemplateName>(
|
|
139
|
+
options: EmailServiceRenderOptions<K>,
|
|
140
|
+
): Promise<EmailServiceRenderResult> {
|
|
141
|
+
const { element, subject, category } = getTemplate({
|
|
142
|
+
key: options.template,
|
|
143
|
+
props: options.props,
|
|
144
|
+
registry,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const [html, text] = await Promise.all([
|
|
148
|
+
renderToHtml(element),
|
|
149
|
+
renderToPlainText(element),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
return { html, text, subject, category };
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async handleWebhook(
|
|
156
|
+
options: EmailServiceWebhookOptions,
|
|
157
|
+
): Promise<EmailServiceWebhookResult> {
|
|
158
|
+
if (!config.webhookSecret) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
"webhookSecret is required in EmailServiceConfig to handle webhooks",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const userHandlers: WebhookHandlerMap = config.webhookHandlers ?? {};
|
|
165
|
+
|
|
166
|
+
const event = provider.verifyWebhook({
|
|
167
|
+
payload: options.payload,
|
|
168
|
+
headers: options.headers,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const handled = await dispatchWebhook(event, userHandlers);
|
|
172
|
+
|
|
173
|
+
return { type: event.type, handled };
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const bounceThreshold = config.bounceThreshold ?? 3;
|
|
178
|
+
|
|
179
|
+
async function dispatchWebhook(
|
|
180
|
+
event: WebhookEvent,
|
|
181
|
+
userHandlers: WebhookHandlerMap,
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
switch (event.type) {
|
|
184
|
+
case "email.sent":
|
|
185
|
+
case "email.delivered":
|
|
186
|
+
case "email.opened":
|
|
187
|
+
case "email.clicked":
|
|
188
|
+
await updateEmailStatus(event.type, event.data.email_id);
|
|
189
|
+
break;
|
|
190
|
+
case "email.bounced":
|
|
191
|
+
await updateEmailStatus(event.type, event.data.email_id);
|
|
192
|
+
await handleBounce(event.data.to);
|
|
193
|
+
break;
|
|
194
|
+
case "email.complained":
|
|
195
|
+
await updateEmailStatus(event.type, event.data.email_id);
|
|
196
|
+
await handleComplaint(event.data.to);
|
|
197
|
+
break;
|
|
198
|
+
case "email.delivery_delayed":
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const userHandler = userHandlers[event.type] as
|
|
203
|
+
| ((e: WebhookEvent) => void | Promise<void>)
|
|
204
|
+
| undefined;
|
|
205
|
+
if (userHandler) {
|
|
206
|
+
await userHandler(event);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function handleBounce(toAddresses: string[]): Promise<void> {
|
|
214
|
+
if (!db) return;
|
|
215
|
+
const email = toAddresses[0];
|
|
216
|
+
if (!email) return;
|
|
217
|
+
|
|
218
|
+
await db
|
|
219
|
+
.update(emailPreferences)
|
|
220
|
+
.set({
|
|
221
|
+
bounceCount: sql`${emailPreferences.bounceCount} + 1`,
|
|
222
|
+
lastBounceAt: new Date(),
|
|
223
|
+
suppressed: sql`CASE WHEN ${emailPreferences.bounceCount} + 1 >= ${bounceThreshold} THEN true ELSE ${emailPreferences.suppressed} END`,
|
|
224
|
+
suppressedAt: sql`CASE WHEN ${emailPreferences.bounceCount} + 1 >= ${bounceThreshold} THEN NOW() ELSE ${emailPreferences.suppressedAt} END`,
|
|
225
|
+
updatedAt: new Date(),
|
|
226
|
+
})
|
|
227
|
+
.where(eq(emailPreferences.email, email));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function handleComplaint(toAddresses: string[]): Promise<void> {
|
|
231
|
+
if (!db) return;
|
|
232
|
+
const email = toAddresses[0];
|
|
233
|
+
if (!email) return;
|
|
234
|
+
|
|
235
|
+
await db
|
|
236
|
+
.update(emailPreferences)
|
|
237
|
+
.set({
|
|
238
|
+
suppressed: true,
|
|
239
|
+
suppressedAt: new Date(),
|
|
240
|
+
updatedAt: new Date(),
|
|
241
|
+
})
|
|
242
|
+
.where(eq(emailPreferences.email, email));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function updateEmailStatus(
|
|
246
|
+
eventType: WebhookEventType,
|
|
247
|
+
resendId: string,
|
|
248
|
+
): Promise<void> {
|
|
249
|
+
if (!db) return;
|
|
250
|
+
|
|
251
|
+
const timestampField = WEBHOOK_TO_STATUS_FIELD[eventType];
|
|
252
|
+
const status = WEBHOOK_TO_STATUS[eventType];
|
|
253
|
+
if (!timestampField || !status) return;
|
|
254
|
+
|
|
255
|
+
await db
|
|
256
|
+
.update(emailSends)
|
|
257
|
+
.set({
|
|
258
|
+
status: status as typeof emailSends.$inferSelect.status,
|
|
259
|
+
[timestampField]: new Date(),
|
|
260
|
+
updatedAt: new Date(),
|
|
261
|
+
})
|
|
262
|
+
.where(eq(emailSends.resendId, resendId));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return service;
|
|
266
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export async function sendWebhook(
|
|
2
|
+
url: string,
|
|
3
|
+
payload: Record<string, unknown>,
|
|
4
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
5
|
+
try {
|
|
6
|
+
const res = await fetch(url, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: { "Content-Type": "application/json" },
|
|
9
|
+
body: JSON.stringify(payload),
|
|
10
|
+
signal: AbortSignal.timeout(10_000),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: `HTTP ${res.status}: ${res.statusText}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return { ok: true };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return {
|
|
22
|
+
ok: false,
|
|
23
|
+
error: err instanceof Error ? err.message : String(err),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function sendSlackNotification(
|
|
29
|
+
webhookUrl: string,
|
|
30
|
+
text: string,
|
|
31
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
32
|
+
return sendWebhook(webhookUrl, { text });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
import { createResendClient } from "@hogsend/plugin-resend";
|
|
36
|
+
|
|
37
|
+
export async function sendEmailNotification(opts: {
|
|
38
|
+
to: string;
|
|
39
|
+
subject: string;
|
|
40
|
+
body: string;
|
|
41
|
+
resendApiKey: string;
|
|
42
|
+
}): Promise<{ ok: boolean; error?: string }> {
|
|
43
|
+
try {
|
|
44
|
+
const client = createResendClient({ apiKey: opts.resendApiKey });
|
|
45
|
+
const { error } = await client.emails.send({
|
|
46
|
+
from: "Hogsend Alerts <alerts@hogsend.com>",
|
|
47
|
+
to: opts.to,
|
|
48
|
+
subject: opts.subject,
|
|
49
|
+
html: opts.body,
|
|
50
|
+
});
|
|
51
|
+
if (error) {
|
|
52
|
+
return { ok: false, error: error.message };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
error: err instanceof Error ? err.message : String(err),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createPostHogService,
|
|
3
|
+
type PostHogService,
|
|
4
|
+
} from "@hogsend/plugin-posthog";
|
|
5
|
+
import { getRedis } from "./redis.js";
|
|
6
|
+
|
|
7
|
+
let _posthog: PostHogService | undefined;
|
|
8
|
+
|
|
9
|
+
export function getPostHog(): PostHogService | undefined {
|
|
10
|
+
if (!process.env.POSTHOG_API_KEY) return undefined;
|
|
11
|
+
if (!_posthog) {
|
|
12
|
+
_posthog = createPostHogService({
|
|
13
|
+
apiKey: process.env.POSTHOG_API_KEY,
|
|
14
|
+
host: process.env.POSTHOG_HOST,
|
|
15
|
+
redis: getRedis(),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return _posthog;
|
|
19
|
+
}
|
package/src/lib/redis.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Redis } from "ioredis";
|
|
2
|
+
|
|
3
|
+
let _redis: Redis | undefined;
|
|
4
|
+
|
|
5
|
+
export function getRedis(): Redis {
|
|
6
|
+
if (!_redis) {
|
|
7
|
+
_redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
|
8
|
+
// Railway private networking (*.railway.internal) is IPv6-only; without
|
|
9
|
+
// family: 0, ioredis only tries IPv4 and silently fails to connect —
|
|
10
|
+
// which is why Postgres (postgres.js does dual-stack) worked but Redis
|
|
11
|
+
// didn't. family: 0 lets DNS resolve both A and AAAA. Harmless locally.
|
|
12
|
+
family: 0,
|
|
13
|
+
// Connect on first command (e.g. the /v1/health probe), not at import.
|
|
14
|
+
lazyConnect: true,
|
|
15
|
+
connectTimeout: 5000,
|
|
16
|
+
maxRetriesPerRequest: 3,
|
|
17
|
+
// Retry a transient blip a few times, then give up cleanly. Without a cap,
|
|
18
|
+
// an environment with no redis (tests, self-host without redis) keeps a
|
|
19
|
+
// socket retrying forever — which hangs vitest and leaks handles. In prod
|
|
20
|
+
// redis is reachable, so it connects on the first try and never gives up.
|
|
21
|
+
retryStrategy: (times) =>
|
|
22
|
+
times > 5 ? null : Math.min(times * 300, 1500),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return _redis;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getRedisIfConnected(): Redis | undefined {
|
|
29
|
+
return _redis;
|
|
30
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
2
|
+
import { emailPreferences, emailSends } from "@hogsend/db";
|
|
3
|
+
import type {
|
|
4
|
+
EmailSuppressionError,
|
|
5
|
+
RetryOptions,
|
|
6
|
+
TemplateName,
|
|
7
|
+
TemplateRegistry,
|
|
8
|
+
} from "@hogsend/email";
|
|
9
|
+
import { getTemplate, renderToHtml } from "@hogsend/email";
|
|
10
|
+
import type { EmailProvider } from "@hogsend/plugin-resend";
|
|
11
|
+
import { eq } from "drizzle-orm";
|
|
12
|
+
import type {
|
|
13
|
+
SendTrackedEmailOptions,
|
|
14
|
+
TrackedSendResult,
|
|
15
|
+
} from "./email-service-types.js";
|
|
16
|
+
|
|
17
|
+
export type PrepareTrackedHtmlFn = (opts: {
|
|
18
|
+
html: string;
|
|
19
|
+
emailSendId: string;
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
db: Database;
|
|
22
|
+
}) => Promise<string>;
|
|
23
|
+
|
|
24
|
+
interface TrackedEmailDeps {
|
|
25
|
+
db: Database;
|
|
26
|
+
provider: EmailProvider;
|
|
27
|
+
/** The client app's template registry, threaded into {@link getTemplate}. */
|
|
28
|
+
registry: TemplateRegistry;
|
|
29
|
+
retryOptions?: RetryOptions;
|
|
30
|
+
prepareTrackedHtml?: PrepareTrackedHtmlFn;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function sendTrackedEmail<K extends TemplateName>(
|
|
34
|
+
opts: TrackedEmailDeps & { options: SendTrackedEmailOptions<K> },
|
|
35
|
+
): Promise<TrackedSendResult> {
|
|
36
|
+
const { db, provider, registry, prepareTrackedHtml, options } = opts;
|
|
37
|
+
|
|
38
|
+
if (!options.skipPreferenceCheck) {
|
|
39
|
+
const suppression = await checkSuppression(
|
|
40
|
+
db,
|
|
41
|
+
options.to,
|
|
42
|
+
options.category,
|
|
43
|
+
);
|
|
44
|
+
if (suppression) {
|
|
45
|
+
const rows = await db
|
|
46
|
+
.insert(emailSends)
|
|
47
|
+
.values({
|
|
48
|
+
templateKey: options.templateKey,
|
|
49
|
+
fromEmail: options.from,
|
|
50
|
+
toEmail: options.to,
|
|
51
|
+
subject: options.subject ?? "",
|
|
52
|
+
category: options.category,
|
|
53
|
+
journeyStateId: options.journeyStateId,
|
|
54
|
+
status: "failed",
|
|
55
|
+
})
|
|
56
|
+
.returning({ id: emailSends.id });
|
|
57
|
+
|
|
58
|
+
const suppressedRow = rows[0];
|
|
59
|
+
if (!suppressedRow) throw new Error("Failed to insert email_sends row");
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
emailSendId: suppressedRow.id,
|
|
63
|
+
resendId: "",
|
|
64
|
+
status:
|
|
65
|
+
suppression === "unsubscribed" ||
|
|
66
|
+
suppression === "category_unsubscribed"
|
|
67
|
+
? "unsubscribed"
|
|
68
|
+
: "suppressed",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const {
|
|
74
|
+
element,
|
|
75
|
+
subject: defaultSubject,
|
|
76
|
+
category,
|
|
77
|
+
} = getTemplate({ key: options.templateKey, props: options.props, registry });
|
|
78
|
+
|
|
79
|
+
const subject = options.subject ?? defaultSubject;
|
|
80
|
+
|
|
81
|
+
const insertRows = await db
|
|
82
|
+
.insert(emailSends)
|
|
83
|
+
.values({
|
|
84
|
+
templateKey: options.templateKey,
|
|
85
|
+
fromEmail: options.from,
|
|
86
|
+
toEmail: options.to,
|
|
87
|
+
subject,
|
|
88
|
+
category: options.category ?? category,
|
|
89
|
+
journeyStateId: options.journeyStateId,
|
|
90
|
+
status: "queued",
|
|
91
|
+
})
|
|
92
|
+
.returning({ id: emailSends.id });
|
|
93
|
+
|
|
94
|
+
const insertedRow = insertRows[0];
|
|
95
|
+
if (!insertedRow) throw new Error("Failed to insert email_sends row");
|
|
96
|
+
const emailSendId = insertedRow.id;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
let html: string | undefined;
|
|
100
|
+
if (options.baseUrl && prepareTrackedHtml) {
|
|
101
|
+
const rawHtml = await renderToHtml(element);
|
|
102
|
+
html = await prepareTrackedHtml({
|
|
103
|
+
html: rawHtml,
|
|
104
|
+
emailSendId,
|
|
105
|
+
baseUrl: options.baseUrl,
|
|
106
|
+
db,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = await provider.send({
|
|
111
|
+
from: options.from,
|
|
112
|
+
to: options.to,
|
|
113
|
+
subject,
|
|
114
|
+
...(html ? { html } : { react: element }),
|
|
115
|
+
tags: options.tags,
|
|
116
|
+
headers: options.headers,
|
|
117
|
+
replyTo: options.replyTo,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await db
|
|
121
|
+
.update(emailSends)
|
|
122
|
+
.set({
|
|
123
|
+
resendId: result.id,
|
|
124
|
+
status: "sent",
|
|
125
|
+
sentAt: new Date(),
|
|
126
|
+
updatedAt: new Date(),
|
|
127
|
+
})
|
|
128
|
+
.where(eq(emailSends.id, emailSendId));
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
emailSendId,
|
|
132
|
+
resendId: result.id,
|
|
133
|
+
status: "sent",
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
await db
|
|
137
|
+
.update(emailSends)
|
|
138
|
+
.set({
|
|
139
|
+
status: "failed",
|
|
140
|
+
updatedAt: new Date(),
|
|
141
|
+
})
|
|
142
|
+
.where(eq(emailSends.id, emailSendId));
|
|
143
|
+
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type SuppressionReason = EmailSuppressionError["reason"] | null;
|
|
149
|
+
|
|
150
|
+
async function checkSuppression(
|
|
151
|
+
db: Database,
|
|
152
|
+
email: string,
|
|
153
|
+
category?: string,
|
|
154
|
+
): Promise<SuppressionReason> {
|
|
155
|
+
const rows = await db
|
|
156
|
+
.select()
|
|
157
|
+
.from(emailPreferences)
|
|
158
|
+
.where(eq(emailPreferences.email, email))
|
|
159
|
+
.limit(1);
|
|
160
|
+
|
|
161
|
+
if (rows.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
const prefs = rows[0];
|
|
164
|
+
if (!prefs) return null;
|
|
165
|
+
|
|
166
|
+
if (prefs.suppressed) return "suppressed";
|
|
167
|
+
if (prefs.unsubscribedAll) return "unsubscribed";
|
|
168
|
+
|
|
169
|
+
if (category && prefs.categories) {
|
|
170
|
+
const categories = prefs.categories as Record<string, boolean>;
|
|
171
|
+
if (categories[category] === false) return "category_unsubscribed";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// First-party tracking event names emitted by the engine's own tracking
|
|
2
|
+
// endpoints (link click + open pixel). These belong to the engine — they are
|
|
3
|
+
// not journey content — so they live here rather than in app-side constants.
|
|
4
|
+
export const EMAIL_OPENED = "email.opened" as const;
|
|
5
|
+
export const EMAIL_LINK_CLICKED = "email.link_clicked" as const;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
3
|
+
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
4
|
+
import type { PostHogService } from "@hogsend/plugin-posthog";
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
|
+
import { ingestEvent } from "./ingestion.js";
|
|
7
|
+
import type { Logger } from "./logger.js";
|
|
8
|
+
|
|
9
|
+
interface EmailSendContext {
|
|
10
|
+
userId: string;
|
|
11
|
+
userEmail: string;
|
|
12
|
+
templateKey: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function resolveEmailSendContext(
|
|
16
|
+
db: Database,
|
|
17
|
+
emailSendId: string,
|
|
18
|
+
): Promise<EmailSendContext | null> {
|
|
19
|
+
const rows = await db
|
|
20
|
+
.select({
|
|
21
|
+
toEmail: emailSends.toEmail,
|
|
22
|
+
templateKey: emailSends.templateKey,
|
|
23
|
+
userId: journeyStates.userId,
|
|
24
|
+
userEmail: journeyStates.userEmail,
|
|
25
|
+
})
|
|
26
|
+
.from(emailSends)
|
|
27
|
+
.leftJoin(journeyStates, eq(emailSends.journeyStateId, journeyStates.id))
|
|
28
|
+
.where(eq(emailSends.id, emailSendId))
|
|
29
|
+
.limit(1);
|
|
30
|
+
|
|
31
|
+
const row = rows[0];
|
|
32
|
+
if (!row) return null;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
userId: row.userId ?? row.toEmail,
|
|
36
|
+
userEmail: row.userEmail ?? row.toEmail,
|
|
37
|
+
templateKey: row.templateKey,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PushTrackingEventOpts {
|
|
42
|
+
db: Database;
|
|
43
|
+
hatchet: HatchetClient;
|
|
44
|
+
registry: JourneyRegistry;
|
|
45
|
+
logger: Logger;
|
|
46
|
+
posthog?: PostHogService;
|
|
47
|
+
event: string;
|
|
48
|
+
emailSendId: string;
|
|
49
|
+
properties?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function pushTrackingEvent(
|
|
53
|
+
opts: PushTrackingEventOpts,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
const { db, hatchet, registry, logger, posthog, event, emailSendId } = opts;
|
|
56
|
+
|
|
57
|
+
const ctx = await resolveEmailSendContext(db, emailSendId);
|
|
58
|
+
if (!ctx) return;
|
|
59
|
+
|
|
60
|
+
const properties: Record<string, unknown> = {
|
|
61
|
+
emailSendId,
|
|
62
|
+
templateKey: ctx.templateKey,
|
|
63
|
+
...opts.properties,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
posthog?.captureEvent({
|
|
67
|
+
distinctId: ctx.userId,
|
|
68
|
+
event,
|
|
69
|
+
properties,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await ingestEvent({
|
|
73
|
+
db,
|
|
74
|
+
registry,
|
|
75
|
+
hatchet,
|
|
76
|
+
logger,
|
|
77
|
+
event: {
|
|
78
|
+
event,
|
|
79
|
+
userId: ctx.userId,
|
|
80
|
+
userEmail: ctx.userEmail,
|
|
81
|
+
properties,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|