@hogsend/engine 0.13.2 → 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/destinations/presets/posthog.ts +35 -0
- package/src/env.ts +7 -0
- package/src/index.ts +13 -0
- package/src/journeys/define-journey.ts +40 -3
- package/src/journeys/journey-context.ts +73 -5
- package/src/lib/identity-token.ts +112 -0
- package/src/lib/ingestion.ts +32 -1
- package/src/lib/outbound.ts +17 -0
- package/src/lib/seed-posthog-destination.ts +34 -2
- package/src/lib/semantic-click.ts +194 -0
- package/src/lib/tracking-events.ts +12 -4
- package/src/lib/tracking.ts +185 -21
- package/src/lib/webhook-signing.ts +2 -1
- package/src/routes/tracking/answer.ts +187 -0
- package/src/routes/tracking/click.ts +62 -2
- package/src/routes/tracking/identify.ts +71 -0
- package/src/routes/tracking/index.ts +4 -0
- package/src/worker.ts +2 -0
- package/src/workflows/confirm-semantic-click.ts +37 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
3
|
+
import {
|
|
4
|
+
type Database,
|
|
5
|
+
linkClicks,
|
|
6
|
+
trackedLinks,
|
|
7
|
+
userEvents,
|
|
8
|
+
} from "@hogsend/db";
|
|
9
|
+
import { and, countDistinct, eq, gte, isNull, lte } from "drizzle-orm";
|
|
10
|
+
import type { Logger } from "./logger.js";
|
|
11
|
+
import { emitOutbound } from "./outbound.js";
|
|
12
|
+
import {
|
|
13
|
+
pushTrackingEvent,
|
|
14
|
+
resolveEmailSendContext,
|
|
15
|
+
} from "./tracking-events.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scanner-burst window: SafeLinks/Proofpoint-style scanners follow EVERY link
|
|
19
|
+
* in an email within seconds of delivery; humans don't. Confirmation of a
|
|
20
|
+
* semantic answer is DEFERRED until the window around the candidate click has
|
|
21
|
+
* fully elapsed, so the gate sees the WHOLE burst — including clicks that land
|
|
22
|
+
* AFTER the candidate. An inline check could never suppress a scanner's first
|
|
23
|
+
* click (the burst isn't visible yet); this one can.
|
|
24
|
+
*/
|
|
25
|
+
export const SEMANTIC_BURST_WINDOW_MS = 30_000;
|
|
26
|
+
export const SEMANTIC_BURST_DISTINCT_LINKS = 3;
|
|
27
|
+
|
|
28
|
+
// Type alias (NOT interface) so it picks up an implicit index signature and
|
|
29
|
+
// satisfies Hatchet's JsonObject task-input constraint.
|
|
30
|
+
export type ConfirmSemanticClickInput = {
|
|
31
|
+
trackedLinkId: string;
|
|
32
|
+
/** ISO instant of the candidate click. */
|
|
33
|
+
clickedAt: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type ConfirmSemanticClickResult =
|
|
37
|
+
| { status: "confirmed"; event: string }
|
|
38
|
+
| { status: "suppressed"; distinctLinks: number }
|
|
39
|
+
/** Another link's answer claimed this send's slot first. */
|
|
40
|
+
| { status: "lost" }
|
|
41
|
+
| { status: "skipped"; reason: string };
|
|
42
|
+
|
|
43
|
+
export interface ConfirmSemanticClickDeps {
|
|
44
|
+
db: Database;
|
|
45
|
+
hatchet: HatchetClient;
|
|
46
|
+
registry: JourneyRegistry;
|
|
47
|
+
logger: Logger;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Confirm (or suppress) one provisional semantic-link answer. Idempotent end
|
|
52
|
+
* to end, so the wrapping Hatchet task can retry safely:
|
|
53
|
+
*
|
|
54
|
+
* 1. Sleep out the remainder of the burst window past the candidate click.
|
|
55
|
+
* 2. Count DISTINCT links of the send clicked inside the window around the
|
|
56
|
+
* candidate — at/over the threshold the whole burst is scanner traffic
|
|
57
|
+
* and the answer is suppressed (the raw clicks stay recorded).
|
|
58
|
+
* 3. Claim the send's answer slot via `ingestEvent` with the
|
|
59
|
+
* `sem:<emailSendId>:<event>` idempotency key (first answer wins; a
|
|
60
|
+
* failed Hatchet publish rolls the claim back inside `ingestEvent`, so a
|
|
61
|
+
* retry re-claims).
|
|
62
|
+
* 4. If we claimed (or a crashed earlier attempt of THIS link did — detected
|
|
63
|
+
* by the stored row's `linkId`), stamp `semanticEmittedAt` and emit the
|
|
64
|
+
* `email.action` outbound envelope with the same key as `dedupeKey`, so
|
|
65
|
+
* re-runs are per-endpoint no-ops.
|
|
66
|
+
*/
|
|
67
|
+
export async function confirmSemanticClick(
|
|
68
|
+
deps: ConfirmSemanticClickDeps,
|
|
69
|
+
input: ConfirmSemanticClickInput,
|
|
70
|
+
): Promise<ConfirmSemanticClickResult> {
|
|
71
|
+
const { db, hatchet, registry, logger } = deps;
|
|
72
|
+
|
|
73
|
+
const clickedAtMs = Date.parse(input.clickedAt);
|
|
74
|
+
if (Number.isNaN(clickedAtMs)) {
|
|
75
|
+
return { status: "skipped", reason: "bad_clicked_at" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const rows = await db
|
|
79
|
+
.select({
|
|
80
|
+
id: trackedLinks.id,
|
|
81
|
+
emailSendId: trackedLinks.emailSendId,
|
|
82
|
+
originalUrl: trackedLinks.originalUrl,
|
|
83
|
+
event: trackedLinks.event,
|
|
84
|
+
eventProperties: trackedLinks.eventProperties,
|
|
85
|
+
})
|
|
86
|
+
.from(trackedLinks)
|
|
87
|
+
.where(eq(trackedLinks.id, input.trackedLinkId))
|
|
88
|
+
.limit(1);
|
|
89
|
+
const link = rows[0];
|
|
90
|
+
if (!link?.event) {
|
|
91
|
+
return { status: "skipped", reason: "not_semantic" };
|
|
92
|
+
}
|
|
93
|
+
const semanticEvent = link.event;
|
|
94
|
+
|
|
95
|
+
// (1) Let the burst window close before judging the click.
|
|
96
|
+
const remainingMs = clickedAtMs + SEMANTIC_BURST_WINDOW_MS - Date.now();
|
|
97
|
+
if (remainingMs > 0) {
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, remainingMs));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// (2) Whole-burst check: distinct links of this send clicked in the window
|
|
102
|
+
// AROUND the candidate (before AND after — the deferral is what makes the
|
|
103
|
+
// "after" half visible).
|
|
104
|
+
const windowStart = new Date(clickedAtMs - SEMANTIC_BURST_WINDOW_MS);
|
|
105
|
+
const windowEnd = new Date(clickedAtMs + SEMANTIC_BURST_WINDOW_MS);
|
|
106
|
+
const burst = await db
|
|
107
|
+
.select({ n: countDistinct(linkClicks.trackedLinkId) })
|
|
108
|
+
.from(linkClicks)
|
|
109
|
+
.innerJoin(trackedLinks, eq(linkClicks.trackedLinkId, trackedLinks.id))
|
|
110
|
+
.where(
|
|
111
|
+
and(
|
|
112
|
+
eq(trackedLinks.emailSendId, link.emailSendId),
|
|
113
|
+
gte(linkClicks.clickedAt, windowStart),
|
|
114
|
+
lte(linkClicks.clickedAt, windowEnd),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
const distinctLinks = burst[0]?.n ?? 0;
|
|
118
|
+
if (distinctLinks >= SEMANTIC_BURST_DISTINCT_LINKS) {
|
|
119
|
+
logger.warn("Semantic answer suppressed: scanner-like click burst", {
|
|
120
|
+
emailSendId: link.emailSendId,
|
|
121
|
+
linkId: link.id,
|
|
122
|
+
event: semanticEvent,
|
|
123
|
+
distinctLinks,
|
|
124
|
+
});
|
|
125
|
+
return { status: "suppressed", distinctLinks };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ctx = await resolveEmailSendContext(db, link.emailSendId);
|
|
129
|
+
if (!ctx) {
|
|
130
|
+
return { status: "skipped", reason: "no_send_context" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// (3) Claim the answer slot. Duplicate key → stored=false BEFORE the Hatchet
|
|
134
|
+
// push, so journeys/destinations see at most one answer per (send, event).
|
|
135
|
+
const semKey = `sem:${link.emailSendId}:${semanticEvent}`;
|
|
136
|
+
const result = await pushTrackingEvent({
|
|
137
|
+
db,
|
|
138
|
+
hatchet,
|
|
139
|
+
registry,
|
|
140
|
+
logger,
|
|
141
|
+
event: semanticEvent,
|
|
142
|
+
emailSendId: link.emailSendId,
|
|
143
|
+
properties: {
|
|
144
|
+
...(link.eventProperties ?? {}),
|
|
145
|
+
linkId: link.id,
|
|
146
|
+
},
|
|
147
|
+
resolvedContext: ctx,
|
|
148
|
+
idempotencyKey: semKey,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// (4) Claimer determination. stored=false usually means another link won —
|
|
152
|
+
// but if the stored row carries THIS link's id, it is a crashed earlier
|
|
153
|
+
// attempt of this very confirmation, and the (idempotent) tail must re-run.
|
|
154
|
+
let isClaimer = result?.stored ?? false;
|
|
155
|
+
if (!isClaimer) {
|
|
156
|
+
const existing = await db
|
|
157
|
+
.select({ properties: userEvents.properties })
|
|
158
|
+
.from(userEvents)
|
|
159
|
+
.where(eq(userEvents.idempotencyKey, semKey))
|
|
160
|
+
.limit(1);
|
|
161
|
+
isClaimer = existing[0]?.properties?.linkId === link.id;
|
|
162
|
+
if (!isClaimer) {
|
|
163
|
+
return { status: "lost" };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await db
|
|
168
|
+
.update(trackedLinks)
|
|
169
|
+
.set({ semanticEmittedAt: new Date(), updatedAt: new Date() })
|
|
170
|
+
.where(
|
|
171
|
+
and(eq(trackedLinks.id, link.id), isNull(trackedLinks.semanticEmittedAt)),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await emitOutbound({
|
|
175
|
+
db,
|
|
176
|
+
hatchet,
|
|
177
|
+
logger,
|
|
178
|
+
event: "email.action",
|
|
179
|
+
dedupeKey: semKey,
|
|
180
|
+
payload: {
|
|
181
|
+
event: semanticEvent,
|
|
182
|
+
properties: link.eventProperties ?? null,
|
|
183
|
+
emailSendId: link.emailSendId,
|
|
184
|
+
templateKey: ctx.templateKey ?? null,
|
|
185
|
+
userId: ctx.userId ?? null,
|
|
186
|
+
to: ctx.to ?? ctx.userEmail ?? "",
|
|
187
|
+
at: new Date().toISOString(),
|
|
188
|
+
linkId: link.id,
|
|
189
|
+
linkUrl: link.originalUrl,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { status: "confirmed", event: semanticEvent };
|
|
194
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
|
2
2
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
3
3
|
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
4
4
|
import { eq } from "drizzle-orm";
|
|
5
|
-
import { ingestEvent } from "./ingestion.js";
|
|
5
|
+
import { type IngestResult, ingestEvent } from "./ingestion.js";
|
|
6
6
|
import type { Logger } from "./logger.js";
|
|
7
7
|
|
|
8
8
|
interface EmailSendContext {
|
|
@@ -122,6 +122,13 @@ export interface PushTrackingEventOpts {
|
|
|
122
122
|
* lazily.
|
|
123
123
|
*/
|
|
124
124
|
resolvedContext?: EmailSendContext | null;
|
|
125
|
+
/**
|
|
126
|
+
* Threaded straight into `ingestEvent` — a duplicate key returns
|
|
127
|
+
* `{ stored: false }` BEFORE the Hatchet push, so journeys never see the
|
|
128
|
+
* duplicate. Semantic link answers use `sem:<emailSendId>:<event>` for
|
|
129
|
+
* first-answer-per-send semantics.
|
|
130
|
+
*/
|
|
131
|
+
idempotencyKey?: string;
|
|
125
132
|
}
|
|
126
133
|
|
|
127
134
|
/**
|
|
@@ -136,14 +143,14 @@ export interface PushTrackingEventOpts {
|
|
|
136
143
|
*/
|
|
137
144
|
export async function pushTrackingEvent(
|
|
138
145
|
opts: PushTrackingEventOpts,
|
|
139
|
-
): Promise<
|
|
146
|
+
): Promise<IngestResult | undefined> {
|
|
140
147
|
const { db, hatchet, registry, logger, event, emailSendId } = opts;
|
|
141
148
|
|
|
142
149
|
const ctx =
|
|
143
150
|
opts.resolvedContext !== undefined
|
|
144
151
|
? opts.resolvedContext
|
|
145
152
|
: await resolveEmailSendContext(db, emailSendId);
|
|
146
|
-
if (!ctx) return;
|
|
153
|
+
if (!ctx) return undefined;
|
|
147
154
|
|
|
148
155
|
const properties: Record<string, unknown> = {
|
|
149
156
|
emailSendId,
|
|
@@ -151,7 +158,7 @@ export async function pushTrackingEvent(
|
|
|
151
158
|
...opts.properties,
|
|
152
159
|
};
|
|
153
160
|
|
|
154
|
-
await ingestEvent({
|
|
161
|
+
return await ingestEvent({
|
|
155
162
|
db,
|
|
156
163
|
registry,
|
|
157
164
|
hatchet,
|
|
@@ -161,6 +168,7 @@ export async function pushTrackingEvent(
|
|
|
161
168
|
userId: ctx.userId,
|
|
162
169
|
userEmail: ctx.userEmail,
|
|
163
170
|
eventProperties: properties,
|
|
171
|
+
idempotencyKey: opts.idempotencyKey,
|
|
164
172
|
},
|
|
165
173
|
});
|
|
166
174
|
}
|
package/src/lib/tracking.ts
CHANGED
|
@@ -1,14 +1,129 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import type { Database } from "@hogsend/db";
|
|
2
3
|
import { trackedLinks } from "@hogsend/db";
|
|
4
|
+
import {
|
|
5
|
+
EMAIL_ACTION_EVENT_ATTR,
|
|
6
|
+
EMAIL_ACTION_PROPS_ATTR,
|
|
7
|
+
HOSTED_ANSWER_HREF,
|
|
8
|
+
} from "@hogsend/email";
|
|
3
9
|
|
|
4
|
-
const
|
|
10
|
+
const ANCHOR_RE = /<a\b[^>]*>/gi;
|
|
11
|
+
const HREF_RE = /\bhref="(https?:\/\/[^"]+)"/i;
|
|
12
|
+
const SENTINEL_HREF_RE = new RegExp(`\\bhref="${HOSTED_ANSWER_HREF}"`, "i");
|
|
13
|
+
const EVENT_ATTR_RE = new RegExp(
|
|
14
|
+
`\\b${EMAIL_ACTION_EVENT_ATTR}="([^"]*)"`,
|
|
15
|
+
"i",
|
|
16
|
+
);
|
|
17
|
+
const PROPS_ATTR_RE = new RegExp(
|
|
18
|
+
`\\b${EMAIL_ACTION_PROPS_ATTR}="([^"]*)"`,
|
|
19
|
+
"i",
|
|
20
|
+
);
|
|
21
|
+
const STRIP_SEMANTIC_ATTRS_RE = new RegExp(
|
|
22
|
+
`\\s*(?:${EMAIL_ACTION_EVENT_ATTR}|${EMAIL_ACTION_PROPS_ATTR})="[^"]*"`,
|
|
23
|
+
"gi",
|
|
24
|
+
);
|
|
5
25
|
|
|
6
26
|
const SKIP_PATTERNS = ["/v1/email/unsubscribe", "/v1/email/preferences"];
|
|
7
27
|
|
|
28
|
+
// Engine-owned event vocabularies (both `email.opened` dot-style and
|
|
29
|
+
// `journey:completed` colon-style exist) — a consumer semantic event in these
|
|
30
|
+
// namespaces would corrupt insights or trigger engine-internal logic.
|
|
31
|
+
const RESERVED_EVENT_NAME_RE = /^(?:email|journey|bucket|contact)[.:]/;
|
|
32
|
+
|
|
33
|
+
// Semantic payloads re-emit on every answer and persist indefinitely — keep
|
|
34
|
+
// them small and scalar (non-scalars don't survive the Hatchet wire anyway).
|
|
35
|
+
const MAX_PROPS_JSON_LENGTH = 2048;
|
|
36
|
+
|
|
8
37
|
function shouldSkipUrl(url: string): boolean {
|
|
9
38
|
return SKIP_PATTERNS.some((pattern) => url.includes(pattern));
|
|
10
39
|
}
|
|
11
40
|
|
|
41
|
+
// React entity-escapes attribute values at render time. Decode the five
|
|
42
|
+
// entities it emits; `&` LAST so `&quot;` round-trips to `"`.
|
|
43
|
+
function decodeAttributeValue(value: string): string {
|
|
44
|
+
return value
|
|
45
|
+
.replace(/"/g, '"')
|
|
46
|
+
.replace(/'/g, "'")
|
|
47
|
+
.replace(/</g, "<")
|
|
48
|
+
.replace(/>/g, ">")
|
|
49
|
+
.replace(/&/g, "&");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface SemanticAttrs {
|
|
53
|
+
event: string;
|
|
54
|
+
/** Raw (encoded) props attribute — part of the dedupe key. */
|
|
55
|
+
propsRaw: string | null;
|
|
56
|
+
properties: Record<string, unknown> | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract + validate the semantic metadata off one `<a …>` tag. Returns null
|
|
61
|
+
* for a plain link. Throws on author error — a semantic link that can't be
|
|
62
|
+
* honored must fail the SEND loudly, not degrade into a silent plain link.
|
|
63
|
+
*/
|
|
64
|
+
function parseSemanticAttrs(tag: string): SemanticAttrs | null {
|
|
65
|
+
const eventMatch = tag.match(EVENT_ATTR_RE);
|
|
66
|
+
if (!eventMatch) return null;
|
|
67
|
+
|
|
68
|
+
const event = decodeAttributeValue(eventMatch[1] ?? "").trim();
|
|
69
|
+
if (!event) {
|
|
70
|
+
throw new Error(`Semantic link has an empty ${EMAIL_ACTION_EVENT_ATTR}`);
|
|
71
|
+
}
|
|
72
|
+
if (RESERVED_EVENT_NAME_RE.test(event)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Semantic link event "${event}" uses a reserved namespace (email/journey/bucket/contact)`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const propsMatch = tag.match(PROPS_ATTR_RE);
|
|
79
|
+
if (!propsMatch) return { event, propsRaw: null, properties: null };
|
|
80
|
+
|
|
81
|
+
const propsRaw = propsMatch[1] ?? "";
|
|
82
|
+
const decoded = decodeAttributeValue(propsRaw);
|
|
83
|
+
if (decoded.length > MAX_PROPS_JSON_LENGTH) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Semantic link "${event}" properties exceed ${MAX_PROPS_JSON_LENGTH} chars`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let parsed: unknown;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(decoded);
|
|
92
|
+
} catch {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Semantic link "${event}" has unparseable ${EMAIL_ACTION_PROPS_ATTR}`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Semantic link "${event}" properties must be a JSON object`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
103
|
+
const t = typeof value;
|
|
104
|
+
if (value !== null && t !== "string" && t !== "number" && t !== "boolean") {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Semantic link "${event}" property "${key}" must be a scalar (string/number/boolean/null)`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
event,
|
|
113
|
+
propsRaw,
|
|
114
|
+
properties: parsed as Record<string, unknown>,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One tracked_links row per distinct (url, event, props) tuple: identical
|
|
119
|
+
// semantic links share a row; the same URL under DIFFERENT events/props must
|
|
120
|
+
// NOT collapse (the old URL-only dedupe would merge "yes" and "no" answers
|
|
121
|
+
// that point at the same thanks page).
|
|
122
|
+
function linkKey(url: string, semantic: SemanticAttrs | null): string {
|
|
123
|
+
const sep = String.fromCharCode(0);
|
|
124
|
+
return [url, semantic?.event ?? "", semantic?.propsRaw ?? ""].join(sep);
|
|
125
|
+
}
|
|
126
|
+
|
|
12
127
|
export async function rewriteLinks(opts: {
|
|
13
128
|
html: string;
|
|
14
129
|
emailSendId: string;
|
|
@@ -17,32 +132,81 @@ export async function rewriteLinks(opts: {
|
|
|
17
132
|
}): Promise<string> {
|
|
18
133
|
const { html, emailSendId, baseUrl, db } = opts;
|
|
19
134
|
|
|
20
|
-
const
|
|
135
|
+
const pending = new Map<
|
|
136
|
+
string,
|
|
137
|
+
{ id: string; url: string; semantic: SemanticAttrs | null }
|
|
138
|
+
>();
|
|
21
139
|
|
|
22
|
-
for (const match of html.matchAll(
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
uniqueUrls.add(url);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
140
|
+
for (const match of html.matchAll(ANCHOR_RE)) {
|
|
141
|
+
const tag = match[0];
|
|
142
|
+
const semantic = parseSemanticAttrs(tag);
|
|
28
143
|
|
|
29
|
-
|
|
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
|
+
}
|
|
30
164
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
165
|
+
const url = tag.match(HREF_RE)?.[1];
|
|
166
|
+
if (!url || shouldSkipUrl(url)) {
|
|
167
|
+
if (semantic) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Semantic link "${semantic.event}" needs an absolute http(s) href outside unsubscribe/preference URLs`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
36
174
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
175
|
+
const key = linkKey(url, semantic);
|
|
176
|
+
if (!pending.has(key)) {
|
|
177
|
+
pending.set(key, { id: randomUUID(), url, semantic });
|
|
178
|
+
}
|
|
40
179
|
}
|
|
41
180
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
181
|
+
if (pending.size === 0) return html;
|
|
182
|
+
|
|
183
|
+
await db.insert(trackedLinks).values(
|
|
184
|
+
[...pending.values()].map((link) => ({
|
|
185
|
+
id: link.id,
|
|
186
|
+
emailSendId,
|
|
187
|
+
originalUrl: link.url,
|
|
188
|
+
event: link.semantic?.event,
|
|
189
|
+
eventProperties: link.semantic?.properties ?? undefined,
|
|
190
|
+
})),
|
|
191
|
+
);
|
|
192
|
+
|
|
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
|
+
}
|
|
203
|
+
const url = tag.match(HREF_RE)?.[1];
|
|
204
|
+
if (!url || shouldSkipUrl(url)) return tag;
|
|
205
|
+
const link = pending.get(linkKey(url, parseSemanticAttrs(tag)));
|
|
206
|
+
if (!link) return tag;
|
|
207
|
+
return tag
|
|
208
|
+
.replace(HREF_RE, `href="${baseUrl}/v1/t/c/${link.id}"`)
|
|
209
|
+
.replace(STRIP_SEMANTIC_ATTRS_RE, "");
|
|
46
210
|
});
|
|
47
211
|
}
|
|
48
212
|
|
|
@@ -25,7 +25,7 @@ import { Webhook } from "svix";
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
* The
|
|
28
|
+
* The 14-event catalog — the SINGLE source of truth (schema, routes, client,
|
|
29
29
|
* CLI all derive from this). The `webhook.test` sentinel is intentionally NOT a
|
|
30
30
|
* member (it is delivered out-of-band regardless of an endpoint's `eventTypes`).
|
|
31
31
|
*/
|
|
@@ -38,6 +38,7 @@ export const WEBHOOK_EVENT_TYPES = [
|
|
|
38
38
|
"email.delivered",
|
|
39
39
|
"email.opened",
|
|
40
40
|
"email.clicked",
|
|
41
|
+
"email.action",
|
|
41
42
|
"email.bounced",
|
|
42
43
|
"email.complained",
|
|
43
44
|
"journey.completed",
|
|
@@ -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
|
+
});
|