Skip to content

feat(email): follow-up tracking — flag sent mail awaiting a reply#1922

Open
kovtcharov wants to merge 5 commits into
mainfrom
claudia/task-1861b2ad
Open

feat(email): follow-up tracking — flag sent mail awaiting a reply#1922
kovtcharov wants to merge 5 commits into
mainfrom
claudia/task-1861b2ad

Conversation

@kovtcharov

Copy link
Copy Markdown
Collaborator

Closes #1606

Why this matters

Before: an email you sent that never got a reply just disappeared — the agent had no way to resurface a dropped thread, the inbox's biggest silent failure mode. After: asking "which of my sent emails are still waiting on a reply?" runs a new read-only check_followups tool that scans the Sent folder of every connected mailbox and returns each thread still awaiting an inbound reply past a configurable window (default 3 days) — recipient, subject, and age, most overdue first. Detection only: the tool never drafts or sends a nudge (sends stay confirmation-gated per #1264; autonomous follow-ups remain #555's scope), and that read-only guarantee is locked in by tests both dynamically (backend call log) and statically (module source).

Also fixes a latent trap this feature would have stepped on: the Outlook backend silently mapped a SENT label to the inbox folder, so a Sent-folder scan against Outlook would have quietly scanned the wrong mail. It now routes to Graph's sentitems, and the detector accepts both Gmail's epoch-millis and Outlook's ISO-8601 internalDate.

Test plan

  • python -m pytest hub/agents/python/email/tests/test_email_followups.py — 15 passed: reply-after-send suppresses the flag, no-reply past window flags (message_id/recipient/subject/age), within-window doesn't, reply before the send doesn't count, one flag per thread, self-addressed skipped, ISO-8601 dates, sort order, loud errors on invalid window, read-only guard (allowed backend calls only + no send/draft references in module source), tool envelope + mailbox tagging.
  • python -m pytest tests/unit/agents/email/ tests/unit/agents/test_email_agent.py tests/unit/email/ hub/agents/python/email/tests/ — 747 passed; the two failures (test_no_unexpected_tool_set, test_set_priority_sender_rejects_invalid_email) fail identically on a clean main checkout in this environment (machine-local memory/keyring state), verified via git stash.
  • python util/lint.py --all --fix — clean (black/isort/pylint/flake8 pass).
  • Live-mailbox smoke (real Gmail + Lemonade): send a message, wait past a 1-day window, ask the agent "what's still unanswered?" — not runnable in this environment; flagging for a hardware pass before release.

Plan for triaging personal Gmail through the Agent UI with two
interchangeable sidecar backends behind one fixed REST seam: a frozen
npm binary for users and a hot-reloading uvicorn-from-source server for
developers. Both run the same agent code against the same local Google
grant, so personal email works identically in each and improving the
agent is a one-file edit with no freeze/publish/patch loop.
The first draft assumed the renderer calls a fixed /v1/email REST URL and
that an Electron-main sidecar manager would host it. Code review found the
UI surfaces email through the chat agent loop + SSE cards (the frontend has
no email REST client), and the agent already runs in-process in the Python
backend reading ~/.gaia/connectors/grants.json.

Reframe: the seam is the chat agent's email-tool layer and the host is the
Python UI backend (no Node backend needed). Both modes proxy over the same
HTTP path to a sidecar on :8131 — frozen binary for users, uvicorn --reload
from source for devs — keeping process isolation identical across modes and
preserving the existing SSE card pipeline.
Fold in the two product requirements and resolve every open item from the
architecture review:

- Dogfooding: user mode runs the exact Hub-published frozen binary (pinned via
  binaries.lock.json), so the Agent UI validates the shipped product. This is the
  rationale the sidecar earns over an editable in-process install.
- Lightweight UI: the sidecar is lazy-fetched on first email use, one platform
  binary only; core ships no email deps.
- Resolve open questions: use a proxy AGENT reusing agent_type=email machinery;
  remove the redundant in-process /v1/email mount (#1768) so the UI has one backend.
- Review fixes: own the dev-mode enabler (module-level app = build_app()), reuse the
  npm fetch SHA/lock integrity check instead of porting it to Python, ephemeral
  per-instance port (no fixed-8131 multi-instance clash), single-writer grant model
  (OAuth stays on backend; sidecar reads only), exact current-contract table.
- Sequence behind the in-flight REST build-out (pre-scan + #1779/#1780/#1781) and
  flag the #1767 in-process decision must be superseded with @itomek sign-off.
…tion)

itomek closed #1767/#1785 because mixing an npm package requiring Node.js into the
Python-backed Agent UI is too dirty. Remove that entirely: the Python backend fetches
the binary itself (verified SHA-256 download, no npx), spawns it, and proxies over
HTTP. No Node runtime, no npm package in the UI's path. The npm package remains the
external Node-integrator channel only.
)

The unanswered email you already sent is the inbox's biggest silent
failure mode — until now the agent had no way to surface it. A new
read-only check_followups tool scans the Sent folder of every connected
mailbox and flags threads whose latest message is still the user's own
outbound mail past a configurable window (default 3 days,
EmailAgentConfig.followup_window_days), returning message id, recipient,
subject, and age — most overdue first. Detection only: it never drafts
or sends a nudge; sends stay confirmation-gated (#1264) and autonomous
follow-ups remain #555's scope.

The Outlook backend gains a SENT branch in list_messages (Graph
sentitems folder) — an unknown label previously fell through to the
inbox folder, which would have made a Sent-folder scan silently read
the wrong mail — and the detector accepts both epoch-millis and
ISO-8601 internalDate values so Gmail and Outlook threads age
identically.
@github-actions github-actions Bot added documentation Documentation changes tests Test changes agent::email Email agent changes labels Jul 1, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions — no blocking issues.

This PR adds a read-only check_followups tool that scans the Sent folder and flags outbound threads nobody has replied to past a configurable window (default 3 days), and fixes a latent Outlook bug where a Sent-folder scan would have silently hit the inbox. The read-only guarantee is enforced by tests both at runtime and statically against the module source — a nice touch for a feature that lives one step away from the send path.

The one thing worth weighing before release: because the Sent folder is scanned newest-first and capped (default 50, max 200), a user with high send volume will have their oldest — i.e. most overdue — sends fall off the end of the scan. For a feature whose whole pitch is "resurface the dropped thread you forgot about," that trade-off deserves a line of user-facing docs. It's bounded and disclosed in the tool's docstring, so it's not a blocker.

The live-mailbox smoke test is correctly flagged as unrun (needs a hardware pass) — appropriately disclosed, not a merge blocker for code review.

🔍 Technical details

🟢 Minor / nits

1. Newest-first scan + max_sent cap can drop the most-overdue sends (followup_tools.py:597, 543). list_messages(label_ids=["SENT"], max_results=max_sent) returns newest-first, so once a mailbox's Sent volume exceeds the budget, the oldest (most overdue) threads — exactly the ones this feature exists to surface — are the ones excluded. Bounded and documented via max_sent, so not blocking, but worth a sentence in docs/guides/email.mdx so users know a very old dropped thread may need a larger max_sent (e.g. "what's unanswered — scan my last 200 sent?").

2. Recipient is derived from To only (followup_tools.py:630-636). A message sent solely to CC recipients (empty To) yields an empty recipients list and is skipped as "note-to-self" — a false negative. Edge case, low priority, but a one-line note or a CC fallback would close it.

3. Outlook Sent scan orders by receivedDateTime (outlook_backend.py:422). For the sentitems folder, sentDateTime is the semantically correct sort key; receivedDateTime happens to track closely on sent items so results are effectively right. Minor.

Process note (not a code issue): docs/plans/email-sidecar-agent-ui.md is a design doc for epic #1767, unrelated to #1606 — bundling it here is harmless (markdown only) but slightly widens the PR's scope beyond follow-up tracking.

Strengths

  • Read-only guarantee is proven, not asserted. test_detector_touches_no_send_path checks the backend call log against an allowlist, and test_module_references_no_send_path greps the module source for send/draft calls — belt and suspenders for a guardrail-sensitive feature.
  • Fail-loudly throughout, matching the GAIA convention. Missing internalDate, missing threadId, empty thread, no user email, and non-positive window_days all raise with actionable messages (followup_tools.py:499, 604, 615, 591, 578) — no silent fallbacks. Config validation mirrors it (config.py:393).
  • Timestamp handling is robust. _timestamp_ms accepts Gmail epoch-millis and Outlook ISO-8601 and raises on anything else rather than guessing an age; _recipient_addresses uses email.utils.getaddresses so a comma inside a quoted display name doesn't shed a garbage recipient (both covered by tests).
  • Consistent with existing patterns. The mixin follows read_tools.py exactly — same agent._backends iteration, _remember_message_mailbox provenance tagging, _envelope_ok/_envelope_err envelopes — and is registered alongside the other read tools with the system prompt, conversation starters, and test_email_agent.py tool-set updated together.
  • Doc sync is right. email.mdx, specification.html, and the npm CHANGELOG are updated; the CHANGELOG correctly notes the REST/MCP contract and SCHEMA_VERSION are unchanged (agent-loop-only tool), so the sidecar SPEC/SKILL/README legitimately stay untouched.

@kovtcharov kovtcharov enabled auto-merge July 1, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent::email Email agent changes documentation Documentation changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(email): follow-up tracking — flag sent mail awaiting a reply

2 participants