diff --git a/packages/environments/mock-gmail/README.md b/packages/environments/mock-gmail/README.md
index 00cdc1233..1d43ac0dd 100644
--- a/packages/environments/mock-gmail/README.md
+++ b/packages/environments/mock-gmail/README.md
@@ -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
@@ -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 |
diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py
index d2f86c44f..dee42714d 100644
--- a/packages/environments/mock-gmail/mock_gmail/api/threads.py
+++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py
@@ -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
@@ -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
@@ -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(
@@ -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:
@@ -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),
):
@@ -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 {
diff --git a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json
index e7002f21b..044a848e3 100644
--- a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json
+++ b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json
@@ -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"
]
},
{
@@ -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"
]
},
{
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json
new file mode 100644
index 000000000..62dc2284a
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json
@@ -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 <fixture-user@example.com> 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"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json
new file mode 100644
index 000000000..53b4280cb
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json
@@ -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"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json
new file mode 100644
index 000000000..a99fe2adc
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json
@@ -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 <fixture-user@example.com> 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"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json
new file mode 100644
index 000000000..31f82efe6
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json
@@ -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"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json
new file mode 100644
index 000000000..c31a4d546
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json
@@ -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 <fixture-user@example.com> wrote: synthetic fixture message one",
+ "historyId": "10165",
+ "internalDate": "1783863600000",
+ "sizeEstimate": 1482
+ }
+ ],
+ "_captured_at": "2026-07-12T13:42:41.532585+00:00"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json
new file mode 100644
index 000000000..727529bf6
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json
@@ -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"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json
new file mode 100644
index 000000000..476af25c2
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json
@@ -0,0 +1,11 @@
+{
+ "threads": [
+ {
+ "id": "19f56999f64f85dd",
+ "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User wrote: > visible message A1 > >",
+ "historyId": "10612"
+ }
+ ],
+ "resultSizeEstimate": 1,
+ "_captured_at": "2026-07-12T14:29:30.472382+00:00"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json
new file mode 100644
index 000000000..5e26b11ea
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json
@@ -0,0 +1,4 @@
+{
+ "resultSizeEstimate": 0,
+ "_captured_at": "2026-07-12T14:29:29.881992+00:00"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json
new file mode 100644
index 000000000..2252d78ce
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json
@@ -0,0 +1,16 @@
+{
+ "threads": [
+ {
+ "id": "19f56a773c7f5c52",
+ "snippet": "visible message D1",
+ "historyId": "10915"
+ },
+ {
+ "id": "19f56a705ee6e94d",
+ "snippet": "visible message C1",
+ "historyId": "11270"
+ }
+ ],
+ "resultSizeEstimate": 2,
+ "_captured_at": "2026-07-12T14:16:53.700176+00:00"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json
new file mode 100644
index 000000000..b001def4a
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json
@@ -0,0 +1,16 @@
+{
+ "threads": [
+ {
+ "id": "19f56a773c7f5c52",
+ "snippet": "visible message D1",
+ "historyId": "10915"
+ },
+ {
+ "id": "19f56a705ee6e94d",
+ "snippet": "hidden message C2 On Sun, Jul 12, 2026 at 10:07 PM Fixture User wrote: > visible message C1 > >",
+ "historyId": "11270"
+ }
+ ],
+ "resultSizeEstimate": 2,
+ "_captured_at": "2026-07-12T14:16:53.981347+00:00"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json
new file mode 100644
index 000000000..aeaf1f7c6
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json
@@ -0,0 +1,16 @@
+{
+ "threads": [
+ {
+ "id": "19f569a1f4cfbf48",
+ "snippet": "visible message B1",
+ "historyId": "10504"
+ },
+ {
+ "id": "19f56999f64f85dd",
+ "snippet": "visible message A1",
+ "historyId": "10574"
+ }
+ ],
+ "resultSizeEstimate": 2,
+ "_captured_at": "2026-07-12T13:54:48.618628+00:00"
+}
diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json
new file mode 100644
index 000000000..fcaed94f4
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json
@@ -0,0 +1,16 @@
+{
+ "threads": [
+ {
+ "id": "19f569a1f4cfbf48",
+ "snippet": "visible message B1",
+ "historyId": "10504"
+ },
+ {
+ "id": "19f56999f64f85dd",
+ "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User wrote: > visible message A1 > >",
+ "historyId": "10574"
+ }
+ ],
+ "resultSizeEstimate": 2,
+ "_captured_at": "2026-07-12T13:54:48.941152+00:00"
+}
diff --git a/packages/environments/mock-gmail/tests/test_api.py b/packages/environments/mock-gmail/tests/test_api.py
index fe869ae98..86655ced8 100644
--- a/packages/environments/mock-gmail/tests/test_api.py
+++ b/packages/environments/mock-gmail/tests/test_api.py
@@ -417,7 +417,8 @@ def test_untrash_thread(self, client):
assert "TRASH" not in msg["labelIds"]
def test_delete_thread(self, client):
- resp = client.get("/gmail/v1/users/me/threads")
+ # Pick a mailbox thread; deterministic date ordering puts the draft first.
+ resp = client.get("/gmail/v1/users/me/threads?labelIds=INBOX")
thread_id = resp.json()["threads"][0]["id"]
resp = client.delete(f"/gmail/v1/users/me/threads/{thread_id}")
diff --git a/packages/environments/mock-gmail/tests/test_conformance.py b/packages/environments/mock-gmail/tests/test_conformance.py
index 00c16bac1..23af909dc 100644
--- a/packages/environments/mock-gmail/tests/test_conformance.py
+++ b/packages/environments/mock-gmail/tests/test_conformance.py
@@ -71,6 +71,14 @@ def _assert_any_shape(real_items: list, mock_item, path: str, strict: bool) -> N
pytest.fail(f"Mock item at {path} matches no fixture item shape: {'; '.join(errors)}")
+def _assert_thread_message_core_types(message: dict) -> None:
+ for key in ("id", "threadId", "snippet", "historyId", "internalDate"):
+ assert isinstance(message[key], str)
+ assert isinstance(message["labelIds"], list)
+ assert all(isinstance(label_id, str) for label_id in message["labelIds"])
+ assert isinstance(message["sizeEstimate"], int)
+
+
class TestProfileConformance:
def test_profile_keys(self, client):
"""Profile response has same keys as real Gmail."""
@@ -525,20 +533,30 @@ def test_thread_get_structure(self, client):
pytest.skip("No threads")
thread_id = threads[0]["id"]
- resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}")
+ resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}?format=full")
mock = resp.json()
# Real threads.get returns {id, historyId, messages} — no snippet
real = load_fixture("thread_get_full.json")
assert set(mock.keys()) == set(real.keys())
+ assert isinstance(mock["id"], str)
+ assert isinstance(mock["historyId"], str)
assert "messages" in mock
assert len(mock["messages"]) > 0
- # Each message in thread should have payload
+ real_message_keys = set(real["messages"][0])
+ # Each message in thread should match the real full-format top-level shape.
for msg in mock["messages"]:
+ assert set(msg) == real_message_keys
+ _assert_thread_message_core_types(msg)
assert "payload" in msg
+ assert "raw" not in msg
+ assert isinstance(msg["payload"], dict)
assert "id" in msg
assert "threadId" in msg
+ for header in msg["payload"]["headers"]:
+ assert set(header) == {"name", "value"}
+ assert all(isinstance(value, str) for value in header.values())
def test_thread_get_metadata_format(self, client):
"""threads.get format=metadata messages have only {mimeType, headers} in payload."""
@@ -553,25 +571,138 @@ def test_thread_get_metadata_format(self, client):
mock = resp.json()
assert set(mock.keys()) == set(real.keys())
+ assert isinstance(mock["id"], str)
+ assert isinstance(mock["historyId"], str)
assert "messages" in mock
- # Each message payload should match metadata format
+ # Every message and payload should match metadata format, not only the first.
real_msg = real["messages"][0]
- mock_msg = mock["messages"][0]
- assert set(real_msg["payload"].keys()) == set(mock_msg["payload"].keys())
+ for mock_msg in mock["messages"]:
+ assert set(mock_msg) == set(real_msg)
+ _assert_thread_message_core_types(mock_msg)
+ assert set(real_msg["payload"]) == set(mock_msg["payload"])
+ assert "raw" not in mock_msg
+ for header in mock_msg["payload"]["headers"]:
+ assert set(header) == {"name", "value"}
+ assert all(isinstance(value, str) for value in header.values())
+
+ def test_thread_get_minimal_format(self, client):
+ """threads.get format=minimal omits payload and raw from every message."""
+ real = load_fixture("thread_get_minimal.json")
+ resp = client.get("/gmail/v1/users/me/threads")
+ threads = resp.json()["threads"]
+ if not threads:
+ pytest.skip("No threads")
+ thread_id = threads[0]["id"]
+
+ resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}?format=minimal")
+ mock = resp.json()
+
+ assert set(mock) == set(real)
+ assert isinstance(mock["id"], str)
+ assert isinstance(mock["historyId"], str)
+ assert "messages" in mock
+
+ # The provider fixture contains two messages and records their API order.
+ assert len(real["messages"]) >= 2
+ real_dates = [int(message["internalDate"]) for message in real["messages"]]
+ assert real_dates == sorted(real_dates)
+
+ real_message_keys = set(real["messages"][0])
+ for real_msg in real["messages"]:
+ assert set(real_msg) == real_message_keys
+ _assert_thread_message_core_types(real_msg)
+ assert "payload" not in real_msg
+ assert "raw" not in real_msg
+ for mock_msg in mock["messages"]:
+ assert set(mock_msg) == real_message_keys
+ _assert_thread_message_core_types(mock_msg)
+ assert "payload" not in mock_msg
+ assert "raw" not in mock_msg
def test_threads_list_structure(self, client):
"""threads.list returns items with {id, snippet, historyId}."""
real = load_fixture("threads_list.json")
- resp = client.get("/gmail/v1/users/me/threads")
+ # The real fixture was captured with maxResults=10.
+ resp = client.get("/gmail/v1/users/me/threads?maxResults=10")
mock = resp.json()
- assert "resultSizeEstimate" in mock
- assert "threads" in mock
+ assert set(mock) == set(real)
+ assert isinstance(mock["resultSizeEstimate"], int)
+ assert isinstance(mock["nextPageToken"], str)
+ assert len(mock["threads"]) == len(real["threads"])
# Each real thread item has {id, snippet, historyId}
for item in real["threads"]:
assert set(item.keys()) == {"id", "snippet", "historyId"}
for item in mock["threads"]:
assert set(item.keys()) == {"id", "snippet", "historyId"}
+ assert all(isinstance(value, str) for value in item.values())
+
+ def test_threads_list_label_filters_share_one_message_fixture(self):
+ """Provider requires one member message to satisfy all label IDs."""
+ same_inbox = load_fixture("threads_list_labels_same_inbox_message.json")
+ same_trash = load_fixture("threads_list_labels_same_trash_message.json")
+ split = load_fixture("threads_list_labels_split_across_messages.json")
+ thread = load_fixture("thread_get_metadata_mixed_trash_a.json")
+
+ visible, trashed = thread["messages"]
+ assert {"INBOX", "SENT"} <= set(visible["labelIds"])
+ assert {"TRASH", "SENT"} <= set(trashed["labelIds"])
+ assert not any(
+ {"INBOX", "TRASH"} <= set(message["labelIds"])
+ for message in thread["messages"]
+ )
+
+ assert [item["id"] for item in same_inbox["threads"]] == [thread["id"]]
+ assert [item["id"] for item in same_trash["threads"]] == [thread["id"]]
+ assert same_inbox["resultSizeEstimate"] == 1
+ assert same_trash["resultSizeEstimate"] == 1
+ assert split == {"resultSizeEstimate": 0}
+
+ def test_threads_list_mixed_trash_order_fixture(self):
+ """Provider capture orders a mixed Trash thread by its visible message."""
+ default = load_fixture("threads_list_mixed_trash_default.json")
+ included = load_fixture("threads_list_mixed_trash_included.json")
+ thread_a = load_fixture("thread_get_metadata_mixed_trash_a.json")
+ thread_b = load_fixture("thread_get_metadata_mixed_trash_b.json")
+
+ assert len(thread_a["messages"]) == 2
+ assert len(thread_b["messages"]) == 1
+ a_visible, a_trashed = thread_a["messages"]
+ (b_visible,) = thread_b["messages"]
+ assert int(a_visible["internalDate"]) < int(b_visible["internalDate"])
+ assert int(b_visible["internalDate"]) < int(a_trashed["internalDate"])
+ assert "TRASH" not in a_visible["labelIds"]
+ assert "TRASH" not in b_visible["labelIds"]
+ assert "TRASH" in a_trashed["labelIds"]
+
+ expected_order = [thread_b["id"], thread_a["id"]]
+ assert [thread["id"] for thread in default["threads"]] == expected_order
+ assert [thread["id"] for thread in included["threads"]] == expected_order
+ assert default["resultSizeEstimate"] == 2
+ assert included["resultSizeEstimate"] == 2
+
+ def test_threads_list_mixed_spam_order_fixture(self):
+ """Provider capture orders a mixed Spam thread by its visible message."""
+ default = load_fixture("threads_list_mixed_spam_default.json")
+ included = load_fixture("threads_list_mixed_spam_included.json")
+ thread_c = load_fixture("thread_get_metadata_mixed_spam_c.json")
+ thread_d = load_fixture("thread_get_metadata_mixed_spam_d.json")
+
+ assert len(thread_c["messages"]) == 2
+ assert len(thread_d["messages"]) == 1
+ c_visible, c_spam = thread_c["messages"]
+ (d_visible,) = thread_d["messages"]
+ assert int(c_visible["internalDate"]) < int(d_visible["internalDate"])
+ assert int(d_visible["internalDate"]) < int(c_spam["internalDate"])
+ assert "SPAM" not in c_visible["labelIds"]
+ assert "SPAM" not in d_visible["labelIds"]
+ assert "SPAM" in c_spam["labelIds"]
+
+ expected_order = [thread_d["id"], thread_c["id"]]
+ assert [thread["id"] for thread in default["threads"]] == expected_order
+ assert [thread["id"] for thread in included["threads"]] == expected_order
+ assert default["resultSizeEstimate"] == 2
+ assert included["resultSizeEstimate"] == 2
class TestSettingsConformance:
diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py
new file mode 100644
index 000000000..1249054c6
--- /dev/null
+++ b/packages/environments/mock-gmail/tests/test_threads.py
@@ -0,0 +1,471 @@
+"""Focused behavior tests for the Gmail Threads read API."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+import pytest
+
+from mock_gmail.models import (
+ Message,
+ MessageLabel,
+ Thread,
+ User,
+ get_session_factory,
+)
+
+
+@pytest.fixture
+def db_session(client):
+ """Open a session against the database initialized by the API client."""
+ db = get_session_factory()()
+ try:
+ yield db
+ finally:
+ db.close()
+
+
+def _user_id(db_session) -> str:
+ return (
+ db_session.query(User.id)
+ .filter(User.email_address == "alex@nexusai.com")
+ .scalar()
+ )
+
+
+def _add_thread(
+ db_session,
+ user_id: str,
+ thread_id: str,
+ messages: list[dict],
+ *,
+ subject: str = "Threads API regression",
+) -> None:
+ db_session.add(
+ Thread(
+ id=thread_id,
+ user_id=user_id,
+ snippet=messages[-1].get("body", "")[:200] if messages else "",
+ history_id=9001,
+ )
+ )
+ for spec in messages:
+ labels = set(spec.get("labels", ["INBOX"]))
+ message = Message(
+ id=spec["id"],
+ thread_id=thread_id,
+ user_id=user_id,
+ sender="threads-regression@example.com",
+ to="alex@nexusai.com",
+ subject=spec.get("subject", subject),
+ snippet=spec.get("body", "")[:200],
+ body_plain=spec.get("body", ""),
+ internal_date=spec["date"],
+ is_read="UNREAD" not in labels,
+ is_starred="STARRED" in labels,
+ is_trash="TRASH" in labels,
+ is_spam="SPAM" in labels,
+ is_draft="DRAFT" in labels,
+ is_sent="SENT" in labels,
+ )
+ db_session.add(message)
+ for label_id in labels - {
+ "UNREAD",
+ "STARRED",
+ "TRASH",
+ "SPAM",
+ "DRAFT",
+ "SENT",
+ }:
+ db_session.add(MessageLabel(message_id=message.id, label_id=label_id))
+ db_session.commit()
+
+
+def _listed_thread_ids(response) -> list[str]:
+ assert response.status_code == 200
+ return [thread["id"] for thread in response.json().get("threads", [])]
+
+
+def _at(month: int, day: int, hour: int = 8, minute: int = 0) -> datetime:
+ return datetime(2099, month, day, hour, minute)
+
+
+def _message_spec(
+ message_id: str,
+ internal_date: datetime,
+ body: str,
+ labels: list[str] | None = None,
+) -> dict:
+ spec = {"id": message_id, "date": internal_date, "body": body}
+ if labels is not None:
+ spec["labels"] = labels
+ return spec
+
+
+class TestThreadsListBehavior:
+ def test_q_matches_any_older_message_and_returns_thread_once(
+ self, client, db_session
+ ):
+ user_id = _user_id(db_session)
+ marker = "older-message-thread-match-7f19"
+ _add_thread(
+ db_session,
+ user_id,
+ "thread-q-old-message",
+ [
+ _message_spec("message-q-old-a", _at(1, 1), marker),
+ _message_spec("message-q-old-b", _at(1, 2), marker),
+ _message_spec(
+ "message-q-newest",
+ _at(1, 3),
+ "The newest message does not contain the search marker.",
+ ),
+ ],
+ )
+
+ response = client.get("/gmail/v1/users/me/threads", params={"q": marker})
+
+ assert _listed_thread_ids(response) == ["thread-q-old-message"]
+ assert response.json()["resultSizeEstimate"] == 1
+
+ def test_same_subject_different_thread_ids_remain_separate(
+ self, client, db_session
+ ):
+ user_id = _user_id(db_session)
+ subject = "Shared subject 4da36d"
+ for suffix in ("a", "b"):
+ _add_thread(
+ db_session,
+ user_id,
+ f"thread-same-subject-{suffix}",
+ [
+ _message_spec(
+ f"message-same-subject-{suffix}",
+ _at(2, 1, minute=ord(suffix) - ord("a")),
+ f"Message {suffix}",
+ )
+ ],
+ subject=subject,
+ )
+
+ response = client.get(
+ "/gmail/v1/users/me/threads",
+ params={"q": f'subject:"{subject}"'},
+ )
+
+ assert set(_listed_thread_ids(response)) == {
+ "thread-same-subject-a",
+ "thread-same-subject-b",
+ }
+ assert response.json()["resultSizeEstimate"] == 2
+
+ def test_all_labels_must_be_on_the_same_message(self, client, db_session):
+ user_id = _user_id(db_session)
+ marker = "thread-label-union-135b"
+ scenarios = {
+ "thread-label-same-message": (["INBOX", "STARRED"], []),
+ "thread-label-split": (["INBOX"], ["STARRED"]),
+ "thread-label-inbox-only": (["INBOX"], ["INBOX"]),
+ "thread-label-starred-only": (["STARRED"], ["STARRED"]),
+ }
+ for thread_id, message_labels in scenarios.items():
+ _add_thread(
+ db_session,
+ user_id,
+ thread_id,
+ [
+ _message_spec(
+ f"{thread_id}-message-{index}",
+ _at(3, index + 1),
+ marker,
+ labels,
+ )
+ for index, labels in enumerate(message_labels)
+ ],
+ )
+
+ response = client.get(
+ "/gmail/v1/users/me/threads",
+ params=[
+ ("q", marker),
+ ("labelIds", "INBOX"),
+ ("labelIds", "STARRED"),
+ ],
+ )
+
+ assert _listed_thread_ids(response) == ["thread-label-same-message"]
+ assert response.json()["resultSizeEstimate"] == 1
+
+ def test_include_spam_trash_controls_hidden_threads(self, client, db_session):
+ user_id = _user_id(db_session)
+ marker = "thread-hidden-7f921"
+ scenarios = {
+ "thread-visible": [["INBOX"]],
+ "thread-trash-only": [["TRASH"]],
+ "thread-spam-only": [["SPAM"]],
+ }
+ for thread_number, (thread_id, message_labels) in enumerate(
+ scenarios.items(), start=1
+ ):
+ _add_thread(
+ db_session,
+ user_id,
+ thread_id,
+ [
+ _message_spec(
+ f"{thread_id}-message-{message_number}",
+ _at(4, thread_number, hour=message_number),
+ marker,
+ labels,
+ )
+ for message_number, labels in enumerate(message_labels, start=1)
+ ],
+ )
+
+ default_response = client.get(
+ "/gmail/v1/users/me/threads",
+ params={"q": marker},
+ )
+ included_response = client.get(
+ "/gmail/v1/users/me/threads",
+ params={"q": marker, "includeSpamTrash": "true"},
+ )
+
+ assert _listed_thread_ids(default_response) == ["thread-visible"]
+ assert default_response.json()["resultSizeEstimate"] == 1
+ # Fully hidden threads use their latest member message as the
+ # deterministic fallback sort timestamp when explicitly included.
+ assert _listed_thread_ids(included_response) == [
+ "thread-spam-only",
+ "thread-trash-only",
+ "thread-visible",
+ ]
+ assert included_response.json()["resultSizeEstimate"] == 3
+
+ @pytest.mark.parametrize("hidden_label", ["TRASH", "SPAM"])
+ def test_newer_hidden_message_does_not_change_thread_order(
+ self, client, db_session, hidden_label
+ ):
+ user_id = _user_id(db_session)
+ hidden_kind = hidden_label.lower()
+ subject_prefix = f"Mixed {hidden_kind} ordering 6e4b2"
+ thread_a = f"thread-mixed-{hidden_kind}-a"
+ thread_b = f"thread-mixed-{hidden_kind}-b"
+ _add_thread(
+ db_session,
+ user_id,
+ thread_a,
+ [
+ _message_spec(
+ f"message-mixed-{hidden_kind}-a-visible",
+ _at(5, 1),
+ "visible message A1",
+ ["INBOX"],
+ ),
+ _message_spec(
+ f"message-mixed-{hidden_kind}-a-hidden",
+ _at(5, 3),
+ "hidden message A2",
+ [hidden_label],
+ ),
+ ],
+ subject=f"{subject_prefix}-A",
+ )
+ _add_thread(
+ db_session,
+ user_id,
+ thread_b,
+ [
+ _message_spec(
+ f"message-mixed-{hidden_kind}-b-visible",
+ _at(5, 2),
+ "visible message B1",
+ ["INBOX"],
+ )
+ ],
+ subject=f"{subject_prefix}-B",
+ )
+
+ expected = [thread_b, thread_a]
+ for include_spam_trash in (False, True):
+ response = client.get(
+ "/gmail/v1/users/me/threads",
+ params={
+ "q": f'subject:"{subject_prefix}"',
+ "includeSpamTrash": str(include_spam_trash).lower(),
+ },
+ )
+ assert _listed_thread_ids(response) == expected
+ assert response.json()["resultSizeEstimate"] == 2
+
+ def test_latest_message_order_and_pagination_are_stable(self, client, db_session):
+ user_id = _user_id(db_session)
+ marker = "thread-page-order-7c981"
+ # Insert in deliberately different order from the API contract.
+ thread_dates = [
+ ("thread-order-oldest", _at(5, 1)),
+ ("thread-order-tie-z", _at(5, 3)),
+ ("thread-order-newest", _at(5, 5)),
+ ("thread-order-tie-a", _at(5, 3)),
+ ("thread-order-second", _at(5, 4)),
+ ]
+ for thread_id, internal_date in thread_dates:
+ messages = []
+ latest_body = marker
+ if thread_id == "thread-order-newest":
+ messages.append(
+ _message_spec(f"{thread_id}-older-message", _at(4, 30), marker)
+ )
+ latest_body = "The latest message does not match the list query."
+ messages.append(
+ _message_spec(
+ f"{thread_id}-latest-message", internal_date, latest_body
+ )
+ )
+ _add_thread(
+ db_session,
+ user_id,
+ thread_id,
+ messages,
+ )
+
+ expected = [
+ "thread-order-newest",
+ "thread-order-second",
+ "thread-order-tie-a",
+ "thread-order-tie-z",
+ "thread-order-oldest",
+ ]
+
+ full_response = client.get(
+ "/gmail/v1/users/me/threads",
+ params={"q": marker, "maxResults": 500},
+ )
+ assert _listed_thread_ids(full_response) == expected
+ assert full_response.json()["resultSizeEstimate"] == len(expected)
+
+ def walk_pages() -> tuple[list[str], list[str]]:
+ collected = []
+ seen_tokens = set()
+ tokens = []
+ page_token = None
+ for _ in range(len(expected)):
+ params = {"q": marker, "maxResults": 2}
+ if page_token is not None:
+ params["pageToken"] = page_token
+ response = client.get("/gmail/v1/users/me/threads", params=params)
+ data = response.json()
+ assert data["resultSizeEstimate"] == len(expected)
+ collected.extend(_listed_thread_ids(response))
+ page_token = data.get("nextPageToken")
+ if page_token is None:
+ return collected, tokens
+ assert page_token not in seen_tokens
+ seen_tokens.add(page_token)
+ tokens.append(page_token)
+ pytest.fail("Pagination did not terminate")
+
+ first_walk, first_tokens = walk_pages()
+ second_walk, second_tokens = walk_pages()
+ assert first_walk == expected
+ assert second_walk == expected
+ assert first_tokens == second_tokens
+ assert len(first_walk) == len(set(first_walk))
+
+
+class TestThreadsGetBehavior:
+ def test_messages_are_ordered_by_date_then_id(self, client, db_session):
+ user_id = _user_id(db_session)
+ _add_thread(
+ db_session,
+ user_id,
+ "thread-get-order",
+ [
+ _message_spec("message-get-newest", _at(6, 3), "newest"),
+ _message_spec("message-get-tie-z", _at(6, 2), "tie z"),
+ _message_spec("message-get-oldest", _at(6, 1), "oldest"),
+ _message_spec("message-get-tie-a", _at(6, 2), "tie a"),
+ ],
+ )
+
+ response = client.get("/gmail/v1/users/me/threads/thread-get-order")
+
+ assert response.status_code == 200
+ assert [message["id"] for message in response.json()["messages"]] == [
+ "message-get-oldest",
+ "message-get-tie-a",
+ "message-get-tie-z",
+ "message-get-newest",
+ ]
+
+ def test_supported_formats_shape_every_message(self, client, db_session):
+ user_id = _user_id(db_session)
+ _add_thread(
+ db_session,
+ user_id,
+ "thread-get-formats",
+ [
+ _message_spec("message-format-a", _at(7, 1), "alpha"),
+ _message_spec("message-format-b", _at(7, 2), "beta"),
+ ],
+ )
+
+ default = client.get("/gmail/v1/users/me/threads/thread-get-formats").json()
+ full = client.get(
+ "/gmail/v1/users/me/threads/thread-get-formats",
+ params={"format": "full"},
+ ).json()
+ metadata = client.get(
+ "/gmail/v1/users/me/threads/thread-get-formats",
+ params={"format": "metadata"},
+ ).json()
+ minimal = client.get(
+ "/gmail/v1/users/me/threads/thread-get-formats",
+ params={"format": "minimal"},
+ ).json()
+
+ assert default == full
+ assert set(full) == {"id", "historyId", "messages"}
+ assert set(metadata) == set(full)
+ assert set(minimal) == set(full)
+ for message in full["messages"]:
+ assert "payload" in message
+ assert "raw" not in message
+ for message in metadata["messages"]:
+ assert set(message["payload"]) == {"mimeType", "headers"}
+ assert "raw" not in message
+ for message in minimal["messages"]:
+ assert set(message) == {
+ "id",
+ "threadId",
+ "labelIds",
+ "snippet",
+ "historyId",
+ "internalDate",
+ "sizeEstimate",
+ }
+ assert "payload" not in message
+ assert "raw" not in message
+
+ for unsupported_format in ("raw", "unsupported"):
+ response = client.get(
+ "/gmail/v1/users/me/threads/thread-get-formats",
+ params={"format": unsupported_format},
+ )
+ assert response.status_code == 400
+
+ def test_not_found_and_empty_thread_return_not_found(self, client, db_session):
+ missing = client.get("/gmail/v1/users/me/threads/7fffffffffffffff")
+ assert missing.status_code == 404
+
+ user_id = _user_id(db_session)
+ _add_thread(db_session, user_id, "thread-empty", [])
+
+ empty = client.get("/gmail/v1/users/me/threads/thread-empty")
+ assert empty.status_code == 404
+
+ listed = client.get(
+ "/gmail/v1/users/me/threads",
+ params={"includeSpamTrash": "true", "maxResults": 500},
+ )
+ assert "thread-empty" not in _listed_thread_ids(listed)