Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4985,6 +4985,13 @@ paths:
schema:
description: "List the trash instead: messages that were soft-deleted and are restorable until purged (30 days after deletion by default, deployment-configurable). Defaults to false (live messages only)."
type: boolean
- description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."
explode: false
in: query
name: filter
schema:
description: "Boolean filter expression (AIP-160-derived). v1 fields: label, from, subject, created. Operators: : = != < <= > >= with AND / OR / NOT and parentheses; whitespace is implicit AND and binds looser than OR. Composes with (ANDs) the flat filters. Unknown fields/operators are rejected with a positioned invalid_filter error. Max 500 chars."
type: string
responses:
"200":
content:
Expand Down
16 changes: 16 additions & 0 deletions cli/src/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ describe("getFlag", () => {
// --to should still work
expect(getFlag(args, "--to")).toBe("a@b.com");
});

it("extracts a messages list filter expression verbatim", () => {
expect(
getFlag(["messages", "list", "--filter", "label:urgent"], "--filter"),
).toBe("label:urgent");
});
});

describe("getFlags", () => {
Expand Down Expand Up @@ -208,6 +214,16 @@ describe("checkFlags (FIX 1: single-dash flag typos)", () => {
expect(() => checkFlags(["--json=true"], ["--json"])).toThrow("process.exit");
});

it("rejects the retired legacy flag while accepting --filter", () => {
const retiredFilterFlag = `--${"q"}`;
expect(() => checkFlags([retiredFilterFlag, "label:urgent"], ["--filter"])).toThrow("process.exit");
expect(mockExit).toHaveBeenCalledWith(2);
mockExit.mockClear();

expect(() => checkFlags(["--filter", "label:urgent"], ["--filter"])).not.toThrow();
expect(mockExit).not.toHaveBeenCalled();
});

it("rejects the removed login --with-key flag", () => {
expect(() => checkFlags(["--with-key"], [])).toThrow("process.exit");
expect(mockExit).toHaveBeenCalledWith(2);
Expand Down
21 changes: 21 additions & 0 deletions cli/src/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@ describe("messages commands", () => {
);
});

it("passes filter through verbatim and omits it when unset", async () => {
mockList.mockReturnValue(summaries());
const { messagesList } = await import("../commands/messages.js");

await messagesList({ filter: "label:urgent" });
expect(mockList).toHaveBeenCalledWith("bot@agents.e2a.dev", {
sort: "asc",
readStatus: "all",
filter: "label:urgent",
limit: 100,
});

mockList.mockClear();
await messagesList({});
expect(mockList).toHaveBeenCalledWith("bot@agents.e2a.dev", {
sort: "asc",
readStatus: "all",
limit: 100,
});
});

it("sanitizes TSV delimiters out of the sender-controlled From field", async () => {
mockList.mockReturnValue(
summaries({
Expand Down
4 changes: 3 additions & 1 deletion cli/src/bin/e2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ Usage:
--direction <d> inbound|outbound|all
--since <ISO> Messages created AT or after this timestamp
(inclusive — dedup by message id when cursoring)
--filter <expr> Boolean filter expression (see API filtering docs)
--conversation <id> Filter to one conversation (alias: --conversation-id)
--read-status <s> unread|read|all (default all — safe for poll loops)
--limit <n> Stop after n messages
Expand Down Expand Up @@ -470,13 +471,14 @@ async function main() {
if (sub === "list") {
checkFlags(rest, [
"--direction", "--since", "--conversation", "--conversation-id",
"--read-status", "--limit", "--agent", "--json",
"--read-status", "--limit", "--agent", "--filter", "--json",
]);
getPositionals(rest, 0, "usage: e2a messages list [options]");
await messagesList({
agent: getFlagChecked(rest, "--agent"),
direction: getFlagChecked(rest, "--direction"),
since: getFlagChecked(rest, "--since"),
filter: getFlagChecked(rest, "--filter"),
conversation: getConversationId(rest),
readStatus: getFlagChecked(rest, "--read-status"),
limit: getFlagChecked(rest, "--limit"),
Expand Down
4 changes: 3 additions & 1 deletion cli/src/commands/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface MessagesListOptions {
agent?: string;
direction?: string;
since?: string;
filter?: string;
conversation?: string;
limit?: string;
readStatus?: string;
Expand All @@ -26,7 +27,7 @@ export interface MessagesLifecycleOptions {
}

const LIST_USAGE =
"usage: e2a messages list [--direction inbound|outbound|all] [--since <ISO>] [--conversation <id>] [--read-status unread|read|all] [--limit <n>] [--agent <inbox>] [--json]";
"usage: e2a messages list [--direction inbound|outbound|all] [--since <ISO>] [--filter <expr>] [--conversation <id>] [--read-status unread|read|all] [--limit <n>] [--agent <inbox>] [--json]";
const GET_USAGE = "usage: e2a messages get <message-id> [--text] [--agent <inbox>] [--json]";
const LIFECYCLE_USAGE =
"usage: e2a messages lifecycle <message-id> (beta) [--agent <inbox>] [--limit <1-100>] [--cursor <cursor>] [--json]";
Expand Down Expand Up @@ -61,6 +62,7 @@ export async function messagesList(opts: MessagesListOptions): Promise<void> {
params.readStatus = opts.readStatus as (typeof READ_STATUSES)[number];
}
if (opts.since) params.since = opts.since;
if (opts.filter !== undefined) params.filter = opts.filter;
if (opts.conversation) params.conversationId = opts.conversation;

let max: number | undefined;
Expand Down
5 changes: 3 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,8 +415,9 @@ single message.

- `GET …/messages` — list inbound + outbound with filters (`direction`,
`read_status`, `sort`, `from`, `subject_contains`, `conversation_id`, `labels`,
`since`, `until`) and cursor pagination. Held outbound drafts appear with
`status=pending_review`.
`since`, `until`) and cursor pagination. `filter` adds boolean composition; see
[message filtering](filtering.md) for its grammar and v1 fields. Held outbound
drafts appear with `status=pending_review`.
- `POST …/messages` — send a new email (a new thread). Returns `202 Accepted` for
every non-terminal outcome — `pending_review` when the agent's protection policy
holds it for review, or `accepted` when the async pipeline durably queues it —
Expand Down
82 changes: 82 additions & 0 deletions docs/filtering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Message filtering (`filter`)

`GET /v1/agents/{email}/messages?filter=…` accepts a small, boolean filter
language. It is an addition to—not a replacement for—the existing flat list
parameters. See the [filter-query design](superpowers/specs/2026-07-25-filter-query-language-design.md)
for rationale and rollout details.

## Syntax

Keywords are uppercase and case-sensitive: `AND`, `OR`, and `NOT`.

```ebnf
query = expression [ WS ] ;
expression = sequence { WS* "AND" WS* sequence } ;
sequence = factor { WS factor } ;
factor = term { WS* "OR" WS* term } ;
term = [ "NOT" WS | "-" ] simple ;
simple = restriction | "(" WS* expression WS* ")" ;
restriction = member [ WS* comparator WS* value ] ;
member = ( TEXT | STRING ) { "." TEXT } ;
comparator = ":" | "=" | "!=" | "<" | "<=" | ">" | ">=" ;
value = STRING | TEXT { ( "." | ":" ) TEXT } ;
```

Whitespace between expressions is implicit `AND`. Binding, from tightest to
loosest, is **`NOT` > `OR` > implicit `AND` > explicit `AND`**. Use
parentheses when that is not what you mean. A restriction without a comparator
is syntactically a bare term, but v1 rejects it: qualify the value with a field.

`STRING` is double-quoted. Quote values containing whitespace or reserved
operator/parenthesis characters; `.` and `:` may remain unquoted in values
(RFC3339 timestamps, email addresses, and `e2a:` labels). Only `\"` (a literal
quote) and `\\` (a literal backslash) are valid escapes. For example:
`subject:"build \"green\""`. A quoted field name is syntactically accepted,
but it must still resolve exactly to a supported v1 field name.

## V1 fields

| Field | Operators | Meaning |
| --- | --- | --- |
| `label` | `:` | Message has the label. Values match `[a-z0-9:_-]+` and are at most 64 characters. Use `NOT label:newsletter` to exclude it. |
| `from` | `:` `=` `!=` | Sender. `:` is case-insensitive substring matching; `=` and `!=` are case-insensitive exact matching. |
| `subject` | `:` `=` `!=` | Subject, with the same matching rules as `from`. A NULL subject matches none of these predicates. |
| `created` | `=` `!=` `<` `<=` `>` `>=` | Creation time. Value is RFC3339 or a `YYYY-MM-DD` UTC calendar date. |

For `from:` and `subject:`, `*` matches any sequence of characters. Literal
`%`, `_`, and `\` are escaped, so they never become SQL wildcards. The wildcard
is only meaningful for `:`; `=` and `!=` remain exact comparisons.

A date-only `created` value denotes the full UTC day `[00:00, next 00:00)`:

- `created=2026-07-01` matches that day; `!=` matches outside it.
- `<` is before that day's midnight; `<=` is before the following midnight.
- `>` starts at the following midnight; `>=` starts at that day's midnight.

Examples:

```text
label:urgent OR label:follow-up
label:urgent AND NOT label:newsletter
(from:"alerts.example" OR subject:"release *") created>=2026-07-01
created=2026-07-01
```

## Composition, errors, and limits

`filter` is ANDed with every flat list filter (`direction`, `read_status`, `from`,
`labels`, `since`, and so on). A pagination cursor is tied to the complete
filter identity, including `filter`; start a new query if any filter changes.

Invalid syntax, unknown fields, unsupported operators, invalid values, and
limit violations return HTTP 400 with error code `invalid_filter`. Parse and
validation errors identify their source position, for example `at column 12`.

The request accepts at most **500 Unicode code points**, nesting depth **64**,
and **512** AST nodes. Invalid UTF-8 and NUL are rejected.

`has:attachment` is intentionally deferred. Inbound attachments are canonical
in raw MIME while outbound attachments currently use JSON, so a correct filter
needs a normalized attachment-count field. It will ship only after a
blue/green-safe expand, backfill, and contract rollout makes that field reliable
for every live writer.
Loading
Loading