feat(mail,store,api): open tracking — customerViewedAt, config-gated, default OFF (HT-32) - #30
Conversation
… 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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds 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. ChangesOpen tracking
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/db/migrate.ts (1)
301-320: 🚀 Performance & Scalability | 🔵 TrivialConsider
NOT VALID+VALIDATE CONSTRAINTfor 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 (...)withoutNOT VALIDscans and validates every existing row while holding anACCESS EXCLUSIVElock onthreads, blocking all reads/writes for the duration. Sincethreadsis 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 separateVALIDATE CONSTRAINT ...;(which only takesSHARE UPDATE EXCLUSIVE) avoids blocking concurrent reads/writes. Since every existing row isNULL(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 | 🔵 TrivialCorrect 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
recordThreadViewcalls 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
📒 Files selected for processing (12)
src/api/conversations.tssrc/api/index.test.tssrc/api/index.tssrc/api/router.tssrc/db/migrate.test.tssrc/db/migrate.tssrc/mail/open-tracking.test.tssrc/mail/open-tracking.tssrc/mail/send.test.tssrc/mail/send.tssrc/store/conversations.test.tssrc/store/conversations.ts
…owerCase() case folds can shift the splice offset (CodeRabbit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
openTrackingconfig meanssendReplypasses 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 aview.-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
bodyHtmlis 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 008 —
customer_viewed_at timestamptz, outbound-only CHECK (inbound/note schema-forbidden). Store —recordThreadView: first view wins, idempotent, silent on every miss (nothing useful to leak).Evidence — the mail-semantics case (charter invariant #5)
bodyHtmlidentical, no pixel substring — plus every pre-existing send/round-trip/idempotency test still passing unmodified (396/396, 19 files).</body>; text part untouched; persisted = sent.customerViewedAton the wire, second fetch changes nothing; invalid token gets the identical gif; disabled + valid token records nothing.🤖 Generated with Claude Code
Summary by CodeRabbit
customerViewedAttimestamp when available.