Skip to content

feat(mail,store,api): open tracking — customerViewedAt, config-gated, default OFF (HT-32) - #30

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-32-open-tracking
Jul 12, 2026
Merged

feat(mail,store,api): open tracking — customerViewedAt, config-gated, default OFF (HT-32)#30
zaridan merged 2 commits into
mainfrom
feat/ht-32-open-tracking

Conversation

@zaridan

@zaridan zaridan commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Why

The final v1.1 increment (HT-32) — and the sensitive one: it touches outbound mail composition, so the byte-identical-when-off guarantee is the headline. Spec: agent-inbox-v1.md §4g (v1.1, #24), including the review-hardened signed-token requirement from #24's CodeRabbit pass.

What

Default OFF, as a stance — no openTracking config means sendReply passes its input through untouched (one conditional is the entire off-path) and nothing is ever recorded: disabling the feature stops recording for pixels already in the wild, not just injection.

Signed view tokens (src/mail/open-tracking.ts) — v.{keyId}.{threadId}.{sig}, same full-HMAC/keyring/rotation model as reply tokens, with a view.-prefixed canonical for domain separation (test-proven: a reply-token signature lifted onto a view token never verifies). Never the bare uuid — the forgery guard #24's review added to the spec. Mint strict, verify total.

Injection before persist, HTML only — the stored bodyHtml is exactly what was sent, so keyed replays and the delivery worker (which rebuild from the stored row) carry the same pixel with zero extra logic. A text-only reply never gets a fabricated HTML part. Keyed replays are unaffected by construction (§4a: the original row's body wins).

GET /api/v1/t/{token}.gif — the API's one unauthenticated surface, matched before Bearer auth with its own matcher (the authenticated route table has no pre-auth special case). Uniform response: 200 + the same 1×1 GIF89a + no-store, valid or not, on or off — no validity leak, and pixels in old mail render harmlessly forever. Its own try/catch guarantees even a store failure answers with the gif, never the JSON error envelope.

Migration 008customer_viewed_at timestamptz, outbound-only CHECK (inbound/note schema-forbidden). StorerecordThreadView: first view wins, idempotent, silent on every miss (nothing useful to leak).

Evidence — the mail-semantics case (charter invariant #5)

  • OFF-path proof: sent html and text are asserted byte-identical to input, stored bodyHtml identical, no pixel substring — plus every pre-existing send/round-trip/idempotency test still passing unmodified (396/396, 19 files).
  • ON-path: token extracted from real sent mail verifies and binds the exact threadId; injected before </body>; text part untouched; persisted = sent.
  • Full API loop: unauthenticated pixel fetch records first view, surfaces as customerViewedAt on the wire, second fetch changes nothing; invalid token gets the identical gif; disabled + valid token records nothing.
  • Token unit suite: round-trip, totality over hostile input, tamper rejection, cross-domain rejection, key rotation, strict minting.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added optional email read/open tracking via a hidden 1×1 pixel.
    • Introduced an unauthenticated pixel endpoint to record the first customer view time for outbound replies.
    • Conversation details now include a customerViewedAt timestamp when available.
  • Bug Fixes
    • Tracking is disabled by default and won’t alter text-only replies or existing HTML when off.
    • Invalid, tampered, or repeated tracking requests are handled safely without updating the recorded timestamp.

… default OFF (HT-32)

Off by default is the product stance (spec §4g): no openTracking config
means byte-identical mail — input passes through sendReply untouched —
and NOTHING is ever recorded (a pixel from mail sent while enabled
stops recording the moment the feature is disabled).

The pixel URL carries a SIGNED view token (src/mail/open-tracking.ts),
never the bare thread uuid — same HMAC/keyring/rotation model as reply
tokens, with a 'view.' canonical prefix for domain separation
(test-proven: a reply-token signature can never verify as a view
token). Minting is strict; verification is total over hostile input.

Injection happens in sendReply BEFORE persist, HTML body only (a
text-only reply is never given a fabricated HTML part), so the stored
bodyHtml is exactly what was sent and every retry path carries the
same pixel with no extra logic.

Migration 008: customer_viewed_at timestamptz, outbound-only CHECK.
Store: recordThreadView — first view wins, idempotent, silent on
every miss.

API: GET /api/v1/t/{token}.gif is the one UNAUTHENTICATED surface,
matched BEFORE Bearer auth, answering 200 + the same 1x1 gif +
no-store whether the token is valid or not, feature on or off — no
validity leak, and pixels in old mail render harmlessly forever.

396/396 tests. Per specs/api/agent-inbox-v1.md §4g (v1.1, HT-25).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9498b941-5cda-4e37-9857-bd01cc840ef1

📥 Commits

Reviewing files that changed from the base of the PR and between fb2e1df and 0436d72.

📒 Files selected for processing (2)
  • src/mail/open-tracking.test.ts
  • src/mail/open-tracking.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/mail/open-tracking.ts
  • src/mail/open-tracking.test.ts

📝 Walkthrough

Walkthrough

Adds config-gated signed open-tracking pixels to outbound replies, records the first customer view for outbound threads, exposes the timestamp in thread responses, and serves a pre-authentication transparent GIF endpoint.

Changes

Open tracking

Layer / File(s) Summary
Customer view persistence
src/db/migrate.ts, src/db/migrate.test.ts, src/store/conversations.ts, src/store/conversations.test.ts, src/api/conversations.ts
Adds the outbound-only customer_viewed_at column, idempotent first-view recording, thread mapping, and ISO timestamp serialization.
Signed tokens and outbound pixel injection
src/mail/open-tracking.ts, src/mail/open-tracking.test.ts, src/mail/send.ts, src/mail/send.test.ts
Creates and verifies view tokens, builds pixel URLs, injects pixels into HTML replies, and persists the effective HTML when tracking is enabled.
Pixel endpoint and API wiring
src/api/router.ts, src/api/index.ts, src/api/conversations.ts, src/api/index.test.ts
Adds the unauthenticated GIF route, token verification and first-view recording, optional dependency wiring, and end-to-end coverage for enabled, invalid, and disabled tracking.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: config-gated open tracking across mail, store, and API with customerViewedAt.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-32-open-tracking

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/db/migrate.ts (1)

301-320: 🚀 Performance & Scalability | 🔵 Trivial

Consider NOT VALID + VALIDATE CONSTRAINT for the new CHECK on a live table.

The constraint logic itself is correct — (direction = 'outbound') OR (customer_viewed_at IS NULL) properly restricts the new column to outbound threads. However, ALTER TABLE ... ADD CONSTRAINT ... CHECK (...) without NOT VALID scans and validates every existing row while holding an ACCESS EXCLUSIVE lock on threads, blocking all reads/writes for the duration. Since threads is an actively written table (delivery leases, replies), this could cause a noticeable stall in production during deploy.

Splitting into ADD CONSTRAINT ... CHECK (...) NOT VALID; followed by a separate VALIDATE CONSTRAINT ...; (which only takes SHARE UPDATE EXCLUSIVE) avoids blocking concurrent reads/writes. Since every existing row is NULL (the column is brand new), validation is cheap either way — the main win is the lock mode.

🗄️ Suggested migration change
 const MIGRATION_008_CUSTOMER_VIEWED_AT = `
 ALTER TABLE threads ADD COLUMN customer_viewed_at timestamptz;
 ALTER TABLE threads ADD CONSTRAINT threads_customer_viewed_at_outbound_only CHECK (
   (direction = 'outbound') OR (customer_viewed_at IS NULL)
-);
+) NOT VALID;
+ALTER TABLE threads VALIDATE CONSTRAINT threads_customer_viewed_at_outbound_only;
 `
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/migrate.ts` around lines 301 - 320, Update
MIGRATION_008_CUSTOMER_VIEWED_AT to add threads_customer_viewed_at_outbound_only
with NOT VALID, then issue a separate ALTER TABLE VALIDATE CONSTRAINT statement.
Preserve the existing CHECK expression and constraint name while using the
lower-lock validation flow for the live threads table.
src/api/index.ts (1)

109-141: 🩺 Stability & Availability | 🔵 Trivial

Correct uniform-response contract; consider rate limiting the unauthenticated pixel route.

The gif-always-200/no-store/try-catch behavior matches spec §4g exactly (no validity/existence leak, store failure never surfaces past the gif). Since this is deliberately the API's one unauthenticated surface, consider adding rate limiting/abuse monitoring at the edge (e.g., reverse proxy or gateway) so it can't be used to flood recordThreadView calls or probe for load-based signals, especially since it's matched before any auth check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/index.ts` around lines 109 - 141, Preserve the uniform
unauthenticated response behavior in the open-tracking pixel branch, and add
rate limiting or abuse monitoring for this route at the edge rather than
changing its response or authentication flow. Target requests identified by
matchOpenTrackingPixel, ensuring controls limit potential recordThreadView
flooding without exposing token validity or store errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/mail/open-tracking.ts`:
- Around line 141-149: Update injectTrackingPixel to find the closing body tag
directly in the original html string and use that match’s index for slicing,
rather than deriving lastIndex from html.toLowerCase(). Preserve the existing
case-insensitive matching and append behavior when no closing tag is found.

---

Nitpick comments:
In `@src/api/index.ts`:
- Around line 109-141: Preserve the uniform unauthenticated response behavior in
the open-tracking pixel branch, and add rate limiting or abuse monitoring for
this route at the edge rather than changing its response or authentication flow.
Target requests identified by matchOpenTrackingPixel, ensuring controls limit
potential recordThreadView flooding without exposing token validity or store
errors.

In `@src/db/migrate.ts`:
- Around line 301-320: Update MIGRATION_008_CUSTOMER_VIEWED_AT to add
threads_customer_viewed_at_outbound_only with NOT VALID, then issue a separate
ALTER TABLE VALIDATE CONSTRAINT statement. Preserve the existing CHECK
expression and constraint name while using the lower-lock validation flow for
the live threads table.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f6919d9c-6df0-4619-8358-a730167710df

📥 Commits

Reviewing files that changed from the base of the PR and between b746919 and fb2e1df.

📒 Files selected for processing (12)
  • src/api/conversations.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/router.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/mail/open-tracking.test.ts
  • src/mail/open-tracking.ts
  • src/mail/send.test.ts
  • src/mail/send.ts
  • src/store/conversations.test.ts
  • src/store/conversations.ts

Comment thread src/mail/open-tracking.ts
…owerCase() case folds can shift the splice offset (CodeRabbit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zaridan
zaridan merged commit 8da1df4 into main Jul 12, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-32-open-tracking branch August 2, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant