Skip to content

feat(email): scheduled daily inbox briefing (#1608)#1923

Open
kovtcharov wants to merge 1 commit into
mainfrom
claudia/email-daily-briefing-1608
Open

feat(email): scheduled daily inbox briefing (#1608)#1923
kovtcharov wants to merge 1 commit into
mainfrom
claudia/email-daily-briefing-1608

Conversation

@kovtcharov

Copy link
Copy Markdown
Collaborator

Closes #1608.

The inbox pre-scan only answered when a user asked for it — there was no way to get the morning briefing #1608 describes without typing a prompt. Now the email sidecar carries an off-by-default briefing schedule and a scheduled trigger: a host scheduler fires POST /v1/email/briefing/run daily and gets back a kind: "email_briefing" envelope wrapping the exact email_pre_scan card the Agent UI already renders (reusing pre_scan_inbox_impl — no re-implemented scan), also persisted to ~/.gaia/email/briefing_latest.json so a consumer can pull the latest briefing without having been the live caller.

Scope note (per the issue's autonomy-engine dependency): the sidecar deliberately runs no timer and push delivery has no home until #555 lands — the seam to the autonomy engine is the single documented trigger plus GET/PUT /v1/email/briefing/schedule. The trigger re-checks enabled itself and answers 409 before any mailbox access, so a stale or misfiring scheduler can never scan a mailbox whose briefing is off; a corrupt schedule file is a 500 naming the file and the fix, never a silent fall-back to disabled. Contract SCHEMA_VERSION bumps 2.1 → 2.2 (additive, same MAJOR — existing 2.x npm clients keep working; the new endpoints are REST-only for now, typed client methods land with the autonomy-engine integration). No LLM-affecting path changes (no prompts/tools/parsing touched), so no eval run is required.

Test plan

  • python -m pytest hub/agents/python/email/tests/ tests/unit/agents/email/ — 630 passed (includes the two issue ACs in test_email_briefing.py: scheduled job → email_pre_scan envelope; disabled schedule → no briefing and the mailbox backend is never touched)
  • npx vitest run in hub/agents/npm/agent-email — 59 passed (TS SCHEMA_VERSION 2.2)
  • python util/lint.py --all — clean
  • Real-sidecar smoke (packaging/server.py on an ephemeral port): /version reports 2.2; schedule defaults disabled; run → 409 while disabled; PUT round-trips and persists; bad time → 422
  • Reviewer: git grep -n briefing hub/agents/npm/agent-email — README/SPEC/SKILL/CHANGELOG, specification.html, openapi.email.json (regenerated), and docs/guides/email.mdx all describe the same off-by-default / host-owns-the-timer semantics

The inbox pre-scan only ran when a user asked for it. Now a host scheduler
can turn it into a daily morning briefing: the sidecar persists an
off-by-default schedule (GET/PUT /v1/email/briefing/schedule) and serves a
trigger (POST /v1/email/briefing/run) that reuses pre_scan_inbox_impl and
returns a kind: email_briefing envelope wrapping the same email_pre_scan
card, also persisting it to ~/.gaia/email/briefing_latest.json as the
interim pull-based delivery surface until push delivery lands with the
autonomy engine (#555).

The sidecar deliberately runs no timer — scheduling stays with the host
(autonomy engine, cron, the GAIA UI scheduler), keeping the seam to #555
a single documented trigger. The trigger re-checks enabled itself and
answers 409 before any mailbox access, so a stale or misfiring scheduler
can never scan a mailbox whose briefing is off. A corrupt schedule file is
a 500 naming the file and the fix, never a silent fall-back to disabled.

Contract SCHEMA_VERSION bumps 2.1 -> 2.2 (additive, same MAJOR, so 2.x npm
clients keep working; TS const follows). The new endpoints are REST-only
for now — typed client methods land with the autonomy-engine integration.
@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

This adds an off-by-default scheduled inbox briefing to the email sidecar: three REST endpoints (GET/PUT schedule, POST run) plus a small scheduled-job module that reuses the existing pre-scan path rather than re-implementing a scan. The standout is discipline — a disabled schedule is a hard 409 before any mailbox is touched, a corrupt schedule file is a loud 500 that names the file and the fix (never a silent fall-back to "disabled"), and the contract bump is genuinely additive (2.1 → 2.2, MAJOR unchanged) so existing 2.x npm clients keep working.

Documentation is synced across every surface the CLAUDE.md rule cares about — README, SPEC, SKILL, CHANGELOG, openapi.email.json, specification.html, and the docs-site guide all describe the same host-owns-the-timer / off-by-default semantics. Tests cover both issue ACs (scheduled job → email_pre_scan envelope; disabled → mailbox never touched, enforced by an exploding fake backend) plus persistence and the REST surface. No blocking issues.

One low-severity note below (a concurrent-/run temp-file race); safe to merge as-is.

🔍 Technical details

Correctness verified

  • run_briefing (api_routes.py:391) calls get_prescan_backend() directly in the body after the enabled check rather than via Depends(...). This is the right call: it keeps the 409 strictly ahead of mailbox resolution, and since get_prescan_backend (api_routes.py:1255) is a plain function that raises HTTPException, the documented 0→503 / 2+→400 resolution still propagates when enabled. The test fixture monkeypatches api_routes.get_prescan_backend, which the body-level call picks up correctly.
  • Error mapping mirrors prescan/search exactly (403/503/502), with an added OSError → 500 for the persist step — consistent with the no-silent-fallback rule.
  • EmailBriefingResult.model_validate(out) round-trips cleanly: schedule is a model_dump() dict and pre_scan is the same known-good envelope prescan already validates.

🟢 Minor — concurrent /run shares one fixed temp filename (briefing.py:595, and save_schedule at briefing.py:546)

Both writers use a fixed .tmp sibling (p.name + ".tmp"). Two overlapping writers (e.g. a scheduler tick racing a manual trigger) can clobber the shared temp and leave one replace() hitting a missing source → OSError → 500. Very low probability in a single-user daily sidecar, so non-blocking — but a unique suffix removes it entirely:

    tmp = lp.with_name(f"{lp.name}.{os.getpid()}.tmp")

(same pattern applies to save_schedule; add import os if you take it). Purely defensive — fine to defer.

Strengths

  • True reuse, not a reimplementationrun_scheduled_briefing wraps pre_scan_inbox_impl, so the briefing card is byte-compatible with prescan() and renders through the same UI card. Exactly the seam the issue asked for.
  • Fail-loud persistence — atomic write-then-rename, absent-file-is-default, corrupt-file-is-a-named-500. test_corrupt_schedule_fails_loudly and the _ExplodingBackend disabled-path test lock the two guarantees down at the unit seam.
  • Docs parity — the schema bump and new endpoints land in all six doc surfaces + the docs-site guide in one PR, which is precisely the bundled-docs discipline CLAUDE.md calls out.

@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): scheduled daily inbox briefing

1 participant