Restrict Gmail Thread Searches to Recent Mail
Rewrites every Gmail thread search on the way in to prepend a newer than: recency term, so Gmail itself returns only recent correspondence.
On this page
What it does
Rewrites every Gmail thread search on the way in to prepend a
newer_than: recency term, so Gmail itself returns only recent
correspondence. With the default max_age_days := 14, a search for
from:alice@example.com reaches Gmail as
newer_than:14d (from:alice@example.com). Searches are never refused —
they come back scoped.
The rewrite is unconditional: it is applied whether or not the caller
already supplied a newer_than: term, so an agent cannot widen the window
by asking for older mail (Gmail ANDs the terms, and the narrower window
wins). The caller's own query is wrapped in parentheses so a top-level
OR cannot escape the recency constraint. A missing, blank, or
non-string query is replaced by the recency filter alone, so a malformed
request yields the tightest filter rather than an unconstrained search.
Companion policy
This policy governs searching only. Fetching a known conversation by ID
takes no query, so gmail/filter-dormant-threads holds the same window on
the direct-read path (get-thread / get-message). The two are designed
to be attached together, and each carries its own copy of max_age_days —
change them in step.
The split exists for a subtle reason: a search-threads response carries
only a truncated preview of each thread's messages (the oldest few), so
recency cannot be tested reliably on the egress side of search — an active
thread whose recent replies fall outside the preview would be wrongly
dropped. Enforcing search recency server-side, in the query, avoids that
failure mode entirely.
Tool name matching
The policy matches the Gmail thread-search tool case-insensitively by
suffix on input.resource.name, covering both naming styles seen on the
thread-centric Gmail MCP surface:
*-search-threads— Google official Gmail MCP server (gmailmcp.googleapis.com, kebab-case tool names).*-search_threads— snake_case variant of the same tool name.
The DTwo gateway prefixes tool names with the configured MCP server name
plus a hyphen (e.g. mail-search-threads), and the suffix match includes
that separator, so an unrelated tool whose name merely ends in these
characters (e.g. research_threads) does not match. Message-centric
community servers (search_emails, search_gmail_messages) also accept
Gmail query syntax; add their suffixes to search_tool_suffixes after
verifying the argument name with the dump-input technique.
Examples
Transformed (scoped)
{
"input": {
"action": "tool_pre_invoke",
"mode": "input",
"resource": { "name": "mail-search-threads", "type": "tool" },
"payload": {
"name": "mail-search-threads",
"args": { "query": "from:alice@example.com", "pageSize": 10 }
}
}
}
allow = true; transform.transformed_payload.query becomes
newer_than:14d (from:alice@example.com) and pageSize is preserved.
Passed through (not a search tool)
A mail-get-thread call has no query to rewrite; the policy leaves it
untouched. Direct reads are governed by the companion egress policy.
Composition
apps/gmail/filter-dormant-threads— the direct-read half of the same recency window. Attach both.apps/gmail/filter-blocked-sendersandapps/gmail/filter-labeled-threads— orthogonal mailbox-hygiene filters that compose cleanly with this one.
Known limitations
- Gmail query terms are message-level.
newer_than:matches threads with at least one message in the window, which is the desired thread-level semantic for a bare listing. But when the caller's own terms match only old messages of an active thread (e.g.from:a participant who last wrote a month ago), Gmail requires one message to satisfy both sides of the AND and the thread may be missing from the listing even though it is active. A directget-threadstill returns it. This is inherent to Gmail query semantics, not fixable in the rewrite. - Search-shaped tools only. Tools not ending in the listed suffixes are untouched; if your server exposes other query-bearing search tools, add their suffixes.
- Validation status. The kebab-case suffix and the rewrite behavior were validated live against the Google official Gmail MCP server behind a DTwo gateway; the snake_case variant comes from documentation and has not been exercised against a live server.
Policy source (Rego)
package gmail.ingress.recent_search_only
# Constrains Gmail thread searches to the recency window by prepending a
# newer_than: term to the caller's query. Transform-only — never denies.
# --- Configuration -----------------------------------------------------------
# How many days of history a search may reach back. Keep in step with
# gmail.egress.filter_dormant_threads, which carries its own copy.
max_age_days := 14
recency_filter := sprintf("newer_than:%dd", [max_age_days])
default allow := true
# --- Scope ---------------------------------------------------------------------
# Only the thread-search tool takes a query. Matched case-insensitively on
# tool-name suffix including the separator: the gateway prefixes tool names
# with the MCP server's configured name (mail, gmail, gm, ...), so a prefix
# match would not port, and the leading hyphen prevents an unrelated tool
# whose name merely ends in these characters (e.g. "research_threads") from
# matching. Both kebab-case and snake_case tool vocabularies are covered.
tool_name := lower(object.get(object.get(input, "resource", {}), "name", ""))
search_tool_suffixes := {
"-search-threads",
"-search_threads",
}
is_search_tool if {
some suffix in search_tool_suffixes
endswith(tool_name, suffix)
}
# --- Transform -----------------------------------------------------------------
# The filter is prepended unconditionally rather than skipped when the caller
# already supplied a newer_than: term, so a caller cannot widen the window by
# asking for one. Gated on the pre-invoke hook: there is nothing to rewrite on
# egress.
transform := {"transformed_payload": object.union(
object.get(object.get(input, "payload", {}), "args", {}),
{"query": new_query},
)} if {
input.action == "tool_pre_invoke"
is_search_tool
original := object.get(object.get(object.get(input, "payload", {}), "args", {}), "query", "")
new_query := build_query(original)
}
# --- Query construction ----------------------------------------------------------
# Three mutually exclusive definitions. The non-string case exists so that a
# malformed query yields the tightest possible filter rather than causing the
# transform to go undefined and the search to run unconstrained.
# Non-string query (number, array, object) — filter alone.
build_query(original) := recency_filter if {
not is_string(original)
}
# Absent or blank query — filter alone.
build_query(original) := recency_filter if {
is_string(original)
trim_space(original) == ""
}
# Non-empty query — parenthesised so a top-level OR in the caller's query
# cannot escape the recency constraint.
build_query(original) := sprintf("%s (%s)", [recency_filter, trim_space(original)]) if {
is_string(original)
trim_space(original) != ""
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)