Skip to content

feat(engine): inbox basics — saved replies & macros, snooze, send & close (HT-76/77/78) - #90

Merged
zaridan merged 2 commits into
mainfrom
feat/ht-76-inbox-basics
Jul 19, 2026
Merged

feat(engine): inbox basics — saved replies & macros, snooze, send & close (HT-76/77/78)#90
zaridan merged 2 commits into
mainfrom
feat/ht-76-inbox-basics

Conversation

@zaridan

@zaridan zaridan commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

The engine halves of the base-features wave (HT-76 / HT-77 / HT-78):

  • Saved replies & macros (migration 024): mailbox-scoped canned responses; a macro is a saved reply with stored 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).
  • Snooze until date (migration 025): snoozed_until legal only with pending (CHECK-enforced, raw-SQL negative-tested); timer wake via a bounded per-minute cron through setConversationStatus with a requireStatus row-lock guard (TOCTOU-closed against concurrent Agent PATCHes) firing status_changed transactionally; inbound mail wakes immediately, reported via message_received(reopened:true) uniformly with every other inbound reopen; plain-pending clears snooze; delete clears snooze (CHECK interplay).
  • Send & Close: reply accepts 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

  • New Features
    • Added timed snoozing for pending conversations, including automatic and inbound-message wake-up.
    • Added “send and close” or “send and mark pending” options when replying.
    • Added saved replies and macros, with mailbox listing and admin-managed creation, editing, and deletion.
    • Added scheduled processing to wake conversations when snoozes expire.
  • Bug Fixes
    • Snooze data is cleared consistently when conversations are deleted or moved out of pending status.

zaridan and others added 2 commits July 19, 2026 13:10
… (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>
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds 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.

Changes

Conversation lifecycle

Layer / File(s) Summary
Snooze and reply API contracts
specs/api/agent-inbox-v1.md, src/api/conversations.ts
Adds snoozedUntil, thenSetStatus, snooze validation, and documented wake and event semantics.
Conversation persistence and status transitions
src/store/conversations.ts, src/db/migrate.ts
Stores snooze timestamps, enforces pending-only snoozes, supports guarded wake transitions, transactional reply status changes, and deletion cleanup.
Scheduled wake processing
src/mail/snooze-wake.ts, src/composition/*, vercel.json
Adds bounded due-snooze processing and a protected cron endpoint scheduled every minute.
Send-and-close integration
src/mail/send.ts, src/mail/send.test.ts
Passes thenSetStatus through new reply persistence without changing outbound message content or replay behavior.
Lifecycle validation
src/api/index.test.ts, src/store/conversations.test.ts, src/mail/*test.ts, src/db/migrate.test.ts
Covers snooze validation, wake paths, event behavior, idempotency, concurrency guards, batch limits, and cleanup.

Saved replies and macros

Layer / File(s) Summary
Saved reply storage
src/db/migrate.ts, src/store/saved-replies.ts, src/store/index.ts
Adds the mailbox-linked saved_replies table and typed CRUD persistence for macro actions and ordered replies.
Saved reply API
src/api/router.ts, src/api/index.ts, src/api/saved-replies.ts
Adds list/create/patch/delete routes with active-agent reads, admin-only writes, validation, mailbox scoping, and JSON responses.
Integration and endpoint tests
src/composition/root.ts, src/api/*test.ts, src/store/saved-replies.test.ts
Wires saved-reply dependencies through application and test setups and covers authorization, validation, ordering, patching, deletion, and cascade behavior.

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

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 52.94% 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 is clearly related to the main changes and concisely names the three major features plus the tracking tickets.
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-76-inbox-basics

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.

🧹 Nitpick comments (3)
src/api/conversations.ts (1)

874-898: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

snoozedUntil validation 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 before new 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 value

Consider bounded concurrency for the wake-pass loop.

Each setConversationStatus call operates on an independent row, so sequentially awaiting all dueIds (up to DEFAULT_BATCH_SIZE = 100) serializes round-trips that could run concurrently (e.g. Promise.all with 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 | 🔵 Trivial

Consider a partial index to support the every-minute due-snooze scan.

The snooze-wake cron runs listDueSnoozed (src/store/conversations.ts) every minute, filtering status = 'pending' AND snoozed_until IS NOT NULL AND snoozed_until <= now() ordered by snoozed_until. Migration 025 adds only the column and CHECK, so this query has no supporting index and degrades to a sequential scan as conversations grows. 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5ce08be and b898ba3.

📒 Files selected for processing (30)
  • specs/api/agent-inbox-v1.md
  • src/api/agents.test.ts
  • src/api/assistants.test.ts
  • src/api/conversations.ts
  • src/api/drafts.test.ts
  • src/api/index.test.ts
  • src/api/index.ts
  • src/api/router.test.ts
  • src/api/router.ts
  • src/api/saved-replies.test.ts
  • src/api/saved-replies.ts
  • src/api/webhooks.test.ts
  • src/composition/app.test.ts
  • src/composition/app.ts
  • src/composition/root.test.ts
  • src/composition/root.ts
  • src/db/migrate.test.ts
  • src/db/migrate.ts
  • src/db/postgres.test.ts
  • src/mail/ingest.test.ts
  • src/mail/send.test.ts
  • src/mail/send.ts
  • src/mail/snooze-wake.test.ts
  • src/mail/snooze-wake.ts
  • src/store/conversations.test.ts
  • src/store/conversations.ts
  • src/store/index.ts
  • src/store/saved-replies.test.ts
  • src/store/saved-replies.ts
  • vercel.json

@zaridan
zaridan merged commit 76a56ee into main Jul 19, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-76-inbox-basics branch July 19, 2026 20:49
zaridan added a commit that referenced this pull request Jul 19, 2026
…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>
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