From 4cda318fd7445e75bfb47b20ac051a9a30412c00 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Fri, 3 Jul 2026 03:21:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(gmail):=20gmail=5Fmark=5Fread=20=E2=80=94?= =?UTF-8?q?=20clear=20UNREAD=20by=20message=20ids=20or=20whole=20thread=20?= =?UTF-8?q?(v0.3.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one mailbox mutation beyond drafts: batchModify removes the UNREAD label from up to 1000 message ids, or threads/{id}/modify clears a whole thread. Never archives, deletes, or sends — posture docs updated to say exactly that. Uses the gmail.modify scope the default connect flow already requests. Co-Authored-By: Claude Fable 5 --- README.md | 3 ++- __init__.py | 29 ++++++++++++++++++++++++++--- gmail.py | 20 +++++++++++++++++++- protoagent.plugin.yaml | 4 ++-- pyproject.toml | 2 +- tests/test_plugin.py | 9 +++++++++ tests/test_services.py | 32 ++++++++++++++++++++++++++++++++ 7 files changed, 91 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 91ed0a6..e2f51ea 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ **Google Workspace** for a [protoAgent](https://github.com/protoLabsAI/protoAgent) agent — built to grow. Ships **Gmail (read + draft)**, **Calendar (read)**, and **Drive (read)** today over a service-agnostic OAuth/REST core, so Docs / Sheets / further services are additive modules, not a rewrite. -Pull-mode posture: the agent lists, searches, reads, and **drafts** — it never sends or auto-replies. A human reviews drafts in the Drafts folder and sends them. +Pull-mode posture: the agent lists, searches, reads, **drafts**, and can **mark mail read** — it never sends, archives, deletes, or auto-replies. A human reviews drafts in the Drafts folder and sends them. ## Tools - `gmail_list_unread(label, max)` · `gmail_search(query, max)` · `gmail_get_thread(thread_id)` — read. - `gmail_create_draft(body, thread_id | to+subject, …)` — **draft only, never sends**. +- `gmail_mark_read(message_ids | thread_id)` — clears UNREAD only; never archives/deletes. - `calendar_list_upcoming(days, calendar_id)` · `calendar_event_detail(event_id, calendar_id)` — read. - `drive_search(query, max)` · `drive_read(file_id, max_chars)` — read; Docs export as text, Sheets as CSV, Slides as text. diff --git a/__init__.py b/__init__.py index c23aad2..c0cff99 100644 --- a/__init__.py +++ b/__init__.py @@ -5,8 +5,9 @@ service (``gmail.py``, ``gcal.py`` — Drive / Docs / Sheets drop in the same way), so expanding to the wider Workspace is new tool functions, not a re-architecture. -Pull-mode posture: the agent lists, searches, reads, and DRAFTS — it never sends or -auto-replies; a human reviews drafts and sends them. Credentials come from plugin +Pull-mode posture: the agent lists, searches, reads, DRAFTS, and can mark mail +read — it never sends, archives, deletes, or auto-replies; a human reviews drafts +and sends them. Credentials come from plugin config (``google.*``) with env fallbacks (``GOOGLE_CLIENT_ID`` / ``GOOGLE_CLIENT_SECRET`` / ``GOOGLE_REFRESH_TOKEN``). Mint the refresh token with the full scope set you intend to grow into so adding a service needs no re-consent. @@ -130,6 +131,28 @@ def gmail_create_draft(body: str, thread_id: str = "", to: str = "", subject: st return f"Draft created (id {out['draftId']}) to {out['to'] or '(thread)'} — \"{out['subject']}\". In Drafts; not sent." +@tool +def gmail_mark_read(message_ids: list[str] | None = None, thread_id: str = "") -> str: + """Mark Gmail messages as read (clears the UNREAD label — nothing is archived, + deleted, or sent). Pass message_ids from gmail_list_unread/gmail_search, OR a + thread_id to mark a whole thread. + + Args: + message_ids: message ids to mark read (up to 1000). + thread_id: mark every message in this thread instead. + """ + from . import gmail + + if not thread_id and not message_ids: + return "Provide message_ids (from gmail_list_unread / gmail_search) or a thread_id." + out = _run(gmail.mark_read, _creds(), message_ids, thread_id) + if isinstance(out, str): + return out + if out.get("threadId"): + return f"Marked thread {out['threadId']} read ({out['marked']} message(s))." + return f"Marked {out['marked']} message(s) read." + + # ── Calendar (read-only) ────────────────────────────────────────────────────── @tool @@ -193,7 +216,7 @@ def drive_read(file_id: str, max_chars: int = 20000) -> str: # Registered tools, grouped by service. Append new service tools here as they land. TOOLS = [ - gmail_list_unread, gmail_search, gmail_get_thread, gmail_create_draft, + gmail_list_unread, gmail_search, gmail_get_thread, gmail_create_draft, gmail_mark_read, calendar_list_upcoming, calendar_event_detail, drive_search, drive_read, ] diff --git a/gmail.py b/gmail.py index fceefb1..93eddbf 100644 --- a/gmail.py +++ b/gmail.py @@ -1,6 +1,8 @@ -"""Gmail service — read + draft (never send). Built on the shared auth core. +"""Gmail service — read, draft (never send), and mark-read. Built on the shared auth core. One of several Workspace service modules; mirrors the protoWorkstacean Gmail tools. +Mark-read is the one mailbox mutation beyond drafts: it only clears the UNREAD +label — it never archives, deletes, or sends. """ from __future__ import annotations @@ -83,6 +85,22 @@ def get_thread(creds: Creds, thread_id: str, *, client=None) -> list[dict]: return out +def mark_read(creds: Creds, message_ids: list[str] | None = None, thread_id: str = "", *, client=None) -> dict: + """Clear the UNREAD label from specific messages (batchModify) or a whole thread. + + The only mutation this performs is removing UNREAD — nothing is archived, + deleted, or sent. Needs the gmail.modify scope. + """ + if thread_id: + data = request(creds, "POST", f"{BASE}/threads/{thread_id}/modify", + json={"removeLabelIds": ["UNREAD"]}, client=client) + return {"threadId": thread_id, "marked": len(data.get("messages") or []) or 1} + ids = [i for i in (message_ids or []) if i][:1000] # batchModify caps at 1000 + request(creds, "POST", f"{BASE}/messages/batchModify", + json={"ids": ids, "removeLabelIds": ["UNREAD"]}, client=client) # 204 on success + return {"marked": len(ids)} + + def build_draft_raw(body: str, to: str = "", subject: str = "", in_reply_to: str = "", references: str = "") -> str: """RFC-822 → base64url, the shape drafts.create wants.""" diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 34fc017..0e11f7d 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -1,10 +1,10 @@ id: google name: Google Workspace -version: 0.2.0 +version: 0.3.0 description: >- Google Workspace for the agent, built to grow. Ships Gmail (read + draft), Calendar (read), and Drive (read) today — gmail_list_unread, gmail_search, - gmail_get_thread, gmail_create_draft (DRAFT ONLY — never sends), + gmail_get_thread, gmail_create_draft (DRAFT ONLY — never sends), gmail_mark_read, calendar_list_upcoming, calendar_event_detail, drive_search, drive_read — over a service-agnostic OAuth/REST core (no SDK) so Docs and Sheets are additive modules, not a rewrite. Pull-mode: the agent reads diff --git a/pyproject.toml b/pyproject.toml index 9918414..151742a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "google-plugin" -version = "0.2.0" +version = "0.3.0" description = "Google Workspace plugin for protoAgent — Gmail (read + draft) + Calendar (read) over a service-agnostic OAuth/REST core, built to grow." requires-python = ">=3.11" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b44b39d..7dcee35 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -67,6 +67,7 @@ def test_register_wires_tools_and_routers(registry): "gmail_search", "gmail_get_thread", "gmail_create_draft", + "gmail_mark_read", "calendar_list_upcoming", "calendar_event_detail", "drive_search", @@ -103,6 +104,14 @@ def test_draft_tool_requires_target(monkeypatch): assert "thread_id" in out and "subject" in out +def test_mark_read_tool_requires_target_and_reports_count(monkeypatch): + monkeypatch.setattr(plugin, "_CREDS", Creds("c", "s", "r")) + assert "message_ids" in plugin.gmail_mark_read.invoke({}) + monkeypatch.setattr(gmail, "mark_read", lambda creds, ids, tid, **kw: {"marked": len(ids)}) + out = plugin.gmail_mark_read.invoke({"message_ids": ["m1", "m2"]}) + assert out == "Marked 2 message(s) read." + + # ── Console view (the four rules) ───────────────────────────────────────────── diff --git a/tests/test_services.py b/tests/test_services.py index 16c03d9..66a0400 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -7,6 +7,7 @@ from __future__ import annotations import base64 +import json import httpx import pytest @@ -156,6 +157,37 @@ def test_calendar_list_and_detail(client): assert detail["organizer"]["email"] == "o@x.com" +def test_mark_read_batch_removes_unread_label(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "oauth2.googleapis.com": + return httpx.Response(200, json={"access_token": "at", "expires_in": 3600}) + seen["path"] = request.url.path + seen["body"] = json.loads(request.read().decode()) + return httpx.Response(204) + + auth._TOKEN_CACHE.clear() + with httpx.Client(transport=httpx.MockTransport(handler)) as c: + out = gmail.mark_read(CREDS, ["m1", "m2", ""], client=c) + assert out == {"marked": 2} # blank id dropped + assert seen["path"].endswith("/messages/batchModify") + assert seen["body"] == {"ids": ["m1", "m2"], "removeLabelIds": ["UNREAD"]} + + +def test_mark_read_thread_modifies_whole_thread(): + def handler(request: httpx.Request) -> httpx.Response: + if request.url.host == "oauth2.googleapis.com": + return httpx.Response(200, json={"access_token": "at", "expires_in": 3600}) + assert request.url.path.endswith("/threads/t1/modify") + return httpx.Response(200, json={"id": "t1", "messages": [{"id": "m1"}, {"id": "m2"}]}) + + auth._TOKEN_CACHE.clear() + with httpx.Client(transport=httpx.MockTransport(handler)) as c: + out = gmail.mark_read(CREDS, thread_id="t1", client=c) + assert out == {"threadId": "t1", "marked": 2} + + # ── Error surfacing + 401 retry ───────────────────────────────────────────────