@hogsend/engine 0.23.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
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.23.0",
44
- "@hogsend/db": "^0.23.0",
45
- "@hogsend/email": "^0.23.0",
46
- "@hogsend/plugin-posthog": "^0.23.0",
47
- "@hogsend/plugin-resend": "^0.23.0"
43
+ "@hogsend/core": "^0.23.1",
44
+ "@hogsend/db": "^0.23.1",
45
+ "@hogsend/email": "^0.23.1",
46
+ "@hogsend/plugin-posthog": "^0.23.1",
47
+ "@hogsend/plugin-resend": "^0.23.1"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.23.0"
50
+ "@hogsend/plugin-postmark": "^0.23.1"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
@@ -144,6 +144,12 @@ const replayRoute = createRoute({
144
144
  },
145
145
  description: "Replay results",
146
146
  },
147
+ 400: {
148
+ content: {
149
+ "application/json": { schema: errorSchema },
150
+ },
151
+ description: "No replay selection (eventIds or filter) provided",
152
+ },
147
153
  },
148
154
  });
149
155
 
@@ -362,14 +368,26 @@ export const bulkRouter = new OpenAPIHono<AppEnv>()
362
368
  conditions.push(lte(userEvents.occurredAt, new Date(body.filter.to)));
363
369
  }
364
370
 
365
- const where = conditions.length > 0 ? and(...conditions) : undefined;
371
+ // Refuse an unscoped replay. With no `eventIds` and no filter the WHERE
372
+ // would collapse to `undefined`, silently re-pushing the most-recent
373
+ // `limit` events back through the full ingestion pipeline (re-triggering
374
+ // journeys, re-evaluating exits). Require an explicit selection.
375
+ if (conditions.length === 0) {
376
+ return c.json(
377
+ {
378
+ error:
379
+ "Replay requires `eventIds` or at least one `filter` field (event, userId, from, to).",
380
+ },
381
+ 400,
382
+ );
383
+ }
366
384
 
367
385
  events = await db
368
386
  .select()
369
387
  .from(userEvents)
370
- .where(where)
388
+ .where(and(...conditions))
371
389
  .orderBy(desc(userEvents.occurredAt))
372
- .limit(body.limit ?? 100);
390
+ .limit(body.limit);
373
391
  }
374
392
 
375
393
  let replayed = 0;
@@ -147,6 +147,14 @@ export const preferencesRouter = new OpenAPIHono<AppEnv>()
147
147
  ? {
148
148
  suppressed: body.suppressed,
149
149
  suppressedAt: body.suppressed ? new Date() : null,
150
+ // Un-suppressing clears the bounce slate. `bounceCount` only
151
+ // drives the auto-suppress threshold (the send-gate keys off
152
+ // `suppressed`/`unsubscribedAll`), so a leftover count would
153
+ // otherwise keep a bounced recipient pinned to the suppression
154
+ // list with no way to remove them.
155
+ ...(body.suppressed
156
+ ? {}
157
+ : { bounceCount: 0, lastBounceAt: null }),
150
158
  }
151
159
  : {}),
152
160
  ...(body.categories !== undefined
@@ -362,8 +362,19 @@ reportingRouter.get("/sends/export", async (c) => {
362
362
  );
363
363
  }
364
364
 
365
+ // The export intentionally returns all matching sends, but is hard-capped at
366
+ // MAX_EXPORT_ROWS. Signal when the result was truncated so a caller never
367
+ // mistakes a partial CSV for the complete history.
368
+ const truncated = rows.length >= MAX_EXPORT_ROWS;
369
+
365
370
  return c.body(lines.join("\n"), 200, {
366
371
  "Content-Type": "text/csv; charset=utf-8",
367
372
  "Content-Disposition": 'attachment; filename="email-sends.csv"',
373
+ ...(truncated
374
+ ? {
375
+ "X-Hogsend-Export-Truncated": "true",
376
+ "X-Hogsend-Export-Limit": String(MAX_EXPORT_ROWS),
377
+ }
378
+ : {}),
368
379
  });
369
380
  });
@@ -1,6 +1,6 @@
1
1
  import { emailPreferences } from "@hogsend/db";
2
2
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
- import { and, count, desc, eq, gt, type SQL } from "drizzle-orm";
3
+ import { and, count, desc, eq, gt, or, type SQL } from "drizzle-orm";
4
4
  import type { AppEnv } from "../../app.js";
5
5
  import { serializePrefs } from "../../lib/contacts.js";
6
6
 
@@ -8,6 +8,11 @@ import { serializePrefs } from "../../lib/contacts.js";
8
8
  // `complained` has no dedicated column — a complaint sets `suppressed` without
9
9
  // incrementing `bounceCount` (see mailer `handleComplaint`), so we identify it
10
10
  // as suppressed-but-not-bounced.
11
+ //
12
+ // IMPORTANT: the `email_preferences` table holds a row for (nearly) every
13
+ // contact, most of whom are NOT suppressed. The "All" view must therefore
14
+ // restrict to recipients suppressed in *some* way — returning `undefined`
15
+ // here would drop the WHERE clause entirely and list every contact.
11
16
  function typeFilter(
12
17
  type: "bounced" | "unsubscribed" | "complained" | undefined,
13
18
  ): SQL | undefined {
@@ -22,7 +27,12 @@ function typeFilter(
22
27
  eq(emailPreferences.bounceCount, 0),
23
28
  );
24
29
  default:
25
- return undefined;
30
+ // "All" = the union of every suppression reason.
31
+ return or(
32
+ eq(emailPreferences.suppressed, true),
33
+ eq(emailPreferences.unsubscribedAll, true),
34
+ gt(emailPreferences.bounceCount, 0),
35
+ );
26
36
  }
27
37
  }
28
38