@hogsend/engine 0.14.0 → 0.16.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 -7
- package/src/env.ts +7 -0
- package/src/index.ts +6 -0
- package/src/journeys/define-journey.ts +40 -3
- package/src/lib/identity-token.ts +112 -0
- package/src/lib/tracking.ts +33 -1
- package/src/routes/tracking/answer.ts +187 -0
- package/src/routes/tracking/click.ts +39 -2
- package/src/routes/tracking/identify.ts +71 -0
- package/src/routes/tracking/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
"svix": "^1.95.1",
|
|
41
41
|
"winston": "^3.19.0",
|
|
42
42
|
"zod": "^4.4.3",
|
|
43
|
-
"@hogsend/core": "^0.
|
|
44
|
-
"@hogsend/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^0.
|
|
43
|
+
"@hogsend/core": "^0.16.0",
|
|
44
|
+
"@hogsend/db": "^0.16.0",
|
|
45
|
+
"@hogsend/email": "^0.16.0",
|
|
46
|
+
"@hogsend/plugin-posthog": "^0.16.0",
|
|
47
|
+
"@hogsend/plugin-resend": "^0.16.0"
|
|
48
48
|
},
|
|
49
49
|
"optionalDependencies": {
|
|
50
|
-
"@hogsend/plugin-postmark": "^0.
|
|
50
|
+
"@hogsend/plugin-postmark": "^0.16.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.15.3",
|
package/src/env.ts
CHANGED
|
@@ -141,6 +141,13 @@ export const env = createEnv({
|
|
|
141
141
|
// Default OFF to avoid a surprise double-emit alongside the existing
|
|
142
142
|
// fire-and-forget PostHog capture path.
|
|
143
143
|
ENABLE_POSTHOG_DESTINATION: z.coerce.boolean().default(false),
|
|
144
|
+
/**
|
|
145
|
+
* Append a short-lived signed identity token (`hs_t`) to tracked-link
|
|
146
|
+
* redirect destinations, so the landing site can stitch the email click
|
|
147
|
+
* to its web session (`POST /v1/t/identify` + posthog.identify). Opt-in:
|
|
148
|
+
* it changes outbound URLs, which can break pre-signed destinations.
|
|
149
|
+
*/
|
|
150
|
+
TRACKING_IDENTITY_TOKEN: z.coerce.boolean().default(false),
|
|
144
151
|
RESEND_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
145
152
|
ADMIN_API_KEY: z.string().min(1).optional(),
|
|
146
153
|
API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
|
package/src/index.ts
CHANGED
|
@@ -205,6 +205,12 @@ export { checkEmailPreferences } from "./lib/enrollment-guards.js";
|
|
|
205
205
|
export { isFrequencyCapped } from "./lib/frequency-cap.js";
|
|
206
206
|
export { addrSpecOf, hostOfFromAddress } from "./lib/from-address.js";
|
|
207
207
|
export { hatchet } from "./lib/hatchet.js";
|
|
208
|
+
export {
|
|
209
|
+
generateIdentityToken,
|
|
210
|
+
type IdentityTokenPayload,
|
|
211
|
+
InvalidIdentityTokenError,
|
|
212
|
+
validateIdentityToken,
|
|
213
|
+
} from "./lib/identity-token.js";
|
|
208
214
|
// --- Ingestion pipeline ---
|
|
209
215
|
export {
|
|
210
216
|
type IngestEvent,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { JsonValue } from "@hatchet-dev/typescript-sdk/v1/types.js";
|
|
2
|
-
import { evaluatePropertyConditions } from "@hogsend/core";
|
|
2
|
+
import { criteriaBuilder, evaluatePropertyConditions } from "@hogsend/core";
|
|
3
3
|
import type {
|
|
4
4
|
JourneyMeta,
|
|
5
|
+
JourneyMetaInput,
|
|
5
6
|
JourneyRunFn,
|
|
6
7
|
JourneyUser,
|
|
8
|
+
JourneyWhere,
|
|
9
|
+
PropertyCondition,
|
|
7
10
|
} from "@hogsend/core/types";
|
|
8
11
|
import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
|
|
9
12
|
import { and, eq, inArray, notInArray } from "drizzle-orm";
|
|
@@ -37,11 +40,45 @@ export interface DefinedJourney {
|
|
|
37
40
|
task: ReturnType<typeof hatchet.durableTask>;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a builder-function `where` to its `PropertyCondition[]` — a
|
|
45
|
+
* one-shot, definition-time call mirroring `defineBucket`'s criteria
|
|
46
|
+
* normalization. A declarative array passes straight through, so existing
|
|
47
|
+
* journeys are unaffected; downstream (registry parse, checkExits, admin
|
|
48
|
+
* routes, Studio) only ever sees plain data.
|
|
49
|
+
*/
|
|
50
|
+
function normalizeWhere(
|
|
51
|
+
where: JourneyWhere | undefined,
|
|
52
|
+
): PropertyCondition[] | undefined {
|
|
53
|
+
if (typeof where !== "function") return where;
|
|
54
|
+
const resolved = where(criteriaBuilder);
|
|
55
|
+
return Array.isArray(resolved) ? resolved : [resolved];
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
export function defineJourney(options: {
|
|
41
|
-
meta:
|
|
59
|
+
meta: JourneyMetaInput;
|
|
42
60
|
run: JourneyRunFn;
|
|
43
61
|
}): DefinedJourney {
|
|
44
|
-
const {
|
|
62
|
+
const { trigger, exitOn, ...rest } = options.meta;
|
|
63
|
+
const triggerWhere = normalizeWhere(trigger.where);
|
|
64
|
+
const meta: JourneyMeta = {
|
|
65
|
+
...rest,
|
|
66
|
+
trigger: {
|
|
67
|
+
event: trigger.event,
|
|
68
|
+
...(triggerWhere ? { where: triggerWhere } : {}),
|
|
69
|
+
},
|
|
70
|
+
...(exitOn
|
|
71
|
+
? {
|
|
72
|
+
exitOn: exitOn.map((exit) => {
|
|
73
|
+
const exitWhere = normalizeWhere(exit.where);
|
|
74
|
+
return {
|
|
75
|
+
event: exit.event,
|
|
76
|
+
...(exitWhere ? { where: exitWhere } : {}),
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
}
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
45
82
|
|
|
46
83
|
const task = hatchet.durableTask({
|
|
47
84
|
name: `journey-${meta.id}`,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Short-lived identity token appended to tracked-link redirects as `hs_t`
|
|
10
|
+
* (opt-in via TRACKING_IDENTITY_TOKEN). The landing site exchanges it at
|
|
11
|
+
* `POST /v1/t/identify` for the distinct id and calls `posthog.identify` —
|
|
12
|
+
* stitching the email click to the web session.
|
|
13
|
+
*
|
|
14
|
+
* ENCRYPTED (AES-256-GCM keyed off BETTER_AUTH_SECRET), not merely signed:
|
|
15
|
+
* the distinct id can fall back to an email address, and a signed-but-
|
|
16
|
+
* readable token would put a base64-decodable email in the URL — into
|
|
17
|
+
* browser history, referrers, and any script on the landing page. The GCM
|
|
18
|
+
* auth tag also covers integrity, so tampering fails decryption.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface IdentityTokenPayload {
|
|
22
|
+
/** The distinct id the landing site should identify as. */
|
|
23
|
+
distinctId: string;
|
|
24
|
+
emailSendId: string;
|
|
25
|
+
exp: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class InvalidIdentityTokenError extends Error {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "InvalidIdentityTokenError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_EXPIRY_SECONDS = 60 * 60; // 1 hour — a click-to-landing hop
|
|
36
|
+
const IV_LENGTH = 12;
|
|
37
|
+
const TAG_LENGTH = 16;
|
|
38
|
+
|
|
39
|
+
function deriveKey(secret: string): Buffer {
|
|
40
|
+
return createHash("sha256").update(secret).digest();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function generateIdentityToken(opts: {
|
|
44
|
+
secret: string;
|
|
45
|
+
distinctId: string;
|
|
46
|
+
emailSendId: string;
|
|
47
|
+
expiresInSeconds?: number;
|
|
48
|
+
}): string {
|
|
49
|
+
const payload: IdentityTokenPayload = {
|
|
50
|
+
distinctId: opts.distinctId,
|
|
51
|
+
emailSendId: opts.emailSendId,
|
|
52
|
+
exp:
|
|
53
|
+
Math.floor(Date.now() / 1000) +
|
|
54
|
+
(opts.expiresInSeconds ?? DEFAULT_EXPIRY_SECONDS),
|
|
55
|
+
};
|
|
56
|
+
const iv = randomBytes(IV_LENGTH);
|
|
57
|
+
const cipher = createCipheriv("aes-256-gcm", deriveKey(opts.secret), iv);
|
|
58
|
+
const ciphertext = Buffer.concat([
|
|
59
|
+
cipher.update(JSON.stringify(payload), "utf-8"),
|
|
60
|
+
cipher.final(),
|
|
61
|
+
]);
|
|
62
|
+
return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
|
|
63
|
+
"base64url",
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateIdentityToken(opts: {
|
|
68
|
+
token: string;
|
|
69
|
+
secret: string;
|
|
70
|
+
}): IdentityTokenPayload {
|
|
71
|
+
let raw: Buffer;
|
|
72
|
+
try {
|
|
73
|
+
raw = Buffer.from(opts.token, "base64url");
|
|
74
|
+
} catch {
|
|
75
|
+
throw new InvalidIdentityTokenError("Malformed token");
|
|
76
|
+
}
|
|
77
|
+
if (raw.length <= IV_LENGTH + TAG_LENGTH) {
|
|
78
|
+
throw new InvalidIdentityTokenError("Malformed token");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const iv = raw.subarray(0, IV_LENGTH);
|
|
82
|
+
const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
|
|
83
|
+
const tag = raw.subarray(raw.length - TAG_LENGTH);
|
|
84
|
+
|
|
85
|
+
let payload: IdentityTokenPayload;
|
|
86
|
+
try {
|
|
87
|
+
const decipher = createDecipheriv(
|
|
88
|
+
"aes-256-gcm",
|
|
89
|
+
deriveKey(opts.secret),
|
|
90
|
+
iv,
|
|
91
|
+
);
|
|
92
|
+
decipher.setAuthTag(tag);
|
|
93
|
+
const plaintext = Buffer.concat([
|
|
94
|
+
decipher.update(ciphertext),
|
|
95
|
+
decipher.final(),
|
|
96
|
+
]).toString("utf-8");
|
|
97
|
+
payload = JSON.parse(plaintext);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new InvalidIdentityTokenError("Bad token");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
typeof payload.distinctId !== "string" ||
|
|
104
|
+
typeof payload.exp !== "number"
|
|
105
|
+
) {
|
|
106
|
+
throw new InvalidIdentityTokenError("Invalid payload shape");
|
|
107
|
+
}
|
|
108
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
109
|
+
throw new InvalidIdentityTokenError("Token expired");
|
|
110
|
+
}
|
|
111
|
+
return payload;
|
|
112
|
+
}
|
package/src/lib/tracking.ts
CHANGED
|
@@ -4,10 +4,12 @@ import { trackedLinks } from "@hogsend/db";
|
|
|
4
4
|
import {
|
|
5
5
|
EMAIL_ACTION_EVENT_ATTR,
|
|
6
6
|
EMAIL_ACTION_PROPS_ATTR,
|
|
7
|
+
HOSTED_ANSWER_HREF,
|
|
7
8
|
} from "@hogsend/email";
|
|
8
9
|
|
|
9
10
|
const ANCHOR_RE = /<a\b[^>]*>/gi;
|
|
10
11
|
const HREF_RE = /\bhref="(https?:\/\/[^"]+)"/i;
|
|
12
|
+
const SENTINEL_HREF_RE = new RegExp(`\\bhref="${HOSTED_ANSWER_HREF}"`, "i");
|
|
11
13
|
const EVENT_ATTR_RE = new RegExp(
|
|
12
14
|
`\\b${EMAIL_ACTION_EVENT_ATTR}="([^"]*)"`,
|
|
13
15
|
"i",
|
|
@@ -137,9 +139,30 @@ export async function rewriteLinks(opts: {
|
|
|
137
139
|
|
|
138
140
|
for (const match of html.matchAll(ANCHOR_RE)) {
|
|
139
141
|
const tag = match[0];
|
|
140
|
-
const url = tag.match(HREF_RE)?.[1];
|
|
141
142
|
const semantic = parseSemanticAttrs(tag);
|
|
142
143
|
|
|
144
|
+
// Sentinel destination: the engine-hosted answer page. Only meaningful on
|
|
145
|
+
// a semantic link, and resolvable only here (the page URL embeds the
|
|
146
|
+
// tracked link's own id, generated client-side below).
|
|
147
|
+
if (SENTINEL_HREF_RE.test(tag)) {
|
|
148
|
+
if (!semantic) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`href="${HOSTED_ANSWER_HREF}" is only valid on a semantic link (EmailAction)`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const key = linkKey(HOSTED_ANSWER_HREF, semantic);
|
|
154
|
+
if (!pending.has(key)) {
|
|
155
|
+
const id = randomUUID();
|
|
156
|
+
pending.set(key, {
|
|
157
|
+
id,
|
|
158
|
+
url: `${baseUrl}/v1/t/a/${id}`,
|
|
159
|
+
semantic,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const url = tag.match(HREF_RE)?.[1];
|
|
143
166
|
if (!url || shouldSkipUrl(url)) {
|
|
144
167
|
if (semantic) {
|
|
145
168
|
throw new Error(
|
|
@@ -168,6 +191,15 @@ export async function rewriteLinks(opts: {
|
|
|
168
191
|
);
|
|
169
192
|
|
|
170
193
|
return html.replace(ANCHOR_RE, (tag) => {
|
|
194
|
+
if (SENTINEL_HREF_RE.test(tag)) {
|
|
195
|
+
const link = pending.get(
|
|
196
|
+
linkKey(HOSTED_ANSWER_HREF, parseSemanticAttrs(tag)),
|
|
197
|
+
);
|
|
198
|
+
if (!link) return tag;
|
|
199
|
+
return tag
|
|
200
|
+
.replace(SENTINEL_HREF_RE, `href="${baseUrl}/v1/t/c/${link.id}"`)
|
|
201
|
+
.replace(STRIP_SEMANTIC_ATTRS_RE, "");
|
|
202
|
+
}
|
|
171
203
|
const url = tag.match(HREF_RE)?.[1];
|
|
172
204
|
if (!url || shouldSkipUrl(url)) return tag;
|
|
173
205
|
const link = pending.get(linkKey(url, parseSemanticAttrs(tag)));
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { trackedLinks } from "@hogsend/db";
|
|
2
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { htmlPage } from "../../lib/html.js";
|
|
6
|
+
import {
|
|
7
|
+
pushTrackingEvent,
|
|
8
|
+
resolveEmailSendContext,
|
|
9
|
+
} from "../../lib/tracking-events.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The hosted answer page — the engine-served landing for a semantic link
|
|
13
|
+
* whose author has no page of their own (`href={HOSTED_ANSWER_HREF}` in
|
|
14
|
+
* `EmailAction`). Possession of the unguessable link id is the auth, the
|
|
15
|
+
* same trust model as unsubscribe.
|
|
16
|
+
*
|
|
17
|
+
* GET shows the recorded answer and an optional free-text box; POST ingests
|
|
18
|
+
* the comment as `<event>.comment` — a real consumer event journeys and
|
|
19
|
+
* destinations can react to. ONE comment per (send, event): the
|
|
20
|
+
* `semc:` idempotency key mirrors first-answer-wins, which also caps what a
|
|
21
|
+
* forwarded link can inject.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const COMMENT_MAX = 2000;
|
|
25
|
+
|
|
26
|
+
function escapeHtml(value: string): string {
|
|
27
|
+
return value
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/'/g, "'");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function describeAnswer(properties: Record<string, unknown> | null): string {
|
|
36
|
+
if (!properties) return "";
|
|
37
|
+
const parts = Object.entries(properties)
|
|
38
|
+
.filter(([, v]) => v !== null && v !== undefined)
|
|
39
|
+
.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`);
|
|
40
|
+
return parts.join(" · ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function loadSemanticLink(
|
|
44
|
+
db: AppEnv["Variables"]["container"]["db"],
|
|
45
|
+
id: string,
|
|
46
|
+
) {
|
|
47
|
+
const rows = await db
|
|
48
|
+
.select({
|
|
49
|
+
id: trackedLinks.id,
|
|
50
|
+
emailSendId: trackedLinks.emailSendId,
|
|
51
|
+
event: trackedLinks.event,
|
|
52
|
+
eventProperties: trackedLinks.eventProperties,
|
|
53
|
+
})
|
|
54
|
+
.from(trackedLinks)
|
|
55
|
+
.where(eq(trackedLinks.id, id))
|
|
56
|
+
.limit(1);
|
|
57
|
+
const link = rows[0];
|
|
58
|
+
return link?.event ? link : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const answerPageRoute = createRoute({
|
|
62
|
+
method: "get",
|
|
63
|
+
path: "/a/:id",
|
|
64
|
+
tags: ["Tracking"],
|
|
65
|
+
summary: "Hosted answer page for a semantic link",
|
|
66
|
+
request: {
|
|
67
|
+
params: z.object({ id: z.string().uuid() }),
|
|
68
|
+
},
|
|
69
|
+
responses: {
|
|
70
|
+
200: {
|
|
71
|
+
description: "Answer page",
|
|
72
|
+
content: { "text/html": { schema: z.string() } },
|
|
73
|
+
},
|
|
74
|
+
404: { description: "Not a semantic link" },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const answerCommentRoute = createRoute({
|
|
79
|
+
method: "post",
|
|
80
|
+
path: "/a/:id",
|
|
81
|
+
tags: ["Tracking"],
|
|
82
|
+
summary: "Attach a free-text comment to a semantic answer",
|
|
83
|
+
request: {
|
|
84
|
+
params: z.object({ id: z.string().uuid() }),
|
|
85
|
+
body: {
|
|
86
|
+
content: {
|
|
87
|
+
"application/x-www-form-urlencoded": {
|
|
88
|
+
schema: z.object({ comment: z.string().min(1).max(COMMENT_MAX) }),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
responses: {
|
|
94
|
+
200: {
|
|
95
|
+
description: "Comment recorded",
|
|
96
|
+
content: { "text/html": { schema: z.string() } },
|
|
97
|
+
},
|
|
98
|
+
404: { description: "Not a semantic link" },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const answerRouter = new OpenAPIHono<AppEnv>()
|
|
103
|
+
.openapi(answerPageRoute, async (c) => {
|
|
104
|
+
const { id } = c.req.valid("param");
|
|
105
|
+
const { db } = c.get("container");
|
|
106
|
+
|
|
107
|
+
const link = await loadSemanticLink(db, id);
|
|
108
|
+
if (!link) {
|
|
109
|
+
return c.html(
|
|
110
|
+
htmlPage({
|
|
111
|
+
title: "Not found",
|
|
112
|
+
body: "<h1>Nothing here</h1><p>This link doesn't lead anywhere.</p>",
|
|
113
|
+
}),
|
|
114
|
+
404,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const answer = describeAnswer(link.eventProperties);
|
|
119
|
+
return c.html(
|
|
120
|
+
htmlPage({
|
|
121
|
+
title: "Thanks — answer recorded",
|
|
122
|
+
body: `
|
|
123
|
+
<h1>Thanks — that's recorded.</h1>
|
|
124
|
+
${answer ? `<p>Your answer: <strong>${answer}</strong></p>` : ""}
|
|
125
|
+
<p>Anything you'd like to add? A sentence here goes straight to the team.</p>
|
|
126
|
+
<form method="post" action="">
|
|
127
|
+
<textarea name="comment" rows="4" maxlength="${COMMENT_MAX}" required
|
|
128
|
+
style="width:100%;padding:10px;border:1px solid #e5e7eb;border-radius:8px;font:inherit"></textarea>
|
|
129
|
+
<button type="submit"
|
|
130
|
+
style="margin-top:12px;padding:10px 18px;border:0;border-radius:8px;background:#1a1a1a;color:#fff;font:inherit;cursor:pointer">
|
|
131
|
+
Send
|
|
132
|
+
</button>
|
|
133
|
+
</form>`,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
})
|
|
137
|
+
.openapi(answerCommentRoute, async (c) => {
|
|
138
|
+
const { id } = c.req.valid("param");
|
|
139
|
+
const { comment } = c.req.valid("form");
|
|
140
|
+
const { db, hatchet, registry, logger } = c.get("container");
|
|
141
|
+
|
|
142
|
+
const link = await loadSemanticLink(db, id);
|
|
143
|
+
if (!link) {
|
|
144
|
+
return c.html(
|
|
145
|
+
htmlPage({
|
|
146
|
+
title: "Not found",
|
|
147
|
+
body: "<h1>Nothing here</h1><p>This link doesn't lead anywhere.</p>",
|
|
148
|
+
}),
|
|
149
|
+
404,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const ctx = await resolveEmailSendContext(db, link.emailSendId);
|
|
154
|
+
if (ctx) {
|
|
155
|
+
// `<event>.comment` is a consumer-namespace event — journeys can wait
|
|
156
|
+
// on it and destinations receive it like any other. First comment per
|
|
157
|
+
// (send, event) wins; repeats are no-ops.
|
|
158
|
+
await pushTrackingEvent({
|
|
159
|
+
db,
|
|
160
|
+
hatchet,
|
|
161
|
+
registry,
|
|
162
|
+
logger,
|
|
163
|
+
event: `${link.event}.comment`,
|
|
164
|
+
emailSendId: link.emailSendId,
|
|
165
|
+
properties: {
|
|
166
|
+
comment,
|
|
167
|
+
parentEvent: link.event,
|
|
168
|
+
...(link.eventProperties ?? {}),
|
|
169
|
+
linkId: link.id,
|
|
170
|
+
},
|
|
171
|
+
resolvedContext: ctx,
|
|
172
|
+
idempotencyKey: `semc:${link.emailSendId}:${link.event}`,
|
|
173
|
+
}).catch((err) => {
|
|
174
|
+
logger.warn("Failed to ingest answer comment", {
|
|
175
|
+
linkId: link.id,
|
|
176
|
+
error: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return c.html(
|
|
182
|
+
htmlPage({
|
|
183
|
+
title: "Thank you",
|
|
184
|
+
body: "<h1>Thank you.</h1><p>Your note is on its way to the team. You can close this tab.</p>",
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
});
|
|
@@ -2,6 +2,7 @@ import { emailSends, linkClicks, trackedLinks } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { generateIdentityToken } from "../../lib/identity-token.js";
|
|
5
6
|
import { emitOutbound } from "../../lib/outbound.js";
|
|
6
7
|
import { EMAIL_LINK_CLICKED } from "../../lib/tracking-event-names.js";
|
|
7
8
|
import {
|
|
@@ -108,13 +109,49 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
108
109
|
});
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// Cross-device identity stitch (opt-in): append a short-lived signed
|
|
113
|
+
// `hs_t` token to the destination so the landing site can identify the
|
|
114
|
+
// session. This is the ONE path that needs the send context BEFORE the
|
|
115
|
+
// redirect — the awaited resolve is shared with the async chain below so
|
|
116
|
+
// the read still happens once.
|
|
117
|
+
let redirectUrl = link.originalUrl;
|
|
118
|
+
let preResolved: Awaited<
|
|
119
|
+
ReturnType<typeof resolveEmailSendContext>
|
|
120
|
+
> | null = null;
|
|
121
|
+
let preResolvedSet = false;
|
|
122
|
+
if (env.TRACKING_IDENTITY_TOKEN) {
|
|
123
|
+
preResolved = await resolveEmailSendContext(db, link.emailSendId);
|
|
124
|
+
preResolvedSet = true;
|
|
125
|
+
if (preResolved?.userId) {
|
|
126
|
+
try {
|
|
127
|
+
const url = new URL(link.originalUrl);
|
|
128
|
+
url.searchParams.set(
|
|
129
|
+
"hs_t",
|
|
130
|
+
generateIdentityToken({
|
|
131
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
132
|
+
distinctId: preResolved.userId,
|
|
133
|
+
emailSendId: link.emailSendId,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
redirectUrl = url.toString();
|
|
137
|
+
} catch {
|
|
138
|
+
// Unparseable destination — redirect untouched rather than break it.
|
|
139
|
+
redirectUrl = link.originalUrl;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
111
144
|
// Resolve the send context ONCE (off the response path) and feed both the
|
|
112
145
|
// re-ingest and the PER-HIT outbound emit — avoiding a duplicate
|
|
113
146
|
// `resolveEmailSendContext` read on the click hot path. NO `dedupeKey`: a
|
|
114
147
|
// NULL dedupe key is distinct in Postgres, so every click creates a fresh
|
|
115
148
|
// delivery to every subscribed destination (per-hit, not first-touch).
|
|
116
149
|
const emailSendId = link.emailSendId;
|
|
117
|
-
void
|
|
150
|
+
void (
|
|
151
|
+
preResolvedSet
|
|
152
|
+
? Promise.resolve(preResolved)
|
|
153
|
+
: resolveEmailSendContext(db, emailSendId)
|
|
154
|
+
)
|
|
118
155
|
.then(async (ctx) => {
|
|
119
156
|
await pushTrackingEvent({
|
|
120
157
|
db,
|
|
@@ -157,6 +194,6 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
157
194
|
})
|
|
158
195
|
.catch(logger.warn);
|
|
159
196
|
|
|
160
|
-
return c.redirect(
|
|
197
|
+
return c.redirect(redirectUrl, 302);
|
|
161
198
|
},
|
|
162
199
|
);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import {
|
|
4
|
+
InvalidIdentityTokenError,
|
|
5
|
+
validateIdentityToken,
|
|
6
|
+
} from "../../lib/identity-token.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Exchange a redirect identity token (`hs_t`) for the distinct id. Called by
|
|
10
|
+
* the LANDING SITE's frontend (CORS is open app-wide) after the user arrives
|
|
11
|
+
* from a tracked email link; the site then calls `posthog.identify` (or its
|
|
12
|
+
* analytics equivalent) with the result — ideally gated behind whatever
|
|
13
|
+
* analytics consent the site already operates under.
|
|
14
|
+
*
|
|
15
|
+
* Possession of a fresh signed token IS the authorization (the same trust
|
|
16
|
+
* model as unsubscribe links): tokens are signed with BETTER_AUTH_SECRET,
|
|
17
|
+
* expire after an hour, and resolve to nothing but the distinct id + send id.
|
|
18
|
+
*/
|
|
19
|
+
const identifyRoute = createRoute({
|
|
20
|
+
method: "post",
|
|
21
|
+
path: "/identify",
|
|
22
|
+
tags: ["Tracking"],
|
|
23
|
+
summary: "Exchange a redirect identity token for the distinct id",
|
|
24
|
+
request: {
|
|
25
|
+
body: {
|
|
26
|
+
content: {
|
|
27
|
+
"application/json": {
|
|
28
|
+
schema: z.object({ token: z.string().min(1).max(2048) }),
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
responses: {
|
|
34
|
+
200: {
|
|
35
|
+
description: "Resolved identity",
|
|
36
|
+
content: {
|
|
37
|
+
"application/json": {
|
|
38
|
+
schema: z.object({
|
|
39
|
+
distinctId: z.string(),
|
|
40
|
+
emailSendId: z.string().optional(),
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
400: { description: "Invalid or expired token" },
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const identifyRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
50
|
+
identifyRoute,
|
|
51
|
+
async (c) => {
|
|
52
|
+
const { token } = c.req.valid("json");
|
|
53
|
+
const { env } = c.get("container");
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const payload = validateIdentityToken({
|
|
57
|
+
token,
|
|
58
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
59
|
+
});
|
|
60
|
+
return c.json(
|
|
61
|
+
{ distinctId: payload.distinctId, emailSendId: payload.emailSendId },
|
|
62
|
+
200,
|
|
63
|
+
);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof InvalidIdentityTokenError) {
|
|
66
|
+
return c.body(null, 400);
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
);
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import { answerRouter } from "./answer.js";
|
|
3
4
|
import { clickRouter } from "./click.js";
|
|
5
|
+
import { identifyRouter } from "./identify.js";
|
|
4
6
|
import { openRouter } from "./open.js";
|
|
5
7
|
|
|
6
8
|
export const trackingRouter = new OpenAPIHono<AppEnv>();
|
|
7
9
|
|
|
8
10
|
trackingRouter.route("/", clickRouter);
|
|
9
11
|
trackingRouter.route("/", openRouter);
|
|
12
|
+
trackingRouter.route("/", answerRouter);
|
|
13
|
+
trackingRouter.route("/", identifyRouter);
|