feat(engine): inbox basics — saved replies & macros, snooze, send & close (HT-76/77/78) - #90
Conversation
… (HT-76/77/78)
Three additive core-free features on the Agent Inbox API:
- HT-76 saved replies & macros: new saved_replies table (migration 024) and
/api/v1/mailboxes/{id}/saved-replies (+ /{replyId}) surface. The engine
stores definitions only — applying a macro's actions (setStatus/addTags/
assignToSelf) is a client-side composition of existing endpoints, zero new
mail or status semantics.
- HT-77 snooze: conversations.snoozed_until (migration 025), a timed
`pending` with a CHECK tying it to status='pending'. PATCH .../status
gains an optional snoozedUntil field. A snooze wakes itself two ways: a
new every-minute cron (src/mail/snooze-wake.ts) via a requireStatus-guarded
setConversationStatus call, and inbound customer mail on the snoozed
conversation (appendThreadInTx's reopen branch, scoped to direction:
'inbound' only — outbound replies and notes never auto-wake it). Both
paths reuse setConversationStatus's transactional event emission.
- HT-78 send & close: POST .../replies gains an optional
thenSetStatus: 'closed'|'pending', applied in the same transaction as the
reply persist (via a new appendThread options param), after it and before
the network send. Never touches mail content/envelope — verified
byte-identical with and without the param.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sts (review) F1: agent-inbox-v1.md §4b wrongly claimed BOTH snooze wake paths fire conversation.status_changed. The code was already correct — only the timer wake goes through setConversationStatus (status_changed); the inbound wake reopens via appendThreadInTx exactly like the existing closed/spam reopen, reporting solely through conversation.message_received's reopened:true field (emitted by src/mail/ingest.ts), never status_changed. Corrected §4b and the changelog; added the missing event-stream assertions: an ingest.ts test proving the inbound wake fires message_received(reopened:true) and zero status_changed, a store-level assertion that appendThreadInTx itself fires no event on that path, and strengthened the timer-wake test's name/ comment to make the "exactly one event, and it's status_changed" proof explicit. F2: softened §4a's "indistinguishable from a two-step reply-then-PATCH" claim for thenSetStatus. A reply to a closed/spam conversation reopens it silently; thenSetStatus's `from` is captured BEFORE that reopen, which is MORE correct than a two-step sequence (a separate PATCH could only observe the already-reopened `active` row) but not identical to it. Documented the two concrete divergences and added tests: replying to a CLOSED conversation with thenSetStatus:'closed' fires no status_changed (net-zero change); with thenSetStatus:'pending' fires status_changed with from:'closed', never from:'active'. Also added the optional migration-025 raw-SQL negative test: the CHECK rejects snoozed_until on every non-pending status, on both INSERT and UPDATE, while NULL stays legal everywhere. F3 (validation strictness): no change — spec-backed, left as-is per review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds timed conversation snoozing with scheduled and inbound wake behavior, transactional send-status updates, and mailbox-scoped saved replies/macros with role-gated CRUD APIs. Database migrations, application wiring, routing, persistence, and end-to-end tests are updated. ChangesConversation lifecycle
Saved replies and macros
Estimated code review effort: 4 (Complex) | ~60 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.
🧹 Nitpick comments (3)
src/api/conversations.ts (1)
874-898: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value
snoozedUntilvalidation accepts non-ISO-8601 strings.
new Date(snoozedUntil)is more permissive than the documented "ISO-8601 timestamp" contract (spec §4b) — it also accepts many non-ISO formats, and JS date-string parsing for non-standard formats isn't fully engine-consistent. Consider a stricter check (e.g. an ISO-8601 regex guard beforenew Date(...)) if malformed-but-parseable inputs should be rejected per the documented contract.🤖 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/conversations.ts` around lines 874 - 898, Strengthen snoozedUntil validation in parsePatchStatusBody by requiring the string to match the documented ISO-8601 timestamp format before constructing a Date, while retaining the existing pending-status requirement and invalid-date rejection.src/mail/snooze-wake.ts (1)
62-78: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueConsider bounded concurrency for the wake-pass loop.
Each
setConversationStatuscall operates on an independent row, so sequentially awaiting alldueIds(up toDEFAULT_BATCH_SIZE = 100) serializes round-trips that could run concurrently (e.g.Promise.allwith a concurrency cap) to reduce one pass's wall-clock time, without changing per-row correctness or event semantics.🤖 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/mail/snooze-wake.ts` around lines 62 - 78, The runSnoozeWake loop currently awaits each setConversationStatus call sequentially; update it to process independent dueIds with bounded concurrency, preserving the requireStatus condition, woken count, report values, and per-row event behavior. Use an appropriate concurrency cap rather than unbounded Promise.all.src/db/migrate.ts (1)
1347-1352: 🚀 Performance & Scalability | 🔵 TrivialConsider a partial index to support the every-minute due-snooze scan.
The snooze-wake cron runs
listDueSnoozed(src/store/conversations.ts) every minute, filteringstatus = 'pending' AND snoozed_until IS NOT NULL AND snoozed_until <= now()ordered bysnoozed_until. Migration 025 adds only the column and CHECK, so this query has no supporting index and degrades to a sequential scan asconversationsgrows. A small partial index keeps the scan bounded to actually-snoozed rows.⚡ Optional partial index for the due-snooze query
ALTER TABLE conversations ADD COLUMN snoozed_until timestamptz; ALTER TABLE conversations ADD CONSTRAINT conversations_snoozed_until_pending_only CHECK ( snoozed_until IS NULL OR status = 'pending' ); +CREATE INDEX conversations_snoozed_until_idx + ON conversations (snoozed_until) + WHERE snoozed_until IS NOT NULL;🤖 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 1347 - 1352, Update MIGRATION_025_CONVERSATION_SNOOZE to add a partial index on conversations.snoozed_until for rows where status = 'pending' and snoozed_until IS NOT NULL, supporting the filtering and ordering performed by listDueSnoozed.
🤖 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.
Nitpick comments:
In `@src/api/conversations.ts`:
- Around line 874-898: Strengthen snoozedUntil validation in
parsePatchStatusBody by requiring the string to match the documented ISO-8601
timestamp format before constructing a Date, while retaining the existing
pending-status requirement and invalid-date rejection.
In `@src/db/migrate.ts`:
- Around line 1347-1352: Update MIGRATION_025_CONVERSATION_SNOOZE to add a
partial index on conversations.snoozed_until for rows where status = 'pending'
and snoozed_until IS NOT NULL, supporting the filtering and ordering performed
by listDueSnoozed.
In `@src/mail/snooze-wake.ts`:
- Around line 62-78: The runSnoozeWake loop currently awaits each
setConversationStatus call sequentially; update it to process independent dueIds
with bounded concurrency, preserving the requireStatus condition, woken count,
report values, and per-row event behavior. Use an appropriate concurrency cap
rather than unbounded Promise.all.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: f25162f2-5d34-41a4-9e33-3ccdb937213e
📒 Files selected for processing (30)
specs/api/agent-inbox-v1.mdsrc/api/agents.test.tssrc/api/assistants.test.tssrc/api/conversations.tssrc/api/drafts.test.tssrc/api/index.test.tssrc/api/index.tssrc/api/router.test.tssrc/api/router.tssrc/api/saved-replies.test.tssrc/api/saved-replies.tssrc/api/webhooks.test.tssrc/composition/app.test.tssrc/composition/app.tssrc/composition/root.test.tssrc/composition/root.tssrc/db/migrate.test.tssrc/db/migrate.tssrc/db/postgres.test.tssrc/mail/ingest.test.tssrc/mail/send.test.tssrc/mail/send.tssrc/mail/snooze-wake.test.tssrc/mail/snooze-wake.tssrc/store/conversations.test.tssrc/store/conversations.tssrc/store/index.tssrc/store/saved-replies.test.tssrc/store/saved-replies.tsvercel.json
…sics (#91) PR #83 landed already-stale: main moved by nine PRs while it was in review, and it shipped one claim that contradicts the charter. - HT-71 operator guide was listed as an open PR under Next. #81 merged at 17:48, ~2h before #83 merged at 19:39, and docs/modules/ has been on main since. Moved to Done with its precision follow-up (#84). - Marketplace was listed under 'Not yet / deferred'. CHARTER §3/§4/§5 were amended the same day (HT-79, #86) to make it a launch-day component of Phase 3 — built now, proven as the dogfood install path. Removed from deferred; marketplace v1 spec (#87, draft) now leads Next. - Added inbox basics (HT-76/77/78, #90, migrations 24-25): saved replies & macros, snooze, send & close — shipped engine features with no STATUS line. - Added catalog reclassification (HT-75, #82): KB and end-user portal are paid, 71-module gap audit closed, open-core line restated. Passkeys stay core, reconciled in #85. - Added passkey login spec (HT-75, specs/auth/passkeys.md, draft.3) to Next. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Summary
The engine halves of the base-features wave (HT-76 / HT-77 / HT-78):
actions(setStatus/addTags/assignToSelf) — definitions only, applied client-side through existing endpoints, zero new mail/status semantics. List = any active Agent; mutations admin-only; strict action-key validation (spec-backed).snoozed_untillegal only withpending(CHECK-enforced, raw-SQL negative-tested); timer wake via a bounded per-minute cron throughsetConversationStatuswith arequireStatusrow-lock guard (TOCTOU-closed against concurrent Agent PATCHes) firingstatus_changedtransactionally; inbound mail wakes immediately, reported viamessage_received(reopened:true)uniformly with every other inbound reopen; plain-pending clears snooze; delete clears snooze (CHECK interplay).thenSetStatus:'closed'|'pending'in the same transaction — byte-identical mail proven with and without the param; net-change event semantics (documented:from= pre-operation status).agent-inbox-v1.md amended (§2/§4a/§4b/§4h) — including fixing its own self-contradiction on wake events that review caught.
Review trail
Sonnet-authored → Opus adversarial review: FIX-FIRST (1 MEDIUM — spec-vs-code event contradiction, resolved by correcting the spec to the uniform-reopen rule [orchestrator call, veto welcome]; 1 LOW wording+tests; 1 style nit accepted as spec-backed) → all applied, every wake path now event-stream-asserted.
Gates
typecheck 0 · lint 0 · test 0 — 1422 passed (+58). Migrations 024–025 are additive (new table, nullable column) — no old-code/new-schema window; they go to prod before merge-deploy per runbook practice. UI halves are design-first, separate tickets.
🤖 Generated with Claude Code
Summary by CodeRabbit