Skip to content

Commit 9b2f7ce

Browse files
author
Rajat
committed
WIP: Docs site, polished a few email blocks
1 parent 6675695 commit 9b2f7ce

117 files changed

Lines changed: 8765 additions & 563 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ARCHITECTURE.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,32 @@ now resolves without warnings). **Anyone with a `next dev` process already
418418
running from before this upgrade needs to restart it** — it will otherwise
419419
keep running the old, now-unlinked 15.x build.
420420

421+
Gap fix (post-Phase-5) — **done**: **ESP config moved from a top-level
422+
contract group to a `settings` namespace**. ESP configuration is a per-team
423+
*singleton setting* (get/upsert/remove/test — never a list, never more than
424+
one per team), unlike Contacts/Templates/Sequences, which are true resource
425+
collections (list, paginate, create many, delete individually). Sitting as
426+
`contract.esp` alongside those made it look like a peer entity when it isn't
427+
one. Fixed by nesting it under a new `contract.settings` group
428+
(`contract.settings.esp`), which also gives a natural home for future
429+
per-team settings (default sending identity, branding, webhook URLs, ...)
430+
without forcing them into ESP's specific schema or a schema-less blob.
431+
Route paths moved from `/esp-config`(`/test`) to `/settings/esp`(`/test`);
432+
`apps/api/src/esp/` was renamed to `apps/api/src/settings/esp/`. Deliberately
433+
**kept the underlying `esp_configs` Postgres table separate** rather than
434+
folding it into a generic settings blob/table: it holds an encrypted secret
435+
(the SMTP password), and an isolated table with narrow, explicitly-scoped
436+
query functions (`getDecryptedEspCredentials` used only internally by mail
437+
sending) is a stronger security boundary than a general "team settings" row
438+
that a future bulk-read endpoint could too easily return unfiltered. MCP tool
439+
names (`get_esp_config`, `update_esp_config`, ...) were left unchanged —
440+
only their internal import paths moved — since renaming them wouldn't add
441+
clarity for MCP/AI consumers. This was a breaking API change (route paths),
442+
made safely pre-launch with no external consumers yet. Validated:
443+
`packages/api-contract`, `apps/api` (`tsc --noEmit` + `pnpm run build`, which
444+
regenerates the OpenAPI doc from the updated contract), and `apps/web`
445+
(`tsc --noEmit` + `next build`) all compile clean.
446+
421447
Gap fix (post-Phase-5) — **done**: fixed a bug in `packages/email-editor`
422448
(present verbatim in CourseLit's original source too, so pre-existing rather
423449
than introduced by the port) where editing any block setting from the
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Automation pipeline: scale review (small/medium workloads)
2+
3+
_Review date: 2026-07-04. Scope: the trigger → enrollment → send pipeline
4+
(`src/automation/*`, `src/mail/sequence-queue.ts`, `src/mail/sequence-worker.ts`,
5+
`src/mail/send.ts`)._
6+
7+
**Verdict:** the architecture (poll-scheduler + durable BullMQ queue + idempotent
8+
enrollment) is the right shape through medium scale (broadcasts of ~10k+,
9+
multi-step sequences under load). The findings below were identified before
10+
pointing real medium-sized workloads at it. Statuses reflect what has been
11+
fixed vs. deliberately accepted.
12+
13+
## Pipeline recap
14+
15+
1. **Trigger** — event-driven (`fire-event.ts`, called synchronously on tag/contact
16+
events) or date-based (`process-rules.ts`, 60s polling loop for `DATE_OCCURRED`
17+
broadcast rules). Both insert rows into `ongoing_sequences` (one row per
18+
contact-in-a-sequence).
19+
2. **Scheduler**`process-ongoing-sequences.ts` polls every 60s for rows whose
20+
`nextEmailScheduledTime` has passed and enqueues each onto the BullMQ
21+
`sequence` queue.
22+
3. **Worker**`sequence-worker.ts``process-ongoing-sequence.ts`: checks quota,
23+
picks the next published/unsent email, renders (Liquid merge tags + open pixel
24+
+ click-tracked links), sends via `sendMail()`, records an `email_deliveries`
25+
row, and either schedules the next email or deletes the row (marking a
26+
broadcast `sent` once every recipient is delivered).
27+
28+
## What was already solid
29+
30+
- **Idempotent enrollment**: unique index on `(sequence_id, contact_id)` plus
31+
`onConflictDoNothing` means a crash between enrollment and `deleteRule` in
32+
`process-rules.ts` re-runs harmlessly.
33+
- **Durable sends**: scheduler/worker decoupling via BullMQ means sends survive
34+
process restarts.
35+
- **Retry model**: on send failure the row stays due with `retryCount`
36+
incremented, so it is retried on a later poll until `sequenceBounceLimit`
37+
a reasonable poor-man's backoff.
38+
- Both polling loops swallow per-item errors, so one bad row cannot stall a tick.
39+
40+
## Findings
41+
42+
### 1. Duplicate enqueues → premature sends in multi-step sequences — **Fixed**
43+
44+
The scheduler re-enqueued **every still-due row on every 60s poll** with no
45+
`jobId`, so BullMQ did not deduplicate. Whenever the worker backlog exceeded
46+
60s (any broadcast beyond a few hundred recipients), the queue filled with
47+
duplicate jobs for the same `ongoingSequenceId`.
48+
49+
- Broadcasts self-healed: the row is deleted after the send, so a duplicate job
50+
hit the `if (!ongoingSequence) return` guard.
51+
- Multi-step sequences did **not**: `processOngoingSequence` never re-checked
52+
that the row was actually due. A duplicate job arriving after the first one
53+
advanced `nextEmailScheduledTime` found email #1 in `sentEmailIds`, picked
54+
email #2 as "next", and sent it immediately — skipping its configured delay.
55+
56+
**Fix (both halves applied):**
57+
- `jobId: ongoingSequence.id` on `sequenceQueue.add()` — BullMQ drops adds whose
58+
id is already waiting/active/delayed, so a row is never queued twice at once.
59+
- Dueness guard at the top of `processOngoingSequence`
60+
(`nextEmailScheduledTime > Date.now()` → return). This is the true safety
61+
net: it also protects against races if a second worker/instance ever runs.
62+
63+
### 2. Throughput ceiling (~1–3 emails/sec) — **Fixed**
64+
65+
The BullMQ `Worker` used the default concurrency of 1, and each job does
66+
~5 sequential DB queries, a JSDOM parse, and an SMTP send over a **non-pooled**
67+
nodemailer transport (a fresh SMTP connection per message). A 10k broadcast
68+
would take 1–3 hours.
69+
70+
**Fix:**
71+
- Worker `concurrency: 10`.
72+
- `pool: true, maxConnections: 5` on the platform default transporter and on
73+
per-team ESP transporters (`mail/transport.ts` — these are already cached per
74+
team, so pooling them is safe).
75+
76+
Note: concurrency > 1 makes finding #1's fix a **prerequisite** — without jobId
77+
dedup + the dueness guard, duplicate jobs for the same row could run
78+
simultaneously and double-send.
79+
80+
### 3. Unbounded Redis growth — **Fixed**
81+
82+
`sequenceQueue.add()` passed no job options and the Queue had no
83+
`defaultJobOptions`, so every completed job was kept in Redis forever (BullMQ's
84+
default). Combined with finding #1, a single 10k broadcast could leave tens of
85+
thousands of dead job hashes behind.
86+
87+
**Fix:** `defaultJobOptions: { removeOnComplete: true, removeOnFail: 5000 }` on
88+
the Queue constructor. `removeOnComplete` must be `true` (remove immediately)
89+
rather than a keep-count: jobs are keyed by ongoing-sequence row id for dedup
90+
(finding #1), and BullMQ silently ignores an `add` whose jobId still exists in
91+
the completed set — a lingering completed job would block that row's next email
92+
tick or retry. Send history lives in logs and `email_deliveries` instead.
93+
94+
### 4. No index on `next_email_scheduled_time`**Fixed**
95+
96+
`getDueOngoingSequences` full-scanned `ongoing_sequences` every minute. The
97+
table stays small because completed rows are deleted, but large long-running
98+
sequence campaigns keep many rows resident.
99+
100+
**Fix:** added a b-tree index on `next_email_scheduled_time` (drizzle migration).
101+
102+
### 5. `countOngoingSequencesForSequence` loaded every row — **Fixed**
103+
104+
It selected all rows for the sequence and returned `rows.length`. After a
105+
broadcast to N contacts, early cleanup calls pulled thousands of rows just to
106+
count them.
107+
108+
**Fix:** use SQL `count()`.
109+
110+
## Test coverage
111+
112+
The pipeline is covered by a vitest suite (`pnpm test`) running against an
113+
in-memory PGlite Postgres with the real drizzle migrations applied (see
114+
`src/test/db.ts`), so unique indexes, `onConflictDoNothing`, and `jsonb_set`
115+
behave exactly as in production. Only `sendMail` and the BullMQ queue are
116+
mocked. Covered: the dueness guard (finding #1's regression), send + delivery
117+
recording + follow-up scheduling, rendering (merge tags, pixel, click-tracked
118+
links), broadcast completion, quota skip, missing-contact cleanup, retry and
119+
bounce-limit handling, enrollment idempotency, due-row selection, jobId-keyed
120+
enqueueing, and event-triggered enrollment (`fire-event.ts`).
121+
122+
One behavior the tests surfaced: `markBroadcastSent`'s
123+
`jsonb_set(report, '{broadcast,sentAt}', …)` silently no-ops unless
124+
`report.broadcast` already exists — which `lockBroadcast` guarantees in the
125+
real flow (`processRule` locks before any delivery). If broadcasts ever get a
126+
second enrollment path that skips `lockBroadcast`, `sentAt` would never be
127+
recorded (status would still flip to `completed`).
128+
129+
## Accepted limitations (documented, not fixed)
130+
131+
- **Single-instance assumption.** The polling loops run inside the API process
132+
(`startAutomation()` in `index.ts`). Running a second API instance would
133+
double every enqueue; the jobId dedup and dueness guard make this safe-ish,
134+
but the design assumes one instance. JSDOM rendering is also CPU work on the
135+
API's event loop — during a big broadcast, API latency will degrade. When
136+
"medium" becomes "large", move the scheduler + worker into a separate process.
137+
- **At-least-once delivery.** A crash between `sendMail()` succeeding and the
138+
`sentEmailIds` update landing re-sends that email on restart. Standard for
139+
email pipelines; a transactional outbox is not worth it at this scale.
140+
- **Quota check is check-then-act.** `hasMailQuotaRemaining`
141+
`incrementMailCount` is not atomic, so a team can overshoot its quota by
142+
roughly the worker concurrency (≤10 emails). Cosmetic.
143+
- **`getDueOngoingSequences` is unbounded.** All due rows are loaded per poll.
144+
Fine at this scale since jobId dedup caps queue growth; add a `LIMIT` +
145+
cursor if tables ever reach 100k+ due rows.

apps/api/package.json

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@
1212
"db:generate": "drizzle-kit generate",
1313
"db:push": "drizzle-kit push",
1414
"db:studio": "drizzle-kit studio",
15-
"test": "node --import tsx --test '**/*.test.ts'"
15+
"test": "vitest run",
16+
"test:watch": "vitest",
17+
"typecheck": "tsc -p tsconfig.test.json"
1618
},
1719
"dependencies": {
1820
"@modelcontextprotocol/sdk": "^1.29.0",
1921
"@node-oauth/oauth2-server": "^5.3.0",
2022
"@sendlit/api-contract": "workspace:*",
23+
"@sendlit/email-editor": "workspace:*",
2124
"@ts-rest/express": "^3.52.1",
2225
"@ts-rest/open-api": "^3.52.1",
2326
"bullmq": "^5.34.0",
@@ -34,10 +37,10 @@
3437
"pg": "^8.13.1",
3538
"pino": "^10.1.0",
3639
"swagger-ui-express": "^5.0.1",
37-
"zod": "^3.25.76",
38-
"@sendlit/email-editor": "workspace:*"
40+
"zod": "^3.25.76"
3941
},
4042
"devDependencies": {
43+
"@electric-sql/pglite": "^0.2.17",
4144
"@types/cors": "^2.8.12",
4245
"@types/express": "^4.17.20",
4346
"@types/jsdom": "^21.1.7",
@@ -49,6 +52,7 @@
4952
"drizzle-kit": "^0.28.1",
5053
"nodemon": "^3.1.10",
5154
"tsx": "^4.20.6",
52-
"typescript": "^5.9.3"
55+
"typescript": "^5.9.3",
56+
"vitest": "^4.1.9"
5357
}
5458
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { eq } from "drizzle-orm";
3+
4+
vi.mock("../db/client", async () => {
5+
const { makeTestDb } = await import("../test/db.js");
6+
return { db: await makeTestDb() };
7+
});
8+
9+
import { db } from "../db/client";
10+
import { ongoingSequences, rules } from "../db/schema";
11+
import { truncateAll, seedTeamAndContact, type TestDb } from "../test/db";
12+
import { seedSequence } from "../test/fixtures";
13+
import { EventType } from "../config/constants";
14+
import { fireEvent } from "./fire-event";
15+
16+
const tdb = db as unknown as TestDb;
17+
18+
beforeEach(async () => {
19+
await truncateAll(tdb);
20+
});
21+
22+
async function seedRule({
23+
teamId,
24+
sequenceId,
25+
event,
26+
eventData,
27+
}: {
28+
teamId: string;
29+
sequenceId: string;
30+
event: string;
31+
eventData?: string;
32+
}) {
33+
await tdb.insert(rules).values({
34+
teamId,
35+
ruleId: `rule-${crypto.randomUUID()}`,
36+
event,
37+
sequenceId,
38+
eventData,
39+
});
40+
}
41+
42+
async function enrolledContactIds(sequenceId: string) {
43+
const rows = await tdb
44+
.select()
45+
.from(ongoingSequences)
46+
.where(eq(ongoingSequences.sequenceId, sequenceId));
47+
return rows.map((r) => r.contactId);
48+
}
49+
50+
describe("fireEvent", () => {
51+
it("enrolls the contact when a TAG_ADDED rule matches the tag", async () => {
52+
const { team, contact } = await seedTeamAndContact(tdb);
53+
const { sequenceRow } = await seedSequence(tdb, {
54+
teamId: team.id,
55+
emails: [{ emailId: "e1" }],
56+
});
57+
await seedRule({
58+
teamId: team.id,
59+
sequenceId: sequenceRow.sequenceId,
60+
event: EventType.TAG_ADDED,
61+
eventData: "vip",
62+
});
63+
64+
await fireEvent({
65+
teamId: team.id,
66+
event: EventType.TAG_ADDED,
67+
eventData: "vip",
68+
contactId: contact.contactId,
69+
});
70+
71+
expect(await enrolledContactIds(sequenceRow.sequenceId)).toEqual([
72+
contact.contactId,
73+
]);
74+
});
75+
76+
it("ignores TAG_ADDED rules for a different tag", async () => {
77+
const { team, contact } = await seedTeamAndContact(tdb);
78+
const { sequenceRow } = await seedSequence(tdb, {
79+
teamId: team.id,
80+
emails: [{ emailId: "e1" }],
81+
});
82+
await seedRule({
83+
teamId: team.id,
84+
sequenceId: sequenceRow.sequenceId,
85+
event: EventType.TAG_ADDED,
86+
eventData: "vip",
87+
});
88+
89+
await fireEvent({
90+
teamId: team.id,
91+
event: EventType.TAG_ADDED,
92+
eventData: "newsletter",
93+
contactId: contact.contactId,
94+
});
95+
96+
expect(await enrolledContactIds(sequenceRow.sequenceId)).toEqual([]);
97+
});
98+
99+
it("does not enroll into sequences that are not active", async () => {
100+
const { team, contact } = await seedTeamAndContact(tdb);
101+
const { sequenceRow } = await seedSequence(tdb, {
102+
teamId: team.id,
103+
status: "paused",
104+
emails: [{ emailId: "e1" }],
105+
});
106+
await seedRule({
107+
teamId: team.id,
108+
sequenceId: sequenceRow.sequenceId,
109+
event: EventType.SUBSCRIBER_ADDED,
110+
});
111+
112+
await fireEvent({
113+
teamId: team.id,
114+
event: EventType.SUBSCRIBER_ADDED,
115+
contactId: contact.contactId,
116+
});
117+
118+
expect(await enrolledContactIds(sequenceRow.sequenceId)).toEqual([]);
119+
});
120+
121+
it("enrolls on SUBSCRIBER_ADDED without any eventData matching", async () => {
122+
const { team, contact } = await seedTeamAndContact(tdb);
123+
const { sequenceRow } = await seedSequence(tdb, {
124+
teamId: team.id,
125+
emails: [{ emailId: "e1" }],
126+
});
127+
await seedRule({
128+
teamId: team.id,
129+
sequenceId: sequenceRow.sequenceId,
130+
event: EventType.SUBSCRIBER_ADDED,
131+
});
132+
133+
await fireEvent({
134+
teamId: team.id,
135+
event: EventType.SUBSCRIBER_ADDED,
136+
contactId: contact.contactId,
137+
});
138+
139+
expect(await enrolledContactIds(sequenceRow.sequenceId)).toEqual([
140+
contact.contactId,
141+
]);
142+
});
143+
});

0 commit comments

Comments
 (0)