Skip to content
Open
3 changes: 2 additions & 1 deletion packages/environments/mock-gmail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Mock Gmail provides a safe, fully stateful Gmail environment where agents can be
- **Full MIME/RFC 2822 support** — agents can send raw base64url-encoded emails exactly like the real API
- **Stateful SQLite backend** — persistent CRUD, multi-user mailboxes, local delivery between users
- **Snapshot/restore** — save and reset DB state for deterministic evaluation runs
- **35 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior
- **47 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior
- **Task-aware seeding** for repo-level example tasks, with DB state diffs and action logs for verifiers
- **MCP server** — expose all endpoints as MCP tools via `fastapi-mcp`
- **Gymnasium environment** — `GmailEnv` for RL-style agent training
Expand Down Expand Up @@ -93,6 +93,7 @@ uv run --extra dev pytest tests -q
|-------|----------------|
| `test_api.py` | Full CRUD for messages, threads, labels, drafts, admin, and task surfaces |
| `test_conformance.py` | Response-shape validation against real Gmail fixtures |
| `test_threads.py` | Thread list/get filtering, ordering, pagination, formats, and orphan handling |
| `test_settings.py` | Settings sub-resources: filters, sendAs, forwarding, delegates, vacation, IMAP, POP, language |
| `test_mime.py` | RFC 2822 build/parse, base64url encoding, message-ID generation |
| `test_snapshots.py` | Snapshot/reset behavior |
Expand Down
78 changes: 62 additions & 16 deletions packages/environments/mock-gmail/mock_gmail/api/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

from __future__ import annotations

from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, case, func
from sqlalchemy.orm import Session

from mock_gmail.models import Thread, Message, MessageLabel
Expand Down Expand Up @@ -34,7 +37,36 @@ def list_threads(
db: Session = Depends(get_db),
_user_id: str = Depends(resolve_user_id),
):
query = db.query(Thread).filter(Thread.user_id == _user_id)
latest_visible_message_date = func.max(
case(
(
Message.is_trash.is_(False) & Message.is_spam.is_(False),
Message.internal_date,
),
else_=None,
)
)
# Provider captures show that mixed Trash and Spam threads stay anchored to
# their latest visible message with includeSpamTrash either false or true.
# Fully hidden threads use their latest member as a deterministic fallback.
thread_sort_date = func.coalesce(
latest_visible_message_date,
func.max(Message.internal_date),
)
latest_message = (
db.query(
Message.thread_id.label("thread_id"),
thread_sort_date.label("internal_date"),
)
.filter(Message.user_id == _user_id)
.group_by(Message.thread_id)
.subquery()
)
query = (
db.query(Thread)
.join(latest_message, latest_message.c.thread_id == Thread.id)
.filter(Thread.user_id == _user_id)
)

if labelIds:
# Normalize: handle both repeated params and comma-separated
Expand All @@ -44,21 +76,24 @@ def list_threads(
lid = lid.strip()
if lid:
normalized.append(lid)
message_label_filters = []
for lid in normalized:
query = query.filter(
Thread.messages.any(
Message.labels.any(MessageLabel.label_id == lid)
if lid not in ("UNREAD", "STARRED", "TRASH", "SPAM", "DRAFT", "SENT")
else (
Message.is_read == False if lid == "UNREAD"
else Message.is_starred == True if lid == "STARRED"
else Message.is_trash == True if lid == "TRASH"
else Message.is_spam == True if lid == "SPAM"
else Message.is_draft == True if lid == "DRAFT"
else Message.is_sent == True
)
message_label_filters.append(
Message.labels.any(MessageLabel.label_id == lid)
if lid not in ("UNREAD", "STARRED", "TRASH", "SPAM", "DRAFT", "SENT")
else (
Message.is_read.is_(False) if lid == "UNREAD"
else Message.is_starred.is_(True) if lid == "STARRED"
else Message.is_trash.is_(True) if lid == "TRASH"
else Message.is_spam.is_(True) if lid == "SPAM"
else Message.is_draft.is_(True) if lid == "DRAFT"
else Message.is_sent.is_(True)
)
)
if message_label_filters:
query = query.filter(
Thread.messages.any(and_(*message_label_filters))
)

if not includeSpamTrash:
query = query.filter(
Expand Down Expand Up @@ -86,7 +121,15 @@ def list_threads(
pass

total = query.count()
threads = query.offset(offset).limit(maxResults).all()
threads = (
query.order_by(
latest_message.c.internal_date.desc(),
Thread.id.asc(),
)
.offset(offset)
.limit(maxResults)
.all()
)

next_token = None
if offset + maxResults < total:
Expand All @@ -108,7 +151,7 @@ def list_threads(
def get_thread(
userId: str,
threadId: str,
format: str = Query("full"),
format: Literal["full", "metadata", "minimal"] = Query("full"),
db: Session = Depends(get_db),
_user_id: str = Depends(resolve_user_id),
):
Expand All @@ -119,9 +162,12 @@ def get_thread(
msgs = (
db.query(Message)
.filter(Message.thread_id == threadId, Message.user_id == _user_id)
.order_by(Message.internal_date.asc())
.order_by(Message.internal_date.asc(), Message.id.asc())
.all()
)
if not msgs:
# Empty Thread rows are orphaned mock state, not public Gmail resources.
raise HTTPException(404, f"Thread {threadId!r} not found")

# Real Gmail threads.get returns {id, historyId, messages} — no snippet (Bug 2)
return {
Expand Down
12 changes: 10 additions & 2 deletions packages/environments/mock-gmail/tests/fixtures/mock_coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,12 @@
"implemented": true,
"fixture": "threads_list.json",
"tests": [
"tests/test_api.py::TestThreads::test_list_threads"
"tests/test_api.py::TestThreads::test_list_threads",
"tests/test_conformance.py::TestThreadsConformance::test_threads_list_structure",
"tests/test_conformance.py::TestThreadsConformance::test_threads_list_label_filters_share_one_message_fixture",
"tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_trash_order_fixture",
"tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_spam_order_fixture",
"tests/test_threads.py::TestThreadsListBehavior"
]
},
{
Expand All @@ -180,7 +185,10 @@
"fixture": "thread_get_full.json",
"tests": [
"tests/test_api.py::TestThreads::test_get_thread",
"tests/test_conformance.py::TestThreadsConformance::test_thread_get_structure"
"tests/test_conformance.py::TestThreadsConformance::test_thread_get_structure",
"tests/test_conformance.py::TestThreadsConformance::test_thread_get_metadata_format",
"tests/test_conformance.py::TestThreadsConformance::test_thread_get_minimal_format",
"tests/test_threads.py::TestThreadsGetBehavior"
]
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"id": "19f56a705ee6e94d",
"historyId": "11270",
"messages": [
{
"id": "19f56a73603c0d83",
"threadId": "19f56a705ee6e94d",
"labelIds": [
"SENT",
"INBOX"
],
"snippet": "visible message C1",
"payload": {
"mimeType": "multipart/alternative",
"headers": [
{
"name": "Subject",
"value": "[env0-fixture] mixed-spam-C"
}
]
},
"sizeEstimate": 594,
"historyId": "11175",
"internalDate": "1783865226000"
},
{
"id": "19f56a7bafc0aceb",
"threadId": "19f56a705ee6e94d",
"labelIds": [
"SENT",
"SPAM"
],
"snippet": "hidden message C2 On Sun, Jul 12, 2026 at 10:07 PM Fixture User &lt;fixture-user@example.com&gt; wrote: visible message C1",
"payload": {
"mimeType": "multipart/alternative",
"headers": [
{
"name": "Subject",
"value": "Re: [env0-fixture] mixed-spam-C"
}
]
},
"sizeEstimate": 1427,
"historyId": "11270",
"internalDate": "1783865260000"
}
],
"_captured_at": "2026-07-12T14:16:54.508049+00:00"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"id": "19f56a773c7f5c52",
"historyId": "10915",
"messages": [
{
"id": "19f56a7a3046d75a",
"threadId": "19f56a773c7f5c52",
"labelIds": [
"UNREAD",
"IMPORTANT",
"SENT",
"INBOX"
],
"snippet": "visible message D1",
"payload": {
"mimeType": "multipart/alternative",
"headers": [
{
"name": "Subject",
"value": "[env0-fixture] mixed-spam-D"
}
]
},
"sizeEstimate": 594,
"historyId": "10915",
"internalDate": "1783865254000"
}
],
"_captured_at": "2026-07-12T14:16:54.240014+00:00"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"id": "19f56999f64f85dd",
"historyId": "10574",
"messages": [
{
"id": "19f5699da6162829",
"threadId": "19f56999f64f85dd",
"labelIds": [
"IMPORTANT",
"SENT",
"INBOX"
],
"snippet": "visible message A1",
"historyId": "10574",
"internalDate": "1783864351000",
"sizeEstimate": 596,
"payload": {
"mimeType": "multipart/alternative",
"headers": [
{
"name": "Subject",
"value": "[env0-fixture] mixed-hidden-A"
}
]
}
},
{
"id": "19f569a79794e0f1",
"threadId": "19f56999f64f85dd",
"labelIds": [
"UNREAD",
"IMPORTANT",
"TRASH",
"SENT"
],
"snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User &lt;fixture-user@example.com&gt; wrote: visible message A1",
"historyId": "10571",
"internalDate": "1783864391000",
"sizeEstimate": 1427,
"payload": {
"mimeType": "multipart/alternative",
"headers": [
{
"name": "Subject",
"value": "Re: [env0-fixture] mixed-hidden-A"
}
]
}
}
],
"_captured_at": "2026-07-12T13:54:49.469882+00:00"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"id": "19f569a1f4cfbf48",
"historyId": "10504",
"messages": [
{
"id": "19f569a5d2a5e0a7",
"threadId": "19f569a1f4cfbf48",
"labelIds": [
"UNREAD",
"IMPORTANT",
"SENT",
"INBOX"
],
"snippet": "visible message B1",
"historyId": "10504",
"internalDate": "1783864384000",
"sizeEstimate": 596,
"payload": {
"mimeType": "multipart/alternative",
"headers": [
{
"name": "Subject",
"value": "[env0-fixture] mixed-hidden-B"
}
]
}
}
],
"_captured_at": "2026-07-12T13:54:49.201377+00:00"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"id": "19f568dea810b4ec",
"historyId": "10165",
"messages": [
{
"id": "19f568e4248d0ed2",
"threadId": "19f568dea810b4ec",
"labelIds": [
"IMPORTANT",
"SENT",
"INBOX"
],
"snippet": "synthetic fixture message one",
"historyId": "10129",
"internalDate": "1783863591000",
"sizeEstimate": 627
},
{
"id": "19f568e69c16a0f5",
"threadId": "19f568dea810b4ec",
"labelIds": [
"IMPORTANT",
"SENT",
"INBOX"
],
"snippet": "synthetic fixture message two On Sun, Jul 12, 2026 at 9:39 PM Fixture User &lt;fixture-user@example.com&gt; wrote: synthetic fixture message one",
"historyId": "10165",
"internalDate": "1783863600000",
"sizeEstimate": 1482
}
],
"_captured_at": "2026-07-12T13:42:41.532585+00:00"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"threads": [
{
"id": "19f56999f64f85dd",
"snippet": "visible message A1",
"historyId": "10612"
}
],
"resultSizeEstimate": 1,
"_captured_at": "2026-07-12T14:29:30.182981+00:00"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"threads": [
{
"id": "19f56999f64f85dd",
"snippet": "<b>hidden</b> message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User wrote: &gt; visible message A1 &gt; &gt;",
"historyId": "10612"
}
],
"resultSizeEstimate": 1,
"_captured_at": "2026-07-12T14:29:30.472382+00:00"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"resultSizeEstimate": 0,
"_captured_at": "2026-07-12T14:29:29.881992+00:00"
}
Loading