feat: release v0.3.0 with caching, retries, and top_* renames#13
Merged
Conversation
- 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.
…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.
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.
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.
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Linear
Part of EXT-1
Summary
rank_*tools totop_*and added cross-folder deduplication.SINCE/BEFORE) and size filters for searching.Changes
Features
search_messages.[Gmail]/Trashand permanent delete option.Message-IDfor drafts to prevent spam filter issues.top_sendersandtop_subscriptions.Performance & Reliability
top_*scans byMessage-IDacross folders.Refactors & Fixes
rank_*tools and lib functions totop_*.delete_list_idto matchList-Idexactly.SEARCHsupport and CR/LF command-injection guard.All Mailin account-wide scans for standard servers.CI/CD
.github/workflowsfor PR description, Muse PR reviews, and changelog updates.Test plan
top_*tools correctly deduplicate messages across multiple folders.SINCE,BEFORE,LARGER, andSMALLERfilters.[Gmail]/Trash.cargo testto validate caching logic and security guards pass.PR description generated by Agent