Skip to content

feat: release v0.3.0 with caching, retries, and top_* renames#13

Merged
WeekendSuperhero merged 83 commits into
mainfrom
weekendsuperhero/fix_ref
Jul 21, 2026
Merged

feat: release v0.3.0 with caching, retries, and top_* renames#13
WeekendSuperhero merged 83 commits into
mainfrom
weekendsuperhero/fix_ref

Conversation

@WeekendSuperhero

@WeekendSuperhero WeekendSuperhero commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Linear

Part of EXT-1

Summary

  • Renamed rank_* tools to top_* and added cross-folder deduplication.
  • Implemented scan caching and transient IMAP connection retries.
  • Added date (SINCE/BEFORE) and size filters for searching.
  • Added permanent delete option and routed Gmail deletes to Trash.
  • Updated GitHub workflows for PR reviews and changelog generation.

Changes

Features

  • Added date and size filters to search_messages.
  • Added Gmail-aware delete routing through [Gmail]/Trash and permanent delete option.
  • Auto-generate Message-ID for drafts to prevent spam filter issues.
  • Exclude account's own address from top_senders and top_subscriptions.

Performance & Reliability

  • Added connection retry with backoff for transient login/connect failures.
  • Wired header scans and special-use mailbox resolution to cache.
  • Deduplicated top_* scans by Message-ID across folders.

Refactors & Fixes

  • Renamed rank_* tools and lib functions to top_*.
  • Fixed delete_list_id to match List-Id exactly.
  • Added UTF-8 SEARCH support and CR/LF command-injection guard.
  • Bypassed All Mail in account-wide scans for standard servers.

CI/CD

  • Added/updated reusable .github/workflows for PR description, Muse PR reviews, and changelog updates.

Test plan

  • Verify top_* tools correctly deduplicate messages across multiple folders.
  • Test IMAP transient connection failures trigger the retry mechanism up to 3 times.
  • Search messages using the new SINCE, BEFORE, LARGER, and SMALLER filters.
  • Delete a message on a Gmail account to confirm it routes to [Gmail]/Trash.
  • Run cargo test to validate caching logic and security guards pass.

PR description generated by Agent

- Add annotations.title to all 21 tools; idempotent_hint on add/remove_flags;
  open_world_hint=false on list_accounts
- Fix server identity: announce agentmail/<version> instead of rmcp/1.7.0
- Map validation and not-found errors to -32602 invalid_params via to_mcp_error
- Inline 17 nested schema types so tool schemas contain no $defs/$ref
- Add schema/title/DESTRUCTIVE_TOOLS regression tests and 6 in-process MCP
  integration tests over serve_on (raw JSON-RPC, no network)
- Explicit rmcp features; ANSI-free stderr logs when not a terminal
- Docs: drop stale rmcp version pins, add Inspector section, CHANGELOG entries
Mechanical move, no behavior change (21 tools / 6 prompts / capabilities
pinned by existing tests): mod.rs (server struct, handler, serve fns, tests),
args.rs, tools_read.rs + tools_write.rs (split #[tool_router] routers combined
via ToolRouter::add), prompts.rs, tasks.rs.
Unbounded-by-default rank_senders/rank_unsubscribe/rank_list_id responses
could be huge on large mailboxes. MCP callers now get 100 entries unless
they ask for more; CLI behavior is unchanged.
New CancelFn callback (mirrors ProgressFn, keeps the core library free of
tokio_util/MCP types) threaded through all scan/delete paths and checked at
mailbox and fetch-chunk boundaries. MCP tools build it from the request's
CancellationToken, so notifications/cancelled and transport shutdown stop
scans at the next boundary. tasks/cancel keeps using JoinHandle::abort().

Library API: scan/delete fns gained a cancel parameter (breaking for lib
consumers; next release should be 0.3.0).
Resources: two templates — email://{account}/{mailbox}/{uid} renders a
message as markdown; the /source variant returns raw RFC822. This is the
missing fetch-one-message-by-UID primitive over MCP (get_messages_by_uid was
CLI-only). Account/mailbox segments are percent-encoded ('/' in mailbox
names as %2F); resources/list is intentionally empty (template-only
discovery). Missing UID → -32002, malformed URI / unknown account → -32602.

Completions: account args complete instantly from config; mailbox args run
an IMAP LIST scoped to the completion context's account (or the default
account), never erroring on failure. Covers all 6 prompts and the email://
templates (mailbox values percent-encoded for template substitution).
README and MCP.md gain MCP Resources and MCP Completions sections (URI
templates, encoding rules, error-code table) plus notes on cooperative
cancellation and the rank_* default limit; CHANGELOG entries for all of it
including the breaking library-API note (next release 0.3.0).
Stringifying every error into AgentmailError::Other defeated both the MCP
error-code mapping and the connection pool's is_connection_error() retry
(Imap(Io)/ConnectionLost never survived as typed variants). Errors now
convert via Into<AgentmailError>, keeping Bad/No/Io distinguishable.
SEARCH queries with non-ASCII text were sent without CHARSET, which RFC
3501 makes invalid (quoted strings are 7-bit) — servers rejected or
silently mismatched them. Queries now get a CHARSET UTF-8 prefix when any
criterion is non-ASCII (UTF-8-in-quoted; literals are impossible with
async-imap's one-shot command writer; accepted by all major servers and
required by IMAP4rev2). Server rejections (BAD / NO [BADCHARSET]) map to a
new InvalidSearch error → -32602 over MCP.

Security: search text containing CR/LF was written to the wire unvalidated
by async-imap, allowing IMAP command injection — quoted() now rejects it.
Per-account ServerCaps cached in the connection pool (one CAPABILITY round
trip per process). Gates:
- move/trash-delete use UID MOVE when advertised, else COPY + \Deleted +
  UID EXPUNGE (RFC 6851 fallback for servers without MOVE)
- permanent delete refuses on servers lacking UIDPLUS, where plain EXPUNGE
  would purge unrelated \Deleted messages
- list_mailboxes omits the RECENT STATUS item on IMAP4rev2-only servers
  (RFC 9051 removed it; rev2 servers reply BAD otherwise)
lettre's build() auto-adds Date but not Message-ID; drafts shipped without
one, breaking threading and tripping some spam filters. Calling
.message_id(None) makes lettre generate a unique <uuid@host>. Tests assert
both Date and Message-ID are present.
New DeleteMode enum threaded through delete_messages, delete_by_sender,
delete_list_id, and unsubscribe_message. A flat permanent: bool arg on each
tool (default false) maps to DeleteMode::Permanent, which skips trash
resolution and flags \Deleted + UID EXPUNGE directly. Responses echo the
permanent flag. CLI is unaffected (no delete subcommands).
find_trash/find_drafts re-issued a LIST on every delete and draft call.
Trash and Drafts are now resolved together by a single LIST, cached per
account with a 5-minute TTL, and invalidated when a mailbox is created.
Resolution logic extracted into pure resolve_trash_from/resolve_drafts_from
functions with unit tests covering RFC 6154 roles, known names, substring
fallback, and the not-found case.
rank_senders/rank_unsubscribe/rank_list_id re-downloaded every message
header on every call (~100 round trips for a 20-mailbox account). Each
mailbox is now validated with one STATUS (UIDVALIDITY/UIDNEXT/MESSAGES):
unchanged mailboxes reuse cached rows (zero fetches), pure-append tails
fetch only the new UIDs, and anything ambiguous re-scans. Cache lives in
Agentmail, keyed by (account, mailbox), invalidated after delete/move.
Correctness is self-checking via STATUS; the new scan_cache module holds a
pure, unit-tested decision function (8 cases).
group_by_sender/group_by_list/group_by_list_id were the library names behind
the rank_senders/rank_unsubscribe/rank_list_id tools — a needless mismatch.
Renamed to rank_senders/rank_unsubscribe/rank_list_id for 1:1 correspondence.
Response types already used the Rank* names.
CHANGELOG (→0.3.0), MCP.md, and README cover the permanent delete flag,
UID MOVE/UIDPLUS gating and the rev2 RECENT note, UTF-8 search, draft
Message-ID, special-use and rank-scan caching, and the breaking library API
changes (DeleteMode, cancel param, rank_* renames, Result-returning query
builder).
Step 6 converted rank_senders and rank_unsubscribe to cached_list_rows but
missed rank_list_id (its match arm differed by a comment, so the replace
skipped it), leaving it on the uncached full-refetch path. Now uses the same
STATUS-validated cache as the other two rank tools.
list_scannable_mailbox_names skipped the RFC 6154 \All role but the
string fallback (servers that don't advertise special-use) didn't catch
[Gmail]/All Mail, so it was scanned and every message double-counted.
Extracted the fallback into is_skippable_scan_name + a tight is_all_mail_name
(exact 'all mail' / '[gmail]/all mail' / '*/all mail' — never a bare
contains('all') that would skip user folders). Unit-tested.
HEADER List-Id search is substring-only (RFC 3501), so delete_list_id with
"news" could delete "newsletter" lists. Now confirms the exact List-Id
per candidate via fetch_list_ids_for_uids before deleting — mirroring the
substring-then-post-filter pattern delete_by_sender already uses. The value
round-trips exactly from rank_list_id's listId output.
The same logical message appears in multiple IMAP mailboxes (a Gmail message
under 3 labels is in 3 mailboxes with 3 different UIDs), so account-wide
rank_senders/rank_unsubscribe/rank_list_id counted it once per folder. Each
scan now carries the Message-ID (added to the header fetch and the row types)
and a per-call seen-set dedups across mailboxes via scan_cache::first_seen.
Counts now reflect unique messages; messages without a Message-ID can't be
deduped and are counted each. Behavior change for label-based providers.
…mantics

CHANGELOG/MCP.md/README: rank_* dedup by Message-ID across folders (counts
are unique messages), account scans skip All Mail even without the \All
attribute, delete_list_id matches List-Id exactly, search filters are
AND-combined case-insensitive substrings, and date/size/OR keys are noted as
unsupported.
rank_senders → top_senders, rank_unsubscribe → top_subscriptions,
rank_list_id → top_mailing_lists (lib fns, MCP tool names, CLI subcommands,
response/arg types, prompts, instructions). 'top' reads as a volume-sorted
summary (who emails you most) rather than an action. Breaking for MCP/CLI
callers; folds into unreleased 0.3.0.
…tions

The Sent folder is scanned by the top-N tools, so a user's own sent mail
ranked them as a top sender of their own inbox. top_senders and
top_subscriptions now skip rows whose From matches the account's own
address. Targeted to the ranking tools (via own_addresses) so the Sent
folder stays visible to find_attachments / list_flags.
Adds SINCE/BEFORE (YYYY-MM-DD, by server internal date) and LARGER/SMALLER
(bytes) to SearchCriteria, the query builder, and search_messages args —
enabling 'older than', 'newer than', and 'bigger than' cleanup queries
server-side. Bad date strings map to -32602. Unit-tested.
On Gmail, \Deleted + EXPUNGE in a label folder only removes that label —
the message survives in All Mail — so in-place expunge silently fails to
delete. ServerCaps.is_gmail() (X-GM-EXT-1) now routes all deletes, including
permanent, through [Gmail]/Trash (which removes the message from every label;
Gmail purges Trash on its own). bulk_delete_messages refuses in-place expunge
on Gmail as a safety net when Trash can't be resolved.
CHANGELOG/MCP.md/README: rank_* → top_* throughout (incl. CLI top-senders/
top-subscriptions), new search date/size filters with the OR/NOT-deferral
rationale (recursive query tree would reintroduce schema $ref), top-N tools
exclude the account's own address, and Gmail deletes route through
[Gmail]/Trash.
iCloud and Gmail routinely reply [AUTHENTICATIONFAILED] to a login, then
accept the same credentials moments later (especially under rapid/concurrent
logins from the startup pre-warm). We never retried connect/auth — the pool's
retry only covers mid-operation drops after a successful acquire — so a
one-off transient surfaced to the host as -32603, which is why a tool call
failed twice before working.

connect() now makes up to 3 attempts (backoff 0.4s, 0.8s) on transient
connect errors and auth NO responses, each a fresh connection. The count is
deliberately tiny: a genuinely wrong password still fails fast and can't
hammer the server into a lockout. Resolution failures (missing password
source) are unaffected — they never reach the server.
… a valid login twice (`[AUTHENTICATIONFAILED]`), and because we didn't retry auth internally, each rejection surfaced to your host as `-32603` — so the host retried, and the third attempt landed once the provider settled.

**Why it wasn't a credential problem:** the error came back as an IMAP `NO` *from the server*, which means a real password was sent and rejected. If the password source (keyring/`cmd`/env) had failed to resolve, you'd have seen a `Credential` error (`-32602`) that never reaches the server. A wrong password fails every single time; "rejected twice, then accepted the same credentials" is a server-side transient — classic iCloud/Gmail behavior, and more likely when several logins fire in quick succession (our startup pre-warm opens one connection per account, and your first tool calls open more).

**The gap I fixed:** our pool's `with_session_retry` only re-runs an operation after a *successful* `acquire()` — it never retried `connect()` itself, and `is_connection_error()` treats an auth `NO` as non-retryable. So a one-off transient had no internal absorption. `connect()` now makes up to 3 attempts with backoff (0.4s, 0.8s), each a fresh connection, on transient connect errors and auth `NO` responses.

**The tradeoff, deliberately:** the retry count is tiny (2 retries) on purpose. We *can't* distinguish a transient rejection from a genuinely wrong password — both are `[AUTHENTICATIONFAILED]` — so retrying trades "absorb a blip" against "more login attempts when the password is actually wrong." Three attempts is well under any provider's lockout threshold, and a truly wrong password still fails in ~1.2s. Password-*resolution* failures (missing keyring entry, broken `cmd`) are untouched — they short-circuit before any server contact.

If you'd rather it not retry auth at all (e.g. you're on a corporate server with an aggressive lockout policy), say so and I'll restrict the retry to connection errors only and leave auth `NO` as fail-fast.
@WeekendSuperhero WeekendSuperhero added the muse:review Request a Muse review round (re-add to re-run) label Jul 21, 2026
@github-actions github-actions Bot added muse:reviewing Muse review round in progress tests:sufficient Muse test review: coverage adequate for this diff muse:error A Muse review agent failed — see workflow run and removed muse:review Request a Muse review round (re-add to re-run) muse:reviewing Muse review round in progress labels Jul 21, 2026
…UTH2 token

Turns a client id into an access token via the installed-app loopback
flow: PKCE S256 (OAUTH_NO_PKCE=1 for Yahoo, which lacks it), CSRF state,
a one-shot 127.0.0.1 listener, code exchange (client secret via Basic
auth when present — accepted by both Google and Yahoo). Prints the
access token to paste into password.raw and the refresh token for a
password.cmd helper. Gmail endpoints/scope are the defaults; Yahoo/AOL
via OAUTH_AUTH_URL/OAUTH_TOKEN_URL/OAUTH_SCOPE. dev-deps sha2 + rand.
@github-actions github-actions Bot removed muse:error A Muse review agent failed — see workflow run tests:sufficient Muse test review: coverage adequate for this diff labels Jul 21, 2026
Yahoo/AOL reject the Google conventions access_type=offline / prompt=
consent (and return a refresh_token without them), so only send them for
Google endpoints (or OAUTH_OFFLINE=1). Document that AOL/Verizon
mailboxes auth through api.login.aol.com while Yahoo mailboxes use
api.login.yahoo.com, and that the mail scope is confidential-client
(secret via Basic auth, no PKCE).
Make "are we holding the connections we open?" answerable with numbers,
not vibes. ConnectionPool now keeps always-on atomic counters
(fresh_logins, idle_reuses, keepalive_pings/drops) updated on every
acquire and keepalive tick regardless of log level, exposed via
Agentmail::connection_stats() -> ConnectionStats. Every accepted fresh
LOGIN logs at info! (visible in release, where debug! compiles out) so
the rare expensive event stays greppable; reuse and keepalive detail log
at debug!. examples/pool_holds.rs runs two reads with an idle gap and
prints a HELD / NOT-HELD verdict from the counters, isolating library
holding from app pool-lifecycle. Test locks the zeroed baseline.
The per-account semaphore already queues work beyond max_connections, but
the cap is a *concurrency* cap: on a cold pool the first max_connections
concurrent requests each open a fresh LOGIN. For AOL/Yahoo, whose limiter
is velocity-sensitive, opening a second connection means a concurrent
login that trips [LIMIT]. Default that family to one connection so all
concurrent work queues on a single held session instead of racing logins.

New pub fns is_login_rate_limited_host / recommended_max_connections carry
the policy; account_max_connections applies it as the host default (an
explicit config value still wins). Route the semaphore through the same
default so cap and queue agree.
@WeekendSuperhero WeekendSuperhero added the muse:review Request a Muse review round (re-add to re-run) label Jul 21, 2026
@github-actions github-actions Bot added muse:reviewing Muse review round in progress security:flagged Muse security review: blocking findings open tests:sufficient Muse test review: coverage adequate for this diff muse:error A Muse review agent failed — see workflow run and removed muse:review Request a Muse review round (re-add to re-run) muse:reviewing Muse review round in progress labels Jul 21, 2026
Fold the Limited and UID-Mode pools into one for UIDONLY-capable accounts
(Yahoo/AOL): a fresh connection ENABLEs UIDONLY at connect and stays UID
Mode for life, so a single held connection serves rankings, reads, AND
sweeps with no mid-life Limited<->UID switch — and each switch was another
LOGIN on rate-limited providers. Safe because AgentMail issues only UID
commands (uid_fetch/uid_search/uid_store/...); the sole non-UID `store` is
an atomic, not IMAP STORE.

- pool: uidonly_born flag + set_uidonly; maybe_enable_uidonly (best-effort,
  Limited fallback on any hiccup); pop_idle_any reuses the UID store first
  and carries the mode so release routes correctly; acquire() marks fresh
  connections. is_uid_mode() getter.
- lib: builder .uidonly(bool) override; build() defaults it to
  header_cache.is_persistent() so the degraded no-cache path stays classic
  Limited/windowed and unchanged; acquire_uid_scan skips ENABLE for
  already-UID sessions; uid_page_size helper.
- tests: born-UID gate follows cache persistence + override; flag wiring;
  uid_page_size; a UID-Mode session serves uid_fetch on the read helper
  (ENABLE precedes the read, UIDFETCH parses). 280 pass.
- examples/uid_mode.rs: runs a UID ranking + ordinary reads on one account
  and prints fresh_logins==1 as the no-mode-switch proof.
@github-actions github-actions Bot removed muse:error A Muse review agent failed — see workflow run security:flagged Muse security review: blocking findings open tests:sufficient Muse test review: coverage adequate for this diff labels Jul 21, 2026
The MCP tools act for a model that reads untrusted email, so a
prompt-injection payload could make create_draft attach a sensitive local
file (exfiltration via the saved draft) or make download_attachments write
attacker bytes into a sensitive directory. Confine both at the trust
boundary.

SEC-3-1 / SEC-3-2: new src/mcp/file_access.rs FileAccessPolicy confines
every tool-supplied path to a sandbox root (AGENTMAIL_FILE_ROOT, default
~/.agentmail/files): reads must resolve to an existing file inside the
root, writes are created inside it, and `..` traversal or symlink escapes
are rejected (root + candidate canonicalized, prefix-checked). create_draft
confines each attachment path before read; download_attachments confines
output_dir before the write. Deny-by-default; the operator widens on
purpose via the env. The library API is unchanged — confinement lives at
the MCP boundary, so trusted programmatic callers are unaffected.

Tests: file_access read/write confinement incl. absolute-escape, `..`, and
unix symlink-escape; extract_message_id (present/absent/round-trip);
guess_content_type (known exts + octet-stream fallback); a multi-pass
windowed-drain test (2 productive passes backfill + 1 empty, counts
aggregate, SELECT==passes+1). 285 pass.
@WeekendSuperhero WeekendSuperhero added the muse:review Request a Muse review round (re-add to re-run) label Jul 21, 2026
@github-actions github-actions Bot added muse:reviewing Muse review round in progress and removed muse:review Request a Muse review round (re-add to re-run) labels Jul 21, 2026
…l errors

A stale-UID call (e.g. replaying UID 434755 from an old ranking sample)
returned MessageNotFound as a JSON-RPC protocol error (-32602). The rmcp
client surfaces that as ServiceError::McpError, which the gateway
classifies as CallAttempt::Wire -> BackendConnectionFailed — so a single
bad tool call was reported as the WHOLE AgentMail backend going down
("Gateway error: backend connection failed: AgentMail: Mcp error: -32602:
Message not found: UID 434755").

Per the MCP spec, tool-EXECUTION failures belong in the result with
isError: true so the LLM sees and reacts to them (here: re-run the
ranking) — protocol errors are for malformed requests. All 23 tool error
arms now return Ok(tool_error_result(&e)) (isError result) instead of
Err(to_mcp_error(&e)). Request-validation guards (missing/unknown/oob
params) stay -32602 protocol errors; resource reads keep protocol errors
(no isError channel). Tests updated: unknown-account, consent-required,
and valid-policy-connect-fail now assert isError results; a wire.rs unit
test pins the contract. 286 pass.
cargo:rerun-if-changed=.git/refs watched the DIRECTORY, whose mtime does
not change when a ref file's contents change — so a new commit on the
active branch left AGENTMAIL_BUILD_SHA baked at the previous commit
(defeating the deploy-skew observability). Resolve .git/HEAD to its ref
file and watch that directly; also watch packed-refs; a detached HEAD is
covered by the HEAD watch. (REV-6-1)

Also: fix the doubled space in the AgentMuse pr-title trigger phrase
(REV-6-2) and add the synchronize event so an opted-in PR description
stays current as commits land (REV-6-3).
Replace the let-chain with a single flat `if let` over `.ok().and_then(..)`.
let-chains are stable (Rust 1.88, edition 2024) and compiled fine here, but
a build script should not depend on edition-gated syntax — this form is
portable to any toolchain. A plain nested `if let` isn't an option: clippy's
collapsible_if (under -D warnings, which does lint build scripts) rejects it.
The flat combinator satisfies both. Ref-resolution logic and the embedded
AGENTMAIL_BUILD_SHA are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

muse:approved All Muse review agents positive review:approved Muse code review: no blocking findings security:clear Muse security review: no blocking findings tests:sufficient Muse test review: coverage adequate for this diff

Development

Successfully merging this pull request may close these issues.

1 participant