@hogsend/engine 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/package.json +7 -6
  2. package/src/app.ts +36 -1
  3. package/src/buckets/check-membership.ts +34 -15
  4. package/src/container.ts +33 -0
  5. package/src/env.ts +29 -0
  6. package/src/index.ts +47 -1
  7. package/src/journeys/define-journey.ts +26 -2
  8. package/src/journeys/journey-context.ts +5 -1
  9. package/src/lib/boot.ts +1 -1
  10. package/src/lib/bucket-emit.ts +47 -2
  11. package/src/lib/contacts.ts +1105 -18
  12. package/src/lib/email-service-types.ts +8 -0
  13. package/src/lib/ingestion.ts +63 -33
  14. package/src/lib/mailer.ts +88 -0
  15. package/src/lib/outbound.ts +216 -0
  16. package/src/lib/preferences.ts +137 -0
  17. package/src/lib/tracked.ts +204 -37
  18. package/src/lib/tracking-events.ts +67 -2
  19. package/src/lib/webhook-signing.ts +151 -0
  20. package/src/lists/define-list.ts +81 -0
  21. package/src/lists/registry-singleton.ts +39 -0
  22. package/src/lists/registry.ts +95 -0
  23. package/src/middleware/api-key.ts +33 -7
  24. package/src/middleware/rate-limit.ts +73 -49
  25. package/src/routes/_shared.ts +30 -0
  26. package/src/routes/admin/api-keys.ts +1 -1
  27. package/src/routes/admin/bulk.ts +7 -3
  28. package/src/routes/admin/contacts.ts +108 -59
  29. package/src/routes/admin/events.ts +65 -0
  30. package/src/routes/admin/index.ts +2 -0
  31. package/src/routes/admin/journeys.ts +3 -1
  32. package/src/routes/admin/preferences.ts +2 -2
  33. package/src/routes/admin/reporting.ts +3 -3
  34. package/src/routes/admin/timeline.ts +5 -2
  35. package/src/routes/admin/webhooks.ts +466 -0
  36. package/src/routes/campaigns/index.ts +252 -0
  37. package/src/routes/contacts/index.ts +231 -0
  38. package/src/routes/email/preferences.ts +27 -3
  39. package/src/routes/email/unsubscribe.ts +7 -49
  40. package/src/routes/emails/index.ts +133 -0
  41. package/src/routes/events/index.ts +119 -0
  42. package/src/routes/index.ts +52 -2
  43. package/src/routes/lists/index.ts +258 -0
  44. package/src/routes/tracking/click.ts +59 -18
  45. package/src/routes/tracking/open.ts +62 -24
  46. package/src/routes/webhooks/sources.ts +69 -10
  47. package/src/webhook-sources/define-webhook-source.ts +57 -5
  48. package/src/webhook-sources/presets/clerk.ts +185 -0
  49. package/src/webhook-sources/presets/index.ts +80 -0
  50. package/src/webhook-sources/presets/segment.ts +120 -0
  51. package/src/webhook-sources/presets/stripe.ts +147 -0
  52. package/src/webhook-sources/presets/supabase.ts +131 -0
  53. package/src/webhook-sources/verify.ts +172 -0
  54. package/src/worker.ts +12 -0
  55. package/src/workflows/bucket-backfill.ts +32 -21
  56. package/src/workflows/bucket-reconcile.ts +20 -5
  57. package/src/workflows/deliver-webhook.ts +399 -0
  58. package/src/workflows/import-contacts.ts +28 -20
  59. package/src/workflows/send-campaign.ts +589 -0
  60. package/src/routes/ingest.ts +0 -71
@@ -0,0 +1,133 @@
1
+ import { getTemplateNames, type TemplateName } from "@hogsend/email";
2
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
+ import type { AppEnv } from "../../app.js";
4
+ import { resolveRecipient } from "../../lib/contacts.js";
5
+ import { errorSchema } from "../../lib/schemas.js";
6
+ import { hasScope } from "../../middleware/api-key.js";
7
+ import { requireIdentity } from "../_shared.js";
8
+
9
+ const emailRequestSchema = z.object({
10
+ to: z.string().email().optional(),
11
+ userId: z.string().min(1).optional(),
12
+ template: z.string().min(1),
13
+ props: z.record(z.string(), z.unknown()).optional(),
14
+ from: z.string().optional(),
15
+ subject: z.string().optional(),
16
+ replyTo: z.union([z.string(), z.array(z.string())]).optional(),
17
+ category: z.string().optional(),
18
+ skipPreferenceCheck: z.boolean().optional(),
19
+ idempotencyKey: z.string().optional(),
20
+ });
21
+
22
+ const emailResponseSchema = z.object({
23
+ emailSendId: z.string(),
24
+ status: z.enum(["queued", "sent", "suppressed", "unsubscribed", "skipped"]),
25
+ reason: z.string().optional(),
26
+ });
27
+
28
+ const sendRoute = createRoute({
29
+ method: "post",
30
+ path: "/",
31
+ tags: ["Emails"],
32
+ summary: "Send a transactional email",
33
+ description:
34
+ "Resolves a recipient by `to` or `userId`, then sends the named template through the engine-owned tracked mailer (journeyless — link-click + open tracking still applies). `skipPreferenceCheck` requires a full-admin key.",
35
+ request: {
36
+ body: {
37
+ content: {
38
+ "application/json": { schema: emailRequestSchema },
39
+ },
40
+ },
41
+ },
42
+ responses: {
43
+ 202: {
44
+ content: {
45
+ "application/json": { schema: emailResponseSchema },
46
+ },
47
+ description: "Email send queued / dispatched",
48
+ },
49
+ 400: {
50
+ content: { "application/json": { schema: errorSchema } },
51
+ description: "Missing recipient or unknown template",
52
+ },
53
+ 403: {
54
+ content: { "application/json": { schema: errorSchema } },
55
+ description: "skipPreferenceCheck requires a full-admin key",
56
+ },
57
+ 404: {
58
+ content: { "application/json": { schema: errorSchema } },
59
+ description: "userId has no resolvable email",
60
+ },
61
+ },
62
+ });
63
+
64
+ export const emailsRouter = new OpenAPIHono<AppEnv>().openapi(
65
+ sendRoute,
66
+ async (c) => {
67
+ const { db, emailService, templates } = c.get("container");
68
+ const apiKey = c.get("apiKey");
69
+ const body = c.req.valid("json");
70
+
71
+ const guard = requireIdentity(
72
+ c,
73
+ { email: body.to, userId: body.userId },
74
+ { field: "to" },
75
+ );
76
+ if (guard) return guard;
77
+
78
+ // `skipPreferenceCheck` is a privileged bypass — gate it on full-admin
79
+ // (§2.5). The data-plane prefix guard already required the `ingest` scope.
80
+ if (body.skipPreferenceCheck) {
81
+ if (!apiKey || !hasScope(apiKey.scopes, "full-admin")) {
82
+ return c.json(
83
+ { error: "skipPreferenceCheck requires a full-admin key" },
84
+ 403,
85
+ );
86
+ }
87
+ }
88
+
89
+ // Validate the template server-side against the wired registry (§2.5).
90
+ if (!getTemplateNames(templates).includes(body.template as TemplateName)) {
91
+ return c.json({ error: `Unknown template: ${body.template}` }, 400);
92
+ }
93
+
94
+ const recipient = await resolveRecipient({
95
+ db,
96
+ userId: body.userId,
97
+ email: body.to,
98
+ });
99
+ if (!recipient) {
100
+ return c.json({ error: "No resolvable email for recipient" }, 404);
101
+ }
102
+
103
+ // The `Idempotency-Key` header wins over the body field (mirrors /v1/events).
104
+ const headerKey = c.req.header("idempotency-key");
105
+ const idempotencyKey = headerKey ?? body.idempotencyKey;
106
+
107
+ // Journeyless send (no journeyStateId) so §5 tracking runs. The
108
+ // denormalized `userId` on the send row is external_id when present, else
109
+ // the contact id fallback (§2.5).
110
+ const result = await emailService.send({
111
+ template: body.template as TemplateName,
112
+ props: (body.props ?? {}) as never,
113
+ to: recipient.email,
114
+ from: body.from,
115
+ subject: body.subject,
116
+ replyTo: body.replyTo,
117
+ category: body.category,
118
+ userId: recipient.externalId ?? recipient.contactId,
119
+ userEmail: recipient.email,
120
+ skipPreferenceCheck: body.skipPreferenceCheck,
121
+ idempotencyKey,
122
+ });
123
+
124
+ return c.json(
125
+ {
126
+ emailSendId: result.emailSendId,
127
+ status: result.status,
128
+ ...(result.reason ? { reason: result.reason } : {}),
129
+ },
130
+ 202,
131
+ );
132
+ },
133
+ );
@@ -0,0 +1,119 @@
1
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../../app.js";
3
+ import { ingestEvent } from "../../lib/ingestion.js";
4
+ import { applyListMembership } from "../../lib/preferences.js";
5
+ import { errorSchema } from "../../lib/schemas.js";
6
+ import { listMembershipError, requireIdentity } from "../_shared.js";
7
+
8
+ const eventRequestSchema = z.object({
9
+ name: z.string().min(1),
10
+ email: z.string().email().optional(),
11
+ userId: z.string().min(1).optional(),
12
+ eventProperties: z.record(z.string(), z.unknown()).optional(),
13
+ contactProperties: z.record(z.string(), z.unknown()).optional(),
14
+ lists: z.record(z.string(), z.boolean()).optional(),
15
+ idempotencyKey: z.string().optional(),
16
+ timestamp: z.string().datetime().optional(),
17
+ });
18
+
19
+ const eventResponseSchema = z.object({
20
+ stored: z.boolean(),
21
+ exits: z.array(
22
+ z.object({
23
+ journeyId: z.string(),
24
+ stateId: z.string(),
25
+ exited: z.boolean(),
26
+ }),
27
+ ),
28
+ // Present only when the event was durably ingested but the (non-atomic,
29
+ // post-ingest) list-membership write failed. The ingest itself succeeded —
30
+ // surfaced as a warning on a 202, not a 400 that conflates "nothing happened"
31
+ // with "event happened, lists failed" (and would tempt a retry double-ingest).
32
+ listsError: z.string().optional(),
33
+ });
34
+
35
+ const eventRoute = createRoute({
36
+ method: "post",
37
+ path: "/",
38
+ tags: ["Events"],
39
+ summary: "Ingest an event",
40
+ description:
41
+ "Stores the event (with eventProperties), merges contactProperties onto the contact, pushes to Hatchet for journey routing, processes exit conditions, and optionally writes list membership. The `Idempotency-Key` header takes precedence over the body field.",
42
+ request: {
43
+ body: {
44
+ content: {
45
+ "application/json": { schema: eventRequestSchema },
46
+ },
47
+ },
48
+ },
49
+ responses: {
50
+ 202: {
51
+ content: {
52
+ "application/json": { schema: eventResponseSchema },
53
+ },
54
+ description: "Event accepted and dispatched",
55
+ },
56
+ 400: {
57
+ content: { "application/json": { schema: errorSchema } },
58
+ description: "Missing recipient or unmanageable list membership",
59
+ },
60
+ },
61
+ });
62
+
63
+ export const eventsRouter = new OpenAPIHono<AppEnv>().openapi(
64
+ eventRoute,
65
+ async (c) => {
66
+ const { db, registry, hatchet, logger } = c.get("container");
67
+ const body = c.req.valid("json");
68
+
69
+ const guard = requireIdentity(c, body);
70
+ if (guard) return guard;
71
+
72
+ // The `Idempotency-Key` header wins over the body field (§2.5).
73
+ const headerKey = c.req.header("idempotency-key");
74
+ const idempotencyKey = headerKey ?? body.idempotencyKey;
75
+
76
+ const result = await ingestEvent({
77
+ db,
78
+ registry,
79
+ hatchet,
80
+ logger,
81
+ event: {
82
+ event: body.name,
83
+ userId: body.userId,
84
+ userEmail: body.email,
85
+ eventProperties: body.eventProperties ?? {},
86
+ contactProperties: body.contactProperties,
87
+ idempotencyKey,
88
+ // §2.5: caller-supplied event time (backfill/replay). The validated ISO
89
+ // string is coerced to a Date inside ingestEvent.
90
+ occurredAt: body.timestamp,
91
+ },
92
+ });
93
+
94
+ // Lists applied AFTER ingest so the contact exists (§2.5 lists ordering).
95
+ // `applyListMembership` writes `email_preferences` independently of the
96
+ // contacts row, so it doesn't race the resolve. Requires a resolvable email.
97
+ //
98
+ // The ingest above is already durable (event stored, journeys dispatched,
99
+ // exits processed). A list-write failure here must NOT be reported as a 400
100
+ // — that would (a) hide a successful ingest behind a "nothing happened"
101
+ // status and (b) tempt a client to retry, re-ingesting the event. Surface it
102
+ // as a `listsError` warning on the 202 instead.
103
+ let listsError: string | undefined;
104
+ if (body.lists && Object.keys(body.lists).length > 0) {
105
+ try {
106
+ await applyListMembership({
107
+ db,
108
+ userId: body.userId,
109
+ email: body.email,
110
+ lists: body.lists,
111
+ });
112
+ } catch (err) {
113
+ listsError = listMembershipError(err);
114
+ }
115
+ }
116
+
117
+ return c.json({ ...result, ...(listsError ? { listsError } : {}) }, 202);
118
+ },
119
+ );
@@ -1,10 +1,16 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { AppEnv } from "../app.js";
3
+ import { requireApiKey, requireScope } from "../middleware/api-key.js";
4
+ import { createRateLimit } from "../middleware/rate-limit.js";
3
5
  import type { DefinedWebhookSource } from "../webhook-sources/define-webhook-source.js";
4
6
  import { adminRouter } from "./admin/index.js";
7
+ import { campaignsRouter } from "./campaigns/index.js";
8
+ import { contactsRouter } from "./contacts/index.js";
5
9
  import { emailRouter } from "./email/index.js";
10
+ import { emailsRouter } from "./emails/index.js";
11
+ import { eventsRouter } from "./events/index.js";
6
12
  import { healthRouter } from "./health.js";
7
- import { ingestRouter } from "./ingest.js";
13
+ import { listsRouter } from "./lists/index.js";
8
14
  import { trackingRouter } from "./tracking/index.js";
9
15
  import { registerWebhookRoutes } from "./webhooks/index.js";
10
16
 
@@ -12,18 +18,62 @@ export interface RegisterRoutesOptions {
12
18
  webhookSources: DefinedWebhookSource[];
13
19
  }
14
20
 
21
+ // Conservative per-key email budget. `/v1/emails` MUST use a distinct prefix so
22
+ // transactional sends don't share the sliding-window budget with contact
23
+ // upserts / event ingest (open risk #15). 30/min/key is well under the
24
+ // contact-upsert default (100/min) — an integration loop sending more than that
25
+ // is almost certainly a runaway.
26
+ const EMAIL_RATE_LIMIT_MAX = 30;
27
+
15
28
  export function registerRoutes(
16
29
  app: OpenAPIHono<AppEnv>,
17
30
  opts: RegisterRoutesOptions,
18
31
  ) {
19
32
  const v1 = new OpenAPIHono<AppEnv>();
20
33
 
34
+ // Open routes: health + tracking pixels/redirects are intentionally
35
+ // unauthenticated (links land in recipient inboxes), and the admin router
36
+ // owns its own `requireAdmin` guard.
21
37
  v1.route("/health", healthRouter);
22
- v1.route("/ingest", ingestRouter);
23
38
  v1.route("/email", emailRouter);
24
39
  v1.route("/admin", adminRouter);
25
40
  v1.route("/t", trackingRouter);
26
41
 
42
+ // The guarded data plane (D5 / decision #16): `requireApiKey` →
43
+ // `requireScope("ingest")` on `/contacts`, `/events`, `/emails`, `/lists`.
44
+ // Each prefix is guarded EXPLICITLY rather than via a root-mounted catch-all
45
+ // sub-app — a sub-app at "/" with `use("*")` also intercepts sibling paths
46
+ // (e.g. `/v1/webhooks`) and 401s them before they reach their own handlers.
47
+ // Both the bare path and its `/*` subtree are covered (Hono treats them as
48
+ // distinct match patterns). `/emails` layers the per-key email rate-limit on
49
+ // top, in strict order auth → scope → rateLimit.
50
+ const emailRateLimit = createRateLimit({
51
+ prefix: "ratelimit:emails",
52
+ max: EMAIL_RATE_LIMIT_MAX,
53
+ });
54
+ for (const base of [
55
+ "/contacts",
56
+ "/events",
57
+ "/emails",
58
+ "/lists",
59
+ "/campaigns",
60
+ ]) {
61
+ v1.use(base, requireApiKey, requireScope("ingest"));
62
+ v1.use(`${base}/*`, requireApiKey, requireScope("ingest"));
63
+ }
64
+ // Register the email rate-limit ONCE. The wildcard pattern `/emails/*` matches
65
+ // BOTH the bare `POST /v1/emails` and any subtree, so a single registration
66
+ // covers the whole emails surface. Registering both bare AND wildcard with the
67
+ // SAME stateful instance double-counts every send (two sliding-window entries
68
+ // per request), halving the effective per-key budget (decision #16 / risk 15).
69
+ v1.use("/emails/*", emailRateLimit);
70
+
71
+ v1.route("/contacts", contactsRouter);
72
+ v1.route("/events", eventsRouter);
73
+ v1.route("/emails", emailsRouter);
74
+ v1.route("/lists", listsRouter);
75
+ v1.route("/campaigns", campaignsRouter);
76
+
27
77
  app.route("/v1", v1);
28
78
 
29
79
  // Webhooks (built-in Resend + injected content sources) are registered on the
@@ -0,0 +1,258 @@
1
+ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
2
+ import type { Database } from "@hogsend/db";
3
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
4
+ import type { AppEnv } from "../../app.js";
5
+ import {
6
+ resolveContact,
7
+ resolveOrCreateContact,
8
+ serializeContact,
9
+ } from "../../lib/contacts.js";
10
+ import type { Logger } from "../../lib/logger.js";
11
+ import { emitOutbound } from "../../lib/outbound.js";
12
+ import { applyListMembership } from "../../lib/preferences.js";
13
+ import { errorSchema } from "../../lib/schemas.js";
14
+ import { getListRegistry } from "../../lists/registry-singleton.js";
15
+ import { listMembershipError } from "../_shared.js";
16
+
17
+ const listSummarySchema = z.object({
18
+ id: z.string(),
19
+ name: z.string(),
20
+ description: z.string().optional(),
21
+ defaultOptIn: z.boolean(),
22
+ });
23
+
24
+ const bodySchema = z.object({
25
+ email: z.string().optional(),
26
+ userId: z.string().optional(),
27
+ });
28
+
29
+ const listRoute = createRoute({
30
+ method: "get",
31
+ path: "/",
32
+ tags: ["Lists"],
33
+ summary: "List defined email lists",
34
+ description:
35
+ "Returns the enabled, code-defined email lists (D3). Membership lives in `email_preferences.categories`; this only enumerates the catalog.",
36
+ responses: {
37
+ 200: {
38
+ content: {
39
+ "application/json": {
40
+ schema: z.object({ lists: z.array(listSummarySchema) }),
41
+ },
42
+ },
43
+ description: "Enabled lists",
44
+ },
45
+ },
46
+ });
47
+
48
+ const subscribeRoute = createRoute({
49
+ method: "post",
50
+ path: "/{id}/subscribe",
51
+ tags: ["Lists"],
52
+ summary: "Subscribe a contact to a list",
53
+ request: {
54
+ params: z.object({ id: z.string() }),
55
+ body: {
56
+ content: { "application/json": { schema: bodySchema } },
57
+ },
58
+ },
59
+ responses: {
60
+ 200: {
61
+ content: {
62
+ "application/json": {
63
+ schema: z.object({
64
+ list: z.string(),
65
+ subscribed: z.literal(true),
66
+ }),
67
+ },
68
+ },
69
+ description: "Contact subscribed",
70
+ },
71
+ 400: {
72
+ content: { "application/json": { schema: errorSchema } },
73
+ description: "Missing recipient or no resolvable email",
74
+ },
75
+ 404: {
76
+ content: { "application/json": { schema: errorSchema } },
77
+ description: "Unknown list id",
78
+ },
79
+ },
80
+ });
81
+
82
+ const unsubscribeRoute = createRoute({
83
+ method: "post",
84
+ path: "/{id}/unsubscribe",
85
+ tags: ["Lists"],
86
+ summary: "Unsubscribe a contact from a list",
87
+ request: {
88
+ params: z.object({ id: z.string() }),
89
+ body: {
90
+ content: { "application/json": { schema: bodySchema } },
91
+ },
92
+ },
93
+ responses: {
94
+ 200: {
95
+ content: {
96
+ "application/json": {
97
+ schema: z.object({
98
+ list: z.string(),
99
+ subscribed: z.literal(false),
100
+ }),
101
+ },
102
+ },
103
+ description: "Contact unsubscribed",
104
+ },
105
+ 400: {
106
+ content: { "application/json": { schema: errorSchema } },
107
+ description: "Missing recipient or no resolvable email",
108
+ },
109
+ 404: {
110
+ content: { "application/json": { schema: errorSchema } },
111
+ description: "Unknown list id",
112
+ },
113
+ },
114
+ });
115
+
116
+ /**
117
+ * The shared side-effect of subscribe + unsubscribe (identical apart from the
118
+ * boolean polarity): validate the list id, guard identity, then resolve/create
119
+ * the contact FIRST (mirroring /v1/contacts + /v1/events) so a real row (and
120
+ * uuid id) exists — without it `resolveRecipient` returns the raw email as the
121
+ * contactId fallback and `email_preferences.user_id` is written as the raw email
122
+ * instead of the `external_id ?? contact.id` uuid, breaking risk-10 key
123
+ * consistency.
124
+ *
125
+ * Returns a discriminated result the caller maps to a status: `unknown_list` /
126
+ * `missing_identity` → 404 / 400; `failed` → 400 with the error message; `ok` →
127
+ * the caller's literally-typed success body. The `valid()` reads stay in each
128
+ * typed `.openapi()` handler; only the polarity-invariant work lives here.
129
+ */
130
+ async function applyListSubscription(opts: {
131
+ db: Database;
132
+ hatchet: HatchetClient;
133
+ logger: Logger;
134
+ id: string;
135
+ email?: string;
136
+ userId?: string;
137
+ subscribed: boolean;
138
+ }): Promise<
139
+ | { kind: "unknown_list" }
140
+ | { kind: "missing_identity" }
141
+ | { kind: "failed"; message: string }
142
+ | { kind: "ok" }
143
+ > {
144
+ const { db, hatchet, logger, id, email, userId, subscribed } = opts;
145
+
146
+ if (!getListRegistry().has(id)) {
147
+ return { kind: "unknown_list" };
148
+ }
149
+
150
+ if (!email && !userId) {
151
+ return { kind: "missing_identity" };
152
+ }
153
+
154
+ try {
155
+ const { id: contactId, created } = await resolveOrCreateContact({
156
+ db,
157
+ userId,
158
+ email,
159
+ });
160
+
161
+ // INTENT-LAYER outbound emit (decision #3): the lists route emits
162
+ // `contact.created` ONLY on first creation (a list flip is not a contact
163
+ // property delta, so no `contact.updated`). Fire-and-forget after a read-back.
164
+ if (created) {
165
+ void resolveContact({ db, id: contactId })
166
+ .then((row) => {
167
+ if (!row) return;
168
+ return emitOutbound({
169
+ db,
170
+ hatchet,
171
+ logger,
172
+ event: "contact.created",
173
+ payload: serializeContact(row),
174
+ });
175
+ })
176
+ .catch(logger.warn);
177
+ }
178
+
179
+ await applyListMembership({
180
+ db,
181
+ userId,
182
+ email,
183
+ lists: { [id]: subscribed },
184
+ });
185
+ } catch (err) {
186
+ return { kind: "failed", message: listMembershipError(err) };
187
+ }
188
+
189
+ return { kind: "ok" };
190
+ }
191
+
192
+ // The lists router does NOT re-apply auth internally — the data-plane prefix
193
+ // guards in `routes/index.ts` (decision #16) apply `requireApiKey` +
194
+ // `requireScope("ingest")` to `/v1/lists` (bare + `/*`) before requests reach
195
+ // this router. Mounting auth here too would double the middleware.
196
+ export const listsRouter = new OpenAPIHono<AppEnv>()
197
+ .openapi(listRoute, (c) => {
198
+ const lists = getListRegistry()
199
+ .getEnabled()
200
+ .map((l) => ({
201
+ id: l.id,
202
+ name: l.name,
203
+ ...(l.description !== undefined ? { description: l.description } : {}),
204
+ defaultOptIn: l.defaultOptIn,
205
+ }));
206
+
207
+ return c.json({ lists }, 200);
208
+ })
209
+ .openapi(subscribeRoute, async (c) => {
210
+ const { db, hatchet, logger } = c.get("container");
211
+ const { id } = c.req.valid("param");
212
+ const { email, userId } = c.req.valid("json");
213
+
214
+ const result = await applyListSubscription({
215
+ db,
216
+ hatchet,
217
+ logger,
218
+ id,
219
+ email,
220
+ userId,
221
+ subscribed: true,
222
+ });
223
+ if (result.kind === "unknown_list") {
224
+ return c.json({ error: `Unknown list: ${id}` }, 404);
225
+ }
226
+ if (result.kind === "missing_identity") {
227
+ return c.json({ error: "email or userId is required" }, 400);
228
+ }
229
+ if (result.kind === "failed") {
230
+ return c.json({ error: result.message }, 400);
231
+ }
232
+ return c.json({ list: id, subscribed: true as const }, 200);
233
+ })
234
+ .openapi(unsubscribeRoute, async (c) => {
235
+ const { db, hatchet, logger } = c.get("container");
236
+ const { id } = c.req.valid("param");
237
+ const { email, userId } = c.req.valid("json");
238
+
239
+ const result = await applyListSubscription({
240
+ db,
241
+ hatchet,
242
+ logger,
243
+ id,
244
+ email,
245
+ userId,
246
+ subscribed: false,
247
+ });
248
+ if (result.kind === "unknown_list") {
249
+ return c.json({ error: `Unknown list: ${id}` }, 404);
250
+ }
251
+ if (result.kind === "missing_identity") {
252
+ return c.json({ error: "email or userId is required" }, 400);
253
+ }
254
+ if (result.kind === "failed") {
255
+ return c.json({ error: result.message }, 400);
256
+ }
257
+ return c.json({ list: id, subscribed: false as const }, 200);
258
+ });
@@ -2,8 +2,12 @@ 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 { emitOutbound } from "../../lib/outbound.js";
5
6
  import { EMAIL_LINK_CLICKED } from "../../lib/tracking-event-names.js";
6
- import { pushTrackingEvent } from "../../lib/tracking-events.js";
7
+ import {
8
+ pushTrackingEvent,
9
+ resolveEmailSendContext,
10
+ } from "../../lib/tracking-events.js";
7
11
 
8
12
  const clickRoute = createRoute({
9
13
  method: "get",
@@ -48,7 +52,10 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
48
52
  null;
49
53
  const userAgent = c.req.header("user-agent") ?? null;
50
54
 
51
- await Promise.all([
55
+ // The `clickedAt` first-touch UPDATE is split OUT of the Promise.all so it can
56
+ // `.returning({ id })` — the `WHERE clickedAt IS NULL` makes a row come back
57
+ // ONLY on the first click, which gates the outbound `email.clicked` emit.
58
+ const [, , clicked] = await Promise.all([
52
59
  db.insert(linkClicks).values({
53
60
  trackedLinkId: link.id,
54
61
  ipAddress: ip,
@@ -72,7 +79,8 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
72
79
  eq(emailSends.id, link.emailSendId),
73
80
  isNull(emailSends.clickedAt),
74
81
  ),
75
- ),
82
+ )
83
+ .returning({ id: emailSends.id }),
76
84
  ]);
77
85
 
78
86
  const {
@@ -82,21 +90,54 @@ export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
82
90
  analytics: posthog,
83
91
  } = c.get("container");
84
92
 
85
- pushTrackingEvent({
86
- db,
87
- hatchet,
88
- registry,
89
- logger,
90
- posthog,
91
- event: EMAIL_LINK_CLICKED,
92
- emailSendId: link.emailSendId,
93
- properties: { linkUrl: link.originalUrl, linkId: link.id },
94
- }).catch((err) => {
95
- logger.warn("Failed to push click tracking event", {
96
- linkId: link.id,
97
- error: err instanceof Error ? err.message : String(err),
98
- });
99
- });
93
+ // Resolve the send context ONCE (off the response path) and feed both the
94
+ // re-ingest (every click) and the first-touch outbound emit (first click
95
+ // only) — avoiding a duplicate `resolveEmailSendContext` read on the click
96
+ // hot path. `dedupeKey` = `email.clicked:<emailSendId>` is defence-in-depth
97
+ // alongside the first-touch gate (`clicked.length > 0`); first-party is the
98
+ // SINGLE emitter for `email.clicked` (the provider-webhook echo is suppressed).
99
+ const emailSendId = link.emailSendId;
100
+ const isFirstClick = clicked.length > 0;
101
+ void resolveEmailSendContext(db, emailSendId)
102
+ .then(async (ctx) => {
103
+ await pushTrackingEvent({
104
+ db,
105
+ hatchet,
106
+ registry,
107
+ logger,
108
+ posthog,
109
+ event: EMAIL_LINK_CLICKED,
110
+ emailSendId,
111
+ properties: { linkUrl: link.originalUrl, linkId: link.id },
112
+ resolvedContext: ctx,
113
+ }).catch((err) => {
114
+ logger.warn("Failed to push click tracking event", {
115
+ linkId: link.id,
116
+ error: err instanceof Error ? err.message : String(err),
117
+ });
118
+ });
119
+
120
+ if (isFirstClick) {
121
+ await emitOutbound({
122
+ db,
123
+ hatchet,
124
+ logger,
125
+ event: "email.clicked",
126
+ dedupeKey: `email.clicked:${emailSendId}`,
127
+ payload: {
128
+ emailSendId,
129
+ resendId: ctx?.resendId ?? null,
130
+ templateKey: ctx?.templateKey ?? null,
131
+ userId: ctx?.userId ?? null,
132
+ to: ctx?.to ?? ctx?.userEmail ?? "",
133
+ at: new Date().toISOString(),
134
+ linkUrl: link.originalUrl,
135
+ linkId: link.id,
136
+ },
137
+ });
138
+ }
139
+ })
140
+ .catch(logger.warn);
100
141
 
101
142
  return c.redirect(link.originalUrl, 302);
102
143
  },